Description
Per discussion with @tannergooding on Discord
Vector128.ExtractMostSignificantBits is quite an important API that is typically used together with comparisons and TrailingZeroCount/LeadingZeroCount to detect positions of an element in a vector - typically used in various IndexOf-like algorithms, etc. Example:
static void PrintPostion()
{
Vector128<byte> src = Vector128.Create((byte)0, 0, 0, 42, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0);
Vector128<byte> val = Vector128.Create((byte)42);
// prints 3 as the index of 42 is "3" in src vector
Console.WriteLine(FirstMatch(src, val));
}
static int FirstMatch(Vector128<byte> src, Vector128<byte> val)
{
Vector128<byte> eq = Vector128.Equals(src, val);
return BitOperations.TrailingZeroCount(eq.ExtractMostSignificantBits());
}Codegen for FirstMatch on x64:
vpcmpeqb xmm0, xmm0, xmm1
vpmovmskb eax, xmm0
tzcnt eax, eaxCodegen for FirstMatch on arm64:
cmeq v16.16b, v0.16b, v1.16b
ldr q17, [@RWD00]
and v16.16b, v16.16b, v17.16b
ldr q17, [@RWD16]
ushl v16.16b, v16.16b, v17.16b
movi v17.4s, #0
ext v17.16b, v16.16b, v17.16b, #8
addv b17, v17.8b
umov w0, v17.b[0]
lsl w0, w0, #8
addv b16, v16.8b
umov w1, v16.b[0]
orr w0, w0, w1
rbit w0, w0
clz w0, w0Because arm64 doesn't have a direct equivalent of movmsk. However, this particular case can be optimized because we know that input of ExtractMostSignificantBits is a comparison's result with all elements being either zero or all-bits-set, in the best case we can perform this smart trick from arm blog by @danlark1
cmeq v16.16b, v0.16b, v1.16b
shrn v16.8b, v16.8h, #4
umov x0, v16.d[0]
rbit x0, x0
clz x0, x0
asr w0, w0, #2its C# equivalent:
static int FirstMatch(Vector128<byte> src, Vector128<byte> val)
{
Vector128<byte> eq = Vector128.Equals(vector, val);
ulong matches = AdvSimd.ShiftRightLogicalNarrowingLower(src.AsUInt16(), 4).AsUInt64().ToScalar();
return BitOperations.TrailingZeroCount(matches) >> 2;
}Performance impact
We expect a nice improvement for small inputs like what http parsing typically sees where we have to find positions of symbols like :, \n etc in relatively small inputs. For large inputs in most of our algorithms we use fast compare == Vector128<>.Zero checks to ignore chunks without matches.
category:cq
theme:vector-codegen
skill-level:intermediate
cost:small
impact:small
Activity
EgorBo commentedon Sep 23, 2022
Benchmark:
EgorBo commentedon Mar 29, 2023
So the task here is to optimize
TrailingZeroCount(comparison.ExtractMostSignificantBits())in:to emit the same codegen as this function does:
Very first task here is to move
ExtractMostSignificantBitsexpansion from importer to lower. Currently,ExtractMostSignificantBitson ARM64 is imported as the following IR:Instead, it should be just
* HWINTRINSIC simd16 int ExtractMostSignificantBitsas is and is expanded to ^ in Lower. It will allow us to perform optimizations in VN on top of a single node instead of this huge tree.Here is where it's expanded currently:
runtime/src/coreclr/jit/hwintrinsicarm64.cpp
Lines 943 to 1122 in 91df184
cc @JulieLeeMSFT
1 remaining item
AndyAyersMS commentedon Aug 4, 2025
Seems unlikely that this will make it into .NET 10, so am moving to .NET 11.
tannergooding commentedon Aug 4, 2025
👍.
Notably we did make a few improvements already, but the remaining work (for .NET 11) is having morph/lowering take advantage of knowing we have
ExtractMSBnow (since we're no longer expanding it on import and instead carry it through).AndyAyersMS commentedon Jun 24, 2026
Looking at this for .NET 11. One thing worth flagging before anyone picks it up: the specific rewrite proposed here (
TrailingZeroCount(mask.ExtractMostSignificantBits())→ theshrntrick) is not semantics-preserving for the no-match case, so it can't be applied as a transparent JIT peephole.The two forms agree only when there is a match. For an all-zero comparison result they differ:
Verified empirically on current
main(arm64):BitOperations.TrailingZeroCount(uint)is contractually 32 for input 0, so silently rewriting changes observable behavior. The hand-writtenFirstMatch_newworks in BCLIndexOf-style code only because the caller guards/handles the no-match sentinel itself; the JIT can't assume that for arbitrary user code.So a correct version needs one of:
shrnlowering but add a small fixup so no-match still yields the original sentinel — e.g. for byte/Vector128,r + (r & 16)maps 16→32 while leaving match indices unchanged). This still drops the memory constant load the current movemask expansion needs.if (mask != 0)guard, as the BCL's ownIndexOfis written).I'm going to prototype option 1 (semantics-preserving, with benchmarking) since it keeps the win for the common matched case and removes the
ldrof the per-lane shift constant.Note
This comment was generated with the assistance of GitHub Copilot.
EgorBo commentedon Jun 24, 2026
@AndyAyersMS see #129688 (comment)
AndyAyersMS commentedon Jun 24, 2026
Update: I see this is already being handled by @jonathandavies-arm in #129688 (which I'd missed when I wrote the above — apologies for the noise). That PR covers the byte/short/int cases for both
PopCountandTrailingZeroCountconsumers and, notably, preserves the no-match semantics I was worried about: forTrailingZeroCountit builds a per-element "index+1 or sentinel 33" vector, does a horizontal min, then subtracts one, so the zero mask still yields 32. So my option-1 concern is already addressed there — no need for a separate change here.Note
This comment was generated with the assistance of GitHub Copilot.
AndyAyersMS commentedon Jun 24, 2026
So is #129688 the direction we want to go here? My version was a bit different, but I can drop it if we're happy with that PR.
EgorBo commentedon Jun 24, 2026
my point we already have a well optimized
Vector*.*IndexOf*API that everyone should use. There are still valid scenarious we might optimize EMSB for. #129688 is doing that, but it needs a better way to detect that the input is all zeroes/ones (e.g. some code runs analysis with VN and leaves a flag on the intrinsic).JulieLeeMSFT commentedon Jul 30, 2026
#129688 will fix this issue.