Repository navigation
Keep negative cast results in R2R when they can't change across versions - #135899
Merged
Merged
Conversation
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: 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. |
Contributor
|
Tagging subscribers to this area: @dotnet/crossgen-contrib |
Contributor
|
Tagging subscribers to 'arch-wasm': @lewing, @pavelsavara |
Contributor
|
Tagging subscribers to 'os-tvos': @vitek-karas, @kotlarmilos, @steveisok, @akoeplinger |
Contributor
|
Tagging subscribers to 'os-ios': @vitek-karas, @kotlarmilos, @steveisok, @akoeplinger |
Contributor
|
Tagging subscribers to 'os-maccatalyst': @vitek-karas, @kotlarmilos, @steveisok, @akoeplinger |
lewing
requested review from
BrzVlad,
adamperlin,
davidwrighton and
vitek-karas
October 15, 2026 22:07
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
davidwrighton
approved these changes
Oct 15, 2026
lewing
marked this pull request as ready for review
October 15, 2026 23:42
lewing
enabled auto-merge (squash)
October 15, 2026 23:43
3 tasks
This was referenced Oct 16, 2026
Closed
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Root cause
compareTypesForCastin crossgen2 (src/coreclr/tools/Common/JitInterface/CorInfoImpl.cs) ends with an#if READYTORUNblock that reported everyMustNotback to the JIT asMay. 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 foldsbox T; isinst X; brto a constant when the answer isMustorMustNot(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:
MemoryExtensions.IndexOfAnyInRange<char>checkslowInclusive is byte or sbyteandis short or ushort or char, which allocated 48 B per call.TieredCompilation=false: affected on any platform.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
MustNotwhen neither type uses COM type equivalence and either of these holds:fromTypeis a value type, andtoTypeis not an interface and is notSystem.Enum.toType, every type infromType's base chain, and every interfacefromTypeimplements are in the version bubble, with these excluded:The policy lives in
IsNegativeCastResultVersionResilientinCorInfoImpl.ReadyToRun.cs.Why this is sound:
MustNotanswer 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.ValueType/Object) are fixed, so a new version could only make such a cast succeed by adding an interface. Interface targets stayMay, andSystem.Enumis excluded in case a struct becomes an enum.Nullable<T>sources don't need excluding: no object's exact type isNullable<T>(the box path already passesgetTypeForBox), and the VM'scompareTypesForCastdoesn't exclude them either.QueryInterface;IDynamicInterfaceCastablesources already returnMayearlier incompareTypesForCast.MustNotwith 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.compareTypesForCastrequires an exact source type before folding a negative result (importer.cppimpCastClassOrIsInstToTree,valuenum.cpp,helperexpansion.cpp).System.Objectis outside the bubble, so the bubble rule doesn't apply there.The test harness's
Crossgen2Option.InputBubblepassed--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:
CallSame(char)on osx-arm64mov w0, #2; ret)CallSame(char)on browser-wasmCallSamenow matches the equivalenttypeof(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:IndexOfAnyInRange('!', '#')onReadOnlySpan<char>, len=16IndexOfAnyInRange/IndexOfAnyExceptInRange(char)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:IndexOfAnyMultiStringdiffers only in one relocation address).IndexOfAnyInRange, both builds take the same time (~101 / ~98 ns).JitDisasmSummaryover 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:TextInfo.ToLowerAsciiInvariant898 → 574 B,TimeSpanParse.StringParser.NextNonDigit616 → 297 B, and the fourStringSearchValues.TryCreateSingleValuesThreeCharsinstantiations 764 → 445 B;DefaultInterpolatedStringHandler.AppendFormatted<bool>1146 → 429 B, andRuntimeCustomAttributeData.InitforDllImportAttribute/FieldOffsetAttribute−125 B / −105 B.Testing
Baseline build:
./build.sh clr+libs -rc release, then./build.sh clr.tools -rc releaseafter the change.New R2R test,
R2RTestSuites.CastFoldingRespectsVersionBubble(fixtureTestCases/CastFolding/CastFolding.cs). It's a theory that runs once as a per-assembly build and once as an input-bubble build:./dotnet.sh test src/coreclr/tools/aot/ILCompiler.ReadyToRun.Tests -c Debug /p:RuntimeConfiguration=Release --filter FullyQualifiedName~CastFoldingRespectsVersionBubbleCorInfoImpl.csis reverted to before this PR./dotnet.sh test src/coreclr/tools/aot/ILCompiler.ReadyToRun.Tests -c Debug /p:RuntimeConfiguration=ReleaseHow the test detects folding:
NEW_OBJECT/IS_INSTANCE_OFimports while browser-wasm uses onlyTYPE_HANDLEfixups.typeof(...)assignability casts: the type-handle imports remain even when the cast folds, so the test checks for imports of theType.IsAssignableFrom/IsAssignableTocalls 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.Objectfrom 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:RunCrossGen2=1)CompositeBuildMode=1)JIT/Generics/JIT.Generics_obox_isinst_br_nullableJIT/Generics/JIT.Generics_robox_isinst_unboxJIT/Methodical/Methodical_othersBoxPatternMatchAndSideEffectsJIT/Directed/Directed_ronullabletypes/isinst*Every run ended with
END EXECUTION - PASSEDand included R2RDump validation.JIT/Intrinsics/TypeIntrinsics_ro(folding ofIsAssignableFrom/IsAssignableTo) andJIT/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 undercorerun. 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 withDOTNET_TieredCompilation=0so the precompiled CoreLib code is never replaced:src/tests/build.sh arm64 Release -GenerateLayoutOnly)TypeIntrinsics_roandopt/Caststhrough the harnessSystem.Runtime.Tests(./dotnet.sh build -t:Test, with the new CoreLib copied into the testhost)System.Collections.Tests(same setup)Not covered:
Side finding (not addressed here)
During the investigation I found that crossgen2 doesn't inline a
where T : structgeneric instantiated overchar(S<char>). JitDump reportscannot get method info, andS<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.