Add a SearchValues implementation for values with unique low nibbles - #106900

Merged
MihaZupan merged 5 commits into
dotnet:mainfrom
MihaZupan:searchvalues-uniqueLowNibble2
Sep 10, 2024
Merged

Add a SearchValues implementation for values with unique low nibbles#106900
MihaZupan merged 5 commits into
dotnet:mainfrom
MihaZupan:searchvalues-uniqueLowNibble2

Conversation

@MihaZupan

@MihaZupanMihaZupan commented Aug 23, 2024

Copy link
Copy Markdown
Member

Based on http://0x80.pl/articles/simd-byte-lookup.html#special-case-3-unique-lower-and-higher-nibbles

If all of the values have a different low nibble, we can use a faster search that takes advantage of that fact.
For example, this applies to the "Sherlock|Holmes|Watson|Irene|Adler|John|Baker" regex pattern which uses SearchValues.Create("ABHIJSW").

As a comparison, the current core lookup for an ASCII set on AVX2 uses: 2 and, 1 shift, 2 shuffles

Vector256<byte>Lookup(Vector256<byte>source,Vector256<byte>bitmapLookup){Vector256<byte>highNibbles=(source.AsInt32()>>>4).AsByte()&Vector256.Create((byte)0xF);Vector256<byte>bitMask=Avx2.Shuffle(bitmapLookup,source);Vector256<byte>bitPositions=Avx2.Shuffle(Vector256.Create(0x8040201008040201).AsByte(),highNibbles);returnbitMask&bitPositions;}

Where the core lookup for values with unique low nibbles uses: 1 comparison, 1 shuffle

Vector256<byte>Lookup(Vector256<byte>source,Vector256<byte>valuesByLowNibble){Vector256<byte>values=Avx2.Shuffle(valuesByLowNibble,source);returnVector256.Equals(source,values);}

(code-wise, most of the implementation in this PR is a copy-paste of the existing ASCII logic, swapping out this core lookup routine)


Consider a benchmark inspired by @lemire's https://lemire.me/blog/2024/07/05/scan-html-faster-with-simd-instructions-net-c-edition/
In this case, we're scanning UTF8 input for bytes relevant to HTML (<, &, \r and \0).
Previously, SearchValues would pick the same implementation as span.IndexOfAny(4 values).
The blog post highlights that a hand-written approach can beat SearchValues in this case -- not anymore :)

publicclassBench{privatestaticreadonlySearchValues<byte>s_searchValues=SearchValues.Create("\0\r&<"u8);privatestaticbyte[]s_bytes=Encoding.ASCII.GetBytes(newstring('x',10_000));[Benchmark]publicintFindHtmlChar()=>s_bytes.AsSpan().IndexOfAny(s_searchValues);}

This approach doubles the searching performance on my AVX2 CPU (Ryzen 1700).
On ARM, it's a 1.6X improvement.

MethodToolchainMeanRatio
FindHtmlCharmain605.7 ns1.00
FindHtmlCharpr304.5 ns0.50

Compared to the implementation for an arbitrary ASCII set, this improves throughput between 1.2x and 1.5x depending on the hardware (see more numbers below).


The UniqueLowNibble approach could be used a lot more aggresively (see benchmarks below).
I conservatively placed it between 3 and 4 values to minimize the risk of regressions for now.
In practice, we're currently only using SearchValues with 4 or more values across runtime/aspnet.

As a follow up, I plan on changing our heuristics around which approach we pick in SearchValues depending on the platform.
After that, we may want to consider using it even with fewer values (e.g. 2 or 3).

We should also consider using PackedSpanHelpers on ARM.
Searching for any subset of ASCII is currently faster than a basic IndexOf('a') on M1 hardware because we're not doing that.


Throughput numbers for scanning through 10k elements (10k bytes or 10k chars).
Rows are ordered from fastest to slowest.

ARM (Apple M1)
MethodMeanError
IndexOfAny1Byte233.2 ns0.24 ns
IndexOfAnyByteInRange253.7 ns0.24 ns
IndexOfAny2Byte274.4 ns0.14 ns
IndexOfAnyUniqueLowNibbleByte275.7 ns0.83 ns
IndexOfAnyAsciiByte346.5 ns0.02 ns
IndexOfAny3Byte346.8 ns0.11 ns
IndexOfAny4Byte444.5 ns0.03 ns
IndexOfAnyByte541.7 ns0.05 ns
IndexOfAny5Byte542.2 ns0.05 ns
IndexOfAnyUniqueLowNibbleChar351.4 ns0.49 ns
IndexOfAnyAsciiChar448.2 ns0.41 ns
IndexOfAny1Char453.2 ns0.23 ns
IndexOfAnyInRange497.6 ns0.31 ns
IndexOfAny2Chars543.2 ns0.37 ns
IndexOfAny3Chars688.8 ns0.19 ns
IndexOfAny4Chars884.2 ns0.21 ns
IndexOfAny5Chars1,079.5 ns0.10 ns
ARM (Azure D8plsv5 VM)
MethodMeanError
IndexOfAny1Byte493.0 ns0.04 ns
IndexOfAnyByteInRange544.2 ns2.92 ns
IndexOfAny2Byte636.7 ns6.03 ns
IndexOfAnyUniqueLowNibbleByte664.5 ns4.58 ns
IndexOfAny3Byte851.6 ns7.29 ns
IndexOfAnyAsciiByte853.6 ns5.32 ns
IndexOfAny4Byte1,067.7 ns8.85 ns
IndexOfAny5Byte1,292.5 ns11.32 ns
IndexOfAnyByte1,309.2 ns10.58 ns
IndexOfAny1Char979.7 ns0.08 ns
IndexOfAnyInRange1,075.4 ns4.08 ns
IndexOfAnyUniqueLowNibbleChar1,088.8 ns53.19 ns
IndexOfAny2Chars1,279.2 ns13.17 ns
IndexOfAnyAsciiChar1,316.2 ns0.91 ns
IndexOfAny3Chars1,702.1 ns14.53 ns
IndexOfAny4Chars2,135.7 ns17.84 ns
IndexOfAny5Chars2,578.2 ns21.88 ns
X64 with Vector256 (i9-10900X - no full Avx512)
MethodMeanError
IndexOfAny1Byte164.1 ns2.56 ns
IndexOfAnyUniqueLowNibbleByte163.8 ns0.53 ns
IndexOfAnyByteInRange200.0 ns1.26 ns
IndexOfAny2Byte214.8 ns2.16 ns
IndexOfAny3Byte216.4 ns1.80 ns
IndexOfAny4Byte227.1 ns1.27 ns
IndexOfAnyAsciiByte248.0 ns2.47 ns
IndexOfAny5Byte252.0 ns0.75 ns
IndexOfAnyByte361.8 ns1.34 ns
IndexOfAny1PackedChar209.1 ns0.23 ns
IndexOfLetterIgnoreCase199.4 ns1.92 ns
IndexOfAnyUniqueLowNibbleChar218.3 ns0.25 ns
IndexOfAny2PackedChars231.7 ns2.57 ns
IndexOfTwoLettersIgnoreCase243.4 ns2.00 ns
IndexOfAny3PackedChars248.4 ns2.82 ns
IndexOfAnyInRangePacked248.7 ns2.49 ns
IndexOfAnyAsciiChar287.2 ns0.38 ns
IndexOfAny1Char304.3 ns3.55 ns
IndexOfAnyInRange395.7 ns3.20 ns
IndexOfAny2Chars416.4 ns5.64 ns
IndexOfAny3Chars410.5 ns3.66 ns
IndexOfAny4Chars440.0 ns3.07 ns
IndexOfAny5Chars496.1 ns1.85 ns
X64 with Vector256 (Ryzen 1700)
MethodMeanError
IndexOfAny1Byte241.3 ns1.51 ns
IndexOfAnyUniqueLowNibbleByte279.0 ns1.54 ns
IndexOfAnyByteInRange368.5 ns1.80 ns
IndexOfAny2Byte369.9 ns1.89 ns
IndexOfAny3Byte447.2 ns2.03 ns
IndexOfAnyAsciiByte455.7 ns2.62 ns
IndexOfAny4Byte557.9 ns1.79 ns
IndexOfAny5Byte640.4 ns3.19 ns
IndexOfAnyByte655.3 ns3.58 ns
IndexOfAny1PackedChar280.7 ns1.48 ns
IndexOfAnyUniqueLowNibbleChar363.0 ns1.94 ns
IndexOfAny2PackedChars365.7 ns1.99 ns
IndexOfLetterIgnoreCase369.2 ns1.98 ns
IndexOfAnyInRangePacked375.1 ns1.27 ns
IndexOfAny3PackedChars448.3 ns2.02 ns
IndexOfTwoLettersIgnoreCase459.5 ns2.24 ns
IndexOfAny1Char461.1 ns1.85 ns
IndexOfAnyAsciiChar545.5 ns18.02 ns
IndexOfAnyInRange718.8 ns4.14 ns
IndexOfAny2Chars734.8 ns2.81 ns
IndexOfAny3Chars922.0 ns2.75 ns
IndexOfAny4Chars1,091.1 ns5.75 ns
IndexOfAny5Chars1,254.2 ns7.16 ns
X64 with Vector512 (Xeon Platinum 8370C)
MethodMeanError
IndexOfAny1Byte99.20 ns0.811 ns
IndexOfAny2Byte186.23 ns0.157 ns
IndexOfAny3Byte236.63 ns0.228 ns
IndexOfAnyByteInRange253.85 ns0.279 ns
IndexOfAnyUniqueLowNibbleByte273.06 ns3.011 ns
IndexOfAny4Byte312.36 ns0.102 ns
IndexOfAnyAsciiByte346.18 ns2.557 ns
IndexOfAny5Byte363.69 ns0.160 ns
IndexOfAnyByte422.75 ns1.270 ns
IndexOfAny1PackedChar165.53 ns3.280 ns
IndexOfAnyInRangePacked168.52 ns2.998 ns
IndexOfLetterIgnoreCase170.30 ns2.769 ns
IndexOfAny1Char194.79 ns0.097 ns
IndexOfAny2PackedChars217.14 ns0.150 ns
IndexOfTwoLettersIgnoreCase239.79 ns0.205 ns
IndexOfAny3PackedChars268.56 ns0.217 ns
IndexOfAnyUniqueLowNibbleChar271.63 ns1.392 ns
IndexOfAnyAsciiChar327.17 ns1.021 ns
IndexOfAny2Chars366.79 ns0.087 ns
IndexOfAny3Chars468.80 ns0.093 ns
IndexOfAnyInRange500.94 ns0.521 ns
IndexOfAny4Chars621.20 ns0.209 ns
IndexOfAny5Chars723.78 ns0.212 ns

@MihaZupanMihaZupan added this to the 10.0.0 milestone Aug 23, 2024
@MihaZupanMihaZupan self-assigned this Aug 23, 2024
@dotnet-policy-service

Copy link
Copy Markdown
Contributor

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

{
// Avoid false positives for the zero character if no other character has a low nibble of zero.
// We can replace it with any other byte that has a non-zero low nibble.
valuesByLowNibble.SetElementUnsafe(0, (byte)1);

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 didn't fully grok this. Why don't we need to check if 1 is already being used?

@MihaZupanMihaZupanSep 5, 2024

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.

All vector elements start out as 0, and not all of them may be initialized.

We map every input character to an element based on its lower nibble.

 0, 16, 32 ... => valuesByLowNibble[0]
1, 17, 33 ... => valuesByLowNibble[1]
15, 31, 47 ... => valuesByLowNibble[15] 

The search works by first picking a potential match based on the low nibble (Shuffle) and then confirming it (Equals).

This means that input characters with a given low nibble only care about the element of valuesByLowNibble for that nibble. Values like 1 or 2 don't care about what the value of valuesByLowNibble[7] is since they'll never be mapped to it.

This also means that it's okay for valuesByLowNibble to be left uninitialized at 0.
The Equals could only match for an input character 0, but those will always be mapped to valuesByLowNibble[0] by the shuffle instead.

The edge case is the 0th nibble since the character 0 could be a false positive there.
But it'll only be a false positive if we don't have the character 0 in our values.
That's the valuesByLowNibble.GetElement(0) == 0 && !lookup.Contains(0) check above.

To avoid false positives for 0, we can use the same trick of setting the element to some "unreachable" value.
We can use any value with a non-zero nibble, as the shuffle will map any inputs with those values to a different element. 1 is just an arbitrary choice.

Edit: I tweaked the comment a bit, hopefully, it's decipherable.

@MihaZupan
MihaZupanforce-pushed the searchvalues-uniqueLowNibble2 branch from d2ae610 to fe3ae67CompareSeptember 6, 2024 17:22
@MihaZupan
MihaZupan merged commit b06d5e2 into dotnet:mainSep 10, 2024
jtschuster pushed a commit to jtschuster/runtime that referenced this pull request Sep 17, 2024
…otnet#106900)
* Add SearchValues implementation for values with unique low nibbles
* More generics
* Tweak comment
* Remove extra empty line
* Update comment
sirntar pushed a commit to sirntar/runtime that referenced this pull request Sep 30, 2024
…otnet#106900)
* Add SearchValues implementation for values with unique low nibbles
* More generics
* Tweak comment
* Remove extra empty line
* Update comment
@github-actionsgithub-actionsBot locked and limited conversation to collaborators Oct 12, 2024
Sign up for freeto subscribe to this conversation on GitHub. Already have an account? Sign in.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@MihaZupan@stephentoub
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content

Add a SearchValues implementation for values with unique low nibbles - #106900

Merged
MihaZupan merged 5 commits into
dotnet:mainfrom
MihaZupan:searchvalues-uniqueLowNibble2
Sep 10, 2024
Merged

Add a SearchValues implementation for values with unique low nibbles#106900
MihaZupan merged 5 commits into
dotnet:mainfrom
MihaZupan:searchvalues-uniqueLowNibble2

Conversation

@MihaZupan

@MihaZupanMihaZupan commented Aug 23, 2024

Copy link
Copy Markdown
Member

Based on http://0x80.pl/articles/simd-byte-lookup.html#special-case-3-unique-lower-and-higher-nibbles

If all of the values have a different low nibble, we can use a faster search that takes advantage of that fact.
For example, this applies to the "Sherlock|Holmes|Watson|Irene|Adler|John|Baker" regex pattern which uses SearchValues.Create("ABHIJSW").

As a comparison, the current core lookup for an ASCII set on AVX2 uses: 2 and, 1 shift, 2 shuffles

Vector256<byte>Lookup(Vector256<byte>source,Vector256<byte>bitmapLookup){Vector256<byte>highNibbles=(source.AsInt32()>>>4).AsByte()&Vector256.Create((byte)0xF);Vector256<byte>bitMask=Avx2.Shuffle(bitmapLookup,source);Vector256<byte>bitPositions=Avx2.Shuffle(Vector256.Create(0x8040201008040201).AsByte(),highNibbles);returnbitMask&bitPositions;}

Where the core lookup for values with unique low nibbles uses: 1 comparison, 1 shuffle

Vector256<byte>Lookup(Vector256<byte>source,Vector256<byte>valuesByLowNibble){Vector256<byte>values=Avx2.Shuffle(valuesByLowNibble,source);returnVector256.Equals(source,values);}

(code-wise, most of the implementation in this PR is a copy-paste of the existing ASCII logic, swapping out this core lookup routine)


Consider a benchmark inspired by @lemire's https://lemire.me/blog/2024/07/05/scan-html-faster-with-simd-instructions-net-c-edition/
In this case, we're scanning UTF8 input for bytes relevant to HTML (<, &, \r and \0).
Previously, SearchValues would pick the same implementation as span.IndexOfAny(4 values).
The blog post highlights that a hand-written approach can beat SearchValues in this case -- not anymore :)

