Skip to content

Keep negative cast results in R2R when they can't change across versions - #135899

Merged
lewing merged 3 commits into
dotnet:mainfrom
lewing:lewing-r2r-box-isinst-fold
Oct 16, 2026
Merged

lewing merged 3 commits into
dotnet:mainfrom
lewing:lewing-r2r-box-isinst-fold

Conversation

@lewing

@lewing lewing commented Oct 15, 2026 •

Copy link
Copy Markdown
Member

Root cause

compareTypesForCast in crossgen2 (src/coreclr/tools/Common/JitInterface/CorInfoImpl.cs) ends with an #if READYTORUN block that reported every MustNot back to the JIT as May. The reason: in R2R, a cast that fails today may succeed after a dependency outside the version bubble is updated, for example when a type gains an interface. The JIT only folds box T; isinst X; br to a constant when the answer is Must or MustNot (impBoxPatternMatch). So in R2R code every negative check kept its box allocation and cast helper call, while positive checks still folded. The runtime JIT and NativeAOT already fold these casts.

The codegen difference is the same for every crossgen2 target, but its impact depends on whether R2R code is ever replaced:

  • Where R2R code is never recompiled at a higher tier: CoreCLR on WebAssembly, iOS and tvOS. For example, MemoryExtensions.IndexOfAnyInRange<char> checks lowInclusive is byte or sbyte and is short or ushort or char, which allocated 48 B per call.
  • MacCatalyst: possibly affected the same way, depending on whether it runs with the JIT; I haven't checked its runtime configuration.
  • Apps with TieredCompilation=false: affected on any platform.
  • Elsewhere: tiered compilation recompiles hot methods with the runtime JIT, which already folds these casts, so the gain is limited to startup and cold code.

I compiled the issue's repro for ios-arm64, tvos-arm64 and maccatalyst-arm64 as well as osx-arm64 and browser-wasm. All show the same before/after codegen.

Change

In R2R, keep MustNot when neither type uses COM type equivalence and either of these holds:

  1. Value-type rule: fromType is a value type, and toType is not an interface and is not System.Enum.
  2. Version-bubble rule: toType, every type in fromType's base chain, and every interface fromType implements are in the version bubble, with these excluded:
    • arrays;
    • generic interfaces and delegates with variance;
    • COM classes.

The policy lives in IsNegativeCastResultVersionResilient in CorInfoImpl.ReadyToRun.cs.

Why this is sound:

  • No new cast logic: for these types the MustNot answer is what crossgen2 already computes and what the runtime JIT and NativeAOT already fold to. The only new claim is that the answer can't change between versions.
  • Value-type rule:
    • Value types are sealed and their base types (ValueType/Object) are fixed, so a new version could only make such a cast succeed by adding an interface. Interface targets stay May, and System.Enum is excluded in case a struct becomes an enum.
    • Nullable<T> sources don't need excluding: no object's exact type is Nullable<T> (the box path already passes getTypeForBox), and the VM's compareTypesForCast doesn't exclude them either.
  • Version-bubble rule: when every type that decides the cast versions with the compiled code, none of them can change without that code being recompiled. The exclusions cover the cases where something outside the static hierarchy decides the answer:
    • array and variant casts also depend on element types and generic arguments;
    • COM objects can be cast to interfaces through QueryInterface;
    • IDynamicInterfaceCastable sources already return May earlier in compareTypesForCast.
  • Shared generics: MustNot with a canonical source is only produced for a non-generic interface target. Under the bubble rule the source's interfaces must also be in the bubble, so no instantiation can add that interface.
  • Other JIT callers are safe: every other place the JIT uses compareTypesForCast requires an exact source type before folding a negative result (importer.cpp impCastClassOrIsInstToTree, valuenum.cpp, helperexpansion.cpp).
  • Build modes: the value-type rule applies the same way in every build mode. The bubble rule applies wherever all of the type's hierarchy is in the bubble: when compiling CoreLib, in composite and input-bubble builds, and for interface-only hierarchies in any build. For classes and structs in an app compiled on its own, System.Object is outside the bubble, so the bubble rule doesn't apply there.

