Repository navigation
JIT: x.SequenceEqual(x) is only folded to true when the length is *not* a constant #134431
Description
Compiler::optVNBasedFoldExpr_Call_Memcmp (src/coreclr/jit/assertionprop.cpp) already knows that a SpanHelpers.SequenceEqual(p, q, n) whose two data arguments share a conservative value number must return true — the data is never read, so the conclusion holds for every n. That test, however, sits inside the !vnStore->IsVNIntegralConstant(lenVN, &len) bail-out branch, so it is only reachable when the length is not a compile-time constant.
For the constant-length shape — span.Slice(0, N) compared against itself, and the StartsWith/EndsWith/Equals overloads that lower onto the same intrinsic after inlining — control instead falls through to the GetImmutableDataFromAddress path, which fails for an ordinary runtime buffer and bails out. The whole comparison survives into the generated code: an unrolled SIMD self-compare for small N, and a genuine out-of-line call to System.SpanHelpers:SequenceEqual once N exceeds lowering's unroll threshold.
Moving the equal-VN test above the constant-length test makes the existing fold apply for any length. The ordering looks incidental rather than deliberate: the block was introduced in its current position by #121985, whose discussion does not touch the constant-length case.
Minimal repro
using System;
using System.Runtime.CompilerServices;
public static class Program
{
[MethodImpl(MethodImplOptions.NoInlining)]
public static bool SelfConst32(ReadOnlySpan<byte> input)
{
ReadOnlySpan<byte> prefix = input.Slice(0, 32);
return prefix.SequenceEqual(prefix); // constant length -> NOT folded
}
[MethodImpl(MethodImplOptions.NoInlining)]
public static bool SelfConst1000(ReadOnlySpan<byte> input)
{
ReadOnlySpan<byte> prefix = input.Slice(0, 1000);
return prefix.SequenceEqual(prefix); // constant length -> keeps an O(n) helper call
}
[MethodImpl(MethodImplOptions.NoInlining)]
public static bool SelfVar(ReadOnlySpan<byte> input, int length)
{
ReadOnlySpan<byte> prefix = input.Slice(0, length);
return prefix.SequenceEqual(prefix); // non-constant length -> already folded to `true`
}
// Negative control: two genuinely different spans must still be compared.
[MethodImpl(MethodImplOptions.NoInlining)]
public static bool TwoConst8(ReadOnlySpan<byte> a, ReadOnlySpan<byte> b)
{
return a.Slice(0, 8).SequenceEqual(b.Slice(0, 8));
}
public static int Main()
{
byte[] big = new byte[2048];
byte[] other = new byte[2048];
other[3] = 1;
ReadOnlySpan<byte> s = big;
ReadOnlySpan<byte> o = other;
int acc = 0;
for (int i = 0; i < 40; i++)
{
acc += SelfConst32(s) ? 1 : 0;
acc += SelfConst1000(s) ? 1 : 0;
acc += SelfVar(s, 32) ? 1 : 0;
acc += TwoConst8(s, s) ? 1 : 0; // equal contents -> true
acc += TwoConst8(s, o) ? 1 : 0; // differing contents -> false
}
Console.WriteLine($"acc={acc}");
return 100;
}
}Run with DOTNET_TieredCompilation=0 DOTNET_ReadyToRun=0 to see FullOpts codegen.
Current codegen
Windows x64, Release JIT, FullOpts:
; Program:SelfConst32(System.ReadOnlySpan`1[byte]):bool ; 49 bytes
cmp dword ptr [rcx+0x08], 32
jl SHORT G_M000_IG04
mov rax, bword ptr [rcx]
vmovups ymm0, ymmword ptr [rax]
vpcmpnequq k1, ymm0, ymmword ptr [rax] ; compares [rax] with itself
kortestb k1, k1
sete al
movzx rax, al
; Program:SelfConst1000(System.ReadOnlySpan`1[byte]):bool ; 44 bytes
mov rdx, bword ptr [rcx]
mov rcx, rdx ; both pointers are the same value
mov r8d, 0x3E8
call [System.SpanHelpers:SequenceEqual(byref,byref,nuint):bool]The non-constant-length sibling SelfVar already folds today (26 bytes, mov eax, 1).
Expected codegen
The same result the JIT already produces for the non-constant-length shape — the bounds check from Slice remains, the comparison disappears:
; Program:SelfConst32(System.ReadOnlySpan`1[byte]):bool ; 27 bytes
cmp dword ptr [rcx+0x08], 32
jl SHORT G_M000_IG04
mov eax, 1
; Program:SelfConst1000(System.ReadOnlySpan`1[byte]):bool ; 30 bytes
cmp dword ptr [rcx+0x08], 0x3E8
jl SHORT G_M000_IG04
mov eax, 1Impact
A prototype that hoists the existing equal-VN block above the constant-length test (+10/−9 lines in one file, the moved block byte-for-byte unchanged) produces:
| Method | Base | Expected | Delta | Note |
|---|---|---|---|---|
SelfConst32 (len 32) |
49 | 27 | −22 (−44.9%) | drops the AVX-512 self-compare; matches a hand-written return true |
| length 8 | 37 | 27 | −10 (−27.0%) | drops an 8-byte load + cmp + sete/movzx |
SelfConst1000 (len 1000) |
44 | 30 | −14 (−31.8%) | removes the call [System.SpanHelpers:SequenceEqual] plus 3 argument-setup instructions |
SelfVar (non-constant len) |
26 | 26 | 0 | control — already folded |
TwoConst8(a, b) (two spans) |
46 | 46 | 0 | negative control — a genuine two-buffer compare is untouched |
Managed behaviour is unchanged: the repro above prints acc=160 and exits 100 under both the unpatched and patched JIT, and an out-of-range Slice(0, 32) still throws ArgumentOutOfRangeException, since that bounds check is upstream of the intrinsic.
SuperPMI asmdiffs, windows/x64, Release, default collection set (12 collections, 2,788,184 contexts):
- Only
libraries_tests_no_tiered_compilation.runshows diffs: 34 contexts, all improved, 0 regressed, −960 bytes, no new missing contexts. - Largest wins:
System.SpanTests.SpanTests:SameSpanSequenceEqual_Long()247→181 (−66);SameSpanStartsWith_Long()/SameSpanEndsWith_Long()161→116 (−45 each); severalSameSpan{StartsWith,EndsWith}_Byte()andSequenceEqualArrayImplicit_Long()at −35;System.Tests.StringTests:StartsWithMatch_Char()178→169. - The other 11 collections — including
libraries.pmi,libraries.crossgen2,benchmarks.run,benchmarks.run_pgo,aspnet2.run,realworld.run,coreclr_tests.run, the NativeAOT collections — are clean (0 contexts with diffs).
Measurement limitations, stated plainly:
- Every corpus win lands in test assemblies (
System.SpanTests.*,System.Tests.StringTests) — code that deliberately compares a span with itself. On this corpus the pattern does not appear in shipping library or application code, so the case rests on removing a pathological O(n) helper call at large constant lengths rather than on broad corpus size savings. - No wall-clock or PerfScore data. Both JITs measured were Release builds, so SuperPMI reported
0.0PerfScore for base and diff in every collection. This is a code-size and removed-instruction/removed-call result, not a measured speedup. tpdiff(throughput) andmetricdiffwere not run.- Only Windows x64 was built and measured; the fold itself is target-independent (assertion propagation), but the size of the removed expansion is target-specific.
Notes
- Scope:
NI_System_SpanHelpers_SequenceEqualonly. The same reasoning is not proposed here foroptVNBasedFoldExpr_Call_Memmove(Memmove(dst, dst, n)), which has no equal-address test at all — that would be a separate change. - Correctness: this is the existing
arg1VN == arg2VN ⇒ trueconclusion, not a new proof.memcmp(p, p, n) == 0holds for everynand every reachablep, and the folded form is more robust under concurrent mutation than the expansion it replaces. Side effects are preserved by the already-presentgtWrapWithSideEffects(..., GTF_ALL_EFFECT, true);Slicebounds checks are upstream of the intrinsic and were verified to still throw. - Cost when the fold does not fire: two
optConservativeNormalVNlookups and one compare, moved earlier on a path reached only for this intrinsic. When it does fire it saves work by skipping up to two 64 KBGetImmutableDataFromAddressmaterialisations. No throughput measurement was taken. - Residual risk: for
SequenceEqual(p, p, 0)the JITDUMP would report the equal-VN fold instead of the existinglength is 0fold (same result, different dump text), so a JIT test matching on the old text would need updating. No JIT test suite was run for the prototype.
Note
This issue was generated with GitHub Copilot.
Activity
dotnet-policy-service commentedon Sep 22, 2026
Tagging subscribers to this area: @JulieLeeMSFT, @jakobbotsch
See info in area-owners.md if you want to be subscribed.
JIT: Fold SequenceEqual self-comparisons with constant length (#135307)