Back to First Principles: Refactoring NoGraphicsAPI's WebGPU Backend with Slang IR Lowering and Dawn Persistent Mapping
Abstract: In Post #3, I shared how we built a Metal Bindless backend for Google Dawn and got Sebastian Aaltonen's NoGraphicsAPI running at a locked 72 FPS across WebGPU and our
GFX_RuntimeWebAssembly container without modifying a single line of business shader code.
> By "make the demos run" standards, that first implementation looked complete. Yet when I stepped back, re-read Sebastian Aaltonen's original No Graphics API essay, and audited our build scripts and synchronization paths line by line, an uncomfortable truth stood out—*while v1 got all three examples rendering on screen, beneath the surface it violated the core philosophy of No Graphics API in four fundamental ways:
1. Fake Unified Addressing (Per-Heap Isolated
WGPUBuffers): EachGpuHeapallocated its own standaloneWGPUBuffer, and Draw/Dispatch calls only bound the buffer belonging toroot's heap—meaning cross-heap pointer dereferences immediately broke;
2. A 256-Byte CPU-Copied
root_buffer: Every Draw/Dispatch sliced up to 256 bytes out of CPU shadow memory and uploaded it to a dedicated buffer, imposing a hard 256-byte ceiling and completely breaking GPU-driven pipelines where a Compute Shader synthesizes Root arguments directly in VRAM for subsequent passes;
3. A Quagmire of Regex Edge Cases: Relying on
rewrite_bda.pyandfix_wgsl.pyto regex-replace C++ headers and WGSL text proved inherently brittle against pointer arrays, multi-level pointers, function pointer parameters,nullptr, and complex control-flow returns;
4. Dual CPU Shadow Buffers + Full
memcmpScans: Maintaining two host copies and diffing them to work around WebGPU's mapping exclusivity defeated the zero-copy promise of modern Unified Memory Architecture (UMA) and ReBAR hardware.
> Half-measures weren't good enough. I decided to tear down those workarounds and execute a clean, full-stack refactor across Slang (
v2026.19), Google Dawn (chromium/8103), and NoGraphicsAPI: pushing pointer lowering and handle semantics 100% down into Slang's AST/IR pipeline, unlocking true Persistent Mapping across both Native and cross-process Dawn Wire (wasm32andwasm64) on top ofBufferMapExtendedUsages, and restoring Sebastian's pure programming model with a 512 MB Global Virtual Arena + 4-byte Immediate Root offset.
---
https://github.com/SetoKaiba/NoGraphicsAPI
https://github.com/SetoKaiba/slang
1. Architectural Retrospective: Why v1 Followed the Letter, Not the Spirit, of No Graphics API
In Sebastian Aaltonen's design philosophy, a modern GPU is simply a computer with a unified virtual address space: no BindGroup juggling, no rigid transfer slots—CPU and GPU share persistently mapped linear memory, and any pointer can freely reference any struct in any heap.
Looking back at our first WebGPU backend, trying to ship quickly without touching the shader compiler forced four expedient compromises:
| Core Pillar | Sebastian Aaltonen's Original Vision | v1 WebGPU Workaround | Fatal Architectural Flaw |
|---|---|---|---|
| 1. GPU Pointers & Virtual Address Space (BDA) | All GpuHeaps live in one unified 64-bit virtual address space; pointers freely cross heaps | Per-Heap standalone WGPUBuffer, encoding pointers as (heap_id << 32) \| offset and binding only root's buffer per Draw | If root lives in a cpu_visible upload heap while root->vertices points into a gpu_only geometry heap, cross-heap dereferencing fails outright, while rebinding BindGroups per Draw revives legacy API overhead |
| 2. Root Arguments ABI | Zero-copy, unbounded size, and full support for Compute Shaders generating Root data directly on the GPU | Dedicated root_buffer, slicing at most 256 bytes from cpu_shadow via wgpuQueueWriteBuffer on every Draw | Hidden per-draw CPU copy, arbitrary 256-byte cap, and complete inability to consume GPU-generated Root arguments (such as root_data_generate.slang) |
| 3. Shader Compilation & Pointer Translation | Native C/C++ raw pointers (T*), pointer arithmetic, address-of (&), and field-granular partial writes | Build-time Python regex scripts (rewrite_bda.py rewriting shadow headers to GpuPtr<T> + fix_wgsl.py patching WGSL text) | Regex breaks on pointer arrays, double pointers, function pointer params, nullptr, and multi-branch returns; GpuPtr<T>::__subscript forces whole-struct loads/stores instead of 4-byte field writes |
| 4. CPU-GPU Memory Mapping | Persistent Mapping: CPU holds a permanent UMA / ReBAR pointer for direct zero-copy writes across submissions | Dual posix_memalign buffers (cpu_shadow + synced_snapshot) with full memcmp scans on every submit | Double host RAM footprint and wasted diff bandwidth, plus redundant staging ring-buffer allocations and GPU Blit copies even on Apple Silicon UMA |
Once these four mismatches were laid bare, the path forward became crystal clear: put compiler semantics inside Slang IR, put persistent mapping inside Dawn's BufferMapExtendedUsages lifecycle, and strip NoGraphicsAPIWebGPU.cpp and shader.slang back down to a razor-thin hardware layer.
flowchart TD
subgraph OldArch ["Before Refactor: v1 Workaround Stack"]
O1["rewrite_bda.py / fix_wgsl.py\nBuild-time regex rewriting on headers & WGSL"]
O2["Per-Heap Standalone WGPUBuffer\nPer-Draw BindGroup switching by root heap_id"]
O3["256-Byte CPU Slice into Dedicated root_buffer\nBreaks GPU-generated Root data"]
O4["Dual CPU Shadow Buffers + Full memcmp"]
end
subgraph NewArch ["After Refactor: Pure 3-Repo Architecture"]
N1["Slang v2026.19 Native AST/IR Lowering\n-fwgsl-bind-bda-buffer + native T* & let emission"]
N2["512 MB Global Virtual Arena (__mega_buffer)\nBidirectional sub-allocation, bound once, cross-heap BDA"]
N3["4-Byte SetImmediates Passing root_offset\nZero-copy, unbounded size, 100% GPU-driven Root"]
N4["True Persistent Map via BufferMapExtendedUsages\nZero-copy Native UMA writes + Wire lazy dirty-subrange sync"]
end
OldArch -->|"Full-Stack Surgery Across Slang / Dawn / NoGraphicsAPI"| NewArch---
2. Unified Addressing Restored: A 512 MB Global Virtual Arena and 4-Byte Immediate Root ABI
Our first surgical strike eliminated per-heap WGPUBuffer isolation and the 256-byte root_buffer.
1. Global Virtual Arena: Eliminating Cross-Heap Isolation and BindGroup Switching
In the refactored NoGraphicsAPIWebGPU.cpp, create_device allocates a single 512 MB global storage buffer (device->mega_buffer, automatically clamped and aligned to maxStorageBufferBindingSize and maxBufferSize) and binds it permanently at @group(0) @binding(0) (ReadOnlyStorage for Graphics pipelines, Storage for Compute pipelines).
Every create_gpu_heap call (aside from MemoryType::readback buffers dedicated to GPU-to-CPU readback) sub-allocates from this unified 512 MB virtual arena using 256-byte-aligned bidirectional partitioning (arena_allocate):
- Null-Pointer Guard Zone (
0 .. 255): Allocations start at byte offset256, guaranteeing thatnullptr(0) never aliases any live heap; - Bottom-Up Allocation for
MemoryType::cpu_visible(256 -> ...): Clusters all CPU-mapped dynamic upload heaps compactly at the low end of the arena; - Top-Down Allocation for
MemoryType::gpu_only(... <- 512 MB): Places static geometry, G-Buffer payloads, and GPU-written storage heaps at the high end—minimizing fragmentation while physically separating CPU-mapped ranges from GPU-written regions; - Unified Pointer Encoding: Virtual addresses map cleanly to
0x1'0000'0000ull + mega_offseton 64-bit targets (macOS Native andwasm64) and directly tomega_offseton 32-bitwasm32.
No matter how many Draws, Dispatches, or GpuHeaps a frame touches, mega_bind_group is bound exactly once per pass, and any pointer in any heap can dereference data in any other heap seamlessly!
2. Killing the 256-Byte Copy: 4-Byte SetImmediates Unlocks True GPU-Driven Root Data
With every heap residing inside the same Global Virtual Arena, passing Root arguments transforms completely.
Since both CPU-written frame allocations and GPU Compute outputs already live inside __mega_buffer, why copy Root structs on the CPU into a separate root_buffer at all?
We deleted device->root_buffer and upload_root_payload() outright, replacing them with WebGPU's chromium_experimental_immediate_data (requires immediate_address_space;). Now, every draw or dispatch simply pushes 4 bytes (uint32 root_offset) via wgpuRenderPassEncoderSetImmediates or wgpuComputePassEncoderSetImmediates!
Look at how clean the WebGPU section of include/NoGraphicsAPI/shader.slang becomes:
#elif defined(NOGRAPHICSAPI_WEBGPU)
#define NGA_WGSL_DIRECTIVES R"(enable chromium_experimental_resource_table;
requires immediate_address_space;
)"
[ForceInline]
uint __gpu_root_offset()
{
__requirePrelude(NGA_WGSL_DIRECTIVES);
__requirePrelude(R"(var<immediate> nga_root_offset : u32;
)");
__intrinsic_asm "(nga_root_offset)";
}
#define GPU_ROOT_DEFAULT(type, name) GPU_ROOT_IMPL(type, name, 16)
#define GPU_ROOT_IMPL(type, name, alignment) static type* name = (type*)__gpu_root_offset()
#define GPU_ADDRESS(value) (&(value))
No ByteAddressBuffer wrapper macros, no special indirection syntax—GPU_ROOT literally casts that 4-byte immediate offset directly into a native C raw pointer type* name!
This unlocks three immediate wins:
- True Zero-Copy: Submitting a command writes a single 32-bit integer offset—zero bytes of Root payload copied;
- No 256-Byte Ceiling: Root structs can be 32 bytes, 256 bytes, or several kilobytes, with shaders fetching only the fields they actually touch;
- 100% Native GPU-Generated Root Support (
root_data_generate.slang): Consider this canonical GPU-driven test from the official NoGraphicsAPI test suite:
// tests/root_data_generate.slang — 0 lines modified from upstream main!
GPU_ROOT(RootDataGenerate, root);
[numthreads(1, 1, 1)]
void computeMain(uint index : SV_DispatchThreadID)
{
root.destination[index].output = root.output;
root.destination[index].output_index = (root.first_index + index) * 64;
root.destination[index].seed = root.seed + index;
for (uint i = 0; i < 60; ++i) root.destination[index].values[i] = (root.seed + index) * 7 + i;
if (index == 0)
{
root.dispatch_arguments[0] = 1;
root.dispatch_arguments[1] = 1;
root.dispatch_arguments[2] = 1;
}
}
Here, one Compute Pass dynamically synthesizes a 256-byte RootData struct (complete with nested output pointer output and 60 computed values) alongside indirect dispatch arguments directly in GPU memory, and the very next pass consumes that GPU address as its GPU_ROOT! Under the old "slice 256 bytes from CPU shadow memory" design, pure GPU-closed-loop pipelines like this were dead on arrival. Under the new architecture, it passes out of the box with zero modifications!
---
3. Farewell to Regex Black Magic: Native WGSL Pointer Lowering Inside Slang v2026.19 IR
After finishing v1, I audited the regular expressions inside rewrite_bda.py and fix_wgsl.py—and the deeper I looked, the clearer it became that regex text patching was a ticking time bomb.
1. Why Regular Expressions Hit a Wall on C++ and WGSL Syntax
Take rewrite_bda.py, which rewrote raw pointer fields in shadow headers:
c = re.sub(
r'(?m)^([^=\n]*?)\b(?:const\s+)?([A-Za-z_][A-Za-z0-9_]*)(?:\s+const)?\s*\*\s*([A-Za-z_][A-Za-z0-9_]*)\s*;',
r'\1GpuPtr<\2> \3;',
c
)
That single regex harbors a minefield of edge cases:
- Pointer arrays (
uint32* buffers[4];) and multi-level pointers (Node** children;) fail to match altogether; - Multiple declarations on one line (
float *a, *b;), namespaced or templated pointee types, and helper function parameters taking raw pointers (void helper(ObjectData* obj)) slip right past; - Expressions involving
nullptrcomparisons, ternary pointer selection (cond ? p1 : p2), or&value(GPU_ADDRESS) breakGpuPtr<T>type checking; - Worst of all is semantic and bandwidth degradation:
GpuPtr<T>relied on Slang's__subscriptoperator, meaning a 4-byte single-field write likeroot.destination[index].seed = x;could degrade into loading and overwriting the entire 256-byte struct! - Likewise,
fix_wgsl.pyused regex and brace counting on emitted WGSL text to locate@vertex fnsignatures andreturnstatements for Y-axis inversion—fragile against multi-line return signatures, nested structs, or early returns.
Compiler problems belong in the compiler—solved properly on the Abstract Syntax Tree (AST) and Intermediate Representation (IR).
We forked official Slang v2026.19 into two orthogonal, single-commit feature branches—webgpu_bindless and webgpu_bda (merged cleanly into webgpu_bindless_bda)—deleting every Python regex script and purging GpuPtr<T> from shader.slang for good.
2. webgpu_bindless: Fixing WGSL Opaque Handle let / var Emission in 17 Lines
Previously, we needed fix_wgsl.py because Slang's WGSL emitter (slang-emit-wgsl.cpp) emitted SSA temporaries returned by inline getResource<texture_2d<f32>>(...) calls as mutable local variables (var _S1 : texture_2d<f32> = ...), violating WGSL's strict rule that opaque texture and sampler handles can only reside in immutable let bindings.
Inside WGSLSourceEmitter::emitVarKeywordImpl, less than 20 lines at the compiler source fixed the root cause cleanly:
- Emit immutable
letfor ordinary SSA results and opaqueIRTextureTypeBase/IRSamplerStateTypeBasehandles; - Emit mutable
varspecifically for composite in-place mutations (kIROp_SwizzleSetlikev.y = ...andkIROp_UpdateElement).
3. webgpu_bda: IR-Level Raw Pointer Byte-Offset Lowering and Cross-Bitness vec2<u32> Layout Alignment
Supporting C-style physical raw pointers T* (AddressSpace::UserPointer and kIROp_RawPointerType in Slang IR) natively on WGSL comes down to a clean three-stage IR Lowering pipeline:
flowchart LR
AST["Slang Frontend AST/IR\nNative T* / Field Deref / ptr + i / nullptr"] --> Pass1["1. WGSLBufferElementTypeLoweringPolicy\n64-bit targets lower stored pointers to vec2u (8B)\nwasm32 targets lower to u32 (4B)"]
Pass1 --> Pass2["2. lowerRawPointersForWGSL\nPointer arithmetic scaled by element stride to u32 byte offset\nIRLoad/IRStore lowered to __mega_buffer ops"]
Pass2 --> Pass3["3. legalizeByteAddressBufferOps\nDecomposed into 4-byte u32 array loads/stores & bitcasts\nSingle-field write touches only those 4 bytes!"]- Field-Granular Loads/Stores and Stride-Accurate Pointer Arithmetic (
lowerRawPointersForWGSL): - In
slang-ir-wgsl-legalize.cpp, the pass recursively lowers raw pointer expressions intou32byte offsets:kIROp_PtrLit(nullptr) becomes0u;kIROp_GetElementPtrandkIROp_GetOffsetPtrquery the target's exact element stride (sizeAlignment.getStride()) to emitbaseOffset + index * stride;kIROp_FieldAddress(e.g.,ptr->seed) adds the exact struct field byte offset. IRLoadandIRStoreon raw pointers then lower directly intoByteAddressBufferLoad/Storeoperations against__mega_buffer. Writingroot.destination[index].seed = ...writes strictly those 4 bytes in VRAM, never clobbering adjacent struct members!- To preserve strict SSA dominance across loops and branches, cached pointer-to-offset instructions are scoped per
IRFuncand inserted immediately after each pointer's defining instruction. - Matching 64-Bit and 32-Bit C++ Struct ABIs Without Leaking
u64(WGSLBufferElementTypeLoweringPolicy): - Here lies a subtle ABI puzzle: on 64-bit targets (macOS Native and
wasm64withMEMORY64=2), a C++ pointerT*inside a shared struct occupies 8 bytes (8-byte aligned), whereas onwasm32it occupies 4 bytes (4-byte aligned). Standard WGSL has no built-inu64type (and since a single WebGPU Storage Buffer never exceeds 4 GB, active offsets fit within the low 32 bits). - How do we preserve exact 8-byte pointer size and alignment inside WGSL structs on 64-bit targets without emitting illegal
u64types? - In
slang-ir-lower-buffer-element-type.cpp, on 64-bit targets we lower stored raw pointer fields touint2(WGSLvec2<u32>, occupying 8 bytes) with synthesized inline pack/unpack helpers (extracting.xon load, packingvec2<u32>(offset, 0u)on store), while on-D__wasm32__targets we lower directly tou32(4 bytes)! Zerou64leakage, and 100% byte-for-byte struct ABI parity across Native 64-bit,wasm32, andwasm64!
4. Second-Pass Compiler Hygiene: Refusing Global Side Effects with Three-Layer Isolation
When the subagents delivered the first working Slang prototype, I didn't merge it right away. Auditing the compiler diff revealed three "shortcut smells" where changes bled outside WGSL raw pointers, so I paused execution to discuss the boundaries first:
"1. Once Slang lowers BDA natively, do we still need
GpuPtr<T>at all?
2. Why was Slang's shared
vk-invert-ymodified—won't that affect Vulkan?
3. What is lowering actually doing here?
emitSimpleTypeflattened 64-bit primitive types into 32-bit! Primitive types shouldn't change—you should only be handlingptrspecifically, without affecting basic data types or other graphics APIs. Don't edit code yet; let's talk this through."
That architectural pause caught three real regressions in the prototype and led to a rock-solid isolation design:
- 100% Restoration of 64-Bit Primitive Types (
i64/u64/double): - The prototype had lazily flattened
Int64Type/UInt64Typetoi32/u32inemitSimpleTypeImplbecause Slang's built-inIRBuilder::emitCastPtrToInthardcoded aUInt64Typereturn, introducingu64into the IR. Flattening at the emitter broke Slang's officialtests/wgsl/int64-uint64-type.slangand truncated legitimate 64-bit integer loads inByteAddressBuffer! - Clean Fix: Keep pointer-to-offset casts strictly
u32at the lowering source, and restore 100% of vanillav2026.1964-bit primitive handling inslang-emit-wgsl.cppandslang-ir-byte-address-legalize.cpp. - 100% Restoration of
slang-ir-vk-invert-y.cppand StrictUserPointerScoping: - Modifying shared
slang-ir-vk-invert-y.cppwithout a target guard meant Vulkan SPIR-V shaders compiled with-fvk-invert-y(where both theSV_Positionstruct key andgl_Positiondecoration coexist) would get flipped twice, canceling out! Meanwhile, matchingAddressSpace::Genericrisked touching WGSL's own logicalptr<function, T>andout/inoutparameters. - Clean Fix: Restore
slang-ir-vk-invert-y.cpp100% to vanillav2026.19; implement WGSL clip-space Y-inversion exclusively insideslang-ir-wgsl-legalize.cpponStage::VertexIRReturnSV_Positionoutputs; and restrict pointer lowering strictly toAddressSpace::UserPointerandkIROp_RawPointerType(both of which were compile errors in vanilla Slang WGSL, guaranteeing zero impact on existing valid WGSL shaders). - Explicit Opt-In Flag
-fwgsl-bind-bda-buffer <binding> <group>+ On-Demand Stage-Aware Synthesis: - I also stipulated: "I don't want every shader automatically injected with a mega-buffer binding—only shaders in projects like NoGraphicsAPI that explicitly opt into simulated BDA."
- Modeled after Slang's official
-fvk-bind-globals, we added-fwgsl-bind-bda-buffer <binding> <group>: - Disabled by Default: Without this flag, all 53 / 53 official Slang
tests/wgsl/tests produce 100% byte-for-byte identical output to vanillav2026.19; - On-Demand Synthesis Even When Enabled: Even with
-fwgsl-bind-bda-buffer 0 0passed inNoGraphicsAPI_slang.cmake, Slang synthesizes__mega_bufferonly if the entrypoint's reachable call graph actually dereferences a raw pointer (e.g.,triangle.slangemits zero storage buffer bindings!); - Stage-Exact Access Modes: Because WebGPU forbids
read_writestorage buffers in Vertex shaders while requiring WGSL access qualifiers in Compute shaders to match the hostBindGroupLayout(Storage), Slang automatically emitsvar<storage, read_write>for Compute entrypoints andvar<storage, read>for Vertex/Fragment entrypoints. That allowedshader.slangto deleteGpuPtr<T>,__mega_buffer, and every#ifdef __COMPUTE__stage macro!
---
4. Breaking WebGPU's Mapping Barrier: True End-to-End Persistent Mapping via Dawn BufferMapExtendedUsages
With unified addressing and Slang IR lowering in place, one pillar of Sebastian Aaltonen's vision remained: Persistent CPU Mapping.
1. Why BufferMapExtendedUsages Is the Key to True Persistent Mapping
Standard WebGPU locks CPU buffer mapping behind two strict rules:
- Usage Exclusivity: A buffer with
WGPUBufferUsage_MapWritecan only pair withCopySrc—never directly withStorage,Uniform, orIndex(forcing staging buffers and explicit copy commands); - Submission Exclusivity: Before
wgpuQueueSubmitexecutes any command referencing a buffer, the CPU must callwgpuBufferUnmap; writing again next frame requires an asynchronouswgpuBufferMapAsyncround-trip.
Inside Google Dawn (chromium/8103), however, an experimental extension already lifts the first restriction: BufferMapExtendedUsages! It allows a single buffer to carry MapWrite | Storage | Uniform | Index | Indirect | CopySrc | CopyDst simultaneously. On Apple Silicon's Metal backend and ReBAR-capable desktop GPUs, Dawn allocates such buffers directly in CPU/GPU physically shared unified memory (MTLResourceStorageModeShared)!
Only two missing links stood in the way: Dawn's native buffer state machine still rejected queue submissions while a buffer remained mapped, and cross-process Dawn Wire (Emscripten WASM) had no mechanism to synchronize persistently mapped buffers across IPC.
We created a persistent_map branch off Dawn chromium/8103 (and merged it with our Metal Bindless branch into gfx_bindless_metal_persistent_map), unlocking both Native and Dawn Wire end to end:
flowchart LR
subgraph WASM ["WKWebView (WASM32 / WASM64 Client)"]
App["NoGraphicsAPI App Code\n*cpu_ptr = data permanent direct writes"] --> Sub["wgpuQueueSubmit\nZero unmap / zero mapAsync round-trips"]
Sub --> Scan["Wire Client Lazy Subrange Tracking\n256B chunked dirty scan on cpu_visible ranges"]
end
subgraph Host ["GFX_Runtime Native Host (Dawn Native / Metal)"]
IPC["Wire Server Receives Dirty Subranges"] --> UMA["Direct Write to Shared Unified Memory\n(MTLResourceStorageModeShared)"]
UMA --> GPU["Apple Silicon GPU Reads Directly\nZero Staging Buffers / Zero Blit Copies!"]
end
Scan -->|"One-Way Streamed Dirty Subrange Updates"| IPC- Dawn Native State-Machine Relaxation (Zero-Copy UMA Direct Writes): For buffers created with
BufferMapExtendedUsagescombining host-mappable and GPU-execution usages, we relaxed the native submission state check so the buffer can safely cross queue submissions while keeping its host-mapped pointer permanently live. On macOS Native,*ptr = valuewrites straight into GPU-visible unified RAM with zero intermediate copies! - Dual-Hop Acceleration Across
GFX_Runtime(WKWebView ↔ Host ↔ GPU): A natural question arises: In our Dawn Wire hybrid runtime whereWKWebViewand the native Host live in separate processes connected via IPC, does Persistent Mapping still buy real performance? Yes—it eliminates major overhead on both hops! - Hop 1 (WKWebView → Host IPC): The WASM client eliminates all per-frame
Unmap+MapAsyncasynchronous IPC round-trip stalls. AtwgpuQueueSubmit, the Wire client automatically packages modified subranges into a one-way update command streamed alongside the submission; - Hop 2 (Host ↔ GPU Hardware): Along the traditional
wgpuQueueWriteBufferpath, the Host must sub-allocate a temporary Staging Ring Buffer, copy the IPC bytes into it, and spin up a GPU Blit encoder to copy from staging into the destination buffer. With Persistent Mapping active, the Host Wire server deserializes incoming updates straight into GPU-visible unified memory—zero staging allocations and zero GPU Blit encoders between Host and GPU! (And if shared memory IPC is wired betweenWKWebViewand Host in the future, even Hop 1's serialization copy disappears.)
2. Converging the Branch Topology: One webgpu_experimental Branch + Compile-Time Toggle
Midway through the refactor, we initially split NoGraphicsAPI into four separate branches (webgpu_bindless, webgpu_bda, webgpu_persistent_map, plus a merge branch). Stepping back, I realized splitting NoGraphicsAPI that way didn't reflect the true dependency graph:
- For
NoGraphicsAPI, Bindless (WGPUResourceTable) is non-negotiable and natively supported by Dawn; - Simulated BDA lives entirely at compile time inside Slang IR, emitting standard WGSL Storage Buffer loads/stores and a 4-byte immediate offset with zero custom runtime requirements;
- Only Persistent Mapping is genuinely an optional runtime capability!
We therefore consolidated NoGraphicsAPI onto a single squashed commit atop upstream main in webgpu_experimental, governed by one CMake option, NOGRAPHICSAPI_WEBGPU_PERSISTENT_MAP:
- Default
ON: RequestsBufferMapExtendedUsagesfor zero-copy cross-submit persistent mapping; - Set to
OFF: Automatically falls back to standard WebGPU 1.0 buffer synchronization (usingwgpuQueueWriteBufferwith a 4 KB clean-page fast skip), allowing the backend to run cleanly on unmodified WebGPU implementations as well.
---
5. Debugging War Stories: Two Deep-Water Traps and Recovering from a 28 FPS Drop Back to 72 FPS
Clean architecture still has to survive adversarial stress tests and real-world frame-time profiling. Two memorable bugs surfaced during verification—and fixing them taught great lessons.
1. Trap #1: Metal / D3D12 Asynchronous Lazy Zero-Clear Clobbering Synchronous CPU memcpy
During adversarial red-team testing on the Dawn persistent mapping milestone, a challenger agent crafted a devious test case: create an uninitialized persistently mapped buffer (mappedAtCreation = false), map it via MapAsync, write a small payload synchronously via CPU memcpy, and immediately submit a Compute Shader to read it back. Every byte came back as 0!
Tracing the timeline uncovered a classic CPU-vs-GPU race condition:
- For security, WebGPU mandates zero-initializing every uninitialized buffer before its first GPU read;
- While the Vulkan backend zeroes host-mapped buffers synchronously on the CPU at map time, the Metal and D3D12 backends deferred initialization until queue submission—at which point lazy-initialization logic prepended an asynchronous GPU zero-fill command (Blit Fill 0) to the front of the GPU command buffer!
- The timeline disaster was clear: at
t1, the CPU synchronouslymemcpyd valid data into unified memory; att2, the GPU began executing the submitted command buffer, ran the prepended lazy-clear blit first, and wiped the CPU's freshly written data back to zero!
The Fix: Align the Metal and D3D12 host-mapping paths with Vulkan—whenever an uninitialized host-visible buffer is mapped, perform a synchronous CPU zero-clear immediately and mark the buffer initialized, eliminating the asynchronous GPU lazy-clear race at the root.
2. Trap #2: "Why Did Enabling Advanced Zero-Copy Drop FPS to 28?" — The 512 MB Full-Arena Snapshot Scan
Once all three repositories merged and passed functional tests, I launched the 262,144-object deferred_renderer inside GFX_Runtime—and got a nasty surprise: a scene that previously ran at a locked 72 FPS had plummeted to 28.0 FPS (34.92 ms frame time), while Host RSS ballooned to 967 MB!
"Adding zero-copy persistent mapping made it 2.5× slower? There's definitely a bug."
Firing up /boost to profile the runtime pinpointed the culprit within minutes:
- Up-Front 512 MB Mapping Triggered a Per-Frame 512 MB Dirty-Scan Storm: In the initial refactor of
create_device,NoGraphicsAPIWebGPU.cppcalledwgpuBufferGetMappedRange(mega_buffer, 0, 512MB)across the entire 512 MB arena up front. On Native Metal, that merely returns a virtual memory pointer at zero cost. Inside the Emscripten Dawn Wire client, however, mapping all 512 MB told the Wire client: "the application might mutate any byte of this 512 MB buffer at any moment!" The Wire client dutifully allocated a 512 MB comparison snapshot in WASM linear memory and scanned all 512 MB word-by-word on every singlewgpuQueueSubmit! In reality, almost the entire 512 MB arena was either unallocated free space or high-endgpu_onlyVRAM—the activecpu_visibleheaps modified each frame totaled only a few hundred kilobytes! Scanning 512 MB–1 GB of memory per frame burned ~25 ms of pure CPU memory bandwidth! - Full-Buffer Overwrite Hazard on Unmap: At the same time, we noticed that the Wire client's original unmap path serialized the entire mapped buffer range back to the server—which, on a shared
__mega_bufferhousing bothcpu_visible(low end) and GPU-computedgpu_onlyStoragedata (high end), risked overwriting live GPU outputs with stale zeros from client memory!
Surgical Dual Fix:
- On-Demand Subrange Mapping in
NoGraphicsAPIWebGPU.cpp: Never map the 512 MB__mega_bufferglobally atcreate_device; instead, callwgpuBufferGetMappedRangestrictly for[mega_offset, aligned_size)whencreate_gpu_heapallocates aMemoryType::cpu_visibleheap! - Lazy Subrange Write Tracking + 256-Byte Chunked Dirty Scanning in Dawn Wire Client: Overhauled the Wire client's persistent-mapping tracker so snapshots and dirty scans are restricted strictly to active subranges (KB–MB scale) actually mapped or written by the client, scanning at 256-byte chunk granularity during submit and unmap (with explicit forced-flush tracking when recycling previously GPU-written blocks).
Recompiling with both fixes transformed the numbers instantly:
deferred_renderer(262,144 dynamic cubes) inGFX_Runtimeleaped from28.0 FPS(34.92 ms) right back to a locked72.0 FPS(13.81 ms, hitting the 72Hz ProMotion VSync ceiling)!- Per-frame Dawn Wire serialization latency dropped to
0.15 – 0.18 ms! - Host resident memory (Host RSS) plunged from
967.0 MBdown to289.1 MB(a 70% reduction)!
---
6. Final Three-Repo Topology and Full-Platform Benchmarks (Native / wasm32 / wasm64)
With the architecture converged, we also enabled and verified full 64-bit WebAssembly builds under Emscripten wasm64 (MEMORY64=2).
All three repositories now sit on clean, single-commit feature branches:
flowchart LR
subgraph SlangRepo ["Slang Compiler (based on v2026.19)"]
SBase["v2026.19 (0fb6b7587)"] --> SBind["webgpu_bindless\n(let opaque handles & mutable var fix)"]
SBase --> SBda["webgpu_bda\n(-fwgsl-bind-bda-buffer & raw pointer IR lowering)"]
SBind --> SMerge["webgpu_bindless_bda\n(merged local slangc)"]
SBda --> SMerge
end
subgraph DawnRepo ["Google Dawn (based on chromium/8103)"]
DBase["chromium/8103"] --> DMap["persistent_map\n(Native state machine + Wire lazy subrange sync)"]
DMtl["gfx_bindless_metal\n(Metal Bindless backend)"] --> DMerge["gfx_bindless_metal_persistent_map"]
DMap --> DMerge
end
subgraph NGARepo ["NoGraphicsAPI"]
NBase["main (ffd9f2a)"] --> NExp["webgpu_experimental (Single Squashed Commit)\n512MB Arena + 4B Immediate Root + Persistent Map + wasm64"]
endVerification Matrix & Production Benchmark Scorecard
- Zero-Regression Slang Compiler Verification:
- With
-fwgsl-bind-bda-bufferomitted, all 53 / 53 official Slangtests/wgsl/tests match vanillav2026.19100% byte for byte; tests/wgsl/int64-uint64-type.slangemits nativei64/u64cleanly, and Vulkan SPIR-V-fvk-invert-yremains 100% identical to upstream.- Dawn & NoGraphicsAPI Test Suites:
- Dawn Wire Buffer & Mapping Unit Tests: 167 / 167 PASSED;
- Cross-Platform Root ABI Layout Check (
check_root_layouts.py): 11 / 11 Root structs (62 / 62 fields) verified across C++, SPIR-V, Metal, and WGSL;root_data_generate.slangpasses with 0 lines changed frommain; - Native Dawn CTests (
PERSISTENT_MAP=ONandOFF): 25 / 25 PASSED (0 failures); - 4-Tier End-to-End Acceptance Suite (Tier 1 Unit ~ Tier 4 Live Pixel Verification): 162 / 162 PASSED (100%, 0 leaks).
GFX_RuntimeCross-Process Steady-State Benchmarks (Apple Silicon, 72Hz VSync Cap):
| Example Scene | Target & Mapping Mode | Steady FPS | Avg Frame Time | Wire Cmd Latency | Host RSS | WebContent RSS |
|---|---|---|---|---|---|---|
deferred_renderer (262,144 dynamic cubes) | wasm32, PERSISTENT_MAP=ON | 72.0 FPS | 13.81 ms | 0.18 ms | 289.1 MB | 232.5 MB |
deferred_renderer (262,144 dynamic cubes) | wasm32, PERSISTENT_MAP=OFF | 72.0 FPS | 13.93 ms | 0.18 ms | 288.9 MB | 234.6 MB |
deferred_renderer (262,144 dynamic cubes) | wasm64 (MEMORY64=2), PERSISTENT_MAP=ON | 72.0 FPS | 14.03 ms | 0.18 ms | 287.8 MB | 231.8 MB |
deferred_renderer (262,144 dynamic cubes) | wasm64 (MEMORY64=2), PERSISTENT_MAP=OFF | 71.9 FPS | 13.92 ms | 0.19 ms | 289.3 MB | 232.4 MB |
cube (Bindless texture + dynamic MVP) | wasm32 / wasm64, PERSISTENT_MAP=ON | 72.0 FPS | ~13.9 ms | 0.20 ms | ~265 MB | ~200 MB |
triangle (Zero-pointer on-demand binding) | wasm32 / wasm64, PERSISTENT_MAP=ON | 72.0 FPS | ~13.9 ms | 0.13 ms | ~262 MB | ~193 MB |
---
7. Closing Thoughts: Less Is More—Push Complexity Down into the Compiler and Runtime
Comparing our codebase before and after this refactor, the most striking difference is how much upper-layer code disappeared:
- Dozens of lines of fragile Python regex scripts in CMake—gone;
- The entire
GpuPtr<T>smart-pointer template and stage-conditional__mega_bufferboilerplate inshader.slang—gone, distilled down to a single line:static type* name = (type*)__gpu_root_offset();; - Per-draw 256-byte CPU
root_buffercopies and per-heapBindGroupswitching in the runtime—all gone.
When bringing a forward-looking graphics architecture onto a constrained target like WebGPU and WebAssembly, gluing things together with regex scripts and hidden memory copies can get a demo on screen fast—but it leaves behind a leaky abstraction. By taking the time to push pointer semantics properly into Slang's IR pipeline and persistent mapping into Dawn's buffer lifecycle and Wire protocol, upper-layer code doesn't have to compromise at all. Respect the true boundaries of the compiler and runtime, and Sebastian Aaltonen's minimalist vision—just pure pointers and data structures*—shines just as brightly on WebGPU.

Article Discussion & Comments
0Sign in to Join the Discussion
Welcome to the discussion. Anyone can register freely to engage with graphics authors and engineers.