Skip to content

Add IndexOfAnyValues - #78093

Merged
MihaZupan merged 9 commits into
dotnet:mainfrom
MihaZupan:indexofanyvalues
Nov 21, 2022
Merged

Add IndexOfAnyValues#78093
MihaZupan merged 9 commits into
dotnet:mainfrom
MihaZupan:indexofanyvalues

Conversation

@MihaZupan

@MihaZupanMihaZupan commented Nov 9, 2022

Copy link
Copy Markdown
Member

Closes#68328
Depends on all IndexOfAny implementations for byte/char of 1-5 values being available, therefore depends on #78015 to make that a reality for Mono.

I also added vectorized paths for IndexOfAnyExcept, LastIndexOfAny, and LastIndexOfAnyExcept for 5 values to SpanHelpers and started using them from existing IndexOfAny(Span, Span) APIs for byte and char.
The changes in SpanHelpers are effectively a copy-paste from existing overloads.

The currently employed categories of specializations for IndexOfAnyValues are:

  • 1-5 values
  • Values within a single range
  • Values in the [0, 127] (ASCII) range
  • Any set of values for bytes (also vectorized)
  • Values in the [0, 255] (Latin1) range for chars (not vectorized, but using efficient checks)
  • Any set of chars via the ProbabilisticMap

Efficiency assumptions for different search primitives are based on numbers collected at 0f3a88b on an Intel i9 10900X CPU:

IndexOf, IndexOfAny, IndexOfAnyInRange and IndexOfAnyAscii numbers
char MethodLengthMeanErrorStdDev
IndexOfAny1281000006.218 us0.0121 us0.0174 us
IndexOf1000004.929 us0.0083 us0.0119 us
IndexOfAny2Values1000005.764 us0.0092 us0.0138 us
IndexOfAny3Values1000006.050 us0.0193 us0.0283 us
IndexOfAny4Values1000006.868 us0.0184 us0.0270 us
IndexOfAny5Values1000007.805 us0.0228 us0.0335 us
IndexOfAnyInRange_Char1000005.194 us0.0106 us0.0159 us
byte MethodLengthMeanErrorStdDev
IndexOfAny1281000004.138 us0.0131 us0.0192 us
IndexOfAny2561000007.578 us0.0683 us0.1023 us
IndexOf1000002.468 us0.0050 us0.0070 us
IndexOfAny2Values1000002.835 us0.0054 us0.0078 us
IndexOfAny3Values1000003.033 us0.0233 us0.0342 us
IndexOfAny4Values1000003.464 us0.0075 us0.0110 us
IndexOfAny5Values1000003.910 us0.0098 us0.0147 us
IndexOfAnyInRange_Byte1000002.594 us0.0077 us0.0113 us

Benchmarks

Benchmark source

ASCII

