Skip to content

Vectorize IndexOfAny on more than 5 chars #68328

Closed

Description

@stephentoub

EDITED BY @stephentoub on 10/26/2022 to add revised API shape:

namespace System
{
    public static class IndexOfAnyValues
    {
        public static IndexOfAnyValues<T> Create<T>(ReadOnlySpan<T> values)  where T : IEquatable<T>?;
        // in the future, could add an `IndexOfAnyValues<char> Create(ReadOnlySpan<string> values) overload
    }

    public class IndexOfAnyValues<T> where T : IEquatable<T>?
    {
        // no public/protected members, including ctors
    }

    public static partial class MemoryExtensions
    {
        // These are identical to the existing overloads, just with IndexOfAnyValues<T> instead of ReadOnlySpan<T> for the values parameter type
        public static int IndexOfAny<T>(this ReadOnlySpan<T> span, IndexOfAnyValues<T> values) where T : IEquatable<T>?;
        public static int IndexOfAnyExcept<T>(this ReadOnlySpan<T> span, IndexOfAnyValues<T> values) where T : IEquatable<T>?;
        public static int LastIndexOfAny<T>(this ReadOnlySpan<T> span, IndexOfAnyValues<T> values) where T : IEquatable<T>?;
        public static int LastIndexOfAnyExcept<T>(this ReadOnlySpan<T> span, IndexOfAnyValues<T> values) where T : IEquatable<T>?;
        // ... repeat for Span overloads
    }
}

API Usage

internal static class HttpRuleParser
{
    private static readonly IndexOfAnyValues<char> s_tokenChars =
        IndexOfAnyValues.Create("!#$%&'*+-.0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ^_`abcdefghijklmnopqrstuvwxyz|~");
    
    public static bool IsToken(ReadOnlySpan<char> input) =>
        input.IndexOfAnyExcept(s_tokenChars) < 0;
}

EDITED BY @MihaZupan on 10/24/2022 to add proposed API shape:

API Proposal

namespace System
{
    public static partial class MemoryExtensions
    {
        public static int IndexOfAny(this ReadOnlySpan<byte> span, IndexOfAnyAsciiValues asciiValues);
        public static int IndexOfAnyExcept(this ReadOnlySpan<byte> span, IndexOfAnyAsciiValues asciiValues);
        public static int LastIndexOfAny(this ReadOnlySpan<byte> span, IndexOfAnyAsciiValues asciiValues);
        public static int LastIndexOfAnyExcept(this ReadOnlySpan<byte> span, IndexOfAnyAsciiValues asciiValues);

        public static int IndexOfAny(this ReadOnlySpan<char> span, IndexOfAnyAsciiValues asciiValues);
        public static int IndexOfAnyExcept(this ReadOnlySpan<char> span, IndexOfAnyAsciiValues asciiValues);
        public static int LastIndexOfAny(this ReadOnlySpan<char> span, IndexOfAnyAsciiValues asciiValues);
        public static int LastIndexOfAnyExcept(this ReadOnlySpan<char> span, IndexOfAnyAsciiValues asciiValues);
        // ... repeat for Span overloads
    }
}

namespace System.Buffers.Text
{    
    public sealed class IndexOfAnyAsciiValues
    {
        public IndexOfAnyAsciiValues(ReadOnlySpan<char> asciiValues);
        // No other public members
    }
}

API Usage

internal static class HttpRuleParser
{
    private static readonly IndexOfAnyAsciiValues s_tokenChars =
        new("!#$%&'*+-.0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ^_`abcdefghijklmnopqrstuvwxyz|~");
    
    public static bool IsToken(ReadOnlySpan<byte> input) =>
        input.IndexOfAnyExcept(s_tokenChars) < 0;
}

Alternative Designs

Static members on Ascii instead of extension methods in MemoryExtensions.
Based on feedback from API review on 2022-10-25.

namespace System.Buffers.Text;

public static class Ascii
{
    public static int IndexOfAny(ReadOnlySpan<byte> span, IndexOfAnyAsciiValues values);
    public static int IndexOfAny(ReadOnlySpan<char> span, IndexOfAnyAsciiValues values);
    // ...
}

public sealed class IndexOfAnyAsciiValues
{
    public IndexOfAnyAsciiValues(ReadOnlySpan<char> asciiValues);
}

Original post:

Earlier in the .NET 7 release, we combined the best of string.IndexOfAny and MemoryExtensions.IndexOfAny to provide a single implementation used by both. As part of this, whereas <= 5 characters are vectorized, > 5 characters use a "probabilistic map":

private static unsafe int IndexOfAnyProbabilistic(ref char searchSpace, int searchSpaceLength, ref char values, int valuesLength)
{
Debug.Assert(searchSpaceLength >= 0);
Debug.Assert(valuesLength >= 0);
ReadOnlySpan<char> valuesSpan = new ReadOnlySpan<char>(ref values, valuesLength);
ProbabilisticMap map = default;
uint* charMap = (uint*)&map;
ProbabilisticMap.Initialize(charMap, valuesSpan);
ref char cur = ref searchSpace;
while (searchSpaceLength != 0)
{
int ch = cur;
if (ProbabilisticMap.IsCharBitSet(charMap, (byte)ch) &&
ProbabilisticMap.IsCharBitSet(charMap, (byte)(ch >> 8)) &&
ProbabilisticMap.SpanContains(valuesSpan, (char)ch))
{
return (int)((nint)Unsafe.ByteOffset(ref searchSpace, ref cur) / sizeof(char));
}
searchSpaceLength--;
cur = ref Unsafe.Add(ref cur, 1);
}
return -1;
}

essentially a Bloom filter to quickly screen out characters that are definitely not in the set so that the more expensive check need only be done if it's likely the character is in the set. While faster than comparing every input character against every character in the set, we still have to walk the input character by character.

@MihaZupan's xoofx/markdig@da756f4 is an implementation of http://0x80.pl/articles/simd-byte-lookup.html#universal-algorithm, and highlights that it's possible to vectorize this operation instead. It would require startup overhead of determining whether all of the set characters are ASCII and generating a bitmap from them, but we're already doing such a loop in order to build the probabilistic map:

public static unsafe void Initialize(uint* charMap, ReadOnlySpan<char> values)
{
#if DEBUG
for (int i = 0; i < Size; i++)
{
Debug.Assert(charMap[i] == 0, "Expected charMap to be zero-initialized.");
}
#endif
bool hasAscii = false;
uint* charMapLocal = charMap; // https://github.com/dotnet/runtime/issues/9040
for (int i = 0; i < values.Length; ++i)
{
int c = values[i];
// Map low bit
SetCharBit(charMapLocal, (byte)c);
// Map high bit
c >>= 8;
if (c == 0)
{
hasAscii = true;
}
else
{
SetCharBit(charMapLocal, (byte)c);
}
}
if (hasAscii)
{
// Common to search for ASCII symbols. Just set the high value once.
charMapLocal[0] |= 1u;
}
}

We could simply augment that to also build up the ASCII bitmap, and if after initialization determine that every character was indeed ASCII, take the vectorized path of using that ASCII bitmap, otherwise fall back to using the probabilistic map as we do today.

Activity

  1. added this to the 7.0.0 milestone on Apr 21, 2022
  2. ghost added
    untriagedNew issue has not been triaged by the area owner
    on Apr 21, 2022
  3. stephentoub commented on Apr 23, 2022

    @stephentoub
    MemberAuthor

    Instead of or in addition to this, we might also consider a new overload:

    IndexOfAny(ReadOnlySpan<char>, UInt128)

    (or potentially a ReadOnlySpan<byte> or Vector128<byte> instead of the UInt128). The existing IndexOfAny overload could use it, but it could also be used directly by code that could precompute the ASCII bitmap, reducing the per-call overhead, e.g. Regex could use this to vectorize set searches too big for the current IndexOfAny.

  4. removed
    untriagedNew issue has not been triaged by the area owner
    on Apr 25, 2022
  5. self-assigned this
    on May 22, 2022
  6. danmoseley commented on May 22, 2022

    @danmoseley
    Contributor

    I’d like to take a look, but if someone can do this sooner, just let me know.

    It looks like the algorithm cannot be immediately transliterated into Vector128 because (at least) it relies on Ssse3.Shuffle and the generic shuffle on Vector128 doesn’t have the same behavior. Therefore we would rely on software fallback for Arm64 and potentially improve later.

  7. deeprobin commented on May 22, 2022

    @deeprobin
    Contributor

    @stephentoub Is the constant 5 dependent on other parameters? Or was this constant only selected based on benchmarks?

    In other words, in any case, if the length is > 5, is it guaranteed that the performance will be better than the probablistic map?


    I was thinking in general about these constants that we have everywhere. Especially that we somehow evaluate them dynamically in the startup phase of the JIT, etc., because certainly in many cases parameters like microarchitecture or the use of NativeAOT or ReadyToRun make a crucial difference (i.e. a (lightweight): Runtime Benchmarking).

  8. stephentoub commented on May 22, 2022

    @stephentoub
    MemberAuthor

    Is the constant 5 dependent on other parameters?

    It's not just a constant. If you look at the implementation, you'll see there's work that has to be done specific to each additional character, each one getting its own vector that needs to be factored in. It's not like there's some number to just change and magically higher counts of chars are vectorized.

  9. MihaZupan commented on May 23, 2022

    @MihaZupan
    Member

    we might also consider a new overload:
    IndexOfAny(ReadOnlySpan<char>, UInt128)
    ... it could also be used directly by code that could precompute the ASCII bitmap

    The only problem I see with such an API is that the pre-computed bitmap used by the vectorized algorithm isn't the ASCII bitmap itself (described a bit under the universal algorithm description).
    My implementation is somewhat different from the "universal algorithm" as it only deals with the ASCII range.
    For each character, the nibbles are swapped and packed together (int position = (c >> 4) | ((c & 0x0F) << 3)).

       01234567
    =>
        4567     ((c & 0xF) << 3)
    &      0123  (c >> 4)
    ==
       X4567123  (the X bit is 0)
    

    The 0th (highest) bit is assumed to be 0 (ASCII), so it can overlap with the 7th bit.
    With this trick, the whole bitmap fits into a single Vector128<byte>.

    I don't know if it's possible to quickly turn an input bitmap representing the set of characters into this modified one.
    For the lowest overhead, the caller would want to precompute the modified bitmap, but that may not be obvious from the API signature.

    As Dan pointed out, the above approach depends on the behavior of Ssse3's Shuffle. I'm not familiar with ARM instructions, but if there's no "4 bit => 4 bit lookup" equivalent, the optimal bitmap may be different.

  10. stephentoub commented on May 23, 2022

    @stephentoub
    MemberAuthor

    For the lowest overhead, the caller would want to precompute the modified bitmap, but that may not be obvious from the API signature.

    There could be a separate method that computes and returns the input to be used.

  11. removed this from the 7.0.0 milestone on Aug 9, 2022
  12. 84 remaining items

  13. stephentoub commented on Oct 27, 2022

    @stephentoub
    MemberAuthor

    What are all Ts and other special cases that we expect to provide an optimized path for in IndexOfAnyValues.Create?

    At least char and byte. If you think it's better to have explicit overloads for char and byte, we could do that, too. Though I'd hope the trimmer would be able to figure out (if not now, eventually) that a method M<T> only called as M<char> could have its byte-based code paths trimmed away.

    And for char at least the cases where all inputs are ASCII (in which case it can use the vectorized implementation) or they aren't (in which case it can use the probabilistic map, or other such lookup tables). I'm hopeful in the future we'll discover additional ways to optimize as well, and that can all be done under the covers without additional APIs. We could also special-case inputs that would be satisfied by other public API, e.g. detect that non-ASCII inputs are all in a single range and then delegate to IndexOfAnyInRange, but for such cases I think we should start simple and just say callers should themselves use those APIs rather than having the API try to detect and "fix" it.

  14. stephentoub commented on Oct 27, 2022

    @stephentoub
    MemberAuthor

    This overload would be ambiguous with the existing method. Consider var mask = IndexOfAnyValues.Create(new ReadOnlySpan(new string[] { "Hello", "World" }));. I think it would need to be called something else.

    It wouldn't be ambiguous from the compiler's perspective: if you used IndexOfAnyValues.Create(new ReadOnlySpan<string>(new string[] { "Hello", "World" })), you'd get an IndexOfAnyValues<char>, and if you used IndexOfAnyValues.Create<string>(new ReadOnlySpan<string>(new string[] { "Hello", "World" })), you'd get an IndexOfAnyValues<string>. However, I agree the distinction is subtle and we might want to use a different name (if we even add such a method in the future; it's not part of this proposal). My main point was that the factory pattern lets us produce an IndexOfAnyValues<char> based on different inputs and that the resulting instance can be specialized for that input without incurring additional per-invocation overheads.

  15. jkotas commented on Oct 27, 2022

    @jkotas
    Member

    At least char and byte. If you think it's better to have explicit overloads for char and byte, we could do that, too.

    I keep thinking about this API primarily as a low-level regex helper that saves us from stamping the complex unsafe vectorized code in every source-generated regex. The API can be used directly too, but I expect only a small number of people are going to be successful in figuring when and how to correctly use it to their advantage.

    Having the surface limited and specialized to just what regex and parsers need would be fine with me (ie byte and char only). I am not even sure we need methods for this on MemoryExtensions. I think having a method on IndexOfAnyValues<T> type would be good enough.

    My main point was that the factory pattern lets us produce an IndexOfAnyValues based on different inputs and that the resulting instance can be specialized for that input without incurring additional per-invocation overheads.

    How would the IndexOfAnyExcept work with the aho corasick? I am not sure what the sensible behavior would be. And if there is a sensible behavior, would it mean that we would need to keep multiple state machines around for different IndexOf variants?

  16. stephentoub commented on Oct 27, 2022

    @stephentoub
    MemberAuthor

    I keep thinking about this API primarily as a low-level regex helper that saves us from stamping the complex unsafe vectorized code in every source-generated regex.

    Why do you believe that's the case? It'll certainly be used by regex, but most places currently doing an IndexOfAny with more than 5 characters can benefit from this (and others that aren't using IndexOfAny because they could do something faster based on precomputing a bitmap). Some examples from a quick search:

    expect only a small number of people are going to be successful in figuring when and how to correctly use it to their advantage.

    I'm surprised by that. Plenty of folks figured out how to write:

    private static readonly char[] s_chars = new char[] { ... };
    ...
    int index = something.IndexOfAny(s_chars);

    This follows the exact same form, just changing the line:

    private static readonly char[] s_chars = new char[] { ... };

    to instead be:

    private static readonly IndexOfAnyValues<char> s_chars = IndexOfAnyValues.Create(new char[] { ... });

    and as an overload of IndexOfAny, you're looking in IntelliSense through the overloads, you see one that takes an IndexOfAnyValues, you see it has a Create method, and you substitute an IndexOfAnyValues for your s_chars. We can even have an analyzer/fixer for it.

    Having the surface limited and specialized to just what regex and parsers need would be fine with me (ie byte and char only). I am not even sure we need methods for this on MemoryExtensions. I think having a method on IndexOfAnyValues type would be good enough.

    I think there's real value having these simply as overloads of the existing IndexOfAny: it's just one more way you can provide inputs to the existing functionality. I'd be fine replacing the generic Create<T>(ReadOnlySpan<T>) with Create(ReadOnlySpan<char>) and Create(ReadOnlySpan<byte>) if you believe that'll be better; I personally like it as a single generic method that accomodates any future optimizations we want for other types, but as I don't have any in mind or that are pressing, we could do without it for now, and I understand the trimming concerns (though I still think we should be thinking about imbuing the trimmer with more smarts around this kind of input/type tracking).

  17. jkotas commented on Oct 27, 2022

    @jkotas
    Member

    Why do you believe that's the case?

    I expect that most people will just use the existing IndexOfAny API that is the most straightforward one to use, and not bother with making their code less readable by outlining the pattern to search for.

    private static readonly char[] s_chars = new char[] { ... };
    
    int index = something.IndexOfAny(s_chars);
    

    I expect only a fraction of read-world places to have outlined cache like this. I expect that majority are just going to allocate the array of chars to search for inline since that it is the most straightforward way to do it. Our examples show you to allocate the chars to search for inline too: https://learn.microsoft.com/en-us/dotnet/api/system.string.indexofany?view=net-7.0.

    Yes, there are certainly going to be cases where this API will be used directly, especially in our own libraries. I see it as an API for advanced performance-oriented developers only.

  18. stephentoub commented on Oct 27, 2022

    @stephentoub
    MemberAuthor

    Our examples show you to allocate the chars to search for inline too

    Our examples show an entire program in a single snippet focused on highlighting what output you get for what input. They're not real-world.

    I expect only a fraction of read-world places to have outlined cache like this.

    I see a lot of it in searching around GitHub. But my searches may be skewed; I asked @marklio for help in getting some data.

    Regardless, whether it's a majority or minority, there are lots of places that will be able to benefit from it (as outlined in #68328 (comment)). We have multiple IndexOfAny overloads taking inputs in a variety of forms; this adds just one more, and I think it's the right way to expose this. I think trying to bury it elsewhere actually makes it more confusing, as it means there's now a set of IndexOfAny methods elsewhere that aren't obviously connected and thus force people to think about whether they need to consider the other one; as an overload, it's just another way of providing the same inputs.

  19. terrajobst commented on Nov 8, 2022

    @terrajobst
    Contributor

    Video

    • Let's move the types to System.Buffers as they are more advanced
    • Let's not have a generic factory method for now as we're only planning to optimize for byte and char
    • We should make sure that the debugger displays something useful for instances of IndexOfAnyValues<T>
    namespace System;
    
    public static partial class MemoryExtensions
    {
        public static int IndexOfAny<T>(this ReadOnlySpan<T> span, IndexOfAnyValues<T> values) where T : IEquatable<T>?;
        public static int IndexOfAnyExcept<T>(this ReadOnlySpan<T> span, IndexOfAnyValues<T> values) where T : IEquatable<T>?;
        public static int LastIndexOfAny<T>(this ReadOnlySpan<T> span, IndexOfAnyValues<T> values) where T : IEquatable<T>?;
        public static int LastIndexOfAnyExcept<T>(this ReadOnlySpan<T> span, IndexOfAnyValues<T> values) where T : IEquatable<T>?;
    }
    namespace System.Buffers;
    
    public static class IndexOfAnyValues
    {
        public static IndexOfAnyValues<byte> Create(ReadOnlySpan<byte> values);
        public static IndexOfAnyValues<char> Create(ReadOnlySpan<char> values);
    }
    
    public class IndexOfAnyValues<T> where T : IEquatable<T>?
    {
    }
  20. added
    api-approvedAPI was approved in API review, it can be implemented
    and removed
    api-ready-for-reviewAPI is ready for review, it is NOT ready for implementation
    on Nov 8, 2022
  21. ghost added
    in-prThere is an active PR which will close this issue when it is merged
    on Nov 9, 2022
  22. ghost removed
    in-prThere is an active PR which will close this issue when it is merged
    on Nov 21, 2022
  23. gfoidl commented on Nov 22, 2022

    @gfoidl
    Member

    This was an interesting journey. Thanks for all the beatiful discussion and thoughts on this!

  24. ghost locked as resolved and limited conversation to collaborators on Dec 22, 2022
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

Type

No type

Projects

No projects

    Milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions