[wasm] Carry R2R function names in a name section, not the export table - #132906
Merged
Conversation
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: 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. |
pavelsavara
approved these changes
Aug 28, 2026
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. |
Contributor
There was a problem hiding this comment.
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
namecustom 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
namesection 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. |
lewing
enabled auto-merge (squash)
August 28, 2026 21:10
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
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.
Fixes #132905.
WebCilObjectWriter.WriteExportsexported 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:
wasm-tools validateeffective type size exceeds the limit of 1000000wasmtimerejects the "before" image identically — both usewasmparserunderneath. 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 belowWriteExports()— already emits an element segment over all function indices, and that, not the export table, is what makes a function reachable. There was aTODO-WASM: Handle exports bettersitting above the loop.Exports do not participate in call resolution
Worth stating explicitly, since it is the obvious objection:
calltakes a function index, not a name, so intra-module calls are unaffected by the export table.wasm-mergepairing 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 bycall_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 noref.funcat 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
calland 202,143call_indirectin a 56,790-function composite, and 0callin 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 (
fillWebcilTableistable.initover the element segment), nor doesWebcilImageReaderor 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 -gsynthesizes 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
namecustom 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
Component stubs are unaffected, since all of their functions are stubs.
Validation
wasm-tools validate,wasm-tools printresolving$getWebcilSize, and a standalone scanner).Ready to Run initialized successfullywhile running fully interpreted — so the timing separation is what carries it.System.Private.CoreLibgoes 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 forTargetOS=browser, 85/0 for the default target.The second commit addresses review feedback: the payload was built through chained
MemoryStreams and a finalToArray(), 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
CheckFunctionExportsasserted 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 indeximust equalimportedFunctionCount + 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.