publicclassBench{privatestaticreadonlySearchValues<byte>s_searchValues=SearchValues.Create("\0\r&<"u8);privatestaticbyte[]s_bytes=Encoding.ASCII.GetBytes(newstring('x',10_000));[Benchmark]publicintFindHtmlChar()=>s_bytes.AsSpan().IndexOfAny(s_searchValues);}

This approach doubles the searching performance on my AVX2 CPU (Ryzen 1700).
On ARM, it's a 1.6X improvement.

MethodToolchainMeanRatio
FindHtmlCharmain605.7 ns1.00
FindHtmlCharpr304.5 ns0.50

Compared to the implementation for an arbitrary ASCII set, this improves throughput between 1.2x and 1.5x depending on the hardware (see more numbers below).


The UniqueLowNibble approach could be used a lot more aggresively (see benchmarks below).
I conservatively placed it between 3 and 4 values to minimize the risk of regressions for now.
In practice, we're currently only using SearchValues with 4 or more values across runtime/aspnet.

As a follow up, I plan on changing our heuristics around which approach we pick in SearchValues depending on the platform.
After that, we may want to consider using it even with fewer values (e.g. 2 or 3).

We should also consider using PackedSpanHelpers on ARM.
Searching for any subset of ASCII is currently faster than a basic IndexOf('a') on M1 hardware because we're not doing that.


Throughput numbers for scanning through 10k elements (10k bytes or 10k chars).
Rows are ordered from fastest to slowest.

ARM (Apple M1)
MethodMeanError
IndexOfAny1Byte233.2 ns0.24 ns
IndexOfAnyByteInRange253.7 ns0.24 ns
IndexOfAny2Byte274.4 ns0.14 ns
IndexOfAnyUniqueLowNibbleByte275.7 ns0.83 ns
IndexOfAnyAsciiByte346.5 ns0.02 ns
IndexOfAny3Byte346.8 ns0.11 ns
IndexOfAny4Byte444.5 ns0.03 ns
IndexOfAnyByte541.7 ns0.05 ns
IndexOfAny5Byte542.2 ns0.05 ns
IndexOfAnyUniqueLowNibbleChar351.4 ns0.49 ns
IndexOfAnyAsciiChar448.2 ns0.41 ns
IndexOfAny1Char453.2 ns0.23 ns
IndexOfAnyInRange497.6 ns0.31 ns
IndexOfAny2Chars543.2 ns0.37 ns
IndexOfAny3Chars688.8 ns0.19 ns
IndexOfAny4Chars884.2 ns0.21 ns
IndexOfAny5Chars1,079.5 ns0.10 ns
ARM (Azure D8plsv5 VM)
MethodMeanError
IndexOfAny1Byte493.0 ns0.04 ns
IndexOfAnyByteInRange544.2 ns2.92 ns
IndexOfAny2Byte636.7 ns6.03 ns
IndexOfAnyUniqueLowNibbleByte664.5 ns4.58 ns
IndexOfAny3Byte851.6 ns7.29 ns
IndexOfAnyAsciiByte853.6 ns5.32 ns
IndexOfAny4Byte1,067.7 ns8.85 ns
IndexOfAny5Byte1,292.5 ns11.32 ns
IndexOfAnyByte1,309.2 ns10.58 ns
IndexOfAny1Char979.7 ns0.08 ns
IndexOfAnyInRange1,075.4 ns4.08 ns
IndexOfAnyUniqueLowNibbleChar1,088.8 ns53.19 ns
IndexOfAny2Chars1,279.2 ns13.17 ns
IndexOfAnyAsciiChar1,316.2 ns0.91 ns
IndexOfAny3Chars1,702.1 ns14.53 ns
IndexOfAny4Chars2,135.7 ns17.84 ns
IndexOfAny5Chars2,578.2 ns21.88 ns
X64 with Vector256 (i9-10900X - no full Avx512)
MethodMeanError
IndexOfAny1Byte164.1 ns2.56 ns
IndexOfAnyUniqueLowNibbleByte163.8 ns0.53 ns
IndexOfAnyByteInRange200.0 ns1.26 ns
IndexOfAny2Byte214.8 ns2.16 ns
IndexOfAny3Byte216.4 ns1.80 ns
IndexOfAny4Byte227.1 ns1.27 ns
IndexOfAnyAsciiByte248.0 ns2.47 ns
IndexOfAny5Byte252.0 ns0.75 ns
IndexOfAnyByte361.8 ns1.34 ns
IndexOfAny1PackedChar209.1 ns0.23 ns
IndexOfLetterIgnoreCase199.4 ns1.92 ns
IndexOfAnyUniqueLowNibbleChar218.3 ns0.25 ns
IndexOfAny2PackedChars231.7 ns2.57 ns
IndexOfTwoLettersIgnoreCase243.4 ns2.00 ns
IndexOfAny3PackedChars248.4 ns2.82 ns
IndexOfAnyInRangePacked248.7 ns2.49 ns
IndexOfAnyAsciiChar287.2 ns0.38 ns
IndexOfAny1Char304.3 ns3.55 ns
IndexOfAnyInRange395.7 ns3.20 ns
IndexOfAny2Chars416.4 ns5.64 ns
IndexOfAny3Chars410.5 ns3.66 ns
IndexOfAny4Chars440.0 ns3.07 ns
IndexOfAny5Chars496.1 ns1.85 ns
X64 with Vector256 (Ryzen 1700)
MethodMeanError
IndexOfAny1Byte241.3 ns1.51 ns
IndexOfAnyUniqueLowNibbleByte279.0 ns1.54 ns
IndexOfAnyByteInRange368.5 ns1.80 ns
IndexOfAny2Byte369.9 ns1.89 ns
IndexOfAny3Byte447.2 ns2.03 ns
IndexOfAnyAsciiByte455.7 ns2.62 ns
IndexOfAny4Byte557.9 ns1.79 ns
IndexOfAny5Byte640.4 ns3.19 ns
IndexOfAnyByte655.3 ns3.58 ns
IndexOfAny1PackedChar280.7 ns1.48 ns
IndexOfAnyUniqueLowNibbleChar363.0 ns1.94 ns
IndexOfAny2PackedChars365.7 ns1.99 ns
IndexOfLetterIgnoreCase369.2 ns1.98 ns
IndexOfAnyInRangePacked375.1 ns1.27 ns
IndexOfAny3PackedChars448.3 ns2.02 ns
IndexOfTwoLettersIgnoreCase459.5 ns2.24 ns
IndexOfAny1Char461.1 ns1.85 ns
IndexOfAnyAsciiChar545.5 ns18.02 ns
IndexOfAnyInRange718.8 ns4.14 ns
IndexOfAny2Chars734.8 ns2.81 ns
IndexOfAny3Chars922.0 ns2.75 ns
IndexOfAny4Chars1,091.1 ns5.75 ns
IndexOfAny5Chars1,254.2 ns7.16 ns
X64 with Vector512 (Xeon Platinum 8370C)
MethodMeanError
IndexOfAny1Byte99.20 ns0.811 ns
IndexOfAny2Byte186.23 ns0.157 ns
IndexOfAny3Byte236.63 ns0.228 ns
IndexOfAnyByteInRange253.85 ns0.279 ns
IndexOfAnyUniqueLowNibbleByte273.06 ns3.011 ns
IndexOfAny4Byte312.36 ns0.102 ns
IndexOfAnyAsciiByte346.18 ns2.557 ns
IndexOfAny5Byte363.69 ns0.160 ns
IndexOfAnyByte422.75 ns1.270 ns
IndexOfAny1PackedChar165.53 ns3.280 ns
IndexOfAnyInRangePacked168.52 ns2.998 ns
IndexOfLetterIgnoreCase170.30 ns2.769 ns
IndexOfAny1Char194.79 ns0.097 ns
IndexOfAny2PackedChars217.14 ns0.150 ns
IndexOfTwoLettersIgnoreCase239.79 ns0.205 ns
IndexOfAny3PackedChars268.56 ns0.217 ns
IndexOfAnyUniqueLowNibbleChar271.63 ns1.392 ns
IndexOfAnyAsciiChar327.17 ns1.021 ns
IndexOfAny2Chars366.79 ns0.087 ns
IndexOfAny3Chars468.80 ns0.093 ns
IndexOfAnyInRange500.94 ns0.521 ns
IndexOfAny4Chars621.20 ns0.209 ns
IndexOfAny5Chars723.78 ns0.212 ns

@MihaZupanMihaZupan added this to the 10.0.0 milestone Aug 23, 2024
@MihaZupanMihaZupan self-assigned this Aug 23, 2024
@dotnet-policy-service

Copy link
Copy Markdown
Contributor

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

{
// Avoid false positives for the zero character if no other character has a low nibble of zero.
// We can replace it with any other byte that has a non-zero low nibble.
valuesByLowNibble.SetElementUnsafe(0, (byte)1);

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 didn't fully grok this. Why don't we need to check if 1 is already being used?

@MihaZupanMihaZupanSep 5, 2024

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.

All vector elements start out as 0, and not all of them may be initialized.

We map every input character to an element based on its lower nibble.

 0, 16, 32 ... => valuesByLowNibble[0]
1, 17, 33 ... => valuesByLowNibble[1]
15, 31, 47 ... => valuesByLowNibble[15] 

The search works by first picking a potential match based on the low nibble (Shuffle) and then confirming it (Equals).

This means that input characters with a given low nibble only care about the element of valuesByLowNibble for that nibble. Values like 1 or 2 don't care about what the value of valuesByLowNibble[7] is since they'll never be mapped to it.

This also means that it's okay for valuesByLowNibble to be left uninitialized at 0.
The Equals could only match for an input character 0, but those will always be mapped to valuesByLowNibble[0] by the shuffle instead.

The edge case is the 0th nibble since the character 0 could be a false positive there.
But it'll only be a false positive if we don't have the character 0 in our values.
That's the valuesByLowNibble.GetElement(0) == 0 && !lookup.Contains(0) check above.

To avoid false positives for 0, we can use the same trick of setting the element to some "unreachable" value.
We can use any value with a non-zero nibble, as the shuffle will map any inputs with those values to a different element. 1 is just an arbitrary choice.

Edit: I tweaked the comment a bit, hopefully, it's decipherable.

@MihaZupan
MihaZupanforce-pushed the searchvalues-uniqueLowNibble2 branch from d2ae610 to fe3ae67CompareSeptember 6, 2024 17:22
@MihaZupan
MihaZupan merged commit b06d5e2 into dotnet:mainSep 10, 2024
jtschuster pushed a commit to jtschuster/runtime that referenced this pull request Sep 17, 2024
…otnet#106900)
* Add SearchValues implementation for values with unique low nibbles
* More generics
* Tweak comment
* Remove extra empty line
* Update comment
sirntar pushed a commit to sirntar/runtime that referenced this pull request Sep 30, 2024
…otnet#106900)
* Add SearchValues implementation for values with unique low nibbles
* More generics
* Tweak comment
* Remove extra empty line
* Update comment
@github-actionsgithub-actionsBot locked and limited conversation to collaborators Oct 12, 2024
Sign up for freeto subscribe to this conversation on GitHub. Already have an account? Sign in.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@MihaZupan@stephentoub
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Add a SearchValues implementation for values with unique low nibbles - #106900

Merged
MihaZupan merged 5 commits into
dotnet:mainfrom
MihaZupan:searchvalues-uniqueLowNibble2
Sep 10, 2024
Merged

Add a SearchValues implementation for values with unique low nibbles#106900
MihaZupan merged 5 commits into
dotnet:mainfrom
MihaZupan:searchvalues-uniqueLowNibble2

Conversation

@MihaZupan

@MihaZupanMihaZupan commented Aug 23, 2024

Copy link
Copy Markdown
Member

Based on http://0x80.pl/articles/simd-byte-lookup.html#special-case-3-unique-lower-and-higher-nibbles

If all of the values have a different low nibble, we can use a faster search that takes advantage of that fact.
For example, this applies to the "Sherlock|Holmes|Watson|Irene|Adler|John|Baker" regex pattern which uses SearchValues.Create("ABHIJSW").

As a comparison, the current core lookup for an ASCII set on AVX2 uses: 2 and, 1 shift, 2 shuffles

Vector256<byte>Lookup(Vector256<byte>source,Vector256<byte>bitmapLookup){Vector256<byte>highNibbles=(source.AsInt32()>>>4).AsByte()&Vector256.Create((byte)0xF);Vector256<byte>bitMask=Avx2.Shuffle(bitmapLookup,source);Vector256<byte>bitPositions=Avx2.Shuffle(Vector256.Create(0x8040201008040201).AsByte(),highNibbles);returnbitMask&bitPositions;}

Where the core lookup for values with unique low nibbles uses: 1 comparison, 1 shuffle

Vector256<byte>Lookup(Vector256<byte>source,Vector256<byte>valuesByLowNibble){Vector256<byte>values=Avx2.Shuffle(valuesByLowNibble,source);returnVector256.Equals(source,values);}

(code-wise, most of the implementation in this PR is a copy-paste of the existing ASCII logic, swapping out this core lookup routine)


Consider a benchmark inspired by @lemire's https://lemire.me/blog/2024/07/05/scan-html-faster-with-simd-instructions-net-c-edition/
In this case, we're scanning UTF8 input for bytes relevant to HTML (<, &, \r and \0).
Previously, SearchValues would pick the same implementation as span.IndexOfAny(4 values).
The blog post highlights that a hand-written approach can beat SearchValues in this case -- not anymore :)

