Repository navigation
Vectorize IndexOfAny on more than 5 chars #68328
Description
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":
runtime/src/libraries/System.Private.CoreLib/src/System/MemoryExtensions.cs
Lines 1140 to 1167 in d58efd1
| 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*)↦ | |
| 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:
runtime/src/libraries/System.Private.CoreLib/src/System/ProbabilisticMap.cs
Lines 31 to 67 in d58efd1
| 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
stephentoub commentedon Apr 23, 2022
Instead of or in addition to this, we might also consider a new overload:
(or potentially a
ReadOnlySpan<byte>orVector128<byte>instead of theUInt128). 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.Regexcould use this to vectorize set searches too big for the current IndexOfAny.danmoseley commentedon May 22, 2022
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.
deeprobin commentedon May 22, 2022
@stephentoub Is the constant
5dependent 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).
stephentoub commentedon May 22, 2022
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.
MihaZupan commentedon May 23, 2022
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)).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.
stephentoub commentedon May 23, 2022
There could be a separate method that computes and returns the input to be used.
84 remaining items
stephentoub commentedon Oct 27, 2022
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 asM<char>could have itsbyte-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.
stephentoub commentedon Oct 27, 2022
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 anIndexOfAnyValues<char>, and if you usedIndexOfAnyValues.Create<string>(new ReadOnlySpan<string>(new string[] { "Hello", "World" })), you'd get anIndexOfAnyValues<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 anIndexOfAnyValues<char>based on different inputs and that the resulting instance can be specialized for that input without incurring additional per-invocation overheads.jkotas commentedon Oct 27, 2022
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
byteandcharonly). I am not even sure we need methods for this on MemoryExtensions. I think having a method onIndexOfAnyValues<T>type would be good enough.How would the
IndexOfAnyExceptwork 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 differentIndexOfvariants?stephentoub commentedon Oct 27, 2022
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:
https://github.com/xoofx/markdig/blob/a4a1a177bc06163452842d016e6c965b9f632b8a/src/Markdig/Helpers/CharacterMap.cs#L137
https://github.com/dotnet/aspnetcore/blob/90802f2befcba3673833d3d27b94b6deca12c1e1/src/Shared/ServerInfrastructure/HttpCharacters.cs#L121
https://github.com/dotnet/aspnetcore/blob/90802f2befcba3673833d3d27b94b6deca12c1e1/src/Shared/ServerInfrastructure/HttpCharacters.cs#L138
https://github.com/dotnet/aspnetcore/blob/90802f2befcba3673833d3d27b94b6deca12c1e1/src/Shared/ServerInfrastructure/HttpCharacters.cs#L155
https://github.com/dotnet/aspnetcore/blob/90802f2befcba3673833d3d27b94b6deca12c1e1/src/Shared/ServerInfrastructure/HttpCharacters.cs#L172
https://github.com/dotnet/aspnetcore/blob/90802f2befcba3673833d3d27b94b6deca12c1e1/src/Shared/ServerInfrastructure/HttpCharacters.cs#L191
https://github.com/dotnet/aspnetcore/blob/90802f2befcba3673833d3d27b94b6deca12c1e1/src/Shared/ServerInfrastructure/HttpCharacters.cs#L209
runtime/src/coreclr/nativeaot/System.Private.CoreLib/src/System/Reflection/Runtime/General/Helpers.cs
Line 136 in bbcff6b
runtime/src/coreclr/System.Private.CoreLib/src/System/TypeNameParser.cs
Line 279 in 8626da3
runtime/src/libraries/Microsoft.Extensions.Configuration.UserSecrets/src/PathHelper.cs
Line 49 in 8ddb0c4
https://github.com/dotnet/runtime/blob/main/src/libraries/Microsoft.Extensions.FileProviders.Physical/src/Internal/PathUtils.cs#L12-L29
runtime/src/libraries/System.IO.Pipes/src/System/IO/Pipes/PipeStream.Unix.cs
Line 212 in eeeeb7a
runtime/src/libraries/System.Net.Primitives/src/System/Net/Cookie.cs
Line 241 in a41ac2c
runtime/src/libraries/System.Private.Uri/src/System/DomainNameHelper.cs
Lines 392 to 393 in 214ca6d
runtime/src/libraries/System.Security.Cryptography/src/System/Security/Cryptography/X509Certificates/X500NameEncoder.cs
Lines 129 to 130 in 2f78734
runtime/src/libraries/System.Text.Json/src/System/Text/Json/Nodes/JsonObject.cs
Line 131 in 8ddb0c4
runtime/src/libraries/System.Private.Xml/src/System/Xml/XmlCharType.cs
Lines 164 to 177 in 28f6daa
https://github.com/PowerShell/PowerShell/blob/ea8f2459d806a1dafa798d6759fdc6c58e30f577/src/System.Management.Automation/engine/CommandCompletion/CompletionCompleters.cs#L4887
https://github.com/dotnet/msbuild/blob/main/src/Shared/EscapingUtilities.cs#L219-L225
https://github.com/dotnet/roslyn/blob/d2f6077c1af6081ea0a26fe69521ec10f7be1ffa/src/Compilers/Core/Portable/DiagnosticAnalyzer/SuppressMessageAttributeState.TargetSymbolResolver.cs#L268-L271
https://github.com/picoe/Eto/blob/a10614cb680b362cf0492a4364ad94fd433cd2e6/src/Eto.Wpf/Forms/Controls/RichTextAreaHandler.cs#L942
https://github.com/dotnet/roslyn/blob/d2f6077c1af6081ea0a26fe69521ec10f7be1ffa/src/Compilers/Core/Portable/DocumentationCommentId.cs#L1472-L1478
https://github.com/SeeminglyScience/EditorServicesCommandSuite/blob/5b77fdf99e7eede488484f3104d647ebb5e830ef/src/EditorServicesCommandSuite/CodeGeneration/PowerShellScriptWriter.cs#L322-L334
https://github.com/dotnet/msbuild/blob/3777dcaf7edb3e86a070037ba53e742dd1872873/src/Build/Definition/Project.cs#L2621-L2625
https://github.com/ionide/proj-info/blob/e523999c98de7b86f2ad67a0a8df286a31a126eb/src/Ionide.ProjInfo.Sln/vendor/EscapingUtilities.cs#L276-L281
I'm surprised by that. Plenty of folks figured out how to write:
This follows the exact same form, just changing the line:
to instead be:
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.
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>)withCreate(ReadOnlySpan<char>)andCreate(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).jkotas commentedon Oct 27, 2022
I expect that most people will just use the existing
IndexOfAnyAPI that is the most straightforward one to use, and not bother with making their code less readable by outlining the pattern to search for.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.
stephentoub commentedon Oct 27, 2022
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 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.
terrajobst commentedon Nov 8, 2022
Video
System.Buffersas they are more advancedbyteandcharIndexOfAnyValues<T>gfoidl commentedon Nov 22, 2022
This was an interesting journey. Thanks for all the beatiful discussion and thoughts on this!