From c2a0d1e7a6ea69ab8c356009846b29520eb45ffd Mon Sep 17 00:00:00 2001 From: Jackson Schuster <36744439+jtschuster@users.noreply.github.com> Date: Thu, 8 Oct 2026 11:08:57 -0700 Subject: [PATCH 01/19] Init Design doc (cherry picked from commit 9fdb27eb3c9b5aa5b202585e70611235dbd0c65d) --- .../features/partitioned-wasm-r2r-modules.md | 316 ++++++++++++++++++ 1 file changed, 316 insertions(+) create mode 100644 docs/design/features/partitioned-wasm-r2r-modules.md diff --git a/docs/design/features/partitioned-wasm-r2r-modules.md b/docs/design/features/partitioned-wasm-r2r-modules.md new file mode 100644 index 00000000000000..35e3a2ecc6bfce --- /dev/null +++ b/docs/design/features/partitioned-wasm-r2r-modules.md @@ -0,0 +1,316 @@ +# Design for partitioned R2R modules with delayed cold code loading + +## Overview + +This doc outlines a design for a logical ReadyToRun (R2R) module which is distributed across a primary WebAssembly module and one or more auxiliary WebAssembly modules. + +Loading the primary module makes the logical R2R module functional and usable. Methods whose compiled code has not yet arrived remain callable through R2R-to-interpreter thunks. Auxiliary modules can arrive later. + +During the first execution of a method whose body is in the auxiliary module (an "aux method"), a new fixup kind, `READYTORUN_FIXUP_Auxiliary_Module` identifies the method as an aux method. The method's `_pActualCode` is set to the interpreter thunk with the correct signature, and `kPrefersInterpreterEntryPoint` is set, as well as a new flag, `kAuxiliaryModulePending`. This flag marks the method as requiring activation once the module has been marked activated. An alternative tracking of methods that require activation which creates a queue of pending methods and drains the queue incrementally once the module is activated is described at the end of this document. The runtime will also mark the module as pending activation. + +Once the auxiliary module is downloaded and marked as pending activation, the host calls the exported `ActivateModule` function, which copies over all data required for methods to be activated and executed, fills the global function table (table 0) with funcrefs, and marks itself as activated via a well-known location in the primary module's data. Once the module is activated, its methods can be activated. + +The interpreter will check for the `kAuxiliaryModulePending` flag as it checks `kPrefersInterpreterEntryPoint`. If set, it will also check the well-known location in the primary module to see if the auxiliary module has been activated. If the module has been activated, the method will be activated. Note this could cause significant work to be done when running fixups. That issue is what the pending method queue + draining alternative seeks to address. + +The runtime must support N `RuntimeFunctions` tables per logical R2R module, with one table per physical Wasm file, spreading the EH and GC info across the auxiliary modules. Alternatively, the primary module must hold all entries. In this case, the Virtual IP field must be present for each RuntimeFunction entry, though GC and EH info could be zero and filled in by the auxiliary 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. + +## Primary Module + +### Data layout + +The primary module defines a data segment large enough to hold all relevant data for the logical module, including data supplied by auxiliary modules. + +Regions whose contents belong exclusively to auxiliary modules are initially filled with zeros. Zero bytes in a data segment are stored literally, so they cost file size before compression but compress very well. + +### Callable methods and thunks + +The primary module contains: + +- All string-referenced thunks. +- Shared R2R-to-interpreter thunks, one per signature shape needed by auxiliary methods. An unactivated aux method's PEP points at the thunk for its signature. +- An element segment defining the primary module's own callable methods and thunks. The slot range reserved for methods in auxiliary modules is left empty and is filled during activation of the auxiliary module. +- `MethodDefEntryPoints` and `InstanceMethodEntryPoints` for the entire logical module. + +### Aux Method Fixups + +For an aux method, the first fixup of its fixup list is an auxiliary-module marker `READYTORUN_FIXUP_Auxiliary_Module` identifying the module that holds its compiled code. In the initial implementation, this is assumed to be the only auxiliary module. Future implementations may append an ID to indicate which auxiliary module the method body belongs to. + +The method's entry in `MethodDefEntryPoints` (or `InstanceMethodEntryPoints`) is unchanged and points to the method's fixup list at a fixed address in the primary module. Before the auxiliary module loads, the fixup list holds only the marker's bytes, followed by reserved zero-filled space for the rest of the list. The complete list, including the marker, is stored in the auxiliary module's passive data. During auxiliary module activation, the module rewrites the entire fixup list, with identical bytes for the marker. No separate lookup structure or per-module fixup-list address is required. + +The compiler must encode the complete fixup list, with the marker as its first fixup, and then emit the marker's bytes and the reserved space. The tail of the list is delta-encoded relative to the marker, so it must not be encoded independently. + +### Method activation behavior + +If activation is unsuccessful because fixup resolution fails, the `kAuxiliaryModulePending` flag is cleared and the `kPrefersInterpreterEntryPoint` flag is left set. Method activation should not be retried after a failure, and the runtime should treat the method as any other interpreter-only method. + +## Auxiliary Modules + +An auxiliary module contains: + +- The function bodies of the contained methods. +- The RuntimeFunctions table for the contained methods. +- The fixup data for the contained methods. +- An Element segment of funcrefs to its contained methods to fill its allotted range in table 0 during activation. +- Any other data required by the module but able to be removed from the primary module. + +It imports: + +- The primary module's memory and function table. +- The primary module's base memory address and base table index. +- (future, optional) functions from the primary module, such as R2R helpers or commonly called managed methods. This enables direct calls to these methods in the primary module. + +It exports: + +- `ActivateModule()`, a method which copies the module's data into its allotted range in the primary module's memory, installs funcrefs to all of the module's compiled methods and funclets into its allotted range of table 0, and marks itself as loaded via a well-known flag in the primary module's memory. Note that installing a funcref does not mean the method is activated and ready to be executed. + +## Auxiliary-Module Metadata + +Metadata for each auxiliary module resides at a well-defined location in the primary module. This metadata enables the runtime to activate the module. + +The Auxiliary module metadata table has a header with 2 fields, the `VirtualIPSpan` (the total virtual IP span of the entire logical module, divided by 2), and the `AuxiliaryModuleCount` (the number of auxiliary modules). The runtime reserves a virtual IP range of `VirtualIPSpan * 2` for the module without needing all `RuntimeFunction` sections to be present. + +Following the header are `AuxiliaryModuleCount` entries with the following data: + +| Field | Meaning | +|---|---| +| Filename RVA | RVA of the string naming the auxiliary module. | +| First method table index | Primary-module-relative table index of the first slot assigned to the auxiliary module. | +| `MethodCount` | Number of runtime-function slots assigned to the auxiliary module, **including funclet slots**. Defines the number of entries in its `RuntimeFunctions` table and the length of its contiguous function-table range. | +| Auxiliary `RuntimeFunctions` table RVA | RVA of that physical module's runtime-function table. The table contains `MethodCount` records. | +| `INITIALIZED_FLAG` | Initialization state, described below. | +| `REQUEST_COUNT` | Initially `0`; incremented when R2R metadata identifies a method in this auxiliary module as required. | + +### Module Initialization states + +| `INITIALIZED_FLAG` | State | +|---|---| +| `0` | Auxiliary module has not been loaded. | +| `1` | Auxiliary module is activated. Data has been copied and the function table range it is allotted has been filled. | + +### Index relationships + +Each auxiliary module's allotted range in the function table is contiguous and has a one-to-one correspondence with its local `RUNTIME_FUNCTION` records, including funclets: + +```text +auxiliaryRelativeIndex = + primaryModuleRelativeTableIndex - firstMethodTableIndex + +actualFunctionTableIndex = + primaryModuleTableBase + primaryModuleRelativeTableIndex + +auxiliaryRuntimeFunctionIndex = auxiliaryRelativeIndex +``` + +`auxiliaryRelativeIndex` must be in `[0, MethodCount)`. + +### Direct-call dependency sets + +Activating method A must also complete the required fixups for any method B that A calls directly, even if B was not independently requested. + +A method's activation state is **not activated** (`_pActualCode` not set, or `kAuxiliaryModulePending` set), **activation-in-progress** (in a visited set local to the activation walk or a new flag on the PEP is set), or **activated** (`kAuxiliaryModulePending` clear and `kPrefersInterpreterEntryPoint` not set). Encountering an activation-in-progress dependency records the dependency rather than recursively restarting its activation. + +Mutually dependent methods, such as A calling B and B calling A, are completed as an activation set. A and B must be activated at the same time and must both succeed activation, or they both fail. + +`READYTORUN_FIXUP_MethodEntry_ReadyToRun` already requests target-method fixup processing. These are implemented as Eager imports which run at module load. This does not work for aux methods whose data and fixups are not present in the primary module. These fixups for aux modules must run either during auxiliary module activation, or be modified to be run as pre-code fixups for methods that have direct calls (which requires handling cycles of these fixups - e.g. M1 direct calls M2 which direct calls M1). + +Direct calls within an auxiliary module are permitted subject to this activation requirement. Direct calls from the primary module to an auxiliary module are not supported. + +Direct calls from one auxiliary module to another are not supported. + +## Compiler Changes + +### Assign methods to physical modules + +The compiler must decide which methods belong in the primary module and which belong in auxiliary modules. + +The initial implementation will support a **hot/cold split**: + +- A PGO- or heuristic-selected hot set goes into the primary module. +- Remaining methods go into a single auxiliary module. + +For demonstration purposes, the initial heuristic can classify **helpers and public non-generic methods as hot**, with everything else classified as cold. This is intended as a simple implementation for the proof of concept, not as a long-term solution. + +The design also permits future arrangements such as: + +- Hot/warm/cold modules. +- A hot primary module plus per-assembly auxiliary modules. + +Auxiliary modules use indexed filenames, for example: + +```text +app.r2r.1.wasm +app.r2r.2.wasm +``` + +### Partition tables and generate fallback thunks + +The compiler must: + +- Split the `RuntimeFunctions` table into per-physical-module tables. +- Arrange function-table indices so that the primary module's R2R methods are contiguous and each auxiliary module's R2R methods occupy a contiguous range. +- Include funclet slots in each auxiliary module's `MethodCount` and preserve the one-to-one correspondence between that range and its local `RUNTIME_FUNCTION` records. +- Generate R2R-to-interpreter thunks for every signature shape needed by auxiliary methods. The primary module holds one thunk per shape, and an unactivated auxiliary method's PEP names the thunk for its signature. The primary module's element segment does not reserve a thunk per auxiliary slot. +- Emit in each auxiliary module an element segment that fills its slot range in the imported table 0 with that module's methods and funclets, so `ActivateModule` can install every funcref. + +### Partition data and emit auxiliary metadata + +> [!NOTE] +> Data partitioning is not strictly required for correctness. Fully populating the primary module's data would work, and data partitioning may be postponed to later implementation phases. + +The compiler must identify memory regions belonging to auxiliary modules and communicate that ownership to the Wasm module writers. Memory owned by the auxiliary modules must not include any target locations of any fixups that may run before the auxiliary module is loaded. The auxiliary module should be able to use `memory.init` instructions to fill data, and must not overwrite any fixup cells that have been resolved. + +The compiler must also: + +- Emit each auxiliary method's fixup list as a marker followed by reserved zero-filled space in the primary module, with the complete list in the auxiliary module's passive data. Lay the auxiliary methods' lists out contiguously so a single copy initializes them. The marker's bytes must be identical in both copies. +- Generate auxiliary-module metadata at a well-known location discoverable by the runtime. +- Adjust code-generation rules so auxiliary code can be loaded safely. +- Retain IL for any method assigned to an auxiliary module. + +## Runtime Changes + +### Multiple runtime-function tables + +A logical R2R module can have multiple `RuntimeFunctions` sections. An auxiliary module's table becomes eligible for lookup when its `INITIALIZED_FLAG` is non-zero. + +`MethodCount`, including funclet slots, defines the length of the auxiliary table. + +### Deferred method activation + +The runtime recognizes an auxiliary-module marker in the first fixup of a method. Whether the auxiliary module is activated or not, the runtime sets `_pActualCode` to point to the interpreter thunk slot in the global function table, sets interpreter preference, and sets `kAuxiliaryModulePending` on the PEP. If the module has been activated, it then activates the method synchronously. If not, the method is interpreted. On subsequent calls, the `kAuxiliaryModulePending` flag tells the interpreter to check the auxiliary module's activation state to see if the method can be activated. + +Resolving which auxiliary module the method belongs to can be done in a few ways. +- Search MethodDefEntryPoints/InstanceMethodEntryPoints, determine what auxiliary module owns the range the entry resides in through the AuxiliaryModuleMetadata. +- Add slots to the range allotted to the auxiliary module for interpreter thunks for each signature in the auxiliary module. `_pActualCode` then already points somewhere in the range of the auxiliary module, so we avoid the MethodDefEntryPoints/InstanceMethodEntryPoints lookup. +- Limit the auxiliary module count to 1. This will be done in the initial implementation. + +Once activated, `_pActualCode` is set to the method's global table index and `kAuxiliaryModulePending` and `kPrefersInterpreterEntryPoint` are cleared, and `_pInterpreterData` is cleared. + +### Host notification + +When an auxiliary module's `REQUEST_COUNT` changes from **`0` to `1`**, a hosting hook notifies the host that a method from that module has been requested. + +CoreCLR calls +``` +void OnAuxiliaryModuleRequest(const char *auxModuleName, int moduleBase, int functionTableBase); +``` + +The hook supplies the primary module's base table index and base memory offset which should be enough to identify the primary module and enable instantiation of the auxiliary module. Future support for direct calls between modules may require a more complex import/export scheme. + +## JavaScript Hosting Changes + +The JavaScript host must: + +1. Trigger auxiliary-module downloads, either in response to `OnAuxiliaryModuleRequest` or through a more proactive download policy. +2. Instantiate each auxiliary module with the required imports. +3. Call that module's `ActivateModule` export. + +Methods are then activated lazily on their next call; the host does not drive method activation. + +### Gaps + +- **Calls from an auxiliary module into another auxiliary module** remain unsupported. +- **First-call latency.** Fixup work and closure resolution happen on the calling thread at an arbitrary point after the auxiliary module is downloaded. The queue alternative below spreads this work out. + +## Alternative: deferred-activation queue drained incrementally + +Instead of activating on first call, the runtime keeps a list of methods that need activating and drains it in batches. This trades more runtime and host machinery for control over when activation cost is paid. + +### Changes relative to on-demand activation + +- **Method loading when the module is not loaded.** Instead of setting `kAuxiliaryModulePending`, add the `MethodDesc` to a deferred-activation list. The `_pActualCode` thunk is left in place, interpreter preference is still set, and `REQUEST_COUNT` and `OnAuxiliaryModuleRequest` behave as before. Methods discovered after module arrival activate synchronously and are never queued. +- **Initialization states.** `INITIALIZED_FLAG` gains a third value. + + | `INITIALIZED_FLAG` | State | + |---|---| + | `0` | Auxiliary module has not been loaded. | + | `1` | Auxiliary module is loaded; deferred fixup processing remains. | + | `2` | The module is loaded and its deferred-activation queue is drained. This does not mean that every method in the module has been activated. | + + Methods in other modules that have not yet loaded do not prevent the transition to `2`. Methods discovered in state `2` activate synchronously, so discovery does not reopen the queue. +- **Activation procedure.** Unchanged, except that the runtime drains the queue instead of waiting for first calls. Methods completed early, including dependencies completed during synchronous activation or by a first call, are removed from the queue. The helper's first-call path can still be kept as a fast path for hot methods. +- **Per-method state.** Not activated, activation-in-progress, and activated are tracked explicitly rather than by a visited set or PEP flag. +- **Activation failure.** A queued method whose fixups fail is rejected for R2R and stays interpreted. It is removed from the queue and does not count as remaining work, so the host's drain loop terminates. + +### Incremental activation entrypoint + +CoreCLR exposes: + +```c +int DoRuntimeDeferredModuleActivationWork(int methodsToProcess); +``` + +The argument is a heuristic budget of root methods to process, not a bound on time. Activating one method can also activate its direct-call dependency closure, so a batch can exceed the budget. + +The return value counts remaining methods pending activation for loaded auxiliary modules. + +Deferred methods belonging to modules that have not loaded are excluded from this count. A return value of `0` means there is no remaining activation work for currently loaded modules; it does not mean that all auxiliary modules have arrived or that every method in them has been activated. + +### JavaScript host changes + +After calling `ActivateModule`, the host calls `DoRuntimeDeferredModuleActivationWork` in batches, yielding to normal execution between batches, until it returns `0` for the currently loaded modules. A JavaScript-side timer is one possible driver. When another auxiliary module arrives, repeat the sequence for the newly available work. + +### Trade-offs + +| | On-demand (default) | Queue and batching (alternative) | +|---|---|---| +| Runtime state | One loaded state, `kAuxiliaryModulePending` flag | Queue, request counting, three module states | +| Host involvement | Host only loads and installs modules | Host drives the batch loop | +| Activation cost | On the first call to each method | Spread across batches by the host | +| Warm-up control | None without an added prefetch pass | Eager, host-scheduled | +| Compiler and R2R format | No new helper or thunk. One new PEP flag and a check in the existing helper. | Existing helper | + + +## Appendix: Method activation PEP state table + +The table covers every combination of four values: + +- **`pep._pActualCode`:** what the PEP dispatches to: `0` (never prepared), `Thunk` (R2R-to-interpreter thunk), or `R2R` (an index naming valid R2R code; for an auxiliary method, its own slot in table 0). +- **`pep.kPreferInterp`:** `kPrefersInterpreterEntryPoint`. +- **`pep.kAuxPending`:** `kAuxiliaryModulePending`. +- **`AuxModuleMetadata.INITIALIZED_FLAG`:** the owning auxiliary module's `INITIALIZED_FLAG` is `1`, meaning `ActivateModule` has run and table 0 holds valid function references to the auxiliary module's bodies. It is meaningless for methods that are not in an auxiliary module. + +| # | `_pActualCode` | `kPreferInterp` | `kAuxPending` | `ModInit` | Status | Meaning | +|---|---|---|---|---|---|---| +| 1 | `0` | 0 | 0 | 0 | Valid | Method not yet prepared. The owning module is not loaded, or the method is not in an auxiliary module. | +| 2 | `0` | 0 | 0 | 1 | Same as 1 | Not yet prepared. `ModInit` is irrelevant until the marker fixup is seen. | +| 3 | `0` | 0 | 1 | 0 | Invalid | Marker processing sets the thunk and the flags together. | +| 4 | `0` | 0 | 1 | 1 | Invalid | As row 3. | +| 5 | `0` | 1 | 0 | 0 | Invalid | Interpreter preference is set when the method is prepared, which also sets `_pActualCode`. | +| 6 | `0` | 1 | 0 | 1 | Invalid | As row 5. | +| 7 | `0` | 1 | 1 | 0 | Invalid | As row 3. | +| 8 | `0` | 1 | 1 | 1 | Invalid | As row 3. | +| 9 | `Interpreter thunk` | 0 | 0 | 0 | Invalid | A thunk means the method is interpreted, so interpreter preference must be set. | +| 10 | `Interpreter thunk` | 0 | 0 | 1 | Invalid | As row 9. | +| 11 | `Interpreter thunk` | 0 | 1 | 0 | Invalid | Pending implies interpreter preference. | +| 12 | `Interpreter thunk` | 0 | 1 | 1 | Invalid | As row 11. | +| 13 | `Interpreter thunk` | 1 | 0 | 0 | Valid | Interpreter-only method: no R2R code exists, or it was rejected. An auxiliary method should never be in this state. | +| 14 | `Interpreter thunk` | 1 | 0 | 1 | Valid | For aux methods, this means the method activation failed and the method will always be interpreted. | +| 15 | `Interpreter thunk` | 1 | 1 | 0 | Valid | Auxiliary method whose module has not loaded. Runs in the interpreter and waits for `ActivateModule`. | +| 16 | `Interpreter thunk` | 1 | 1 | 1 | Valid | Auxiliary method whose module is activated. Either no call has happened since module activation, or activation is currently running. | +| 17 | `R2R` | 0 | 0 | 0 | Valid | R2R code in the primary module, or a method outside an auxiliary module. | +| 18 | `R2R` | 0 | 0 | 1 | Same as 17 | Also the state of an activated auxiliary method, where `ModInit` must be `1`. | +| 19 | `R2R` | 0 | 1 | 0 | Invalid | Pending means not activated, so `_pActualCode` cannot name R2R code. | +| 20 | `R2R` | 0 | 1 | 1 | Invalid | As row 19. | +| 21 | `R2R` | 1 | 0 | 0 | Invalid | Interpreter preference is cleared when R2R code is valid and activated. | +| 22 | `R2R` | 1 | 0 | 1 | Invalid | As row 21. | +| 23 | `R2R` | 1 | 1 | 0 | Invalid | `_pActualCode` cannot name an auxiliary slot before the module loads, because the slot is still empty. | +| 24 | `R2R` | 1 | 1 | 1 | Transient | Inside activation, after `_pActualCode` is repointed and before the flags are cleared. Single-threaded hosting makes it unobservable. | + +Valid, distinct states and how an auxiliary method moves between them: + +```text +1 (unprepared) + → 13 no R2R code, or R2R code rejected (not an auxiliary method) + → 17 R2R code found in the primary module + → 15 marker seen, module not loaded + → 16 → 18 marker seen, module loaded: activated synchronously +15 → 16 ActivateModule runs (funcrefs installed, ModInit becomes 1) +16 → 18 first call or direct-call closure activation succeeds +16 → 14 activation fails: the method is always interpreted on future calls +``` + + +> [!NOTE] +> This document was formatted and edited from the supplied design text by GitHub Copilot. From 8949b1def3dd56212bf8a80c81111f9ed4657cb2 Mon Sep 17 00:00:00 2001 From: Jackson Schuster <36744439+jtschuster@users.noreply.github.com> Date: Tue, 13 Oct 2026 14:58:04 -0700 Subject: [PATCH 02/19] Add implementation phases (cherry picked from commit c517c3a89944e1c4f82ffe66cc7e784bd246c30e) --- .../features/partitioned-wasm-r2r-modules.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/docs/design/features/partitioned-wasm-r2r-modules.md b/docs/design/features/partitioned-wasm-r2r-modules.md index 35e3a2ecc6bfce..3c983d1f4050aa 100644 --- a/docs/design/features/partitioned-wasm-r2r-modules.md +++ b/docs/design/features/partitioned-wasm-r2r-modules.md @@ -262,6 +262,20 @@ After calling `ActivateModule`, the host calls `DoRuntimeDeferredModuleActivatio | Compiler and R2R format | No new helper or thunk. One new PEP flag and a check in the existing helper. | Existing helper | +## Implementation phases + +### 1. Support delayed resolution of R2R code within the same module. + +Using either call-counting in the interpreter or a timer in the host JavaScript code, force methods to be interpreted (even if R2R code exists for them in the primary module) before installing R2R code. This doesn't require any changes to crossgen2 or the webcil format. + +### 2. Split some wasm function bodies into the cold module. + +Leave all other R2R metadata (fixups, EH and GC info, etc) in the primary module, but move some cold wasm method bodies to a cold module. `ActivateModule` only has to update the global function table. Requires changes from crossgen2, the host, and the runtime. + +### 3. Move metadata for aux methods into the auxiliary module. + +Move the `RuntimeFunctions` table entries and fixups to the auxiliary module, and have method activation resolve this info using the Auxiliary Module Metadata entries. + ## Appendix: Method activation PEP state table The table covers every combination of four values: From b3d3fc8694afb76e2ef853fc7f89cdafa3205fa3 Mon Sep 17 00:00:00 2001 From: Jackson Schuster <36744439+jtschuster@users.noreply.github.com> Date: Tue, 13 Oct 2026 17:39:06 -0700 Subject: [PATCH 03/19] Some changes related to tiering (cherry picked from commit 2f27390c442835bd7cf49595c447acaa9846ed99) --- .../features/partitioned-wasm-r2r-modules.md | 106 ++++++------------ 1 file changed, 34 insertions(+), 72 deletions(-) diff --git a/docs/design/features/partitioned-wasm-r2r-modules.md b/docs/design/features/partitioned-wasm-r2r-modules.md index 3c983d1f4050aa..f311ce172e3e0a 100644 --- a/docs/design/features/partitioned-wasm-r2r-modules.md +++ b/docs/design/features/partitioned-wasm-r2r-modules.md @@ -6,16 +6,30 @@ This doc outlines a design for a logical ReadyToRun (R2R) module which is distri Loading the primary module makes the logical R2R module functional and usable. Methods whose compiled code has not yet arrived remain callable through R2R-to-interpreter thunks. Auxiliary modules can arrive later. -During the first execution of a method whose body is in the auxiliary module (an "aux method"), a new fixup kind, `READYTORUN_FIXUP_Auxiliary_Module` identifies the method as an aux method. The method's `_pActualCode` is set to the interpreter thunk with the correct signature, and `kPrefersInterpreterEntryPoint` is set, as well as a new flag, `kAuxiliaryModulePending`. This flag marks the method as requiring activation once the module has been marked activated. An alternative tracking of methods that require activation which creates a queue of pending methods and drains the queue incrementally once the module is activated is described at the end of this document. The runtime will also mark the module as pending activation. +The way these auxiliary modules and the methods they hold code for ("aux methods") begin with unoptimized, interpreted code, and later are updated with more optimized code is analogous to code tiering. Therefore, the existing tiering, code versioning, and call counting code will be utilized (with some modifications) for the delayed arrival of R2R code. -Once the auxiliary module is downloaded and marked as pending activation, the host calls the exported `ActivateModule` function, which copies over all data required for methods to be activated and executed, fills the global function table (table 0) with funcrefs, and marks itself as activated via a well-known location in the primary module's data. Once the module is activated, its methods can be activated. +Auxiliary modules will hold the wasm method bodies for their aux methods, as well as their fixup data, GC info, and EH info in the `RuntimeFunctions` table. The runtime therefore must support N `RuntimeFunctions` tables per logical R2R module, with one table per physical Wasm file, spreading the EH and GC info across the auxiliary modules. -The interpreter will check for the `kAuxiliaryModulePending` flag as it checks `kPrefersInterpreterEntryPoint`. If set, it will also check the well-known location in the primary module to see if the auxiliary module has been activated. If the module has been activated, the method will be activated. Note this could cause significant work to be done when running fixups. That issue is what the pending method queue + draining alternative seeks to address. - -The runtime must support N `RuntimeFunctions` tables per logical R2R module, with one table per physical Wasm file, spreading the EH and GC info across the auxiliary modules. Alternatively, the primary module must hold all entries. In this case, the Virtual IP field must be present for each RuntimeFunction entry, though GC and EH info could be zero and filled in by the auxiliary module. +Auxiliary modules export a function, `ActivateModule()`, which copies all its data into the shared memory, and fills in its allotted range in the global function table. Once the module is activated, the tiering system can run fixups and enable calling the R2R code. 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. +## Flow + +During the first interpreter execution / compilation of bytecode of any method (not just aux methods), the interpreter will insert code at the beginning of the method to decrement its call counter and, if it has reached 0, call the `CallCountingStub.TargetForThresholdReached` method with the required arguments. `TargetForThresholdReached` will queue the method for "tiering". Tiering means resolving and activating its R2R code once its aux module is available (if it's an aux method), or removing the call-counting bytecode (if it's not an aux method). + +Auxiliary modules are downloaded eagerly unless it is determined better to wait and download on demand. + +To reduce cold download size, the mapping of method to auxiliary module resides in a separate module that is downloaded eagerly. + +Once the mapping is available, the runtime can begin to drain the queue of methods that need tiering. Methods in the queue that are not aux methods may have their callcounting bytecode removed (or not), and are removed from the queue. Aux methods will be added to a separate, module-specific queue to be drained again once the aux module is downloaded and activated. + +Once the auxiliary module is downloaded and marked as pending activation (via a flag in a well known location, or its module-specific aux method queue being non-empty), the host calls the exported `ActivateModule` function, which copies over all data required for methods to be activated and executed, fills the global function table (table 0) with funcrefs, and marks itself as activated via a well-known location in the primary module's data. Once the module is activated, its methods can be activated/tiered up. + +Tiering currently assumes a background worker thread can be created to compile the optimized versions of methods. However, WASM is single-threaded and cannot follow that same pattern. Instead, the queue of methods to tier up will be drained incrementally by the host. + +For each module-specific queue of aux methods, the host will check if the module has been downloaded and activated, and tier up methods in the queue by running fixups, setting the PEP to point to the method's entry in the global function table, and clearing `kPrefersInterpreterEntryPoint`. + ## Primary Module ### Data layout @@ -31,19 +45,17 @@ The primary module contains: - All string-referenced thunks. - Shared R2R-to-interpreter thunks, one per signature shape needed by auxiliary methods. An unactivated aux method's PEP points at the thunk for its signature. - An element segment defining the primary module's own callable methods and thunks. The slot range reserved for methods in auxiliary modules is left empty and is filled during activation of the auxiliary module. -- `MethodDefEntryPoints` and `InstanceMethodEntryPoints` for the entire logical module. +- `MethodDefEntryPoints` and `InstanceMethodEntryPoints` for the methods in the primary module. ### Aux Method Fixups -For an aux method, the first fixup of its fixup list is an auxiliary-module marker `READYTORUN_FIXUP_Auxiliary_Module` identifying the module that holds its compiled code. In the initial implementation, this is assumed to be the only auxiliary module. Future implementations may append an ID to indicate which auxiliary module the method body belongs to. - The method's entry in `MethodDefEntryPoints` (or `InstanceMethodEntryPoints`) is unchanged and points to the method's fixup list at a fixed address in the primary module. Before the auxiliary module loads, the fixup list holds only the marker's bytes, followed by reserved zero-filled space for the rest of the list. The complete list, including the marker, is stored in the auxiliary module's passive data. During auxiliary module activation, the module rewrites the entire fixup list, with identical bytes for the marker. No separate lookup structure or per-module fixup-list address is required. The compiler must encode the complete fixup list, with the marker as its first fixup, and then emit the marker's bytes and the reserved space. The tail of the list is delta-encoded relative to the marker, so it must not be encoded independently. ### Method activation behavior -If activation is unsuccessful because fixup resolution fails, the `kAuxiliaryModulePending` flag is cleared and the `kPrefersInterpreterEntryPoint` flag is left set. Method activation should not be retried after a failure, and the runtime should treat the method as any other interpreter-only method. +If activation is unsuccessful because fixup resolution fails, the `kPrefersInterpreterEntryPoint` flag is left set. Method activation should not be retried after a failure, and the runtime should treat the method as any other interpreter-only method. ## Auxiliary Modules @@ -65,22 +77,25 @@ It exports: - `ActivateModule()`, a method which copies the module's data into its allotted range in the primary module's memory, installs funcrefs to all of the module's compiled methods and funclets into its allotted range of table 0, and marks itself as loaded via a well-known flag in the primary module's memory. Note that installing a funcref does not mean the method is activated and ready to be executed. -## Auxiliary-Module Metadata +## Auxiliary Module EntryPoints -Metadata for each auxiliary module resides at a well-defined location in the primary module. This metadata enables the runtime to activate the module. +The Auxiliary module entrypoints module contains the `MethodDefEntryPoints` and `InstanceMethodEntryPoints` for the aux methods. These sections tell which index in the `RuntimeFunctions` table to find information for the method. A single logical `RuntimeFunctions` is spread across the primary and auxiliary modules. Using the length of the primary module's `RuntimeFunctions`, and the `MethodCount` field in each Auxiliary module metadata entry (see below), the owning auxiliary module can be determined. + +## Auxiliary Module Metadata -The Auxiliary module metadata table has a header with 2 fields, the `VirtualIPSpan` (the total virtual IP span of the entire logical module, divided by 2), and the `AuxiliaryModuleCount` (the number of auxiliary modules). The runtime reserves a virtual IP range of `VirtualIPSpan * 2` for the module without needing all `RuntimeFunction` sections to be present. +Metadata for each auxiliary module resides at a well-defined location in the primary module. This metadata enables the runtime to activate the module. -Following the header are `AuxiliaryModuleCount` entries with the following data: +The Auxiliary Module Metadata table is a count-prefixed array of entries with the following fields: | Field | Meaning | |---|---| -| Filename RVA | RVA of the string naming the auxiliary module. | -| First method table index | Primary-module-relative table index of the first slot assigned to the auxiliary module. | +| `FilenameRVA` | RVA of the string naming the auxiliary module. | +| `RelativeTableBase` | Primary-module-relative table index of the first slot assigned to the auxiliary module. | | `MethodCount` | Number of runtime-function slots assigned to the auxiliary module, **including funclet slots**. Defines the number of entries in its `RuntimeFunctions` table and the length of its contiguous function-table range. | -| Auxiliary `RuntimeFunctions` table RVA | RVA of that physical module's runtime-function table. The table contains `MethodCount` records. | +| `RuntimeFunctionsRVA` | RVA of this physical module's runtime-function table. The table contains `MethodCount` records. | +| `MethodDefEntryPointsRVA` | RVA of this auxiliary module's MethodDefEntryPoints section | +| `InstanceMethodEntryPointsRVA` | RVA of this auxiliary modules InstanceMethodEntryPoints section | | `INITIALIZED_FLAG` | Initialization state, described below. | -| `REQUEST_COUNT` | Initially `0`; incremented when R2R metadata identifies a method in this auxiliary module as required. | ### Module Initialization states @@ -109,7 +124,7 @@ auxiliaryRuntimeFunctionIndex = auxiliaryRelativeIndex Activating method A must also complete the required fixups for any method B that A calls directly, even if B was not independently requested. -A method's activation state is **not activated** (`_pActualCode` not set, or `kAuxiliaryModulePending` set), **activation-in-progress** (in a visited set local to the activation walk or a new flag on the PEP is set), or **activated** (`kAuxiliaryModulePending` clear and `kPrefersInterpreterEntryPoint` not set). Encountering an activation-in-progress dependency records the dependency rather than recursively restarting its activation. +An aux method's may be **unvisited** (it has no interpreter bytecode or R2R code), **interpreted** (`kPrefersInterpreterEntryPoint` is set), **activation-in-progress** (currently being tiered up, and present in a visited set during recursive activation of direct-called methods), or **activated** (`kPrefersInterpreterEntryPoint` not set). Encountering an activation-in-progress dependency must record the dependency rather than infinitely recursiving and restarting its activation. Mutually dependent methods, such as A calling B and B calling A, are completed as an activation set. A and B must be activated at the same time and must both succeed activation, or they both fail. @@ -156,9 +171,6 @@ The compiler must: ### Partition data and emit auxiliary metadata -> [!NOTE] -> Data partitioning is not strictly required for correctness. Fully populating the primary module's data would work, and data partitioning may be postponed to later implementation phases. - The compiler must identify memory regions belonging to auxiliary modules and communicate that ownership to the Wasm module writers. Memory owned by the auxiliary modules must not include any target locations of any fixups that may run before the auxiliary module is loaded. The auxiliary module should be able to use `memory.init` instructions to fill data, and must not overwrite any fixup cells that have been resolved. The compiler must also: @@ -276,55 +288,5 @@ Leave all other R2R metadata (fixups, EH and GC info, etc) in the primary module Move the `RuntimeFunctions` table entries and fixups to the auxiliary module, and have method activation resolve this info using the Auxiliary Module Metadata entries. -## Appendix: Method activation PEP state table - -The table covers every combination of four values: - -- **`pep._pActualCode`:** what the PEP dispatches to: `0` (never prepared), `Thunk` (R2R-to-interpreter thunk), or `R2R` (an index naming valid R2R code; for an auxiliary method, its own slot in table 0). -- **`pep.kPreferInterp`:** `kPrefersInterpreterEntryPoint`. -- **`pep.kAuxPending`:** `kAuxiliaryModulePending`. -- **`AuxModuleMetadata.INITIALIZED_FLAG`:** the owning auxiliary module's `INITIALIZED_FLAG` is `1`, meaning `ActivateModule` has run and table 0 holds valid function references to the auxiliary module's bodies. It is meaningless for methods that are not in an auxiliary module. - -| # | `_pActualCode` | `kPreferInterp` | `kAuxPending` | `ModInit` | Status | Meaning | -|---|---|---|---|---|---|---| -| 1 | `0` | 0 | 0 | 0 | Valid | Method not yet prepared. The owning module is not loaded, or the method is not in an auxiliary module. | -| 2 | `0` | 0 | 0 | 1 | Same as 1 | Not yet prepared. `ModInit` is irrelevant until the marker fixup is seen. | -| 3 | `0` | 0 | 1 | 0 | Invalid | Marker processing sets the thunk and the flags together. | -| 4 | `0` | 0 | 1 | 1 | Invalid | As row 3. | -| 5 | `0` | 1 | 0 | 0 | Invalid | Interpreter preference is set when the method is prepared, which also sets `_pActualCode`. | -| 6 | `0` | 1 | 0 | 1 | Invalid | As row 5. | -| 7 | `0` | 1 | 1 | 0 | Invalid | As row 3. | -| 8 | `0` | 1 | 1 | 1 | Invalid | As row 3. | -| 9 | `Interpreter thunk` | 0 | 0 | 0 | Invalid | A thunk means the method is interpreted, so interpreter preference must be set. | -| 10 | `Interpreter thunk` | 0 | 0 | 1 | Invalid | As row 9. | -| 11 | `Interpreter thunk` | 0 | 1 | 0 | Invalid | Pending implies interpreter preference. | -| 12 | `Interpreter thunk` | 0 | 1 | 1 | Invalid | As row 11. | -| 13 | `Interpreter thunk` | 1 | 0 | 0 | Valid | Interpreter-only method: no R2R code exists, or it was rejected. An auxiliary method should never be in this state. | -| 14 | `Interpreter thunk` | 1 | 0 | 1 | Valid | For aux methods, this means the method activation failed and the method will always be interpreted. | -| 15 | `Interpreter thunk` | 1 | 1 | 0 | Valid | Auxiliary method whose module has not loaded. Runs in the interpreter and waits for `ActivateModule`. | -| 16 | `Interpreter thunk` | 1 | 1 | 1 | Valid | Auxiliary method whose module is activated. Either no call has happened since module activation, or activation is currently running. | -| 17 | `R2R` | 0 | 0 | 0 | Valid | R2R code in the primary module, or a method outside an auxiliary module. | -| 18 | `R2R` | 0 | 0 | 1 | Same as 17 | Also the state of an activated auxiliary method, where `ModInit` must be `1`. | -| 19 | `R2R` | 0 | 1 | 0 | Invalid | Pending means not activated, so `_pActualCode` cannot name R2R code. | -| 20 | `R2R` | 0 | 1 | 1 | Invalid | As row 19. | -| 21 | `R2R` | 1 | 0 | 0 | Invalid | Interpreter preference is cleared when R2R code is valid and activated. | -| 22 | `R2R` | 1 | 0 | 1 | Invalid | As row 21. | -| 23 | `R2R` | 1 | 1 | 0 | Invalid | `_pActualCode` cannot name an auxiliary slot before the module loads, because the slot is still empty. | -| 24 | `R2R` | 1 | 1 | 1 | Transient | Inside activation, after `_pActualCode` is repointed and before the flags are cleared. Single-threaded hosting makes it unobservable. | - -Valid, distinct states and how an auxiliary method moves between them: - -```text -1 (unprepared) - → 13 no R2R code, or R2R code rejected (not an auxiliary method) - → 17 R2R code found in the primary module - → 15 marker seen, module not loaded - → 16 → 18 marker seen, module loaded: activated synchronously -15 → 16 ActivateModule runs (funcrefs installed, ModInit becomes 1) -16 → 18 first call or direct-call closure activation succeeds -16 → 14 activation fails: the method is always interpreted on future calls -``` - - > [!NOTE] -> This document was formatted and edited from the supplied design text by GitHub Copilot. +> This document was written and edited with assistance from GitHub Copilot. From 08b17a14a0b531d698531901beb71d9dc989f342 Mon Sep 17 00:00:00 2001 From: Jackson Schuster <36744439+jtschuster@users.noreply.github.com> Date: Wed, 14 Oct 2026 16:38:53 -0700 Subject: [PATCH 04/19] Update document to include tiering information and clarifications. (cherry picked from commit 7941e01c12f99e03eeaeb6b1379961ca2c0e61f2) --- docs/design/coreclr/botr/readytorun-format.md | 37 +++- .../features/partitioned-wasm-r2r-modules.md | 205 +++++++++--------- 2 files changed, 133 insertions(+), 109 deletions(-) diff --git a/docs/design/coreclr/botr/readytorun-format.md b/docs/design/coreclr/botr/readytorun-format.md index 9d1ecec82689ae..f8359fd72d97a1 100644 --- a/docs/design/coreclr/botr/readytorun-format.md +++ b/docs/design/coreclr/botr/readytorun-format.md @@ -149,6 +149,11 @@ struct READYTORUN_SECTION DWORD Type; // READYTORUN_SECTION_XXX IMAGE_DATA_DIRECTORY Section; }; + +struct IMAGE_DATA_DIRECTORY { + uint32_t VirtualAddress; + uint32_t Size; +} ``` The `READYTORUN_CORE_HEADER` structure is immediately followed by an array of `READYTORUN_SECTION` records @@ -475,13 +480,31 @@ IPs or PortableEntryPoint structures. ## ReadyToRunSectionType.MethodDefEntryPoints -This section contains a native format sparse array (see 4 Native Format) that maps methoddef rows to -method entrypoints. Methoddef is used as index into the array. The element of the array is index of the -method in `RuntimeFunctions`, followed by list of slots that need to be filled before the method +This section contains a [native format sparse array](#sparse-array) that maps methoddef rows to +method entrypoints. Methoddef is used as index into the array. Each element encodes the index of the +method in `RuntimeFunctions` and an optional list of slots that need to be filled before the method can start executing. -The index of the method is left-shifted by 1 bit with the low bit indicating whether a list of slots -to fix up follows. The list of slots is encoded as follows (same encoding as used by NGen): +Each element begins with a native-format unsigned integer. Its decoded value contains the method +index and flags indicating whether a fixup list is present and how to locate it: + +| Decoded value | Meaning | Following data | +|:--------------|:--------|:---------------| +| `methodIndex << 1` | Bit 0 is clear: no fixup list. | None. | +| `(methodIndex << 2) \| 1` | Bits 0-1 are `01`: inline fixup list. | The fixup-list bytes immediately follow the encoded integer. | +| `(methodIndex << 2) \| 3` | Bits 0-1 are `11`: reference to a previously emitted fixup list. | A native-format unsigned integer encoding the backward byte distance to the fixup list. | + +For the backward-reference form, the distance is measured from the start of the encoded distance +field to the start of the earlier fixup list: + +```text +fixupListOffset = distanceFieldOffset - decodedDistance +``` + +This allows multiple entrypoint records to share the same fixup list. The backward reference is part +of the entrypoint record, not part of the fixup list's section and slot index encoding. + +The fixup list itself is encoded as follows (same encoding as used by NGen): ``` READYTORUN_IMPORT_SECTIONS absolute index @@ -586,6 +609,10 @@ hash code calculation is implemented in the value, represented by the `EntryPointWithBlobVertex` class, stores the method index in the runtime function table, the fixups blob and a blob encoding the method signature. +The method index and optional fixup list use the same entrypoint encoding described in +[MethodDefEntryPoints](#readytorunsectiontypemethoddefentrypoints), including backward references +to previously emitted fixup lists. + **Note:** In contrast to non-generic method entrypoints, this section is image-wide for composite R2R images. It represents all generics needed by all assemblies within the composite executable. As mentioned elsewhere in this document, CoreCLR runtime requires changes to diff --git a/docs/design/features/partitioned-wasm-r2r-modules.md b/docs/design/features/partitioned-wasm-r2r-modules.md index f311ce172e3e0a..41a66b3cd59581 100644 --- a/docs/design/features/partitioned-wasm-r2r-modules.md +++ b/docs/design/features/partitioned-wasm-r2r-modules.md @@ -8,7 +8,7 @@ Loading the primary module makes the logical R2R module functional and usable. M The way these auxiliary modules and the methods they hold code for ("aux methods") begin with unoptimized, interpreted code, and later are updated with more optimized code is analogous to code tiering. Therefore, the existing tiering, code versioning, and call counting code will be utilized (with some modifications) for the delayed arrival of R2R code. -Auxiliary modules will hold the wasm method bodies for their aux methods, as well as their fixup data, GC info, and EH info in the `RuntimeFunctions` table. The runtime therefore must support N `RuntimeFunctions` tables per logical R2R module, with one table per physical Wasm file, spreading the EH and GC info across the auxiliary modules. +Auxiliary modules will hold the wasm method bodies for their aux methods, as well as their fixup data, GC info, and EH info. Auxiliary modules export a function, `ActivateModule()`, which copies all its data into the shared memory, and fills in its allotted range in the global function table. Once the module is activated, the tiering system can run fixups and enable calling the R2R code. @@ -30,6 +30,22 @@ Tiering currently assumes a background worker thread can be created to compile t For each module-specific queue of aux methods, the host will check if the module has been downloaded and activated, and tier up methods in the queue by running fixups, setting the PEP to point to the method's entry in the global function table, and clearing `kPrefersInterpreterEntryPoint`. +## Partitioned Data + +### RuntimeFunctions + +A single logical `RuntimeFunctions` section exists, spread across the primary module and auxiliary modules. The primary module contains contiguous entries for the methods it contains, as well as the last entry (and the unwind data it points to), and zeroed memory for auxiliary modules to fill in during activation. The last entry in the section allows the runtime to determine the total VirtualIP allocation required for the virtual module. When a module is activated, it fills in its allotted range in the RuntimeFunctions table for activation/tiering of its methods. + +VirtualIP lookup must use a range-aware binary search that only searches the ranges of activated modules, using the AuxiliaryModuleMetadata activation info to determine active ranges. + +### MethodDefEntryPoints and InstanceMethodEntryPoints + +Two `MethodDefEntryPoints` and `InstanceMethodEntryPoints` exist, one in the primary module and the Auxiliary EntryPoint Module. Fixup lists for the entries in the Auxiliary EntryPoint Module exist in the auxiliary modules. The runtime must check both sections when the Auxiliary EntryPoint 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. + +### ImportSection + +The `ImportSection` (fixups) will be split among primary and auxiliary modules. Each module will have a range of `READYTORUN_IMPORT_SECTION` descriptors for the methods contained within each module. The primary module will allocate space in the WebCil `ImportSection` for auxiliary modules to copy their ImportSection entries, as well as the fixup signatures, non-zero import cells, and auxiliary data to their respective RVAs in the data section. Methods in the auxiliary module may reference fixups in the primary module, but auxiliary modules may not reference fixups owned by other auxiliary modules. + ## Primary Module ### Data layout @@ -47,12 +63,6 @@ The primary module contains: - An element segment defining the primary module's own callable methods and thunks. The slot range reserved for methods in auxiliary modules is left empty and is filled during activation of the auxiliary module. - `MethodDefEntryPoints` and `InstanceMethodEntryPoints` for the methods in the primary module. -### Aux Method Fixups - -The method's entry in `MethodDefEntryPoints` (or `InstanceMethodEntryPoints`) is unchanged and points to the method's fixup list at a fixed address in the primary module. Before the auxiliary module loads, the fixup list holds only the marker's bytes, followed by reserved zero-filled space for the rest of the list. The complete list, including the marker, is stored in the auxiliary module's passive data. During auxiliary module activation, the module rewrites the entire fixup list, with identical bytes for the marker. No separate lookup structure or per-module fixup-list address is required. - -The compiler must encode the complete fixup list, with the marker as its first fixup, and then emit the marker's bytes and the reserved space. The tail of the list is delta-encoded relative to the marker, so it must not be encoded independently. - ### Method activation behavior If activation is unsuccessful because fixup resolution fails, the `kPrefersInterpreterEntryPoint` flag is left set. Method activation should not be retried after a failure, and the runtime should treat the method as any other interpreter-only method. @@ -77,14 +87,65 @@ It exports: - `ActivateModule()`, a method which copies the module's data into its allotted range in the primary module's memory, installs funcrefs to all of the module's compiled methods and funclets into its allotted range of table 0, and marks itself as loaded via a well-known flag in the primary module's memory. Note that installing a funcref does not mean the method is activated and ready to be executed. -## Auxiliary Module EntryPoints +## Auxiliary EntryPoints Module + +The auxiliary entrypoints module contains the `MethodDefEntryPoints` and `InstanceMethodEntryPoints` for the aux methods, excluding the fixup cell info. These sections tell which index in the `RuntimeFunctions` table to find information for the method, as well as which fixups to resolve. A single logical `RuntimeFunctions` is spread across the primary and auxiliary modules. Using the length of the primary module's `RuntimeFunctions` range, and the `MethodCount` field in each Auxiliary module metadata entry (see below), the owning auxiliary module can be determined. + +The EntryPoints sections will leave zeroed space for the fixup lists that will be filled in by the auxiliary module during activation. Fixups for methods from the auxiliary module cannot be run until the module is activated and these indices are filled in. This enables the EntryPoints Module to be smaller, and allow "tiering" of non-R2R method to occur sooner. + +The `MethodDefEntryPoints` sparse array will use backwards-distance encoding to point to each entry's fixup list. Each auxiliary module will be allocated space for all its encoded fixup lists in a blob before the array. This enables the auxiliary module's `ActivateModule()` function to simply copy a data element to a known location. -The Auxiliary module entrypoints module contains the `MethodDefEntryPoints` and `InstanceMethodEntryPoints` for the aux methods. These sections tell which index in the `RuntimeFunctions` table to find information for the method. A single logical `RuntimeFunctions` is spread across the primary and auxiliary modules. Using the length of the primary module's `RuntimeFunctions`, and the `MethodCount` field in each Auxiliary module metadata entry (see below), the owning auxiliary module can be determined. +``` +Lower addresses +┌───────────────────────────────────┐ +│ Aux A fixup lists │ ◄── installed by Aux A +├───────────────────────────────────┤ +│ Aux B fixup lists │ ◄── installed by Aux B +├───────────────────────────────────┤ +│ Sparse-array header │ ◄── R2R directory points HERE +│ Block-index array │ +│ Tree nodes and entrypoint records │ +│ [method index + flags][distance]│ +└───────────────────────────┬───────┘ + │ + backward reference to the relevant list +Higher addresses +``` + +Similarly, the `InstanceMethodEntryPoints` hashtable will allocate contiguous memory to hold the EntryPoints records for all instance methods in an auxiliary module. The auxiliry module can then copy a data element to the correct offset to fill in the list of fixups. + +``` +Hash index Method-record storage + +Bucket 0 ─── entry for A2 ──────────────┐ + └── entry for B1 ────────────────────────┐ + │ │ +Bucket 1 ─── entry for A1 ──────────┐ │ │ + │ │ │ + ▼ │ │ + ┌────────────────┐ │ + │ A1 record │ │ + │ A2 record ◄────┼──┘ + │ A3 record │ │ + └────────────────┘ │ + Aux A's records │ + │ + ┌────────────────┐ │ + │ B1 record ◄────┼──┘ + │ B2 record │ + └────────────────┘ + Aux B's records +``` ## Auxiliary Module Metadata Metadata for each auxiliary module resides at a well-defined location in the primary module. This metadata enables the runtime to activate the module. +Header: +| `InstanceMethodEntryPointsRVA` | RVA of the auxiliary modules' InstanceMethodEntryPoints section | +| `MethodDefEntryPointsRVA` | RVA of the auxiliary modules' MethodDefEntryPoints section | +| `AuxiliaryModuleCount` | Count of auxiliary modules | + The Auxiliary Module Metadata table is a count-prefixed array of entries with the following fields: | Field | Meaning | @@ -92,9 +153,6 @@ The Auxiliary Module Metadata table is a count-prefixed array of entries with th | `FilenameRVA` | RVA of the string naming the auxiliary module. | | `RelativeTableBase` | Primary-module-relative table index of the first slot assigned to the auxiliary module. | | `MethodCount` | Number of runtime-function slots assigned to the auxiliary module, **including funclet slots**. Defines the number of entries in its `RuntimeFunctions` table and the length of its contiguous function-table range. | -| `RuntimeFunctionsRVA` | RVA of this physical module's runtime-function table. The table contains `MethodCount` records. | -| `MethodDefEntryPointsRVA` | RVA of this auxiliary module's MethodDefEntryPoints section | -| `InstanceMethodEntryPointsRVA` | RVA of this auxiliary modules InstanceMethodEntryPoints section | | `INITIALIZED_FLAG` | Initialization state, described below. | ### Module Initialization states @@ -102,32 +160,28 @@ The Auxiliary Module Metadata table is a count-prefixed array of entries with th | `INITIALIZED_FLAG` | State | |---|---| | `0` | Auxiliary module has not been loaded. | -| `1` | Auxiliary module is activated. Data has been copied and the function table range it is allotted has been filled. | +| `1` | Auxiliary module is activated. Data has been copied and the function table range it is allotted has been filled. Methods still exist in the tiering queue. | +| `2` | Auxiliary module is activated, and its tiering queue has been drained. R2R code should be resolved synchronously on first call instead of setting up call counting adding to the queue. | + ### Index relationships -Each auxiliary module's allotted range in the function table is contiguous and has a one-to-one correspondence with its local `RUNTIME_FUNCTION` records, including funclets: +Each auxiliary module's allotted range in the function table is contiguous and has a one-to-one correspondence with its local `RuntimeFunctions` entries, including funclets. -```text -auxiliaryRelativeIndex = - primaryModuleRelativeTableIndex - firstMethodTableIndex +A method's logical `RuntimeFunctions` index is equal to the sum of the count of entries in the primary module's and preceding `RuntimeFunctions` tables, plus its local index within that range. -actualFunctionTableIndex = - primaryModuleTableBase + primaryModuleRelativeTableIndex +A method's index in the global function table (table 0) is the `__table_base` of the primary module plus the method's logical-module-relative `RuntimeFunctions` index. That is, ` = __table_base + ` where the resolved RuntimeFunctions index is the index resolved in the MethodDefEntryPoints or InstanceMethodEntryPoints table. -auxiliaryRuntimeFunctionIndex = auxiliaryRelativeIndex -``` +The count of entries in the auxiliary module's `RuntimeFunctions` table must be equal to the `AuxiliaryModuleMetadata`'s `MethodCount` field for that module. -`auxiliaryRelativeIndex` must be in `[0, MethodCount)`. +This relationship between `RuntimeFunctions` index and function table index (relative to __table_base) must also hold for the primary module. ### Direct-call dependency sets -Activating method A must also complete the required fixups for any method B that A calls directly, even if B was not independently requested. +Activating method A must also complete the required fixups for any method B that A calls directly, even if B was not independently requested. All the methods that are direct-called by A or direct called by any of those methods, recursively, are considered the direct-call dependency set for root method A. Activation of all methods in a dependency set must succeed for the root method to be sucessfully activated. Subsets of the dependency set of A may succeed even if other subsets fail - they are not necessary all-or-none. An aux method's may be **unvisited** (it has no interpreter bytecode or R2R code), **interpreted** (`kPrefersInterpreterEntryPoint` is set), **activation-in-progress** (currently being tiered up, and present in a visited set during recursive activation of direct-called methods), or **activated** (`kPrefersInterpreterEntryPoint` not set). Encountering an activation-in-progress dependency must record the dependency rather than infinitely recursiving and restarting its activation. -Mutually dependent methods, such as A calling B and B calling A, are completed as an activation set. A and B must be activated at the same time and must both succeed activation, or they both fail. - `READYTORUN_FIXUP_MethodEntry_ReadyToRun` already requests target-method fixup processing. These are implemented as Eager imports which run at module load. This does not work for aux methods whose data and fixups are not present in the primary module. These fixups for aux modules must run either during auxiliary module activation, or be modified to be run as pre-code fixups for methods that have direct calls (which requires handling cycles of these fixups - e.g. M1 direct calls M2 which direct calls M1). Direct calls within an auxiliary module are permitted subject to this activation requirement. Direct calls from the primary module to an auxiliary module are not supported. @@ -163,9 +217,9 @@ app.r2r.2.wasm The compiler must: -- Split the `RuntimeFunctions` table into per-physical-module tables. +- Split the `RuntimeFunctions` entries and GC and EH info across auxiliary modules, keeping data for each auxiliary module contiguous, and emitting the last entry of the table to the primary module. - Arrange function-table indices so that the primary module's R2R methods are contiguous and each auxiliary module's R2R methods occupy a contiguous range. -- Include funclet slots in each auxiliary module's `MethodCount` and preserve the one-to-one correspondence between that range and its local `RUNTIME_FUNCTION` records. +- Include funclet slots in each AuxiliaryModuleMetadata's `MethodCount` and preserve the one-to-one correspondence between that range and its local `RUNTIME_FUNCTION` records. - Generate R2R-to-interpreter thunks for every signature shape needed by auxiliary methods. The primary module holds one thunk per shape, and an unactivated auxiliary method's PEP names the thunk for its signature. The primary module's element segment does not reserve a thunk per auxiliary slot. - Emit in each auxiliary module an element segment that fills its slot range in the imported table 0 with that module's methods and funclets, so `ActivateModule` can install every funcref. @@ -175,8 +229,8 @@ The compiler must identify memory regions belonging to auxiliary modules and com The compiler must also: -- Emit each auxiliary method's fixup list as a marker followed by reserved zero-filled space in the primary module, with the complete list in the auxiliary module's passive data. Lay the auxiliary methods' lists out contiguously so a single copy initializes them. The marker's bytes must be identical in both copies. - Generate auxiliary-module metadata at a well-known location discoverable by the runtime. +- Generate `MethodDefEntryPoints` and `InstanceMethodEntryPoints` sections for the primary module and Auxiliary EntryPoints Module. - Adjust code-generation rules so auxiliary code can be loaded safely. - Retain IL for any method assigned to an auxiliary module. @@ -184,95 +238,38 @@ The compiler must also: ### Multiple runtime-function tables -A logical R2R module can have multiple `RuntimeFunctions` sections. An auxiliary module's table becomes eligible for lookup when its `INITIALIZED_FLAG` is non-zero. - -`MethodCount`, including funclet slots, defines the length of the auxiliary table. - -### Deferred method activation - -The runtime recognizes an auxiliary-module marker in the first fixup of a method. Whether the auxiliary module is activated or not, the runtime sets `_pActualCode` to point to the interpreter thunk slot in the global function table, sets interpreter preference, and sets `kAuxiliaryModulePending` on the PEP. If the module has been activated, it then activates the method synchronously. If not, the method is interpreted. On subsequent calls, the `kAuxiliaryModulePending` flag tells the interpreter to check the auxiliary module's activation state to see if the method can be activated. +A logical R2R module can have two `MethodDefEntryPoints`, and `InstanceMethodEntryPoints`. The runtime must attempt to resolve R2R code using the Auxiliary EntryPoints tables when present. -Resolving which auxiliary module the method belongs to can be done in a few ways. -- Search MethodDefEntryPoints/InstanceMethodEntryPoints, determine what auxiliary module owns the range the entry resides in through the AuxiliaryModuleMetadata. -- Add slots to the range allotted to the auxiliary module for interpreter thunks for each signature in the auxiliary module. `_pActualCode` then already points somewhere in the range of the auxiliary module, so we avoid the MethodDefEntryPoints/InstanceMethodEntryPoints lookup. -- Limit the auxiliary module count to 1. This will be done in the initial implementation. +A module's `RuntimeFunctions` table may be only partially filled. VirtualIP lookup must use a range-aware binary search that only searches the ranges of activated modules, using the AuxiliaryModuleMetadata activation info to determine active ranges. -Once activated, `_pActualCode` is set to the method's global table index and `kAuxiliaryModulePending` and `kPrefersInterpreterEntryPoint` are cleared, and `_pInterpreterData` is cleared. +The runtime also must export methods to the host to enable draining the tiering queue. -### Host notification - -When an auxiliary module's `REQUEST_COUNT` changes from **`0` to `1`**, a hosting hook notifies the host that a method from that module has been requested. - -CoreCLR calls +```C +// Runs once the Auxiliary EntryPoints Module is activated +// Removes MethodDescs that don't have R2R code in auxiliary modules +// Moves MethodDescs that do have R2R code to their Module-specific queue. +// Returns how many methods are left in queue +int TierUpMethods(int methodsToProcess); ``` -void OnAuxiliaryModuleRequest(const char *auxModuleName, int moduleBase, int functionTableBase); + +```C +// Activates methods whose owning modules are activated +// Returns how many methods are in the queue +int ActivateMethods(int methodsToProcess); ``` -The hook supplies the primary module's base table index and base memory offset which should be enough to identify the primary module and enable instantiation of the auxiliary module. Future support for direct calls between modules may require a more complex import/export scheme. +The `methodsToProcess` argument is a heuristic budget of root methods to process, not a bound on time. Activating one method can also activate its direct-call dependency closure, so a batch can exceed the budget. + +Deferred methods belonging to modules that have not loaded are excluded from this count. A return value of `0` means there is no remaining activation work for currently loaded modules; it does not mean that all auxiliary modules have arrived or that every method in them has been activated. ## JavaScript Hosting Changes The JavaScript host must: -1. Trigger auxiliary-module downloads, either in response to `OnAuxiliaryModuleRequest` or through a more proactive download policy. +1. Trigger auxiliary-module downloads, either in response to tiering requests or through a more proactive download policy. 2. Instantiate each auxiliary module with the required imports. 3. Call that module's `ActivateModule` export. - -Methods are then activated lazily on their next call; the host does not drive method activation. - -### Gaps - -- **Calls from an auxiliary module into another auxiliary module** remain unsupported. -- **First-call latency.** Fixup work and closure resolution happen on the calling thread at an arbitrary point after the auxiliary module is downloaded. The queue alternative below spreads this work out. - -## Alternative: deferred-activation queue drained incrementally - -Instead of activating on first call, the runtime keeps a list of methods that need activating and drains it in batches. This trades more runtime and host machinery for control over when activation cost is paid. - -### Changes relative to on-demand activation - -- **Method loading when the module is not loaded.** Instead of setting `kAuxiliaryModulePending`, add the `MethodDesc` to a deferred-activation list. The `_pActualCode` thunk is left in place, interpreter preference is still set, and `REQUEST_COUNT` and `OnAuxiliaryModuleRequest` behave as before. Methods discovered after module arrival activate synchronously and are never queued. -- **Initialization states.** `INITIALIZED_FLAG` gains a third value. - - | `INITIALIZED_FLAG` | State | - |---|---| - | `0` | Auxiliary module has not been loaded. | - | `1` | Auxiliary module is loaded; deferred fixup processing remains. | - | `2` | The module is loaded and its deferred-activation queue is drained. This does not mean that every method in the module has been activated. | - - Methods in other modules that have not yet loaded do not prevent the transition to `2`. Methods discovered in state `2` activate synchronously, so discovery does not reopen the queue. -- **Activation procedure.** Unchanged, except that the runtime drains the queue instead of waiting for first calls. Methods completed early, including dependencies completed during synchronous activation or by a first call, are removed from the queue. The helper's first-call path can still be kept as a fast path for hot methods. -- **Per-method state.** Not activated, activation-in-progress, and activated are tracked explicitly rather than by a visited set or PEP flag. -- **Activation failure.** A queued method whose fixups fail is rejected for R2R and stays interpreted. It is removed from the queue and does not count as remaining work, so the host's drain loop terminates. - -### Incremental activation entrypoint - -CoreCLR exposes: - -```c -int DoRuntimeDeferredModuleActivationWork(int methodsToProcess); -``` - -The argument is a heuristic budget of root methods to process, not a bound on time. Activating one method can also activate its direct-call dependency closure, so a batch can exceed the budget. - -The return value counts remaining methods pending activation for loaded auxiliary modules. - -Deferred methods belonging to modules that have not loaded are excluded from this count. A return value of `0` means there is no remaining activation work for currently loaded modules; it does not mean that all auxiliary modules have arrived or that every method in them has been activated. - -### JavaScript host changes - -After calling `ActivateModule`, the host calls `DoRuntimeDeferredModuleActivationWork` in batches, yielding to normal execution between batches, until it returns `0` for the currently loaded modules. A JavaScript-side timer is one possible driver. When another auxiliary module arrives, repeat the sequence for the newly available work. - -### Trade-offs - -| | On-demand (default) | Queue and batching (alternative) | -|---|---|---| -| Runtime state | One loaded state, `kAuxiliaryModulePending` flag | Queue, request counting, three module states | -| Host involvement | Host only loads and installs modules | Host drives the batch loop | -| Activation cost | On the first call to each method | Spread across batches by the host | -| Warm-up control | None without an added prefetch pass | Eager, host-scheduled | -| Compiler and R2R format | No new helper or thunk. One new PEP flag and a check in the existing helper. | Existing helper | - +4. Drain the module-specific queue of methods that need to be tiered up. ## Implementation phases From 40cf02ad631f0c734afdd85bd78a7195fd8975b7 Mon Sep 17 00:00:00 2001 From: Jackson Schuster <36744439+jtschuster@users.noreply.github.com> Date: Thu, 15 Oct 2026 17:23:38 -0700 Subject: [PATCH 05/19] Use inline method activation and delay direct calls in auxiliary modules. (cherry picked from commit da9d206b795d67b39ff34c0aff031c6782ff4608) --- .../features/partitioned-wasm-r2r-modules.md | 181 ++++++++++++------ 1 file changed, 122 insertions(+), 59 deletions(-) diff --git a/docs/design/features/partitioned-wasm-r2r-modules.md b/docs/design/features/partitioned-wasm-r2r-modules.md index 41a66b3cd59581..f610bb0d579b73 100644 --- a/docs/design/features/partitioned-wasm-r2r-modules.md +++ b/docs/design/features/partitioned-wasm-r2r-modules.md @@ -6,41 +6,117 @@ This doc outlines a design for a logical ReadyToRun (R2R) module which is distri Loading the primary module makes the logical R2R module functional and usable. Methods whose compiled code has not yet arrived remain callable through R2R-to-interpreter thunks. Auxiliary modules can arrive later. -The way these auxiliary modules and the methods they hold code for ("aux methods") begin with unoptimized, interpreted code, and later are updated with more optimized code is analogous to code tiering. Therefore, the existing tiering, code versioning, and call counting code will be utilized (with some modifications) for the delayed arrival of R2R code. - Auxiliary modules will hold the wasm method bodies for their aux methods, as well as their fixup data, GC info, and EH info. -Auxiliary modules export a function, `ActivateModule()`, which copies all its data into the shared memory, and fills in its allotted range in the global function table. Once the module is activated, the tiering system can run fixups and enable calling the R2R code. +Auxiliary modules export a function, `ActivateModule()`, which copies all its data into the shared memory, and fills in its allotted range in the global function table. Once the module is activated, the runtime can run fixups and enable executing R2R code for the methods in the auxiliary module. + +The mapping for which methods have code in the auxiliary modules is in a separate module called the Auxiliary EntryPoints Module. This contains a second set of `MethodDefEntryPoints` and `InstanceMethodEntryPoints` sections. Until that module is downloaded, it's not possible to know which methods may have R2R code and which will always be interpreted. + +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. ## Flow -During the first interpreter execution / compilation of bytecode of any method (not just aux methods), the interpreter will insert code at the beginning of the method to decrement its call counter and, if it has reached 0, call the `CallCountingStub.TargetForThresholdReached` method with the required arguments. `TargetForThresholdReached` will queue the method for "tiering". Tiering means resolving and activating its R2R code once its aux module is available (if it's an aux method), or removing the call-counting bytecode (if it's not an aux method). +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. Auxiliary modules are downloaded eagerly unless it is determined better to wait and download on demand. -To reduce cold download size, the mapping of method to auxiliary module resides in a separate module that is downloaded eagerly. +To reduce cold download size, the mapping of method to auxiliary module resides in a separate module, the Auxiliary EntryPoints Module, that is downloaded eagerly. + +Once an auxiliary module is downloaded (we may also require the module to be "requested"), the host calls the exported `ActivateModule()` function, which copies over all data required for methods to be activated and executed, fills the global function table (table 0) with funcrefs, and marks itself as activated via a well-known location in the primary module's data. Once the module is activated, its methods can be activated. + +### Pseudocode + +The following code describes the interpreter flow for activation of aux methods. 2 new PEP flags and a field in the interpreter data are introduced to track activation status and cache lookups: `kPendingAuxiliaryEntryPoints`, `kPendingAuxiliaryModule`, and `InterpreterData.pAuxActivationFlag`. + +The helper names below are illustrative only. Entry-point lookup only reads the method's entry-point record; it does not run fixups or publish a callable target. Status addresses refer to cells in shared memory. `EnsureInterpreterFallback` ensures interpreter bytecode and data exist and leaves `kPrefersInterpreterEntryPoint` set. + +```csharp +InterpOrR2R DiscoverMethod(PEP pep) +{ + var primary = pep.GetModule(); + EntryPoint entry = LookupEntryPoint( + primary.MethodDefEntryPoints, primary.InstanceMethodEntryPoints, pep.MethodDesc); + if (entry is not null) + { + return ActivateOrFallBackToInterpreter(pep, entry); + } + + if (!IsActivated(primary.AuxiliaryModuleMetadataHeader.EntryPointsModuleActivationStatus)) + { + EnsureInterpreterFallback(pep); + pep.InterpreterData.pAuxActivationFlag = &primary.AuxiliaryModuleMetadataHeader.EntryPointsModuleActivationStatus; + pep.SetFlag(kPendingAuxiliaryEntryPoints); + return InterpOrR2R.Interp; + } + + return ResolveAuxiliaryMethod(pep); +} + +InterpOrR2R ResolveAuxiliaryMethod(PEP pep) +{ + // Only called after the auxiliary entry-point tables are activated. + pep.ClearFlag(kPendingAuxiliaryEntryPoints); + pep.ClearFlag(kPendingAuxiliaryModule); + + AuxiliaryModuleMetadataHeader metadata = pep.GetModule().AuxiliaryModuleMetadataHeader; + EntryPoint entry = LookupEntryPoint( + metadata.MethodDefEntryPointsRVA, metadata.InstanceMethodEntryPointsRVA, pep.MethodDesc); + if (entry is null) + { + EnsureInterpreterFallback(pep); + return InterpOrR2R.Interp; + } + + AuxiliaryModuleMetadataEntry auxModuleMetadata = FindAuxiliaryModuleMetadataForRuntimeIndex(entry.RuntimeFunctionIndex); + if (!IsActivated(auxModuleMetadata.ActivationStatus)) + { + EnsureInterpreterFallback(pep); + pep.InterpreterData.pAuxActivationFlag = &auxModuleMetadata.ActivationStatus; + pep.SetFlag(kPendingAuxiliaryModule); + return InterpOrR2R.Interp; + } + + return ActivateOrFallBackToInterpreter(pep, entry); +} +``` + +On subsequent invocations (but not continuation resumption), the interpreter checks the pending flags and their cached status flag address before executing the method body: -Once the mapping is available, the runtime can begin to drain the queue of methods that need tiering. Methods in the queue that are not aux methods may have their callcounting bytecode removed (or not), and are removed from the queue. Aux methods will be added to a separate, module-specific queue to be drained again once the aux module is downloaded and activated. +```csharp +InterpOrR2R ReenterDiscoveredMethod(PEP pep) +{ + if (!pep.HasFlag(kPrefersInterpreterEntryPoint)) + { + return InterpOrR2R.R2R; + } -Once the auxiliary module is downloaded and marked as pending activation (via a flag in a well known location, or its module-specific aux method queue being non-empty), the host calls the exported `ActivateModule` function, which copies over all data required for methods to be activated and executed, fills the global function table (table 0) with funcrefs, and marks itself as activated via a well-known location in the primary module's data. Once the module is activated, its methods can be activated/tiered up. + if (!pep.HasFlag(kPendingAuxiliaryEntryPoints) && !pep.HasFlag(kPendingAuxiliaryModule)) + { + return InterpOrR2R.Interp; + } -Tiering currently assumes a background worker thread can be created to compile the optimized versions of methods. However, WASM is single-threaded and cannot follow that same pattern. Instead, the queue of methods to tier up will be drained incrementally by the host. + if (!IsActivated(pep.InterpreterData.pAuxActivationFlag)) + { + return InterpOrR2R.Interp; + } -For each module-specific queue of aux methods, the host will check if the module has been downloaded and activated, and tier up methods in the queue by running fixups, setting the PEP to point to the method's entry in the global function table, and clearing `kPrefersInterpreterEntryPoint`. + return ResolveAuxiliaryMethod(pep); +} +``` ## Partitioned Data ### RuntimeFunctions -A single logical `RuntimeFunctions` section exists, spread across the primary module and auxiliary modules. The primary module contains contiguous entries for the methods it contains, as well as the last entry (and the unwind data it points to), and zeroed memory for auxiliary modules to fill in during activation. The last entry in the section allows the runtime to determine the total VirtualIP allocation required for the virtual module. When a module is activated, it fills in its allotted range in the RuntimeFunctions table for activation/tiering of its methods. +A single logical `RuntimeFunctions` section exists, spread across the primary module and auxiliary modules. The primary module contains contiguous entries for the methods it contains, as well as the last entry for the last function in the last aux module (and the unwind data it points to), and zeroed memory for auxiliary modules to fill in during activation. The last entry in the section allows the runtime to determine the total VirtualIP allocation required for the virtual module. When a module is activated, it fills in its allotted range in the RuntimeFunctions table for activation of its methods. VirtualIP lookup must use a range-aware binary search that only searches the ranges of activated modules, using the AuxiliaryModuleMetadata activation info to determine active ranges. ### MethodDefEntryPoints and InstanceMethodEntryPoints -Two `MethodDefEntryPoints` and `InstanceMethodEntryPoints` exist, one in the primary module and the Auxiliary EntryPoint Module. Fixup lists for the entries in the Auxiliary EntryPoint Module exist in the auxiliary modules. The runtime must check both sections when the Auxiliary EntryPoint 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. +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. ### ImportSection @@ -85,13 +161,13 @@ It imports: It exports: -- `ActivateModule()`, a method which copies the module's data into its allotted range in the primary module's memory, installs funcrefs to all of the module's compiled methods and funclets into its allotted range of table 0, and marks itself as loaded via a well-known flag in the primary module's memory. Note that installing a funcref does not mean the method is activated and ready to be executed. +- `ActivateModule()`, a method which copies the module's data into its allotted range in the primary module's memory, installs funcrefs to all of the module's compiled methods and funclets into its allotted range of table 0, and marks itself as activated via a well-known flag in the primary module's memory. Note that installing a funcref does not mean the method is activated and ready to be executed. ## Auxiliary EntryPoints Module The auxiliary entrypoints module contains the `MethodDefEntryPoints` and `InstanceMethodEntryPoints` for the aux methods, excluding the fixup cell info. These sections tell which index in the `RuntimeFunctions` table to find information for the method, as well as which fixups to resolve. A single logical `RuntimeFunctions` is spread across the primary and auxiliary modules. Using the length of the primary module's `RuntimeFunctions` range, and the `MethodCount` field in each Auxiliary module metadata entry (see below), the owning auxiliary module can be determined. -The EntryPoints sections will leave zeroed space for the fixup lists that will be filled in by the auxiliary module during activation. Fixups for methods from the auxiliary module cannot be run until the module is activated and these indices are filled in. This enables the EntryPoints Module to be smaller, and allow "tiering" of non-R2R method to occur sooner. +The EntryPoints sections will leave zeroed space for the fixup lists that will be filled in by the auxiliary module during activation. Fixups for methods from the auxiliary module cannot be run until the module is activated and these indices are filled in. This enables the EntryPoints Module to be smaller, and allow methods without R2R code to be identified as interpreter-only sooner. The `MethodDefEntryPoints` sparse array will use backwards-distance encoding to point to each entry's fixup list. Each auxiliary module will be allocated space for all its encoded fixup lists in a blob before the array. This enables the auxiliary module's `ActivateModule()` function to simply copy a data element to a known location. @@ -112,7 +188,7 @@ Lower addresses Higher addresses ``` -Similarly, the `InstanceMethodEntryPoints` hashtable will allocate contiguous memory to hold the EntryPoints records for all instance methods in an auxiliary module. The auxiliry module can then copy a data element to the correct offset to fill in the list of fixups. +Similarly, the `InstanceMethodEntryPoints` hashtable will allocate contiguous memory to hold the EntryPoints records for all instance methods in an auxiliary module. The auxiliary module can then copy a data element to the correct offset to fill in the list of fixups. ``` Hash index Method-record storage @@ -142,8 +218,13 @@ Bucket 1 ─── entry for A1 ──────────┐ │ Metadata for each auxiliary module resides at a well-defined location in the primary module. This metadata enables the runtime to activate the module. Header: + +| Field | Meaning | +|---|---| | `InstanceMethodEntryPointsRVA` | RVA of the auxiliary modules' InstanceMethodEntryPoints section | | `MethodDefEntryPointsRVA` | RVA of the auxiliary modules' MethodDefEntryPoints section | +| `EntryPointsModuleActivationStatus` | Flag indicating whether the Auxiliary EntryPoints Module has completed activation and the auxiliary `InstanceMethodEntryPoints` and `MethodDefEntryPoints` tables are valid for reading. This also could be a flag on the ModuleDesc. | +| `PrimaryModuleMethodCount` | The count of functions (including funclets) that have code and a `RuntimeFunctions` entry in the primary module. This doesn't include the final `RuntimeFunctions` entry used for VirtualIP calculations. | | `AuxiliaryModuleCount` | Count of auxiliary modules | The Auxiliary Module Metadata table is a count-prefixed array of entries with the following fields: @@ -152,23 +233,14 @@ The Auxiliary Module Metadata table is a count-prefixed array of entries with th |---|---| | `FilenameRVA` | RVA of the string naming the auxiliary module. | | `RelativeTableBase` | Primary-module-relative table index of the first slot assigned to the auxiliary module. | -| `MethodCount` | Number of runtime-function slots assigned to the auxiliary module, **including funclet slots**. Defines the number of entries in its `RuntimeFunctions` table and the length of its contiguous function-table range. | -| `INITIALIZED_FLAG` | Initialization state, described below. | - -### Module Initialization states - -| `INITIALIZED_FLAG` | State | -|---|---| -| `0` | Auxiliary module has not been loaded. | -| `1` | Auxiliary module is activated. Data has been copied and the function table range it is allotted has been filled. Methods still exist in the tiering queue. | -| `2` | Auxiliary module is activated, and its tiering queue has been drained. R2R code should be resolved synchronously on first call instead of setting up call counting adding to the queue. | - +| `MethodCount` | Number of method slots assigned to the auxiliary module, including funclets. Defines the number of entries in its `RuntimeFunctions` table and the length of its contiguous function-table range. | +| `ActivationStatus` | Flag indicating whether the auxiliary module has completed activation and its methods can be activated and the R2R code executed. This also could be a flag on the ModuleDesc. | ### Index relationships Each auxiliary module's allotted range in the function table is contiguous and has a one-to-one correspondence with its local `RuntimeFunctions` entries, including funclets. -A method's logical `RuntimeFunctions` index is equal to the sum of the count of entries in the primary module's and preceding `RuntimeFunctions` tables, plus its local index within that range. +A method's logical `RuntimeFunctions` index is equal to the sum of `PrimaryModuleMethodCount` and preceding auxiliary modules' `MethodCount`, plus its local index within that range. A method's index in the global function table (table 0) is the `__table_base` of the primary module plus the method's logical-module-relative `RuntimeFunctions` index. That is, ` = __table_base + ` where the resolved RuntimeFunctions index is the index resolved in the MethodDefEntryPoints or InstanceMethodEntryPoints table. @@ -176,18 +248,6 @@ The count of entries in the auxiliary module's `RuntimeFunctions` table must be This relationship between `RuntimeFunctions` index and function table index (relative to __table_base) must also hold for the primary module. -### Direct-call dependency sets - -Activating method A must also complete the required fixups for any method B that A calls directly, even if B was not independently requested. All the methods that are direct-called by A or direct called by any of those methods, recursively, are considered the direct-call dependency set for root method A. Activation of all methods in a dependency set must succeed for the root method to be sucessfully activated. Subsets of the dependency set of A may succeed even if other subsets fail - they are not necessary all-or-none. - -An aux method's may be **unvisited** (it has no interpreter bytecode or R2R code), **interpreted** (`kPrefersInterpreterEntryPoint` is set), **activation-in-progress** (currently being tiered up, and present in a visited set during recursive activation of direct-called methods), or **activated** (`kPrefersInterpreterEntryPoint` not set). Encountering an activation-in-progress dependency must record the dependency rather than infinitely recursiving and restarting its activation. - -`READYTORUN_FIXUP_MethodEntry_ReadyToRun` already requests target-method fixup processing. These are implemented as Eager imports which run at module load. This does not work for aux methods whose data and fixups are not present in the primary module. These fixups for aux modules must run either during auxiliary module activation, or be modified to be run as pre-code fixups for methods that have direct calls (which requires handling cycles of these fixups - e.g. M1 direct calls M2 which direct calls M1). - -Direct calls within an auxiliary module are permitted subject to this activation requirement. Direct calls from the primary module to an auxiliary module are not supported. - -Direct calls from one auxiliary module to another are not supported. - ## Compiler Changes ### Assign methods to physical modules @@ -238,38 +298,21 @@ The compiler must also: ### Multiple runtime-function tables -A logical R2R module can have two `MethodDefEntryPoints`, and `InstanceMethodEntryPoints`. The runtime must attempt to resolve R2R code using the Auxiliary EntryPoints tables when present. +A logical R2R module can have two `MethodDefEntryPoints` and `InstanceMethodEntryPoints`. The runtime must attempt to resolve R2R code using the Auxiliary EntryPoints tables when present. A module's `RuntimeFunctions` table may be only partially filled. VirtualIP lookup must use a range-aware binary search that only searches the ranges of activated modules, using the AuxiliaryModuleMetadata activation info to determine active ranges. -The runtime also must export methods to the host to enable draining the tiering queue. - -```C -// Runs once the Auxiliary EntryPoints Module is activated -// Removes MethodDescs that don't have R2R code in auxiliary modules -// Moves MethodDescs that do have R2R code to their Module-specific queue. -// Returns how many methods are left in queue -int TierUpMethods(int methodsToProcess); -``` - -```C -// Activates methods whose owning modules are activated -// Returns how many methods are in the queue -int ActivateMethods(int methodsToProcess); -``` - -The `methodsToProcess` argument is a heuristic budget of root methods to process, not a bound on time. Activating one method can also activate its direct-call dependency closure, so a batch can exceed the budget. +### Interpreter changes -Deferred methods belonging to modules that have not loaded are excluded from this count. A return value of `0` means there is no remaining activation work for currently loaded modules; it does not mean that all auxiliary modules have arrived or that every method in them has been activated. +The interpreter must implement the tracking and activation flow defined above. ## JavaScript Hosting Changes The JavaScript host must: -1. Trigger auxiliary-module downloads, either in response to tiering requests or through a more proactive download policy. +1. Trigger auxiliary-module downloads, either eagerly or in response to method activation requests. 2. Instantiate each auxiliary module with the required imports. 3. Call that module's `ActivateModule` export. -4. Drain the module-specific queue of methods that need to be tiered up. ## Implementation phases @@ -285,5 +328,25 @@ Leave all other R2R metadata (fixups, EH and GC info, etc) in the primary module Move the `RuntimeFunctions` table entries and fixups to the auxiliary module, and have method activation resolve this info using the Auxiliary Module Metadata entries. +## Appendix + +### Direct-call dependency sets + +Direct calls within an auxiliary module will not be implemented in V1, and this section is irrelevant until they are implemented. It is left for completeness. + +Activating method A must also complete the required fixups for any method B that A calls directly, even if B was not independently requested. All the methods that are direct-called by A or direct called by any of those methods, recursively, are considered the direct-call dependency set for root method A. Activation of all methods in a dependency set must succeed for the root method to be successfully activated. Subsets of the dependency set of A may succeed even if other subsets fail - they are not necessary all-or-none. + +An aux method may be **unvisited** (it has no interpreter bytecode or R2R code), **interpreted** (`kPrefersInterpreterEntryPoint` is set), **activation-in-progress** (currently being activated, and present in a visited set during recursive activation of direct-called methods), or **activated** (`kPrefersInterpreterEntryPoint` not set). Encountering an activation-in-progress dependency must record the dependency rather than infinitely recursing and restarting its activation. + +`READYTORUN_FIXUP_MethodEntry_ReadyToRun` already requests target-method fixup processing. These are implemented as Eager imports which run at module load. This does not work for aux methods whose data and fixups are not present in the primary module. These fixups for aux modules must run either during auxiliary module activation, or be modified to be run as pre-code fixups for methods that have direct calls (which requires handling cycles of these fixups - e.g. M1 direct calls M2 which direct calls M1). + +Direct calls within an auxiliary module are not supported in V1. They can be implemented later, subject to this activation requirement. + +Direct calls from the primary module to an auxiliary module are not supported. + +Direct calls from one auxiliary module to another are not supported. + +Direct calls from an auxiliary module to the primary module are possible through complex collaboration between the host, exported methods in the primary module, and required imports in the auxiliary module. The required complexity of an implementation is not justified for V1, and is not supported. + > [!NOTE] > This document was written and edited with assistance from GitHub Copilot. From e4c7a551807326fe41c64c0fc598858b46ffe7e0 Mon Sep 17 00:00:00 2001 From: Jackson Schuster <36744439+jtschuster@users.noreply.github.com> Date: Fri, 16 Oct 2026 16:46:22 -0700 Subject: [PATCH 06/19] MethodDefEntryPoints per assembly, clarify activation checks (cherry picked from commit 574c919f4e8515768ade2c504d21d5919ee9bce9) --- .../features/partitioned-wasm-r2r-modules.md | 61 ++++++++----------- 1 file changed, 26 insertions(+), 35 deletions(-) diff --git a/docs/design/features/partitioned-wasm-r2r-modules.md b/docs/design/features/partitioned-wasm-r2r-modules.md index f610bb0d579b73..6589dabad1c086 100644 --- a/docs/design/features/partitioned-wasm-r2r-modules.md +++ b/docs/design/features/partitioned-wasm-r2r-modules.md @@ -14,11 +14,9 @@ The mapping for which methods have code in the auxiliary modules is in a separat 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. - ## 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. +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. Checking for activation of the EntryPoints module and the auxiliary module should be as cheap as possible. In this design, it is a simple flag check in the WASM memory. Auxiliary modules are downloaded eagerly unless it is determined better to wait and download on demand. @@ -43,7 +41,7 @@ InterpOrR2R DiscoverMethod(PEP pep) return ActivateOrFallBackToInterpreter(pep, entry); } - if (!IsActivated(primary.AuxiliaryModuleMetadataHeader.EntryPointsModuleActivationStatus)) + if (primary.AuxiliaryModuleMetadataHeader.EntryPointsModuleActivationStatus == 0) { EnsureInterpreterFallback(pep); pep.InterpreterData.pAuxActivationFlag = &primary.AuxiliaryModuleMetadataHeader.EntryPointsModuleActivationStatus; @@ -62,7 +60,7 @@ InterpOrR2R ResolveAuxiliaryMethod(PEP pep) AuxiliaryModuleMetadataHeader metadata = pep.GetModule().AuxiliaryModuleMetadataHeader; EntryPoint entry = LookupEntryPoint( - metadata.MethodDefEntryPointsRVA, metadata.InstanceMethodEntryPointsRVA, pep.MethodDesc); + metadata.MethodDefEntryPointsInfoRVA, metadata.InstanceMethodEntryPointsRVA, pep.MethodDesc); if (entry is null) { EnsureInterpreterFallback(pep); @@ -70,7 +68,7 @@ InterpOrR2R ResolveAuxiliaryMethod(PEP pep) } AuxiliaryModuleMetadataEntry auxModuleMetadata = FindAuxiliaryModuleMetadataForRuntimeIndex(entry.RuntimeFunctionIndex); - if (!IsActivated(auxModuleMetadata.ActivationStatus)) + if (auxModuleMetadata.ActivationStatus == 0) { EnsureInterpreterFallback(pep); pep.InterpreterData.pAuxActivationFlag = &auxModuleMetadata.ActivationStatus; @@ -116,7 +114,7 @@ VirtualIP lookup must use a range-aware binary search that only searches the ran ### 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. +Each `MethodDefEntryPoints` section (one for each assembly in the R2R image), as well as the image-wide `InstanceMethodEntryPoints` section will have a partial entry in each (shared by all assemblies in the R2R image), 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. ### ImportSection @@ -167,28 +165,9 @@ It exports: The auxiliary entrypoints module contains the `MethodDefEntryPoints` and `InstanceMethodEntryPoints` for the aux methods, excluding the fixup cell info. These sections tell which index in the `RuntimeFunctions` table to find information for the method, as well as which fixups to resolve. A single logical `RuntimeFunctions` is spread across the primary and auxiliary modules. Using the length of the primary module's `RuntimeFunctions` range, and the `MethodCount` field in each Auxiliary module metadata entry (see below), the owning auxiliary module can be determined. -The EntryPoints sections will leave zeroed space for the fixup lists that will be filled in by the auxiliary module during activation. Fixups for methods from the auxiliary module cannot be run until the module is activated and these indices are filled in. This enables the EntryPoints Module to be smaller, and allow methods without R2R code to be identified as interpreter-only sooner. - -The `MethodDefEntryPoints` sparse array will use backwards-distance encoding to point to each entry's fixup list. Each auxiliary module will be allocated space for all its encoded fixup lists in a blob before the array. This enables the auxiliary module's `ActivateModule()` function to simply copy a data element to a known location. - -``` -Lower addresses -┌───────────────────────────────────┐ -│ Aux A fixup lists │ ◄── installed by Aux A -├───────────────────────────────────┤ -│ Aux B fixup lists │ ◄── installed by Aux B -├───────────────────────────────────┤ -│ Sparse-array header │ ◄── R2R directory points HERE -│ Block-index array │ -│ Tree nodes and entrypoint records │ -│ [method index + flags][distance]│ -└───────────────────────────┬───────┘ - │ - backward reference to the relevant list -Higher addresses -``` +The EntryPoints sections in the Auxiliary EntryPoints Module will leave space for the fixup lists that will be filled in by the auxiliary module during activation. To enable auxiliary modules to activate before the Auxiliary EntryPoints Module, this space should not be written overwritten during activation of the Auxiliary EntryPoints Module. Fixups for methods from the auxiliary module cannot be run until the module is activated and these indices are filled in. This enables the EntryPoints Module to be smaller, and allow methods without R2R code to be identified as interpreter-only sooner. -Similarly, the `InstanceMethodEntryPoints` hashtable will allocate contiguous memory to hold the EntryPoints records for all instance methods in an auxiliary module. The auxiliary module can then copy a data element to the correct offset to fill in the list of fixups. +The `InstanceMethodEntryPoints` hashtable will allocate contiguous memory to hold the data for all instance methods in an auxiliary module. The auxiliary module can then copy a data element to the correct offset to fill in the list of fixups. ``` Hash index Method-record storage @@ -215,19 +194,19 @@ Bucket 1 ─── entry for A1 ──────────┐ │ ## Auxiliary Module Metadata -Metadata for each auxiliary module resides at a well-defined location in the primary module. This metadata enables the runtime to activate the module. +Metadata for each auxiliary module resides at a well-defined location in the primary module, probably in a new section. This metadata enables the runtime to activate the module. Header: | Field | Meaning | |---|---| | `InstanceMethodEntryPointsRVA` | RVA of the auxiliary modules' InstanceMethodEntryPoints section | -| `MethodDefEntryPointsRVA` | RVA of the auxiliary modules' MethodDefEntryPoints section | +| `MethodDefEntryPointsInfoRVA` | RVA of an array of (IMAGE_DATA_DIRECTORY `CorHeader`, IMAGE_DATA_DIRECTORY `MethodDefEntryPointsRVA`) tuples that allow the runtime to resolve an assembly's auxiliary MethodDefEntryPoints section | | `EntryPointsModuleActivationStatus` | Flag indicating whether the Auxiliary EntryPoints Module has completed activation and the auxiliary `InstanceMethodEntryPoints` and `MethodDefEntryPoints` tables are valid for reading. This also could be a flag on the ModuleDesc. | | `PrimaryModuleMethodCount` | The count of functions (including funclets) that have code and a `RuntimeFunctions` entry in the primary module. This doesn't include the final `RuntimeFunctions` entry used for VirtualIP calculations. | | `AuxiliaryModuleCount` | Count of auxiliary modules | -The Auxiliary Module Metadata table is a count-prefixed array of entries with the following fields: +Following the header is the Auxiliary Module Metadata table. The table is an array of entries with the following fields: | Field | Meaning | |---|---| @@ -236,6 +215,12 @@ The Auxiliary Module Metadata table is a count-prefixed array of entries with th | `MethodCount` | Number of method slots assigned to the auxiliary module, including funclets. Defines the number of entries in its `RuntimeFunctions` table and the length of its contiguous function-table range. | | `ActivationStatus` | Flag indicating whether the auxiliary module has completed activation and its methods can be activated and the R2R code executed. This also could be a flag on the ModuleDesc. | +The `MethodDefEntryPointsInfoRVA` points to data identical to the format of the `ComponentAssemblies` section. Once filled in by the Auxiliary EntryPoints Module, the `CorHeader` will point to the same location in the assembly as the entry in the `ComponentAssemblies` section (right now crossgen2 emits these as zeros and relies on correlated order with the `ManifestMetadata` assembly names - this ordering must be preserved). The `MethodDefEntryPointsRVA` will point to the auxiliary `MethodDefEntryPoints` section for that assembly. All this data would reside within the Auxiliary Module Metadata section. + +### Alternative data format + +Instead of putting bookkeeping for the Auxiliary EntryPoints Module inside the Auxiliary Module Metadata section, new sections could be created for the image-wide `AuxiliaryInstanceMethodEntryPoints` section and per-assembly auxiliary `AuxiliaryMethodDefEntryPoints` sections. The AuxiliaryModuleMetadata header could remove the `InstanceMethodEntryPointsRVA` and `MethodDefEntryPointsInfoRVA` fields. + ### Index relationships Each auxiliary module's allotted range in the function table is contiguous and has a one-to-one correspondence with its local `RuntimeFunctions` entries, including funclets. @@ -298,7 +283,7 @@ The compiler must also: ### Multiple runtime-function tables -A logical R2R module can have two `MethodDefEntryPoints` and `InstanceMethodEntryPoints`. The runtime must attempt to resolve R2R code using the Auxiliary EntryPoints tables when present. +A logical R2R module can have two `MethodDefEntryPoints` per assembly and two `InstanceMethodEntryPoints` per image. The runtime must attempt to resolve R2R code using the Auxiliary EntryPoints Module data once it has been activated. A module's `RuntimeFunctions` table may be only partially filled. VirtualIP lookup must use a range-aware binary search that only searches the ranges of activated modules, using the AuxiliaryModuleMetadata activation info to determine active ranges. @@ -310,21 +295,27 @@ The interpreter must implement the tracking and activation flow defined above. The JavaScript host must: -1. Trigger auxiliary-module downloads, either eagerly or in response to method activation requests. +1. Trigger auxiliary-module downloads (including the Auxiliary EntryPoints Module), either eagerly or in response to method activation requests. 2. Instantiate each auxiliary module with the required imports. 3. Call that module's `ActivateModule` export. ## 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. + ### 1. Support delayed resolution of R2R code within the same module. Using either call-counting in the interpreter or a timer in the host JavaScript code, force methods to be interpreted (even if R2R code exists for them in the primary module) before installing R2R code. This doesn't require any changes to crossgen2 or the webcil format. -### 2. Split some wasm function bodies into the cold module. +### 2. Split InstanceMethodEntryPoints and MethodDefEntryPoints sections out + +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. Leave all other R2R metadata (fixups, EH and GC info, etc) in the primary module, but move some cold wasm method bodies to a cold module. `ActivateModule` only has to update the global function table. Requires changes from crossgen2, the host, and the runtime. -### 3. Move metadata for aux methods into the auxiliary module. +### 4. Move metadata for aux methods into the auxiliary module. Move the `RuntimeFunctions` table entries and fixups to the auxiliary module, and have method activation resolve this info using the Auxiliary Module Metadata entries. From d98f4ed44bbfa458b9407ea4dcaf482697b8b857 Mon Sep 17 00:00:00 2001 From: Jackson Schuster <36744439+jtschuster@users.noreply.github.com> Date: Mon, 19 Oct 2026 11:23:24 -0700 Subject: [PATCH 07/19] Add more diagrams and minor wording changes (cherry picked from commit 236f8291c3ca4a83ed6d2b5ff66a2ae0ec10a579) --- .../features/partitioned-wasm-r2r-modules.md | 95 ++++++++++++++----- 1 file changed, 72 insertions(+), 23 deletions(-) diff --git a/docs/design/features/partitioned-wasm-r2r-modules.md b/docs/design/features/partitioned-wasm-r2r-modules.md index 6589dabad1c086..4aafa94237dc3d 100644 --- a/docs/design/features/partitioned-wasm-r2r-modules.md +++ b/docs/design/features/partitioned-wasm-r2r-modules.md @@ -112,6 +112,27 @@ A single logical `RuntimeFunctions` section exists, spread across the primary mo VirtualIP lookup must use a range-aware binary search that only searches the ranges of activated modules, using the AuxiliaryModuleMetadata activation info to determine active ranges. +```text +AuxiliaryModuleMetadata RuntimeFunctions table + Index Owning module + ┌─────┬───────────────┐ +Header: ┌─┤ 0 │ Primary │ + PrimaryModuleMethodCount = 3 ─────────────►┤ │ 1 │ Primary │ + AuxiliaryModuleCount = 2 └─┤ 2 │ Primary │ + ├─────┼───────────────┤ +Entries: ┌─┤ 3 │ Aux A │ + Aux A │ │ 4 │ Aux A │ + MethodCount = 4 ────────────────────────►┤ │ 5 │ Aux A │ + RelativeTableBase = 3 └─┤ 6 │ Aux A │ + ├─────┼───────────────┤ + Aux B ┌─┤ 7 │ Aux B │ + MethodCount = 3 ────────────────────────►┤ │ 8 │ Aux B │ + RelativeTableBase = 7 └─┤ 9 │ Aux B │ + └─────┴───────────────┘ +``` + +The entire table is allocated space in the primary module. Slots 0-2 are populated, slots 3-8 zeroed, and slot 9 (plus its unwind data) is populated for VirtualIP sizing. The method in slot 9 is still "owned" by Auxiliary module B, and is included in its MethodCount. + ### MethodDefEntryPoints and InstanceMethodEntryPoints Each `MethodDefEntryPoints` section (one for each assembly in the R2R image), as well as the image-wide `InstanceMethodEntryPoints` section will have a partial entry in each (shared by all assemblies in the R2R image), 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. @@ -139,7 +160,7 @@ The primary module contains: ### Method activation behavior -If activation is unsuccessful because fixup resolution fails, the `kPrefersInterpreterEntryPoint` flag is left set. Method activation should not be retried after a failure, and the runtime should treat the method as any other interpreter-only method. +If activation is unsuccessful because fixup resolution fails, the `kPrefersInterpreterEntryPoint` flag is left set. Method activation should not be retried after a failure. After failed activation, the runtime should treat the method as any other interpreter-only method. ## Auxiliary Modules @@ -163,33 +184,61 @@ It exports: ## Auxiliary EntryPoints Module -The auxiliary entrypoints module contains the `MethodDefEntryPoints` and `InstanceMethodEntryPoints` for the aux methods, excluding the fixup cell info. These sections tell which index in the `RuntimeFunctions` table to find information for the method, as well as which fixups to resolve. A single logical `RuntimeFunctions` is spread across the primary and auxiliary modules. Using the length of the primary module's `RuntimeFunctions` range, and the `MethodCount` field in each Auxiliary module metadata entry (see below), the owning auxiliary module can be determined. +The auxiliary entrypoints holds information about what methods have code in any auxiliary module. There is one AuxiliaryEntryPointsModule per logical R2R module. It contains one or more `MethodDefEntryPoints` sections (one for each assembly in the R2R image), and one `InstanceMethodEntryPoints` (for the entire image). Together, these hold a mappings from method to `RuntimeFunctions` index. Each section for the aux methods, excluding the fixup cell info. These sections tell which index in the `RuntimeFunctions` table to find information for the method, as well as which fixups to resolve. A single logical `RuntimeFunctions` is spread across the primary and auxiliary modules. Using the length of the primary module's `RuntimeFunctions` range, and the `MethodCount` field in each Auxiliary module metadata entry (see below), the owning auxiliary module can be determined. The EntryPoints sections in the Auxiliary EntryPoints Module will leave space for the fixup lists that will be filled in by the auxiliary module during activation. To enable auxiliary modules to activate before the Auxiliary EntryPoints Module, this space should not be written overwritten during activation of the Auxiliary EntryPoints Module. Fixups for methods from the auxiliary module cannot be run until the module is activated and these indices are filled in. This enables the EntryPoints Module to be smaller, and allow methods without R2R code to be identified as interpreter-only sooner. -The `InstanceMethodEntryPoints` hashtable will allocate contiguous memory to hold the data for all instance methods in an auxiliary module. The auxiliary module can then copy a data element to the correct offset to fill in the list of fixups. +### Auxiliary MethodDefEntryPoints format + +The auxiliary `MethodDefEntryPoints` section for each assembly may have code spread across multiple auxiliary modules. The AuxiliaryEntryPointsModule will hold the sparse array metadata (header and block-index array), as well as the `RuntimeFunctions` index and flags for each entry, and backwards-distance-encoded offset to the method's fixup list. The actual fixup list will be owned by the Auxiliary Module that holds the code for that method. Each auxiliary module will be allocated contiguous space for all its encoded fixup lists in a blob before the array. This enables the auxiliary module's `ActivateModule()` function to simply copy a data element to a known location. + +``` +Lower addresses +┌───────────────────────────────────┐ +│ Aux A fixup lists │ ◄── installed by Aux A +├───────────────────────────────────┤ +│ Aux B fixup lists │ ◄── installed by Aux B +├───────────────────────────────────┤ +│ Sparse-array header │ ◄── R2R directory points HERE +│ Block-index array │ +│ Tree nodes and entrypoint records │ +│ [method index + flags][distance]│ +└───────────────────────────┬───────┘ + │ + backward reference to the relevant list +Higher addresses +``` + +### Auxiliary InstanceMethodEntryPoints format + +The AuxiliaryEntryPointsModule contains the `InstanceMethodEntryPoints` hash table metadata and the method records it points to. Each record contains the method signature, the encoded `RuntimeFunctions` index and flags, and, when fixups are required, a backward distance to its fixup list. Each auxiliary module has a contiguous reserved region before its method records for its encoded fixup lists. The auxiliary module copies only those lists into the reserved region during activation; the signatures, indices, flags, and distances are supplied by the AuxiliaryEntryPointsModule. ``` -Hash index Method-record storage - -Bucket 0 ─── entry for A2 ──────────────┐ - └── entry for B1 ────────────────────────┐ - │ │ -Bucket 1 ─── entry for A1 ──────────┐ │ │ - │ │ │ - ▼ │ │ - ┌────────────────┐ │ - │ A1 record │ │ - │ A2 record ◄────┼──┘ - │ A3 record │ │ - └────────────────┘ │ - Aux A's records │ - │ - ┌────────────────┐ │ - │ B1 record ◄────┼──┘ - │ B2 record │ - └────────────────┘ - Aux B's records +Hash index (supplied by AuxiliaryEntryPointsModule) + +Bucket 0 ─── [low hash][relative offset] ────┐ + └── [low hash][relative offset] ────┼───────────────────────────────────────────────┐ +Bucket 1 ─── [low hash][relative offset] ─┐ │ │ + │ │ Aux A storage │ Aux B storage + │ │ Lower addresses │ Lower addresses + │ │ ┌───────────────────────────────────┐ │ ┌───────────────────────────────────┐ + │ │ │ Reserved fixup-list space │◄─┐ │ │ Reserved fixup-list space │◄─┐ + │ │ │ (filled by Aux A) │ │ │ │ (filled by Aux B) │ │ + │ │ ├───────────────────────────────────┤ │ │ ├───────────────────────────────────┤ │ + └──┼─►│ A1 signature │ │ └──►│ B1 signature │ │ + │ │ RuntimeFunctions index + flags │ │ │ RuntimeFunctions index + flags │ │ + │ │ backward distance │──┤ │ backward distance │──┤ + │ ├───────────────────────────────────┤ │ ├───────────────────────────────────┤ │ + └─►│ A2 signature │ │ │ B2 signature │ │ + │ RuntimeFunctions index + flags │ │ │ RuntimeFunctions index + flags │ │ + │ backward distance │──┘ │ backward distance │──┘ + └───────────────────────────────────┘ └───────────────────────────────────┘ + Higher addresses Higher addresses + +Method records are supplied by AuxiliaryEntryPointsModule. +Each backward distance identifies that record's list within the reserved space. +The illustrated records have fixups; their index-and-flags field is encoded as +(RuntimeFunctions index << 2) | 3. Records without fixups omit the distance. ``` ## Auxiliary Module Metadata From 7ede5e2d6f6a67b90ea5f5dbd519c16917315886 Mon Sep 17 00:00:00 2001 From: Jackson Schuster <36744439+jtschuster@users.noreply.github.com> Date: Mon, 26 Oct 2026 22:52:32 -0700 Subject: [PATCH 08/19] Update partitioned R2R modules design for the delayed activation prototype Describe the two-phase flow as implemented: methods that miss wait for an auxiliary EntryPoints era, methods found in an inactive auxiliary module wait for its activation flag, and activation happens inline at call boundaries. Document that EntryPoints and module activation are independent, how failed activation is handled, and the simulation knobs used by the prototype. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 9444d653-1735-42ee-a4a7-119174358f8d --- .../features/partitioned-wasm-r2r-modules.md | 129 ++++++++++-------- 1 file changed, 75 insertions(+), 54 deletions(-) diff --git a/docs/design/features/partitioned-wasm-r2r-modules.md b/docs/design/features/partitioned-wasm-r2r-modules.md index 4aafa94237dc3d..b07a409e1f3ce9 100644 --- a/docs/design/features/partitioned-wasm-r2r-modules.md +++ b/docs/design/features/partitioned-wasm-r2r-modules.md @@ -18,69 +18,65 @@ Metadata and activation info about auxiliary modules is stored in the AuxiliaryM 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. Checking for activation of the EntryPoints module and the auxiliary module should be as cheap as possible. In this design, it is a simple flag check in the WASM memory. +A lookup that finds nothing does not prove the method will always be interpreted. Generic code can live in an image other than the method's defining module, and an image that is loaded later can have the code. A method that misses therefore records the *auxiliary EntryPoints era* it observed, and repeats the lookup once the era has advanced. The era is global, and advances each time an Auxiliary EntryPoints Module is activated or a new image with code for unrelated generics is registered. For the same reason, the module that will eventually hold a missed method's code is not known up front, so the method waits on the era rather than on a specific module's EntryPoints status. + Auxiliary modules are downloaded eagerly unless it is determined better to wait and download on demand. -To reduce cold download size, the mapping of method to auxiliary module resides in a separate module, the Auxiliary EntryPoints Module, that is downloaded eagerly. +The Auxiliary EntryPoints Module and the auxiliary modules can be activated in either order. An auxiliary module that is activated first is not observable, because no lookup can find its methods until the EntryPoints module arrives. The era then advances, and the retried lookup finds the method and activates it directly. An EntryPoints module activated first leaves the methods it maps in the pending-module state until their module arrives. + +To reduce cold download size, the mapping of method to auxiliary module resides in a separate module, the Auxiliary EntryPoints Module, which is downloaded eagerly. Once an auxiliary module is downloaded (we may also require the module to be "requested"), the host calls the exported `ActivateModule()` function, which copies over all data required for methods to be activated and executed, fills the global function table (table 0) with funcrefs, and marks itself as activated via a well-known location in the primary module's data. Once the module is activated, its methods can be activated. ### Pseudocode -The following code describes the interpreter flow for activation of aux methods. 2 new PEP flags and a field in the interpreter data are introduced to track activation status and cache lookups: `kPendingAuxiliaryEntryPoints`, `kPendingAuxiliaryModule`, and `InterpreterData.pAuxActivationFlag`. +The following code describes the interpreter flow for activation of aux methods. 2 PEP flags track what a method is waiting on: `kAwaitingAuxiliaryEntryPoints` (the lookup missed and may succeed after the era advances) and `kPendingAuxiliaryModule` (the code is in an auxiliary module that is not yet activated). The interpreter method data caches the same state as a discriminated value, so call sites decide with a few loads whether to try activation: nothing to wait for (the default), the address of an auxiliary module activation flag, or the era observed by the missed lookup. -The helper names below are illustrative only. Entry-point lookup only reads the method's entry-point record; it does not run fixups or publish a callable target. Status addresses refer to cells in shared memory. `EnsureInterpreterFallback` ensures interpreter bytecode and data exist and leaves `kPrefersInterpreterEntryPoint` set. +The helper names below are illustrative only. Entry-point lookup only reads the method's entry-point record; it does not run fixups or publish a callable target. Status addresses refer to cells in shared memory. `EnsureInterpreterFallback` ensures interpreter bytecode and data exist and leaves `kPrefersInterpreterEntryPoint` set. `CandidateImages` is the defining module's image, then the alternate generic location, then the images registered for unrelated generics. ```csharp InterpOrR2R DiscoverMethod(PEP pep) { - var primary = pep.GetModule(); - EntryPoint entry = LookupEntryPoint( - primary.MethodDefEntryPoints, primary.InstanceMethodEntryPoints, pep.MethodDesc); - if (entry is not null) - { - return ActivateOrFallBackToInterpreter(pep, entry); - } - - if (primary.AuxiliaryModuleMetadataHeader.EntryPointsModuleActivationStatus == 0) - { - EnsureInterpreterFallback(pep); - pep.InterpreterData.pAuxActivationFlag = &primary.AuxiliaryModuleMetadataHeader.EntryPointsModuleActivationStatus; - pep.SetFlag(kPendingAuxiliaryEntryPoints); - return InterpOrR2R.Interp; - } - - return ResolveAuxiliaryMethod(pep); -} - -InterpOrR2R ResolveAuxiliaryMethod(PEP pep) -{ - // Only called after the auxiliary entry-point tables are activated. - pep.ClearFlag(kPendingAuxiliaryEntryPoints); - pep.ClearFlag(kPendingAuxiliaryModule); + // Read before any lookup, so an activation or registration that happens during the lookup is not missed. + var era = CurrentAuxiliaryEntryPointsEra; - AuxiliaryModuleMetadataHeader metadata = pep.GetModule().AuxiliaryModuleMetadataHeader; - EntryPoint entry = LookupEntryPoint( - metadata.MethodDefEntryPointsInfoRVA, metadata.InstanceMethodEntryPointsRVA, pep.MethodDesc); - if (entry is null) + foreach (var image in CandidateImages(pep.MethodDesc)) { - EnsureInterpreterFallback(pep); - return InterpOrR2R.Interp; - } + EntryPoint entry = LookupEntryPoint( + image.MethodDefEntryPoints, image.InstanceMethodEntryPoints, pep.MethodDesc); + if (entry is null && image.AuxiliaryEntryPointsModuleActivationStatus != 0) + { + entry = LookupEntryPoint( + image.AuxMethodDefEntryPoints, image.AuxInstanceMethodEntryPoints, pep.MethodDesc); + } + + if (entry is null) + { + continue; + } + + AuxiliaryModuleMetadataEntry auxModule = FindAuxiliaryModuleMetadataForRuntimeIndex(entry.RuntimeFunctionIndex); + if (auxModule is not null && auxModule.ActivationStatus == 0) + { + // Found, but the code has not arrived. Stop searching: this is the image that owns the method. + EnsureInterpreterFallback(pep); + pep.InterpreterData.AuxiliaryMethodInfo = CheckActivationFlag(&auxModule.ActivationStatus); + pep.SetFlag(kPendingAuxiliaryModule); + return InterpOrR2R.Interp; + } - AuxiliaryModuleMetadataEntry auxModuleMetadata = FindAuxiliaryModuleMetadataForRuntimeIndex(entry.RuntimeFunctionIndex); - if (auxModuleMetadata.ActivationStatus == 0) - { - EnsureInterpreterFallback(pep); - pep.InterpreterData.pAuxActivationFlag = &auxModuleMetadata.ActivationStatus; - pep.SetFlag(kPendingAuxiliaryModule); - return InterpOrR2R.Interp; + return ActivateOrFallBackToInterpreter(pep, entry); } - return ActivateOrFallBackToInterpreter(pep, entry); + // Nothing found. An Auxiliary EntryPoints Module activated later, or an image registered later, may have the code. + EnsureInterpreterFallback(pep); + pep.InterpreterData.AuxiliaryMethodInfo = CheckEra(era); + pep.SetFlag(kAwaitingAuxiliaryEntryPoints); + return InterpOrR2R.Interp; } ``` -On subsequent invocations (but not continuation resumption), the interpreter checks the pending flags and their cached status flag address before executing the method body: +On subsequent invocations (but not continuation resumption), the interpreter checks the cached state before executing the method body: ```csharp InterpOrR2R ReenterDiscoveredMethod(PEP pep) @@ -90,17 +86,23 @@ InterpOrR2R ReenterDiscoveredMethod(PEP pep) return InterpOrR2R.R2R; } - if (!pep.HasFlag(kPendingAuxiliaryEntryPoints) && !pep.HasFlag(kPendingAuxiliaryModule)) + var info = pep.InterpreterData.AuxiliaryMethodInfo; + bool ready = info.Kind switch { - return InterpOrR2R.Interp; - } + CheckActivationFlag => IsActivated(info.ActivationFlagAddress), + CheckEra => info.Era != CurrentAuxiliaryEntryPointsEra, + _ => false, + }; - if (!IsActivated(pep.InterpreterData.pAuxActivationFlag)) + if (!ready) { return InterpOrR2R.Interp; } - return ResolveAuxiliaryMethod(pep); + // Clear before the lookup and fixups so a reentrant call does not activate the method twice. + pep.ClearFlag(kAwaitingAuxiliaryEntryPoints); + pep.ClearFlag(kPendingAuxiliaryModule); + return DiscoverMethod(pep); } ``` @@ -135,7 +137,7 @@ The entire table is allocated space in the primary module. Slots 0-2 are populat ### MethodDefEntryPoints and InstanceMethodEntryPoints -Each `MethodDefEntryPoints` section (one for each assembly in the R2R image), as well as the image-wide `InstanceMethodEntryPoints` section will have a partial entry in each (shared by all assemblies in the R2R image), 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. +Each `MethodDefEntryPoints` section (one for each assembly in the R2R image), as well as the image-wide `InstanceMethodEntryPoints` section (shared by all assemblies in the R2R image), will have a partial copy in each of 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. ### ImportSection @@ -160,7 +162,7 @@ The primary module contains: ### Method activation behavior -If activation is unsuccessful because fixup resolution fails, the `kPrefersInterpreterEntryPoint` flag is left set. Method activation should not be retried after a failure. After failed activation, the runtime should treat the method as any other interpreter-only method. +If activation is unsuccessful because fixup resolution fails (or throws), the `kPrefersInterpreterEntryPoint` flag is left set. Method activation should not be retried after a failure. After failed activation, the runtime should treat the method as any other interpreter-only method, including resetting the cached interpreter method data so calls no longer re-enter the prestub. ## Auxiliary Modules @@ -184,9 +186,9 @@ It exports: ## Auxiliary EntryPoints Module -The auxiliary entrypoints holds information about what methods have code in any auxiliary module. There is one AuxiliaryEntryPointsModule per logical R2R module. It contains one or more `MethodDefEntryPoints` sections (one for each assembly in the R2R image), and one `InstanceMethodEntryPoints` (for the entire image). Together, these hold a mappings from method to `RuntimeFunctions` index. Each section for the aux methods, excluding the fixup cell info. These sections tell which index in the `RuntimeFunctions` table to find information for the method, as well as which fixups to resolve. A single logical `RuntimeFunctions` is spread across the primary and auxiliary modules. Using the length of the primary module's `RuntimeFunctions` range, and the `MethodCount` field in each Auxiliary module metadata entry (see below), the owning auxiliary module can be determined. +The Auxiliary EntryPoints Module holds information about what methods have code in any auxiliary module. There is one AuxiliaryEntryPointsModule per logical R2R module. It contains one or more `MethodDefEntryPoints` sections (one for each assembly in the R2R image), and one `InstanceMethodEntryPoints` (for the entire image). Together, these hold a mapping from method to `RuntimeFunctions` index. Each section covers only the aux methods, excluding the fixup cell info. These sections tell which index in the `RuntimeFunctions` table to find information for the method, as well as which fixups to resolve. A single logical `RuntimeFunctions` is spread across the primary and auxiliary modules. Using the length of the primary module's `RuntimeFunctions` range, and the `MethodCount` field in each Auxiliary module metadata entry (see below), the owning auxiliary module can be determined. -The EntryPoints sections in the Auxiliary EntryPoints Module will leave space for the fixup lists that will be filled in by the auxiliary module during activation. To enable auxiliary modules to activate before the Auxiliary EntryPoints Module, this space should not be written overwritten during activation of the Auxiliary EntryPoints Module. Fixups for methods from the auxiliary module cannot be run until the module is activated and these indices are filled in. This enables the EntryPoints Module to be smaller, and allow methods without R2R code to be identified as interpreter-only sooner. +The EntryPoints sections in the Auxiliary EntryPoints Module will leave space for the fixup lists that will be filled in by the auxiliary module during activation. To enable auxiliary modules to activate before the Auxiliary EntryPoints Module, this space should not be overwritten during activation of the Auxiliary EntryPoints Module. Fixups for methods from the auxiliary module cannot be run until the module is activated and these indices are filled in. This enables the EntryPoints Module to be smaller, and allows methods without R2R code to be identified as interpreter-only sooner. ### Auxiliary MethodDefEntryPoints format @@ -338,7 +340,7 @@ A module's `RuntimeFunctions` table may be only partially filled. VirtualIP look ### Interpreter changes -The interpreter must implement the tracking and activation flow defined above. +The interpreter must implement the tracking and activation flow defined above. Call sites check the cached state on the interpreter method data, which is shared by every alias of the same bytecode, such as the declaration of a `MethodImpl` that remaps a vtable slot within its own type. The state is not cleared when R2R code is published, so calls through such an alias still reach the prestub and are redirected to the published native code. ## JavaScript Hosting Changes @@ -350,12 +352,31 @@ The JavaScript host must: ## 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. +V1 will focus on single-threaded, composite mode support. The designs shouldn't make multithreaded WASM or non-composite mode incompatible, but it will be disabled for those configurations in the initial implementation. ### 1. Support delayed resolution of R2R code within the same module. Using either call-counting in the interpreter or a timer in the host JavaScript code, force methods to be interpreted (even if R2R code exists for them in the primary module) before installing R2R code. This doesn't require any changes to crossgen2 or the webcil format. +The prototype simulates both phases of the flow above and is compiled with `FEATURE_AUXILIARY_R2R_MODULES`, enabled by default on single-threaded Browser-WASM builds with `FEATURE_INTERPRETER`. It can be excluded with `-cmakeargs "-DFEATURE_AUXILIARY_R2R_MODULES=0"`. This prototype does not split modules, download code, or change image formats. + +**Simulated downloads.** Each simulated download is a countdown that saturates at zero, and zero means activated. The "activation flag" cell therefore counts down, rather than being nonzero once activated as in the real design. A call-boundary check of a method waiting on the countdown consumes one count. Preparing a method, resuming an existing interpreter continuation, and executing an exception clause do not. + +- `DOTNET_R2RDelayedActivationCallCount` simulates the auxiliary module download. Each composite image has its own countdown, shared by all its component assemblies and by every method waiting on that image. It defaults to `0`, marking auxiliary code ready immediately and preserving eager R2R resolution. Setting it to `3e8` (hexadecimal for 1,000) marks the image ready after 1,000 checks. +- `DOTNET_R2RDelayedEntryPointsActivationCallCount` simulates the Auxiliary EntryPoints Module download. A single countdown stands in for every composite image, and counts calls to methods awaiting EntryPoints. It defaults to `DOTNET_R2RDelayedActivationCallCount` when not specified. When it reaches zero, the auxiliary EntryPoints era advances. + +**Eligibility.** Delay applies to IL methods in a composite image whose IL bodies are retained, including when generic code is found outside its defining assembly. Wrapper stubs, `UnmanagedCallersOnly` methods, methods whose defining module has stripped IL, and lookups for direct calls from R2R code (which cannot wait) keep their existing preparation paths. Non-composite images and images with stripped IL are unchanged. This is decided once, before the EntryPoints-activation check in the R2R lookup, so an ineligible method is never kept interpreted merely because its image's EntryPoints are inactive. + +**Phase 1: awaiting EntryPoints.** While an image's EntryPoints module is inactive, the lookup treats its methods as not found. When any lookup misses, the method records the era read before the lookup began and waits for the era to advance. The era is a static field next to the list of unrelated R2R modules. It advances when the EntryPoints simulation completes and when a new image is registered for unrelated generics, since either can make a previous miss succeed. The era is an opaque type in `auxiliaryentrypointsera.h`, shared with the interpreter compiler because `InterpMethod` stores it; only `ReadyToRunInfo` creates or compares eras. Because a miss always waits, interpreted methods with no R2R code do a cheap era check on each call and repeat their lookup once per era advance. + +**Phase 2: pending module.** When the lookup finds the method in an image whose auxiliary module is not yet activated, it records a pointer to that image's activation flag and stops searching other images. Generic code found outside the defining assembly records the code-owning image's flag, not the defining module's. + +**Cached state.** The state is cached in an `AuxiliaryMethodInfo` on `InterpMethod`, a discriminated union of the activation flag pointer and the era. `ShouldTryActivateMethod()` advances the corresponding simulated countdown and reports whether the call should try activation. The method's portable entrypoint also carries `kPendingAuxiliaryModule` or `kAwaitingAuxiliaryEntryPoints`, and the method's runtime code data holds the same pointer or era for the prestub. + +**Activation.** Once ready, activation runs synchronously in the prestub, repeating the lookup with the existing R2R fixup processing before publishing native code through the method's portable entrypoint. Call sites only check readiness, then call the prestub as they would for an unprepared method. Readiness is checked at call boundaries, before the callee's interpreter frame exists. Interpreter-to-interpreter calls (including `calli` and delegate calls) check in `ResolveInterpreterCode`. Calls from R2R code arrive through the R2R-to-interpreter thunk, which checks in `ExecuteInterpretedMethodWithArgs_PortableEntryPoint` and activates under a `PrestubMethodFrame` so the incoming arguments stay reported to the GC. Debugger function evaluation, the remaining caller of `CallDescrWorkerInternal`, checks there and activates the same way. When activation succeeds, the call that observed readiness runs the native code. Resuming an interpreter continuation or executing an exception clause never checks readiness, so a method with a suspended interpreted continuation activates on its next fresh call. A method prepared for the first time after activation uses ordinary eager R2R resolution. Interpreter dispatch caches are bypassed after publication, while active interpreter frames and suspended interpreter continuations retain their bytecode. + +**Failure.** The pending and awaiting flags are cleared before the lookup and fixups to prevent reentrant activation. If the lookup finds no R2R body again, the method records the new era or flag and keeps waiting. If fixups reject the R2R body or throw, the method stays interpreted, its cached `AuxiliaryMethodInfo` is reset so later calls skip the prestub, and activation is not retried. + ### 2. Split InstanceMethodEntryPoints and MethodDefEntryPoints sections out 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. From 75e2047f5ec5be540d9329d964477a2383d13c57 Mon Sep 17 00:00:00 2001 From: Jackson Schuster <36744439+jtschuster@users.noreply.github.com> Date: Mon, 26 Oct 2026 22:41:26 -0700 Subject: [PATCH 09/19] [wasm][R2R] Delay activation of R2R methods in auxiliary modules (simulated) Prototype of the first phase of partitioned R2R modules for browser-wasm: a composite image's methods stay interpreted until their auxiliary module is "activated", then switch to their R2R code. The download is simulated by counting calls, without any format, crossgen2 or host changes. - Add FEATURE_AUXILIARY_R2R_MODULES (single-threaded browser-wasm with the interpreter) and DOTNET_R2RDelayedActivationCallCount. Zero preserves eager R2R resolution. - When lookup finds a method in an image whose auxiliary module is not yet activated, record that image's activation flag, keep the method interpreted, and set kPendingAuxiliaryModule on its portable entry point. Stop searching other images once a dependency is recorded. - Cache the flag on InterpMethod (AuxiliaryMethodInfo). Interpreter calls, the R2R-to-interpreter thunk and CallDescrWorkerInternal check it at call boundaries and activate through the prestub. Activation that is rejected or throws leaves the method interpreted. - Do not delay UnmanagedCallersOnly methods, wrapper stubs, stripped-IL images, or lookups for direct calls from R2R code. - Re-enter ExecuteInterpretedMethodWithArgs_PortableEntryPoint_Complex for methods waiting on activation, keeping its MethodImpl handling. - Add the DelayedR2RActivation tests, including a MethodImpl alias case. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 9444d653-1735-42ee-a4a7-119174358f8d (cherry picked from commit d397db8a9b0ce5b8432842e684bf7f07f166633a) --- src/coreclr/clrdefinitions.cmake | 3 + src/coreclr/clrfeatures.cmake | 13 + src/coreclr/inc/clrconfigvalues.h | 4 + .../interpreter/inc/interpretershared.h | 23 + src/coreclr/vm/interpexec.cpp | 51 ++- src/coreclr/vm/interpexec.h | 13 + src/coreclr/vm/jitinterface.cpp | 7 + src/coreclr/vm/method.hpp | 65 +++ src/coreclr/vm/precode_portable.hpp | 27 ++ src/coreclr/vm/prestub.cpp | 166 ++++++- src/coreclr/vm/readytoruninfo.cpp | 35 ++ src/coreclr/vm/readytoruninfo.h | 31 ++ src/coreclr/vm/wasm/calldescrworkerwasm.cpp | 10 +- .../DelayedR2RActivation.cs | 412 ++++++++++++++++++ .../DelayedR2RActivation.csproj | 18 + .../DelayedR2RActivationAliasHelper.il | 75 ++++ .../DelayedR2RActivationAliasHelper.ilproj | 8 + .../DelayedR2RActivation_Eager.csproj | 18 + .../DelayedR2RActivation_Immediate.csproj | 18 + 19 files changed, 979 insertions(+), 18 deletions(-) create mode 100644 src/tests/readytorun/wasm/DelayedR2RActivation/DelayedR2RActivation.cs create mode 100644 src/tests/readytorun/wasm/DelayedR2RActivation/DelayedR2RActivation.csproj create mode 100644 src/tests/readytorun/wasm/DelayedR2RActivation/DelayedR2RActivationAliasHelper.il create mode 100644 src/tests/readytorun/wasm/DelayedR2RActivation/DelayedR2RActivationAliasHelper.ilproj create mode 100644 src/tests/readytorun/wasm/DelayedR2RActivation/DelayedR2RActivation_Eager.csproj create mode 100644 src/tests/readytorun/wasm/DelayedR2RActivation/DelayedR2RActivation_Immediate.csproj diff --git a/src/coreclr/clrdefinitions.cmake b/src/coreclr/clrdefinitions.cmake index da196ef4cb99a9..79fe50bf8180a9 100644 --- a/src/coreclr/clrdefinitions.cmake +++ b/src/coreclr/clrdefinitions.cmake @@ -129,6 +129,9 @@ if (CLR_CMAKE_TARGET_WIN32) endif (CLR_CMAKE_TARGET_WIN32) add_compile_definitions($<${FEATURE_INTERPRETER}:FEATURE_INTERPRETER>) +if (FEATURE_AUXILIARY_R2R_MODULES) + add_compile_definitions(FEATURE_AUXILIARY_R2R_MODULES) +endif() if (FEATURE_DYNAMIC_CODE_COMPILED) add_compile_definitions(FEATURE_DYNAMIC_CODE_COMPILED) endif() diff --git a/src/coreclr/clrfeatures.cmake b/src/coreclr/clrfeatures.cmake index 040fe3d8516291..5c5a1621a17f14 100644 --- a/src/coreclr/clrfeatures.cmake +++ b/src/coreclr/clrfeatures.cmake @@ -104,6 +104,19 @@ if(NOT DEFINED FEATURE_INTERPRETER) endif() endif() +# Auxiliary R2R modules currently require single-threaded Browser-WASM and interpreter fallback. +if(NOT DEFINED FEATURE_AUXILIARY_R2R_MODULES) + set(FEATURE_AUXILIARY_R2R_MODULES 0) + if(CLR_CMAKE_TARGET_BROWSER AND CLR_CMAKE_TARGET_ARCH_WASM AND FEATURE_INTERPRETER AND NOT FEATURE_MULTITHREADING) + set(FEATURE_AUXILIARY_R2R_MODULES 1) + endif() +endif() + +if(FEATURE_AUXILIARY_R2R_MODULES AND + (NOT CLR_CMAKE_TARGET_BROWSER OR NOT CLR_CMAKE_TARGET_ARCH_WASM OR NOT FEATURE_INTERPRETER OR FEATURE_MULTITHREADING)) + message(FATAL_ERROR "FEATURE_AUXILIARY_R2R_MODULES requires single-threaded Browser-WASM with FEATURE_INTERPRETER.") +endif() + if(NOT DEFINED FEATURE_STANDALONE_GC) set(FEATURE_STANDALONE_GC 1) endif(NOT DEFINED FEATURE_STANDALONE_GC) diff --git a/src/coreclr/inc/clrconfigvalues.h b/src/coreclr/inc/clrconfigvalues.h index 816cf930c64005..4446198ce7669d 100644 --- a/src/coreclr/inc/clrconfigvalues.h +++ b/src/coreclr/inc/clrconfigvalues.h @@ -301,6 +301,10 @@ RETAIL_CONFIG_STRING_INFO(EXTERNAL_Interpreter, W("Interpreter"), "Enables Inter RETAIL_CONFIG_DWORD_INFO(EXTERNAL_InterpMode, W("InterpMode"), 0, "Enables Interpreter for a subset of all methods depending on the specified mode.") #endif // FEATURE_INTERPRETER +#ifdef FEATURE_AUXILIARY_R2R_MODULES +RETAIL_CONFIG_DWORD_INFO(EXTERNAL_R2RDelayedActivationCallCount, W("R2RDelayedActivationCallCount"), 0, "Activate auxiliary R2R code after this many pending interpreter entries across a composite image. Zero preserves eager activation.") +#endif // FEATURE_AUXILIARY_R2R_MODULES + RETAIL_CONFIG_DWORD_INFO(EXTERNAL_JitHostMaxSlabCache, W("JitHostMaxSlabCache"), 0x1000000, "Sets jit host max slab cache size, 16MB default") RETAIL_CONFIG_DWORD_INFO(EXTERNAL_JitOptimizeType, W("JitOptimizeType"), 0 /* OPT_DEFAULT */, "") diff --git a/src/coreclr/interpreter/inc/interpretershared.h b/src/coreclr/interpreter/inc/interpretershared.h index a2676e163c2c07..af6bc638f1d11c 100644 --- a/src/coreclr/interpreter/inc/interpretershared.h +++ b/src/coreclr/interpreter/inc/interpretershared.h @@ -32,6 +32,25 @@ class MethodTable; struct CallStubHeader; +#ifdef FEATURE_AUXILIARY_R2R_MODULES +// Cached on InterpMethod so the interpreter can decide with few memory loads whether a call should try to +// switch the method to R2R code. Set by the VM; the interpreter compiler only default-constructs it. +struct AuxiliaryMethodInfo +{ + // The activation flag of the auxiliary module holding the method's R2R code, or null if there is nothing to wait for. + COMPILER_SHARED_TYPE(uint32_t*, PTR_DWORD, auxiliaryModuleActivationFlag); + + AuxiliaryMethodInfo() : auxiliaryModuleActivationFlag(nullptr) {} + + bool MayActivateMethod() const { return auxiliaryModuleActivationFlag != nullptr; } + +#ifndef INTERPRETER_COMPILER_INTERNAL + // Defined in the VM's interpexec.h, since it needs ReadyToRunInfo. + inline bool ShouldTryActivateMethod() const; +#endif // !INTERPRETER_COMPILER_INTERNAL +}; +#endif // FEATURE_AUXILIARY_R2R_MODULES + struct InterpMethod { #if DEBUG @@ -46,6 +65,10 @@ struct InterpMethod bool initLocals; bool unmanagedCallersOnly; bool publishSecretStubParam; +#ifdef FEATURE_AUXILIARY_R2R_MODULES + // Set by the VM when the method is interpreted only until an auxiliary module is activated + AuxiliaryMethodInfo auxiliaryMethodInfo; +#endif // FEATURE_AUXILIARY_R2R_MODULES int32_t codeSize; // size in int32_t slots // Maps Continuation.State (suspension-point index) to the byte offset of the matching diff --git a/src/coreclr/vm/interpexec.cpp b/src/coreclr/vm/interpexec.cpp index 281eb5176de9a4..fa476d5f234ff7 100644 --- a/src/coreclr/vm/interpexec.cpp +++ b/src/coreclr/vm/interpexec.cpp @@ -1477,6 +1477,45 @@ static InterpByteCodeStart* PrepareInterpreterCode(MethodDesc* targetMethod, Int return targetIp; } +static InterpByteCodeStart* ResolveInterpreterCode(MethodDesc*& targetMethod, InterpMethodContextFrame* pFrame, InterpreterFrame* pInterpreterFrame, const int32_t* ip) +{ + InterpByteCodeStart* targetIp; + if (!targetMethod->IsInterpreterCodeInitialized(targetIp)) + { + targetIp = PrepareInterpreterCode(targetMethod, pFrame, pInterpreterFrame, ip); + } + +#ifdef FEATURE_AUXILIARY_R2R_MODULES + // This info is not cleared when R2R code is published, so calls through a stale override alias still + // reach the prestub and are redirected to the published native code. + if (targetIp != nullptr + && targetIp->Method->auxiliaryMethodInfo.ShouldTryActivateMethod()) + { + pFrame->ip = ip; + pInterpreterFrame->SetTopInterpMethodContextFrame(pFrame); + MethodDesc* pActualMethod = targetIp->Method->methodHnd; + // A method that already switched to R2R code has no interpreter code left, so a call through an alias + // only needs the redirect below. + if (pActualMethod->GetInterpreterCode() != nullptr) + { + GCX_PREEMP_REGION_BEGIN(); + CallWithSEHWrapper( + [pActualMethod]() { + return pActualMethod->DoPrestub(nullptr, CallerGCMode::Coop); + }); + GCX_PREEMP_REGION_END(); + } + if (pActualMethod->GetInterpreterCode() == nullptr) + { + targetMethod = pActualMethod; + return nullptr; + } + } +#endif // FEATURE_AUXILIARY_R2R_MODULES + + return targetIp; +} + void InterpExecMethod(InterpreterFrame *pInterpreterFrame, InterpMethodContextFrame *pFrame, InterpThreadContext *pThreadContext, ExceptionClauseArgs *pExceptionClauseArgs) { CONTRACTL @@ -3582,11 +3621,7 @@ void InterpExecMethod(InterpreterFrame *pInterpreterFrame, InterpMethodContextFr targetMethod = NonVirtualEntry2MethodDesc(actualTarget); } - PTR_InterpByteCodeStart targetIp; - if (!targetMethod->IsInterpreterCodeInitialized(targetIp)) - { - targetIp = PrepareInterpreterCode(targetMethod, pFrame, pInterpreterFrame, ip); - } + PTR_InterpByteCodeStart targetIp = ResolveInterpreterCode(targetMethod, pFrame, pInterpreterFrame, ip); if (targetIp != NULL) { pFrame->ip = ip; @@ -3680,11 +3715,7 @@ void InterpExecMethod(InterpreterFrame *pInterpreterFrame, InterpMethodContextFr // Save current execution state for when we return from called method pFrame->ip = ip; - InterpByteCodeStart* targetIp; - if (!targetMethod->IsInterpreterCodeInitialized(targetIp)) - { - targetIp = PrepareInterpreterCode(targetMethod, pFrame, pInterpreterFrame, ip); - } + InterpByteCodeStart* targetIp = ResolveInterpreterCode(targetMethod, pFrame, pInterpreterFrame, ip); if (targetIp == NULL) { // If we didn't get the interpreter code pointer setup, then this is a method we need to invoke as a compiled method. diff --git a/src/coreclr/vm/interpexec.h b/src/coreclr/vm/interpexec.h index 7300cda7a14b6d..24d6103b042c3f 100644 --- a/src/coreclr/vm/interpexec.h +++ b/src/coreclr/vm/interpexec.h @@ -123,4 +123,17 @@ void InvokeManagedMethod(MethodDesc *pMD, int8_t *pArgs, int8_t *pRet, PCODE tar void InterpDispatchCache_ReclaimAll(); void InterpDispatchCache_ClearForLoaderAllocator(LoaderAllocator* pLoaderAllocator); +#ifdef FEATURE_AUXILIARY_R2R_MODULES +inline bool AuxiliaryMethodInfo::ShouldTryActivateMethod() const +{ + LIMITED_METHOD_CONTRACT; + if (auxiliaryModuleActivationFlag == nullptr) + return false; + + // TODO: remove the simulated download once auxiliary modules are separate + ReadyToRunInfo::AdvanceSimulatedAuxiliaryModuleDownload(auxiliaryModuleActivationFlag); + return ReadyToRunInfo::IsAuxiliaryModuleActivated(auxiliaryModuleActivationFlag); +} +#endif // FEATURE_AUXILIARY_R2R_MODULES + #endif // _INTERPEXEC_H_ diff --git a/src/coreclr/vm/jitinterface.cpp b/src/coreclr/vm/jitinterface.cpp index 5c3388339b4d50..a5f30bb0c2f109 100644 --- a/src/coreclr/vm/jitinterface.cpp +++ b/src/coreclr/vm/jitinterface.cpp @@ -14109,6 +14109,13 @@ BOOL LoadDynamicInfoEntry(Module *currentModule, } PrepareCodeConfig config(NativeCodeVersion(pMD), FALSE, TRUE); +#ifdef FEATURE_AUXILIARY_R2R_MODULES + // R2R callers call this method directly, so it must have its R2R entry point now. + // Do not simulate a delayed auxiliary module activation for this. + // Once we no longer need simulations, we can remove this call. + // The compile should ensure we never have a direct call across different auxiliary modules. + config.DisableAuxiliaryModuleDelay(); +#endif // FEATURE_AUXILIARY_R2R_MODULES PCODE pEntryPoint = pMD->GetModule()->GetReadyToRunInfo()->GetEntryPoint(pMD, &config, TRUE /* fFixups */); if (pEntryPoint == (PCODE)NULL) { diff --git a/src/coreclr/vm/method.hpp b/src/coreclr/vm/method.hpp index 1f860e4c8cd3c3..73e3aed344ac39 100644 --- a/src/coreclr/vm/method.hpp +++ b/src/coreclr/vm/method.hpp @@ -281,6 +281,11 @@ struct MethodDescCodeData final #ifdef FEATURE_INTERPRETER InterpreterCalliCookie CalliCookie; #endif // FEATURE_INTERPRETER +#ifdef FEATURE_AUXILIARY_R2R_MODULES + // Activation status of the auxiliary module holding this method's R2R code. Zero means activated. + // TODO: This points at a shared countdown standing in for the auxiliary module's activation flag. + PTR_DWORD AuxiliaryModuleActivationFlag; +#endif // FEATURE_AUXILIARY_R2R_MODULES #if defined(_DEBUG) && defined(ALLOW_SXS_JIT) PatchpointInfo *AltJitPatchpointInfo; #endif // _DEBUG && ALLOW_SXS_JIT @@ -2350,6 +2355,11 @@ class MethodDesc bool TryPublishR2RCodeForUnmanagedCallersOnly(); #endif // FEATURE_PORTABLE_ENTRYPOINTS +#ifdef FEATURE_AUXILIARY_R2R_MODULES + void TryActivateDelayedR2RCode(CallerGCMode callerGCMode); + void RecordDelayedR2RCode(PrepareCodeConfig* pConfig, PCODE pCode); +#endif // FEATURE_AUXILIARY_R2R_MODULES + private: PCODE GetPrecompiledCode(PrepareCodeConfig* pConfig, bool shouldTier); PCODE GetPrecompiledR2RCode(PrepareCodeConfig* pConfig); @@ -2434,6 +2444,56 @@ class PrepareCodeConfig CallerGCMode GetCallerGCMode(); void SetCallerGCMode(CallerGCMode mode); +#ifdef FEATURE_AUXILIARY_R2R_MODULES + // Activation status of the auxiliary module holding the method's R2R code, set when lookup delays it. + // TODO: This points at a shared countdown standing in for the auxiliary module's activation flag. + PTR_DWORD GetAuxiliaryModuleActivationFlag() const + { + LIMITED_METHOD_CONTRACT; + return m_pAuxiliaryModuleActivationFlag; + } + + bool HasKnownAuxiliaryModuleDependency() const + { + LIMITED_METHOD_CONTRACT; + return m_pAuxiliaryModuleActivationFlag != nullptr; + } + + bool IsAuxiliaryModuleActivated() const + { + CONTRACTL + { + NOTHROW; + GC_NOTRIGGER; + MODE_ANY; + PRECONDITION(m_pAuxiliaryModuleActivationFlag != nullptr); + } + CONTRACTL_END; + + return ReadyToRunInfo::IsAuxiliaryModuleActivated(m_pAuxiliaryModuleActivationFlag); + } + + void SetAuxiliaryModuleActivationFlag(PTR_DWORD pAuxiliaryModuleActivationFlag) + { + LIMITED_METHOD_CONTRACT; + _ASSERTE(pAuxiliaryModuleActivationFlag != nullptr && m_pAuxiliaryModuleActivationFlag == nullptr); + m_pAuxiliaryModuleActivationFlag = pAuxiliaryModuleActivationFlag; + } + + // Lookups that commit a method to R2R code for direct calls from R2R callers cannot wait for activation. + bool MayDelayAuxiliaryModule() const + { + LIMITED_METHOD_CONTRACT; + return m_mayDelayAuxiliaryModule; + } + + void DisableAuxiliaryModuleDelay() + { + LIMITED_METHOD_CONTRACT; + m_mayDelayAuxiliaryModule = false; + } +#endif // FEATURE_AUXILIARY_R2R_MODULES + public: bool IsForMulticoreJit() const { @@ -2566,6 +2626,11 @@ class PrepareCodeConfig BOOL m_ReadyToRunRejectedPrecompiledCode; CallerGCMode m_callerGCMode; +#ifdef FEATURE_AUXILIARY_R2R_MODULES + PTR_DWORD m_pAuxiliaryModuleActivationFlag = nullptr; + bool m_mayDelayAuxiliaryModule = true; +#endif // FEATURE_AUXILIARY_R2R_MODULES + #ifdef FEATURE_MULTICOREJIT private: bool m_isForMulticoreJit; diff --git a/src/coreclr/vm/precode_portable.hpp b/src/coreclr/vm/precode_portable.hpp index 7853b46844fb79..bcbcd22fe387ef 100644 --- a/src/coreclr/vm/precode_portable.hpp +++ b/src/coreclr/vm/precode_portable.hpp @@ -41,6 +41,9 @@ class PortableEntryPoint final // closed-static-retbuf resolution list, so a redundant registration attempt // can be rejected without a linear scan of that list. kPendingClosedStaticRetBufResolution = 0x8, +#ifdef FEATURE_AUXILIARY_R2R_MODULES + kPendingAuxiliaryModule = 0x10, +#endif // FEATURE_AUXILIARY_R2R_MODULES }; Volatile _flags; @@ -143,6 +146,30 @@ class PortableEntryPoint final ClearFlagsInterlocked(kPendingClosedStaticRetBufResolution); } +#ifdef FEATURE_AUXILIARY_R2R_MODULES + bool IsPendingAuxiliaryModule() const + { + LIMITED_METHOD_CONTRACT; + _ASSERTE(IsValid()); + return (_flags & kPendingAuxiliaryModule) != 0; + } + + void SetPendingAuxiliaryModule() + { + LIMITED_METHOD_CONTRACT; + _ASSERTE(IsValid()); + _ASSERTE(PrefersInterpreterEntryPoint()); + SetFlagsInterlocked(kPendingAuxiliaryModule); + } + + void ClearPendingAuxiliaryModule() + { + LIMITED_METHOD_CONTRACT; + _ASSERTE(IsValid()); + ClearFlagsInterlocked(kPendingAuxiliaryModule); + } +#endif // FEATURE_AUXILIARY_R2R_MODULES + // Atomically install an interpreter thunk if _pActualCode is still NULL. // Returns true if the thunk was installed, false if _pActualCode was already set. bool TrySetInterpreterThunk(void* thunk) diff --git a/src/coreclr/vm/prestub.cpp b/src/coreclr/vm/prestub.cpp index d58887b8be3c73..812280ed9c676c 100644 --- a/src/coreclr/vm/prestub.cpp +++ b/src/coreclr/vm/prestub.cpp @@ -312,6 +312,10 @@ PCODE MethodDesc::PrepareInitialCode(CallerGCMode callerGCMode) STANDARD_VM_CONTRACT; PrepareCodeConfig config(NativeCodeVersion(this), TRUE, TRUE); config.SetCallerGCMode(callerGCMode); +#ifdef FEATURE_AUXILIARY_R2R_MODULES + if (m_codeData->AuxiliaryModuleActivationFlag != nullptr) + config.SetAuxiliaryModuleActivationFlag(m_codeData->AuxiliaryModuleActivationFlag); +#endif return PrepareCode(&config); } @@ -396,6 +400,9 @@ PCODE MethodDesc::PrepareCode(PrepareCodeConfig* pConfig) LOG((LF_CLASSLOADER, LL_INFO1000000, " In PrepareILBasedCode, calling JitCompileCode\n")); pCode = JitCompileCode(pConfig); +#ifdef FEATURE_AUXILIARY_R2R_MODULES + RecordDelayedR2RCode(pConfig, pCode); +#endif // FEATURE_AUXILIARY_R2R_MODULES } else { @@ -498,6 +505,12 @@ PCODE MethodDesc::GetPrecompiledR2RCode(PrepareCodeConfig* pConfig) pCode = pAlreadyExaminedInfos[0]->GetEntryPoint(this, pConfig, TRUE /* fFixups */); } +#ifdef FEATURE_AUXILIARY_R2R_MODULES + // The method's code was found in an image whose auxiliary module is not yet activated, so the search is over. + if (pConfig->HasKnownAuxiliaryModuleDependency()) + return (PCODE)NULL; +#endif // FEATURE_AUXILIARY_R2R_MODULES + // Generics may be located in several places if (pCode == (PCODE)NULL && HasClassOrMethodInstantiation()) { @@ -510,6 +523,11 @@ PCODE MethodDesc::GetPrecompiledR2RCode(PrepareCodeConfig* pConfig) pCode = pAlreadyExaminedInfos[1]->GetEntryPoint(this, pConfig, TRUE /* fFixups */); } +#ifdef FEATURE_AUXILIARY_R2R_MODULES + if (pConfig->HasKnownAuxiliaryModuleDependency()) + return (PCODE)NULL; +#endif // FEATURE_AUXILIARY_R2R_MODULES + if (pCode == (PCODE)NULL) { // R2R also supports a concept of R2R code that has code for "Unrelated" generics embedded within it @@ -522,10 +540,14 @@ PCODE MethodDesc::GetPrecompiledR2RCode(PrepareCodeConfig* pConfig) if (pUnrelatedInfo == pAlreadyExaminedInfos[1]) continue; pCode = pUnrelatedInfo->GetEntryPoint(this, pConfig, TRUE /* fFixups */); +#ifdef FEATURE_AUXILIARY_R2R_MODULES + if (pConfig->HasKnownAuxiliaryModuleDependency()) + return (PCODE)NULL; +#endif // FEATURE_AUXILIARY_R2R_MODULES } } } -#endif +#endif // FEATURE_READYTORUN return pCode; } @@ -552,6 +574,105 @@ bool MethodDesc::TryPublishR2RCodeForUnmanagedCallersOnly() } #endif // FEATURE_PORTABLE_ENTRYPOINTS +#ifdef FEATURE_AUXILIARY_R2R_MODULES +// Records why an interpreted method may get R2R code later, so callers check before interpreting it. +// A method found in a pending auxiliary module waits for that module. +void MethodDesc::RecordDelayedR2RCode(PrepareCodeConfig* pConfig, PCODE pCode) +{ + STANDARD_VM_CONTRACT; + + if (!PortableEntryPoint::PrefersInterpreterEntryPoint(pCode)) + return; + + _ASSERTE(pCode == GetPortableEntryPoint()); + _ASSERTE(m_codeData != nullptr); + PortableEntryPoint* entryPoint = PortableEntryPoint::ToPortableEntryPoint(pCode); + InterpMethod* pInterpMethod = PortableEntryPoint::HasInterpreterData(pCode) + ? static_cast(PortableEntryPoint::GetInterpreterData(pCode))->Method + : nullptr; + + // R2R code that was rejected is never retried, so the method stays interpreted. + if (pConfig->ReadyToRunRejectedPrecompiledCode()) + { + if (pInterpMethod != nullptr) + pInterpMethod->auxiliaryMethodInfo = AuxiliaryMethodInfo(); + return; + } + + // The InterpMethod info is not cleared when R2R code is published, so calls through a stale override alias + // still reach the prestub and are redirected to the published native code. + if (pConfig->HasKnownAuxiliaryModuleDependency()) + { + m_codeData->AuxiliaryModuleActivationFlag = pConfig->GetAuxiliaryModuleActivationFlag(); + entryPoint->SetPendingAuxiliaryModule(); + if (pInterpMethod != nullptr) + { + pInterpMethod->auxiliaryMethodInfo.auxiliaryModuleActivationFlag = pConfig->GetAuxiliaryModuleActivationFlag(); + } + } + else if (pInterpMethod != nullptr) + { + // Nothing to wait for, so the method stays interpreted. + pInterpMethod->auxiliaryMethodInfo = AuxiliaryMethodInfo(); + } +} + +// Called by the prestub. A method waiting on its auxiliary module already has interpreter code, so callers +// only send it back to the prestub once the module is activated. Switch it to its R2R code if it has any. +void MethodDesc::TryActivateDelayedR2RCode(CallerGCMode callerGCMode) +{ + CONTRACTL + { + THROWS; + GC_TRIGGERS; + MODE_PREEMPTIVE; + } + CONTRACTL_END; + + PCODE temporaryEntryPoint = GetPortableEntryPointIfExists(); + if (temporaryEntryPoint == (PCODE)NULL) + return; + + PortableEntryPoint* entryPoint = PortableEntryPoint::ToPortableEntryPoint(temporaryEntryPoint); + if (!entryPoint->PrefersInterpreterEntryPoint()) + return; + + if (!entryPoint->IsPendingAuxiliaryModule() + || !ReadyToRunInfo::IsAuxiliaryModuleActivated(m_codeData->AuxiliaryModuleActivationFlag)) + return; + + entryPoint->ClearPendingAuxiliaryModule(); + EX_TRY + { + PrepareCodeConfig config(NativeCodeVersion(this), FALSE, TRUE); + config.SetCallerGCMode(callerGCMode); + PCODE code = GetPrecompiledCode(&config, false); + if (code != (PCODE)NULL) + { + PoisonInterpreterCode(); + PerfMap::LogPreCompiledMethod(this, code); + DACNotifyCompilationFinished(this, code); + } + else + { + RecordDelayedR2RCode(&config, temporaryEntryPoint); + } + } + EX_CATCH + { + // The method already runs in the interpreter, so failing to load its R2R code (for example, + // a fixup that throws) must not fail the call. It stays interpreted and is not retried. + RethrowTerminalExceptions(); + if (PortableEntryPoint::HasInterpreterData(temporaryEntryPoint)) + { + static_cast(PortableEntryPoint::GetInterpreterData(temporaryEntryPoint))->Method->auxiliaryMethodInfo = + AuxiliaryMethodInfo(); + } + } + EX_END_CATCH +} +#endif // FEATURE_AUXILIARY_R2R_MODULES + PCODE MethodDesc::GetMulticoreJitCode(PrepareCodeConfig* pConfig, bool* pWasTier0) { STANDARD_VM_CONTRACT; @@ -2217,12 +2338,25 @@ void ExecuteInterpretedMethodWithArgs_PortableEntryPoint_Complex(PCODE portableE // The method to run; differs from pMethod when a MethodImpl in the same type redirects pMethod's slot. MethodDesc* pTargetMethod = pMethod; InterpByteCodeStart* targetIp = pMethod->GetInterpreterCode(); + const bool firstCall = (targetIp == NULL); + bool enterPrestubPortion = firstCall; + bool runPrestub = firstCall; if (targetIp != NULL) { // PrepareInterpreterCode may have stored the MethodImpl target's code on the declaration; run as that method. pTargetMethod = targetIp->Method->methodHnd; +#ifdef FEATURE_AUXILIARY_R2R_MODULES + // This is not only reached on the first call: a method waiting for its R2R code comes back here once the wait + // is over. A call through a stale alias of a method that already switched to R2R code only needs the dispatch. + if (targetIp->Method->auxiliaryMethodInfo.ShouldTryActivateMethod()) + { + enterPrestubPortion = true; + runPrestub = pTargetMethod->GetInterpreterCode() != NULL; + } +#endif // FEATURE_AUXILIARY_R2R_MODULES } - else + + if (enterPrestubPortion) { MAKE_CURRENT_THREAD_AVAILABLE_EX(GetThread()); @@ -2241,11 +2375,14 @@ void ExecuteInterpretedMethodWithArgs_PortableEntryPoint_Complex(PCODE portableE { GCX_PREEMP_REGION_BEGIN(); // Match the interpreter's PrepareInterpreterCode: run the method that pMethod's slot resolves to. - if (pMethod->IsVtableSlot()) + if (firstCall && pMethod->IsVtableSlot()) { pTargetMethod = MethodTable::MapMethodDeclToMethodImpl(pMethod); } - (void)pTargetMethod->DoPrestub(NULL /* MethodTable */, CallerGCMode::Coop); + if (runPrestub) + { + (void)pTargetMethod->DoPrestub(NULL /* MethodTable */, CallerGCMode::Coop); + } targetIp = pTargetMethod->GetInterpreterCode(); GCX_PREEMP_REGION_END(); } @@ -2317,16 +2454,26 @@ void ExecuteInterpretedMethodWithArgs_PortableEntryPoint_Complex(PCODE portableE void ExecuteInterpretedMethodWithArgs_PortableEntryPoint(PCODE portableEntrypoint, TransitionBlock* block, size_t argsSize, int8_t* retBuff) { - PCODE targetIp; + InterpByteCodeStart* targetIp = PortableEntryPoint::HasInterpreterData(portableEntrypoint) + ? static_cast(PortableEntryPoint::GetInterpreterData(portableEntrypoint)) + : nullptr; + +#ifdef FEATURE_AUXILIARY_R2R_MODULES + if (targetIp != nullptr + && targetIp->Method->auxiliaryMethodInfo.MayActivateMethod()) + { + ExecuteInterpretedMethodWithArgs_PortableEntryPoint_Complex(portableEntrypoint, block, argsSize, retBuff); + return; + } +#endif // FEATURE_AUXILIARY_R2R_MODULES - if (!PortableEntryPoint::HasInterpreterData(portableEntrypoint)) + if (targetIp == nullptr) { // In this case, we're using this entrypoint like the prestub. ExecuteInterpretedMethodWithArgs_PortableEntryPoint_Complex(portableEntrypoint, block, argsSize, retBuff); } else { - targetIp = (PCODE)PortableEntryPoint::GetInterpreterData(portableEntrypoint); int8_t* args = (int8_t*)(block + 1); // Copy arguments to the stack @@ -2535,6 +2682,11 @@ PCODE MethodDesc::DoPrestub(MethodTable *pDispatchingMT, CallerGCMode callerGCMo } #endif // FEATURE_CODE_VERSIONING +#ifdef FEATURE_AUXILIARY_R2R_MODULES + // A method waiting on its auxiliary module has interpreter code, so ShouldCallPrestub() is false either way. + TryActivateDelayedR2RCode(callerGCMode); +#endif // FEATURE_AUXILIARY_R2R_MODULES + if (!ShouldCallPrestub()) { LOG((LF_CLASSLOADER, LL_INFO10000, diff --git a/src/coreclr/vm/readytoruninfo.cpp b/src/coreclr/vm/readytoruninfo.cpp index b0d0d865506f7a..98669e6b9906b8 100644 --- a/src/coreclr/vm/readytoruninfo.cpp +++ b/src/coreclr/vm/readytoruninfo.cpp @@ -720,6 +720,7 @@ bool ReadyToRunInfo::IsNativeImageSharedBy(PTR_Module pModule1, PTR_Module pModu // tightly associated with the module itself. ReadyToRunInfo* s_pGlobalR2RModules = NULL; PTR_ReadyToRunInfo ReadyToRunInfo::GetUnrelatedR2RModules() { return s_pGlobalR2RModules; } + void ReadyToRunInfo::RegisterUnrelatedR2RModule() { LIMITED_METHOD_CONTRACT; // This function takes no locks, and can never fail @@ -853,6 +854,14 @@ ReadyToRunInfo::ReadyToRunInfo(Module * pModule, LoaderAllocator* pLoaderAllocat // In this case, we'll pull the ready to run image layout from pLayout. _ASSERT(pLayout != NULL); m_pCompositeInfo = this; +#ifdef FEATURE_AUXILIARY_R2R_MODULES + // TODO: These should point to the location of the flag in the R2R image + m_pAuxiliaryModuleActivationFlag = (PTR_DWORD)pamTracker->Track(pLoaderAllocator->GetLowFrequencyHeap()->AllocMem(S_SIZE_T(sizeof(DWORD)))); + // Only composite images have auxiliary modules. + *m_pAuxiliaryModuleActivationFlag = pNativeImage != nullptr + ? CLRConfig::GetConfigValue(CLRConfig::EXTERNAL_R2RDelayedActivationCallCount) + : 0; +#endif // FEATURE_AUXILIARY_R2R_MODULES m_component = ReadyToRunCoreInfo(pLayout, &pHeader->CoreHeader); m_pComposite = &m_component; m_isComponentAssembly = false; @@ -1317,6 +1326,9 @@ PCODE ReadyToRunInfo::GetEntryPoint(MethodDesc * pMD, PrepareCodeConfig* pConfig #ifdef PROFILING_SUPPORTED BOOL fShouldSearchCache = TRUE; #endif // PROFILING_SUPPORTED +#ifdef FEATURE_AUXILIARY_R2R_MODULES + bool mayDelayR2RCode = false; +#endif // FEATURE_AUXILIARY_R2R_MODULES mdToken token = pMD->GetMemberDef(); int rid = RidFromToken(token); if (rid == 0) @@ -1335,6 +1347,18 @@ PCODE ReadyToRunInfo::GetEntryPoint(MethodDesc * pMD, PrepareCodeConfig* pConfig ETW::MethodLog::GetR2RGetEntryPointStart(pMD); +#ifdef FEATURE_AUXILIARY_R2R_MODULES + // There are conditions where we cannot delay the use of R2R code. In the future, the compiler + // should ensure these conditions for us. + mayDelayR2RCode = pConfig != NULL + && pConfig->MayDelayAuxiliaryModule() + && !HasStrippedILBodies() + && pMD->IsIL() + && !pMD->IsWrapperStub() + && (!pMD->GetModule()->IsReadyToRun() || !pMD->GetModule()->GetReadyToRunInfo()->HasStrippedILBodies()) + && !pMD->HasUnmanagedCallersOnlyAttribute(); +#endif // FEATURE_AUXILIARY_R2R_MODULES + uint offset; // Async variants and unboxing stubs are stored in the instance methods table. if (pMD->HasClassOrMethodInstantiation() @@ -1369,6 +1393,17 @@ PCODE ReadyToRunInfo::GetEntryPoint(MethodDesc * pMD, PrepareCodeConfig* pConfig goto done; } +#ifdef FEATURE_AUXILIARY_R2R_MODULES + if (mayDelayR2RCode + && !IsAuxiliaryModuleActivated(m_pCompositeInfo->GetAuxiliaryModuleActivationFlag(pMD))) + { + // The method exists in this logical R2R image, but is considered an aux method and its aux module is not yet activated. + // Mark it as such, telling callers to return when the flag is set. + pConfig->SetAuxiliaryModuleActivationFlag(m_pCompositeInfo->GetAuxiliaryModuleActivationFlag(pMD)); + goto done; + } +#endif // FEATURE_AUXILIARY_R2R_MODULES + #ifdef PROFILING_SUPPORTED { BEGIN_PROFILER_CALLBACK(CORProfilerTrackCacheSearches()); diff --git a/src/coreclr/vm/readytoruninfo.h b/src/coreclr/vm/readytoruninfo.h index 469ca418252297..c569c394886d14 100644 --- a/src/coreclr/vm/readytoruninfo.h +++ b/src/coreclr/vm/readytoruninfo.h @@ -201,6 +201,10 @@ class ReadyToRunInfo PTR_ReadyToRunInfo m_pNextR2RForUnrelatedCode; TADDR m_pLoadedImageBase; +#ifdef FEATURE_AUXILIARY_R2R_MODULES + PTR_DWORD m_pAuxiliaryModuleActivationFlag = 0; +#endif // FEATURE_AUXILIARY_R2R_MODULES + public: ReadyToRunInfo(Module * pModule, LoaderAllocator* pLoaderAllocator, READYTORUN_HEADER * pHeader, NativeImage * pNativeImage, ReadyToRunLoadedImage * pLayout, AllocMemTracker *pamTracker); @@ -249,6 +253,33 @@ class ReadyToRunInfo void RegisterVirtualIPRange(Module* pModule); #endif // TARGET_WASM +#ifdef FEATURE_AUXILIARY_R2R_MODULES + PTR_DWORD GetAuxiliaryModuleActivationFlag(MethodDesc* pMD) const + { + LIMITED_METHOD_CONTRACT; + // If/When we use multiple auxiliary modules, this will need to search the AuxiliaryEntryPointsModule to find which flag to use + return m_pAuxiliaryModuleActivationFlag; + } + + // The encoding of the activation flag is owned by the R2R image; every reader goes through this. + static bool IsAuxiliaryModuleActivated(PTR_DWORD pActivationFlag) + { + LIMITED_METHOD_CONTRACT; + _ASSERTE(pActivationFlag != nullptr); + return VolatileLoad(pActivationFlag) == 0; + } + + // TODO: Remove once auxiliary modules are downloaded. Stands in for the download by counting down the + // flag once per call, and returns whether the module is now activated. + static void AdvanceSimulatedAuxiliaryModuleDownload(PTR_DWORD pActivationFlag) + { + LIMITED_METHOD_CONTRACT; + _ASSERTE(pActivationFlag != nullptr); + if (!IsAuxiliaryModuleActivated(pActivationFlag)) + VolatileStore(pActivationFlag, VolatileLoad(pActivationFlag) - 1); + } +#endif // FEATURE_AUXILIARY_R2R_MODULES + void RegisterResumptionStub(PCODE stubEntryPoint); PCODE GetEntryPoint(MethodDesc * pMD, PrepareCodeConfig* pConfig, BOOL fFixups); diff --git a/src/coreclr/vm/wasm/calldescrworkerwasm.cpp b/src/coreclr/vm/wasm/calldescrworkerwasm.cpp index ce68dee60941ac..84fc52198dc35f 100644 --- a/src/coreclr/vm/wasm/calldescrworkerwasm.cpp +++ b/src/coreclr/vm/wasm/calldescrworkerwasm.cpp @@ -43,7 +43,15 @@ extern "C" void STDCALL CallDescrWorkerInternal(CallDescrData* pCallDescrData) MethodDesc* pMethod = PortableEntryPoint::GetMethodDesc(pCallDescrData->pTarget); InterpByteCodeStart* targetIp = pMethod->GetInterpreterCode(); - if (targetIp == NULL && pMethod->ShouldCallPrestub()) + bool activateDelayedR2RCode = false; +#ifdef FEATURE_AUXILIARY_R2R_MODULES + if (targetIp != NULL) + { + activateDelayedR2RCode = targetIp->Method->auxiliaryMethodInfo.ShouldTryActivateMethod(); + } +#endif // FEATURE_AUXILIARY_R2R_MODULES + + if ((targetIp == NULL && pMethod->ShouldCallPrestub()) || activateDelayedR2RCode) { // DoPrestub may trigger a GC. Report the arguments through a PrestubMethodFrame, as the // prestub does for calls from managed code. DispatchCallSimple provides no TransitionBlock, diff --git a/src/tests/readytorun/wasm/DelayedR2RActivation/DelayedR2RActivation.cs b/src/tests/readytorun/wasm/DelayedR2RActivation/DelayedR2RActivation.cs new file mode 100644 index 00000000000000..eb83909fe6fc12 --- /dev/null +++ b/src/tests/readytorun/wasm/DelayedR2RActivation/DelayedR2RActivation.cs @@ -0,0 +1,412 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Reflection; +using System.Runtime; +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; +using System.Threading.Tasks; +using DelayedR2RActivationHelpers; +using Xunit; + +namespace System.Runtime +{ + [AttributeUsage(AttributeTargets.Method)] + internal sealed class BypassReadyToRunAttribute : Attribute + { + } +} + +public class DelayedR2RActivation +{ + // Mirrors the private layout of PortableEntryPoint in src/coreclr/vm/precode_portable.hpp: _flags follows + // the _pActualCode, _pMD, and _pInterpreterData pointers. Keep these in sync with that file. + private static readonly int PortableEntryPointFlagsOffset = 3 * IntPtr.Size; + private const int PrefersInterpreterEntryPoint = 0x4; + private const int PendingAuxiliaryModule = 0x10; + + // A delayed method waits for the auxiliary module that holds its code (pending auxiliary module). The wait is + // simulated by counting calls to the waiting methods. + private static readonly int s_activationCallCount = ReadCallCount("DOTNET_R2RDelayedActivationCallCount", 0); + private static readonly bool s_moduleDelayed = s_activationCallCount >= 1000; + // Counts between 1 and 999 can run out while the test sets up, so only check states for 0 or at least 1000. + private static readonly bool s_checkStates = s_activationCallCount == 0 || s_moduleDelayed; + private static Action s_atRecursiveBottom; + + [Fact] + [BypassReadyToRun] + [RuntimeAsyncMethodGeneration(false)] + public static async Task ActivatesCompositeImageAsync() + { + IntPtr suspendedEntryPoint = PrepareMethod(nameof(SuspendedTargetAsync)); + IntPtr repeatedlySuspendedEntryPoint = PrepareMethod(nameof(RepeatedlySuspendedTargetAsync)); + IntPtr[] entryPoints = + [ + PrepareMethod(nameof(DirectTarget)), + PrepareMethod(nameof(DelegateTarget)), + PrepareMethod(nameof(ReflectionTarget)), + PrepareMethod(typeof(DelayedR2RActivation).GetMethod(nameof(GenericTarget)).MakeGenericMethod(typeof(int))), + PrepareMethod(nameof(GenericCaller)), + PrepareMethod(nameof(RecursiveTarget)), + PrepareMethod(nameof(ExceptionTarget)), + PrepareMethod(nameof(CompiledCaller)), + PrepareMethod(nameof(NativeToInterpreterTarget)), + PrepareMethod(typeof(VirtualTarget).GetMethod(nameof(VirtualTarget.Invoke))), + PrepareMethod(typeof(CovariantTarget).GetMethod(nameof(CovariantTarget.Invoke))), + suspendedEntryPoint, + repeatedlySuspendedEntryPoint, + PrepareMethod(nameof(CrossModuleGenericCaller)), + PrepareMethod(nameof(CalliTarget)), + PrepareMethod(typeof(List).GetMethod("AddWithResize", BindingFlags.Instance | BindingFlags.NonPublic)), + PrepareMethod(typeof(AliasTarget).GetMethod(nameof(AliasTarget.Implementation))) + ]; + IntPtr untouchedEntryPoint = PrepareMethod(nameof(UntouchedTarget)); + IntPtr cctorEntryPoint = PrepareMethod(typeof(CctorTarget).TypeInitializer); + IntPtr interpreterOnlyEntryPoint = PrepareMethod(nameof(InterpreterOnlyTarget)); + bool untouchedWasDelayed = IsDelayed(untouchedEntryPoint); + + if (s_checkStates) + { + bool expectPending = s_moduleDelayed; + for (int index = 0; index < entryPoints.Length; index++) + { + Assert.True(expectPending == PrefersInterpreter(entryPoints[index]), $"PrefersInterpreter, entryPoints[{index}]"); + Assert.True(expectPending == IsPendingAuxiliaryModule(entryPoints[index]), $"IsPendingAuxiliaryModule, entryPoints[{index}]"); + } + Assert.Equal(expectPending, untouchedWasDelayed); + Assert.True(expectPending == IsPendingAuxiliaryModule(cctorEntryPoint), "IsPendingAuxiliaryModule, class constructor"); + } + + VerifyCalls(1, callCompiledTarget: false); + + object value = new(); + TaskCompletionSource originalSource = new(); + Task original = SuspendedTargetAsync(originalSource.Task, value); + Assert.False(original.IsCompleted); + + int suspensionCount = 3; + TaskCompletionSource[] sources = new TaskCompletionSource[suspensionCount]; + Task[] pending = new Task[suspensionCount]; + for (int index = 0; index < suspensionCount; index++) + { + sources[index] = new(); + pending[index] = sources[index].Task; + } + + long[] counts = new long[3]; + Task suspended = RepeatedlySuspendedTargetAsync(pending, value, counts); + if (s_checkStates && s_moduleDelayed) + { + // Both continuations must still be interpreted so they resume after their methods are published. + Assert.True(IsDelayed(suspendedEntryPoint)); + Assert.True(IsDelayed(repeatedlySuspendedEntryPoint)); + } + + s_atRecursiveBottom = () => + { + s_atRecursiveBottom = null; + int invocation = 0; + for (int moduleInvocation = 0; + moduleInvocation < s_activationCallCount && IsPendingAuxiliaryModule(entryPoints[0]); + moduleInvocation++, invocation++) + { + Assert.Equal(invocation * 7 + 13, DirectTarget(invocation)); + } + + if (s_checkStates && s_moduleDelayed) + { + Assert.False(IsDelayed(entryPoints[0]), "IsDelayed, DirectTarget after activation"); + Assert.False(PrefersInterpreter(entryPoints[0]), "PrefersInterpreter, DirectTarget after activation"); + Assert.True(IsDelayed(suspendedEntryPoint)); + Assert.True(IsDelayed(repeatedlySuspendedEntryPoint)); + } + + for (int index = 0; index < suspensionCount; index++) + { + Assert.False(suspended.IsCompleted); + sources[index].SetResult(index + 1); + Assert.Equal(((long)index + 1) * (index + 2) / 2, counts[1]); + if (index == 0 || index == suspensionCount - 1) + { + GC.Collect(); + } + } + + Assert.Equal(0, RecursiveTarget(0)); + Task completed = SuspendedTargetAsync(Task.FromResult(37), value); + Assert.True(completed.IsCompletedSuccessfully); + Assert.Same(value, completed.GetAwaiter().GetResult()); + }; + Assert.Equal(10, RecursiveTarget(4)); + Assert.Null(s_atRecursiveBottom); + + Assert.Same(value, await suspended); + Assert.Equal(1L, counts[0]); + Assert.Equal(1L, counts[2]); + Assert.Same(value, await RepeatedlySuspendedTargetAsync(Array.Empty>(), value, new long[3])); + + originalSource.SetResult(43); + Assert.Same(value, await original); + + // Not called until the wait is over, so its first call finds and publishes its R2R code. + Assert.Equal(untouchedWasDelayed, IsDelayed(untouchedEntryPoint)); + Assert.Equal(untouchedWasDelayed, PrefersInterpreter(untouchedEntryPoint)); + Assert.Equal(36, UntouchedTarget(5)); + Assert.False(IsDelayed(untouchedEntryPoint), "IsDelayed, untouched"); + Assert.False(PrefersInterpreter(untouchedEntryPoint)); + + // The class constructor is first run by the VM after activation, through a function-pointer call. + Assert.Equal(59, CctorTarget.Value); + Assert.False(IsDelayed(cctorEntryPoint), "IsDelayed, class constructor"); + Assert.False(PrefersInterpreter(cctorEntryPoint)); + + IntPtr lateEntryPoint = PrepareMethod(nameof(LatePreparedTarget)); + Assert.False(IsDelayed(lateEntryPoint), "IsDelayed, late prepared"); + Assert.False(PrefersInterpreter(lateEntryPoint)); + Assert.Equal(46, LatePreparedTarget(5)); + + VerifyCalls(7, callCompiledTarget: true); + foreach (IntPtr entryPoint in entryPoints) + { + Assert.False(IsDelayed(entryPoint), "IsDelayed, after activation"); + Assert.False(PrefersInterpreter(entryPoint)); + } + + for (int invocation = 0; invocation < 3; invocation++) + { + Assert.Equal(invocation + 11, InterpreterOnlyTarget(invocation)); + // Implementation has already activated, so these calls only take the alias's redirect. + Assert.Equal(invocation + 53, AliasCaller.CallDeclared(new AliasTarget(), invocation)); + Assert.True(PrefersInterpreter(interpreterOnlyEntryPoint)); + // It has no R2R code, so there is nothing to wait for. + Assert.False(IsDelayed(interpreterOnlyEntryPoint), "IsDelayed, interpreter only"); + } + } + + [BypassReadyToRun] + private static IntPtr PrepareMethod(string methodName) => + PrepareMethod(typeof(DelayedR2RActivation).GetMethod(methodName)); + + [BypassReadyToRun] + private static IntPtr PrepareMethod(MethodBase method) + { + RuntimeHelpers.PrepareMethod(method.MethodHandle); + return method.MethodHandle.GetFunctionPointer(); + } + + [BypassReadyToRun] + [MethodImpl(MethodImplOptions.NoInlining)] + private static unsafe int CallThroughFunctionPointer(int value) + { + delegate* calliTarget = &CalliTarget; + return calliTarget(value); + } + + [BypassReadyToRun] + private static void VerifyCalls(int value, bool callCompiledTarget) + { + Func callback = DelegateTarget; + Assert.Equal(value * 7 + 13, DirectTarget(value)); + Assert.Equal(value * 7 + 13, callback(value)); + Assert.Equal(value * 7 + 13, + (int)typeof(DelayedR2RActivation).GetMethod(nameof(ReflectionTarget)).Invoke(null, new object[] { value })); + Assert.Equal(value * 7 + 13, GenericTarget(value)); + // The compiled caller roots both generic bodies; interpreter-only callers do not. + Assert.Equal(2 * (value * 7 + 13), GenericCaller(value)); + Assert.Equal(5, CrossModuleGenericCaller()); + object marker = new(); + Assert.Same(marker, ExceptionTarget(marker)); + Assert.Equal(callCompiledTarget ? value + 17 : value, CompiledCaller(callCompiledTarget, value)); + VirtualBase virtualTarget = new VirtualTarget(); + Assert.Equal(value + 23, virtualTarget.Invoke(value)); + Assert.Equal(value + 23, ((IVirtualTarget)virtualTarget).Invoke(value)); + CovariantBase covariantTarget = new CovariantTarget(); + Assert.Equal("activated", covariantTarget.Invoke()); + Assert.Equal(value + 19, CallThroughFunctionPointer(value)); + IVirtualTarget structTarget = new StructTarget(); + Assert.Equal(value + 29, structTarget.Invoke(value)); + // Calls Implementation through the interpreter's alias on Declared. After activation, the alias + // must send the call to Implementation's R2R code. + Assert.Equal(value + 53, AliasCaller.CallDeclared(new AliasTarget(), value)); + } + + [BypassReadyToRun] + private static int ReadCallCount(string variable, int defaultValue) => + Environment.GetEnvironmentVariable(variable) is string value + ? int.Parse(value, NumberStyles.HexNumber, CultureInfo.InvariantCulture) + : defaultValue; + + [BypassReadyToRun] + private static bool IsPendingAuxiliaryModule(IntPtr entryPoint) + { + return (Marshal.ReadInt32(entryPoint, PortableEntryPointFlagsOffset) & PendingAuxiliaryModule) != 0; + } + + // True while the method waits for its auxiliary module. + [BypassReadyToRun] + private static bool IsDelayed(IntPtr entryPoint) => IsPendingAuxiliaryModule(entryPoint); + + [BypassReadyToRun] + private static bool PrefersInterpreter(IntPtr entryPoint) + { + return (Marshal.ReadInt32(entryPoint, PortableEntryPointFlagsOffset) & PrefersInterpreterEntryPoint) != 0; + } + + [MethodImpl(MethodImplOptions.NoInlining)] + public static int DirectTarget(int value) => value * 7 + 13; + + [MethodImpl(MethodImplOptions.NoInlining)] + public static int DelegateTarget(int value) => value * 7 + 13; + + [MethodImpl(MethodImplOptions.NoInlining)] + public static int ReflectionTarget(int value) => value * 7 + 13; + + [MethodImpl(MethodImplOptions.NoInlining)] + public static int GenericTarget(int value) => value * 7 + 13; + + [MethodImpl(MethodImplOptions.NoInlining)] + public static int GenericCaller(int value) => GenericTarget(value) + GenericTarget(value); + + [MethodImpl(MethodImplOptions.NoInlining)] + public static int RecursiveTarget(int depth) + { + if (depth == 0) + { + s_atRecursiveBottom?.Invoke(); + return 0; + } + + return depth + RecursiveTarget(depth - 1); + } + + [MethodImpl(MethodImplOptions.NoInlining)] + public static int CompiledCaller(bool callTarget, int value) => callTarget ? NativeToInterpreterTarget(value) : value; + + [MethodImpl(MethodImplOptions.NoInlining)] + public static int NativeToInterpreterTarget(int value) => value + 17; + + [MethodImpl(MethodImplOptions.NoInlining)] + public static int UntouchedTarget(int value) => value + 31; + + [MethodImpl(MethodImplOptions.NoInlining)] + public static int CalliTarget(int value) => value + 19; + + [MethodImpl(MethodImplOptions.NoInlining)] + public static int LatePreparedTarget(int value) => value + 41; + + public struct CrossModuleValue + { + public int Value; + } + + [MethodImpl(MethodImplOptions.NoInlining)] + public static int CrossModuleGenericCaller() + { + List values = new(); + values.Add(new CrossModuleValue { Value = 5 }); + return values[0].Value; + } + + [MethodImpl(MethodImplOptions.NoInlining)] + public static async Task SuspendedTargetAsync(Task pending, object value) + { + await pending; + GC.KeepAlive(value); + return value; + } + + [MethodImpl(MethodImplOptions.NoInlining)] + public static async Task RepeatedlySuspendedTargetAsync(Task[] pending, object value, long[] counts) + { + counts[0]++; + try + { + for (int index = 0; index < pending.Length; index++) + { + counts[1] += await pending[index]; + } + + return value; + } + finally + { + counts[2]++; + GC.KeepAlive(value); + } + } + + [MethodImpl(MethodImplOptions.NoInlining)] + public static object ExceptionTarget(object value) + { + try + { + throw new InvalidOperationException(); + } + catch (InvalidOperationException) + { + return value; + } + finally + { + GC.KeepAlive(value); + } + } + + [BypassReadyToRun] + [MethodImpl(MethodImplOptions.NoInlining)] + public static int InterpreterOnlyTarget(int value) => value + 11; + + public interface IVirtualTarget + { + int Invoke(int value); + } + + public class VirtualBase + { + [MethodImpl(MethodImplOptions.NoInlining)] + public virtual int Invoke(int value) => -1; + } + + public sealed class VirtualTarget : VirtualBase, IVirtualTarget + { + [MethodImpl(MethodImplOptions.NoInlining)] + public override int Invoke(int value) => value + 23; + } + + public class CovariantBase + { + [MethodImpl(MethodImplOptions.NoInlining)] + public virtual object Invoke() => null; + } + + public sealed class CovariantTarget : CovariantBase + { + [MethodImpl(MethodImplOptions.NoInlining)] + public override string Invoke() => "activated"; + } + + public struct StructTarget : IVirtualTarget + { + [MethodImpl(MethodImplOptions.NoInlining)] + public int Invoke(int value) => value + 29; + } + + public static class CctorTarget + { + private static readonly int s_value; + + static CctorTarget() + { + s_value = 59; + } + + public static int Value + { + [MethodImpl(MethodImplOptions.NoInlining)] + get => s_value; + } + } +} diff --git a/src/tests/readytorun/wasm/DelayedR2RActivation/DelayedR2RActivation.csproj b/src/tests/readytorun/wasm/DelayedR2RActivation/DelayedR2RActivation.csproj new file mode 100644 index 00000000000000..2117740e3fedad --- /dev/null +++ b/src/tests/readytorun/wasm/DelayedR2RActivation/DelayedR2RActivation.csproj @@ -0,0 +1,18 @@ + + + true + $(Features);runtime-async=on + true + true + true + true + $(CrossGen2TestExtraArguments) --compilebubblegenerics --opt-cross-module System.Private.CoreLib + true + + + + + + + + diff --git a/src/tests/readytorun/wasm/DelayedR2RActivation/DelayedR2RActivationAliasHelper.il b/src/tests/readytorun/wasm/DelayedR2RActivation/DelayedR2RActivationAliasHelper.il new file mode 100644 index 00000000000000..0d31d701a06661 --- /dev/null +++ b/src/tests/readytorun/wasm/DelayedR2RActivation/DelayedR2RActivationAliasHelper.il @@ -0,0 +1,75 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +.assembly extern System.Runtime +{ + .publickeytoken = (B0 3F 5F 7F 11 D5 0A 3A) +} + +.assembly DelayedR2RActivationAliasHelper +{ +} + +.module DelayedR2RActivationAliasHelper.dll + +.namespace System.Runtime +{ + // Crossgen2 skips methods marked with an attribute of this name. + .class private auto ansi sealed beforefieldinit BypassReadyToRunAttribute extends [System.Runtime]System.Attribute + { + .method public hidebysig specialname rtspecialname instance void .ctor() cil managed + { + ldarg.0 + call instance void [System.Runtime]System.Attribute::.ctor() + ret + } + } +} + +.namespace DelayedR2RActivationHelpers +{ + // Implementation takes over the vtable slot of Declared through a MethodImpl in the same type. + // The interpreter resolves a call to Declared to Implementation and caches Implementation's + // byte code on Declared, which makes Declared an alias of Implementation. + .class public auto ansi beforefieldinit AliasTarget extends [System.Runtime]System.Object + { + .method public hidebysig specialname rtspecialname instance void .ctor() cil managed + { + ldarg.0 + call instance void [System.Runtime]System.Object::.ctor() + ret + } + + // Same result as Implementation: a call that reaches this body instead of Implementation's is a + // separate interpreter inconsistency for MethodImpls in the same type, not what this test checks. + .method public hidebysig newslot virtual instance int32 Declared(int32 'value') cil managed noinlining + { + ldarg.1 + ldc.i4.s 53 + add + ret + } + + .method public hidebysig newslot virtual instance int32 Implementation(int32 'value') cil managed noinlining + { + .override DelayedR2RActivationHelpers.AliasTarget::Declared + ldarg.1 + ldc.i4.s 53 + add + ret + } + } + + .class public abstract auto ansi sealed beforefieldinit AliasCaller extends [System.Runtime]System.Object + { + // Stays interpreted so that its non-virtual call to Declared goes through the interpreter's alias. + .method public hidebysig static int32 CallDeclared(class DelayedR2RActivationHelpers.AliasTarget target, int32 'value') cil managed noinlining + { + .custom instance void System.Runtime.BypassReadyToRunAttribute::.ctor() = (01 00 00 00) + ldarg.0 + ldarg.1 + call instance int32 DelayedR2RActivationHelpers.AliasTarget::Declared(int32) + ret + } + } +} diff --git a/src/tests/readytorun/wasm/DelayedR2RActivation/DelayedR2RActivationAliasHelper.ilproj b/src/tests/readytorun/wasm/DelayedR2RActivation/DelayedR2RActivationAliasHelper.ilproj new file mode 100644 index 00000000000000..eeea6745acc06e --- /dev/null +++ b/src/tests/readytorun/wasm/DelayedR2RActivation/DelayedR2RActivationAliasHelper.ilproj @@ -0,0 +1,8 @@ + + + Library + + + + + diff --git a/src/tests/readytorun/wasm/DelayedR2RActivation/DelayedR2RActivation_Eager.csproj b/src/tests/readytorun/wasm/DelayedR2RActivation/DelayedR2RActivation_Eager.csproj new file mode 100644 index 00000000000000..99e4700fd23a10 --- /dev/null +++ b/src/tests/readytorun/wasm/DelayedR2RActivation/DelayedR2RActivation_Eager.csproj @@ -0,0 +1,18 @@ + + + true + $(Features);runtime-async=on + true + true + true + true + $(CrossGen2TestExtraArguments) --compilebubblegenerics --opt-cross-module System.Private.CoreLib + true + + + + + + + + diff --git a/src/tests/readytorun/wasm/DelayedR2RActivation/DelayedR2RActivation_Immediate.csproj b/src/tests/readytorun/wasm/DelayedR2RActivation/DelayedR2RActivation_Immediate.csproj new file mode 100644 index 00000000000000..849d92b33b4ef3 --- /dev/null +++ b/src/tests/readytorun/wasm/DelayedR2RActivation/DelayedR2RActivation_Immediate.csproj @@ -0,0 +1,18 @@ + + + true + $(Features);runtime-async=on + true + true + true + true + $(CrossGen2TestExtraArguments) --compilebubblegenerics --opt-cross-module System.Private.CoreLib + true + + + + + + + + From df37fda908e436f39ca738ed61a5ea0bd1cbff72 Mon Sep 17 00:00:00 2001 From: Jackson Schuster <36744439+jtschuster@users.noreply.github.com> Date: Mon, 26 Oct 2026 22:43:22 -0700 Subject: [PATCH 10/19] [wasm][R2R] Simulate delayed Auxiliary EntryPoints modules with an era Second phase of partitioned R2R modules: a method's R2R lookup can also miss because the module that maps methods to code (the Auxiliary EntryPoints Module) is not yet available, or because the image that has the code is loaded later. Such methods wait for the next auxiliary EntryPoints era instead of for a specific module, then repeat the lookup. - Add AuxiliaryEntryPointsEra, an opaque value only ReadyToRunInfo creates or compares, and a global era next to the unrelated R2R modules list. The era advances when the simulated EntryPoints download completes and when an image with code for unrelated generics is registered. - Record the era read before the lookup when it misses, set kAwaitingAuxiliaryEntryPoints, and cache the era in AuxiliaryMethodInfo, which becomes a discriminated union of the activation flag and the era. - The lookup treats methods of an image as not found while its EntryPoints module is inactive. - Add DOTNET_R2RDelayedEntryPointsActivationCallCount, defaulting to DOTNET_R2RDelayedActivationCallCount, and the DelayedModuleOnly and DelayedEntryPointsOnly test variants. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 9444d653-1735-42ee-a4a7-119174358f8d (cherry picked from commit 8806b13e855c7d4fdbdd9b4f0071f65b992869fa) --- src/coreclr/inc/clrconfigvalues.h | 1 + .../interpreter/inc/auxiliaryentrypointsera.h | 33 ++++++++ .../interpreter/inc/interpretershared.h | 22 ++++-- src/coreclr/vm/interpexec.h | 19 +++-- src/coreclr/vm/method.hpp | 21 +++++ src/coreclr/vm/precode_portable.hpp | 23 ++++++ src/coreclr/vm/prestub.cpp | 40 ++++++++-- src/coreclr/vm/readytoruninfo.cpp | 30 +++++++- src/coreclr/vm/readytoruninfo.h | 76 +++++++++++++++++++ .../DelayedR2RActivation.cs | 60 +++++++++++---- ...2RActivation_DelayedEntryPointsOnly.csproj | 19 +++++ ...ayedR2RActivation_DelayedModuleOnly.csproj | 19 +++++ 12 files changed, 329 insertions(+), 34 deletions(-) create mode 100644 src/coreclr/interpreter/inc/auxiliaryentrypointsera.h create mode 100644 src/tests/readytorun/wasm/DelayedR2RActivation/DelayedR2RActivation_DelayedEntryPointsOnly.csproj create mode 100644 src/tests/readytorun/wasm/DelayedR2RActivation/DelayedR2RActivation_DelayedModuleOnly.csproj diff --git a/src/coreclr/inc/clrconfigvalues.h b/src/coreclr/inc/clrconfigvalues.h index 4446198ce7669d..9017600fb53307 100644 --- a/src/coreclr/inc/clrconfigvalues.h +++ b/src/coreclr/inc/clrconfigvalues.h @@ -303,6 +303,7 @@ RETAIL_CONFIG_DWORD_INFO(EXTERNAL_InterpMode, W("InterpMode"), 0, "Enables Inter #ifdef FEATURE_AUXILIARY_R2R_MODULES RETAIL_CONFIG_DWORD_INFO(EXTERNAL_R2RDelayedActivationCallCount, W("R2RDelayedActivationCallCount"), 0, "Activate auxiliary R2R code after this many pending interpreter entries across a composite image. Zero preserves eager activation.") +RETAIL_CONFIG_DWORD_INFO(EXTERNAL_R2RDelayedEntryPointsActivationCallCount, W("R2RDelayedEntryPointsActivationCallCount"), 0, "Activate auxiliary R2R EntryPoints modules after this many calls to methods awaiting them. Defaults to R2RDelayedActivationCallCount when not specified.") #endif // FEATURE_AUXILIARY_R2R_MODULES RETAIL_CONFIG_DWORD_INFO(EXTERNAL_JitHostMaxSlabCache, W("JitHostMaxSlabCache"), 0x1000000, "Sets jit host max slab cache size, 16MB default") diff --git a/src/coreclr/interpreter/inc/auxiliaryentrypointsera.h b/src/coreclr/interpreter/inc/auxiliaryentrypointsera.h new file mode 100644 index 00000000000000..2517db65dc9716 --- /dev/null +++ b/src/coreclr/interpreter/inc/auxiliaryentrypointsera.h @@ -0,0 +1,33 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +// This header is shared by the interpreter and the VM so the era can be stored on InterpMethod. +#ifndef _AUXILIARYENTRYPOINTSERA_H_ +#define _AUXILIARYENTRYPOINTSERA_H_ + +#ifdef FEATURE_AUXILIARY_R2R_MODULES + +#include + +class ReadyToRunInfo; + +// The auxiliary EntryPoints era observed by a method whose R2R lookup may have missed because +// auxiliary EntryPoints modules were not yet activated. Only ReadyToRunInfo creates or compares eras. +class AuxiliaryEntryPointsEra +{ + friend class ReadyToRunInfo; + + // 0 means not awaiting EntryPoints; the global era starts at 1. + uint32_t m_value; + + explicit AuxiliaryEntryPointsEra(uint32_t value) : m_value(value) {} + +public: + AuxiliaryEntryPointsEra() : m_value(0) {} + + bool IsAwaiting() const { return m_value != 0; } +}; + +#endif // FEATURE_AUXILIARY_R2R_MODULES + +#endif // _AUXILIARYENTRYPOINTSERA_H_ diff --git a/src/coreclr/interpreter/inc/interpretershared.h b/src/coreclr/interpreter/inc/interpretershared.h index af6bc638f1d11c..181294ba572191 100644 --- a/src/coreclr/interpreter/inc/interpretershared.h +++ b/src/coreclr/interpreter/inc/interpretershared.h @@ -7,6 +7,7 @@ #define _INTERPRETERSHARED_H_ #include "intopsshared.h" +#include "auxiliaryentrypointsera.h" #ifdef _MSC_VER #define INTERP_API @@ -33,16 +34,27 @@ class MethodTable; struct CallStubHeader; #ifdef FEATURE_AUXILIARY_R2R_MODULES +enum class AuxiliaryMethodInfoDiscriminator : uint8_t +{ + InterpretOnly, + CheckAuxiliaryModuleActivationFlag, + CheckAuxiliaryEntryPointsEra +}; + // Cached on InterpMethod so the interpreter can decide with few memory loads whether a call should try to // switch the method to R2R code. Set by the VM; the interpreter compiler only default-constructs it. struct AuxiliaryMethodInfo { - // The activation flag of the auxiliary module holding the method's R2R code, or null if there is nothing to wait for. - COMPILER_SHARED_TYPE(uint32_t*, PTR_DWORD, auxiliaryModuleActivationFlag); + union + { + COMPILER_SHARED_TYPE(uint32_t*, PTR_DWORD, auxiliaryModuleActivationFlag); + AuxiliaryEntryPointsEra auxiliaryEntryPointsEra; + }; + AuxiliaryMethodInfoDiscriminator discriminator; - AuxiliaryMethodInfo() : auxiliaryModuleActivationFlag(nullptr) {} + AuxiliaryMethodInfo() : auxiliaryModuleActivationFlag(nullptr), discriminator(AuxiliaryMethodInfoDiscriminator::InterpretOnly) {} - bool MayActivateMethod() const { return auxiliaryModuleActivationFlag != nullptr; } + bool MayActivateMethod() const { return discriminator != AuxiliaryMethodInfoDiscriminator::InterpretOnly; } #ifndef INTERPRETER_COMPILER_INTERNAL // Defined in the VM's interpexec.h, since it needs ReadyToRunInfo. @@ -66,7 +78,7 @@ struct InterpMethod bool unmanagedCallersOnly; bool publishSecretStubParam; #ifdef FEATURE_AUXILIARY_R2R_MODULES - // Set by the VM when the method is interpreted only until an auxiliary module is activated + // Set by the VM when the method is interpreted only until an auxiliary module or EntryPoints module is activated AuxiliaryMethodInfo auxiliaryMethodInfo; #endif // FEATURE_AUXILIARY_R2R_MODULES int32_t codeSize; // size in int32_t slots diff --git a/src/coreclr/vm/interpexec.h b/src/coreclr/vm/interpexec.h index 24d6103b042c3f..27045e09803688 100644 --- a/src/coreclr/vm/interpexec.h +++ b/src/coreclr/vm/interpexec.h @@ -127,12 +127,19 @@ void InterpDispatchCache_ClearForLoaderAllocator(LoaderAllocator* pLoaderAllocat inline bool AuxiliaryMethodInfo::ShouldTryActivateMethod() const { LIMITED_METHOD_CONTRACT; - if (auxiliaryModuleActivationFlag == nullptr) - return false; - - // TODO: remove the simulated download once auxiliary modules are separate - ReadyToRunInfo::AdvanceSimulatedAuxiliaryModuleDownload(auxiliaryModuleActivationFlag); - return ReadyToRunInfo::IsAuxiliaryModuleActivated(auxiliaryModuleActivationFlag); + switch (discriminator) + { + case AuxiliaryMethodInfoDiscriminator::CheckAuxiliaryModuleActivationFlag: + // TODO: remove the simulated download once auxiliary modules are separate + ReadyToRunInfo::AdvanceSimulatedAuxiliaryModuleDownload(auxiliaryModuleActivationFlag); + return ReadyToRunInfo::IsAuxiliaryModuleActivated(auxiliaryModuleActivationFlag); + case AuxiliaryMethodInfoDiscriminator::CheckAuxiliaryEntryPointsEra: + // TODO: remove the simulated download once auxiliary EntryPoints modules are separate + ReadyToRunInfo::AdvanceSimulatedAuxiliaryEntryPointsDownload(); + return ReadyToRunInfo::IsAuxiliaryEntryPointsEraStale(auxiliaryEntryPointsEra); + default: + return false; + } } #endif // FEATURE_AUXILIARY_R2R_MODULES diff --git a/src/coreclr/vm/method.hpp b/src/coreclr/vm/method.hpp index 73e3aed344ac39..eaa39576ec3b19 100644 --- a/src/coreclr/vm/method.hpp +++ b/src/coreclr/vm/method.hpp @@ -285,6 +285,7 @@ struct MethodDescCodeData final // Activation status of the auxiliary module holding this method's R2R code. Zero means activated. // TODO: This points at a shared countdown standing in for the auxiliary module's activation flag. PTR_DWORD AuxiliaryModuleActivationFlag; + AuxiliaryEntryPointsEra AuxiliaryEntryPointsEra; #endif // FEATURE_AUXILIARY_R2R_MODULES #if defined(_DEBUG) && defined(ALLOW_SXS_JIT) PatchpointInfo *AltJitPatchpointInfo; @@ -2492,6 +2493,25 @@ class PrepareCodeConfig LIMITED_METHOD_CONTRACT; m_mayDelayAuxiliaryModule = false; } + + bool GetIsAwaitingAuxiliaryModuleEntryPoints() const + { + LIMITED_METHOD_CONTRACT; + return m_auxiliaryEntryPointsEra.IsAwaiting(); + } + + AuxiliaryEntryPointsEra GetAuxiliaryEntryPointsEra() const + { + LIMITED_METHOD_CONTRACT; + return m_auxiliaryEntryPointsEra; + } + + void SetIsAwaitingAuxiliaryModuleEntryPoints(AuxiliaryEntryPointsEra era) + { + LIMITED_METHOD_CONTRACT; + _ASSERTE(era.IsAwaiting()); + m_auxiliaryEntryPointsEra = era; + } #endif // FEATURE_AUXILIARY_R2R_MODULES public: @@ -2629,6 +2649,7 @@ class PrepareCodeConfig #ifdef FEATURE_AUXILIARY_R2R_MODULES PTR_DWORD m_pAuxiliaryModuleActivationFlag = nullptr; bool m_mayDelayAuxiliaryModule = true; + AuxiliaryEntryPointsEra m_auxiliaryEntryPointsEra; #endif // FEATURE_AUXILIARY_R2R_MODULES #ifdef FEATURE_MULTICOREJIT diff --git a/src/coreclr/vm/precode_portable.hpp b/src/coreclr/vm/precode_portable.hpp index bcbcd22fe387ef..c823a37b553b17 100644 --- a/src/coreclr/vm/precode_portable.hpp +++ b/src/coreclr/vm/precode_portable.hpp @@ -43,6 +43,7 @@ class PortableEntryPoint final kPendingClosedStaticRetBufResolution = 0x8, #ifdef FEATURE_AUXILIARY_R2R_MODULES kPendingAuxiliaryModule = 0x10, + kAwaitingAuxiliaryEntryPoints = 0x20, #endif // FEATURE_AUXILIARY_R2R_MODULES }; Volatile _flags; @@ -168,6 +169,28 @@ class PortableEntryPoint final _ASSERTE(IsValid()); ClearFlagsInterlocked(kPendingAuxiliaryModule); } + + bool IsAwaitingAuxiliaryEntryPoints() const + { + LIMITED_METHOD_CONTRACT; + _ASSERTE(IsValid()); + return (_flags & kAwaitingAuxiliaryEntryPoints) != 0; + } + + void SetAwaitingAuxiliaryEntryPoints() + { + LIMITED_METHOD_CONTRACT; + _ASSERTE(IsValid()); + _ASSERTE(PrefersInterpreterEntryPoint()); + SetFlagsInterlocked(kAwaitingAuxiliaryEntryPoints); + } + + void ClearAwaitingAuxiliaryEntryPoints() + { + LIMITED_METHOD_CONTRACT; + _ASSERTE(IsValid()); + ClearFlagsInterlocked(kAwaitingAuxiliaryEntryPoints); + } #endif // FEATURE_AUXILIARY_R2R_MODULES // Atomically install an interpreter thunk if _pActualCode is still NULL. diff --git a/src/coreclr/vm/prestub.cpp b/src/coreclr/vm/prestub.cpp index 812280ed9c676c..fe06b2dcb012c2 100644 --- a/src/coreclr/vm/prestub.cpp +++ b/src/coreclr/vm/prestub.cpp @@ -497,6 +497,10 @@ PCODE MethodDesc::GetPrecompiledR2RCode(PrepareCodeConfig* pConfig) PCODE pCode = (PCODE)NULL; #ifdef FEATURE_READYTORUN +#ifdef FEATURE_AUXILIARY_R2R_MODULES + AuxiliaryEntryPointsEra searchedEra = ReadyToRunInfo::GetCurrentAuxiliaryEntryPointsEra(); +#endif + ReadyToRunInfo* pAlreadyExaminedInfos[2] = {NULL, NULL}; Module * pModule = GetModule(); if (pModule->IsReadyToRun()) @@ -547,6 +551,16 @@ PCODE MethodDesc::GetPrecompiledR2RCode(PrepareCodeConfig* pConfig) } } } + +#ifdef FEATURE_AUXILIARY_R2R_MODULES + if (pCode == (PCODE)NULL && !pConfig->ReadyToRunRejectedPrecompiledCode()) + { + // Even when every searched image has its EntryPoints module activated, an image registered later may have + // the code, which also advances the era. + pConfig->SetIsAwaitingAuxiliaryModuleEntryPoints(searchedEra); + } +#endif + #endif // FEATURE_READYTORUN return pCode; @@ -576,7 +590,8 @@ bool MethodDesc::TryPublishR2RCodeForUnmanagedCallersOnly() #ifdef FEATURE_AUXILIARY_R2R_MODULES // Records why an interpreted method may get R2R code later, so callers check before interpreting it. -// A method found in a pending auxiliary module waits for that module. +// A method found in a pending auxiliary module waits for that module; a method not found while +// auxiliary EntryPoints modules were pending waits for the EntryPoints era to advance. void MethodDesc::RecordDelayedR2RCode(PrepareCodeConfig* pConfig, PCODE pCode) { STANDARD_VM_CONTRACT; @@ -604,17 +619,26 @@ void MethodDesc::RecordDelayedR2RCode(PrepareCodeConfig* pConfig, PCODE pCode) if (pConfig->HasKnownAuxiliaryModuleDependency()) { m_codeData->AuxiliaryModuleActivationFlag = pConfig->GetAuxiliaryModuleActivationFlag(); + m_codeData->AuxiliaryEntryPointsEra = AuxiliaryEntryPointsEra(); + entryPoint->ClearAwaitingAuxiliaryEntryPoints(); entryPoint->SetPendingAuxiliaryModule(); if (pInterpMethod != nullptr) { pInterpMethod->auxiliaryMethodInfo.auxiliaryModuleActivationFlag = pConfig->GetAuxiliaryModuleActivationFlag(); + pInterpMethod->auxiliaryMethodInfo.discriminator = AuxiliaryMethodInfoDiscriminator::CheckAuxiliaryModuleActivationFlag; } } - else if (pInterpMethod != nullptr) + else if (pConfig->GetIsAwaitingAuxiliaryModuleEntryPoints()) { - // Nothing to wait for, so the method stays interpreted. - pInterpMethod->auxiliaryMethodInfo = AuxiliaryMethodInfo(); + m_codeData->AuxiliaryEntryPointsEra = pConfig->GetAuxiliaryEntryPointsEra(); + entryPoint->SetAwaitingAuxiliaryEntryPoints(); + if (pInterpMethod != nullptr) + { + pInterpMethod->auxiliaryMethodInfo.auxiliaryEntryPointsEra = pConfig->GetAuxiliaryEntryPointsEra(); + pInterpMethod->auxiliaryMethodInfo.discriminator = AuxiliaryMethodInfoDiscriminator::CheckAuxiliaryEntryPointsEra; + } } + // Otherwise no R2R lookup was done, so there is nothing to wait for and the default InterpretOnly info stays. } // Called by the prestub. A method waiting on its auxiliary module already has interpreter code, so callers @@ -637,11 +661,15 @@ void MethodDesc::TryActivateDelayedR2RCode(CallerGCMode callerGCMode) if (!entryPoint->PrefersInterpreterEntryPoint()) return; - if (!entryPoint->IsPendingAuxiliaryModule() - || !ReadyToRunInfo::IsAuxiliaryModuleActivated(m_codeData->AuxiliaryModuleActivationFlag)) + bool moduleActivated = entryPoint->IsPendingAuxiliaryModule() + && ReadyToRunInfo::IsAuxiliaryModuleActivated(m_codeData->AuxiliaryModuleActivationFlag); + bool entryPointsEraStale = entryPoint->IsAwaitingAuxiliaryEntryPoints() + && ReadyToRunInfo::IsAuxiliaryEntryPointsEraStale(m_codeData->AuxiliaryEntryPointsEra); + if (!moduleActivated && !entryPointsEraStale) return; entryPoint->ClearPendingAuxiliaryModule(); + entryPoint->ClearAwaitingAuxiliaryEntryPoints(); EX_TRY { PrepareCodeConfig config(NativeCodeVersion(this), FALSE, TRUE); diff --git a/src/coreclr/vm/readytoruninfo.cpp b/src/coreclr/vm/readytoruninfo.cpp index 98669e6b9906b8..f33312ba0052c8 100644 --- a/src/coreclr/vm/readytoruninfo.cpp +++ b/src/coreclr/vm/readytoruninfo.cpp @@ -745,6 +745,11 @@ void ReadyToRunInfo::RegisterUnrelatedR2RModule() oldGlobalValue = s_pGlobalR2RModules; m_pNextR2RForUnrelatedCode = dac_cast(dac_cast(oldGlobalValue) | 0x1); } + +#ifdef FEATURE_AUXILIARY_R2R_MODULES + // Generic methods that missed in every image searched so far may have code in this new image. + IncrementAuxiliaryEntryPointsEra(); +#endif // FEATURE_AUXILIARY_R2R_MODULES } } else @@ -857,10 +862,19 @@ ReadyToRunInfo::ReadyToRunInfo(Module * pModule, LoaderAllocator* pLoaderAllocat #ifdef FEATURE_AUXILIARY_R2R_MODULES // TODO: These should point to the location of the flag in the R2R image m_pAuxiliaryModuleActivationFlag = (PTR_DWORD)pamTracker->Track(pLoaderAllocator->GetLowFrequencyHeap()->AllocMem(S_SIZE_T(sizeof(DWORD)))); - // Only composite images have auxiliary modules. - *m_pAuxiliaryModuleActivationFlag = pNativeImage != nullptr - ? CLRConfig::GetConfigValue(CLRConfig::EXTERNAL_R2RDelayedActivationCallCount) - : 0; + if (pNativeImage != nullptr) + { + EnsureAuxiliaryEntryPointsModuleStatus(); + m_pAuxiliaryEntryPointsModuleStatus = &s_simulatedAuxiliaryEntryPointsCallsRemaining; + *m_pAuxiliaryModuleActivationFlag = CLRConfig::GetConfigValue(CLRConfig::EXTERNAL_R2RDelayedActivationCallCount); + } + else + { + // Only composite images have auxiliary modules. + m_pAuxiliaryEntryPointsModuleStatus = (PTR_DWORD)pamTracker->Track(pLoaderAllocator->GetLowFrequencyHeap()->AllocMem(S_SIZE_T(sizeof(DWORD)))); + *m_pAuxiliaryEntryPointsModuleStatus = 0; + *m_pAuxiliaryModuleActivationFlag = 0; + } #endif // FEATURE_AUXILIARY_R2R_MODULES m_component = ReadyToRunCoreInfo(pLayout, &pHeader->CoreHeader); m_pComposite = &m_component; @@ -1357,6 +1371,12 @@ PCODE ReadyToRunInfo::GetEntryPoint(MethodDesc * pMD, PrepareCodeConfig* pConfig && !pMD->IsWrapperStub() && (!pMD->GetModule()->IsReadyToRun() || !pMD->GetModule()->GetReadyToRunInfo()->HasStrippedILBodies()) && !pMD->HasUnmanagedCallersOnlyAttribute(); + + // The EntryPoints for this method may be in an auxiliary EntryPoints module that is not yet activated. + if (mayDelayR2RCode && !m_pCompositeInfo->IsAuxiliaryEntryPointsModuleActivated()) + { + goto done; + } #endif // FEATURE_AUXILIARY_R2R_MODULES uint offset; @@ -1394,6 +1414,8 @@ PCODE ReadyToRunInfo::GetEntryPoint(MethodDesc * pMD, PrepareCodeConfig* pConfig } #ifdef FEATURE_AUXILIARY_R2R_MODULES + _ASSERTE(!mayDelayR2RCode || m_pCompositeInfo->IsAuxiliaryEntryPointsModuleActivated()); + if (mayDelayR2RCode && !IsAuxiliaryModuleActivated(m_pCompositeInfo->GetAuxiliaryModuleActivationFlag(pMD))) { diff --git a/src/coreclr/vm/readytoruninfo.h b/src/coreclr/vm/readytoruninfo.h index c569c394886d14..aa20dfc37ade51 100644 --- a/src/coreclr/vm/readytoruninfo.h +++ b/src/coreclr/vm/readytoruninfo.h @@ -14,6 +14,7 @@ #include "inlinetracking.h" #include "wellknownattributes.h" #include "nativeimage.h" +#include "auxiliaryentrypointsera.h" typedef DPTR(struct READYTORUN_SECTION) PTR_READYTORUN_SECTION; @@ -203,6 +204,13 @@ class ReadyToRunInfo #ifdef FEATURE_AUXILIARY_R2R_MODULES PTR_DWORD m_pAuxiliaryModuleActivationFlag = 0; + PTR_DWORD m_pAuxiliaryEntryPointsModuleStatus = 0; + + // Starts at 1 so a recorded era is never 0, which callers use to mean "not awaiting EntryPoints". + inline static DWORD s_auxiliaryEntryPointsEra = 1; + // TODO: Remove once auxiliary EntryPoints modules are downloaded. Stands in for the EntryPoints module status + // of every composite image; (DWORD)-1 means not yet read from config. + inline static DWORD s_simulatedAuxiliaryEntryPointsCallsRemaining = (DWORD)-1; #endif // FEATURE_AUXILIARY_R2R_MODULES public: @@ -261,6 +269,48 @@ class ReadyToRunInfo return m_pAuxiliaryModuleActivationFlag; } + // The era advances each time an auxiliary EntryPoints module is activated in any R2R image, or a new R2R image + // with code for unrelated generics is registered. + static AuxiliaryEntryPointsEra GetCurrentAuxiliaryEntryPointsEra() + { + LIMITED_METHOD_CONTRACT; + return AuxiliaryEntryPointsEra(VolatileLoad(&s_auxiliaryEntryPointsEra)); + } + + // Returns true if auxiliary EntryPoints modules have been activated since the given era was observed. + static bool IsAuxiliaryEntryPointsEraStale(AuxiliaryEntryPointsEra era) + { + LIMITED_METHOD_CONTRACT; + _ASSERTE(era.IsAwaiting()); + return era.m_value != VolatileLoad(&s_auxiliaryEntryPointsEra); + } + + // Indicates that one or more new auxiliary EntryPoints modules have been activated. + static void IncrementAuxiliaryEntryPointsEra() + { + LIMITED_METHOD_CONTRACT; + VolatileStore(&s_auxiliaryEntryPointsEra, VolatileLoad(&s_auxiliaryEntryPointsEra) + 1); + } + + PTR_DWORD GetAuxiliaryEntryPointsModuleStatus() const + { + LIMITED_METHOD_CONTRACT; + return m_pAuxiliaryEntryPointsModuleStatus; + } + + static bool IsAuxiliaryEntryPointsModuleActivated(PTR_DWORD pModuleStatus) + { + LIMITED_METHOD_CONTRACT; + _ASSERTE(pModuleStatus != nullptr); + return VolatileLoad(pModuleStatus) == 0; + } + + bool IsAuxiliaryEntryPointsModuleActivated() const + { + LIMITED_METHOD_CONTRACT; + return IsAuxiliaryEntryPointsModuleActivated(m_pAuxiliaryEntryPointsModuleStatus); + } + // The encoding of the activation flag is owned by the R2R image; every reader goes through this. static bool IsAuxiliaryModuleActivated(PTR_DWORD pActivationFlag) { @@ -278,6 +328,32 @@ class ReadyToRunInfo if (!IsAuxiliaryModuleActivated(pActivationFlag)) VolatileStore(pActivationFlag, VolatileLoad(pActivationFlag) - 1); } + + // TODO: Remove the simulation below once auxiliary EntryPoints modules are downloaded. It stands in for + // the download by counting calls to methods awaiting EntryPoints, and activates the EntryPoints modules + // (advancing the era) once the count reaches zero. + static void EnsureAuxiliaryEntryPointsModuleStatus() + { + LIMITED_METHOD_CONTRACT; + if (s_simulatedAuxiliaryEntryPointsCallsRemaining == (DWORD)-1) + { + s_simulatedAuxiliaryEntryPointsCallsRemaining = + CLRConfig::IsConfigOptionSpecified(W("R2RDelayedEntryPointsActivationCallCount")) + ? CLRConfig::GetConfigValue(CLRConfig::EXTERNAL_R2RDelayedEntryPointsActivationCallCount) + : CLRConfig::GetConfigValue(CLRConfig::EXTERNAL_R2RDelayedActivationCallCount); + } + } + + static void AdvanceSimulatedAuxiliaryEntryPointsDownload() + { + LIMITED_METHOD_CONTRACT; + EnsureAuxiliaryEntryPointsModuleStatus(); + if (s_simulatedAuxiliaryEntryPointsCallsRemaining == 0) + return; + + if (--s_simulatedAuxiliaryEntryPointsCallsRemaining == 0) + IncrementAuxiliaryEntryPointsEra(); + } #endif // FEATURE_AUXILIARY_R2R_MODULES void RegisterResumptionStub(PCODE stubEntryPoint); diff --git a/src/tests/readytorun/wasm/DelayedR2RActivation/DelayedR2RActivation.cs b/src/tests/readytorun/wasm/DelayedR2RActivation/DelayedR2RActivation.cs index eb83909fe6fc12..c6b50a8c44f5ea 100644 --- a/src/tests/readytorun/wasm/DelayedR2RActivation/DelayedR2RActivation.cs +++ b/src/tests/readytorun/wasm/DelayedR2RActivation/DelayedR2RActivation.cs @@ -27,13 +27,19 @@ public class DelayedR2RActivation private static readonly int PortableEntryPointFlagsOffset = 3 * IntPtr.Size; private const int PrefersInterpreterEntryPoint = 0x4; private const int PendingAuxiliaryModule = 0x10; + private const int AwaitingAuxiliaryEntryPoints = 0x20; - // A delayed method waits for the auxiliary module that holds its code (pending auxiliary module). The wait is - // simulated by counting calls to the waiting methods. + // A delayed method waits twice: first for the auxiliary EntryPoints module that says where its code is + // (awaiting EntryPoints), then for the auxiliary module that holds the code (pending auxiliary module). + // Each wait is simulated by counting calls to the waiting methods. private static readonly int s_activationCallCount = ReadCallCount("DOTNET_R2RDelayedActivationCallCount", 0); + private static readonly int s_entryPointsCallCount = + ReadCallCount("DOTNET_R2RDelayedEntryPointsActivationCallCount", s_activationCallCount); + private static readonly bool s_entryPointsDelayed = s_entryPointsCallCount >= 1000; private static readonly bool s_moduleDelayed = s_activationCallCount >= 1000; // Counts between 1 and 999 can run out while the test sets up, so only check states for 0 or at least 1000. - private static readonly bool s_checkStates = s_activationCallCount == 0 || s_moduleDelayed; + private static readonly bool s_checkStates = + (s_entryPointsCallCount == 0 || s_entryPointsDelayed) && (s_activationCallCount == 0 || s_moduleDelayed); private static Action s_atRecursiveBottom; [Fact] @@ -70,13 +76,17 @@ public static async Task ActivatesCompositeImageAsync() if (s_checkStates) { - bool expectPending = s_moduleDelayed; + // Methods wait for their EntryPoints first. With EntryPoints available, they wait for their module. + bool expectAwaiting = s_entryPointsDelayed; + bool expectPending = !s_entryPointsDelayed && s_moduleDelayed; for (int index = 0; index < entryPoints.Length; index++) { - Assert.True(expectPending == PrefersInterpreter(entryPoints[index]), $"PrefersInterpreter, entryPoints[{index}]"); + Assert.True((expectAwaiting || expectPending) == PrefersInterpreter(entryPoints[index]), $"PrefersInterpreter, entryPoints[{index}]"); + Assert.True(expectAwaiting == IsAwaitingAuxiliaryEntryPoints(entryPoints[index]), $"IsAwaitingAuxiliaryEntryPoints, entryPoints[{index}]"); Assert.True(expectPending == IsPendingAuxiliaryModule(entryPoints[index]), $"IsPendingAuxiliaryModule, entryPoints[{index}]"); } - Assert.Equal(expectPending, untouchedWasDelayed); + Assert.Equal(expectAwaiting || expectPending, untouchedWasDelayed); + Assert.True(expectAwaiting == IsAwaitingAuxiliaryEntryPoints(cctorEntryPoint), "IsAwaitingAuxiliaryEntryPoints, class constructor"); Assert.True(expectPending == IsPendingAuxiliaryModule(cctorEntryPoint), "IsPendingAuxiliaryModule, class constructor"); } @@ -98,7 +108,7 @@ public static async Task ActivatesCompositeImageAsync() long[] counts = new long[3]; Task suspended = RepeatedlySuspendedTargetAsync(pending, value, counts); - if (s_checkStates && s_moduleDelayed) + if (s_checkStates && (s_entryPointsDelayed || s_moduleDelayed)) { // Both continuations must still be interpreted so they resume after their methods are published. Assert.True(IsDelayed(suspendedEntryPoint)); @@ -109,6 +119,21 @@ public static async Task ActivatesCompositeImageAsync() { s_atRecursiveBottom = null; int invocation = 0; + for (int entryPointsInvocation = 0; + entryPointsInvocation < s_entryPointsCallCount && IsAwaitingAuxiliaryEntryPoints(entryPoints[0]); + entryPointsInvocation++, invocation++) + { + Assert.Equal(invocation * 7 + 13, DirectTarget(invocation)); + } + + if (s_checkStates && s_entryPointsDelayed) + { + // The EntryPoints were activated, and the retried lookup found DirectTarget in its auxiliary module. + Assert.False(IsAwaitingAuxiliaryEntryPoints(entryPoints[0]), "IsAwaitingAuxiliaryEntryPoints, DirectTarget after EntryPoints activation"); + Assert.Equal(s_moduleDelayed, IsPendingAuxiliaryModule(entryPoints[0])); + Assert.Equal(s_moduleDelayed, PrefersInterpreter(entryPoints[0])); + } + for (int moduleInvocation = 0; moduleInvocation < s_activationCallCount && IsPendingAuxiliaryModule(entryPoints[0]); moduleInvocation++, invocation++) @@ -116,7 +141,7 @@ public static async Task ActivatesCompositeImageAsync() Assert.Equal(invocation * 7 + 13, DirectTarget(invocation)); } - if (s_checkStates && s_moduleDelayed) + if (s_checkStates && (s_entryPointsDelayed || s_moduleDelayed)) { Assert.False(IsDelayed(entryPoints[0]), "IsDelayed, DirectTarget after activation"); Assert.False(PrefersInterpreter(entryPoints[0]), "PrefersInterpreter, DirectTarget after activation"); @@ -151,7 +176,7 @@ public static async Task ActivatesCompositeImageAsync() originalSource.SetResult(43); Assert.Same(value, await original); - // Not called until the wait is over, so its first call finds and publishes its R2R code. + // Not called until both waits are over, so its first call finds and publishes its R2R code. Assert.Equal(untouchedWasDelayed, IsDelayed(untouchedEntryPoint)); Assert.Equal(untouchedWasDelayed, PrefersInterpreter(untouchedEntryPoint)); Assert.Equal(36, UntouchedTarget(5)); @@ -181,8 +206,8 @@ public static async Task ActivatesCompositeImageAsync() // Implementation has already activated, so these calls only take the alias's redirect. Assert.Equal(invocation + 53, AliasCaller.CallDeclared(new AliasTarget(), invocation)); Assert.True(PrefersInterpreter(interpreterOnlyEntryPoint)); - // It has no R2R code, so there is nothing to wait for. - Assert.False(IsDelayed(interpreterOnlyEntryPoint), "IsDelayed, interpreter only"); + // A later image may still have code for it, so a miss keeps waiting for the next EntryPoints era. + Assert.True(IsAwaitingAuxiliaryEntryPoints(interpreterOnlyEntryPoint), "IsAwaitingAuxiliaryEntryPoints, interpreter only"); } } @@ -245,9 +270,18 @@ private static bool IsPendingAuxiliaryModule(IntPtr entryPoint) return (Marshal.ReadInt32(entryPoint, PortableEntryPointFlagsOffset) & PendingAuxiliaryModule) != 0; } - // True while the method waits for its auxiliary module. [BypassReadyToRun] - private static bool IsDelayed(IntPtr entryPoint) => IsPendingAuxiliaryModule(entryPoint); + private static bool IsAwaitingAuxiliaryEntryPoints(IntPtr entryPoint) + { + return (Marshal.ReadInt32(entryPoint, PortableEntryPointFlagsOffset) & AwaitingAuxiliaryEntryPoints) != 0; + } + + // True while the method waits for either its EntryPoints or its auxiliary module. + [BypassReadyToRun] + private static bool IsDelayed(IntPtr entryPoint) + { + return (Marshal.ReadInt32(entryPoint, PortableEntryPointFlagsOffset) & (PendingAuxiliaryModule | AwaitingAuxiliaryEntryPoints)) != 0; + } [BypassReadyToRun] private static bool PrefersInterpreter(IntPtr entryPoint) diff --git a/src/tests/readytorun/wasm/DelayedR2RActivation/DelayedR2RActivation_DelayedEntryPointsOnly.csproj b/src/tests/readytorun/wasm/DelayedR2RActivation/DelayedR2RActivation_DelayedEntryPointsOnly.csproj new file mode 100644 index 00000000000000..3ef2fda84082bf --- /dev/null +++ b/src/tests/readytorun/wasm/DelayedR2RActivation/DelayedR2RActivation_DelayedEntryPointsOnly.csproj @@ -0,0 +1,19 @@ + + + true + $(Features);runtime-async=on + true + true + true + true + $(CrossGen2TestExtraArguments) --compilebubblegenerics --opt-cross-module System.Private.CoreLib + true + + + + + + + + + diff --git a/src/tests/readytorun/wasm/DelayedR2RActivation/DelayedR2RActivation_DelayedModuleOnly.csproj b/src/tests/readytorun/wasm/DelayedR2RActivation/DelayedR2RActivation_DelayedModuleOnly.csproj new file mode 100644 index 00000000000000..5519d9f4e8b1ac --- /dev/null +++ b/src/tests/readytorun/wasm/DelayedR2RActivation/DelayedR2RActivation_DelayedModuleOnly.csproj @@ -0,0 +1,19 @@ + + + true + $(Features);runtime-async=on + true + true + true + true + $(CrossGen2TestExtraArguments) --compilebubblegenerics --opt-cross-module System.Private.CoreLib + true + + + + + + + + + From 984183fbb250c31a37fae61ea68fdbf67fd44cb9 Mon Sep 17 00:00:00 2001 From: Jackson Schuster <36744439+jtschuster@users.noreply.github.com> Date: Mon, 26 Oct 2026 22:52:44 -0700 Subject: [PATCH 11/19] TEMPORARY: fix crossgen2 build break in NewObjectFixupSignature Same change as dotnet/runtime#136497. Revert this commit once that fix is in the branch's base. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 9444d653-1735-42ee-a4a7-119174358f8d --- .../ReadyToRun/NewObjectFixupSignature.cs | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/src/coreclr/tools/aot/ILCompiler.ReadyToRun/Compiler/DependencyAnalysis/ReadyToRun/NewObjectFixupSignature.cs b/src/coreclr/tools/aot/ILCompiler.ReadyToRun/Compiler/DependencyAnalysis/ReadyToRun/NewObjectFixupSignature.cs index 9e85586fa3f7c2..3a5376d5faf26f 100644 --- a/src/coreclr/tools/aot/ILCompiler.ReadyToRun/Compiler/DependencyAnalysis/ReadyToRun/NewObjectFixupSignature.cs +++ b/src/coreclr/tools/aot/ILCompiler.ReadyToRun/Compiler/DependencyAnalysis/ReadyToRun/NewObjectFixupSignature.cs @@ -51,13 +51,10 @@ public override int CompareToImpl(ISortableNode other, CompilerComparer comparer return comparer.Compare(_typeDesc, otherNode._typeDesc); } - protected override DependencyList ComputeNonRelocationBasedDependencies(NodeFactory factory) + protected override void ComputeNonRelocationBasedDependencies(DependencySink sink, NodeFactory factory) { - DependencyList dependencies = base.ComputeNonRelocationBasedDependencies(factory); - - factory.AddVirtualMethodDiscoveryDependencies(ref dependencies, _typeDesc); - - return dependencies; + base.ComputeNonRelocationBasedDependencies(sink, factory); + factory.AddVirtualMethodDiscoveryDependencies(sink, _typeDesc); } } } From ac365f6c9b12093a6abb74549696a50000d24a58 Mon Sep 17 00:00:00 2001 From: Jackson Schuster <36744439+jtschuster@users.noreply.github.com> Date: Mon, 26 Oct 2026 22:38:11 -0700 Subject: [PATCH 12/19] TEMPORARY: skip module-less composite ReadyToRunInfo in GetEntryPoint A composite image's components register the composite's own ReadyToRunInfo, which has no module, on the unrelated R2R modules list. Looking it up decodes signatures against a NULL module and crashes with "function signature mismatch" on browser-wasm. Skip it until the registration is fixed. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 9444d653-1735-42ee-a4a7-119174358f8d --- src/coreclr/vm/readytoruninfo.cpp | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/coreclr/vm/readytoruninfo.cpp b/src/coreclr/vm/readytoruninfo.cpp index f33312ba0052c8..533279dc653a12 100644 --- a/src/coreclr/vm/readytoruninfo.cpp +++ b/src/coreclr/vm/readytoruninfo.cpp @@ -1350,6 +1350,11 @@ PCODE ReadyToRunInfo::GetEntryPoint(MethodDesc * pMD, PrepareCodeConfig* pConfig // If R2R code is disabled for this module, simply behave as if it is never found if (ReadyToRunCodeDisabled()) goto done; + // TODO: Temporary workaround for a bug on main. A composite image's components register the composite's own + // ReadyToRunInfo, which has no module, on the unrelated R2R modules list. Looking it up decodes signatures + // against a NULL module and crashes. Skip it until the registration is fixed. + if (m_pModule == NULL) + goto done; // Return-dropping async thunks are VM-synthesized. They share the same metadata // token and signature shape as a regular async variant, so the R2R lookup below From b19742f1a714ae932fef5c5868b15023893afdfb Mon Sep 17 00:00:00 2001 From: Jackson Schuster <36744439+jtschuster@users.noreply.github.com> Date: Tue, 27 Oct 2026 11:32:00 -0700 Subject: [PATCH 13/19] Revert "TEMPORARY: fix crossgen2 build break in NewObjectFixupSignature" The fix landed in main as dotnet/runtime#136497. This reverts commit 984183fbb25. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 9444d653-1735-42ee-a4a7-119174358f8d --- .../ReadyToRun/NewObjectFixupSignature.cs | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/coreclr/tools/aot/ILCompiler.ReadyToRun/Compiler/DependencyAnalysis/ReadyToRun/NewObjectFixupSignature.cs b/src/coreclr/tools/aot/ILCompiler.ReadyToRun/Compiler/DependencyAnalysis/ReadyToRun/NewObjectFixupSignature.cs index 3a5376d5faf26f..9e85586fa3f7c2 100644 --- a/src/coreclr/tools/aot/ILCompiler.ReadyToRun/Compiler/DependencyAnalysis/ReadyToRun/NewObjectFixupSignature.cs +++ b/src/coreclr/tools/aot/ILCompiler.ReadyToRun/Compiler/DependencyAnalysis/ReadyToRun/NewObjectFixupSignature.cs @@ -51,10 +51,13 @@ public override int CompareToImpl(ISortableNode other, CompilerComparer comparer return comparer.Compare(_typeDesc, otherNode._typeDesc); } - protected override void ComputeNonRelocationBasedDependencies(DependencySink sink, NodeFactory factory) + protected override DependencyList ComputeNonRelocationBasedDependencies(NodeFactory factory) { - base.ComputeNonRelocationBasedDependencies(sink, factory); - factory.AddVirtualMethodDiscoveryDependencies(sink, _typeDesc); + DependencyList dependencies = base.ComputeNonRelocationBasedDependencies(factory); + + factory.AddVirtualMethodDiscoveryDependencies(ref dependencies, _typeDesc); + + return dependencies; } } } From 62d7975471bae12d8f1137646bccec1df3bac9ca Mon Sep 17 00:00:00 2001 From: Jackson Schuster <36744439+jtschuster@users.noreply.github.com> Date: Tue, 27 Oct 2026 13:09:12 -0700 Subject: [PATCH 14/19] Implement partitioned Browser-WASM ReadyToRun auxiliary modules Emit physical entrypoint and cold-code modules with deferred metadata ownership, activate them independently through CoreCLR and browser hosting, and integrate opt-in publishing and compression. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 0797c2c0-9a42-4488-8454-7761c25a6e19 --- .../features/partitioned-wasm-r2r-modules.md | 36 +- eng/native.wasm.targets | 1 + src/coreclr/hosts/corerun/wasm/libCorerun.js | 148 ++++- src/coreclr/inc/readytorun.h | 27 +- .../nativeaot/Runtime/inc/ModuleHeaders.h | 2 +- .../Compiler/ObjectWriter/ObjectWriter.cs | 10 +- .../ObjectWriter/WasmSymbolManager.cs | 1 + .../WebCilObjectWriter.Auxiliary.cs | 581 ++++++++++++++++++ .../ObjectWriter/WebCilObjectWriter.cs | 58 +- .../Common/Internal/Runtime/ModuleHeaders.cs | 5 +- .../TestCases/R2RTestSuites.cs | 24 + .../TestCases/Webcil/WasmWebcilModule.cs | 18 + .../TestCasesRunner/WasmR2RAssert.cs | 56 +- .../CodeGen/ReadyToRunObjectWriter.cs | 15 + .../ReadyToRun/AuxiliaryModuleMetadataNode.cs | 55 ++ .../ExceptionInfoLookupTableNode.cs | 6 +- .../ReadyToRun/ImportSectionNode.cs | 1 + .../ReadyToRun/InstanceEntryPointTableNode.cs | 16 +- .../ReadyToRun/MethodEntryPointTableNode.cs | 19 +- .../ReadyToRun/MethodGCInfoNode.cs | 7 +- .../ReadyToRun/MethodWithGCInfo.cs | 5 + .../ReadyToRun/ReadyToRunHeaderNode.cs | 2 +- .../ReadyToRun/RuntimeFunctionsGCInfoNode.cs | 1 + .../ReadyToRunCodegenNodeFactory.cs | 32 + .../Compiler/ReadyToRunCodegenCompilation.cs | 16 + .../ILCompiler.ReadyToRun.csproj | 2 + .../aot/crossgen2/Crossgen2RootCommand.cs | 3 + src/coreclr/tools/aot/crossgen2/Program.cs | 14 + .../aot/crossgen2/Properties/Resources.resx | 9 + src/coreclr/tools/aot/crossgen2/README.md | 51 ++ src/coreclr/vm/codeman.cpp | 52 +- src/coreclr/vm/interpexec.h | 2 - src/coreclr/vm/method.hpp | 1 - src/coreclr/vm/readytoruninfo.cpp | 337 +++++++--- src/coreclr/vm/readytoruninfo.h | 64 +- src/coreclr/vm/wasm/helpers.cpp | 7 + src/mono/browser/build/README.md | 23 + ...ET.Sdk.WebAssembly.Browser.CoreCLR.targets | 37 ++ ...rosoft.NET.Sdk.WebAssembly.Browser.targets | 59 +- src/mono/sample/wasm/Directory.Build.props | 1 + src/mono/sample/wasm/Directory.Build.targets | 1 + .../wasm/Wasm.Build.Tests/ReadyToRunTests.cs | 47 +- .../WebcilInWasmSizesTests.cs | 107 +++- .../libs/Common/JavaScript/CMakeLists.txt | 1 + .../libs/Common/JavaScript/host/assets.ts | 30 +- .../host/auxiliary-modules.test.mjs | 311 ++++++++++ .../JavaScript/host/auxiliary-modules.ts | 105 ++++ .../libs/Common/JavaScript/loader/assets.ts | 20 +- .../libs/Common/JavaScript/loader/config.ts | 2 + .../libs/Common/JavaScript/loader/dotnet.d.ts | 3 + .../Common/JavaScript/types/public-api.ts | 1 + src/native/package.json | 1 + .../AttachWebcilSizes.cs | 4 +- .../BootJsonBuilderHelper.cs | 2 + .../BootJsonData.cs | 6 + .../CollectWasmAuxiliaryR2RModules.cs | 87 +++ .../GenerateWasmBootJson.cs | 9 +- .../DelayedR2RActivation.cs | 200 ++++++ .../PartitionedR2RActivation_AllCold.csproj | 16 + .../PartitionedR2RActivation_AllHot.csproj | 16 + .../PartitionedR2RActivation_Eager.csproj | 18 + ...ionedR2RActivation_EntryPointsFirst.csproj | 19 + ...artitionedR2RActivation_ModuleFirst.csproj | 20 + 63 files changed, 2661 insertions(+), 169 deletions(-) create mode 100644 src/coreclr/tools/Common/Compiler/ObjectWriter/WebCilObjectWriter.Auxiliary.cs create mode 100644 src/coreclr/tools/aot/ILCompiler.ReadyToRun/Compiler/DependencyAnalysis/ReadyToRun/AuxiliaryModuleMetadataNode.cs create mode 100644 src/coreclr/tools/aot/crossgen2/README.md create mode 100644 src/native/libs/Common/JavaScript/host/auxiliary-modules.test.mjs create mode 100644 src/native/libs/Common/JavaScript/host/auxiliary-modules.ts create mode 100644 src/tasks/Microsoft.NET.Sdk.WebAssembly.Pack.Tasks/CollectWasmAuxiliaryR2RModules.cs create mode 100644 src/tests/readytorun/wasm/DelayedR2RActivation/PartitionedR2RActivation_AllCold.csproj create mode 100644 src/tests/readytorun/wasm/DelayedR2RActivation/PartitionedR2RActivation_AllHot.csproj create mode 100644 src/tests/readytorun/wasm/DelayedR2RActivation/PartitionedR2RActivation_Eager.csproj create mode 100644 src/tests/readytorun/wasm/DelayedR2RActivation/PartitionedR2RActivation_EntryPointsFirst.csproj create mode 100644 src/tests/readytorun/wasm/DelayedR2RActivation/PartitionedR2RActivation_ModuleFirst.csproj diff --git a/docs/design/features/partitioned-wasm-r2r-modules.md b/docs/design/features/partitioned-wasm-r2r-modules.md index b07a409e1f3ce9..86685c029ff0b3 100644 --- a/docs/design/features/partitioned-wasm-r2r-modules.md +++ b/docs/design/features/partitioned-wasm-r2r-modules.md @@ -14,6 +14,26 @@ The mapping for which methods have code in the auxiliary modules is in a separat Metadata and activation info about auxiliary modules is stored in the AuxiliaryModuleMetadata section in the primary module. +### Implemented V1 + +Phases 1 through 4 are implemented for single-threaded CoreCLR Browser-WASM composite images with retained IL. The feature is opt-in: + +```shell +dotnet publish -p:PublishReadyToRun=true -p:PublishReadyToRunComposite=true -p:WasmEnableAuxiliaryR2RModules=true +``` + +Publishing requires matching locally built runtime and WebAssembly SDK packs. Crossgen2 exposes the corresponding `--wasm-auxiliary-modules` option. Non-browser, non-composite, multithreaded, and stripped-IL configurations are rejected. + +The compiler emits a primary module, an `.entrypoints.wasm` mapping module, and a `.1.wasm` cold-code module, plus an `.auxiliary.json` publication manifest. Helpers and public non-generic methods are hot; methods that cannot use interpreter fallback are also kept primary. Direct managed-call optimization is disabled in this mode. Shared fallback thunks and eagerly accessed data remain primary. + +The cold-code module owns its methods and funclets, runtime-function records, GC/unwind and exception-handling data, fixup lists, and exclusively cold import cells and signatures. The mapping module owns auxiliary entrypoint tables and their method-identification signatures. Shared imports, async-resume data rebased during image registration, and the final runtime-function/unwind record needed to reserve the virtual-IP range remain primary. Activation never overwrites those resident data. + +Both sidecars import the primary module's memory, table, bases, stack pointer, exception tag, and async global. Their data and element segments are passive. `ActivateModule()` installs those segments and writes its activation cell last; repeated activation is a no-op. Mapping activation also notifies the runtime to advance the global EntryPoints era. The browser host starts sidecar loading without adding it to the startup barrier. + +The existing five `DelayedR2RActivation` test variants retain the phase-1 simulation. Five `PartitionedR2RActivation` variants exercise real sidecars, eager loading, both manually controlled activation orders, and all-hot/all-cold partition boundaries. Coverage includes GC, exception handlers, generics from another image, delegates, reflection, managed and unmanaged function pointers, and suspended interpreter continuations. Zero-length partitions are valid; their empty sidecars preserve the same activation contract and contiguous runtime-function ranges. + +See the [compiler usage and restrictions](../../../src/coreclr/tools/aot/crossgen2/README.md) and [browser build integration](../../../src/mono/browser/build/README.md) for the emitted-file contract and supported diagnostic options. + ## 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. Checking for activation of the EntryPoints module and the auxiliary module should be as cheap as possible. In this design, it is a simple flag check in the WASM memory. @@ -268,9 +288,19 @@ Following the header is the Auxiliary Module Metadata table. The table is an arr The `MethodDefEntryPointsInfoRVA` points to data identical to the format of the `ComponentAssemblies` section. Once filled in by the Auxiliary EntryPoints Module, the `CorHeader` will point to the same location in the assembly as the entry in the `ComponentAssemblies` section (right now crossgen2 emits these as zeros and relies on correlated order with the `ManifestMetadata` assembly names - this ordering must be preserved). The `MethodDefEntryPointsRVA` will point to the auxiliary `MethodDefEntryPoints` section for that assembly. All this data would reside within the Auxiliary Module Metadata section. -### Alternative data format +### Implemented alternative data format + +V1 uses separate sections for the image-wide `AuxiliaryInstanceMethodEntryPoints` and per-assembly `AuxiliaryMethodDefEntryPoints`, rather than the two entrypoint-directory fields described above. ReadyToRun format version 33 identifies this layout, so older runtimes do not search an incomplete runtime-function table. + +| Section | Value | Scope | +|---|---|---| +| `AuxiliaryModuleMetadata` | 128 | Image-wide | +| `AuxiliaryMethodDefEntryPoints` | 129 | Per component assembly | +| `AuxiliaryInstanceMethodEntryPoints` | 130 | Image-wide | + +The metadata header consists of four 32-bit fields, in order: `EntryPointsModuleActivationStatus`, `PrimaryModuleMethodCount`, `AuxiliaryModuleCount`, and `EntryPointsFilenameRVA`. It is followed by `AuxiliaryModuleCount` entries, each containing four 32-bit fields: `FilenameRVA`, `RelativeTableBase`, `MethodCount`, and `ActivationStatus`. Filename RVAs are relative to the primary WebCIL payload and identify sibling basenames. Real activation cells are `0` while pending and `1` when active. The primary export `getAuxiliaryModuleMetadata()` returns the metadata's address in shared memory. -Instead of putting bookkeeping for the Auxiliary EntryPoints Module inside the Auxiliary Module Metadata section, new sections could be created for the image-wide `AuxiliaryInstanceMethodEntryPoints` section and per-assembly auxiliary `AuxiliaryMethodDefEntryPoints` sections. The AuxiliaryModuleMetadata header could remove the `InstanceMethodEntryPointsRVA` and `MethodDefEntryPointsInfoRVA` fields. +Virtual-IP lookup searches only active contiguous ranges and checks a function's own unwind data for its end address. Funclet enumeration is bounded by the owning module's runtime-function range, rather than reading unloaded entries in the next range. ### Index relationships @@ -360,7 +390,7 @@ Using either call-counting in the interpreter or a timer in the host JavaScript The prototype simulates both phases of the flow above and is compiled with `FEATURE_AUXILIARY_R2R_MODULES`, enabled by default on single-threaded Browser-WASM builds with `FEATURE_INTERPRETER`. It can be excluded with `-cmakeargs "-DFEATURE_AUXILIARY_R2R_MODULES=0"`. This prototype does not split modules, download code, or change image formats. -**Simulated downloads.** Each simulated download is a countdown that saturates at zero, and zero means activated. The "activation flag" cell therefore counts down, rather than being nonzero once activated as in the real design. A call-boundary check of a method waiting on the countdown consumes one count. Preparing a method, resuming an existing interpreter continuation, and executing an exception clause do not. +**Simulated downloads.** Each simulated download consumes a configured number of call-boundary checks. The simulated EntryPoints countdown saturates at zero. The simulated body-module cell encodes the remaining count plus one and saturates at one, keeping its ready value consistent with real activation cells while distinguishing countdowns from a real pending cell (`0`). Preparing a method, resuming an existing interpreter continuation, and executing an exception clause do not consume a count. - `DOTNET_R2RDelayedActivationCallCount` simulates the auxiliary module download. Each composite image has its own countdown, shared by all its component assemblies and by every method waiting on that image. It defaults to `0`, marking auxiliary code ready immediately and preserving eager R2R resolution. Setting it to `3e8` (hexadecimal for 1,000) marks the image ready after 1,000 checks. - `DOTNET_R2RDelayedEntryPointsActivationCallCount` simulates the Auxiliary EntryPoints Module download. A single countdown stands in for every composite image, and counts calls to methods awaiting EntryPoints. It defaults to `DOTNET_R2RDelayedActivationCallCount` when not specified. When it reaches zero, the auxiliary EntryPoints era advances. diff --git a/eng/native.wasm.targets b/eng/native.wasm.targets index 50b8b62332d981..62480ab8ddb277 100644 --- a/eng/native.wasm.targets +++ b/eng/native.wasm.targets @@ -137,6 +137,7 @@ + diff --git a/src/coreclr/hosts/corerun/wasm/libCorerun.js b/src/coreclr/hosts/corerun/wasm/libCorerun.js index c2d8598af73c9a..4df60c79ee820d 100644 --- a/src/coreclr/hosts/corerun/wasm/libCorerun.js +++ b/src/coreclr/hosts/corerun/wasm/libCorerun.js @@ -20,6 +20,145 @@ function libCoreRunFactory() { } const mergeCoreRun = { $CORERUN: { + auxiliaryModuleLoads: [], + waitForAuxiliaryModules: async () => { + await Promise.resolve(); + await Promise.all(CORERUN.auxiliaryModuleLoads); + }, + installAuxiliaryModuleGates: () => { + if (ENV["DOTNET_WasmAuxiliaryManualActivation"] !== "1") return; + const releases = {}; + CORERUN.auxiliaryModuleGates = {}; + CORERUN.auxiliaryModuleCompletions = { ENTRYPOINTS: [], MODULES: [] }; + const requested = new Set(); + let preceding = Promise.resolve(); + for (const stage of ["ENTRYPOINTS", "MODULES"]) { + CORERUN.auxiliaryModuleGates[stage] = new Promise(resolve => releases[stage] = resolve); + } + const observeLine = message => { + for (const stage of ["ENTRYPOINTS", "MODULES"]) { + if (message.trim() === "AUXILIARY_R2R_ACTIVATE_" + stage && !requested.has(stage)) { + requested.add(stage); + const previous = preceding; + preceding = Promise.all(CORERUN.auxiliaryModuleCompletions[stage]); + // Console.WriteLine may still have native frames on the stack. + setTimeout(() => releases[stage](previous), 0); + } + } + }; + const originalOut = out; + out = message => { + originalOut(message); + observeLine(message); + }; + const installWriteObserver = () => { + const write = FS.write; + const decoder = new TextDecoder(); + let pending = ""; + FS.write = (stream, buffer, offset, length, ...args) => { + const written = write.call(FS, stream, buffer, offset, length, ...args); + if (stream.fd === 1) { + const lines = (pending + decoder.decode(buffer.subarray(offset, offset + written), { stream: true })).split("\n"); + pending = lines.pop(); + lines.forEach(observeLine); + } + return written; + }; + }; + // NODERAWFS bypasses out, and replaces FS.write after library postsets run. + const preRun = Module["preRun"]; + Module["preRun"] = typeof preRun === "function" + ? [preRun, installWriteObserver] + : [...preRun || [], installWriteObserver]; + }, + queueAuxiliaryModules: (instance, imports, payloadPtr, payloadSize, tableSize, wasmPath) => { + if (instance.exports.getAuxiliaryModuleMetadata === undefined) return; + if (typeof instance.exports.getAuxiliaryModuleMetadata !== "function") throw new Error("Invalid getAuxiliaryModuleMetadata export"); + const address = instance.exports.getAuxiliaryModuleMetadata() >>> 0; + if (address === 0) return; + const end = payloadPtr + payloadSize; + const heap = new Uint8Array(wasmMemory.buffer); + if (end > heap.length || address < payloadPtr || address % 4 !== 0 || address + 16 > end) { + throw new Error("Auxiliary R2R module metadata is outside the primary payload"); + } + const view = new DataView(wasmMemory.buffer); + const count = view.getUint32(address + 8, true); + if (count > (end - address - 16) / 16) throw new Error("Truncated auxiliary R2R module metadata"); + const modules = []; + const names = new Set(); + const addModule = (filenameRva, statusAddress, entrypoints) => { + const start = payloadPtr + filenameRva; + if (view.getUint32(statusAddress, true) > 1 || start >= end) throw new Error("Invalid auxiliary R2R module metadata"); + let filenameEnd = start; + while (filenameEnd < end && heap[filenameEnd] !== 0) filenameEnd++; + if (filenameEnd === end) throw new Error("Unterminated auxiliary R2R module filename"); + const name = new TextDecoder("utf-8", { fatal: true }).decode(heap.subarray(start, filenameEnd)); + if (!/^[^./\\][^/\\?#:]*\.wasm$/.test(name) || names.has(name)) throw new Error(`Invalid auxiliary R2R module filename '${name}'`); + names.add(name); + modules.push({ name, statusAddress, entrypoints }); + }; + addModule(view.getUint32(address + 12, true), address, true); + let nextTableIndex = view.getUint32(address + 4, true); + if (nextTableIndex > tableSize) throw new Error("Invalid primary R2R module table range"); + for (let i = 0; i < count; i++) { + const entry = address + 16 + i * 16; + const tableBase = view.getUint32(entry + 4, true); + const methodCount = view.getUint32(entry + 8, true); + if (tableBase !== nextTableIndex || tableBase + methodCount > tableSize) { + throw new Error("Invalid auxiliary R2R module table range"); + } + nextTableIndex += methodCount; + addModule(view.getUint32(entry, true), entry + 12, false); + } + // Native registration finishes after the synchronous probe returns. Never activate in the probe. + const directory = wasmPath.substring(0, wasmPath.lastIndexOf("/") + 1); + const operations = modules.map(module => Promise.resolve().then(async () => { + try { + if (new DataView(wasmMemory.buffer).getUint32(module.statusAddress, true) === 1) return; + if (CORERUN.auxiliaryModuleGates) { + await CORERUN.auxiliaryModuleGates[module.entrypoints ? "ENTRYPOINTS" : "MODULES"]; + } + const delayName = module.entrypoints + ? "DOTNET_WasmAuxiliaryEntryPointsDelayMilliseconds" + : "DOTNET_WasmAuxiliaryModuleDelayMilliseconds"; + const delay = Number(ENV[delayName] || 0); + if (!Number.isInteger(delay) || delay < 0 || delay > 2147483647) { + throw new Error(`Invalid ${delayName}: ${ENV[delayName]}`); + } + if (delay !== 0) await new Promise(resolve => setTimeout(resolve, delay)); + const bytes = FS.readFile(directory + module.name); + const { instance } = await WebAssembly.instantiate(bytes, imports); + if (typeof instance.exports.ActivateModule !== "function") throw new Error("Missing ActivateModule export"); + if (new DataView(wasmMemory.buffer).getUint32(module.statusAddress, true) === 0) { + instance.exports.ActivateModule(); + if (new DataView(wasmMemory.buffer).getUint32(module.statusAddress, true) !== 1) { + throw new Error("ActivateModule did not publish its activation status"); + } + if (module.entrypoints) { + if (typeof wasmExports.dotnet_wasm_notify_auxiliary_entrypoints_activated !== "function") { + throw new Error("Runtime does not support auxiliary R2R modules"); + } + wasmExports.dotnet_wasm_notify_auxiliary_entrypoints_activated(); + } + } + } catch (reason) { + const error = new Error(`Failed to load auxiliary R2R module '${module.name}': ${reason instanceof Error ? reason.message : String(reason)}`); + err(error.message); + return error; + } + })); + if (CORERUN.auxiliaryModuleCompletions) { + for (let i = 0; i < modules.length; i++) { + CORERUN.auxiliaryModuleCompletions[modules[i].entrypoints ? "ENTRYPOINTS" : "MODULES"].push(operations[i]); + } + } + const completion = Promise.all(operations).then(errors => { + const error = errors.find(error => error !== undefined); + if (error) throw error; + }); + CORERUN.auxiliaryModuleLoads.push(completion); + void completion.catch(() => {}); + }, selfInitialize: () => { const browserVirtualAppBase = "/";// keep in sync other places that define browserVirtualAppBase FS.createPath("/", browserVirtualAppBase, true, true); @@ -32,6 +171,8 @@ function libCoreRunFactory() { } ENV["DOTNET_SYSTEM_GLOBALIZATION_INVARIANT"] = "true"; + Module["waitForAuxiliaryModules"] = CORERUN.waitForAuxiliaryModules; + CORERUN.installAuxiliaryModuleGates(); if (ENVIRONMENT_IS_NODE) { const original_proc_exit = _proc_exit; @@ -226,6 +367,7 @@ function libCoreRunFactory() { var payloadPtr = 0; var wasmInstance; + var imports; const sp = stackSave(); try { const ptrPtr = stackAlloc(4); @@ -242,7 +384,7 @@ function libCoreRunFactory() { throw new Error("__async_continuation was not preserved by the linker or optimizer"); } payloadPtr = HEAPU32[ptrPtr >>> 2 >>> 0]; - wasmInstance = new WebAssembly.Instance(wasmModule, { + imports = { webcil: { memory: wasmMemory, __stack_pointer: wasmExports.__stack_pointer, @@ -253,7 +395,8 @@ function libCoreRunFactory() { // Runtime-async continuation return value, shared with the runtime module. __async_continuation: wasmExports.__async_continuation } - }); + }; + wasmInstance = new WebAssembly.Instance(wasmModule, imports); } catch (e) { const errorMessage = e instanceof Error ? e.message : String(e); console.error("Failed to construct WebAssembly instance for Webcil image:", { wasmPath, errorMessage }); @@ -281,6 +424,7 @@ function libCoreRunFactory() { HEAPU32[outDataStartPtr >>> 2 >>> 0] = payloadPtr; HEAPU32[outSize >>> 2 >>> 0] = payloadSize; HEAPU32[(outSize + 4) >>> 2 >>> 0] = 0; + CORERUN.queueAuxiliaryModules(wasmInstance, imports, payloadPtr, payloadSize, tableSize, wasmPath); return true; } }; diff --git a/src/coreclr/inc/readytorun.h b/src/coreclr/inc/readytorun.h index 63ef3b699aae3a..edbe458a099cbd 100644 --- a/src/coreclr/inc/readytorun.h +++ b/src/coreclr/inc/readytorun.h @@ -19,7 +19,7 @@ // src/coreclr/nativeaot/Runtime/inc/ModuleHeaders.h // If you update this, ensure you run `git grep MINIMUM_READYTORUN_MAJOR_VERSION` // and handle pending work. -#define READYTORUN_MAJOR_VERSION 32 +#define READYTORUN_MAJOR_VERSION 33 #define READYTORUN_MINOR_VERSION 0x0000 #define MINIMUM_READYTORUN_MAJOR_VERSION 26 @@ -82,6 +82,7 @@ // method's ReadyToRun entry point and fixups. // R2R Version 31.1 adds READYTORUN_FLAG_SINGLE_THREADED, which records that the image lays out [ThreadStatic] fields as regular statics // for a runtime built without FEATURE_MULTITHREADING. +// R2R Version 33 adds partitioned WebAssembly images with auxiliary entrypoints and partially populated runtime-function tables. struct READYTORUN_CORE_HEADER { @@ -156,6 +157,9 @@ enum class ReadyToRunSectionType : uint32_t ProxyTypeMaps = 125, // Added in V18.3 TypeMapAssemblyTargets = 126, // Added in V18.3 WasmAsyncResumeInfo = 127, // Added in V29 + AuxiliaryModuleMetadata = 128, + AuxiliaryMethodDefEntryPoints = 129, + AuxiliaryInstanceMethodEntryPoints = 130, // If you add a new section consider whether it is a breaking or non-breaking change. // Usually it is non-breaking, but if it is preferable to have older runtimes fail @@ -170,6 +174,27 @@ struct READYTORUN_SECTION IMAGE_DATA_DIRECTORY Section; }; +struct READYTORUN_AUXILIARY_MODULE_METADATA +{ + DWORD EntryPointsModuleActivationStatus; + DWORD PrimaryModuleMethodCount; + DWORD AuxiliaryModuleCount; + DWORD EntryPointsFilenameRVA; +}; + +struct READYTORUN_AUXILIARY_MODULE +{ + DWORD FilenameRVA; + DWORD RelativeTableBase; + DWORD MethodCount; + DWORD ActivationStatus; +}; + +static_assert(sizeof(READYTORUN_AUXILIARY_MODULE_METADATA) == 16); +static_assert(sizeof(READYTORUN_AUXILIARY_MODULE) == 16); +static_assert(offsetof(READYTORUN_AUXILIARY_MODULE_METADATA, EntryPointsModuleActivationStatus) == 0); +static_assert(offsetof(READYTORUN_AUXILIARY_MODULE, ActivationStatus) == 12); + enum class ReadyToRunImportSectionType : uint8_t { Unknown = 0, diff --git a/src/coreclr/nativeaot/Runtime/inc/ModuleHeaders.h b/src/coreclr/nativeaot/Runtime/inc/ModuleHeaders.h index b3d4c5732b4a30..e49a82c3c62a36 100644 --- a/src/coreclr/nativeaot/Runtime/inc/ModuleHeaders.h +++ b/src/coreclr/nativeaot/Runtime/inc/ModuleHeaders.h @@ -11,7 +11,7 @@ struct ReadyToRunHeaderConstants { static const uint32_t Signature = 0x00525452; // 'RTR' - static const uint32_t CurrentMajorVersion = 32; + static const uint32_t CurrentMajorVersion = 33; static const uint32_t CurrentMinorVersion = 0; }; diff --git a/src/coreclr/tools/Common/Compiler/ObjectWriter/ObjectWriter.cs b/src/coreclr/tools/Common/Compiler/ObjectWriter/ObjectWriter.cs index b256dfa9769e2d..f6b9689f573e8c 100644 --- a/src/coreclr/tools/Common/Compiler/ObjectWriter/ObjectWriter.cs +++ b/src/coreclr/tools/Common/Compiler/ObjectWriter/ObjectWriter.cs @@ -441,10 +441,12 @@ public virtual void EmitObject(Stream outputFileStream, IReadOnlyCollection _entries.TryGetValue(name, out Entry entry) ? entry : _aliases[name]; public int GetImportCount() => _importCounts.Values.Sum(); + public int GetImportCount(WasmIndexSpace indexSpace) => _importCounts[indexSpace]; public int GetDefinitionCount(WasmIndexSpace indexSpace) => _definitionCounts[indexSpace]; diff --git a/src/coreclr/tools/Common/Compiler/ObjectWriter/WebCilObjectWriter.Auxiliary.cs b/src/coreclr/tools/Common/Compiler/ObjectWriter/WebCilObjectWriter.Auxiliary.cs new file mode 100644 index 00000000000000..7f4c642e18f430 --- /dev/null +++ b/src/coreclr/tools/Common/Compiler/ObjectWriter/WebCilObjectWriter.Auxiliary.cs @@ -0,0 +1,581 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +#if READYTORUN +using System; +using System.Buffers.Binary; +using System.Collections.Generic; +using System.IO; +using System.Text; +using System.Text.Json; +using ILCompiler.DependencyAnalysis; +using ILCompiler.DependencyAnalysis.ReadyToRun; +using ILCompiler.DependencyAnalysis.Wasm; +using ILCompiler.DependencyAnalysisFramework; +using ILCompiler.ObjectWriter.WasmInstructions; +using Internal.Text; + +namespace ILCompiler.ObjectWriter; + +internal sealed partial class WebCilObjectWriter +{ + private IReadOnlyCollection _partitionNodes; + private int[] _physicalFunctionIndices; + private int[] _primaryFunctionIndices; + private int _activationSignature; + private List _auxiliaryDataRanges; + + private readonly struct AuxiliaryDataRange + { + public readonly int Offset; + public readonly int Length; + public readonly bool EntryPoints; + + public AuxiliaryDataRange(int offset, int length, bool entryPoints) + { + Offset = offset; + Length = length; + EntryPoints = entryPoints; + } + } + + public override void EmitObject(Stream stream, IReadOnlyCollection nodes, IObjectDumper dumper, Logger logger) + { + _partitionNodes = nodes; + base.EmitObject(stream, nodes, dumper, logger); + } + + private List GetAuxiliaryDataRanges() + { + List ranges = new(); + int primaryCount = 0; + int totalCount = 0; + MethodWithGCInfo lastMethod = null; + foreach (MethodWithGCInfo method in _nodeFactory.EnumerateCompiledMethods()) + { + if (!_nodeFactory.IsAuxiliaryMethod(method)) + primaryCount += method.FrameInfos.Length; + totalCount += method.FrameInfos.Length; + lastMethod = method; + } + + int finalUnwindOffset = lastMethod is null ? -1 : + lastMethod.GCInfoNode.CalculateFuncletOffsets(_nodeFactory)[lastMethod.FrameInfos.Length - 1]; + foreach (DependencyNode dependency in _partitionNodes) + { + if (dependency is not ObjectNode node || node is not ISymbolNode symbol || + !_definedSymbols.TryGetValue(GetMangledName(symbol), out SymbolDefinition definition) || + _sections[definition.SectionIndex] is not WebcilSection section) + continue; + + int offset = checked((int)section.Header.VirtualAddress + (int)definition.Value); + int fixupSize = node switch + { + MethodEntryPointTableNode { IsAuxiliary: true } table => table.Offset, + InstanceEntryPointTableNode { IsAuxiliary: true } table => table.Offset, + _ => -1, + }; + if (fixupSize >= 0) + { + Add(offset - fixupSize, fixupSize, entryPoints: false); + Add(offset, definition.Size, entryPoints: true); + } + else if (node is RuntimeFunctionsTableNode) + { + // The final function and its unwind data remain readable before any sidecar arrives. + Add(offset + primaryCount * 8, Math.Max(totalCount - primaryCount - 1, 0) * 8, entryPoints: false); + } + else if (node is RuntimeFunctionsGCInfoNode gcInfo) + { + foreach ((int start, int length) in gcInfo.AuxiliaryDataRanges) + { + if (finalUnwindOffset < start || finalUnwindOffset >= start + length) + Add(offset + start, length, entryPoints: false); + } + } + else if (node is EHInfoNode ehInfo) + { + foreach ((int start, int length) in ehInfo.AuxiliaryDataRanges) + Add(offset + start, length, entryPoints: false); + } + } + + AddExclusiveImports(); + ranges.Sort(static (left, right) => left.Offset.CompareTo(right.Offset)); + List merged = new(); + foreach (AuxiliaryDataRange range in ranges) + { + if (merged.Count > 0) + { + AuxiliaryDataRange previous = merged[merged.Count - 1]; + if (previous.Offset + previous.Length > range.Offset) + throw new InvalidDataException("Overlapping auxiliary WebAssembly data ownership."); + if (previous.EntryPoints == range.EntryPoints && previous.Offset + previous.Length == range.Offset) + { + merged[merged.Count - 1] = new AuxiliaryDataRange(previous.Offset, + checked(previous.Length + range.Length), range.EntryPoints); + continue; + } + } + merged.Add(range); + } + return merged; + + void Add(int offset, int length, bool entryPoints) + { + if (length > 0) + ranges.Add(new AuxiliaryDataRange(offset, length, entryPoints)); + } + + void AddExclusiveImports() + { + HashSet primaryImports = new(); + HashSet coldImports = new(); + List imports = new(); + foreach (DependencyNode dependency in _partitionNodes) + { + if (dependency is Import import) + { + imports.Add(import); + if (import.Table.IsEager) + primaryImports.Add(import); + } + if (dependency is not ObjectNode objectNode || objectNode.ShouldSkipEmittingObjectNode(_nodeFactory)) + continue; + bool cold = objectNode is MethodWithGCInfo method && _nodeFactory.IsAuxiliaryMethod(method); + HashSet usedImports = cold ? coldImports : primaryImports; + if (objectNode is MethodWithGCInfo body) + { + foreach (ISymbolNode fixup in body.Fixups) + { + if (fixup is Import methodImport) + usedImports.Add(methodImport); + } + } + // Resident read-only and writable data can use import cells too. + if (objectNode.GetData(_nodeFactory, relocsOnly: true).Relocs is Relocation[] relocations) + { + foreach (Relocation relocation in relocations) + { + if (relocation.Target is Import referencedImport) + usedImports.Add(referencedImport); + } + } + } + coldImports.ExceptWith(primaryImports); + HashSet primarySignatures = new(); + foreach (Import import in imports) + { + if (!coldImports.Contains(import)) + primarySignatures.Add(_nodeFactory.ObjectInterner.GetDeduplicatedSymbol(_nodeFactory, import.Signature)); + } + HashSet movedSignatures = new(); + foreach (Import import in coldImports) + { + AddSymbolRange(import, import.Table.EntrySize); + if (_definedSymbols.TryGetValue(GetMangledName(import.Table.SignatureArray), out SymbolDefinition array)) + { + WebcilSection section = (WebcilSection)_sections[array.SectionIndex]; + Add(checked((int)section.Header.VirtualAddress + (int)array.Value + + import.ImportSignature.OffsetFromBeginningOfArray), sizeof(uint), entryPoints: false); + } + ISymbolNode signature = _nodeFactory.ObjectInterner.GetDeduplicatedSymbol(_nodeFactory, import.Signature); + if (!primarySignatures.Contains(signature) && movedSignatures.Add(signature)) + AddSymbolRange(signature, length: 0); + } + } + + void AddSymbolRange(ISymbolNode symbol, int length) + { + if (_definedSymbols.TryGetValue(GetMangledName(symbol), out SymbolDefinition definition)) + { + WebcilSection section = (WebcilSection)_sections[definition.SectionIndex]; + Add(checked((int)section.Header.VirtualAddress + (int)definition.Value), + length == 0 ? definition.Size : length, entryPoints: false); + } + } + } + + private static bool ContainsAuxiliaryData(List ranges, int offset) + { + int low = 0; + int high = ranges.Count - 1; + while (low <= high) + { + int middle = low + (high - low) / 2; + AuxiliaryDataRange range = ranges[middle]; + if (offset < range.Offset) + high = middle - 1; + else if (offset - range.Offset >= range.Length) + low = middle + 1; + else + return true; + } + return false; + } + + private void EmitPartitionedObjectFile(Stream primaryOutput, WebcilPayloadDataSegment payloadSegment, WebcilSection[] sections) + { + if (primaryOutput is not FileStream file) + throw new InvalidOperationException("Partitioned WebAssembly output requires a file stream."); + + WriteMemoryImport((ulong)payloadSegment.ContentSize); + FinalizeSectionEntryCounts(); + + using MemoryStream encodedPayload = new(); + payloadSegment.EmitToStream(encodedPayload); + byte[] payload = encodedPayload.ToArray().AsSpan(payloadSegment.HeaderSize).ToArray(); + List ranges = _auxiliaryDataRanges; + byte[] primaryPayload = (byte[])payload.Clone(); + foreach (AuxiliaryDataRange range in ranges) + primaryPayload.AsSpan(range.Offset, range.Length).Clear(); + + int metadataOffset = checked((int)ResolveSymbolRVA(sections, + _definedSymbols[GetMangledName(_nodeFactory.AuxiliaryModuleMetadata)])); + int primaryCount = BinaryPrimitives.ReadInt32LittleEndian(payload.AsSpan(metadataOffset + 4)); + int coldCount = BinaryPrimitives.ReadInt32LittleEndian(payload.AsSpan(metadataOffset + 24)); + int managedCount = checked(primaryCount + coldCount); + int[] tableFunctions = GetTableSlotFunctionIndices(); + HashSet hotFunctions = new(); + HashSet coldFunctions = new(); + for (int slot = 0; slot < managedCount; slot++) + (slot < primaryCount ? hotFunctions : coldFunctions).Add(tableFunctions[slot]); + + int functionImports = _wasmSymbolManager.GetImportCount(WasmIndexSpace.Function); + List functions = new(); + foreach (WasmSymbol symbol in _wasmSymbolManager.GetDefinitions(WasmIndexSpace.Function)) + { + if (!symbol.IsImport) + functions.Add(symbol); + } + + List functionTypes = new(); + Stream typeIndices = _sections.GetSection(WasmObjectNodeSection.FunctionSection.Name).ContentReadStream; + typeIndices.Position = 0; + while (typeIndices.Position < typeIndices.Length) + functionTypes.Add(checked((int)DwarfHelper.ReadULEB128(typeIndices, out _).Value)); + + WriteModule(primaryOutput, physicalModule: 0); + string stem = Path.Combine(Path.GetDirectoryName(file.Name), Path.GetFileNameWithoutExtension(file.Name)); + using (FileStream entryPointsOutput = File.Create(stem + ".entrypoints.wasm")) + WriteModule(entryPointsOutput, physicalModule: 1); + using (FileStream coldOutput = File.Create(stem + ".1.wasm")) + WriteModule(coldOutput, physicalModule: 2); + using (FileStream manifestFile = File.Create(stem + ".auxiliary.json")) + using (Utf8JsonWriter manifest = new(manifestFile)) + { + manifest.WriteStartObject(); + manifest.WriteString("entrypoints", Path.GetFileName(stem) + ".entrypoints.wasm"); + manifest.WriteStartArray("modules"); + manifest.WriteStringValue(Path.GetFileName(stem) + ".1.wasm"); + manifest.WriteEndArray(); + manifest.WriteEndObject(); + } + _physicalFunctionIndices = null; + + void WriteModule(Stream output, int physicalModule) + { + List kept = new(); + _physicalFunctionIndices = new int[functionImports + functions.Count]; + Array.Fill(_physicalFunctionIndices, -1); + for (int index = 0; index < functionImports; index++) + _physicalFunctionIndices[index] = index; + if (physicalModule != 1) + { + for (int ordinal = 0; ordinal < functions.Count; ordinal++) + { + int index = functions[ordinal].Index; + if (physicalModule != 0 && _wasmStubNames.Contains(functions[ordinal].Name)) + continue; + bool managed = hotFunctions.Contains(index) || coldFunctions.Contains(index); + if (!managed || (physicalModule == 0 ? hotFunctions : coldFunctions).Contains(index)) + { + _physicalFunctionIndices[index] = functionImports + kept.Count; + kept.Add(ordinal); + } + } + if (physicalModule == 0) + _primaryFunctionIndices = _physicalFunctionIndices; + } + + int codeSectionIndex = _sections.GetSectionIndex(ObjectNodeSection.WasmCodeSection.Name); + Stream originalCode = _sections[codeSectionIndex].ContentReadStream; + originalCode.Position = 0; + using MemoryStream resolvedCode = new(); + if (physicalModule != 1) + { + ValidateDirectCalls(originalCode, codeSectionIndex); + originalCode.Position = 0; + if (_resolvableRelocations.TryGetValue(codeSectionIndex, out List relocations)) + ResolveRelocations(codeSectionIndex, originalCode, resolvedCode, relocations, shrink: true); + else + originalCode.CopyTo(resolvedCode); + } + resolvedCode.Position = 0; + List codeBlobs = physicalModule == 1 ? new() : ParseCodeBlobs(resolvedCode); + List moduleRanges = new(); + foreach (AuxiliaryDataRange range in ranges) + { + if (physicalModule != 0 && range.EntryPoints == (physicalModule == 1)) + moduleRanges.Add(range); + } + + EmitWasmHeader(output); + _sections.GetSection(ObjectNodeSection.WasmTypeSection.Name).EmitToStream(output); + _sections.GetSection(WasmObjectNodeSection.ImportSection.Name).EmitToStream(output); + WriteRawSection(output, WasmSectionType.Function, section => + { + WriteUnsigned(section, kept.Count + (physicalModule == 0 ? 0 : 1)); + foreach (int ordinal in kept) + WriteUnsigned(section, functionTypes[ordinal]); + if (physicalModule != 0) + WriteUnsigned(section, _activationSignature); + }); + if (physicalModule == 0) + _sections.GetSection(WasmObjectNodeSection.GlobalSection.Name).EmitToStream(output); + WriteRawSection(output, WasmSectionType.Export, section => + { + WriteUnsigned(section, physicalModule == 0 ? _wasmStubNames.Count + 2 : 1); + if (physicalModule == 0) + { + WriteExport(section, "table", 1, 0); + WriteExport(section, "webcilVersion", 3, _definedGlobals["webcilVersion"].Index); + foreach (Utf8String name in _wasmStubNames) + WriteExport(section, name.ToString(), 0, _physicalFunctionIndices[_wasmSymbolManager.GetSymbol(name).Index]); + } + else + WriteExport(section, "ActivateModule", 0, functionImports + kept.Count); + }); + if (physicalModule != 1) + { + WriteRawSection(output, WasmSectionType.Element, section => + { + if (physicalModule == 0) + { + WriteUnsigned(section, 2); + WriteElements(section, 0, primaryCount, passive: false); + WriteElements(section, managedCount, tableFunctions.Length - managedCount, passive: false); + } + else + { + WriteUnsigned(section, 1); + WriteElements(section, primaryCount, coldCount, passive: true); + } + }); + } + WriteRawSection(output, WasmSectionType.DataCount, section => + WriteUnsigned(section, physicalModule == 0 ? 2 : moduleRanges.Count)); + WriteRawSection(output, WasmSectionType.Code, section => + { + WriteUnsigned(section, kept.Count + (physicalModule == 0 ? 0 : 1)); + foreach (int ordinal in kept) + { + if (functions[ordinal].Name.ToString() == "getAuxiliaryModuleMetadata") + { + WriteBody(section, new WasmFunctionBody(new WasmFuncType(new([]), new([WasmValueType.I32])), + [Global.Get(ImageBaseGlobalIndex), I32.Const(metadataOffset), I32.Add])); + } + else + { + CodeBlob blob = codeBlobs[ordinal]; + WriteUnsigned(section, checked((int)blob.Size)); + section.Write(resolvedCode.GetBuffer().AsSpan(checked((int)blob.Start), checked((int)blob.Size))); + } + } + if (physicalModule != 0) + WriteBody(section, CreateActivationBody(moduleRanges, metadataOffset, + physicalModule == 1, primaryCount, coldCount)); + }); + WriteRawSection(output, WasmSectionType.Data, section => + { + if (physicalModule == 0) + { + WriteUnsigned(section, 2); + section.WriteByte(1); + WriteUnsigned(section, 8); + Span size = stackalloc byte[8]; + BinaryPrimitives.WriteInt32LittleEndian(size, primaryPayload.Length); + BinaryPrimitives.WriteInt32LittleEndian(size.Slice(4), MethodCount); + section.Write(size); + section.WriteByte(0); + section.WriteByte(0x23); + WriteUnsigned(section, ImageBaseGlobalIndex); + section.WriteByte(0x0B); + WriteUnsigned(section, primaryPayload.Length); + section.Write(primaryPayload); + } + else + { + WriteUnsigned(section, moduleRanges.Count); + foreach (AuxiliaryDataRange range in moduleRanges) + { + section.WriteByte(1); + WriteUnsigned(section, range.Length); + section.Write(payload.AsSpan(range.Offset, range.Length)); + } + } + }, alignedSuffixLength: physicalModule == 0 ? primaryPayload.Length : 0); + if (_emitNameSection) + { + List names = new(); + foreach (int ordinal in kept) + { + WasmSymbol symbol = functions[ordinal]; + names.Add(new WasmSymbol(symbol.Name, WasmIndexSpace.Function, + _physicalFunctionIndices[symbol.Index], IsImport: false)); + } + if (physicalModule != 0) + names.Add(new WasmSymbol(new Utf8String("ActivateModule"), WasmIndexSpace.Function, + functionImports + kept.Count, IsImport: false)); + new WasmNameSection(names).EmitToStream(output); + } + + void WriteElements(Stream section, int start, int count, bool passive) + { + section.WriteByte(passive ? (byte)1 : (byte)0); + if (passive) + section.WriteByte(0); + else + { + section.WriteByte(0x23); + WriteUnsigned(section, TableBaseGlobalIndex); + section.WriteByte(0x41); + WriteSigned(section, start); + section.WriteByte(0x6A); + section.WriteByte(0x0B); + } + WriteUnsigned(section, count); + for (int slot = start; slot < start + count; slot++) + { + int index = _physicalFunctionIndices[tableFunctions[slot]]; + if (index < 0) + throw new InvalidDataException("Auxiliary element references an unavailable WebAssembly function."); + WriteUnsigned(section, index); + } + } + } + } + + private void ValidateDirectCalls(Stream code, int sectionIndex) + { + if (!_resolvableRelocations.TryGetValue(sectionIndex, out List relocations)) + return; + code.Position = 0; + List blobs = ParseCodeBlobs(code); + int ordinal = 0; + int imports = _wasmSymbolManager.GetImportCount(WasmIndexSpace.Function); + foreach (SymbolicRelocation relocation in relocations) + { + while (ordinal < blobs.Count && relocation.Offset >= blobs[ordinal].End) + ordinal++; + if (ordinal >= blobs.Count || _physicalFunctionIndices[imports + ordinal] < 0 || + relocation.Type != RelocType.WASM_FUNCTION_INDEX_LEB) + continue; + WasmSymbol target = _wasmSymbolManager.GetSymbol(relocation.SymbolName); + if (_physicalFunctionIndices[target.Index] < 0) + throw new InvalidDataException($"Direct call across auxiliary WebAssembly partitions: {target.Name}."); + } + } + + private WasmFunctionBody CreateActivationBody(List ranges, int metadataOffset, + bool entryPoints, int tableStart, int tableCount) + { + int status = metadataOffset + (entryPoints + ? AuxiliaryModuleMetadataNode.EntryPointsStatusOffset : AuxiliaryModuleMetadataNode.ModuleStatusOffset); + List instructions = new() + { + Global.Get(ImageBaseGlobalIndex), I32.Load((ulong)status), I32.Eqz, Block.If(WasmBlockType.Empty), + }; + for (int index = 0; index < ranges.Count; index++) + { + AuxiliaryDataRange range = ranges[index]; + instructions.Add(Global.Get(ImageBaseGlobalIndex)); + instructions.Add(I32.Const(range.Offset)); + instructions.Add(I32.Add); + instructions.Add(I32.Const(0)); + instructions.Add(I32.Const(range.Length)); + instructions.Add(Memory.Init(index)); + } + foreach (PendingBaseReloc relocation in _pendingBaseRelocs) + { + WebcilSection section = (WebcilSection)_sections[relocation.SectionIndex]; + int offset = checked((int)section.Header.VirtualAddress + (int)relocation.Offset); + if (!ContainsAuxiliaryData(ranges, offset)) + continue; + int baseGlobal = relocation.FileRelocType switch + { + RelocType.IMAGE_REL_BASED_HIGHLOW => ImageBaseGlobalIndex, + RelocType.IMAGE_REL_BASED_WASM32_TABLE => TableBaseGlobalIndex, + _ => throw new InvalidDataException($"Unsupported auxiliary base relocation: {relocation.FileRelocType}."), + }; + instructions.Add(Global.Get(ImageBaseGlobalIndex)); + instructions.Add(Global.Get(ImageBaseGlobalIndex)); + instructions.Add(I32.Load((ulong)offset)); + instructions.Add(Global.Get(baseGlobal)); + instructions.Add(I32.Add); + instructions.Add(I32.Store((ulong)offset)); + } + if (!entryPoints) + { + instructions.Add(Global.Get(TableBaseGlobalIndex)); + instructions.Add(I32.Const(tableStart)); + instructions.Add(I32.Add); + instructions.Add(I32.Const(0)); + instructions.Add(I32.Const(tableCount)); + instructions.Add(Table.Init(0)); + } + instructions.Add(Global.Get(ImageBaseGlobalIndex)); + instructions.Add(I32.Const(1)); + instructions.Add(I32.Store((ulong)status)); + instructions.Add(Block.End); + return new WasmFunctionBody(new WasmFuncType(new([]), new([])), instructions.ToArray()); + } + + private static void WriteBody(Stream stream, WasmFunctionBody body) + { + byte[] bytes = new byte[body.EncodeSize()]; + body.Encode(bytes); + stream.Write(bytes); + } + + private static void WriteExport(Stream stream, string name, byte kind, int index) + { + byte[] bytes = Encoding.UTF8.GetBytes(name); + WriteUnsigned(stream, bytes.Length); + stream.Write(bytes); + stream.WriteByte(kind); + WriteUnsigned(stream, index); + } + + private static void WriteRawSection(Stream output, WasmSectionType type, Action emit, + int alignedSuffixLength = 0) + { + using MemoryStream content = new(); + emit(content); + if (alignedSuffixLength != 0) + { + long alignedOffset = output.Position + 1 + DwarfHelper.SizeOfULEB128((ulong)content.Length) + + content.Length - alignedSuffixLength; + new PaddingWasmSection(WebcilSectionAlignment, alignedOffset).EmitToStream(output); + } + output.WriteByte((byte)type); + WriteUnsigned(output, checked((int)content.Length)); + content.Position = 0; + content.CopyTo(output); + } + + private static void WriteUnsigned(Stream stream, int value) + { + Span bytes = stackalloc byte[10]; + int length = DwarfHelper.WriteULEB128(bytes, checked((ulong)value)); + stream.Write(bytes.Slice(0, length)); + } + + private static void WriteSigned(Stream stream, int value) + { + Span bytes = stackalloc byte[10]; + int length = DwarfHelper.WriteSLEB128(bytes, value); + stream.Write(bytes.Slice(0, length)); + } +} +#endif diff --git a/src/coreclr/tools/Common/Compiler/ObjectWriter/WebCilObjectWriter.cs b/src/coreclr/tools/Common/Compiler/ObjectWriter/WebCilObjectWriter.cs index eaf100b5c10fce..1b7cde9411e2e7 100644 --- a/src/coreclr/tools/Common/Compiler/ObjectWriter/WebCilObjectWriter.cs +++ b/src/coreclr/tools/Common/Compiler/ObjectWriter/WebCilObjectWriter.cs @@ -25,12 +25,17 @@ namespace ILCompiler.ObjectWriter /// /// WebCIL object file format writer. /// - internal sealed class WebCilObjectWriter : WasmObjectWriter + internal sealed partial class WebCilObjectWriter : WasmObjectWriter { public const int WebcilSectionAlignment = 16; protected override CodeDataLayout LayoutMode => CodeDataLayout.Separate; - private protected override bool ShouldDeduplicateFunctionBodies => true; + private protected override bool ShouldDeduplicateFunctionBodies => +#if READYTORUN + !_nodeFactory.UsesWasmAuxiliaryModules; +#else + true; +#endif // We use 2 Wasm data segments for webcil, // 1 for the payload size, and the second for the payload itself. @@ -279,10 +284,16 @@ private protected override void EmitSectionsAndLayout() // and function table via active segments, so the only stub the host calls beyond // getWebcilSize is patchWebcilHeader: the header's tableBase field is read from linear memory // and cannot come from a segment. - const int stubCount = 2; + const int stubCount = 3; int totalMethodCount = MethodCount + stubCount; InsertWasmStub(new Utf8String("getWebcilSize"), GetWebcilSize); InsertWasmStub(new Utf8String("patchWebcilHeader"), PatchWebcilHeader); + InsertWasmStub(new Utf8String("getAuxiliaryModuleMetadata"), + new WasmFunctionBody(new WasmFuncType(new([]), new([WasmValueType.I32])), [I32.Const(0)])); +#if READYTORUN + if (_nodeFactory.UsesWasmAuxiliaryModules) + _activationSignature = RegisterSignature(new WasmFuncType(new([]), new([]))); +#endif Debug.Assert(MethodCount == totalMethodCount); @@ -479,6 +490,10 @@ private protected override void EmitObjectFile(Stream outputFileStream) // which is required for resolving file-level relocations whose RVA depends on the section VAs. AssignWebcilSectionVirtualAddresses(webcilSections); +#if READYTORUN + if (_nodeFactory.UsesWasmAuxiliaryModules) + _auxiliaryDataRanges = GetAuxiliaryDataRanges(); +#endif // We can now build our base relocs with the correct addresses BuildBaseRelocMap(); @@ -497,6 +512,13 @@ private protected override void EmitObjectFile(Stream outputFileStream) webcilSections, ImageBaseOffsetExpr); +#if READYTORUN + if (_nodeFactory.UsesWasmAuxiliaryModules) + { + EmitPartitionedObjectFile(outputFileStream, webcilPayloadSegment, webcilSections); + return; + } +#endif // Writing our memory import <- size of the webcil segment (for an accurate minimum size) WriteMemoryImport((ulong)webcilPayloadSegment.ContentSize); FinalizeSectionEntryCounts(); @@ -590,7 +612,16 @@ public void EmitSymbolMap(string path) using var writer = new StreamWriter(path, append: false, new UTF8Encoding(encoderShouldEmitUTF8Identifier: false)); foreach (WasmSymbol symbol in _wasmSymbolManager.GetDefinitions(WasmIndexSpace.Function)) { - writer.Write(symbol.Index.ToString(CultureInfo.InvariantCulture)); + int index = symbol.Index; +#if READYTORUN + if (_primaryFunctionIndices is not null) + { + index = _primaryFunctionIndices[index]; + if (index < 0) + continue; + } +#endif + writer.Write(index.ToString(CultureInfo.InvariantCulture)); writer.Write(':'); writer.WriteLine(symbol.Name); } @@ -653,6 +684,10 @@ private void BuildBaseRelocMap() // (page RVA -> list of (type<<12 | offsetInPage) WORD entries). // Note that this handling is logically the same as the implementation in the PE Object Writer. uint targetRva = webcilSection.Header.VirtualAddress + (uint)pending.Offset; +#if READYTORUN + if (_auxiliaryDataRanges is not null && ContainsAuxiliaryData(_auxiliaryDataRanges, checked((int)targetRva))) + continue; +#endif Debug.Assert(targetRva != 0); // this section should have been assigned a non-zero VirtualAddress at this point. uint pageRva = targetRva & ~(WebcilRelocPageSize - 1); ushort offsetInPage = (ushort)(targetRva & (WebcilRelocPageSize - 1)); @@ -906,12 +941,14 @@ private unsafe int ResolveReloc(int sectionIndex, MemoryStream sourceStream, lon if (shrink && Relocation.IsVariableLength(reloc.Type)) { - actualLength = Relocation.WriteVariableLengthValue(reloc.Type, pData, symbol.Index + addend); + actualLength = Relocation.WriteVariableLengthValue(reloc.Type, pData, + GetPhysicalSymbolIndex(symbol) + addend); } else { - Relocation.WriteValue(reloc.Type, pData, symbol.Index + addend); + Relocation.WriteValue(reloc.Type, pData, GetPhysicalSymbolIndex(symbol) + addend); } + break; } @@ -1046,6 +1083,15 @@ int WriteRelocFromDataSpan(SymbolicRelocation reloc, byte* pData, int length) } } + private int GetPhysicalSymbolIndex(WasmSymbol symbol) + { +#if READYTORUN + if (_physicalFunctionIndices is not null && symbol.IndexSpace == WasmIndexSpace.Function) + return Math.Max(_physicalFunctionIndices[symbol.Index], 0); +#endif + return symbol.Index; + } + private void ResolveRelocations(int sectionIndex, Stream sectionStream, MemoryStream dstStream, List relocs, long sectionStart = 0, bool shrink = false) { Debug.Assert(sectionStream.CanSeek); diff --git a/src/coreclr/tools/Common/Internal/Runtime/ModuleHeaders.cs b/src/coreclr/tools/Common/Internal/Runtime/ModuleHeaders.cs index 5c2bd42f340552..2ad44b84908002 100644 --- a/src/coreclr/tools/Common/Internal/Runtime/ModuleHeaders.cs +++ b/src/coreclr/tools/Common/Internal/Runtime/ModuleHeaders.cs @@ -15,7 +15,7 @@ internal struct ReadyToRunHeaderConstants { public const uint Signature = 0x00525452; // 'RTR' - public const ushort CurrentMajorVersion = 32; + public const ushort CurrentMajorVersion = 33; public const ushort CurrentMinorVersion = 0; } #if READYTORUN @@ -85,6 +85,9 @@ enum ReadyToRunSectionType ProxyTypeMaps = 125, // Added to CoreCLR in V18.3 TypeMapAssemblyTargets = 126, // Added in V18.3 WasmAsyncResumeInfo = 127, // Added in V29 + AuxiliaryModuleMetadata = 128, + AuxiliaryMethodDefEntryPoints = 129, + AuxiliaryInstanceMethodEntryPoints = 130, // // NativeAOT ReadyToRun sections diff --git a/src/coreclr/tools/aot/ILCompiler.ReadyToRun.Tests/TestCases/R2RTestSuites.cs b/src/coreclr/tools/aot/ILCompiler.ReadyToRun.Tests/TestCases/R2RTestSuites.cs index 7443a53f811c41..4333f168a47ac4 100644 --- a/src/coreclr/tools/aot/ILCompiler.ReadyToRun.Tests/TestCases/R2RTestSuites.cs +++ b/src/coreclr/tools/aot/ILCompiler.ReadyToRun.Tests/TestCases/R2RTestSuites.cs @@ -30,6 +30,30 @@ public R2RTestSuites(ITestOutputHelper output) _output = output; } + [ConditionalTheory(typeof(TestPaths), nameof(TestPaths.IsWasmTarget))] + [InlineData(false)] + [InlineData(true)] + public void AuxiliaryModulesSeparateEntryPointsAndColdData(bool optimize) + { + CompiledAssembly input = new() + { + AssemblyName = nameof(AuxiliaryModulesSeparateEntryPointsAndColdData), + SourceResourceNames = ["Webcil/WasmWebcilModule.cs"], + }; + new R2RTestRunner(_output).Run(new R2RTestCase( + nameof(AuxiliaryModulesSeparateEntryPointsAndColdData), + [ + new(nameof(AuxiliaryModulesSeparateEntryPointsAndColdData), [new CrossgenAssembly(input)]) + { + Options = [Crossgen2Option.Composite], + AdditionalArgs = ["--wasm-auxiliary-modules", "--wasm-debug-info", "all", + optimize ? "--optimize" : "--optimize-disabled"], + OutputFileExtension = ".wasm", + Validate = WasmR2RAssert.AssertAuxiliaryModuleLayout, + }, + ])); + } + [Theory] [InlineData(false, false)] [InlineData(false, true)] diff --git a/src/coreclr/tools/aot/ILCompiler.ReadyToRun.Tests/TestCases/Webcil/WasmWebcilModule.cs b/src/coreclr/tools/aot/ILCompiler.ReadyToRun.Tests/TestCases/Webcil/WasmWebcilModule.cs index ad54cbf0b9edd2..23c57ef3840d2a 100644 --- a/src/coreclr/tools/aot/ILCompiler.ReadyToRun.Tests/TestCases/Webcil/WasmWebcilModule.cs +++ b/src/coreclr/tools/aot/ILCompiler.ReadyToRun.Tests/TestCases/Webcil/WasmWebcilModule.cs @@ -128,6 +128,24 @@ public static int AddIntegers(int left, int right) return left + right; } + [MethodImpl(MethodImplOptions.NoInlining)] + public static int CallAuxiliaryMethod(int value) => AuxiliaryMethod(value); + + [MethodImpl(MethodImplOptions.NoInlining | MethodImplOptions.NoOptimization)] + private static int AuxiliaryMethod(int value) + { + try + { + if (value < 0) + throw new InvalidOperationException("auxiliary method"); + return s_primes[value % s_primes.Length]; + } + finally + { + s_counter++; + } + } + [MethodImpl(MethodImplOptions.NoInlining)] public static void FoldableBodyOne() { diff --git a/src/coreclr/tools/aot/ILCompiler.ReadyToRun.Tests/TestCasesRunner/WasmR2RAssert.cs b/src/coreclr/tools/aot/ILCompiler.ReadyToRun.Tests/TestCasesRunner/WasmR2RAssert.cs index e14499a5ea25f1..4f94e8e3888087 100644 --- a/src/coreclr/tools/aot/ILCompiler.ReadyToRun.Tests/TestCasesRunner/WasmR2RAssert.cs +++ b/src/coreclr/tools/aot/ILCompiler.ReadyToRun.Tests/TestCasesRunner/WasmR2RAssert.cs @@ -22,6 +22,53 @@ namespace ILCompiler.ReadyToRun.Tests.TestCasesRunner; internal static class WasmR2RAssert { + public static void AssertAuxiliaryModuleLayout(ReadyToRunReader reader) + { + Assert.True(WasmSymbolMapHasExpectedFunctionNames(reader, out string diagnostic), diagnostic); + ReadOnlySpan image = reader.CompositeReader.GetEntireImage().AsSpan(); + ReadyToRunSection metadata = reader.ReadyToRunHeader.Sections[ReadyToRunSectionType.AuxiliaryModuleMetadata]; + int metadataOffset = reader.CompositeReader.GetOffset(metadata.RelativeVirtualAddress); + Assert.Equal(0u, BinaryPrimitives.ReadUInt32LittleEndian(image.Slice(metadataOffset))); + int hotCount = BinaryPrimitives.ReadInt32LittleEndian(image.Slice(metadataOffset + 4)); + Assert.True(hotCount > 0); + Assert.Equal(1, BinaryPrimitives.ReadInt32LittleEndian(image.Slice(metadataOffset + 8))); + Assert.Equal(hotCount, BinaryPrimitives.ReadInt32LittleEndian(image.Slice(metadataOffset + 20))); + int coldCount = BinaryPrimitives.ReadInt32LittleEndian(image.Slice(metadataOffset + 24)); + Assert.True(coldCount > 1, "The cold method and its finally funclet must both have slots."); + Assert.Equal(0, BinaryPrimitives.ReadInt32LittleEndian(image.Slice(metadataOffset + 28))); + ReadyToRunSection runtimeFunctions = reader.ReadyToRunHeader.Sections[ReadyToRunSectionType.RuntimeFunctions]; + Assert.Equal((hotCount + coldCount) * 8, runtimeFunctions.Size); + int runtimeOffset = reader.CompositeReader.GetOffset(runtimeFunctions.RelativeVirtualAddress); + Assert.All(image.Slice(runtimeOffset + hotCount * 8, (coldCount - 1) * 8).ToArray(), + value => Assert.Equal(0, value)); + Assert.NotEqual(0u, BinaryPrimitives.ReadUInt32LittleEndian( + image.Slice(runtimeOffset + (hotCount + coldCount - 1) * 8 + 4))); + ReadyToRunSection auxiliaryInstances = reader.ReadyToRunHeader.Sections[ReadyToRunSectionType.AuxiliaryInstanceMethodEntryPoints]; + Assert.All(image.Slice(reader.CompositeReader.GetOffset(auxiliaryInstances.RelativeVirtualAddress), + auxiliaryInstances.Size).ToArray(), value => Assert.Equal(0, value)); + + string stem = Path.Combine(Path.GetDirectoryName(reader.Filename)!, Path.GetFileNameWithoutExtension(reader.Filename)); + Assert.True(File.Exists(stem + ".auxiliary.json")); + foreach (string suffix in new[] { ".entrypoints.wasm", ".1.wasm" }) + { + ReadOnlySpan sidecar = File.ReadAllBytes(stem + suffix); + Assert.True(TryGetWasmSectionBounds(sidecar, WasmSectionKind.Data, out int offset, out int end)); + uint count = ReadWasmUleb32(sidecar, ref offset, end); + Assert.True(count > 0); + int nonzeroBytes = 0; + for (uint index = 0; index < count; index++) + { + Assert.Equal(1u, ReadWasmUleb32(sidecar, ref offset, end)); + int size = checked((int)ReadWasmUleb32(sidecar, ref offset, end)); + foreach (byte value in sidecar.Slice(offset, size)) + nonzeroBytes += value != 0 ? 1 : 0; + offset += size; + } + Assert.True(nonzeroBytes > 0); + Assert.Equal(end, offset); + } + } + public static void AssertFrameIdentityPrologs(WebcilImageReader reader) { ReadOnlySpan image = reader.GetEntireImage().AsSpan(); @@ -85,8 +132,7 @@ public static void AssertWebcilSegmentLayout(WebcilImageReader reader, bool isCo Assert.True(definedFunctionCount > 0, "Expected functions in the Webcil wrapper"); if (isComponentStub) { - // Component forwarding stubs contain no compiled methods, only the two host-called stubs. - Assert.Equal(2u, definedFunctionCount); + Assert.Equal(3u, definedFunctionCount); } string[] actualFunctionExports = ReadWasmExports(reader) @@ -94,7 +140,7 @@ public static void AssertWebcilSegmentLayout(WebcilImageReader reader, bool isCo .Select(export => export.Key) .Order(StringComparer.Ordinal) .ToArray(); - Assert.Equal(["getWebcilSize", "patchWebcilHeader"], actualFunctionExports); + Assert.Equal(["getAuxiliaryModuleMetadata", "getWebcilSize", "patchWebcilHeader"], actualFunctionExports); Assert.True( TryGetWasmSectionBounds(image, WasmSectionKind.Global, out int offset, out int end), @@ -574,14 +620,14 @@ private static void CheckFunctionExports( // Only the host-called stubs are exported. Exporting every compiled function counts towards // the engine's effective-type-size limit and makes a framework-sized composite unloadable; // function names live in the name section instead, which CheckFunctionNames verifies. - // Every image needs the same two stubs: getWebcilSize, and patchWebcilHeader to record the table base. + // The metadata getter returns zero for ordinary and component images. List exportedFunctions = exports .Where(export => export.Value.Kind == WasmImportKind.Function) .Select(export => export.Key) .Order(StringComparer.Ordinal) .ToList(); - string[] expectedExports = ["getWebcilSize", "patchWebcilHeader"]; + string[] expectedExports = ["getAuxiliaryModuleMetadata", "getWebcilSize", "patchWebcilHeader"]; if (!exportedFunctions.SequenceEqual(expectedExports, StringComparer.Ordinal)) { failures.Add( diff --git a/src/coreclr/tools/aot/ILCompiler.ReadyToRun/CodeGen/ReadyToRunObjectWriter.cs b/src/coreclr/tools/aot/ILCompiler.ReadyToRun/CodeGen/ReadyToRunObjectWriter.cs index dd10042941b16e..245b75f9a5e1ab 100644 --- a/src/coreclr/tools/aot/ILCompiler.ReadyToRun/CodeGen/ReadyToRunObjectWriter.cs +++ b/src/coreclr/tools/aot/ILCompiler.ReadyToRun/CodeGen/ReadyToRunObjectWriter.cs @@ -281,6 +281,21 @@ public void EmitReadyToRunObjects(ReadyToRunContainerFormat format, Logger logge { if (!succeeded) { + if (_nodeFactory.UsesWasmAuxiliaryModules) + { + string stem = Path.Combine(Path.GetDirectoryName(_objectFilePath), + Path.GetFileNameWithoutExtension(_objectFilePath)); + foreach (string suffix in new[] { ".entrypoints.wasm", ".1.wasm", ".auxiliary.json" }) + { + try + { + File.Delete(stem + suffix); + } + catch + { + } + } + } // If there was an exception while generating the OBJ file, make sure we don't leave the unfinished // object file around. try diff --git a/src/coreclr/tools/aot/ILCompiler.ReadyToRun/Compiler/DependencyAnalysis/ReadyToRun/AuxiliaryModuleMetadataNode.cs b/src/coreclr/tools/aot/ILCompiler.ReadyToRun/Compiler/DependencyAnalysis/ReadyToRun/AuxiliaryModuleMetadataNode.cs new file mode 100644 index 00000000000000..a5b713c7f7c4e5 --- /dev/null +++ b/src/coreclr/tools/aot/ILCompiler.ReadyToRun/Compiler/DependencyAnalysis/ReadyToRun/AuxiliaryModuleMetadataNode.cs @@ -0,0 +1,55 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Text; +using Internal.Text; + +namespace ILCompiler.DependencyAnalysis.ReadyToRun; + +public sealed class AuxiliaryModuleMetadataNode : HeaderTableNode +{ + public const int HeaderSize = 16; + public const int EntrySize = 16; + public const int EntryPointsStatusOffset = 0; + public const int ModuleStatusOffset = HeaderSize + 12; + + public string OutputFileName { get; set; } + + public override void AppendMangledName(NameMangler nameMangler, Utf8StringBuilder builder) + => builder.Append(nameMangler.CompilationUnitPrefix).Append("__AuxiliaryModuleMetadata"u8); + + public override ObjectData GetData(NodeFactory factory, bool relocsOnly = false) + { + ObjectDataBuilder builder = new(factory, relocsOnly); + builder.RequireInitialAlignment(4); + builder.AddSymbol(this); + int primaryCount = 0; + int auxiliaryCount = 0; + if (!relocsOnly) + { + foreach (MethodWithGCInfo method in factory.EnumerateCompiledMethods()) + { + if (factory.IsAuxiliaryMethod(method)) + auxiliaryCount += method.FrameInfos.Length; + else + primaryCount += method.FrameInfos.Length; + } + } + + byte[] entryPointsName = Encoding.UTF8.GetBytes(OutputFileName + ".entrypoints.wasm\0"); + byte[] moduleName = Encoding.UTF8.GetBytes(OutputFileName + ".1.wasm\0"); + builder.EmitInt(0); + builder.EmitInt(primaryCount); + builder.EmitInt(1); + builder.EmitReloc(this, RelocType.IMAGE_REL_BASED_ADDR32NB, HeaderSize + EntrySize); + builder.EmitReloc(this, RelocType.IMAGE_REL_BASED_ADDR32NB, HeaderSize + EntrySize + entryPointsName.Length); + builder.EmitInt(primaryCount); + builder.EmitInt(auxiliaryCount); + builder.EmitInt(0); + builder.EmitBytes(entryPointsName); + builder.EmitBytes(moduleName); + return builder.ToObjectData(); + } + + public override int ClassCode => 193275841; +} diff --git a/src/coreclr/tools/aot/ILCompiler.ReadyToRun/Compiler/DependencyAnalysis/ReadyToRun/ExceptionInfoLookupTableNode.cs b/src/coreclr/tools/aot/ILCompiler.ReadyToRun/Compiler/DependencyAnalysis/ReadyToRun/ExceptionInfoLookupTableNode.cs index 8b11f1410e8844..ba36f2970122bb 100644 --- a/src/coreclr/tools/aot/ILCompiler.ReadyToRun/Compiler/DependencyAnalysis/ReadyToRun/ExceptionInfoLookupTableNode.cs +++ b/src/coreclr/tools/aot/ILCompiler.ReadyToRun/Compiler/DependencyAnalysis/ReadyToRun/ExceptionInfoLookupTableNode.cs @@ -25,6 +25,7 @@ public class EHInfoNode : ObjectNode, ISymbolDefinitionNode public int Offset => 0; private ArrayBuilder _ehInfoBuilder; + public readonly List<(int Offset, int Length)> AuxiliaryDataRanges = new(); public EHInfoNode() { @@ -98,7 +99,10 @@ internal void LayoutMethodsWithEHInfo() if (ehInfo != null && ehInfo.Data.Length != 0) { _methodNodes.Add(method); - _ehInfoOffsets.Add(_ehInfoNode.AddEHInfo(ehInfo.Data)); + int offset = _ehInfoNode.AddEHInfo(ehInfo.Data); + _ehInfoOffsets.Add(offset); + if (_nodeFactory.IsAuxiliaryMethod(method)) + _ehInfoNode.AuxiliaryDataRanges.Add((offset, ehInfo.Data.Length)); } } } diff --git a/src/coreclr/tools/aot/ILCompiler.ReadyToRun/Compiler/DependencyAnalysis/ReadyToRun/ImportSectionNode.cs b/src/coreclr/tools/aot/ILCompiler.ReadyToRun/Compiler/DependencyAnalysis/ReadyToRun/ImportSectionNode.cs index ab66d21faf1596..ffb9e7d59f89e8 100644 --- a/src/coreclr/tools/aot/ILCompiler.ReadyToRun/Compiler/DependencyAnalysis/ReadyToRun/ImportSectionNode.cs +++ b/src/coreclr/tools/aot/ILCompiler.ReadyToRun/Compiler/DependencyAnalysis/ReadyToRun/ImportSectionNode.cs @@ -92,6 +92,7 @@ public void AddImport(NodeFactory factory, Import import) } public string Name => _name; + internal ISymbolNode SignatureArray => _signatures; public bool EmitPrecode => _emitPrecode; diff --git a/src/coreclr/tools/aot/ILCompiler.ReadyToRun/Compiler/DependencyAnalysis/ReadyToRun/InstanceEntryPointTableNode.cs b/src/coreclr/tools/aot/ILCompiler.ReadyToRun/Compiler/DependencyAnalysis/ReadyToRun/InstanceEntryPointTableNode.cs index f1ccd58e0b62d2..c058f4a2f51a8b 100644 --- a/src/coreclr/tools/aot/ILCompiler.ReadyToRun/Compiler/DependencyAnalysis/ReadyToRun/InstanceEntryPointTableNode.cs +++ b/src/coreclr/tools/aot/ILCompiler.ReadyToRun/Compiler/DependencyAnalysis/ReadyToRun/InstanceEntryPointTableNode.cs @@ -22,10 +22,14 @@ public class InstanceEntryPointTableNode : HeaderTableNode, ISignatureEmitter { private readonly NodeFactory _factory; private bool _materializedSignature; + private int _entryPointsOffset; + public bool IsAuxiliary { get; } + public override int Offset => _entryPointsOffset; - public InstanceEntryPointTableNode(NodeFactory factory) + public InstanceEntryPointTableNode(NodeFactory factory, bool auxiliary = false) { _factory = factory; + IsAuxiliary = auxiliary; _factory.ManifestMetadataTable.RegisterEmitter(this); } @@ -51,7 +55,7 @@ public void MaterializeSignature() public override void AppendMangledName(NameMangler nameMangler, Utf8StringBuilder sb) { sb.Append(nameMangler.CompilationUnitPrefix); - sb.Append("__ReadyToRunInstanceEntryPointTable"u8); + sb.Append(IsAuxiliary ? "__ReadyToRunAuxiliaryInstanceEntryPointTable"u8 : "__ReadyToRunInstanceEntryPointTable"u8); } public static byte[] BuildSignatureForMethodDefinedInModule(MethodDesc method, NodeFactory factory) @@ -100,6 +104,7 @@ public override ObjectData GetData(NodeFactory factory, bool relocsOnly = false) NativeWriter hashtableWriter = new NativeWriter(); + Section fixupsSection = IsAuxiliary ? hashtableWriter.NewSection() : null; Section hashtableSection = hashtableWriter.NewSection(); VertexHashtable vertexHashtable = new VertexHashtable(); hashtableSection.Place(vertexHashtable); @@ -109,6 +114,9 @@ public override ObjectData GetData(NodeFactory factory, bool relocsOnly = false) foreach (MethodWithGCInfo method in factory.EnumerateCompiledMethods(null, CompiledMethodCategory.Instantiated)) { + if (factory.IsAuxiliaryMethod(method) != IsAuxiliary) + continue; + // Resumption stubs are discovered via READYTORUN_FIXUP_ResumptionStubEntryPoint fixups // on their parent async variant methods, so they do not need entries in the InstanceEntryPointTable. if (method.Method is AsyncResumptionStub) @@ -136,6 +144,7 @@ public override ObjectData GetData(NodeFactory factory, bool relocsOnly = false) { fixupBlob = new BlobVertex(fixup); uniqueFixups.Add(fixup, fixupBlob); + fixupsSection?.Place(fixupBlob); } EntryPointVertex entryPointVertex = new EntryPointWithBlobVertex((uint)methodIndex, fixupBlob, signatureBlob); @@ -150,6 +159,7 @@ public override ObjectData GetData(NodeFactory factory, bool relocsOnly = false) MemoryStream hashtableContent = new MemoryStream(); hashtableWriter.Save(hashtableContent); + _entryPointsOffset = vertexHashtable.VertexOffset; return new ObjectData( data: hashtableContent.ToArray(), relocs: null, @@ -157,6 +167,6 @@ public override ObjectData GetData(NodeFactory factory, bool relocsOnly = false) definedSymbols: new ISymbolDefinitionNode[] { this }); } - public override int ClassCode => -348722540; + public override int ClassCode => IsAuxiliary ? -348722539 : -348722540; } } diff --git a/src/coreclr/tools/aot/ILCompiler.ReadyToRun/Compiler/DependencyAnalysis/ReadyToRun/MethodEntryPointTableNode.cs b/src/coreclr/tools/aot/ILCompiler.ReadyToRun/Compiler/DependencyAnalysis/ReadyToRun/MethodEntryPointTableNode.cs index ff1a46918b186e..b30e31c84ae3a7 100644 --- a/src/coreclr/tools/aot/ILCompiler.ReadyToRun/Compiler/DependencyAnalysis/ReadyToRun/MethodEntryPointTableNode.cs +++ b/src/coreclr/tools/aot/ILCompiler.ReadyToRun/Compiler/DependencyAnalysis/ReadyToRun/MethodEntryPointTableNode.cs @@ -17,6 +17,10 @@ namespace ILCompiler.DependencyAnalysis.ReadyToRun { public class MethodEntryPointTableNode : ModuleSpecificHeaderTableNode { + public bool IsAuxiliary { get; } + public override int Offset => _entryPointsOffset; + private int _entryPointsOffset; + private struct EntryPoint { public static EntryPoint Null = new EntryPoint(-1, null); @@ -33,11 +37,14 @@ public EntryPoint(int methodIndex, MethodWithGCInfo method) } } - public MethodEntryPointTableNode(EcmaModule module) : base(module) + public MethodEntryPointTableNode(EcmaModule module, bool auxiliary = false) : base(module) { + IsAuxiliary = auxiliary; } - protected override string ModuleSpecificName => "__ReadyToRunMethodEntryPointTable__"; + protected override string ModuleSpecificName => IsAuxiliary + ? "__ReadyToRunAuxiliaryMethodEntryPointTable__" + : "__ReadyToRunMethodEntryPointTable__"; public override ObjectData GetData(NodeFactory factory, bool relocsOnly = false) { @@ -50,6 +57,9 @@ public override ObjectData GetData(NodeFactory factory, bool relocsOnly = false) foreach (MethodWithGCInfo method in factory.EnumerateCompiledMethods(_module, CompiledMethodCategory.NonInstantiated)) { + if (factory.IsAuxiliaryMethod(method) != IsAuxiliary) + continue; + Debug.Assert(method.Method is EcmaMethod); EcmaMethod ecmaMethod = (EcmaMethod)method.Method; Debug.Assert(ecmaMethod.Module == _module); @@ -70,6 +80,7 @@ public override ObjectData GetData(NodeFactory factory, bool relocsOnly = false) NativeWriter writer = new NativeWriter(); + Section fixupsSection = IsAuxiliary ? writer.NewSection() : null; Section arraySection = writer.NewSection(); VertexArray vertexArray = new VertexArray(arraySection); arraySection.Place(vertexArray); @@ -88,6 +99,7 @@ public override ObjectData GetData(NodeFactory factory, bool relocsOnly = false) { fixupBlobVertex = new BlobVertex(fixups); uniqueFixups.Add(fixups, fixupBlobVertex); + fixupsSection?.Place(fixupBlobVertex); } EntryPointVertex entryPointVertex = new EntryPointVertex((uint)entryPoint.MethodIndex, fixupBlobVertex); vertexArray.Set(rid, entryPointVertex); @@ -98,6 +110,7 @@ public override ObjectData GetData(NodeFactory factory, bool relocsOnly = false) MemoryStream arrayContent = new MemoryStream(); writer.Save(arrayContent); + _entryPointsOffset = vertexArray.VertexOffset; return new ObjectData( data: arrayContent.ToArray(), relocs: null, @@ -106,6 +119,6 @@ public override ObjectData GetData(NodeFactory factory, bool relocsOnly = false) } protected internal override int Phase => (int)ObjectNodePhase.Ordered; - public override int ClassCode => (int)ObjectNodeOrder.MethodEntrypointTableNode; + public override int ClassCode => (int)ObjectNodeOrder.MethodEntrypointTableNode + (IsAuxiliary ? 1000 : 0); } } diff --git a/src/coreclr/tools/aot/ILCompiler.ReadyToRun/Compiler/DependencyAnalysis/ReadyToRun/MethodGCInfoNode.cs b/src/coreclr/tools/aot/ILCompiler.ReadyToRun/Compiler/DependencyAnalysis/ReadyToRun/MethodGCInfoNode.cs index 850ec40f1a7c14..a587d7ddf78391 100644 --- a/src/coreclr/tools/aot/ILCompiler.ReadyToRun/Compiler/DependencyAnalysis/ReadyToRun/MethodGCInfoNode.cs +++ b/src/coreclr/tools/aot/ILCompiler.ReadyToRun/Compiler/DependencyAnalysis/ReadyToRun/MethodGCInfoNode.cs @@ -256,11 +256,13 @@ public MethodGCInfoNodeDeduplicatingComparer(NodeFactory factory) NodeFactory _factory; public bool Equals(MethodGCInfoNode a, MethodGCInfoNode b) { - return a.EncodeDataCore(_factory).SequenceEqual(b.EncodeDataCore(_factory)); + return _factory.IsAuxiliaryMethod(a._methodNode) == _factory.IsAuxiliaryMethod(b._methodNode) && + a.EncodeDataCore(_factory).SequenceEqual(b.EncodeDataCore(_factory)); } public int GetHashCode(MethodGCInfoNode node) { HashCode hashcode = new HashCode(); + hashcode.Add(_factory.IsAuxiliaryMethod(node._methodNode)); foreach (var item in node.EncodeDataCore(_factory)) { hashcode.Add(item); @@ -285,6 +287,7 @@ public override void EncodeData(ref ObjectDataBuilder dataBuilder, NodeFactory f factory.RuntimeFunctionsGCInfo.Deduplicator.Add(this); bool targetAlignsGCInfoComponents = TargetAlignsGCInfoComponents(factory.Target.Architecture); + int startOffset = dataBuilder.CountBytes; foreach (var item in EncodeDataCore(factory)) { @@ -303,6 +306,8 @@ public override void EncodeData(ref ObjectDataBuilder dataBuilder, NodeFactory f dataBuilder.EmitReloc(item.Symbol, RelocType.IMAGE_REL_BASED_ADDR32NB, item.SymbolDelta); } } + if (factory.IsAuxiliaryMethod(_methodNode)) + factory.RuntimeFunctionsGCInfo.AuxiliaryDataRanges.Add((startOffset, dataBuilder.CountBytes - startOffset)); } protected override string GetName(NodeFactory context) diff --git a/src/coreclr/tools/aot/ILCompiler.ReadyToRun/Compiler/DependencyAnalysis/ReadyToRun/MethodWithGCInfo.cs b/src/coreclr/tools/aot/ILCompiler.ReadyToRun/Compiler/DependencyAnalysis/ReadyToRun/MethodWithGCInfo.cs index febeb3ed56391b..f4d5498bfeaf0a 100644 --- a/src/coreclr/tools/aot/ILCompiler.ReadyToRun/Compiler/DependencyAnalysis/ReadyToRun/MethodWithGCInfo.cs +++ b/src/coreclr/tools/aot/ILCompiler.ReadyToRun/Compiler/DependencyAnalysis/ReadyToRun/MethodWithGCInfo.cs @@ -268,6 +268,11 @@ public byte[] GetFixupBlob(NodeFactory factory) protected override void ComputeNonRelocationBasedDependencies(DependencySink sink, NodeFactory factory) { sink.Add(GCInfoNode, "Unwind & GC info"); + if (factory.UsesWasmAuxiliaryModules && factory.IsAuxiliaryMethod(this)) + { + sink.Add(factory.WasmR2RToInterpreterThunk(WasmLowering.GetSignature(_method)), + "Interpreter fallback for auxiliary method"); + } if (this.ColdCodeNode != null) { diff --git a/src/coreclr/tools/aot/ILCompiler.ReadyToRun/Compiler/DependencyAnalysis/ReadyToRun/ReadyToRunHeaderNode.cs b/src/coreclr/tools/aot/ILCompiler.ReadyToRun/Compiler/DependencyAnalysis/ReadyToRun/ReadyToRunHeaderNode.cs index b62c14241370e0..9d6b0ef9c45c54 100644 --- a/src/coreclr/tools/aot/ILCompiler.ReadyToRun/Compiler/DependencyAnalysis/ReadyToRun/ReadyToRunHeaderNode.cs +++ b/src/coreclr/tools/aot/ILCompiler.ReadyToRun/Compiler/DependencyAnalysis/ReadyToRun/ReadyToRunHeaderNode.cs @@ -18,7 +18,7 @@ public abstract class HeaderTableNode : ObjectNode, ISymbolDefinitionNode { public abstract void AppendMangledName(NameMangler nameMangler, Utf8StringBuilder sb); - public int Offset => 0; + public virtual int Offset => 0; public override bool IsShareable => false; diff --git a/src/coreclr/tools/aot/ILCompiler.ReadyToRun/Compiler/DependencyAnalysis/ReadyToRun/RuntimeFunctionsGCInfoNode.cs b/src/coreclr/tools/aot/ILCompiler.ReadyToRun/Compiler/DependencyAnalysis/ReadyToRun/RuntimeFunctionsGCInfoNode.cs index aad3718edb6938..312175b06eafee 100644 --- a/src/coreclr/tools/aot/ILCompiler.ReadyToRun/Compiler/DependencyAnalysis/ReadyToRun/RuntimeFunctionsGCInfoNode.cs +++ b/src/coreclr/tools/aot/ILCompiler.ReadyToRun/Compiler/DependencyAnalysis/ReadyToRun/RuntimeFunctionsGCInfoNode.cs @@ -13,6 +13,7 @@ public RuntimeFunctionsGCInfoNode() } public HashSet Deduplicator; + public readonly List<(int Offset, int Length)> AuxiliaryDataRanges = new(); protected internal override int Phase => (int)ObjectNodePhase.Ordered; diff --git a/src/coreclr/tools/aot/ILCompiler.ReadyToRun/Compiler/DependencyAnalysis/ReadyToRunCodegenNodeFactory.cs b/src/coreclr/tools/aot/ILCompiler.ReadyToRun/Compiler/DependencyAnalysis/ReadyToRunCodegenNodeFactory.cs index 2f36734970cb45..0568cdc5fa0ba5 100644 --- a/src/coreclr/tools/aot/ILCompiler.ReadyToRun/Compiler/DependencyAnalysis/ReadyToRunCodegenNodeFactory.cs +++ b/src/coreclr/tools/aot/ILCompiler.ReadyToRun/Compiler/DependencyAnalysis/ReadyToRunCodegenNodeFactory.cs @@ -6,6 +6,7 @@ using System.Collections.Generic; using System.Diagnostics; using System.Linq; +using System.Reflection; using ILCompiler.DependencyAnalysis.Wasm; using ILCompiler.DependencyAnalysis.ReadyToRun; @@ -72,6 +73,7 @@ public struct NodeFactoryOptimizationFlags public bool StripInliningInfo; public bool StripDebugInfo; public bool StripILBodies; + public bool WasmAuxiliaryModules; public HashSet CompiledMethodDefs; } @@ -106,6 +108,23 @@ public sealed partial class NodeFactory List _markedILBodyFixupSignatures = new List(); public bool MarkingComplete => _markingComplete; + public bool UsesWasmAuxiliaryModules => OptimizationFlags.WasmAuxiliaryModules && !OptimizationFlags.IsComponentModule; + + public bool IsAuxiliaryMethod(MethodWithGCInfo method) + { + if (!UsesWasmAuxiliaryModules || method.IsJitHelper) + return false; + + MethodDesc target = method.Method; + MethodDesc definition = target.GetTypicalMethodDefinition(); + if (definition is not EcmaMethod ecmaMethod || target.IsAsyncVariant() || + target.IsAsyncCall() || target.IsUnmanagedCallersOnly || + target.IsPInvoke || ecmaMethod.MetadataReader.GetMethodDefinition(ecmaMethod.Handle).RelativeVirtualAddress == 0) + return false; + + return (ecmaMethod.Attributes & MethodAttributes.MemberAccessMask) != MethodAttributes.Public || + target.HasInstantiation || target.OwningType.HasInstantiation; + } public void GenerateHotColdMap(DependencyAnalyzerBase dependencyGraph) { @@ -598,6 +617,7 @@ private void CreateNodeCaches() public SymbolNodeRange DelayLoadMethodCallThunks; public InstanceEntryPointTableNode InstanceEntryPointTable; + public AuxiliaryModuleMetadataNode AuxiliaryModuleMetadata; public ManifestMetadataTableNode ManifestMetadataTable; @@ -1144,6 +1164,11 @@ public void AttachToDependencyGraph(DependencyAnalyzerBase graph, I MethodEntryPointTableNode methodEntryPointTable = new MethodEntryPointTableNode(inputModule); tableHeader.Add(Internal.Runtime.ReadyToRunSectionType.MethodDefEntryPoints, methodEntryPointTable); + if (UsesWasmAuxiliaryModules) + { + tableHeader.Add(Internal.Runtime.ReadyToRunSectionType.AuxiliaryMethodDefEntryPoints, + new MethodEntryPointTableNode(inputModule, auxiliary: true)); + } TypesTableNode typesTable = new TypesTableNode(inputModule); tableHeader.Add(Internal.Runtime.ReadyToRunSectionType.AvailableTypes, typesTable); @@ -1200,6 +1225,13 @@ public void AttachToDependencyGraph(DependencyAnalyzerBase graph, I InstanceEntryPointTable = new InstanceEntryPointTableNode(this); Header.Add(Internal.Runtime.ReadyToRunSectionType.InstanceMethodEntryPoints, InstanceEntryPointTable); + if (UsesWasmAuxiliaryModules) + { + Header.Add(Internal.Runtime.ReadyToRunSectionType.AuxiliaryInstanceMethodEntryPoints, + new InstanceEntryPointTableNode(this, auxiliary: true)); + AuxiliaryModuleMetadata = new AuxiliaryModuleMetadataNode(); + Header.Add(Internal.Runtime.ReadyToRunSectionType.AuxiliaryModuleMetadata, AuxiliaryModuleMetadata); + } ImportSectionsTable = new ImportSectionsTableNode(this); Header.Add(Internal.Runtime.ReadyToRunSectionType.ImportSections, ImportSectionsTable); diff --git a/src/coreclr/tools/aot/ILCompiler.ReadyToRun/Compiler/ReadyToRunCodegenCompilation.cs b/src/coreclr/tools/aot/ILCompiler.ReadyToRun/Compiler/ReadyToRunCodegenCompilation.cs index 2b3317ee0a1520..54d41a37d0a0b1 100644 --- a/src/coreclr/tools/aot/ILCompiler.ReadyToRun/Compiler/ReadyToRunCodegenCompilation.cs +++ b/src/coreclr/tools/aot/ILCompiler.ReadyToRun/Compiler/ReadyToRunCodegenCompilation.cs @@ -4,6 +4,7 @@ using System; using System.Collections.Concurrent; using System.Collections.Generic; +using System.Collections.Immutable; using System.Diagnostics; using System.IO; using System.Runtime.CompilerServices; @@ -325,6 +326,9 @@ public sealed class ReadyToRunCodegenCompilation : Compilation internal bool IsDirectManagedHelperEligible(MethodDesc method) { + if (NodeFactory.UsesWasmAuxiliaryModules) + return false; + if (NodeFactory.Target.Architecture is TargetArchitecture.ARM or TargetArchitecture.ARM64) return false; @@ -435,6 +439,18 @@ public override void Compile(string outputFile) var nodes = _dependencyGraph.MarkedNodeList; nodes = _fileLayoutOptimizer.ApplyProfilerGuidedMethodSort(nodes); + if (NodeFactory.UsesWasmAuxiliaryModules) + { + int hotIndex = 0; + int coldIndex = nodes.Length; + foreach (DependencyNodeCore node in nodes) + { + if (node is MethodWithGCInfo method) + method.CustomSort = NodeFactory.IsAuxiliaryMethod(method) ? coldIndex++ : hotIndex++; + } + nodes = nodes.Sort(new SortableDependencyNode.ObjectNodeComparer(CompilerComparer.Instance)); + NodeFactory.AuxiliaryModuleMetadata.OutputFileName = Path.GetFileNameWithoutExtension(outputFile); + } using (PerfEventSource.StartStopEvents.EmittingEvents()) { diff --git a/src/coreclr/tools/aot/ILCompiler.ReadyToRun/ILCompiler.ReadyToRun.csproj b/src/coreclr/tools/aot/ILCompiler.ReadyToRun/ILCompiler.ReadyToRun.csproj index 884a896251a5a6..1a03247115785a 100644 --- a/src/coreclr/tools/aot/ILCompiler.ReadyToRun/ILCompiler.ReadyToRun.csproj +++ b/src/coreclr/tools/aot/ILCompiler.ReadyToRun/ILCompiler.ReadyToRun.csproj @@ -209,6 +209,7 @@ + @@ -301,6 +302,7 @@ + diff --git a/src/coreclr/tools/aot/crossgen2/Crossgen2RootCommand.cs b/src/coreclr/tools/aot/crossgen2/Crossgen2RootCommand.cs index 22bac7896d6f0f..31efb56637ec7f 100644 --- a/src/coreclr/tools/aot/crossgen2/Crossgen2RootCommand.cs +++ b/src/coreclr/tools/aot/crossgen2/Crossgen2RootCommand.cs @@ -134,6 +134,8 @@ internal class Crossgen2RootCommand : RootCommand new("--perfmap-format-version") { DefaultValueFactory = _ => 0, Description = SR.PerfMapFormatVersionOption }; public Option WasmDebugInfoOption { get; } = new("--wasm-debug-info") { CustomParser = MakeWasmDebugInfo, DefaultValueFactory = MakeWasmDebugInfo, Description = SR.WasmDebugInfoOption, HelpName = "formats" }; + public Option WasmAuxiliaryModules { get; } = + new("--wasm-auxiliary-modules") { Description = SR.WasmAuxiliaryModulesOption }; public Option CrossModuleInlining { get; } = new("--opt-cross-module") { Description = SR.CrossModuleInlining }; public Option AsyncMethodOptimization { get; } = @@ -233,6 +235,7 @@ public Crossgen2RootCommand(string[] args) : base(SR.Crossgen2BannerText) Options.Add(PerfMapPath); Options.Add(PerfMapFormatVersion); Options.Add(WasmDebugInfoOption); + Options.Add(WasmAuxiliaryModules); Options.Add(CrossModuleInlining); Options.Add(AsyncMethodOptimization); Options.Add(NonLocalGenericsModule); diff --git a/src/coreclr/tools/aot/crossgen2/Program.cs b/src/coreclr/tools/aot/crossgen2/Program.cs index b55537a97d61c9..653ae775cfcbd1 100644 --- a/src/coreclr/tools/aot/crossgen2/Program.cs +++ b/src/coreclr/tools/aot/crossgen2/Program.cs @@ -91,6 +91,19 @@ public int Run() ?? GetTargetAllowsRuntimeCodeGeneration(targetOS, targetArchitecture); bool targetIsSingleThreaded = Get(_command.TargetSingleThreaded) ?? GetTargetIsSingleThreaded(targetArchitecture); + if (Get(_command.WasmAuxiliaryModules) && + (targetArchitecture != TargetArchitecture.Wasm32 || targetOS != TargetOS.Browser || + !Get(_command.Composite) || !targetIsSingleThreaded || Get(_command.StripILBodies) || + Get(_command.OutputFormat) == ReadyToRunContainerFormat.MachO)) + { + throw new CommandLineException(SR.WasmAuxiliaryModulesUnsupportedConfiguration); + } + if (Get(_command.WasmAuxiliaryModules) && + (Get(_command.Map) || Get(_command.MapCsv) || Get(_command.Pdb) || Get(_command.PerfMap) || + !string.IsNullOrEmpty(Get(_command.CallChainProfileFile)))) + { + throw new CommandLineException(SR.WasmAuxiliaryModulesUnsupportedDiagnostics); + } // Crossgen2 is partial AOT and its pre-compiled methods can be thrown away at runtime if // they mismatch in required ISAs or computed layouts of structs. On targets that allow @@ -691,6 +704,7 @@ private void RunSingleCompilation(Dictionary inFilePaths, Instru nodeFactoryFlags.StripInliningInfo = Get(_command.StripInliningInfo); nodeFactoryFlags.StripDebugInfo = Get(_command.StripDebugInfo); nodeFactoryFlags.StripILBodies = Get(_command.StripILBodies); + nodeFactoryFlags.WasmAuxiliaryModules = Get(_command.WasmAuxiliaryModules); builder .UseMapFile(Get(_command.Map)) diff --git a/src/coreclr/tools/aot/crossgen2/Properties/Resources.resx b/src/coreclr/tools/aot/crossgen2/Properties/Resources.resx index 3929bdad4d5fde..071f879cc987e6 100644 --- a/src/coreclr/tools/aot/crossgen2/Properties/Resources.resx +++ b/src/coreclr/tools/aot/crossgen2/Properties/Resources.resx @@ -477,4 +477,13 @@ Replace IL method bodies of compiled methods with a throwing stub in composite R2R output assemblies. + + Partition a single-threaded composite Browser WebAssembly image into a primary image and delayed auxiliary modules. + + + --wasm-auxiliary-modules requires --targetarch wasm --targetos browser --composite, the WebAssembly output format, a single-threaded target, and retained IL bodies (without --strip-il-bodies). + + + --wasm-auxiliary-modules supports WebAssembly name sections and symbol maps, but not single-image map, PDB, perfmap, or call-chain profile files. + \ No newline at end of file diff --git a/src/coreclr/tools/aot/crossgen2/README.md b/src/coreclr/tools/aot/crossgen2/README.md new file mode 100644 index 00000000000000..e2d59341804702 --- /dev/null +++ b/src/coreclr/tools/aot/crossgen2/README.md @@ -0,0 +1,51 @@ +# Crossgen2 partitioned Browser-WASM output + +`--wasm-auxiliary-modules` partitions a composite Browser-WASM ReadyToRun image +into independently activated WebAssembly modules. It requires a single-threaded +target and retained IL; it cannot be combined with `--strip-il-bodies`. + +For example: + +```shell +crossgen2 app.dll --composite --targetarch wasm --targetos browser \ + --wasm-auxiliary-modules -r:System.Private.CoreLib.dll -o:app.r2r.wasm +``` + +In addition to the composite image and its component forwarding images, this emits: + +| File | Contents | +|---|---| +| `app.r2r.wasm` | Hot methods, interpreter fallback thunks, shared imports and reserved logical memory/table layout | +| `app.r2r.entrypoints.wasm` | Auxiliary sparse and instantiated-method entrypoint records | +| `app.r2r.1.wasm` | Cold methods and funclets, their fixup lists, exclusive import cells/signatures, GC/unwind and exception data | +| `app.r2r.auxiliary.json` | Sibling filenames: `{"entrypoints":"app.r2r.entrypoints.wasm","modules":["app.r2r.1.wasm"]}` | + +Public nongeneric methods initially stay hot. Compiler-generated wrappers, +unmanaged entrypoints, runtime helpers and async/resumption methods also stay hot. +Other eligible IL methods go into the cold module. Managed calls use indirect +imports, and non-managed helper function bodies are duplicated where needed. + +All modules use the primary image's `webcil` imports and the same memory/table +bases. Sidecar instantiation does not install code or data. Its idempotent +`ActivateModule()` export installs passive data, rebases its owned import cells, +installs cold table entries, then publishes activation status. The entrypoint and +body sidecars can activate in either order. The host must notify the runtime when +entrypoints activate. + +The primary exports `getAuxiliaryModuleMetadata(): i32`. It returns an absolute +pointer to section 128, or zero for an ordinary image. The header has four +little-endian DWORDs: entrypoint status, primary managed function count, auxiliary +module count, and entrypoint filename RVA. Each auxiliary descriptor has four +DWORDs: filename RVA, table-relative start, managed function count, and status. +Counts include funclets but exclude helper slots. Status zero means pending; +status one means active. Sections 129 and 130 hold auxiliary per-component +MethodDef and image-wide instantiated-method entrypoints. + +Shared/eager imports, method read-only/writable data and async resume-info remain +resident. The final runtime-function entry and its unwind data also remain +resident so the runtime can reserve the complete virtual-IP range before download. +Cold metadata never overwrites a fixup cell that primary code can resolve. + +Per-module WebAssembly name sections and primary symbol maps are supported. +Single-image map, PDB, perfmap and call-chain profile output is rejected because +its file-relative addresses cannot describe multiple physical modules. diff --git a/src/coreclr/vm/codeman.cpp b/src/coreclr/vm/codeman.cpp index 26dbc0d7d43989..b3e439074d0104 100644 --- a/src/coreclr/vm/codeman.cpp +++ b/src/coreclr/vm/codeman.cpp @@ -5975,6 +5975,8 @@ TADDR ExecutionManager::GetWasmVirtualIPFromFunctionTableIndex(DWORD functionInd ReadyToRunInfo* pR2RInfo = pModule->GetReadyToRunInfo(); DWORD localIndex = functionIndex - pSection->minFunctionTableIndex; + if (!pR2RInfo->IsRuntimeFunctionActive(localIndex)) + return 0; do { PTR_RUNTIME_FUNCTION pRuntimeFunction = pR2RInfo->GetRuntimeFunctions() + localIndex; @@ -6046,32 +6048,11 @@ TADDR ExecutionManager::GetWasmFunctionTableIndexFromVirtualIP(TADDR virtualIP) return 0; } - DWORD localVirtualIP32 = (DWORD)localVirtualIP; - PTR_RUNTIME_FUNCTION pRuntimeFunctions = pR2RInfo->GetRuntimeFunctions(); - - // Find the function entry with the greatest BeginAddress <= localVirtualIP. - DWORD low = 0; - DWORD high = runtimeFunctionCount; - while (low + 1 < high) - { - DWORD mid = low + ((high - low) / 2); - DWORD beginAddress = RUNTIME_FUNCTION__BeginAddress(pRuntimeFunctions + mid); - if (beginAddress <= localVirtualIP32) - { - low = mid; - } - else - { - high = mid; - } - } - - if (RUNTIME_FUNCTION__BeginAddress(pRuntimeFunctions + low) > localVirtualIP32) - { + int functionIndex = pR2RInfo->FindRuntimeFunction(static_cast(localVirtualIP)); + if (functionIndex < 0) return 0; - } - return pR2RInfo->GetMinFunctionTableIndex() + low; + return pR2RInfo->GetMinFunctionTableIndex() + static_cast(functionIndex); } #endif // TARGET_WASM @@ -6503,14 +6484,14 @@ PCODE ExecutionManager::getNextJumpStub(MethodDesc* pMD, PCODE target, #endif // !DACCESS_COMPILE static void GetFuncletStartOffsetsHelper(PCODE pCodeStart, SIZE_T size, SIZE_T ofsAdj, - PTR_RUNTIME_FUNCTION pFunctionEntry, TADDR moduleBase, + PTR_RUNTIME_FUNCTION pFunctionEntry, PTR_RUNTIME_FUNCTION pFunctionTableEnd, TADDR moduleBase, DWORD * pnFunclets, DWORD* pStartFuncletOffsets, DWORD dwLength) { _ASSERTE(FitsInU4((pCodeStart + size) - moduleBase)); DWORD endAddress = (DWORD)((pCodeStart + size) - moduleBase); - // Entries are sorted and terminated by sentinel value (DWORD)-1 - for (; RUNTIME_FUNCTION__BeginAddress(pFunctionEntry) < endAddress; pFunctionEntry++) + // An auxiliary module's next range may not be installed yet. + for (; pFunctionEntry < pFunctionTableEnd && RUNTIME_FUNCTION__BeginAddress(pFunctionEntry) < endAddress; pFunctionEntry++) { #ifdef TARGET_AMD64 _ASSERTE((pFunctionEntry->UnwindData & RUNTIME_FUNCTION_INDIRECT) == 0); @@ -7215,13 +7196,17 @@ BOOL ReadyToRunJitManager::JitCodeToMethodInfo(RangeSection * pRangeSection, Module * pModule = pRangeSection->_pR2RModule; ReadyToRunInfo * pInfo = pModule->GetReadyToRunInfo(); - COUNT_T nRuntimeFunctions = pInfo->m_nRuntimeFunctions; PTR_RUNTIME_FUNCTION pRuntimeFunctions = pInfo->m_pRuntimeFunctions; +#ifdef TARGET_WASM + int MethodIndex = pInfo->FindRuntimeFunction(RelativePc); +#else + COUNT_T nRuntimeFunctions = pInfo->m_nRuntimeFunctions; int MethodIndex = NativeUnwindInfoLookupTable::LookupUnwindInfoForMethod(RelativePc, pRuntimeFunctions, 0, nRuntimeFunctions - 1); +#endif // TARGET_WASM if (MethodIndex < 0) return FALSE; @@ -7310,6 +7295,12 @@ TADDR ReadyToRunJitManager::GetFuncletStartAddress(EECodeInfo * pCodeInfo) DWORD ReadyToRunJitManager::GetFuncletStartOffsets(const METHODTOKEN& MethodToken, DWORD* pStartFuncletOffsets, DWORD dwLength) { PTR_RUNTIME_FUNCTION pFirstFuncletFunctionEntry = dac_cast(MethodToken.m_pCodeHeader) + 1; + ReadyToRunInfo* pInfo = JitTokenToReadyToRunInfo(MethodToken); + DWORD rangeEnd = pInfo->GetRuntimeFunctionCount(); +#ifdef TARGET_WASM + rangeEnd = pInfo->GetRuntimeFunctionRangeEnd( + static_cast(pFirstFuncletFunctionEntry - pInfo->GetRuntimeFunctions() - 1)); +#endif TADDR moduleBase = JitTokenToModuleFunctionsBase(MethodToken); DWORD nFunclets = 0; @@ -7322,13 +7313,12 @@ DWORD ReadyToRunJitManager::GetFuncletStartOffsets(const METHODTOKEN& MethodToke // fragments until the first funclet, if any, is found. GetFuncletStartOffsetsHelper(regionInfo.hotStartAddress, regionInfo.hotSize, 0, - pFirstFuncletFunctionEntry, moduleBase, + pFirstFuncletFunctionEntry, pInfo->GetRuntimeFunctions() + rangeEnd, moduleBase, &nFunclets, pStartFuncletOffsets, dwLength); // Technically, the cold code is not a funclet, but it looks like the debugger wants it if (regionInfo.coldSize > 0) { - ReadyToRunInfo * pInfo = JitTokenToReadyToRunInfo(MethodToken); PTR_RUNTIME_FUNCTION pRuntimeFunctions = pInfo->m_pRuntimeFunctions; int i = 0; while (true) @@ -7342,7 +7332,7 @@ DWORD ReadyToRunJitManager::GetFuncletStartOffsets(const METHODTOKEN& MethodToke } GetFuncletStartOffsetsHelper(regionInfo.coldStartAddress, regionInfo.coldSize, 0, - pFirstFuncletFunctionEntry, moduleBase, + pFirstFuncletFunctionEntry, pRuntimeFunctions + pInfo->GetRuntimeFunctionCount(), moduleBase, &nFunclets, pStartFuncletOffsets, dwLength); } diff --git a/src/coreclr/vm/interpexec.h b/src/coreclr/vm/interpexec.h index 27045e09803688..42df461545f6db 100644 --- a/src/coreclr/vm/interpexec.h +++ b/src/coreclr/vm/interpexec.h @@ -130,11 +130,9 @@ inline bool AuxiliaryMethodInfo::ShouldTryActivateMethod() const switch (discriminator) { case AuxiliaryMethodInfoDiscriminator::CheckAuxiliaryModuleActivationFlag: - // TODO: remove the simulated download once auxiliary modules are separate ReadyToRunInfo::AdvanceSimulatedAuxiliaryModuleDownload(auxiliaryModuleActivationFlag); return ReadyToRunInfo::IsAuxiliaryModuleActivated(auxiliaryModuleActivationFlag); case AuxiliaryMethodInfoDiscriminator::CheckAuxiliaryEntryPointsEra: - // TODO: remove the simulated download once auxiliary EntryPoints modules are separate ReadyToRunInfo::AdvanceSimulatedAuxiliaryEntryPointsDownload(); return ReadyToRunInfo::IsAuxiliaryEntryPointsEraStale(auxiliaryEntryPointsEra); default: diff --git a/src/coreclr/vm/method.hpp b/src/coreclr/vm/method.hpp index eaa39576ec3b19..75dd16d38b3d71 100644 --- a/src/coreclr/vm/method.hpp +++ b/src/coreclr/vm/method.hpp @@ -2447,7 +2447,6 @@ class PrepareCodeConfig #ifdef FEATURE_AUXILIARY_R2R_MODULES // Activation status of the auxiliary module holding the method's R2R code, set when lookup delays it. - // TODO: This points at a shared countdown standing in for the auxiliary module's activation flag. PTR_DWORD GetAuxiliaryModuleActivationFlag() const { LIMITED_METHOD_CONTRACT; diff --git a/src/coreclr/vm/readytoruninfo.cpp b/src/coreclr/vm/readytoruninfo.cpp index 533279dc653a12..29ae725ce2e0ac 100644 --- a/src/coreclr/vm/readytoruninfo.cpp +++ b/src/coreclr/vm/readytoruninfo.cpp @@ -91,6 +91,93 @@ IMAGE_DATA_DIRECTORY * ReadyToRunCoreInfo::FindSection(ReadyToRunSectionType typ return NULL; } +#ifdef TARGET_WASM +DWORD ReadyToRunInfo::GetRuntimeFunctionRangeEnd(DWORD index) const +{ + LIMITED_METHOD_CONTRACT; + _ASSERTE(index < m_nRuntimeFunctions); +#ifdef FEATURE_AUXILIARY_R2R_MODULES + if (m_pAuxiliaryModuleMetadata != nullptr) + { + PTR_READYTORUN_AUXILIARY_MODULE entry = GetAuxiliaryModule(index); + return entry == nullptr ? m_pAuxiliaryModuleMetadata->PrimaryModuleMethodCount : + entry->RelativeTableBase + entry->MethodCount; + } +#endif + return m_nRuntimeFunctions; +} + +bool ReadyToRunInfo::IsRuntimeFunctionActive(DWORD index) const +{ + LIMITED_METHOD_DAC_CONTRACT; + if (index >= m_nRuntimeFunctions) + return false; +#ifdef FEATURE_AUXILIARY_R2R_MODULES + if (m_pAuxiliaryModuleMetadata != nullptr) + { + PTR_DWORD activationFlag = GetAuxiliaryModuleActivationFlag(index); + return activationFlag == nullptr || IsAuxiliaryModuleActivated(activationFlag); + } +#endif // FEATURE_AUXILIARY_R2R_MODULES + return true; +} + +int ReadyToRunInfo::FindRuntimeFunction(DWORD relativeVirtualIP) const +{ + LIMITED_METHOD_DAC_CONTRACT; + + DWORD rangeCount = 1; +#ifdef FEATURE_AUXILIARY_R2R_MODULES + PTR_READYTORUN_AUXILIARY_MODULE entries = nullptr; + if (m_pAuxiliaryModuleMetadata != nullptr) + { + rangeCount += m_pAuxiliaryModuleMetadata->AuxiliaryModuleCount; + entries = dac_cast( + dac_cast(m_pAuxiliaryModuleMetadata) + sizeof(READYTORUN_AUXILIARY_MODULE_METADATA)); + } +#endif // FEATURE_AUXILIARY_R2R_MODULES + + for (DWORD range = 0; range < rangeCount; range++) + { + DWORD first = 0; + DWORD count = m_nRuntimeFunctions; +#ifdef FEATURE_AUXILIARY_R2R_MODULES + if (entries != nullptr) + { + if (range == 0) + count = m_pAuxiliaryModuleMetadata->PrimaryModuleMethodCount; + else + { + if (!IsAuxiliaryModuleActivated(&entries[range - 1].ActivationStatus)) + continue; + first = entries[range - 1].RelativeTableBase; + count = entries[range - 1].MethodCount; + } + } +#endif // FEATURE_AUXILIARY_R2R_MODULES + if (count == 0 || relativeVirtualIP < RUNTIME_FUNCTION__BeginAddress(m_pRuntimeFunctions + first)) + continue; + + DWORD low = first; + DWORD high = first + count; + while (low + 1 < high) + { + DWORD middle = low + (high - low) / 2; + if (RUNTIME_FUNCTION__BeginAddress(m_pRuntimeFunctions + middle) <= relativeVirtualIP) + low = middle; + else + high = middle; + } + + // The following entry can belong to an absent module. Read this function's own unwind data instead. + if (relativeVirtualIP < RUNTIME_FUNCTION__EndAddress(m_pRuntimeFunctions + low, + dac_cast(GetImage()->GetBase()))) + return static_cast(low); + } + return -1; +} +#endif // TARGET_WASM + PTR_MethodDesc ReadyToRunInfo::GetMethodDescForEntryPoint(PCODE entryPoint) { CONTRACTL @@ -754,6 +841,8 @@ void ReadyToRunInfo::RegisterUnrelatedR2RModule() } else { + // Publish only a tenured component, so failed module initialization cannot leave a dangling context. + InterlockedCompareExchangeT(&m_pCompositeInfo->m_pEntryPointModule, m_pModule, nullptr); m_pCompositeInfo->RegisterUnrelatedR2RModule(); } } @@ -828,6 +917,7 @@ PTR_ReadyToRunInfo ReadyToRunInfo::ComputeAlternateGenericLocationForR2RCode(Met ReadyToRunInfo::ReadyToRunInfo(Module * pModule, LoaderAllocator* pLoaderAllocator, READYTORUN_HEADER * pHeader, NativeImage *pNativeImage, ReadyToRunLoadedImage * pLayout, AllocMemTracker *pamTracker) : m_pModule(pModule), + m_pEntryPointModule(pModule), m_pHeader(pHeader), m_pNativeImage(pModule != NULL ? pNativeImage: NULL), // m_pNativeImage is only set for composite image components, not the composite R2R info itself m_readyToRunCodeDisabled(FALSE), @@ -859,23 +949,6 @@ ReadyToRunInfo::ReadyToRunInfo(Module * pModule, LoaderAllocator* pLoaderAllocat // In this case, we'll pull the ready to run image layout from pLayout. _ASSERT(pLayout != NULL); m_pCompositeInfo = this; -#ifdef FEATURE_AUXILIARY_R2R_MODULES - // TODO: These should point to the location of the flag in the R2R image - m_pAuxiliaryModuleActivationFlag = (PTR_DWORD)pamTracker->Track(pLoaderAllocator->GetLowFrequencyHeap()->AllocMem(S_SIZE_T(sizeof(DWORD)))); - if (pNativeImage != nullptr) - { - EnsureAuxiliaryEntryPointsModuleStatus(); - m_pAuxiliaryEntryPointsModuleStatus = &s_simulatedAuxiliaryEntryPointsCallsRemaining; - *m_pAuxiliaryModuleActivationFlag = CLRConfig::GetConfigValue(CLRConfig::EXTERNAL_R2RDelayedActivationCallCount); - } - else - { - // Only composite images have auxiliary modules. - m_pAuxiliaryEntryPointsModuleStatus = (PTR_DWORD)pamTracker->Track(pLoaderAllocator->GetLowFrequencyHeap()->AllocMem(S_SIZE_T(sizeof(DWORD)))); - *m_pAuxiliaryEntryPointsModuleStatus = 0; - *m_pAuxiliaryModuleActivationFlag = 0; - } -#endif // FEATURE_AUXILIARY_R2R_MODULES m_component = ReadyToRunCoreInfo(pLayout, &pHeader->CoreHeader); m_pComposite = &m_component; m_isComponentAssembly = false; @@ -944,6 +1017,53 @@ ReadyToRunInfo::ReadyToRunInfo(Module * pModule, LoaderAllocator* pLoaderAllocat m_nRuntimeFunctions = 0; } + IMAGE_DATA_DIRECTORY* pAuxiliaryMetadataDir = m_pComposite->FindSection(ReadyToRunSectionType::AuxiliaryModuleMetadata); + if (pAuxiliaryMetadataDir != nullptr) + { +#ifdef FEATURE_AUXILIARY_R2R_MODULES + if (pAuxiliaryMetadataDir->Size < sizeof(READYTORUN_AUXILIARY_MODULE_METADATA) || + (pAuxiliaryMetadataDir->VirtualAddress & (alignof(DWORD) - 1)) != 0 || + (pNativeImage == nullptr && !m_isComponentAssembly) || + HasStrippedILBodies()) + COMPlusThrowHR(COR_E_BADIMAGEFORMAT); + + m_pAuxiliaryModuleMetadata = dac_cast( + m_pComposite->GetLayout()->GetDirectoryData(pAuxiliaryMetadataDir)); + if (m_pAuxiliaryModuleMetadata->AuxiliaryModuleCount > + (pAuxiliaryMetadataDir->Size - sizeof(READYTORUN_AUXILIARY_MODULE_METADATA)) / sizeof(READYTORUN_AUXILIARY_MODULE) || + m_pAuxiliaryModuleMetadata->PrimaryModuleMethodCount > m_nRuntimeFunctions || + m_pAuxiliaryModuleMetadata->EntryPointsModuleActivationStatus > 1) + COMPlusThrowHR(COR_E_BADIMAGEFORMAT); + + PTR_READYTORUN_AUXILIARY_MODULE entries = dac_cast( + dac_cast(m_pAuxiliaryModuleMetadata) + sizeof(READYTORUN_AUXILIARY_MODULE_METADATA)); + DWORD nextFunction = m_pAuxiliaryModuleMetadata->PrimaryModuleMethodCount; + for (DWORD i = 0; i < m_pAuxiliaryModuleMetadata->AuxiliaryModuleCount; i++) + { + if (entries[i].RelativeTableBase != nextFunction || + entries[i].MethodCount > m_nRuntimeFunctions - nextFunction || + entries[i].ActivationStatus > 1) + COMPlusThrowHR(COR_E_BADIMAGEFORMAT); + nextFunction += entries[i].MethodCount; + } + if (nextFunction != m_nRuntimeFunctions) + COMPlusThrowHR(COR_E_BADIMAGEFORMAT); +#else + COMPlusThrowHR(COR_E_BADIMAGEFORMAT); +#endif // FEATURE_AUXILIARY_R2R_MODULES + } +#ifdef FEATURE_AUXILIARY_R2R_MODULES + else if (!m_isComponentAssembly && pNativeImage != nullptr) + { + EnsureAuxiliaryEntryPointsModuleStatus(); + m_pAuxiliaryEntryPointsModuleStatus = &s_simulatedAuxiliaryEntryPointsCallsRemaining; + m_pAuxiliaryModuleActivationFlag = (PTR_DWORD)pamTracker->Track( + pLoaderAllocator->GetLowFrequencyHeap()->AllocMem(S_SIZE_T(sizeof(DWORD)))); + DWORD callsRemaining = CLRConfig::GetConfigValue(CLRConfig::EXTERNAL_R2RDelayedActivationCallCount); + *m_pAuxiliaryModuleActivationFlag = callsRemaining == UINT32_MAX ? callsRemaining : callsRemaining + 1; + } +#endif // FEATURE_AUXILIARY_R2R_MODULES + #ifdef TARGET_WASM // For WASM, the min function table index is stored as a u32 immediately after the // sentinel entry (0xFFFFFFFF) at the end of the RUNTIME_FUNCTION table. @@ -1207,6 +1327,34 @@ static bool SigMatchesMethodDesc(MethodDesc* pMD, SigPointer &sig, ModuleBase * return true; } +static bool TryGetEntryPointOffset( + MethodDesc* pMD, Module* pModule, NativeArray& methodDefEntryPoints, + NativeHashtable& instanceEntryPoints, uint* pOffset) +{ + STANDARD_VM_CONTRACT; + + if (!pMD->HasClassOrMethodInstantiation() && + !pMD->IsAsyncVariantMethod() && !pMD->IsUnboxingStub()) + return methodDefEntryPoints.TryGetAt(RidFromToken(pMD->GetMemberDef()) - 1, pOffset); + + if (instanceEntryPoints.IsNull()) + return false; + + NativeHashtable::Enumerator lookup = instanceEntryPoints.Lookup(GetVersionResilientMethodHashCode(pMD)); + NativeParser entryParser; + while (lookup.GetNext(entryParser)) + { + PCCOR_SIGNATURE pBlob = (PCCOR_SIGNATURE)entryParser.GetBlob(); + SigPointer sig(pBlob); + if (SigMatchesMethodDesc(pMD, sig, pModule)) + { + *pOffset = entryParser.GetOffset() + static_cast(sig.GetPtr() - pBlob); + return true; + } + } + return false; +} + bool ReadyToRunInfo::GetPgoInstrumentationData(MethodDesc * pMD, BYTE** pAllocatedMemory, ICorJitInfo::PgoInstrumentationSchema**ppSchema, UINT *pcSchema, BYTE** pInstrumentationData) { STANDARD_VM_CONTRACT; @@ -1337,6 +1485,7 @@ PCODE ReadyToRunInfo::GetEntryPoint(MethodDesc * pMD, PrepareCodeConfig* pConfig #endif PCODE pEntryPoint = (PCODE)NULL; + Module* entryPointModule = m_pEntryPointModule; #ifdef PROFILING_SUPPORTED BOOL fShouldSearchCache = TRUE; #endif // PROFILING_SUPPORTED @@ -1350,10 +1499,7 @@ PCODE ReadyToRunInfo::GetEntryPoint(MethodDesc * pMD, PrepareCodeConfig* pConfig // If R2R code is disabled for this module, simply behave as if it is never found if (ReadyToRunCodeDisabled()) goto done; - // TODO: Temporary workaround for a bug on main. A composite image's components register the composite's own - // ReadyToRunInfo, which has no module, on the unrelated R2R modules list. Looking it up decodes signatures - // against a NULL module and crashes. Skip it until the registration is fixed. - if (m_pModule == NULL) + if (entryPointModule == nullptr) goto done; // Return-dropping async thunks are VM-synthesized. They share the same metadata @@ -1378,56 +1524,55 @@ PCODE ReadyToRunInfo::GetEntryPoint(MethodDesc * pMD, PrepareCodeConfig* pConfig && !pMD->HasUnmanagedCallersOnlyAttribute(); // The EntryPoints for this method may be in an auxiliary EntryPoints module that is not yet activated. - if (mayDelayR2RCode && !m_pCompositeInfo->IsAuxiliaryEntryPointsModuleActivated()) + if (mayDelayR2RCode && m_pCompositeInfo->m_pAuxiliaryModuleMetadata == nullptr && + !m_pCompositeInfo->IsAuxiliaryEntryPointsModuleActivated()) { goto done; } #endif // FEATURE_AUXILIARY_R2R_MODULES uint offset; - // Async variants and unboxing stubs are stored in the instance methods table. - if (pMD->HasClassOrMethodInstantiation() - || pMD->IsAsyncVariantMethod() - || pMD->IsUnboxingStub()) + if (!TryGetEntryPointOffset(pMD, entryPointModule, m_methodDefEntryPoints, m_instMethodEntryPoints, &offset)) { - if (m_instMethodEntryPoints.IsNull()) - goto done; - - NativeHashtable::Enumerator lookup = m_instMethodEntryPoints.Lookup(GetVersionResilientMethodHashCode(pMD)); - NativeParser entryParser; - offset = (uint)-1; - while (lookup.GetNext(entryParser)) - { - PCCOR_SIGNATURE pBlob = (PCCOR_SIGNATURE)entryParser.GetBlob(); - SigPointer sig(pBlob); - if (SigMatchesMethodDesc(pMD, sig, m_pModule)) +#ifdef FEATURE_AUXILIARY_R2R_MODULES + if (m_pAuxiliaryModuleMetadata != nullptr && m_pCompositeInfo->IsAuxiliaryEntryPointsModuleActivated()) + { + NativeArray auxiliaryMethodDefs; + NativeHashtable auxiliaryInstances; + IMAGE_DATA_DIRECTORY* methodDefs = m_component.FindSection(ReadyToRunSectionType::AuxiliaryMethodDefEntryPoints); + if (methodDefs != nullptr) + auxiliaryMethodDefs = NativeArray(&m_nativeReader, methodDefs->VirtualAddress); + IMAGE_DATA_DIRECTORY* instances = m_pComposite->FindSection(ReadyToRunSectionType::AuxiliaryInstanceMethodEntryPoints); + if (instances != nullptr) { - // Get the updated SigPointer location, so we can calculate the size of the blob, - // in order to skip the blob and find the entry point data. - offset = entryParser.GetOffset() + (uint)(sig.GetPtr() - pBlob); - break; + NativeParser parser(&m_nativeReader, instances->VirtualAddress); + auxiliaryInstances = NativeHashtable(parser); } + if (!TryGetEntryPointOffset(pMD, entryPointModule, auxiliaryMethodDefs, auxiliaryInstances, &offset)) + goto done; } - - if (offset == (uint)-1) - goto done; - } - else - { - if (!m_methodDefEntryPoints.TryGetAt(rid - 1, &offset)) + else +#endif // FEATURE_AUXILIARY_R2R_MODULES goto done; } -#ifdef FEATURE_AUXILIARY_R2R_MODULES - _ASSERTE(!mayDelayR2RCode || m_pCompositeInfo->IsAuxiliaryEntryPointsModuleActivated()); + uint id; + offset = m_nativeReader.DecodeUnsigned(offset, &id); + uint runtimeFunctionIndex; + runtimeFunctionIndex = (id & 1) != 0 ? id >> 2 : id >> 1; + if (runtimeFunctionIndex >= m_nRuntimeFunctions) + COMPlusThrowHR(COR_E_BADIMAGEFORMAT); - if (mayDelayR2RCode - && !IsAuxiliaryModuleActivated(m_pCompositeInfo->GetAuxiliaryModuleActivationFlag(pMD))) +#ifdef FEATURE_AUXILIARY_R2R_MODULES { - // The method exists in this logical R2R image, but is considered an aux method and its aux module is not yet activated. - // Mark it as such, telling callers to return when the flag is set. - pConfig->SetAuxiliaryModuleActivationFlag(m_pCompositeInfo->GetAuxiliaryModuleActivationFlag(pMD)); - goto done; + PTR_DWORD activationFlag = m_pCompositeInfo->GetAuxiliaryModuleActivationFlag(runtimeFunctionIndex); + if (activationFlag != nullptr && !IsAuxiliaryModuleActivated(activationFlag) && + (m_pAuxiliaryModuleMetadata != nullptr || mayDelayR2RCode)) + { + if (mayDelayR2RCode) + pConfig->SetAuxiliaryModuleActivationFlag(activationFlag); + goto done; + } } #endif // FEATURE_AUXILIARY_R2R_MODULES @@ -1450,9 +1595,6 @@ PCODE ReadyToRunInfo::GetEntryPoint(MethodDesc * pMD, PrepareCodeConfig* pConfig } #endif // PROFILING_SUPPORTED - uint id; - offset = m_nativeReader.DecodeUnsigned(offset, &id); - if (id & 1) { if (id & 2) @@ -1467,7 +1609,7 @@ PCODE ReadyToRunInfo::GetEntryPoint(MethodDesc * pMD, PrepareCodeConfig* pConfig BOOL mayUsePrecompiledPInvokeMethods = TRUE; mayUsePrecompiledPInvokeMethods = !pConfig->IsForMulticoreJit(); - if (!m_pModule->FixupDelayList(dac_cast(GetImage()->GetBase()) + offset, mayUsePrecompiledPInvokeMethods)) + if (!entryPointModule->FixupDelayList(dac_cast(GetImage()->GetBase()) + offset, mayUsePrecompiledPInvokeMethods)) { pConfig->SetReadyToRunRejectedPrecompiledCode(); goto done; @@ -1614,25 +1756,64 @@ BOOL ReadyToRunInfo::MethodIterator::Next() if (m_pInfo->ReadyToRunCodeDisabled()) return FALSE; - // Enumerate non-generic methods - while (++m_methodDefIndex < (int)m_pInfo->m_methodDefEntryPoints.GetCount()) + for (;;) { - uint offset; - if (m_pInfo->m_methodDefEntryPoints.TryGetAt(m_methodDefIndex, &offset)) + // Enumerate non-generic methods. + while (++m_methodDefIndex < (int)m_methodDefEntryPoints.GetCount()) { + uint offset; + if (!m_methodDefEntryPoints.TryGetAt(m_methodDefIndex, &offset)) + continue; +#ifdef TARGET_WASM + uint id; + m_pInfo->m_nativeReader.DecodeUnsigned(offset, &id); + if (!m_pInfo->IsRuntimeFunctionActive((id & 1) != 0 ? id >> 2 : id >> 1)) + continue; +#endif // TARGET_WASM return TRUE; } - } - // Enumerate generic instantiations - m_genericParser = m_genericEnum.GetNext(); - if (!m_genericParser.IsNull()) - { - ParseGenericMethodSignatureAndRid(&m_genericCurrentOffset, &m_genericCurrentRid); - return TRUE; - } + // Enumerate generic instantiations. + while (!(m_genericParser = m_genericEnum.GetNext()).IsNull()) + { + ParseGenericMethodSignatureAndRid(&m_genericCurrentOffset, &m_genericCurrentRid); +#ifdef TARGET_WASM + if (m_genericCurrentOffset != static_cast(-1)) + { + uint id; + m_pInfo->m_nativeReader.DecodeUnsigned(m_genericCurrentOffset, &id); + if (!m_pInfo->IsRuntimeFunctionActive((id & 1) != 0 ? id >> 2 : id >> 1)) + continue; + } +#endif // TARGET_WASM + return TRUE; + } - return FALSE; +#ifdef FEATURE_AUXILIARY_R2R_MODULES + if (!m_enumeratingAuxiliary && m_pInfo->m_pAuxiliaryModuleMetadata != nullptr && + m_pInfo->m_pCompositeInfo->IsAuxiliaryEntryPointsModuleActivated()) + { + m_enumeratingAuxiliary = true; + m_methodDefIndex = -1; + m_methodDefEntryPoints = NativeArray(); + m_instMethodEntryPoints = NativeHashtable(); + IMAGE_DATA_DIRECTORY* methodDefs = m_pInfo->m_component.FindSection(ReadyToRunSectionType::AuxiliaryMethodDefEntryPoints); + if (methodDefs != nullptr) + m_methodDefEntryPoints = NativeArray(&m_pInfo->m_nativeReader, methodDefs->VirtualAddress); + IMAGE_DATA_DIRECTORY* instances = m_pInfo->m_pComposite->FindSection(ReadyToRunSectionType::AuxiliaryInstanceMethodEntryPoints); + if (instances != nullptr) + { + NativeParser parser(&m_pInfo->m_nativeReader, instances->VirtualAddress); + m_instMethodEntryPoints = NativeHashtable(parser); + } + NativeFormat::PTR_NativeHashtable hash = m_instMethodEntryPoints.IsNull() + ? nullptr : NativeFormat::PTR_NativeHashtable(&m_instMethodEntryPoints); + m_genericEnum = NativeHashtable::AllEntriesEnumerator(hash); + continue; + } +#endif // FEATURE_AUXILIARY_R2R_MODULES + return FALSE; + } } MethodDesc * ReadyToRunInfo::MethodIterator::GetMethodDesc() @@ -1640,15 +1821,15 @@ MethodDesc * ReadyToRunInfo::MethodIterator::GetMethodDesc() STANDARD_VM_CONTRACT; mdMethodDef methodToken = mdTokenNil; - if (m_methodDefIndex < (int)m_pInfo->m_methodDefEntryPoints.GetCount()) + if (m_methodDefIndex < (int)m_methodDefEntryPoints.GetCount()) { methodToken = mdtMethodDef | (m_methodDefIndex + 1); - return MemberLoader::GetMethodDescFromMethodDef(m_pInfo->m_pModule, methodToken, FALSE); + return MemberLoader::GetMethodDescFromMethodDef(m_pInfo->m_pEntryPointModule, methodToken, FALSE); } else { _ASSERTE(m_genericCurrentOffset > 0 && m_genericCurrentSig != NULL); - return ZapSig::DecodeMethod(m_pInfo->m_pModule, m_pInfo->m_pModule, m_genericCurrentSig); + return ZapSig::DecodeMethod(m_pInfo->m_pEntryPointModule, m_pInfo->m_pEntryPointModule, m_genericCurrentSig); } } @@ -1664,9 +1845,9 @@ MethodDesc * ReadyToRunInfo::MethodIterator::GetMethodDesc_NoRestore() CONTRACTL_END; uint offset; - if (m_methodDefIndex < (int)m_pInfo->m_methodDefEntryPoints.GetCount()) + if (m_methodDefIndex < (int)m_methodDefEntryPoints.GetCount()) { - if (!m_pInfo->m_methodDefEntryPoints.TryGetAt(m_methodDefIndex, &offset)) + if (!m_methodDefEntryPoints.TryGetAt(m_methodDefIndex, &offset)) { return NULL; } diff --git a/src/coreclr/vm/readytoruninfo.h b/src/coreclr/vm/readytoruninfo.h index aa20dfc37ade51..b8677717fbfa8d 100644 --- a/src/coreclr/vm/readytoruninfo.h +++ b/src/coreclr/vm/readytoruninfo.h @@ -17,6 +17,8 @@ #include "auxiliaryentrypointsera.h" typedef DPTR(struct READYTORUN_SECTION) PTR_READYTORUN_SECTION; +typedef DPTR(struct READYTORUN_AUXILIARY_MODULE_METADATA) PTR_READYTORUN_AUXILIARY_MODULE_METADATA; +typedef DPTR(struct READYTORUN_AUXILIARY_MODULE) PTR_READYTORUN_AUXILIARY_MODULE; class NativeImage; class PrepareCodeConfig; @@ -145,6 +147,7 @@ class ReadyToRunInfo friend class HotColdMappingLookupTable; PTR_Module m_pModule; + PTR_Module m_pEntryPointModule = nullptr; PTR_ModuleBase m_pNativeManifestModule; PTR_READYTORUN_HEADER m_pHeader; bool m_isComponentAssembly; @@ -203,13 +206,13 @@ class ReadyToRunInfo TADDR m_pLoadedImageBase; #ifdef FEATURE_AUXILIARY_R2R_MODULES + PTR_READYTORUN_AUXILIARY_MODULE_METADATA m_pAuxiliaryModuleMetadata = nullptr; PTR_DWORD m_pAuxiliaryModuleActivationFlag = 0; PTR_DWORD m_pAuxiliaryEntryPointsModuleStatus = 0; // Starts at 1 so a recorded era is never 0, which callers use to mean "not awaiting EntryPoints". inline static DWORD s_auxiliaryEntryPointsEra = 1; - // TODO: Remove once auxiliary EntryPoints modules are downloaded. Stands in for the EntryPoints module status - // of every composite image; (DWORD)-1 means not yet read from config. + // Legacy call-count simulation for unpartitioned composite images; (DWORD)-1 means not configured. inline static DWORD s_simulatedAuxiliaryEntryPointsCallsRemaining = (DWORD)-1; #endif // FEATURE_AUXILIARY_R2R_MODULES @@ -259,13 +262,37 @@ class ReadyToRunInfo return (PCODE)(GetMinVirtualIP() + RUNTIME_FUNCTION__BeginAddress(&m_pRuntimeFunctions[r2rFunctionIndex])); } void RegisterVirtualIPRange(Module* pModule); + int FindRuntimeFunction(DWORD relativeVirtualIP) const; + bool IsRuntimeFunctionActive(DWORD index) const; + DWORD GetRuntimeFunctionRangeEnd(DWORD index) const; #endif // TARGET_WASM #ifdef FEATURE_AUXILIARY_R2R_MODULES - PTR_DWORD GetAuxiliaryModuleActivationFlag(MethodDesc* pMD) const + PTR_READYTORUN_AUXILIARY_MODULE GetAuxiliaryModule(DWORD runtimeFunctionIndex) const { LIMITED_METHOD_CONTRACT; - // If/When we use multiple auxiliary modules, this will need to search the AuxiliaryEntryPointsModule to find which flag to use + if (m_pAuxiliaryModuleMetadata != nullptr) + { + PTR_READYTORUN_AUXILIARY_MODULE entries = dac_cast( + dac_cast(m_pAuxiliaryModuleMetadata) + sizeof(READYTORUN_AUXILIARY_MODULE_METADATA)); + for (DWORD i = 0; i < m_pAuxiliaryModuleMetadata->AuxiliaryModuleCount; i++) + { + if (runtimeFunctionIndex >= entries[i].RelativeTableBase && + runtimeFunctionIndex - entries[i].RelativeTableBase < entries[i].MethodCount) + return entries + i; + } + } + return nullptr; + } + + PTR_DWORD GetAuxiliaryModuleActivationFlag(DWORD runtimeFunctionIndex) const + { + LIMITED_METHOD_CONTRACT; + if (m_pAuxiliaryModuleMetadata != nullptr) + { + PTR_READYTORUN_AUXILIARY_MODULE entry = GetAuxiliaryModule(runtimeFunctionIndex); + return entry == nullptr ? nullptr : &entry->ActivationStatus; + } return m_pAuxiliaryModuleActivationFlag; } @@ -308,7 +335,10 @@ class ReadyToRunInfo bool IsAuxiliaryEntryPointsModuleActivated() const { LIMITED_METHOD_CONTRACT; - return IsAuxiliaryEntryPointsModuleActivated(m_pAuxiliaryEntryPointsModuleStatus); + if (m_pAuxiliaryModuleMetadata != nullptr) + return VolatileLoad(&m_pAuxiliaryModuleMetadata->EntryPointsModuleActivationStatus) == 1; + return m_pAuxiliaryEntryPointsModuleStatus == nullptr || + IsAuxiliaryEntryPointsModuleActivated(m_pAuxiliaryEntryPointsModuleStatus); } // The encoding of the activation flag is owned by the R2R image; every reader goes through this. @@ -316,22 +346,18 @@ class ReadyToRunInfo { LIMITED_METHOD_CONTRACT; _ASSERTE(pActivationFlag != nullptr); - return VolatileLoad(pActivationFlag) == 0; + return VolatileLoad(pActivationFlag) == 1; } - // TODO: Remove once auxiliary modules are downloaded. Stands in for the download by counting down the - // flag once per call, and returns whether the module is now activated. static void AdvanceSimulatedAuxiliaryModuleDownload(PTR_DWORD pActivationFlag) { LIMITED_METHOD_CONTRACT; _ASSERTE(pActivationFlag != nullptr); - if (!IsAuxiliaryModuleActivated(pActivationFlag)) + // Real activation flags are 0 or 1. Only the legacy countdown simulation uses values above 1. + if (VolatileLoad(pActivationFlag) > 1) VolatileStore(pActivationFlag, VolatileLoad(pActivationFlag) - 1); } - // TODO: Remove the simulation below once auxiliary EntryPoints modules are downloaded. It stands in for - // the download by counting calls to methods awaiting EntryPoints, and activates the EntryPoints modules - // (advancing the era) once the count reaches zero. static void EnsureAuxiliaryEntryPointsModuleStatus() { LIMITED_METHOD_CONTRACT; @@ -347,7 +373,8 @@ class ReadyToRunInfo static void AdvanceSimulatedAuxiliaryEntryPointsDownload() { LIMITED_METHOD_CONTRACT; - EnsureAuxiliaryEntryPointsModuleStatus(); + if (s_simulatedAuxiliaryEntryPointsCallsRemaining == (DWORD)-1) + return; if (s_simulatedAuxiliaryEntryPointsCallsRemaining == 0) return; @@ -460,6 +487,11 @@ class ReadyToRunInfo ReadyToRunInfo * m_pInfo; int m_methodDefIndex; + NativeFormat::NativeArray m_methodDefEntryPoints; + NativeFormat::NativeHashtable m_instMethodEntryPoints; +#ifdef FEATURE_AUXILIARY_R2R_MODULES + bool m_enumeratingAuxiliary = false; +#endif // FEATURE_AUXILIARY_R2R_MODULES NativeFormat::NativeHashtable::AllEntriesEnumerator m_genericEnum; NativeFormat::NativeParser m_genericParser; uint m_genericCurrentOffset; @@ -472,6 +504,8 @@ class ReadyToRunInfo MethodIterator(ReadyToRunInfo * pInfo) : m_pInfo(pInfo), m_methodDefIndex(-1), + m_methodDefEntryPoints(pInfo->m_methodDefEntryPoints), + m_instMethodEntryPoints(pInfo->m_instMethodEntryPoints), m_genericEnum(), m_genericParser(), m_genericCurrentOffset(-1), @@ -479,9 +513,9 @@ class ReadyToRunInfo m_genericCurrentSig(NULL) { NativeFormat::PTR_NativeHashtable pHash = NULL; - if (!pInfo->m_instMethodEntryPoints.IsNull()) + if (!m_instMethodEntryPoints.IsNull()) { - pHash = NativeFormat::PTR_NativeHashtable(&pInfo->m_instMethodEntryPoints); + pHash = NativeFormat::PTR_NativeHashtable(&m_instMethodEntryPoints); } m_genericEnum = NativeFormat::NativeHashtable::AllEntriesEnumerator(pHash); diff --git a/src/coreclr/vm/wasm/helpers.cpp b/src/coreclr/vm/wasm/helpers.cpp index 6b57b3e8c29d8b..0ea3af1b157dbe 100644 --- a/src/coreclr/vm/wasm/helpers.cpp +++ b/src/coreclr/vm/wasm/helpers.cpp @@ -14,6 +14,13 @@ #define WASM_STRINGIFY_HELPER(value) #value #define WASM_STRINGIFY(value) WASM_STRINGIFY_HELPER(value) +extern "C" void dotnet_wasm_notify_auxiliary_entrypoints_activated() +{ + LIMITED_METHOD_CONTRACT; +#ifdef FEATURE_AUXILIARY_R2R_MODULES + ReadyToRunInfo::IncrementAuxiliaryEntryPointsEra(); +#endif // FEATURE_AUXILIARY_R2R_MODULES +} extern "C" void STDCALL CallCountingStubCode() { diff --git a/src/mono/browser/build/README.md b/src/mono/browser/build/README.md index c4aa79a7092970..fe6b1274fcffed 100644 --- a/src/mono/browser/build/README.md +++ b/src/mono/browser/build/README.md @@ -139,6 +139,29 @@ If encountering build performance issues, you can use the rollup `--perf` option The .cpuprofile file generated by node can be opened in the Performance tab of Chrome or Edge's devtools. +## Partitioned CoreCLR ReadyToRun images + +Single-threaded CoreCLR browser apps can opt in to partitioned composite ReadyToRun output: + +```console +dotnet publish -p:PublishReadyToRun=true -p:PublishReadyToRunComposite=true -p:WasmEnableAuxiliaryR2RModules=true +``` + +`WasmEnableAuxiliaryR2RModules` defaults to `false`. It passes `--wasm-auxiliary-modules` to +crossgen2 and requires retained IL; `--strip-il` and threaded builds are not supported. +In-tree browser samples also forward this property through `BuildSampleInTree`. + +The compiler emits an `.auxiliary.json` manifest beside the primary image. Its explicit +EntryPoints and body filenames are collected as sibling `_framework` assets, fingerprinted +and compressed by the normal static-web-assets pipeline, and recorded in the boot config's +`auxiliaryAssembly` resources. Sidecars are not managed assemblies and are never converted +to Webcil or added to the trusted platform assembly list. + +The browser starts independent background loads after the primary image is registered. +Neither EntryPoints nor body activation waits for the other, and cold downloads are not +part of the startup barrier. A failed sidecar is reported through the host logger while +its methods retain their interpreter fallback. + ## Blazor You can test local build of runtime in blazor by overriding location where blazor resolves runtime pack from diff --git a/src/mono/nuget/Microsoft.NET.Sdk.WebAssembly.Pack/build/Microsoft.NET.Sdk.WebAssembly.Browser.CoreCLR.targets b/src/mono/nuget/Microsoft.NET.Sdk.WebAssembly.Pack/build/Microsoft.NET.Sdk.WebAssembly.Browser.CoreCLR.targets index db9a9a14367669..a26d183d009be9 100644 --- a/src/mono/nuget/Microsoft.NET.Sdk.WebAssembly.Pack/build/Microsoft.NET.Sdk.WebAssembly.Browser.CoreCLR.targets +++ b/src/mono/nuget/Microsoft.NET.Sdk.WebAssembly.Pack/build/Microsoft.NET.Sdk.WebAssembly.Browser.CoreCLR.targets @@ -35,8 +35,38 @@ Copyright (c) .NET Foundation. All rights reserved. $(PublishReadyToRunCrossgen2ExtraArgs);--opt-cross-module:* $(PublishReadyToRunCrossgen2ExtraArgs);--instruction-set:relaxed-simd + $(PublishReadyToRunCrossgen2ExtraArgs);--wasm-auxiliary-modules + + + + <_WasmAuxiliaryPrimaryImage Include="@(_ReadyToRunCompileList->WithMetadataValue('CreateCompositeImage', 'true')->'%(OutputR2RImage)')" /> + + + + + + + + <_WasmReadyToRunAuxiliaryStamp>$([MSBuild]::NormalizePath('$(IntermediateOutputPath)', 'R2R', 'auxiliary-modules.stamp')) + <_PublishReadyToRunCrossgen2ExtraArgs Condition="'$(WasmEnableAuxiliaryR2RModules)' == 'true' and !$([System.String]::Copy('$(PublishReadyToRunCrossgen2ExtraArgs)').Contains('--wasm-auxiliary-modules'))">$(_PublishReadyToRunCrossgen2ExtraArgs);--wasm-auxiliary-modules + + + + + <_ReadyToRunCompilerInputs Include="$(_WasmReadyToRunAuxiliaryStamp)" /> + + + + <_WasmWebcilStampProperty Include="PublishTrimmed" /> <_WasmWebcilStampProperty Include="PublishReadyToRunComposite" /> + <_WasmWebcilStampProperty Include="WasmEnableAuxiliaryR2RModules" /> <_WasmEmittedPerAppR2R Include="@(_ReadyToRunCompileList->'%(OutputR2RImage)')" /> + <_WasmEmittedPerAppR2R Include="@(_WasmEmittedAuxiliaryR2R)" /> <_WasmEmittedPerAppR2R Include="@(_ReadyToRunCompositeBuildInput->'$(_WasmPublishR2RDir)%(FileName).wasm');@(_ReadyToRunCompositeUnrootedBuildInput->'$(_WasmPublishR2RDir)%(FileName).wasm')" Condition="'$(PublishReadyToRunComposite)' == 'true'" /> diff --git a/src/mono/sample/wasm/Directory.Build.props b/src/mono/sample/wasm/Directory.Build.props index b63362260ba5e2..ec36531fc4ef6f 100644 --- a/src/mono/sample/wasm/Directory.Build.props +++ b/src/mono/sample/wasm/Directory.Build.props @@ -54,6 +54,7 @@ $(Nested_RuntimeFlavor) $(Nested_PublishReadyToRun) false + $(Nested_WasmEnableAuxiliaryR2RModules) diff --git a/src/mono/sample/wasm/Directory.Build.targets b/src/mono/sample/wasm/Directory.Build.targets index bd4f2de1aa2de6..2dc4d0c188b406 100644 --- a/src/mono/sample/wasm/Directory.Build.targets +++ b/src/mono/sample/wasm/Directory.Build.targets @@ -45,6 +45,7 @@ + diff --git a/src/mono/wasm/Wasm.Build.Tests/ReadyToRunTests.cs b/src/mono/wasm/Wasm.Build.Tests/ReadyToRunTests.cs index 2c2adc87bffce2..8c7f33b69e4acf 100644 --- a/src/mono/wasm/Wasm.Build.Tests/ReadyToRunTests.cs +++ b/src/mono/wasm/Wasm.Build.Tests/ReadyToRunTests.cs @@ -4,6 +4,7 @@ using System.Collections.Generic; using System.IO; using System.Linq; +using System.Text.Json; using System.Text.RegularExpressions; using System.Threading.Tasks; using Microsoft.NET.Sdk.WebAssembly; @@ -68,6 +69,13 @@ public Task PublishRunAllPages(Configuration config, bool trimmed) public Task PublishRunAllPagesComposite(Configuration config, bool trimmed, bool nativeRelink) => PublishRunAllPagesCore(config, trimmed, nativeRelink, composite: true); + [Theory] + [InlineData(false)] + [InlineData(true)] + [TestCategory("no-workload")] + public Task PublishRunAllPagesAuxiliary(bool nativeRelink) + => PublishRunAllPagesCore(Configuration.Release, trimmed: true, nativeRelink, composite: true, auxiliaryModules: true); + // CoreCLR relinks dotnet.native.wasm for Blazor when WasmBuildNative=true; the relink is driven by the // IsBrowserWasmProject triggers in BrowserWasmApp.CoreCLR.targets. AssertBundle(isNativeBuild: true) // proves the served dotnet.native.wasm was relinked rather than the runtime-pack prebuilt. @@ -78,11 +86,11 @@ public Task PublishRunAllPagesComposite(Configuration config, bool trimmed, bool public Task PublishRunAllPagesNativeRelink(Configuration config, bool trimmed) => PublishRunAllPagesCore(config, trimmed, nativeRelink: true, composite: false); - private async Task PublishRunAllPagesCore(Configuration config, bool trimmed, bool nativeRelink, bool composite) + private async Task PublishRunAllPagesCore(Configuration config, bool trimmed, bool nativeRelink, bool composite, bool auxiliaryModules = false) { // Publish runs per-app crossgen2 for the whole closure, trimmed or not: even the untrimmed CoreLib // is a per-app image, not the runtime pack's. nativeRelink also relinks dotnet.native.wasm. - string label = $"r2r_pub_{(trimmed ? "trim" : "notrim")}{(nativeRelink ? "_native" : "")}{(composite ? "_composite" : "")}"; + string label = $"r2r_pub_{(trimmed ? "trim" : "notrim")}{(nativeRelink ? "_native" : "")}{(composite ? "_composite" : "")}{(auxiliaryModules ? "_auxiliary" : "")}"; string extraItems = composite ? """""" : string.Empty; @@ -92,6 +100,8 @@ private async Task PublishRunAllPagesCore(Configuration config, bool trimmed, bo if (composite) LogR2RSuffixLibraryMarker(); string extraArgs = GetR2RBuildArgs(config, composite); + if (auxiliaryModules) + extraArgs += " -p:WasmEnableAuxiliaryR2RModules=true"; if (nativeRelink) { // CoreCLR relinks dotnet.native.wasm via the in-tree targets + EMSDK_PATH, not the browser @@ -113,14 +123,26 @@ private async Task PublishRunAllPagesCore(Configuration config, bool trimmed, bo AssertNoManagedAssembliesOutsideFramework(frameworkDir); AssertTrimmedClosureIsFullyStaged(config, frameworkDir); AssertPerAppCrossgenRan(config, expected: true); + if (auxiliaryModules) + AssertAuxiliaryModulesStaged(config, frameworkDir); if (trimmed && !nativeRelink) { + string? removedSidecar = auxiliaryModules + ? Directory.EnumerateFiles(GetObjSubDir(config, "R2R"), "*.entrypoints.wasm").Single() + : null; + if (removedSidecar is not null) + File.Delete(removedSidecar); BlazorPublish(info, config, new PublishOptions(UseCache: false, ExtraMSBuildArgs: extraArgs)); + if (removedSidecar is not null) + { + Assert.True(File.Exists(removedSidecar), $"Missing auxiliary output '{removedSidecar}' was not recompiled."); + AssertAuxiliaryModulesStaged(config, frameworkDir); + } if (composite) AssertCompositeReadyToRun(frameworkDir); AssertNoDuplicateAssemblies(frameworkDir); - if (composite) + if (composite && !auxiliaryModules) AssertSwitchingCompositeModeRecompiles(info, config, extraArgs, frameworkDir); } @@ -480,6 +502,25 @@ private void AssertCompositeReadyToRun(string frameworkDir) $"Expected a component stub for System.Private.CoreLib in '{frameworkDir}'."); } + private void AssertAuxiliaryModulesStaged(Configuration config, string frameworkDir) + { + string manifest = Assert.Single(Directory.EnumerateFiles(GetObjSubDir(config, "R2R"), "*.auxiliary.json")); + using JsonDocument document = JsonDocument.Parse(File.ReadAllText(manifest)); + string[] filenames = document.RootElement.GetProperty("modules").EnumerateArray() + .Select(module => module.GetString()!) + .Prepend(document.RootElement.GetProperty("entrypoints").GetString()!) + .OrderBy(name => name, System.StringComparer.Ordinal) + .ToArray(); + AssetsData assets = (AssetsData)_provider.GetBootJson(_provider.GetBootConfigPath(frameworkDir)).resources; + Assert.NotNull(assets.auxiliaryAssembly); + Assert.Equal(filenames, assets.auxiliaryAssembly.Select(asset => asset.virtualPath).OrderBy(name => name, System.StringComparer.Ordinal)); + foreach (GeneralAsset sidecar in assets.auxiliaryAssembly) + { + Assert.True(File.Exists(Path.Combine(frameworkDir, sidecar.name)), $"Missing published sidecar '{sidecar.name}'."); + Assert.DoesNotContain(assets.coreAssembly.Concat(assets.assembly), asset => asset.virtualPath == sidecar.virtualPath); + } + } + private const string SuffixLibraryLoadedMessage = "Loaded R2rSuffixLibrary.r2r: 42"; // Make the app call into the R2rSuffixLibrary test asset (referenced for composite runs) so the run proves diff --git a/src/mono/wasm/Wasm.Build.Tests/WebcilInWasmSizesTests.cs b/src/mono/wasm/Wasm.Build.Tests/WebcilInWasmSizesTests.cs index baba622b2e778b..44fec8623d5ec1 100644 --- a/src/mono/wasm/Wasm.Build.Tests/WebcilInWasmSizesTests.cs +++ b/src/mono/wasm/Wasm.Build.Tests/WebcilInWasmSizesTests.cs @@ -7,6 +7,7 @@ using System.Linq; using System.Reflection.Metadata; using System.Reflection.PortableExecutable; +using System.Text.Json; using Microsoft.Build.Framework; using Microsoft.Build.Utilities; using Microsoft.NET.Sdk.WebAssembly; @@ -361,6 +362,102 @@ public void ConvertDllsToWebcil_FallsBackToIL_WhenPrebuiltMvidMismatches(bool wr Assert.Equal(0, tableSize); } + [Theory] + [InlineData(null)] + [InlineData("missing")] + [InlineData("path")] + [InlineData("duplicate")] + [InlineData("invalid-json")] + public void AuxiliaryModules_AreCollectedFromManifest(string? failure) + { + using var directory = new TempDirectory(); + string primary = Path.Combine(directory.Path, "unusual.primary.dll"); + string entrypoints = "arbitrary-mapping.wasm"; + string body = failure is "path" ? "../escape.wasm" : failure is "duplicate" ? entrypoints : "arbitrary-body.wasm"; + File.WriteAllText(Path.ChangeExtension(primary, ".auxiliary.json"), + failure is "invalid-json" ? "invalid" : JsonSerializer.Serialize(new { entrypoints, modules = new[] { body } })); + File.WriteAllBytes(Path.Combine(directory.Path, entrypoints), [0]); + if (failure is null) + File.WriteAllBytes(Path.Combine(directory.Path, body), [0]); + + var engine = new TestBuildEngine { AllowErrors = failure is not null }; + var task = new CollectWasmAuxiliaryR2RModules { BuildEngine = engine, PrimaryImages = [new TaskItem(primary)] }; + Assert.Equal(failure is null, task.Execute()); + if (failure is not null) + { + Assert.Single(engine.Errors); + return; + } + Assert.Equal(new[] { entrypoints, body }, task.Modules.Select(item => Path.GetFileName(item.ItemSpec))); + Assert.All(task.Modules, item => Assert.Equal("_framework/" + Path.GetFileName(item.ItemSpec), item.GetMetadata("RelativePath"))); + Assert.Equal(3, task.FileWrites.Length); + } + + [Fact] + public void AuxiliaryModules_AreNotParsedAsWebcilOrAddedToManagedAssemblies() + { + using var directory = new TempDirectory(); + string path = Path.Combine(directory.Path, "arbitrary-body.wasm"); + File.WriteAllBytes(path, [0]); + var resource = new TaskItem(path); + resource.SetMetadata("OriginalItemSpec", path); + resource.SetMetadata("AssetTraitName", "WasmResource"); + resource.SetMetadata("AssetTraitValue", "readyToRunAuxiliary"); + resource.SetMetadata("Integrity", "sidecar-hash"); + var sizes = new AttachWebcilSizes { BuildEngine = new TestBuildEngine(), Candidates = [resource] }; + Assert.True(sizes.Execute()); + Assert.Equal(string.Empty, Assert.Single(sizes.CandidatesWithSizes).GetMetadata("PayloadSize")); + + var endpoint = new TaskItem("_framework/arbitrary-body.fingerprint.wasm"); + endpoint.SetMetadata("AssetFile", path); + string output = Path.Combine(directory.Path, "boot.json"); + var boot = new GenerateWasmBootJson + { + BuildEngine = new TestBuildEngine(), + AssemblyPath = typeof(WebcilInWasmSizesTests).Assembly.Location, + Resources = sizes.CandidatesWithSizes, + Endpoints = [endpoint], + FingerprintAssets = true, + InvariantGlobalization = "true", + TargetFrameworkVersion = "v11.0", + UseMonoRuntime = "false", + OutputPath = output, + }; + Assert.True(boot.Execute()); + using JsonDocument document = JsonDocument.Parse(File.ReadAllText(output)); + JsonElement resources = document.RootElement.GetProperty("resources"); + JsonElement auxiliary = Assert.Single(resources.GetProperty("auxiliaryAssembly").EnumerateArray()); + Assert.Equal("arbitrary-body.wasm", auxiliary.GetProperty("virtualPath").GetString()); + Assert.Equal("arbitrary-body.fingerprint.wasm", auxiliary.GetProperty("name").GetString()); + Assert.Equal("sha256-sidecar-hash", auxiliary.GetProperty("hash").GetString()); + Assert.False(auxiliary.TryGetProperty("payloadSize", out _)); + Assert.Empty(resources.GetProperty("assembly").EnumerateArray()); + Assert.Empty(resources.GetProperty("coreAssembly").EnumerateArray()); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void AuxiliaryModules_MissingOutputsInvalidateIncrementalCompilation(bool missingManifest) + { + using var directory = new TempDirectory(); + string primary = Path.Combine(directory.Path, "app.wasm"); + string manifest = Path.ChangeExtension(primary, ".auxiliary.json"); + if (!missingManifest) + { + File.WriteAllText(manifest, """{"entrypoints":"mapping.wasm","modules":["body.wasm"]}"""); + File.WriteAllBytes(Path.Combine(directory.Path, "mapping.wasm"), [0]); + } + var task = new CollectWasmAuxiliaryR2RModules + { + BuildEngine = new TestBuildEngine(), + PrimaryImages = [new TaskItem(primary)], + AllowMissingFiles = true, + }; + Assert.True(task.Execute()); + Assert.Equal(missingManifest ? manifest : Path.Combine(directory.Path, "body.wasm"), Assert.Single(task.MissingOutputs).ItemSpec); + } + private const byte SectionCustom = 0x00; private const byte SectionType = 0x01; private const byte SectionFunction = 0x03; @@ -642,6 +739,8 @@ private sealed class TempDirectory : IDisposable private sealed class TestBuildEngine : IBuildEngine { + public bool AllowErrors { get; set; } + public List Errors { get; } = []; public bool ContinueOnError => false; public int LineNumberOfTaskNode => 0; public int ColumnNumberOfTaskNode => 0; @@ -654,7 +753,13 @@ public void LogCustomEvent(CustomBuildEventArgs e) { } - public void LogErrorEvent(BuildErrorEventArgs e) => Assert.Fail(e.Message ?? "Build error"); + public void LogErrorEvent(BuildErrorEventArgs e) + { + if (AllowErrors) + Errors.Add(e.Message ?? "Build error"); + else + Assert.Fail(e.Message ?? "Build error"); + } public void LogMessageEvent(BuildMessageEventArgs e) { diff --git a/src/native/libs/Common/JavaScript/CMakeLists.txt b/src/native/libs/Common/JavaScript/CMakeLists.txt index eafcfbb4ee38af..f5f65db347fef2 100644 --- a/src/native/libs/Common/JavaScript/CMakeLists.txt +++ b/src/native/libs/Common/JavaScript/CMakeLists.txt @@ -23,6 +23,7 @@ set(ROLLUP_TS_SOURCES "${CLR_SRC_NATIVE_DIR}/libs/Common/JavaScript/cross-module/index.ts" "${CLR_SRC_NATIVE_DIR}/libs/Common/JavaScript/ems-ambient/index.ts" "${CLR_SRC_NATIVE_DIR}/libs/Common/JavaScript/host/assets.ts" + "${CLR_SRC_NATIVE_DIR}/libs/Common/JavaScript/host/auxiliary-modules.ts" "${CLR_SRC_NATIVE_DIR}/libs/Common/JavaScript/host/cross-module.ts" "${CLR_SRC_NATIVE_DIR}/libs/Common/JavaScript/host/host.ts" "${CLR_SRC_NATIVE_DIR}/libs/Common/JavaScript/host/index.ts" diff --git a/src/native/libs/Common/JavaScript/host/assets.ts b/src/native/libs/Common/JavaScript/host/assets.ts index d3e12d1756aa08..58fee01e0c2450 100644 --- a/src/native/libs/Common/JavaScript/host/assets.ts +++ b/src/native/libs/Common/JavaScript/host/assets.ts @@ -5,9 +5,13 @@ import type { CharPtr, VfsAsset, VoidPtr, VoidPtrPtr } from "../types"; import { _ems_ } from "../ems-ambient"; import { browserVirtualAppBase, sizeOfPtr } from "../per-module"; +import { loadAuxiliaryModules, readAuxiliaryModuleMetadata } from "./auxiliary-modules"; const hasInstantiateStreaming = typeof WebAssembly !== "undefined" && typeof WebAssembly.instantiateStreaming === "function"; const loadedAssemblies: Map = new Map(); +type StartAuxiliaryModules = () => void; +const pendingAuxiliaryModules = new Map(); +const auxiliaryModuleLoads: Promise[] = []; // Self-installing wrapper version; keep in sync with WebcilConstants.WASM_WRAPPER_VERSION_SELF_INSTALLING. const webcilWrapperVersion = 2; @@ -49,7 +53,7 @@ export function registerDllBytes(bytes: Uint8Array, virtualPath: string, shortNa } } -export async function instantiateWebcilModule(webcilPromise: Promise, memory: WebAssembly.Memory, virtualPath: string, tableSize?: number, payloadSize?: number): Promise { +export async function instantiateWebcilModule(webcilPromise: Promise, memory: WebAssembly.Memory, virtualPath: string, tableSize?: number, payloadSize?: number, loadAuxiliaryModule?: (name: string) => Promise): Promise { // Boot-config sizes let us reserve the payload and table ranges before streaming instantiation. // Assets without a tableSize are IL-only images. if (typeof payloadSize !== "number" || payloadSize === 0) { @@ -71,7 +75,20 @@ export async function instantiateWebcilModule(webcilPromise: Promise, const data = await res.arrayBuffer(); instance = (await WebAssembly.instantiate(data, imports)).instance; } + const modules = readAuxiliaryModuleMetadata(instance, memory, payloadPtr, payloadSize, tableEntries); + if (modules.length !== 0 && !loadAuxiliaryModule) { + throw new Error(`Webcil asset '${virtualPath}' has auxiliary modules but no auxiliary resource loader.`); + } finishWebcilInstance(instance, payloadPtr, payloadSize, tableEntries, virtualPath); + if (modules.length !== 0) { + pendingAuxiliaryModules.set(payloadPtr, () => { + auxiliaryModuleLoads.push(loadAuxiliaryModules(modules, memory, imports, loadAuxiliaryModule!, () => { + const notify = _ems_.wasmExports.dotnet_wasm_notify_auxiliary_entrypoints_activated; + if (typeof notify !== "function") throw new Error("Runtime does not support auxiliary R2R modules."); + notify(); + }, error => _ems_.dotnetLogger.error(error.message, error))); + }); + } } catch (err) { // Instantiation failed after the payload buffer was allocated; free it to avoid leaking // unmanaged memory. (A grown R2R table cannot be shrunk back, but a failed R2R instantiate is fatal.) @@ -82,6 +99,11 @@ export async function instantiateWebcilModule(webcilPromise: Promise, } } +export async function waitForAuxiliaryModules(): Promise { + await Promise.resolve(); + await Promise.all(auxiliaryModuleLoads); +} + async function checkWebcilResponse(webcilPromise: Promise, virtualPath: string): Promise { const res = await webcilPromise; if (!res || res.ok === false) { @@ -183,6 +205,12 @@ export function BrowserHost_ExternalAssemblyProbe(pathPtr: CharPtr, outDataStart // int64_t target _ems_.HEAPU32[outSize as any >>> 2] = assembly.length; _ems_.HEAPU32[((outSize as any) + 4) >>> 2] = 0; + const startAuxiliaryModules = pendingAuxiliaryModules.get(assembly.ptr); + if (startAuxiliaryModules) { + pendingAuxiliaryModules.delete(assembly.ptr); + // The synchronous probe must return before native image registration and any activation. + queueMicrotask(startAuxiliaryModules); + } return true; } _ems_.dotnetLogger.debug(`Assembly not found: '${path}'`); diff --git a/src/native/libs/Common/JavaScript/host/auxiliary-modules.test.mjs b/src/native/libs/Common/JavaScript/host/auxiliary-modules.test.mjs new file mode 100644 index 00000000000000..f0281afe1d7e9c --- /dev/null +++ b/src/native/libs/Common/JavaScript/host/auxiliary-modules.test.mjs @@ -0,0 +1,311 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { createRequire } from "node:module"; +import { test } from "node:test"; +import vm from "node:vm"; + +const require = createRequire(import.meta.url); +const ts = require(process.env.TYPESCRIPT_PATH || "typescript"); +const source = readFileSync(new URL("./auxiliary-modules.ts", import.meta.url), "utf8"); +const javascript = ts.transpileModule(source, { compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.ES2022 } }).outputText; +const { readAuxiliaryModuleMetadata, loadAuxiliaryModules } = await import(`data:text/javascript;base64,${Buffer.from(javascript).toString("base64")}`); +const corerunSource = readFileSync(new URL("../../../../../coreclr/hosts/corerun/wasm/libCorerun.js", import.meta.url), "utf8"); + +test("browser: auxiliary boot resources survive configuration merges", () => { + const configSource = readFileSync(new URL("../loader/config.ts", import.meta.url), "utf8"); + const javascript = ts.transpileModule(configSource, { compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.CommonJS } }).outputText; + const context = { exports: {}, require: () => ({ browserVirtualAppBase: "/" }) }; + vm.runInNewContext(javascript, context); + const first = { name: "app.entrypoints.hash.wasm", virtualPath: "app.entrypoints.wasm" }; + const second = { name: "app.1.hash.wasm", virtualPath: "app.1.wasm" }; + context.exports.mergeLoaderConfig({ resources: { auxiliaryAssembly: [first] } }); + context.exports.mergeLoaderConfig({ environmentVariables: { CUSTOM: "1" } }); + context.exports.mergeLoaderConfig({ resources: { auxiliaryAssembly: [second] } }); + assert.deepEqual([...context.exports.loaderConfig.resources.auxiliaryAssembly], [first, second]); +}); + +function uleb(value) { + const bytes = []; + do { + const byte = value & 127; + value >>>= 7; + bytes.push(byte | (value ? 128 : 0)); + } while (value); + return bytes; +} +function string(value) { + const bytes = [...new TextEncoder().encode(value)]; + return [...uleb(bytes.length), ...bytes]; +} +function section(id, bytes) { + return [id, ...uleb(bytes.length), ...bytes]; +} +function auxiliaryWasm(statusOffset, installTable) { + const address = offset => [0x23, 0, 0x41, ...uleb(offset), 0x6a]; + const instructions = [ + ...address(statusOffset), 0x28, 2, 0, 0x04, 0x40, 0x0f, 0x0b, + ...address(180 + statusOffset), 0x41, 0, 0x41, 4, 0xfc, 8, 0, 0, + ]; + if (installTable) instructions.push(0x23, 1, 0x41, 4, 0x6a, 0x41, 0, 0x41, 1, 0xfc, 12, 0, 0); + instructions.push(...address(statusOffset), 0x41, 1, 0x36, 2, 0, 0x0b); + const activate = [0, ...instructions]; + const body = [0, 0x41, 42, 0x0b]; + return new Uint8Array([ + 0, 97, 115, 109, 1, 0, 0, 0, + ...section(1, [2, 0x60, 0, 0, 0x60, 0, 1, 0x7f]), + ...section(2, [ + 4, + ...string("webcil"), ...string("memory"), 2, 0, 1, + ...string("webcil"), ...string("__memory_base"), 3, 0x7f, 0, + ...string("webcil"), ...string("__table_base"), 3, 0x7f, 0, + ...string("webcil"), ...string("__indirect_function_table"), 1, 0x70, 0, 0, + ]), + ...section(3, [2, 0, 1]), + ...section(7, [1, ...string("ActivateModule"), 0, 0]), + ...section(9, [1, 1, 0, 1, 1]), + ...section(12, [1]), + ...section(10, [2, ...uleb(activate.length), ...activate, ...uleb(body.length), ...body]), + ...section(11, [1, 1, 4, 101, 102, 103, 104]), + ]); +} +function fixture() { + const memory = new WebAssembly.Memory({ initial: 1 }); + const table = new WebAssembly.Table({ element: "anyfunc", initial: 12 }); + const payloadPtr = 256; + const view = new DataView(memory.buffer); + const values = [0, 4, 1, 64, 96, 4, 1, 0]; + values.forEach((value, i) => view.setUint32(payloadPtr + i * 4, value, true)); + new Uint8Array(memory.buffer).set(new TextEncoder().encode("app.entrypoints.wasm\0"), payloadPtr + 64); + new Uint8Array(memory.buffer).set(new TextEncoder().encode("app.1.wasm\0"), payloadPtr + 96); + const primary = { exports: { getAuxiliaryModuleMetadata: () => payloadPtr } }; + const imports = { webcil: { + memory, + __memory_base: new WebAssembly.Global({ value: "i32", mutable: false }, payloadPtr), + __table_base: new WebAssembly.Global({ value: "i32", mutable: false }, 0), + __indirect_function_table: table, + } }; + let notifications = 0; + const errors = []; + const modules = readAuxiliaryModuleMetadata(primary, memory, payloadPtr, 512, 5); + const bytes = { "app.entrypoints.wasm": auxiliaryWasm(0, false), "app.1.wasm": auxiliaryWasm(28, true) }; + const notify = () => { + assert.equal(view.getUint32(payloadPtr, true), 1); + notifications++; + }; + return { memory, table, payloadPtr, view, primary, imports, errors, modules, bytes, notify, notifications: () => notifications }; +} +async function until(predicate) { + for (let i = 0; i < 100 && !predicate(); i++) await new Promise(resolve => setImmediate(resolve)); + assert.ok(predicate()); +} + +for (const first of ["app.entrypoints.wasm", "app.1.wasm"]) { + test(`browser: independent activation, ${first} first`, async () => { + const f = fixture(); + const pending = new Map(); + const completion = loadAuxiliaryModules(f.modules, f.memory, f.imports, + name => new Promise(resolve => pending.set(name, resolve)), f.notify, error => f.errors.push(error)); + assert.equal(f.view.getUint32(f.payloadPtr, true), 0); + assert.equal(f.view.getUint32(f.payloadPtr + 28, true), 0); + assert.equal(f.table.get(4), null); + pending.get(first)(new Response(f.bytes[first])); + await until(() => f.view.getUint32(f.payloadPtr + (first === "app.1.wasm" ? 28 : 0), true) === 1); + assert.equal(f.view.getUint32(f.payloadPtr + (first === "app.1.wasm" ? 0 : 28), true), 0); + const second = first === "app.1.wasm" ? "app.entrypoints.wasm" : "app.1.wasm"; + pending.get(second)(new Response(f.bytes[second])); + await completion; + assert.equal(f.notifications(), 1); + assert.equal(f.table.get(4)(), 42); + assert.equal(f.table.length, 12); + assert.deepEqual([...new Uint8Array(f.memory.buffer, f.payloadPtr + 208, 4)], [101, 102, 103, 104]); + await loadAuxiliaryModules(f.modules, f.memory, f.imports, () => assert.fail("duplicate download"), f.notify, error => f.errors.push(error)); + assert.equal(f.notifications(), 1); + assert.deepEqual(f.errors, []); + const duplicate = await WebAssembly.instantiate(f.bytes["app.1.wasm"], f.imports); + new Uint8Array(f.memory.buffer)[f.payloadPtr + 208] = 77; + duplicate.instance.exports.ActivateModule(); + assert.equal(new Uint8Array(f.memory.buffer)[f.payloadPtr + 208], 77); + }); +} + +test("browser: all-hot image activates an empty auxiliary body range", async () => { + const f = fixture(); + f.view.setUint32(f.payloadPtr + 4, 5, true); + f.view.setUint32(f.payloadPtr + 20, 5, true); + f.view.setUint32(f.payloadPtr + 24, 0, true); + f.bytes["app.1.wasm"] = auxiliaryWasm(28, false); + const modules = readAuxiliaryModuleMetadata(f.primary, f.memory, f.payloadPtr, 512, 5); + await loadAuxiliaryModules(modules, f.memory, f.imports, + async name => new Response(f.bytes[name]), f.notify, error => f.errors.push(error)); + assert.equal(f.view.getUint32(f.payloadPtr, true), 1); + assert.equal(f.view.getUint32(f.payloadPtr + 28, true), 1); + assert.equal(f.notifications(), 1); + assert.equal(f.table.get(4), null); + assert.equal(f.table.length, 12); + assert.deepEqual(f.errors, []); +}); + +for (const failure of ["missing", "invalid", "no-export"]) { + test(`browser: ${failure} sidecar is reported and awaitable`, async () => { + const f = fixture(); + const completion = loadAuxiliaryModules(f.modules, f.memory, f.imports, async name => { + if (name === "app.entrypoints.wasm") return new Response(f.bytes[name]); + if (failure === "missing") return new Response(null, { status: 404 }); + return new Response(failure === "invalid" ? new Uint8Array([0]) : new Uint8Array([0, 97, 115, 109, 1, 0, 0, 0])); + }, f.notify, error => f.errors.push(error)); + await assert.rejects(completion, /app\.1\.wasm/); + assert.equal(f.errors.length, 1); + assert.equal(f.notifications(), 1); + assert.equal(f.view.getUint32(f.payloadPtr + 28, true), 0); + }); +} + +test("browser: rejects corrupt metadata before loading sidecars", () => { + const f = fixture(); + assert.deepEqual(readAuxiliaryModuleMetadata({ exports: {} }, f.memory, 256, 512, 5), []); + assert.throws(() => readAuxiliaryModuleMetadata({ exports: { getAuxiliaryModuleMetadata: 1 } }, f.memory, 256, 512, 5), /export/); + f.view.setUint32(f.payloadPtr + 8, 0, true); + f.view.setUint32(f.payloadPtr + 4, 6, true); + assert.throws(() => readAuxiliaryModuleMetadata(f.primary, f.memory, 256, 512, 5), /table range/); + f.view.setUint32(f.payloadPtr + 4, 4, true); + f.view.setUint32(f.payloadPtr + 8, 1000, true); + assert.throws(() => readAuxiliaryModuleMetadata(f.primary, f.memory, 256, 512, 5), /truncated/); + f.view.setUint32(f.payloadPtr + 8, 1, true); + new Uint8Array(f.memory.buffer).set(new TextEncoder().encode("../bad.wasm\0"), f.payloadPtr + 96); + assert.throws(() => readAuxiliaryModuleMetadata(f.primary, f.memory, 256, 512, 5), /filename/); +}); + +for (const stage of ["eager", "missing", "delay-entrypoints", "delay-body", "all-hot"]) { + test(`corerun: activation is deferred until probe returns; ${stage}`, async () => { + const f = fixture(); + if (stage === "all-hot") { + f.view.setUint32(f.payloadPtr + 4, 5, true); + f.view.setUint32(f.payloadPtr + 20, 5, true); + f.view.setUint32(f.payloadPtr + 24, 0, true); + f.bytes["app.1.wasm"] = auxiliaryWasm(28, false); + } + let probed = false; + let library; + const timers = []; + const env = stage === "delay-entrypoints" + ? { DOTNET_WasmAuxiliaryEntryPointsDelayMilliseconds: "100" } + : stage === "delay-body" ? { DOTNET_WasmAuxiliaryModuleDelayMilliseconds: "100" } : {}; + const context = { + LibraryManager: { library: {} }, autoAddDeps: () => {}, addToLibrary: value => library = value, + WebAssembly, TextDecoder, Error, DataView, Uint8Array, Set, Promise, ENV: env, + setTimeout: callback => timers.push(callback), + wasmMemory: f.memory, + wasmExports: { dotnet_wasm_notify_auxiliary_entrypoints_activated: f.notify }, + FS: { readFile: path => { + assert.ok(probed); + const name = path.substring(path.lastIndexOf("/") + 1); + if (stage === "missing" && name === "app.1.wasm") throw new Error("Missing file"); + return f.bytes[name]; + } }, + err: message => f.errors.push(message), + }; + vm.runInNewContext(corerunSource, context); + context.CORERUN = library.$CORERUN; + context.CORERUN.queueAuxiliaryModules(f.primary, f.imports, f.payloadPtr, 512, 5, "/app/app.wasm"); + assert.equal(f.view.getUint32(f.payloadPtr, true), 0); + probed = true; + if (stage === "missing") { + await assert.rejects(context.CORERUN.waitForAuxiliaryModules(), /Missing file/); + assert.equal(f.errors.length, 1); + } else { + if (stage.startsWith("delay")) { + const pendingOffset = stage === "delay-body" ? 28 : 0; + await until(() => f.view.getUint32(f.payloadPtr + (28 - pendingOffset), true) === 1); + assert.equal(f.view.getUint32(f.payloadPtr + pendingOffset, true), 0); + assert.equal(timers.length, 1); + timers[0](); + } + + await context.CORERUN.waitForAuxiliaryModules(); + assert.equal(f.view.getUint32(f.payloadPtr + 28, true), 1); + if (stage === "all-hot") + assert.equal(f.table.get(4), null); + else + assert.equal(f.table.get(4)(), 42); + } + assert.equal(f.notifications(), 1); + }); +} + +for (const [first, rawFs] of [["ENTRYPOINTS", false], ["MODULES", false], ["ENTRYPOINTS", true], ["MODULES", true]]) { + test(`corerun: ${rawFs ? "raw FS" : "stdout"} gate releases ${first} first on a later event-loop turn`, async () => { + const f = fixture(); + const timers = []; + const printed = []; + const reads = []; + let finishFirstCompile; + const firstCompile = new Promise(resolve => finishFirstCompile = resolve); + const firstFilename = first === "MODULES" ? "app.1.wasm" : "app.entrypoints.wasm"; + let library; + const context = { + LibraryManager: { library: {} }, autoAddDeps: () => {}, addToLibrary: value => library = value, + WebAssembly: { instantiate: async (bytes, imports) => { + if (bytes === f.bytes[firstFilename]) await firstCompile; + return WebAssembly.instantiate(bytes, imports); + } }, + TextDecoder, Error, DataView, Uint8Array, Set, Promise, + ENV: { DOTNET_WasmAuxiliaryManualActivation: "1" }, + Module: { preRun: [] }, + setTimeout: callback => timers.push(callback), + out: text => printed.push(text), + wasmMemory: f.memory, + wasmExports: { dotnet_wasm_notify_auxiliary_entrypoints_activated: f.notify }, + FS: { + write: (stream, buffer, offset, length) => { + printed.push(new TextDecoder().decode(buffer.subarray(offset, offset + length))); + return length; + }, + readFile: path => { + const name = path.substring(path.lastIndexOf("/") + 1); + reads.push(name); + return f.bytes[name]; + }, + }, + err: message => f.errors.push(message), + }; + vm.runInNewContext(corerunSource, context); + context.CORERUN = library.$CORERUN; + context.CORERUN.installAuxiliaryModuleGates(); + context.Module.preRun.forEach(callback => callback()); + context.CORERUN.queueAuxiliaryModules(f.primary, f.imports, f.payloadPtr, 512, 5, "/app/app.wasm"); + await new Promise(resolve => setImmediate(resolve)); + assert.equal(f.view.getUint32(f.payloadPtr, true), 0); + assert.equal(f.view.getUint32(f.payloadPtr + 28, true), 0); + const emit = stage => { + const marker = "AUXILIARY_R2R_ACTIVATE_" + stage; + if (rawFs) { + const bytes = new TextEncoder().encode("xx" + marker + "\n"); + context.FS.write({ fd: 1 }, bytes, 2, 9); + context.FS.write({ fd: 1 }, bytes, 11, bytes.length - 11); + } else { + context.out(marker); + } + }; + emit(first); + assert.equal(timers.length, 1); + assert.equal(f.view.getUint32(f.payloadPtr + (first === "MODULES" ? 28 : 0), true), 0); + timers.shift()(); + const second = first === "MODULES" ? "ENTRYPOINTS" : "MODULES"; + emit(second); + timers.shift()(); + await new Promise(resolve => setImmediate(resolve)); + assert.deepEqual(reads, [firstFilename]); + assert.equal(f.view.getUint32(f.payloadPtr, true), 0); + assert.equal(f.view.getUint32(f.payloadPtr + 28, true), 0); + finishFirstCompile(); + await context.CORERUN.waitForAuxiliaryModules(); + assert.deepEqual(reads, [firstFilename, second === "MODULES" ? "app.1.wasm" : "app.entrypoints.wasm"]); + assert.equal(f.notifications(), 1); + assert.equal(f.table.get(4)(), 42); + const newline = rawFs ? "\n" : ""; + assert.equal(printed.join(""), "AUXILIARY_R2R_ACTIVATE_" + first + newline + "AUXILIARY_R2R_ACTIVATE_" + second + newline); + }); +} diff --git a/src/native/libs/Common/JavaScript/host/auxiliary-modules.ts b/src/native/libs/Common/JavaScript/host/auxiliary-modules.ts new file mode 100644 index 00000000000000..b3e9255cfe4c7b --- /dev/null +++ b/src/native/libs/Common/JavaScript/host/auxiliary-modules.ts @@ -0,0 +1,105 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +type AuxiliaryModule = { name: string, statusAddress: number, entrypoints: boolean }; + +export function readAuxiliaryModuleMetadata(instance: WebAssembly.Instance, memory: WebAssembly.Memory, payloadPtr: number, payloadSize: number, tableSize: number): AuxiliaryModule[] { + const getMetadata = instance.exports.getAuxiliaryModuleMetadata; + if (getMetadata === undefined) { + return []; + } + if (typeof getMetadata !== "function") { + throw new Error("Invalid getAuxiliaryModuleMetadata export."); + } + const address = getMetadata() >>> 0; + if (address === 0) { + return []; + } + const heap = new Uint8Array(memory.buffer); + const payloadEnd = payloadPtr + payloadSize; + if (payloadEnd > heap.length || address < payloadPtr || address % 4 !== 0 || address + 16 > payloadEnd) { + throw new Error("Auxiliary R2R module metadata is outside the primary payload."); + } + const view = new DataView(memory.buffer); + const count = view.getUint32(address + 8, true); + if (count > (payloadEnd - address - 16) / 16) { + throw new Error("Auxiliary R2R module metadata is truncated."); + } + const names = new Set(); + function readModule(filenameRva: number, statusAddress: number, entrypoints: boolean): AuxiliaryModule { + const status = view.getUint32(statusAddress, true); + const start = payloadPtr + filenameRva; + if (status > 1 || start < payloadPtr || start >= payloadEnd) { + throw new Error("Invalid auxiliary R2R module metadata."); + } + let end = start; + while (end < payloadEnd && heap[end] !== 0) end++; + if (end === payloadEnd) { + throw new Error("Unterminated auxiliary R2R module filename."); + } + const name = new TextDecoder("utf-8", { fatal: true }).decode(heap.subarray(start, end)); + if (!/^[^./\\][^/\\?#:]*\.wasm$/.test(name) || names.has(name)) { + throw new Error(`Invalid or duplicate auxiliary R2R module filename '${name}'.`); + } + names.add(name); + return { name, statusAddress, entrypoints }; + } + const modules = [readModule(view.getUint32(address + 12, true), address, true)]; + let nextTableIndex = view.getUint32(address + 4, true); + if (nextTableIndex > tableSize) { + throw new Error("Invalid primary R2R module table range."); + } + for (let i = 0; i < count; i++) { + const entry = address + 16 + i * 16; + const tableBase = view.getUint32(entry + 4, true); + const methodCount = view.getUint32(entry + 8, true); + if (tableBase !== nextTableIndex || tableBase + methodCount > tableSize) { + throw new Error("Invalid auxiliary R2R module table range."); + } + nextTableIndex += methodCount; + modules.push(readModule(view.getUint32(entry, true), entry + 12, false)); + } + return modules; +} + +// Each operation settles independently: EntryPoints must not wait for a cold body (or vice versa). +// Settled errors are retained for test/host waiters and reported immediately, without unhandled rejections. +export function loadAuxiliaryModules(modules: AuxiliaryModule[], memory: WebAssembly.Memory, imports: WebAssembly.Imports, load: (name: string) => Promise, notifyEntryPoints: () => void, reportError: (error: Error) => void): Promise { + const operations = modules.map(async module => { + try { + if (new DataView(memory.buffer).getUint32(module.statusAddress, true) === 1) { + return; + } + const response = await load(module.name); + if (!response.ok) { + throw new Error(`HTTP status: ${response.status} ${response.statusText}`); + } + const { instance } = await WebAssembly.instantiate(await response.arrayBuffer(), imports); + const activate = instance.exports.ActivateModule; + if (typeof activate !== "function") { + throw new Error("Missing ActivateModule export."); + } + // A second load may have completed while this instance was being compiled. + if (new DataView(memory.buffer).getUint32(module.statusAddress, true) === 0) { + activate(); + if (new DataView(memory.buffer).getUint32(module.statusAddress, true) !== 1) { + throw new Error("ActivateModule did not publish its activation status."); + } + if (module.entrypoints) { + notifyEntryPoints(); + } + } + } catch (reason) { + const error = new Error(`Failed to load auxiliary R2R module '${module.name}': ${reason instanceof Error ? reason.message : String(reason)}`, { cause: reason }); + reportError(error); + return error; + } + }); + const completion = Promise.all(operations).then(errors => { + const error = errors.find(error => error !== undefined); + if (error) throw error; + }); + // Keep the rejecting completion awaitable while marking background failures as observed. + void completion.catch(() => {}); + return completion; +} diff --git a/src/native/libs/Common/JavaScript/loader/assets.ts b/src/native/libs/Common/JavaScript/loader/assets.ts index 73ee929b60e42c..9e6d73788a7585 100644 --- a/src/native/libs/Common/JavaScript/loader/assets.ts +++ b/src/native/libs/Common/JavaScript/loader/assets.ts @@ -177,13 +177,30 @@ async function fetchWebcil(assetInternal: AssetEntryInternal): Promise { const webcilPromise = loadResource(assetInternal); const memory = await wasmMemoryPromiseController.promise; - const instancePromise = dotnetBrowserHostExports.instantiateWebcilModule(webcilPromise, memory, assetInternal.virtualPath!, assetInternal.tableSize, assetInternal.payloadSize); + const instancePromise = dotnetBrowserHostExports.instantiateWebcilModule(webcilPromise, memory, assetInternal.virtualPath!, assetInternal.tableSize, assetInternal.payloadSize, loadAuxiliaryModule); await instancePromise; } finally { onDownloadedAsset(assetInternal); } } +async function loadAuxiliaryModule(name: string): Promise { + const asset = loaderConfig.resources?.auxiliaryAssembly?.find(asset => asset.virtualPath === name); + if (!asset) { + throw new Error(`Auxiliary R2R module '${name}' is missing from boot resources.`); + } + const assetInternal = asset as AssetEntryInternal; + assetInternal.behavior = "webcil"; + assetInternal.resolvedUrl ??= locateFile(assetInternal.name); + // These are background resources and must not enter the startup download barrier/counters. + try { + return await loadResource(assetInternal); + } finally { + if (assetInternal.inprogress) finishThrottling(assetInternal); + assetInternal.buffer = null!; + assetInternal.pendingDownload = undefined; + } +} async function fetchDll(assetInternal: AssetEntryInternal): Promise { const virtualPath = assetInternal.virtualPath!; assetInternal.behavior = "assembly"; @@ -644,6 +661,7 @@ export async function prefetchAllResources(extraVfs?: VfsAsset[]): Promise // Data assets: fetch and discard if (resources.coreAssembly) resources.coreAssembly.forEach(a => enqueueAsset(a, "assembly")); if (resources.assembly) resources.assembly.forEach(a => enqueueAsset(a, "assembly")); + if (resources.auxiliaryAssembly) resources.auxiliaryAssembly.forEach(a => enqueueAsset(a, "webcil")); if (resources.vfs) resources.vfs.forEach(a => enqueueAsset(a, "vfs")); if (extraVfs) extraVfs.forEach(a => enqueueAsset(a, "vfs")); if (resources.icu) resources.icu.forEach(a => enqueueAsset(a, "icu")); diff --git a/src/native/libs/Common/JavaScript/loader/config.ts b/src/native/libs/Common/JavaScript/loader/config.ts index 11c9c19e6115f9..9459ad325998dd 100644 --- a/src/native/libs/Common/JavaScript/loader/config.ts +++ b/src/native/libs/Common/JavaScript/loader/config.ts @@ -63,6 +63,7 @@ function mergeResources(target: Assets, source: Assets): Assets { source.hash = source.hash ?? target.hash; source.coreAssembly = [...target.coreAssembly!, ...source.coreAssembly || []]; source.assembly = [...target.assembly!, ...source.assembly || []]; + source.auxiliaryAssembly = [...target.auxiliaryAssembly!, ...source.auxiliaryAssembly || []]; source.lazyAssembly = [...target.lazyAssembly!, ...source.lazyAssembly || []]; source.corePdb = [...target.corePdb!, ...source.corePdb || []]; source.pdb = [...target.pdb!, ...source.pdb || []]; @@ -114,6 +115,7 @@ function normalizeConfig(target: LoaderConfigInternal) { function normalizeResources(target: Assets) { if (!target.coreAssembly) target.coreAssembly = []; if (!target.assembly) target.assembly = []; + if (!target.auxiliaryAssembly) target.auxiliaryAssembly = []; if (!target.lazyAssembly) target.lazyAssembly = []; if (!target.corePdb) target.corePdb = []; if (!target.pdb) target.pdb = []; diff --git a/src/native/libs/Common/JavaScript/loader/dotnet.d.ts b/src/native/libs/Common/JavaScript/loader/dotnet.d.ts index 4220f6abea87fa..a2fe28cc9a509f 100644 --- a/src/native/libs/Common/JavaScript/loader/dotnet.d.ts +++ b/src/native/libs/Common/JavaScript/loader/dotnet.d.ts @@ -258,6 +258,9 @@ interface Assets { jsModuleRuntime: JsAsset[]; wasmSymbols?: SymbolsAsset[]; wasmNative: WasmAsset[]; + auxiliaryAssembly?: (WasmAsset & { + virtualPath: string; + })[]; icu?: IcuAsset[]; satelliteResources?: { [cultureName: string]: AssemblyAsset[]; diff --git a/src/native/libs/Common/JavaScript/types/public-api.ts b/src/native/libs/Common/JavaScript/types/public-api.ts index 895335f8d1b44c..a9b40cf01cc0c2 100644 --- a/src/native/libs/Common/JavaScript/types/public-api.ts +++ b/src/native/libs/Common/JavaScript/types/public-api.ts @@ -222,6 +222,7 @@ export interface Assets { jsModuleRuntime: JsAsset[]; wasmSymbols?: SymbolsAsset[]; wasmNative: WasmAsset[]; + auxiliaryAssembly?: (WasmAsset & { virtualPath: string })[]; icu?: IcuAsset[]; satelliteResources?: { [cultureName: string]: AssemblyAsset[]; diff --git a/src/native/package.json b/src/native/package.json index 6cfd9b61522416..a201a64e91ab85 100644 --- a/src/native/package.json +++ b/src/native/package.json @@ -15,6 +15,7 @@ "rollup:release": "rollup -c --environment Configuration:Release,ProductVersion:11.0,ContinuousIntegrationBuild:false,RuntimeFlavor:CoreCLR", "rollup:debug": "rollup -c --environment Configuration:Debug,ProductVersion:11.0,ContinuousIntegrationBuild:false,RuntimeFlavor:CoreCLR", "lint": "eslint --no-color --max-warnings=0 \"./**/*.ts\" \"./**/*.js\"", + "test:auxiliary-modules": "node --test ./libs/Common/JavaScript/host/auxiliary-modules.test.mjs", "format": "eslint --fix \"./**/*.ts\" \"./*.js\"" }, "keywords": [ diff --git a/src/tasks/Microsoft.NET.Sdk.WebAssembly.Pack.Tasks/AttachWebcilSizes.cs b/src/tasks/Microsoft.NET.Sdk.WebAssembly.Pack.Tasks/AttachWebcilSizes.cs index e19d2f21c029be..b3f1dd0a80f903 100644 --- a/src/tasks/Microsoft.NET.Sdk.WebAssembly.Pack.Tasks/AttachWebcilSizes.cs +++ b/src/tasks/Microsoft.NET.Sdk.WebAssembly.Pack.Tasks/AttachWebcilSizes.cs @@ -65,7 +65,9 @@ private static bool IsWebcilInWasm(ITaskItem candidate) if (!string.Equals(candidate.GetMetadata("Extension"), ".wasm", StringComparison.OrdinalIgnoreCase)) return false; + string traitValue = candidate.GetMetadata("AssetTraitValue"); return !(string.Equals(candidate.GetMetadata("AssetTraitName"), "WasmResource", StringComparison.OrdinalIgnoreCase) - && string.Equals(candidate.GetMetadata("AssetTraitValue"), "native", StringComparison.OrdinalIgnoreCase)); + && (string.Equals(traitValue, "native", StringComparison.OrdinalIgnoreCase) + || string.Equals(traitValue, "readyToRunAuxiliary", StringComparison.OrdinalIgnoreCase))); } } diff --git a/src/tasks/Microsoft.NET.Sdk.WebAssembly.Pack.Tasks/BootJsonBuilderHelper.cs b/src/tasks/Microsoft.NET.Sdk.WebAssembly.Pack.Tasks/BootJsonBuilderHelper.cs index 554ae0f476f01f..de365b3fb6964e 100644 --- a/src/tasks/Microsoft.NET.Sdk.WebAssembly.Pack.Tasks/BootJsonBuilderHelper.cs +++ b/src/tasks/Microsoft.NET.Sdk.WebAssembly.Pack.Tasks/BootJsonBuilderHelper.cs @@ -123,6 +123,7 @@ static void AddDictionary(StringBuilder sb, Dictionary? res) } AddDictionary(sb, resources.assembly); + AddDictionary(sb, resources.auxiliaryAssembly); AddDictionary(sb, resources.coreAssembly); AddDictionary(sb, resources.jsModuleWorker); @@ -254,6 +255,7 @@ public string TransformResourcesToAssets(BootJsonData config, bool bundlerFriend assets.icu = MapGeneralAssets(resources.icu); assets.coreAssembly = MapWebcilAssets(resources.coreAssembly); assets.assembly = MapWebcilAssets(resources.assembly); + assets.auxiliaryAssembly = MapGeneralAssets(resources.auxiliaryAssembly); assets.corePdb = MapGeneralAssets(resources.corePdb); assets.pdb = MapGeneralAssets(resources.pdb); assets.lazyAssembly = MapWebcilAssets(resources.lazyAssembly); diff --git a/src/tasks/Microsoft.NET.Sdk.WebAssembly.Pack.Tasks/BootJsonData.cs b/src/tasks/Microsoft.NET.Sdk.WebAssembly.Pack.Tasks/BootJsonData.cs index a161042c338bfa..c279b61189757a 100644 --- a/src/tasks/Microsoft.NET.Sdk.WebAssembly.Pack.Tasks/BootJsonData.cs +++ b/src/tasks/Microsoft.NET.Sdk.WebAssembly.Pack.Tasks/BootJsonData.cs @@ -215,6 +215,9 @@ public class ResourcesData /// public ResourceHashesByNameDictionary assembly { get; set; } = new ResourceHashesByNameDictionary(); + [DataMember(EmitDefaultValue = false)] + public ResourceHashesByNameDictionary auxiliaryAssembly { get; set; } + /// /// "debug" (.pdb) resources needed to start MonoVM /// @@ -313,6 +316,9 @@ public class AssetsData /// public List assembly { get; set; } = new(); + [DataMember(EmitDefaultValue = false)] + public List auxiliaryAssembly { get; set; } + /// /// "debug" (.pdb) resources needed to start MonoVM /// diff --git a/src/tasks/Microsoft.NET.Sdk.WebAssembly.Pack.Tasks/CollectWasmAuxiliaryR2RModules.cs b/src/tasks/Microsoft.NET.Sdk.WebAssembly.Pack.Tasks/CollectWasmAuxiliaryR2RModules.cs new file mode 100644 index 00000000000000..658998a643edb9 --- /dev/null +++ b/src/tasks/Microsoft.NET.Sdk.WebAssembly.Pack.Tasks/CollectWasmAuxiliaryR2RModules.cs @@ -0,0 +1,87 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.IO; +using System.Text.Json; +using Microsoft.Build.Framework; +using Microsoft.Build.Utilities; + +namespace Microsoft.NET.Sdk.WebAssembly; + +public sealed class CollectWasmAuxiliaryR2RModules : Task +{ + [Required] + public ITaskItem[] PrimaryImages { get; set; } + + public bool AllowMissingFiles { get; set; } + + [Output] + public ITaskItem[] Modules { get; set; } = Array.Empty(); + + [Output] + public ITaskItem[] FileWrites { get; set; } = Array.Empty(); + + [Output] + public ITaskItem[] MissingOutputs { get; set; } = Array.Empty(); + + public override bool Execute() + { + var modules = new List(); + var writes = new List(); + var missing = new List(); + var names = new HashSet(StringComparer.OrdinalIgnoreCase); + try + { + foreach (ITaskItem primary in PrimaryImages) + { + string path = Path.GetFullPath(primary.ItemSpec); + string manifest = Path.ChangeExtension(path, ".auxiliary.json"); + if (AllowMissingFiles && !File.Exists(manifest)) + { + missing.Add(new TaskItem(manifest)); + continue; + } + using JsonDocument document = JsonDocument.Parse(File.ReadAllText(manifest)); + writes.Add(new TaskItem(manifest)); + AddModule(document.RootElement.GetProperty("entrypoints").GetString()); + foreach (JsonElement module in document.RootElement.GetProperty("modules").EnumerateArray()) + AddModule(module.GetString()); + + void AddModule(string? name) + { + if (string.IsNullOrEmpty(name) || name != Path.GetFileName(name) || + name.IndexOfAny(new[] { '/', '\\', ':', '?', '#' }) >= 0 || + name.StartsWith(".", StringComparison.Ordinal) || + !name.EndsWith(".wasm", StringComparison.Ordinal) || !names.Add(name)) + { + throw new InvalidDataException($"Invalid or duplicate auxiliary module filename '{name}' in '{manifest}'."); + } + string modulePath = Path.Combine(Path.GetDirectoryName(path)!, name); + if (!File.Exists(modulePath)) + { + if (!AllowMissingFiles) + throw new FileNotFoundException($"Auxiliary ReadyToRun module listed in '{manifest}' does not exist.", modulePath); + missing.Add(new TaskItem(modulePath)); + } + + var item = new TaskItem(modulePath); + item.SetMetadata("ContentRoot", Path.GetDirectoryName(modulePath) + Path.DirectorySeparatorChar); + item.SetMetadata("RelativePath", "_framework/" + name); + item.SetMetadata("OriginalItemSpec", modulePath); + modules.Add(item); + writes.Add(new TaskItem(modulePath)); + } + } + } + catch (Exception exception) when (exception is IOException or InvalidDataException or JsonException or InvalidOperationException or KeyNotFoundException or UnauthorizedAccessException) + { + Log.LogErrorFromException(exception, showStackTrace: false); + } + Modules = modules.ToArray(); + FileWrites = writes.ToArray(); + MissingOutputs = missing.ToArray(); + return !Log.HasLoggedErrors; + } +} diff --git a/src/tasks/Microsoft.NET.Sdk.WebAssembly.Pack.Tasks/GenerateWasmBootJson.cs b/src/tasks/Microsoft.NET.Sdk.WebAssembly.Pack.Tasks/GenerateWasmBootJson.cs index 56104bbc936f3c..eec275cddb1714 100644 --- a/src/tasks/Microsoft.NET.Sdk.WebAssembly.Pack.Tasks/GenerateWasmBootJson.cs +++ b/src/tasks/Microsoft.NET.Sdk.WebAssembly.Pack.Tasks/GenerateWasmBootJson.cs @@ -294,6 +294,12 @@ private void WriteBootConfig(string entryAssemblyName) } } } + else if (string.Equals("WasmResource", assetTraitName, StringComparison.OrdinalIgnoreCase) + && string.Equals("readyToRunAuxiliary", assetTraitValue, StringComparison.OrdinalIgnoreCase)) + { + MapFingerprintedAsset(resourceData, resourceRoute, resourceName); + resourceList = resourceData.auxiliaryAssembly ??= new ResourceHashesByNameDictionary(); + } else if (string.Equals("WasmResource", assetTraitName, StringComparison.OrdinalIgnoreCase) && string.Equals("readyToRunComposite", assetTraitValue, StringComparison.OrdinalIgnoreCase)) { @@ -422,7 +428,8 @@ private void WriteBootConfig(string entryAssemblyName) bool isWebcilInWasmAssembly = IsTargeting110OrLater() && string.Equals(fileExtension, ".wasm", StringComparison.OrdinalIgnoreCase) && !(string.Equals(assetTraitName, "WasmResource", StringComparison.OrdinalIgnoreCase) - && string.Equals(assetTraitValue, "native", StringComparison.OrdinalIgnoreCase)); + && (string.Equals(assetTraitValue, "native", StringComparison.OrdinalIgnoreCase) + || string.Equals(assetTraitValue, "readyToRunAuxiliary", StringComparison.OrdinalIgnoreCase))); if (isWebcilInWasmAssembly) { diff --git a/src/tests/readytorun/wasm/DelayedR2RActivation/DelayedR2RActivation.cs b/src/tests/readytorun/wasm/DelayedR2RActivation/DelayedR2RActivation.cs index c6b50a8c44f5ea..797979c50ec190 100644 --- a/src/tests/readytorun/wasm/DelayedR2RActivation/DelayedR2RActivation.cs +++ b/src/tests/readytorun/wasm/DelayedR2RActivation/DelayedR2RActivation.cs @@ -9,9 +9,27 @@ using System.Runtime.CompilerServices; using System.Runtime.InteropServices; using System.Threading.Tasks; +#if !PARTITIONED_WASM_R2R_BOUNDARY using DelayedR2RActivationHelpers; using Xunit; +#endif +#if PARTITIONED_WASM_R2R_BOUNDARY +public static class PartitionedR2RBoundary +{ +#if PARTITIONED_WASM_R2R_ALL_HOT + public static int Main() + { + const int PrefersInterpreterEntryPoint = 0x4; + MethodInfo method = typeof(PartitionedR2RBoundary).GetMethod(nameof(Main)); + IntPtr entryPoint = method.MethodHandle.GetFunctionPointer(); + return (Marshal.ReadInt32(entryPoint, 3 * IntPtr.Size) & PrefersInterpreterEntryPoint) == 0 ? 100 : 101; + } +#else + private static int Main() => 100; +#endif +} +#else namespace System.Runtime { [AttributeUsage(AttributeTargets.Method)] @@ -42,6 +60,185 @@ public class DelayedR2RActivation (s_entryPointsCallCount == 0 || s_entryPointsDelayed) && (s_activationCallCount == 0 || s_moduleDelayed); private static Action s_atRecursiveBottom; +#if PARTITIONED_WASM_R2R + [Fact] + [BypassReadyToRun] + [RuntimeAsyncMethodGeneration(false)] + public static async Task ActivatesDownloadedModulesAsync() + { + bool manualActivation = Environment.GetEnvironmentVariable("DOTNET_WasmAuxiliaryManualActivation") == "1"; + bool bodyFirst = Environment.GetEnvironmentVariable("DOTNET_WasmAuxiliaryBodyFirst") == "1"; + MethodInfo coldMethod = typeof(DelayedR2RActivation).GetMethod( + nameof(PartitionedColdTarget), BindingFlags.Static | BindingFlags.NonPublic); + MethodInfo genericMethod = typeof(DelayedR2RActivation).GetMethod( + nameof(PartitionedGenericTarget), BindingFlags.Static | BindingFlags.NonPublic).MakeGenericMethod(typeof(int)); + IntPtr coldEntryPoint = PrepareMethod(coldMethod); + IntPtr genericEntryPoint = PrepareMethod(genericMethod); + IntPtr virtualEntryPoint = PrepareMethod( + typeof(PartitionedVirtualTarget).GetMethod(nameof(PartitionedVirtualTarget.Invoke))); + IntPtr exceptionEntryPoint = PrepareMethod(typeof(DelayedR2RActivation).GetMethod( + nameof(PartitionedExceptionTarget), BindingFlags.Static | BindingFlags.NonPublic)); + IntPtr suspendedEntryPoint = PrepareMethod(typeof(DelayedR2RActivation).GetMethod( + nameof(PartitionedSuspendedTarget), BindingFlags.Static | BindingFlags.NonPublic)); + IntPtr crossModuleEntryPoint = PrepareMethod( + typeof(List).GetMethod("AddWithResize", BindingFlags.Instance | BindingFlags.NonPublic)); + // Unmanaged function pointers are Wasm table indices, not PortableEntryPoint addresses. + PrepareMethod(typeof(DelayedR2RActivation).GetMethod(nameof(PartitionedUnmanagedTarget))); + Assert.Equal(42, CallPartitionedUnmanagedTarget(1)); + object marker = new(); + TaskCompletionSource source = new(); + Task suspended = PartitionedSuspendedTarget(source.Task, marker); + Assert.False(suspended.IsCompleted, nameof(PartitionedSuspendedTarget)); + + if (manualActivation) + { + Assert.True(PrefersInterpreter(coldEntryPoint)); + Assert.True(IsAwaitingAuxiliaryEntryPoints(coldEntryPoint)); + Assert.True(IsAwaitingAuxiliaryEntryPoints(genericEntryPoint)); + Assert.True(IsAwaitingAuxiliaryEntryPoints(virtualEntryPoint)); + Assert.True(IsAwaitingAuxiliaryEntryPoints(exceptionEntryPoint)); + Assert.True(IsAwaitingAuxiliaryEntryPoints(suspendedEntryPoint)); + Assert.True(IsAwaitingAuxiliaryEntryPoints(crossModuleEntryPoint)); + VerifyPartitionedCalls(2, marker); + + if (bodyFirst) + { + Console.WriteLine("AUXILIARY_R2R_ACTIVATE_MODULES"); + await Task.Delay(20); + VerifyPartitionedCalls(3, marker); + Assert.True(IsAwaitingAuxiliaryEntryPoints(coldEntryPoint)); + Assert.True(PrefersInterpreter(coldEntryPoint)); + Console.WriteLine("AUXILIARY_R2R_ACTIVATE_ENTRYPOINTS"); + } + else + { + Console.WriteLine("AUXILIARY_R2R_ACTIVATE_ENTRYPOINTS"); + long deadline = Environment.TickCount64 + 30000; + while (IsAwaitingAuxiliaryEntryPoints(coldEntryPoint) && Environment.TickCount64 < deadline) + { + await Task.Delay(1); + Assert.Equal(34, PartitionedColdTarget(3)); + } + Assert.True(IsPendingAuxiliaryModule(coldEntryPoint)); + Assert.True(PrefersInterpreter(coldEntryPoint)); + VerifyPartitionedCalls(3, marker); + Console.WriteLine("AUXILIARY_R2R_ACTIVATE_MODULES"); + } + } + + long activationDeadline = Environment.TickCount64 + 30000; + while (PrefersInterpreter(coldEntryPoint) && Environment.TickCount64 < activationDeadline) + { + await Task.Delay(1); + Assert.Equal(41, PartitionedColdTarget(4)); + } + Assert.False(PrefersInterpreter(coldEntryPoint), nameof(PartitionedColdTarget)); + VerifyPartitionedCalls(5, marker); + Assert.False(PrefersInterpreter(genericEntryPoint), nameof(PartitionedGenericTarget)); + Assert.False(PrefersInterpreter(virtualEntryPoint), nameof(PartitionedVirtualTarget.Invoke)); + Assert.False(PrefersInterpreter(exceptionEntryPoint), nameof(PartitionedExceptionTarget)); + Assert.False(PrefersInterpreter(crossModuleEntryPoint), "AddWithResize"); + Assert.False(IsDelayed(coldEntryPoint)); + Assert.False(IsDelayed(genericEntryPoint)); + + GC.Collect(); + source.SetResult(17); + Assert.Same(marker, await suspended); + Assert.Same(marker, await PartitionedSuspendedTarget(Task.FromResult(19), marker)); + Assert.False(PrefersInterpreter(suspendedEntryPoint), nameof(PartitionedSuspendedTarget)); + } + + [BypassReadyToRun] + private static void VerifyPartitionedCalls(int value, object marker) + { + Func callback = PartitionedColdTarget; + Assert.Equal(value * 7 + 13, PartitionedColdTarget(value)); + Assert.Equal(value * 7 + 13, callback(value)); + Assert.Equal(value * 7 + 13, CallPartitionedFunctionPointer(value)); + MethodInfo method = typeof(DelayedR2RActivation).GetMethod( + nameof(PartitionedColdTarget), BindingFlags.Static | BindingFlags.NonPublic); + Assert.Equal(value * 7 + 13, (int)method.Invoke(null, new object[] { value })); + Assert.Equal(value + 23, PartitionedGenericTarget(value)); + Assert.Equal(value + 29, ((IVirtualTarget)new PartitionedVirtualTarget()).Invoke(value)); + Assert.Same(marker, PartitionedExceptionTarget(marker)); + Assert.Equal(5, CrossModuleGenericCaller()); + Assert.Equal(value * 9 + 65, PartitionedCompiledCaller(value)); + } + + [MethodImpl(MethodImplOptions.NoInlining)] + private static int PartitionedColdTarget(int value) => value * 7 + 13; + + [BypassReadyToRun] + private static unsafe int CallPartitionedFunctionPointer(int value) + { + delegate* target = &PartitionedColdTarget; + return target(value); + } + + [BypassReadyToRun] + private static unsafe int CallPartitionedUnmanagedTarget(int value) + { + delegate* unmanaged target = &PartitionedUnmanagedTarget; + return target(value); + } + + [UnmanagedCallersOnly] + public static int PartitionedUnmanagedTarget(int value) => value + 41; + + [MethodImpl(MethodImplOptions.NoInlining)] + private static int PartitionedGenericTarget(int value) + { + GC.KeepAlive(typeof(T)); + return value + 23; + } + + [MethodImpl(MethodImplOptions.NoInlining)] + public static int PartitionedCompiledCaller(int value) + { + object marker = new(); + if (!ReferenceEquals(marker, PartitionedExceptionTarget(marker))) + throw new InvalidOperationException(); + + return PartitionedColdTarget(value) + PartitionedGenericTarget(value) + + ((IVirtualTarget)new PartitionedVirtualTarget()).Invoke(value); + } + + [MethodImpl(MethodImplOptions.NoInlining)] + private static object PartitionedExceptionTarget(object value) + { + try + { + throw new InvalidOperationException(); + } + catch (InvalidOperationException) + { + GC.Collect(); + return value; + } + finally + { + GC.KeepAlive(value); + } + } + + [MethodImpl(MethodImplOptions.NoInlining)] + private static async Task PartitionedSuspendedTarget(Task pending, object value) + { + await pending; + GC.KeepAlive(value); + return value; + } + + private sealed class PartitionedVirtualTarget : IVirtualTarget + { + [MethodImpl(MethodImplOptions.NoInlining)] + public int Invoke(int value) + { + GC.KeepAlive(typeof(T)); + return value + 29; + } + } +#else [Fact] [BypassReadyToRun] [RuntimeAsyncMethodGeneration(false)] @@ -211,6 +408,8 @@ public static async Task ActivatesCompositeImageAsync() } } +#endif + [BypassReadyToRun] private static IntPtr PrepareMethod(string methodName) => PrepareMethod(typeof(DelayedR2RActivation).GetMethod(methodName)); @@ -444,3 +643,4 @@ public static int Value } } } +#endif diff --git a/src/tests/readytorun/wasm/DelayedR2RActivation/PartitionedR2RActivation_AllCold.csproj b/src/tests/readytorun/wasm/DelayedR2RActivation/PartitionedR2RActivation_AllCold.csproj new file mode 100644 index 00000000000000..617977c8d46537 --- /dev/null +++ b/src/tests/readytorun/wasm/DelayedR2RActivation/PartitionedR2RActivation_AllCold.csproj @@ -0,0 +1,16 @@ + + + true + $(DefineConstants);PARTITIONED_WASM_R2R_BOUNDARY + true + false + Exe + true + true + $(CrossGen2TestExtraArguments) --wasm-auxiliary-modules + true + + + + + diff --git a/src/tests/readytorun/wasm/DelayedR2RActivation/PartitionedR2RActivation_AllHot.csproj b/src/tests/readytorun/wasm/DelayedR2RActivation/PartitionedR2RActivation_AllHot.csproj new file mode 100644 index 00000000000000..6daa49fe985deb --- /dev/null +++ b/src/tests/readytorun/wasm/DelayedR2RActivation/PartitionedR2RActivation_AllHot.csproj @@ -0,0 +1,16 @@ + + + true + $(DefineConstants);PARTITIONED_WASM_R2R_BOUNDARY;PARTITIONED_WASM_R2R_ALL_HOT + true + false + Exe + true + true + $(CrossGen2TestExtraArguments) --wasm-auxiliary-modules + true + + + + + diff --git a/src/tests/readytorun/wasm/DelayedR2RActivation/PartitionedR2RActivation_Eager.csproj b/src/tests/readytorun/wasm/DelayedR2RActivation/PartitionedR2RActivation_Eager.csproj new file mode 100644 index 00000000000000..128fd35290caa3 --- /dev/null +++ b/src/tests/readytorun/wasm/DelayedR2RActivation/PartitionedR2RActivation_Eager.csproj @@ -0,0 +1,18 @@ + + + true + $(Features);runtime-async=on + $(DefineConstants);PARTITIONED_WASM_R2R + true + true + true + true + $(CrossGen2TestExtraArguments) --wasm-auxiliary-modules --compilebubblegenerics --opt-cross-module System.Private.CoreLib + true + + + + + + + diff --git a/src/tests/readytorun/wasm/DelayedR2RActivation/PartitionedR2RActivation_EntryPointsFirst.csproj b/src/tests/readytorun/wasm/DelayedR2RActivation/PartitionedR2RActivation_EntryPointsFirst.csproj new file mode 100644 index 00000000000000..b96b323c7acf6c --- /dev/null +++ b/src/tests/readytorun/wasm/DelayedR2RActivation/PartitionedR2RActivation_EntryPointsFirst.csproj @@ -0,0 +1,19 @@ + + + true + $(Features);runtime-async=on + $(DefineConstants);PARTITIONED_WASM_R2R + true + true + true + true + $(CrossGen2TestExtraArguments) --wasm-auxiliary-modules --compilebubblegenerics --opt-cross-module System.Private.CoreLib + true + + + + + + + + diff --git a/src/tests/readytorun/wasm/DelayedR2RActivation/PartitionedR2RActivation_ModuleFirst.csproj b/src/tests/readytorun/wasm/DelayedR2RActivation/PartitionedR2RActivation_ModuleFirst.csproj new file mode 100644 index 00000000000000..2ba845cfa0a8f0 --- /dev/null +++ b/src/tests/readytorun/wasm/DelayedR2RActivation/PartitionedR2RActivation_ModuleFirst.csproj @@ -0,0 +1,20 @@ + + + true + $(Features);runtime-async=on + $(DefineConstants);PARTITIONED_WASM_R2R + true + true + true + true + $(CrossGen2TestExtraArguments) --wasm-auxiliary-modules --compilebubblegenerics --opt-cross-module System.Private.CoreLib + true + + + + + + + + + From f0984cc980d4d6201d1daebc15305c1c2c240d87 Mon Sep 17 00:00:00 2001 From: Jackson Schuster <36744439+jtschuster@users.noreply.github.com> Date: Tue, 27 Oct 2026 15:00:08 -0700 Subject: [PATCH 15/19] Preserve direct managed helpers across auxiliary Wasm modules Duplicate eligible managed helper code and funclets without duplicating logical table slots or metadata. Validate cold helper calls, native GC and exception handling, and document activation scheduling. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 0797c2c0-9a42-4488-8454-7761c25a6e19 --- .../features/partitioned-wasm-r2r-modules.md | 4 +- .../WebCilObjectWriter.Auxiliary.cs | 14 ++++- .../TestCases/ManagedHelpers/HelperCalls.cs | 8 +++ .../TestCases/R2RTestSuites.cs | 29 ++++++++++ .../TestCasesRunner/WasmR2RAssert.cs | 56 +++++++++++++++++++ .../Compiler/ReadyToRunCodegenCompilation.cs | 3 - src/coreclr/tools/aot/crossgen2/README.md | 7 ++- 7 files changed, 114 insertions(+), 7 deletions(-) diff --git a/docs/design/features/partitioned-wasm-r2r-modules.md b/docs/design/features/partitioned-wasm-r2r-modules.md index 86685c029ff0b3..dd23a0cc2433a5 100644 --- a/docs/design/features/partitioned-wasm-r2r-modules.md +++ b/docs/design/features/partitioned-wasm-r2r-modules.md @@ -24,12 +24,14 @@ dotnet publish -p:PublishReadyToRun=true -p:PublishReadyToRunComposite=true -p:W Publishing requires matching locally built runtime and WebAssembly SDK packs. Crossgen2 exposes the corresponding `--wasm-auxiliary-modules` option. Non-browser, non-composite, multithreaded, and stripped-IL configurations are rejected. -The compiler emits a primary module, an `.entrypoints.wasm` mapping module, and a `.1.wasm` cold-code module, plus an `.auxiliary.json` publication manifest. Helpers and public non-generic methods are hot; methods that cannot use interpreter fallback are also kept primary. Direct managed-call optimization is disabled in this mode. Shared fallback thunks and eagerly accessed data remain primary. +The compiler emits a primary module, an `.entrypoints.wasm` mapping module, and a `.1.wasm` cold-code module, plus an `.auxiliary.json` publication manifest. Helpers and public non-generic methods are hot; methods that cannot use interpreter fallback are also kept primary. Ordinary managed calls remain indirect. Eligible managed compiler helpers retain direct calls through local copies of their bodies and funclets, sharing the primary's logical slots and metadata. Shared fallback thunks and eagerly accessed data remain primary. The cold-code module owns its methods and funclets, runtime-function records, GC/unwind and exception-handling data, fixup lists, and exclusively cold import cells and signatures. The mapping module owns auxiliary entrypoint tables and their method-identification signatures. Shared imports, async-resume data rebased during image registration, and the final runtime-function/unwind record needed to reserve the virtual-IP range remain primary. Activation never overwrites those resident data. Both sidecars import the primary module's memory, table, bases, stack pointer, exception tag, and async global. Their data and element segments are passive. `ActivateModule()` installs those segments and writes its activation cell last; repeated activation is a no-op. Mapping activation also notifies the runtime to advance the global EntryPoints era. The browser host starts sidecar loading without adding it to the startup barrier. +Activation runs on the browser's JavaScript thread. A synchronous managed computation started before activation can block the event loop and remain interpreted until it yields or returns, even if the sidecars have finished downloading. Startup measurements include this fallback interval; post-activation throughput measurements must first verify that both activation cells are active. + The existing five `DelayedR2RActivation` test variants retain the phase-1 simulation. Five `PartitionedR2RActivation` variants exercise real sidecars, eager loading, both manually controlled activation orders, and all-hot/all-cold partition boundaries. Coverage includes GC, exception handlers, generics from another image, delegates, reflection, managed and unmanaged function pointers, and suspended interpreter continuations. Zero-length partitions are valid; their empty sidecars preserve the same activation contract and contiguous runtime-function ranges. See the [compiler usage and restrictions](../../../src/coreclr/tools/aot/crossgen2/README.md) and [browser build integration](../../../src/mono/browser/build/README.md) for the emitted-file contract and supported diagnostic options. diff --git a/src/coreclr/tools/Common/Compiler/ObjectWriter/WebCilObjectWriter.Auxiliary.cs b/src/coreclr/tools/Common/Compiler/ObjectWriter/WebCilObjectWriter.Auxiliary.cs index 7f4c642e18f430..0c0ed4f66cdeb3 100644 --- a/src/coreclr/tools/Common/Compiler/ObjectWriter/WebCilObjectWriter.Auxiliary.cs +++ b/src/coreclr/tools/Common/Compiler/ObjectWriter/WebCilObjectWriter.Auxiliary.cs @@ -240,6 +240,16 @@ private void EmitPartitionedObjectFile(Stream primaryOutput, WebcilPayloadDataSe HashSet coldFunctions = new(); for (int slot = 0; slot < managedCount; slot++) (slot < primaryCount ? hotFunctions : coldFunctions).Add(tableFunctions[slot]); + HashSet managedHelperFunctions = new(); + foreach (MethodWithGCInfo method in _nodeFactory.EnumerateCompiledMethods()) + { + if (!method.IsJitHelper) + continue; + + int firstSlot = _nodeFactory.RuntimeFunctionsTable.GetIndex(method); + for (int frame = 0; frame < method.FrameInfos.Length; frame++) + managedHelperFunctions.Add(tableFunctions[firstSlot + frame]); + } int functionImports = _wasmSymbolManager.GetImportCount(WasmIndexSpace.Function); List functions = new(); @@ -288,7 +298,9 @@ void WriteModule(Stream output, int physicalModule) if (physicalModule != 0 && _wasmStubNames.Contains(functions[ordinal].Name)) continue; bool managed = hotFunctions.Contains(index) || coldFunctions.Contains(index); - if (!managed || (physicalModule == 0 ? hotFunctions : coldFunctions).Contains(index)) + // Helper copies share the primary's logical slots, frame identities and metadata. + if (!managed || managedHelperFunctions.Contains(index) || + (physicalModule == 0 ? hotFunctions : coldFunctions).Contains(index)) { _physicalFunctionIndices[index] = functionImports + kept.Count; kept.Add(ordinal); diff --git a/src/coreclr/tools/aot/ILCompiler.ReadyToRun.Tests/TestCases/ManagedHelpers/HelperCalls.cs b/src/coreclr/tools/aot/ILCompiler.ReadyToRun.Tests/TestCases/ManagedHelpers/HelperCalls.cs index 8d5c3160c40c2d..0b9207ec93a1e9 100644 --- a/src/coreclr/tools/aot/ILCompiler.ReadyToRun.Tests/TestCases/ManagedHelpers/HelperCalls.cs +++ b/src/coreclr/tools/aot/ILCompiler.ReadyToRun.Tests/TestCases/ManagedHelpers/HelperCalls.cs @@ -13,4 +13,12 @@ public static int Sum(int count) return result; } + + private static int SumCold(int count, object value) + { + int result = ((string)value).Length; + for (int i = 0; i < count; i++) + result = checked(result + i); + return result; + } } diff --git a/src/coreclr/tools/aot/ILCompiler.ReadyToRun.Tests/TestCases/R2RTestSuites.cs b/src/coreclr/tools/aot/ILCompiler.ReadyToRun.Tests/TestCases/R2RTestSuites.cs index 4333f168a47ac4..9c18953a706ead 100644 --- a/src/coreclr/tools/aot/ILCompiler.ReadyToRun.Tests/TestCases/R2RTestSuites.cs +++ b/src/coreclr/tools/aot/ILCompiler.ReadyToRun.Tests/TestCases/R2RTestSuites.cs @@ -342,6 +342,35 @@ static void Validate(ReadyToRunReader reader) } } + [ConditionalTheory(typeof(TestPaths), nameof(TestPaths.IsWasmTarget))] + [InlineData(1)] + [InlineData(2)] + public void WasmManagedHelpersAreLocalToEachBodyModule(int parallelism) + { + var input = new CompiledAssembly + { + AssemblyName = nameof(WasmManagedHelpersAreLocalToEachBodyModule), + SourceResourceNames = ["ManagedHelpers/HelperCalls.cs"], + }; + + new R2RTestRunner(_output).Run(new R2RTestCase( + nameof(WasmManagedHelpersAreLocalToEachBodyModule), + [ + new(nameof(WasmManagedHelpersAreLocalToEachBodyModule), [new CrossgenAssembly(input)]) + { + Options = [Crossgen2Option.Composite, Crossgen2Option.Optimize], + AdditionalArgs = + [ + "--unrooted-input-file-paths", TestPaths.SystemPrivateCoreLibPath, + "--wasm-auxiliary-modules", + "--parallelism", parallelism.ToString(), + ], + OutputFileExtension = ".wasm", + Validate = WasmR2RAssert.AssertAuxiliaryManagedHelpers, + }, + ])); + } + [ConditionalFact(typeof(TestPaths), nameof(TestPaths.IsNotWasmTarget))] public void BasicCrossModuleInlining() { diff --git a/src/coreclr/tools/aot/ILCompiler.ReadyToRun.Tests/TestCasesRunner/WasmR2RAssert.cs b/src/coreclr/tools/aot/ILCompiler.ReadyToRun.Tests/TestCasesRunner/WasmR2RAssert.cs index 4f94e8e3888087..afb5dacdfc273c 100644 --- a/src/coreclr/tools/aot/ILCompiler.ReadyToRun.Tests/TestCasesRunner/WasmR2RAssert.cs +++ b/src/coreclr/tools/aot/ILCompiler.ReadyToRun.Tests/TestCasesRunner/WasmR2RAssert.cs @@ -22,6 +22,62 @@ namespace ILCompiler.ReadyToRun.Tests.TestCasesRunner; internal static class WasmR2RAssert { + public static void AssertAuxiliaryManagedHelpers(ReadyToRunReader reader) + { + ReadOnlySpan primary = reader.CompositeReader.GetEntireImage().AsSpan(); + string bodyPath = Path.ChangeExtension(reader.Filename, ".1.wasm"); + ReadOnlySpan body = File.ReadAllBytes(bodyPath); + Dictionary primaryNames = ReadFunctionNames(primary); + Dictionary bodyNames = ReadFunctionNames(body); + Assert.DoesNotContain(primaryNames.Values, name => name.EndsWith("HelperCalls__SumCold", StringComparison.Ordinal)); + Assert.Contains(bodyNames.Values, name => name.EndsWith("HelperCalls__SumCold", StringComparison.Ordinal)); + Assert.Contains(primaryNames.Values, name => name.EndsWith("HelperCalls__Sum", StringComparison.Ordinal)); + Assert.DoesNotContain(bodyNames.Values, name => name.EndsWith("HelperCalls__Sum", StringComparison.Ordinal)); + + ReadyToRunSection metadata = reader.ReadyToRunHeader.Sections[ReadyToRunSectionType.AuxiliaryModuleMetadata]; + int metadataOffset = reader.CompositeReader.GetOffset(metadata.RelativeVirtualAddress); + int hotCount = BinaryPrimitives.ReadInt32LittleEndian(primary.Slice(metadataOffset + 4)); + int coldCount = BinaryPrimitives.ReadInt32LittleEndian(primary.Slice(metadataOffset + 24)); + Assert.Equal(hotCount, BinaryPrimitives.ReadInt32LittleEndian(primary.Slice(metadataOffset + 20))); + Assert.Equal((hotCount + coldCount) * 8, + reader.ReadyToRunHeader.Sections[ReadyToRunSectionType.RuntimeFunctions].Size); + + Assert.True(TryGetWasmSectionBounds(body, WasmSectionKind.Element, out int offset, out int end)); + Assert.Equal(1u, ReadWasmUleb32(body, ref offset, end)); + Assert.Equal(1u, ReadWasmUleb32(body, ref offset, end)); + Assert.Equal(0, ReadWasmByte(body, ref offset, end)); + Assert.Equal((uint)coldCount, ReadWasmUleb32(body, ref offset, end)); + HashSet installedFunctions = new(); + for (int index = 0; index < coldCount; index++) + installedFunctions.Add(ReadWasmUleb32(body, ref offset, end)); + Assert.Equal(end, offset); + + foreach (string helper in new[] { "ThrowHelpers__ThrowOverflowException", "CastHelpers__ChkCastInterface" }) + { + string helperName = Assert.Single(primaryNames.Values.Where(name => name.EndsWith(helper, StringComparison.Ordinal))); + foreach (string frameName in primaryNames.Values.Where(name => + name == helperName || name.StartsWith(helperName + "_funclet_", StringComparison.Ordinal))) + { + uint localIndex = Assert.Single(bodyNames.Where(entry => entry.Value == frameName)).Key; + Assert.DoesNotContain(localIndex, installedFunctions); + } + } + + static Dictionary ReadFunctionNames(ReadOnlySpan image) + { + Assert.True(TryGetWasmFunctionNameSubsection(image, out int offset, out int end)); + uint count = ReadWasmUleb32(image, ref offset, end); + Dictionary names = new(); + for (uint index = 0; index < count; index++) + { + uint functionIndex = ReadWasmUleb32(image, ref offset, end); + names.Add(functionIndex, ReadWasmName(image, ref offset, end)); + } + Assert.Equal(end, offset); + return names; + } + } + public static void AssertAuxiliaryModuleLayout(ReadyToRunReader reader) { Assert.True(WasmSymbolMapHasExpectedFunctionNames(reader, out string diagnostic), diagnostic); diff --git a/src/coreclr/tools/aot/ILCompiler.ReadyToRun/Compiler/ReadyToRunCodegenCompilation.cs b/src/coreclr/tools/aot/ILCompiler.ReadyToRun/Compiler/ReadyToRunCodegenCompilation.cs index 54d41a37d0a0b1..b8874ae9f0300d 100644 --- a/src/coreclr/tools/aot/ILCompiler.ReadyToRun/Compiler/ReadyToRunCodegenCompilation.cs +++ b/src/coreclr/tools/aot/ILCompiler.ReadyToRun/Compiler/ReadyToRunCodegenCompilation.cs @@ -326,9 +326,6 @@ public sealed class ReadyToRunCodegenCompilation : Compilation internal bool IsDirectManagedHelperEligible(MethodDesc method) { - if (NodeFactory.UsesWasmAuxiliaryModules) - return false; - if (NodeFactory.Target.Architecture is TargetArchitecture.ARM or TargetArchitecture.ARM64) return false; diff --git a/src/coreclr/tools/aot/crossgen2/README.md b/src/coreclr/tools/aot/crossgen2/README.md index e2d59341804702..eb635c2bcce8fc 100644 --- a/src/coreclr/tools/aot/crossgen2/README.md +++ b/src/coreclr/tools/aot/crossgen2/README.md @@ -22,8 +22,11 @@ In addition to the composite image and its component forwarding images, this emi Public nongeneric methods initially stay hot. Compiler-generated wrappers, unmanaged entrypoints, runtime helpers and async/resumption methods also stay hot. -Other eligible IL methods go into the cold module. Managed calls use indirect -imports, and non-managed helper function bodies are duplicated where needed. +Other eligible IL methods go into the cold module. Ordinary managed calls use +indirect imports. Eligible managed JIT helpers remain directly callable: their +bodies and funclets are copied into body modules while their logical table slots, +virtual-IP identities and metadata remain canonical in the primary. +Non-managed helper function bodies are also duplicated where needed. All modules use the primary image's `webcil` imports and the same memory/table bases. Sidecar instantiation does not install code or data. Its idempotent From 4cdaaf6f032fdb72e8b92e4f00caa5855fb58729 Mon Sep 17 00:00:00 2001 From: Jackson Schuster <36744439+jtschuster@users.noreply.github.com> Date: Tue, 27 Oct 2026 17:30:50 -0700 Subject: [PATCH 16/19] Document final auxiliary Wasm size and performance measurements Record all nine macro scenarios, full module compression sizes, monitored startup/interaction measurements, and separately activation-gated throughput. Preserve reproducible evidence and limitations without claiming statistical significance. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 0797c2c0-9a42-4488-8454-7761c25a6e19 --- .../partitioned-wasm-r2r-modules-sizes.csv | 55 +++++++++++++ .../features/partitioned-wasm-r2r-modules.md | 79 ++++++++++++++++++- 2 files changed, 132 insertions(+), 2 deletions(-) create mode 100644 docs/design/features/partitioned-wasm-r2r-modules-sizes.csv diff --git a/docs/design/features/partitioned-wasm-r2r-modules-sizes.csv b/docs/design/features/partitioned-wasm-r2r-modules-sizes.csv new file mode 100644 index 00000000000000..e9b18164081cdd --- /dev/null +++ b/docs/design/features/partitioned-wasm-r2r-modules-sizes.csv @@ -0,0 +1,55 @@ +side,app,role,files,raw_bytes,gzip_bytes,brotli_bytes +baseline,empty-browser,primary,1,3626082,1042189,722337 +baseline,empty-browser,entrypoints-map,0,0,0,0 +baseline,empty-browser,body,0,0,0,0 +baseline,empty-blazor,primary,1,14748120,3659495,2190179 +baseline,empty-blazor,entrypoints-map,0,0,0,0 +baseline,empty-blazor,body,0,0,0,0 +baseline,blazing-pizza,primary,1,25231901,5327995,3100237 +baseline,blazing-pizza,entrypoints-map,0,0,0,0 +baseline,blazing-pizza,body,0,0,0,0 +baseline,micro-benchmarks,primary,1,3912482,1127476,775941 +baseline,micro-benchmarks,entrypoints-map,0,0,0,0 +baseline,micro-benchmarks,body,0,0,0,0 +baseline,havit-bootstrap,primary,1,42669126,8164595,4731287 +baseline,havit-bootstrap,entrypoints-map,0,0,0,0 +baseline,havit-bootstrap,body,0,0,0,0 +baseline,mud-blazor,primary,1,136209465,23167417,12019321 +baseline,mud-blazor,entrypoints-map,0,0,0,0 +baseline,mud-blazor,body,0,0,0,0 +baseline,igniteui-light,primary,1,29373727,5891038,3426600 +baseline,igniteui-light,entrypoints-map,0,0,0,0 +baseline,igniteui-light,body,0,0,0,0 +baseline,blazor-perf,primary,1,15420384,3780025,2266750 +baseline,blazor-perf,entrypoints-map,0,0,0,0 +baseline,blazor-perf,body,0,0,0,0 +baseline,avalonia-bench,primary,1,15190773,3722407,2377841 +baseline,avalonia-bench,entrypoints-map,0,0,0,0 +baseline,avalonia-bench,body,0,0,0,0 +feature,empty-browser,primary,1,1591530,379683,271875 +feature,empty-browser,entrypoints-map,1,111661,58585,48451 +feature,empty-browser,body,1,2897189,708921,477380 +feature,empty-blazor,primary,1,4607065,933941,641456 +feature,empty-blazor,entrypoints-map,1,605836,283176,227702 +feature,empty-blazor,body,1,13609113,2839154,1508192 +feature,blazing-pizza,primary,1,7131888,1258291,830275 +feature,blazing-pizza,entrypoints-map,1,1257548,564851,445728 +feature,blazing-pizza,body,1,25590289,4142627,2126338 +feature,micro-benchmarks,primary,1,1669058,397843,283578 +feature,micro-benchmarks,entrypoints-map,1,121194,63397,52604 +feature,micro-benchmarks,body,1,3159760,777089,521213 +feature,havit-bootstrap,primary,1,11473928,1775730,1138165 +feature,havit-bootstrap,entrypoints-map,1,2099936,909297,716273 +feature,havit-bootstrap,body,1,43239255,6455088,3366232 +feature,mud-blazor,primary,1,27514543,3250352,1911039 +feature,mud-blazor,entrypoints-map,1,8101906,3087422,2335534 +feature,mud-blazor,body,1,145879819,19527694,8980534 +feature,igniteui-light,primary,1,8235409,1373042,893843 +feature,igniteui-light,entrypoints-map,1,1479638,666186,518666 +feature,igniteui-light,body,1,29714718,4540681,2375772 +feature,blazor-perf,primary,1,4743053,954992,651958 +feature,blazor-perf,entrypoints-map,1,635863,296544,239116 +feature,blazor-perf,body,1,14276433,2934765,1565229 +feature,avalonia-bench,primary,1,5966799,1227308,847497 +feature,avalonia-bench,entrypoints-map,1,615587,295663,237782 +feature,avalonia-bench,body,1,12931853,2600819,1524572 diff --git a/docs/design/features/partitioned-wasm-r2r-modules.md b/docs/design/features/partitioned-wasm-r2r-modules.md index dd23a0cc2433a5..be28fe00cfe8a4 100644 --- a/docs/design/features/partitioned-wasm-r2r-modules.md +++ b/docs/design/features/partitioned-wasm-r2r-modules.md @@ -36,6 +36,81 @@ The existing five `DelayedR2RActivation` test variants retain the phase-1 simula See the [compiler usage and restrictions](../../../src/coreclr/tools/aot/crossgen2/README.md) and [browser build integration](../../../src/mono/browser/build/README.md) for the emitted-file contract and supported diagnostic options. +### Measured V1 results + +The final Release comparison used feature commit `f0984cc980d4d6201d1daebc15305c1c2c240d87` against the unchanged phase-1 control at `1d0ddf7b2e680164058928e14b615de37a46504e`. This measures the complete feature change, not just toggling the publishing property on the new compiler. All nine applications came from the same macro-benchmark checkout at `84f9ac6e9c293f1ce50f64dcb36d747daf8f5509`, including its preserved pre-existing local changes. Publishing used SDK `11.0.100-rc.2.26504.105`, locally built `12.0.0-dev` packs, and the frozen final compiler. + +All 36 normal desktop/mobile result rows were measured again, separately from 12 activation-gated steady-state pages. External cold/warm startup counts were 10/5; the internal microbenchmark exports only its single cold page and standard seven-window sampling. UI walkthrough counts remained unchanged: five for the standalone Blazor applications, three per Avalonia scenario, and one per hosted Blazor scenario. These are descriptive local measurements on a monitored shared Linux desktop, not exclusive-machine or statistical-significance claims. Attempts overlapping observed external runtime builds/tests were excluded and repeated; a failed hosted-server readiness attempt was also repeated without assuming its cause. + +#### Physical modules and transferred size + +The following sizes are KiB of Brotli-compressed R2R modules. The control has one module; the feature has a primary, an entrypoint map, and one cold-body module. The [size inventory](partitioned-wasm-r2r-modules-sizes.csv) records exact raw, gzip, and Brotli bytes for every role and application. + +| Application | Control | Primary | Entrypoint map | Cold body | Combined change | +|---|---:|---:|---:|---:|---:| +| empty-browser | 705.4 | 265.5 | 47.3 | 466.2 | +10.4% | +| empty-blazor | 2138.8 | 626.4 | 222.4 | 1472.8 | +8.5% | +| blazing-pizza | 3027.6 | 810.8 | 435.3 | 2076.5 | +9.7% | +| micro-benchmarks | 757.8 | 276.9 | 51.4 | 509.0 | +10.5% | +| havit-bootstrap | 4620.4 | 1111.5 | 699.5 | 3287.3 | +10.3% | +| mud-blazor | 11737.6 | 1866.2 | 2280.8 | 8770.1 | +10.0% | +| igniteui-light | 3346.3 | 872.9 | 506.5 | 2320.1 | +10.6% | +| blazor-perf | 2213.6 | 636.7 | 233.5 | 1528.5 | +8.4% | +| avalonia-bench | 2322.1 | 827.6 | 232.2 | 1488.8 | +9.8% | + +#### Startup and initial interactions + +Times below are control / partitioned milliseconds, with smaller being better. Startup is the unchanged harness's cold managed-readiness metric except for micro-benchmarks, which reports .NET creation, and hosted blazor-perf, which reports its server-rendered landing page rather than WASM startup. + +| Application | Desktop cold | Mobile cold | Framework Brotli change | Completed encoded download change | +|---|---:|---:|---:|---:| +| empty-browser | 140 / 153 | 1492 / 1317 | +2.4% | +2.9% | +| empty-blazor | 396 / 446 | 2835 / 2778 | +3.4% | +3.8% | +| blazing-pizza | 419 / 471 | 3523 / 3113 | +4.4% | +4.4% | +| micro-benchmarks | 151 / 139 | 1518 / 1354 | +2.5% | +3.0% | +| havit-bootstrap | 526 / 517 | 4874 / 4052 | +5.5% | +5.0% | +| mud-blazor | 1276 / 929 | 10438 / 6507 | +6.4% | +6.4% | +| igniteui-light | 529 / 560 | 4144 / 3531 | +5.0% | +5.2% | +| blazor-perf | 29 / 30 | 212 / 206 | +3.1% | +3.6% | +| avalonia-bench | 384 / 608 | 3538 / 3731 | +3.2% | +3.5% | + +Framework sizes are on-disk totals, not necessarily fetched bytes. Completed download audits include finished asynchronous sidecar transfers; the harness's earlier startup download snapshot can omit those transfers. Hosted blazor-perf's download row uses its actual `/counter-heavy-wasm` client route, not the small landing page. Its full 22-scenario desktop walkthrough, including nine WASM client scenarios, was retained. + +Earlier managed readiness is not the same as faster initial interactions. Desktop walkthrough times regressed from 126 to 334 ms for Pizza (+165%), 2674 to 4718 ms for Havit (+76%), 5059 to 15882 ms for Mud (+214%), and 731 to 1035 ms for Ignite (+42%). Activation state was not recorded on those normal walkthrough pages, so these regressions are not all attributed to one mechanism. + +The ungated synchronous JSON benchmark measured 368948 / 5978 operations per second on desktop and 116166 / 1987 on mobile. Separate diagnostics verified that its long initial managed call prevents JavaScript activation until it returns, leaving the measured loop interpreted. These startup/fallback costs are retained, not replaced by the following steady-state numbers. + +#### Verified post-activation throughput + +Separate fresh pages waited for actual mapping/body status `1/1` before timing. Each repetition used the original seven two-second windows, discarded the first, and applied the existing interquartile-range filtering. Values below are medians of three repetitions per side/profile. All 252 feature window-boundary checks observed active status, and each feature page forwarded exactly one real native mapping-activation notification. + +| Profile | Operations per second | Control | Partitioned | Change | +|---|---|---:|---:|---:| +| Desktop | JSON parse | 375077 | 341759 | -8.88% | +| Mobile | JSON parse | 116459 | 107613 | -7.60% | +| Desktop | JavaScript interop | 782032 | 789858 | +1.00% | +| Mobile | JavaScript interop | 263550 | 261608 | -0.74% | +| Desktop | Exceptions | 176 | 165 | -6.25% | +| Mobile | Exceptions | 59 | 55 | -6.78% | + +Avalonia required an artifact-only SDK11 eligibility override and native SkiaSharp relinking using matching local workload targets/tasks. Both sides rendered successfully; this is not out-of-box SDK11/harness support. No tracked benchmark source changes or package/workload installations were needed for the workaround. + +The V1 heuristic therefore reduces the primary download and can improve managed readiness, but increases total transfer size and does not eliminate initial-interaction or steady-throughput regressions. It remains an opt-in experimental tradeoff, not a general performance improvement. + +#### Validation and retained evidence + +Browser Release and Linux x64 Release builds passed. The compiler suite passed 226 tests with 39 skips; 17 loader tests, five simulation variants, five physical variants, and eight engine activation/boundary cases passed. A CoreLib-inclusive native helper fixture also verified 200 exceptions, 400 finally executions, 400 forced collections, and live GC roots. + +The local evidence root is `~/.copilot/session-state/0797c2c0-9a42-4488-8454-7761c25a6e19/files/aux-benchmarks/final-f0984cc980d4/`. Its `comparison-summary.json`, `performance-comparison.csv`, `module-role-sizes.csv`, `steady-performance-comparison.csv`, raw results, completed download audits, monitoring/retry logs, and 342 checksummed evidence files retain the full comparison. `reproduce-commands.json` records exact commands, environments, inputs, and exit results; the older parent-directory cohort is provisional and is not pooled with these results. Compiler/native validation commands and logs are separately indexed by `artifacts/aux-helper-validation.json` in the feature worktree. + +Final publishing inputs are identified by SHA256: + +| Input | SHA256 | +|---|---| +| Frozen crossgen2 executable | `6683be0a5645d5c2db67c53081d2a1837f3ceb20f8f5686d0a211e4f697db7ef` | +| Browser runtime pack | `b3b63fc2e104c9c6b5f33cdf4bb2c612f7ffcf7f6f785ca499527fd177c41e8b` | +| WebAssembly SDK pack | `e7498abf56efe8675ac566ccbf59f365938332a82e9843e9b2907c678c7623fe` | + ## 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. Checking for activation of the EntryPoints module and the auxiliary module should be as cheap as possible. In this design, it is a simple flag check in the WASM memory. @@ -425,7 +500,7 @@ Move the `RuntimeFunctions` table entries and fixups to the auxiliary module, an ### Direct-call dependency sets -Direct calls within an auxiliary module will not be implemented in V1, and this section is irrelevant until they are implemented. It is left for completeness. +Ordinary managed direct calls within an auxiliary module will not be implemented in V1, and this section is irrelevant until they are implemented. The eligible managed compiler-helper copies described above are a separate validated exception; they do not enable these general dependency sets. This section is left for completeness. Activating method A must also complete the required fixups for any method B that A calls directly, even if B was not independently requested. All the methods that are direct-called by A or direct called by any of those methods, recursively, are considered the direct-call dependency set for root method A. Activation of all methods in a dependency set must succeed for the root method to be successfully activated. Subsets of the dependency set of A may succeed even if other subsets fail - they are not necessary all-or-none. @@ -433,7 +508,7 @@ An aux method may be **unvisited** (it has no interpreter bytecode or R2R code), `READYTORUN_FIXUP_MethodEntry_ReadyToRun` already requests target-method fixup processing. These are implemented as Eager imports which run at module load. This does not work for aux methods whose data and fixups are not present in the primary module. These fixups for aux modules must run either during auxiliary module activation, or be modified to be run as pre-code fixups for methods that have direct calls (which requires handling cycles of these fixups - e.g. M1 direct calls M2 which direct calls M1). -Direct calls within an auxiliary module are not supported in V1. They can be implemented later, subject to this activation requirement. +Ordinary managed direct calls within an auxiliary module are not supported in V1. They can be implemented later, subject to this activation requirement. Direct calls from the primary module to an auxiliary module are not supported. From 53380e2f8bbba886eb80d853f106d45eebd2f4ba Mon Sep 17 00:00:00 2001 From: Jackson Schuster <36744439+jtschuster@users.noreply.github.com> Date: Wed, 28 Oct 2026 14:23:14 -0700 Subject: [PATCH 17/19] Select partitioned WASM hot methods from MIBC profiles Use explicit MIBC method membership with canonical generic matching, preserving mandatory primary methods and the no-profile heuristic. Keep auxiliary PGO publishes fully compiled and fix doubled entrypoint-table offsets exposed by generic coverage. Add compiler/SDK regression cases and matched Havit size evidence. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 0797c2c0-9a42-4488-8454-7761c25a6e19 --- ...artitioned-wasm-r2r-modules-mibc-sizes.csv | 25 +++++ .../features/partitioned-wasm-r2r-modules.md | 35 +++++- .../TestCases/R2RTestSuites.cs | 103 ++++++++++++++++++ .../TestCases/Webcil/WasmWebcilModule.cs | 18 +++ .../TestCasesRunner/R2RTestRunner.cs | 3 + .../TestCasesRunner/WasmR2RAssert.cs | 43 ++++++-- .../ReadyToRun/ReadyToRunHeaderNode.cs | 3 + .../ReadyToRunCodegenNodeFactory.cs | 14 ++- .../Compiler/ProfileDataManager.cs | 14 +++ src/coreclr/tools/aot/crossgen2/README.md | 24 +++- src/mono/browser/build/README.md | 17 +++ ...ET.Sdk.WebAssembly.Browser.CoreCLR.targets | 3 +- .../CoreCLRWasmNativeDefaultsTests.cs | 25 +++-- 13 files changed, 302 insertions(+), 25 deletions(-) create mode 100644 docs/design/features/partitioned-wasm-r2r-modules-mibc-sizes.csv diff --git a/docs/design/features/partitioned-wasm-r2r-modules-mibc-sizes.csv b/docs/design/features/partitioned-wasm-r2r-modules-mibc-sizes.csv new file mode 100644 index 00000000000000..77e9b7025d8772 --- /dev/null +++ b/docs/design/features/partitioned-wasm-r2r-modules-mibc-sizes.csv @@ -0,0 +1,25 @@ +variant,role,raw,gzip,brotli +heuristic,primary,11473928,1775725,1138160 +heuristic,map,2099936,909297,716273 +heuristic,body,43239255,6455104,3365735 +heuristic,all,56813119,9140126,5220168 +sparse,primary,6765354,729050,387406 +sparse,map,2188618,979681,768394 +sparse,body,48078684,7345638,3914392 +sparse,all,57032656,9054369,5070192 +empty,primary,6764118,728715,388491 +empty,map,2188630,979496,768322 +empty,body,48079617,7345659,3913065 +empty,all,57032365,9053870,5069878 +union,primary,6766483,729415,388545 +union,map,2188602,979495,769139 +union,body,48077643,7345520,3914205 +union,all,57032728,9054430,5071889 +repeated,primary,6765354,729042,388073 +repeated,map,2188618,979681,768394 +repeated,body,48078684,7345640,3913053 +repeated,all,57032656,9054363,5069520 +merged-union,primary,6766483,729392,389344 +merged-union,map,2188602,979495,769139 +merged-union,body,48077643,7345520,3915052 +merged-union,all,57032728,9054407,5073535 diff --git a/docs/design/features/partitioned-wasm-r2r-modules.md b/docs/design/features/partitioned-wasm-r2r-modules.md index be28fe00cfe8a4..c2c9b268652e87 100644 --- a/docs/design/features/partitioned-wasm-r2r-modules.md +++ b/docs/design/features/partitioned-wasm-r2r-modules.md @@ -24,7 +24,7 @@ dotnet publish -p:PublishReadyToRun=true -p:PublishReadyToRunComposite=true -p:W Publishing requires matching locally built runtime and WebAssembly SDK packs. Crossgen2 exposes the corresponding `--wasm-auxiliary-modules` option. Non-browser, non-composite, multithreaded, and stripped-IL configurations are rejected. -The compiler emits a primary module, an `.entrypoints.wasm` mapping module, and a `.1.wasm` cold-code module, plus an `.auxiliary.json` publication manifest. Helpers and public non-generic methods are hot; methods that cannot use interpreter fallback are also kept primary. Ordinary managed calls remain indirect. Eligible managed compiler helpers retain direct calls through local copies of their bodies and funclets, sharing the primary's logical slots and metadata. Shared fallback thunks and eagerly accessed data remain primary. +The compiler emits a primary module, an `.entrypoints.wasm` mapping module, and a `.1.wasm` cold-code module, plus an `.auxiliary.json` publication manifest. Without MIBC input, helpers and public non-generic methods on non-generic types are hot; methods that cannot use interpreter fallback are also kept primary. With explicit `--mibc` inputs, the union of profiled methods replaces the visibility/genericity heuristic, while mandatory primary methods and helpers stay resident. Concrete reference instantiations select their shared canonical body; value-type instantiations stay distinct. An empty or unmatched explicit profile moves every eligible method cold rather than restoring the heuristic. This selects by membership, not CPU weight, and consumes existing profiles without collecting a browser trace. Ordinary managed calls remain indirect. Eligible managed compiler helpers retain direct calls through local copies of their bodies and funclets, sharing the primary's logical slots and metadata. Shared fallback thunks and eagerly accessed data remain primary. The cold-code module owns its methods and funclets, runtime-function records, GC/unwind and exception-handling data, fixup lists, and exclusively cold import cells and signatures. The mapping module owns auxiliary entrypoint tables and their method-identification signatures. Shared imports, async-resume data rebased during image registration, and the final runtime-function/unwind record needed to reserve the virtual-IP range remain primary. Activation never overwrites those resident data. @@ -38,7 +38,7 @@ See the [compiler usage and restrictions](../../../src/coreclr/tools/aot/crossge ### Measured V1 results -The final Release comparison used feature commit `f0984cc980d4d6201d1daebc15305c1c2c240d87` against the unchanged phase-1 control at `1d0ddf7b2e680164058928e14b615de37a46504e`. This measures the complete feature change, not just toggling the publishing property on the new compiler. All nine applications came from the same macro-benchmark checkout at `84f9ac6e9c293f1ce50f64dcb36d747daf8f5509`, including its preserved pre-existing local changes. Publishing used SDK `11.0.100-rc.2.26504.105`, locally built `12.0.0-dev` packs, and the frozen final compiler. +The final Release comparison used feature commit `f0984cc980d4d6201d1daebc15305c1c2c240d87` against the unchanged phase-1 control at `1d0ddf7b2e680164058928e14b615de37a46504e`. These results use the no-MIBC heuristic, not profile-guided partitioning. This measures the complete feature change, not just toggling the publishing property on the new compiler. All nine applications came from the same macro-benchmark checkout at `84f9ac6e9c293f1ce50f64dcb36d747daf8f5509`, including its preserved pre-existing local changes. Publishing used SDK `11.0.100-rc.2.26504.105`, locally built `12.0.0-dev` packs, and the frozen final compiler. All 36 normal desktop/mobile result rows were measured again, separately from 12 activation-gated steady-state pages. External cold/warm startup counts were 10/5; the internal microbenchmark exports only its single cold page and standard seven-window sampling. UI walkthrough counts remained unchanged: five for the standalone Blazor applications, three per Avalonia scenario, and one per hosted Blazor scenario. These are descriptive local measurements on a monitored shared Linux desktop, not exclusive-machine or statistical-significance claims. Attempts overlapping observed external runtime builds/tests were excluded and repeated; a failed hosted-server readiness attempt was also repeated without assuming its cause. @@ -111,6 +111,37 @@ Final publishing inputs are identified by SHA256: | Browser runtime pack | `b3b63fc2e104c9c6b5f33cdf4bb2c612f7ffcf7f6f785ca499527fd177c41e8b` | | WebAssembly SDK pack | `e7498abf56efe8675ac566ccbf59f365938332a82e9843e9b2907c678c7623fe` | +### MIBC-selected Havit comparison + +The follow-up comparison uses the same updated compiler on both sides, SHA256 `f8b896e39df267bc89b5accb57c3763625b997e295b0c07ea2acb462d26f70b9`. It compares the no-profile heuristic against a four-method MIBC generated with `dotnet-pgo create-mibc-from-method-list` from matching trimmed Havit assemblies. This is a synthetic selection profile, not a captured startup profile or evidence of improved startup performance. Its SHA256 is `2fb912f8174663eed44a57fa6bd5f1960db7e200e82a3e63f4c1675229b1c319`. + +With matching updated compiler and SDK build targets, add the existing profile item to the application project and use the auxiliary publish command above: + +```xml + + + +``` + +The SDK forwards this as `--mibc`/`-m` without implicitly requesting `--partial` in auxiliary mode. The profile therefore selects placement rather than reducing the compiled coverage. All six measured variants retain 113,991 managed function/funclet slots. + +The following sizes are KiB of Brotli-compressed R2R module files, not whole-application transfer sizes: + +| Module | No MIBC | Four-method MIBC | +|---|---:|---:| +| Primary | 1111.5 | 378.3 | +| Entrypoint map | 699.5 | 750.4 | +| Cold body | 3286.9 | 3822.6 | +| Combined | 5097.8 | 4951.4 | + +Primary size falls 66.0%; primary plus mapping falls 37.7%. Combined Brotli size falls 2.9%, while combined raw size grows 0.4%. This moves most code to the body module; it does not demonstrate that the four selected methods cover startup. No startup timing or completed network-transfer measurement was repeated for this cohort. + +The [MIBC size inventory](partitioned-wasm-r2r-modules-mibc-sizes.csv) records exact raw, gzip and Brotli bytes for the heuristic, four-method profile (`sparse`), empty profile, two-profile union, repeated profile, and premerged union. Repeated and premerged profiles preserve the corresponding slot counts and raw role sizes; whole-image byte identity is not claimed. + +All six real publishes and browser smokes passed, with five rendered UI navigations each, both activation cells active, and a real native mapping-activation notification. Publishing used an isolated SDK package copy with only the changed source target overlaid; the archived SDK/runtime packages, old compiler, and prior nine-app modules were preserved. The compiler suite passed 232 tests with 39 skips. Six direct MSBuild profile-flow cases passed after the SDK repair, including the auxiliary/profile case that failed before it; the extended Wasm.Build.Tests xUnit fixture was not run because its test assets were not prepared. A CoreLib-inclusive native run with an explicit unmatched MIBC also passed activation, 200 exceptions, 400 finally executions, and 400 forced collections with live roots. + +Local size/publish/browser evidence and exact commands are in `~/src/macro-benchmarks/artifacts/mibc-hotcold-full-f8b896-20261028/report.json`; compiler/native validation is indexed by `artifacts/mibc-selection-validation.json` in the feature worktree. + ## 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. Checking for activation of the EntryPoints module and the auxiliary module should be as cheap as possible. In this design, it is a simple flag check in the WASM memory. diff --git a/src/coreclr/tools/aot/ILCompiler.ReadyToRun.Tests/TestCases/R2RTestSuites.cs b/src/coreclr/tools/aot/ILCompiler.ReadyToRun.Tests/TestCases/R2RTestSuites.cs index 9c18953a706ead..5469476838130a 100644 --- a/src/coreclr/tools/aot/ILCompiler.ReadyToRun.Tests/TestCases/R2RTestSuites.cs +++ b/src/coreclr/tools/aot/ILCompiler.ReadyToRun.Tests/TestCases/R2RTestSuites.cs @@ -1,16 +1,24 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +extern alias crossgen2; + using System; using System.Buffers.Binary; using System.Collections.Generic; using System.IO; using System.Linq; using System.Reflection.PortableExecutable; +using System.Reflection.Metadata; +using System.Reflection.Metadata.Ecma335; +using crossgen2::ILCompiler; using ILCompiler.ReadyToRun.Tests.TestCasesRunner; using ILCompiler.Reflection.ReadyToRun; using Internal.ReadyToRunConstants; using Internal.Runtime; +using Internal.TypeSystem; +using Internal.TypeSystem.Ecma; +using TypeSystemMetadataEmitter = crossgen2::Internal.TypeSystem.TypeSystemMetadataEmitter; using Xunit; using Xunit.Abstractions; @@ -30,6 +38,101 @@ public R2RTestSuites(ITestOutputHelper output) _output = output; } + [ConditionalTheory(typeof(TestPaths), nameof(TestPaths.IsWasmTarget))] + [InlineData(0, 1)] + [InlineData(0, 2)] + [InlineData(1, 1)] + [InlineData(1, 2)] + [InlineData(2, 1)] + [InlineData(2, 2)] + public void AuxiliaryModulesUseMibcMethodMembership(int profileMode, int parallelism) + { + CompiledAssembly input = new() + { + AssemblyName = nameof(AuxiliaryModulesUseMibcMethodMembership), + SourceResourceNames = ["Webcil/WasmWebcilModule.cs"], + }; + List args = ["--wasm-auxiliary-modules", "--wasm-debug-info", "all", + "--optimize-disabled", "--parallelism", parallelism.ToString()]; + new R2RTestRunner(_output).Run(new R2RTestCase( + nameof(AuxiliaryModulesUseMibcMethodMembership), + [ + new(nameof(AuxiliaryModulesUseMibcMethodMembership), [new CrossgenAssembly(input)]) + { + Options = [Crossgen2Option.Composite], + AdditionalArgs = args, + OutputFileExtension = ".wasm", + BeforeCompile = PrepareProfiles, + Validate = reader => WasmR2RAssert.AssertAuxiliaryProfileSelection(reader, + hasHotMethods: profileMode != 0, hasHotValueTypes: profileMode == 2), + }, + ])); + + void PrepareProfiles() + { + string directory = Path.GetDirectoryName(input.FilePath)!; + WriteProfile(Path.Combine(directory, "startup.mibc"), includePrivate: profileMode != 0, + includeGenerics: profileMode == 1, useCanon: false); + if (profileMode == 2) + WriteProfile(Path.Combine(directory, "generics.mibc"), includePrivate: false, + includeGenerics: true, useCanon: true); + } + + void WriteProfile(string path, bool includePrivate, bool includeGenerics, bool useCanon) + { + TargetDetails target = new(TargetArchitecture.Wasm32, TargetOS.Browser, TargetAbi.NativeAot); + CompilerTypeSystemContext context = new(target, SharedGenericsMode.CanonicalReferenceTypes) + { + InputFilePaths = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + [input.AssemblyName] = input.FilePath, + ["System.Private.CoreLib"] = TestPaths.SystemPrivateCoreLibPath, + }, + ReferenceFilePaths = new Dictionary(StringComparer.OrdinalIgnoreCase), + }; + context.SetSystemModule((EcmaModule)context.GetModuleForSimpleName("System.Private.CoreLib")); + EcmaModule module = (EcmaModule)context.GetModuleForSimpleName(input.AssemblyName); + MetadataType type = module.GetType("Webcil"u8, "WasmWebcilModule"u8); + TypeSystemMetadataEmitter emitter = new(new AssemblyNameInfo(Path.GetFileName(path)), context); + emitter.InjectSystemPrivateCanon(); + emitter.AllowUseOfAddGlobalMethod(); + InstructionEncoder group = new(new BlobBuilder()); + if (includePrivate) + AddMethod(type.GetMethod("AuxiliaryMethod"u8, null)); + if (includeGenerics) + { + List arguments = [useCanon ? context.CanonType : context.GetWellKnownType(WellKnownType.String)]; + if (useCanon) + arguments.Add(context.GetWellKnownType(WellKnownType.Int32)); + foreach (TypeDesc argument in arguments) + { + AddMethod(type.GetMethod("ProfiledGeneric"u8, null).MakeInstantiatedMethod(new Instantiation(argument))); + MetadataType holder = type.GetNestedType("ProfiledHolder`1"u8); + AddMethod(holder.MakeInstantiatedType(argument).GetMethod("Hot"u8, null)); + } + } + group.OpCode(ILOpCode.Ret); + MethodDefinitionHandle groupHandle = emitter.AddGlobalMethod("Startup", group, maxStack: 1); + InstructionEncoder dictionary = new(new BlobBuilder()); + dictionary.LoadString(emitter.Builder.GetOrAddUserString(input.AssemblyName + ";")); + dictionary.OpCode(ILOpCode.Ldtoken); + dictionary.Token(groupHandle); + dictionary.OpCode(ILOpCode.Pop); + dictionary.OpCode(ILOpCode.Ret); + emitter.AddGlobalMethod("AssemblyDictionary", dictionary, maxStack: 2); + using (FileStream output = File.Create(path)) + emitter.SerializeToStream(output); + args.AddRange(["--mibc", path]); + + void AddMethod(MethodDesc method) + { + group.OpCode(ILOpCode.Ldtoken); + group.Token(emitter.GetMethodRef(method)); + group.OpCode(ILOpCode.Pop); + } + } + } + [ConditionalTheory(typeof(TestPaths), nameof(TestPaths.IsWasmTarget))] [InlineData(false)] [InlineData(true)] diff --git a/src/coreclr/tools/aot/ILCompiler.ReadyToRun.Tests/TestCases/Webcil/WasmWebcilModule.cs b/src/coreclr/tools/aot/ILCompiler.ReadyToRun.Tests/TestCases/Webcil/WasmWebcilModule.cs index 23c57ef3840d2a..918c732b131e52 100644 --- a/src/coreclr/tools/aot/ILCompiler.ReadyToRun.Tests/TestCases/Webcil/WasmWebcilModule.cs +++ b/src/coreclr/tools/aot/ILCompiler.ReadyToRun.Tests/TestCases/Webcil/WasmWebcilModule.cs @@ -4,6 +4,7 @@ using System; using System.Reflection; using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; namespace Webcil; @@ -146,6 +147,23 @@ private static int AuxiliaryMethod(int value) } } + [MethodImpl(MethodImplOptions.NoInlining)] + private static T ProfiledGeneric(T value) => value; + + public sealed class ProfiledHolder + { + [MethodImpl(MethodImplOptions.NoInlining)] + public static T Hot(T value) => value; + } + + [MethodImpl(MethodImplOptions.NoInlining)] + public static int CallProfiledGenerics(int value) => + ProfiledGeneric(value) + ProfiledGeneric("method").Length + + ProfiledHolder.Hot(value) + ProfiledHolder.Hot("type").Length; + + [UnmanagedCallersOnly] + public static int ProfiledUnmanaged(int value) => value + 1; + [MethodImpl(MethodImplOptions.NoInlining)] public static void FoldableBodyOne() { diff --git a/src/coreclr/tools/aot/ILCompiler.ReadyToRun.Tests/TestCasesRunner/R2RTestRunner.cs b/src/coreclr/tools/aot/ILCompiler.ReadyToRun.Tests/TestCasesRunner/R2RTestRunner.cs index 66d5d6332489bd..c2cff6b1008be7 100644 --- a/src/coreclr/tools/aot/ILCompiler.ReadyToRun.Tests/TestCasesRunner/R2RTestRunner.cs +++ b/src/coreclr/tools/aot/ILCompiler.ReadyToRun.Tests/TestCasesRunner/R2RTestRunner.cs @@ -89,6 +89,8 @@ internal sealed class CrossgenCompilation(string name, List as /// public List AdditionalArgs { get; init; } = new(); + public Action? BeforeCompile { get; init; } + /// /// Optional validator for this compilation's R2R output image. /// @@ -212,6 +214,7 @@ public void Run(R2RTestCase testCase) foreach(var compilation in testCase.Compilations) { + compilation.BeforeCompile?.Invoke(); string outputPath = RunCrossgenCompilation( testCase.Name, compilation, driver, compilation.FilePath, assemblyPaths); diff --git a/src/coreclr/tools/aot/ILCompiler.ReadyToRun.Tests/TestCasesRunner/WasmR2RAssert.cs b/src/coreclr/tools/aot/ILCompiler.ReadyToRun.Tests/TestCasesRunner/WasmR2RAssert.cs index afb5dacdfc273c..4c1bd798355cf9 100644 --- a/src/coreclr/tools/aot/ILCompiler.ReadyToRun.Tests/TestCasesRunner/WasmR2RAssert.cs +++ b/src/coreclr/tools/aot/ILCompiler.ReadyToRun.Tests/TestCasesRunner/WasmR2RAssert.cs @@ -62,22 +62,43 @@ public static void AssertAuxiliaryManagedHelpers(ReadyToRunReader reader) Assert.DoesNotContain(localIndex, installedFunctions); } } + } - static Dictionary ReadFunctionNames(ReadOnlySpan image) + public static void AssertAuxiliaryProfileSelection(ReadyToRunReader reader, bool hasHotMethods, bool hasHotValueTypes) + { + Dictionary primary = ReadFunctionNames(reader.CompositeReader.GetEntireImage().AsSpan()); + Dictionary body = ReadFunctionNames(File.ReadAllBytes(Path.ChangeExtension(reader.Filename, ".1.wasm"))); + Check("WasmWebcilModule__AddIntegers", hot: false); + Check("WasmWebcilModule__ProfiledUnmanaged", hot: true); + Check("WasmWebcilModule__AuxiliaryMethod", hot: hasHotMethods); + Check("__ProfiledGeneric", hot: hasHotMethods); + Check("ProfiledHolder_1__Hot", hot: hasHotMethods); + Check("__ProfiledGeneric", hot: hasHotValueTypes); + Check("ProfiledHolder_1__Hot", hot: hasHotValueTypes); + + void Check(string name, bool hot) { - Assert.True(TryGetWasmFunctionNameSubsection(image, out int offset, out int end)); - uint count = ReadWasmUleb32(image, ref offset, end); - Dictionary names = new(); - for (uint index = 0; index < count; index++) - { - uint functionIndex = ReadWasmUleb32(image, ref offset, end); - names.Add(functionIndex, ReadWasmName(image, ref offset, end)); - } - Assert.Equal(end, offset); - return names; + Dictionary owner = hot ? primary : body; + Dictionary other = hot ? body : primary; + Assert.Contains(owner.Values, value => value.Contains(name, StringComparison.Ordinal)); + Assert.DoesNotContain(other.Values, value => value.Contains(name, StringComparison.Ordinal)); } } + private static Dictionary ReadFunctionNames(ReadOnlySpan image) + { + Assert.True(TryGetWasmFunctionNameSubsection(image, out int offset, out int end)); + uint count = ReadWasmUleb32(image, ref offset, end); + Dictionary names = new(); + for (uint index = 0; index < count; index++) + { + uint functionIndex = ReadWasmUleb32(image, ref offset, end); + names.Add(functionIndex, ReadWasmName(image, ref offset, end)); + } + Assert.Equal(end, offset); + return names; + } + public static void AssertAuxiliaryModuleLayout(ReadyToRunReader reader) { Assert.True(WasmSymbolMapHasExpectedFunctionNames(reader, out string diagnostic), diagnostic); diff --git a/src/coreclr/tools/aot/ILCompiler.ReadyToRun/Compiler/DependencyAnalysis/ReadyToRun/ReadyToRunHeaderNode.cs b/src/coreclr/tools/aot/ILCompiler.ReadyToRun/Compiler/DependencyAnalysis/ReadyToRun/ReadyToRunHeaderNode.cs index 9d6b0ef9c45c54..2435dc7643eaf6 100644 --- a/src/coreclr/tools/aot/ILCompiler.ReadyToRun/Compiler/DependencyAnalysis/ReadyToRun/ReadyToRunHeaderNode.cs +++ b/src/coreclr/tools/aot/ILCompiler.ReadyToRun/Compiler/DependencyAnalysis/ReadyToRun/ReadyToRunHeaderNode.cs @@ -20,6 +20,9 @@ public abstract class HeaderTableNode : ObjectNode, ISymbolDefinitionNode public virtual int Offset => 0; + // The definition already accounts for any leading fixup blobs. + int ISymbolNode.Offset => 0; + public override bool IsShareable => false; protected override string GetName(NodeFactory factory) => this.GetMangledName(factory.NameMangler); diff --git a/src/coreclr/tools/aot/ILCompiler.ReadyToRun/Compiler/DependencyAnalysis/ReadyToRunCodegenNodeFactory.cs b/src/coreclr/tools/aot/ILCompiler.ReadyToRun/Compiler/DependencyAnalysis/ReadyToRunCodegenNodeFactory.cs index 0568cdc5fa0ba5..08934e6c916542 100644 --- a/src/coreclr/tools/aot/ILCompiler.ReadyToRun/Compiler/DependencyAnalysis/ReadyToRunCodegenNodeFactory.cs +++ b/src/coreclr/tools/aot/ILCompiler.ReadyToRun/Compiler/DependencyAnalysis/ReadyToRunCodegenNodeFactory.cs @@ -82,6 +82,7 @@ public struct NodeFactoryOptimizationFlags public sealed partial class NodeFactory { private bool _markingComplete; + private readonly HashSet _mibcHotMethods; public CompilerTypeSystemContext TypeSystemContext { get; } @@ -109,7 +110,6 @@ public sealed partial class NodeFactory public bool MarkingComplete => _markingComplete; public bool UsesWasmAuxiliaryModules => OptimizationFlags.WasmAuxiliaryModules && !OptimizationFlags.IsComponentModule; - public bool IsAuxiliaryMethod(MethodWithGCInfo method) { if (!UsesWasmAuxiliaryModules || method.IsJitHelper) @@ -122,6 +122,9 @@ public bool IsAuxiliaryMethod(MethodWithGCInfo method) target.IsPInvoke || ecmaMethod.MetadataReader.GetMethodDefinition(ecmaMethod.Handle).RelativeVirtualAddress == 0) return false; + if (_mibcHotMethods is not null) + return !_mibcHotMethods.Contains(target); + return (ecmaMethod.Attributes & MethodAttributes.MemberAccessMask) != MethodAttributes.Public || target.HasInstantiation || target.OwningType.HasInstantiation; } @@ -370,6 +373,15 @@ public NodeFactory( TypeSystemContext = context; CompilationModuleGroup = compilationModuleGroup; ProfileDataManager = profileDataManager; + if (UsesWasmAuxiliaryModules && profileDataManager.HasMibcProfile) + { + _mibcHotMethods = new HashSet(); + foreach (MethodDesc method in profileDataManager.GetMibcProfileMethods()) + { + // Concrete reference instantiations share the emitted canonical body. + _mibcHotMethods.Add(method.GetCanonMethodTarget(CanonicalFormKind.Specific)); + } + } Target = context.Target; NameMangler = nameMangler; MetadataManager = new ReadyToRunTableManager(context); diff --git a/src/coreclr/tools/aot/ILCompiler.ReadyToRun/Compiler/ProfileDataManager.cs b/src/coreclr/tools/aot/ILCompiler.ReadyToRun/Compiler/ProfileDataManager.cs index cf1d8b49582057..26122e5468d0a1 100644 --- a/src/coreclr/tools/aot/ILCompiler.ReadyToRun/Compiler/ProfileDataManager.cs +++ b/src/coreclr/tools/aot/ILCompiler.ReadyToRun/Compiler/ProfileDataManager.cs @@ -22,6 +22,7 @@ public class ProfileDataManager // cannot be modified after loading and is accessed concurrently // without locks. private readonly ProfileDataMap _inputProfileData; + private readonly int _mibcFileCount; // When synthesis is enabled, this represents the map of methods with // synthesized PGO data. This is accessed and modified concurrently. private readonly ProfileDataMap _synthesizedProfileData; @@ -50,6 +51,7 @@ public ProfileDataManager(Logger logger, _compilationGroup = compilationGroup; _callChainProfile = callChainProfile; HashSet versionBubble = new HashSet(versionBubbleModules); + _mibcFileCount = mibcFiles.Count; { // Parse MIbc Data @@ -109,6 +111,18 @@ public bool IsMethodInInputProfileData(MethodDesc method) return _inputProfileData.Contains(method); } + public bool HasMibcProfile => _mibcFileCount > 0; + + public IEnumerable GetMibcProfileMethods() + { + // MIBC inputs precede embedded IBC data; only explicit MIBC files select the partition. + for (int index = 0; index < _mibcFileCount; index++) + { + foreach (MethodProfileData method in _inputData[index].GetAllMethodProfileData()) + yield return method.Method; + } + } + public MethodProfileData this[MethodDesc method] { get diff --git a/src/coreclr/tools/aot/crossgen2/README.md b/src/coreclr/tools/aot/crossgen2/README.md index eb635c2bcce8fc..baf5227359847e 100644 --- a/src/coreclr/tools/aot/crossgen2/README.md +++ b/src/coreclr/tools/aot/crossgen2/README.md @@ -20,7 +20,7 @@ In addition to the composite image and its component forwarding images, this emi | `app.r2r.1.wasm` | Cold methods and funclets, their fixup lists, exclusive import cells/signatures, GC/unwind and exception data | | `app.r2r.auxiliary.json` | Sibling filenames: `{"entrypoints":"app.r2r.entrypoints.wasm","modules":["app.r2r.1.wasm"]}` | -Public nongeneric methods initially stay hot. Compiler-generated wrappers, +Without `--mibc`, public nongeneric methods on nongeneric types initially stay hot. Compiler-generated wrappers, unmanaged entrypoints, runtime helpers and async/resumption methods also stay hot. Other eligible IL methods go into the cold module. Ordinary managed calls use indirect imports. Eligible managed JIT helpers remain directly callable: their @@ -28,6 +28,28 @@ bodies and funclets are copied into body modules while their logical table slots virtual-IP identities and metadata remain canonical in the primary. Non-managed helper function bodies are also duplicated where needed. +With one or more `--mibc startup.mibc` inputs, methods present in the union of +the profiles stay primary, regardless of visibility or genericity. Remaining +eligible methods go cold. Concrete reference-type instantiations select their +shared canonical body; value-type instantiations remain distinct. Mandatory +primary methods and helpers keep the rules above. An explicitly supplied empty +profile, or one with no matching methods, moves every eligible method cold; +it does not restore the no-profile heuristic. Embedded IBC and synthesized +optimization profiles do not select auxiliary-module ownership. + +For example: + +```shell +crossgen2 app.dll --composite --targetarch wasm --targetos browser \ + --wasm-auxiliary-modules --mibc startup.mibc \ + -r:System.Private.CoreLib.dll -o:app.r2r.wasm +``` + +Partitioning uses method membership, not a CPU-weight threshold. Use a profile +captured over the intended startup/interaction interval and matching the +published assemblies. This option consumes existing MIBC data; it does not +collect a browser profile. + All modules use the primary image's `webcil` imports and the same memory/table bases. Sidecar instantiation does not install code or data. Its idempotent `ActivateModule()` export installs passive data, rebases its owned import cells, diff --git a/src/mono/browser/build/README.md b/src/mono/browser/build/README.md index fe6b1274fcffed..639fc204f7f8ba 100644 --- a/src/mono/browser/build/README.md +++ b/src/mono/browser/build/README.md @@ -151,6 +151,23 @@ dotnet publish -p:PublishReadyToRun=true -p:PublishReadyToRunComposite=true -p:W crossgen2 and requires retained IL; `--strip-il` and threaded builds are not supported. In-tree browser samples also forward this property through `BuildSampleInTree`. +To use MIBC profile data for the hot/cold split, add the existing ReadyToRun profile item to +the app project: + +```xml + + + +``` + +The publish pipeline passes each item to crossgen2 as `--mibc` (or its `-m` alias). +Multiple items combine the methods from their profiles. With an explicit profile, methods +present in the profile stay in the primary image and the remaining eligible methods go to +auxiliary bodies. Without an explicit profile, the compiler uses its default hot/cold heuristic. +Auxiliary publishing still compiles the full eligible method set; supplying a profile does not +implicitly enable crossgen2's `--partial` compilation mode. +Use a profile collected from the intended application scenario when choosing production hot methods. + The compiler emits an `.auxiliary.json` manifest beside the primary image. Its explicit EntryPoints and body filenames are collected as sibling `_framework` assets, fingerprinted and compressed by the normal static-web-assets pipeline, and recorded in the boot config's diff --git a/src/mono/nuget/Microsoft.NET.Sdk.WebAssembly.Pack/build/Microsoft.NET.Sdk.WebAssembly.Browser.CoreCLR.targets b/src/mono/nuget/Microsoft.NET.Sdk.WebAssembly.Pack/build/Microsoft.NET.Sdk.WebAssembly.Browser.CoreCLR.targets index a26d183d009be9..114a1cf82ab9fe 100644 --- a/src/mono/nuget/Microsoft.NET.Sdk.WebAssembly.Pack/build/Microsoft.NET.Sdk.WebAssembly.Browser.CoreCLR.targets +++ b/src/mono/nuget/Microsoft.NET.Sdk.WebAssembly.Pack/build/Microsoft.NET.Sdk.WebAssembly.Browser.CoreCLR.targets @@ -284,7 +284,8 @@ Copyright (c) .NET Foundation. All rights reserved. Condition="'$(PublishReadyToRun)' == 'true'"> - $(PublishReadyToRunCrossgen2ExtraArgs);--partial + + $(PublishReadyToRunCrossgen2ExtraArgs);--partial diff --git a/src/mono/wasm/Wasm.Build.Tests/CoreCLRWasmNativeDefaultsTests.cs b/src/mono/wasm/Wasm.Build.Tests/CoreCLRWasmNativeDefaultsTests.cs index 0e39a85f4d630e..4f2f71ba518246 100644 --- a/src/mono/wasm/Wasm.Build.Tests/CoreCLRWasmNativeDefaultsTests.cs +++ b/src/mono/wasm/Wasm.Build.Tests/CoreCLRWasmNativeDefaultsTests.cs @@ -190,14 +190,17 @@ public void PublishReadyToRunDirectoryMatchesSdkOutputCasing() } // Verify that explicit PGO items remain available to the SDK ReadyToRun pipeline and enable - // --partial only when ReadyToRun is on. Stops before crossgen2; the compiler arguments only let - // restore resolve a compiler (in-build when BASE_DIR is set, SDK pack otherwise). + // --partial only when ReadyToRun is on without auxiliary modules. Stops before crossgen2; + // the compiler arguments only let restore resolve a compiler (in-build when BASE_DIR is set, + // SDK pack otherwise). [Theory] - [InlineData(/*profileSet*/ true, /*r2r*/ true, /*expectPartial*/ true)] - [InlineData(/*profileSet*/ false, /*r2r*/ true, /*expectPartial*/ false)] - [InlineData(/*profileSet*/ true, /*r2r*/ false, /*expectPartial*/ false)] - [InlineData(/*profileSet*/ false, /*r2r*/ false, /*expectPartial*/ false)] - public void PublishReadyToRunPgoFilesControlsPartialCompilation(bool profileSet, bool r2r, bool expectPartial) + [InlineData(/*profileSet*/ true, /*r2r*/ true, /*auxiliary*/ false, /*expectPartial*/ true)] + [InlineData(/*profileSet*/ false, /*r2r*/ true, /*auxiliary*/ false, /*expectPartial*/ false)] + [InlineData(/*profileSet*/ true, /*r2r*/ false, /*auxiliary*/ false, /*expectPartial*/ false)] + [InlineData(/*profileSet*/ false, /*r2r*/ false, /*auxiliary*/ false, /*expectPartial*/ false)] + [InlineData(/*profileSet*/ true, /*r2r*/ true, /*auxiliary*/ true, /*expectPartial*/ false)] + [InlineData(/*profileSet*/ false, /*r2r*/ true, /*auxiliary*/ true, /*expectPartial*/ false)] + public void PublishReadyToRunPgoFilesControlsPartialCompilation(bool profileSet, bool r2r, bool auxiliary, bool expectPartial) { Configuration config = Configuration.Release; string profilePath = Path.Combine(BuildEnvironment.TestDataPath, "DotNet_Blazor_Wasm.mibc"); @@ -215,8 +218,12 @@ public void PublishReadyToRunPgoFilesControlsPartialCompilation(bool profileSet, config, aot: false, TestAsset.WasmBasicTestApp, - $"coreclr_r2r_profile_{profileSet}_{r2r}", - extraProperties: $"{r2r}", + $"coreclr_r2r_profile_{profileSet}_{r2r}_{auxiliary}", + extraProperties: $""" + {r2r} + {auxiliary} + {auxiliary} + """, extraItems: profileItem, insertAtEnd: printValueTarget); From 769fb16e3f9e9a63c1a73b75e05e84addd74664a Mon Sep 17 00:00:00 2001 From: jtschuster <36744439+jtschuster@users.noreply.github.com> Date: Thu, 29 Oct 2026 23:27:28 +0000 Subject: [PATCH 18/19] Group cold-exclusive WebAssembly R2R imports before layout Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../features/partitioned-wasm-r2r-modules.md | 4 + .../WebCilObjectWriter.Auxiliary.cs | 90 ++--- .../WasmImportLayoutTests.cs | 371 ++++++++++++++++++ .../ReadyToRun/GCRefMapNode.cs | 21 +- .../DependencyAnalysis/ReadyToRun/Import.cs | 6 + .../ReadyToRun/WasmImportLayout.cs | 74 ++++ .../ILCompiler.ReadyToRun.csproj | 1 + .../host/auxiliary-modules.test.mjs | 11 +- .../DelayedR2RActivation.cs | 28 ++ 9 files changed, 550 insertions(+), 56 deletions(-) create mode 100644 src/coreclr/tools/aot/ILCompiler.ReadyToRun.Tests/WasmImportLayoutTests.cs create mode 100644 src/coreclr/tools/aot/ILCompiler.ReadyToRun/Compiler/DependencyAnalysis/ReadyToRun/WasmImportLayout.cs diff --git a/docs/design/features/partitioned-wasm-r2r-modules.md b/docs/design/features/partitioned-wasm-r2r-modules.md index c2c9b268652e87..ced701a9fb149a 100644 --- a/docs/design/features/partitioned-wasm-r2r-modules.md +++ b/docs/design/features/partitioned-wasm-r2r-modules.md @@ -28,6 +28,10 @@ The compiler emits a primary module, an `.entrypoints.wasm` mapping module, and The cold-code module owns its methods and funclets, runtime-function records, GC/unwind and exception-handling data, fixup lists, and exclusively cold import cells and signatures. The mapping module owns auxiliary entrypoint tables and their method-identification signatures. Shared imports, async-resume data rebased during image registration, and the final runtime-function/unwind record needed to reserve the virtual-IP range remain primary. Activation never overwrites those resident data. +Before assigning import offsets, the WebCIL writer classifies import usage from method fixups and relocations. Each import table and its parallel signature-RVA array use the same primary-first, cold-exclusive-last partition key, retaining the existing deterministic order within each group. GC reference maps follow physical cell order, including empty records for non-method cells. Table identities and entry sizes do not change; fixup lists and relocations are emitted against the new indices and offsets rather than patched afterward. + +Eager, shared, unused, and resident-data-referenced cells stay primary. Signature ownership is computed after symbol deduplication: signatures shared with primary cells or directly referenced by resident data remain primary even when some referencing cells move cold. Cold-exclusive signature blobs are stably grouped at the end of data emission. Alignment gaps are not claimed as cold data, so aligned blobs can still require multiple passive segments. Portable import-thunk records are resident data and pin their referenced cells primary; moving those records would require a separate ownership analysis and is deliberately not inferred from a cold method's use of the cell. GC reference maps remain resident because their stride-indexed encoding covers both groups. + Both sidecars import the primary module's memory, table, bases, stack pointer, exception tag, and async global. Their data and element segments are passive. `ActivateModule()` installs those segments and writes its activation cell last; repeated activation is a no-op. Mapping activation also notifies the runtime to advance the global EntryPoints era. The browser host starts sidecar loading without adding it to the startup barrier. Activation runs on the browser's JavaScript thread. A synchronous managed computation started before activation can block the event loop and remain interpreted until it yields or returns, even if the sidecars have finished downloading. Startup measurements include this fallback interval; post-activation throughput measurements must first verify that both activation cells are active. diff --git a/src/coreclr/tools/Common/Compiler/ObjectWriter/WebCilObjectWriter.Auxiliary.cs b/src/coreclr/tools/Common/Compiler/ObjectWriter/WebCilObjectWriter.Auxiliary.cs index 0c0ed4f66cdeb3..6d4c3c92370ddc 100644 --- a/src/coreclr/tools/Common/Compiler/ObjectWriter/WebCilObjectWriter.Auxiliary.cs +++ b/src/coreclr/tools/Common/Compiler/ObjectWriter/WebCilObjectWriter.Auxiliary.cs @@ -24,6 +24,7 @@ internal sealed partial class WebCilObjectWriter private int[] _primaryFunctionIndices; private int _activationSignature; private List _auxiliaryDataRanges; + private WasmImportLayout _importLayout; private readonly struct AuxiliaryDataRange { @@ -41,10 +42,51 @@ public AuxiliaryDataRange(int offset, int length, bool entryPoints) public override void EmitObject(Stream stream, IReadOnlyCollection nodes, IObjectDumper dumper, Logger logger) { + if (_nodeFactory.UsesWasmAuxiliaryModules) + { + _importLayout = PrepareImportLayout(nodes); + nodes = _importLayout.OrderSignatureBlobs(nodes); + } _partitionNodes = nodes; base.EmitObject(stream, nodes, dumper, logger); } + private WasmImportLayout PrepareImportLayout(IReadOnlyCollection nodes) + { + WasmImportLayout layout = new(); + HashSet signatureArrays = new(); + foreach (DependencyNode dependency in nodes) + { + if (dependency is Import import) + { + layout.AddImport(import); + signatureArrays.Add(import.Table.SignatureArray); + } + } + foreach (DependencyNode dependency in nodes) + { + if (dependency is not ObjectNode node || node.ShouldSkipEmittingObjectNode(_nodeFactory)) + continue; + bool cold = node is MethodWithGCInfo method && _nodeFactory.IsAuxiliaryMethod(method); + if (node is MethodWithGCInfo body) + { + foreach (ISymbolNode fixup in body.Fixups) + layout.AddUse(fixup, cold); + } + // Signature-array references follow their owning import cells, not the resident container. + // Other resident data, including portable import-thunk records, must remain usable immediately. + if (node is ISymbolNode symbol && signatureArrays.Contains(symbol)) + continue; + if (node.GetData(_nodeFactory, relocsOnly: true).Relocs is Relocation[] relocations) + { + foreach (Relocation relocation in relocations) + layout.AddUse(relocation.Target, cold); + } + } + layout.Complete(symbol => _nodeFactory.ObjectInterner.GetDeduplicatedSymbol(_nodeFactory, symbol)); + return layout; + } + private List GetAuxiliaryDataRanges() { List ranges = new(); @@ -129,48 +171,7 @@ void Add(int offset, int length, bool entryPoints) void AddExclusiveImports() { - HashSet primaryImports = new(); - HashSet coldImports = new(); - List imports = new(); - foreach (DependencyNode dependency in _partitionNodes) - { - if (dependency is Import import) - { - imports.Add(import); - if (import.Table.IsEager) - primaryImports.Add(import); - } - if (dependency is not ObjectNode objectNode || objectNode.ShouldSkipEmittingObjectNode(_nodeFactory)) - continue; - bool cold = objectNode is MethodWithGCInfo method && _nodeFactory.IsAuxiliaryMethod(method); - HashSet usedImports = cold ? coldImports : primaryImports; - if (objectNode is MethodWithGCInfo body) - { - foreach (ISymbolNode fixup in body.Fixups) - { - if (fixup is Import methodImport) - usedImports.Add(methodImport); - } - } - // Resident read-only and writable data can use import cells too. - if (objectNode.GetData(_nodeFactory, relocsOnly: true).Relocs is Relocation[] relocations) - { - foreach (Relocation relocation in relocations) - { - if (relocation.Target is Import referencedImport) - usedImports.Add(referencedImport); - } - } - } - coldImports.ExceptWith(primaryImports); - HashSet primarySignatures = new(); - foreach (Import import in imports) - { - if (!coldImports.Contains(import)) - primarySignatures.Add(_nodeFactory.ObjectInterner.GetDeduplicatedSymbol(_nodeFactory, import.Signature)); - } - HashSet movedSignatures = new(); - foreach (Import import in coldImports) + foreach (Import import in _importLayout.ColdImports) { AddSymbolRange(import, import.Table.EntrySize); if (_definedSymbols.TryGetValue(GetMangledName(import.Table.SignatureArray), out SymbolDefinition array)) @@ -179,10 +180,9 @@ void AddExclusiveImports() Add(checked((int)section.Header.VirtualAddress + (int)array.Value + import.ImportSignature.OffsetFromBeginningOfArray), sizeof(uint), entryPoints: false); } - ISymbolNode signature = _nodeFactory.ObjectInterner.GetDeduplicatedSymbol(_nodeFactory, import.Signature); - if (!primarySignatures.Contains(signature) && movedSignatures.Add(signature)) - AddSymbolRange(signature, length: 0); } + foreach (ISymbolNode signature in _importLayout.ColdSignatures) + AddSymbolRange(signature, length: 0); } void AddSymbolRange(ISymbolNode symbol, int length) diff --git a/src/coreclr/tools/aot/ILCompiler.ReadyToRun.Tests/WasmImportLayoutTests.cs b/src/coreclr/tools/aot/ILCompiler.ReadyToRun.Tests/WasmImportLayoutTests.cs new file mode 100644 index 00000000000000..d0ac36ba2cdffe --- /dev/null +++ b/src/coreclr/tools/aot/ILCompiler.ReadyToRun.Tests/WasmImportLayoutTests.cs @@ -0,0 +1,371 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +extern alias crossgen2; + +using System; +using System.Buffers.Binary; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using crossgen2::ILCompiler; +using crossgen2::ILCompiler.DependencyAnalysis; +using crossgen2::ILCompiler.DependencyAnalysis.ReadyToRun; +using crossgen2::ILCompiler.Win32Resources; +using crossgen2::Internal.ReadyToRunConstants; +using crossgen2::Internal.Text; +using ILCompiler.DependencyAnalysisFramework; +using Internal.TypeSystem; +using Internal.TypeSystem.Ecma; +using Xunit; + +namespace ILCompiler.ReadyToRun.Tests; + +public class WasmImportLayoutTests +{ + private static readonly SortableDependencyNode.EmbeddedObjectNodeComparer s_comparer = + new(CompilerComparer.Instance); + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void MixedImportsHaveOneColdRunInBothArrays(bool reverse) + { + ImportSectionNode table = Table(); + Import[] imports = Enumerable.Range(1, 8).Select(id => + new Import(table, new ReadyToRunHelperSignature((ReadyToRunHelper)id))).ToArray(); + Import[] original = imports.OrderBy(import => import, s_comparer).ToArray(); + HashSet cold = new(original.Where((_, index) => index % 2 == 0)); + Assert.Equal(4, Runs(original, cold)); + + WasmImportLayout layout = new(); + foreach (Import import in reverse ? imports.Reverse() : imports) + { + layout.AddImport(import); + layout.AddUse(import, cold.Contains(import)); + } + layout.Complete(symbol => symbol); + + Import[] ordered = imports.OrderBy(import => import, s_comparer).ToArray(); + Assert.Equal(original.Where(import => !cold.Contains(import)).Concat(original.Where(cold.Contains)), ordered); + Assert.Equal(1, Runs(ordered, layout.ColdImports.ToHashSet())); + Assert.Equal(ordered.Select(import => import.ImportSignature), + imports.Select(import => import.ImportSignature).OrderBy(signature => signature, s_comparer)); + Assert.True(cold.Select(import => (ISymbolNode)import.Signature).ToHashSet().SetEquals(layout.ColdSignatures)); + } + + [Fact] + public void SharedEagerUnusedAndResidentDataImportsStayPrimary() + { + ImportSectionNode table = Table(); + Signature sharedSignature = new ReadyToRunHelperSignature((ReadyToRunHelper)1); + Import hot = new(table, sharedSignature); + Import shared = new(table, new ReadyToRunHelperSignature((ReadyToRunHelper)2)); + Import coldWithSharedSignature = new(table, sharedSignature); + Import residentData = new(table, new ReadyToRunHelperSignature((ReadyToRunHelper)3)); + Import eager = new(Table(eager: true), new ReadyToRunHelperSignature((ReadyToRunHelper)4)); + Import unused = new(table, new ReadyToRunHelperSignature((ReadyToRunHelper)5)); + Import exclusive = new(table, new ReadyToRunHelperSignature((ReadyToRunHelper)6)); + WasmImportLayout layout = new(); + foreach (Import import in new[] { hot, shared, coldWithSharedSignature, residentData, eager, unused, exclusive }) + layout.AddImport(import); + layout.AddUse(hot, cold: false); + layout.AddUse(shared, cold: false); + layout.AddUse(shared, cold: true); + layout.AddUse(coldWithSharedSignature, cold: true); + layout.AddUse(residentData, cold: true); + layout.AddUse(residentData, cold: false); + layout.AddUse(eager, cold: true); + layout.AddUse(exclusive, cold: true); + layout.Complete(symbol => symbol); + + Assert.True(new HashSet { coldWithSharedSignature, exclusive }.SetEquals(layout.ColdImports)); + Assert.Same(exclusive.Signature, Assert.Single(layout.ColdSignatures)); + Assert.DoesNotContain(sharedSignature, layout.ColdSignatures); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void SharedDeduplicatedAndDirectlyReferencedSignaturesStayPrimary(bool directReference) + { + Signature first = new ReadyToRunHelperSignature((ReadyToRunHelper)1); + Signature alias = new ReadyToRunHelperSignature((ReadyToRunHelper)2); + Import cold = new(Table(), first); + WasmImportLayout layout = new(); + layout.AddImport(cold); + layout.AddUse(cold, cold: true); + if (directReference) + layout.AddUse(alias, cold: false); + else + layout.AddImport(new Import(Table(), alias)); + layout.Complete(symbol => symbol == alias ? first : symbol); + Assert.Same(cold, Assert.Single(layout.ColdImports)); + Assert.Empty(layout.ColdSignatures); + } + + [Fact] + public void EagerValidationFixupsStillPrecedeReadyToRunCommitFixups() + { + ImportSectionNode table = Table(eager: true); + Import commit = new(table, new TestSignature(1), sortLast: true); + Import validation = new(table, new TestSignature(2)); + WasmImportLayout layout = new(); + layout.AddImport(commit); + layout.AddImport(validation); + layout.AddUse(commit, cold: true); + layout.AddUse(validation, cold: true); + layout.Complete(symbol => symbol); + Assert.Empty(layout.ColdImports); + Assert.True(s_comparer.Compare(validation, commit) < 0); + Assert.True(s_comparer.Compare(validation.ImportSignature, commit.ImportSignature) < 0); + } + + [Fact] + public void SignatureBlobsAreStablyGroupedWithoutMovingOrderedTables() + { + ImportSectionNode table = Table(); + Signature[] signatures = Enumerable.Range(1, 4) + .Select(id => (Signature)new ReadyToRunHelperSignature((ReadyToRunHelper)id)).ToArray(); + WasmImportLayout layout = new(); + for (int index = 0; index < signatures.Length; index++) + { + Import import = new(table, signatures[index]); + layout.AddImport(import); + layout.AddUse(import, cold: index % 2 == 0); + } + layout.Complete(symbol => symbol); + DependencyNode[] original = [table, signatures[0], signatures[1], signatures[2], signatures[3]]; + Assert.Equal([table, signatures[1], signatures[3], signatures[0], signatures[2]], + layout.OrderSignatureBlobs(original)); + } + + [Theory] + [InlineData(0, false)] + [InlineData(0, true)] + [InlineData(4, false)] + [InlineData(4, true)] + public void EmptyAndSingleOwnerPartitions(int count, bool cold) + { + WasmImportLayout layout = new(); + Import[] imports = Enumerable.Range(1, count).Select(id => + new Import(Table(), new ReadyToRunHelperSignature((ReadyToRunHelper)id))).ToArray(); + foreach (Import import in imports) + { + layout.AddImport(import); + layout.AddUse(import, cold); + } + layout.Complete(symbol => symbol); + Assert.Equal(cold ? count : 0, layout.ColdImports.Count()); + Assert.Equal(cold ? count : 0, layout.ColdSignatures.Count()); + Assert.Equal(imports.Cast(), layout.OrderSignatureBlobs(imports)); + } + + [Theory] + [InlineData(0)] + [InlineData(8)] + [InlineData(1025)] + public void EmittedTablesUsePartitionedOffsetsAndPreserveAlignment(int count) + { + NodeFactory factory = CreateFactory(); + ImportSectionNode table = new("EmittedImports", ReadyToRunImportSectionType.Unknown, + 0, 4, emitPrecode: true, emitGCRefMap: true); + Import[] imports = Enumerable.Range(1, count).Select(id => + new Import(table, new TestSignature(id))).ToArray(); + HashSet coldImports = imports.Where((_, index) => index % 2 == 0).ToHashSet(); + int originalRuns = Runs(imports.OrderBy(import => import, s_comparer), coldImports); + WasmImportLayout layout = new(); + for (int index = 0; index < count; index++) + { + table.AddImport(factory, imports[index]); + layout.AddImport(imports[index]); + layout.AddUse(imports[index], cold: index % 2 == 0); + } + layout.Complete(symbol => symbol); + SortableDependencyNode.DependencySink sink = new(); + table.AddStaticDependencies(sink, factory); + using var dependencies = sink.Drain(); + List tables = new(); + while (dependencies.MoveNext()) + tables.Add(Assert.IsAssignableFrom(dependencies.Dependency)); + ObjectNode cells = Assert.Single(tables, node => node is not GCRefMapNode && node != table.SignatureArray); + ObjectNode.ObjectData cellData = cells.GetData(factory); + ObjectNode.ObjectData signatureData = ((ObjectNode)table.SignatureArray).GetData(factory); + ObjectNode.ObjectData map = Assert.Single(tables.OfType()).GetData(factory); + + Assert.Equal(4, cellData.Alignment); + Assert.Equal(count * 4, cellData.Data.Length); + Assert.Equal(count * 4, signatureData.Data.Length); + Import[] ordered = imports.OrderBy(import => import, s_comparer).ToArray(); + for (int index = 0; index < count; index++) + { + Import import = ordered[index]; + Assert.Equal(index, import.IndexFromBeginningOfArray); + Assert.Equal(index * 4, import.OffsetFromBeginningOfArray); + Assert.Equal(index, import.ImportSignature.IndexFromBeginningOfArray); + Assert.Equal(index * 4, import.ImportSignature.OffsetFromBeginningOfArray); + Assert.Same(import.Signature, signatureData.Relocs[index].Target); + Assert.Equal(index * 4, signatureData.Relocs[index].Offset); + } + Import[] physicalCells = cellData.DefinedSymbols.OfType() + .OrderBy(import => ((ISymbolDefinitionNode)import).Offset).ToArray(); + int emittedRuns = Runs(physicalCells, layout.ColdImports.ToHashSet()); + Assert.Equal(ordered, physicalCells); + Assert.Equal(count == 0 ? 0 : 1, emittedRuns); + if (count > 2) + Assert.True(emittedRuns < originalRuns, + "The emitted table must have fewer cold-owned ranges than the unpartitioned order."); + if (count == 0) + { + Assert.Empty(map.Data); + } + else + { + int headerSize = (count / GCRefMapNode.GCREFMAP_LOOKUP_STRIDE + 1) * 4; + Assert.Equal(headerSize, BinaryPrimitives.ReadInt32LittleEndian(map.Data)); + Assert.Equal(headerSize + count, map.Data.Length); + Assert.All(map.Data.Skip(headerSize), value => Assert.Equal(0, value)); + if (count > GCRefMapNode.GCREFMAP_LOOKUP_STRIDE) + Assert.Equal(headerSize + GCRefMapNode.GCREFMAP_LOOKUP_STRIDE, + BinaryPrimitives.ReadInt32LittleEndian(map.Data.AsSpan(4))); + } + + table.InitializeIndexFromBeginningOfArray(3); + EcmaModule module = factory.TypeSystemContext.GetModuleFromPath(typeof(WasmImportLayoutTests).Assembly.Location); + MethodWithGCInfo method = new(module.GetType("ILCompiler.ReadyToRun.Tests"u8, "WasmImportLayoutTests"u8) + .GetMethod("MixedImportsHaveOneColdRunInBothArrays"u8, null)); + method.SetCode(new ObjectNode.ObjectData([], [], 1, [])); + foreach (Import import in ordered.Where((_, index) => index % 2 == 0).Reverse()) + method.Fixups.Add(import); + if (count > 0) + { + byte[] blob = method.GetFixupBlob(factory); + NibbleWriter expected = new(); + expected.WriteUInt(3); + expected.WriteUInt(0); + for (int index = 2; index < count; index += 2) + expected.WriteUInt(2); + expected.WriteUInt(0); + expected.WriteUInt(0); + Assert.Equal(expected.ToArray(), blob); + } + } + + [Fact] + public void GCMapsFollowCellsIncludingGenericMethodsAndNonMethodRecords() + { + NodeFactory factory = CreateFactory(hostTarget: true); + CompilerTypeSystemContext context = factory.TypeSystemContext; + EcmaModule module = context.GetModuleFromPath(typeof(WasmImportLayoutTests).Assembly.Location); + MetadataType type = module.GetType("ILCompiler.ReadyToRun.Tests"u8, "WasmImportLayoutTests"u8); + MethodDesc generic = type.GetMethod("GenericArgument"u8, null).MakeInstantiatedMethod( + new Instantiation(context.GetWellKnownType(WellKnownType.Object))); + ImportSectionNode table = new("GCImports", ReadyToRunImportSectionType.StubDispatch, 0, 8, + emitPrecode: false, emitGCRefMap: true); + Import[] imports = + [ + new MethodImport(table, new TestSignature(1), type.GetMethod("ObjectArgument"u8, null)), + new MethodImport(table, new TestSignature(2), type.GetMethod("IntegerArgument"u8, null)), + new MethodImport(table, new TestSignature(3), generic), + new Import(table, new TestSignature(4)), + new Import(table, new TestSignature(5)), + ]; + WasmImportLayout layout = new(); + for (int index = 0; index < imports.Length; index++) + { + table.AddImport(factory, imports[index]); + layout.AddImport(imports[index]); + layout.AddUse(imports[index], cold: index % 2 == 0); + } + layout.Complete(symbol => symbol); + SortableDependencyNode.DependencySink sink = new(); + table.AddStaticDependencies(sink, factory); + using var dependencies = sink.Drain(); + GCRefMapNode? map = null; + while (dependencies.MoveNext()) + { + if (dependencies.Dependency is GCRefMapNode gcMap) + map = gcMap; + } + Assert.NotNull(map); + GCRefMapBuilder expected = new(factory.Target, relocsOnly: false); + foreach (Import import in imports.OrderBy(import => import, s_comparer)) + { + if (import is IMethodNode method) + expected.GetCallRefMap(method.Method, isUnboxingStub: false); + else + expected.Flush(); + } + Assert.Equal(expected.Builder.ToObjectData().Data, map.GetData(factory).Data.AsSpan(4).ToArray()); + } + + private static void ObjectArgument(object value) { } + private static void IntegerArgument(int value) { } + private static void GenericArgument(T value) { } + + private static NodeFactory CreateFactory(bool hostTarget = false) + { + CompilerTypeSystemContext context = new(new TargetDetails( + hostTarget ? TargetArchitecture.X64 : TargetArchitecture.Wasm32, + hostTarget ? TargetOS.Linux : TargetOS.Browser, + TargetAbi.NativeAot), SharedGenericsMode.CanonicalReferenceTypes); + if (hostTarget) + { + context.ReferenceFilePaths = Directory.GetFiles(Path.GetDirectoryName(typeof(object).Assembly.Location)!, "*.dll") + .ToDictionary(path => Path.GetFileNameWithoutExtension(path), StringComparer.OrdinalIgnoreCase); + context.SetSystemModule(context.GetModuleFromPath(typeof(object).Assembly.Location)); + } + EcmaModule module = context.GetModuleFromPath(typeof(WasmImportLayoutTests).Assembly.Location); + NoMethodsCompilationModuleGroup group = new(new ReadyToRunCompilationModuleGroupConfig + { + Context = context, + IsCompositeBuildMode = true, + CompilationModuleSet = [module], + VersionBubbleModuleSet = [], + CrossModuleInlineable = [], + }); + return new NodeFactory(context, group, null!, null!, null!, null!, new ResourceData(module), + 0, default, ReadyToRunContainerFormat.Wasm, 0, module, -1, -1); + } + + private sealed class MethodImport(ImportSectionNode table, Signature signature, MethodDesc method) + : Import(table, signature), IMethodNode + { + public MethodDesc Method => method; + public override int ClassCode => 173201195; + } + + private sealed class TestSignature(int id) : Signature + { + public override int ClassCode => 173201194; + + public override ObjectData GetData(NodeFactory factory, bool relocsOnly = false) => + new(BitConverter.GetBytes(id), [], 1, [this]); + + public override void AppendMangledName(NameMangler nameMangler, Utf8StringBuilder sb) => + sb.Append("TestSignature" + id); + + public override int CompareToImpl(ISortableNode other, CompilerComparer comparer) => + id.CompareTo(((TestSignature)other).Id); + + private int Id => id; + } + + private static ImportSectionNode Table(bool eager = false) => + new("TestImports", ReadyToRunImportSectionType.Unknown, + eager ? ReadyToRunImportSectionFlags.Eager : 0, 4, emitPrecode: false, emitGCRefMap: false); + + private static int Runs(IEnumerable imports, HashSet cold) + { + int runs = 0; + bool previous = false; + foreach (Import import in imports) + { + bool current = cold.Contains(import); + if (current && !previous) + runs++; + previous = current; + } + return runs; + } +} diff --git a/src/coreclr/tools/aot/ILCompiler.ReadyToRun/Compiler/DependencyAnalysis/ReadyToRun/GCRefMapNode.cs b/src/coreclr/tools/aot/ILCompiler.ReadyToRun/Compiler/DependencyAnalysis/ReadyToRun/GCRefMapNode.cs index 476049f45ca652..85f94eef3d84c5 100644 --- a/src/coreclr/tools/aot/ILCompiler.ReadyToRun/Compiler/DependencyAnalysis/ReadyToRun/GCRefMapNode.cs +++ b/src/coreclr/tools/aot/ILCompiler.ReadyToRun/Compiler/DependencyAnalysis/ReadyToRun/GCRefMapNode.cs @@ -16,12 +16,12 @@ public class GCRefMapNode : ObjectNode, ISymbolDefinitionNode public const int GCREFMAP_LOOKUP_STRIDE = 1024; private readonly ImportSectionNode _importSection; - private readonly List _methods; + private readonly List _imports; public GCRefMapNode(ImportSectionNode importSection) { _importSection = importSection; - _methods = new List(); + _imports = new List(); } public override ObjectNodeSection GetSection(NodeFactory factory) => ObjectNodeSection.ReadOnlyDataSection; @@ -34,13 +34,13 @@ public GCRefMapNode(ImportSectionNode importSection) public int Offset => 0; - public bool IsEmpty => _methods.Count == 0; + public bool IsEmpty => _imports.Count == 0; public void AddImport(Import import) { - lock (_methods) + lock (_imports) { - _methods.Add(import as IMethodNode); + _imports.Add(import); } } @@ -52,7 +52,7 @@ public void AppendMangledName(NameMangler nameMangler, Utf8StringBuilder sb) public override ObjectData GetData(NodeFactory factory, bool relocsOnly = false) { - if (_methods.Count == 0 || relocsOnly) + if (_imports.Count == 0 || relocsOnly) { return new ObjectData( data: Array.Empty(), @@ -61,13 +61,14 @@ public override ObjectData GetData(NodeFactory factory, bool relocsOnly = false) definedSymbols: new ISymbolDefinitionNode[] { this }); } - _methods.MergeSort(CompilerComparer.Instance); + // Preserve a record for every cell, including non-method cells, in physical import order. + _imports.MergeSort(new EmbeddedObjectNodeComparer(CompilerComparer.Instance)); GCRefMapBuilder builder = new GCRefMapBuilder(factory.Target, relocsOnly); builder.Builder.RequireInitialAlignment(4); builder.Builder.AddSymbol(this); // First, emit the initial ref map offset and reserve the offset map entries - int offsetCount = _methods.Count / GCREFMAP_LOOKUP_STRIDE; + int offsetCount = _imports.Count / GCREFMAP_LOOKUP_STRIDE; builder.Builder.EmitInt((offsetCount + 1) * sizeof(int)); ObjectDataBuilder.Reservation[] offsets = new ObjectDataBuilder.Reservation[offsetCount]; @@ -79,9 +80,9 @@ public override ObjectData GetData(NodeFactory factory, bool relocsOnly = false) // Next, generate the actual method GC ref maps and update the offset map int nextOffsetIndex = 0; int nextMethodIndex = GCREFMAP_LOOKUP_STRIDE - 1; - for (int methodIndex = 0; methodIndex < _methods.Count; methodIndex++) + for (int methodIndex = 0; methodIndex < _imports.Count; methodIndex++) { - IMethodNode methodNode = _methods[methodIndex]; + IMethodNode methodNode = _imports[methodIndex] as IMethodNode; if (methodNode == null) { // Flush an empty GC ref map block to prevent diff --git a/src/coreclr/tools/aot/ILCompiler.ReadyToRun/Compiler/DependencyAnalysis/ReadyToRun/Import.cs b/src/coreclr/tools/aot/ILCompiler.ReadyToRun/Compiler/DependencyAnalysis/ReadyToRun/Import.cs index cadc8013774a08..8dff054a7c5163 100644 --- a/src/coreclr/tools/aot/ILCompiler.ReadyToRun/Compiler/DependencyAnalysis/ReadyToRun/Import.cs +++ b/src/coreclr/tools/aot/ILCompiler.ReadyToRun/Compiler/DependencyAnalysis/ReadyToRun/Import.cs @@ -24,6 +24,12 @@ public class Import : EmbeddedObjectNode, ISymbolDefinitionNode, ISortableSymbol public Signature Signature => ImportSignature.Target; + internal void SetPartition(bool coldExclusive) + { + // Both arrays must use the same partition key before assigning any indices or offsets. + CustomSort = ImportSignature.CustomSort = coldExclusive ? 1 : 0; + } + public Import(ImportSectionNode tableNode, Signature importSignature, MethodDesc callingMethod = null, bool sortLast = false) { Table = tableNode; diff --git a/src/coreclr/tools/aot/ILCompiler.ReadyToRun/Compiler/DependencyAnalysis/ReadyToRun/WasmImportLayout.cs b/src/coreclr/tools/aot/ILCompiler.ReadyToRun/Compiler/DependencyAnalysis/ReadyToRun/WasmImportLayout.cs new file mode 100644 index 00000000000000..0d0fb69a3cd533 --- /dev/null +++ b/src/coreclr/tools/aot/ILCompiler.ReadyToRun/Compiler/DependencyAnalysis/ReadyToRun/WasmImportLayout.cs @@ -0,0 +1,74 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using ILCompiler.DependencyAnalysisFramework; + +namespace ILCompiler.DependencyAnalysis.ReadyToRun; + +// Ownership is collected before layout and reused when extracting the sidecar's data ranges. +internal sealed class WasmImportLayout +{ + private readonly List _imports = new(); + private readonly HashSet _primaryImports = new(); + private readonly HashSet _coldImports = new(); + private readonly HashSet _primarySignatures = new(); + private readonly HashSet _coldSignatures = new(); + + internal IEnumerable ColdImports => _coldImports; + internal IEnumerable ColdSignatures => _coldSignatures; + + internal void AddImport(Import import) + { + _imports.Add(import); + if (import.Table.IsEager) + _primaryImports.Add(import); + } + + internal void AddUse(ISymbolNode symbol, bool cold) + { + if (symbol is Import import) + (cold ? _coldImports : _primaryImports).Add(import); + else if (!cold && symbol is Signature) + _primarySignatures.Add(symbol); + } + + internal void Complete(Func deduplicate) + { + _coldImports.ExceptWith(_primaryImports); + HashSet primarySignatures = new(); + foreach (ISymbolNode signature in _primarySignatures) + primarySignatures.Add(deduplicate(signature)); + foreach (Import import in _imports) + { + bool cold = _coldImports.Contains(import); + import.SetPartition(cold); + if (!cold) + primarySignatures.Add(deduplicate(import.Signature)); + } + foreach (Import import in _coldImports) + { + ISymbolNode signature = deduplicate(import.Signature); + if (!primarySignatures.Contains(signature)) + _coldSignatures.Add(signature); + } + } + + internal IReadOnlyCollection OrderSignatureBlobs(IReadOnlyCollection nodes) + { + List ordered = new(nodes.Count); + List cold = new(); + foreach (DependencyNode node in nodes) + { + if (node is Signature signature && _coldSignatures.Contains(signature)) + cold.Add(node); + else + ordered.Add(node); + } + // Keep the existing deterministic order in each group. Only independent signature blobs move; + // ordered tables (which assign import indices before fixup-list emission) stay in place. + ordered.AddRange(cold); + return ordered; + } +} diff --git a/src/coreclr/tools/aot/ILCompiler.ReadyToRun/ILCompiler.ReadyToRun.csproj b/src/coreclr/tools/aot/ILCompiler.ReadyToRun/ILCompiler.ReadyToRun.csproj index 1a03247115785a..7d13f6028fe080 100644 --- a/src/coreclr/tools/aot/ILCompiler.ReadyToRun/ILCompiler.ReadyToRun.csproj +++ b/src/coreclr/tools/aot/ILCompiler.ReadyToRun/ILCompiler.ReadyToRun.csproj @@ -343,6 +343,7 @@ + diff --git a/src/native/libs/Common/JavaScript/host/auxiliary-modules.test.mjs b/src/native/libs/Common/JavaScript/host/auxiliary-modules.test.mjs index f0281afe1d7e9c..3f853ac5063615 100644 --- a/src/native/libs/Common/JavaScript/host/auxiliary-modules.test.mjs +++ b/src/native/libs/Common/JavaScript/host/auxiliary-modules.test.mjs @@ -105,6 +105,9 @@ async function until(predicate) { for (const first of ["app.entrypoints.wasm", "app.1.wasm"]) { test(`browser: independent activation, ${first} first`, async () => { const f = fixture(); + // A primary-owned import can already be resolved before either sidecar arrives. + const primaryImport = f.payloadPtr + 192; + f.view.setUint32(primaryImport, 0x12345678, true); const pending = new Map(); const completion = loadAuxiliaryModules(f.modules, f.memory, f.imports, name => new Promise(resolve => pending.set(name, resolve)), f.notify, error => f.errors.push(error)); @@ -114,13 +117,19 @@ for (const first of ["app.entrypoints.wasm", "app.1.wasm"]) { pending.get(first)(new Response(f.bytes[first])); await until(() => f.view.getUint32(f.payloadPtr + (first === "app.1.wasm" ? 28 : 0), true) === 1); assert.equal(f.view.getUint32(f.payloadPtr + (first === "app.1.wasm" ? 0 : 28), true), 0); + const resolvedImport = f.payloadPtr + (first === "app.1.wasm" ? 208 : 180); + assert.deepEqual([...new Uint8Array(f.memory.buffer, resolvedImport, 4)], [101, 102, 103, 104]); + f.view.setUint32(resolvedImport, 0x11223344, true); const second = first === "app.1.wasm" ? "app.entrypoints.wasm" : "app.1.wasm"; pending.get(second)(new Response(f.bytes[second])); await completion; assert.equal(f.notifications(), 1); assert.equal(f.table.get(4)(), 42); assert.equal(f.table.length, 12); - assert.deepEqual([...new Uint8Array(f.memory.buffer, f.payloadPtr + 208, 4)], [101, 102, 103, 104]); + assert.equal(f.view.getUint32(primaryImport, true), 0x12345678); + assert.equal(f.view.getUint32(resolvedImport, true), 0x11223344); + if (first === "app.entrypoints.wasm") + assert.deepEqual([...new Uint8Array(f.memory.buffer, f.payloadPtr + 208, 4)], [101, 102, 103, 104]); await loadAuxiliaryModules(f.modules, f.memory, f.imports, () => assert.fail("duplicate download"), f.notify, error => f.errors.push(error)); assert.equal(f.notifications(), 1); assert.deepEqual(f.errors, []); diff --git a/src/tests/readytorun/wasm/DelayedR2RActivation/DelayedR2RActivation.cs b/src/tests/readytorun/wasm/DelayedR2RActivation/DelayedR2RActivation.cs index 797979c50ec190..a0d360ce5568d2 100644 --- a/src/tests/readytorun/wasm/DelayedR2RActivation/DelayedR2RActivation.cs +++ b/src/tests/readytorun/wasm/DelayedR2RActivation/DelayedR2RActivation.cs @@ -163,8 +163,36 @@ private static void VerifyPartitionedCalls(int value, object marker) Assert.Same(marker, PartitionedExceptionTarget(marker)); Assert.Equal(5, CrossModuleGenericCaller()); Assert.Equal(value * 9 + 65, PartitionedCompiledCaller(value)); + Type[] primaryTypes = PartitionedPrimaryImports(); + Type[] coldTypes = PartitionedColdImports(); + Assert.Same(typeof(PartitionedBHot), primaryTypes[0]); + Assert.Same(typeof(PartitionedDHot), primaryTypes[1]); + Assert.Same(typeof(PartitionedShared), primaryTypes[2]); + Assert.Same(typeof(PartitionedACold), coldTypes[0]); + Assert.Same(typeof(PartitionedCCold), coldTypes[1]); + Assert.Same(typeof(PartitionedECold), coldTypes[2]); + Assert.Same(primaryTypes[2], coldTypes[3]); + Assert.Same(typeof(PartitionedShared), PartitionedImportGeneric()); } + [MethodImpl(MethodImplOptions.NoInlining)] + public static Type[] PartitionedPrimaryImports() => + new[] { typeof(PartitionedBHot), typeof(PartitionedDHot), typeof(PartitionedShared) }; + + [MethodImpl(MethodImplOptions.NoInlining | MethodImplOptions.NoOptimization)] + private static Type[] PartitionedColdImports() => + new[] { typeof(PartitionedACold), typeof(PartitionedCCold), typeof(PartitionedECold), typeof(PartitionedShared) }; + + [MethodImpl(MethodImplOptions.NoInlining)] + private static Type PartitionedImportGeneric() => typeof(T); + + private sealed class PartitionedACold { } + private sealed class PartitionedBHot { } + private sealed class PartitionedCCold { } + private sealed class PartitionedDHot { } + private sealed class PartitionedECold { } + private sealed class PartitionedShared { } + [MethodImpl(MethodImplOptions.NoInlining)] private static int PartitionedColdTarget(int value) => value * 7 + 13; From 1f95cb54bbff1ba899e157896a7e24b6350d2b3a Mon Sep 17 00:00:00 2001 From: Jackson Schuster <36744439+jtschuster@users.noreply.github.com> Date: Thu, 29 Oct 2026 16:27:39 -0700 Subject: [PATCH 19/19] Separate auxiliary worker preparation from resource loading Publish the auxiliary compiler as a standalone module worker, keep generic downloads Response-only, and preserve shared retry and throttle policy. Cover worker preparation and loader compatibility without diagnostic tracing. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 0797c2c0-9a42-4488-8454-7761c25a6e19 --- eng/testing/tests.browser.targets | 2 + src/coreclr/tools/aot/crossgen2/README.md | 30 +++ .../Directory.Build.props | 2 + .../libs/Common/JavaScript/CMakeLists.txt | 4 + .../libs/Common/JavaScript/host/assets.ts | 7 +- .../host/auxiliary-modules.test.mjs | 241 +++++++++++++++++- .../JavaScript/host/auxiliary-modules.ts | 17 +- .../libs/Common/JavaScript/loader/assets.ts | 129 +++++++--- .../loader/auxiliary-module-worker-client.ts | 76 ++++++ .../loader/auxiliary-module-worker.ts | 22 ++ .../libs/Common/JavaScript/loader/config.ts | 2 + .../Common/JavaScript/types/public-api.ts | 1 + src/native/rollup.config.js | 12 + .../AssetsComputingHelper.cs | 2 + .../BootJsonBuilderHelper.cs | 16 +- .../BootJsonData.cs | 7 + .../ComputeWasmPublishAssets.cs | 4 +- 17 files changed, 523 insertions(+), 51 deletions(-) create mode 100644 src/native/libs/Common/JavaScript/loader/auxiliary-module-worker-client.ts create mode 100644 src/native/libs/Common/JavaScript/loader/auxiliary-module-worker.ts diff --git a/eng/testing/tests.browser.targets b/eng/testing/tests.browser.targets index a2eae35f007daf..d396371b6bfb25 100644 --- a/eng/testing/tests.browser.targets +++ b/eng/testing/tests.browser.targets @@ -348,6 +348,8 @@ + diff --git a/src/coreclr/tools/aot/crossgen2/README.md b/src/coreclr/tools/aot/crossgen2/README.md index baf5227359847e..9df145bccc5d74 100644 --- a/src/coreclr/tools/aot/crossgen2/README.md +++ b/src/coreclr/tools/aot/crossgen2/README.md @@ -74,3 +74,33 @@ Cold metadata never overwrites a fixup cell that primary code can resolve. Per-module WebAssembly name sections and primary symbol maps are supported. Single-image map, PDB, perfmap and call-chain profile output is rejected because its file-relative addresses cannot describe multiple physical modules. + +## Experimental worker preparation + +Browser hosts can opt into background auxiliary download and compilation: + +```javascript +dotnet.withEnvironmentVariable("DOTNET_WasmAuxiliaryWorkerCompilation", "1"); +``` + +The worker receives the resolved resource URL and the existing fetch options, +including integrity, cache and credentials. Download retries and throttling still +use the resource loader. Custom resource providers remain on the main thread; +their response bodies are transferred to the worker for compilation. The worker +returns a compiled `WebAssembly.Module`, not an instance or runtime memory. +The main thread instantiates the compiled module after native image registration, +then runs `ActivateModule` and entrypoint notification immediately in the +instantiation continuation. It uses `WebAssembly.instantiate(module, imports)`, +because Chromium rejects the synchronous `Instance` constructor for modules +larger than 8 MB even when they are already compiled. This avoids starting +compilation on the page but does not guarantee same-turn instantiation on every +browser. + +This option is off by default and uses the separately published +`dotnet.auxiliary.worker.js` module worker. It requires compiled-module cloning +and transferable streams for custom responses. The worker script must be served +from the application's origin and allowed by Content Security Policy; Blob URLs +are not used. Failures are reported through the existing auxiliary-module error +path rather than silently switching modes. Workers terminate when their pending +operations settle. This does not allow activation during a long managed call: +the main thread must still reach a scheduling opportunity. diff --git a/src/installer/pkg/sfx/Microsoft.NETCore.App/Directory.Build.props b/src/installer/pkg/sfx/Microsoft.NETCore.App/Directory.Build.props index c12c12479224ae..89215fd909fa53 100644 --- a/src/installer/pkg/sfx/Microsoft.NETCore.App/Directory.Build.props +++ b/src/installer/pkg/sfx/Microsoft.NETCore.App/Directory.Build.props @@ -232,6 +232,8 @@ + + diff --git a/src/native/libs/Common/JavaScript/CMakeLists.txt b/src/native/libs/Common/JavaScript/CMakeLists.txt index f5f65db347fef2..fb0a75331e02b0 100644 --- a/src/native/libs/Common/JavaScript/CMakeLists.txt +++ b/src/native/libs/Common/JavaScript/CMakeLists.txt @@ -30,6 +30,8 @@ set(ROLLUP_TS_SOURCES "${CLR_SRC_NATIVE_DIR}/libs/Common/JavaScript/host/per-module.ts" "${CLR_SRC_NATIVE_DIR}/libs/Common/JavaScript/host/types.ts" "${CLR_SRC_NATIVE_DIR}/libs/Common/JavaScript/loader/assets.ts" + "${CLR_SRC_NATIVE_DIR}/libs/Common/JavaScript/loader/auxiliary-module-worker-client.ts" + "${CLR_SRC_NATIVE_DIR}/libs/Common/JavaScript/loader/auxiliary-module-worker.ts" "${CLR_SRC_NATIVE_DIR}/libs/Common/JavaScript/loader/bootstrap.ts" "${CLR_SRC_NATIVE_DIR}/libs/Common/JavaScript/loader/config.ts" "${CLR_SRC_NATIVE_DIR}/libs/Common/JavaScript/loader/cross-module.ts" @@ -123,6 +125,8 @@ set(ROLLUP_TS_SOURCES set(ROLLUP_OUTPUTS "${STATIC_LIB_DESTINATION}/dotnet.js" "${STATIC_LIB_DESTINATION}/dotnet.js.map" + "${STATIC_LIB_DESTINATION}/dotnet.auxiliary.worker.js" + "${STATIC_LIB_DESTINATION}/dotnet.auxiliary.worker.js.map" "${STATIC_LIB_DESTINATION}/dotnet.d.ts" "${STATIC_LIB_DESTINATION}/libSystem.Native.Browser.js" "${STATIC_LIB_DESTINATION}/libSystem.Native.Browser.js.map" diff --git a/src/native/libs/Common/JavaScript/host/assets.ts b/src/native/libs/Common/JavaScript/host/assets.ts index 58fee01e0c2450..a43d8b9297cad8 100644 --- a/src/native/libs/Common/JavaScript/host/assets.ts +++ b/src/native/libs/Common/JavaScript/host/assets.ts @@ -6,6 +6,7 @@ import { _ems_ } from "../ems-ambient"; import { browserVirtualAppBase, sizeOfPtr } from "../per-module"; import { loadAuxiliaryModules, readAuxiliaryModuleMetadata } from "./auxiliary-modules"; +import type { AuxiliaryModuleSource } from "./auxiliary-modules"; const hasInstantiateStreaming = typeof WebAssembly !== "undefined" && typeof WebAssembly.instantiateStreaming === "function"; const loadedAssemblies: Map = new Map(); @@ -53,7 +54,7 @@ export function registerDllBytes(bytes: Uint8Array, virtualPath: string, shortNa } } -export async function instantiateWebcilModule(webcilPromise: Promise, memory: WebAssembly.Memory, virtualPath: string, tableSize?: number, payloadSize?: number, loadAuxiliaryModule?: (name: string) => Promise): Promise { +export async function instantiateWebcilModule(webcilPromise: Promise, memory: WebAssembly.Memory, virtualPath: string, tableSize?: number, payloadSize?: number, prepareAuxiliaryModule?: (name: string) => Promise): Promise { // Boot-config sizes let us reserve the payload and table ranges before streaming instantiation. // Assets without a tableSize are IL-only images. if (typeof payloadSize !== "number" || payloadSize === 0) { @@ -76,13 +77,13 @@ export async function instantiateWebcilModule(webcilPromise: Promise, instance = (await WebAssembly.instantiate(data, imports)).instance; } const modules = readAuxiliaryModuleMetadata(instance, memory, payloadPtr, payloadSize, tableEntries); - if (modules.length !== 0 && !loadAuxiliaryModule) { + if (modules.length !== 0 && !prepareAuxiliaryModule) { throw new Error(`Webcil asset '${virtualPath}' has auxiliary modules but no auxiliary resource loader.`); } finishWebcilInstance(instance, payloadPtr, payloadSize, tableEntries, virtualPath); if (modules.length !== 0) { pendingAuxiliaryModules.set(payloadPtr, () => { - auxiliaryModuleLoads.push(loadAuxiliaryModules(modules, memory, imports, loadAuxiliaryModule!, () => { + auxiliaryModuleLoads.push(loadAuxiliaryModules(modules, memory, imports, prepareAuxiliaryModule!, () => { const notify = _ems_.wasmExports.dotnet_wasm_notify_auxiliary_entrypoints_activated; if (typeof notify !== "function") throw new Error("Runtime does not support auxiliary R2R modules."); notify(); diff --git a/src/native/libs/Common/JavaScript/host/auxiliary-modules.test.mjs b/src/native/libs/Common/JavaScript/host/auxiliary-modules.test.mjs index f0281afe1d7e9c..10d37c1bfcda5c 100644 --- a/src/native/libs/Common/JavaScript/host/auxiliary-modules.test.mjs +++ b/src/native/libs/Common/JavaScript/host/auxiliary-modules.test.mjs @@ -12,6 +12,11 @@ const ts = require(process.env.TYPESCRIPT_PATH || "typescript"); const source = readFileSync(new URL("./auxiliary-modules.ts", import.meta.url), "utf8"); const javascript = ts.transpileModule(source, { compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.ES2022 } }).outputText; const { readAuxiliaryModuleMetadata, loadAuxiliaryModules } = await import(`data:text/javascript;base64,${Buffer.from(javascript).toString("base64")}`); +const workerClientSource = readFileSync(new URL("../loader/auxiliary-module-worker-client.ts", import.meta.url), "utf8"); +const workerClientJavascript = ts.transpileModule(workerClientSource, { compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.ES2022 } }).outputText; +const { fetchAndCompileAuxiliaryModuleInWorker, compileAuxiliaryResponseInWorker } = await import(`data:text/javascript;base64,${Buffer.from(workerClientJavascript).toString("base64")}`); +const workerSource = readFileSync(new URL("../loader/auxiliary-module-worker.ts", import.meta.url), "utf8"); +const workerJavascript = ts.transpileModule(workerSource, { compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.CommonJS } }).outputText; const corerunSource = readFileSync(new URL("../../../../../coreclr/hosts/corerun/wasm/libCorerun.js", import.meta.url), "utf8"); test("browser: auxiliary boot resources survive configuration merges", () => { @@ -21,10 +26,12 @@ test("browser: auxiliary boot resources survive configuration merges", () => { vm.runInNewContext(javascript, context); const first = { name: "app.entrypoints.hash.wasm", virtualPath: "app.entrypoints.wasm" }; const second = { name: "app.1.hash.wasm", virtualPath: "app.1.wasm" }; - context.exports.mergeLoaderConfig({ resources: { auxiliaryAssembly: [first] } }); + const worker = { name: "dotnet.auxiliary.worker.hash.js" }; + context.exports.mergeLoaderConfig({ resources: { auxiliaryAssembly: [first], jsModuleAuxiliaryWorker: [worker] } }); context.exports.mergeLoaderConfig({ environmentVariables: { CUSTOM: "1" } }); context.exports.mergeLoaderConfig({ resources: { auxiliaryAssembly: [second] } }); assert.deepEqual([...context.exports.loaderConfig.resources.auxiliaryAssembly], [first, second]); + assert.deepEqual([...context.exports.loaderConfig.resources.jsModuleAuxiliaryWorker], [worker]); }); function uleb(value) { @@ -131,6 +138,238 @@ for (const first of ["app.entrypoints.wasm", "app.1.wasm"]) { }); } +for (const first of ["app.entrypoints.wasm", "app.1.wasm"]) { + test(`browser: prepared-module overload activates in its continuation, ${first} first`, async () => { + const f = fixture(); + const pending = new Map(); + const completion = loadAuxiliaryModules(f.modules, f.memory, f.imports, + name => new Promise(resolve => pending.set(name, resolve)), f.notify, error => f.errors.push(error)); + const originalInstantiate = WebAssembly.instantiate; + const observedStatuses = []; + WebAssembly.instantiate = (module, imports) => { + assert(module instanceof WebAssembly.Module); + const instance = new WebAssembly.Instance(module, imports); + queueMicrotask(() => queueMicrotask(() => observedStatuses.push([ + f.view.getUint32(f.payloadPtr, true), f.view.getUint32(f.payloadPtr + 28, true), + ]))); + return Promise.resolve(instance); + }; + try { + const second = first === "app.1.wasm" ? "app.entrypoints.wasm" : "app.1.wasm"; + const prepared = name => new WebAssembly.Module(f.bytes[name]); + pending.get(first)(prepared(first)); + await until(() => observedStatuses.length === 1 || f.errors.length !== 0); + assert.deepEqual(f.errors, []); + assert.deepEqual(observedStatuses, [first === "app.1.wasm" ? [0, 1] : [1, 0]]); + pending.get(second)(prepared(second)); + await completion; + assert.equal(f.notifications(), 1); + assert.equal(f.table.get(4)(), 42); + assert.deepEqual(observedStatuses.at(-1), [1, 1]); + } finally { + WebAssembly.instantiate = originalInstantiate; + } + }); +} + +test("browser: worker preparation preserves requests, custom bodies, failures and cleanup", async () => { + const originalWorker = globalThis.Worker; + const originalDocument = globalThis.document; + const originalLocation = globalThis.location; + const workers = []; + const requests = []; + const options = { cache: "no-cache", credentials: "include", integrity: "sha256-example" }; + const workerUrl = "https://example.test/app/dotnet.auxiliary.worker.hash.js"; + const bytes = auxiliaryWasm(0, false); + globalThis.document = { baseURI: "https://example.test/app/" }; + globalThis.Worker = class { + constructor(url, options) { + assert.equal(url, workerUrl); + assert.deepEqual(options, { type: "module", name: "dotnet-auxiliary-compiler" }); + workers.push(this); + this.ready = Promise.resolve().then(() => { + const context = { + exports: {}, WebAssembly, Response, Error, + fetch: async (url, options) => { + requests.push({ url, options }); + if (url.endsWith("missing.wasm")) return new Response(null, { status: 404 }); + return new Response(url.endsWith("invalid.wasm") ? new Uint8Array([0]) : bytes, + { headers: { "Content-Type": "application/wasm" } }); + }, + postMessage: result => { + if (!this.terminated) this.onmessage({ data: result }); + }, + }; + vm.runInNewContext(workerJavascript, context); + return context; + }); + } + postMessage(request, transfer) { + this.transfer = transfer; + void this.ready.then(context => { + if (!this.terminated) return context.onmessage({ data: request }); + }); + } + terminate() { + this.terminated = true; + } + }; + try { + const [prepared, missing] = await Promise.all([ + fetchAndCompileAuxiliaryModuleInWorker(workerUrl, "app.wasm", options), + fetchAndCompileAuxiliaryModuleInWorker(workerUrl, "missing.wasm", {}), + ]); + assert(prepared.module instanceof WebAssembly.Module); + assert.equal(prepared.ok, true); + assert.equal(missing.status, 404); + assert.equal(workers.length, 1); + assert.equal(workers[0].terminated, true); + assert.deepEqual(requests[0], { url: "https://example.test/app/app.wasm", options }); + await assert.rejects(fetchAndCompileAuxiliaryModuleInWorker(workerUrl, "invalid.wasm", {}), WebAssembly.CompileError); + assert.equal(workers[1].terminated, true); + const custom = await compileAuxiliaryResponseInWorker(workerUrl, new Response(bytes)); + assert(custom.module instanceof WebAssembly.Module); + assert.equal(requests.length, 3); + assert.equal(workers[2].transfer.length, 1); + assert.equal(workers[2].terminated, true); + await assert.rejects(compileAuxiliaryResponseInWorker(workerUrl, new Response(null)), /no body/); + const crashed = fetchAndCompileAuxiliaryModuleInWorker(workerUrl, "app.wasm", {}); + const deserializeFailed = fetchAndCompileAuxiliaryModuleInWorker(workerUrl, "app.wasm", {}); + workers[3].onerror({ message: "worker crashed", preventDefault() {} }); + await assert.rejects(crashed, /worker crashed/); + await assert.rejects(deserializeFailed, /worker crashed/); + assert.equal(workers[3].terminated, true); + delete globalThis.document; + globalThis.location = { href: "https://example.test/app/worker.js" }; + const workerHosted = await fetchAndCompileAuxiliaryModuleInWorker(workerUrl, "app.wasm", {}); + assert(workerHosted.module instanceof WebAssembly.Module); + assert.equal(requests.at(-1).url, "https://example.test/app/app.wasm"); + assert.equal(workers[4].terminated, true); + } finally { + if (originalWorker === undefined) delete globalThis.Worker; + else globalThis.Worker = originalWorker; + if (originalDocument === undefined) delete globalThis.document; + else globalThis.document = originalDocument; + if (originalLocation === undefined) delete globalThis.location; + else globalThis.location = originalLocation; + } +}); + +for (const provider of ["network", "custom", "preloaded", "pending", "redirect", "default", "disabled", "retry", "compile-error"]) { + test(`browser: auxiliary preparation preserves resource policy, ${provider}`, async () => { + const bytes = auxiliaryWasm(0, false); + const compiled = new WebAssembly.Module(bytes); + const asset = { name: "app.1.hash.wasm", virtualPath: "app.1.wasm", hash: "sha256-example", cache: "reload" }; + if (provider === "preloaded") asset.buffer = bytes.buffer; + if (provider === "pending") asset.pendingDownload = { response: Promise.resolve(new Response(bytes)) }; + const config = { + maxParallelDownloads: 16, + enableDownloadRetry: true, + environmentVariables: { DOTNET_WasmAuxiliaryWorkerCompilation: provider === "disabled" ? "0" : "1" }, + resources: { + auxiliaryAssembly: [asset], + jsModuleAuxiliaryWorker: [{ name: "dotnet.auxiliary.worker.hash.js" }], + }, + }; + const fetched = []; + const prepared = []; + const compiledResponses = []; + const createPromiseCompletionSource = () => { + let resolve, reject; + const promise = new Promise((a, b) => { + resolve = a; reject = b; + }); + return { promise, resolve, reject }; + }; + const dependencies = { + "./cross-module": { + dotnetAssert: { check: (condition, message) => assert(condition, message) }, + dotnetLogger: { debug() {} }, + }, + "./per-module": { ENVIRONMENT_IS_WEB: true }, + "./promise-completion-source": { createPromiseCompletionSource, delay: () => Promise.resolve() }, + "./bootstrap": { + locateFile: name => `https://example.test/app/${name}`, + makeURLAbsoluteWithApplicationBase: name => new URL(name, "https://example.test/").href, + }, + "./polyfills": { + fetchLike: async (url, options) => { + fetched.push({ url, options }); + return new Response(bytes); + }, + responseLike: (_url, body, options) => new Response(body, options), + }, + "./config": { loaderConfig: config }, + "./callbacks": {}, + "./auxiliary-module-worker-client": { + fetchAndCompileAuxiliaryModuleInWorker: async (workerUrl, url, options) => { + prepared.push({ workerUrl, url, options }); + if (provider === "compile-error") throw new WebAssembly.CompileError("Invalid auxiliary body"); + if (provider === "retry" && prepared.length === 1) return new Response(null, { status: 500 }); + return { ok: true, status: 200, statusText: "OK", module: compiled }; + }, + compileAuxiliaryResponseInWorker: async (workerUrl, response) => { + assert.equal(workerUrl, "https://example.test/app/dotnet.auxiliary.worker.hash.js"); + compiledResponses.push(await response.arrayBuffer()); + return { ok: true, status: 200, statusText: "OK", module: compiled }; + }, + }, + }; + const loaderSource = readFileSync(new URL("../loader/assets.ts", import.meta.url), "utf8") + + "\nexport { prepareAuxiliaryModule, loadResource };\nexport const activeDownloads = () => currentParallelDownloads;\n"; + const loaderJavascript = ts.transpileModule(loaderSource, + { compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.CommonJS } }).outputText; + const context = { + exports: {}, WebAssembly, Error, + require: name => { + assert(name in dependencies, `Unexpected loader dependency '${name}'`); + return dependencies[name]; + }, + }; + vm.runInNewContext(loaderJavascript, context); + context.exports.resolveAllDownloadsQueued(); + context.exports.setLoadBootResourceCallback((type, name) => { + if (name.endsWith(".js")) return undefined; + if (provider === "custom") return Promise.resolve(new Response(bytes)); + if (provider === "redirect") return "cdn/app.wasm"; + return null; + }); + if (provider === "compile-error") { + await assert.rejects(context.exports.prepareAuxiliaryModule(asset.virtualPath), WebAssembly.CompileError); + assert.equal(prepared.length, 1); + } else if (provider === "default") { + const response = await context.exports.loadResource({ ...asset, behavior: "webcil", resolvedUrl: "https://example.test/app/app.1.hash.wasm" }); + assert(response instanceof Response); + assert.equal(prepared.length, 0); + assert.equal(fetched.length, 1); + } else if (provider === "disabled") { + const result = await context.exports.prepareAuxiliaryModule(asset.virtualPath); + assert(result instanceof Response); + assert.equal(prepared.length, 0); + assert.equal(fetched.length, 1); + } else { + const result = await context.exports.prepareAuxiliaryModule(asset.virtualPath); + assert.equal(result, compiled); + assert.equal(fetched.length, 0); + if (["custom", "preloaded", "pending"].includes(provider)) { + assert.equal(prepared.length, 0); + assert.equal(compiledResponses.length, 1); + assert.deepEqual(new Uint8Array(compiledResponses[0]), bytes); + } else { + assert.equal(prepared.length, provider === "retry" ? 2 : 1); + const request = prepared.at(-1); + assert.equal(request.workerUrl, "https://example.test/app/dotnet.auxiliary.worker.hash.js"); + assert.equal(request.url, provider === "redirect" ? "https://example.test/cdn/app.wasm" : "https://example.test/app/app.1.hash.wasm"); + assert.deepEqual({ ...request.options }, { cache: "reload", integrity: asset.hash }); + } + } + if (provider !== "default") { + assert.equal(context.exports.activeDownloads(), 0); + assert.equal(asset.inprogress, false); + } + }); +} + test("browser: all-hot image activates an empty auxiliary body range", async () => { const f = fixture(); f.view.setUint32(f.payloadPtr + 4, 5, true); diff --git a/src/native/libs/Common/JavaScript/host/auxiliary-modules.ts b/src/native/libs/Common/JavaScript/host/auxiliary-modules.ts index b3e9255cfe4c7b..2a30aa992f4292 100644 --- a/src/native/libs/Common/JavaScript/host/auxiliary-modules.ts +++ b/src/native/libs/Common/JavaScript/host/auxiliary-modules.ts @@ -2,6 +2,7 @@ // The .NET Foundation licenses this file to you under the MIT license. type AuxiliaryModule = { name: string, statusAddress: number, entrypoints: boolean }; +export type AuxiliaryModuleSource = Response | WebAssembly.Module; export function readAuxiliaryModuleMetadata(instance: WebAssembly.Instance, memory: WebAssembly.Memory, payloadPtr: number, payloadSize: number, tableSize: number): AuxiliaryModule[] { const getMetadata = instance.exports.getAuxiliaryModuleMetadata; @@ -64,17 +65,23 @@ export function readAuxiliaryModuleMetadata(instance: WebAssembly.Instance, memo // Each operation settles independently: EntryPoints must not wait for a cold body (or vice versa). // Settled errors are retained for test/host waiters and reported immediately, without unhandled rejections. -export function loadAuxiliaryModules(modules: AuxiliaryModule[], memory: WebAssembly.Memory, imports: WebAssembly.Imports, load: (name: string) => Promise, notifyEntryPoints: () => void, reportError: (error: Error) => void): Promise { +export function loadAuxiliaryModules(modules: AuxiliaryModule[], memory: WebAssembly.Memory, imports: WebAssembly.Imports, prepare: (name: string) => Promise, notifyEntryPoints: () => void, reportError: (error: Error) => void): Promise { const operations = modules.map(async module => { try { if (new DataView(memory.buffer).getUint32(module.statusAddress, true) === 1) { return; } - const response = await load(module.name); - if (!response.ok) { - throw new Error(`HTTP status: ${response.status} ${response.statusText}`); + const source = await prepare(module.name); + let instance: WebAssembly.Instance; + if (source instanceof WebAssembly.Module) { + // Chromium limits the synchronous constructor to modules below 8 MB, even when compiled. + instance = await WebAssembly.instantiate(source, imports); + } else { + if (!source.ok) { + throw new Error(`HTTP status: ${source.status} ${source.statusText}`); + } + instance = (await WebAssembly.instantiate(await source.arrayBuffer(), imports)).instance; } - const { instance } = await WebAssembly.instantiate(await response.arrayBuffer(), imports); const activate = instance.exports.ActivateModule; if (typeof activate !== "function") { throw new Error("Missing ActivateModule export."); diff --git a/src/native/libs/Common/JavaScript/loader/assets.ts b/src/native/libs/Common/JavaScript/loader/assets.ts index 9e6d73788a7585..37af0afe05ae6e 100644 --- a/src/native/libs/Common/JavaScript/loader/assets.ts +++ b/src/native/libs/Common/JavaScript/loader/assets.ts @@ -10,6 +10,11 @@ import { locateFile, makeURLAbsoluteWithApplicationBase } from "./bootstrap"; import { fetchLike, responseLike } from "./polyfills"; import { loaderConfig } from "./config"; import { loaderCallbacks } from "./callbacks"; +import type { AuxiliaryModuleSource } from "../host/auxiliary-modules"; +import { fetchAndCompileAuxiliaryModuleInWorker, compileAuxiliaryResponseInWorker } from "./auxiliary-module-worker-client"; + +type ResourceStatus = Pick; +type ResourceRequest = { response: Response } | { url: string, options: RequestInit, contentType: string }; let throttlingPCS: PromiseCompletionSource | undefined; let currentParallelDownloads = 0; @@ -50,26 +55,30 @@ export async function loadJSModule(asset: JsAsset): Promise { } totalAssetsToDownload++; if (!mod) { - if (assetInternal.name && !asset.resolvedUrl) { - asset.resolvedUrl = locateFile(assetInternal.name, true); - } - assetInternal.behavior = "js-module-dotnet"; - if (typeof loadBootResourceCallback === "function") { - const blazorType = behaviorToBlazorAssetTypeMap[assetInternal.behavior]; - dotnetAssert.check(blazorType, `Unsupported asset behavior: ${assetInternal.behavior}`); - const customLoadResult = loadBootResourceCallback(blazorType, assetInternal.name, asset.resolvedUrl!, assetInternal.hash ?? "", assetInternal.behavior); - dotnetAssert.check(typeof customLoadResult === "string", "loadBootResourceCallback for JS modules must return string URL"); - asset.resolvedUrl = makeURLAbsoluteWithApplicationBase(customLoadResult); - } - - if (!asset.resolvedUrl) throw new Error("Invalid config, resources is not set"); - mod = await import(/* webpackIgnore: true */ asset.resolvedUrl); + const url = resolveJSModuleUrl(assetInternal); + mod = await import(/* webpackIgnore: true */ url); asset.moduleExports = mod; } onDownloadedAsset(assetInternal); return mod; } +function resolveJSModuleUrl(asset: AssetEntryInternal): string { + asset.resolvedUrl ??= locateFile(asset.name, true); + asset.behavior = "js-module-dotnet"; + if (typeof loadBootResourceCallback === "function") { + const blazorType = behaviorToBlazorAssetTypeMap[asset.behavior]; + dotnetAssert.check(blazorType, `Unsupported asset behavior: ${asset.behavior}`); + const customLoadResult = loadBootResourceCallback(blazorType, asset.name, asset.resolvedUrl, asset.hash ?? "", asset.behavior); + if (customLoadResult !== undefined && customLoadResult !== null) { + dotnetAssert.check(typeof customLoadResult === "string", "loadBootResourceCallback for JS modules must return string URL"); + asset.resolvedUrl = makeURLAbsoluteWithApplicationBase(customLoadResult); + } + } + if (!asset.resolvedUrl) throw new Error("Invalid config, resources is not set"); + return asset.resolvedUrl; +} + export async function callLibraryInitializerOnRuntimeConfigLoaded(asset: JsAsset): Promise { const module = await loadJSModule(asset); const name = asset.name || asset.resolvedUrl || "unknown"; @@ -177,14 +186,14 @@ async function fetchWebcil(assetInternal: AssetEntryInternal): Promise { const webcilPromise = loadResource(assetInternal); const memory = await wasmMemoryPromiseController.promise; - const instancePromise = dotnetBrowserHostExports.instantiateWebcilModule(webcilPromise, memory, assetInternal.virtualPath!, assetInternal.tableSize, assetInternal.payloadSize, loadAuxiliaryModule); + const instancePromise = dotnetBrowserHostExports.instantiateWebcilModule(webcilPromise, memory, assetInternal.virtualPath!, assetInternal.tableSize, assetInternal.payloadSize, prepareAuxiliaryModule); await instancePromise; } finally { onDownloadedAsset(assetInternal); } } -async function loadAuxiliaryModule(name: string): Promise { +async function prepareAuxiliaryModule(name: string): Promise { const asset = loaderConfig.resources?.auxiliaryAssembly?.find(asset => asset.virtualPath === name); if (!asset) { throw new Error(`Auxiliary R2R module '${name}' is missing from boot resources.`); @@ -194,7 +203,23 @@ async function loadAuxiliaryModule(name: string): Promise { assetInternal.resolvedUrl ??= locateFile(assetInternal.name); // These are background resources and must not enter the startup download barrier/counters. try { - return await loadResource(assetInternal); + if (!ENVIRONMENT_IS_WEB || loaderConfig.environmentVariables?.["DOTNET_WasmAuxiliaryWorkerCompilation"] !== "1") { + return await loadResource(assetInternal); + } + const workers = loaderConfig.resources?.jsModuleAuxiliaryWorker; + if (!workers || workers.length !== 1) { + throw new Error("Auxiliary R2R compilation requires one worker script in boot resources."); + } + const workerUrl = resolveJSModuleUrl(workers[0] as AssetEntryInternal); + const result = await withResourceDownloadPolicy(assetInternal, async () => { + const request = await resolveResourceRequest(assetInternal); + if ("response" in request) { + if (!request.response.ok) return request.response; + return compileAuxiliaryResponseInWorker(workerUrl, request.response); + } + return fetchAndCompileAuxiliaryModuleInWorker(workerUrl, request.url, request.options); + }); + return "module" in result ? result.module : result; } finally { if (assetInternal.inprogress) finishThrottling(assetInternal); assetInternal.buffer = null!; @@ -404,22 +429,39 @@ async function fetchText(asset: AssetEntryInternal): Promise { return response.text(); } +function isLocalResource(asset: AssetEntryInternal): boolean { + return ENVIRONMENT_IS_SHELL || ENVIRONMENT_IS_NODE || !!asset.resolvedUrl?.startsWith("file://"); +} + function loadResource(asset: AssetEntryInternal): Promise { - if (ENVIRONMENT_IS_SHELL || ENVIRONMENT_IS_NODE || asset.resolvedUrl && asset.resolvedUrl.indexOf("file://") !== -1) { - // no need to retry or throttle local file access + if (isLocalResource(asset)) { return loadResourceFetch(asset); } + return withResourceDownloadPolicy(asset, async () => { + const response = await loadResourceFetch(asset); + if (!noBuffer[asset.behavior]) { + asset.buffer = await response.arrayBuffer(); + } + return response; + }); +} + +function withResourceDownloadPolicy(asset: AssetEntryInternal, download: () => Promise): Promise { + if (isLocalResource(asset)) { + // no need to retry or throttle local file access + return download(); + } if (!loaderConfig.enableDownloadRetry || noRetry[asset.behavior]) { // only throttle, no retry - return loadResourceThrottle(asset); + return loadResourceThrottle(asset, download); } // retry and throttle - return loadResourceRetry(asset); + return loadResourceRetry(asset, download); } const noRetryStatusCodes = new Set([400, 401, 403, 404, 405, 406, 409, 410, 411, 413, 414, 415, 422, 426, 501, 505,]); -async function loadResourceRetry(asset: AssetEntryInternal): Promise { - let response: Response; +async function loadResourceRetry(asset: AssetEntryInternal, download: () => Promise): Promise { + let response: T | Response; response = await loadResourceAttempt(); if (response.ok || asset.isOptional || noRetryStatusCodes.has(response.status)) { return response; @@ -443,10 +485,10 @@ async function loadResourceRetry(asset: AssetEntryInternal): Promise { } throw new Error(`Failed to load resource '${asset.name}' from '${asset.resolvedUrl}' after multiple attempts. Last HTTP status: ${response.status} ${response.statusText}`); - async function loadResourceAttempt(): Promise { - let response: Response; + async function loadResourceAttempt(): Promise { + let response: T | Response; try { - response = await loadResourceThrottle(asset); + response = await loadResourceThrottle(asset, download); if (!response) { response = responseLike(asset.resolvedUrl!, null, { status: 404, @@ -454,6 +496,7 @@ async function loadResourceRetry(asset: AssetEntryInternal): Promise { }); } } catch (err: any) { + if (err instanceof WebAssembly.CompileError) throw err; response = responseLike(asset.resolvedUrl!, null, { status: 500, statusText: err.message || "Exception during fetch", @@ -465,21 +508,17 @@ async function loadResourceRetry(asset: AssetEntryInternal): Promise { // in order to prevent net::ERR_INSUFFICIENT_RESOURCES if we start downloading too many files at same time on a device with low resources // also when downloading in background -async function loadResourceThrottle(asset: AssetEntryInternal): Promise { +async function loadResourceThrottle(asset: AssetEntryInternal, download: () => Promise): Promise { while (throttlingPCS && !asset.priority) { await throttlingPCS.promise; } try { startThrottling(asset); - const responsePromise = loadResourceFetch(asset); - const response = await responsePromise; + const response = await download(); dotnetAssert.check(response, "Bad response in loadResourceThrottle"); - if (!noBuffer[asset.behavior]) { - asset.buffer = await response.arrayBuffer(); - } - if (!leaveAfterInstantiation[asset.behavior]) { + if (!response.ok || !leaveAfterInstantiation[asset.behavior]) { finishThrottling(asset); } return response; @@ -511,21 +550,28 @@ function finishThrottling(asset: AssetEntryInternal) { } async function loadResourceFetch(asset: AssetEntryInternal): Promise { + const request = await resolveResourceRequest(asset); + if ("response" in request) return request.response; + dotnetLogger.debug(`Attempting to download '${sanitizeUrl(request.url)}'`); + return fetchLike(request.url, request.options, request.contentType); +} + +async function resolveResourceRequest(asset: AssetEntryInternal): Promise { const expectedContentType = behaviorToContentTypeMap[asset.behavior]; dotnetAssert.check(expectedContentType, `Unsupported asset behavior: ${asset.behavior}`); if (asset.buffer) { const arrayBuffer = await asset.buffer; - return responseLike(asset.resolvedUrl!, arrayBuffer, { + return { response: responseLike(asset.resolvedUrl!, arrayBuffer, { status: 200, statusText: "OK", headers: { "Content-Length": arrayBuffer.byteLength.toString(), "Content-Type": expectedContentType, } - }); + }) }; } if (asset.pendingDownload) { - return asset.pendingDownload.response; + return { response: await asset.pendingDownload.response }; } if (typeof loadBootResourceCallback === "function") { const blazorType = behaviorToBlazorAssetTypeMap[asset.behavior]; @@ -533,8 +579,12 @@ async function loadResourceFetch(asset: AssetEntryInternal): Promise { const customLoadResult = loadBootResourceCallback(blazorType, asset.name, asset.resolvedUrl!, asset.hash ?? "", asset.behavior); if (typeof customLoadResult === "string") { asset.resolvedUrl = makeURLAbsoluteWithApplicationBase(customLoadResult); - } else if (typeof customLoadResult === "object") { - return customLoadResult as any; + } else if (customLoadResult !== null && typeof customLoadResult === "object") { + const response = await customLoadResult; + if (!("arrayBuffer" in response)) { + throw new Error(`Custom loader for '${asset.name}' must return a Response.`); + } + return { response }; } } dotnetAssert.check(asset.resolvedUrl, "Bad asset.resolvedUrl"); @@ -560,8 +610,7 @@ async function loadResourceFetch(asset: AssetEntryInternal): Promise { } } - dotnetLogger.debug(`Attempting to download '${sanitizeUrl(asset.resolvedUrl!)}'`); - return fetchLike(asset.resolvedUrl!, fetchOptions, expectedContentType); + return { url: asset.resolvedUrl!, options: fetchOptions, contentType: expectedContentType }; } function onDownloadedAsset(asset: AssetEntryInternal): void { diff --git a/src/native/libs/Common/JavaScript/loader/auxiliary-module-worker-client.ts b/src/native/libs/Common/JavaScript/loader/auxiliary-module-worker-client.ts new file mode 100644 index 00000000000000..2425319ff7063a --- /dev/null +++ b/src/native/libs/Common/JavaScript/loader/auxiliary-module-worker-client.ts @@ -0,0 +1,76 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +export type AuxiliaryWorkerRequest = { id: number, url: string, options?: RequestInit, body?: ReadableStream, contentType?: string }; +export type AuxiliaryWorkerResult = { id: number, status: number, statusText: string, module?: WebAssembly.Module, error?: string, errorName?: string }; +type CompiledAuxiliaryDownload = { ok: true, status: number, statusText: string, module: WebAssembly.Module }; +type PendingCompilation = { resolve: (value: Response | CompiledAuxiliaryDownload) => void, reject: (error: Error) => void }; +const pending = new Map(); +let compiler: Worker | undefined; +let nextId = 0; + +export function fetchAndCompileAuxiliaryModuleInWorker(workerUrl: string, url: string, options: RequestInit): Promise { + const baseUrl = globalThis.document?.baseURI ?? globalThis.location.href; + return prepareModule(workerUrl, { id: nextId++, url: new URL(url, baseUrl).href, options }); +} + +export async function compileAuxiliaryResponseInWorker(workerUrl: string, response: Response): Promise { + if (!response.body) throw new Error("Auxiliary R2R module response has no body."); + return prepareModule(workerUrl, { id: nextId++, url: response.url, body: response.body, + contentType: response.headers.get("Content-Type") ?? "" }); +} + +function stopCompiler(): void { + compiler?.terminate(); + compiler = undefined; +} + +function failCompilations(error: Error): void { + for (const operation of pending.values()) operation.reject(error); + pending.clear(); + stopCompiler(); +} + +async function prepareModule(workerUrl: string, request: AuxiliaryWorkerRequest): Promise { + if (!compiler) { + try { + compiler = new Worker(workerUrl, { type: "module", name: "dotnet-auxiliary-compiler" }); + } catch (error) { + stopCompiler(); + throw error; + } + compiler.onerror = event => { + event.preventDefault(); + failCompilations(new Error(`Auxiliary compilation worker failed: ${event.message}`)); + }; + compiler.onmessageerror = () => failCompilations(new Error("Cannot deserialize auxiliary compilation worker result.")); + compiler.onmessage = (event: MessageEvent) => { + const result = event.data; + const operation = pending.get(result.id); + if (!operation) { + failCompilations(new Error("Unexpected auxiliary compilation worker result.")); + return; + } + pending.delete(result.id); + if (result.error !== undefined) { + operation.reject(result.errorName === "CompileError" + ? new WebAssembly.CompileError(result.error) : new Error(result.error)); + } else if (result.module) { + operation.resolve({ ok: true, status: result.status, statusText: result.statusText, module: result.module }); + } else { + operation.resolve(new Response(null, { status: result.status, statusText: result.statusText })); + } + if (pending.size === 0) stopCompiler(); + }; + } + return new Promise((resolve, reject) => { + pending.set(request.id, { resolve, reject }); + try { + compiler!.postMessage(request, request.body ? [request.body] : []); + } catch (error) { + pending.delete(request.id); + if (pending.size === 0) stopCompiler(); + reject(error); + } + }); +} diff --git a/src/native/libs/Common/JavaScript/loader/auxiliary-module-worker.ts b/src/native/libs/Common/JavaScript/loader/auxiliary-module-worker.ts new file mode 100644 index 00000000000000..d0207bb10def64 --- /dev/null +++ b/src/native/libs/Common/JavaScript/loader/auxiliary-module-worker.ts @@ -0,0 +1,22 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +import type { AuxiliaryWorkerRequest } from "./auxiliary-module-worker-client"; + +globalThis.onmessage = async (event: MessageEvent) => { + const { id, url, options, body, contentType } = event.data; + try { + const response = body ? new Response(body, { headers: { "Content-Type": contentType! } }) : await fetch(url, options); + if (!response.ok) { + globalThis.postMessage({ id, status: response.status, statusText: response.statusText }); + return; + } + const module = response.headers.get("Content-Type") === "application/wasm" && typeof WebAssembly.compileStreaming === "function" + ? await WebAssembly.compileStreaming(response) + : await WebAssembly.compile(await response.arrayBuffer()); + globalThis.postMessage({ id, status: response.status, statusText: response.statusText, module }); + } catch (error) { + globalThis.postMessage({ id, error: error instanceof Error ? error.message : String(error), + errorName: error instanceof Error ? error.name : "Error" }); + } +}; diff --git a/src/native/libs/Common/JavaScript/loader/config.ts b/src/native/libs/Common/JavaScript/loader/config.ts index 9459ad325998dd..46c7e6376fceb5 100644 --- a/src/native/libs/Common/JavaScript/loader/config.ts +++ b/src/native/libs/Common/JavaScript/loader/config.ts @@ -68,6 +68,7 @@ function mergeResources(target: Assets, source: Assets): Assets { source.corePdb = [...target.corePdb!, ...source.corePdb || []]; source.pdb = [...target.pdb!, ...source.pdb || []]; source.jsModuleNative = [...target.jsModuleNative!, ...source.jsModuleNative || []]; + source.jsModuleAuxiliaryWorker = [...target.jsModuleAuxiliaryWorker!, ...source.jsModuleAuxiliaryWorker || []]; source.jsModuleDiagnostics = [...target.jsModuleDiagnostics!, ...source.jsModuleDiagnostics || []]; source.jsModuleRuntime = [...target.jsModuleRuntime!, ...source.jsModuleRuntime || []]; source.wasmSymbols = [...target.wasmSymbols!, ...source.wasmSymbols || []]; @@ -120,6 +121,7 @@ function normalizeResources(target: Assets) { if (!target.corePdb) target.corePdb = []; if (!target.pdb) target.pdb = []; if (!target.jsModuleNative) target.jsModuleNative = []; + if (!target.jsModuleAuxiliaryWorker) target.jsModuleAuxiliaryWorker = []; if (!target.jsModuleDiagnostics) target.jsModuleDiagnostics = []; if (!target.jsModuleRuntime) target.jsModuleRuntime = []; if (!target.wasmSymbols) target.wasmSymbols = []; diff --git a/src/native/libs/Common/JavaScript/types/public-api.ts b/src/native/libs/Common/JavaScript/types/public-api.ts index a9b40cf01cc0c2..eadc63f98be92e 100644 --- a/src/native/libs/Common/JavaScript/types/public-api.ts +++ b/src/native/libs/Common/JavaScript/types/public-api.ts @@ -217,6 +217,7 @@ export interface Assets { lazyAssembly?: AssemblyAsset[]; corePdb?: PdbAsset[]; pdb?: PdbAsset[]; + jsModuleAuxiliaryWorker?: JsAsset[]; jsModuleDiagnostics?: JsAsset[]; jsModuleNative: JsAsset[]; jsModuleRuntime: JsAsset[]; diff --git a/src/native/rollup.config.js b/src/native/rollup.config.js index 32f3e3260f493e..fb5adee0d94e01 100644 --- a/src/native/rollup.config.js +++ b/src/native/rollup.config.js @@ -50,6 +50,17 @@ const dotnetJS = configure({ } }); +const dotnetAuxiliaryWorkerJS = configure({ + input: "./libs/Common/JavaScript/loader/auxiliary-module-worker.ts", + output: [{ + file: staticLibDestination + "/dotnet.auxiliary.worker.js", + }], + terser: { + compress: { module: true }, + mangle: { module: true }, + } +}); + const libNativeBrowser = configure({ input: "./libs/System.Native.Browser/native/index.ts", output: [{ @@ -162,6 +173,7 @@ const libBrowserHost = configure({ export default defineConfig([ dotnetJS, + dotnetAuxiliaryWorkerJS, dotnetDTS, libNativeBrowser, libBrowserUtils, diff --git a/src/tasks/Microsoft.NET.Sdk.WebAssembly.Pack.Tasks/AssetsComputingHelper.cs b/src/tasks/Microsoft.NET.Sdk.WebAssembly.Pack.Tasks/AssetsComputingHelper.cs index 320d57bdb3a37f..57ceb327e9369f 100644 --- a/src/tasks/Microsoft.NET.Sdk.WebAssembly.Pack.Tasks/AssetsComputingHelper.cs +++ b/src/tasks/Microsoft.NET.Sdk.WebAssembly.Pack.Tasks/AssetsComputingHelper.cs @@ -27,6 +27,7 @@ public class AssetsComputingHelper private static readonly string[] dotnetJsSingleThreadNames = new[] { "dotnet", + "dotnet.auxiliary.worker", "dotnet.native", "dotnet.runtime" }; @@ -135,6 +136,7 @@ public static string GetCandidateRelativePath(ITaskItem candidate, bool fingerpr relativePath = (fileName, extension) switch { ("dotnet", ".js") => string.Concat(fileName, fingerprintDotNetJs ? requiredFingerprint : optionalFingerprint, extension), + ("dotnet.auxiliary.worker", ".js") => string.Concat(fileName, requiredFingerprint, extension), ("dotnet.runtime", ".js") => string.Concat(fileName, requiredFingerprint, extension), ("dotnet.diagnostics", ".js") => string.Concat(fileName, requiredFingerprint, extension), ("dotnet.native", ".js") => string.Concat(fileName, requiredFingerprint, extension), diff --git a/src/tasks/Microsoft.NET.Sdk.WebAssembly.Pack.Tasks/BootJsonBuilderHelper.cs b/src/tasks/Microsoft.NET.Sdk.WebAssembly.Pack.Tasks/BootJsonBuilderHelper.cs index de365b3fb6964e..c9f342d888ad10 100644 --- a/src/tasks/Microsoft.NET.Sdk.WebAssembly.Pack.Tasks/BootJsonBuilderHelper.cs +++ b/src/tasks/Microsoft.NET.Sdk.WebAssembly.Pack.Tasks/BootJsonBuilderHelper.cs @@ -127,6 +127,7 @@ static void AddDictionary(StringBuilder sb, Dictionary? res) AddDictionary(sb, resources.coreAssembly); AddDictionary(sb, resources.jsModuleWorker); + AddDictionary(sb, resources.jsModuleAuxiliaryWorker); AddDictionary(sb, resources.jsModuleDiagnostics); AddDictionary(sb, resources.jsModuleNative); AddDictionary(sb, resources.jsModuleRuntime); @@ -162,7 +163,9 @@ static void AddDictionary(StringBuilder sb, Dictionary? res) ResourcesData resources = (ResourcesData)bootConfig.resources; string resourceExtension = Path.GetExtension(resourceName); - if (TargetFrameworkVersion < version110 && resourceName.StartsWith("dotnet.native.worker", StringComparison.OrdinalIgnoreCase) && string.Equals(resourceExtension, ".mjs", StringComparison.OrdinalIgnoreCase)) + if (resourceName.StartsWith("dotnet.auxiliary.worker", StringComparison.OrdinalIgnoreCase) && string.Equals(resourceExtension, ".js", StringComparison.OrdinalIgnoreCase)) + return resources.jsModuleAuxiliaryWorker ??= new(); + else if (TargetFrameworkVersion < version110 && resourceName.StartsWith("dotnet.native.worker", StringComparison.OrdinalIgnoreCase) && string.Equals(resourceExtension, ".mjs", StringComparison.OrdinalIgnoreCase)) return resources.jsModuleWorker ??= new(); else if (resourceName.StartsWith("dotnet.diagnostics", StringComparison.OrdinalIgnoreCase) && string.Equals(resourceExtension, ".js", StringComparison.OrdinalIgnoreCase)) return resources.jsModuleDiagnostics ??= new(); @@ -225,6 +228,17 @@ public string TransformResourcesToAssets(BootJsonData config, bool bundlerFriend assets.jsModuleRuntime = MapJsAssets(resources.jsModuleRuntime); assets.jsModuleNative = MapJsAssets(resources.jsModuleNative); assets.jsModuleWorker = MapJsAssets(resources.jsModuleWorker); + assets.jsModuleAuxiliaryWorker = resources.jsModuleAuxiliaryWorker?.Select(a => + { + var asset = new JsAsset() { name = a.Key }; + if (bundlerFriendly) + { + string escaped = EscapeName(a.Key); + imports.Add($"const {escaped} = new URL(\"./{a.Key}\", import.meta.url).href;"); + asset.resolvedUrl = EncodeJavascriptVariableInJson(escaped); + } + return asset; + }).ToList(); assets.jsModuleDiagnostics = MapJsAssets(resources.jsModuleDiagnostics); assets.wasmNative = resources.wasmNative?.Select(a => diff --git a/src/tasks/Microsoft.NET.Sdk.WebAssembly.Pack.Tasks/BootJsonData.cs b/src/tasks/Microsoft.NET.Sdk.WebAssembly.Pack.Tasks/BootJsonData.cs index c279b61189757a..64719c79c4d4ea 100644 --- a/src/tasks/Microsoft.NET.Sdk.WebAssembly.Pack.Tasks/BootJsonData.cs +++ b/src/tasks/Microsoft.NET.Sdk.WebAssembly.Pack.Tasks/BootJsonData.cs @@ -190,6 +190,9 @@ public class ResourcesData [DataMember(EmitDefaultValue = false)] public ResourceHashesByNameDictionary jsModuleDiagnostics { get; set; } + [DataMember(EmitDefaultValue = false)] + public ResourceHashesByNameDictionary jsModuleAuxiliaryWorker { get; set; } + [DataMember(EmitDefaultValue = false)] public ResourceHashesByNameDictionary jsModuleNative { get; set; } @@ -291,6 +294,9 @@ public class AssetsData [DataMember(EmitDefaultValue = false)] public List jsModuleDiagnostics { get; set; } + [DataMember(EmitDefaultValue = false)] + public List jsModuleAuxiliaryWorker { get; set; } + [DataMember(EmitDefaultValue = false)] public List jsModuleNative { get; set; } @@ -374,6 +380,7 @@ public class JsAsset { public string name { get; set; } public string moduleExports { get; set; } + public string resolvedUrl { get; set; } } [DataContract] diff --git a/src/tasks/Microsoft.NET.Sdk.WebAssembly.Pack.Tasks/ComputeWasmPublishAssets.cs b/src/tasks/Microsoft.NET.Sdk.WebAssembly.Pack.Tasks/ComputeWasmPublishAssets.cs index 6646a8fa6ba1dd..e986edf0eec7db 100644 --- a/src/tasks/Microsoft.NET.Sdk.WebAssembly.Pack.Tasks/ComputeWasmPublishAssets.cs +++ b/src/tasks/Microsoft.NET.Sdk.WebAssembly.Pack.Tasks/ComputeWasmPublishAssets.cs @@ -217,7 +217,9 @@ private void ProcessNativeAssets( { var extension = ".js"; var baseName = Path.GetFileNameWithoutExtension(key); - if (baseName.StartsWith("dotnet.native.worker")) { + if (baseName.StartsWith("dotnet.auxiliary.worker")) + baseName = "dotnet.auxiliary.worker"; + else if (baseName.StartsWith("dotnet.native.worker")) { baseName = "dotnet.native.worker"; extension = ".mjs"; } else if (baseName.StartsWith("dotnet.native"))