Repository navigation
ReadyToRun: box T; isinst X is not folded for exact value-type instantiations, so R2R code allocates #135892
Description
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
bytebranch, including itsUnsafe.BitCast<char, byte>→ThrowNotSupportedExceptionpath. - Composite with
--inputbubblebehaves the same. - The IL is the canonical
box !!T; isinst System.Byte; brtrue.sshape handled byimpBoxPatternMatch.
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;
}
#endifimpBoxPatternMatch 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.
Metadata
Metadata
Assignees
Labels
Type
Projects
- StatusShow more project fieldsNo status
Activity
dotnet-policy-service commentedon Oct 15, 2026
Tagging subscribers to this area: @dotnet/crossgen-contrib
See info in area-owners.md if you want to be subscribed.
dotnet-policy-service commentedon Oct 15, 2026
Tagging subscribers to 'arch-wasm': @lewing, @pavelsavara
See info in area-owners.md if you want to be subscribed.
lewing commentedon Oct 15, 2026
Possible managed workaround while the JIT side is investigated
The only CoreLib hot paths that use this pattern on a generic
Tare the four range APIs inMemoryExtensions.cs:IndexOfAnyInRange,IndexOfAnyExceptInRange,LastIndexOfAnyInRangeandLastIndexOfAnyExceptInRange. Between them they have 16lowInclusive is byte or sbyte/is short or ushort or char/is int or uint/is long or ulong/nint/nuintchecks. The dispatch has been there since the APIs were added in #76803. The otheris <primitive>hits in CoreLib areDebug.Asserts or realobjecttype 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 atypeofvariant (crossgen2 12.0.0-alpha.1.26480.103,-O):ISINSTANCEOFcallsCallSame(is byte or sbyte ...)CallSameTypeof(typeof(T) == ...)CallSameCallSameTypeofFor these APIs the two forms are equivalent.
T : IComparable<T>means aTvalue whose runtime type isbyteimpliesT == byte: no otherTsatisfying the constraint can hold a boxedbyte, sinceobjectandNullable<byte>don't implement the matchingIComparable<T>. So the change shouldn't alter behavior. It would remove the 48 B/call allocation and ~75 ns overhead measured forReadOnlySpan<char>.IndexOfAnyInRangeon CoreCLR browser-wasm R2R composite.A JIT-side fix is still worth pursuing: user code that dispatches with
x is inton a genericThits 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.
lewing commentedon Oct 15, 2026
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 everyMustNottoMayunderREADYTORUN, soimpBoxPatternMatchnever folds negativebox; isinstchecks in R2R code.A fix is in progress. It keeps
MustNotwhen the source is a non-Nullablevalue type and the target is neither an interface norSystem.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'sCallSamecompiles to the same constant return as thetypeofvariant (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*InRangeAPIs.Note
This comment was drafted with GitHub Copilot assistance.
lewing commentedon Oct 16, 2026
fix is #135899
dotnet-policy-service commentedon Oct 16, 2026
Tagging subscribers to 'arch-wasm': @lewing, @pavelsavara
See info in area-owners.md if you want to be subscribed.
dotnet-policy-service commentedon Oct 16, 2026
Tagging subscribers to 'os-ios': @vitek-karas, @kotlarmilos, @steveisok, @akoeplinger
See info in area-owners.md if you want to be subscribed.
dotnet-policy-service commentedon Oct 16, 2026
Tagging subscribers to 'os-maccatalyst': @vitek-karas, @kotlarmilos, @steveisok, @akoeplinger
See info in area-owners.md if you want to be subscribed.
dotnet-policy-service commentedon Oct 16, 2026
Tagging subscribers to 'os-tvos': @vitek-karas, @kotlarmilos, @steveisok, @akoeplinger
See info in area-owners.md if you want to be subscribed.
Keep negative cast results in R2R when they can't change across versi…