Skip to content

Commit a65f908

Browse files
LostBeardclaude
andcommitted
Wasm: shared linear memory keyed PER MaxLinearMemoryPages (fix custom-max accumulation)
The 4.12.1-local.4 shared-memory fix only shared the DEFAULT max (16384): `UsesSharedMemory` required `_maxLinearMemoryPages == 16384`. The ML test lane creates ~569 per-test accelerators at a CUSTOM max (32768 = 2 GiB; DA3-Small OOMs at the 16384 default on grow), so each took the PRIVATE `_cachedWasmMemory` path and re-accumulated the exact reservation leak at 2 GiB each -> V8 address-space cap -> `new WebAssembly.Memory()` CONSTRUCTOR throws "could not allocate memory" (Tuvok's full ML sweep on local.4: 88 -> 91, unchanged; order-trace showed first ~430 Wasm tests pass then collapse = accumulation, not Phase-A exhaustion). Fix (Tuvok's proposed direction): generalize the single shared memory to ONE shared memory per distinct MaxLinearMemoryPages value. - s_sharedWasmMemory/Buffer/Pages/CreateCount/gate -> Dictionary<int, SharedMemEntry> s_sharedByMaxPages keyed by max-pages (entry = Memory + Buffer + Pages + CreateCount + per-group Gate). GetSharedMemEntry(max) creates on first use. - UsesSharedMemory => _useSharedPool (dropped the ==16384). Cached* properties + the dispatch gate route to SharedEntry (this accel's max-group). All 32768 accels share one 32768 memory; all 16384 share one 16384 memory. Spec rule (supplied memory max == module-declared import max) holds because every accel in a group declares the same max. ML lane: 569 x 2 GiB -> one 2 GiB reservation. - Explicit-WorkerCount accelerators (oversubscription stress tests, want worker isolation) keep their PRIVATE memory, unchanged. Per-group gate (different maxes -> different memories -> independent gates, no cross-group contention; captured per-dispatch so release hits the right gate). - SharedWasmMemoryCreateCount/Pages now sum across groups (= number of distinct maxes in use). Regression: new WasmTests.Wasm_SharedLinearMemory_CustomMaxPages_AlsoBounded (K accels at 32768 construct <= 1 shared memory + CPU-oracle). The original default-only test stays; the custom-max gap slipped through because nothing exercised max != 16384. Bundles the Wasm SIMD128 Phase-1 emitter foundation that landed since local.4 (additive; scalar path byte-identical). Gate: PMT_FILTER=WasmTests 519/0/17 (incl. both shared-memory tests + record large- sort times, all green). Version 4.12.1-local.5 (forks stay 2.0.16). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 35f85f8 commit a65f908

5 files changed

Lines changed: 182 additions & 107 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ Wrapper-only (forks stay **2.0.16**). Adds a new selection-gate capability flag:
99
- **`AcceleratorRequirements.RequiresScatterStores`** (rules out WebGL) - declare it when a kernel writes a computed/arbitrary output index (`out[someIndex] = ...`) or more than one element of one buffer per thread that isn't the consecutive `v*storeCount+slot` layout. WebGL Transform-Feedback captures one output record per vertex at the thread's own slot (gather-only), so in-kernel scatter can't run there; the flag filters WebGL at `EnumerateCompatibleDevices` / `CreatePreferredAccelerator` / `Satisfies` time. (WebGL still scatters at the host/algorithm layer - e.g. RadixSort via render-to-texture.)
1010
- A compile-time fail-loud guard for this class (mirroring the atomics/barriers/Scan throws) was prototyped and backed out - the blunt criterion false-positived on legitimate positional multi-store + grid-stride-loop kernels. The correct codegen-level criterion is a tracked open item (`Plans/webgl-multistore-fail-loud-guard-plan-2026-06-13.md`). For now use the selection flag.
1111
- **Wasm: process-persistent shared Web Worker pool.** The Web Worker pool is now process-static (`WasmAccelerator.s_sharedWorkerPool`) - created once per tab and reused across every default-WorkerCount accelerator - instead of being created and `terminate()`d per accelerator. `Worker.terminate()` is an asynchronous browser signal, so a fresh-accelerator-per-test pattern (PMT's ~531-test Wasm lane) spun up a new `hardwareConcurrency` pool while the previous pool's threads were still winding down -> transient worker oversubscription that compounded across the lane -> the pure-spin barrier couldn't schedule all workers in its window -> compute-heavy tests starved and timed out late while light tests stayed fast. The shared pool removes both the terminate churn and the per-test re-create cost. Safe across accelerators: the worker-side module-cache key is a process-static monotonic id (no cross-accelerator collision), a memory-buffer change invalidates a reused worker's cached instances, and each accelerator detaches its handlers on Dispose. Bounded at ~`hardwareConcurrency-2`: an explicit `WorkerCount` (oversubscription stress tests) keeps a private pool, and a worker still checked out at an abnormal Dispose is terminated+removed rather than stranded. Locked by `WasmTests.Wasm_SharedWorkerPool_PersistsAndStaysBoundedAcrossAccelerators`. Gate: `PMT_FILTER=WasmTests` 516/0/17.
12-
- **Wasm: process-static shared linear memory** (the second half the persistent worker pool unmasked). A `new WebAssembly.Memory({ shared: true })` reserves its full `maximum` (default 16384 pages = 1 GiB) of virtual address space at construction and can never relocate, so each default accelerator that built its own memory burned a full 1 GiB reservation. Before the persistent pool, `Worker.terminate()` per accelerator Dispose dropped the workers' references so the old reservation was freed/GC'd per test; with the persistent pool the workers pin the last memory they instantiated against (until they next swap), so across a ~569-test lane the per-accelerator memories accumulated up to `workerCount` live 1 GiB reservations until V8's address-space cap was hit and the `new WebAssembly.Memory(...)` constructor threw `could not allocate memory`. Default-WorkerCount accelerators now share ONE process-static `WebAssembly.Memory` per tab (`WasmAccelerator.s_sharedWasmMemory`), grown to the lane high-water and never re-created -> a single reservation. Safe because the linear memory is per-dispatch transient working/staging memory (zero region -> copy-in -> run -> copy-out; no cross-accelerator state); a process-wide `SemaphoreSlim` serializes the shared-memory dispatch window across concurrently-alive accelerators (zero-cost in the sequential case). Scoped to default `MaxLinearMemoryPages` (16384): a custom-max accelerator (e.g. ML at 32768) or explicit-`WorkerCount` accelerator keeps a private memory, because the kernel module declares its memory-import maximum = its own `MaxLinearMemoryPages` and the spec requires the supplied memory's maximum be <= the module's declared maximum. Bonus: with persistent workers and one persistent memory the buffer only changes on `grow()`, so after high-water the workers stop re-instantiating kernels entirely (the per-test new-memory churn is gone). Diagnostics `WasmAccelerator.SharedWasmMemoryCreateCount` / `SharedWasmMemoryPages`; locked by `WasmTests.Wasm_SharedLinearMemory_PersistsAndStaysBoundedAcrossAccelerators`.
12+
- **Wasm: process-static shared linear memory** (the second half the persistent worker pool unmasked). A `new WebAssembly.Memory({ shared: true })` reserves its full `maximum` (default 16384 pages = 1 GiB) of virtual address space at construction and can never relocate, so each default accelerator that built its own memory burned a full 1 GiB reservation. Before the persistent pool, `Worker.terminate()` per accelerator Dispose dropped the workers' references so the old reservation was freed/GC'd per test; with the persistent pool the workers pin the last memory they instantiated against (until they next swap), so across a ~569-test lane the per-accelerator memories accumulated up to `workerCount` live 1 GiB reservations until V8's address-space cap was hit and the `new WebAssembly.Memory(...)` constructor threw `could not allocate memory`. Default-WorkerCount accelerators now share a process-static `WebAssembly.Memory` keyed by their `MaxLinearMemoryPages` (`WasmAccelerator.s_sharedByMaxPages`) - ONE shared memory per distinct max value, grown to the lane high-water and never re-created -> a single reservation per max-group. Safe because the linear memory is per-dispatch transient working/staging memory (zero region -> copy-in -> run -> copy-out; no cross-accelerator state); a per-group `SemaphoreSlim` serializes that group's dispatch window across concurrently-alive accelerators (zero-cost in the sequential case). Keyed by max because the kernel module declares its memory-import maximum = its own `MaxLinearMemoryPages` and the spec requires the supplied memory's maximum equal the module's declared maximum - so all 16384 accelerators share a 16384 memory, all 32768 (e.g. ML's DA3-Small at 2 GiB) share a 32768 memory, etc. An explicit-`WorkerCount` accelerator (oversubscription stress tests, which want worker isolation) keeps a private memory. Bonus: with persistent workers and a persistent memory the buffer only changes on `grow()`, so after high-water the workers stop re-instantiating kernels entirely (the per-test new-memory churn is gone). (Originally only the default 16384 was shared, which missed the ML test lane's ~569 accelerators at a custom 32768 max - they re-accumulated the leak at 2 GiB each; generalized to per-max.) Diagnostics `WasmAccelerator.SharedWasmMemoryCreateCount` / `SharedWasmMemoryPages` (summed across groups); locked by `WasmTests.Wasm_SharedLinearMemory_PersistsAndStaysBoundedAcrossAccelerators` (default max) + `Wasm_SharedLinearMemory_CustomMaxPages_AlsoBounded` (custom max).
1313
- **Wasm SIMD128 emitter foundation (Phase 1 of the SIMD port).** Additive groundwork only - no production kernel emits v128 yet, so the scalar path is byte-identical. Adds the v128 value type and the 0xFD-prefixed SIMD opcode set to `WasmOpCodes` (spec-verified; sub-opcodes are u32-LEB128 after the prefix, so multi-byte ones like `f32x4.add`=228 encode correctly), v128 emit helpers in `WasmModuleBuilder` (`EmitSimd`/`EmitSimdMem`/`EmitSimdLane`/`EmitV128Const`/`EmitI8x16Shuffle`), and the runtime SIMD capability surface: `WasmBackend.RuntimeSupportsWasmSimd` (via `System.Runtime.Intrinsics.Wasm.PackedSimd.IsSupported` - if the running Blazor WASM build has SIMD enabled, the browser/workers accept v128), `ForceScalar`/`ForceSimd` test overrides, `EffectiveWasmSimd`, `WasmCapabilityContext.WasmSimd`, and `WasmAccelerator.SupportsSimd`. **Non-SIMD devices stay first-class forever** (the scalar path is a supported mode, not a deprecated fallback - real hardware/browsers without wasm SIMD are common; see the dual-build technique in `BlazorWASMSIMDDetectExample`). Verified by the offline `DemoConsole -- wasm-simd-probe`: a hand-built v128 module is `wasm-validate`-clean and `wasm2wat`-decodes to the intended instructions.
1414

1515
## 4.12.0 (2026-06-13) - Sync/async contract: async-only where it waits/observes, sync for fire-and-forget

SpawnDev.ILGPU.Demo/UnitTests/WasmTests.cs

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1740,6 +1740,58 @@ public async Task Wasm_SharedLinearMemory_PersistsAndStaysBoundedAcrossAccelerat
17401740
$"-> {createAfterExplicit}) — it must use a private memory and leave the shared one untouched.");
17411741
}
17421742

1743+
// Custom-MaxLinearMemoryPages sharing (2026-06-14, Geordi). The original shared-memory fix only
1744+
// shared the DEFAULT max (16384), so the ML lane — which creates ~569 per-test accelerators at a
1745+
// CUSTOM max (32768 = 2 GiB, DA3-Small needs it) — took the PRIVATE path and re-accumulated the
1746+
// 2 GiB reservation leak (Tuvok's full ML sweep on local.4: 88->91, unchanged). The fix keys the
1747+
// shared memory by max-pages so each max-group collapses to ONE reservation. This test locks that:
1748+
// K accelerators at a NON-default max construct AT MOST ONE shared memory for that max. (The test
1749+
// above only exercised the default max, which is why the custom-max gap slipped through green.)
1750+
[TestMethod(Timeout = 120000)]
1751+
public async Task Wasm_SharedLinearMemory_CustomMaxPages_AlsoBounded()
1752+
{
1753+
const int count = 4096;
1754+
const int accelerators = 5;
1755+
const int customMaxPages = 32768; // 2 GiB — the ML DA3-Small value
1756+
1757+
var oracle = new int[count];
1758+
for (int i = 0; i < count; i++) oracle[i] = i * 7 + 1;
1759+
1760+
int createCountBefore = WasmAccelerator.SharedWasmMemoryCreateCount;
1761+
for (int a = 0; a < accelerators; a++)
1762+
{
1763+
var context = Context.Create().Wasm().ToContext();
1764+
// Default WorkerCount (so it uses the shared pool) but a CUSTOM max-pages.
1765+
var accelerator = await context.CreateWasmAcceleratorAsync(
1766+
new WasmBackendOptions { MaxLinearMemoryPages = customMaxPages });
1767+
try
1768+
{
1769+
if (((WasmAccelerator)accelerator).MaxLinearMemoryPages != customMaxPages)
1770+
throw new Exception("custom MaxLinearMemoryPages did not take effect.");
1771+
using var buf = accelerator.Allocate1D<int>(count);
1772+
var fill = accelerator.LoadAutoGroupedStreamKernel<Index1D, ArrayView<int>>(
1773+
(i, v) => v[i] = i * 7 + 1);
1774+
fill((Index1D)count, buf.View);
1775+
await accelerator.SynchronizeAsync();
1776+
var result = await buf.CopyToHostAsync<int>();
1777+
for (int i = 0; i < count; i++)
1778+
if (result[i] != oracle[i])
1779+
throw new Exception(
1780+
$"Custom-max accelerator #{a}: result[{i}]={result[i]} expected {oracle[i]} — " +
1781+
$"shared (per-max) linear memory produced wrong output.");
1782+
}
1783+
finally { accelerator.Dispose(); context.Dispose(); }
1784+
}
1785+
int created = WasmAccelerator.SharedWasmMemoryCreateCount - createCountBefore;
1786+
// At most ONE 32768 memory constructed across all K (0 if a prior test already built the
1787+
// 32768 group). Pre-fix this would have been K (one private 2 GiB memory per accelerator) and
1788+
// the reservations would accumulate to the V8 cap on a long lane.
1789+
if (created > 1)
1790+
throw new Exception(
1791+
$"{created} shared memories were constructed across {accelerators} custom-max ({customMaxPages}) " +
1792+
$"accelerators — the custom-max reservation leak (Tuvok's ML-lane 88->91) has regressed (expected <= 1).");
1793+
}
1794+
17431795
// Wasm SIMD128 emitter foundation (Phase 1, 2026-06-14, Geordi). Pure-CPU regression guard on
17441796
// the v128 encoding — NO browser/GPU needed, just byte assertions. Locks the part most likely
17451797
// to silently break: SIMD sub-opcodes are u32-LEB128 after the 0xFD prefix (NOT single bytes

SpawnDev.ILGPU/SpawnDev.ILGPU.csproj

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,9 @@
44
<TargetFramework>net10.0</TargetFramework>
55
<ImplicitUsings>enable</ImplicitUsings>
66
<Nullable>enable</Nullable>
7-
<Version>4.12.1-local.4</Version>
7+
<Version>4.12.1-local.5</Version>
88
<!-- Brief current-version highlights only. Full per-version history with code samples lives in CHANGELOG.md (linked from the README). -->
9-
<PackageReleaseNotes>4.12.1: WebGPU cooperative GEMV grid-stride fix; ±inf/NaN scalar kernel params on WebGL+Wasm; AcceleratorRequirements.RequiresScatterStores flag; Wasm process-persistent shared Web Worker pool AND shared linear memory (default accelerators share one pool + one WebAssembly.Memory per tab, fixing per-accelerator worker-churn starvation and 1 GiB-reservation accumulation across long test lanes). Forks stay 2.0.16. Full per-version history with details: CHANGELOG.md at https://github.com/LostBeard/SpawnDev.ILGPU/blob/master/CHANGELOG.md</PackageReleaseNotes>
9+
<PackageReleaseNotes>4.12.1: WebGPU cooperative GEMV grid-stride fix; ±inf/NaN scalar kernel params on WebGL+Wasm; AcceleratorRequirements.RequiresScatterStores flag; Wasm process-persistent shared Web Worker pool AND shared linear memory keyed per MaxLinearMemoryPages (default-WorkerCount accelerators share one pool + one WebAssembly.Memory per distinct max per tab, fixing worker-churn starvation and the WebAssembly.Memory-reservation accumulation across long test lanes — at both the default 1 GiB and custom maxes like 2 GiB); Wasm SIMD128 emitter foundation (additive groundwork, scalar path unchanged). Forks stay 2.0.16. Full per-version history with details: CHANGELOG.md at https://github.com/LostBeard/SpawnDev.ILGPU/blob/master/CHANGELOG.md</PackageReleaseNotes>
1010
<GeneratePackageOnBuild>True</GeneratePackageOnBuild>
1111
<GenerateDocumentationFile>true</GenerateDocumentationFile>
1212
<EmbedAllSources>true</EmbedAllSources>

SpawnDev.ILGPU/Wasm/CLAUDE.md

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -119,13 +119,17 @@ state), so sharing is correct. Bonus: with persistent workers AND one persistent
119119
only changes on `grow()`, so after high-water the workers **stop re-instantiating kernels entirely**
120120
(per-test new-memory→instance-cache-clear churn gone).
121121

122-
- **Routing:** `UsesSharedMemory` = `_useSharedPool && _maxLinearMemoryPages == 16384`. The whole
123-
create/grow/reuse block + Dispose read/write through `CachedWasmMemory`/`CachedMemoryBuffer`/
124-
`CachedWasmPages` properties that pick static-vs-instance backing. A custom-`MaxLinearMemoryPages`
125-
accelerator (e.g. ML DA3 at 32768) or explicit-`WorkerCount` accelerator keeps a PRIVATE
126-
`_cachedWasmMemory` — required, since the kernel module declares its import max = its own
127-
MaxLinearMemoryPages and the spec needs supplied-max ≤ module-declared-max (a 16384 memory can't
128-
back 32768 modules or vice-versa), and these are rare/long-lived so no accumulation.
122+
- **Routing:** `UsesSharedMemory` = `_useSharedPool` (any default-WorkerCount accel). The shared memory
123+
is keyed by `MaxLinearMemoryPages` in `s_sharedByMaxPages` (Dictionary<int, SharedMemEntry>) — ONE
124+
shared memory PER distinct max. The create/grow/reuse block + Dispose read/write through
125+
`CachedWasmMemory`/`CachedMemoryBuffer`/`CachedWasmPages``SharedEntry` (this accel's max-group) when
126+
shared, else the private `_cachedWasmMemory`. Keyed by max because the kernel module declares its
127+
import max = its own MaxLinearMemoryPages and the spec needs supplied-max == module-declared-max — so
128+
all 16384 accels share a 16384 memory, all 32768 (ML DA3) share a 32768 memory. Only explicit-
129+
`WorkerCount` accels (want worker isolation) keep PRIVATE memory. **History:** originally only the
130+
default 16384 was shared (`== 16384` gate), which missed the ML lane's ~569 accels at a custom 32768 max
131+
— they took the private path and re-accumulated the leak at 2 GiB each (Tuvok's full ML sweep on
132+
local.4: 88→91, unchanged); generalized to per-max 2026-06-14.
129133
- **Concurrency:** `s_sharedMemoryGate` (a `SemaphoreSlim(1,1)`) serializes the shared-memory dispatch
130134
window (acquire→zero→copy-IN→exec→copy-OUT) across accelerators — within one accelerator dispatches
131135
already serialize via `_pendingWork`; this extends that to two concurrently-alive default

0 commit comments

Comments
 (0)