publicclassBench{privatestaticreadonlySearchValues<byte>s_searchValues=SearchValues.Create("\0\r&<"u8);privatestaticbyte[]s_bytes=Encoding.ASCII.GetBytes(newstring('x',10_000));[Benchmark]publicintFindHtmlChar()=>s_bytes.AsSpan().IndexOfAny(s_searchValues);}

This approach doubles the searching performance on my AVX2 CPU (Ryzen 1700).
On ARM, it's a 1.6X improvement.

MethodToolchainMeanRatio
FindHtmlCharmain605.7 ns1.00
FindHtmlCharpr304.5 ns0.50

Compared to the implementation for an arbitrary ASCII set, this improves throughput between 1.2x and 1.5x depending on the hardware (see more numbers below).


The UniqueLowNibble approach could be used a lot more aggresively (see benchmarks below).
I conservatively placed it between 3 and 4 values to minimize the risk of regressions for now.
In practice, we're currently only using SearchValues with 4 or more values across runtime/aspnet.

As a follow up, I plan on changing our heuristics around which approach we pick in SearchValues depending on the platform.
After that, we may want to consider using it even with fewer values (e.g. 2 or 3).

We should also consider using PackedSpanHelpers on ARM.
Searching for any subset of ASCII is currently faster than a basic IndexOf('a') on M1 hardware because we're not doing that.


Throughput numbers for scanning through 10k elements (10k bytes or 10k chars).
Rows are ordered from fastest to slowest.

ARM (Apple M1)
MethodMeanError
IndexOfAny1Byte233.2 ns0.24 ns
IndexOfAnyByteInRange253.7 ns0.24 ns
IndexOfAny2Byte274.4 ns0.14 ns
IndexOfAnyUniqueLowNibbleByte275.7 ns0.83 ns
IndexOfAnyAsciiByte346.5 ns0.02 ns
IndexOfAny3Byte346.8 ns0.11 ns
IndexOfAny4Byte444.5 ns0.03 ns
IndexOfAnyByte541.7 ns0.05 ns
IndexOfAny5Byte542.2 ns0.05 ns
IndexOfAnyUniqueLowNibbleChar351.4 ns0.49 ns
IndexOfAnyAsciiChar448.2 ns0.41 ns
IndexOfAny1Char453.2 ns0.23 ns
IndexOfAnyInRange497.6 ns0.31 ns
IndexOfAny2Chars543.2 ns0.37 ns
IndexOfAny3Chars688.8 ns0.19 ns
IndexOfAny4Chars884.2 ns0.21 ns
IndexOfAny5Chars1,079.5 ns0.10 ns
ARM (Azure D8plsv5 VM)
MethodMeanError
IndexOfAny1Byte493.0 ns0.04 ns
IndexOfAnyByteInRange544.2 ns2.92 ns
IndexOfAny2Byte636.7 ns6.03 ns
IndexOfAnyUniqueLowNibbleByte664.5 ns4.58 ns
IndexOfAny3Byte851.6 ns7.29 ns
IndexOfAnyAsciiByte853.6 ns5.32 ns
IndexOfAny4Byte1,067.7 ns8.85 ns
IndexOfAny5Byte1,292.5 ns11.32 ns
IndexOfAnyByte1,309.2 ns10.58 ns
IndexOfAny1Char979.7 ns0.08 ns
IndexOfAnyInRange1,075.4 ns4.08 ns
IndexOfAnyUniqueLowNibbleChar1,088.8 ns53.19 ns
IndexOfAny2Chars1,279.2 ns13.17 ns
IndexOfAnyAsciiChar1,316.2 ns0.91 ns
IndexOfAny3Chars1,702.1 ns14.53 ns
IndexOfAny4Chars2,135.7 ns17.84 ns
IndexOfAny5Chars2,578.2 ns21.88 ns
X64 with Vector256 (i9-10900X - no full Avx512)
MethodMeanError
IndexOfAny1Byte164.1 ns2.56 ns
IndexOfAnyUniqueLowNibbleByte163.8 ns0.53 ns
IndexOfAnyByteInRange200.0 ns1.26 ns
IndexOfAny2Byte214.8 ns2.16 ns
IndexOfAny3Byte216.4 ns1.80 ns
IndexOfAny4Byte227.1 ns1.27 ns
IndexOfAnyAsciiByte248.0 ns2.47 ns
IndexOfAny5Byte252.0 ns0.75 ns
IndexOfAnyByte361.8 ns1.34 ns
IndexOfAny1PackedChar209.1 ns0.23 ns
IndexOfLetterIgnoreCase199.4 ns1.92 ns
IndexOfAnyUniqueLowNibbleChar218.3 ns0.25 ns
IndexOfAny2PackedChars231.7 ns2.57 ns
IndexOfTwoLettersIgnoreCase243.4 ns2.00 ns
IndexOfAny3PackedChars248.4 ns2.82 ns
IndexOfAnyInRangePacked248.7 ns2.49 ns
IndexOfAnyAsciiChar287.2 ns0.38 ns
IndexOfAny1Char304.3 ns3.55 ns
IndexOfAnyInRange395.7 ns3.20 ns
IndexOfAny2Chars416.4 ns5.64 ns
IndexOfAny3Chars410.5 ns3.66 ns
IndexOfAny4Chars440.0 ns3.07 ns
IndexOfAny5Chars496.1 ns1.85 ns
X64 with Vector256 (Ryzen 1700)
MethodMeanError
IndexOfAny1Byte241.3 ns1.51 ns
IndexOfAnyUniqueLowNibbleByte279.0 ns1.54 ns
IndexOfAnyByteInRange368.5 ns1.80 ns
IndexOfAny2Byte369.9 ns1.89 ns
IndexOfAny3Byte447.2 ns2.03 ns
IndexOfAnyAsciiByte455.7 ns2.62 ns
IndexOfAny4Byte557.9 ns1.79 ns
IndexOfAny5Byte640.4 ns3.19 ns
IndexOfAnyByte655.3 ns3.58 ns
IndexOfAny1PackedChar280.7 ns1.48 ns
IndexOfAnyUniqueLowNibbleChar363.0 ns1.94 ns
IndexOfAny2PackedChars365.7 ns1.99 ns
IndexOfLetterIgnoreCase369.2 ns1.98 ns
IndexOfAnyInRangePacked375.1 ns1.27 ns
IndexOfAny3PackedChars448.3 ns2.02 ns
IndexOfTwoLettersIgnoreCase459.5 ns2.24 ns
IndexOfAny1Char461.1 ns1.85 ns
IndexOfAnyAsciiChar545.5 ns18.02 ns
IndexOfAnyInRange718.8 ns4.14 ns
IndexOfAny2Chars734.8 ns2.81 ns
IndexOfAny3Chars922.0 ns2.75 ns
IndexOfAny4Chars1,091.1 ns5.75 ns
IndexOfAny5Chars1,254.2 ns7.16 ns
X64 with Vector512 (Xeon Platinum 8370C)
MethodMeanError
IndexOfAny1Byte99.20 ns0.811 ns
IndexOfAny2Byte186.23 ns0.157 ns
IndexOfAny3Byte236.63 ns0.228 ns
IndexOfAnyByteInRange253.85 ns0.279 ns
IndexOfAnyUniqueLowNibbleByte273.06 ns3.011 ns
IndexOfAny4Byte312.36 ns0.102 ns
IndexOfAnyAsciiByte346.18 ns2.557 ns
IndexOfAny5Byte363.69 ns0.160 ns
IndexOfAnyByte422.75 ns1.270 ns
IndexOfAny1PackedChar165.53 ns3.280 ns
IndexOfAnyInRangePacked168.52 ns2.998 ns
IndexOfLetterIgnoreCase170.30 ns2.769 ns
IndexOfAny1Char194.79 ns0.097 ns
IndexOfAny2PackedChars217.14 ns0.150 ns
IndexOfTwoLettersIgnoreCase239.79 ns0.205 ns
IndexOfAny3PackedChars268.56 ns0.217 ns
IndexOfAnyUniqueLowNibbleChar271.63 ns1.392 ns
IndexOfAnyAsciiChar327.17 ns1.021 ns
IndexOfAny2Chars366.79 ns0.087 ns
IndexOfAny3Chars468.80 ns0.093 ns
IndexOfAnyInRange500.94 ns0.521 ns
IndexOfAny4Chars621.20 ns0.209 ns
IndexOfAny5Chars723.78 ns0.212 ns

@MihaZupanMihaZupan added this to the 10.0.0 milestone Aug 23, 2024
@MihaZupanMihaZupan self-assigned this Aug 23, 2024
@dotnet-policy-service

Copy link
Copy Markdown
Contributor

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

{
// Avoid false positives for the zero character if no other character has a low nibble of zero.
// We can replace it with any other byte that has a non-zero low nibble.
valuesByLowNibble.SetElementUnsafe(0, (byte)1);

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 didn't fully grok this. Why don't we need to check if 1 is already being used?

@MihaZupanMihaZupanSep 5, 2024

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.

All vector elements start out as 0, and not all of them may be initialized.

We map every input character to an element based on its lower nibble.

 0, 16, 32 ... => valuesByLowNibble[0]
1, 17, 33 ... => valuesByLowNibble[1]
15, 31, 47 ... => valuesByLowNibble[15] 

The search works by first picking a potential match based on the low nibble (Shuffle) and then confirming it (Equals).

This means that input characters with a given low nibble only care about the element of valuesByLowNibble for that nibble. Values like 1 or 2 don't care about what the value of valuesByLowNibble[7] is since they'll never be mapped to it.

This also means that it's okay for valuesByLowNibble to be left uninitialized at 0.
The Equals could only match for an input character 0, but those will always be mapped to valuesByLowNibble[0] by the shuffle instead.

The edge case is the 0th nibble since the character 0 could be a false positive there.
But it'll only be a false positive if we don't have the character 0 in our values.
That's the valuesByLowNibble.GetElement(0) == 0 && !lookup.Contains(0) check above.

To avoid false positives for 0, we can use the same trick of setting the element to some "unreachable" value.
We can use any value with a non-zero nibble, as the shuffle will map any inputs with those values to a different element. 1 is just an arbitrary choice.

Edit: I tweaked the comment a bit, hopefully, it's decipherable.

@MihaZupan
MihaZupanforce-pushed the searchvalues-uniqueLowNibble2 branch from d2ae610 to fe3ae67CompareSeptember 6, 2024 17:22
@MihaZupan
MihaZupan merged commit b06d5e2 into dotnet:mainSep 10, 2024
jtschuster pushed a commit to jtschuster/runtime that referenced this pull request Sep 17, 2024
…otnet#106900)
* Add SearchValues implementation for values with unique low nibbles
* More generics
* Tweak comment
* Remove extra empty line
* Update comment
sirntar pushed a commit to sirntar/runtime that referenced this pull request Sep 30, 2024
…otnet#106900)
* Add SearchValues implementation for values with unique low nibbles
* More generics
* Tweak comment
* Remove extra empty line
* Update comment
@github-actionsgithub-actionsBot locked and limited conversation to collaborators Oct 12, 2024
Sign up for freeto subscribe to this conversation on GitHub. Already have an account? Sign in.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@MihaZupan@stephentoub
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Add a SearchValues implementation for values with unique low nibbles - #106900

Merged
MihaZupan merged 5 commits into
dotnet:mainfrom
MihaZupan:searchvalues-uniqueLowNibble2
Sep 10, 2024
Merged

Add a SearchValues implementation for values with unique low nibbles#106900
MihaZupan merged 5 commits into
dotnet:mainfrom
MihaZupan:searchvalues-uniqueLowNibble2

Conversation

@MihaZupan

@MihaZupanMihaZupan commented Aug 23, 2024

Copy link
Copy Markdown
Member

Based on http://0x80.pl/articles/simd-byte-lookup.html#special-case-3-unique-lower-and-higher-nibbles

If all of the values have a different low nibble, we can use a faster search that takes advantage of that fact.
For example, this applies to the "Sherlock|Holmes|Watson|Irene|Adler|John|Baker" regex pattern which uses SearchValues.Create("ABHIJSW").

As a comparison, the current core lookup for an ASCII set on AVX2 uses: 2 and, 1 shift, 2 shuffles

Vector256<byte>Lookup(Vector256<byte>source,Vector256<byte>bitmapLookup){Vector256<byte>highNibbles=(source.AsInt32()>>>4).AsByte()&Vector256.Create((byte)0xF);Vector256<byte>bitMask=Avx2.Shuffle(bitmapLookup,source);Vector256<byte>bitPositions=Avx2.Shuffle(Vector256.Create(0x8040201008040201).AsByte(),highNibbles);returnbitMask&bitPositions;}

Where the core lookup for values with unique low nibbles uses: 1 comparison, 1 shuffle

Vector256<byte>Lookup(Vector256<byte>source,Vector256<byte>valuesByLowNibble){Vector256<byte>values=Avx2.Shuffle(valuesByLowNibble,source);returnVector256.Equals(source,values);}

(code-wise, most of the implementation in this PR is a copy-paste of the existing ASCII logic, swapping out this core lookup routine)


Consider a benchmark inspired by @lemire's https://lemire.me/blog/2024/07/05/scan-html-faster-with-simd-instructions-net-c-edition/
In this case, we're scanning UTF8 input for bytes relevant to HTML (<, &, \r and \0).
Previously, SearchValues would pick the same implementation as span.IndexOfAny(4 values).
The blog post highlights that a hand-written approach can beat SearchValues in this case -- not anymore :)

