Skip to content

[wasm] Carry R2R function names in a name section, not the export table - #132906

Merged
lewing merged 3 commits into
mainfrom
lewing-wasm-r2r-name-section
Aug 28, 2026
Merged

[wasm] Carry R2R function names in a name section, not the export table#132906
lewing merged 3 commits into
mainfrom
lewing-wasm-r2r-name-section

Conversation

@lewing

@lewing lewing commented Aug 28, 2026

Copy link
Copy Markdown
Member

Fixes #132905.

WebCilObjectWriter.WriteExports exported every defined function. At framework scale that produces a module no conforming engine will load, because exports count towards the engine's effective-type-size limit.

Measured over the full wasi-wasm framework closure (181 assemblies + corelib), with function count identical either side so the export section is the only variable:

functions exports export section wasm-tools validate
before 232,673 232,675 29.0 MB effective type size exceeds the limit of 1000000
after 232,673 4 65 bytes ✅ valid

wasmtime rejects the "before" image identically — both use wasmparser underneath. The limit is reached somewhere around 160,000 exported functions, so a small composite is unaffected and only framework-sized ones fail.

Why the exports were there, and why nothing needs them

The V0 writer exported bodies "so that the resulting module can be loaded and method bodies can be called by an external loader" (#122111). But WriteElements() — immediately below WriteExports() — already emits an element segment over all function indices, and that, not the export table, is what makes a function reachable. There was a TODO-WASM: Handle exports better sitting above the loop.

Exports do not participate in call resolution

Worth stating explicitly, since it is the obvious objection:

  • A direct call takes a function index, not a name, so intra-module calls are unaffected by the export table.
  • A cross-module call appears as a function import in the calling module, resolved against the provider's exports. Neither a composite nor a single-assembly image has any function imports — both carry the same seven: four globals, a table, a tag, and memory. There is nothing to pair.
  • Exports matter only when something outside looks a function up by name: a host at instantiation, or wasm-merge pairing an import to an export. The stubs that serve that purpose are kept.

Cross-module linkage is the shared table, not imports. Every image is instantiated against the host's single table with its own tableBase, its functions are installed into that slice, and a cross-assembly call resolves through an R2R fixup cell to an absolute table index dispatched by call_indirect. That mechanism is identical in composite and non-composite builds; only the number of modules sharing the table differs.

The one mechanism by which removing exports could invalidate a module is ref.func, which requires its target to be "declared" — via an export, a global initializer, or an element segment. That is not in play on two independent counts: these images contain no ref.func at all, and the element segment already declares every function regardless.

For reference, R2R wasm today emits no direct calls whatsoever. A census over decoded WAT finds 0 call and 202,143 call_indirect in a 56,790-function composite, and 0 call in single-assembly images as well. That is context rather than a dependency: the reasoning above holds either way.

Also verified that neither JS loader reads per-function exports (fillWebcilTable is table.init over the element segment), nor does WebcilImageReader or r2rdump.

The catch, and the reason this is not just a deletion

crossgen2 emits no custom sections, so the export names were the only record of function names — wasm-merge -g synthesizes a merged module's name section from them. Simply dropping the exports would leave every composite frame anonymous in a debugger.

So this emits a name custom section instead. It is ignored by engines, counts towards no limit, and can be stripped when size matters — which is the only arrangement that gets loadable-at-scale, named, and small at the same time.

Only the stubs the host actually calls are exported now. Stub names are recorded at InsertWasmStub, so the export set is derived rather than hardcoded.

Effect by image kind

image functions exports name section
composite 52,693 4 4.8 MB
component stub 3 5 — unchanged 53 B
single-assembly 31 4 2 KB

Component stubs are unaffected, since all of their functions are stubs.

Validation

  • Reproduced the failure before fixing it, so the pass distinguishes something.
  • Name section verified index-sorted, duplicate-free, contiguous over the defined-function range, no trailing bytes, by three independent parsers (wasm-tools validate, wasm-tools print resolving $getWebcilSize, and a standalone scanner).
  • Browser end-to-end in both composite and non-composite modes, each against a control with R2R disabled and, for composite, a stale-stub control. R2R-active runs at 5 ms against 112–119 ms interpreted, with identical program output. A passing run alone would not have shown R2R was used — a stale stub still logs Ready to Run initialized successfully while running fully interpreted — so the timing separation is what carries it.
  • The non-composite run is the sharpest evidence that exports are not the linkage mechanism: System.Private.CoreLib goes from 52,454 functions to 5 exports, and the other images still call into it. Its per-module R2R log is byte-identical to the pre-change baseline.
  • ILCompiler.ReadyToRun.Tests: 55/0 for TargetOS=browser, 85/0 for the default target.

The second commit addresses review feedback: the payload was built through chained MemoryStreams and a final ToArray(), so it is now sized in one pass and streamed to the output. The emitted bytes are unchanged — the name section is byte-identical before and after the rewrite, confirmed by sha256 over the 28 MB framework-scale section. No memory measurement is claimed: peak RSS at framework scale is dominated by crossgen2's ~2.7 GiB compilation working set, and run-to-run spread there is wider than the effect, so RSS is the wrong instrument.

Test change

CheckFunctionExports asserted that exports cover every defined function — the invariant being removed. It is replaced by a stronger one: the name section must name every defined function, and index i must equal importedFunctionCount + i.

The exact-index requirement is deliberate. Count, ordering, uniqueness and a lower bound are all relational properties that a uniformly shifted map satisfies, so only an absolute anchor excludes an off-by-one — which is exactly the failure mode a name section has to be trusted not to have. Confirmed by negative controls: suppressing the name section, and emitting Index + 1, each fail the test with a specific diagnostic.

Note

This pull request description was generated by GitHub Copilot.

lewing and others added 3 commits August 28, 2026 14:51
WriteExports exported every defined function, which does not scale: exports
count towards the engine's effective-type-size limit, and a composite over the
framework closure exceeds it. Measured on 181 assemblies / 232,673 functions,
where the export section alone is 29 MB and both wasm-tools and wasmtime
reject the image with "effective type size exceeds the limit of 1000000".

Nothing needed those exports. The element segment, not the export table, is
what makes a function reachable, and a self-installing image has the engine
install that segment at instantiation. But the export names were the only
record of function names, since crossgen2 emits no custom sections, so simply
dropping them would leave every composite frame anonymous in a debugger.

Emit a name custom section instead and export only the stubs the host calls.
A custom section is ignored by engines, counts towards no limit, and can be
stripped when size matters. The framework composite now validates with 4
exports totalling 65 bytes.

Replace the test's export-covers-every-function assertion, which encoded the
old invariant, with the stronger one that replaces it: the name section must
name every defined function, sorted and duplicate-free.

Fixes #132905

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Two review findings on the name section.

The payload was built through three chained MemoryStreams and a final
ToArray(), so a framework-sized composite held several multiples of a ~28 MB
payload on the large object heap at once. Size it in a first pass instead and
write it straight to the output stream, which costs one array of symbol
references and nothing else. The emitted bytes are unchanged.

The test checked count, ordering, uniqueness and the lower bound of the name
map, but not that its indices cover the defined-function range exactly, so a
uniformly shifted map would have passed - which is precisely the off-by-one a
name section has to be trusted not to have. Require index i to equal
importedFunctionCount + i.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
The format never required exporting them; crossgen2 simply exported more than
the contract asked for. State the constraint and where function names live
now, so the next reader does not reintroduce it.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
@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.

@lewing lewing added the arch-wasm WebAssembly architecture label Aug 28, 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.

@lewing
lewing marked this pull request as ready for review August 28, 2026 20:22
Copilot AI lite review requested due to automatic review settings August 28, 2026 20:22
@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.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

This PR addresses WebAssembly engine validation failures at framework scale by stopping the WebCil wasm object writer from exporting every defined function (which bloats the export section and can exceed effective type-size limits). Instead, it preserves function names by emitting a name custom section and restricts the export table to only the small set of host-called stub functions.

Changes:

  • Emit a wasm name custom section containing function names for all defined functions.
  • Export only writer-inserted stub functions (tracked at stub insertion time) rather than all functions.
  • Update ReadyToRun wasm assertions to validate the name section mapping, and document the new constraint.

Reviewed changes

Copilot reviewed 6 out of 6 changed files in this pull request and generated 1 comment.

Show a summary per file
File Description
src/coreclr/tools/Common/Compiler/ObjectWriter/WebCilObjectWriter.cs Appends a name section at the end of the module; limits function exports to host-called stubs.
src/coreclr/tools/Common/Compiler/ObjectWriter/WasmObjectWriter.cs Tracks writer-inserted stub names to drive the reduced export set.
src/coreclr/tools/Common/Compiler/ObjectWriter/Wasm/WasmNameSection.cs Implements emission of the name custom section’s function-name subsection.
src/coreclr/tools/aot/ILCompiler.ReadyToRun/ILCompiler.ReadyToRun.csproj Includes the new WasmNameSection.cs in the ReadyToRun tool build.
src/coreclr/tools/aot/ILCompiler.ReadyToRun.Tests/TestCasesRunner/WasmR2RAssert.cs Reworks tests to assert stub-only exports and validate the name section covers defined functions correctly.
docs/design/mono/webcil.md Documents that compiled method bodies should not be exported en masse; names live in the name section.

Comment thread docs/design/mono/webcil.md
@lewing
lewing enabled auto-merge (squash) August 28, 2026 21:10
@lewing
lewing merged commit d2ea726 into main Aug 28, 2026
122 checks passed
@lewing
lewing deleted the lewing-wasm-r2r-name-section branch August 28, 2026 23:31
lewing added a commit to lewing/runtime that referenced this pull request Aug 29, 2026
Measured on a 232,673-function post-dotnet#132906 composite: wasm-merge peaks
at 4.25 GB RSS and wasm-opt at 2.70 GB, so a 4 GB CI container will not
survive the merge. The peak is the whole working set rather than a
delta, which is why it is measurable here when the earlier attempt to
see a 28 MB allocation change inside crossgen2 was not.

Also records that wasm-merge renumbers the name map alongside the
functions -- the composite's function 0 lands at merged index 10,105 --
and that dropping -g from the fold removes the name section while
wasm-tools validate still answers YES.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 7b49a31b-9632-4a48-bab4-bfcc98487a5f
@dotnet-milestone-bot dotnet-milestone-bot Bot added this to the 12.0-preview1 milestone Aug 29, 2026
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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[wasi] Composite R2R images export every function, exceeding the engine type-size limit at framework scale

3 participants