[wasm] Generate the R2R-to-interpreter thunk table - #132926
Draft
pavelsavara wants to merge 11 commits into
Draft
Conversation
Catches the checked-in table up with main: dotnet#132274 removed the only managed caller of compressBound(), so the P/Invoke is no longer in the shipping System.IO.Compression, and ZipArchive now reaches the native RNG directly.
The 'I' thunks that let R2R code call an interpreted method were hand-written
in vm/wasm/helpers.cpp, so every new call shape needed a hand-authored thunk.
Emit them from the WasmAppBuilder generator instead: 17 hand-written entries
become 69 generated plus 1, and helpers.cpp loses ~330 lines.
Fix two parameter transpositions that the struct-returning shapes hit as soon
as the generator started emitting them. crossgen2 lays the wasm parameters out
as (callersStackPointer, [this], [retBuf], args..., portableEntrypoint), reading
the buffer back from retBufLocalIndex = 1 + (hasThis ? 1 : 0):
- Returning the struct by value makes clang insert its own sret pointer at
parameter 0, ahead of callersStackPointer. These thunks are now void with an
explicit int8_t* retBuf.
- retBuf was placed first unconditionally; for an instance method it follows
'this'.
Neither is detectable at run time. The stack pointer, the return buffer, 'this'
and every by-reference argument are all i32, so a transposed order still passes
call_indirect type checking and instead writes the return value over the
caller's frame pointer, surfacing later as an unrelated NullReferenceException
or an out-of-bounds trap.
The generated table is browser-only; wasi keeps the hand-written thunk. The one
remaining hand-written entry is IS16l2ip, whose 'l2' argument (a 16-byte value
passed across two i64 parameters) maps one signature token to several C
parameters, which the generator cannot express yet.
SignatureMapper mixes two things: reflection over scanned assemblies, which needs a LogAdapter and so drags in Microsoft.Build, and a pure mapping from signature tokens to native types, which needs nothing. Make it partial and move the pure half out, so a test can compile it directly instead of pulling MSBuild into a compiler test assembly. No behaviour change: the generator emits a byte-identical portable entrypoint table and an identical interp-to-managed table afterwards.
The R2R-to-interpreter thunks are written by the WasmAppBuilder generator but called by code crossgen2 emits, and nothing at run time can detect a disagreement, so test that the two agree. GeneratedThunkMatchesLoweredWasmSignature lowers a managed signature with crossgen2 and requires the generator to produce the same wasm parameter arity and types for the resulting key. That catches a missing hidden return buffer, which is what returning the struct by value produces, but it cannot catch two same-typed parameters being swapped: 'this' and retBuf are both i32, so a transposition leaves the type sequence identical. ThunkParametersFollowCrossgen2Order covers the positions separately, which is the only way that case is visible. Both were checked by reintroducing each bug: the transposition fails 3 cases in the ordering theory and none elsewhere, and dropping the return buffer fails 10.
The generated portable entrypoint thunks wrote every argument through (int64_t), which converts a float or double to its integer value instead of storing its bits: 1.5 arrived as 1. The interpreter reads those slots back as ARG_F32/ARG_F64, so every floating point argument crossing an R2R to interpreter call was corrupt. This was a regression for Iidp and Ildp, whose hand-written thunks used a typed 'double args[1]' and stored the value correctly, and was wrong from the start for the float and double shapes the generator discovered on its own. The unit tests cannot see this: they compare parameter types and positions, not the stores. The runtime test added alongside covers it.
The struct-returning shapes added to the pregenerated cookie list feed both generators, and only the R2R-to-interpreter half was corrected: the interpreter-to-R2R thunks still called through a pointer declared as returning the struct by value, so the compiler inserted its own sret pointer at parameter 0, ahead of the stack pointer, while the R2R callee expects (callersStackPointer, [this], retBuf, args..., portableEntrypoint). Every parameter involved is an i32, so the mismatch passed call_indirect type checking and corrupted memory instead. It showed up as an out-of-bounds access during EventSource start-up, far from the call, and it broke tests that have nothing to do with struct returns: WasmR2RStructAlignment passes on main, passes with the P/Invoke table regenerated, and failed once the thunk table was generated. Native callees keep the by-value form, which is what their own C ABI gives them.
Methods marked BypassReadyToRun are skipped by crossgen2 and run interpreted while the rest of the assembly is compiled, so a single test assembly can put a thunk on a call in either direction. Cover the shapes the thunk table carries: struct returns of 8, 12 and 16 bytes from both instance and static methods, struct arguments, mixed float, double and long scalars, void, and an interpreted method calling back into compiled code. Every case checks a value rather than only that the call returned. Nothing here traps when it goes wrong: the stack pointer, the return buffer, 'this' and every by-reference argument are i32, so a thunk with its parameters in the wrong order still passes call_indirect type checking and quietly returns bad data. The callees are NoInlining so that an inlined callee cannot skip the transition and leave the test passing without exercising anything. This covers two bugs the unit tests structurally cannot reach, both found by running it: float and double arguments stored through an integer cast, and the interpreter-to-R2R struct return convention.
'l2' is a 16-byte value (Int128, UInt128, Decimal128) passed by value across two i64 wasm parameters. SignatureMapper rejected the token outright, so IS16l2ip had to stay hand-written: one signature token maps to several C parameters, which the generator could not express. Expand a multi-slot token into one parameter per slot in both directions, as arg<n>Lo and arg<n>Hi, stored into consecutive transition block slots and read back through consecutive ARG_I64 accessors. TokenToNativeType and TokenToArgType still reject an unexpanded multi-slot token, so one cannot quietly collapse into a single parameter -- the shape every parameter bug in this area has taken. 'V2' and 'V4' now fail with a specific message instead: these thunks have no portable spelling for a v128 and nothing generates one today. The generated CallInterpreter_L2_I32_RetS16 is identical to the hand-written thunk it replaces, which was itself verified against the wasm crossgen2 emits. This empties the hand-written table, so it is removed. Browser is unaffected; every thunk it uses is generated. wasi has no generated table yet, so it now has no portable entrypoint thunks at all and a call needing one reports a missing key. wasi had 17 before this series and needs its own generated table, which requires a wasi testhost to scan.
The wasi portable entrypoint table was left behind when the generator took over the browser one: wasi had 17 hand-written thunks on main, then 1, then none once the multi-slot shape removed the last of them. Generate wasi's table too, so it has the same 70 entries as browser, and drop the browser-only guards on the CMake source entry, the extern declarations and the cache population. The other wasi tables (interp-to-managed, pinvoke, reverse) are regenerated at the same time; they were stale against the current scan set. This restores wasi to the state it had before this series and no further. It does not make R2R work there: an R2R image is a wasm module that has to be instantiated at run time against the runtime's memory and indirect function table, which only the JavaScript host does (libCorerun.js, host/assets.ts). wasi has no equivalent, so its table stays latent until that exists. Generated but not compiled locally: 'build.cmd -os wasi -subset clr' fails on a Windows host because the cross-components build passes clang flags to cl.exe (D8021: invalid numeric argument '/Werror'), which predates this change. The table was produced from a managed-only 'clr.corelib+libs' build whose testhost matches browser's exactly -- 181 assemblies, no difference in either direction. CI's wasi leg is the first thing that will compile the file.
Every other platform block in configureplatform.cmake keys on CLR_CMAKE_HOST_OS. The wasi one keyed on CLR_CMAKE_TARGET_OS, so a wasi cross-components build - which compiles host tools with MSVC on Windows - still got CLR_CMAKE_HOST_UNIX=1 and CLR_CMAKE_HOST_ARCH=wasm. That handed cl.exe the clang flags from configurecompiler.cmake, failing with D8021 on /Werror.
Contributor
|
Tagging subscribers to 'arch-wasm': @lewing, @pavelsavara |
|
Azure Pipelines: Successfully started running 4 pipeline(s). 12 pipeline(s) were filtered out due to trigger conditions. There may be pipelines that require an authorized user to comment /azp run to run. |
jkotas
reviewed
Aug 29, 2026
| <EmbeddedResource Include="TestCases/**/*.cs" LogicalName="%(RecursiveDir)%(Filename)%(Extension)" /> | ||
| </ItemGroup> | ||
|
|
||
| <!-- The wasm R2R-to-interpreter thunks are emitted by these two files but called by code crossgen2 |
Member
There was a problem hiding this comment.
Should these thunks be generated by crossgen instead? Crossgen knows the exact set of signatures that need R2R-to-interpreter thunk.
Or is this PR just adding more throw-away workarounds?
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
The R2R-to-interpreter (
'I') thunks were hand-written C++ invm/wasm/helpers.cpp, so every callshape a compiled app reached needed a hand-authored thunk. The WasmAppBuilder generator now emits
that table for both targets.
17hand-written entries become70generated, none left by hand, andhelpers.cpploses ~356lines. Browser and wasi each get the same 70 entries.
The generated thunks follow the parameter convention crossgen2 emits, which the hand-written ones
did not implement correctly for struct returns or for floating-point arguments.
The parameter convention
crossgen2 lays out the wasm parameters of both thunk directions as
reading the return buffer back from
retBufLocalIndex = 1 + (hasThis ? 1 : 0)(
WasmR2RToInterpreterThunkNode.EmitCode). Two consequences the generator has to honour:voidwith an explicitint8_t* retBuf. Returning thestruct by value instead makes clang insert its own sret pointer at parameter 0, ahead of
callersStackPointer. This applies in both directions.retBuffollowsthison an instance method rather than coming first.Neither mistake is detectable at run time: the stack pointer, the return buffer,
thisand everyby-reference argument are all
i32, so a transposed order still passescall_indirecttypechecking, and the corruption surfaces far from its cause.
Floating-point arguments are stored as their own bits rather than through an
(int64_t)cast, whichwould convert the value.
Changes
InterpToNativeGenerator— emitsg_wasmGeneratedPortableEntryPointThunks; struct returnstake an explicit
retBufin both directions;floatanddoublearguments are stored asthemselves.
PortableEntryPointThunkSignature(new) — owns the parameter ordering. The emitter builds itsdeclarations from it and the tests assert against it, so a test cannot pass while the emitted file
disagrees.
SignatureMapper— split into apartialclass so the pure token half can be linked into thetest project without dragging MSBuild into a compiler test assembly. The emitted tables are
byte-identical across the split.
l2— a 16-byte value (Int128/UInt128/Decimal128) carried in twoi64parameters expands into one parameter per slot in both directions. The single-type accessors
reject an unexpanded token so one cannot quietly collapse into a single parameter. The generated
CallInterpreter_L2_I32_RetS16is identical to the hand-written thunk it replaces.helpers.cpp— no hand-written thunks remain; the cache is populated unconditionally from thegenerated table. The missing-key diagnostic is kept and points at
pregeneratedInterpreterToNativeSignatures.CMakeLists.txt/callhelpers.hpp— the generated table is wired unconditionally for bothbrowser and wasi.
Build fixes (separable)
Three commits fix a Windows-host wasi build, which could not configure or compile before them. They
are independent of the thunk work and can move to their own PR:
build-runtime.cmdandbuild-native.cmddid not treatwasias a cross-target, so they rancopy_version_files.cmd(which copies only*.hand*.rc) instead of the.ps1that alsoproduces
_version.c. CMake configure then failed with fourCannot find source fileerrors.configureplatform.cmakekeyedCLR_CMAKE_HOST_WASIoffCLR_CMAKE_TARGET_OSwhile every otherplatform block in the file keys off
CLR_CMAKE_HOST_OS. A wasi cross-components build, whichcompiles host tools with MSVC, therefore got
CLR_CMAKE_HOST_UNIX=1andCLR_CMAKE_HOST_ARCH=wasm, andcl.exereceived clang flags —D8021: invalid numeric argument '/Werror'.Both are invisible on a Linux host, where the wrong flags land on clang and are accepted.
Validation
Runtime test (
src/tests/readytorun/wasm/WasmInterpreterTransitions) —[BypassReadyToRun]makes crossgen2 skip selected methods so one assembly exercises both directions across struct
returns of 8/12/16 bytes, instance and static, struct arguments, mixed
float/double/long, void,and an interpreted callback into compiled code. Every case asserts a value, and callees are
NoInliningso an inlined callee cannot skip the transition and pass vacuously.Unit tests — 18 added to
WasmArgumentLayoutTests; 70 pass, 0 skipped:GeneratedThunkMatchesLoweredWasmSignature— 10 shapes; crossgen2 lowers a managed signature andthe generator must produce the same wasm parameter arity and types for the resulting key.
ThunkParametersFollowCrossgen2Order— 7 cases pinning parameter positions. Separate from theabove because comparing lowered parameter types cannot see an ordering bug when the transposed
parameters are both
i32.GenericContextArgumentFollowsTheReturnBuffer.Coverage — every signature key present on main is covered by the generated table, with no
duplicate keys.
Builds — browser
clr+libs+host, and wasiclrincluding the generated wasi table, verifiedcompiled and linked rather than merely generated.
Known gaps
run time against the runtime's linear memory and indirect function table, which only the JavaScript
host does (
libCorerun.js,host/assets.ts). wasi has no equivalent, so its table compiles andlinks but nothing reaches it yet.
CrossGen2OutputFormat=wasmis also still browser-only insrc/tests.V2/V4(Vector256<T>/Vector512<T>) fail with a specificmessage naming what is missing — no native type these thunks can spell, no interpreter stack
accessor, no known slot size — and a bare
V(Vector128<T>) falls to the generic invalid-tokenerror. crossgen2 and the runtime both encode these keys already; only the generator cannot emit a
thunk. Nothing in the cookie list or in interop needs one today, and interop rejects multi-slot
types up front with
WASM0068.is transcribed from crossgen2's source, because it is not recoverable from
WasmFuncTypewhen theparameters are all
i32. It fails if the generator is reordered; it would not notice if crossgen2changed its order. The runtime test covers that gap.