Skip to content

Repository files navigation

subscript-gpu

WebGPU and TypeGPU for subscript programs — the standard WebGPU API in its standard JavaScript shape, and a TypeGPU-shaped layer of typed schemas and kernels over it, for a statically-typed embedded scripting language, over any webgpu.h implementation.

subscript is a TypeScript-subset embedded language with a C execution model: sound types, deterministic memory, zero-copy C interop, a hot-reload development tier and a native shipping tier. subscript-gpu gives its scripts the GPU — through the same requestAdapter / createRenderPipeline / beginRenderPass API every WebGPU tutorial, book, and browser devtool teaches, not through a bespoke engine binding.

The repository holds two products. gpu/ is the WebGPU binding itself. typegpu/ is a TypeGPU-shaped layer on top of it: typed GPU schemas and kernels, authored in subscript, compiled to WGSL and typed host code at build time — see the TypeGPU layer.

import { gpu, GPUAdapter, GPUBuffer, GPUBufferUsage, GPUDevice } from "./webgpu";

export async function main(): Promise<void> {
  const adapter: GPUAdapter | null = await gpu.requestAdapter();
  if (adapter === null) { gpu.dispose(); return; }

  const device: GPUDevice | null = await adapter.requestDevice();
  if (device === null) { adapter.dispose(); gpu.dispose(); return; }

  const buffer: GPUBuffer = device.createBuffer({
    size: 32,
    usage: GPUBufferUsage.COPY_DST,
  });
  // … encode, submit, await completion — then dispose what you created.
}

That is condensed from a real program in the repository's test suite (gpu/programs/a17-api-roundtrip.ts — the full version also prints its progress); the suite runs headless on every test run, on both execution tiers, against committed byte-exact goldens.

Why it exists

A native application that embeds subscript owns a C ABI and its main loop. When its scripts need the GPU, the usual options are:

  • Invent an engine-specific script API — every function, every descriptor, every lifetime rule is yours to design, document, and teach. Nobody outside the project knows it.
  • Bind the raw C API 1:1 — scripts inherit sType chains, count-plus-pointer pairs, and integer enums. It works, and reads like C with extra steps.

subscript-gpu takes the third option: the WebGPU JavaScript API is already the best-documented GPU API in existence, and subscript's sound TypeScript subset can carry its shape — string-literal enums, descriptor dictionaries with defaults, async adapter and device requests, method-per-object encoders. Scripts written against subscript-gpu look like the WebGPU code on MDN and in the gpuweb samples, and tsserver completes and checks them with no custom editor plugin.

Underneath, the backend is an ordinary webgpu.h implementation, chosen at link time.

How it works

script:   webgpu.ts            gpuweb-IDL shape (GPUDevice, createBuffer, "rgba8unorm", await)
             │  generated API layer — subscript source
mirror:   sgpu.generated.d.ts  ambient C mirror + CEnum wire-mapped enum aliases
             │  generated by `subscript bind` from sgpu.h
facade:   subscript-gpu-facade Rust crate, 151 extern "C" entry points (sgpu*)
             │  generated from webgpu.yml + policy.toml
C ABI:    webgpu.h             the standard WebGPU C API
             │  link-time choice
backend:  yawgpu │ Dawn │ wgpu-native

Every layer above webgpu.h is generated from pinned sources — the webgpu-headers webgpu.yml for the facade and the gpuweb WebIDL for the API layer — and committed under byte-identical regeneration gates: hand-editing a generated file fails a test that tells you to run the generator.

Three design decisions carry most of the weight:

  • The shape is JavaScript's; the semantics are subscript's. async methods are real subscript async functions the host steps at its loop boundary — no promise objects, no microtask queue, no GC. Every script-owned wrapper has an explicit dispose() and the [Symbol.dispose] hook, so TS 5.2 using bindings dispose at scope exit; create-owns, and a host-owned wrapper has neither member, so a wrong disposal fails at check time. Events (device loss, uncaptured errors) are poll-drain methods, not an EventTarget. Each deviation is a recorded policy decision, not an accident.
  • Enums are strings in script and integers on the wire — with no conversion. A GPUTextureFormat like "rgba8unorm" is a wire-mapped literal union (28 aliases, 292 members): its runtime representation is the C enum value, so writing a descriptor member is a plain store, and an unmapped integer coming back from C traps at the boundary with the alias name and value. The string never exists at run time.
  • The backend is a link-time choice, resolved by environment. yawgpu is the Tier-1 backend — its CPU-only Noop backend is what the whole test suite runs on, headless. Dawn is the conformance oracle for arbitration. GPU semantics live in the backend; this project validates what a binding must — argument conversion, lifetimes, completion delivery.

The TypeGPU layer

TypeGPU gives GPU programs typed data schemas with automatic memory layout, and kernels authored in the host language. Its JavaScript implementation depends on Proxy, callable schema objects, and new Function — all of which subscript rejects permanently. The typegpu/ layer keeps the idea and moves the machinery to compile time.

You author one *.gpu.ts module — schema classes, kernel functions, and a manifest — and the subscript compiler checks it:

export class VecAddF32Value {
  value: f32;
}

export function vecAdd(
  a: StorageArray<VecAddF32Value>,
  b: StorageArray<VecAddF32Value>,
  out: MutStorageArray<VecAddF32Value>,
  id: GlobalInvocationId,
): void {
  if (id.x < out.length) {
    let value: VecAddF32Value = a[id.x];
    value.value = value.value + b[id.x].value;
    out[id.x] = value;
  }
}

export const __gpu: GpuModuleSpec = {
  compute: [{
    fn: "vecAdd",
    bindings: [
      { binding: 0, kind: "storage", schema: "VecAddF32Value" },
      { binding: 1, kind: "storage", schema: "VecAddF32Value" },
      { binding: 2, kind: "storage-rw", schema: "VecAddF32Value" },
    ],
    workgroupSizeX: 4,
  }],
};

That is typegpu/programs/b04-vecadd.gpu.ts, verbatim minus nothing — the kernel indexes typed storage bindings with a[i] (subscript class index signatures), and the checker owns every type in it.

stgpu-codegen compiles the module through the subscript checker, walks the typed HIR — no second parser — and emits a committed *.gpu.generated.ts beside it:

  • the WGSL, as string constants, emitted from the same typed HIR the checker produced;
  • layout constants with explicit padding (VecAddF32Value_SIZE, _ALIGN, per-field offsets) and a @CStruct host mirror;
  • typed buffer and pipeline classes over the WebGPU API layer — createVecAddF32ValueBuffer(device, count, usage), createVecAddPipeline(device), writeFrom(queue, index, value) over writeBufferF32 — so the host side of a dispatch is typed end to end;
  • disposal helpers in reverse creation order.

Your program imports the generated module next to webgpu.ts and drives an ordinary WebGPU dispatch with it (typegpu/programs/b04-vecadd.ts is the complete worked example, byte-exact on both tiers). To regenerate after editing a GPU module, run the command each generated file names in its header, from typegpu/:

cargo run --offline -p typegpu-codegen --bin stgpu-codegen -- gen programs/b04-vecadd.gpu.ts
cargo run --offline -p typegpu-codegen --bin stgpu-codegen -- gen-lib   # the support library

The kernel corpus covers vector arithmetic, saxpy with a uniform, a particle system, a WGSL standard-library subset, and a render pipeline. Everything above runs headless in the shared gate on the Noop backend; numeric GPU truth comes from the live lane — typegpu/tools/live.sh with SUBSCRIPT_GPU_BACKEND=metal|vulkan runs the x* live programs on a real adapter and is recorded, never CI-required.

Performance

One comparison matters: the same per-frame encode loop (beginRenderPass → 1000 × (setBindGroup with a dynamic offset → draw) → endfinish), written once as a script on the shipping tier and once as hand-written Rust calling the same webgpu.h directly.

Script through the full JS-shaped API layer encodes at 1.056× of hand-written native code against the same backend — a measured +5.6% on the encode loop, inside the project's pre-registered ≤1.2× budget. Script is a first-class place to encode frames, not a scripting tax.

The decomposition behind that number was isolated with a generated measurement backend whose calls cost ~nothing: of the binding's total overhead, a bit over half is the JS-shaped API layer's wrappers, and the rest splits about evenly between the script-to-C boundary and the Rust facade. Absolute per-draw times (machine-specific), the protocol, the spread, and every run are in specs/tracking/p7-perf.md.

Quality

  • The program suite is the definition. 37 programs with committed goldens run under both subscript tiers — the in-process dev JIT and the ship tier's emitted-C-compiled binary — and the outputs must be byte-identical to each other and to the golden, headless, on every test run. The TypeGPU layer adds its own eight-program differential suite to the same gate. Accept programs and reject programs both count: a binding is defined as much by what it refuses.
  • Everything generated is gated. Facade, header, mirror, API layer, enum aliases: byte-identical regeneration tests, each demonstrated red before it was trusted. cargo fmt --check and cargo clippy -D warnings are standing gates under a pinned toolchain.
  • Three desktop platforms. The full gate is green on macOS (arm64, where the goldens are authored), Windows (MSVC), and Linux (x86-64, GNU toolchain) — the Linux bring-up found and fixed a linker-argument-order defect upstream on its first run.
  • Tutorials are measured. Code snippets quoted in the docs are checked against their source files by a test, and every command and output shown was run against the repository as committed.
  • Real-device runs are recorded, never CI-required. The windowed example has device runs on macOS (Apple M2, Metal) and Windows (NVIDIA RTX 5060 Ti, Vulkan); CI needs no GPU anywhere.
  • Co-developed with its dependencies, in the open. This project's measurements drove twenty-plus recorded upstream changes in subscript (R-series: wire-mapped enums across the FFI boundary, entry-less dev sessions, host entry hooks, …) and found real defects in yawgpu (a superlinear encode path, a surface-configure sentinel rejection) — each with a minimal reproduction in the tracking records, and none worked around silently.

The windowed example

gpu/examples/windowed-triangle/ is a teaching artifact: a winit host that owns the window, the surface, and the event loop, and a subscript script that owns the pipeline and encodes every frame — hot-reloadable, on the dev tier.

The split is the lesson. The host pushes what it owns into exported script entries — init(instance, device, format) once, then frame(view, key) per frame — so a parameter stays host-owned for the duration of the call, and the host-owned device wrapper has no dispose() to misuse. The script decides what the key values mean (space advances the clear color) and builds its pipeline against the format the host actually configured, which crosses the entry boundary typed as GPUTextureFormat and validated before the entry body runs. Runs on macOS/Metal and Windows/Vulkan; see its README for the build and the key bindings.

Tutorials

Building and testing

Rust (the pinned toolchain in rust-toolchain.toml), plus Node + TypeScript for the tsc gate.

sh tools/gate.sh        # fmt, clippy, workspace tests, hygiene, tsc

Without a backend library the gate runs everything headless-testable and prints one loud pending line for the backend suite. To run the full differential suite, point it at a yawgpu build:

SUBSCRIPT_GPU_BACKEND_LIB_DIR=<dir with libyawgpu> sh tools/gate.sh

The suite runs on yawgpu's Noop backend — no GPU, no window, no device. SUBSCRIPT_GPU_BACKEND=metal|vulkan selects a real adapter at run time for the examples and device runs.

Status

The library is complete: the generated facade over the pinned webgpu.h, the generated JS-shaped API layer, the two-tier differential program suite, the performance gate with its recorded result, host-embedding for Rust and C with measured tutorials, the windowed example with host-pushed entry parameters, typed f32 buffer paths, using-ready disposal hooks, validation programs derived from the WebGPU CTS, and an experimental wgpu-native backend with a recorded catalogue of where it diverges from the pinned header. The TypeGPU layer sits on top as a second product in the same workspace, with its own contracts under typegpu/specs/.

The full gate is green headless on macOS, Windows, and Linux.

Design records live in specs/: specs/blocks/ holds the area contracts, specs/tracking/ the evidence, and specs/subscript-gpu-project-plan.md the plan and its phase history.

License

Dual-licensed under either of

at your option. Unless you explicitly state otherwise, any contribution intentionally submitted for inclusion in the work by you, as defined in the Apache-2.0 license, shall be dual-licensed as above, without any additional terms or conditions.

About

WebGPU and TypeGPU scripting for native apps, in a statically-typed TypeScript subset

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Contributors

Languages