publicclassBench{privatestaticreadonlySearchValues<byte>s_searchValues=SearchValues.Create("\0\r&<"u8);privatestaticbyte[]s_bytes=Encoding.ASCII.GetBytes(newstring('x',10_000));[Benchmark]publicintFindHtmlChar()=>s_bytes.AsSpan().IndexOfAny(s_searchValues);}

This approach doubles the searching performance on my AVX2 CPU (Ryzen 1700).
On ARM, it's a 1.6X improvement.

MethodToolchainMeanRatio
FindHtmlCharmain605.7 ns1.00
FindHtmlCharpr304.5 ns0.50

Compared to the implementation for an arbitrary ASCII set, this improves throughput between 1.2x and 1.5x depending on the hardware (see more numbers below).


The UniqueLowNibble approach could be used a lot more aggresively (see benchmarks below).
I conservatively placed it between 3 and 4 values to minimize the risk of regressions for now.
In practice, we're currently only using SearchValues with 4 or more values across runtime/aspnet.

As a follow up, I plan on changing our heuristics around which approach we pick in SearchValues depending on the platform.
After that, we may want to consider using it even with fewer values (e.g. 2 or 3).

We should also consider using PackedSpanHelpers on ARM.
Searching for any subset of ASCII is currently faster than a basic IndexOf('a') on M1 hardware because we're not doing that.


Throughput numbers for scanning through 10k elements (10k bytes or 10k chars).
Rows are ordered from fastest to slowest.

ARM (Apple M1)
MethodMeanError
IndexOfAny1Byte233.2 ns0.24 ns
IndexOfAnyByteInRange253.7 ns0.24 ns
IndexOfAny2Byte274.4 ns0.14 ns
IndexOfAnyUniqueLowNibbleByte275.7 ns0.83 ns
IndexOfAnyAsciiByte346.5 ns0.02 ns
IndexOfAny3Byte346.8 ns0.11 ns
IndexOfAny4Byte444.5 ns0.03 ns
IndexOfAnyByte541.7 ns0.05 ns
IndexOfAny5Byte542.2 ns0.05 ns
IndexOfAnyUniqueLowNibbleChar351.4 ns0.49 ns
IndexOfAnyAsciiChar448.2 ns0.41 ns
IndexOfAny1Char453.2 ns0.23 ns
IndexOfAnyInRange497.6 ns0.31 ns
IndexOfAny2Chars543.2 ns0.37 ns
IndexOfAny3Chars688.8 ns0.19 ns
IndexOfAny4Chars884.2 ns0.21 ns
IndexOfAny5Chars1,079.5 ns0.10 ns
ARM (Azure D8plsv5 VM)
MethodMeanError
IndexOfAny1Byte493.0 ns0.04 ns
IndexOfAnyByteInRange544.2 ns2.92 ns
IndexOfAny2Byte636.7 ns6.03 ns
IndexOfAnyUniqueLowNibbleByte664.5 ns4.58 ns
IndexOfAny3Byte851.6 ns7.29 ns
IndexOfAnyAsciiByte853.6 ns5.32 ns
IndexOfAny4Byte1,067.7 ns8.85 ns
IndexOfAny5Byte1,292.5 ns11.32 ns
IndexOfAnyByte1,309.2 ns10.58 ns
IndexOfAny1Char979.7 ns0.08 ns
IndexOfAnyInRange1,075.4 ns4.08 ns
IndexOfAnyUniqueLowNibbleChar1,088.8 ns53.19 ns
IndexOfAny2Chars1,279.2 ns13.17 ns
IndexOfAnyAsciiChar1,316.2 ns0.91 ns
IndexOfAny3Chars1,702.1 ns14.53 ns
IndexOfAny4Chars2,135.7 ns17.84 ns
IndexOfAny5Chars2,578.2 ns21.88 ns
X64 with Vector256 (i9-10900X - no full Avx512)
MethodMeanError
IndexOfAny1Byte164.1 ns2.56 ns
IndexOfAnyUniqueLowNibbleByte163.8 ns0.53 ns
IndexOfAnyByteInRange200.0 ns1.26 ns
IndexOfAny2Byte214.8 ns2.16 ns
IndexOfAny3Byte216.4 ns1.80 ns
IndexOfAny4Byte227.1 ns1.27 ns
IndexOfAnyAsciiByte248.0 ns2.47 ns
IndexOfAny5Byte252.0 ns0.75 ns
IndexOfAnyByte361.8 ns1.34 ns
IndexOfAny1PackedChar209.1 ns0.23 ns
IndexOfLetterIgnoreCase199.4 ns1.92 ns
IndexOfAnyUniqueLowNibbleChar218.3 ns0.25 ns
IndexOfAny2PackedChars231.7 ns2.57 ns
IndexOfTwoLettersIgnoreCase243.4 ns2.00 ns
IndexOfAny3PackedChars248.4 ns2.82 ns
IndexOfAnyInRangePacked248.7 ns2.49 ns
IndexOfAnyAsciiChar287.2 ns0.38 ns
IndexOfAny1Char304.3 ns3.55 ns
IndexOfAnyInRange395.7 ns3.20 ns
IndexOfAny2Chars416.4 ns5.64 ns
IndexOfAny3Chars410.5 ns3.66 ns
IndexOfAny4Chars440.0 ns3.07 ns
IndexOfAny5Chars496.1 ns1.85 ns
X64 with Vector256 (Ryzen 1700)
MethodMeanError
IndexOfAny1Byte241.3 ns1.51 ns
IndexOfAnyUniqueLowNibbleByte279.0 ns1.54 ns
IndexOfAnyByteInRange368.5 ns1.80 ns
IndexOfAny2Byte369.9 ns1.89 ns
IndexOfAny3Byte447.2 ns2.03 ns
IndexOfAnyAsciiByte455.7 ns2.62 ns
IndexOfAny4Byte557.9 ns1.79 ns
IndexOfAny5Byte640.4 ns3.19 ns
IndexOfAnyByte655.3 ns3.58 ns
IndexOfAny1PackedChar280.7 ns1.48 ns
IndexOfAnyUniqueLowNibbleChar363.0 ns1.94 ns
IndexOfAny2PackedChars365.7 ns1.99 ns
IndexOfLetterIgnoreCase369.2 ns1.98 ns
IndexOfAnyInRangePacked375.1 ns1.27 ns
IndexOfAny3PackedChars448.3 ns2.02 ns
IndexOfTwoLettersIgnoreCase459.5 ns2.24 ns
IndexOfAny1Char461.1 ns1.85 ns
IndexOfAnyAsciiChar545.5 ns18.02 ns
IndexOfAnyInRange718.8 ns4.14 ns
IndexOfAny2Chars734.8 ns2.81 ns
IndexOfAny3Chars922.0 ns2.75 ns
IndexOfAny4Chars1,091.1 ns5.75 ns
IndexOfAny5Chars1,254.2 ns7.16 ns
X64 with Vector512 (Xeon Platinum 8370C)
MethodMeanError
IndexOfAny1Byte99.20 ns0.811 ns
IndexOfAny2Byte186.23 ns0.157 ns
IndexOfAny3Byte236.63 ns0.228 ns
IndexOfAnyByteInRange253.85 ns0.279 ns
IndexOfAnyUniqueLowNibbleByte273.06 ns3.011 ns
IndexOfAny4Byte312.36 ns0.102 ns
IndexOfAnyAsciiByte346.18 ns2.557 ns
IndexOfAny5Byte363.69 ns0.160 ns
IndexOfAnyByte422.75 ns1.270 ns
IndexOfAny1PackedChar165.53 ns3.280 ns
IndexOfAnyInRangePacked168.52 ns2.998 ns
IndexOfLetterIgnoreCase170.30 ns2.769 ns
IndexOfAny1Char194.79 ns0.097 ns
IndexOfAny2PackedChars217.14 ns0.150 ns
IndexOfTwoLettersIgnoreCase239.79 ns0.205 ns
IndexOfAny3PackedChars268.56 ns0.217 ns
IndexOfAnyUniqueLowNibbleChar271.63 ns1.392 ns
IndexOfAnyAsciiChar327.17 ns1.021 ns
IndexOfAny2Chars366.79 ns0.087 ns
IndexOfAny3Chars468.80 ns0.093 ns
IndexOfAnyInRange500.94 ns0.521 ns
IndexOfAny4Chars621.20 ns0.209 ns
IndexOfAny5Chars723.78 ns0.212 ns

@MihaZupanMihaZupan added this to the 10.0.0 milestone Aug 23, 2024
@MihaZupanMihaZupan self-assigned this Aug 23, 2024
@dotnet-policy-service

Copy link
Copy Markdown
Contributor

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

{
// Avoid false positives for the zero character if no other character has a low nibble of zero.
// We can replace it with any other byte that has a non-zero low nibble.
valuesByLowNibble.SetElementUnsafe(0, (byte)1);

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 didn't fully grok this. Why don't we need to check if 1 is already being used?

@MihaZupanMihaZupanSep 5, 2024

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.

All vector elements start out as 0, and not all of them may be initialized.

We map every input character to an element based on its lower nibble.

 0, 16, 32 ... => valuesByLowNibble[0]
1, 17, 33 ... => valuesByLowNibble[1]
15, 31, 47 ... => valuesByLowNibble[15] 

The search works by first picking a potential match based on the low nibble (Shuffle) and then confirming it (Equals).

This means that input characters with a given low nibble only care about the element of valuesByLowNibble for that nibble. Values like 1 or 2 don't care about what the value of valuesByLowNibble[7] is since they'll never be mapped to it.

This also means that it's okay for valuesByLowNibble to be left uninitialized at 0.
The Equals could only match for an input character 0, but those will always be mapped to valuesByLowNibble[0] by the shuffle instead.

The edge case is the 0th nibble since the character 0 could be a false positive there.
But it'll only be a false positive if we don't have the character 0 in our values.
That's the valuesByLowNibble.GetElement(0) == 0 && !lookup.Contains(0) check above.

To avoid false positives for 0, we can use the same trick of setting the element to some "unreachable" value.
We can use any value with a non-zero nibble, as the shuffle will map any inputs with those values to a different element. 1 is just an arbitrary choice.

Edit: I tweaked the comment a bit, hopefully, it's decipherable.

@MihaZupan
MihaZupanforce-pushed the searchvalues-uniqueLowNibble2 branch from d2ae610 to fe3ae67CompareSeptember 6, 2024 17:22
@MihaZupan
MihaZupan merged commit b06d5e2 into dotnet:mainSep 10, 2024
jtschuster pushed a commit to jtschuster/runtime that referenced this pull request Sep 17, 2024
…otnet#106900)
* Add SearchValues implementation for values with unique low nibbles
* More generics
* Tweak comment
* Remove extra empty line
* Update comment
sirntar pushed a commit to sirntar/runtime that referenced this pull request Sep 30, 2024
…otnet#106900)
* Add SearchValues implementation for values with unique low nibbles
* More generics
* Tweak comment
* Remove extra empty line
* Update comment
@github-actionsgithub-actionsBot locked and limited conversation to collaborators Oct 12, 2024
Sign up for freeto subscribe to this conversation on GitHub. Already have an account? Sign in.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@MihaZupan@stephentoub
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content

Add a SearchValues implementation for values with unique low nibbles - #106900

Merged
MihaZupan merged 5 commits into
dotnet:mainfrom
MihaZupan:searchvalues-uniqueLowNibble2
Sep 10, 2024
Merged

Add a SearchValues implementation for values with unique low nibbles#106900
MihaZupan merged 5 commits into
dotnet:mainfrom
MihaZupan:searchvalues-uniqueLowNibble2

Conversation

@MihaZupan

@MihaZupanMihaZupan commented Aug 23, 2024

Copy link
Copy Markdown
Member

Based on http://0x80.pl/articles/simd-byte-lookup.html#special-case-3-unique-lower-and-higher-nibbles

If all of the values have a different low nibble, we can use a faster search that takes advantage of that fact.
For example, this applies to the "Sherlock|Holmes|Watson|Irene|Adler|John|Baker" regex pattern which uses SearchValues.Create("ABHIJSW").

As a comparison, the current core lookup for an ASCII set on AVX2 uses: 2 and, 1 shift, 2 shuffles

Vector256<byte>Lookup(Vector256<byte>source,Vector256<byte>bitmapLookup){Vector256<byte>highNibbles=(source.AsInt32()>>>4).AsByte()&Vector256.Create((byte)0xF);Vector256<byte>bitMask=Avx2.Shuffle(bitmapLookup,source);Vector256<byte>bitPositions=Avx2.Shuffle(Vector256.Create(0x8040201008040201).AsByte(),highNibbles);returnbitMask&bitPositions;}

Where the core lookup for values with unique low nibbles uses: 1 comparison, 1 shuffle

Vector256<byte>Lookup(Vector256<byte>source,Vector256<byte>valuesByLowNibble){Vector256<byte>values=Avx2.Shuffle(valuesByLowNibble,source);returnVector256.Equals(source,values);}

(code-wise, most of the implementation in this PR is a copy-paste of the existing ASCII logic, swapping out this core lookup routine)


Consider a benchmark inspired by @lemire's https://lemire.me/blog/2024/07/05/scan-html-faster-with-simd-instructions-net-c-edition/
In this case, we're scanning UTF8 input for bytes relevant to HTML (<, &, \r and \0).
Previously, SearchValues would pick the same implementation as span.IndexOfAny(4 values).
The blog post highlights that a hand-written approach can beat SearchValues in this case -- not anymore :)

publicclassBench{privatestaticreadonlySearchValues<byte>s_searchValues=SearchValues.Create("\0\r&<"u8);privatestaticbyte[]s_bytes=Encoding.ASCII.GetBytes(newstring('x',10_000));[Benchmark]publicintFindHtmlChar()=>s_bytes.AsSpan().IndexOfAny(s_searchValues);}

This approach doubles the searching performance on my AVX2 CPU (Ryzen 1700).
On ARM, it's a 1.6X improvement.

MethodToolchainMeanRatio
FindHtmlCharmain605.7 ns1.00
FindHtmlCharpr304.5 ns0.50

Compared to the implementation for an arbitrary ASCII set, this improves throughput between 1.2x and 1.5x depending on the hardware (see more numbers below).


The UniqueLowNibble approach could be used a lot more aggresively (see benchmarks below).
I conservatively placed it between 3 and 4 values to minimize the risk of regressions for now.
In practice, we're currently only using SearchValues with 4 or more values across runtime/aspnet.

As a follow up, I plan on changing our heuristics around which approach we pick in SearchValues depending on the platform.
After that, we may want to consider using it even with fewer values (e.g. 2 or 3).

We should also consider using PackedSpanHelpers on ARM.
Searching for any subset of ASCII is currently faster than a basic IndexOf('a') on M1 hardware because we're not doing that.


Throughput numbers for scanning through 10k elements (10k bytes or 10k chars).
Rows are ordered from fastest to slowest.

ARM (Apple M1)
MethodMeanError
IndexOfAny1Byte233.2 ns0.24 ns
IndexOfAnyByteInRange253.7 ns0.24 ns
IndexOfAny2Byte274.4 ns0.14 ns
IndexOfAnyUniqueLowNibbleByte275.7 ns0.83 ns
IndexOfAnyAsciiByte346.5 ns0.02 ns
IndexOfAny3Byte346.8 ns0.11 ns
IndexOfAny4Byte444.5 ns0.03 ns
IndexOfAnyByte541.7 ns0.05 ns
IndexOfAny5Byte542.2 ns0.05 ns
IndexOfAnyUniqueLowNibbleChar351.4 ns0.49 ns
IndexOfAnyAsciiChar448.2 ns0.41 ns
IndexOfAny1Char453.2 ns0.23 ns
IndexOfAnyInRange497.6 ns0.31 ns
IndexOfAny2Chars543.2 ns0.37 ns
IndexOfAny3Chars688.8 ns0.19 ns
IndexOfAny4Chars884.2 ns0.21 ns
IndexOfAny5Chars1,079.5 ns0.10 ns
ARM (Azure D8plsv5 VM)
MethodMeanError
IndexOfAny1Byte493.0 ns0.04 ns
IndexOfAnyByteInRange544.2 ns2.92 ns
IndexOfAny2Byte636.7 ns6.03 ns
IndexOfAnyUniqueLowNibbleByte664.5 ns4.58 ns
IndexOfAny3Byte851.6 ns7.29 ns
IndexOfAnyAsciiByte853.6 ns5.32 ns
IndexOfAny4Byte1,067.7 ns8.85 ns
IndexOfAny5Byte1,292.5 ns11.32 ns
IndexOfAnyByte1,309.2 ns10.58 ns
IndexOfAny1Char979.7 ns0.08 ns
IndexOfAnyInRange1,075.4 ns4.08 ns
IndexOfAnyUniqueLowNibbleChar1,088.8 ns53.19 ns
IndexOfAny2Chars1,279.2 ns13.17 ns
IndexOfAnyAsciiChar1,316.2 ns0.91 ns
IndexOfAny3Chars1,702.1 ns14.53 ns
IndexOfAny4Chars2,135.7 ns17.84 ns
IndexOfAny5Chars2,578.2 ns21.88 ns
X64 with Vector256 (i9-10900X - no full Avx512)
MethodMeanError
IndexOfAny1Byte164.1 ns2.56 ns
IndexOfAnyUniqueLowNibbleByte163.8 ns0.53 ns
IndexOfAnyByteInRange200.0 ns1.26 ns
IndexOfAny2Byte214.8 ns2.16 ns
IndexOfAny3Byte216.4 ns1.80 ns
IndexOfAny4Byte227.1 ns1.27 ns
IndexOfAnyAsciiByte248.0 ns2.47 ns
IndexOfAny5Byte252.0 ns0.75 ns
IndexOfAnyByte361.8 ns1.34 ns
IndexOfAny1PackedChar209.1 ns0.23 ns
IndexOfLetterIgnoreCase199.4 ns1.92 ns
IndexOfAnyUniqueLowNibbleChar218.3 ns0.25 ns
IndexOfAny2PackedChars231.7 ns2.57 ns
IndexOfTwoLettersIgnoreCase243.4 ns2.00 ns
IndexOfAny3PackedChars248.4 ns2.82 ns
IndexOfAnyInRangePacked248.7 ns2.49 ns
IndexOfAnyAsciiChar287.2 ns0.38 ns
IndexOfAny1Char304.3 ns3.55 ns
IndexOfAnyInRange395.7 ns3.20 ns
IndexOfAny2Chars416.4 ns5.64 ns
IndexOfAny3Chars410.5 ns3.66 ns
IndexOfAny4Chars440.0 ns3.07 ns
IndexOfAny5Chars496.1 ns1.85 ns
X64 with Vector256 (Ryzen 1700)
MethodMeanError
IndexOfAny1Byte241.3 ns1.51 ns
IndexOfAnyUniqueLowNibbleByte279.0 ns1.54 ns
IndexOfAnyByteInRange368.5 ns1.80 ns
IndexOfAny2Byte369.9 ns1.89 ns
IndexOfAny3Byte447.2 ns2.03 ns
IndexOfAnyAsciiByte455.7 ns2.62 ns
IndexOfAny4Byte557.9 ns1.79 ns
IndexOfAny5Byte640.4 ns3.19 ns
IndexOfAnyByte655.3 ns3.58 ns
IndexOfAny1PackedChar280.7 ns1.48 ns
IndexOfAnyUniqueLowNibbleChar363.0 ns1.94 ns
IndexOfAny2PackedChars365.7 ns1.99 ns
IndexOfLetterIgnoreCase369.2 ns1.98 ns
IndexOfAnyInRangePacked375.1 ns1.27 ns
IndexOfAny3PackedChars448.3 ns2.02 ns
IndexOfTwoLettersIgnoreCase459.5 ns2.24 ns
IndexOfAny1Char461.1 ns1.85 ns
IndexOfAnyAsciiChar545.5 ns18.02 ns
IndexOfAnyInRange718.8 ns4.14 ns
IndexOfAny2Chars734.8 ns2.81 ns
IndexOfAny3Chars922.0 ns2.75 ns
IndexOfAny4Chars1,091.1 ns5.75 ns
IndexOfAny5Chars1,254.2 ns7.16 ns
X64 with Vector512 (Xeon Platinum 8370C)
MethodMeanError
IndexOfAny1Byte99.20 ns0.811 ns
IndexOfAny2Byte186.23 ns0.157 ns
IndexOfAny3Byte236.63 ns0.228 ns
IndexOfAnyByteInRange253.85 ns0.279 ns
IndexOfAnyUniqueLowNibbleByte273.06 ns3.011 ns
IndexOfAny4Byte312.36 ns0.102 ns
IndexOfAnyAsciiByte346.18 ns2.557 ns
IndexOfAny5Byte363.69 ns0.160 ns
IndexOfAnyByte422.75 ns1.270 ns
IndexOfAny1PackedChar165.53 ns3.280 ns
IndexOfAnyInRangePacked168.52 ns2.998 ns
IndexOfLetterIgnoreCase170.30 ns2.769 ns
IndexOfAny1Char194.79 ns0.097 ns
IndexOfAny2PackedChars217.14 ns0.150 ns
IndexOfTwoLettersIgnoreCase239.79 ns0.205 ns
IndexOfAny3PackedChars268.56 ns0.217 ns
IndexOfAnyUniqueLowNibbleChar271.63 ns1.392 ns
IndexOfAnyAsciiChar327.17 ns1.021 ns
IndexOfAny2Chars366.79 ns0.087 ns
IndexOfAny3Chars468.80 ns0.093 ns
IndexOfAnyInRange500.94 ns0.521 ns
IndexOfAny4Chars621.20 ns0.209 ns
IndexOfAny5Chars723.78 ns0.212 ns

@MihaZupanMihaZupan added this to the 10.0.0 milestone Aug 23, 2024
@MihaZupanMihaZupan self-assigned this Aug 23, 2024
@dotnet-policy-service

Copy link
Copy Markdown
Contributor

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

{
// Avoid false positives for the zero character if no other character has a low nibble of zero.
// We can replace it with any other byte that has a non-zero low nibble.
valuesByLowNibble.SetElementUnsafe(0, (byte)1);

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 didn't fully grok this. Why don't we need to check if 1 is already being used?

@MihaZupanMihaZupanSep 5, 2024

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.

All vector elements start out as 0, and not all of them may be initialized.

We map every input character to an element based on its lower nibble.

 0, 16, 32 ... => valuesByLowNibble[0]
1, 17, 33 ... => valuesByLowNibble[1]
15, 31, 47 ... => valuesByLowNibble[15] 

The search works by first picking a potential match based on the low nibble (Shuffle) and then confirming it (Equals).

This means that input characters with a given low nibble only care about the element of valuesByLowNibble for that nibble. Values like 1 or 2 don't care about what the value of valuesByLowNibble[7] is since they'll never be mapped to it.

This also means that it's okay for valuesByLowNibble to be left uninitialized at 0.
The Equals could only match for an input character 0, but those will always be mapped to valuesByLowNibble[0] by the shuffle instead.

The edge case is the 0th nibble since the character 0 could be a false positive there.
But it'll only be a false positive if we don't have the character 0 in our values.
That's the valuesByLowNibble.GetElement(0) == 0 && !lookup.Contains(0) check above.

To avoid false positives for 0, we can use the same trick of setting the element to some "unreachable" value.
We can use any value with a non-zero nibble, as the shuffle will map any inputs with those values to a different element. 1 is just an arbitrary choice.

Edit: I tweaked the comment a bit, hopefully, it's decipherable.

@MihaZupan
MihaZupanforce-pushed the searchvalues-uniqueLowNibble2 branch from d2ae610 to fe3ae67CompareSeptember 6, 2024 17:22
@MihaZupan
MihaZupan merged commit b06d5e2 into dotnet:mainSep 10, 2024
jtschuster pushed a commit to jtschuster/runtime that referenced this pull request Sep 17, 2024
…otnet#106900)
* Add SearchValues implementation for values with unique low nibbles
* More generics
* Tweak comment
* Remove extra empty line
* Update comment
sirntar pushed a commit to sirntar/runtime that referenced this pull request Sep 30, 2024
…otnet#106900)
* Add SearchValues implementation for values with unique low nibbles
* More generics
* Tweak comment
* Remove extra empty line
* Update comment
@github-actionsgithub-actionsBot locked and limited conversation to collaborators Oct 12, 2024
Sign up for freeto subscribe to this conversation on GitHub. Already have an account? Sign in.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@MihaZupan@stephentoub
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Add a SearchValues implementation for values with unique low nibbles - #106900

Merged
MihaZupan merged 5 commits into
dotnet:mainfrom
MihaZupan:searchvalues-uniqueLowNibble2
Sep 10, 2024
Merged

Add a SearchValues implementation for values with unique low nibbles#106900
MihaZupan merged 5 commits into
dotnet:mainfrom
MihaZupan:searchvalues-uniqueLowNibble2

Conversation

@MihaZupan

@MihaZupanMihaZupan commented Aug 23, 2024

Copy link
Copy Markdown
Member

Based on http://0x80.pl/articles/simd-byte-lookup.html#special-case-3-unique-lower-and-higher-nibbles

If all of the values have a different low nibble, we can use a faster search that takes advantage of that fact.
For example, this applies to the "Sherlock|Holmes|Watson|Irene|Adler|John|Baker" regex pattern which uses SearchValues.Create("ABHIJSW").

As a comparison, the current core lookup for an ASCII set on AVX2 uses: 2 and, 1 shift, 2 shuffles

Vector256<byte>Lookup(Vector256<byte>source,Vector256<byte>bitmapLookup){Vector256<byte>highNibbles=(source.AsInt32()>>>4).AsByte()&Vector256.Create((byte)0xF);Vector256<byte>bitMask=Avx2.Shuffle(bitmapLookup,source);Vector256<byte>bitPositions=Avx2.Shuffle(Vector256.Create(0x8040201008040201).AsByte(),highNibbles);returnbitMask&bitPositions;}

Where the core lookup for values with unique low nibbles uses: 1 comparison, 1 shuffle

Vector256<byte>Lookup(Vector256<byte>source,Vector256<byte>valuesByLowNibble){Vector256<byte>values=Avx2.Shuffle(valuesByLowNibble,source);returnVector256.Equals(source,values);}

(code-wise, most of the implementation in this PR is a copy-paste of the existing ASCII logic, swapping out this core lookup routine)


Consider a benchmark inspired by @lemire's https://lemire.me/blog/2024/07/05/scan-html-faster-with-simd-instructions-net-c-edition/
In this case, we're scanning UTF8 input for bytes relevant to HTML (<, &, \r and \0).
Previously, SearchValues would pick the same implementation as span.IndexOfAny(4 values).
The blog post highlights that a hand-written approach can beat SearchValues in this case -- not anymore :)

publicclassBench{privatestaticreadonlySearchValues<byte>s_searchValues=SearchValues.Create("\0\r&<"u8);privatestaticbyte[]s_bytes=Encoding.ASCII.GetBytes(newstring('x',10_000));[Benchmark]publicintFindHtmlChar()=>s_bytes.AsSpan().IndexOfAny(s_searchValues);}

This approach doubles the searching performance on my AVX2 CPU (Ryzen 1700).
On ARM, it's a 1.6X improvement.

MethodToolchainMeanRatio
FindHtmlCharmain605.7 ns1.00
FindHtmlCharpr304.5 ns0.50

Compared to the implementation for an arbitrary ASCII set, this improves throughput between 1.2x and 1.5x depending on the hardware (see more numbers below).


The UniqueLowNibble approach could be used a lot more aggresively (see benchmarks below).
I conservatively placed it between 3 and 4 values to minimize the risk of regressions for now.
In practice, we're currently only using SearchValues with 4 or more values across runtime/aspnet.

As a follow up, I plan on changing our heuristics around which approach we pick in SearchValues depending on the platform.
After that, we may want to consider using it even with fewer values (e.g. 2 or 3).

We should also consider using PackedSpanHelpers on ARM.
Searching for any subset of ASCII is currently faster than a basic IndexOf('a') on M1 hardware because we're not doing that.


Throughput numbers for scanning through 10k elements (10k bytes or 10k chars).
Rows are ordered from fastest to slowest.

ARM (Apple M1)
MethodMeanError
IndexOfAny1Byte233.2 ns0.24 ns
IndexOfAnyByteInRange253.7 ns0.24 ns
IndexOfAny2Byte274.4 ns0.14 ns
IndexOfAnyUniqueLowNibbleByte275.7 ns0.83 ns
IndexOfAnyAsciiByte346.5 ns0.02 ns
IndexOfAny3Byte346.8 ns0.11 ns
IndexOfAny4Byte444.5 ns0.03 ns
IndexOfAnyByte541.7 ns0.05 ns
IndexOfAny5Byte542.2 ns0.05 ns
IndexOfAnyUniqueLowNibbleChar351.4 ns0.49 ns
IndexOfAnyAsciiChar448.2 ns0.41 ns
IndexOfAny1Char453.2 ns0.23 ns
IndexOfAnyInRange497.6 ns0.31 ns
IndexOfAny2Chars543.2 ns0.37 ns
IndexOfAny3Chars688.8 ns0.19 ns
IndexOfAny4Chars884.2 ns0.21 ns
IndexOfAny5Chars1,079.5 ns0.10 ns
ARM (Azure D8plsv5 VM)
MethodMeanError
IndexOfAny1Byte493.0 ns0.04 ns
IndexOfAnyByteInRange544.2 ns2.92 ns
IndexOfAny2Byte636.7 ns6.03 ns
IndexOfAnyUniqueLowNibbleByte664.5 ns4.58 ns
IndexOfAny3Byte851.6 ns7.29 ns
IndexOfAnyAsciiByte853.6 ns5.32 ns
IndexOfAny4Byte1,067.7 ns8.85 ns
IndexOfAny5Byte1,292.5 ns11.32 ns
IndexOfAnyByte1,309.2 ns10.58 ns
IndexOfAny1Char979.7 ns0.08 ns
IndexOfAnyInRange1,075.4 ns4.08 ns
IndexOfAnyUniqueLowNibbleChar1,088.8 ns53.19 ns
IndexOfAny2Chars1,279.2 ns13.17 ns
IndexOfAnyAsciiChar1,316.2 ns0.91 ns
IndexOfAny3Chars1,702.1 ns14.53 ns
IndexOfAny4Chars2,135.7 ns17.84 ns
IndexOfAny5Chars2,578.2 ns21.88 ns
X64 with Vector256 (i9-10900X - no full Avx512)
MethodMeanError
IndexOfAny1Byte164.1 ns2.56 ns
IndexOfAnyUniqueLowNibbleByte163.8 ns0.53 ns
IndexOfAnyByteInRange200.0 ns1.26 ns
IndexOfAny2Byte214.8 ns2.16 ns
IndexOfAny3Byte216.4 ns1.80 ns
IndexOfAny4Byte227.1 ns1.27 ns
IndexOfAnyAsciiByte248.0 ns2.47 ns
IndexOfAny5Byte252.0 ns0.75 ns
IndexOfAnyByte361.8 ns1.34 ns
IndexOfAny1PackedChar209.1 ns0.23 ns
IndexOfLetterIgnoreCase199.4 ns1.92 ns
IndexOfAnyUniqueLowNibbleChar218.3 ns0.25 ns
IndexOfAny2PackedChars231.7 ns2.57 ns
IndexOfTwoLettersIgnoreCase243.4 ns2.00 ns
IndexOfAny3PackedChars248.4 ns2.82 ns
IndexOfAnyInRangePacked248.7 ns2.49 ns
IndexOfAnyAsciiChar287.2 ns0.38 ns
IndexOfAny1Char304.3 ns3.55 ns
IndexOfAnyInRange395.7 ns3.20 ns
IndexOfAny2Chars416.4 ns5.64 ns
IndexOfAny3Chars410.5 ns3.66 ns
IndexOfAny4Chars440.0 ns3.07 ns
IndexOfAny5Chars496.1 ns1.85 ns
X64 with Vector256 (Ryzen 1700)
MethodMeanError
IndexOfAny1Byte241.3 ns1.51 ns
IndexOfAnyUniqueLowNibbleByte279.0 ns1.54 ns
IndexOfAnyByteInRange368.5 ns1.80 ns
IndexOfAny2Byte369.9 ns1.89 ns
IndexOfAny3Byte447.2 ns2.03 ns
IndexOfAnyAsciiByte455.7 ns2.62 ns
IndexOfAny4Byte557.9 ns1.79 ns
IndexOfAny5Byte640.4 ns3.19 ns
IndexOfAnyByte655.3 ns3.58 ns
IndexOfAny1PackedChar280.7 ns1.48 ns
IndexOfAnyUniqueLowNibbleChar363.0 ns1.94 ns
IndexOfAny2PackedChars365.7 ns1.99 ns
IndexOfLetterIgnoreCase369.2 ns1.98 ns
IndexOfAnyInRangePacked375.1 ns1.27 ns
IndexOfAny3PackedChars448.3 ns2.02 ns
IndexOfTwoLettersIgnoreCase459.5 ns2.24 ns
IndexOfAny1Char461.1 ns1.85 ns
IndexOfAnyAsciiChar545.5 ns18.02 ns
IndexOfAnyInRange718.8 ns4.14 ns
IndexOfAny2Chars734.8 ns2.81 ns
IndexOfAny3Chars922.0 ns2.75 ns
IndexOfAny4Chars1,091.1 ns5.75 ns
IndexOfAny5Chars1,254.2 ns7.16 ns
X64 with Vector512 (Xeon Platinum 8370C)
MethodMeanError
IndexOfAny1Byte99.20 ns0.811 ns
IndexOfAny2Byte186.23 ns0.157 ns
IndexOfAny3Byte236.63 ns0.228 ns
IndexOfAnyByteInRange253.85 ns0.279 ns
IndexOfAnyUniqueLowNibbleByte273.06 ns3.011 ns
IndexOfAny4Byte312.36 ns0.102 ns
IndexOfAnyAsciiByte346.18 ns2.557 ns
IndexOfAny5Byte363.69 ns0.160 ns
IndexOfAnyByte422.75 ns1.270 ns
IndexOfAny1PackedChar165.53 ns3.280 ns
IndexOfAnyInRangePacked168.52 ns2.998 ns
IndexOfLetterIgnoreCase170.30 ns2.769 ns
IndexOfAny1Char194.79 ns0.097 ns
IndexOfAny2PackedChars217.14 ns0.150 ns
IndexOfTwoLettersIgnoreCase239.79 ns0.205 ns
IndexOfAny3PackedChars268.56 ns0.217 ns
IndexOfAnyUniqueLowNibbleChar271.63 ns1.392 ns
IndexOfAnyAsciiChar327.17 ns1.021 ns
IndexOfAny2Chars366.79 ns0.087 ns
IndexOfAny3Chars468.80 ns0.093 ns
IndexOfAnyInRange500.94 ns0.521 ns
IndexOfAny4Chars621.20 ns0.209 ns
IndexOfAny5Chars723.78 ns0.212 ns

@MihaZupanMihaZupan added this to the 10.0.0 milestone Aug 23, 2024
@MihaZupanMihaZupan self-assigned this Aug 23, 2024
@dotnet-policy-service

Copy link
Copy Markdown
Contributor

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

{
// Avoid false positives for the zero character if no other character has a low nibble of zero.
// We can replace it with any other byte that has a non-zero low nibble.
valuesByLowNibble.SetElementUnsafe(0, (byte)1);

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 didn't fully grok this. Why don't we need to check if 1 is already being used?

@MihaZupanMihaZupanSep 5, 2024

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.

All vector elements start out as 0, and not all of them may be initialized.

We map every input character to an element based on its lower nibble.

 0, 16, 32 ... => valuesByLowNibble[0]
1, 17, 33 ... => valuesByLowNibble[1]
15, 31, 47 ... => valuesByLowNibble[15] 

The search works by first picking a potential match based on the low nibble (Shuffle) and then confirming it (Equals).

This means that input characters with a given low nibble only care about the element of valuesByLowNibble for that nibble. Values like 1 or 2 don't care about what the value of valuesByLowNibble[7] is since they'll never be mapped to it.

This also means that it's okay for valuesByLowNibble to be left uninitialized at 0.
The Equals could only match for an input character 0, but those will always be mapped to valuesByLowNibble[0] by the shuffle instead.

The edge case is the 0th nibble since the character 0 could be a false positive there.
But it'll only be a false positive if we don't have the character 0 in our values.
That's the valuesByLowNibble.GetElement(0) == 0 && !lookup.Contains(0) check above.

To avoid false positives for 0, we can use the same trick of setting the element to some "unreachable" value.
We can use any value with a non-zero nibble, as the shuffle will map any inputs with those values to a different element. 1 is just an arbitrary choice.

Edit: I tweaked the comment a bit, hopefully, it's decipherable.

@MihaZupan
MihaZupanforce-pushed the searchvalues-uniqueLowNibble2 branch from d2ae610 to fe3ae67CompareSeptember 6, 2024 17:22
@MihaZupan
MihaZupan merged commit b06d5e2 into dotnet:mainSep 10, 2024
jtschuster pushed a commit to jtschuster/runtime that referenced this pull request Sep 17, 2024
…otnet#106900)
* Add SearchValues implementation for values with unique low nibbles
* More generics
* Tweak comment
* Remove extra empty line
* Update comment
sirntar pushed a commit to sirntar/runtime that referenced this pull request Sep 30, 2024
…otnet#106900)
* Add SearchValues implementation for values with unique low nibbles
* More generics
* Tweak comment
* Remove extra empty line
* Update comment
@github-actionsgithub-actionsBot locked and limited conversation to collaborators Oct 12, 2024
Sign up for freeto subscribe to this conversation on GitHub. Already have an account? Sign in.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@MihaZupan@stephentoub
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Add a SearchValues implementation for values with unique low nibbles - #106900

Merged
MihaZupan merged 5 commits into
dotnet:mainfrom
MihaZupan:searchvalues-uniqueLowNibble2
Sep 10, 2024
Merged

Add a SearchValues implementation for values with unique low nibbles#106900
MihaZupan merged 5 commits into
dotnet:mainfrom
MihaZupan:searchvalues-uniqueLowNibble2

Conversation

@MihaZupan

@MihaZupanMihaZupan commented Aug 23, 2024

Copy link
Copy Markdown
Member

Based on http://0x80.pl/articles/simd-byte-lookup.html#special-case-3-unique-lower-and-higher-nibbles

If all of the values have a different low nibble, we can use a faster search that takes advantage of that fact.
For example, this applies to the "Sherlock|Holmes|Watson|Irene|Adler|John|Baker" regex pattern which uses SearchValues.Create("ABHIJSW").

As a comparison, the current core lookup for an ASCII set on AVX2 uses: 2 and, 1 shift, 2 shuffles

Vector256<byte>Lookup(Vector256<byte>source,Vector256<byte>bitmapLookup){Vector256<byte>highNibbles=(source.AsInt32()>>>4).AsByte()&Vector256.Create((byte)0xF);Vector256<byte>bitMask=Avx2.Shuffle(bitmapLookup,source);Vector256<byte>bitPositions=Avx2.Shuffle(Vector256.Create(0x8040201008040201).AsByte(),highNibbles);returnbitMask&bitPositions;}

Where the core lookup for values with unique low nibbles uses: 1 comparison, 1 shuffle

Vector256<byte>Lookup(Vector256<byte>source,Vector256<byte>valuesByLowNibble){Vector256<byte>values=Avx2.Shuffle(valuesByLowNibble,source);returnVector256.Equals(source,values);}

(code-wise, most of the implementation in this PR is a copy-paste of the existing ASCII logic, swapping out this core lookup routine)


Consider a benchmark inspired by @lemire's https://lemire.me/blog/2024/07/05/scan-html-faster-with-simd-instructions-net-c-edition/
In this case, we're scanning UTF8 input for bytes relevant to HTML (<, &, \r and \0).
Previously, SearchValues would pick the same implementation as span.IndexOfAny(4 values).
The blog post highlights that a hand-written approach can beat SearchValues in this case -- not anymore :)

publicclassBench{privatestaticreadonlySearchValues<byte>s_searchValues=SearchValues.Create("\0\r&<"u8);privatestaticbyte[]s_bytes=Encoding.ASCII.GetBytes(newstring('x',10_000));[Benchmark]publicintFindHtmlChar()=>s_bytes.AsSpan().IndexOfAny(s_searchValues);}

This approach doubles the searching performance on my AVX2 CPU (Ryzen 1700).
On ARM, it's a 1.6X improvement.

MethodToolchainMeanRatio
FindHtmlCharmain605.7 ns1.00
FindHtmlCharpr304.5 ns0.50

Compared to the implementation for an arbitrary ASCII set, this improves throughput between 1.2x and 1.5x depending on the hardware (see more numbers below).


The UniqueLowNibble approach could be used a lot more aggresively (see benchmarks below).
I conservatively placed it between 3 and 4 values to minimize the risk of regressions for now.
In practice, we're currently only using SearchValues with 4 or more values across runtime/aspnet.

As a follow up, I plan on changing our heuristics around which approach we pick in SearchValues depending on the platform.
After that, we may want to consider using it even with fewer values (e.g. 2 or 3).

We should also consider using PackedSpanHelpers on ARM.
Searching for any subset of ASCII is currently faster than a basic IndexOf('a') on M1 hardware because we're not doing that.


Throughput numbers for scanning through 10k elements (10k bytes or 10k chars).
Rows are ordered from fastest to slowest.

ARM (Apple M1)
MethodMeanError
IndexOfAny1Byte233.2 ns0.24 ns
IndexOfAnyByteInRange253.7 ns0.24 ns
IndexOfAny2Byte274.4 ns0.14 ns
IndexOfAnyUniqueLowNibbleByte275.7 ns0.83 ns
IndexOfAnyAsciiByte346.5 ns0.02 ns
IndexOfAny3Byte346.8 ns0.11 ns
IndexOfAny4Byte444.5 ns0.03 ns
IndexOfAnyByte541.7 ns0.05 ns
IndexOfAny5Byte542.2 ns0.05 ns
IndexOfAnyUniqueLowNibbleChar351.4 ns0.49 ns
IndexOfAnyAsciiChar448.2 ns0.41 ns
IndexOfAny1Char453.2 ns0.23 ns
IndexOfAnyInRange497.6 ns0.31 ns
IndexOfAny2Chars543.2 ns0.37 ns
IndexOfAny3Chars688.8 ns0.19 ns
IndexOfAny4Chars884.2 ns0.21 ns
IndexOfAny5Chars1,079.5 ns0.10 ns
ARM (Azure D8plsv5 VM)
MethodMeanError
IndexOfAny1Byte493.0 ns0.04 ns
IndexOfAnyByteInRange544.2 ns2.92 ns
IndexOfAny2Byte636.7 ns6.03 ns
IndexOfAnyUniqueLowNibbleByte664.5 ns4.58 ns
IndexOfAny3Byte851.6 ns7.29 ns
IndexOfAnyAsciiByte853.6 ns5.32 ns
IndexOfAny4Byte1,067.7 ns8.85 ns
IndexOfAny5Byte1,292.5 ns11.32 ns
IndexOfAnyByte1,309.2 ns10.58 ns
IndexOfAny1Char979.7 ns0.08 ns
IndexOfAnyInRange1,075.4 ns4.08 ns
IndexOfAnyUniqueLowNibbleChar1,088.8 ns53.19 ns
IndexOfAny2Chars1,279.2 ns13.17 ns
IndexOfAnyAsciiChar1,316.2 ns0.91 ns
IndexOfAny3Chars1,702.1 ns14.53 ns
IndexOfAny4Chars2,135.7 ns17.84 ns
IndexOfAny5Chars2,578.2 ns21.88 ns
X64 with Vector256 (i9-10900X - no full Avx512)
MethodMeanError
IndexOfAny1Byte164.1 ns2.56 ns
IndexOfAnyUniqueLowNibbleByte163.8 ns0.53 ns
IndexOfAnyByteInRange200.0 ns1.26 ns
IndexOfAny2Byte214.8 ns2.16 ns
IndexOfAny3Byte216.4 ns1.80 ns
IndexOfAny4Byte227.1 ns1.27 ns
IndexOfAnyAsciiByte248.0 ns2.47 ns
IndexOfAny5Byte252.0 ns0.75 ns
IndexOfAnyByte361.8 ns1.34 ns
IndexOfAny1PackedChar209.1 ns0.23 ns
IndexOfLetterIgnoreCase199.4 ns1.92 ns
IndexOfAnyUniqueLowNibbleChar218.3 ns0.25 ns
IndexOfAny2PackedChars231.7 ns2.57 ns
IndexOfTwoLettersIgnoreCase243.4 ns2.00 ns
IndexOfAny3PackedChars248.4 ns2.82 ns
IndexOfAnyInRangePacked248.7 ns2.49 ns
IndexOfAnyAsciiChar287.2 ns0.38 ns
IndexOfAny1Char304.3 ns3.55 ns
IndexOfAnyInRange395.7 ns3.20 ns
IndexOfAny2Chars416.4 ns5.64 ns
IndexOfAny3Chars410.5 ns3.66 ns
IndexOfAny4Chars440.0 ns3.07 ns
IndexOfAny5Chars496.1 ns1.85 ns
X64 with Vector256 (Ryzen 1700)
MethodMeanError
IndexOfAny1Byte241.3 ns1.51 ns
IndexOfAnyUniqueLowNibbleByte279.0 ns1.54 ns
IndexOfAnyByteInRange368.5 ns1.80 ns
IndexOfAny2Byte369.9 ns1.89 ns
IndexOfAny3Byte447.2 ns2.03 ns
IndexOfAnyAsciiByte455.7 ns2.62 ns
IndexOfAny4Byte557.9 ns1.79 ns
IndexOfAny5Byte640.4 ns3.19 ns
IndexOfAnyByte655.3 ns3.58 ns
IndexOfAny1PackedChar280.7 ns1.48 ns
IndexOfAnyUniqueLowNibbleChar363.0 ns1.94 ns
IndexOfAny2PackedChars365.7 ns1.99 ns
IndexOfLetterIgnoreCase369.2 ns1.98 ns
IndexOfAnyInRangePacked375.1 ns1.27 ns
IndexOfAny3PackedChars448.3 ns2.02 ns
IndexOfTwoLettersIgnoreCase459.5 ns2.24 ns
IndexOfAny1Char461.1 ns1.85 ns
IndexOfAnyAsciiChar545.5 ns18.02 ns
IndexOfAnyInRange718.8 ns4.14 ns
IndexOfAny2Chars734.8 ns2.81 ns
IndexOfAny3Chars922.0 ns2.75 ns
IndexOfAny4Chars1,091.1 ns5.75 ns
IndexOfAny5Chars1,254.2 ns7.16 ns
X64 with Vector512 (Xeon Platinum 8370C)
MethodMeanError
IndexOfAny1Byte99.20 ns0.811 ns
IndexOfAny2Byte186.23 ns0.157 ns
IndexOfAny3Byte236.63 ns0.228 ns
IndexOfAnyByteInRange253.85 ns0.279 ns
IndexOfAnyUniqueLowNibbleByte273.06 ns3.011 ns
IndexOfAny4Byte312.36 ns0.102 ns
IndexOfAnyAsciiByte346.18 ns2.557 ns
IndexOfAny5Byte363.69 ns0.160 ns
IndexOfAnyByte422.75 ns1.270 ns
IndexOfAny1PackedChar165.53 ns3.280 ns
IndexOfAnyInRangePacked168.52 ns2.998 ns
IndexOfLetterIgnoreCase170.30 ns2.769 ns
IndexOfAny1Char194.79 ns0.097 ns
IndexOfAny2PackedChars217.14 ns0.150 ns
IndexOfTwoLettersIgnoreCase239.79 ns0.205 ns
IndexOfAny3PackedChars268.56 ns0.217 ns
IndexOfAnyUniqueLowNibbleChar271.63 ns1.392 ns
IndexOfAnyAsciiChar327.17 ns1.021 ns
IndexOfAny2Chars366.79 ns0.087 ns
IndexOfAny3Chars468.80 ns0.093 ns
IndexOfAnyInRange500.94 ns0.521 ns
IndexOfAny4Chars621.20 ns0.209 ns
IndexOfAny5Chars723.78 ns0.212 ns

@MihaZupanMihaZupan added this to the 10.0.0 milestone Aug 23, 2024
@MihaZupanMihaZupan self-assigned this Aug 23, 2024
@dotnet-policy-service

Copy link
Copy Markdown
Contributor

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

{
// Avoid false positives for the zero character if no other character has a low nibble of zero.
// We can replace it with any other byte that has a non-zero low nibble.
valuesByLowNibble.SetElementUnsafe(0, (byte)1);

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 didn't fully grok this. Why don't we need to check if 1 is already being used?

@MihaZupanMihaZupanSep 5, 2024

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.

All vector elements start out as 0, and not all of them may be initialized.

We map every input character to an element based on its lower nibble.

 0, 16, 32 ... => valuesByLowNibble[0]
1, 17, 33 ... => valuesByLowNibble[1]
15, 31, 47 ... => valuesByLowNibble[15] 

The search works by first picking a potential match based on the low nibble (Shuffle) and then confirming it (Equals).

This means that input characters with a given low nibble only care about the element of valuesByLowNibble for that nibble. Values like 1 or 2 don't care about what the value of valuesByLowNibble[7] is since they'll never be mapped to it.

This also means that it's okay for valuesByLowNibble to be left uninitialized at 0.
The Equals could only match for an input character 0, but those will always be mapped to valuesByLowNibble[0] by the shuffle instead.

The edge case is the 0th nibble since the character 0 could be a false positive there.
But it'll only be a false positive if we don't have the character 0 in our values.
That's the valuesByLowNibble.GetElement(0) == 0 && !lookup.Contains(0) check above.

To avoid false positives for 0, we can use the same trick of setting the element to some "unreachable" value.
We can use any value with a non-zero nibble, as the shuffle will map any inputs with those values to a different element. 1 is just an arbitrary choice.

Edit: I tweaked the comment a bit, hopefully, it's decipherable.

@MihaZupan
MihaZupanforce-pushed the searchvalues-uniqueLowNibble2 branch from d2ae610 to fe3ae67CompareSeptember 6, 2024 17:22
@MihaZupan
MihaZupan merged commit b06d5e2 into dotnet:mainSep 10, 2024
jtschuster pushed a commit to jtschuster/runtime that referenced this pull request Sep 17, 2024
…otnet#106900)
* Add SearchValues implementation for values with unique low nibbles
* More generics
* Tweak comment
* Remove extra empty line
* Update comment
sirntar pushed a commit to sirntar/runtime that referenced this pull request Sep 30, 2024
…otnet#106900)
* Add SearchValues implementation for values with unique low nibbles
* More generics
* Tweak comment
* Remove extra empty line
* Update comment
@github-actionsgithub-actionsBot locked and limited conversation to collaborators Oct 12, 2024
Sign up for freeto subscribe to this conversation on GitHub. Already have an account? Sign in.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@MihaZupan@stephentoub
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content

Add a SearchValues implementation for values with unique low nibbles - #106900

Merged
MihaZupan merged 5 commits into
dotnet:mainfrom
MihaZupan:searchvalues-uniqueLowNibble2
Sep 10, 2024
Merged

Add a SearchValues implementation for values with unique low nibbles#106900
MihaZupan merged 5 commits into
dotnet:mainfrom
MihaZupan:searchvalues-uniqueLowNibble2

Conversation

@MihaZupan

@MihaZupanMihaZupan commented Aug 23, 2024

Copy link
Copy Markdown
Member

Based on http://0x80.pl/articles/simd-byte-lookup.html#special-case-3-unique-lower-and-higher-nibbles

If all of the values have a different low nibble, we can use a faster search that takes advantage of that fact.
For example, this applies to the "Sherlock|Holmes|Watson|Irene|Adler|John|Baker" regex pattern which uses SearchValues.Create("ABHIJSW").

As a comparison, the current core lookup for an ASCII set on AVX2 uses: 2 and, 1 shift, 2 shuffles

Vector256<byte>Lookup(Vector256<byte>source,Vector256<byte>bitmapLookup){Vector256<byte>highNibbles=(source.AsInt32()>>>4).AsByte()&Vector256.Create((byte)0xF);Vector256<byte>bitMask=Avx2.Shuffle(bitmapLookup,source);Vector256<byte>bitPositions=Avx2.Shuffle(Vector256.Create(0x8040201008040201).AsByte(),highNibbles);returnbitMask&bitPositions;}

Where the core lookup for values with unique low nibbles uses: 1 comparison, 1 shuffle

Vector256<byte>Lookup(Vector256<byte>source,Vector256<byte>valuesByLowNibble){Vector256<byte>values=Avx2.Shuffle(valuesByLowNibble,source);returnVector256.Equals(source,values);}

(code-wise, most of the implementation in this PR is a copy-paste of the existing ASCII logic, swapping out this core lookup routine)


Consider a benchmark inspired by @lemire's https://lemire.me/blog/2024/07/05/scan-html-faster-with-simd-instructions-net-c-edition/
In this case, we're scanning UTF8 input for bytes relevant to HTML (<, &, \r and \0).
Previously, SearchValues would pick the same implementation as span.IndexOfAny(4 values).
The blog post highlights that a hand-written approach can beat SearchValues in this case -- not anymore :)

