gpu-async-reactor 0.1.0

Await GPU work as a memory descriptor: a no_std completion port, deadline estimator, typed wait plans and a small FILL executor, with CUDA, HIP and Metal backends and std, tokio and io_uring hosts.
Documentation
gpu-async-reactor-0.1.0 has been yanked.

gpu-async-reactor

crates.io docs.rs CI status: early license: MIT OR Apache-2.0 MSRV 1.88 no_std core unsafe: backends only backends: CUDA | HIP | Metal

Await GPU work as a memory descriptor. A GPU stream stores a sequence number into a word of host-visible memory behind its work. That word is to device completion what a file descriptor is to I/O readiness: this crate makes it a first-class awaitable source, and supplies the wait plans, the deadline estimator and a small executor around it, so a host thread can sleep until the GPU is nearly done, park in hardware on the completion word, and run other ready work in between.

๐Ÿ“‘ Contents

๐Ÿš€ Quick start

[dependencies]
gpu-async-reactor = { version = "0.1", features = ["cuda"] }

Wait for a CUDA stream with the plan the Metrale serve A/B picked (park on the completion word, query the stream every millisecond as the error authority):

use gpu_async_reactor::backend::cuda::{CuStream, CudaPort, MappedLine};
use gpu_async_reactor::backend::wordwait::WordWait;
use gpu_async_reactor::host::std_host::StdClock;
use gpu_async_reactor::{Slot, WaitPlan, run_inline, wait};

let plan = WaitPlan::parse("flag:park_us=100,backstop_us=1000")?;
let line = MappedLine::new()?;                       // one 64-byte line, device-mapped
let stream = unsafe { CuStream::from_raw(raw) };     // your live CUstream
let (clock, mut seq) = (StdClock::new(), 0);
// ... enqueue work on `stream` ...
let mut port = CudaPort::new(&line, stream, Slot::new(0).unwrap(), &mut seq, WordWait::probe().ok());
let report = run_inline(wait(&plan, None, &mut port, &clock, &clock), "my wait")??;
println!("{:?} after {} ns, {} parks", report.kind, report.wait_ns, report.parks);

Run several waits and CPU work on one thread (no GPU needed for this one):

cargo run --example fill_executor --features std,wordwait
cargo run --release --example cuda_wait --features cuda      # needs an NVIDIA GPU

๐Ÿงญ Design

One crate, organised as ports and hosts (SBIO): the core holds every decision and does no I/O; backends and hosts are generic parameters chosen once by the binary. There is no dyn in the core.

piece what it is
CompletionLine one 64-byte, 64-aligned line of eight AtomicU64 completion words. On aarch64 the exclusive-reservation granule is the line, so one parked waiter watches up to eight streams.
MemoryDescriptor word slot of a line reaching sequence number target (wrapping order). The awaitable source.
WaitPlan how one wait is spent, parsed once from text: block (the vendor's blocking wait), poll (status queries) or flag:park_us=โ€ฆ,backstop_us=โ€ฆ (park on the word, with a status query every backstop as the error authority), optionally +predict:margin_us=โ€ฆ,min_sleep_us=โ€ฆ,max_sleep_us=โ€ฆ,window=โ€ฆ. Every field is required; nothing is defaulted.
WaitBands which plan runs, by the rows of the current batch: <plan>;rows>=32:<plan>.
PointTable the deadline estimator: per readback point, the minimum of the last window exact residuals; a wake that finds the work already done is censored downward, so over-sleep corrects itself geometrically.
CompletionPort + wait the per-backend port (query, block, poll, arm, park) and the async driver that runs a plan over it. wait never returns Ok before the port's status query or blocking wait said done: the device word is a wake source, never the authority.
Clock, Timer the only time sources. ManualClock replays any wait from virtual time.
Executor + Reactor a single-threaded executor over tasks of one future type. When no task is ready, the reactor sleeps to the earliest pending deadline (a predicted completion), then parks on the completion line of the waiting descriptors, bounded by their deadlines. Completions are checked between task polls, so ready work always runs between waits, and the idle time is counted (RunStats).
ExecutorKind + resolve the selectable executor (naive, gpu-reactor, io-uring) with a declared fallback chain, a coded warning, and an opt-in strict refusal.

The wait, step by step:

block?  โ”€โ”€ yes โ”€โ”€โ–ถ port.block()                                   โ–ถ Blocked
  โ”‚ no
query() โ”€โ”€ done โ”€โ–ถ                                                 โ–ถ Immediate (exact residual)
  โ”‚ pending
flag? โ”€โ”€ arm(): enqueue store(seq) behind the work
predict? โ”€โ”€ Timer.sleep_until(start + predicted โˆ’ margin); query() โ”€โ”€ done โ”€โ–ถ Late (censored)
park: flag โ”€โ”€ park(park_us) until the word reaches seq, query() every backstop_us
      poll โ”€โ”€ query() until done                                   โ–ถ Parked (exact residual)

๐Ÿ”Œ Backends

feature module provides tested on
(none) core everything above, no_std + alloc; builds for wasm32-unknown-unknown and wasm32v1-none unit tests, virtual clock
wordwait backend::wordwait the user-mode park: aarch64 ldxr + wfet (FEAT_WFxT) or wfe; x86-64 umonitor/umwait (Intel WAITPKG) or monitorx/mwaitx (AMD) GB10 (aarch64); x86-64 compile-checked
cuda backend::cuda MappedLine (page-locked, device-mapped, stored into by cuStreamWriteValue64), CudaPort GB10, CUDA 13
hip backend::hip MappedLine over hipHostMalloc(Mapped | Coherent) and hipStreamWriteValue64 compile-checked (no AMD box yet)
metal backend::metal SharedEventPort: an MTLSharedEvent (signaledValue, waitUntilSignaledValue:timeoutMS:) compile-checked for aarch64-apple-darwin
feature host provides
std host::std_host StdClock, StdIdle (thread sleep, then the word-wait park or a yield loop), Watcher (a thread that wakes Wakers; usable from any runtime)
tokio host::tokio TokioClock: Clock + Timer over tokio::time (1 ms timer wheel: for consumers that are not latency-critical)
io-uring host::uring UringIdle: the executor's deadline sleep is one IORING_OP_TIMEOUT with a nanosecond timespec (no timer-slack rounding; the same ring can carry network I/O)

Every vendor entry point is resolved by name at run time (libcuda.so.1, libamdhip64.so), so the crate links nothing and a machine without the driver fails only the constructor. Adding hardware means one LineDriver table (the CUDA and HIP lines share the ownership logic) or one CompletionPort.

โš™๏ธ Choosing an executor

ExecutorKind the waiting thread needs declared fallback
naive runs the wait inline: a blocking predicted sleep, then the plan's park nothing none (runs anywhere)
gpu-reactor runs the wait as an executor task; idles by sleeping to the earliest deadline, then parking on the line a user-mode word wait naive
io-uring as gpu-reactor, with the deadline sleep an IORING_OP_TIMEOUT io_uring and a word wait gpu-reactor

resolve(requested, strictness, &caps) walks the chain once at startup. With Strictness::Fallback an unavailable executor yields the fallback plus a coded ExecutorFallback warning naming what was asked, why it is unavailable, and what runs; with Strictness::Strict it is an ExecutorRefused error. Declared is the generic shape: implement it for any other setting with a fallback chain.

๐Ÿ“Š Benchmarks

All numbers are from the Metrale Engine on an NVIDIA GB10 (Grace Arm + Blackwell, coherent memory), CUDA 13, serving through the in-tree predecessor of this crate (identical logic). Method: same-session, rotated boots per arm, clean box (load < 4, no other GPU work), ratios paired to the spin baseline booted in the same rotation, 95 % CIs (Student-t on paired ratios); GPU energy from the NVML total-energy counter read twice per cell.

Wake latency and park cost (micro):

measurement value
store โ†’ wake of a ldxr+wfe park (GB10) 0.3-0.4 ยตs
CUDA host-function โ†’ eventfd โ†’ poll wake (for comparison) 60-80 ยตs
park calls during a 10 ms wait at a 100 ยตs bound ~95 (a clrex after each park makes it spin: 160,636; regression test in wordwait)
Metal (M3 Max): MTLSharedEvent wait vs waitUntilCompleted wakes 36-56 ยตs sooner, +20-25 % waiter CPU

Serve A/B, Qwen3.6-35B-A3B FP8, n = 5 rotated boots, ratio to the spinning cuStreamSynchronize:

C plan tok/s scheduler CPU / token GPU J / token
4 flag 1.000 [0.996, 1.004] 1.000 1.000 [0.979, 1.021]
8 flag 1.001 [0.997, 1.005] 1.000 0.994 [0.984, 1.004]
16 flag 1.000 [0.999, 1.001] 0.999 0.986 [0.982, 0.990]
4 flag+predict 0.995 [0.990, 1.000] 0.363 1.007
8 flag+predict 0.997 [0.993, 1.002] 0.370 1.001
16 flag+predict 0.998 [0.996, 0.999] 0.319 0.992
32 flag+predict (n = 3) 1.000 [0.998, 1.001] 0.506 0.992 [0.985, 0.999]
64 flag+predict (n = 3) 0.999 [0.996, 1.002] 0.677 0.991 [0.980, 1.002]

Reading: parking on the word holds throughput parity with a spin; adding the predicted sleep gives back two thirds of the scheduler core at C4-C16 for a 0.2-0.5 % throughput cost, and is at parity from C32. Waking from an interrupt or a file descriptor instead lost 0-3 % (35B) and up to 9 % (27B, C8) and multiplied context switches 9-50x per token. CPU power of a WFE park is not on any GB10 rail; only the GPU rail is metered. The executor A/B (naive vs gpu-reactor vs io-uring, same method, C1-C64) measured them equal within noise in tok/s, CPU and J/token, so naive is the default.

๐Ÿ›ก๏ธ Safety

  • The core module carries #![forbid(unsafe_code)]; the crate root denies unsafe_code and only the backend modules (and the io_uring host's submission push) allow it. Every unsafe block has a SAFETY: comment; every public function is safe except the two handle constructors below.
  • Handles: CuStream::from_raw and HipStream::from_raw are unsafe fn: the driver dereferences the handle, and a bogus or destroyed one faults inside the driver (CWE-822). Build them only from live streams you own.
  • Use-after-free of the mapped page (CWE-416) is excluded by construction: a line accepts a store only from a stream of the context (CUDA) or device (HIP) it was made in, and its Drop synchronizes that context or device before freeing the page; if that synchronization fails, the 4 KiB page is leaked, never freed under a pending store. These rules are tested against a fake driver with no GPU (backend::mapped tests).
  • The device word is never the authority. A wait returns Ok only after the stream's status query or blocking wait said done; a lost or late store costs at most one backstop interval.
  • The park reads the line only through atomics and leaves the exclusive monitor armed (a clrex raises the event and turns every park into a spin).

โ— Errors

One append-only #[repr(u16)] registry, ErrorCode, with a {} form template per code; the arguments are kept and substituted only when the error is displayed. The code is the identity: a host maps it into its own registry (Error::code, Error::args) and never branches on the text.

code name meaning
0x0001 InvalidPlan a plan or band text that does not parse (names the text and the fault)
0x0002 ParkFallback warning: a park the host cannot run, and the declared fallback that runs
0x0003 ExecutorFallback warning: an executor the host cannot run, and the fallback that runs
0x0004 ExecutorRefused a strict executor the host cannot run
0x0005 InvalidExecutor an unknown executor name
0x0006 PendedInline a future pended on an inline host (a bug, never a hang)
0x0007 Stalled tasks remain and nothing is registered that could wake them
0x0008 Backend a vendor call failed (names the call and status)

๐Ÿฆ€ MSRV

Rust 1.88 (edition 2024, let chains). The MSRV is raised only in a minor release.

๐Ÿšง Status

0.1.0, early release. The core, the CUDA backend and the aarch64 word wait run in production in the Metrale Engine on GB10; HIP, x86-64 word waits and Metal are compile-checked, awaiting hardware runs. Not yet published to crates.io.

โš–๏ธ License

Licensed under either of Apache License, Version 2.0 or MIT license at your option. Unless you explicitly state otherwise, any contribution intentionally submitted for inclusion in this crate by you, as defined in the Apache-2.0 license, shall be dual licensed as above, without any additional terms or conditions.