Skip to content

JIT: x.SequenceEqual(x) is only folded to true when the length is *not* a constant #134431

Closed

Description

@AndyAyersMS

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, 1

Impact

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.run shows 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); several SameSpan{StartsWith,EndsWith}_Byte() and SequenceEqualArrayImplicit_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.0 PerfScore for base and diff in every collection. This is a code-size and removed-instruction/removed-call result, not a measured speedup.
  • tpdiff (throughput) and metricdiff were 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_SequenceEqual only. The same reasoning is not proposed here for optVNBasedFoldExpr_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 ⇒ true conclusion, not a new proof. memcmp(p, p, n) == 0 holds for every n and every reachable p, and the folded form is more robust under concurrent mutation than the expansion it replaces. Side effects are preserved by the already-present gtWrapWithSideEffects(..., GTF_ALL_EFFECT, true); Slice bounds checks are upstream of the intrinsic and were verified to still throw.
  • Cost when the fold does not fire: two optConservativeNormalVN lookups 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 KB GetImmutableDataFromAddress materialisations. No throughput measurement was taken.
  • Residual risk: for SequenceEqual(p, p, 0) the JITDUMP would report the equal-VN fold instead of the existing length is 0 fold (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

  1. dotnet-policy-service commented on Sep 22, 2026

    @dotnet-policy-service
    Contributor

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

  2. added this to the 12.0.0 milestone on Sep 25, 2026
  3. removed
    untriagedNew issue has not been triaged by the area owner
    on Sep 25, 2026
  4. added a commit that references this issue on Oct 7, 2026
    aa595bc
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

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

    Type

    No type

    Projects

    No projects

      Milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions