Skip to content

[wasm] Generate the R2R-to-interpreter thunk table - #132926

Draft
pavelsavara wants to merge 11 commits into
dotnet:mainfrom
pavelsavara:wasm_thunk_signatures
Draft

[wasm] Generate the R2R-to-interpreter thunk table#132926
pavelsavara wants to merge 11 commits into
dotnet:mainfrom
pavelsavara:wasm_thunk_signatures

Conversation

@pavelsavara

Copy link
Copy Markdown
Member

Summary

The R2R-to-interpreter ('I') thunks were hand-written C++ in vm/wasm/helpers.cpp, so every call
shape a compiled app reached needed a hand-authored thunk. The WasmAppBuilder generator now emits
that table for both targets.

17 hand-written entries become 70 generated, none left by hand, and helpers.cpp loses ~356
lines. 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

(callersStackPointer, [this], [retBuf], args..., portableEntrypoint)

reading the return buffer back from retBufLocalIndex = 1 + (hasThis ? 1 : 0)
(WasmR2RToInterpreterThunkNode.EmitCode). Two consequences the generator has to honour:

  • A thunk returning a struct is declared void with an explicit int8_t* retBuf. Returning the
    struct by value instead makes clang insert its own sret pointer at parameter 0, ahead of
    callersStackPointer. This applies in both directions.
  • retBuf follows this on an instance method rather than coming first.

Neither mistake 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 the corruption surfaces far from its cause.

Floating-point arguments are stored as their own bits rather than through an (int64_t) cast, which
would convert the value.

Changes

  • InterpToNativeGenerator — emits g_wasmGeneratedPortableEntryPointThunks; struct returns
    take an explicit retBuf in both directions; float and double arguments are stored as
    themselves.
  • PortableEntryPointThunkSignature (new) — owns the parameter ordering. The emitter builds its
    declarations from it and the tests assert against it, so a test cannot pass while the emitted file
    disagrees.
  • SignatureMapper — split into a partial class so the pure token half can be linked into the
    test project without dragging MSBuild into a compiler test assembly. The emitted tables are
    byte-identical across the split.
  • Multi-slot l2 — a 16-byte value (Int128/UInt128/Decimal128) carried in two i64
    parameters 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_RetS16 is identical to the hand-written thunk it replaces.
  • helpers.cpp — no hand-written thunks remain; the cache is populated unconditionally from the
    generated table. The missing-key diagnostic is kept and points at
    pregeneratedInterpreterToNativeSignatures.
  • CMakeLists.txt / callhelpers.hpp — the generated table is wired unconditionally for both
    browser 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.cmd and build-native.cmd did not treat wasi as a cross-target, so they ran
    copy_version_files.cmd (which copies only *.h and *.rc) instead of the .ps1 that also
    produces _version.c. CMake configure then failed with four Cannot find source file errors.
  • configureplatform.cmake keyed CLR_CMAKE_HOST_WASI off CLR_CMAKE_TARGET_OS while every other
    platform block in the file keys off CLR_CMAKE_HOST_OS. A wasi cross-components build, which
    compiles host tools with MSVC, therefore got CLR_CMAKE_HOST_UNIX=1 and
    CLR_CMAKE_HOST_ARCH=wasm, and cl.exe received 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
NoInlining so 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 and
    the generator must produce the same wasm parameter arity and types for the resulting key.
  • ThunkParametersFollowCrossgen2Order — 7 cases pinning parameter positions. Separate from the
    above 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 wasi clr including the generated wasi table, verified
compiled and linked rather than merely generated.

Known gaps

  • This does not make R2R work on wasi. An R2R image is a wasm module that must be instantiated at
    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 and
    links but nothing reaches it yet. CrossGen2OutputFormat=wasm is also still browser-only in
    src/tests.
  • No v128 shape is supported. V2/V4 (Vector256<T>/Vector512<T>) fail with a specific
    message 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-token
    error. 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.
  • The positional unit test is a pinning test, not an independent derivation. The expected order
    is transcribed from crossgen2's source, because it is not recoverable from WasmFuncType when the
    parameters are all i32. It fails if the generator is reordered; it would not notice if crossgen2
    changed its order. The runtime test covers that gap.
  • Only the two runtime tests above were run locally, not the wider browser CoreCLR test tree.

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.
@pavelsavara pavelsavara added this to the 12.0.0 milestone Aug 29, 2026
@pavelsavara pavelsavara self-assigned this Aug 29, 2026
@pavelsavara pavelsavara added arch-wasm WebAssembly architecture area-ReadyToRun os-browser Browser variant of arch-wasm labels Aug 29, 2026
@dotnet-policy-service

Copy link
Copy Markdown
Contributor

Tagging subscribers to 'arch-wasm': @lewing, @pavelsavara
See info in area-owners.md if you want to be subscribed.

@azure-pipelines

Copy link
Copy Markdown
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.

<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

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

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?

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

Labels

arch-wasm WebAssembly architecture area-ReadyToRun os-browser Browser variant of arch-wasm

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants