Skip to content

Latest commit

 

History

History
323 lines (266 loc) · 12.2 KB

File metadata and controls

323 lines (266 loc) · 12.2 KB

subscript-gpu for Rust hosts

Your application owns the window, the frame loop, and the GPU objects that outlive a frame. A script owns encoding. The script programs against the WebGPU JS API shape — requestAdapter, createBuffer, beginRenderPass — and never names the C facade below it. The GPU work reads like WebGPU and reloads like a script.

A Rust host gets one thing a C host cannot get: the development tier runs in your process. ReloadSession compiles the program in memory, and your host calls its entry directly. No C compiler runs, and no generated artifact reaches the disk.

Read this first, plainly:

  • You supply the backend. Every command below needs SUBSCRIPT_GPU_BACKEND_LIB_DIR. The variable points at a directory that holds a yawgpu build. On macOS that is libyawgpu.dylib with libtint_shim.dylib beside it. The static library is unused. This repository neither vendors nor builds one.
  • The generation chain is fixed to this workspace. It reads engine/src/lib.rs and writes eight files to fixed paths here. The "you write it" row below means that file, in this checkout.
  • Lifetimes are manual. Create-owns plus explicit release, and no finalizers. Your host releases what your host created.
  • Scope. This document is enough to run the two examples this repository ships. It is not enough to build a host in your own crate. "What this document does not cover" says why.

Everything below is the committed, gate-pinned pair programs/a27-host-compute.ts and programs/a28-host-triangle.ts. The outputs come from a run of them.

The artifacts

Artifact What it is Who writes it
engine/src/lib.rs your host's script-visible surface, as extern "C" functions you
engine/engine.h the C header for that surface generated
mirror/engine.generated.d.ts the ambient declarations scripts see generated
mirror/sgpu.generated.d.ts the facade mirror, which is internal plumbing generated
api/webgpu.ts the WebGPU-shaped API layer a script imports generated

cargo run -p subscript-gpu-codegen --offline writes all five, plus the facade, its Rust surface, and the dev-JIT symbol table. Every output is gated byte-identical, so no output can drift from its source.

Two hosts, two divisions of labour

Example Who creates the GPU objects What the host does
compute_host over a27 the script step async, and nothing else
triangle_host over a28 the host own the device and the frame target, pump the instance, drive frames

Run them:

export SUBSCRIPT_GPU_BACKEND_LIB_DIR=<directory that holds the backend library>
cargo run -p subscript-gpu-harness --bin compute_host --features backend-yawgpu
cargo run -p subscript-gpu-harness --bin triangle_host --features backend-yawgpu

The minimal host: step the async, call the entry

A script that owns everything needs no host support beyond async. The entry is export async function main(). It suspends at the first await and only a step resumes it, so the host drains the pending work after the call:

    let mut actions = Vec::new();
    actions.push(HostAction::AsyncStep);
    session
        .async_step()
        .map_err(|error| format!("initial async step: {error}"))?;
    actions.push(HostAction::ScriptEntry);
    session
        .call_main()
        .map_err(|error| format!("call script entry: {error}"))?;

    while session.async_pending() != 0 {
        actions.push(HostAction::AsyncStep);
        session
            .async_step()
            .map_err(|error| format!("resume async entry: {error}"))?;
    }

This host pumps no GPU events, because it owns no instance. The API layer pumps the script's own instance inside every await. A host pumps what it owns, and nothing else.

session.take_output() drains what print wrote. The run prints:

dispatch:encoded-workgroups=4
completion:submitted=true:mapped=true
readback:observed=1,2,3,4
resources:released

The read-back bytes are the pre-dispatch contents. The headless substrate records a dispatch and does not execute the shader, so computed values need a real device (see "Select a real backend").

A frame host in four steps

1. Declare what your host lends the script. Handles are opaque pointers. Mark a type the sgpu mirror already declares as external, so this mirror references it instead of a second declaration of it:

/// @subscript-external
pub type SGPUTextureView = *mut c_void;

/// Opaque engine handle.
pub type EngineHandle = *mut EngineImpl;

/// Returns the current engine-owned offscreen render-target view.
///
/// # Safety
///
/// `engine` must be null or point to a live allocation from an engine create function.
#[no_mangle]
pub unsafe extern "C" fn engineAcquireFrameView(engine: EngineHandle) -> SGPUTextureView {}

Mark the lifecycle pair /// @subscript-host-only. The marker must be the whole line, because a reason appended to it turns the marker off.

2. Regenerate the header and the mirror. One command (cargo run -p subscript-gpu-codegen --offline) turns the Rust declarations above into the C header:

/* @subscript-external SGPUDevice */

/* @subscript-external SGPUInstance */

/* @subscript-external SGPUTextureView */

typedef struct EngineImpl *EngineHandle;

uint32_t engineReady(EngineHandle engine);
EngineHandle engineCurrent(void);
SGPUInstance engineAcquireInstance(EngineHandle engine);
SGPUDevice engineAcquireDevice(EngineHandle engine);
SGPUTextureView engineAcquireFrameView(EngineHandle engine);
void engineProcessEvents(EngineHandle engine);
uint32_t engineFrameWidth(EngineHandle _engine);
uint32_t engineFrameHeight(EngineHandle _engine);
uint32_t engineReadbackFrame(EngineHandle engine);
uint32_t engineFrameSample(EngineHandle engine, uint32_t x, uint32_t y);
int32_t engineValue(EngineHandle engine);

and that header into the ambient declarations a script compiles against:

declare function engineReady(engine: EngineHandle): u32;
declare function engineCurrent(): EngineHandle;
declare function engineAcquireInstance(engine: EngineHandle): SGPUInstance;
declare function engineAcquireDevice(engine: EngineHandle): SGPUDevice;
declare function engineAcquireFrameView(engine: EngineHandle): SGPUTextureView;
declare function engineProcessEvents(engine: EngineHandle): void;
declare function engineFrameWidth(_engine: EngineHandle): u32;
declare function engineFrameHeight(_engine: EngineHandle): u32;
declare function engineReadbackFrame(engine: EngineHandle): u32;
declare function engineFrameSample(engine: EngineHandle, x: u32, y: u32): u32;
declare function engineValue(engine: EngineHandle): i32;

Neither list holds engineCreate or engineRelease. Host-only functions leave both artifacts, so a script cannot name the lifecycle of an object the host owns. That is a stronger rule than a review comment: the script fails to compile.

3. Let the script borrow what you own. The script wraps your handles in API-layer types and never creates the frame target:

  const device: GPUDevice = borrowGPUDevice(
    engineAcquireInstance(frameEngine),
    engineAcquireDevice(frameEngine),
  );
  const queue: GPUQueue = device.queue();
  const frameView: GPUTextureView = new GPUTextureView(
    engineAcquireFrameView(frameEngine),
  );

borrowGPUDevice is the borrow entry point and requestDevice() is the owning one. Both give a GPUDevice. A borrowed wrapper releases only what it acquired itself, so the host-owned device survives the script's release:

  command.release();
  pass.release();
  encoder.release();
  pipeline.release();
  shader.release();
  // The wrapper borrowed the engine's device: release drops wrapper-owned
  // queue state but never releases the host-owned device handle.
  queue.release();
  device.release();
  print("resources:released");

4. Drive frames. Create what you own before the session. Pump it, step the script's async, then call the entry. Drain the pending work before the next frame:

    for frame in 0..2 {
        process_engine_events(engine, &mut actions);
        step_async(&mut session, &mut actions)?;
        actions.push(HostAction::ScriptEntry);
        session
            .call_main()
            .map_err(|error| format!("call triangle frame {frame} entry: {error}"))?;

        while session.async_pending() != 0 {
            process_engine_events(engine, &mut actions);
            step_async(&mut session, &mut actions)?;
        }
    }

Each frame prints:

frame:target=64x64
render:encoded-clear=0,0,0.25,1:draw=3
completion:submitted=true
readback:ready=true
samples:device-only
resources:released

Ship the same program

The ship tier compiles and links the program instead of hosting it, so main belongs to the emitted program. Host code runs through two named hooks:

        run_c_aot_with_native_libraries_and_host_hooks(
            &files(),
            &libraries,
            Some(ENGINE_PRE_ENTRY_HOOK),
            Some(ENGINE_POST_RUN_HOOK),
        )

The pre-entry hook runs after subscript_init and before the script's entry, and it creates what the host owns. The post-run hook runs after the async pump and releases that. Post-run runs after a trap as well, so what the host creates is always released.

The suite runs both examples under the development JIT and the shipped C build, and compares both against one committed golden.

What a Rust host must know

  • One Context belongs to one thread. Calls on a session, and the entries it runs, come from one thread at a time.
  • A host pumps what it owns. A host with no GPU objects pumps nothing. A host that owns an instance pumps that instance.
  • Async completes only if the host steps it. A host that never steps leaves the script parked forever, by design.
  • Release order lives in the examples, not in prose. Both scripts release in order, in the script text, because readers copy examples.
  • A headless run proves argument conversion, lifetimes, and completion delivery. It proves nothing about pixels, which is why the triangle prints samples:device-only.

Select a real backend

SUBSCRIPT_GPU_BACKEND takes default, metal, vulkan or gles. With the variable unset the run uses the headless substrate. On a real device the triangle's sample line becomes samples:covered=true:not-covered=true. An unsatisfiable request writes a diagnostic and returns a null instance. It never falls back to the headless substrate, because a device run that quietly became a headless run would prove nothing.

What this document does not cover

  • How to obtain a backend library. This repository neither vendors an implementation nor documents a build of one.
  • A host in your own crate. The generator reads engine/src/lib.rs in this workspace and writes to fixed paths in it. A downstream crate must hand-maintain the dev-JIT symbol table and the facade ABI block, which no gate here covers.
  • Presentation. The frame target is an offscreen texture. A required gate must run with no window, so the gated example opens none. The reasons are in specs/blocks/host-embedding.md (H3a).

Reading on