Skip to content

Include unboxing stubs in r2r images - #132787

Open
BrzVlad wants to merge 1 commit into
dotnet:mainfrom
BrzVlad:feature-r2r-unbox-stubs
Open

Include unboxing stubs in r2r images#132787
BrzVlad wants to merge 1 commit into
dotnet:mainfrom
BrzVlad:feature-r2r-unbox-stubs

Conversation

@BrzVlad

@BrzVlad BrzVlad commented Aug 26, 2026

Copy link
Copy Markdown
Member

We R2R image. For this we need to create a special method that inherits from ILStubMethod. This needs to have an EmitIL implementation (for the method body) and a MethodSignature so that the method can be encoded/decoded. This new stub method will be added during dependency analysis for methods on valuetypes that can be called through an interface. These unbox stubs are included in the InstanceMethodEntryPoints table where they are encoded based on the signature (together with the other methods that not uniquely identified by a method rid). When the runtime needs to load such a stub, it will build the right signature for the unbox stub and lookup in these tables.

NativeAot already has UnboxingThunk and GenericUnboxingThunk for this purpose. These are created per generic definition and r2r inflates them as needed. They are created as method residing on an artificial BoxedValueType, which represents also the first argument they receive. UnboxingThunk simply unboxes the object and then dispatches to the target method. Given the implementation is simple, NativeAOT actually uses some custom assembly thunks for this purpose (UnboxingStubNode), which we don't yet on r2r to reduce the scope of the change. GenericUnboxingThunk handles the scenario where we need to obtain the method table instantiation from the boxed instance, so that we can pass it to the valuetype method (which expects it explicitly given it doesn't receive an object as this).

Shared generic methods are not yet handled. I believe the runtime currently expects unboxing thunks to not receive any method inst hidden arg, so additional changes might be needed there.

This bumps R2R version because an older runtime, whose SigMatchesMethodDesc doesn't check the unboxing bit, could incorrectly match a normal method with an unbox stub, due to the signatures being otherwise identical.

We want to include unboxing stubs into the R2R image to avoid their interpretation on platforms with no jit. For this we need to create a special method that inherits from ILStubMethod. This needs to have an EmitIL implementation (for the method body) and a MethodSignature so that the method can be encoded/decoded. This new stub method will be added during dependency analysis for virtual methods on valuetypes (methods that are either called through an interface or from normal virtual calls from Object). These unbox stubs are included in the `InstanceMethodEntryPoints` table where they are encoded based on the signature (together with the other methods that not uniquely identified by a method rid). When the runtime needs to load such a stub, it will build the right signature for the unbox stub and lookup in these tables.

NativeAot already has UnboxingThunk and GenericUnboxingThunk for this purpose. These are created per generic definition and r2r inflates them as needed. They are created as method residing on an artificial BoxedValueType, which represents also the first argument they receive. UnboxingThunk simply unboxes the object and then dispatches to the target method. Given the implementation is simple, NativeAOT actually uses some custom assembly thunks for this purpose (UnboxingStubNode), which we don't do yet on r2r to reduce the scope of the change. GenericUnboxingThunk handles the scenario where we need to obtain the method table instantiation from the boxed instance, so that we can pass it to the valuetype method (which expects it explicitly given it doesn't receive an object as this).

Shared generic methods are not yet handled. I believe the runtime currently expects unboxing thunks to not receive any method inst hidden arg, so additional changes might be needed there.

This bumps R2R version because an older runtime, whose SigMatchesMethodDesc doesn't check the unboxing bit, could incorrectly match a normal method with an unbox stub, due to the signatures being otherwise identical. Bumped minor version since major version was just bumped a few days ago and it seems redundant to do it again.
Copilot AI lite review requested due to automatic review settings August 26, 2026 15:08
@BrzVlad
BrzVlad requested review from MichalStrehovsky and davidwrighton and removed request for MichalStrehovsky and Copilot August 26, 2026 15:10
@BrzVlad

BrzVlad commented Aug 26, 2026

Copy link
Copy Markdown
Member Author

/azp run runtime-coreclr crossgen2 outerloop

@azure-pipelines

Copy link
Copy Markdown
Azure Pipelines:
Successfully started running 1 pipeline(s).

@hez2010

hez2010 commented Aug 26, 2026

Copy link
Copy Markdown
Contributor

With this change will we be able to remove the bail-out here?

#if READYTORUN
if (unboxingStub)
{
// We need an unboxing stub that points to an instantiated method but this is not happening in R2R.
info->detail = CORINFO_DEVIRTUALIZATION_DETAIL.CORINFO_DEVIRTUALIZATION_FAILED_CANON;
return false;
}

