Skip to content

[wasm][coreclr] Enable composite ReadyToRun publishing - #134618

Merged
lewing merged 14 commits into
mainfrom
lewing-enable-wasm-composite-r2r
Oct 1, 2026
Merged

lewing merged 14 commits into
mainfrom
lewing-enable-wasm-composite-r2r

Conversation

@lewing

@lewing lewing commented Sep 24, 2026 •

Copy link
Copy Markdown
Member

Summary

  • Enable PublishReadyToRunComposite for CoreCLR browser-wasm when WebCIL is enabled. Composite is opt-in; the default per-assembly R2R mode is unchanged.
  • Publish the composite owner (<entry>.r2r.wasm, the name crossgen2 records in each component stub) as a static web asset with a readyToRunComposite trait, defined through DefineStaticWebAssets.
  • GenerateWasmBootJson routes the owner to resources.coreAssembly and flags it isCompositeImage: true. The loader keeps its .wasm virtual path and leaves it out of the TPA, and component stubs stay in the TPA. Owner identity comes from the flag, not the file name, so an ordinary library named *.r2r still loads as a managed assembly.
  • Route composite component stubs through ConvertDllsToWebcil, keep the linked R2R closure across repeated trimmed publishes, and prune stale per-app images and stubs.
  • Invalidate per-app R2R images when the composite mode or crossgen2 arguments change. Component stubs share the per-assembly <name>.wasm names, so without this, switching composite to per-assembly shipped stale stubs that failed at startup.
  • Composite needs ReadyToRun tasks that name the owner <entry>.r2r.wasm. The base SDK doesn't yet (Port runtime PR dotnet/runtime#133111 R2R changes to SDK Crossgen tasks sdk#56395), so a composite publish with stock SDK tasks fails fast with an actionable error. Wasm.Build.Tests use the in-repo Crossgen2Tasks, which now also ship in the BuildWasmApps Helix payload.

Validation

On main as of 2026-09-29, including WebCIL wrapper v2 (#134312), WASI composite in the shared Crossgen2Tasks (#133265), and the browser R2R strip defaults (#134690):

Benchmark (IndexOfMax<double>(3079), browser, earlier revision of this PR): composite R2R ran the SIMD path with a median of 4,040 ns/op, vs. 1,013,940 ns/op interpreted and 10,652,060 ns/op with per-assembly R2R.

Part of testing #134559. That issue's default per-assembly scenario is not changed by this PR.

Note

This pull request description was generated with GitHub Copilot assistance.

Enable composite R2R output for browser CoreCLR, load the owner image before runtime initialization, and preserve component stubs and linked inputs across incremental publishes. Fix WebCIL function relocations and add browser publish coverage.

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.

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.

Copilot review overview

🟡 Changes recommended

Composite mode is silently accepted during ordinary builds, and stale per-assembly R2R outputs may remain after switching to composite publishing.

Get a fresh assessment by requesting another Copilot review.

Review effort: Lite
Findings: 1 Medium severity

Open (1)
What changed in this PR

This PR enables composite ReadyToRun publishing for CoreCLR browser-WASM with WebCIL, including runtime loading, asset routing, relocation handling, and browser coverage.

Changes:

  • Routes composite assemblies through CoreCLR startup while retaining component stubs in the TPA.
  • Adds composite R2R staging, fingerprinting, invalidation, and WebCIL relocation support.
  • Adds browser tests and documentation.

Two moderate issues remain in CoreCLR target validation and stale R2R output cleanup.

File Summary
src/​tasks/​Microsoft.NET.Sdk.WebAssembly.Pack.Tasks/​GenerateWasmBootJson.cs Classifies composite images as core assemblies.
src/​native/​libs/​Common/​JavaScript/​loader/​run.ts Loads composite core assemblies before CoreCLR initialization.
src/​native/​libs/​Common/​JavaScript/​loader/​assets.ts Preserves composite .r2r.wasm paths.
src/​native/​libs/​Common/​JavaScript/​host/​host.ts Excludes composite images from the TPA.
src/​mono/​wasm/​Wasm.Build.Tests/​ReadyToRunTests.cs Adds composite publishing and browser coverage.
src/​mono/​nuget/​Microsoft.NET.Sdk.WebAssembly.Pack/​build/​Microsoft.NET.Sdk.WebAssembly.Browser.targets Publishes composite assets and manages invalidation stamps.
src/​mono/​nuget/​Microsoft.NET.Sdk.WebAssembly.Pack/​build/​Microsoft.NET.Sdk.WebAssembly.Browser.CoreCLR.targets Adds composite routing, validation, and output handling.
src/​coreclr/​tools/​Common/​Compiler/​ObjectWriter/​WebCilObjectWriter.cs Handles WebCIL function-index relocations.
docs/​workflow/​testing/​libraries/​testing-wasm.md Documents composite ReadyToRun testing.

@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.

The loader now decides once, while fetching resources.coreAssembly, whether
an asset is the composite ReadyToRun owner and records it on the asset; the
host builds the TPA from that flag instead of re-deriving it from the
.r2r.wasm suffix.

The composite test app now calls into the referenced R2rSuffixLibrary.r2r
at startup and asserts it loaded, so an ordinary library whose name ends in
.r2r is verified to load as a managed component rather than only checked in
the boot manifest.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

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.

Copilot review overview

🔵 Needs a closer look

The composite owner must be identified by its exact entry name, and download-then-create coverage is missing.

Review effort: Lite
Findings: 1 Medium severity

Open (1)

@pavelsavara

Copy link
Copy Markdown
Member
image

Comment thread src/mono/wasm/Wasm.Build.Tests/ReadyToRunTests.cs Outdated
Comment thread src/mono/wasm/Wasm.Build.Tests/ReadyToRunTests.cs
Comment thread src/native/libs/Common/JavaScript/loader/assets.ts Outdated
@pavelsavara

pavelsavara commented Sep 25, 2026 •

Copy link
Copy Markdown
Member

I assume we will want the shipping default for user apps to be PublishReadyToRunComposite=true.

@jkotas @davidwrighton Could you please explain what does "composite" mean technically for WASM ?
I think we can have "composite" also with multiple files.
I understand that direct calls are not possible across the .wasm module boundary.
I also understand that with --opt-cross-module:* we get VDID bubble and cross-assembly inlining.
What else does "composite" mode mean ?

My mental map:

  • A) right now we ship full eager R2R per assembly.
  • B) next step is --partial PGO, R2R file per assembly.
  • C) We could ship 2 parts: 1) eager downloaded PGO profile selected R2R composite, which would include IL. 2) optional post-start downloaded R2R "complement".

We can also ship

  • D) one full eager R2R monolith - like Mono AOT, but customers disliked the download/startup penalty.
  • E) post-start R2R module per assembly. This would be good for HTTP caches, when most of the app doesn't change every app release.
  • F) on-demand shards, we can shard the complement into even smaller chunks, which would be lazily downloaded only when some method gets hot enough. This is good for download bandwidth (azure egress cost), browser memory (especially on low range mobile devices), v8 JIT CPU time (mobile battery life)

A) is opt-in until we fix all/most library tests.
We need to make B) work now. For that it's important that R2R/interp split works between any 2 methods, we are not there yet. We need to setup library tests and perf lanes for it as we agreed with @lewing

In my testing of C) so far, loading the second half (of the same assembly) doesn't work for various reasons. I have not tested single-file composite yet.

The challenge for E) F) is with --opt-cross-module:* we don't know which assemblies inlined which code from others, so we don't know which of them we could keep and which to replace for the new app. I think whole app MVID is preventing it. Please advise.

I think C) is most likely, but I would not like to give up on possibility of F) just yet.

lewing and others added 2 commits September 25, 2026 09:42
Stock SDK ReadyToRun tasks name the browser composite owner '<entry>.r2r.dll'
and plan component outputs as '<name>.dll', while crossgen2 emits
'<entry>.r2r.wasm' plus '<name>.wasm' stubs. Fail fast with an actionable
error instead of a downstream ConvertDllsToWebcil file-not-found.

Ship the Crossgen2Tasks shim to BuildWasmApps Helix payloads and use it only
for composite Wasm.Build.Tests so per-assembly R2R keeps stock SDK coverage.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
- Boot config: flag the composite owner with isCompositeImage (new
  WasmResource/readyToRunComposite trait) instead of the loader inferring it
  from the .r2r.wasm file name.
- Define the composite owner static web asset with DefineStaticWebAssets
  rather than a hand-built candidate.
- Stale-prune keeps what crossgen2 actually emitted (compile outputs plus
  composite component stubs), not the task's publish plan.
- Move R2rSuffixLibrary into testassets; GetBootConfigPath picks the newest
  fingerprinted dotnet.js so composite assertions use the shared helper.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
lewing and others added 3 commits September 25, 2026 15:02
#132721 duplicated the composite and container-format errors into
_WasmCoreClrSelectR2RDirectories. That brought back the composite
rejection this PR removes and failed PublishRunAllPagesComposite on CI.
Keep the checks in _WasmCoreClrValidateReadyToRun, which runs first, and
move the new WasmPerformanceInstrumentation check there.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
…ish (#134689)

## Summary

Pass `--strip-debug-info` to crossgen2 for CoreCLR browser-wasm
ReadyToRun publish. This drops the R2R `DebugInfo` section (native-to-IL
offset maps and variable locations) from shipped images. Opt out with
`PublishReadyToRunStripDebugInfo=false` to keep the data for debugging
R2R code (e.g. the cDAC work in #133086 / #133890).

Also makes crossgen2 argument changes invalidate per-app R2R images.
`_CreateR2RImages` only tracks file inputs, so toggling
`PublishReadyToRunStripDebugInfo` (or any
`PublishReadyToRunCrossgen2ExtraArgs` change) previously left stale
images in `obj/R2R`. The arguments are now written to
`obj/wasm-r2r-args.stamp` (only when different) and added to
`_ReadyToRunCompilerInputs`, mirroring the existing P/Invoke manifest
input.

> [!IMPORTANT]
> Stacked on #134618, which rewrites the same targets file. Retarget to
`main` after it merges.

## Size impact

Per-assembly R2R images compiled directly with crossgen2 using the SDK's
browser-wasm arguments (`--obj-format:wasm --opt-cross-module:*
--codegenopt:JitWasm*NyiToR2RUnsupported=1`), with and without
`--strip-debug-info`. Total for System.Private.CoreLib,
System.Text.Json, System.Linq, and System.Collections:

| Compiler | Raw saved | gzip -9 saved | brotli -q 11 saved |
| --- | --- | --- | --- |
| Current (#134618 base) | 859,712 B (2.4%) | 624,690 B (7.1%) | 565,366
B (9.4%) |
| With #133086 variable info | 2,105,520 B (5.6%) | 1,332,733 B (13.9%)
| 1,121,574 B (17.0%) |

<details>
<summary>Per-assembly brotli sizes (bytes, keep → strip)</summary>

| Assembly | Current | With #133086 |
| --- | --- | --- |
| System.Private.CoreLib | 4,822,231 → 4,347,541 | 5,331,799 → 4,403,849
|
| System.Text.Json | 823,314 → 761,533 | 887,982 → 754,444 |
| System.Linq | 219,177 → 200,471 | 238,853 → 200,441 |
| System.Collections | 119,635 → 109,446 | 130,742 → 109,068 |

The #133086 compiler is based on an older commit, so compare keep vs.
strip within a column rather than across columns.
</details>

## Validation

- MSBuild evaluation: `--strip-debug-info` is present by default, absent
with `PublishReadyToRunStripDebugInfo=false`, and absent when
`PublishReadyToRun` is off.
- Incremental harness: `_CreateR2RImages` runs on first build, skips
when unchanged, reruns on opt-out, skips when repeated, and reruns when
switching back.
- Not yet run: an end-to-end browser-wasm publish loading stripped
images in a browser (Wasm.Build.Tests in CI will cover this).

Runtime-pack framework R2R images (used only by the dev-loop build) are
unchanged; publish recompiles the whole closure through these targets.
`--strip-inlining-info` is intentionally not included: it removes
`CrossModuleInlineInfo`, and cross-module inlining is load-bearing on
wasm, so it needs separate validation.

> [!NOTE]
> This pull request was created with assistance from GitHub Copilot.

---------

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
lewing and others added 2 commits September 25, 2026 23:07
#134690 adds --strip-debug-info and --strip-inlining-info for browser and
wasi ReadyToRun where the other strip defaults live, so the wasm pack
shouldn't add its own.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Composite component stubs are written as <name>.wasm, the same names as
per-assembly images. After a composite publish, a per-assembly republish
(-p:PublishReadyToRunComposite=false) found them up to date, pruned the
composite owner, and shipped stubs that fail startup. Add the mode to the
crossgen2 configuration stamp, and switch modes in the trimmed composite
test.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Comment thread src/tasks/Microsoft.NET.Sdk.WebAssembly.Pack.Tasks/BootJsonData.cs
Comment thread src/mono/wasm/Wasm.Build.Tests/ProjectProviderBase.cs Outdated
Comment thread src/libraries/sendtohelix-browser.targets
lewing added a commit that referenced this pull request Oct 1, 2026
## Why

CoreCLR WASI composite ReadyToRun currently names the composite owner
image `composite-r2r.wasm`. `PrepareForReadyToRunCompilation` in
Crossgen2Tasks forces that name whenever `Crossgen2Tool` TargetOS is
`wasi`. The browser composite work in #134618 uses the task's default
name instead: `<entry>.r2r.wasm`, the main assembly name with
`.r2r.wasm`.

This PR drops the TargetOS special case so WASI gets the same owner name
as browser. It lands ahead of the dotnet/sdk#56395 port, so that PR
doesn't carry the WASI rule into the SDK.

The two delivery models stay different on purpose. Browser instantiates
the composite at runtime through the JS loader. WASI composes it offline
into the host with `wasm-merge`/`wasm-opt`. Only the naming contract
changes.

## Design

The composite's own file name, as written by crossgen2, is the single
authority. It is also the owner name every component stub records.

- **`PrepareForReadyToRunCompilation`:** the `wasi` branch is removed. A
wasm composite owner is now `<entry>.r2r.wasm` on both targets.
- **`WasiApp.CoreCLR.targets`:** the composite path now comes from the
planned composite compilation, `@(_ReadyToRunCompileList)` with
`CreateCompositeImage=true`, via `%(OutputR2RImage)`. The build errors
unless there is exactly one composite compilation. Because the path
isn't hardcoded, WASI keeps working with either SDK naming.
- **`wasi_r2r_probe.hpp`:** the host reserves a 256-byte composite name
buffer, zero-initialized, and exports its address and capacity
(`wasi_r2r_composite_name_base`, `wasi_r2r_composite_name_cap`). The
probe serves the composite only when the requested name matches the
recorded name, and only if that name is non-empty. An uncomposed host
therefore serves no composite. There is no wildcard matching and no name
compiled into C++.
- **`ComposeWasiReadyToRun`:** the composer reads the two new exports
and checks that the name is non-empty, contains no NUL, and fits the
buffer. Its shim now imports `webcil.memory` and includes an active data
segment that writes `Path.GetFileName(CompositePath)`, NUL-terminated,
into the host buffer. This is the same mechanism that already installs
the payload. A prebuilt host, such as the runtime-test corerun, can
therefore be composed with a composite of any name.
- **Docs:** updated the WASI host composition section of
`docs/design/mono/webcil.md` and
`docs/workflow/building/coreclr/wasi-r2r.md`.

## Validation

- `./build.sh clr+libs+host+packs -os wasi -arch wasm -c Release`
finished with 0 warnings and 0 errors.
- `./dotnet.sh publish
src/mono/sample/wasi/console/Wasi.Console.Sample.csproj -c Release
-p:TargetOS=wasi -p:TargetArchitecture=wasm -p:RuntimeFlavor=CoreCLR
-p:PublishReadyToRun=true` succeeded. The composer logged
`composite='Wasi.Console.Sample.r2r.wasm'`.
- The published app ran under wasmtime with `DOTNET_ReadyToRunLogFile`
set. It exited 0, and the log shows `Ready to Run initialized
successfully` for System.Private.CoreLib, Wasi.Console.Sample,
System.Runtime, System.Console, System.Threading and
System.Runtime.InteropServices.
- **Negative check:** I composed the same host with a copy of the
composite renamed to `Wrong.r2r.wasm`. Startup then traps in `EEStartup`
because CoreLib's owner composite isn't served. A name mismatch fails
loudly rather than silently interpreting.
- **Non-R2R publish** of the same sample: the app runs on the
interpreter, and the log shows `Ready to Run header not found`.
- **Not run:** browser `ReadyToRunTests`. The removed code only executed
when TargetOS was `wasi`, and the browser naming path is unchanged.

## Interaction with #134813

This PR merges cleanly with #134813's head (checked with `git
merge-tree`); it doesn't depend on #134813. The runtime-test harness in
#134813 passes the same `composite-r2r.wasm` path to both crossgen2 and
`WasiR2RComposer.proj`. The composer records that name in the shared
corerun, so the harness needs no changes. I did not build or run the
runtime tests with both PRs merged.

## Follow-up in dotnet/sdk#56395

- Remove the `wasi` TargetOS block from `CreateReadyToRunFileToPublish`.
- Change `It_uses_the_fixed_wasm_path_for_a_wasi_composite_owner` to
expect `<entry>.r2r.wasm`, the same as browser.

> [!NOTE]
> This pull request description was generated with assistance from
GitHub Copilot.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
…unchanged

Mark the Crossgen2Tasks shim wiring (Helix payload, composite task
validation, test args) with TODO-WASM pointing at #135023.

Revert the newest-file selection in ProjectProviderBase.GetBootConfigPath.
Only the composite mode-switch republish leaves another mode's fingerprinted
dotnet.js behind, so clear the published _framework there instead. The mode
switch only needs obj to be incremental.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
@lewing

lewing commented Oct 1, 2026

Copy link
Copy Markdown
Member Author

/ba-g failures are all filed or known

@lewing
lewing merged commit fae2389 into main Oct 1, 2026
137 of 139 checks passed
@lewing
lewing deleted the lewing-enable-wasm-composite-r2r branch October 1, 2026 18:46
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

arch-wasm WebAssembly architecture area-Host

Projects

None yet

Development

Successfully merging this pull request may close these issues.

6 participants