Skip to content

fabric stage: one stage driver over an engine binding, so CUDA, Metal and XPU stages share the boot and the rounds (#2005, stacked on #2060) - #2241

Open
jschmied wants to merge 10 commits into
ashhart:mainfrom
jschmied:stage-driver
Open

jschmied wants to merge 10 commits into
ashhart:mainfrom
jschmied:stage-driver

Conversation

@jschmied

@jschmied jschmied commented Nov 5, 2026

Copy link
Copy Markdown
Contributor

What this changes

Before this PR, the boot and round loop of a pipeline stage lived in #2115's qwen3_5/cuda_stage.zig, written against the Qwen CUDA engine. This PR moves that code, which has no CUDA calls in it, into zig/src/fabric/stage/driver.zig as Driver(B), generic over an engine binding B. A Metal or XPU stage then writes only its binding.

What the driver holds (driver.zig)

  • bootRing: the boot over the ring, in rounds 1 and 2. Each stage appends its description, then its ready report, and rank 0 checks every one against the layout with boot.check and checkLayout.
  • fingerprint(version, config, ranges).
  • Slots: the same code on every rank, so all ranks apply a plan's open, keep, close and generation checks alike.
  • Lead: rank 0's rounds. It encodes the plan, applies it to its own slots, sends it, checks the last stage's echo, and runs the forward with the hop.
  • serve: a follower's loop. It receives a plan, passes it on, applies it, and runs the forward with the hop.
  • Hop, Window and Entry.

What a binding provides

  • The types Engine, Cache and Part. A Part has cache and rows fields.
  • version, backend and most_streams.
  • boundary(e), shapes(parallel, context) and kvDtype(e).
  • open and close for a slot's cache, and reset for a stream opened on that slot.
  • position(cache) and window(cache), the cache's last window that no plan has settled yet.
  • rollback(e, cache, accepted), which keeps that window's first accepted rows.
  • forward(e, parts, ids, mode, hop): the binding's layer range over the round's rows. mode is the plan's window or prompt, and hop is null on a single engine.

Checks on this head

  • Head commit: 27e4d7e33
  • Lean checker, on the base and on this head: 424 on the base and 424 on this head, 0 new
  • Tests run:
    • zig test zig/src/fabric/stage/stage.zig -lc, the test-stage module: 44/44 pass. That is 41 named tests: 35 from the base and 6 new in driver_test.zig.
    • zig build test: RC 0, 0 errors
  • CI: the host set test-kernel-contract test-golden test-server-security test-pull-security fuzz-cortheon: RC 0, 0 errors. git diff --check: clean, 0 lines.
  • Compiles for macOS on aarch64 (--test-no-exec, which is what test-stage-compile does). Not run on a Mac.

driver_test.zig: a fake engine on loopback rings

The fake engine's layer l mixes l and a per-stream running state into every row, so the output depends on the layers' order and on each stream's state. The test runs 13 rounds:

  • a 2-chunk prompt;
  • steps;
  • a second stream beside the first;
  • a keep-alive;
  • windows that keep 3, 0, 5 and 2 of 5 rows;
  • closes.

What it shows:

  • Byte-equal: the fake engine's head bytes from 2 stages (0:3,3:6) and from 3 stages (0:2,2:5,5:6) equal those of one engine.
  • Planted faults:
    • a stage that runs one layer short of its declared range changes the bytes;
    • a rollback that moves the position but not the state changes the bytes, on the middle stage and on the last;
    • a dropped rollback is refused as OtherPosition, and rank 0 ends;
    • a stage that describes a range other than the layout's is refused at boot as OtherRange.

Receipt

Checklist

  • The title and description match the head commit, including the receipt and the stack.
  • New tests fail before the change, pass after it, and skip cleanly without their dependency. The planted faults fail as planted.
  • Comments and docstrings are one line. No measurements or history in the source.
  • No personal data, machine names, internal hosts or local paths in the diff or its commits. No AI attribution lines.

Questions

  1. bootRing expects every rank on one backend, as qwen3_5 cuda: the 27B as a pipeline stage, logits byte-equal to the whole model at 2 and 3 stages (RFC #2005 step 2, stacked on #2241) #2115 did. A mixed CUDA and Metal ring would need a backend for each rank in the layout. Should that go into the layout setting, or into the description alone?
  2. Lead.step takes the plan's mode, and the binding maps it to its own keep: the last row's logits for a prompt, every row's for a window. Rank-0 sampling on the GPU would need a third keep. Should that be a binding option, or should it wait for the step that adds it?

…und's plan and hidden rows (Linux and macOS)

zig/src/fabric/stage is the first step of ashhart#2005: each rank listens on its own address only, connects to the next
rank and accepts the one before. A hello checks that both ends of every link agree on the world, on being
neighbours, and on the checkpoint's and layout's fingerprint. Frames carry a 32-byte header (magic, version, kind,
round, rows, payload bytes) and every send and receive has a deadline. A frame of another kind or round, or past the
receiver's buffer, is refused, and any failed call leaves the link broken, since its stream may be mid-frame. A rank
that fails closes both links, so its neighbours see it and the ring ends as one.

It uses libc sockets only, with no CUDA or Metal module. fabric/tp's control plane imports cuda through config.zig,
so this does not reuse it. TF_STAGE_RANK, TF_STAGE_WORLD, TF_STAGE_PEERS and TF_STAGE_TIMEOUT_S name a rank's
settings.

`zig build test-stage` runs rings of two and three ranks on loopback threads, and `test-stage-compile` builds the
tests for aarch64 Linux and macOS.
… any frame

A stage rank opens a port, so a ring needs a key: TF_STAGE_SECRET_FILE names a file of at least 16 bytes that only
its owner may read, and the key is its SHA-256. When a link forms, both ends send a fresh nonce from /dev/urandom,
then an HMAC-SHA256 over a label, their role (connector or acceptor), rank, world, the fingerprint and both nonces.
Each end checks the other's proof in constant time, so the key never crosses the wire and a proof can't be
replayed. A rank without the key is refused by both its neighbours (error.BadSecret), and a stranger that connects
and talks first is refused at its first frame.

This authenticates the ring when it forms. It doesn't encrypt, and it doesn't protect each frame after that, so
it's for a trusted network.
…wildcard listen and a key file over 4 KiB are refused
…y report, rank 0's round plan, the rows layout and the keep-alive, each mismatch refused by its name
…yte-stable and each refused by its name, for a CUDA or Metal stage
…ytes on the wire read TFSP; fixtures rewritten
…d the keep-alive beats mid-round too

RFC ashhart#2005 section 6.1 has a stage refuse rows whose generation is not its own, but the plan carried none. A stream is
now 28 bytes: the generation u32 at 24, 0 for an open (BadOpen otherwise), and plan.sameGeneration refuses another by
name (OtherGeneration). The keep-alive is due once 2 s pass without a plan, not only when idle, so a stage's long
forward elsewhere in the ring never reads as a dead ring; a stage already counts any frame as heard. Ready's warm
means executable for the declared shapes, not graphs captured. The plan fixtures are rewritten (140 bytes for the
round) and plan_open_generation added.
… prompt chunk, so a stage picks the engine's kernels

A Qwen stage runs a prompt chunk through the prompt GEMMs and a window through the one-row-exact path, and the
bits differ, so a follower must know which one rank 0 runs. A keep-alive is a window; any other mode byte is
BadMode. Fixtures plan_prompt, plan_bad_mode and plan_keepalive_mode added; plan_bad_reserved pokes byte 21.
…e passes it on to the next

recv marked the link broken on any error, a received stop too, so the rank that got it could only hang up: in a
ring of three the last stage saw Closed where rank 0 had stopped cleanly. A stop is a whole frame; only other
errors break the link now.
… binding, so CUDA, Metal and XPU stages share them

driver.zig takes the backend-neutral half of ashhart#2115's Qwen CUDA stage: the boot over the ring (descriptions,
then ready reports, rank 0 checking each), the fingerprint, the slots that apply a plan's open, keep, close and
generation checks alike on every rank, rank 0's rounds with the plan's echo, and a follower's serve loop.
`Driver(B)` is generic over a binding that names its engine, cache and forward part and supplies the boundary,
the shapes, the cache's position, window and rollback, and the forward of its layer range given the round's mode.

driver_test.zig runs it over a fake engine whose every layer mixes its index and a per-stream state into the
rows: 2- and 3-stage loopback rings give one engine's bytes, a stage one layer short and a rollback that moves
the position but not the state both change the bytes, a dropped rollback is refused as OtherPosition, and a
range described against the layout is refused at boot as OtherRange.
@ashhart

ashhart commented Nov 5, 2026

Copy link
Copy Markdown
Owner

Thanks @jschmied. Pulling the boot and rounds out of #2115 into a driver with no CUDA in it, and testing it over a fake engine whose output depends on layer order and stream state, is a clean way to share it. This is host-side only, so it can follow #2060 when that lands. The engine bindings that would use it, #2115 and any Metal or XPU stage, still wait on the decision on #2005, so this one isn't urgent.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants