Skip to content

ReadyToRun: box T; isinst X is not folded for exact value-type instantiations, so R2R code allocates #135892

Closed

Description

@lewing

Description

When crossgen2 compiles an exact value-type instantiation of a generic method that type-tests its argument with is (IL box !!T; isinst X; brtrue), the box and cast are not folded away. The R2R code allocates a box and calls the cast helper for every check. The runtime JIT and Mono AOT fold the same IL to constants.

This matters most on CoreCLR WebAssembly, where R2R code is final (there is no tier-up to replace it). MemoryExtensions.IndexOfAnyInRange<T> / IndexOfAnyExceptInRange<T> use this pattern (lowInclusive is byte or sbyte, is short or ushort or char, ...). On browser-wasm R2R composite, span.IndexOfAnyInRange('!', '#') on a ReadOnlySpan<char> allocates 48 bytes per call and costs ~85 ns at 16–64 chars, versus ~11 ns for comparable vectorized searches (IndexOf, IndexOfAny) that don't go through this pattern.

Repro

using System;
using System.Runtime.CompilerServices;

public static class R
{
    [MethodImpl(MethodImplOptions.AggressiveInlining)]
    public static int Same<T>(T low)
    {
        if (low is byte or sbyte) return 1;
        if (low is short or ushort or char) return 2;
        return 3;
    }

    [MethodImpl(MethodImplOptions.NoInlining)]
    public static int CallSame(char c) => Same(c);

