[wasm] Delete hand-written R2R-to-interpreter thunks; use crossgen2's - #132965

Closed
pavelsavara wants to merge 6 commits into
dotnet:mainfrom
pavelsavara:wasm_fix_r2r_interp_thunks
Closed

[wasm] Delete hand-written R2R-to-interpreter thunks; use crossgen2's#132965
pavelsavara wants to merge 6 commits into
dotnet:mainfrom
pavelsavara:wasm_fix_r2r_interp_thunks

Conversation

@pavelsavara

Copy link
Copy Markdown
Member

Addresses @jkotas's feedback on #132926: rather than grow another static table of hand-written R2R-to-interpreter thunks, delete them and rely on the ones crossgen2 already emits.

What

The R2R-to-interpreter transition thunks were a hand-written table in vm/wasm/helpers.cpp (g_wasmPortableEntryPointThunks + the CallInterpreter_* functions). crossgen2 already emits these thunks into each R2R image (WasmR2RToInterpreterThunkNode, keyed "I"+signature, from #127483), and the runtime discovers them by string via LookupPregeneratedThunkByString. The static table was therefore a buggy, incomplete duplicate that shadowed the correct crossgen2 thunks — it was checked first, yet had no float/double-return shape, among others.

This removes the table and routes LookupPortableEntryPointThunk solely through the R2R string hash. Pure interpreter keeps working via the existing deferral path (EnsurePortableEntryPointIsCallableFromR2RAddPendingPortableEntryPointThunk, resolved when an R2R module loads). helpers.cpp: −368 / +5.

Test

Adds src/tests/readytorun/wasm/WasmInterpreterTransitions, exercising both transition directions across struct (S8/S12/S16), scalar, void, and float/double returns. [BypassReadyToRun] + composite R2R ensures every cross-call actually hits a thunk (a wrong parameter order still type-checks under call_indirect, so the cases assert pinned values).

Validation (local, Windows host)

  • Browser CoreCLR: WasmInterpreterTransitions passes (Expected 100 / Actual 100).
  • Sibling browser R2R tests pass: WasmR2RStructAlignment, Runtime_131640.
  • WasmArgumentLayoutTests (crossgen2 lowering, unchanged here): 52 / 52.
  • console-node CoreCLR sample runs clean.
  • helpers.cpp compiles for both browser and wasi (the wasi target runtime builds).

Commits

  1. [wasm] Treat wasi as a cross-target in the Windows build scripts — a separable wasi-on-Windows build fix (also carried in [wasm] Generate the native-entry-point-to-interpreter thunk table #132926) so the wasi CoreCLR build configures on a Windows host. This is only one of the two separable wasi build fixes; the configureplatform.cmake cross-components fix is not included, so a full wasi build is not green from this PR alone. Can be split into its own PR if preferred.
  2. [wasm] Delete hand-written R2R-to-interpreter thunks; use crossgen2's.

Draft while gathering CI signal.

Note

This PR description was generated with AI assistance.

build-runtime.cmd and build-native.cmd only treated android and browser as cross-targets, so a wasi build on a Windows host ran copy_version_files.cmd (which copies only *.h/*.rc) instead of copy_version_files.ps1, which also generates _version.c, and CMake configure then failed with missing source files.
The R2R-to-interpreter transition thunks were a hand-written table in vm/wasm/helpers.cpp (g_wasmPortableEntryPointThunks plus the CallInterpreter_* functions). crossgen2 already emits these thunks into each R2R image (WasmR2RToInterpreterThunkNode, keyed 'I'+signature) and the runtime discovers them by string via LookupPregeneratedThunkByString, so the static table is a buggy, incomplete duplicate that shadows the correct crossgen2 thunks - it had no float/double-return shape, among others.
Remove the table and route LookupPortableEntryPointThunk solely through the R2R string hash. Pure interpreter keeps working via the existing deferral path (EnsurePortableEntryPointIsCallableFromR2R -> AddPendingPortableEntryPointThunk).
Add src/tests/readytorun/wasm/WasmInterpreterTransitions, which exercises both transition directions across struct, scalar, void, and float/double returns.
@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.

@pavelsavarapavelsavara added the arch-wasm WebAssembly architecture label Aug 31, 2026
@dotnet-policy-service

Copy link
Copy Markdown
Contributor

Tagging subscribers to this area: @JulieLeeMSFT, @BrzVlad, @janvorli
See info in area-owners.md if you want to be subscribed.

Comment threadsrc/coreclr/vm/wasm/helpers.cpp Outdated
Comment threadsrc/coreclr/vm/wasm/helpers.cpp Outdated
@pavelsavara

pavelsavara commented Aug 31, 2026

Copy link
Copy Markdown
MemberAuthor

Why the pregenerated portable-entry-point thunk table cannot be deleted

The R2R→interpreter thunk is needed whenever a caller reaches an interpreted method through a materialized native entry point — i.e. any call_indirect: ldftn/delegate/virtual dispatch, plus every R2R→interpreter call. On wasm that entry point is an index into __indirect_function_table, and the thunk is the function that builds the interpreter frame and enters it with the right signature.

crossgen2 emits these thunks (WasmR2RToInterpreterThunkNode, keyed 'I'+sig) into each R2R image, discovered at runtime via LookupPregeneratedThunkByString. That source exists only for shapes a loaded, crossgen'd module rooted.

Pure interpreter loads zero R2R images. With DOTNET_ReadyToRun=0, or simply when nothing was crossgen'd (dev inner-loop), no R2R module is present.

But the interpreter still needs a portable entry point the instant managed code takes a delegate/function-pointer to an interpreted method.

This leaves us with

  • pre-generated C thunks for known signatures.
  • crossgen'd thunks
    • R2R webcil which even pure interp would load module to be able to install those thunks
    • LLVM linkable .o file that dotnet.native.wasm would link

Both options are incomplete because we don't pre-create thunks for signatures in the app assemblies.
Doing so means running crossgen in the dev-loop too.

None of this is on critical path for me, to unblock Blazor on R2R. I'm closing this and we will re-consider later.

I'm back to #132926

cc @jkotas@davidwrighton

@jkotas

jkotas commented Aug 31, 2026

Copy link
Copy Markdown
Member

Pure interpreter loads zero R2R images. With DOTNET_ReadyToRun=0, or simply when nothing was crossgen'd (dev inner-loop), no R2R module is present.

Right. It means that you should not need any R2R->interpreter thunks (there is no R2R code), managed->managed calls should be going through interpreter without any intermediate thunks, and managed<->native calls should be covered by thunks generated by the pre-generated C thunks.

Can you share a few examples of situations that are causing problems once the hardcoded list of thunks is deleted?

I'm back to #132926

The change in #132926 is dead-end.

@pavelsavara

pavelsavara commented Sep 1, 2026

Copy link
Copy Markdown
MemberAuthor

It means that you should not need any R2R->interpreter thunks (there is no R2R code)

Correct no R2R code.

and managed<->native calls should be covered by thunks generated by the pre-generated C thunks.

That's what #132926 is generating.
I think the subject of that PR is confusion, it should speak about "interp-to-native-entrypoint" thunks rather than "interp-to-R2R" thunks. I will update that PR.

Can you share a few examples of situations that are causing problems once the hardcoded list of thunks is deleted?

On wasm a native function pointer is a typed index into the function table. Even a pure interpreter must (A) convert a managed method (delegate / ldftn) into such a native pointer, and (B) have that pointer be callable — from the interpreter and from native/host code — which requires a real, per-signature wasm function (the thunk) that marshals the typed args and re-enters the interpreter.

  • Default constructorscallhelpers.cpp:581: RuntimeHelpers.CallDefaultConstructor does a calli ctorCode. In pure interp that helper runs interpreted, and its calli calls through _pActualCode = the thunk.
  • Finalizerscomutilnative.cpp:797: RunFinalizers invokes the finalizer via its function pointer.
  • Class constructorsmethodtable.cpp:3580: CallClassConstructor invokes the cctor via its function pointer.
  • Plus the original CI failure: Dictionary.Add materializing an interpreted comparer's entry point.

@jkotas

Copy link
Copy Markdown
Member

On wasm a native function pointer is a typed index into the function table. Even a pure interpreter must (A) convert a managed method (delegate / ldftn) into such a native pointer, and (B) have that pointer be callable

That's not correct. Pure interpreter is expected to use portable-entry-point for managed method delegate. This portable-entry-point does not have to have _pActualCode filled in when the thunk does not exist.

if (PortableEntryPoint::PrefersInterpreterEntryPoint(calliFunctionPointer) || !PortableEntryPoint::HasNativeEntryPoint(calliFunctionPointer))
gotoCALL_INTERP_METHOD;
is expected to detect this situation and keep interpreting the call without ever calling via _pActualCode.

@jkotas

Copy link
Copy Markdown
Member

Do you see the condition that I have linked to be false in the problematic cases? There are multiple clones of this condition in interpexec.cpp handling different types of calls.

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

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@pavelsavara@jkotas
, 'i'); if (__m === '*' || __re.test(location.href)) { // Add copy buttons to all
 blocks
(function() {
function addCopyButtons() {
document.querySelectorAll('pre code').forEach(function(codeBlock) {
if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;
codeBlock.parentElement.setAttribute('data-copy-added', 'true');
var btn = document.createElement('button');
btn.textContent = 'Copy';
btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';
btn.onmouseover = function() { this.style.opacity = '1'; };
btn.onmouseout = function() { this.style.opacity = '0.7'; };
btn.onclick = function() {
navigator.clipboard.writeText(codeBlock.textContent).then(function() {
btn.textContent = 'Copied!';
setTimeout(function() { btn.textContent = 'Copy'; }, 1500);
});
};
codeBlock.parentElement.style.position = 'relative';
codeBlock.parentElement.appendChild(btn);
});
}
addCopyButtons();
// Re-run on dynamic content
var observer = new MutationObserver(addCopyButtons);
observer.observe(document.body, { childList: true, subtree: true });
})();
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content

[wasm] Delete hand-written R2R-to-interpreter thunks; use crossgen2's - #132965

Closed
pavelsavara wants to merge 6 commits into
dotnet:mainfrom
pavelsavara:wasm_fix_r2r_interp_thunks
Closed

[wasm] Delete hand-written R2R-to-interpreter thunks; use crossgen2's#132965
pavelsavara wants to merge 6 commits into
dotnet:mainfrom
pavelsavara:wasm_fix_r2r_interp_thunks

Conversation

@pavelsavara

Copy link
Copy Markdown
Member

Addresses @jkotas's feedback on #132926: rather than grow another static table of hand-written R2R-to-interpreter thunks, delete them and rely on the ones crossgen2 already emits.

What

The R2R-to-interpreter transition thunks were a hand-written table in vm/wasm/helpers.cpp (g_wasmPortableEntryPointThunks + the CallInterpreter_* functions). crossgen2 already emits these thunks into each R2R image (WasmR2RToInterpreterThunkNode, keyed "I"+signature, from #127483), and the runtime discovers them by string via LookupPregeneratedThunkByString. The static table was therefore a buggy, incomplete duplicate that shadowed the correct crossgen2 thunks — it was checked first, yet had no float/double-return shape, among others.

This removes the table and routes LookupPortableEntryPointThunk solely through the R2R string hash. Pure interpreter keeps working via the existing deferral path (EnsurePortableEntryPointIsCallableFromR2RAddPendingPortableEntryPointThunk, resolved when an R2R module loads). helpers.cpp: −368 / +5.

Test

Adds src/tests/readytorun/wasm/WasmInterpreterTransitions, exercising both transition directions across struct (S8/S12/S16), scalar, void, and float/double returns. [BypassReadyToRun] + composite R2R ensures every cross-call actually hits a thunk (a wrong parameter order still type-checks under call_indirect, so the cases assert pinned values).

Validation (local, Windows host)

  • Browser CoreCLR: WasmInterpreterTransitions passes (Expected 100 / Actual 100).
  • Sibling browser R2R tests pass: WasmR2RStructAlignment, Runtime_131640.
  • WasmArgumentLayoutTests (crossgen2 lowering, unchanged here): 52 / 52.
  • console-node CoreCLR sample runs clean.
  • helpers.cpp compiles for both browser and wasi (the wasi target runtime builds).

Commits

  1. [wasm] Treat wasi as a cross-target in the Windows build scripts — a separable wasi-on-Windows build fix (also carried in [wasm] Generate the native-entry-point-to-interpreter thunk table #132926) so the wasi CoreCLR build configures on a Windows host. This is only one of the two separable wasi build fixes; the configureplatform.cmake cross-components fix is not included, so a full wasi build is not green from this PR alone. Can be split into its own PR if preferred.
  2. [wasm] Delete hand-written R2R-to-interpreter thunks; use crossgen2's.

Draft while gathering CI signal.

Note

This PR description was generated with AI assistance.

build-runtime.cmd and build-native.cmd only treated android and browser as cross-targets, so a wasi build on a Windows host ran copy_version_files.cmd (which copies only *.h/*.rc) instead of copy_version_files.ps1, which also generates _version.c, and CMake configure then failed with missing source files.
The R2R-to-interpreter transition thunks were a hand-written table in vm/wasm/helpers.cpp (g_wasmPortableEntryPointThunks plus the CallInterpreter_* functions). crossgen2 already emits these thunks into each R2R image (WasmR2RToInterpreterThunkNode, keyed 'I'+signature) and the runtime discovers them by string via LookupPregeneratedThunkByString, so the static table is a buggy, incomplete duplicate that shadows the correct crossgen2 thunks - it had no float/double-return shape, among others.
Remove the table and route LookupPortableEntryPointThunk solely through the R2R string hash. Pure interpreter keeps working via the existing deferral path (EnsurePortableEntryPointIsCallableFromR2R -> AddPendingPortableEntryPointThunk).
Add src/tests/readytorun/wasm/WasmInterpreterTransitions, which exercises both transition directions across struct, scalar, void, and float/double returns.
@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.

@pavelsavarapavelsavara added the arch-wasm WebAssembly architecture label Aug 31, 2026
@dotnet-policy-service

Copy link
Copy Markdown
Contributor

Tagging subscribers to this area: @JulieLeeMSFT, @BrzVlad, @janvorli
See info in area-owners.md if you want to be subscribed.

Comment threadsrc/coreclr/vm/wasm/helpers.cpp Outdated
Comment threadsrc/coreclr/vm/wasm/helpers.cpp Outdated
@pavelsavara

pavelsavara commented Aug 31, 2026

Copy link
Copy Markdown
MemberAuthor

Why the pregenerated portable-entry-point thunk table cannot be deleted

The R2R→interpreter thunk is needed whenever a caller reaches an interpreted method through a materialized native entry point — i.e. any call_indirect: ldftn/delegate/virtual dispatch, plus every R2R→interpreter call. On wasm that entry point is an index into __indirect_function_table, and the thunk is the function that builds the interpreter frame and enters it with the right signature.

crossgen2 emits these thunks (WasmR2RToInterpreterThunkNode, keyed 'I'+sig) into each R2R image, discovered at runtime via LookupPregeneratedThunkByString. That source exists only for shapes a loaded, crossgen'd module rooted.

Pure interpreter loads zero R2R images. With DOTNET_ReadyToRun=0, or simply when nothing was crossgen'd (dev inner-loop), no R2R module is present.

But the interpreter still needs a portable entry point the instant managed code takes a delegate/function-pointer to an interpreted method.

This leaves us with

  • pre-generated C thunks for known signatures.
  • crossgen'd thunks
    • R2R webcil which even pure interp would load module to be able to install those thunks
    • LLVM linkable .o file that dotnet.native.wasm would link

Both options are incomplete because we don't pre-create thunks for signatures in the app assemblies.
Doing so means running crossgen in the dev-loop too.

None of this is on critical path for me, to unblock Blazor on R2R. I'm closing this and we will re-consider later.

I'm back to #132926

cc @jkotas@davidwrighton

@jkotas

jkotas commented Aug 31, 2026

Copy link
Copy Markdown
Member

Pure interpreter loads zero R2R images. With DOTNET_ReadyToRun=0, or simply when nothing was crossgen'd (dev inner-loop), no R2R module is present.

Right. It means that you should not need any R2R->interpreter thunks (there is no R2R code), managed->managed calls should be going through interpreter without any intermediate thunks, and managed<->native calls should be covered by thunks generated by the pre-generated C thunks.

Can you share a few examples of situations that are causing problems once the hardcoded list of thunks is deleted?

I'm back to #132926

The change in #132926 is dead-end.

@pavelsavara

pavelsavara commented Sep 1, 2026

Copy link
Copy Markdown
MemberAuthor

It means that you should not need any R2R->interpreter thunks (there is no R2R code)

Correct no R2R code.

and managed<->native calls should be covered by thunks generated by the pre-generated C thunks.

That's what #132926 is generating.
I think the subject of that PR is confusion, it should speak about "interp-to-native-entrypoint" thunks rather than "interp-to-R2R" thunks. I will update that PR.

Can you share a few examples of situations that are causing problems once the hardcoded list of thunks is deleted?

On wasm a native function pointer is a typed index into the function table. Even a pure interpreter must (A) convert a managed method (delegate / ldftn) into such a native pointer, and (B) have that pointer be callable — from the interpreter and from native/host code — which requires a real, per-signature wasm function (the thunk) that marshals the typed args and re-enters the interpreter.

  • Default constructorscallhelpers.cpp:581: RuntimeHelpers.CallDefaultConstructor does a calli ctorCode. In pure interp that helper runs interpreted, and its calli calls through _pActualCode = the thunk.
  • Finalizerscomutilnative.cpp:797: RunFinalizers invokes the finalizer via its function pointer.
  • Class constructorsmethodtable.cpp:3580: CallClassConstructor invokes the cctor via its function pointer.
  • Plus the original CI failure: Dictionary.Add materializing an interpreted comparer's entry point.

@jkotas

Copy link
Copy Markdown
Member

On wasm a native function pointer is a typed index into the function table. Even a pure interpreter must (A) convert a managed method (delegate / ldftn) into such a native pointer, and (B) have that pointer be callable

That's not correct. Pure interpreter is expected to use portable-entry-point for managed method delegate. This portable-entry-point does not have to have _pActualCode filled in when the thunk does not exist.

if (PortableEntryPoint::PrefersInterpreterEntryPoint(calliFunctionPointer) || !PortableEntryPoint::HasNativeEntryPoint(calliFunctionPointer))
gotoCALL_INTERP_METHOD;
is expected to detect this situation and keep interpreting the call without ever calling via _pActualCode.

@jkotas

Copy link
Copy Markdown
Member

Do you see the condition that I have linked to be false in the problematic cases? There are multiple clones of this condition in interpexec.cpp handling different types of calls.

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

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@pavelsavara@jkotas
, 'i'); if (__m === '*' || __re.test(location.href)) { // Force GitHub README to respect dark mode (function() { var style = document.createElement('style'); style.textContent = ' .markdown-body { color-scheme: dark light; } .markdown-body pre { background: #161b22 !important; } .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; } .markdown-body table th, .markdown-body table td { border-color: #30363d !important; } .markdown-body img { background: #0d1117; } .markdown-body blockquote { border-left-color: #8b949e; } .markdown-body hr { border-color: #30363d; } '; document.head.appendChild(style); })(); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

[wasm] Delete hand-written R2R-to-interpreter thunks; use crossgen2's - #132965

Closed
pavelsavara wants to merge 6 commits into
dotnet:mainfrom
pavelsavara:wasm_fix_r2r_interp_thunks
Closed

[wasm] Delete hand-written R2R-to-interpreter thunks; use crossgen2's#132965
pavelsavara wants to merge 6 commits into
dotnet:mainfrom
pavelsavara:wasm_fix_r2r_interp_thunks

Conversation

@pavelsavara

Copy link
Copy Markdown
Member

Addresses @jkotas's feedback on #132926: rather than grow another static table of hand-written R2R-to-interpreter thunks, delete them and rely on the ones crossgen2 already emits.

What

The R2R-to-interpreter transition thunks were a hand-written table in vm/wasm/helpers.cpp (g_wasmPortableEntryPointThunks + the CallInterpreter_* functions). crossgen2 already emits these thunks into each R2R image (WasmR2RToInterpreterThunkNode, keyed "I"+signature, from #127483), and the runtime discovers them by string via LookupPregeneratedThunkByString. The static table was therefore a buggy, incomplete duplicate that shadowed the correct crossgen2 thunks — it was checked first, yet had no float/double-return shape, among others.

This removes the table and routes LookupPortableEntryPointThunk solely through the R2R string hash. Pure interpreter keeps working via the existing deferral path (EnsurePortableEntryPointIsCallableFromR2RAddPendingPortableEntryPointThunk, resolved when an R2R module loads). helpers.cpp: −368 / +5.

Test

Adds src/tests/readytorun/wasm/WasmInterpreterTransitions, exercising both transition directions across struct (S8/S12/S16), scalar, void, and float/double returns. [BypassReadyToRun] + composite R2R ensures every cross-call actually hits a thunk (a wrong parameter order still type-checks under call_indirect, so the cases assert pinned values).

Validation (local, Windows host)

  • Browser CoreCLR: WasmInterpreterTransitions passes (Expected 100 / Actual 100).
  • Sibling browser R2R tests pass: WasmR2RStructAlignment, Runtime_131640.
  • WasmArgumentLayoutTests (crossgen2 lowering, unchanged here): 52 / 52.
  • console-node CoreCLR sample runs clean.
  • helpers.cpp compiles for both browser and wasi (the wasi target runtime builds).

Commits

  1. [wasm] Treat wasi as a cross-target in the Windows build scripts — a separable wasi-on-Windows build fix (also carried in [wasm] Generate the native-entry-point-to-interpreter thunk table #132926) so the wasi CoreCLR build configures on a Windows host. This is only one of the two separable wasi build fixes; the configureplatform.cmake cross-components fix is not included, so a full wasi build is not green from this PR alone. Can be split into its own PR if preferred.
  2. [wasm] Delete hand-written R2R-to-interpreter thunks; use crossgen2's.

Draft while gathering CI signal.

Note

This PR description was generated with AI assistance.

build-runtime.cmd and build-native.cmd only treated android and browser as cross-targets, so a wasi build on a Windows host ran copy_version_files.cmd (which copies only *.h/*.rc) instead of copy_version_files.ps1, which also generates _version.c, and CMake configure then failed with missing source files.
The R2R-to-interpreter transition thunks were a hand-written table in vm/wasm/helpers.cpp (g_wasmPortableEntryPointThunks plus the CallInterpreter_* functions). crossgen2 already emits these thunks into each R2R image (WasmR2RToInterpreterThunkNode, keyed 'I'+signature) and the runtime discovers them by string via LookupPregeneratedThunkByString, so the static table is a buggy, incomplete duplicate that shadows the correct crossgen2 thunks - it had no float/double-return shape, among others.
Remove the table and route LookupPortableEntryPointThunk solely through the R2R string hash. Pure interpreter keeps working via the existing deferral path (EnsurePortableEntryPointIsCallableFromR2R -> AddPendingPortableEntryPointThunk).
Add src/tests/readytorun/wasm/WasmInterpreterTransitions, which exercises both transition directions across struct, scalar, void, and float/double returns.
@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.

@pavelsavarapavelsavara added the arch-wasm WebAssembly architecture label Aug 31, 2026
@dotnet-policy-service

Copy link
Copy Markdown
Contributor

Tagging subscribers to this area: @JulieLeeMSFT, @BrzVlad, @janvorli
See info in area-owners.md if you want to be subscribed.

Comment threadsrc/coreclr/vm/wasm/helpers.cpp Outdated
Comment threadsrc/coreclr/vm/wasm/helpers.cpp Outdated
@pavelsavara

pavelsavara commented Aug 31, 2026

Copy link
Copy Markdown
MemberAuthor

Why the pregenerated portable-entry-point thunk table cannot be deleted

The R2R→interpreter thunk is needed whenever a caller reaches an interpreted method through a materialized native entry point — i.e. any call_indirect: ldftn/delegate/virtual dispatch, plus every R2R→interpreter call. On wasm that entry point is an index into __indirect_function_table, and the thunk is the function that builds the interpreter frame and enters it with the right signature.

crossgen2 emits these thunks (WasmR2RToInterpreterThunkNode, keyed 'I'+sig) into each R2R image, discovered at runtime via LookupPregeneratedThunkByString. That source exists only for shapes a loaded, crossgen'd module rooted.

Pure interpreter loads zero R2R images. With DOTNET_ReadyToRun=0, or simply when nothing was crossgen'd (dev inner-loop), no R2R module is present.

But the interpreter still needs a portable entry point the instant managed code takes a delegate/function-pointer to an interpreted method.

This leaves us with

  • pre-generated C thunks for known signatures.
  • crossgen'd thunks
    • R2R webcil which even pure interp would load module to be able to install those thunks
    • LLVM linkable .o file that dotnet.native.wasm would link

Both options are incomplete because we don't pre-create thunks for signatures in the app assemblies.
Doing so means running crossgen in the dev-loop too.

None of this is on critical path for me, to unblock Blazor on R2R. I'm closing this and we will re-consider later.

I'm back to #132926

cc @jkotas@davidwrighton

@jkotas

jkotas commented Aug 31, 2026

Copy link
Copy Markdown
Member

Pure interpreter loads zero R2R images. With DOTNET_ReadyToRun=0, or simply when nothing was crossgen'd (dev inner-loop), no R2R module is present.

Right. It means that you should not need any R2R->interpreter thunks (there is no R2R code), managed->managed calls should be going through interpreter without any intermediate thunks, and managed<->native calls should be covered by thunks generated by the pre-generated C thunks.

Can you share a few examples of situations that are causing problems once the hardcoded list of thunks is deleted?

I'm back to #132926

The change in #132926 is dead-end.

@pavelsavara

pavelsavara commented Sep 1, 2026

Copy link
Copy Markdown
MemberAuthor

It means that you should not need any R2R->interpreter thunks (there is no R2R code)

Correct no R2R code.

and managed<->native calls should be covered by thunks generated by the pre-generated C thunks.

That's what #132926 is generating.
I think the subject of that PR is confusion, it should speak about "interp-to-native-entrypoint" thunks rather than "interp-to-R2R" thunks. I will update that PR.

Can you share a few examples of situations that are causing problems once the hardcoded list of thunks is deleted?

On wasm a native function pointer is a typed index into the function table. Even a pure interpreter must (A) convert a managed method (delegate / ldftn) into such a native pointer, and (B) have that pointer be callable — from the interpreter and from native/host code — which requires a real, per-signature wasm function (the thunk) that marshals the typed args and re-enters the interpreter.

  • Default constructorscallhelpers.cpp:581: RuntimeHelpers.CallDefaultConstructor does a calli ctorCode. In pure interp that helper runs interpreted, and its calli calls through _pActualCode = the thunk.
  • Finalizerscomutilnative.cpp:797: RunFinalizers invokes the finalizer via its function pointer.
  • Class constructorsmethodtable.cpp:3580: CallClassConstructor invokes the cctor via its function pointer.
  • Plus the original CI failure: Dictionary.Add materializing an interpreted comparer's entry point.

@jkotas

Copy link
Copy Markdown
Member

On wasm a native function pointer is a typed index into the function table. Even a pure interpreter must (A) convert a managed method (delegate / ldftn) into such a native pointer, and (B) have that pointer be callable

That's not correct. Pure interpreter is expected to use portable-entry-point for managed method delegate. This portable-entry-point does not have to have _pActualCode filled in when the thunk does not exist.

if (PortableEntryPoint::PrefersInterpreterEntryPoint(calliFunctionPointer) || !PortableEntryPoint::HasNativeEntryPoint(calliFunctionPointer))
gotoCALL_INTERP_METHOD;
is expected to detect this situation and keep interpreting the call without ever calling via _pActualCode.

@jkotas

Copy link
Copy Markdown
Member

Do you see the condition that I have linked to be false in the problematic cases? There are multiple clones of this condition in interpexec.cpp handling different types of calls.

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

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@pavelsavara@jkotas
, 'i'); if (__m === '*' || __re.test(location.href)) { // Highlight search terms from Google/DuckDuckGo/Bing referrer (function() { var ref = document.referrer; var terms = []; if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) { var url = new URL(ref); var q = url.searchParams.get('q') || url.searchParams.get('p'); if (q) { terms = q.split(/\s+/).filter(function(t) { return t.length > 2; }); } } if (terms.length === 0) return; var style = document.createElement('style'); style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }'; document.head.appendChild(style); function highlight(node) { if (node.nodeType === 3) { // text node var text = node.textContent; var found = false; terms.forEach(function(term) { var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\]\\]/g, '\\') + ')', 'gi'); if (regex.test(text)) { found = true; var frag = document.createDocumentFragment(); var parts = text.split(regex); parts.forEach(function(part, i) { if (i % 2 === 0) { frag.appendChild(document.createTextNode(part)); } else { var span = document.createElement('span'); span.className = 'userscript-highlight'; span.textContent = part; frag.appendChild(span); } }); node.parentNode.replaceChild(frag, node); } }); } else if (node.nodeType === 1 && node.childNodes) { // element var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT']; if (!skipTags.includes(node.tagName)) { Array.from(node.childNodes).forEach(highlight); } } } highlight(document.body); // Re-highlight on dynamic content var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1 || node.nodeType === 3) highlight(node); }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

[wasm] Delete hand-written R2R-to-interpreter thunks; use crossgen2's - #132965

Closed
pavelsavara wants to merge 6 commits into
dotnet:mainfrom
pavelsavara:wasm_fix_r2r_interp_thunks
Closed

[wasm] Delete hand-written R2R-to-interpreter thunks; use crossgen2's#132965
pavelsavara wants to merge 6 commits into
dotnet:mainfrom
pavelsavara:wasm_fix_r2r_interp_thunks

Conversation

@pavelsavara

Copy link
Copy Markdown
Member

Addresses @jkotas's feedback on #132926: rather than grow another static table of hand-written R2R-to-interpreter thunks, delete them and rely on the ones crossgen2 already emits.

What

The R2R-to-interpreter transition thunks were a hand-written table in vm/wasm/helpers.cpp (g_wasmPortableEntryPointThunks + the CallInterpreter_* functions). crossgen2 already emits these thunks into each R2R image (WasmR2RToInterpreterThunkNode, keyed "I"+signature, from #127483), and the runtime discovers them by string via LookupPregeneratedThunkByString. The static table was therefore a buggy, incomplete duplicate that shadowed the correct crossgen2 thunks — it was checked first, yet had no float/double-return shape, among others.

This removes the table and routes LookupPortableEntryPointThunk solely through the R2R string hash. Pure interpreter keeps working via the existing deferral path (EnsurePortableEntryPointIsCallableFromR2RAddPendingPortableEntryPointThunk, resolved when an R2R module loads). helpers.cpp: −368 / +5.

Test

Adds src/tests/readytorun/wasm/WasmInterpreterTransitions, exercising both transition directions across struct (S8/S12/S16), scalar, void, and float/double returns. [BypassReadyToRun] + composite R2R ensures every cross-call actually hits a thunk (a wrong parameter order still type-checks under call_indirect, so the cases assert pinned values).

Validation (local, Windows host)

  • Browser CoreCLR: WasmInterpreterTransitions passes (Expected 100 / Actual 100).
  • Sibling browser R2R tests pass: WasmR2RStructAlignment, Runtime_131640.
  • WasmArgumentLayoutTests (crossgen2 lowering, unchanged here): 52 / 52.
  • console-node CoreCLR sample runs clean.
  • helpers.cpp compiles for both browser and wasi (the wasi target runtime builds).

Commits

  1. [wasm] Treat wasi as a cross-target in the Windows build scripts — a separable wasi-on-Windows build fix (also carried in [wasm] Generate the native-entry-point-to-interpreter thunk table #132926) so the wasi CoreCLR build configures on a Windows host. This is only one of the two separable wasi build fixes; the configureplatform.cmake cross-components fix is not included, so a full wasi build is not green from this PR alone. Can be split into its own PR if preferred.
  2. [wasm] Delete hand-written R2R-to-interpreter thunks; use crossgen2's.

Draft while gathering CI signal.

Note

This PR description was generated with AI assistance.

build-runtime.cmd and build-native.cmd only treated android and browser as cross-targets, so a wasi build on a Windows host ran copy_version_files.cmd (which copies only *.h/*.rc) instead of copy_version_files.ps1, which also generates _version.c, and CMake configure then failed with missing source files.
The R2R-to-interpreter transition thunks were a hand-written table in vm/wasm/helpers.cpp (g_wasmPortableEntryPointThunks plus the CallInterpreter_* functions). crossgen2 already emits these thunks into each R2R image (WasmR2RToInterpreterThunkNode, keyed 'I'+signature) and the runtime discovers them by string via LookupPregeneratedThunkByString, so the static table is a buggy, incomplete duplicate that shadows the correct crossgen2 thunks - it had no float/double-return shape, among others.
Remove the table and route LookupPortableEntryPointThunk solely through the R2R string hash. Pure interpreter keeps working via the existing deferral path (EnsurePortableEntryPointIsCallableFromR2R -> AddPendingPortableEntryPointThunk).
Add src/tests/readytorun/wasm/WasmInterpreterTransitions, which exercises both transition directions across struct, scalar, void, and float/double returns.
@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.

@pavelsavarapavelsavara added the arch-wasm WebAssembly architecture label Aug 31, 2026
@dotnet-policy-service

Copy link
Copy Markdown
Contributor

Tagging subscribers to this area: @JulieLeeMSFT, @BrzVlad, @janvorli
See info in area-owners.md if you want to be subscribed.

Comment threadsrc/coreclr/vm/wasm/helpers.cpp Outdated
Comment threadsrc/coreclr/vm/wasm/helpers.cpp Outdated
@pavelsavara

pavelsavara commented Aug 31, 2026

Copy link
Copy Markdown
MemberAuthor

Why the pregenerated portable-entry-point thunk table cannot be deleted

The R2R→interpreter thunk is needed whenever a caller reaches an interpreted method through a materialized native entry point — i.e. any call_indirect: ldftn/delegate/virtual dispatch, plus every R2R→interpreter call. On wasm that entry point is an index into __indirect_function_table, and the thunk is the function that builds the interpreter frame and enters it with the right signature.

crossgen2 emits these thunks (WasmR2RToInterpreterThunkNode, keyed 'I'+sig) into each R2R image, discovered at runtime via LookupPregeneratedThunkByString. That source exists only for shapes a loaded, crossgen'd module rooted.

Pure interpreter loads zero R2R images. With DOTNET_ReadyToRun=0, or simply when nothing was crossgen'd (dev inner-loop), no R2R module is present.

But the interpreter still needs a portable entry point the instant managed code takes a delegate/function-pointer to an interpreted method.

This leaves us with

  • pre-generated C thunks for known signatures.
  • crossgen'd thunks
    • R2R webcil which even pure interp would load module to be able to install those thunks
    • LLVM linkable .o file that dotnet.native.wasm would link

Both options are incomplete because we don't pre-create thunks for signatures in the app assemblies.
Doing so means running crossgen in the dev-loop too.

None of this is on critical path for me, to unblock Blazor on R2R. I'm closing this and we will re-consider later.

I'm back to #132926

cc @jkotas@davidwrighton

@jkotas

jkotas commented Aug 31, 2026

Copy link
Copy Markdown
Member

Pure interpreter loads zero R2R images. With DOTNET_ReadyToRun=0, or simply when nothing was crossgen'd (dev inner-loop), no R2R module is present.

Right. It means that you should not need any R2R->interpreter thunks (there is no R2R code), managed->managed calls should be going through interpreter without any intermediate thunks, and managed<->native calls should be covered by thunks generated by the pre-generated C thunks.

Can you share a few examples of situations that are causing problems once the hardcoded list of thunks is deleted?

I'm back to #132926

The change in #132926 is dead-end.

@pavelsavara

pavelsavara commented Sep 1, 2026

Copy link
Copy Markdown
MemberAuthor

It means that you should not need any R2R->interpreter thunks (there is no R2R code)

Correct no R2R code.

and managed<->native calls should be covered by thunks generated by the pre-generated C thunks.

That's what #132926 is generating.
I think the subject of that PR is confusion, it should speak about "interp-to-native-entrypoint" thunks rather than "interp-to-R2R" thunks. I will update that PR.

Can you share a few examples of situations that are causing problems once the hardcoded list of thunks is deleted?

On wasm a native function pointer is a typed index into the function table. Even a pure interpreter must (A) convert a managed method (delegate / ldftn) into such a native pointer, and (B) have that pointer be callable — from the interpreter and from native/host code — which requires a real, per-signature wasm function (the thunk) that marshals the typed args and re-enters the interpreter.

  • Default constructorscallhelpers.cpp:581: RuntimeHelpers.CallDefaultConstructor does a calli ctorCode. In pure interp that helper runs interpreted, and its calli calls through _pActualCode = the thunk.
  • Finalizerscomutilnative.cpp:797: RunFinalizers invokes the finalizer via its function pointer.
  • Class constructorsmethodtable.cpp:3580: CallClassConstructor invokes the cctor via its function pointer.
  • Plus the original CI failure: Dictionary.Add materializing an interpreted comparer's entry point.

@jkotas

Copy link
Copy Markdown
Member

On wasm a native function pointer is a typed index into the function table. Even a pure interpreter must (A) convert a managed method (delegate / ldftn) into such a native pointer, and (B) have that pointer be callable

That's not correct. Pure interpreter is expected to use portable-entry-point for managed method delegate. This portable-entry-point does not have to have _pActualCode filled in when the thunk does not exist.

if (PortableEntryPoint::PrefersInterpreterEntryPoint(calliFunctionPointer) || !PortableEntryPoint::HasNativeEntryPoint(calliFunctionPointer))
gotoCALL_INTERP_METHOD;
is expected to detect this situation and keep interpreting the call without ever calling via _pActualCode.

@jkotas

Copy link
Copy Markdown
Member

Do you see the condition that I have linked to be false in the problematic cases? There are multiple clones of this condition in interpexec.cpp handling different types of calls.

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

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@pavelsavara@jkotas
, 'i'); if (__m === '*' || __re.test(location.href)) { // Strip utm_, fbclid, gclid, etc. from all links on page (function() { var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid', 'ref', 'ref_src', 'source', 'medium', 'campaign']; function cleanUrl(url) { try { var u = new URL(url, window.location.origin); var changed = false; trackingParams.forEach(function(p) { if (u.searchParams.has(p)) { u.searchParams.delete(p); changed = true; } }); return changed ? u.toString() : url; } catch (e) { return url; } } function cleanLinks() { document.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } cleanLinks(); var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1) { if (node.tagName === 'A') cleanLinks(); node.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content

[wasm] Delete hand-written R2R-to-interpreter thunks; use crossgen2's - #132965

Closed
pavelsavara wants to merge 6 commits into
dotnet:mainfrom
pavelsavara:wasm_fix_r2r_interp_thunks
Closed

[wasm] Delete hand-written R2R-to-interpreter thunks; use crossgen2's#132965
pavelsavara wants to merge 6 commits into
dotnet:mainfrom
pavelsavara:wasm_fix_r2r_interp_thunks

Conversation

@pavelsavara

Copy link
Copy Markdown
Member

Addresses @jkotas's feedback on #132926: rather than grow another static table of hand-written R2R-to-interpreter thunks, delete them and rely on the ones crossgen2 already emits.

What

The R2R-to-interpreter transition thunks were a hand-written table in vm/wasm/helpers.cpp (g_wasmPortableEntryPointThunks + the CallInterpreter_* functions). crossgen2 already emits these thunks into each R2R image (WasmR2RToInterpreterThunkNode, keyed "I"+signature, from #127483), and the runtime discovers them by string via LookupPregeneratedThunkByString. The static table was therefore a buggy, incomplete duplicate that shadowed the correct crossgen2 thunks — it was checked first, yet had no float/double-return shape, among others.

This removes the table and routes LookupPortableEntryPointThunk solely through the R2R string hash. Pure interpreter keeps working via the existing deferral path (EnsurePortableEntryPointIsCallableFromR2RAddPendingPortableEntryPointThunk, resolved when an R2R module loads). helpers.cpp: −368 / +5.

Test

Adds src/tests/readytorun/wasm/WasmInterpreterTransitions, exercising both transition directions across struct (S8/S12/S16), scalar, void, and float/double returns. [BypassReadyToRun] + composite R2R ensures every cross-call actually hits a thunk (a wrong parameter order still type-checks under call_indirect, so the cases assert pinned values).

Validation (local, Windows host)

  • Browser CoreCLR: WasmInterpreterTransitions passes (Expected 100 / Actual 100).
  • Sibling browser R2R tests pass: WasmR2RStructAlignment, Runtime_131640.
  • WasmArgumentLayoutTests (crossgen2 lowering, unchanged here): 52 / 52.
  • console-node CoreCLR sample runs clean.
  • helpers.cpp compiles for both browser and wasi (the wasi target runtime builds).

Commits

  1. [wasm] Treat wasi as a cross-target in the Windows build scripts — a separable wasi-on-Windows build fix (also carried in [wasm] Generate the native-entry-point-to-interpreter thunk table #132926) so the wasi CoreCLR build configures on a Windows host. This is only one of the two separable wasi build fixes; the configureplatform.cmake cross-components fix is not included, so a full wasi build is not green from this PR alone. Can be split into its own PR if preferred.
  2. [wasm] Delete hand-written R2R-to-interpreter thunks; use crossgen2's.

Draft while gathering CI signal.

Note

This PR description was generated with AI assistance.

build-runtime.cmd and build-native.cmd only treated android and browser as cross-targets, so a wasi build on a Windows host ran copy_version_files.cmd (which copies only *.h/*.rc) instead of copy_version_files.ps1, which also generates _version.c, and CMake configure then failed with missing source files.
The R2R-to-interpreter transition thunks were a hand-written table in vm/wasm/helpers.cpp (g_wasmPortableEntryPointThunks plus the CallInterpreter_* functions). crossgen2 already emits these thunks into each R2R image (WasmR2RToInterpreterThunkNode, keyed 'I'+signature) and the runtime discovers them by string via LookupPregeneratedThunkByString, so the static table is a buggy, incomplete duplicate that shadows the correct crossgen2 thunks - it had no float/double-return shape, among others.
Remove the table and route LookupPortableEntryPointThunk solely through the R2R string hash. Pure interpreter keeps working via the existing deferral path (EnsurePortableEntryPointIsCallableFromR2R -> AddPendingPortableEntryPointThunk).
Add src/tests/readytorun/wasm/WasmInterpreterTransitions, which exercises both transition directions across struct, scalar, void, and float/double returns.
@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.

@pavelsavarapavelsavara added the arch-wasm WebAssembly architecture label Aug 31, 2026
@dotnet-policy-service

Copy link
Copy Markdown
Contributor

Tagging subscribers to this area: @JulieLeeMSFT, @BrzVlad, @janvorli
See info in area-owners.md if you want to be subscribed.

Comment threadsrc/coreclr/vm/wasm/helpers.cpp Outdated
Comment threadsrc/coreclr/vm/wasm/helpers.cpp Outdated
@pavelsavara

pavelsavara commented Aug 31, 2026

Copy link
Copy Markdown
MemberAuthor

Why the pregenerated portable-entry-point thunk table cannot be deleted

The R2R→interpreter thunk is needed whenever a caller reaches an interpreted method through a materialized native entry point — i.e. any call_indirect: ldftn/delegate/virtual dispatch, plus every R2R→interpreter call. On wasm that entry point is an index into __indirect_function_table, and the thunk is the function that builds the interpreter frame and enters it with the right signature.

crossgen2 emits these thunks (WasmR2RToInterpreterThunkNode, keyed 'I'+sig) into each R2R image, discovered at runtime via LookupPregeneratedThunkByString. That source exists only for shapes a loaded, crossgen'd module rooted.

Pure interpreter loads zero R2R images. With DOTNET_ReadyToRun=0, or simply when nothing was crossgen'd (dev inner-loop), no R2R module is present.

But the interpreter still needs a portable entry point the instant managed code takes a delegate/function-pointer to an interpreted method.

This leaves us with

  • pre-generated C thunks for known signatures.
  • crossgen'd thunks
    • R2R webcil which even pure interp would load module to be able to install those thunks
    • LLVM linkable .o file that dotnet.native.wasm would link

Both options are incomplete because we don't pre-create thunks for signatures in the app assemblies.
Doing so means running crossgen in the dev-loop too.

None of this is on critical path for me, to unblock Blazor on R2R. I'm closing this and we will re-consider later.

I'm back to #132926

cc @jkotas@davidwrighton

@jkotas

jkotas commented Aug 31, 2026

Copy link
Copy Markdown
Member

Pure interpreter loads zero R2R images. With DOTNET_ReadyToRun=0, or simply when nothing was crossgen'd (dev inner-loop), no R2R module is present.

Right. It means that you should not need any R2R->interpreter thunks (there is no R2R code), managed->managed calls should be going through interpreter without any intermediate thunks, and managed<->native calls should be covered by thunks generated by the pre-generated C thunks.

Can you share a few examples of situations that are causing problems once the hardcoded list of thunks is deleted?

I'm back to #132926

The change in #132926 is dead-end.

@pavelsavara

pavelsavara commented Sep 1, 2026

Copy link
Copy Markdown
MemberAuthor

It means that you should not need any R2R->interpreter thunks (there is no R2R code)

Correct no R2R code.

and managed<->native calls should be covered by thunks generated by the pre-generated C thunks.

That's what #132926 is generating.
I think the subject of that PR is confusion, it should speak about "interp-to-native-entrypoint" thunks rather than "interp-to-R2R" thunks. I will update that PR.

Can you share a few examples of situations that are causing problems once the hardcoded list of thunks is deleted?

On wasm a native function pointer is a typed index into the function table. Even a pure interpreter must (A) convert a managed method (delegate / ldftn) into such a native pointer, and (B) have that pointer be callable — from the interpreter and from native/host code — which requires a real, per-signature wasm function (the thunk) that marshals the typed args and re-enters the interpreter.

  • Default constructorscallhelpers.cpp:581: RuntimeHelpers.CallDefaultConstructor does a calli ctorCode. In pure interp that helper runs interpreted, and its calli calls through _pActualCode = the thunk.
  • Finalizerscomutilnative.cpp:797: RunFinalizers invokes the finalizer via its function pointer.
  • Class constructorsmethodtable.cpp:3580: CallClassConstructor invokes the cctor via its function pointer.
  • Plus the original CI failure: Dictionary.Add materializing an interpreted comparer's entry point.

@jkotas

Copy link
Copy Markdown
Member

On wasm a native function pointer is a typed index into the function table. Even a pure interpreter must (A) convert a managed method (delegate / ldftn) into such a native pointer, and (B) have that pointer be callable

That's not correct. Pure interpreter is expected to use portable-entry-point for managed method delegate. This portable-entry-point does not have to have _pActualCode filled in when the thunk does not exist.

if (PortableEntryPoint::PrefersInterpreterEntryPoint(calliFunctionPointer) || !PortableEntryPoint::HasNativeEntryPoint(calliFunctionPointer))
gotoCALL_INTERP_METHOD;
is expected to detect this situation and keep interpreting the call without ever calling via _pActualCode.

@jkotas

Copy link
Copy Markdown
Member

Do you see the condition that I have linked to be false in the problematic cases? There are multiple clones of this condition in interpexec.cpp handling different types of calls.

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

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@pavelsavara@jkotas
, 'i'); if (__m === '*' || __re.test(location.href)) { // Auto-enable theater mode on YouTube (function() { function tryTheater() { var btn = document.querySelector('button[aria-label="Theater mode"], ytd-player #player button[title="Theater mode"]'); if (btn && !btn.classList.contains('activated')) { btn.click(); } } // Try immediately tryTheater(); // Try after navigation (SPA) var lastUrl = location.href; setInterval(function() { if (location.href !== lastUrl) { lastUrl = location.href; setTimeout(tryTheater, 500); } }, 1000); // Also try on player load var observer = new MutationObserver(tryTheater); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

[wasm] Delete hand-written R2R-to-interpreter thunks; use crossgen2's - #132965

Closed
pavelsavara wants to merge 6 commits into
dotnet:mainfrom
pavelsavara:wasm_fix_r2r_interp_thunks
Closed

[wasm] Delete hand-written R2R-to-interpreter thunks; use crossgen2's#132965
pavelsavara wants to merge 6 commits into
dotnet:mainfrom
pavelsavara:wasm_fix_r2r_interp_thunks

Conversation

@pavelsavara

Copy link
Copy Markdown
Member

Addresses @jkotas's feedback on #132926: rather than grow another static table of hand-written R2R-to-interpreter thunks, delete them and rely on the ones crossgen2 already emits.

What

The R2R-to-interpreter transition thunks were a hand-written table in vm/wasm/helpers.cpp (g_wasmPortableEntryPointThunks + the CallInterpreter_* functions). crossgen2 already emits these thunks into each R2R image (WasmR2RToInterpreterThunkNode, keyed "I"+signature, from #127483), and the runtime discovers them by string via LookupPregeneratedThunkByString. The static table was therefore a buggy, incomplete duplicate that shadowed the correct crossgen2 thunks — it was checked first, yet had no float/double-return shape, among others.

This removes the table and routes LookupPortableEntryPointThunk solely through the R2R string hash. Pure interpreter keeps working via the existing deferral path (EnsurePortableEntryPointIsCallableFromR2RAddPendingPortableEntryPointThunk, resolved when an R2R module loads). helpers.cpp: −368 / +5.

Test

Adds src/tests/readytorun/wasm/WasmInterpreterTransitions, exercising both transition directions across struct (S8/S12/S16), scalar, void, and float/double returns. [BypassReadyToRun] + composite R2R ensures every cross-call actually hits a thunk (a wrong parameter order still type-checks under call_indirect, so the cases assert pinned values).

Validation (local, Windows host)

  • Browser CoreCLR: WasmInterpreterTransitions passes (Expected 100 / Actual 100).
  • Sibling browser R2R tests pass: WasmR2RStructAlignment, Runtime_131640.
  • WasmArgumentLayoutTests (crossgen2 lowering, unchanged here): 52 / 52.
  • console-node CoreCLR sample runs clean.
  • helpers.cpp compiles for both browser and wasi (the wasi target runtime builds).

Commits

  1. [wasm] Treat wasi as a cross-target in the Windows build scripts — a separable wasi-on-Windows build fix (also carried in [wasm] Generate the native-entry-point-to-interpreter thunk table #132926) so the wasi CoreCLR build configures on a Windows host. This is only one of the two separable wasi build fixes; the configureplatform.cmake cross-components fix is not included, so a full wasi build is not green from this PR alone. Can be split into its own PR if preferred.
  2. [wasm] Delete hand-written R2R-to-interpreter thunks; use crossgen2's.

Draft while gathering CI signal.

Note

This PR description was generated with AI assistance.

build-runtime.cmd and build-native.cmd only treated android and browser as cross-targets, so a wasi build on a Windows host ran copy_version_files.cmd (which copies only *.h/*.rc) instead of copy_version_files.ps1, which also generates _version.c, and CMake configure then failed with missing source files.
The R2R-to-interpreter transition thunks were a hand-written table in vm/wasm/helpers.cpp (g_wasmPortableEntryPointThunks plus the CallInterpreter_* functions). crossgen2 already emits these thunks into each R2R image (WasmR2RToInterpreterThunkNode, keyed 'I'+signature) and the runtime discovers them by string via LookupPregeneratedThunkByString, so the static table is a buggy, incomplete duplicate that shadows the correct crossgen2 thunks - it had no float/double-return shape, among others.
Remove the table and route LookupPortableEntryPointThunk solely through the R2R string hash. Pure interpreter keeps working via the existing deferral path (EnsurePortableEntryPointIsCallableFromR2R -> AddPendingPortableEntryPointThunk).
Add src/tests/readytorun/wasm/WasmInterpreterTransitions, which exercises both transition directions across struct, scalar, void, and float/double returns.
@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.

@pavelsavarapavelsavara added the arch-wasm WebAssembly architecture label Aug 31, 2026
@dotnet-policy-service

Copy link
Copy Markdown
Contributor

Tagging subscribers to this area: @JulieLeeMSFT, @BrzVlad, @janvorli
See info in area-owners.md if you want to be subscribed.

Comment threadsrc/coreclr/vm/wasm/helpers.cpp Outdated
Comment threadsrc/coreclr/vm/wasm/helpers.cpp Outdated
@pavelsavara

pavelsavara commented Aug 31, 2026

Copy link
Copy Markdown
MemberAuthor

Why the pregenerated portable-entry-point thunk table cannot be deleted

The R2R→interpreter thunk is needed whenever a caller reaches an interpreted method through a materialized native entry point — i.e. any call_indirect: ldftn/delegate/virtual dispatch, plus every R2R→interpreter call. On wasm that entry point is an index into __indirect_function_table, and the thunk is the function that builds the interpreter frame and enters it with the right signature.

crossgen2 emits these thunks (WasmR2RToInterpreterThunkNode, keyed 'I'+sig) into each R2R image, discovered at runtime via LookupPregeneratedThunkByString. That source exists only for shapes a loaded, crossgen'd module rooted.

Pure interpreter loads zero R2R images. With DOTNET_ReadyToRun=0, or simply when nothing was crossgen'd (dev inner-loop), no R2R module is present.

But the interpreter still needs a portable entry point the instant managed code takes a delegate/function-pointer to an interpreted method.

This leaves us with

  • pre-generated C thunks for known signatures.
  • crossgen'd thunks
    • R2R webcil which even pure interp would load module to be able to install those thunks
    • LLVM linkable .o file that dotnet.native.wasm would link

Both options are incomplete because we don't pre-create thunks for signatures in the app assemblies.
Doing so means running crossgen in the dev-loop too.

None of this is on critical path for me, to unblock Blazor on R2R. I'm closing this and we will re-consider later.

I'm back to #132926

cc @jkotas@davidwrighton

@jkotas

jkotas commented Aug 31, 2026

Copy link
Copy Markdown
Member

Pure interpreter loads zero R2R images. With DOTNET_ReadyToRun=0, or simply when nothing was crossgen'd (dev inner-loop), no R2R module is present.

Right. It means that you should not need any R2R->interpreter thunks (there is no R2R code), managed->managed calls should be going through interpreter without any intermediate thunks, and managed<->native calls should be covered by thunks generated by the pre-generated C thunks.

Can you share a few examples of situations that are causing problems once the hardcoded list of thunks is deleted?

I'm back to #132926

The change in #132926 is dead-end.

@pavelsavara

pavelsavara commented Sep 1, 2026

Copy link
Copy Markdown
MemberAuthor

It means that you should not need any R2R->interpreter thunks (there is no R2R code)

Correct no R2R code.

and managed<->native calls should be covered by thunks generated by the pre-generated C thunks.

That's what #132926 is generating.
I think the subject of that PR is confusion, it should speak about "interp-to-native-entrypoint" thunks rather than "interp-to-R2R" thunks. I will update that PR.

Can you share a few examples of situations that are causing problems once the hardcoded list of thunks is deleted?

On wasm a native function pointer is a typed index into the function table. Even a pure interpreter must (A) convert a managed method (delegate / ldftn) into such a native pointer, and (B) have that pointer be callable — from the interpreter and from native/host code — which requires a real, per-signature wasm function (the thunk) that marshals the typed args and re-enters the interpreter.

  • Default constructorscallhelpers.cpp:581: RuntimeHelpers.CallDefaultConstructor does a calli ctorCode. In pure interp that helper runs interpreted, and its calli calls through _pActualCode = the thunk.
  • Finalizerscomutilnative.cpp:797: RunFinalizers invokes the finalizer via its function pointer.
  • Class constructorsmethodtable.cpp:3580: CallClassConstructor invokes the cctor via its function pointer.
  • Plus the original CI failure: Dictionary.Add materializing an interpreted comparer's entry point.

@jkotas

Copy link
Copy Markdown
Member

On wasm a native function pointer is a typed index into the function table. Even a pure interpreter must (A) convert a managed method (delegate / ldftn) into such a native pointer, and (B) have that pointer be callable

That's not correct. Pure interpreter is expected to use portable-entry-point for managed method delegate. This portable-entry-point does not have to have _pActualCode filled in when the thunk does not exist.

if (PortableEntryPoint::PrefersInterpreterEntryPoint(calliFunctionPointer) || !PortableEntryPoint::HasNativeEntryPoint(calliFunctionPointer))
gotoCALL_INTERP_METHOD;
is expected to detect this situation and keep interpreting the call without ever calling via _pActualCode.

@jkotas

Copy link
Copy Markdown
Member

Do you see the condition that I have linked to be false in the problematic cases? There are multiple clones of this condition in interpexec.cpp handling different types of calls.

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

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@pavelsavara@jkotas
, 'i'); if (__m === '*' || __re.test(location.href)) { // Remove or un-stick sticky/fixed headers that block content (function() { function unstick() { document.querySelectorAll('header, nav, [role="banner"], .header, .navbar, .sticky, .fixed-top, [style*="position: fixed"], [style*="position:sticky"]').forEach(function(el) { if (el.style.position === 'fixed' || el.style.position === 'sticky' || getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') { el.style.position = 'static'; el.style.top = 'auto'; el.style.zIndex = 'auto'; } }); } unstick(); var observer = new MutationObserver(unstick); observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] }); })(); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

[wasm] Delete hand-written R2R-to-interpreter thunks; use crossgen2's - #132965

Closed
pavelsavara wants to merge 6 commits into
dotnet:mainfrom
pavelsavara:wasm_fix_r2r_interp_thunks
Closed

[wasm] Delete hand-written R2R-to-interpreter thunks; use crossgen2's#132965
pavelsavara wants to merge 6 commits into
dotnet:mainfrom
pavelsavara:wasm_fix_r2r_interp_thunks

Conversation

@pavelsavara

Copy link
Copy Markdown
Member

Addresses @jkotas's feedback on #132926: rather than grow another static table of hand-written R2R-to-interpreter thunks, delete them and rely on the ones crossgen2 already emits.

What

The R2R-to-interpreter transition thunks were a hand-written table in vm/wasm/helpers.cpp (g_wasmPortableEntryPointThunks + the CallInterpreter_* functions). crossgen2 already emits these thunks into each R2R image (WasmR2RToInterpreterThunkNode, keyed "I"+signature, from #127483), and the runtime discovers them by string via LookupPregeneratedThunkByString. The static table was therefore a buggy, incomplete duplicate that shadowed the correct crossgen2 thunks — it was checked first, yet had no float/double-return shape, among others.

This removes the table and routes LookupPortableEntryPointThunk solely through the R2R string hash. Pure interpreter keeps working via the existing deferral path (EnsurePortableEntryPointIsCallableFromR2RAddPendingPortableEntryPointThunk, resolved when an R2R module loads). helpers.cpp: −368 / +5.

Test

Adds src/tests/readytorun/wasm/WasmInterpreterTransitions, exercising both transition directions across struct (S8/S12/S16), scalar, void, and float/double returns. [BypassReadyToRun] + composite R2R ensures every cross-call actually hits a thunk (a wrong parameter order still type-checks under call_indirect, so the cases assert pinned values).

Validation (local, Windows host)

  • Browser CoreCLR: WasmInterpreterTransitions passes (Expected 100 / Actual 100).
  • Sibling browser R2R tests pass: WasmR2RStructAlignment, Runtime_131640.
  • WasmArgumentLayoutTests (crossgen2 lowering, unchanged here): 52 / 52.
  • console-node CoreCLR sample runs clean.
  • helpers.cpp compiles for both browser and wasi (the wasi target runtime builds).

Commits

  1. [wasm] Treat wasi as a cross-target in the Windows build scripts — a separable wasi-on-Windows build fix (also carried in [wasm] Generate the native-entry-point-to-interpreter thunk table #132926) so the wasi CoreCLR build configures on a Windows host. This is only one of the two separable wasi build fixes; the configureplatform.cmake cross-components fix is not included, so a full wasi build is not green from this PR alone. Can be split into its own PR if preferred.
  2. [wasm] Delete hand-written R2R-to-interpreter thunks; use crossgen2's.

Draft while gathering CI signal.

Note

This PR description was generated with AI assistance.

build-runtime.cmd and build-native.cmd only treated android and browser as cross-targets, so a wasi build on a Windows host ran copy_version_files.cmd (which copies only *.h/*.rc) instead of copy_version_files.ps1, which also generates _version.c, and CMake configure then failed with missing source files.
The R2R-to-interpreter transition thunks were a hand-written table in vm/wasm/helpers.cpp (g_wasmPortableEntryPointThunks plus the CallInterpreter_* functions). crossgen2 already emits these thunks into each R2R image (WasmR2RToInterpreterThunkNode, keyed 'I'+signature) and the runtime discovers them by string via LookupPregeneratedThunkByString, so the static table is a buggy, incomplete duplicate that shadows the correct crossgen2 thunks - it had no float/double-return shape, among others.
Remove the table and route LookupPortableEntryPointThunk solely through the R2R string hash. Pure interpreter keeps working via the existing deferral path (EnsurePortableEntryPointIsCallableFromR2R -> AddPendingPortableEntryPointThunk).
Add src/tests/readytorun/wasm/WasmInterpreterTransitions, which exercises both transition directions across struct, scalar, void, and float/double returns.
@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.

@pavelsavarapavelsavara added the arch-wasm WebAssembly architecture label Aug 31, 2026
@dotnet-policy-service

Copy link
Copy Markdown
Contributor

Tagging subscribers to this area: @JulieLeeMSFT, @BrzVlad, @janvorli
See info in area-owners.md if you want to be subscribed.

Comment threadsrc/coreclr/vm/wasm/helpers.cpp Outdated
Comment threadsrc/coreclr/vm/wasm/helpers.cpp Outdated
@pavelsavara

pavelsavara commented Aug 31, 2026

Copy link
Copy Markdown
MemberAuthor

Why the pregenerated portable-entry-point thunk table cannot be deleted

The R2R→interpreter thunk is needed whenever a caller reaches an interpreted method through a materialized native entry point — i.e. any call_indirect: ldftn/delegate/virtual dispatch, plus every R2R→interpreter call. On wasm that entry point is an index into __indirect_function_table, and the thunk is the function that builds the interpreter frame and enters it with the right signature.

crossgen2 emits these thunks (WasmR2RToInterpreterThunkNode, keyed 'I'+sig) into each R2R image, discovered at runtime via LookupPregeneratedThunkByString. That source exists only for shapes a loaded, crossgen'd module rooted.

Pure interpreter loads zero R2R images. With DOTNET_ReadyToRun=0, or simply when nothing was crossgen'd (dev inner-loop), no R2R module is present.

But the interpreter still needs a portable entry point the instant managed code takes a delegate/function-pointer to an interpreted method.

This leaves us with

  • pre-generated C thunks for known signatures.
  • crossgen'd thunks
    • R2R webcil which even pure interp would load module to be able to install those thunks
    • LLVM linkable .o file that dotnet.native.wasm would link

Both options are incomplete because we don't pre-create thunks for signatures in the app assemblies.
Doing so means running crossgen in the dev-loop too.

None of this is on critical path for me, to unblock Blazor on R2R. I'm closing this and we will re-consider later.

I'm back to #132926

cc @jkotas@davidwrighton

@jkotas

jkotas commented Aug 31, 2026

Copy link
Copy Markdown
Member

Pure interpreter loads zero R2R images. With DOTNET_ReadyToRun=0, or simply when nothing was crossgen'd (dev inner-loop), no R2R module is present.

Right. It means that you should not need any R2R->interpreter thunks (there is no R2R code), managed->managed calls should be going through interpreter without any intermediate thunks, and managed<->native calls should be covered by thunks generated by the pre-generated C thunks.

Can you share a few examples of situations that are causing problems once the hardcoded list of thunks is deleted?

I'm back to #132926

The change in #132926 is dead-end.

@pavelsavara

pavelsavara commented Sep 1, 2026

Copy link
Copy Markdown
MemberAuthor

It means that you should not need any R2R->interpreter thunks (there is no R2R code)

Correct no R2R code.

and managed<->native calls should be covered by thunks generated by the pre-generated C thunks.

That's what #132926 is generating.
I think the subject of that PR is confusion, it should speak about "interp-to-native-entrypoint" thunks rather than "interp-to-R2R" thunks. I will update that PR.

Can you share a few examples of situations that are causing problems once the hardcoded list of thunks is deleted?

On wasm a native function pointer is a typed index into the function table. Even a pure interpreter must (A) convert a managed method (delegate / ldftn) into such a native pointer, and (B) have that pointer be callable — from the interpreter and from native/host code — which requires a real, per-signature wasm function (the thunk) that marshals the typed args and re-enters the interpreter.

  • Default constructorscallhelpers.cpp:581: RuntimeHelpers.CallDefaultConstructor does a calli ctorCode. In pure interp that helper runs interpreted, and its calli calls through _pActualCode = the thunk.
  • Finalizerscomutilnative.cpp:797: RunFinalizers invokes the finalizer via its function pointer.
  • Class constructorsmethodtable.cpp:3580: CallClassConstructor invokes the cctor via its function pointer.
  • Plus the original CI failure: Dictionary.Add materializing an interpreted comparer's entry point.

@jkotas

Copy link
Copy Markdown
Member

On wasm a native function pointer is a typed index into the function table. Even a pure interpreter must (A) convert a managed method (delegate / ldftn) into such a native pointer, and (B) have that pointer be callable

That's not correct. Pure interpreter is expected to use portable-entry-point for managed method delegate. This portable-entry-point does not have to have _pActualCode filled in when the thunk does not exist.

if (PortableEntryPoint::PrefersInterpreterEntryPoint(calliFunctionPointer) || !PortableEntryPoint::HasNativeEntryPoint(calliFunctionPointer))
gotoCALL_INTERP_METHOD;
is expected to detect this situation and keep interpreting the call without ever calling via _pActualCode.

@jkotas

Copy link
Copy Markdown
Member

Do you see the condition that I have linked to be false in the problematic cases? There are multiple clones of this condition in interpexec.cpp handling different types of calls.

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

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@pavelsavara@jkotas
, 'i'); if (__m === '*' || __re.test(location.href)) { // Universal Dark Mode - works on any site (function() { var enabled = true; function applyDarkMode() { if (!enabled) return; // Create style element if it doesn't exist var style = document.getElementById('universal-dark-mode-style'); if (!style) { style = document.createElement('style'); style.id = 'universal-dark-mode-style'; document.head.appendChild(style); } // Dark mode CSS - inverts colors but preserves images/video style.textContent = ' /* Invert everything except media */ html { filter: invert(1) hue-rotate(180deg) !important; background: #1a1a2e !important; } /* Restore images, videos, iframes, canvas */ img, video, iframe, canvas, svg, picture, [style*="background-image"] { filter: invert(1) hue-rotate(180deg) !important; } /* Preserve specific elements that should not be inverted */ .no-dark-mode, .no-dark-mode *, [data-theme="light"], [data-theme="light"], .ace_editor, .ace_editor *, .CodeMirror, .CodeMirror *, .monaco-editor, .monaco-editor *, .markdown-body pre, .markdown-body pre *, .highlight, .highlight *, pre code, pre code * { filter: none !important; } /* Fix common UI elements */ .modal, .popup, .dropdown-menu, .tooltip, .popover { filter: invert(1) hue-rotate(180deg) !important; background: #2d2d44 !important; border-color: #444 !important; } /* Scrollbars */ ::-webkit-scrollbar { background: #1a1a2e !important; } ::-webkit-scrollbar-thumb { background: #444 !important; } ::-webkit-scrollbar-thumb:hover { background: #555 !important; } /* Selection */ ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; } ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; } '; } function removeDarkMode() { var style = document.getElementById('universal-dark-mode-style'); if (style) style.remove(); } // Toggle with Alt+Shift+D document.addEventListener('keydown', function(e) { if (e.altKey && e.shiftKey && e.key === 'D') { e.preventDefault(); enabled = !enabled; if (enabled) { applyDarkMode(); console.log('[Universal Dark Mode] Enabled'); } else { removeDarkMode(); console.log('[Universal Dark Mode] Disabled'); } } }); // Apply on load applyDarkMode(); // Re-apply on dynamic content var observer = new MutationObserver(function(mutations) { if (enabled && !document.getElementById('universal-dark-mode-style')) { applyDarkMode(); } }); observer.observe(document.head, { childList: true }); console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle'); })(); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content

[wasm] Delete hand-written R2R-to-interpreter thunks; use crossgen2's - #132965

Closed
pavelsavara wants to merge 6 commits into
dotnet:mainfrom
pavelsavara:wasm_fix_r2r_interp_thunks
Closed

[wasm] Delete hand-written R2R-to-interpreter thunks; use crossgen2's#132965
pavelsavara wants to merge 6 commits into
dotnet:mainfrom
pavelsavara:wasm_fix_r2r_interp_thunks

Conversation

@pavelsavara

Copy link
Copy Markdown
Member

Addresses @jkotas's feedback on #132926: rather than grow another static table of hand-written R2R-to-interpreter thunks, delete them and rely on the ones crossgen2 already emits.

What

The R2R-to-interpreter transition thunks were a hand-written table in vm/wasm/helpers.cpp (g_wasmPortableEntryPointThunks + the CallInterpreter_* functions). crossgen2 already emits these thunks into each R2R image (WasmR2RToInterpreterThunkNode, keyed "I"+signature, from #127483), and the runtime discovers them by string via LookupPregeneratedThunkByString. The static table was therefore a buggy, incomplete duplicate that shadowed the correct crossgen2 thunks — it was checked first, yet had no float/double-return shape, among others.

This removes the table and routes LookupPortableEntryPointThunk solely through the R2R string hash. Pure interpreter keeps working via the existing deferral path (EnsurePortableEntryPointIsCallableFromR2RAddPendingPortableEntryPointThunk, resolved when an R2R module loads). helpers.cpp: −368 / +5.

Test

Adds src/tests/readytorun/wasm/WasmInterpreterTransitions, exercising both transition directions across struct (S8/S12/S16), scalar, void, and float/double returns. [BypassReadyToRun] + composite R2R ensures every cross-call actually hits a thunk (a wrong parameter order still type-checks under call_indirect, so the cases assert pinned values).

Validation (local, Windows host)

  • Browser CoreCLR: WasmInterpreterTransitions passes (Expected 100 / Actual 100).
  • Sibling browser R2R tests pass: WasmR2RStructAlignment, Runtime_131640.
  • WasmArgumentLayoutTests (crossgen2 lowering, unchanged here): 52 / 52.
  • console-node CoreCLR sample runs clean.
  • helpers.cpp compiles for both browser and wasi (the wasi target runtime builds).

Commits

  1. [wasm] Treat wasi as a cross-target in the Windows build scripts — a separable wasi-on-Windows build fix (also carried in [wasm] Generate the native-entry-point-to-interpreter thunk table #132926) so the wasi CoreCLR build configures on a Windows host. This is only one of the two separable wasi build fixes; the configureplatform.cmake cross-components fix is not included, so a full wasi build is not green from this PR alone. Can be split into its own PR if preferred.
  2. [wasm] Delete hand-written R2R-to-interpreter thunks; use crossgen2's.

Draft while gathering CI signal.

Note

This PR description was generated with AI assistance.

build-runtime.cmd and build-native.cmd only treated android and browser as cross-targets, so a wasi build on a Windows host ran copy_version_files.cmd (which copies only *.h/*.rc) instead of copy_version_files.ps1, which also generates _version.c, and CMake configure then failed with missing source files.
The R2R-to-interpreter transition thunks were a hand-written table in vm/wasm/helpers.cpp (g_wasmPortableEntryPointThunks plus the CallInterpreter_* functions). crossgen2 already emits these thunks into each R2R image (WasmR2RToInterpreterThunkNode, keyed 'I'+signature) and the runtime discovers them by string via LookupPregeneratedThunkByString, so the static table is a buggy, incomplete duplicate that shadows the correct crossgen2 thunks - it had no float/double-return shape, among others.
Remove the table and route LookupPortableEntryPointThunk solely through the R2R string hash. Pure interpreter keeps working via the existing deferral path (EnsurePortableEntryPointIsCallableFromR2R -> AddPendingPortableEntryPointThunk).
Add src/tests/readytorun/wasm/WasmInterpreterTransitions, which exercises both transition directions across struct, scalar, void, and float/double returns.
@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.

@pavelsavarapavelsavara added the arch-wasm WebAssembly architecture label Aug 31, 2026
@dotnet-policy-service

Copy link
Copy Markdown
Contributor

Tagging subscribers to this area: @JulieLeeMSFT, @BrzVlad, @janvorli
See info in area-owners.md if you want to be subscribed.

Comment threadsrc/coreclr/vm/wasm/helpers.cpp Outdated
Comment threadsrc/coreclr/vm/wasm/helpers.cpp Outdated
@pavelsavara

pavelsavara commented Aug 31, 2026

Copy link
Copy Markdown
MemberAuthor

Why the pregenerated portable-entry-point thunk table cannot be deleted

The R2R→interpreter thunk is needed whenever a caller reaches an interpreted method through a materialized native entry point — i.e. any call_indirect: ldftn/delegate/virtual dispatch, plus every R2R→interpreter call. On wasm that entry point is an index into __indirect_function_table, and the thunk is the function that builds the interpreter frame and enters it with the right signature.

crossgen2 emits these thunks (WasmR2RToInterpreterThunkNode, keyed 'I'+sig) into each R2R image, discovered at runtime via LookupPregeneratedThunkByString. That source exists only for shapes a loaded, crossgen'd module rooted.

Pure interpreter loads zero R2R images. With DOTNET_ReadyToRun=0, or simply when nothing was crossgen'd (dev inner-loop), no R2R module is present.

But the interpreter still needs a portable entry point the instant managed code takes a delegate/function-pointer to an interpreted method.

This leaves us with

  • pre-generated C thunks for known signatures.
  • crossgen'd thunks
    • R2R webcil which even pure interp would load module to be able to install those thunks
    • LLVM linkable .o file that dotnet.native.wasm would link

Both options are incomplete because we don't pre-create thunks for signatures in the app assemblies.
Doing so means running crossgen in the dev-loop too.

None of this is on critical path for me, to unblock Blazor on R2R. I'm closing this and we will re-consider later.

I'm back to #132926

cc @jkotas@davidwrighton

@jkotas

jkotas commented Aug 31, 2026

Copy link
Copy Markdown
Member

Pure interpreter loads zero R2R images. With DOTNET_ReadyToRun=0, or simply when nothing was crossgen'd (dev inner-loop), no R2R module is present.

Right. It means that you should not need any R2R->interpreter thunks (there is no R2R code), managed->managed calls should be going through interpreter without any intermediate thunks, and managed<->native calls should be covered by thunks generated by the pre-generated C thunks.

Can you share a few examples of situations that are causing problems once the hardcoded list of thunks is deleted?

I'm back to #132926

The change in #132926 is dead-end.

@pavelsavara

pavelsavara commented Sep 1, 2026

Copy link
Copy Markdown
MemberAuthor

It means that you should not need any R2R->interpreter thunks (there is no R2R code)

Correct no R2R code.

and managed<->native calls should be covered by thunks generated by the pre-generated C thunks.

That's what #132926 is generating.
I think the subject of that PR is confusion, it should speak about "interp-to-native-entrypoint" thunks rather than "interp-to-R2R" thunks. I will update that PR.

Can you share a few examples of situations that are causing problems once the hardcoded list of thunks is deleted?

On wasm a native function pointer is a typed index into the function table. Even a pure interpreter must (A) convert a managed method (delegate / ldftn) into such a native pointer, and (B) have that pointer be callable — from the interpreter and from native/host code — which requires a real, per-signature wasm function (the thunk) that marshals the typed args and re-enters the interpreter.

  • Default constructorscallhelpers.cpp:581: RuntimeHelpers.CallDefaultConstructor does a calli ctorCode. In pure interp that helper runs interpreted, and its calli calls through _pActualCode = the thunk.
  • Finalizerscomutilnative.cpp:797: RunFinalizers invokes the finalizer via its function pointer.
  • Class constructorsmethodtable.cpp:3580: CallClassConstructor invokes the cctor via its function pointer.
  • Plus the original CI failure: Dictionary.Add materializing an interpreted comparer's entry point.

@jkotas

Copy link
Copy Markdown
Member

On wasm a native function pointer is a typed index into the function table. Even a pure interpreter must (A) convert a managed method (delegate / ldftn) into such a native pointer, and (B) have that pointer be callable

That's not correct. Pure interpreter is expected to use portable-entry-point for managed method delegate. This portable-entry-point does not have to have _pActualCode filled in when the thunk does not exist.

if (PortableEntryPoint::PrefersInterpreterEntryPoint(calliFunctionPointer) || !PortableEntryPoint::HasNativeEntryPoint(calliFunctionPointer))
gotoCALL_INTERP_METHOD;
is expected to detect this situation and keep interpreting the call without ever calling via _pActualCode.

@jkotas

Copy link
Copy Markdown
Member

Do you see the condition that I have linked to be false in the problematic cases? There are multiple clones of this condition in interpexec.cpp handling different types of calls.

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

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@pavelsavara@jkotas