WEP: wasi:webgpu Bindings
Context
Wado targets WASI P3 (Target WASI P3 Only),
where a program reaches files, sockets, clocks and randomness but has no way to
reach a GPU. wasi:webgpu is the WASI proposal for that: Phase 2, portable to
Linux, Windows, macOS, Android and the Web, one WIT package at 0.3.0-rc.2.
The proposal is the WebGPU spec with its JS assumptions removed, generated from
the WebGPU IDL. One file, one interface webgpu, and inside it 33 resources, 62
records, 34 enums, 12 variants, 5 flags, 13 typedefs, 8 async func, and a
single free function, get-gpu. Presenting to a screen is a declared non-goal
of the proposal, so what it covers is GPU compute and offscreen rendering.
Two things it needs from the toolchain are already there. wado-from-idl reads
WIT and emits a wasi:* module, and a Wado module may bind a Component Model
import in any namespace, with #[cm] naming the target
(Declaration Identity).
Decision
The package is bundled, like every other wasi:*
wasi:webgpu appears in the binding table, so the namespace is reserved and a
program imports it with no package of its own. What that costs is measured
below, and it is small.
The module is generated from the proposal's WIT
wasi:* is generated, never hand-written (wado-from-idl/AGENTS.md), and this
package is WIT like the rest, so whatever carries the module generates it.
wasmtime does not ship this WIT, so it comes from a vendor/wasi-webgpu
submodule, and mise run update-stdlib-wasi regenerates from there.
Run against 0.3.0-rc.2, the WIT frontend emits the whole interface as 2588
lines. It skips nothing and approximates nothing.
The interface is the effect, and each resource is one too
A WIT interface's free functions become a Wado interface, so webgpu yields
Webgpu with get_gpu(), the one operation that hands out a handle. Effects
and resources are unified, so a program names every resource it touches in its
own with clause:
use { Webgpu, Gpu, GpuAdapter, GpuDevice, GpuBuffer } from "wasi:webgpu";
export fn run() with (Webgpu, Gpu, GpuAdapter, GpuDevice, GpuBuffer) {
let gpu = Webgpu::get_gpu();
let Some(adapter) = gpu.request_adapter(null).wait() else { return; };
let Ok(device) = adapter.request_device(null).wait() else { return; };
let buffer = device.create_buffer(GpuBufferDescriptor {
size: 256,
usage: GpuBufferUsage::Storage | GpuBufferUsage::CopySrc,
mapped_at_creation: null,
label: null,
});
}
An async operation returns AsyncCall<T>
Eight operations are async func: request-adapter, request-device,
map-async, the two create-*-pipeline-async, pop-error-scope,
on-submitted-work-done and get-compilation-info. Each follows
Generic AsyncCall<T>, so the call site
.wait()s. gpu-device also hands out a future and a stream: lost()
answers Future<GpuDeviceLostInfo> and on_uncaptured_error() a
Stream<GpuError>.
The handles are affine CM resources
wado-lang:web collapses its handles into one unrestricted universal handle
(Resource Inheritance). Nothing of
the sort applies here: every wasi:webgpu resource is a CM resource, so
Ownership Analysis governs it and the
boundary carries own and borrow handles as it does for any other WASI
package. The proposal borrows in 41 places, twelve of them record fields, so a
descriptor is a struct with reference-typed fields:
pub struct GpuBindGroupDescriptor {
pub layout: &GpuBindGroupLayout,
pub entries: List<GpuBindGroupEntry>,
pub label: Option<String>,
}
gpu-queue.submit and gpu-render-pass-encoder.execute-bundles take
list<borrow<t>>, which is a List<&T>: the element is a borrow at the
boundary and a reference on the Wado side. Every container reads the same way,
a tuple, an option and a result alongside a list. Only a bare borrow<t> is
the i32 handle the boundary passes.
What the boundary already carries
Compiled against the generated module, these shapes reach a valid component:
the descriptor above, a variant payload holding a record that holds a borrow
(GpuBindingResource::GpuBufferBinding), a List<&T> argument, an async
operation through .wait(), and a flags value built with |.
They also run. wado-run-webgpu's own test compiles a Wado program that
dispatches a WGSL compute shader and checks what the GPU wrote back, so the
binding is covered end to end and not only to the point of compiling.
Roadmap
- [x] Vendor the proposal at
vendor/wasi-webgpu, regenerated bymise run update-stdlib-wasiwith the other WASI packages. - [x] Generate
wado-compiler/lib/wasi/webgpu/and registerwasi:webgpuin the binding table, which also reserves the namespace. - [x] E2E fixtures for the container shapes the package leans on:
list<borrow<t>>,option<borrow<t>>, a borrow held by a tuple and by a result, a list parameter beside a resource return, and aresult<ok, err>parameter. They arecompile_only, since the host lives in another binary and the e2e harness has none. - [ ] E2E fixtures for the two shapes still covered only by compiling a program
by hand: a descriptor record carrying a borrow, and an
asyncrequest through.wait(). - [x]
stdlib-wasi-webgpu.md, generated with the other stdlib docs and linked from the cheatsheet and the spec. One interface the size of the rest of WASI together keeps its own file rather than burying the WASI reference. - [ ] A compute walkthrough, from
get_gputo a dispatched pass. The generated reference carries no prose, andwado-run-webgpu/tests/fixtures/compute_double.wadois the program it would explain.
Known gaps
- What bundling costs is measured, against the module generated from
0.3.0-rc.2. The registry bootstrap eachwadoprocess runs grows by the 8 ms the module takes to lex and parse, and each compilation's private copy of that registry by 0.9 ms, so the e2e suite gains about 12 s of its 1608 s.mise run test-stdlibgains three files and stays inside its own run-to-run spread,wado format79 ms, and a release binary the module's 87 KB of source. The vendored proposal is 692 KB beside wasmtime's 93 MB. Nothing here needs a mechanism to stay cheap; what is unmeasured is the same module once the LSP ships it to a browser. - The bootstrap parses every bundled binding module, and each compilation copies
the registry it fills, so both scale with what is bundled rather than with what
a program imports — about 1.6 ms per compilation at today's sizes. Making
either demand-driven means running the two-pass
useresolution over a package closure instead of the whole table. wado runhas no host for it, and does not gain one. wasmtime ships none;wasi-gfx/wasi-gfx-runtimedoes, as thewasi-webgpu-wasmtimecrate, against the samewasi:webgpu@0.3.0-rc.2the module is generated from. It requires wasmtime 48 where the workspace pins 49.0.0, and the GPU stack behind it is 31 crates — naga, wgpu-core, wgpu-hal and ash among them — about 38 s of a clean release build and 3.1 MB of the binary. So the host iswado-run-webgpu, its own crate and its own workspace, reached through External Subcommands. What is open is when the two wasmtimes meet again: on that day the separate workspace, its own lockfile and thetest-webgpuCI job all collapse into the main ones.- A machine with no GPU has no adapter, and wgpu's
noopbackend is opt-in and computes nothing.mesa-vulkan-driverssupplies a software adapter (lavapipe), a 98.5 MB install, and that is whattest-webgpuruns on. A developer machine without it gets the runner's diagnostic and no run. - The runner defines
request-adapteritself, over the onewasi-webgpu-wasmtimelinks. The host crate has no hook for choosing an adapter or reporting the one it chose, and the runner needs both:--gpu-adapterhands the guest the one adapter it names, and--log-level infonames the adapter each request returned. So a change to the host crate'srequest-adapterdoes not reach the runner until the runner's copy is changed to match. - The runner defines
on-submitted-work-donetoo. The host crate's 0.3.1 awaits a wgpu callback but polls no device, so the callback never fires and the guest hangs; 0.3.0 returned without waiting. The runner's copy polls every device until it is idle. Once the host crate polls, the copy is redundant. - The version rides in every
#[cm]path, so anrc.3rewrites all 2588 lines. Regenerating handles that. What nothing records is which version the bundled module was cut from, beyond the submodule the generated header names. - Presenting to a screen is outside the proposal.
gpu-canvas-contextis declared but nothing hands one out, so a program can render to a texture and not to a window. The proposal points atwasi-gfxfor the missing half. - Every data path copies:
write-buffer-with-copy,write-texture-with-copyandget-mapped-range-get-with-copytake or return alist<u8>, so a buffer upload crosses linear memory twice. This is the proposal's shape, not the binding's. - WebGPU's two
record<DOMString, V>maps arrive as resources withadd/get/has/removerather than as aTreeMap<String, V>, so a pipeline's constant overrides are built through handle calls. - A typedef becomes a newtype, so
buffer.size()answersGpuSize64Outand needsas u64to meet au64. 13 typedefs carry this, most of them widths. - An
option<descriptor>parameter takes an explicitOption::Some(…), since the compiler rejects a default argument on a#[cm]operation (The Web Interface for Wado records the same gap). WebGPU makes most descriptors optional, so this is the common call shape.