Comment on lines +165 to 175
#if READYTORUN
if (!implementingMethodInstantiation.Signature.IsStatic
&& canonImpl.OwningType.IsValueType
&& NodeFactory.CanPrecompileUnboxingStub(canonImpl))
{
dynamicDependencies.Add(new CombinedDependencyListEntry(factory.UnboxingStub(canonImpl), null, "Unboxing thunk for interface GVM"));
}
#endif

// Static virtuals cannot be further overridden so this is an impl use. Otherwise it's a virtual slot use.
if (implementingMethodInstantiation.Signature.IsStatic)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could we do this and put the READYTORUN logic in the preexisting READYTORUN block in GetVirtualMethodImplNode?

Suggested change
#if READYTORUN
if (!implementingMethodInstantiation.Signature.IsStatic
&& canonImpl.OwningType.IsValueType
&& NodeFactory.CanPrecompileUnboxingStub(canonImpl))
{
dynamicDependencies.Add(new CombinedDependencyListEntry(factory.UnboxingStub(canonImpl), null, "Unboxing thunk for interface GVM"));
}
#endif
// Static virtuals cannot be further overridden so this is an impl use. Otherwise it's a virtual slot use.
if (implementingMethodInstantiation.Signature.IsStatic)
// Static virtuals and methods on valuetypes cannot be further overridden so this is an impl use. Otherwise it's a virtual slot use.
if (implementingMethodInstantiation.Signature.IsStatic || implementingMethodInstantiation.OwningType.IsValueType)

/// Represents a thunk to call instance method on boxed valuetypes.
/// </summary>
private sealed partial class UnboxingThunk : ILStubMethod
private sealed class UnboxingThunk : ILStubMethod, IPrefixMangledMethod

@MichalStrehovsky MichalStrehovsky Aug 26, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note: we don't use these in native AOT except in the runtimelab branch for the WASM prototype. Native AOT hand-emits assembly instead of using this. IL based non-generic unboxing thunk is possibly less efficient than the handemitted assembly.

@davidwrighton davidwrighton left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Holistic Review

Motivation: Precompiling unboxing stubs is justified where the runtime cannot generate a usable frameless shuffle thunk, particularly on targets without runtime code generation.

Approach: The current dependency analysis is too broad because it emits a complete R2R method for every reachable unboxing stub, including common cases already handled by a much smaller runtime-generated shuffle thunk.

Summary: ⚠️ Needs Changes. Please restrict precompiled unboxing stubs to targets without runtime code generation and signatures that the runtime cannot adapt with its frameless shuffle-thunk machinery.


Detailed Findings

❌ Emission policy — Avoid precompiling stubs already handled by shuffle thunks

I don't think we should precompile every reachable unboxing stub. On targets that support runtime code generation, the common case is already handled by a much smaller frameless shuffle thunk. Emitting a complete R2R method for those cases increases image size without providing a clear benefit.

I think we should restrict precompiled unboxing stubs to two cases:

  1. The target cannot generate code at runtime. Please use the existing ReadyToRunCompilerContext.TargetAllowsRuntimeCodeGeneration property rather than duplicating the iOS, MacCatalyst, tvOS, Browser, WASI, and Wasm target checks.
  2. The runtime cannot represent the required adaptation as a frameless shuffle thunk. In particular, an instantiating unboxing stub cannot use the current shuffle machinery when adding the hidden instantiation argument requires moving or introducing an argument on the stack.

Crossgen2 should be able to approximate the second condition using its existing ArgIterator. Build the source layout as an unboxing stub, where the hidden instantiation argument is suppressed, and the destination layout as the underlying target method, where that argument is present. If the layouts have different stack-area sizes, or an argument's stack location changes, the runtime would need a framed stub and we should emit the full R2R method.

x86 must bypass this ArgIterator stack-move approximation. Its arguments are generally stack-based, but MakeUnboxingStubWorker uses the architecture-specific EmitUnboxMethodStub, which supports this calling convention without falling back to an IL stub. Accordingly, CanGenerateRuntimeShuffleThunk should return true for x86 before examining argument locations.

I suggest separating "can compile" from "should precompile" so the predicate polarity remains clear:

private static bool CanCompileUnboxingStub(MethodDesc method)
{
    return !method.RequiresInstMethodDescArg() &&
        !method.IsAsyncCall();
}

private static bool ShouldPrecompileUnboxingStub(MethodDesc method)
{
    if (!CanCompileUnboxingStub(method))
        return false;

    ReadyToRunCompilerContext context = (ReadyToRunCompilerContext)method.Context;
    return !context.TargetAllowsRuntimeCodeGeneration ||
        !CanGenerateRuntimeShuffleThunk(method);
}