This case is already vectorized for chars (#76740), but now the init cost is removed as well.
For bytes, it's the difference between an O(n * m) loop and a vectorized O(n).

Details
MethodLengthMeanError
IndexOfAnyValues_Char12.412 ns0.0161 ns
CurrentChar15.981 ns0.0283 ns
IndexOfAnyValues_Char77.077 ns0.0288 ns
CurrentChar731.202 ns0.0640 ns
IndexOfAnyValues_Char82.605 ns0.0038 ns
CurrentChar829.698 ns0.3410 ns
IndexOfAnyValues_Char162.608 ns0.0074 ns
CurrentChar1664.374 ns2.3112 ns
IndexOfAnyValues_Char323.229 ns0.0051 ns
CurrentChar3250.769 ns0.1097 ns
IndexOfAnyValues_Char1000005,829.302 ns36.4721 ns
CurrentChar1000005,848.152 ns8.7200 ns
IndexOfAnyValues_Byte11.749 ns0.0165 ns
CurrentByte123.468 ns0.1097 ns
IndexOfAnyValues_Byte75.331 ns0.0169 ns
CurrentByte7155.727 ns0.9470 ns
IndexOfAnyValues_Byte82.590 ns0.0530 ns
CurrentByte8177.266 ns1.8686 ns
IndexOfAnyValues_Byte162.519 ns0.0047 ns
CurrentByte16343.562 ns0.2631 ns
IndexOfAnyValues_Byte323.067 ns0.0037 ns
CurrentByte32684.387 ns2.0701 ns
IndexOfAnyValues_Byte1000004,713.635 ns4.6342 ns
CurrentByte1000002,117,894.760 ns1,282.3044 ns

1-5 values

1-5 values are pretty much the same as calling IndexOfAny("aeiou") if the value is constant at the call site.
If the value is not constant, IndexOfAnyValues is about 1 ns cheaper as we don't have to go through the length check switch.

Single range

The single range case is pretty much identical to calling IndexOfAnyInRange directly.

[0, 255] values

This test is using "abcÀÄÈÌÐÔØÜàäèì" as the value (Latin1 for chars).
For chars, this is the difference between ProbabilisticMap and BitVector256.
For bytes, this is the difference between a simple O(n * m) loop and a vectorized O(n).

Details
MethodLengthMeanError
IndexOfAnyValues_Char12.016 ns0.0077 ns
CurrentChar15.174 ns0.1958 ns
IndexOfAnyValues_Char75.872 ns0.0270 ns
CurrentChar720.783 ns0.0390 ns
IndexOfAnyValues_Char86.491 ns0.0057 ns
CurrentChar831.064 ns0.8008 ns
IndexOfAnyValues_Char1611.795 ns0.0666 ns
CurrentChar1638.854 ns0.1773 ns
IndexOfAnyValues_Char3223.249 ns0.0497 ns
CurrentChar3256.224 ns0.3486 ns
IndexOfAnyValues_Char10000065,447.107 ns662.7650 ns
CurrentChar10000067,424.026 ns88.6333 ns
IndexOfAnyValues_Byte11.834 ns0.0106 ns
CurrentByte15.760 ns0.0078 ns
IndexOfAnyValues_Byte74.951 ns0.0051 ns
CurrentByte732.489 ns0.1421 ns
IndexOfAnyValues_Byte84.163 ns0.0044 ns
CurrentByte846.305 ns0.2549 ns
IndexOfAnyValues_Byte164.157 ns0.0029 ns
CurrentByte1681.151 ns0.2816 ns
IndexOfAnyValues_Byte325.328 ns0.0040 ns
CurrentByte32149.091 ns0.2623 ns
IndexOfAnyValues_Byte1000007,589.072 ns9.0928 ns
CurrentByte100000441,889.687 ns2,916.3709 ns

ProbabilisticMap for chars

Both will use a simple loop for short values and a ProbabilisticMap for longer ones.
IndexOfAnyValues can avoid the init overhead for constructing the ProbabilisticMap.

Details

Short values list ("ažćčš" + "\u1000").

MethodLengthMeanError
IndexOfAnyValues_Char12.081 ns0.0303 ns
CurrentChar16.142 ns0.0804 ns
IndexOfAnyValues_Char77.091 ns0.0792 ns
CurrentChar726.791 ns0.1502 ns
IndexOfAnyValues_Char87.238 ns0.0562 ns
CurrentChar822.512 ns0.7063 ns
IndexOfAnyValues_Char1616.136 ns0.0569 ns
CurrentChar1631.549 ns0.0510 ns
IndexOfAnyValues_Char3243.433 ns2.7478 ns
CurrentChar3256.958 ns0.2508 ns
IndexOfAnyValues_Char10000089,132.803 ns80.5150 ns
CurrentChar10000090,523.407 ns145.6607 ns

Long values list ("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ" + "\u1000").

MethodLengthMeanError
IndexOfAnyValues_Char12.091 ns0.0040 ns
CurrentChar14.861 ns0.0095 ns
IndexOfAnyValues_Char76.986 ns0.0286 ns
CurrentChar720.385 ns0.1865 ns
IndexOfAnyValues_Char87.233 ns0.0341 ns
CurrentChar820.572 ns0.0368 ns
IndexOfAnyValues_Char1612.478 ns0.0383 ns
CurrentChar1667.647 ns0.0471 ns
IndexOfAnyValues_Char3230.781 ns0.0629 ns
CurrentChar3284.401 ns0.8956 ns
IndexOfAnyValues_Char10000068,211.509 ns47.4626 ns
CurrentChar10000067,217.969 ns63.2336 ns

HttpRuleParser.IsToken

This is an example of an implementation we will replace with IndexOfAnyValues.

Details
MethodLengthMeanError
IndexOfAnyValues_Char12.683 ns0.0108 ns
CurrentChar11.655 ns0.0046 ns
IndexOfAnyValues_Char77.184 ns0.0185 ns
CurrentChar74.989 ns0.1115 ns
IndexOfAnyValues_Char82.402 ns0.0025 ns
CurrentChar84.787 ns0.0155 ns
IndexOfAnyValues_Char162.409 ns0.0052 ns
CurrentChar169.100 ns0.0119 ns
IndexOfAnyValues_Char323.306 ns0.0078 ns
CurrentChar3219.486 ns0.5098 ns
IndexOfAnyValues_Char1000006,004.741 ns4.4187 ns
CurrentChar10000057,569.776 ns49.4496 ns
IndexOfAnyValues_Byte11.749 ns0.0058 ns
CurrentByte11.677 ns0.0243 ns
IndexOfAnyValues_Byte74.835 ns0.0779 ns
CurrentByte75.112 ns0.0038 ns
IndexOfAnyValues_Byte82.521 ns0.0046 ns
CurrentByte85.274 ns0.0052 ns
IndexOfAnyValues_Byte162.519 ns0.0021 ns
CurrentByte169.113 ns0.0166 ns
IndexOfAnyValues_Byte323.141 ns0.0023 ns
CurrentByte3219.417 ns1.0021 ns
IndexOfAnyValues_Byte1000004,762.442 ns21.2032 ns
CurrentByte10000057,967.138 ns97.4594 ns

@ghost

ghost commented Nov 9, 2022

Copy link
Copy Markdown

Note regarding the new-api-needs-documentation label:

This serves as a reminder for when your PR is modifying a ref *.cs file and adding/modifying public APIs, to please make sure the API implementation in the src *.cs file is documented with triple slash comments, so the PR reviewers can sign off that change.

@MihaZupan

Copy link
Copy Markdown
MemberAuthor

cc: @gfoidl

Comment threadsrc/libraries/System.Memory/tests/Span/IndexOfAny.byte.cs Outdated
Comment threadsrc/libraries/System.Private.CoreLib/src/System/MemoryExtensions.cs Outdated
@ghostghost assigned MihaZupanNov 9, 2022
@ghost

ghost commented Nov 9, 2022

Copy link
Copy Markdown

Tagging subscribers to this area: @dotnet/area-system-memory
See info in area-owners.md if you want to be subscribed.

Issue Details

Implements #68328
Depends on all IndexOfAny implementations for byte/char of 1-5 values being available, therefore depends on #78015 to make that a reality for Mono.

I also added vectorized paths for IndexOfAnyExcept, LastIndexOfAny, and LastIndexOfAnyExcept for 5 values to SpanHelpers and started using them from existing IndexOfAny(Span, Span) APIs for byte and char.
The changes in SpanHelpers are effectively a copy-paste from existing overloads.

The currently employed categories of specializations for IndexOfAnyValues are:

  • 1-5 values
  • Values within a single range
  • Values in the [0, 127] (ASCII) range
    • Additional special handling for AsciiLetter, AsciiLetterOrDigit, AsciiHexDigit, AsciiHexDigitLower, AsciiHexDigitUpper.
    • I have to investigate if these actually make a difference. If they do, we could easily add others (e.g. for Base64) later on.
  • Any set of values for bytes (also vectorized)
  • Values in the [0, 255] (Latin1) range for chars (not vectorized, but using efficient checks)
  • Any set of chars via the ProbabilisticMap

ToDo: benchmarks

Author:MihaZupan
Assignees:-
Labels:

area-System.Memory

Milestone:8.0.0

@MihaZupanMihaZupan added the blocked Issue/PR is blocked on something - see comments label Nov 9, 2022

@gfoidlgfoidl left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What I've seen so far looks good 👍🏻 (need to have another look in the next days)

@MihaZupan

Copy link
Copy Markdown
MemberAuthor

need to have another look in the next days

Thanks for the reviews, they're much appreciated.

I'll get back to this PR in about a week. Hopefully, #78015 is resolved by then.

Comment threadsrc/libraries/System.Private.CoreLib/src/System/String.Manipulation.cs Outdated
@stephentoubstephentoub added blocked Issue/PR is blocked on something - see comments and removed blocked Issue/PR is blocked on something - see comments labels Nov 17, 2022
@MihaZupan
MihaZupan marked this pull request as ready for review November 20, 2022 22:37
@MihaZupan

MihaZupan commented Nov 20, 2022

Copy link
Copy Markdown
MemberAuthor

I've updated the PR and moved it out of draft.
I added a few more tests and updated the initial post with all the benchmark numbers.

I removed the IAsciiSet special cases as I didn't see a big improvement with them for the amount of code (and possible runtime duplication) needed for a few edge cases.

@MihaZupan

Copy link
Copy Markdown
MemberAuthor

I've added a temporary workaround for the lack of MONO APIs while waiting on #78015: 1e7684f.

@MihaZupanMihaZupan removed the blocked Issue/PR is blocked on something - see comments label Nov 20, 2022
@gfoidl

Copy link
Copy Markdown
Member

Had another look, no comments added, so LGTM.
It's a nice layering of the methods.

}

