Unroll SequenceEqual(ref byte, ref byte, nuint) in JIT - #83945

Merged
EgorBo merged 15 commits into
dotnet:mainfrom
EgorBo:unroll-memcmp
Mar 29, 2023
Merged

Unroll SequenceEqual(ref byte, ref byte, nuint) in JIT#83945
EgorBo merged 15 commits into
dotnet:mainfrom
EgorBo:unroll-memcmp

Conversation

@EgorBo

@EgorBoEgorBo commented Mar 26, 2023

Copy link
Copy Markdown
Member

Unroll SequenceEqual for constant length [1..15] (will add SIMD separately if this lands) for both x64 and arm64.
Example (utf8 literal):

boolTest1(ReadOnlySpan<byte>data)=>"hello world"u8.SequenceEqual(data);boolTest2(Span<byte>data)=>data.StartsWith("test"u8);

Codegen diff: https://www.diffchecker.com/E1laymuB/

Limitations

Unfortunately, it works only when a constant span (either RVA or e.g. data.Slice(0, 10)) is on the left. It happens because we use left span's Length here:

((uint)length)*size);// If this multiplication overflows, the Span we got overflows the entire address range. There's no happy outcome for this api in such a case so we choose not to take the overhead of checking.

In theory, JIT is smart enough to perform things like:

if (x==42)
{
Foo(x); // x will be replaced with 42
}

via AssertProp, but in this case it's a bit more complicated than that. Perhaps, we can assist it with IsKnowConstant. Or we can use RHS span's length instead if we think that a constant span is more likely to appear on the right side.
Works for StartsWith.

Motivation

Mainly, these comparisons in TE.

Benchmarks

[Benchmark]publicintTE_Json(){returnGetRequestType("/json"u8);}[Benchmark]publicintTE_Plaintext(){returnGetRequestType("/plaintext"u8);}publicstaticclassPaths{publicstaticReadOnlySpan<byte>Json=>"/json"u8;publicstaticReadOnlySpan<byte>Plaintext=>"/plaintext"u8;}[MethodImpl(MethodImplOptions.NoInlining)]privatestaticintGetRequestType(ReadOnlySpan<byte>path){// Simulate TE scenarioif(path.Length==10&&Paths.Plaintext.SequenceEqual(path)){return1;}elseif(path.Length==5&&Paths.Json.SequenceEqual(path)){return2;}return3;}staticbyte[]data1=newbyte[100];staticbyte[]data2=newbyte[100];[Benchmark]publicboolEquals_15(){returndata1.AsSpan(0,15).SequenceEqual(data2.AsSpan(0,15));}
MethodToolchainMean
TE_Json\runtime-base\corerun.exe2.0567 ns
TE_Json\runtime\corerun.exe0.9143 ns
TE_Plaintext\runtime-base\corerun.exe1.8862 ns
TE_Plaintext\runtime\corerun.exe1.1548 ns
Equals_15\runtime-base\corerun.exe1.6169 ns
Equals_15\runtime\corerun.exe0.5172 ns

(the difference should be bigger when SIMD is enabled)

@ghostghost assigned EgorBoMar 26, 2023
@ghostghost added the area-CodeGen-coreclr CLR JIT compiler in src/coreclr/src/jit and related components such as SuperPMI label Mar 26, 2023
@ghost

Copy link
Copy Markdown

Tagging subscribers to this area: @JulieLeeMSFT, @jakobbotsch, @kunalspathak
See info in area-owners.md if you want to be subscribed.

Issue Details

Unroll SequenceEqual for constant length [1..15] (will add SIMD separately if this lands) for both x64 and arm64.
Example (utf8 literal):

boolTest(ReadOnlySpan<byte>data){return"hello world!"u8.SequenceEqual(data);}

Codegen diff: https://www.diffchecker.com/0VOpmvMj/

Limitations

Unfortunately, it works only when a constant span (either RVA or e.g. data.Slice(0, 10)) is on the left. It happens because we use left span's Length here:

((uint)length)*size);// If this multiplication overflows, the Span we got overflows the entire address range. There's no happy outcome for this api in such a case so we choose not to take the overhead of checking.

In theory, JIT is smart enough to perform things like:

if (x==42)
{
Foo(x); // x will be replaced with 42
}

via AssertProp, but in this case it's a bit more complicated than that. Perhaps, we can assist it with IsKnowConstant. Or we can use RHS span's length instead if we think that a constant span is more likely to appear on the right side (which is likely the case for e.g. StartsWith).

Motivation

Mainly, these comparisons in TE.

Benchmarks

[Benchmark]publicintTE_Json(){returnGetRequestType("/json"u8);}[Benchmark]publicintTE_Plaintext(){returnGetRequestType("/plaintext"u8);}[MethodImpl(MethodImplOptions.NoInlining)]privatestaticintGetRequestType(ReadOnlySpan<byte>path){// Simulate TE scenarioif(path.Length==10&&Paths.Plaintext.SequenceEqual(path)){return1;}elseif(path.Length==5&&Paths.Json.SequenceEqual(path)){return2;}return3;}staticbyte[]data1=newbyte[100];staticbyte[]data2=newbyte[100];[Benchmark]publicboolEquals_15(){returndata1.AsSpan(0,15).SequenceEqual(data2.AsSpan(0,15));}
MethodToolchainMean
TE_Json\runtime-base\corerun.exe2.0567 ns
TE_Json\runtime\corerun.exe0.9143 ns
TE_Plaintext\runtime-base\corerun.exe1.8862 ns
TE_Plaintext\runtime\corerun.exe1.1548 ns
Equals_15\runtime-base\corerun.exe1.6169 ns
Equals_15\runtime\corerun.exe0.5172 ns
Author:EgorBo
Assignees:EgorBo
Labels:

area-CodeGen-coreclr

Milestone:-

@EgorBo
EgorBo marked this pull request as ready for review March 26, 2023 21:37
@gfoidl

Copy link
Copy Markdown
Member

Can be dasm for Test1 be something like

G_M000_IG01: ;; offset=0000HG_M000_IG02: ;; offset=0000H 488B01 movrax, bword ptr [rcx] 8B5108 movedx, dword ptr [rcx+08H] 83FA0B cmpedx,117404je SHORT G_M000_IG04G_M000_IG03: ;; offset=000BH 33C0 xoreax,eax EB24 jmp SHORT G_M000_IG05G_M000_IG04: ;; offset=000FH 48BA68656C6C6F20776F movrdx,0x6F77206F6C6C6568483310xorrdx, qword ptr [rax] 48B96C6F20776F726C64 movrcx,0x646C726F77206F6C48334803xorrcx, qword ptr [rax+03H] 480BD1 orrdx,rcx 0F94C0 sete al 0FB6C0 movzxrax,alG_M000_IG05: ;; offset=0033H C3 ret; Total bytes of code 52

?
So that the "constant" LHS (here "hello world"u8) is read as long-constant instead of loaded from memory. Save two memory loads.

The assembly above is produced by this simple C# approach.

Code
usingSystem.Runtime.CompilerServices;usingSystem.Runtime.InteropServices;ReadOnlySpan<byte>test="hello world"u8;Console.WriteLine(Test1(test));
#if !DEBUGfor(inti=0;i<100;++i){if(i%10==0)Thread.Sleep(100);_=Test1(test);}
#endif
staticboolTest1(ReadOnlySpan<byte>data)=>"hello world"u8.FastSequenceEqual(data);internalstaticclassMySpanExtensions{[MethodImpl(MethodImplOptions.AggressiveInlining)]publicstaticboolFastSequenceEqual(thisReadOnlySpan<byte>left,ReadOnlySpan<byte>right){nuintlen=(uint)left.Length;if((uint)right.Length!=len)returnfalse;if(len>=sizeof(long)&&len<=2*sizeof(long)){refbyteleftRef=refMemoryMarshal.GetReference(left);refbyterightRef=refMemoryMarshal.GetReference(right);longl0=Unsafe.ReadUnaligned<long>(refleftRef);longl1=Unsafe.ReadUnaligned<long>(refUnsafe.Add(refleftRef,len-sizeof(long)));longr0=Unsafe.ReadUnaligned<long>(refrightRef);longr1=Unsafe.ReadUnaligned<long>(refUnsafe.Add(refrightRef,len-sizeof(long)));longt0=l0^r0;longt1=l1^r1;longt=t0|t1;returnt==0;}thrownewNotSupportedException();}}

PS: the XOR-trick here is 👍🏻

Comment threadsrc/coreclr/jit/lower.cpp Outdated
Comment on lines +1967 to +2006
// We're going to emit something like the following:
//
// bool result = ((*(int*)leftArg ^ *(int*)rightArg) |
// (*(int*)(leftArg + 1) ^ *((int*)(rightArg + 1)))) == 0;
//
// ^ in the given example we unroll for length=5
//
// In IR:
//
// * EQ int
// +--* OR int
// | +--* XOR int
// | | +--* IND int
// | | | \--* LCL_VAR byref V1
// | | \--* IND int
// | | \--* LCL_VAR byref V2
// | \--* XOR int
// | +--* IND int
// | | \--* ADD byref
// | | +--* LCL_VAR byref V1
// | | \--* CNS_INT int 1
// | \--* IND int
// | \--* ADD byref
// | +--* LCL_VAR byref V2
// | \--* CNS_INT int 1
// \--* CNS_INT int 0
//
GenTree* l1Indir = comp->gtNewIndir(loadType, lArgUse.Def());
GenTree* r1Indir = comp->gtNewIndir(loadType, rArgUse.Def());
GenTree* lXor = comp->gtNewOperNode(GT_XOR, TYP_INT, l1Indir, r1Indir);
GenTree* l2Offs = comp->gtNewIconNode(cnsSize - loadWidth);
GenTree* l2AddOffs = comp->gtNewOperNode(GT_ADD, lArg->TypeGet(), lArgClone, l2Offs);
GenTree* l2Indir = comp->gtNewIndir(loadType, l2AddOffs);
GenTree* r2Offs = comp->gtCloneExpr(l2Offs); // offset is the same
GenTree* r2AddOffs = comp->gtNewOperNode(GT_ADD, rArg->TypeGet(), rArgClone, r2Offs);
GenTree* r2Indir = comp->gtNewIndir(loadType, r2AddOffs);
GenTree* rXor = comp->gtNewOperNode(GT_XOR, TYP_INT, l2Indir, r2Indir);
GenTree* resultOr = comp->gtNewOperNode(GT_OR, TYP_INT, lXor, rXor);
GenTree* zeroCns = comp->gtNewIconNode(0);
result = comp->gtNewOperNode(GT_EQ, TYP_INT, resultOr, zeroCns);

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.

Are you sure this is better than the naive version for ARM64 with CCMPs? What is the ARM64 codegen diff if you create AND(EQ(IND, IND), EQ(IND, IND)) instead?

@EgorBoEgorBoMar 27, 2023

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.

The current codegen is (comparing 16 bytes):

F9400001 ldr x1,[x0]F9400043 ldr x3,[x2]CA030021 eor x1, x1, x3F9400000 ldr x0,[x0]F9400042 ldr x2,[x2]CA020000 eor x0, x0, x2AA000020 orr x0, x1, x0F100001F cmp x0, #09A9F17E0 cset x0, eq

cmp version presumably needs ifConversion path? Because here is what I see when I follow your suggestion:

F9400001 ldr x1,[x0]F9400043 ldr x3,[x2]EB03003F cmp x1, x39A9F17E1 cset x1, eqF9400000 ldr x0,[x0]F9400042 ldr x2,[x2]EB02001F cmp x0, x29A9F17E0 cset x0, eqEA00003F tst x1, x09A9F07E0 cset x0, ne

so we need to either do this opt in codegen or earlier for that. For me arm64 codegen doesn't look too bad, it's still better than not unrolled.

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.

No, this should not need if-conversion. Are you calling lowering on these new nodes? I would expect TryLowerAndOrToCCMP to kick in and the ARM64 "naive" IR to result in ldr, ldr, ldr, ldr, cmp, ccmp, cset.

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.

No, this should not need if-conversion. Are you calling lowering on these new nodes? I would expect TryLowerAndOrToCCMP to kick in and the ARM64 "naive" IR to result in ldr, ldr, ldr, ldr, cmp, ccmp, cset.

still doesn't want to convert to CCMP, IsInvariantInRange check fails, presumably because of IND side effects. Still, I think the current version is better than non-unrolled

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.

You should be able to insert it in the right order so that there is no interference, e.g. probably

t0 = IND
t1 = IND
t2 = IND
t3 = IND
t4 = EQ(t0, t1)
t5 = EQ(t2, t3)
t6 = AND(t4, t5)

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.

Although it's a bit odd there would be interference even with

t0 =IND
t1 =IND
t2 =EQ(t0, t1)
t3 =IND
t4 =IND
t5 =EQ(t3, t4)
t6 =AND(t2, t5)

Probably something I should take a look at.

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.

but just in case I pushed a change to move all IND nodes to the front

@gfoidl

Copy link
Copy Markdown
Member

I have a general question: why needs this to be done in JIT and not in managed code? Is it about throughput and / or IL-size?
In some areas there's native -> managed, here it's the opposite.
This is not a rant, just a question out of curiosity. And it makes contributing a bit harder (at least for me, as I don't know the internals of JIT very much).

To implement this in pure C# something like RuntimeHelpers.IsKnownConstant(ReadOnlySpan<byte>) is missing.
Assuming such a method exists, then it could look like:

Example managed implementation
[MethodImpl(MethodImplOptions.AggressiveInlining)]publicstaticboolSequenceEqual(thisReadOnlySpan<byte>left,ReadOnlySpan<byte>right){nuintlen=(uint)left.Length;if((uint)right.Length!=len)returnfalse;if(/* missing piece */RuntimeHelpers.IsKnownConstant(left)){if(len>=sizeof(int)&&len<=2*sizeof(int)){refbyteleftRef=refMemoryMarshal.GetReference(left);refbyterightRef=refMemoryMarshal.GetReference(right);intl0=Unsafe.ReadUnaligned<int>(refleftRef);intr0=Unsafe.ReadUnaligned<int>(refrightRef);intl1=Unsafe.ReadUnaligned<int>(refUnsafe.Add(refleftRef,len-sizeof(int)));intr1=Unsafe.ReadUnaligned<int>(refUnsafe.Add(refrightRef,len-sizeof(int)));intt0=l0^r0;intt1=l1^r1;intt=t0|t1;returnt==0;}if(len>=sizeof(long)&&len<=2*sizeof(long)){refbyteleftRef=refMemoryMarshal.GetReference(left);refbyterightRef=refMemoryMarshal.GetReference(right);longl0=Unsafe.ReadUnaligned<long>(refleftRef);longr0=Unsafe.ReadUnaligned<long>(refrightRef);longl1=Unsafe.ReadUnaligned<long>(refUnsafe.Add(refleftRef,len-sizeof(long)));longr1=Unsafe.ReadUnaligned<long>(refUnsafe.Add(refrightRef,len-sizeof(long)));longt0=l0^r0;longt1=l1^r1;longt=t0|t1;returnt==0;}if(Vector128.IsHardwareAccelerated&&len>=(uint)Vector128<byte>.Count&&len<=2*(uint)Vector128<byte>.Count){refbyteleftRef=refMemoryMarshal.GetReference(left);refbyterightRef=refMemoryMarshal.GetReference(right);Vector128<byte>l0=Vector128.LoadUnsafe(refleftRef);Vector128<byte>r0=Vector128.LoadUnsafe(refrightRef);Vector128<byte>t0=l0^r0;Vector128<byte>l1=Vector128.LoadUnsafe(refleftRef,len-(uint)Vector128<byte>.Count);Vector128<byte>r1=Vector128.LoadUnsafe(refrightRef,len-(uint)Vector128<byte>.Count);Vector128<byte>t1=l1^r1;Vector128<byte>t=t0|t1;returnt==Vector128<byte>.Zero;}if(Vector256.IsHardwareAccelerated&&len>=(uint)Vector256<byte>.Count&&len<=2*(uint)Vector256<byte>.Count){refbyteleftRef=refMemoryMarshal.GetReference(left);refbyterightRef=refMemoryMarshal.GetReference(right);Vector256<byte>l0=Vector256.LoadUnsafe(refleftRef);Vector256<byte>r0=Vector256.LoadUnsafe(refrightRef);Vector256<byte>t0=l0^r0;Vector256<byte>l1=Vector256.LoadUnsafe(refleftRef,len-(uint)Vector256<byte>.Count);Vector256<byte>r1=Vector256.LoadUnsafe(refrightRef,len-(uint)Vector256<byte>.Count);Vector256<byte>t1=l1^r1;Vector256<byte>t=t0|t1;returnt==Vector256<byte>.Zero;}}// Current implementation of SequenceEqualCore w/o length-check (already done)returnSequenceEqualCore(left,right);}

So it's more IL and more work for the JIT to do. Are these the reasons why it's done via [Intrinsic] directly in the JIT?

@EgorBo

Copy link
Copy Markdown
MemberAuthor

@gfoidl there are two issues with the managed approach:

  1. We can only detect constant length for Span in late phases of JIT, so for IsKnownConstant case we'll have to carry a large tree through all phases. And yes, it needs extra support on JIT side for IsKnownConstant
  2. I tried to do the same for String.Equals unrolling once and hit two issues: first, this happy-path creates a huge amount of locals so for some deep callsites we can stop tracking locals because of that (or stop inlining more into the graph). Second, inliner's budget problem. See Unroll String.Equals for constant input [0..16] length #64821

}
else if (strcmp(className, "SpanHelpers") == 0)
{
if (strcmp(methodName, "SequenceEqual") == 0)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I know this is completely unrelated to the fix or changes being done, sorry... but has anyone ever tried reversing the methodName and className tests for a performance hack in the JIT itself?

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.

@IDisposablelookupNamedIntrinsics never show up in our JIT traces so we don't bother. This code is only executed for methods with [Intrinsic] attribute so for 99% of methods it doesn't kick in.

We could use here a Trie/binary search if it was a real problem

@gfoidl

Copy link
Copy Markdown
Member

@EgorBo thanks for the info, I understand.

Comment threadsrc/coreclr/jit/lower.cpp Outdated
Comment on lines +2014 to +2018
// Call LowerNode on these to create addressing modes if needed
LowerNode(l2Indir);
LowerNode(r2Indir);
LowerNode(lXor);
LowerNode(rXor);

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.

Seems like you could just make this function return the first new node you added, since the call was replaced anyway, and have "normal" lowering proceed from there.

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.

Good idea, done

@EgorBo

Copy link
Copy Markdown
MemberAuthor

@EgorBo thanks for the info, I understand.

Still, filed a PR #84002 to make it possible, so now you can use IsKnownConstant(span.Length)

Comment on lines +1957 to +1961
LIR::Use lArgUse;
LIR::Use rArgUse;
bool lFoundUse = BlockRange().TryGetUse(lArg, &lArgUse);
bool rFoundUse = BlockRange().TryGetUse(rArg, &rArgUse);
assert(lFoundUse && rFoundUse);

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.

It's a bit wasteful to go looking for the uses of this given that we know the arg they come from. E.g. you could do

Suggested change
LIR::Use lArgUse;
LIR::Use rArgUse;
bool lFoundUse = BlockRange().TryGetUse(lArg, &lArgUse);
bool rFoundUse = BlockRange().TryGetUse(rArg, &rArgUse);
assert(lFoundUse && rFoundUse);
CallArg* lArg = call->gtArgs.GetUserArgByIndex(0);
GenTree*& lArgNode = lArg->GetLateNode() == nullptr ? lArg->EarlyNodeRef() : lArg->LateNodeRef();
...
LIR::Use lArgUse(BlockRange(), &lArgNode, call);

I don't have a super strong opinion on it.

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.

Thank, will check in a follow up once SPMI is collected - want to see if it's worth the effort to improve this expansion. jit-diff utils found around 30 methods only

Comment threadsrc/coreclr/jit/lower.cpp Outdated
Co-authored-by: Jakob Botsch Nielsen <Jakob.botsch.nielsen@gmail.com>
Comment threadsrc/coreclr/jit/lower.cpp Outdated
Comment threadsrc/coreclr/jit/lower.cpp
Comment threadsrc/coreclr/jit/lower.cpp
@EgorBo

Copy link
Copy Markdown
MemberAuthor

Failures are #83655 and #80619

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

Labels

area-CodeGen-coreclrCLR JIT compiler in src/coreclr/src/jit and related components such as SuperPMI

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants

@EgorBo@gfoidl@IDisposable@jakobbotsch
, '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

Unroll SequenceEqual(ref byte, ref byte, nuint) in JIT - #83945

Merged
EgorBo merged 15 commits into
dotnet:mainfrom
EgorBo:unroll-memcmp
Mar 29, 2023
Merged

Unroll SequenceEqual(ref byte, ref byte, nuint) in JIT#83945
EgorBo merged 15 commits into
dotnet:mainfrom
EgorBo:unroll-memcmp

Conversation

@EgorBo

@EgorBoEgorBo commented Mar 26, 2023

Copy link
Copy Markdown
Member

Unroll SequenceEqual for constant length [1..15] (will add SIMD separately if this lands) for both x64 and arm64.
Example (utf8 literal):

boolTest1(ReadOnlySpan<byte>data)=>"hello world"u8.SequenceEqual(data);boolTest2(Span<byte>data)=>data.StartsWith("test"u8);

Codegen diff: https://www.diffchecker.com/E1laymuB/

Limitations

Unfortunately, it works only when a constant span (either RVA or e.g. data.Slice(0, 10)) is on the left. It happens because we use left span's Length here:

((uint)length)*size);// If this multiplication overflows, the Span we got overflows the entire address range. There's no happy outcome for this api in such a case so we choose not to take the overhead of checking.

In theory, JIT is smart enough to perform things like:

if (x==42)
{
Foo(x); // x will be replaced with 42
}

via AssertProp, but in this case it's a bit more complicated than that. Perhaps, we can assist it with IsKnowConstant. Or we can use RHS span's length instead if we think that a constant span is more likely to appear on the right side.
Works for StartsWith.

Motivation

Mainly, these comparisons in TE.

Benchmarks

[Benchmark]publicintTE_Json(){returnGetRequestType("/json"u8);}[Benchmark]publicintTE_Plaintext(){returnGetRequestType("/plaintext"u8);}publicstaticclassPaths{publicstaticReadOnlySpan<byte>Json=>"/json"u8;publicstaticReadOnlySpan<byte>Plaintext=>"/plaintext"u8;}[MethodImpl(MethodImplOptions.NoInlining)]privatestaticintGetRequestType(ReadOnlySpan<byte>path){// Simulate TE scenarioif(path.Length==10&&Paths.Plaintext.SequenceEqual(path)){return1;}elseif(path.Length==5&&Paths.Json.SequenceEqual(path)){return2;}return3;}staticbyte[]data1=newbyte[100];staticbyte[]data2=newbyte[100];[Benchmark]publicboolEquals_15(){returndata1.AsSpan(0,15).SequenceEqual(data2.AsSpan(0,15));}
MethodToolchainMean
TE_Json\runtime-base\corerun.exe2.0567 ns
TE_Json\runtime\corerun.exe0.9143 ns
TE_Plaintext\runtime-base\corerun.exe1.8862 ns
TE_Plaintext\runtime\corerun.exe1.1548 ns
Equals_15\runtime-base\corerun.exe1.6169 ns
Equals_15\runtime\corerun.exe0.5172 ns

(the difference should be bigger when SIMD is enabled)

@ghostghost assigned EgorBoMar 26, 2023
@ghostghost added the area-CodeGen-coreclr CLR JIT compiler in src/coreclr/src/jit and related components such as SuperPMI label Mar 26, 2023
@ghost

Copy link
Copy Markdown

Tagging subscribers to this area: @JulieLeeMSFT, @jakobbotsch, @kunalspathak
See info in area-owners.md if you want to be subscribed.

Issue Details

Unroll SequenceEqual for constant length [1..15] (will add SIMD separately if this lands) for both x64 and arm64.
Example (utf8 literal):

boolTest(ReadOnlySpan<byte>data){return"hello world!"u8.SequenceEqual(data);}

Codegen diff: https://www.diffchecker.com/0VOpmvMj/

Limitations

Unfortunately, it works only when a constant span (either RVA or e.g. data.Slice(0, 10)) is on the left. It happens because we use left span's Length here:

((uint)length)*size);// If this multiplication overflows, the Span we got overflows the entire address range. There's no happy outcome for this api in such a case so we choose not to take the overhead of checking.

In theory, JIT is smart enough to perform things like:

if (x==42)
{
Foo(x); // x will be replaced with 42
}

via AssertProp, but in this case it's a bit more complicated than that. Perhaps, we can assist it with IsKnowConstant. Or we can use RHS span's length instead if we think that a constant span is more likely to appear on the right side (which is likely the case for e.g. StartsWith).

Motivation

Mainly, these comparisons in TE.

Benchmarks

[Benchmark]publicintTE_Json(){returnGetRequestType("/json"u8);}[Benchmark]publicintTE_Plaintext(){returnGetRequestType("/plaintext"u8);}[MethodImpl(MethodImplOptions.NoInlining)]privatestaticintGetRequestType(ReadOnlySpan<byte>path){// Simulate TE scenarioif(path.Length==10&&Paths.Plaintext.SequenceEqual(path)){return1;}elseif(path.Length==5&&Paths.Json.SequenceEqual(path)){return2;}return3;}staticbyte[]data1=newbyte[100];staticbyte[]data2=newbyte[100];[Benchmark]publicboolEquals_15(){returndata1.AsSpan(0,15).SequenceEqual(data2.AsSpan(0,15));}
MethodToolchainMean
TE_Json\runtime-base\corerun.exe2.0567 ns
TE_Json\runtime\corerun.exe0.9143 ns
TE_Plaintext\runtime-base\corerun.exe1.8862 ns
TE_Plaintext\runtime\corerun.exe1.1548 ns
Equals_15\runtime-base\corerun.exe1.6169 ns
Equals_15\runtime\corerun.exe0.5172 ns
Author:EgorBo
Assignees:EgorBo
Labels:

area-CodeGen-coreclr

Milestone:-

@EgorBo
EgorBo marked this pull request as ready for review March 26, 2023 21:37
@gfoidl

Copy link
Copy Markdown
Member

Can be dasm for Test1 be something like

G_M000_IG01: ;; offset=0000HG_M000_IG02: ;; offset=0000H 488B01 movrax, bword ptr [rcx] 8B5108 movedx, dword ptr [rcx+08H] 83FA0B cmpedx,117404je SHORT G_M000_IG04G_M000_IG03: ;; offset=000BH 33C0 xoreax,eax EB24 jmp SHORT G_M000_IG05G_M000_IG04: ;; offset=000FH 48BA68656C6C6F20776F movrdx,0x6F77206F6C6C6568483310xorrdx, qword ptr [rax] 48B96C6F20776F726C64 movrcx,0x646C726F77206F6C48334803xorrcx, qword ptr [rax+03H] 480BD1 orrdx,rcx 0F94C0 sete al 0FB6C0 movzxrax,alG_M000_IG05: ;; offset=0033H C3 ret; Total bytes of code 52

?
So that the "constant" LHS (here "hello world"u8) is read as long-constant instead of loaded from memory. Save two memory loads.

The assembly above is produced by this simple C# approach.

Code
usingSystem.Runtime.CompilerServices;usingSystem.Runtime.InteropServices;ReadOnlySpan<byte>test="hello world"u8;Console.WriteLine(Test1(test));
#if !DEBUGfor(inti=0;i<100;++i){if(i%10==0)Thread.Sleep(100);_=Test1(test);}
#endif
staticboolTest1(ReadOnlySpan<byte>data)=>"hello world"u8.FastSequenceEqual(data);internalstaticclassMySpanExtensions{[MethodImpl(MethodImplOptions.AggressiveInlining)]publicstaticboolFastSequenceEqual(thisReadOnlySpan<byte>left,ReadOnlySpan<byte>right){nuintlen=(uint)left.Length;if((uint)right.Length!=len)returnfalse;if(len>=sizeof(long)&&len<=2*sizeof(long)){refbyteleftRef=refMemoryMarshal.GetReference(left);refbyterightRef=refMemoryMarshal.GetReference(right);longl0=Unsafe.ReadUnaligned<long>(refleftRef);longl1=Unsafe.ReadUnaligned<long>(refUnsafe.Add(refleftRef,len-sizeof(long)));longr0=Unsafe.ReadUnaligned<long>(refrightRef);longr1=Unsafe.ReadUnaligned<long>(refUnsafe.Add(refrightRef,len-sizeof(long)));longt0=l0^r0;longt1=l1^r1;longt=t0|t1;returnt==0;}thrownewNotSupportedException();}}

PS: the XOR-trick here is 👍🏻

Comment threadsrc/coreclr/jit/lower.cpp Outdated
Comment on lines +1967 to +2006
// We're going to emit something like the following:
//
// bool result = ((*(int*)leftArg ^ *(int*)rightArg) |
// (*(int*)(leftArg + 1) ^ *((int*)(rightArg + 1)))) == 0;
//
// ^ in the given example we unroll for length=5
//
// In IR:
//
// * EQ int
// +--* OR int
// | +--* XOR int
// | | +--* IND int
// | | | \--* LCL_VAR byref V1
// | | \--* IND int
// | | \--* LCL_VAR byref V2
// | \--* XOR int
// | +--* IND int
// | | \--* ADD byref
// | | +--* LCL_VAR byref V1
// | | \--* CNS_INT int 1
// | \--* IND int
// | \--* ADD byref
// | +--* LCL_VAR byref V2
// | \--* CNS_INT int 1
// \--* CNS_INT int 0
//
GenTree* l1Indir = comp->gtNewIndir(loadType, lArgUse.Def());
GenTree* r1Indir = comp->gtNewIndir(loadType, rArgUse.Def());
GenTree* lXor = comp->gtNewOperNode(GT_XOR, TYP_INT, l1Indir, r1Indir);
GenTree* l2Offs = comp->gtNewIconNode(cnsSize - loadWidth);
GenTree* l2AddOffs = comp->gtNewOperNode(GT_ADD, lArg->TypeGet(), lArgClone, l2Offs);
GenTree* l2Indir = comp->gtNewIndir(loadType, l2AddOffs);
GenTree* r2Offs = comp->gtCloneExpr(l2Offs); // offset is the same
GenTree* r2AddOffs = comp->gtNewOperNode(GT_ADD, rArg->TypeGet(), rArgClone, r2Offs);
GenTree* r2Indir = comp->gtNewIndir(loadType, r2AddOffs);
GenTree* rXor = comp->gtNewOperNode(GT_XOR, TYP_INT, l2Indir, r2Indir);
GenTree* resultOr = comp->gtNewOperNode(GT_OR, TYP_INT, lXor, rXor);
GenTree* zeroCns = comp->gtNewIconNode(0);
result = comp->gtNewOperNode(GT_EQ, TYP_INT, resultOr, zeroCns);

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.

Are you sure this is better than the naive version for ARM64 with CCMPs? What is the ARM64 codegen diff if you create AND(EQ(IND, IND), EQ(IND, IND)) instead?

@EgorBoEgorBoMar 27, 2023

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.

The current codegen is (comparing 16 bytes):

F9400001 ldr x1,[x0]F9400043 ldr x3,[x2]CA030021 eor x1, x1, x3F9400000 ldr x0,[x0]F9400042 ldr x2,[x2]CA020000 eor x0, x0, x2AA000020 orr x0, x1, x0F100001F cmp x0, #09A9F17E0 cset x0, eq

cmp version presumably needs ifConversion path? Because here is what I see when I follow your suggestion:

F9400001 ldr x1,[x0]F9400043 ldr x3,[x2]EB03003F cmp x1, x39A9F17E1 cset x1, eqF9400000 ldr x0,[x0]F9400042 ldr x2,[x2]EB02001F cmp x0, x29A9F17E0 cset x0, eqEA00003F tst x1, x09A9F07E0 cset x0, ne

so we need to either do this opt in codegen or earlier for that. For me arm64 codegen doesn't look too bad, it's still better than not unrolled.

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.

No, this should not need if-conversion. Are you calling lowering on these new nodes? I would expect TryLowerAndOrToCCMP to kick in and the ARM64 "naive" IR to result in ldr, ldr, ldr, ldr, cmp, ccmp, cset.

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.

No, this should not need if-conversion. Are you calling lowering on these new nodes? I would expect TryLowerAndOrToCCMP to kick in and the ARM64 "naive" IR to result in ldr, ldr, ldr, ldr, cmp, ccmp, cset.

still doesn't want to convert to CCMP, IsInvariantInRange check fails, presumably because of IND side effects. Still, I think the current version is better than non-unrolled

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.

You should be able to insert it in the right order so that there is no interference, e.g. probably

t0 = IND
t1 = IND
t2 = IND
t3 = IND
t4 = EQ(t0, t1)
t5 = EQ(t2, t3)
t6 = AND(t4, t5)

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.

Although it's a bit odd there would be interference even with

t0 =IND
t1 =IND
t2 =EQ(t0, t1)
t3 =IND
t4 =IND
t5 =EQ(t3, t4)
t6 =AND(t2, t5)

Probably something I should take a look at.

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.

but just in case I pushed a change to move all IND nodes to the front

@gfoidl

Copy link
Copy Markdown
Member

I have a general question: why needs this to be done in JIT and not in managed code? Is it about throughput and / or IL-size?
In some areas there's native -> managed, here it's the opposite.
This is not a rant, just a question out of curiosity. And it makes contributing a bit harder (at least for me, as I don't know the internals of JIT very much).

To implement this in pure C# something like RuntimeHelpers.IsKnownConstant(ReadOnlySpan<byte>) is missing.
Assuming such a method exists, then it could look like:

Example managed implementation
[MethodImpl(MethodImplOptions.AggressiveInlining)]publicstaticboolSequenceEqual(thisReadOnlySpan<byte>left,ReadOnlySpan<byte>right){nuintlen=(uint)left.Length;if((uint)right.Length!=len)returnfalse;if(/* missing piece */RuntimeHelpers.IsKnownConstant(left)){if(len>=sizeof(int)&&len<=2*sizeof(int)){refbyteleftRef=refMemoryMarshal.GetReference(left);refbyterightRef=refMemoryMarshal.GetReference(right);intl0=Unsafe.ReadUnaligned<int>(refleftRef);intr0=Unsafe.ReadUnaligned<int>(refrightRef);intl1=Unsafe.ReadUnaligned<int>(refUnsafe.Add(refleftRef,len-sizeof(int)));intr1=Unsafe.ReadUnaligned<int>(refUnsafe.Add(refrightRef,len-sizeof(int)));intt0=l0^r0;intt1=l1^r1;intt=t0|t1;returnt==0;}if(len>=sizeof(long)&&len<=2*sizeof(long)){refbyteleftRef=refMemoryMarshal.GetReference(left);refbyterightRef=refMemoryMarshal.GetReference(right);longl0=Unsafe.ReadUnaligned<long>(refleftRef);longr0=Unsafe.ReadUnaligned<long>(refrightRef);longl1=Unsafe.ReadUnaligned<long>(refUnsafe.Add(refleftRef,len-sizeof(long)));longr1=Unsafe.ReadUnaligned<long>(refUnsafe.Add(refrightRef,len-sizeof(long)));longt0=l0^r0;longt1=l1^r1;longt=t0|t1;returnt==0;}if(Vector128.IsHardwareAccelerated&&len>=(uint)Vector128<byte>.Count&&len<=2*(uint)Vector128<byte>.Count){refbyteleftRef=refMemoryMarshal.GetReference(left);refbyterightRef=refMemoryMarshal.GetReference(right);Vector128<byte>l0=Vector128.LoadUnsafe(refleftRef);Vector128<byte>r0=Vector128.LoadUnsafe(refrightRef);Vector128<byte>t0=l0^r0;Vector128<byte>l1=Vector128.LoadUnsafe(refleftRef,len-(uint)Vector128<byte>.Count);Vector128<byte>r1=Vector128.LoadUnsafe(refrightRef,len-(uint)Vector128<byte>.Count);Vector128<byte>t1=l1^r1;Vector128<byte>t=t0|t1;returnt==Vector128<byte>.Zero;}if(Vector256.IsHardwareAccelerated&&len>=(uint)Vector256<byte>.Count&&len<=2*(uint)Vector256<byte>.Count){refbyteleftRef=refMemoryMarshal.GetReference(left);refbyterightRef=refMemoryMarshal.GetReference(right);Vector256<byte>l0=Vector256.LoadUnsafe(refleftRef);Vector256<byte>r0=Vector256.LoadUnsafe(refrightRef);Vector256<byte>t0=l0^r0;Vector256<byte>l1=Vector256.LoadUnsafe(refleftRef,len-(uint)Vector256<byte>.Count);Vector256<byte>r1=Vector256.LoadUnsafe(refrightRef,len-(uint)Vector256<byte>.Count);Vector256<byte>t1=l1^r1;Vector256<byte>t=t0|t1;returnt==Vector256<byte>.Zero;}}// Current implementation of SequenceEqualCore w/o length-check (already done)returnSequenceEqualCore(left,right);}

So it's more IL and more work for the JIT to do. Are these the reasons why it's done via [Intrinsic] directly in the JIT?

@EgorBo

Copy link
Copy Markdown
MemberAuthor

@gfoidl there are two issues with the managed approach:

  1. We can only detect constant length for Span in late phases of JIT, so for IsKnownConstant case we'll have to carry a large tree through all phases. And yes, it needs extra support on JIT side for IsKnownConstant
  2. I tried to do the same for String.Equals unrolling once and hit two issues: first, this happy-path creates a huge amount of locals so for some deep callsites we can stop tracking locals because of that (or stop inlining more into the graph). Second, inliner's budget problem. See Unroll String.Equals for constant input [0..16] length #64821

}
else if (strcmp(className, "SpanHelpers") == 0)
{
if (strcmp(methodName, "SequenceEqual") == 0)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I know this is completely unrelated to the fix or changes being done, sorry... but has anyone ever tried reversing the methodName and className tests for a performance hack in the JIT itself?

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.

@IDisposablelookupNamedIntrinsics never show up in our JIT traces so we don't bother. This code is only executed for methods with [Intrinsic] attribute so for 99% of methods it doesn't kick in.

We could use here a Trie/binary search if it was a real problem

@gfoidl

Copy link
Copy Markdown
Member

@EgorBo thanks for the info, I understand.

Comment threadsrc/coreclr/jit/lower.cpp Outdated
Comment on lines +2014 to +2018
// Call LowerNode on these to create addressing modes if needed
LowerNode(l2Indir);
LowerNode(r2Indir);
LowerNode(lXor);
LowerNode(rXor);

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.

Seems like you could just make this function return the first new node you added, since the call was replaced anyway, and have "normal" lowering proceed from there.

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.

Good idea, done

@EgorBo

Copy link
Copy Markdown
MemberAuthor

@EgorBo thanks for the info, I understand.

Still, filed a PR #84002 to make it possible, so now you can use IsKnownConstant(span.Length)

Comment on lines +1957 to +1961
LIR::Use lArgUse;
LIR::Use rArgUse;
bool lFoundUse = BlockRange().TryGetUse(lArg, &lArgUse);
bool rFoundUse = BlockRange().TryGetUse(rArg, &rArgUse);
assert(lFoundUse && rFoundUse);

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.

It's a bit wasteful to go looking for the uses of this given that we know the arg they come from. E.g. you could do

Suggested change
LIR::Use lArgUse;
LIR::Use rArgUse;
bool lFoundUse = BlockRange().TryGetUse(lArg, &lArgUse);
bool rFoundUse = BlockRange().TryGetUse(rArg, &rArgUse);
assert(lFoundUse && rFoundUse);
CallArg* lArg = call->gtArgs.GetUserArgByIndex(0);
GenTree*& lArgNode = lArg->GetLateNode() == nullptr ? lArg->EarlyNodeRef() : lArg->LateNodeRef();
...
LIR::Use lArgUse(BlockRange(), &lArgNode, call);

I don't have a super strong opinion on it.

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.

Thank, will check in a follow up once SPMI is collected - want to see if it's worth the effort to improve this expansion. jit-diff utils found around 30 methods only

Comment threadsrc/coreclr/jit/lower.cpp Outdated
Co-authored-by: Jakob Botsch Nielsen <Jakob.botsch.nielsen@gmail.com>
Comment threadsrc/coreclr/jit/lower.cpp Outdated
Comment threadsrc/coreclr/jit/lower.cpp
Comment threadsrc/coreclr/jit/lower.cpp
@EgorBo

Copy link
Copy Markdown
MemberAuthor

Failures are #83655 and #80619

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

Labels

area-CodeGen-coreclrCLR JIT compiler in src/coreclr/src/jit and related components such as SuperPMI

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants

@EgorBo@gfoidl@IDisposable@jakobbotsch
, '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

Unroll SequenceEqual(ref byte, ref byte, nuint) in JIT - #83945

Merged
EgorBo merged 15 commits into
dotnet:mainfrom
EgorBo:unroll-memcmp
Mar 29, 2023
Merged

Unroll SequenceEqual(ref byte, ref byte, nuint) in JIT#83945
EgorBo merged 15 commits into
dotnet:mainfrom
EgorBo:unroll-memcmp

Conversation

@EgorBo

@EgorBoEgorBo commented Mar 26, 2023

Copy link
Copy Markdown
Member

Unroll SequenceEqual for constant length [1..15] (will add SIMD separately if this lands) for both x64 and arm64.
Example (utf8 literal):

boolTest1(ReadOnlySpan<byte>data)=>"hello world"u8.SequenceEqual(data);boolTest2(Span<byte>data)=>data.StartsWith("test"u8);

Codegen diff: https://www.diffchecker.com/E1laymuB/

Limitations

Unfortunately, it works only when a constant span (either RVA or e.g. data.Slice(0, 10)) is on the left. It happens because we use left span's Length here:

((uint)length)*size);// If this multiplication overflows, the Span we got overflows the entire address range. There's no happy outcome for this api in such a case so we choose not to take the overhead of checking.

In theory, JIT is smart enough to perform things like:

if (x==42)
{
Foo(x); // x will be replaced with 42
}

via AssertProp, but in this case it's a bit more complicated than that. Perhaps, we can assist it with IsKnowConstant. Or we can use RHS span's length instead if we think that a constant span is more likely to appear on the right side.
Works for StartsWith.

Motivation

Mainly, these comparisons in TE.

Benchmarks

[Benchmark]publicintTE_Json(){returnGetRequestType("/json"u8);}[Benchmark]publicintTE_Plaintext(){returnGetRequestType("/plaintext"u8);}publicstaticclassPaths{publicstaticReadOnlySpan<byte>Json=>"/json"u8;publicstaticReadOnlySpan<byte>Plaintext=>"/plaintext"u8;}[MethodImpl(MethodImplOptions.NoInlining)]privatestaticintGetRequestType(ReadOnlySpan<byte>path){// Simulate TE scenarioif(path.Length==10&&Paths.Plaintext.SequenceEqual(path)){return1;}elseif(path.Length==5&&Paths.Json.SequenceEqual(path)){return2;}return3;}staticbyte[]data1=newbyte[100];staticbyte[]data2=newbyte[100];[Benchmark]publicboolEquals_15(){returndata1.AsSpan(0,15).SequenceEqual(data2.AsSpan(0,15));}
MethodToolchainMean
TE_Json\runtime-base\corerun.exe2.0567 ns
TE_Json\runtime\corerun.exe0.9143 ns
TE_Plaintext\runtime-base\corerun.exe1.8862 ns
TE_Plaintext\runtime\corerun.exe1.1548 ns
Equals_15\runtime-base\corerun.exe1.6169 ns
Equals_15\runtime\corerun.exe0.5172 ns

(the difference should be bigger when SIMD is enabled)

@ghostghost assigned EgorBoMar 26, 2023
@ghostghost added the area-CodeGen-coreclr CLR JIT compiler in src/coreclr/src/jit and related components such as SuperPMI label Mar 26, 2023
@ghost

Copy link
Copy Markdown

Tagging subscribers to this area: @JulieLeeMSFT, @jakobbotsch, @kunalspathak
See info in area-owners.md if you want to be subscribed.

Issue Details

Unroll SequenceEqual for constant length [1..15] (will add SIMD separately if this lands) for both x64 and arm64.
Example (utf8 literal):

boolTest(ReadOnlySpan<byte>data){return"hello world!"u8.SequenceEqual(data);}

Codegen diff: https://www.diffchecker.com/0VOpmvMj/

Limitations

Unfortunately, it works only when a constant span (either RVA or e.g. data.Slice(0, 10)) is on the left. It happens because we use left span's Length here:

((uint)length)*size);// If this multiplication overflows, the Span we got overflows the entire address range. There's no happy outcome for this api in such a case so we choose not to take the overhead of checking.

In theory, JIT is smart enough to perform things like:

if (x==42)
{
Foo(x); // x will be replaced with 42
}

via AssertProp, but in this case it's a bit more complicated than that. Perhaps, we can assist it with IsKnowConstant. Or we can use RHS span's length instead if we think that a constant span is more likely to appear on the right side (which is likely the case for e.g. StartsWith).

Motivation

Mainly, these comparisons in TE.

Benchmarks

[Benchmark]publicintTE_Json(){returnGetRequestType("/json"u8);}[Benchmark]publicintTE_Plaintext(){returnGetRequestType("/plaintext"u8);}[MethodImpl(MethodImplOptions.NoInlining)]privatestaticintGetRequestType(ReadOnlySpan<byte>path){// Simulate TE scenarioif(path.Length==10&&Paths.Plaintext.SequenceEqual(path)){return1;}elseif(path.Length==5&&Paths.Json.SequenceEqual(path)){return2;}return3;}staticbyte[]data1=newbyte[100];staticbyte[]data2=newbyte[100];[Benchmark]publicboolEquals_15(){returndata1.AsSpan(0,15).SequenceEqual(data2.AsSpan(0,15));}
MethodToolchainMean
TE_Json\runtime-base\corerun.exe2.0567 ns
TE_Json\runtime\corerun.exe0.9143 ns
TE_Plaintext\runtime-base\corerun.exe1.8862 ns
TE_Plaintext\runtime\corerun.exe1.1548 ns
Equals_15\runtime-base\corerun.exe1.6169 ns
Equals_15\runtime\corerun.exe0.5172 ns
Author:EgorBo
Assignees:EgorBo
Labels:

area-CodeGen-coreclr

Milestone:-

@EgorBo
EgorBo marked this pull request as ready for review March 26, 2023 21:37
@gfoidl

Copy link
Copy Markdown
Member

Can be dasm for Test1 be something like

G_M000_IG01: ;; offset=0000HG_M000_IG02: ;; offset=0000H 488B01 movrax, bword ptr [rcx] 8B5108 movedx, dword ptr [rcx+08H] 83FA0B cmpedx,117404je SHORT G_M000_IG04G_M000_IG03: ;; offset=000BH 33C0 xoreax,eax EB24 jmp SHORT G_M000_IG05G_M000_IG04: ;; offset=000FH 48BA68656C6C6F20776F movrdx,0x6F77206F6C6C6568483310xorrdx, qword ptr [rax] 48B96C6F20776F726C64 movrcx,0x646C726F77206F6C48334803xorrcx, qword ptr [rax+03H] 480BD1 orrdx,rcx 0F94C0 sete al 0FB6C0 movzxrax,alG_M000_IG05: ;; offset=0033H C3 ret; Total bytes of code 52

?
So that the "constant" LHS (here "hello world"u8) is read as long-constant instead of loaded from memory. Save two memory loads.

The assembly above is produced by this simple C# approach.

Code
usingSystem.Runtime.CompilerServices;usingSystem.Runtime.InteropServices;ReadOnlySpan<byte>test="hello world"u8;Console.WriteLine(Test1(test));
#if !DEBUGfor(inti=0;i<100;++i){if(i%10==0)Thread.Sleep(100);_=Test1(test);}
#endif
staticboolTest1(ReadOnlySpan<byte>data)=>"hello world"u8.FastSequenceEqual(data);internalstaticclassMySpanExtensions{[MethodImpl(MethodImplOptions.AggressiveInlining)]publicstaticboolFastSequenceEqual(thisReadOnlySpan<byte>left,ReadOnlySpan<byte>right){nuintlen=(uint)left.Length;if((uint)right.Length!=len)returnfalse;if(len>=sizeof(long)&&len<=2*sizeof(long)){refbyteleftRef=refMemoryMarshal.GetReference(left);refbyterightRef=refMemoryMarshal.GetReference(right);longl0=Unsafe.ReadUnaligned<long>(refleftRef);longl1=Unsafe.ReadUnaligned<long>(refUnsafe.Add(refleftRef,len-sizeof(long)));longr0=Unsafe.ReadUnaligned<long>(refrightRef);longr1=Unsafe.ReadUnaligned<long>(refUnsafe.Add(refrightRef,len-sizeof(long)));longt0=l0^r0;longt1=l1^r1;longt=t0|t1;returnt==0;}thrownewNotSupportedException();}}

PS: the XOR-trick here is 👍🏻

Comment threadsrc/coreclr/jit/lower.cpp Outdated
Comment on lines +1967 to +2006
// We're going to emit something like the following:
//
// bool result = ((*(int*)leftArg ^ *(int*)rightArg) |
// (*(int*)(leftArg + 1) ^ *((int*)(rightArg + 1)))) == 0;
//
// ^ in the given example we unroll for length=5
//
// In IR:
//
// * EQ int
// +--* OR int
// | +--* XOR int
// | | +--* IND int
// | | | \--* LCL_VAR byref V1
// | | \--* IND int
// | | \--* LCL_VAR byref V2
// | \--* XOR int
// | +--* IND int
// | | \--* ADD byref
// | | +--* LCL_VAR byref V1
// | | \--* CNS_INT int 1
// | \--* IND int
// | \--* ADD byref
// | +--* LCL_VAR byref V2
// | \--* CNS_INT int 1
// \--* CNS_INT int 0
//
GenTree* l1Indir = comp->gtNewIndir(loadType, lArgUse.Def());
GenTree* r1Indir = comp->gtNewIndir(loadType, rArgUse.Def());
GenTree* lXor = comp->gtNewOperNode(GT_XOR, TYP_INT, l1Indir, r1Indir);
GenTree* l2Offs = comp->gtNewIconNode(cnsSize - loadWidth);
GenTree* l2AddOffs = comp->gtNewOperNode(GT_ADD, lArg->TypeGet(), lArgClone, l2Offs);
GenTree* l2Indir = comp->gtNewIndir(loadType, l2AddOffs);
GenTree* r2Offs = comp->gtCloneExpr(l2Offs); // offset is the same
GenTree* r2AddOffs = comp->gtNewOperNode(GT_ADD, rArg->TypeGet(), rArgClone, r2Offs);
GenTree* r2Indir = comp->gtNewIndir(loadType, r2AddOffs);
GenTree* rXor = comp->gtNewOperNode(GT_XOR, TYP_INT, l2Indir, r2Indir);
GenTree* resultOr = comp->gtNewOperNode(GT_OR, TYP_INT, lXor, rXor);
GenTree* zeroCns = comp->gtNewIconNode(0);
result = comp->gtNewOperNode(GT_EQ, TYP_INT, resultOr, zeroCns);

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.

Are you sure this is better than the naive version for ARM64 with CCMPs? What is the ARM64 codegen diff if you create AND(EQ(IND, IND), EQ(IND, IND)) instead?

@EgorBoEgorBoMar 27, 2023

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.

The current codegen is (comparing 16 bytes):

F9400001 ldr x1,[x0]F9400043 ldr x3,[x2]CA030021 eor x1, x1, x3F9400000 ldr x0,[x0]F9400042 ldr x2,[x2]CA020000 eor x0, x0, x2AA000020 orr x0, x1, x0F100001F cmp x0, #09A9F17E0 cset x0, eq

cmp version presumably needs ifConversion path? Because here is what I see when I follow your suggestion:

F9400001 ldr x1,[x0]F9400043 ldr x3,[x2]EB03003F cmp x1, x39A9F17E1 cset x1, eqF9400000 ldr x0,[x0]F9400042 ldr x2,[x2]EB02001F cmp x0, x29A9F17E0 cset x0, eqEA00003F tst x1, x09A9F07E0 cset x0, ne

so we need to either do this opt in codegen or earlier for that. For me arm64 codegen doesn't look too bad, it's still better than not unrolled.

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.

No, this should not need if-conversion. Are you calling lowering on these new nodes? I would expect TryLowerAndOrToCCMP to kick in and the ARM64 "naive" IR to result in ldr, ldr, ldr, ldr, cmp, ccmp, cset.

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.

No, this should not need if-conversion. Are you calling lowering on these new nodes? I would expect TryLowerAndOrToCCMP to kick in and the ARM64 "naive" IR to result in ldr, ldr, ldr, ldr, cmp, ccmp, cset.

still doesn't want to convert to CCMP, IsInvariantInRange check fails, presumably because of IND side effects. Still, I think the current version is better than non-unrolled

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.

You should be able to insert it in the right order so that there is no interference, e.g. probably

t0 = IND
t1 = IND
t2 = IND
t3 = IND
t4 = EQ(t0, t1)
t5 = EQ(t2, t3)
t6 = AND(t4, t5)

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.

Although it's a bit odd there would be interference even with

t0 =IND
t1 =IND
t2 =EQ(t0, t1)
t3 =IND
t4 =IND
t5 =EQ(t3, t4)
t6 =AND(t2, t5)

Probably something I should take a look at.

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.

but just in case I pushed a change to move all IND nodes to the front

@gfoidl

Copy link
Copy Markdown
Member

I have a general question: why needs this to be done in JIT and not in managed code? Is it about throughput and / or IL-size?
In some areas there's native -> managed, here it's the opposite.
This is not a rant, just a question out of curiosity. And it makes contributing a bit harder (at least for me, as I don't know the internals of JIT very much).

To implement this in pure C# something like RuntimeHelpers.IsKnownConstant(ReadOnlySpan<byte>) is missing.
Assuming such a method exists, then it could look like:

Example managed implementation
[MethodImpl(MethodImplOptions.AggressiveInlining)]publicstaticboolSequenceEqual(thisReadOnlySpan<byte>left,ReadOnlySpan<byte>right){nuintlen=(uint)left.Length;if((uint)right.Length!=len)returnfalse;if(/* missing piece */RuntimeHelpers.IsKnownConstant(left)){if(len>=sizeof(int)&&len<=2*sizeof(int)){refbyteleftRef=refMemoryMarshal.GetReference(left);refbyterightRef=refMemoryMarshal.GetReference(right);intl0=Unsafe.ReadUnaligned<int>(refleftRef);intr0=Unsafe.ReadUnaligned<int>(refrightRef);intl1=Unsafe.ReadUnaligned<int>(refUnsafe.Add(refleftRef,len-sizeof(int)));intr1=Unsafe.ReadUnaligned<int>(refUnsafe.Add(refrightRef,len-sizeof(int)));intt0=l0^r0;intt1=l1^r1;intt=t0|t1;returnt==0;}if(len>=sizeof(long)&&len<=2*sizeof(long)){refbyteleftRef=refMemoryMarshal.GetReference(left);refbyterightRef=refMemoryMarshal.GetReference(right);longl0=Unsafe.ReadUnaligned<long>(refleftRef);longr0=Unsafe.ReadUnaligned<long>(refrightRef);longl1=Unsafe.ReadUnaligned<long>(refUnsafe.Add(refleftRef,len-sizeof(long)));longr1=Unsafe.ReadUnaligned<long>(refUnsafe.Add(refrightRef,len-sizeof(long)));longt0=l0^r0;longt1=l1^r1;longt=t0|t1;returnt==0;}if(Vector128.IsHardwareAccelerated&&len>=(uint)Vector128<byte>.Count&&len<=2*(uint)Vector128<byte>.Count){refbyteleftRef=refMemoryMarshal.GetReference(left);refbyterightRef=refMemoryMarshal.GetReference(right);Vector128<byte>l0=Vector128.LoadUnsafe(refleftRef);Vector128<byte>r0=Vector128.LoadUnsafe(refrightRef);Vector128<byte>t0=l0^r0;Vector128<byte>l1=Vector128.LoadUnsafe(refleftRef,len-(uint)Vector128<byte>.Count);Vector128<byte>r1=Vector128.LoadUnsafe(refrightRef,len-(uint)Vector128<byte>.Count);Vector128<byte>t1=l1^r1;Vector128<byte>t=t0|t1;returnt==Vector128<byte>.Zero;}if(Vector256.IsHardwareAccelerated&&len>=(uint)Vector256<byte>.Count&&len<=2*(uint)Vector256<byte>.Count){refbyteleftRef=refMemoryMarshal.GetReference(left);refbyterightRef=refMemoryMarshal.GetReference(right);Vector256<byte>l0=Vector256.LoadUnsafe(refleftRef);Vector256<byte>r0=Vector256.LoadUnsafe(refrightRef);Vector256<byte>t0=l0^r0;Vector256<byte>l1=Vector256.LoadUnsafe(refleftRef,len-(uint)Vector256<byte>.Count);Vector256<byte>r1=Vector256.LoadUnsafe(refrightRef,len-(uint)Vector256<byte>.Count);Vector256<byte>t1=l1^r1;Vector256<byte>t=t0|t1;returnt==Vector256<byte>.Zero;}}// Current implementation of SequenceEqualCore w/o length-check (already done)returnSequenceEqualCore(left,right);}

So it's more IL and more work for the JIT to do. Are these the reasons why it's done via [Intrinsic] directly in the JIT?

@EgorBo

Copy link
Copy Markdown
MemberAuthor

@gfoidl there are two issues with the managed approach:

  1. We can only detect constant length for Span in late phases of JIT, so for IsKnownConstant case we'll have to carry a large tree through all phases. And yes, it needs extra support on JIT side for IsKnownConstant
  2. I tried to do the same for String.Equals unrolling once and hit two issues: first, this happy-path creates a huge amount of locals so for some deep callsites we can stop tracking locals because of that (or stop inlining more into the graph). Second, inliner's budget problem. See Unroll String.Equals for constant input [0..16] length #64821

}
else if (strcmp(className, "SpanHelpers") == 0)
{
if (strcmp(methodName, "SequenceEqual") == 0)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I know this is completely unrelated to the fix or changes being done, sorry... but has anyone ever tried reversing the methodName and className tests for a performance hack in the JIT itself?

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.

@IDisposablelookupNamedIntrinsics never show up in our JIT traces so we don't bother. This code is only executed for methods with [Intrinsic] attribute so for 99% of methods it doesn't kick in.

We could use here a Trie/binary search if it was a real problem

@gfoidl

Copy link
Copy Markdown
Member

@EgorBo thanks for the info, I understand.

Comment threadsrc/coreclr/jit/lower.cpp Outdated
Comment on lines +2014 to +2018
// Call LowerNode on these to create addressing modes if needed
LowerNode(l2Indir);
LowerNode(r2Indir);
LowerNode(lXor);
LowerNode(rXor);

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.

Seems like you could just make this function return the first new node you added, since the call was replaced anyway, and have "normal" lowering proceed from there.

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.

Good idea, done

@EgorBo

Copy link
Copy Markdown
MemberAuthor

@EgorBo thanks for the info, I understand.

Still, filed a PR #84002 to make it possible, so now you can use IsKnownConstant(span.Length)

Comment on lines +1957 to +1961
LIR::Use lArgUse;
LIR::Use rArgUse;
bool lFoundUse = BlockRange().TryGetUse(lArg, &lArgUse);
bool rFoundUse = BlockRange().TryGetUse(rArg, &rArgUse);
assert(lFoundUse && rFoundUse);

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.

It's a bit wasteful to go looking for the uses of this given that we know the arg they come from. E.g. you could do

Suggested change
LIR::Use lArgUse;
LIR::Use rArgUse;
bool lFoundUse = BlockRange().TryGetUse(lArg, &lArgUse);
bool rFoundUse = BlockRange().TryGetUse(rArg, &rArgUse);
assert(lFoundUse && rFoundUse);
CallArg* lArg = call->gtArgs.GetUserArgByIndex(0);
GenTree*& lArgNode = lArg->GetLateNode() == nullptr ? lArg->EarlyNodeRef() : lArg->LateNodeRef();
...
LIR::Use lArgUse(BlockRange(), &lArgNode, call);

I don't have a super strong opinion on it.

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.

Thank, will check in a follow up once SPMI is collected - want to see if it's worth the effort to improve this expansion. jit-diff utils found around 30 methods only

Comment threadsrc/coreclr/jit/lower.cpp Outdated
Co-authored-by: Jakob Botsch Nielsen <Jakob.botsch.nielsen@gmail.com>
Comment threadsrc/coreclr/jit/lower.cpp Outdated
Comment threadsrc/coreclr/jit/lower.cpp
Comment threadsrc/coreclr/jit/lower.cpp
@EgorBo

Copy link
Copy Markdown
MemberAuthor

Failures are #83655 and #80619

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

Labels

area-CodeGen-coreclrCLR JIT compiler in src/coreclr/src/jit and related components such as SuperPMI

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants

@EgorBo@gfoidl@IDisposable@jakobbotsch
, '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

Unroll SequenceEqual(ref byte, ref byte, nuint) in JIT - #83945

Merged
EgorBo merged 15 commits into
dotnet:mainfrom
EgorBo:unroll-memcmp
Mar 29, 2023
Merged

Unroll SequenceEqual(ref byte, ref byte, nuint) in JIT#83945
EgorBo merged 15 commits into
dotnet:mainfrom
EgorBo:unroll-memcmp

Conversation

@EgorBo

@EgorBoEgorBo commented Mar 26, 2023

Copy link
Copy Markdown
Member

Unroll SequenceEqual for constant length [1..15] (will add SIMD separately if this lands) for both x64 and arm64.
Example (utf8 literal):

boolTest1(ReadOnlySpan<byte>data)=>"hello world"u8.SequenceEqual(data);boolTest2(Span<byte>data)=>data.StartsWith("test"u8);

Codegen diff: https://www.diffchecker.com/E1laymuB/

Limitations

Unfortunately, it works only when a constant span (either RVA or e.g. data.Slice(0, 10)) is on the left. It happens because we use left span's Length here:

((uint)length)*size);// If this multiplication overflows, the Span we got overflows the entire address range. There's no happy outcome for this api in such a case so we choose not to take the overhead of checking.

In theory, JIT is smart enough to perform things like:

if (x==42)
{
Foo(x); // x will be replaced with 42
}

via AssertProp, but in this case it's a bit more complicated than that. Perhaps, we can assist it with IsKnowConstant. Or we can use RHS span's length instead if we think that a constant span is more likely to appear on the right side.
Works for StartsWith.

Motivation

Mainly, these comparisons in TE.

Benchmarks

[Benchmark]publicintTE_Json(){returnGetRequestType("/json"u8);}[Benchmark]publicintTE_Plaintext(){returnGetRequestType("/plaintext"u8);}publicstaticclassPaths{publicstaticReadOnlySpan<byte>Json=>"/json"u8;publicstaticReadOnlySpan<byte>Plaintext=>"/plaintext"u8;}[MethodImpl(MethodImplOptions.NoInlining)]privatestaticintGetRequestType(ReadOnlySpan<byte>path){// Simulate TE scenarioif(path.Length==10&&Paths.Plaintext.SequenceEqual(path)){return1;}elseif(path.Length==5&&Paths.Json.SequenceEqual(path)){return2;}return3;}staticbyte[]data1=newbyte[100];staticbyte[]data2=newbyte[100];[Benchmark]publicboolEquals_15(){returndata1.AsSpan(0,15).SequenceEqual(data2.AsSpan(0,15));}
MethodToolchainMean
TE_Json\runtime-base\corerun.exe2.0567 ns
TE_Json\runtime\corerun.exe0.9143 ns
TE_Plaintext\runtime-base\corerun.exe1.8862 ns
TE_Plaintext\runtime\corerun.exe1.1548 ns
Equals_15\runtime-base\corerun.exe1.6169 ns
Equals_15\runtime\corerun.exe0.5172 ns

(the difference should be bigger when SIMD is enabled)

@ghostghost assigned EgorBoMar 26, 2023
@ghostghost added the area-CodeGen-coreclr CLR JIT compiler in src/coreclr/src/jit and related components such as SuperPMI label Mar 26, 2023
@ghost

Copy link
Copy Markdown

Tagging subscribers to this area: @JulieLeeMSFT, @jakobbotsch, @kunalspathak
See info in area-owners.md if you want to be subscribed.

Issue Details

Unroll SequenceEqual for constant length [1..15] (will add SIMD separately if this lands) for both x64 and arm64.
Example (utf8 literal):

boolTest(ReadOnlySpan<byte>data){return"hello world!"u8.SequenceEqual(data);}

Codegen diff: https://www.diffchecker.com/0VOpmvMj/

Limitations

Unfortunately, it works only when a constant span (either RVA or e.g. data.Slice(0, 10)) is on the left. It happens because we use left span's Length here:

((uint)length)*size);// If this multiplication overflows, the Span we got overflows the entire address range. There's no happy outcome for this api in such a case so we choose not to take the overhead of checking.

In theory, JIT is smart enough to perform things like:

if (x==42)
{
Foo(x); // x will be replaced with 42
}

via AssertProp, but in this case it's a bit more complicated than that. Perhaps, we can assist it with IsKnowConstant. Or we can use RHS span's length instead if we think that a constant span is more likely to appear on the right side (which is likely the case for e.g. StartsWith).

Motivation

Mainly, these comparisons in TE.

Benchmarks

[Benchmark]publicintTE_Json(){returnGetRequestType("/json"u8);}[Benchmark]publicintTE_Plaintext(){returnGetRequestType("/plaintext"u8);}[MethodImpl(MethodImplOptions.NoInlining)]privatestaticintGetRequestType(ReadOnlySpan<byte>path){// Simulate TE scenarioif(path.Length==10&&Paths.Plaintext.SequenceEqual(path)){return1;}elseif(path.Length==5&&Paths.Json.SequenceEqual(path)){return2;}return3;}staticbyte[]data1=newbyte[100];staticbyte[]data2=newbyte[100];[Benchmark]publicboolEquals_15(){returndata1.AsSpan(0,15).SequenceEqual(data2.AsSpan(0,15));}
MethodToolchainMean
TE_Json\runtime-base\corerun.exe2.0567 ns
TE_Json\runtime\corerun.exe0.9143 ns
TE_Plaintext\runtime-base\corerun.exe1.8862 ns
TE_Plaintext\runtime\corerun.exe1.1548 ns
Equals_15\runtime-base\corerun.exe1.6169 ns
Equals_15\runtime\corerun.exe0.5172 ns
Author:EgorBo
Assignees:EgorBo
Labels:

area-CodeGen-coreclr

Milestone:-

@EgorBo
EgorBo marked this pull request as ready for review March 26, 2023 21:37
@gfoidl

Copy link
Copy Markdown
Member

Can be dasm for Test1 be something like

G_M000_IG01: ;; offset=0000HG_M000_IG02: ;; offset=0000H 488B01 movrax, bword ptr [rcx] 8B5108 movedx, dword ptr [rcx+08H] 83FA0B cmpedx,117404je SHORT G_M000_IG04G_M000_IG03: ;; offset=000BH 33C0 xoreax,eax EB24 jmp SHORT G_M000_IG05G_M000_IG04: ;; offset=000FH 48BA68656C6C6F20776F movrdx,0x6F77206F6C6C6568483310xorrdx, qword ptr [rax] 48B96C6F20776F726C64 movrcx,0x646C726F77206F6C48334803xorrcx, qword ptr [rax+03H] 480BD1 orrdx,rcx 0F94C0 sete al 0FB6C0 movzxrax,alG_M000_IG05: ;; offset=0033H C3 ret; Total bytes of code 52

?
So that the "constant" LHS (here "hello world"u8) is read as long-constant instead of loaded from memory. Save two memory loads.

The assembly above is produced by this simple C# approach.

Code
usingSystem.Runtime.CompilerServices;usingSystem.Runtime.InteropServices;ReadOnlySpan<byte>test="hello world"u8;Console.WriteLine(Test1(test));
#if !DEBUGfor(inti=0;i<100;++i){if(i%10==0)Thread.Sleep(100);_=Test1(test);}
#endif
staticboolTest1(ReadOnlySpan<byte>data)=>"hello world"u8.FastSequenceEqual(data);internalstaticclassMySpanExtensions{[MethodImpl(MethodImplOptions.AggressiveInlining)]publicstaticboolFastSequenceEqual(thisReadOnlySpan<byte>left,ReadOnlySpan<byte>right){nuintlen=(uint)left.Length;if((uint)right.Length!=len)returnfalse;if(len>=sizeof(long)&&len<=2*sizeof(long)){refbyteleftRef=refMemoryMarshal.GetReference(left);refbyterightRef=refMemoryMarshal.GetReference(right);longl0=Unsafe.ReadUnaligned<long>(refleftRef);longl1=Unsafe.ReadUnaligned<long>(refUnsafe.Add(refleftRef,len-sizeof(long)));longr0=Unsafe.ReadUnaligned<long>(refrightRef);longr1=Unsafe.ReadUnaligned<long>(refUnsafe.Add(refrightRef,len-sizeof(long)));longt0=l0^r0;longt1=l1^r1;longt=t0|t1;returnt==0;}thrownewNotSupportedException();}}

PS: the XOR-trick here is 👍🏻

Comment threadsrc/coreclr/jit/lower.cpp Outdated
Comment on lines +1967 to +2006
// We're going to emit something like the following:
//
// bool result = ((*(int*)leftArg ^ *(int*)rightArg) |
// (*(int*)(leftArg + 1) ^ *((int*)(rightArg + 1)))) == 0;
//
// ^ in the given example we unroll for length=5
//
// In IR:
//
// * EQ int
// +--* OR int
// | +--* XOR int
// | | +--* IND int
// | | | \--* LCL_VAR byref V1
// | | \--* IND int
// | | \--* LCL_VAR byref V2
// | \--* XOR int
// | +--* IND int
// | | \--* ADD byref
// | | +--* LCL_VAR byref V1
// | | \--* CNS_INT int 1
// | \--* IND int
// | \--* ADD byref
// | +--* LCL_VAR byref V2
// | \--* CNS_INT int 1
// \--* CNS_INT int 0
//
GenTree* l1Indir = comp->gtNewIndir(loadType, lArgUse.Def());
GenTree* r1Indir = comp->gtNewIndir(loadType, rArgUse.Def());
GenTree* lXor = comp->gtNewOperNode(GT_XOR, TYP_INT, l1Indir, r1Indir);
GenTree* l2Offs = comp->gtNewIconNode(cnsSize - loadWidth);
GenTree* l2AddOffs = comp->gtNewOperNode(GT_ADD, lArg->TypeGet(), lArgClone, l2Offs);
GenTree* l2Indir = comp->gtNewIndir(loadType, l2AddOffs);
GenTree* r2Offs = comp->gtCloneExpr(l2Offs); // offset is the same
GenTree* r2AddOffs = comp->gtNewOperNode(GT_ADD, rArg->TypeGet(), rArgClone, r2Offs);
GenTree* r2Indir = comp->gtNewIndir(loadType, r2AddOffs);
GenTree* rXor = comp->gtNewOperNode(GT_XOR, TYP_INT, l2Indir, r2Indir);
GenTree* resultOr = comp->gtNewOperNode(GT_OR, TYP_INT, lXor, rXor);
GenTree* zeroCns = comp->gtNewIconNode(0);
result = comp->gtNewOperNode(GT_EQ, TYP_INT, resultOr, zeroCns);

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.

Are you sure this is better than the naive version for ARM64 with CCMPs? What is the ARM64 codegen diff if you create AND(EQ(IND, IND), EQ(IND, IND)) instead?

@EgorBoEgorBoMar 27, 2023

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.

The current codegen is (comparing 16 bytes):

F9400001 ldr x1,[x0]F9400043 ldr x3,[x2]CA030021 eor x1, x1, x3F9400000 ldr x0,[x0]F9400042 ldr x2,[x2]CA020000 eor x0, x0, x2AA000020 orr x0, x1, x0F100001F cmp x0, #09A9F17E0 cset x0, eq

cmp version presumably needs ifConversion path? Because here is what I see when I follow your suggestion:

F9400001 ldr x1,[x0]F9400043 ldr x3,[x2]EB03003F cmp x1, x39A9F17E1 cset x1, eqF9400000 ldr x0,[x0]F9400042 ldr x2,[x2]EB02001F cmp x0, x29A9F17E0 cset x0, eqEA00003F tst x1, x09A9F07E0 cset x0, ne

so we need to either do this opt in codegen or earlier for that. For me arm64 codegen doesn't look too bad, it's still better than not unrolled.

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.

No, this should not need if-conversion. Are you calling lowering on these new nodes? I would expect TryLowerAndOrToCCMP to kick in and the ARM64 "naive" IR to result in ldr, ldr, ldr, ldr, cmp, ccmp, cset.

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.

No, this should not need if-conversion. Are you calling lowering on these new nodes? I would expect TryLowerAndOrToCCMP to kick in and the ARM64 "naive" IR to result in ldr, ldr, ldr, ldr, cmp, ccmp, cset.

still doesn't want to convert to CCMP, IsInvariantInRange check fails, presumably because of IND side effects. Still, I think the current version is better than non-unrolled

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.

You should be able to insert it in the right order so that there is no interference, e.g. probably

t0 = IND
t1 = IND
t2 = IND
t3 = IND
t4 = EQ(t0, t1)
t5 = EQ(t2, t3)
t6 = AND(t4, t5)

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.

Although it's a bit odd there would be interference even with

t0 =IND
t1 =IND
t2 =EQ(t0, t1)
t3 =IND
t4 =IND
t5 =EQ(t3, t4)
t6 =AND(t2, t5)

Probably something I should take a look at.

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.

but just in case I pushed a change to move all IND nodes to the front

@gfoidl

Copy link
Copy Markdown
Member

I have a general question: why needs this to be done in JIT and not in managed code? Is it about throughput and / or IL-size?
In some areas there's native -> managed, here it's the opposite.
This is not a rant, just a question out of curiosity. And it makes contributing a bit harder (at least for me, as I don't know the internals of JIT very much).

To implement this in pure C# something like RuntimeHelpers.IsKnownConstant(ReadOnlySpan<byte>) is missing.
Assuming such a method exists, then it could look like:

Example managed implementation
[MethodImpl(MethodImplOptions.AggressiveInlining)]publicstaticboolSequenceEqual(thisReadOnlySpan<byte>left,ReadOnlySpan<byte>right){nuintlen=(uint)left.Length;if((uint)right.Length!=len)returnfalse;if(/* missing piece */RuntimeHelpers.IsKnownConstant(left)){if(len>=sizeof(int)&&len<=2*sizeof(int)){refbyteleftRef=refMemoryMarshal.GetReference(left);refbyterightRef=refMemoryMarshal.GetReference(right);intl0=Unsafe.ReadUnaligned<int>(refleftRef);intr0=Unsafe.ReadUnaligned<int>(refrightRef);intl1=Unsafe.ReadUnaligned<int>(refUnsafe.Add(refleftRef,len-sizeof(int)));intr1=Unsafe.ReadUnaligned<int>(refUnsafe.Add(refrightRef,len-sizeof(int)));intt0=l0^r0;intt1=l1^r1;intt=t0|t1;returnt==0;}if(len>=sizeof(long)&&len<=2*sizeof(long)){refbyteleftRef=refMemoryMarshal.GetReference(left);refbyterightRef=refMemoryMarshal.GetReference(right);longl0=Unsafe.ReadUnaligned<long>(refleftRef);longr0=Unsafe.ReadUnaligned<long>(refrightRef);longl1=Unsafe.ReadUnaligned<long>(refUnsafe.Add(refleftRef,len-sizeof(long)));longr1=Unsafe.ReadUnaligned<long>(refUnsafe.Add(refrightRef,len-sizeof(long)));longt0=l0^r0;longt1=l1^r1;longt=t0|t1;returnt==0;}if(Vector128.IsHardwareAccelerated&&len>=(uint)Vector128<byte>.Count&&len<=2*(uint)Vector128<byte>.Count){refbyteleftRef=refMemoryMarshal.GetReference(left);refbyterightRef=refMemoryMarshal.GetReference(right);Vector128<byte>l0=Vector128.LoadUnsafe(refleftRef);Vector128<byte>r0=Vector128.LoadUnsafe(refrightRef);Vector128<byte>t0=l0^r0;Vector128<byte>l1=Vector128.LoadUnsafe(refleftRef,len-(uint)Vector128<byte>.Count);Vector128<byte>r1=Vector128.LoadUnsafe(refrightRef,len-(uint)Vector128<byte>.Count);Vector128<byte>t1=l1^r1;Vector128<byte>t=t0|t1;returnt==Vector128<byte>.Zero;}if(Vector256.IsHardwareAccelerated&&len>=(uint)Vector256<byte>.Count&&len<=2*(uint)Vector256<byte>.Count){refbyteleftRef=refMemoryMarshal.GetReference(left);refbyterightRef=refMemoryMarshal.GetReference(right);Vector256<byte>l0=Vector256.LoadUnsafe(refleftRef);Vector256<byte>r0=Vector256.LoadUnsafe(refrightRef);Vector256<byte>t0=l0^r0;Vector256<byte>l1=Vector256.LoadUnsafe(refleftRef,len-(uint)Vector256<byte>.Count);Vector256<byte>r1=Vector256.LoadUnsafe(refrightRef,len-(uint)Vector256<byte>.Count);Vector256<byte>t1=l1^r1;Vector256<byte>t=t0|t1;returnt==Vector256<byte>.Zero;}}// Current implementation of SequenceEqualCore w/o length-check (already done)returnSequenceEqualCore(left,right);}

So it's more IL and more work for the JIT to do. Are these the reasons why it's done via [Intrinsic] directly in the JIT?

@EgorBo

Copy link
Copy Markdown
MemberAuthor

@gfoidl there are two issues with the managed approach:

  1. We can only detect constant length for Span in late phases of JIT, so for IsKnownConstant case we'll have to carry a large tree through all phases. And yes, it needs extra support on JIT side for IsKnownConstant
  2. I tried to do the same for String.Equals unrolling once and hit two issues: first, this happy-path creates a huge amount of locals so for some deep callsites we can stop tracking locals because of that (or stop inlining more into the graph). Second, inliner's budget problem. See Unroll String.Equals for constant input [0..16] length #64821

}
else if (strcmp(className, "SpanHelpers") == 0)
{
if (strcmp(methodName, "SequenceEqual") == 0)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I know this is completely unrelated to the fix or changes being done, sorry... but has anyone ever tried reversing the methodName and className tests for a performance hack in the JIT itself?

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.

@IDisposablelookupNamedIntrinsics never show up in our JIT traces so we don't bother. This code is only executed for methods with [Intrinsic] attribute so for 99% of methods it doesn't kick in.

We could use here a Trie/binary search if it was a real problem

@gfoidl

Copy link
Copy Markdown
Member

@EgorBo thanks for the info, I understand.

Comment threadsrc/coreclr/jit/lower.cpp Outdated
Comment on lines +2014 to +2018
// Call LowerNode on these to create addressing modes if needed
LowerNode(l2Indir);
LowerNode(r2Indir);
LowerNode(lXor);
LowerNode(rXor);

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.

Seems like you could just make this function return the first new node you added, since the call was replaced anyway, and have "normal" lowering proceed from there.

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.

Good idea, done

@EgorBo

Copy link
Copy Markdown
MemberAuthor

@EgorBo thanks for the info, I understand.

Still, filed a PR #84002 to make it possible, so now you can use IsKnownConstant(span.Length)

Comment on lines +1957 to +1961
LIR::Use lArgUse;
LIR::Use rArgUse;
bool lFoundUse = BlockRange().TryGetUse(lArg, &lArgUse);
bool rFoundUse = BlockRange().TryGetUse(rArg, &rArgUse);
assert(lFoundUse && rFoundUse);

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.

It's a bit wasteful to go looking for the uses of this given that we know the arg they come from. E.g. you could do

Suggested change
LIR::Use lArgUse;
LIR::Use rArgUse;
bool lFoundUse = BlockRange().TryGetUse(lArg, &lArgUse);
bool rFoundUse = BlockRange().TryGetUse(rArg, &rArgUse);
assert(lFoundUse && rFoundUse);
CallArg* lArg = call->gtArgs.GetUserArgByIndex(0);
GenTree*& lArgNode = lArg->GetLateNode() == nullptr ? lArg->EarlyNodeRef() : lArg->LateNodeRef();
...
LIR::Use lArgUse(BlockRange(), &lArgNode, call);

I don't have a super strong opinion on it.

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.

Thank, will check in a follow up once SPMI is collected - want to see if it's worth the effort to improve this expansion. jit-diff utils found around 30 methods only

Comment threadsrc/coreclr/jit/lower.cpp Outdated
Co-authored-by: Jakob Botsch Nielsen <Jakob.botsch.nielsen@gmail.com>
Comment threadsrc/coreclr/jit/lower.cpp Outdated
Comment threadsrc/coreclr/jit/lower.cpp
Comment threadsrc/coreclr/jit/lower.cpp
@EgorBo

Copy link
Copy Markdown
MemberAuthor

Failures are #83655 and #80619

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

Labels

area-CodeGen-coreclrCLR JIT compiler in src/coreclr/src/jit and related components such as SuperPMI

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants

@EgorBo@gfoidl@IDisposable@jakobbotsch
, '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

Unroll SequenceEqual(ref byte, ref byte, nuint) in JIT - #83945

Merged
EgorBo merged 15 commits into
dotnet:mainfrom
EgorBo:unroll-memcmp
Mar 29, 2023
Merged

Unroll SequenceEqual(ref byte, ref byte, nuint) in JIT#83945
EgorBo merged 15 commits into
dotnet:mainfrom
EgorBo:unroll-memcmp

Conversation

@EgorBo

@EgorBoEgorBo commented Mar 26, 2023

Copy link
Copy Markdown
Member

Unroll SequenceEqual for constant length [1..15] (will add SIMD separately if this lands) for both x64 and arm64.
Example (utf8 literal):

boolTest1(ReadOnlySpan<byte>data)=>"hello world"u8.SequenceEqual(data);boolTest2(Span<byte>data)=>data.StartsWith("test"u8);

Codegen diff: https://www.diffchecker.com/E1laymuB/

Limitations

Unfortunately, it works only when a constant span (either RVA or e.g. data.Slice(0, 10)) is on the left. It happens because we use left span's Length here:

((uint)length)*size);// If this multiplication overflows, the Span we got overflows the entire address range. There's no happy outcome for this api in such a case so we choose not to take the overhead of checking.

In theory, JIT is smart enough to perform things like:

if (x==42)
{
Foo(x); // x will be replaced with 42
}

via AssertProp, but in this case it's a bit more complicated than that. Perhaps, we can assist it with IsKnowConstant. Or we can use RHS span's length instead if we think that a constant span is more likely to appear on the right side.
Works for StartsWith.

Motivation

Mainly, these comparisons in TE.

Benchmarks

[Benchmark]publicintTE_Json(){returnGetRequestType("/json"u8);}[Benchmark]publicintTE_Plaintext(){returnGetRequestType("/plaintext"u8);}publicstaticclassPaths{publicstaticReadOnlySpan<byte>Json=>"/json"u8;publicstaticReadOnlySpan<byte>Plaintext=>"/plaintext"u8;}[MethodImpl(MethodImplOptions.NoInlining)]privatestaticintGetRequestType(ReadOnlySpan<byte>path){// Simulate TE scenarioif(path.Length==10&&Paths.Plaintext.SequenceEqual(path)){return1;}elseif(path.Length==5&&Paths.Json.SequenceEqual(path)){return2;}return3;}staticbyte[]data1=newbyte[100];staticbyte[]data2=newbyte[100];[Benchmark]publicboolEquals_15(){returndata1.AsSpan(0,15).SequenceEqual(data2.AsSpan(0,15));}
MethodToolchainMean
TE_Json\runtime-base\corerun.exe2.0567 ns
TE_Json\runtime\corerun.exe0.9143 ns
TE_Plaintext\runtime-base\corerun.exe1.8862 ns
TE_Plaintext\runtime\corerun.exe1.1548 ns
Equals_15\runtime-base\corerun.exe1.6169 ns
Equals_15\runtime\corerun.exe0.5172 ns

(the difference should be bigger when SIMD is enabled)

@ghostghost assigned EgorBoMar 26, 2023
@ghostghost added the area-CodeGen-coreclr CLR JIT compiler in src/coreclr/src/jit and related components such as SuperPMI label Mar 26, 2023
@ghost

Copy link
Copy Markdown

Tagging subscribers to this area: @JulieLeeMSFT, @jakobbotsch, @kunalspathak
See info in area-owners.md if you want to be subscribed.

Issue Details

Unroll SequenceEqual for constant length [1..15] (will add SIMD separately if this lands) for both x64 and arm64.
Example (utf8 literal):

boolTest(ReadOnlySpan<byte>data){return"hello world!"u8.SequenceEqual(data);}

Codegen diff: https://www.diffchecker.com/0VOpmvMj/

Limitations

Unfortunately, it works only when a constant span (either RVA or e.g. data.Slice(0, 10)) is on the left. It happens because we use left span's Length here:

((uint)length)*size);// If this multiplication overflows, the Span we got overflows the entire address range. There's no happy outcome for this api in such a case so we choose not to take the overhead of checking.

In theory, JIT is smart enough to perform things like:

if (x==42)
{
Foo(x); // x will be replaced with 42
}

via AssertProp, but in this case it's a bit more complicated than that. Perhaps, we can assist it with IsKnowConstant. Or we can use RHS span's length instead if we think that a constant span is more likely to appear on the right side (which is likely the case for e.g. StartsWith).

Motivation

Mainly, these comparisons in TE.

Benchmarks

[Benchmark]publicintTE_Json(){returnGetRequestType("/json"u8);}[Benchmark]publicintTE_Plaintext(){returnGetRequestType("/plaintext"u8);}[MethodImpl(MethodImplOptions.NoInlining)]privatestaticintGetRequestType(ReadOnlySpan<byte>path){// Simulate TE scenarioif(path.Length==10&&Paths.Plaintext.SequenceEqual(path)){return1;}elseif(path.Length==5&&Paths.Json.SequenceEqual(path)){return2;}return3;}staticbyte[]data1=newbyte[100];staticbyte[]data2=newbyte[100];[Benchmark]publicboolEquals_15(){returndata1.AsSpan(0,15).SequenceEqual(data2.AsSpan(0,15));}
MethodToolchainMean
TE_Json\runtime-base\corerun.exe2.0567 ns
TE_Json\runtime\corerun.exe0.9143 ns
TE_Plaintext\runtime-base\corerun.exe1.8862 ns
TE_Plaintext\runtime\corerun.exe1.1548 ns
Equals_15\runtime-base\corerun.exe1.6169 ns
Equals_15\runtime\corerun.exe0.5172 ns
Author:EgorBo
Assignees:EgorBo
Labels:

area-CodeGen-coreclr

Milestone:-

@EgorBo
EgorBo marked this pull request as ready for review March 26, 2023 21:37
@gfoidl

Copy link
Copy Markdown
Member

Can be dasm for Test1 be something like

G_M000_IG01: ;; offset=0000HG_M000_IG02: ;; offset=0000H 488B01 movrax, bword ptr [rcx] 8B5108 movedx, dword ptr [rcx+08H] 83FA0B cmpedx,117404je SHORT G_M000_IG04G_M000_IG03: ;; offset=000BH 33C0 xoreax,eax EB24 jmp SHORT G_M000_IG05G_M000_IG04: ;; offset=000FH 48BA68656C6C6F20776F movrdx,0x6F77206F6C6C6568483310xorrdx, qword ptr [rax] 48B96C6F20776F726C64 movrcx,0x646C726F77206F6C48334803xorrcx, qword ptr [rax+03H] 480BD1 orrdx,rcx 0F94C0 sete al 0FB6C0 movzxrax,alG_M000_IG05: ;; offset=0033H C3 ret; Total bytes of code 52

?
So that the "constant" LHS (here "hello world"u8) is read as long-constant instead of loaded from memory. Save two memory loads.

The assembly above is produced by this simple C# approach.

Code
usingSystem.Runtime.CompilerServices;usingSystem.Runtime.InteropServices;ReadOnlySpan<byte>test="hello world"u8;Console.WriteLine(Test1(test));
#if !DEBUGfor(inti=0;i<100;++i){if(i%10==0)Thread.Sleep(100);_=Test1(test);}
#endif
staticboolTest1(ReadOnlySpan<byte>data)=>"hello world"u8.FastSequenceEqual(data);internalstaticclassMySpanExtensions{[MethodImpl(MethodImplOptions.AggressiveInlining)]publicstaticboolFastSequenceEqual(thisReadOnlySpan<byte>left,ReadOnlySpan<byte>right){nuintlen=(uint)left.Length;if((uint)right.Length!=len)returnfalse;if(len>=sizeof(long)&&len<=2*sizeof(long)){refbyteleftRef=refMemoryMarshal.GetReference(left);refbyterightRef=refMemoryMarshal.GetReference(right);longl0=Unsafe.ReadUnaligned<long>(refleftRef);longl1=Unsafe.ReadUnaligned<long>(refUnsafe.Add(refleftRef,len-sizeof(long)));longr0=Unsafe.ReadUnaligned<long>(refrightRef);longr1=Unsafe.ReadUnaligned<long>(refUnsafe.Add(refrightRef,len-sizeof(long)));longt0=l0^r0;longt1=l1^r1;longt=t0|t1;returnt==0;}thrownewNotSupportedException();}}

PS: the XOR-trick here is 👍🏻

Comment threadsrc/coreclr/jit/lower.cpp Outdated
Comment on lines +1967 to +2006
// We're going to emit something like the following:
//
// bool result = ((*(int*)leftArg ^ *(int*)rightArg) |
// (*(int*)(leftArg + 1) ^ *((int*)(rightArg + 1)))) == 0;
//
// ^ in the given example we unroll for length=5
//
// In IR:
//
// * EQ int
// +--* OR int
// | +--* XOR int
// | | +--* IND int
// | | | \--* LCL_VAR byref V1
// | | \--* IND int
// | | \--* LCL_VAR byref V2
// | \--* XOR int
// | +--* IND int
// | | \--* ADD byref
// | | +--* LCL_VAR byref V1
// | | \--* CNS_INT int 1
// | \--* IND int
// | \--* ADD byref
// | +--* LCL_VAR byref V2
// | \--* CNS_INT int 1
// \--* CNS_INT int 0
//
GenTree* l1Indir = comp->gtNewIndir(loadType, lArgUse.Def());
GenTree* r1Indir = comp->gtNewIndir(loadType, rArgUse.Def());
GenTree* lXor = comp->gtNewOperNode(GT_XOR, TYP_INT, l1Indir, r1Indir);
GenTree* l2Offs = comp->gtNewIconNode(cnsSize - loadWidth);
GenTree* l2AddOffs = comp->gtNewOperNode(GT_ADD, lArg->TypeGet(), lArgClone, l2Offs);
GenTree* l2Indir = comp->gtNewIndir(loadType, l2AddOffs);
GenTree* r2Offs = comp->gtCloneExpr(l2Offs); // offset is the same
GenTree* r2AddOffs = comp->gtNewOperNode(GT_ADD, rArg->TypeGet(), rArgClone, r2Offs);
GenTree* r2Indir = comp->gtNewIndir(loadType, r2AddOffs);
GenTree* rXor = comp->gtNewOperNode(GT_XOR, TYP_INT, l2Indir, r2Indir);
GenTree* resultOr = comp->gtNewOperNode(GT_OR, TYP_INT, lXor, rXor);
GenTree* zeroCns = comp->gtNewIconNode(0);
result = comp->gtNewOperNode(GT_EQ, TYP_INT, resultOr, zeroCns);

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.

Are you sure this is better than the naive version for ARM64 with CCMPs? What is the ARM64 codegen diff if you create AND(EQ(IND, IND), EQ(IND, IND)) instead?

@EgorBoEgorBoMar 27, 2023

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.

The current codegen is (comparing 16 bytes):

F9400001 ldr x1,[x0]F9400043 ldr x3,[x2]CA030021 eor x1, x1, x3F9400000 ldr x0,[x0]F9400042 ldr x2,[x2]CA020000 eor x0, x0, x2AA000020 orr x0, x1, x0F100001F cmp x0, #09A9F17E0 cset x0, eq

cmp version presumably needs ifConversion path? Because here is what I see when I follow your suggestion:

F9400001 ldr x1,[x0]F9400043 ldr x3,[x2]EB03003F cmp x1, x39A9F17E1 cset x1, eqF9400000 ldr x0,[x0]F9400042 ldr x2,[x2]EB02001F cmp x0, x29A9F17E0 cset x0, eqEA00003F tst x1, x09A9F07E0 cset x0, ne

so we need to either do this opt in codegen or earlier for that. For me arm64 codegen doesn't look too bad, it's still better than not unrolled.

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.

No, this should not need if-conversion. Are you calling lowering on these new nodes? I would expect TryLowerAndOrToCCMP to kick in and the ARM64 "naive" IR to result in ldr, ldr, ldr, ldr, cmp, ccmp, cset.

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.

No, this should not need if-conversion. Are you calling lowering on these new nodes? I would expect TryLowerAndOrToCCMP to kick in and the ARM64 "naive" IR to result in ldr, ldr, ldr, ldr, cmp, ccmp, cset.

still doesn't want to convert to CCMP, IsInvariantInRange check fails, presumably because of IND side effects. Still, I think the current version is better than non-unrolled

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.

You should be able to insert it in the right order so that there is no interference, e.g. probably

t0 = IND
t1 = IND
t2 = IND
t3 = IND
t4 = EQ(t0, t1)
t5 = EQ(t2, t3)
t6 = AND(t4, t5)

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.

Although it's a bit odd there would be interference even with

t0 =IND
t1 =IND
t2 =EQ(t0, t1)
t3 =IND
t4 =IND
t5 =EQ(t3, t4)
t6 =AND(t2, t5)

Probably something I should take a look at.

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.

but just in case I pushed a change to move all IND nodes to the front

@gfoidl

Copy link
Copy Markdown
Member

I have a general question: why needs this to be done in JIT and not in managed code? Is it about throughput and / or IL-size?
In some areas there's native -> managed, here it's the opposite.
This is not a rant, just a question out of curiosity. And it makes contributing a bit harder (at least for me, as I don't know the internals of JIT very much).

To implement this in pure C# something like RuntimeHelpers.IsKnownConstant(ReadOnlySpan<byte>) is missing.
Assuming such a method exists, then it could look like:

Example managed implementation
[MethodImpl(MethodImplOptions.AggressiveInlining)]publicstaticboolSequenceEqual(thisReadOnlySpan<byte>left,ReadOnlySpan<byte>right){nuintlen=(uint)left.Length;if((uint)right.Length!=len)returnfalse;if(/* missing piece */RuntimeHelpers.IsKnownConstant(left)){if(len>=sizeof(int)&&len<=2*sizeof(int)){refbyteleftRef=refMemoryMarshal.GetReference(left);refbyterightRef=refMemoryMarshal.GetReference(right);intl0=Unsafe.ReadUnaligned<int>(refleftRef);intr0=Unsafe.ReadUnaligned<int>(refrightRef);intl1=Unsafe.ReadUnaligned<int>(refUnsafe.Add(refleftRef,len-sizeof(int)));intr1=Unsafe.ReadUnaligned<int>(refUnsafe.Add(refrightRef,len-sizeof(int)));intt0=l0^r0;intt1=l1^r1;intt=t0|t1;returnt==0;}if(len>=sizeof(long)&&len<=2*sizeof(long)){refbyteleftRef=refMemoryMarshal.GetReference(left);refbyterightRef=refMemoryMarshal.GetReference(right);longl0=Unsafe.ReadUnaligned<long>(refleftRef);longr0=Unsafe.ReadUnaligned<long>(refrightRef);longl1=Unsafe.ReadUnaligned<long>(refUnsafe.Add(refleftRef,len-sizeof(long)));longr1=Unsafe.ReadUnaligned<long>(refUnsafe.Add(refrightRef,len-sizeof(long)));longt0=l0^r0;longt1=l1^r1;longt=t0|t1;returnt==0;}if(Vector128.IsHardwareAccelerated&&len>=(uint)Vector128<byte>.Count&&len<=2*(uint)Vector128<byte>.Count){refbyteleftRef=refMemoryMarshal.GetReference(left);refbyterightRef=refMemoryMarshal.GetReference(right);Vector128<byte>l0=Vector128.LoadUnsafe(refleftRef);Vector128<byte>r0=Vector128.LoadUnsafe(refrightRef);Vector128<byte>t0=l0^r0;Vector128<byte>l1=Vector128.LoadUnsafe(refleftRef,len-(uint)Vector128<byte>.Count);Vector128<byte>r1=Vector128.LoadUnsafe(refrightRef,len-(uint)Vector128<byte>.Count);Vector128<byte>t1=l1^r1;Vector128<byte>t=t0|t1;returnt==Vector128<byte>.Zero;}if(Vector256.IsHardwareAccelerated&&len>=(uint)Vector256<byte>.Count&&len<=2*(uint)Vector256<byte>.Count){refbyteleftRef=refMemoryMarshal.GetReference(left);refbyterightRef=refMemoryMarshal.GetReference(right);Vector256<byte>l0=Vector256.LoadUnsafe(refleftRef);Vector256<byte>r0=Vector256.LoadUnsafe(refrightRef);Vector256<byte>t0=l0^r0;Vector256<byte>l1=Vector256.LoadUnsafe(refleftRef,len-(uint)Vector256<byte>.Count);Vector256<byte>r1=Vector256.LoadUnsafe(refrightRef,len-(uint)Vector256<byte>.Count);Vector256<byte>t1=l1^r1;Vector256<byte>t=t0|t1;returnt==Vector256<byte>.Zero;}}// Current implementation of SequenceEqualCore w/o length-check (already done)returnSequenceEqualCore(left,right);}

So it's more IL and more work for the JIT to do. Are these the reasons why it's done via [Intrinsic] directly in the JIT?

@EgorBo

Copy link
Copy Markdown
MemberAuthor

@gfoidl there are two issues with the managed approach:

  1. We can only detect constant length for Span in late phases of JIT, so for IsKnownConstant case we'll have to carry a large tree through all phases. And yes, it needs extra support on JIT side for IsKnownConstant
  2. I tried to do the same for String.Equals unrolling once and hit two issues: first, this happy-path creates a huge amount of locals so for some deep callsites we can stop tracking locals because of that (or stop inlining more into the graph). Second, inliner's budget problem. See Unroll String.Equals for constant input [0..16] length #64821

}
else if (strcmp(className, "SpanHelpers") == 0)
{
if (strcmp(methodName, "SequenceEqual") == 0)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I know this is completely unrelated to the fix or changes being done, sorry... but has anyone ever tried reversing the methodName and className tests for a performance hack in the JIT itself?

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.

@IDisposablelookupNamedIntrinsics never show up in our JIT traces so we don't bother. This code is only executed for methods with [Intrinsic] attribute so for 99% of methods it doesn't kick in.

We could use here a Trie/binary search if it was a real problem

@gfoidl

Copy link
Copy Markdown
Member

@EgorBo thanks for the info, I understand.

Comment threadsrc/coreclr/jit/lower.cpp Outdated
Comment on lines +2014 to +2018
// Call LowerNode on these to create addressing modes if needed
LowerNode(l2Indir);
LowerNode(r2Indir);
LowerNode(lXor);
LowerNode(rXor);

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.

Seems like you could just make this function return the first new node you added, since the call was replaced anyway, and have "normal" lowering proceed from there.

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.

Good idea, done

@EgorBo

Copy link
Copy Markdown
MemberAuthor

@EgorBo thanks for the info, I understand.

Still, filed a PR #84002 to make it possible, so now you can use IsKnownConstant(span.Length)

Comment on lines +1957 to +1961
LIR::Use lArgUse;
LIR::Use rArgUse;
bool lFoundUse = BlockRange().TryGetUse(lArg, &lArgUse);
bool rFoundUse = BlockRange().TryGetUse(rArg, &rArgUse);
assert(lFoundUse && rFoundUse);

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.

It's a bit wasteful to go looking for the uses of this given that we know the arg they come from. E.g. you could do

Suggested change
LIR::Use lArgUse;
LIR::Use rArgUse;
bool lFoundUse = BlockRange().TryGetUse(lArg, &lArgUse);
bool rFoundUse = BlockRange().TryGetUse(rArg, &rArgUse);
assert(lFoundUse && rFoundUse);
CallArg* lArg = call->gtArgs.GetUserArgByIndex(0);
GenTree*& lArgNode = lArg->GetLateNode() == nullptr ? lArg->EarlyNodeRef() : lArg->LateNodeRef();
...
LIR::Use lArgUse(BlockRange(), &lArgNode, call);

I don't have a super strong opinion on it.

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.

Thank, will check in a follow up once SPMI is collected - want to see if it's worth the effort to improve this expansion. jit-diff utils found around 30 methods only

Comment threadsrc/coreclr/jit/lower.cpp Outdated
Co-authored-by: Jakob Botsch Nielsen <Jakob.botsch.nielsen@gmail.com>
Comment threadsrc/coreclr/jit/lower.cpp Outdated
Comment threadsrc/coreclr/jit/lower.cpp
Comment threadsrc/coreclr/jit/lower.cpp
@EgorBo

Copy link
Copy Markdown
MemberAuthor

Failures are #83655 and #80619

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

Labels

area-CodeGen-coreclrCLR JIT compiler in src/coreclr/src/jit and related components such as SuperPMI

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants

@EgorBo@gfoidl@IDisposable@jakobbotsch
, '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

Unroll SequenceEqual(ref byte, ref byte, nuint) in JIT - #83945

Merged
EgorBo merged 15 commits into
dotnet:mainfrom
EgorBo:unroll-memcmp
Mar 29, 2023
Merged

Unroll SequenceEqual(ref byte, ref byte, nuint) in JIT#83945
EgorBo merged 15 commits into
dotnet:mainfrom
EgorBo:unroll-memcmp

Conversation

@EgorBo

@EgorBoEgorBo commented Mar 26, 2023

Copy link
Copy Markdown
Member

Unroll SequenceEqual for constant length [1..15] (will add SIMD separately if this lands) for both x64 and arm64.
Example (utf8 literal):

boolTest1(ReadOnlySpan<byte>data)=>"hello world"u8.SequenceEqual(data);boolTest2(Span<byte>data)=>data.StartsWith("test"u8);

Codegen diff: https://www.diffchecker.com/E1laymuB/

Limitations

Unfortunately, it works only when a constant span (either RVA or e.g. data.Slice(0, 10)) is on the left. It happens because we use left span's Length here:

((uint)length)*size);// If this multiplication overflows, the Span we got overflows the entire address range. There's no happy outcome for this api in such a case so we choose not to take the overhead of checking.

In theory, JIT is smart enough to perform things like:

if (x==42)
{
Foo(x); // x will be replaced with 42
}

via AssertProp, but in this case it's a bit more complicated than that. Perhaps, we can assist it with IsKnowConstant. Or we can use RHS span's length instead if we think that a constant span is more likely to appear on the right side.
Works for StartsWith.

Motivation

Mainly, these comparisons in TE.

Benchmarks

[Benchmark]publicintTE_Json(){returnGetRequestType("/json"u8);}[Benchmark]publicintTE_Plaintext(){returnGetRequestType("/plaintext"u8);}publicstaticclassPaths{publicstaticReadOnlySpan<byte>Json=>"/json"u8;publicstaticReadOnlySpan<byte>Plaintext=>"/plaintext"u8;}[MethodImpl(MethodImplOptions.NoInlining)]privatestaticintGetRequestType(ReadOnlySpan<byte>path){// Simulate TE scenarioif(path.Length==10&&Paths.Plaintext.SequenceEqual(path)){return1;}elseif(path.Length==5&&Paths.Json.SequenceEqual(path)){return2;}return3;}staticbyte[]data1=newbyte[100];staticbyte[]data2=newbyte[100];[Benchmark]publicboolEquals_15(){returndata1.AsSpan(0,15).SequenceEqual(data2.AsSpan(0,15));}
MethodToolchainMean
TE_Json\runtime-base\corerun.exe2.0567 ns
TE_Json\runtime\corerun.exe0.9143 ns
TE_Plaintext\runtime-base\corerun.exe1.8862 ns
TE_Plaintext\runtime\corerun.exe1.1548 ns
Equals_15\runtime-base\corerun.exe1.6169 ns
Equals_15\runtime\corerun.exe0.5172 ns

(the difference should be bigger when SIMD is enabled)

@ghostghost assigned EgorBoMar 26, 2023
@ghostghost added the area-CodeGen-coreclr CLR JIT compiler in src/coreclr/src/jit and related components such as SuperPMI label Mar 26, 2023
@ghost

Copy link
Copy Markdown

Tagging subscribers to this area: @JulieLeeMSFT, @jakobbotsch, @kunalspathak
See info in area-owners.md if you want to be subscribed.

Issue Details

Unroll SequenceEqual for constant length [1..15] (will add SIMD separately if this lands) for both x64 and arm64.
Example (utf8 literal):

boolTest(ReadOnlySpan<byte>data){return"hello world!"u8.SequenceEqual(data);}

Codegen diff: https://www.diffchecker.com/0VOpmvMj/

Limitations

Unfortunately, it works only when a constant span (either RVA or e.g. data.Slice(0, 10)) is on the left. It happens because we use left span's Length here:

((uint)length)*size);// If this multiplication overflows, the Span we got overflows the entire address range. There's no happy outcome for this api in such a case so we choose not to take the overhead of checking.

In theory, JIT is smart enough to perform things like:

if (x==42)
{
Foo(x); // x will be replaced with 42
}

via AssertProp, but in this case it's a bit more complicated than that. Perhaps, we can assist it with IsKnowConstant. Or we can use RHS span's length instead if we think that a constant span is more likely to appear on the right side (which is likely the case for e.g. StartsWith).

Motivation

Mainly, these comparisons in TE.

Benchmarks

[Benchmark]publicintTE_Json(){returnGetRequestType("/json"u8);}[Benchmark]publicintTE_Plaintext(){returnGetRequestType("/plaintext"u8);}[MethodImpl(MethodImplOptions.NoInlining)]privatestaticintGetRequestType(ReadOnlySpan<byte>path){// Simulate TE scenarioif(path.Length==10&&Paths.Plaintext.SequenceEqual(path)){return1;}elseif(path.Length==5&&Paths.Json.SequenceEqual(path)){return2;}return3;}staticbyte[]data1=newbyte[100];staticbyte[]data2=newbyte[100];[Benchmark]publicboolEquals_15(){returndata1.AsSpan(0,15).SequenceEqual(data2.AsSpan(0,15));}
MethodToolchainMean
TE_Json\runtime-base\corerun.exe2.0567 ns
TE_Json\runtime\corerun.exe0.9143 ns
TE_Plaintext\runtime-base\corerun.exe1.8862 ns
TE_Plaintext\runtime\corerun.exe1.1548 ns
Equals_15\runtime-base\corerun.exe1.6169 ns
Equals_15\runtime\corerun.exe0.5172 ns
Author:EgorBo
Assignees:EgorBo
Labels:

area-CodeGen-coreclr

Milestone:-

@EgorBo
EgorBo marked this pull request as ready for review March 26, 2023 21:37
@gfoidl

Copy link
Copy Markdown
Member

Can be dasm for Test1 be something like

G_M000_IG01: ;; offset=0000HG_M000_IG02: ;; offset=0000H 488B01 movrax, bword ptr [rcx] 8B5108 movedx, dword ptr [rcx+08H] 83FA0B cmpedx,117404je SHORT G_M000_IG04G_M000_IG03: ;; offset=000BH 33C0 xoreax,eax EB24 jmp SHORT G_M000_IG05G_M000_IG04: ;; offset=000FH 48BA68656C6C6F20776F movrdx,0x6F77206F6C6C6568483310xorrdx, qword ptr [rax] 48B96C6F20776F726C64 movrcx,0x646C726F77206F6C48334803xorrcx, qword ptr [rax+03H] 480BD1 orrdx,rcx 0F94C0 sete al 0FB6C0 movzxrax,alG_M000_IG05: ;; offset=0033H C3 ret; Total bytes of code 52

?
So that the "constant" LHS (here "hello world"u8) is read as long-constant instead of loaded from memory. Save two memory loads.

The assembly above is produced by this simple C# approach.

Code
usingSystem.Runtime.CompilerServices;usingSystem.Runtime.InteropServices;ReadOnlySpan<byte>test="hello world"u8;Console.WriteLine(Test1(test));
#if !DEBUGfor(inti=0;i<100;++i){if(i%10==0)Thread.Sleep(100);_=Test1(test);}
#endif
staticboolTest1(ReadOnlySpan<byte>data)=>"hello world"u8.FastSequenceEqual(data);internalstaticclassMySpanExtensions{[MethodImpl(MethodImplOptions.AggressiveInlining)]publicstaticboolFastSequenceEqual(thisReadOnlySpan<byte>left,ReadOnlySpan<byte>right){nuintlen=(uint)left.Length;if((uint)right.Length!=len)returnfalse;if(len>=sizeof(long)&&len<=2*sizeof(long)){refbyteleftRef=refMemoryMarshal.GetReference(left);refbyterightRef=refMemoryMarshal.GetReference(right);longl0=Unsafe.ReadUnaligned<long>(refleftRef);longl1=Unsafe.ReadUnaligned<long>(refUnsafe.Add(refleftRef,len-sizeof(long)));longr0=Unsafe.ReadUnaligned<long>(refrightRef);longr1=Unsafe.ReadUnaligned<long>(refUnsafe.Add(refrightRef,len-sizeof(long)));longt0=l0^r0;longt1=l1^r1;longt=t0|t1;returnt==0;}thrownewNotSupportedException();}}

PS: the XOR-trick here is 👍🏻

Comment threadsrc/coreclr/jit/lower.cpp Outdated
Comment on lines +1967 to +2006
// We're going to emit something like the following:
//
// bool result = ((*(int*)leftArg ^ *(int*)rightArg) |
// (*(int*)(leftArg + 1) ^ *((int*)(rightArg + 1)))) == 0;
//
// ^ in the given example we unroll for length=5
//
// In IR:
//
// * EQ int
// +--* OR int
// | +--* XOR int
// | | +--* IND int
// | | | \--* LCL_VAR byref V1
// | | \--* IND int
// | | \--* LCL_VAR byref V2
// | \--* XOR int
// | +--* IND int
// | | \--* ADD byref
// | | +--* LCL_VAR byref V1
// | | \--* CNS_INT int 1
// | \--* IND int
// | \--* ADD byref
// | +--* LCL_VAR byref V2
// | \--* CNS_INT int 1
// \--* CNS_INT int 0
//
GenTree* l1Indir = comp->gtNewIndir(loadType, lArgUse.Def());
GenTree* r1Indir = comp->gtNewIndir(loadType, rArgUse.Def());
GenTree* lXor = comp->gtNewOperNode(GT_XOR, TYP_INT, l1Indir, r1Indir);
GenTree* l2Offs = comp->gtNewIconNode(cnsSize - loadWidth);
GenTree* l2AddOffs = comp->gtNewOperNode(GT_ADD, lArg->TypeGet(), lArgClone, l2Offs);
GenTree* l2Indir = comp->gtNewIndir(loadType, l2AddOffs);
GenTree* r2Offs = comp->gtCloneExpr(l2Offs); // offset is the same
GenTree* r2AddOffs = comp->gtNewOperNode(GT_ADD, rArg->TypeGet(), rArgClone, r2Offs);
GenTree* r2Indir = comp->gtNewIndir(loadType, r2AddOffs);
GenTree* rXor = comp->gtNewOperNode(GT_XOR, TYP_INT, l2Indir, r2Indir);
GenTree* resultOr = comp->gtNewOperNode(GT_OR, TYP_INT, lXor, rXor);
GenTree* zeroCns = comp->gtNewIconNode(0);
result = comp->gtNewOperNode(GT_EQ, TYP_INT, resultOr, zeroCns);

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.

Are you sure this is better than the naive version for ARM64 with CCMPs? What is the ARM64 codegen diff if you create AND(EQ(IND, IND), EQ(IND, IND)) instead?

@EgorBoEgorBoMar 27, 2023

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.

The current codegen is (comparing 16 bytes):

F9400001 ldr x1,[x0]F9400043 ldr x3,[x2]CA030021 eor x1, x1, x3F9400000 ldr x0,[x0]F9400042 ldr x2,[x2]CA020000 eor x0, x0, x2AA000020 orr x0, x1, x0F100001F cmp x0, #09A9F17E0 cset x0, eq

cmp version presumably needs ifConversion path? Because here is what I see when I follow your suggestion:

F9400001 ldr x1,[x0]F9400043 ldr x3,[x2]EB03003F cmp x1, x39A9F17E1 cset x1, eqF9400000 ldr x0,[x0]F9400042 ldr x2,[x2]EB02001F cmp x0, x29A9F17E0 cset x0, eqEA00003F tst x1, x09A9F07E0 cset x0, ne

so we need to either do this opt in codegen or earlier for that. For me arm64 codegen doesn't look too bad, it's still better than not unrolled.

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.

No, this should not need if-conversion. Are you calling lowering on these new nodes? I would expect TryLowerAndOrToCCMP to kick in and the ARM64 "naive" IR to result in ldr, ldr, ldr, ldr, cmp, ccmp, cset.

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.

No, this should not need if-conversion. Are you calling lowering on these new nodes? I would expect TryLowerAndOrToCCMP to kick in and the ARM64 "naive" IR to result in ldr, ldr, ldr, ldr, cmp, ccmp, cset.

still doesn't want to convert to CCMP, IsInvariantInRange check fails, presumably because of IND side effects. Still, I think the current version is better than non-unrolled

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.

You should be able to insert it in the right order so that there is no interference, e.g. probably

t0 = IND
t1 = IND
t2 = IND
t3 = IND
t4 = EQ(t0, t1)
t5 = EQ(t2, t3)
t6 = AND(t4, t5)

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.

Although it's a bit odd there would be interference even with

t0 =IND
t1 =IND
t2 =EQ(t0, t1)
t3 =IND
t4 =IND
t5 =EQ(t3, t4)
t6 =AND(t2, t5)

Probably something I should take a look at.

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.

but just in case I pushed a change to move all IND nodes to the front

@gfoidl

Copy link
Copy Markdown
Member

I have a general question: why needs this to be done in JIT and not in managed code? Is it about throughput and / or IL-size?
In some areas there's native -> managed, here it's the opposite.
This is not a rant, just a question out of curiosity. And it makes contributing a bit harder (at least for me, as I don't know the internals of JIT very much).

To implement this in pure C# something like RuntimeHelpers.IsKnownConstant(ReadOnlySpan<byte>) is missing.
Assuming such a method exists, then it could look like:

Example managed implementation
[MethodImpl(MethodImplOptions.AggressiveInlining)]publicstaticboolSequenceEqual(thisReadOnlySpan<byte>left,ReadOnlySpan<byte>right){nuintlen=(uint)left.Length;if((uint)right.Length!=len)returnfalse;if(/* missing piece */RuntimeHelpers.IsKnownConstant(left)){if(len>=sizeof(int)&&len<=2*sizeof(int)){refbyteleftRef=refMemoryMarshal.GetReference(left);refbyterightRef=refMemoryMarshal.GetReference(right);intl0=Unsafe.ReadUnaligned<int>(refleftRef);intr0=Unsafe.ReadUnaligned<int>(refrightRef);intl1=Unsafe.ReadUnaligned<int>(refUnsafe.Add(refleftRef,len-sizeof(int)));intr1=Unsafe.ReadUnaligned<int>(refUnsafe.Add(refrightRef,len-sizeof(int)));intt0=l0^r0;intt1=l1^r1;intt=t0|t1;returnt==0;}if(len>=sizeof(long)&&len<=2*sizeof(long)){refbyteleftRef=refMemoryMarshal.GetReference(left);refbyterightRef=refMemoryMarshal.GetReference(right);longl0=Unsafe.ReadUnaligned<long>(refleftRef);longr0=Unsafe.ReadUnaligned<long>(refrightRef);longl1=Unsafe.ReadUnaligned<long>(refUnsafe.Add(refleftRef,len-sizeof(long)));longr1=Unsafe.ReadUnaligned<long>(refUnsafe.Add(refrightRef,len-sizeof(long)));longt0=l0^r0;longt1=l1^r1;longt=t0|t1;returnt==0;}if(Vector128.IsHardwareAccelerated&&len>=(uint)Vector128<byte>.Count&&len<=2*(uint)Vector128<byte>.Count){refbyteleftRef=refMemoryMarshal.GetReference(left);refbyterightRef=refMemoryMarshal.GetReference(right);Vector128<byte>l0=Vector128.LoadUnsafe(refleftRef);Vector128<byte>r0=Vector128.LoadUnsafe(refrightRef);Vector128<byte>t0=l0^r0;Vector128<byte>l1=Vector128.LoadUnsafe(refleftRef,len-(uint)Vector128<byte>.Count);Vector128<byte>r1=Vector128.LoadUnsafe(refrightRef,len-(uint)Vector128<byte>.Count);Vector128<byte>t1=l1^r1;Vector128<byte>t=t0|t1;returnt==Vector128<byte>.Zero;}if(Vector256.IsHardwareAccelerated&&len>=(uint)Vector256<byte>.Count&&len<=2*(uint)Vector256<byte>.Count){refbyteleftRef=refMemoryMarshal.GetReference(left);refbyterightRef=refMemoryMarshal.GetReference(right);Vector256<byte>l0=Vector256.LoadUnsafe(refleftRef);Vector256<byte>r0=Vector256.LoadUnsafe(refrightRef);Vector256<byte>t0=l0^r0;Vector256<byte>l1=Vector256.LoadUnsafe(refleftRef,len-(uint)Vector256<byte>.Count);Vector256<byte>r1=Vector256.LoadUnsafe(refrightRef,len-(uint)Vector256<byte>.Count);Vector256<byte>t1=l1^r1;Vector256<byte>t=t0|t1;returnt==Vector256<byte>.Zero;}}// Current implementation of SequenceEqualCore w/o length-check (already done)returnSequenceEqualCore(left,right);}

So it's more IL and more work for the JIT to do. Are these the reasons why it's done via [Intrinsic] directly in the JIT?

@EgorBo

Copy link
Copy Markdown
MemberAuthor

@gfoidl there are two issues with the managed approach:

  1. We can only detect constant length for Span in late phases of JIT, so for IsKnownConstant case we'll have to carry a large tree through all phases. And yes, it needs extra support on JIT side for IsKnownConstant
  2. I tried to do the same for String.Equals unrolling once and hit two issues: first, this happy-path creates a huge amount of locals so for some deep callsites we can stop tracking locals because of that (or stop inlining more into the graph). Second, inliner's budget problem. See Unroll String.Equals for constant input [0..16] length #64821

}
else if (strcmp(className, "SpanHelpers") == 0)
{
if (strcmp(methodName, "SequenceEqual") == 0)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I know this is completely unrelated to the fix or changes being done, sorry... but has anyone ever tried reversing the methodName and className tests for a performance hack in the JIT itself?

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.

@IDisposablelookupNamedIntrinsics never show up in our JIT traces so we don't bother. This code is only executed for methods with [Intrinsic] attribute so for 99% of methods it doesn't kick in.

We could use here a Trie/binary search if it was a real problem

@gfoidl

Copy link
Copy Markdown
Member

@EgorBo thanks for the info, I understand.

Comment threadsrc/coreclr/jit/lower.cpp Outdated
Comment on lines +2014 to +2018
// Call LowerNode on these to create addressing modes if needed
LowerNode(l2Indir);
LowerNode(r2Indir);
LowerNode(lXor);
LowerNode(rXor);

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.

Seems like you could just make this function return the first new node you added, since the call was replaced anyway, and have "normal" lowering proceed from there.

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.

Good idea, done

@EgorBo

Copy link
Copy Markdown
MemberAuthor

@EgorBo thanks for the info, I understand.

Still, filed a PR #84002 to make it possible, so now you can use IsKnownConstant(span.Length)

Comment on lines +1957 to +1961
LIR::Use lArgUse;
LIR::Use rArgUse;
bool lFoundUse = BlockRange().TryGetUse(lArg, &lArgUse);
bool rFoundUse = BlockRange().TryGetUse(rArg, &rArgUse);
assert(lFoundUse && rFoundUse);

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.

It's a bit wasteful to go looking for the uses of this given that we know the arg they come from. E.g. you could do

Suggested change
LIR::Use lArgUse;
LIR::Use rArgUse;
bool lFoundUse = BlockRange().TryGetUse(lArg, &lArgUse);
bool rFoundUse = BlockRange().TryGetUse(rArg, &rArgUse);
assert(lFoundUse && rFoundUse);
CallArg* lArg = call->gtArgs.GetUserArgByIndex(0);
GenTree*& lArgNode = lArg->GetLateNode() == nullptr ? lArg->EarlyNodeRef() : lArg->LateNodeRef();
...
LIR::Use lArgUse(BlockRange(), &lArgNode, call);

I don't have a super strong opinion on it.

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.

Thank, will check in a follow up once SPMI is collected - want to see if it's worth the effort to improve this expansion. jit-diff utils found around 30 methods only

Comment threadsrc/coreclr/jit/lower.cpp Outdated
Co-authored-by: Jakob Botsch Nielsen <Jakob.botsch.nielsen@gmail.com>
Comment threadsrc/coreclr/jit/lower.cpp Outdated
Comment threadsrc/coreclr/jit/lower.cpp
Comment threadsrc/coreclr/jit/lower.cpp
@EgorBo

Copy link
Copy Markdown
MemberAuthor

Failures are #83655 and #80619

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

Labels

area-CodeGen-coreclrCLR JIT compiler in src/coreclr/src/jit and related components such as SuperPMI

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants

@EgorBo@gfoidl@IDisposable@jakobbotsch
, '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

Unroll SequenceEqual(ref byte, ref byte, nuint) in JIT - #83945

Merged
EgorBo merged 15 commits into
dotnet:mainfrom
EgorBo:unroll-memcmp
Mar 29, 2023
Merged

Unroll SequenceEqual(ref byte, ref byte, nuint) in JIT#83945
EgorBo merged 15 commits into
dotnet:mainfrom
EgorBo:unroll-memcmp

Conversation

@EgorBo

@EgorBoEgorBo commented Mar 26, 2023

Copy link
Copy Markdown
Member

Unroll SequenceEqual for constant length [1..15] (will add SIMD separately if this lands) for both x64 and arm64.
Example (utf8 literal):

boolTest1(ReadOnlySpan<byte>data)=>"hello world"u8.SequenceEqual(data);boolTest2(Span<byte>data)=>data.StartsWith("test"u8);

Codegen diff: https://www.diffchecker.com/E1laymuB/

Limitations

Unfortunately, it works only when a constant span (either RVA or e.g. data.Slice(0, 10)) is on the left. It happens because we use left span's Length here:

((uint)length)*size);// If this multiplication overflows, the Span we got overflows the entire address range. There's no happy outcome for this api in such a case so we choose not to take the overhead of checking.

In theory, JIT is smart enough to perform things like:

if (x==42)
{
Foo(x); // x will be replaced with 42
}

via AssertProp, but in this case it's a bit more complicated than that. Perhaps, we can assist it with IsKnowConstant. Or we can use RHS span's length instead if we think that a constant span is more likely to appear on the right side.
Works for StartsWith.

Motivation

Mainly, these comparisons in TE.

Benchmarks

[Benchmark]publicintTE_Json(){returnGetRequestType("/json"u8);}[Benchmark]publicintTE_Plaintext(){returnGetRequestType("/plaintext"u8);}publicstaticclassPaths{publicstaticReadOnlySpan<byte>Json=>"/json"u8;publicstaticReadOnlySpan<byte>Plaintext=>"/plaintext"u8;}[MethodImpl(MethodImplOptions.NoInlining)]privatestaticintGetRequestType(ReadOnlySpan<byte>path){// Simulate TE scenarioif(path.Length==10&&Paths.Plaintext.SequenceEqual(path)){return1;}elseif(path.Length==5&&Paths.Json.SequenceEqual(path)){return2;}return3;}staticbyte[]data1=newbyte[100];staticbyte[]data2=newbyte[100];[Benchmark]publicboolEquals_15(){returndata1.AsSpan(0,15).SequenceEqual(data2.AsSpan(0,15));}
MethodToolchainMean
TE_Json\runtime-base\corerun.exe2.0567 ns
TE_Json\runtime\corerun.exe0.9143 ns
TE_Plaintext\runtime-base\corerun.exe1.8862 ns
TE_Plaintext\runtime\corerun.exe1.1548 ns
Equals_15\runtime-base\corerun.exe1.6169 ns
Equals_15\runtime\corerun.exe0.5172 ns

(the difference should be bigger when SIMD is enabled)

@ghostghost assigned EgorBoMar 26, 2023
@ghostghost added the area-CodeGen-coreclr CLR JIT compiler in src/coreclr/src/jit and related components such as SuperPMI label Mar 26, 2023
@ghost

Copy link
Copy Markdown

Tagging subscribers to this area: @JulieLeeMSFT, @jakobbotsch, @kunalspathak
See info in area-owners.md if you want to be subscribed.

Issue Details

Unroll SequenceEqual for constant length [1..15] (will add SIMD separately if this lands) for both x64 and arm64.
Example (utf8 literal):

boolTest(ReadOnlySpan<byte>data){return"hello world!"u8.SequenceEqual(data);}

Codegen diff: https://www.diffchecker.com/0VOpmvMj/

Limitations

Unfortunately, it works only when a constant span (either RVA or e.g. data.Slice(0, 10)) is on the left. It happens because we use left span's Length here:

((uint)length)*size);// If this multiplication overflows, the Span we got overflows the entire address range. There's no happy outcome for this api in such a case so we choose not to take the overhead of checking.

In theory, JIT is smart enough to perform things like:

if (x==42)
{
Foo(x); // x will be replaced with 42
}

via AssertProp, but in this case it's a bit more complicated than that. Perhaps, we can assist it with IsKnowConstant. Or we can use RHS span's length instead if we think that a constant span is more likely to appear on the right side (which is likely the case for e.g. StartsWith).

Motivation

Mainly, these comparisons in TE.

Benchmarks

[Benchmark]publicintTE_Json(){returnGetRequestType("/json"u8);}[Benchmark]publicintTE_Plaintext(){returnGetRequestType("/plaintext"u8);}[MethodImpl(MethodImplOptions.NoInlining)]privatestaticintGetRequestType(ReadOnlySpan<byte>path){// Simulate TE scenarioif(path.Length==10&&Paths.Plaintext.SequenceEqual(path)){return1;}elseif(path.Length==5&&Paths.Json.SequenceEqual(path)){return2;}return3;}staticbyte[]data1=newbyte[100];staticbyte[]data2=newbyte[100];[Benchmark]publicboolEquals_15(){returndata1.AsSpan(0,15).SequenceEqual(data2.AsSpan(0,15));}
MethodToolchainMean
TE_Json\runtime-base\corerun.exe2.0567 ns
TE_Json\runtime\corerun.exe0.9143 ns
TE_Plaintext\runtime-base\corerun.exe1.8862 ns
TE_Plaintext\runtime\corerun.exe1.1548 ns
Equals_15\runtime-base\corerun.exe1.6169 ns
Equals_15\runtime\corerun.exe0.5172 ns
Author:EgorBo
Assignees:EgorBo
Labels:

area-CodeGen-coreclr

Milestone:-

@EgorBo
EgorBo marked this pull request as ready for review March 26, 2023 21:37
@gfoidl

Copy link
Copy Markdown
Member

Can be dasm for Test1 be something like

G_M000_IG01: ;; offset=0000HG_M000_IG02: ;; offset=0000H 488B01 movrax, bword ptr [rcx] 8B5108 movedx, dword ptr [rcx+08H] 83FA0B cmpedx,117404je SHORT G_M000_IG04G_M000_IG03: ;; offset=000BH 33C0 xoreax,eax EB24 jmp SHORT G_M000_IG05G_M000_IG04: ;; offset=000FH 48BA68656C6C6F20776F movrdx,0x6F77206F6C6C6568483310xorrdx, qword ptr [rax] 48B96C6F20776F726C64 movrcx,0x646C726F77206F6C48334803xorrcx, qword ptr [rax+03H] 480BD1 orrdx,rcx 0F94C0 sete al 0FB6C0 movzxrax,alG_M000_IG05: ;; offset=0033H C3 ret; Total bytes of code 52

?
So that the "constant" LHS (here "hello world"u8) is read as long-constant instead of loaded from memory. Save two memory loads.

The assembly above is produced by this simple C# approach.

Code
usingSystem.Runtime.CompilerServices;usingSystem.Runtime.InteropServices;ReadOnlySpan<byte>test="hello world"u8;Console.WriteLine(Test1(test));
#if !DEBUGfor(inti=0;i<100;++i){if(i%10==0)Thread.Sleep(100);_=Test1(test);}
#endif
staticboolTest1(ReadOnlySpan<byte>data)=>"hello world"u8.FastSequenceEqual(data);internalstaticclassMySpanExtensions{[MethodImpl(MethodImplOptions.AggressiveInlining)]publicstaticboolFastSequenceEqual(thisReadOnlySpan<byte>left,ReadOnlySpan<byte>right){nuintlen=(uint)left.Length;if((uint)right.Length!=len)returnfalse;if(len>=sizeof(long)&&len<=2*sizeof(long)){refbyteleftRef=refMemoryMarshal.GetReference(left);refbyterightRef=refMemoryMarshal.GetReference(right);longl0=Unsafe.ReadUnaligned<long>(refleftRef);longl1=Unsafe.ReadUnaligned<long>(refUnsafe.Add(refleftRef,len-sizeof(long)));longr0=Unsafe.ReadUnaligned<long>(refrightRef);longr1=Unsafe.ReadUnaligned<long>(refUnsafe.Add(refrightRef,len-sizeof(long)));longt0=l0^r0;longt1=l1^r1;longt=t0|t1;returnt==0;}thrownewNotSupportedException();}}

PS: the XOR-trick here is 👍🏻

Comment threadsrc/coreclr/jit/lower.cpp Outdated
Comment on lines +1967 to +2006
// We're going to emit something like the following:
//
// bool result = ((*(int*)leftArg ^ *(int*)rightArg) |
// (*(int*)(leftArg + 1) ^ *((int*)(rightArg + 1)))) == 0;
//
// ^ in the given example we unroll for length=5
//
// In IR:
//
// * EQ int
// +--* OR int
// | +--* XOR int
// | | +--* IND int
// | | | \--* LCL_VAR byref V1
// | | \--* IND int
// | | \--* LCL_VAR byref V2
// | \--* XOR int
// | +--* IND int
// | | \--* ADD byref
// | | +--* LCL_VAR byref V1
// | | \--* CNS_INT int 1
// | \--* IND int
// | \--* ADD byref
// | +--* LCL_VAR byref V2
// | \--* CNS_INT int 1
// \--* CNS_INT int 0
//
GenTree* l1Indir = comp->gtNewIndir(loadType, lArgUse.Def());
GenTree* r1Indir = comp->gtNewIndir(loadType, rArgUse.Def());
GenTree* lXor = comp->gtNewOperNode(GT_XOR, TYP_INT, l1Indir, r1Indir);
GenTree* l2Offs = comp->gtNewIconNode(cnsSize - loadWidth);
GenTree* l2AddOffs = comp->gtNewOperNode(GT_ADD, lArg->TypeGet(), lArgClone, l2Offs);
GenTree* l2Indir = comp->gtNewIndir(loadType, l2AddOffs);
GenTree* r2Offs = comp->gtCloneExpr(l2Offs); // offset is the same
GenTree* r2AddOffs = comp->gtNewOperNode(GT_ADD, rArg->TypeGet(), rArgClone, r2Offs);
GenTree* r2Indir = comp->gtNewIndir(loadType, r2AddOffs);
GenTree* rXor = comp->gtNewOperNode(GT_XOR, TYP_INT, l2Indir, r2Indir);
GenTree* resultOr = comp->gtNewOperNode(GT_OR, TYP_INT, lXor, rXor);
GenTree* zeroCns = comp->gtNewIconNode(0);
result = comp->gtNewOperNode(GT_EQ, TYP_INT, resultOr, zeroCns);

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.

Are you sure this is better than the naive version for ARM64 with CCMPs? What is the ARM64 codegen diff if you create AND(EQ(IND, IND), EQ(IND, IND)) instead?

@EgorBoEgorBoMar 27, 2023

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.

The current codegen is (comparing 16 bytes):

F9400001 ldr x1,[x0]F9400043 ldr x3,[x2]CA030021 eor x1, x1, x3F9400000 ldr x0,[x0]F9400042 ldr x2,[x2]CA020000 eor x0, x0, x2AA000020 orr x0, x1, x0F100001F cmp x0, #09A9F17E0 cset x0, eq

cmp version presumably needs ifConversion path? Because here is what I see when I follow your suggestion:

F9400001 ldr x1,[x0]F9400043 ldr x3,[x2]EB03003F cmp x1, x39A9F17E1 cset x1, eqF9400000 ldr x0,[x0]F9400042 ldr x2,[x2]EB02001F cmp x0, x29A9F17E0 cset x0, eqEA00003F tst x1, x09A9F07E0 cset x0, ne

so we need to either do this opt in codegen or earlier for that. For me arm64 codegen doesn't look too bad, it's still better than not unrolled.

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.

No, this should not need if-conversion. Are you calling lowering on these new nodes? I would expect TryLowerAndOrToCCMP to kick in and the ARM64 "naive" IR to result in ldr, ldr, ldr, ldr, cmp, ccmp, cset.

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.

No, this should not need if-conversion. Are you calling lowering on these new nodes? I would expect TryLowerAndOrToCCMP to kick in and the ARM64 "naive" IR to result in ldr, ldr, ldr, ldr, cmp, ccmp, cset.

still doesn't want to convert to CCMP, IsInvariantInRange check fails, presumably because of IND side effects. Still, I think the current version is better than non-unrolled

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.

You should be able to insert it in the right order so that there is no interference, e.g. probably

t0 = IND
t1 = IND
t2 = IND
t3 = IND
t4 = EQ(t0, t1)
t5 = EQ(t2, t3)
t6 = AND(t4, t5)

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.

Although it's a bit odd there would be interference even with

t0 =IND
t1 =IND
t2 =EQ(t0, t1)
t3 =IND
t4 =IND
t5 =EQ(t3, t4)
t6 =AND(t2, t5)

Probably something I should take a look at.

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.

but just in case I pushed a change to move all IND nodes to the front

@gfoidl

Copy link
Copy Markdown
Member

I have a general question: why needs this to be done in JIT and not in managed code? Is it about throughput and / or IL-size?
In some areas there's native -> managed, here it's the opposite.
This is not a rant, just a question out of curiosity. And it makes contributing a bit harder (at least for me, as I don't know the internals of JIT very much).

To implement this in pure C# something like RuntimeHelpers.IsKnownConstant(ReadOnlySpan<byte>) is missing.
Assuming such a method exists, then it could look like:

Example managed implementation
[MethodImpl(MethodImplOptions.AggressiveInlining)]publicstaticboolSequenceEqual(thisReadOnlySpan<byte>left,ReadOnlySpan<byte>right){nuintlen=(uint)left.Length;if((uint)right.Length!=len)returnfalse;if(/* missing piece */RuntimeHelpers.IsKnownConstant(left)){if(len>=sizeof(int)&&len<=2*sizeof(int)){refbyteleftRef=refMemoryMarshal.GetReference(left);refbyterightRef=refMemoryMarshal.GetReference(right);intl0=Unsafe.ReadUnaligned<int>(refleftRef);intr0=Unsafe.ReadUnaligned<int>(refrightRef);intl1=Unsafe.ReadUnaligned<int>(refUnsafe.Add(refleftRef,len-sizeof(int)));intr1=Unsafe.ReadUnaligned<int>(refUnsafe.Add(refrightRef,len-sizeof(int)));intt0=l0^r0;intt1=l1^r1;intt=t0|t1;returnt==0;}if(len>=sizeof(long)&&len<=2*sizeof(long)){refbyteleftRef=refMemoryMarshal.GetReference(left);refbyterightRef=refMemoryMarshal.GetReference(right);longl0=Unsafe.ReadUnaligned<long>(refleftRef);longr0=Unsafe.ReadUnaligned<long>(refrightRef);longl1=Unsafe.ReadUnaligned<long>(refUnsafe.Add(refleftRef,len-sizeof(long)));longr1=Unsafe.ReadUnaligned<long>(refUnsafe.Add(refrightRef,len-sizeof(long)));longt0=l0^r0;longt1=l1^r1;longt=t0|t1;returnt==0;}if(Vector128.IsHardwareAccelerated&&len>=(uint)Vector128<byte>.Count&&len<=2*(uint)Vector128<byte>.Count){refbyteleftRef=refMemoryMarshal.GetReference(left);refbyterightRef=refMemoryMarshal.GetReference(right);Vector128<byte>l0=Vector128.LoadUnsafe(refleftRef);Vector128<byte>r0=Vector128.LoadUnsafe(refrightRef);Vector128<byte>t0=l0^r0;Vector128<byte>l1=Vector128.LoadUnsafe(refleftRef,len-(uint)Vector128<byte>.Count);Vector128<byte>r1=Vector128.LoadUnsafe(refrightRef,len-(uint)Vector128<byte>.Count);Vector128<byte>t1=l1^r1;Vector128<byte>t=t0|t1;returnt==Vector128<byte>.Zero;}if(Vector256.IsHardwareAccelerated&&len>=(uint)Vector256<byte>.Count&&len<=2*(uint)Vector256<byte>.Count){refbyteleftRef=refMemoryMarshal.GetReference(left);refbyterightRef=refMemoryMarshal.GetReference(right);Vector256<byte>l0=Vector256.LoadUnsafe(refleftRef);Vector256<byte>r0=Vector256.LoadUnsafe(refrightRef);Vector256<byte>t0=l0^r0;Vector256<byte>l1=Vector256.LoadUnsafe(refleftRef,len-(uint)Vector256<byte>.Count);Vector256<byte>r1=Vector256.LoadUnsafe(refrightRef,len-(uint)Vector256<byte>.Count);Vector256<byte>t1=l1^r1;Vector256<byte>t=t0|t1;returnt==Vector256<byte>.Zero;}}// Current implementation of SequenceEqualCore w/o length-check (already done)returnSequenceEqualCore(left,right);}

So it's more IL and more work for the JIT to do. Are these the reasons why it's done via [Intrinsic] directly in the JIT?

@EgorBo

Copy link
Copy Markdown
MemberAuthor

@gfoidl there are two issues with the managed approach:

  1. We can only detect constant length for Span in late phases of JIT, so for IsKnownConstant case we'll have to carry a large tree through all phases. And yes, it needs extra support on JIT side for IsKnownConstant
  2. I tried to do the same for String.Equals unrolling once and hit two issues: first, this happy-path creates a huge amount of locals so for some deep callsites we can stop tracking locals because of that (or stop inlining more into the graph). Second, inliner's budget problem. See Unroll String.Equals for constant input [0..16] length #64821

}
else if (strcmp(className, "SpanHelpers") == 0)
{
if (strcmp(methodName, "SequenceEqual") == 0)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I know this is completely unrelated to the fix or changes being done, sorry... but has anyone ever tried reversing the methodName and className tests for a performance hack in the JIT itself?

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.

@IDisposablelookupNamedIntrinsics never show up in our JIT traces so we don't bother. This code is only executed for methods with [Intrinsic] attribute so for 99% of methods it doesn't kick in.

We could use here a Trie/binary search if it was a real problem

@gfoidl

Copy link
Copy Markdown
Member

@EgorBo thanks for the info, I understand.

Comment threadsrc/coreclr/jit/lower.cpp Outdated
Comment on lines +2014 to +2018
// Call LowerNode on these to create addressing modes if needed
LowerNode(l2Indir);
LowerNode(r2Indir);
LowerNode(lXor);
LowerNode(rXor);

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.

Seems like you could just make this function return the first new node you added, since the call was replaced anyway, and have "normal" lowering proceed from there.

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.

Good idea, done

@EgorBo

Copy link
Copy Markdown
MemberAuthor

@EgorBo thanks for the info, I understand.

Still, filed a PR #84002 to make it possible, so now you can use IsKnownConstant(span.Length)

Comment on lines +1957 to +1961
LIR::Use lArgUse;
LIR::Use rArgUse;
bool lFoundUse = BlockRange().TryGetUse(lArg, &lArgUse);
bool rFoundUse = BlockRange().TryGetUse(rArg, &rArgUse);
assert(lFoundUse && rFoundUse);

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.

It's a bit wasteful to go looking for the uses of this given that we know the arg they come from. E.g. you could do

Suggested change
LIR::Use lArgUse;
LIR::Use rArgUse;
bool lFoundUse = BlockRange().TryGetUse(lArg, &lArgUse);
bool rFoundUse = BlockRange().TryGetUse(rArg, &rArgUse);
assert(lFoundUse && rFoundUse);
CallArg* lArg = call->gtArgs.GetUserArgByIndex(0);
GenTree*& lArgNode = lArg->GetLateNode() == nullptr ? lArg->EarlyNodeRef() : lArg->LateNodeRef();
...
LIR::Use lArgUse(BlockRange(), &lArgNode, call);

I don't have a super strong opinion on it.

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.

Thank, will check in a follow up once SPMI is collected - want to see if it's worth the effort to improve this expansion. jit-diff utils found around 30 methods only

Comment threadsrc/coreclr/jit/lower.cpp Outdated
Co-authored-by: Jakob Botsch Nielsen <Jakob.botsch.nielsen@gmail.com>
Comment threadsrc/coreclr/jit/lower.cpp Outdated
Comment threadsrc/coreclr/jit/lower.cpp
Comment threadsrc/coreclr/jit/lower.cpp
@EgorBo

Copy link
Copy Markdown
MemberAuthor

Failures are #83655 and #80619

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

Labels

area-CodeGen-coreclrCLR JIT compiler in src/coreclr/src/jit and related components such as SuperPMI

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants

@EgorBo@gfoidl@IDisposable@jakobbotsch
, '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

Unroll SequenceEqual(ref byte, ref byte, nuint) in JIT - #83945

Merged
EgorBo merged 15 commits into
dotnet:mainfrom
EgorBo:unroll-memcmp
Mar 29, 2023
Merged

Unroll SequenceEqual(ref byte, ref byte, nuint) in JIT#83945
EgorBo merged 15 commits into
dotnet:mainfrom
EgorBo:unroll-memcmp

Conversation

@EgorBo

@EgorBoEgorBo commented Mar 26, 2023

Copy link
Copy Markdown
Member

Unroll SequenceEqual for constant length [1..15] (will add SIMD separately if this lands) for both x64 and arm64.
Example (utf8 literal):

boolTest1(ReadOnlySpan<byte>data)=>"hello world"u8.SequenceEqual(data);boolTest2(Span<byte>data)=>data.StartsWith("test"u8);

Codegen diff: https://www.diffchecker.com/E1laymuB/

Limitations

Unfortunately, it works only when a constant span (either RVA or e.g. data.Slice(0, 10)) is on the left. It happens because we use left span's Length here:

((uint)length)*size);// If this multiplication overflows, the Span we got overflows the entire address range. There's no happy outcome for this api in such a case so we choose not to take the overhead of checking.

In theory, JIT is smart enough to perform things like:

if (x==42)
{
Foo(x); // x will be replaced with 42
}

via AssertProp, but in this case it's a bit more complicated than that. Perhaps, we can assist it with IsKnowConstant. Or we can use RHS span's length instead if we think that a constant span is more likely to appear on the right side.
Works for StartsWith.

Motivation

Mainly, these comparisons in TE.

Benchmarks

[Benchmark]publicintTE_Json(){returnGetRequestType("/json"u8);}[Benchmark]publicintTE_Plaintext(){returnGetRequestType("/plaintext"u8);}publicstaticclassPaths{publicstaticReadOnlySpan<byte>Json=>"/json"u8;publicstaticReadOnlySpan<byte>Plaintext=>"/plaintext"u8;}[MethodImpl(MethodImplOptions.NoInlining)]privatestaticintGetRequestType(ReadOnlySpan<byte>path){// Simulate TE scenarioif(path.Length==10&&Paths.Plaintext.SequenceEqual(path)){return1;}elseif(path.Length==5&&Paths.Json.SequenceEqual(path)){return2;}return3;}staticbyte[]data1=newbyte[100];staticbyte[]data2=newbyte[100];[Benchmark]publicboolEquals_15(){returndata1.AsSpan(0,15).SequenceEqual(data2.AsSpan(0,15));}
MethodToolchainMean
TE_Json\runtime-base\corerun.exe2.0567 ns
TE_Json\runtime\corerun.exe0.9143 ns
TE_Plaintext\runtime-base\corerun.exe1.8862 ns
TE_Plaintext\runtime\corerun.exe1.1548 ns
Equals_15\runtime-base\corerun.exe1.6169 ns
Equals_15\runtime\corerun.exe0.5172 ns

(the difference should be bigger when SIMD is enabled)

@ghostghost assigned EgorBoMar 26, 2023
@ghostghost added the area-CodeGen-coreclr CLR JIT compiler in src/coreclr/src/jit and related components such as SuperPMI label Mar 26, 2023
@ghost

Copy link
Copy Markdown

Tagging subscribers to this area: @JulieLeeMSFT, @jakobbotsch, @kunalspathak
See info in area-owners.md if you want to be subscribed.

Issue Details

Unroll SequenceEqual for constant length [1..15] (will add SIMD separately if this lands) for both x64 and arm64.
Example (utf8 literal):

boolTest(ReadOnlySpan<byte>data){return"hello world!"u8.SequenceEqual(data);}

Codegen diff: https://www.diffchecker.com/0VOpmvMj/

Limitations

Unfortunately, it works only when a constant span (either RVA or e.g. data.Slice(0, 10)) is on the left. It happens because we use left span's Length here:

((uint)length)*size);// If this multiplication overflows, the Span we got overflows the entire address range. There's no happy outcome for this api in such a case so we choose not to take the overhead of checking.

In theory, JIT is smart enough to perform things like:

if (x==42)
{
Foo(x); // x will be replaced with 42
}

via AssertProp, but in this case it's a bit more complicated than that. Perhaps, we can assist it with IsKnowConstant. Or we can use RHS span's length instead if we think that a constant span is more likely to appear on the right side (which is likely the case for e.g. StartsWith).

Motivation

Mainly, these comparisons in TE.

Benchmarks

[Benchmark]publicintTE_Json(){returnGetRequestType("/json"u8);}[Benchmark]publicintTE_Plaintext(){returnGetRequestType("/plaintext"u8);}[MethodImpl(MethodImplOptions.NoInlining)]privatestaticintGetRequestType(ReadOnlySpan<byte>path){// Simulate TE scenarioif(path.Length==10&&Paths.Plaintext.SequenceEqual(path)){return1;}elseif(path.Length==5&&Paths.Json.SequenceEqual(path)){return2;}return3;}staticbyte[]data1=newbyte[100];staticbyte[]data2=newbyte[100];[Benchmark]publicboolEquals_15(){returndata1.AsSpan(0,15).SequenceEqual(data2.AsSpan(0,15));}
MethodToolchainMean
TE_Json\runtime-base\corerun.exe2.0567 ns
TE_Json\runtime\corerun.exe0.9143 ns
TE_Plaintext\runtime-base\corerun.exe1.8862 ns
TE_Plaintext\runtime\corerun.exe1.1548 ns
Equals_15\runtime-base\corerun.exe1.6169 ns
Equals_15\runtime\corerun.exe0.5172 ns
Author:EgorBo
Assignees:EgorBo
Labels:

area-CodeGen-coreclr

Milestone:-

@EgorBo
EgorBo marked this pull request as ready for review March 26, 2023 21:37
@gfoidl

Copy link
Copy Markdown
Member

Can be dasm for Test1 be something like

G_M000_IG01: ;; offset=0000HG_M000_IG02: ;; offset=0000H 488B01 movrax, bword ptr [rcx] 8B5108 movedx, dword ptr [rcx+08H] 83FA0B cmpedx,117404je SHORT G_M000_IG04G_M000_IG03: ;; offset=000BH 33C0 xoreax,eax EB24 jmp SHORT G_M000_IG05G_M000_IG04: ;; offset=000FH 48BA68656C6C6F20776F movrdx,0x6F77206F6C6C6568483310xorrdx, qword ptr [rax] 48B96C6F20776F726C64 movrcx,0x646C726F77206F6C48334803xorrcx, qword ptr [rax+03H] 480BD1 orrdx,rcx 0F94C0 sete al 0FB6C0 movzxrax,alG_M000_IG05: ;; offset=0033H C3 ret; Total bytes of code 52

?
So that the "constant" LHS (here "hello world"u8) is read as long-constant instead of loaded from memory. Save two memory loads.

The assembly above is produced by this simple C# approach.

Code
usingSystem.Runtime.CompilerServices;usingSystem.Runtime.InteropServices;ReadOnlySpan<byte>test="hello world"u8;Console.WriteLine(Test1(test));
#if !DEBUGfor(inti=0;i<100;++i){if(i%10==0)Thread.Sleep(100);_=Test1(test);}
#endif
staticboolTest1(ReadOnlySpan<byte>data)=>"hello world"u8.FastSequenceEqual(data);internalstaticclassMySpanExtensions{[MethodImpl(MethodImplOptions.AggressiveInlining)]publicstaticboolFastSequenceEqual(thisReadOnlySpan<byte>left,ReadOnlySpan<byte>right){nuintlen=(uint)left.Length;if((uint)right.Length!=len)returnfalse;if(len>=sizeof(long)&&len<=2*sizeof(long)){refbyteleftRef=refMemoryMarshal.GetReference(left);refbyterightRef=refMemoryMarshal.GetReference(right);longl0=Unsafe.ReadUnaligned<long>(refleftRef);longl1=Unsafe.ReadUnaligned<long>(refUnsafe.Add(refleftRef,len-sizeof(long)));longr0=Unsafe.ReadUnaligned<long>(refrightRef);longr1=Unsafe.ReadUnaligned<long>(refUnsafe.Add(refrightRef,len-sizeof(long)));longt0=l0^r0;longt1=l1^r1;longt=t0|t1;returnt==0;}thrownewNotSupportedException();}}

PS: the XOR-trick here is 👍🏻

Comment threadsrc/coreclr/jit/lower.cpp Outdated
Comment on lines +1967 to +2006
// We're going to emit something like the following:
//
// bool result = ((*(int*)leftArg ^ *(int*)rightArg) |
// (*(int*)(leftArg + 1) ^ *((int*)(rightArg + 1)))) == 0;
//
// ^ in the given example we unroll for length=5
//
// In IR:
//
// * EQ int
// +--* OR int
// | +--* XOR int
// | | +--* IND int
// | | | \--* LCL_VAR byref V1
// | | \--* IND int
// | | \--* LCL_VAR byref V2
// | \--* XOR int
// | +--* IND int
// | | \--* ADD byref
// | | +--* LCL_VAR byref V1
// | | \--* CNS_INT int 1
// | \--* IND int
// | \--* ADD byref
// | +--* LCL_VAR byref V2
// | \--* CNS_INT int 1
// \--* CNS_INT int 0
//
GenTree* l1Indir = comp->gtNewIndir(loadType, lArgUse.Def());
GenTree* r1Indir = comp->gtNewIndir(loadType, rArgUse.Def());
GenTree* lXor = comp->gtNewOperNode(GT_XOR, TYP_INT, l1Indir, r1Indir);
GenTree* l2Offs = comp->gtNewIconNode(cnsSize - loadWidth);
GenTree* l2AddOffs = comp->gtNewOperNode(GT_ADD, lArg->TypeGet(), lArgClone, l2Offs);
GenTree* l2Indir = comp->gtNewIndir(loadType, l2AddOffs);
GenTree* r2Offs = comp->gtCloneExpr(l2Offs); // offset is the same
GenTree* r2AddOffs = comp->gtNewOperNode(GT_ADD, rArg->TypeGet(), rArgClone, r2Offs);
GenTree* r2Indir = comp->gtNewIndir(loadType, r2AddOffs);
GenTree* rXor = comp->gtNewOperNode(GT_XOR, TYP_INT, l2Indir, r2Indir);
GenTree* resultOr = comp->gtNewOperNode(GT_OR, TYP_INT, lXor, rXor);
GenTree* zeroCns = comp->gtNewIconNode(0);
result = comp->gtNewOperNode(GT_EQ, TYP_INT, resultOr, zeroCns);

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.

Are you sure this is better than the naive version for ARM64 with CCMPs? What is the ARM64 codegen diff if you create AND(EQ(IND, IND), EQ(IND, IND)) instead?

@EgorBoEgorBoMar 27, 2023

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.

The current codegen is (comparing 16 bytes):

F9400001 ldr x1,[x0]F9400043 ldr x3,[x2]CA030021 eor x1, x1, x3F9400000 ldr x0,[x0]F9400042 ldr x2,[x2]CA020000 eor x0, x0, x2AA000020 orr x0, x1, x0F100001F cmp x0, #09A9F17E0 cset x0, eq

cmp version presumably needs ifConversion path? Because here is what I see when I follow your suggestion:

F9400001 ldr x1,[x0]F9400043 ldr x3,[x2]EB03003F cmp x1, x39A9F17E1 cset x1, eqF9400000 ldr x0,[x0]F9400042 ldr x2,[x2]EB02001F cmp x0, x29A9F17E0 cset x0, eqEA00003F tst x1, x09A9F07E0 cset x0, ne

so we need to either do this opt in codegen or earlier for that. For me arm64 codegen doesn't look too bad, it's still better than not unrolled.

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.

No, this should not need if-conversion. Are you calling lowering on these new nodes? I would expect TryLowerAndOrToCCMP to kick in and the ARM64 "naive" IR to result in ldr, ldr, ldr, ldr, cmp, ccmp, cset.

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.

No, this should not need if-conversion. Are you calling lowering on these new nodes? I would expect TryLowerAndOrToCCMP to kick in and the ARM64 "naive" IR to result in ldr, ldr, ldr, ldr, cmp, ccmp, cset.

still doesn't want to convert to CCMP, IsInvariantInRange check fails, presumably because of IND side effects. Still, I think the current version is better than non-unrolled

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.

You should be able to insert it in the right order so that there is no interference, e.g. probably

t0 = IND
t1 = IND
t2 = IND
t3 = IND
t4 = EQ(t0, t1)
t5 = EQ(t2, t3)
t6 = AND(t4, t5)

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.

Although it's a bit odd there would be interference even with

t0 =IND
t1 =IND
t2 =EQ(t0, t1)
t3 =IND
t4 =IND
t5 =EQ(t3, t4)
t6 =AND(t2, t5)

Probably something I should take a look at.

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.

but just in case I pushed a change to move all IND nodes to the front

@gfoidl

Copy link
Copy Markdown
Member

I have a general question: why needs this to be done in JIT and not in managed code? Is it about throughput and / or IL-size?
In some areas there's native -> managed, here it's the opposite.
This is not a rant, just a question out of curiosity. And it makes contributing a bit harder (at least for me, as I don't know the internals of JIT very much).

To implement this in pure C# something like RuntimeHelpers.IsKnownConstant(ReadOnlySpan<byte>) is missing.
Assuming such a method exists, then it could look like:

Example managed implementation
[MethodImpl(MethodImplOptions.AggressiveInlining)]publicstaticboolSequenceEqual(thisReadOnlySpan<byte>left,ReadOnlySpan<byte>right){nuintlen=(uint)left.Length;if((uint)right.Length!=len)returnfalse;if(/* missing piece */RuntimeHelpers.IsKnownConstant(left)){if(len>=sizeof(int)&&len<=2*sizeof(int)){refbyteleftRef=refMemoryMarshal.GetReference(left);refbyterightRef=refMemoryMarshal.GetReference(right);intl0=Unsafe.ReadUnaligned<int>(refleftRef);intr0=Unsafe.ReadUnaligned<int>(refrightRef);intl1=Unsafe.ReadUnaligned<int>(refUnsafe.Add(refleftRef,len-sizeof(int)));intr1=Unsafe.ReadUnaligned<int>(refUnsafe.Add(refrightRef,len-sizeof(int)));intt0=l0^r0;intt1=l1^r1;intt=t0|t1;returnt==0;}if(len>=sizeof(long)&&len<=2*sizeof(long)){refbyteleftRef=refMemoryMarshal.GetReference(left);refbyterightRef=refMemoryMarshal.GetReference(right);longl0=Unsafe.ReadUnaligned<long>(refleftRef);longr0=Unsafe.ReadUnaligned<long>(refrightRef);longl1=Unsafe.ReadUnaligned<long>(refUnsafe.Add(refleftRef,len-sizeof(long)));longr1=Unsafe.ReadUnaligned<long>(refUnsafe.Add(refrightRef,len-sizeof(long)));longt0=l0^r0;longt1=l1^r1;longt=t0|t1;returnt==0;}if(Vector128.IsHardwareAccelerated&&len>=(uint)Vector128<byte>.Count&&len<=2*(uint)Vector128<byte>.Count){refbyteleftRef=refMemoryMarshal.GetReference(left);refbyterightRef=refMemoryMarshal.GetReference(right);Vector128<byte>l0=Vector128.LoadUnsafe(refleftRef);Vector128<byte>r0=Vector128.LoadUnsafe(refrightRef);Vector128<byte>t0=l0^r0;Vector128<byte>l1=Vector128.LoadUnsafe(refleftRef,len-(uint)Vector128<byte>.Count);Vector128<byte>r1=Vector128.LoadUnsafe(refrightRef,len-(uint)Vector128<byte>.Count);Vector128<byte>t1=l1^r1;Vector128<byte>t=t0|t1;returnt==Vector128<byte>.Zero;}if(Vector256.IsHardwareAccelerated&&len>=(uint)Vector256<byte>.Count&&len<=2*(uint)Vector256<byte>.Count){refbyteleftRef=refMemoryMarshal.GetReference(left);refbyterightRef=refMemoryMarshal.GetReference(right);Vector256<byte>l0=Vector256.LoadUnsafe(refleftRef);Vector256<byte>r0=Vector256.LoadUnsafe(refrightRef);Vector256<byte>t0=l0^r0;Vector256<byte>l1=Vector256.LoadUnsafe(refleftRef,len-(uint)Vector256<byte>.Count);Vector256<byte>r1=Vector256.LoadUnsafe(refrightRef,len-(uint)Vector256<byte>.Count);Vector256<byte>t1=l1^r1;Vector256<byte>t=t0|t1;returnt==Vector256<byte>.Zero;}}// Current implementation of SequenceEqualCore w/o length-check (already done)returnSequenceEqualCore(left,right);}

So it's more IL and more work for the JIT to do. Are these the reasons why it's done via [Intrinsic] directly in the JIT?

@EgorBo

Copy link
Copy Markdown
MemberAuthor

@gfoidl there are two issues with the managed approach:

  1. We can only detect constant length for Span in late phases of JIT, so for IsKnownConstant case we'll have to carry a large tree through all phases. And yes, it needs extra support on JIT side for IsKnownConstant
  2. I tried to do the same for String.Equals unrolling once and hit two issues: first, this happy-path creates a huge amount of locals so for some deep callsites we can stop tracking locals because of that (or stop inlining more into the graph). Second, inliner's budget problem. See Unroll String.Equals for constant input [0..16] length #64821

}
else if (strcmp(className, "SpanHelpers") == 0)
{
if (strcmp(methodName, "SequenceEqual") == 0)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I know this is completely unrelated to the fix or changes being done, sorry... but has anyone ever tried reversing the methodName and className tests for a performance hack in the JIT itself?

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.

@IDisposablelookupNamedIntrinsics never show up in our JIT traces so we don't bother. This code is only executed for methods with [Intrinsic] attribute so for 99% of methods it doesn't kick in.

We could use here a Trie/binary search if it was a real problem

@gfoidl

Copy link
Copy Markdown
Member

@EgorBo thanks for the info, I understand.

Comment threadsrc/coreclr/jit/lower.cpp Outdated
Comment on lines +2014 to +2018
// Call LowerNode on these to create addressing modes if needed
LowerNode(l2Indir);
LowerNode(r2Indir);
LowerNode(lXor);
LowerNode(rXor);

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.

Seems like you could just make this function return the first new node you added, since the call was replaced anyway, and have "normal" lowering proceed from there.

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.

Good idea, done

@EgorBo

Copy link
Copy Markdown
MemberAuthor

@EgorBo thanks for the info, I understand.

Still, filed a PR #84002 to make it possible, so now you can use IsKnownConstant(span.Length)

Comment on lines +1957 to +1961
LIR::Use lArgUse;
LIR::Use rArgUse;
bool lFoundUse = BlockRange().TryGetUse(lArg, &lArgUse);
bool rFoundUse = BlockRange().TryGetUse(rArg, &rArgUse);
assert(lFoundUse && rFoundUse);

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.

It's a bit wasteful to go looking for the uses of this given that we know the arg they come from. E.g. you could do

Suggested change
LIR::Use lArgUse;
LIR::Use rArgUse;
bool lFoundUse = BlockRange().TryGetUse(lArg, &lArgUse);
bool rFoundUse = BlockRange().TryGetUse(rArg, &rArgUse);
assert(lFoundUse && rFoundUse);
CallArg* lArg = call->gtArgs.GetUserArgByIndex(0);
GenTree*& lArgNode = lArg->GetLateNode() == nullptr ? lArg->EarlyNodeRef() : lArg->LateNodeRef();
...
LIR::Use lArgUse(BlockRange(), &lArgNode, call);

I don't have a super strong opinion on it.

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.

Thank, will check in a follow up once SPMI is collected - want to see if it's worth the effort to improve this expansion. jit-diff utils found around 30 methods only

Comment threadsrc/coreclr/jit/lower.cpp Outdated
Co-authored-by: Jakob Botsch Nielsen <Jakob.botsch.nielsen@gmail.com>
Comment threadsrc/coreclr/jit/lower.cpp Outdated
Comment threadsrc/coreclr/jit/lower.cpp
Comment threadsrc/coreclr/jit/lower.cpp
@EgorBo

Copy link
Copy Markdown
MemberAuthor

Failures are #83655 and #80619

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

Labels

area-CodeGen-coreclrCLR JIT compiler in src/coreclr/src/jit and related components such as SuperPMI

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants

@EgorBo@gfoidl@IDisposable@jakobbotsch