The test harness's Crossgen2Option.InputBubble passed --input-bubble, which crossgen2 doesn't accept (the option is --inputbubble). No existing test used it; this PR fixes it so the new test can cover input-bubble builds.

Results

Repro from the issue, compiled with in-tree crossgen2 and JITs:

Before After
CallSame(char) on osx-arm64 180 B, 4 box allocations + 3 cast helper calls 20 B (mov w0, #2; ret)
CallSame(char) on browser-wasm 415 B 23 B

CallSame now matches the equivalent typeof(T) == version.

CoreCLR browser-wasm R2R composite (CoreLib + benchmark app, --composite --opt-cross-module:*), Node/V8 on an Apple M-series host, 5 interleaved rounds, medians. "Before" is the in-tree crossgen2 without this PR; "value-type rule only" is this PR's first commit:

Benchmark Before Value-type rule only This PR (de835bf)
IndexOfAnyInRange('!', '#') on ReadOnlySpan<char>, len=16 89.2 ns 11.8 ns 11.6 ns
same, len=1024 114.9 ns 42.7 ns 42.7 ns
Allocations per call, IndexOfAnyInRange/IndexOfAnyExceptInRange (char) 48 B 0 B 0 B
  • Other benchmarks: for the 36 others, the median ratio of this PR to before is 0.998 (range 0.96–1.13), and none differs from the value-type-rule-only build by more than 5% with non-overlapping ranges.
  • SearchValues<string> at len=1024: in the full run this benchmark is about 12 ns slower (91.1 → 103.3 ns). This is an artifact of the measurement context, not a regression:
    • Its hot methods compile to identical code with and without the change (IndexOfAnyMultiString differs only in one relocation address).
    • The gap depends on which benchmarks ran earlier in the process: run alone or after only IndexOfAnyInRange, both builds take the same time (~101 / ~98 ns).
    • Data alignment, V8 wasm inlining and tiering were ruled out. The likely cause is machine-code placement in V8, which shifts because the change shrinks unrelated methods.
  • Code size (JitDisasmSummary over the composite): 14 of 12,409 methods change size, all smaller; none grow and none are added or removed. The total is −4,927 B, compared with 11 methods and −3,980 B for the value-type rule only. Examples:
    • from the value-type rule: TextInfo.ToLowerAsciiInvariant 898 → 574 B, TimeSpanParse.StringParser.NextNonDigit 616 → 297 B, and the four StringSearchValues.TryCreateSingleValuesThreeChars instantiations 764 → 445 B;
    • added by the bubble rule: DefaultInterpolatedStringHandler.AppendFormatted<bool> 1146 → 429 B, and RuntimeCustomAttributeData.Init for DllImportAttribute/FieldOffsetAttribute −125 B / −105 B.

Testing

Baseline build: ./build.sh clr+libs -rc release, then ./build.sh clr.tools -rc release after the change.

New R2R test, R2RTestSuites.CastFoldingRespectsVersionBubble (fixture TestCases/CastFolding/CastFolding.cs). It's a theory that runs once as a per-assembly build and once as an input-bubble build:

Command Result
./dotnet.sh test src/coreclr/tools/aot/ILCompiler.ReadyToRun.Tests -c Debug /p:RuntimeConfiguration=Release --filter FullyQualifiedName~CastFoldingRespectsVersionBubble 2 passed; both fail when CorInfoImpl.cs is reverted to before this PR
Same, with any one of the bubble rule's four guards removed (target in bubble, interface list in bubble, COM, variance) fails each time
./dotnet.sh test src/coreclr/tools/aot/ILCompiler.ReadyToRun.Tests -c Debug /p:RuntimeConfiguration=Release 149 passed, 15 skipped, 0 failed

How the test detects folding:

  • Box/isinst casts: it looks for the types in import entries of any fixup kind, because x64/arm64 reference them through NEW_OBJECT/IS_INSTANCE_OF imports while browser-wasm uses only TYPE_HANDLE fixups.
  • typeof(...) assignability casts: the type-handle imports remain even when the cast folds, so the test checks for imports of the Type.IsAssignableFrom/IsAssignableTo calls instead.

I checked the fixture's imports for linux-x64 and browser-wasm with R2RDump, but didn't run the test project for those targets.

Existing runtime tests compiled with crossgen2, osx-arm64 Release. These ran with the value-type rule alone. In these builds every class or struct hierarchy includes System.Object from outside the bubble, so the bubble rule wouldn't change them. I first confirmed that the crossgen2 in Core_Root folds the repro's value-type casts. Build commands:

src/tests/build.sh arm64 Release -test:<project> /p:LibrariesConfiguration=Debug
export CORE_ROOT=artifacts/tests/coreclr/osx.arm64.Release/Tests/Core_Root RunCrossGen2=1
Test runner Relevant tests R2R per assembly (RunCrossGen2=1) R2R composite (CompositeBuildMode=1)
JIT/Generics/JIT.Generics_o box_isinst_br_nullable passed passed
JIT/Generics/JIT.Generics_ro box_isinst_unbox passed passed
JIT/Methodical/Methodical_others BoxPatternMatchAndSideEffects passed passed
JIT/Directed/Directed_ro nullabletypes/isinst* passed passed

Every run ended with END EXECUTION - PASSED and included R2RDump validation.

JIT/Intrinsics/TypeIntrinsics_ro (folding of IsAssignableFrom/IsAssignableTo) and JIT/opt/Casts (tests, shared_Casts, shared2) have no standalone runner. For those, I compiled each assembly with Core_Root's crossgen2 (-O --targetos:osx --targetarch:arm64) and ran its test methods through a small reflection harness under corerun. All passed, with per-assembly R2R only. The R2R runs JIT-compiled about half as many methods (e.g. 81 vs. 164), so the precompiled code was used.

CoreLib compiled with both rules, osx-arm64 Release. CoreLib is its own version bubble, so this is where the bubble rule applies most. I rebuilt it with ./build.sh clr.nativecorelib -rc release (which uses the updated crossgen2), then ran with DOTNET_TieredCompilation=0 so the precompiled CoreLib code is never replaced:

Tests Result
The four runtime-test runners above, on a regenerated Core_Root (src/tests/build.sh arm64 Release -GenerateLayoutOnly) all passed
TypeIntrinsics_ro and opt/Casts through the harness all passed
System.Runtime.Tests (./dotnet.sh build -t:Test, with the new CoreLib copied into the testhost) 80,039 total, 0 failed, 90 skipped
System.Collections.Tests (same setup) 33,958 total, 0 failed, 0 skipped

Not covered:

  • runtime tests on x64, arm or wasm;
  • runtime tests on a composite framework built with the bubble rule;
  • checking which individual test methods actually hit the new fold;
  • the full runtime test suite.

Side finding (not addressed here)

During the investigation I found that crossgen2 doesn't inline a where T : struct generic instantiated over char (S<char>). JitDump reports cannot get method info, and S<char> isn't precompiled either, so the call falls back to the runtime JIT. This is unrelated to this change and is addressed separately in #135906.

Resolves #135892

Note

This PR description was generated with GitHub Copilot.

lewing and others added 2 commits October 15, 2026 16:39
crossgen2's compareTypesForCast reported every MustNot as May in
ReadyToRun, so the JIT could not fold negative `box T; isinst X` checks
and kept a box allocation and cast helper call for each one (e.g.
`value is byte or sbyte` in MemoryExtensions.IndexOfAnyInRange<char>).

A boxed value type is sealed, and the only non-interface types it can
be cast to are itself and its fixed base types, so a negative result to
a non-interface, non-System.Enum target can't change across versions.
Keep MustNot in that case (excluding Nullable and type-equivalent types).

Fixes dotnet#135892

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: e57ce813-cc00-4e28-824c-2eecc4d8fb9f
Depending on the target, box and isinst reference their types through
NEW_OBJECT/IS_INSTANCE_OF imports or TYPE_HANDLE fixups (e.g. wasm only
has the latter), so check for the type name in any import. Use
non-generic cases so a shared canonical body doesn't add imports for
the same types.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: e57ce813-cc00-4e28-824c-2eecc4d8fb9f
@azure-pipelines

Copy link
Copy Markdown
Azure Pipelines:
Successfully started running 3 pipeline(s).
13 pipeline(s) were filtered out due to trigger conditions.
There may be pipelines that require an authorized user to comment /azp run to run.

@dotnet-policy-service

Copy link
Copy Markdown
Contributor

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

@lewing lewing added arch-wasm WebAssembly architecture os-ios Apple iOS os-tvos Apple tvOS labels Oct 15, 2026
@dotnet-policy-service

Copy link
Copy Markdown
Contributor

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

@lewing lewing added the os-maccatalyst MacCatalyst OS label Oct 15, 2026
@dotnet-policy-service

Copy link
Copy Markdown
Contributor

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

@dotnet-policy-service

Copy link
Copy Markdown
Contributor

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

@lewing lewing added the os-browser Browser variant of arch-wasm label Oct 15, 2026
@dotnet-policy-service

Copy link
Copy Markdown
Contributor

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

@lewing lewing added the os-wasi Related to WASI variant of arch-wasm label Oct 15, 2026
Comment thread src/coreclr/tools/Common/JitInterface/CorInfoImpl.cs Outdated
Address review feedback:
- Drop the Nullable<T> source exclusion. No object's exact type is
  Nullable<T> (the box path passes getTypeForBox), and the VM's
  compareTypesForCast doesn't exclude it.
- Also keep MustNot when toType and every type in fromType's base chain
  and interface list version with the compilation (e.g. CoreLib, composite
  and input-bubble builds). Arrays, variant targets and COM types stay May.

Move the policy into CorInfoImpl.ReadyToRun.cs. Cover both per-assembly
and input-bubble builds in the R2R test, and fix the test harness's
InputBubble option, which passed --input-bubble instead of --inputbubble.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: e57ce813-cc00-4e28-824c-2eecc4d8fb9f
@lewing lewing changed the title Fold box+isinst of value types to non-interface types in R2R Keep negative cast results in R2R when they can't change across versions Oct 15, 2026
@lewing

lewing commented Oct 16, 2026

Copy link
Copy Markdown
Member Author

/ba-g All failures are known or pre-existing: #135966 (wasm R2R TypeIntrinsics_r GC hole, fix in #135970), #135963, #135888/#135889, and two Mutex race tests that fail identically on main (build 1637356).

@lewing
lewing merged commit d770a24 into dotnet:main Oct 16, 2026
104 of 106 checks passed
@lewing
lewing deleted the lewing-r2r-box-isinst-fold branch October 16, 2026 19:34
@dotnet-milestone-bot dotnet-milestone-bot Bot added this to the 12.0-preview1 milestone Oct 19, 2026
lewing added a commit that referenced this pull request Oct 19, 2026
…s in the version bubble (#135906)

## Problem

crossgen2 neither precompiles nor inlines a generic method or type with
a `where T : struct` or `where T : unmanaged` constraint when it is
instantiated over a CoreLib primitive (for example `S<char>` or
`GS<int>.Get`) from code outside CoreLib's version bubble. The same
generic without the constraint compiles and inlines fine. When inlining
fails, the JIT dump shows:

```
INLINER: ... result 'failed this callee' reason 'cannot get method info' for 'V:CallS(char):int' calling 'V:S[char](char):int'
```

## Root cause


`ReadyToRunCompilationModuleGroupBase.ComputeInstantiationVersionsWithCode`
counts a primitive type argument as part of the generic's version bubble
only when the generic parameter has **no** type constraints at all. C#
writes `where T : struct` and `where T : unmanaged` into metadata as a
`System.ValueType` type constraint (the `unmanaged` modreq is not
visible in the type system). That makes the constraint list non-empty,
so `VersionsWithMethodBody` returns false, and two things follow:

- the instantiation is not compiled into the defining module, and
- `CorInfoImpl.getMethodInfo` → `CanInline` returns false, which the JIT
reports as "cannot get method info".

The rule dates from #1889. Its design doc already says primitives are in
the bubble "as long as the generic is not constrained on an interface or
class". `System.ValueType` is just how C# encodes `struct`: every
primitive satisfies it, and it exposes nothing an unconstrained `T`
can't already use. The runtime needs no change.
`ReadyToRunInfo::ComputeAlternateGenericLocationForR2RCode` skips
primitive arguments, so these instantiations are looked up in the
defining module, the same path the unconstrained case already uses.

## Fix

When the type argument is a primitive, ignore `System.ValueType`
constraints. Any other constraint, interface or class, still
disqualifies the instantiation, as before. The `object`/`string` branch
is unchanged.

Other changes in this PR:

- **R2R reader:** `ILCompiler.Reflection.ReadyToRun`'s
`CrossModuleInliningInfoSection` now names local inlinees that have no
compiled body of their own, such as `AggressiveInlining` helpers, using
the image metadata. Previously they showed as `<MethodDef 0x…>`. The new
test's inlining assertion needs this, and it also improves R2RDump
output.
- **Docs:**
`docs/design/features/crossgen2-compilation-structure-enhancements.md`
now states the rule.
- **Test:** new `R2RTestSuites.StructConstrainedPrimitiveInstantiations`
(`[Fact]`, fixture
`TestCases/PrimitiveInstantiations/StructConstrainedGenerics.cs`). It
asserts that `StructConstrained<int>`, `UnmanagedConstrained<int>` and
`StructConstrainedBox<int>.GetValueHashCode` are compiled, and that the
`struct`- and `unmanaged`-constrained `AggressiveInlining` helpers are
inlined.

## Impact

- **Affected:**
  - native per-assembly R2R without `--opt-cross-module`;
  - app-only composite without `--opt-cross-module` or `--inputbubble`.

These instantiations are jitted at startup instead of using R2R code,
and they miss R2R inlining.
- **Not affected, so this change is a no-op there:**
- browser-wasm defaults: per-assembly plus `--opt-cross-module:*`, used
for both app publish and the shared framework;
- composites that include the framework: browser composite, WASI and
Apple mobile.

Interface constraints still disqualify primitive instantiations by
design. The separate generic-math issue (calls to static virtual methods
such as `INumber<T>` operators that can't be resolved across the version
bubble) is tracked in #134559 and is not addressed here.

Related: #135899 (the same crossgen2 area, found while working on it).

## Validation

All runs on osx-arm64, checked:

- `./build.sh clr+libs -lc Release -c checked`: succeeded with 0
warnings and 0 errors.
- `./build.sh -s clr.r2rtests -lc Release -c checked -test`: 51 total,
**36 passed, 0 failed**, 15 skipped (target-specific tests).
- I ran the same command with only the crossgen2 change reverted.
`StructConstrainedPrimitiveInstantiations` **failed** with `Expected
compiled method 'StructConstrainedGenerics.StructConstrained<int>' not
found`, and passed again once the fix was restored.
- I also ran a standalone repro through the built crossgen2 for both
`--targetos:osx --targetarch:arm64 -O` and `--targetos:browser
--targetarch:wasm --obj-format:wasm -O`, with default args.
  - `SN<int>`, `MN<int>` and `GS<int>.Get` are now compiled.
- `CallS(char)` and `CallM(char)` now have the same code size as the
unconstrained `CallU(char)`, which shows the call is inlined.
- `IN<int>`, which has an `IEquatable<T>` constraint, is still not
compiled, as intended.

**Not run:** the CI browser-wasm R2R test leg
(`CLR_R2R_Tests_browser_wasm`), which needs a browser CoreCLR build. The
fix doesn't depend on the target, and the new test is a plain `[Fact]`,
so that leg will run it.

> [!NOTE]
> This PR description and the change were generated with GitHub Copilot.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: f5ac7e42-b3b6-4fe3-bf0b-f40970e8fae0
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

arch-wasm WebAssembly architecture area-ReadyToRun os-browser Browser variant of arch-wasm os-ios Apple iOS os-maccatalyst MacCatalyst OS os-tvos Apple tvOS os-wasi Related to WASI variant of arch-wasm

Projects

None yet

Development

Successfully merging this pull request may close these issues.

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

2 participants