    [MethodImpl(MethodImplOptions.NoInlining)]
    public static int CallCoreLib(ReadOnlySpan<char> s) => s.IndexOfAnyInRange('!', '#');
}
crossgen2 --targetos:osx --targetarch:arm64 -O [--opt-cross-module:*] -r:<framework>/*.dll boxrepro.dll --out:boxrepro.r2r \
  --codegenopt:JitDisasm=R:Call* --codegenopt:JitStdOutFile=disasm.txt

Results (crossgen2 12.0.0-alpha.1.26480.103; the same for --targetos:browser/wasi --targetarch:wasm):

Method Mode Box allocations (READYTORUN_NEW / NEWFAST) ISINSTANCEOF helper calls
CallSame (same-assembly inlinee) default 4 3
CallCoreLib default 0 (not inlined, calls IndexOfAnyInRange<char>) 0
CallCoreLib --opt-cross-module:* 4 3 (IndexOfAnyInRange<char> inlined)
  • The inlined code also keeps the dead byte branch, including its Unsafe.BitCast<char, byte> → ThrowNotSupportedException path.
  • Composite with --inputbubble behaves the same.
  • The IL is the canonical box !!T; isinst System.Byte; brtrue.s shape handled by impBoxPatternMatch.

Root cause (updated)

An earlier version of this issue said crossgen's compareTypesForCast has no R2R-specific behavior. That was wrong. compareTypesForCast in src/coreclr/tools/Common/JitInterface/CorInfoImpl.cs ends with an #if READYTORUN block that reports every MustNot back to the JIT as May:

#if READYTORUN
            // In R2R it is a breaking change for a previously positive
            // cast to become negative, but not for a previously negative
            // cast to become positive. So in R2R a negative result is
            // always reported back as May.
            if (result == TypeCompareState.MustNot)
            {
                result = TypeCompareState.May;
            }
#endif

impBoxPatternMatch only folds BOX; ISINST to null/false on MustNot, so every negative check keeps its box and helper call. Positive checks (Must) still fold: for T=char the is char check folds, while the is byte / is short checks before it don't. Generics, inlining and the version bubble play no part; a non-generic (object)c is byte behaves the same way.

Expected

box; isinst; br with an exact value type folds to a constant, as it does under the runtime JIT. No allocation, and the dead branches are removed.

Configuration

  • crossgen2 12.0.0-alpha.1.26480.103 (also in-tree crossgen2 from main @ 5891955)
  • Targets checked: osx-arm64, browser-wasm, wasi-wasm

Note

This issue was drafted with GitHub Copilot assistance.

Activity

  1. dotnet-policy-service commented on Oct 15, 2026

    @dotnet-policy-service
    Contributor

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

  2. dotnet-policy-service commented on Oct 15, 2026

    @dotnet-policy-service
    Contributor

    Tagging subscribers to 'arch-wasm': @lewing, @pavelsavara
    See info in area-owners.md if you want to be subscribed.

  3. lewing commented on Oct 15, 2026

    @lewing
    MemberAuthor

    Possible managed workaround while the JIT side is investigated

    The only CoreLib hot paths that use this pattern on a generic T are the four range APIs in MemoryExtensions.cs: IndexOfAnyInRange, IndexOfAnyExceptInRange, LastIndexOfAnyInRange and LastIndexOfAnyExceptInRange. Between them they have 16 lowInclusive is byte or sbyte / is short or ushort or char / is int or uint / is long or ulong / nint/nuint checks. The dispatch has been there since the APIs were added in #76803. The other is <primitive> hits in CoreLib are Debug.Asserts or real object type tests.

    Switching those checks to the usual CoreLib form, typeof(T) == typeof(byte) || typeof(T) == typeof(sbyte) and so on, folds correctly under crossgen2. The same repro with a typeof variant (crossgen2 12.0.0-alpha.1.26480.103, -O):

    [MethodImpl(MethodImplOptions.AggressiveInlining)]
    public static int SameTypeof<T>(T low)
    {
        if (typeof(T) == typeof(byte) || typeof(T) == typeof(sbyte)) return 1;
        if (typeof(T) == typeof(short) || typeof(T) == typeof(ushort) || typeof(T) == typeof(char)) return 2;
        return 3;
    }
    
    [MethodImpl(MethodImplOptions.NoInlining)]
    public static int CallSameTypeof(char c) => SameTypeof(c);
    Method Target Box allocations ISINSTANCEOF calls Code size
    CallSame (is byte or sbyte ...) browser-wasm 4 3 415 B
    CallSameTypeof (typeof(T) == ...) browser-wasm 0 0 23 B
    CallSame osx-arm64 4 3 180 B
    CallSameTypeof osx-arm64 0 0 20 B

    For these APIs the two forms are equivalent. T : IComparable<T> means a T value whose runtime type is byte implies T == byte: no other T satisfying the constraint can hold a boxed byte, since object and Nullable<byte> don't implement the matching IComparable<T>. So the change shouldn't alter behavior. It would remove the 48 B/call allocation and ~75 ns overhead measured for ReadOnlySpan<char>.IndexOfAnyInRange on CoreCLR browser-wasm R2R composite.

    A JIT-side fix is still worth pursuing: user code that dispatches with x is int on a generic T hits the same R2R boxing, and on WebAssembly there's no tier-up to recover. We're looking at that separately (checked JIT + JitDump to find where the fold bails in R2R).

    Note

    This comment was drafted with GitHub Copilot assistance.

  4. lewing commented on Oct 15, 2026

    @lewing
    MemberAuthor

    Update: the root cause is in crossgen2's JIT-EE layer, not the JIT. I've updated the issue description accordingly. compareTypesForCast (CorInfoImpl.cs) downgrades every MustNot to May under READYTORUN, so impBoxPatternMatch never folds negative box; isinst checks in R2R code.

    A fix is in progress. It keeps MustNot when the source is a non-Nullable value type and the target is neither an interface nor System.Enum, and neither type uses type equivalence. A boxed value type is sealed, so its non-interface cast targets (itself, ValueType, Object) can't change between versions. With that change, the repro's CallSame compiles to the same constant return as the typeof variant (osx-arm64: 180 → 20 B; browser-wasm: 415 → 23 B; no allocation or cast helper calls).

    If that lands, the managed typeof(T) == workaround suggested above shouldn't be needed for the *InRange APIs.

    Note

    This comment was drafted with GitHub Copilot assistance.

  5. lewing commented on Oct 16, 2026

    @lewing
    MemberAuthor

    fix is #135899

  6. added this to the 12.0.0 milestone on Oct 16, 2026
  7. self-assigned this
    on Oct 16, 2026
  8. added
    arch-wasmWebAssembly architecture
    os-wasiRelated to WASI variant of arch-wasm
    os-browserBrowser variant of arch-wasm
    and removed
    untriagedNew issue has not been triaged by the area owner
    arch-wasmWebAssembly architecture
    on Oct 16, 2026
  9. dotnet-policy-service commented on Oct 16, 2026

    @dotnet-policy-service
    Contributor

    Tagging subscribers to 'arch-wasm': @lewing, @pavelsavara
    See info in area-owners.md if you want to be subscribed.

  10. dotnet-policy-service commented on Oct 16, 2026

    @dotnet-policy-service
    Contributor

    Tagging subscribers to 'os-ios': @vitek-karas, @kotlarmilos, @steveisok, @akoeplinger
    See info in area-owners.md if you want to be subscribed.

  11. dotnet-policy-service commented on Oct 16, 2026

    @dotnet-policy-service
    Contributor

    Tagging subscribers to 'os-maccatalyst': @vitek-karas, @kotlarmilos, @steveisok, @akoeplinger
    See info in area-owners.md if you want to be subscribed.

  12. dotnet-policy-service commented on Oct 16, 2026

    @dotnet-policy-service
    Contributor

    Tagging subscribers to 'os-tvos': @vitek-karas, @kotlarmilos, @steveisok, @akoeplinger
    See info in area-owners.md if you want to be subscribed.

  13. added a commit that references this issue on Oct 16, 2026
    d770a24
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

Type

No type

Projects

  • Status
    No status

Milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions