GFX Brand Logo
gfx.graphics
Back to Articles

Back to First Principles: Refactoring NoGraphicsAPI's WebGPU Backend with Slang IR Lowering and Dawn Persistent Mapping

Seto

Seto

@seto

2026-10-3024 min read

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_Runtime WebAssembly 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): Each GpuHeap allocated its own standalone WGPUBuffer, and Draw/Dispatch calls only bound the buffer belonging to root'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.py and fix_wgsl.py to 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 memcmp Scans: 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 (wasm32 and wasm64) on top of BufferMapExtendedUsages, 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 PillarSebastian Aaltonen's Original Visionv1 WebGPU WorkaroundFatal Architectural Flaw
1. GPU Pointers & Virtual Address Space (BDA)All GpuHeaps live in one unified 64-bit virtual address space; pointers freely cross heapsPer-Heap standalone WGPUBuffer, encoding pointers as (heap_id << 32) \| offset and binding only root's buffer per DrawIf 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 ABIZero-copy, unbounded size, and full support for Compute Shaders generating Root data directly on the GPUDedicated root_buffer, slicing at most 256 bytes from cpu_shadow via wgpuQueueWriteBuffer on every DrawHidden 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 TranslationNative C/C++ raw pointers (T*), pointer arithmetic, address-of (&), and field-granular partial writesBuild-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 MappingPersistent Mapping: CPU holds a permanent UMA / ReBAR pointer for direct zero-copy writes across submissionsDual posix_memalign buffers (cpu_shadow + synced_snapshot) with full memcmp scans on every submitDouble 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.

Mermaid Diagram
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 offset 256, guaranteeing that nullptr (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_offset on 64-bit targets (macOS Native and wasm64) and directly to mega_offset on 32-bit wasm32.

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:

  1. True Zero-Copy: Submitting a command writes a single 32-bit integer offset—zero bytes of Root payload copied;
  2. No 256-Byte Ceiling: Root structs can be 32 bytes, 256 bytes, or several kilobytes, with shaders fetching only the fields they actually touch;
  3. 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 nullptr comparisons, ternary pointer selection (cond ? p1 : p2), or &value (GPU_ADDRESS) break GpuPtr<T> type checking;
  • Worst of all is semantic and bandwidth degradation: GpuPtr<T> relied on Slang's __subscript operator, meaning a 4-byte single-field write like root.destination[index].seed = x; could degrade into loading and overwriting the entire 256-byte struct!
  • Likewise, fix_wgsl.py used regex and brace counting on emitted WGSL text to locate @vertex fn signatures and return statements 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 let for ordinary SSA results and opaque IRTextureTypeBase / IRSamplerStateTypeBase handles;
  • Emit mutable var specifically for composite in-place mutations (kIROp_SwizzleSet like v.y = ... and kIROp_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:

Mermaid Diagram
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!"]
  1. Field-Granular Loads/Stores and Stride-Accurate Pointer Arithmetic (lowerRawPointersForWGSL):
    • In slang-ir-wgsl-legalize.cpp, the pass recursively lowers raw pointer expressions into u32 byte offsets: kIROp_PtrLit (nullptr) becomes 0u; kIROp_GetElementPtr and kIROp_GetOffsetPtr query the target's exact element stride (sizeAlignment.getStride()) to emit baseOffset + index * stride; kIROp_FieldAddress (e.g., ptr->seed) adds the exact struct field byte offset.
    • IRLoad and IRStore on raw pointers then lower directly into ByteAddressBufferLoad / Store operations against __mega_buffer. Writing root.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 IRFunc and inserted immediately after each pointer's defining instruction.
  2. 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 wasm64 with MEMORY64=2), a C++ pointer T* inside a shared struct occupies 8 bytes (8-byte aligned), whereas on wasm32 it occupies 4 bytes (4-byte aligned). Standard WGSL has no built-in u64 type (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 u64 types?
    • In slang-ir-lower-buffer-element-type.cpp, on 64-bit targets we lower stored raw pointer fields to uint2 (WGSL vec2<u32>, occupying 8 bytes) with synthesized inline pack/unpack helpers (extracting .x on load, packing vec2<u32>(offset, 0u) on store), while on -D__wasm32__ targets we lower directly to u32 (4 bytes)! Zero u64 leakage, and 100% byte-for-byte struct ABI parity across Native 64-bit, wasm32, and wasm64!

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-y modified—won't that affect Vulkan?

3. What is lowering actually doing here? emitSimpleType flattened 64-bit primitive types into 32-bit! Primitive types shouldn't change—you should only be handling ptr specifically, 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:

  1. 100% Restoration of 64-Bit Primitive Types (i64 / u64 / double):
    • The prototype had lazily flattened Int64Type / UInt64Type to i32 / u32 in emitSimpleTypeImpl because Slang's built-in IRBuilder::emitCastPtrToInt hardcoded a UInt64Type return, introducing u64 into the IR. Flattening at the emitter broke Slang's official tests/wgsl/int64-uint64-type.slang and truncated legitimate 64-bit integer loads in ByteAddressBuffer!
    • Clean Fix: Keep pointer-to-offset casts strictly u32 at the lowering source, and restore 100% of vanilla v2026.19 64-bit primitive handling in slang-emit-wgsl.cpp and slang-ir-byte-address-legalize.cpp.
  2. 100% Restoration of slang-ir-vk-invert-y.cpp and Strict UserPointer Scoping:
    • Modifying shared slang-ir-vk-invert-y.cpp without a target guard meant Vulkan SPIR-V shaders compiled with -fvk-invert-y (where both the SV_Position struct key and gl_Position decoration coexist) would get flipped twice, canceling out! Meanwhile, matching AddressSpace::Generic risked touching WGSL's own logical ptr<function, T> and out/inout parameters.
    • Clean Fix: Restore slang-ir-vk-invert-y.cpp 100% to vanilla v2026.19; implement WGSL clip-space Y-inversion exclusively inside slang-ir-wgsl-legalize.cpp on Stage::Vertex IRReturn SV_Position outputs; and restrict pointer lowering strictly to AddressSpace::UserPointer and kIROp_RawPointerType (both of which were compile errors in vanilla Slang WGSL, guaranteeing zero impact on existing valid WGSL shaders).
  3. 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 vanilla v2026.19;
      • On-Demand Synthesis Even When Enabled: Even with -fwgsl-bind-bda-buffer 0 0 passed in NoGraphicsAPI_slang.cmake, Slang synthesizes __mega_buffer only if the entrypoint's reachable call graph actually dereferences a raw pointer (e.g., triangle.slang emits zero storage buffer bindings!);
      • Stage-Exact Access Modes: Because WebGPU forbids read_write storage buffers in Vertex shaders while requiring WGSL access qualifiers in Compute shaders to match the host BindGroupLayout (Storage), Slang automatically emits var<storage, read_write> for Compute entrypoints and var<storage, read> for Vertex/Fragment entrypoints. That allowed shader.slang to delete GpuPtr<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:

  1. Usage Exclusivity: A buffer with WGPUBufferUsage_MapWrite can only pair with CopySrc—never directly with Storage, Uniform, or Index (forcing staging buffers and explicit copy commands);
  2. Submission Exclusivity: Before wgpuQueueSubmit executes any command referencing a buffer, the CPU must call wgpuBufferUnmap; writing again next frame requires an asynchronous wgpuBufferMapAsync round-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:

Mermaid Diagram
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
  1. Dawn Native State-Machine Relaxation (Zero-Copy UMA Direct Writes): For buffers created with BufferMapExtendedUsages combining 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 = value writes straight into GPU-visible unified RAM with zero intermediate copies!
  2. Dual-Hop Acceleration Across GFX_Runtime (WKWebView ↔ Host ↔ GPU): A natural question arises: In our Dawn Wire hybrid runtime where WKWebView and 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 + MapAsync asynchronous IPC round-trip stalls. At wgpuQueueSubmit, 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 wgpuQueueWriteBuffer path, 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 between WKWebView and 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: Requests BufferMapExtendedUsages for zero-copy cross-submit persistent mapping;
  • Set to OFF: Automatically falls back to standard WebGPU 1.0 buffer synchronization (using wgpuQueueWriteBuffer with 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 synchronously memcpyd valid data into unified memory; at t2, 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:

  1. Up-Front 512 MB Mapping Triggered a Per-Frame 512 MB Dirty-Scan Storm: In the initial refactor of create_device, NoGraphicsAPIWebGPU.cpp called wgpuBufferGetMappedRange(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 single wgpuQueueSubmit! In reality, almost the entire 512 MB arena was either unallocated free space or high-end gpu_only VRAM—the active cpu_visible heaps 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!
  2. 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_buffer housing both cpu_visible (low end) and GPU-computed gpu_only Storage data (high end), risked overwriting live GPU outputs with stale zeros from client memory!

Surgical Dual Fix:

  1. On-Demand Subrange Mapping in NoGraphicsAPIWebGPU.cpp: Never map the 512 MB __mega_buffer globally at create_device; instead, call wgpuBufferGetMappedRange strictly for [mega_offset, aligned_size) when create_gpu_heap allocates a MemoryType::cpu_visible heap!
  2. 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) in GFX_Runtime leaped from 28.0 FPS (34.92 ms) right back to a locked 72.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 MB down to 289.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:

Mermaid Diagram
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"]
    end

Verification Matrix & Production Benchmark Scorecard

  1. Zero-Regression Slang Compiler Verification:
    • With -fwgsl-bind-bda-buffer omitted, all 53 / 53 official Slang tests/wgsl/ tests match vanilla v2026.19 100% byte for byte;
    • tests/wgsl/int64-uint64-type.slang emits native i64 / u64 cleanly, and Vulkan SPIR-V -fvk-invert-y remains 100% identical to upstream.
  2. 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.slang passes with 0 lines changed from main;
    • Native Dawn CTests (PERSISTENT_MAP=ON and OFF): 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).
  3. GFX_Runtime Cross-Process Steady-State Benchmarks (Apple Silicon, 72Hz VSync Cap):
Example SceneTarget & Mapping ModeSteady FPSAvg Frame TimeWire Cmd LatencyHost RSSWebContent RSS
deferred_renderer (262,144 dynamic cubes)wasm32, PERSISTENT_MAP=ON72.0 FPS13.81 ms0.18 ms289.1 MB232.5 MB
deferred_renderer (262,144 dynamic cubes)wasm32, PERSISTENT_MAP=OFF72.0 FPS13.93 ms0.18 ms288.9 MB234.6 MB
deferred_renderer (262,144 dynamic cubes)wasm64 (MEMORY64=2), PERSISTENT_MAP=ON72.0 FPS14.03 ms0.18 ms287.8 MB231.8 MB
deferred_renderer (262,144 dynamic cubes)wasm64 (MEMORY64=2), PERSISTENT_MAP=OFF71.9 FPS13.92 ms0.19 ms289.3 MB232.4 MB
cube (Bindless texture + dynamic MVP)wasm32 / wasm64, PERSISTENT_MAP=ON72.0 FPS~13.9 ms0.20 ms~265 MB~200 MB
triangle (Zero-pointer on-demand binding)wasm32 / wasm64, PERSISTENT_MAP=ON72.0 FPS~13.9 ms0.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_buffer boilerplate in shader.slang—gone, distilled down to a single line: static type* name = (type*)__gpu_root_offset();;
  • Per-draw 256-byte CPU root_buffer copies and per-heap BindGroup switching 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.

Read this article in another language:
Seto
WebGPUWGSLCompute ShadersReal-time Graphics

Article Discussion & Comments

0

Sign in to Join the Discussion

Welcome to the discussion. Anyone can register freely to engage with graphics authors and engineers.

Loading comments...