private static bool CanGenerateRuntimeShuffleThunk(MethodDesc method)
{
    // The ordinary unboxing stub only adjusts 'this' and tail-jumps.
    if (!method.RequiresInstMethodTableArg())
        return true;

    // x86 has a specialized implementation that supports its stack-based ABI.
    if (method.Context.Target.Architecture == TargetArchitecture.X86)
        return true;

    (ArgIterator<TypeHandle> source, TransitionBlock transitionBlock) =
        GCRefMapBuilder.BuildArgIterator(
            method.Signature,
            method.Context,
            methodRequiresInstArg: method.RequiresInstArg(),
            isUnboxingStub: true);

    (ArgIterator<TypeHandle> destination, _) =
        GCRefMapBuilder.BuildArgIterator(
            method.Signature,
            method.Context,
            methodRequiresInstArg: method.RequiresInstArg(),
            isUnboxingStub: false);

    // GenerateShuffleArrayPortable rejects an instantiating shuffle when
    // the source and destination stack sizes differ.
    if (source.SizeOfFrameArgumentArray() != destination.SizeOfFrameArgumentArray())
        return false;

    while (true)
    {
        int sourceOffset = source.GetNextOffset();
        int destinationOffset = destination.GetNextOffset();

        Debug.Assert(
            (sourceOffset == TransitionBlock.InvalidOffset) ==
            (destinationOffset == TransitionBlock.InvalidOffset));

        if (sourceOffset == TransitionBlock.InvalidOffset)
            return true;

        ArgLocDesc? sourceLocation = source.GetArgLoc(sourceOffset);
        ArgLocDesc? destinationLocation = destination.GetArgLoc(destinationOffset);

        if (StackLocationChanged(
            transitionBlock,
            sourceOffset,
            sourceLocation,
            destinationOffset,
            destinationLocation))
        {
            return false;
        }
    }

    static bool StackLocationChanged(
        TransitionBlock transitionBlock,
        int sourceOffset,
        ArgLocDesc? sourceLocation,
        int destinationOffset,
        ArgLocDesc? destinationLocation)
    {
        bool sourceUsesStack =
            transitionBlock.IsStackArgumentOffset(sourceOffset) ||
            sourceLocation is { m_byteStackSize: > 0 };

        bool destinationUsesStack =
            transitionBlock.IsStackArgumentOffset(destinationOffset) ||
            destinationLocation is { m_byteStackSize: > 0 };

        if (sourceUsesStack != destinationUsesStack)
            return true;

        if (!sourceUsesStack)
            return false;

        // GetArgLoc describes arguments split between registers and the stack.
        // If either side has such a description, conservatively require the
        // stack portions to be identical.
        if (sourceLocation.HasValue || destinationLocation.HasValue)
        {
            return !sourceLocation.HasValue ||
                !destinationLocation.HasValue ||
                sourceLocation.Value.m_byteStackIndex != destinationLocation.Value.m_byteStackIndex ||
                sourceLocation.Value.m_byteStackSize != destinationLocation.Value.m_byteStackSize;
        }

        return sourceOffset != destinationOffset;
    }
}

The important behavior is:

  • CanGenerateRuntimeShuffleThunk returns false when a stack move is needed.
  • ShouldPrecompileUnboxingStub consequently returns true for that method.
  • Ordinary all-register unboxing stubs remain runtime-generated on JIT-capable targets.
  • x86 uses its specialized runtime stub instead of being classified by its normal stack-based argument convention.

Please also add coverage showing that:

  • A target without runtime code generation emits an otherwise ordinary unboxing stub.
  • A target with runtime code generation does not emit an ordinary/all-register unboxing stub.
  • A target with runtime code generation emits an instantiating unboxing stub when adding the hidden context changes the stack layout.

Note

This review was created by GitHub Copilot.

if (sigIsAsync != pMD->IsAsyncVariantMethod())
return false;

// Unboxing stubs share the metadata token, owner type and instantiation of the method they

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I do not think this comment is necessary. ENCODE_METHOD_SIG_AsyncVariant a few lines above handles the same situation and it does not have any comment.

uint offset;
// Async variants are stored in the instance methods table
// Async variants and unboxing stubs are stored in the instance methods table. Unboxing stubs
// go there even for non-generic types, because they share a metadata token with the method

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unboxing stubs go there even for non-generic types

This makes it sound like that the non-generic async variants do not go into this table. Is it really the case?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants