Skip to content

Latest commit

 

History

History
435 lines (349 loc) · 14.9 KB

File metadata and controls

435 lines (349 loc) · 14.9 KB

subscript-gpu for C and C++ hosts

Your application is C or C++. It owns main, 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 reaches the GPU through a facade you link, not through anything you write.

A C host runs the ship tier: subscript emit writes the program as one C translation unit, and you compile it like your own sources. The runtime header declares no entry point that loads script source at run time, so there is no dynamic-load path to look for. The development tier, with the in-process JIT and hot reload, needs a Rust host — see docs/tutorial-rust.md.

Read this first, plainly:

  • This repository ships no C host. Invariant 7 admits only generated C in the tree, so the host code below lives in this document alone. It is not under the snippet gate that pins the rest of the documentation. Every command, diagnostic and output here comes from a run against commit a21a5db, recorded in specs/tracking/p6-host-embedding.md.
  • You supply three things this repository does not. The first is a yawgpu build — on macOS libyawgpu.dylib with libtint_shim.dylib beside it. The second is the subscript command, built from the revision pinned in specs/tracking/pins.md. The third is the subscript runtime archive and its include directory.
  • Lifetimes are manual. Create-owns plus explicit release, and no finalizers. Your host releases what your host created.
  • Every command here targets a Unix host. MSVC names an archive <name>.lib, takes the import library by path, and has no rpath. The suite passes on Windows, and specs/tracking/windows-msvc.md records the differences. No Windows host run exists for this document.

The two programs below are this repository's committed examples, programs/a27-host-compute.ts and programs/a28-host-triangle.ts. Both produce their committed goldens under a C host.

Setup

Set the environment once. The first variable selects the backend library, and the last two tell the toolchain where the runtime is:

export SUBSCRIPT_GPU_BACKEND_LIB_DIR=<directory that holds the backend library>
export SUBSCRIPT_RUNTIME_INCLUDE=<subscript checkout>/runtime/include
export SUBSCRIPT_RUNTIME_LIB=<subscript checkout>/target/release/libsubscript_runtime.a

Build the three archives a host links. Run these from the repository root:

cargo build --offline -p subscript-gpu-facade --features backend-yawgpu
cargo build --offline -p subscript-gpu-engine
cargo build --offline -p subscript-runtime

They write target/debug/libsubscript_gpu_facade.a, libsubscript_gpu_engine.a and libsubscript_runtime.a. If SUBSCRIPT_GPU_BACKEND_LIB_DIR is unset, the first build fails and says so:

subscript-gpu-facade: cannot resolve the backend library directory for `yawgpu`.
Set SUBSCRIPT_GPU_BACKEND_LIB_DIR to the directory containing the backend
library, or make `pkg-config --variable=libdir yawgpu` succeed.

subscript link-flags prints the runtime include flag and the archive path, for a build system that prefers to ask:

$ subscript link-flags
-I<subscript checkout>/runtime/include
<subscript checkout>/target/release/libsubscript_runtime.a

Step 1 — put the script beside the API layer

A script imports the API layer as a same-directory sibling (import { … } from "./webgpu"). Copy the layer next to your script:

mkdir -p app build host
cp api/webgpu.ts app/
cp programs/a27-host-compute.ts app/compute.ts

Step 2 — check the program

$ subscript check app/compute.ts --mirror mirror/sgpu.generated.d.ts
check: app/compute.ts: no errors

One --mirror per ambient file. This program needs only the facade mirror, because the script creates every GPU object itself.

Step 3 — emit the C

subscript emit app/compute.ts --mirror mirror/sgpu.generated.d.ts \
    --no-entry -o build/gen/

This writes build/gen/program.c and build/gen/program.alloc.h. --no-entry omits the generated main, because your host supplies it.

Step 4 — the minimal host

The script owns every GPU object here, so the host only steps the script's async work:

/* host/compute_main.c */
#include "subscript_runtime.h"

#include <stdio.h>

int main(void) {
    subscript_rt_context *ctx = subscript_rt_ctx_new();
    subscript_init(ctx);

    subscript_rt_ctx_async_step(ctx);
    subscript_rt_ctx_enter_script(ctx);
    subscript_export_main(ctx);
    subscript_rt_ctx_exit_script(ctx);
    while (subscript_rt_ctx_async_pending(ctx) != 0u) {
        subscript_rt_ctx_async_step(ctx);
    }

    if (subscript_rt_ctx_trap_kind(ctx) != 0u) {
        uint64_t message_len = 0;
        const uint8_t *message = subscript_rt_ctx_trap_message(ctx, &message_len);
        fprintf(stderr, "script trapped: %.*s\n", (int)message_len, message);
        subscript_rt_ctx_release(ctx);
        return 1;
    }

    uint64_t out_len = 0;
    const uint8_t *out = subscript_rt_ctx_stdout(ctx, &out_len);
    fwrite(out, 1, (size_t)out_len, stdout);

    subscript_rt_ctx_release(ctx);
    return 0;
}

Five facts make this the whole protocol:

  • One Context owns every script allocation, and subscript_rt_ctx_release frees them together.
  • subscript_init runs once per Context, before any entry.
  • subscript_rt_ctx_enter_script and _exit_script bracket each entry call.
  • An async entry parks at its first await. Only subscript_rt_ctx_async_step resumes it, and subscript_rt_ctx_async_pending reports what remains.
  • Nothing unwinds across the boundary. A script fault records a trap, and trap kind 0 means the run completed.

Step 5 — compile and link

cc -std=c11 -O2 \
    -I"$SUBSCRIPT_RUNTIME_INCLUDE" -Ifacade \
    build/gen/program.c host/compute_main.c \
    target/debug/libsubscript_gpu_facade.a \
    "$SUBSCRIPT_RUNTIME_LIB" \
    -L"$SUBSCRIPT_GPU_BACKEND_LIB_DIR" -lyawgpu \
    -Wl,-rpath,"$SUBSCRIPT_GPU_BACKEND_LIB_DIR" \
    -o build/compute_host

./build/compute_host prints the committed golden:

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.

Step 6 — your engine, bound

A host that owns GPU objects presents them to the script through its own C header. Write the header, and follow three rules:

  1. Handles are opaque pointer typedefs (typedef struct EngineImpl *EngineHandle;).
  2. A type the sgpu mirror already declares is marked external, so the binder references it instead of a second declaration of it: /* @subscript-external SGPUTextureView */.
  3. The lifecycle pair stays out of the header. A script cannot own what it cannot name.

This repository generates that header from Rust, and the result is the shape a hand-written one takes:

/* @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);

subscript bind turns it into an ambient mirror. The command takes no include path, and libclang resolves #include "sgpu.h" beside the header, so put both headers in one directory and run the command there:

mkdir -p build/headers
cp facade/sgpu.h engine/engine.h build/headers/
cd build/headers && subscript bind --header engine.h -o engine.generated.d.ts

The output is byte-identical to this repository's committed mirror/engine.generated.d.ts. One header therefore serves a Rust host and a C host, with no second source of truth.

Step 7 — a host that owns the device and the frame target

The frame program borrows the host's device and renders into a host-owned offscreen target. Check and emit it with both mirrors:

cp programs/a28-host-triangle.ts app/frame.ts
subscript check app/frame.ts --mirror mirror/sgpu.generated.d.ts \
    --mirror build/headers/engine.generated.d.ts \
    --mirror mirror/wire-enum-aliases.generated.d.ts
subscript emit app/frame.ts --mirror mirror/sgpu.generated.d.ts \
    --mirror build/headers/engine.generated.d.ts \
    --mirror mirror/wire-enum-aliases.generated.d.ts \
    --no-entry -o build/gen-frame/

The third mirror declares the wire-mapped enum aliases. The engine mirror types engineFrameFormat with one of them, so a check without it fails with unknown type name GPUTextureFormat``.

The host creates the engine before the entry, pumps it once per frame, and releases it after the loop:

/* host/frame_main.c */
#include "subscript_runtime.h"
#include "engine.h"

#include <stdio.h>

/* Host-only lifecycle: absent from engine.h, so no script can name it. */
EngineHandle engineCreate(void);
void engineRelease(EngineHandle engine);
void engineHostPreEntry(void *context);
void engineHostPostRun(void *context);

int main(void) {
    subscript_rt_context *ctx = subscript_rt_ctx_new();
    subscript_init(ctx);

    engineHostPreEntry(NULL);
    EngineHandle engine = engineCurrent();
    if (engineReady(engine) != 1u) {
        fprintf(stderr, "host: engine creation failed\n");
        subscript_rt_ctx_release(ctx);
        return 1;
    }

    for (int frame = 0; frame < 2; frame += 1) {
        engineProcessEvents(engine);
        subscript_rt_ctx_async_step(ctx);

        subscript_rt_ctx_enter_script(ctx);
        subscript_export_main(ctx);
        subscript_rt_ctx_exit_script(ctx);

        while (subscript_rt_ctx_async_pending(ctx) != 0u) {
            engineProcessEvents(engine);
            subscript_rt_ctx_async_step(ctx);
        }
    }

    if (subscript_rt_ctx_trap_kind(ctx) != 0u) {
        uint64_t message_len = 0;
        const uint8_t *message = subscript_rt_ctx_trap_message(ctx, &message_len);
        fprintf(stderr, "script trapped: %.*s\n", (int)message_len, message);
    }

    uint64_t out_len = 0;
    const uint8_t *out = subscript_rt_ctx_stdout(ctx, &out_len);
    fwrite(out, 1, (size_t)out_len, stdout);

    engineHostPostRun(NULL);
    subscript_rt_ctx_release(ctx);
    return 0;
}

The host declares the lifecycle itself, because rule 3 keeps it out of engine.h. In this repository the pair is spelled engineHostPreEntry and engineHostPostRun, and it also publishes the handle that engineCurrent() returns. In your own engine the pair is whatever your create and release functions are called.

Link the engine archive as well, and add its include directory:

cc -std=c11 -O2 \
    -I"$SUBSCRIPT_RUNTIME_INCLUDE" -Ifacade -Iengine \
    build/gen-frame/program.c host/frame_main.c \
    target/debug/libsubscript_gpu_engine.a \
    target/debug/libsubscript_gpu_facade.a \
    "$SUBSCRIPT_RUNTIME_LIB" \
    -L"$SUBSCRIPT_GPU_BACKEND_LIB_DIR" -lyawgpu \
    -Wl,-rpath,"$SUBSCRIPT_GPU_BACKEND_LIB_DIR" \
    -o build/frame_host

./build/frame_host prints these six lines once per frame:

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

samples:device-only is the headless answer. The substrate records draw calls and does not rasterise them, so a headless run proves argument conversion, lifetimes and completion delivery, and proves nothing about pixels. Select a real backend with SUBSCRIPT_GPU_BACKEND=metal, and the same line becomes samples:covered=true:not-covered=true.

C++ hosts

sgpu.h and engine.h carry no extern "C" guard, so a C++ translation unit must wrap them. Without the wrap the link fails and names the cause:

Undefined symbols for architecture arm64:
  "engineReady(EngineImpl*)", referenced from:
      _main in main.o
   NOTE: found '_engineReady' ... declaration possibly missing 'extern "C"'

Wrap both the include and your own declarations of the host-only functions:

extern "C" {
#include "engine.h"
}

extern "C" {
EngineHandle engineCreate(void);
void engineRelease(EngineHandle engine);
void engineHostPreEntry(void *context);
void engineHostPostRun(void *context);
}

subscript_runtime.h guards itself, so it needs no wrap. Compile the emitted program.c as C11 and your host as C++, then link them together. The wrapped C++ host prints the same output as the C one.

The rules a C host must know

  • A Context belongs to one thread. Every subscript_rt_* call and every entry call on one Context comes from one thread at a time.
  • Entries take no arguments. Data crosses through your own C surface. The host stages the frame's inputs in its engine before the call, and the script reads them through the mirror.
  • A host pumps what it owns. The frame host pumps its instance. The compute host owns no instance and pumps nothing, because the API layer pumps the script's instance inside every await.
  • Callbacks reach the script only when your thread calls. Every facade completion is a future the script polls after a pump. No callback arrives spontaneously.
  • The print sink is cumulative. A host that runs for a long time registers a print observer instead of a drain of the sink.
  • Release what you created, and only that. The script releases its own wrappers. A wrapper built by borrowGPUDevice releases the queue state it acquired and never the borrowed device.

What this document does not cover

  • How to obtain a backend library. This repository neither vendors an implementation nor documents a build of one.
  • Hot reload. In-place swap needs the JIT, which needs a Rust host.
  • Presentation. The frame target is an offscreen texture, because a required gate must run with no window.

Reading on