return new IndexOfAnyCharValuesProbabilistic(values);
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm still a little concerned about the size impact this method is going to have. Any use of IndexOfAnyValues.Create is going to root all possible implementations (IndexOfEmptyValues, IndexOfAny1Value, IndexOfAny2Values, IndexOfAny3Values, IndexOfAny4Values, IndexOfAny5Values, IndexOfAnyLatin1CharValues, IndexOfAnyCharValuesProbabilistic, and IndexOfAnyValuesInRange), regardless of which is actually used. Basically we're adding a choke point.

I don't have a better answer, unless we want to limit what this API produces, such that we don't special-case APIs that already have a direct public entrypoint (IndexOf0/1/2/3, IndexOfRange).

Let's go with it for now, but keep an eye on it. It'd also be helpful in this regard for us to either in this PR or immediately after follow it up with using these APIs everywhere they're applicable and see what kind of impact it actually has on our size benchmarks, and then what we can do about it. For example, maybe there are ways to share most of the code associated with the vectorization of the individual algorithms, such that each doesn't bring in nearly as much as it does today (Adam and I had spoken about an approach where we'd parameterize the algorithms with a generic struct that provided the setup and comparisons as methods that the driver could then call appropriately as part of loops, unrolled call sites, etc.) For use within corelib, we might also want to make some of these helpers internal and allow those uses to bypass the public Create in order to directly instantiate the needed type. If things still end up being bad, we could consider replacing the general Create with more specialized ones focused on known characteristics of the data. Worst case, we could also employ some additional linker switches to remove various code paths if we want to trade off optimal speed for size.

All that said, I still really like the simplicity of the design we currently have, and that it affords us the ability to pick the best implementation given the supplied data.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm still a little concerned about the size impact this method is going to have

This PR adds 50kB to System.Private.CoreLib.dll with R2R. It is not end of the world given how much we add to the product in each release.

we'd parameterize the algorithms with a generic struct

Note that this only helps with IL size. It does not help with native code size. Also, the generic structs cost some startup time and memory at runtime.

We may want to look into whether the factory can be interpreted at compile time by the native aot compiler. cc @MichalStrehovsky

@MihaZupanMihaZupanNov 21, 2022

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's go with it for now, but keep an eye on it. It'd also be helpful in this regard for us to either in this PR or immediately after follow it up with using these APIs everywhere they're applicable and see what kind of impact it actually has on our size benchmarks

👍 already working on that.
FWIW, in quite a few places so far I've been able to delete substantial chunks of code with this API, so it'd be pretty cool if this was actually a net size improvement in the long run.

Over a bunch of places across runtime where I've used this API locally, I've never used it for the "(IndexOf0/1/2/3, IndexOfRange)" set you called out. Likewise, for cases with 4/5 values, I've never used it if I knew that it would just defer to the existing 4/5 value implementation that could be reached via IndexOfAny(const).
Ignoring cases where the underlying hardware doesn't support these algorithms, we're really just using Ascii in the vast majority of cases from within runtime. Regex can also make similar decisions and avoid using this API for cases that wouldn't benefit.

That said, it's still nice that this API gives you an optimal implementation even if you aren't as concerned about startup/size costs and just want to use the same API for everything without requiring you to be aware of internal implementation details.

Adam and I had spoken about an approach where we'd parameterize the algorithms with a generic struct that provided the setup and comparisons as methods that the driver could then call appropriately as part of loops, unrolled call sites, etc.

I'm curious about what this would look like.

@stephentoubstephentoubNov 21, 2022

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This PR adds 50kB to System.Private.CoreLib.dll with R2R. It is not end of the world given how much we add to the product in each release.

Yeah, my concern isn't the all-up size of corelib, rather the impact on a small trimmed app. But your lack of concern lowers my concern :)

We may want to look into whether the factory can be interpreted at compile time by the native aot compiler

👍

@stephentoubstephentoubNov 21, 2022

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

so it'd be pretty cool if this was actually a net size improvement.

It would be.

I've never used it for the "(IndexOf0/1/2/3, IndexOfRange)" set you called out

Right. Which begs the question of whether it's actually worth including those, since whether we use them or not, at present all that code is going to be kept with the current trimming.

Regex can also make similar decisions and avoid using this API for cases that wouldn't benefit.

Yes, my intention is that the source generator and compiler only use this for cases where there isn't currently a better direct API for it. In large part that's to help with readability, but it'll also help reduce startup overheads.

I'm curious about what this would look like.

The rough strawman, which we never actually tried, was something along the lines of (very rough pseudo code)

internalinterfaceIIndexOfComparer<T>{boolIsMatch(Titem);boolIsMatch(Vector128<T>items);boolIsMatch(Vector256<T>items);}

then with a shared driver like:

internalintIndexOfCore<T,TCore>(ReadOnlySpan<T>span,TComparercomparer)whereTComparer:IIndexOfComparer<T>{if(!Vector128.IsHardwareAccelerated||span.Length<Vector128<T>.Count){// TODO: Unroll for short spansfor(inti=0;i<span.Length;i++)if(comparer.IsMatch(span[i]))returni;}elseif(!Vector256.IsHardwareAccelerated||span.Length<Vector256<T>.Count){
...if(comparer.IsMatch(currentVector))
return FindIndex(currentVector);
...}else{
...// same for Vector256}return-1;}

and then use like:

publicstaticboolIndexOfAny(ReadOnlySpan<char>span,charvalue0,charvalue1)=>IndexOfCore(span,newIndexOfAny2(value0,value1));privatereadonlystructIndexOfAny2<T>:IIndexOfComparer<T>{privateT_value1,value2;privatereadonlyVector128<T>_vector128_1,_vector128_2;privatereadonlyVector256<T>_vector256_1,_vector256_2;publicIndexOfAny2(Tvalue1,Tvalue2){_value1=value1;_value2=value2;_vector128_1=Vector128.Create(value1);_vector128_2=Vector128.Create(value2);
...}publicboolIsMatch(Tvalue)=>value==_value1||value==_value2;publicboolIsMatch(Vector128values)=>values==_vector128_1||values==_vector128_2;
...}

etc. It'd end up looking similar in structure to what you currently have in this PR, actually, just at a lower level.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We may want to look into whether the factory can be interpreted at compile time by the native aot compiler. cc @MichalStrehovsky

You mean to interpret the Create method this comment is on ahead of time depending on the callsite?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

IndexOfAnyValues.Create is expected to be often used in static constructors with constant input. For example, like this: https://github.com/dotnet/runtime/pull/78666/files#diff-2599fdb4dd17bc235b019eb03aed3a26260765050d1e48419bc3e44319ecb147R31-R32

@MihaZupan

Copy link
Copy Markdown
MemberAuthor

Failures are #78584, #69101

@MihaZupan
MihaZupan merged commit 8171bd0 into dotnet:mainNov 21, 2022
@stephentoub

Copy link
Copy Markdown
Member

Awesome.

Sign up for freeto subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

area-System.Memoryblog-candidateCompleted PRs that are candidate topics for blog post coveragenew-api-needs-documentation

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Vectorize IndexOfAny on more than 5 chars

8 participants

@MihaZupan@gfoidl@stephentoub@jkotas@teo-tsirpanis@MichalStrehovsky@jeffhandley@jozkee