publicclassBench{privatestaticreadonlySearchValues<byte>s_searchValues=SearchValues.Create("\0\r&<"u8);privatestaticbyte[]s_bytes=Encoding.ASCII.GetBytes(newstring('x',10_000));[Benchmark]publicintFindHtmlChar()=>s_bytes.AsSpan().IndexOfAny(s_searchValues);}

This approach doubles the searching performance on my AVX2 CPU (Ryzen 1700).
On ARM, it's a 1.6X improvement.

MethodToolchainMeanRatio
FindHtmlCharmain605.7 ns1.00
FindHtmlCharpr304.5 ns0.50

Compared to the implementation for an arbitrary ASCII set, this improves throughput between 1.2x and 1.5x depending on the hardware (see more numbers below).


The UniqueLowNibble approach could be used a lot more aggresively (see benchmarks below).
I conservatively placed it between 3 and 4 values to minimize the risk of regressions for now.
In practice, we're currently only using SearchValues with 4 or more values across runtime/aspnet.

As a follow up, I plan on changing our heuristics around which approach we pick in SearchValues depending on the platform.
After that, we may want to consider using it even with fewer values (e.g. 2 or 3).

We should also consider using PackedSpanHelpers on ARM.
Searching for any subset of ASCII is currently faster than a basic IndexOf('a') on M1 hardware because we're not doing that.


Throughput numbers for scanning through 10k elements (10k bytes or 10k chars).
Rows are ordered from fastest to slowest.

ARM (Apple M1)
MethodMeanError
IndexOfAny1Byte233.2 ns0.24 ns
IndexOfAnyByteInRange253.7 ns0.24 ns
IndexOfAny2Byte274.4 ns0.14 ns
IndexOfAnyUniqueLowNibbleByte275.7 ns0.83 ns
IndexOfAnyAsciiByte346.5 ns0.02 ns
IndexOfAny3Byte346.8 ns0.11 ns
IndexOfAny4Byte444.5 ns0.03 ns
IndexOfAnyByte541.7 ns0.05 ns
IndexOfAny5Byte542.2 ns0.05 ns
IndexOfAnyUniqueLowNibbleChar351.4 ns0.49 ns
IndexOfAnyAsciiChar448.2 ns0.41 ns
IndexOfAny1Char453.2 ns0.23 ns
IndexOfAnyInRange497.6 ns0.31 ns
IndexOfAny2Chars543.2 ns0.37 ns
IndexOfAny3Chars688.8 ns0.19 ns
IndexOfAny4Chars884.2 ns0.21 ns
IndexOfAny5Chars1,079.5 ns0.10 ns
ARM (Azure D8plsv5 VM)
MethodMeanError
IndexOfAny1Byte493.0 ns0.04 ns
IndexOfAnyByteInRange544.2 ns2.92 ns
IndexOfAny2Byte636.7 ns6.03 ns
IndexOfAnyUniqueLowNibbleByte664.5 ns4.58 ns
IndexOfAny3Byte851.6 ns7.29 ns
IndexOfAnyAsciiByte853.6 ns5.32 ns
IndexOfAny4Byte1,067.7 ns8.85 ns
IndexOfAny5Byte1,292.5 ns11.32 ns
IndexOfAnyByte1,309.2 ns10.58 ns
IndexOfAny1Char979.7 ns0.08 ns
IndexOfAnyInRange1,075.4 ns4.08 ns
IndexOfAnyUniqueLowNibbleChar1,088.8 ns53.19 ns
IndexOfAny2Chars1,279.2 ns13.17 ns
IndexOfAnyAsciiChar1,316.2 ns0.91 ns
IndexOfAny3Chars1,702.1 ns14.53 ns
IndexOfAny4Chars2,135.7 ns17.84 ns
IndexOfAny5Chars2,578.2 ns21.88 ns
X64 with Vector256 (i9-10900X - no full Avx512)
MethodMeanError
IndexOfAny1Byte164.1 ns2.56 ns
IndexOfAnyUniqueLowNibbleByte163.8 ns0.53 ns
IndexOfAnyByteInRange200.0 ns1.26 ns
IndexOfAny2Byte214.8 ns2.16 ns
IndexOfAny3Byte216.4 ns1.80 ns
IndexOfAny4Byte227.1 ns1.27 ns
IndexOfAnyAsciiByte248.0 ns2.47 ns
IndexOfAny5Byte252.0 ns0.75 ns
IndexOfAnyByte361.8 ns1.34 ns
IndexOfAny1PackedChar209.1 ns0.23 ns
IndexOfLetterIgnoreCase199.4 ns1.92 ns
IndexOfAnyUniqueLowNibbleChar218.3 ns0.25 ns
IndexOfAny2PackedChars231.7 ns2.57 ns
IndexOfTwoLettersIgnoreCase243.4 ns2.00 ns
IndexOfAny3PackedChars248.4 ns2.82 ns
IndexOfAnyInRangePacked248.7 ns2.49 ns
IndexOfAnyAsciiChar287.2 ns0.38 ns
IndexOfAny1Char304.3 ns3.55 ns
IndexOfAnyInRange395.7 ns3.20 ns
IndexOfAny2Chars416.4 ns5.64 ns
IndexOfAny3Chars410.5 ns3.66 ns
IndexOfAny4Chars440.0 ns3.07 ns
IndexOfAny5Chars496.1 ns1.85 ns
X64 with Vector256 (Ryzen 1700)
MethodMeanError
IndexOfAny1Byte241.3 ns1.51 ns
IndexOfAnyUniqueLowNibbleByte279.0 ns1.54 ns
IndexOfAnyByteInRange368.5 ns1.80 ns
IndexOfAny2Byte369.9 ns1.89 ns
IndexOfAny3Byte447.2 ns2.03 ns
IndexOfAnyAsciiByte455.7 ns2.62 ns
IndexOfAny4Byte557.9 ns1.79 ns
IndexOfAny5Byte640.4 ns3.19 ns
IndexOfAnyByte655.3 ns3.58 ns
IndexOfAny1PackedChar280.7 ns1.48 ns
IndexOfAnyUniqueLowNibbleChar363.0 ns1.94 ns
IndexOfAny2PackedChars365.7 ns1.99 ns
IndexOfLetterIgnoreCase369.2 ns1.98 ns
IndexOfAnyInRangePacked375.1 ns1.27 ns
IndexOfAny3PackedChars448.3 ns2.02 ns
IndexOfTwoLettersIgnoreCase459.5 ns2.24 ns
IndexOfAny1Char461.1 ns1.85 ns
IndexOfAnyAsciiChar545.5 ns18.02 ns
IndexOfAnyInRange718.8 ns4.14 ns
IndexOfAny2Chars734.8 ns2.81 ns
IndexOfAny3Chars922.0 ns2.75 ns
IndexOfAny4Chars1,091.1 ns5.75 ns
IndexOfAny5Chars1,254.2 ns7.16 ns
X64 with Vector512 (Xeon Platinum 8370C)
MethodMeanError
IndexOfAny1Byte99.20 ns0.811 ns
IndexOfAny2Byte186.23 ns0.157 ns
IndexOfAny3Byte236.63 ns0.228 ns
IndexOfAnyByteInRange253.85 ns0.279 ns
IndexOfAnyUniqueLowNibbleByte273.06 ns3.011 ns
IndexOfAny4Byte312.36 ns0.102 ns
IndexOfAnyAsciiByte346.18 ns2.557 ns
IndexOfAny5Byte363.69 ns0.160 ns
IndexOfAnyByte422.75 ns1.270 ns
IndexOfAny1PackedChar165.53 ns3.280 ns
IndexOfAnyInRangePacked168.52 ns2.998 ns
IndexOfLetterIgnoreCase170.30 ns2.769 ns
IndexOfAny1Char194.79 ns0.097 ns
IndexOfAny2PackedChars217.14 ns0.150 ns
IndexOfTwoLettersIgnoreCase239.79 ns0.205 ns
IndexOfAny3PackedChars268.56 ns0.217 ns
IndexOfAnyUniqueLowNibbleChar271.63 ns1.392 ns
IndexOfAnyAsciiChar327.17 ns1.021 ns
IndexOfAny2Chars366.79 ns0.087 ns
IndexOfAny3Chars468.80 ns0.093 ns
IndexOfAnyInRange500.94 ns0.521 ns
IndexOfAny4Chars621.20 ns0.209 ns
IndexOfAny5Chars723.78 ns0.212 ns

@MihaZupanMihaZupan added this to the 10.0.0 milestone Aug 23, 2024
@MihaZupanMihaZupan self-assigned this Aug 23, 2024
@dotnet-policy-service

Copy link
Copy Markdown
Contributor

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

{
// Avoid false positives for the zero character if no other character has a low nibble of zero.
// We can replace it with any other byte that has a non-zero low nibble.
valuesByLowNibble.SetElementUnsafe(0, (byte)1);

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 didn't fully grok this. Why don't we need to check if 1 is already being used?

@MihaZupanMihaZupanSep 5, 2024

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.

All vector elements start out as 0, and not all of them may be initialized.

We map every input character to an element based on its lower nibble.

 0, 16, 32 ... => valuesByLowNibble[0]
1, 17, 33 ... => valuesByLowNibble[1]
15, 31, 47 ... => valuesByLowNibble[15] 

The search works by first picking a potential match based on the low nibble (Shuffle) and then confirming it (Equals).

This means that input characters with a given low nibble only care about the element of valuesByLowNibble for that nibble. Values like 1 or 2 don't care about what the value of valuesByLowNibble[7] is since they'll never be mapped to it.

This also means that it's okay for valuesByLowNibble to be left uninitialized at 0.
The Equals could only match for an input character 0, but those will always be mapped to valuesByLowNibble[0] by the shuffle instead.

The edge case is the 0th nibble since the character 0 could be a false positive there.
But it'll only be a false positive if we don't have the character 0 in our values.
That's the valuesByLowNibble.GetElement(0) == 0 && !lookup.Contains(0) check above.

To avoid false positives for 0, we can use the same trick of setting the element to some "unreachable" value.
We can use any value with a non-zero nibble, as the shuffle will map any inputs with those values to a different element. 1 is just an arbitrary choice.

Edit: I tweaked the comment a bit, hopefully, it's decipherable.

@MihaZupan
MihaZupanforce-pushed the searchvalues-uniqueLowNibble2 branch from d2ae610 to fe3ae67CompareSeptember 6, 2024 17:22
@MihaZupan
MihaZupan merged commit b06d5e2 into dotnet:mainSep 10, 2024
jtschuster pushed a commit to jtschuster/runtime that referenced this pull request Sep 17, 2024
…otnet#106900)
* Add SearchValues implementation for values with unique low nibbles
* More generics
* Tweak comment
* Remove extra empty line
* Update comment
sirntar pushed a commit to sirntar/runtime that referenced this pull request Sep 30, 2024
…otnet#106900)
* Add SearchValues implementation for values with unique low nibbles
* More generics
* Tweak comment
* Remove extra empty line
* Update comment
@github-actionsgithub-actionsBot locked and limited conversation to collaborators Oct 12, 2024
Sign up for freeto subscribe to this conversation on GitHub. Already have an account? Sign in.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@MihaZupan@stephentoub