Skip to content

[browser][coreCLR] R2R: take the method-keyed virtual-dispatch fast path on wasm (replace the #130585 workaround) #132412

Description

@pavelsavara

Tracking issue for replacing the workaround added for #130585 with a real fix.

Background

#130585: on browser-wasm, crossgen2 dies with NotImplementedException while the object writer materializes import signatures, because a TypeHandle fixup was emitted for a type that cannot be reverse-mapped to a module token (canonically [S.P.CoreLib]System.Reflection.MemberInfo). This blocked 4 of 7 browser-wasm jobs in #132339, which turns PublishReadyToRun on by default for CoreCLR browser-wasm.

The workaround that landed

A guard in embedGenericHandle's CORINFO_HANDLETYPE_CLASS branch in src/coreclr/tools/aot/ILCompiler.ReadyToRun/JitInterface/CorInfoImpl.ReadyToRun.cs, which rejects the method for R2R (RequiresRuntimeJitException) when the type handle could not be encoded as a module token:

// Mirrors SignatureContext.GetTargetModule: only an EcmaType *definition* is looked up in the
// resolver, everything else is encoded against the local context and cannot fail.
private bool CanEncodeTypeHandleFixup(TypeDesc type)
{
    if (type.IsPrimitive || type.IsString || type.IsObject || type.IsWellKnownType(WellKnownType.TypedReference))
        return true;

    if (type.GetTypeDefinition() is not EcmaType ecmaType)
        return true;

    return !_compilation.NodeFactory.Resolver.GetModuleTokenForType(
        ecmaType, allowDynamicallyCreatedReference: true, throwIfNotFound: false).IsNull;
}

This is early enough to prevent the crash: the rejected method never reaches SetCode, so its Import node is never marked, ImportSectionNode.AddImport never runs, and the un-encodable signature is never materialized.

It is a workaround, not a fix. The affected methods silently lose ReadyToRun and fall back to the runtime JIT. That is a correctness-safe outcome but it costs exactly the startup benefit R2R exists to provide, and on wasm the runtime JIT is the expensive path.

Measured cost

Against xunit.runner.utility.netcoreapp10.dll (browser-wasm, Release), crossgen2 --verbose:

count
Rejected by the pre-existing embedClassHandle guard (MessageHandler<T> over out-of-bubble interfaces) 68
Rejected by this workaround 1 (System.Reflection.MemberInfo, in Xunit.RunnerReporterUtility.GetAvailableRunnerReporters)

So on this assembly the incremental cost is small. It has not been measured across the whole framework/test closure, and the affected set grows with any assembly containing a non-devirtualizable cross-bubble virtual call.

Proper fix (F1)

Take the method-keyed ReadyToRun fast path on wasm instead of the generic helper, so the declaring type handle is never needed. The fixup then keys on the callee MemberRef, which the consuming assembly always has a token for.

1. Drop the carve-out. src/coreclr/jit/importer.cpp:2726:

// Wasm R2R cannot use the CORINFO_HELP_READYTORUN_VIRTUAL_FUNC_PTR fast path because it
// relies on DelayLoad_Helper_Obj dynamic-helper thunks, which are not implemented on wasm.
// Fall through to the runtime CORINFO_HELP_VIRTUAL_FUNC_PTR helper instead.
#if defined(FEATURE_READYTORUN) && !defined(TARGET_WASM)

With TARGET_WASM the block is preprocessed out, so impImportLdvirtftn falls through to CORINFO_HELP_VIRTUAL_FUNC_PTR, which takes the declaring type handle as an argument — that argument is the whole reason the un-encodable TypeHandle fixup exists.

2. Implement the dynamic-helper thunks. src/coreclr/vm/wasm/dynamichelpers.cpp:37-45 — both are stubs today:

extern "C" void STDCALL DelayLoad_Helper_Obj()
{
    PORTABILITY_ASSERT("DelayLoad_Helper_Obj is not implemented on wasm");
}

extern "C" void STDCALL DelayLoad_Helper_ObjObj()
{
    PORTABILITY_ASSERT("DelayLoad_Helper_ObjObj is not implemented on wasm");
}

Plain DelayLoad_Helper in the same file is implemented for wasm and is the template: a naked function that saves/restores __stack_pointer around a call to a C++ worker ending in DynamicHelperWorker. The _Obj variants additionally have to carry an object reference through the TransitionBlock so the worker can resolve against the actual this. Registration is already wired up in jitinterface.cpp (READYTORUN_HELPER_DelayLoad_Helper_ObjGetEEFuncEntryPoint(DelayLoad_Helper_Obj)).

3. Decide how the VirtualEntry cell is invoked on wasm. WasmImportThunk.cs:46-49 currently hard-rejects the virtual-call thunk:

if (useVirtualCall)
{
    // In wasm we should always be using a helper to get the function pointer target, and then dispatching on that instead of using a thunk
    throw new System.NotSupportedException(nameof(useVirtualCall));
}

This is deliberate — wasm has no arbitrary indirect branches, only call_indirect through a typed table, so the design avoids indirect-branch-through-thunk. Making the method-keyed path work likely means keeping the "helper returns a function pointer, then call_indirect" shape but keying the cell on the method rather than passing a type handle. That is a design decision for the wasm R2R owners, not a mechanical port. Note WasmImportThunk already accepts DelayLoad_Helper_Obj/_ObjObj in its non-virtual branch, so the compiler side is partly in place.

4. Don't forget the second route into the same helper. Compiler::getVirtMethodPointerTree (src/coreclr/jit/morph.cpp:5739, reached from fgMorphTailCallViaHelpers at morph.cpp:5445) embeds the parent type handle the same way and has no READYTORUN_VIRTUAL_FUNC_PTR alternative on any target. Either fix it too, or confirm the tail-call-via-helper path is unreachable on wasm — otherwise the same un-encodable fixup can still be produced.

Alternative if F1 stays blocked (F2)

Synthesize a TypeRef in the manifest metadata for out-of-bubble types instead of failing. ModuleTokenResolver already has a manifest fallback that looks up existing entities (_manifestMutableModule.TryGetExistingEntityHandle), an AddModuleTokenForType registration path, and MutableModule is by design a module new tokens can be created in. Concerns:

  • _manifestMutableModule is written from parallel compile threads; MutableModule's concurrency contract for new-token creation was not verified.
  • Every synthesized TypeRef adds a manifest assembly-ref + MVID entry, hard-binding the R2R image to that MVID — a real version-resilience behaviour change beyond wasm.
  • It removes the crash for all targets but leaves wasm on the slow generic helper, so it is strictly worse than F1 for performance.

Done when

  • The workaround guard in embedGenericHandle can be removed (or demoted to an assert) without reintroducing the crash.
  • Methods with non-devirtualizable cross-bubble virtual calls are R2R-compiled on browser-wasm rather than rejected.

Related: #130585, #132339.

Note

This issue body was generated by GitHub Copilot from a local investigation and reviewed before posting. The code references were verified against the tree; the F1 design direction is a proposal, not a validated implementation.

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

    Milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions