Skip to content

[Proposal] Partitioned R2R WASM modules - #135544

Open
jtschuster wants to merge 8 commits into
dotnet:mainfrom
jtschuster:runtime-r2r-aux-module
Open

jtschuster wants to merge 8 commits into
dotnet:mainfrom
jtschuster:runtime-r2r-aux-module

Conversation

@jtschuster

@jtschuster jtschuster commented Oct 9, 2026 •

Copy link
Copy Markdown
Member

This doc outlines a proposal for splitting a single R2R module into multiple modules to reduce the cold download size and improve startup.

This is motivated by CoreCLR R2R having a significantly larger cold download size and worse startup than Mono. By only loading hot-path code in the first module and allowing that code to run while the rest is downloaded, we should be able to improve startup.

The initial implementation will be focused on single-threaded WASM and a single cold module, but the design shouldn't prohibit future support multi-threaded WASM or multiple auxiliary modules.

Original version was written by @davidwrighton.

@jtschuster jtschuster self-assigned this Oct 9, 2026
@jtschuster jtschuster added NO-MERGE The PR is not ready for merge yet (see discussion for detailed reasons) design-discussion Ongoing discussion about design without consensus arch-wasm WebAssembly architecture labels Oct 9, 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 1 pipeline(s).
15 pipeline(s) were filtered out due to trigger conditions.
There may be pipelines that require an authorized user to comment /azp run to run.

@dotnet-policy-service

Copy link
Copy Markdown
Contributor

Tagging subscribers to this area: @dotnet/area-meta
See info in area-owners.md if you want to be subscribed.

@dotnet-policy-service

Copy link
Copy Markdown
Contributor

Tagging subscribers to this area: @dotnet/crossgen-contrib
See info in area-owners.md if you want to be subscribed.

@davidwrighton

Copy link
Copy Markdown
Member

Note, while this is my basic idea, I've also asked @jtschuster to look into leveraging tiered compilation for this sort of work. That would have the benefit of having fewer things that we need at the initial stage, but we might have more complex bookkeeping or detection of existing methods that we should switch to the R2R plan after the secondary module arrives.

@jtschuster
jtschuster marked this pull request as ready for review October 14, 2026 23:41

Metadata and activation info about auxiliary modules is stored in the AuxiliaryModuleMetadata section in the primary module.

This design currently supports single-threaded hosting only. Auxiliary-module installation and method activation run on the runtime's single execution thread. Multithreaded hosting is outside the scope of this design.

@jkotas jkotas Oct 16, 2026 •

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.

I do not see anything in the design that makes it single-threaded only. If there is some detail that is specific to single threading, I would call it out inline and delete the general note here.

It is fine to say that the initial implementation will be for single threaded wasm only in the implementation section.

@jkotas jkotas Oct 16, 2026 •

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.

You may want to say that this is going to target composite mode only. Is that right?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

I removed this line from the overview and added a note in the Implementation phases section that V1 focuses on single-threaded, composite mode support.


## Flow

When a new method is discovered to be executed, the runtime will attempt to resolve R2R code for the method, first in the primary module, then in the auxiliary modules, if the Auxiliary EntryPoints Module information is available. If the method is determined to be in an auxiliary module, the runtime will check if the module has been activated, and activate the method if it is. Otherwise, flags on the PEP will track what is holding activation of the method: the Auxiliary EntryPoints Module, or the auxiliary module containing the code. Subsequent calls to the interpreted method will check activation status and attempt activation of the R2R code.

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.

This check is on the hot path through the interpreter. We really want this check to be very cheap (ideally, one test + branch) unless there is a chance we may be able to activate new method (ie the code was downloaded). You may want to call it out explicitly.


### MethodDefEntryPoints and InstanceMethodEntryPoints

Two `MethodDefEntryPoints` and `InstanceMethodEntryPoints` exist, one in the primary module and the Auxiliary EntryPoints Module. Fixup lists for the entries in the Auxiliary EntryPoints Module exist in the auxiliary modules. The runtime must check both sections when the Auxiliary EntryPoints Module is downloaded. The primary module will allocate space for each section at the RVA designated in the Auxiliary Module Metadata. The entries for each method and their RuntimeFunctions index are present in the Auxiliary EntryPoints Module, but the fixup lists are placed in the auxiliary modules and copied during activation.

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.

Two MethodDefEntryPoints and InstanceMethodEntryPoints exist, one in the primary module

Assuming we are targeting the composite mode, there should be one MethodDefEntryPoints per assembly.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

I updated the AuxiliaryModuleMetadata section to include a MethodDefEntryPointsInfoRVA field which allows the runtime to find an auxiliary MethodDefEntryPoints table for each assembly. The data pointed to is structured and ordered like the ComponentAssemblies section (an array of 2 section pointers per entry) and points to zeroed memory allocated for the auxiliary MethodDefEntryPoints sections.

I also updated the AuxiliaryEntryPointsModule to include a MethodDefEntryPoints section per assembly. Activation populates the space allocated in the primary module for each auxiliary MethodDefEntryPoints.

return ActivateOrFallBackToInterpreter(pep, entry);
}

if (primary.AuxiliaryModuleMetadataHeader.EntryPointsModuleActivationStatus == 0)

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.

I think this makes an assumption that there is only one auxiliary module. It is fine simplifying assumption to make for v1 design. If you make that assumption for v1 design (and assuming that we are targeting composite mode only for v1 design), this can be just simple global flag. No need to store anything in the InterpreterData.

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.

(And for the eventual design with multiple aux modules, I do not think we know at this point which aux module is the method going to show up in, so it would have to be more complicated than this. You need to worry about that for now.)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

I think we should only need a single AuxiliaryEntryPointsModule for a logical R2R image, even if there are many auxiliary modules. Do you see a blocker?

We also should be able to know which aux module the method belongs to once we have its RuntimeFunction index. If we lay out the RuntimeFunctions in contiguous ranges starting with the primary module and order the aux modules the same as the AuxiliaryModuleMetadata section, we can do a bounds check on the RuntimeFunctions index to determine which Auxiliary module it belongs to.

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.

II think we should only need a single AuxiliaryEntryPointsModule for a logical R2R image, even if there are many auxiliary modules.

Right.

I do not see how you can get away with a single EntryPointsModuleActivationStatus flag shared by many auxiliary modules.

I do not think it matters a whole lot. I would focus on a single auxiliary module only in the initial implementation, and worry about extending it to multiple auxiliary modules later.

(Also, it looks odd for EntryPointsModuleActivationStatus to be in a Header. The module headers are typically readonly and part of image payload.)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

it looks odd for EntryPointsModuleActivationStatus to be in a Header. The module headers are typically readonly and part of image payload.

If you mean the PE / WebCIL headers, the flag isn't there. I think I wrote poorly modeled pseudocode. The flag is in the "header" within the AuxiliaryModuleMetadata section's data, not the Module Headers. It may look clearer written like primary.AuxiliaryModuleMetadataSection.Header.EntryPointsModuleActivationStatus.

I do not think it matters a whole lot. I would focus on a single auxiliary module only in the initial implementation, and worry about extending it to multiple auxiliary modules later.

Good point, if 1 auxiliary module is enough this all becomes moot.

@pavelsavara pavelsavara left a comment

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.

I made few comments about async, lazy, sharding and MT. Hope it helps.


Move entries of the `InstanceMethodEntryPoints` and `MethodDefEntryPoints` sections into the Auxiliary EntryPoints Module during object emission in crossgen2. Implement activation of the Auxiliary EntryPoints Module in the host. Update the runtime to check both once the Auxiliary EntryPoints Module is activated. All other data is left in the primary module and activation of the Auxiliary EntryPoints Module will also mark auxiliary modules as activated so that activation of those methods can complete.

### 3. Split some wasm function bodies into the cold module.

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.

Term "cold module" is confusing to me. Because we say app "cold start" and speak about primary module to be eagerly downloaded for it. Can we use "auxiliary module" consistently ? They could also be called lazy or late loaded shards. In javascript bundlers they are called "chunks".


The JavaScript host must:

1. Trigger auxiliary-module downloads (including the Auxiliary EntryPoints Module), either eagerly or in response to method activation requests.

@pavelsavara pavelsavara Oct 20, 2026 •

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.

We can trigger fetch - download browser API from synchronous method, as soon as your trigger activates. But the API is async and you will be only able to instantiate the wasm module and activate it's functions after you yielded all the way back up to browser event loop.

In short, you can't synchronously block and wait for the download to finish (as opposed to loading a .DLL on normal OS synchronously).

We will only benefit from lazy loaded aux module "on the next user click", not within same synchronous busy loop as is usual with JIT and OSR.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

I am aware of that, but I don't have a good sense for how often we return to the browser event loop - I had assumed it wasn't particularly infrequent and we wouldn't have to wait too long once the auxiliary module is downloaded. It sounds like maybe my assumption is wrong, though?

@pavelsavara pavelsavara Oct 21, 2026 •

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.

Web pages are expected to yield to browser event loop often, otherwise the UI doesn't re-render either. So long running synchronous CPU bound jobs are atypical in our apps.

Sometimes you can't avoid it, for example deserializing 10 MB of json. If that happens without R2R of the JSON parser being already downloaded/installed, the UI will be frozen while interpreter is slowly processing that JSON. For that reason span/utf8/json/simd methods need to be "forced" into the PGO/eager set.

Advanced users would be willing to use streaming JSON parser or decompose their CPU bound jobs into multiple Tasks with await Tasks.Yield() in between. Ultimate answer is support for MT.


## Implementation phases

V1 will focus on single-threaded, composite mode support. The designs shouldn't make multithreaded WASM or non-composite mode incompatible, but it will disabled for those configurations in the initial implementation.

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.

Multi-threaded WASM module instantiation is really complex. It needs to be downloaded or transfered asynchronously to a WebWorker (thread) and then installed into each of them. The table0 is private to each WebWorker. That means that passing function pointer that could lead into aux module from one thread to another could point to function index that is not yet installed on the target/calling thread.
Also any new emscripten pthread would not be aware that it needs to install all existing aux modules first, before handing over the execution to managed code.


## Appendix

### Direct-call dependency sets

@pavelsavara pavelsavara Oct 20, 2026 •

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.

Dependency sets are interesting because they should inform which methods should be emitted into the same aux module (shard). Especially when direct calls would be supported.
The delay/latency of lazy async download and instantiation (say 100ms) could be prohibitive when done separately step by step on each recursive call. (when called methods are often in different file)


V1 will focus on single-threaded, composite mode support. The designs shouldn't make multithreaded WASM or non-composite mode incompatible, but it will disabled for those configurations in the initial implementation.

### 1. Support delayed resolution of R2R code within the same module.

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.

How do we know which aux module (shard) to download when we want to start activating some method lazily ?

We will also need to map it from crossgen file name suffix into full download URL, with fingerprint. That's WASM SDK and should probably work as is.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Once the AuxiliaryEntryPointsModule is downloaded and activated, we can look up the RuntimeFunctions index for the method. The RuntimeFunctions will be laid out so that each auxiliary module has a contiguous range which starts at sum(<previous modules' MethodCount>) and ends at start + MethodCount where MethodCount is found in the AuxiliaryModuleMetadata section.

The FilenameRVA field in the AuxiliaryModuleMetadata section has the file name, would that be enough to construct the URL from?

This branch has not been deployed

No deployments
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 design-discussion Ongoing discussion about design without consensus NO-MERGE The PR is not ready for merge yet (see discussion for detailed reasons)

Projects

Status: No status

Development

Successfully merging this pull request may close these issues.

4 participants