draft spec stash
This commit is contained in:
parent
16f337e4ce
commit
91cc8a4b26
4 changed files with 3804 additions and 0 deletions
367
DESIGN_DIRECTIVES.md
Normal file
367
DESIGN_DIRECTIVES.md
Normal file
|
|
@ -0,0 +1,367 @@
|
|||
# Design Directives
|
||||
|
||||
|
||||
Captured design directives for the swactor data-movement / coordination-plane work.
|
||||
These are the constraints and decisions as directed — not proposals. Resume from here.
|
||||
|
||||
## Problem & domain
|
||||
- Building a **library for neural nets over heterogeneous, WAN-connected, churning hardware**.
|
||||
- The thing being designed: how nodes **coordinate transfer/streaming of large blobs** — weights, activations, data — as a **reusable primitive**.
|
||||
- **Nodes are trusted.** All trustless/P2P-style concerns (incentives, adversarial verification, Sybil, DHT discovery) are irrelevant.
|
||||
- Focus is **ML workloads**, not general P2P.
|
||||
|
||||
## Scope discipline
|
||||
- **Control plane first.** Performance (pre-alloc, buffering, chunked streams) is for later — *we don't know our bottlenecks yet*.
|
||||
- The problem is **much smaller than what an object store solves** — don't over-engineer toward Ray/Plasma scope.
|
||||
- Want **a solid set of abstractions that make designing distributed graphs over a WAN easy.**
|
||||
|
||||
## The library goal (ought) & lifecycle
|
||||
- (For directional purposes only, we are only building one piece currently)
|
||||
- Input: **a workload graph + a pool of hardware.** The library **distributes the work according to the graph across the hardware.**
|
||||
- The meta lifecycle:
|
||||
1. **define graph and pool in top-level code**
|
||||
2. graph is **broken up per optimal placement** onto the extant resource pool
|
||||
3. nodes **establish edges/networking**
|
||||
4. nodes **fetch the weights/data they need**
|
||||
5. **data flows end-to-end**
|
||||
6. **teardown**, etc.
|
||||
|
||||
## Layers
|
||||
- **Total compute graph** (e.g. inference over the transformer weights in a GGUF).
|
||||
- **Roles created strategically** from **model shape + available hardware**.
|
||||
- **Distribution of the model.**
|
||||
- **Piping the data around.**
|
||||
|
||||
## Roles, graph, edges
|
||||
- **Shape is owned by the graph, decided before roles are assigned.**
|
||||
- A **role = a portion of the compute graph**: tensors flow in through the network boundary, get processed, new tensors flow back out.
|
||||
- The graph is **known ahead of time** (pipelined inference). Nodes know ahead of time: they'll **send** activations, the **role they send to**, and that they'll **receive**.
|
||||
- Each node knows: its **incoming tensors, outgoing tensors, and its own held weights.**
|
||||
- The control plane specifies **size + shape + abstracted role endpoints** ("I am X sending Y to Z; I am Z receiving Y from X").
|
||||
- **Graph-specifying code references a role abstractly** — not how the data reaches it or is received.
|
||||
- Assume a **placement algorithm exists** (hand-defined at first, real later) — assignment/boundaries are already decided.
|
||||
|
||||
## swactor's mandate & boundaries
|
||||
- **swactor is the runtime: the coordination plane for data movement and resource provisioning.**
|
||||
- swactor actors are **strongly-typed FSMs passing messages**, to allow strict design of what the data looks like.
|
||||
- The **CP is owned by an orchestrator** that: **runs and observes SWIM**, **handles resource provisioning**, and **stages execution on the nodes**.
|
||||
- swactor actor paradigm = the **CP/dataflow layer**; **tinygrad = GPU execution.**
|
||||
- **swactor does NOT handle the stream. iroh handles the streaming** — forget the nuts and bolts of the bytes on the wire.
|
||||
- swactor is **not** doing flow control / credit / RTS-CTS — that solves a different problem, because **everything but the transport route is known ahead of time.**
|
||||
|
||||
## What's known vs. unknown
|
||||
- **Known ahead of time:** the blob's **shape/dtype family** and its **`capacity`**
|
||||
(the per-chunk byte ceiling, `max_seq_len × hidden × dtype`), plus the abstracted
|
||||
endpoint (role).
|
||||
- **Unknown at runtime:** the **transport** — who role X physically is, how to reach
|
||||
it — *and* each chunk's actual **`extent`** (e.g. the prompt length: prefill
|
||||
carries many rows, decode one), a per-step runtime fact `≤ capacity`.
|
||||
- Runtime job: **resolve the transport problem** and bind the abstract role to a concrete route.
|
||||
|
||||
## swactor's concrete job (the gate)
|
||||
- Assume over-the-wire streams are solved: **bytes travel fast and safe and arrive in a known buffer location.**
|
||||
- **Receive side:** (zero-)deserialize the already-buffered bytes; **signal that we have the tensors** so they can be piped to the GPU.
|
||||
- **Send side:** bytes are cooked from the GPU; swactor **populates a send buffer** and **resolves the sink.**
|
||||
- What swactor tells the process (tinygrad) is **an actual location of the bytes on-device.**
|
||||
- swactor **handles the stages** of this handoff, **not the actual movement.**
|
||||
- **swactor actors are simple gates.**
|
||||
- → Concretely realized in **The process-facing layer (stream & sink)** below.
|
||||
|
||||
## The process-facing layer (stream & sink)
|
||||
|
||||
Framing: we are designing the **typed send/receive infrastructure**, not the process.
|
||||
The process view is the motivating lens — start from what the local compute sees, then
|
||||
build the CP that drives it.
|
||||
|
||||
- **The role is a function:** typed chunks come in, get computed, typed chunks go out,
|
||||
through ports the process exposes. For the CP these ports are an **arbitrary address +
|
||||
handler space**; the first iteration uses one inbound and one outbound, but nothing
|
||||
assumes that count. Inbound and outbound ports are **independent** — not a coupled 1:1
|
||||
pass-through; the source and the sink in a single process are unrelated.
|
||||
- **The process is driven, not autonomous.** It waits for the CP to stage a chunk,
|
||||
computes, hands the result back. (We lean toward a blocking pull as the natural shape
|
||||
for a single straight-line GPU worker, but that's a hedge, not a committed API — the
|
||||
load-bearing point is that the CP drives.)
|
||||
- **A chunk is a typed tensor, not bytes** — fully parsed, sanitized, shaped, i.e.
|
||||
everything except the handoff to tinygrad and the GPU copy, which is the one step the
|
||||
process performs. Shape/dtype are already determined inside the process by its role;
|
||||
they aren't designed here, and we stay abstract about the type machinery for now.
|
||||
- **The control plane handles ordering** and correlation between inbound and outbound
|
||||
chunks. The process tags and tracks nothing.
|
||||
- **The sink carries its destination.** The process knows the next address and binds it
|
||||
to the sink — the sink, as a type/struct, has the *next address* built in. **Resolving
|
||||
that address, serializing the chunk, and streaming it out is the CP's job.** This is
|
||||
where we reach for **swactor's distributed address space**: the CP outside the process
|
||||
receives and parses typed chunks, feeds them into the local process (tinygrad + GPU),
|
||||
then on the way out resolves the sink's address and streams the outbound chunk.
|
||||
- **Statefulness** (KV cache, position) is worker-internal and out of scope for this
|
||||
layer.
|
||||
- **Handle/payload representation and zero-copy staging are deferred** — performance
|
||||
lives there, behind an unchanged process-facing surface.
|
||||
|
||||
This is an **abstract in-process surface** — a good vantage to design from, not a locked
|
||||
API:
|
||||
- an inbound typed-chunk source the process is driven from,
|
||||
- an outbound typed sink the process pushes to, carrying its next address,
|
||||
- both shaped by the role, both independent.
|
||||
|
||||
The CP's mandate from here: **receive + parse typed chunks into a process, and resolve +
|
||||
serialize + stream typed chunks out of it.**
|
||||
|
||||
## End-to-end single pass (abstract)
|
||||
|
||||
Assumes SWIM converged and a start signal received. The pass is one repeating **edge**
|
||||
(role A → role B) plus two ends; the **orchestrator is just another participant** (sink →
|
||||
role0, source ← roleN), and **tokens are typed chunks** like activations.
|
||||
|
||||
- **Egress (role A, CP):** take the typed chunk from the sink; serialize → wire bytes;
|
||||
resolve the sink's abstract next-address → concrete route. Serialize and resolve are
|
||||
independent operations.
|
||||
- **Transport:** stream bytes A → B (iroh; not swactor's concern).
|
||||
- **Ingress (role B, CP):** reassemble → deserialize + validate against the role-known
|
||||
spec → stage → drive the local process.
|
||||
- **Ends:** orch → role0 ships *tokens*; roleN → orch ships a *token*. Not special cases
|
||||
— edges whose endpoint is the orch and whose chunk type is tokens.
|
||||
|
||||
The open seam, designed next: **addressing** — binding the sink's abstract next-address to
|
||||
a concrete remote endpoint.
|
||||
|
||||
## The edge: addressing, signals, and the byte boundary
|
||||
|
||||
How an edge is established and how chunks flow across it. Steady state assumes SWIM
|
||||
converged and edges established.
|
||||
|
||||
**Addressing — orchestrator-direct, no inter-end handshake**
|
||||
|
||||
- An edge is a **pair of stream actors** (distinct from the process-driving actor): a
|
||||
`Tx` (send) and an `Rx` (receive). **Neither end knows the other's actor address.**
|
||||
The data plane is addressed by **`(node_id, edge_id)`**: the Tx sends to the
|
||||
consumer's node by its `node_id`, and the `edge_id` at the head of the stream
|
||||
demuxes it to the right `Rx`.
|
||||
- **There is no inter-end negotiation** — the two ends never exchange a message; each
|
||||
is handed everything it needs at provisioning (the orchestrator owns placement).
|
||||
This still avoids per-chunk negotiation and the RTS-CTS/credit flow-control the
|
||||
directives rule out ("everything but the route is known ahead of time") — it just
|
||||
avoids the per-*edge* handshake too.
|
||||
- The data endpoint is **handed down as the stable `node_id`** (the orchestrator knows
|
||||
it from placement); iroh resolves the live path from it — so there's no stale
|
||||
mapping to rot. (Churn is deferred; we design the happy case where both ends are
|
||||
resolvable.)
|
||||
- **Byte-level backpressure is pushed into the streaming logic**, not the actor layer.
|
||||
Actors hold the edge; the streaming layer owns moving the bytes.
|
||||
|
||||
**The buffer-ownership baton & signals**
|
||||
|
||||
- The sink buffer is owned by either the process or the actor system at any instant;
|
||||
signals are the handoffs.
|
||||
- Egress: process **`done`** (buffer filled) → egress actor hands `(buffer, endpoint)` to
|
||||
streaming → streaming **`released`** returns the slot to the alloc pool.
|
||||
- Ingress: streaming **`landed`** → ingress actor inspects → **`ready`** drives the
|
||||
process.
|
||||
- **`done` is non-blocking.** The process never blocks after signalling it produced a
|
||||
chunk. It blocks only on **alloc** (acquiring a send slot) and on **recv** (a chunk
|
||||
arriving). With a single buffer, chunk *k+1*'s alloc blocks until chunk *k*'s
|
||||
`released`. (Double-buffering deferred.)
|
||||
|
||||
**The byte boundary — egress trusts, ingress verifies**
|
||||
|
||||
- **Egress:** no actor-level parsing. The buffer is correct by construction, the endpoint
|
||||
is bound from setup, and the receiver knows how to decode (type known a priori). The
|
||||
egress actor hands the buffer straight to the streaming layer. **swactor touches zero
|
||||
bytes on egress.**
|
||||
- **Ingress:** swactor enters the byte path only to **read/check, never to transform**.
|
||||
The **`Rx` and its edge service double as the inspector** — no new actor.
|
||||
- Behind the **stream abstraction (data-plane integrity):** the framed `[extent]`
|
||||
prefix is read and its `extent` bytes arrive complete (`extent ≤ capacity`).
|
||||
Size/length lives here; a short or torn frame means no `landed`.
|
||||
- The **ingress actor (control-plane gate):** given a complete chunk, clears it to
|
||||
drive the process (belongs to this edge, expected in sequence), then flags `ready`.
|
||||
The designated home for any sanity/terms check; thin in the happy path, but where
|
||||
checking lives so the process is never handed an unvetted chunk.
|
||||
- Net swactor byte-contract, both sides: **never transforms payload bytes; reads them
|
||||
only to inspect, and only on ingress.**
|
||||
|
||||
**Open (not yet decided):** the depth of the ingress check — pure terms/sequence gate vs.
|
||||
cracking the payload for a content-level (shape/dtype) sanity check before `ready`.
|
||||
|
||||
---
|
||||
|
||||
## Ingress check depth — decided (resolves "Open" above)
|
||||
|
||||
**Optimistic ingress: a chunk is accepted on its framed length alone.** If the
|
||||
`[extent]` prefix reads cleanly, `extent ≤ capacity`, and that many bytes arrive
|
||||
complete, they go to the process as-is — no peeking inside, no
|
||||
deserialize-to-validate, no shape/dtype content check. Correct-by-construction
|
||||
egress + a clean framed read on ingress is the entire gate. Nodes are trusted;
|
||||
content trust is total.
|
||||
|
||||
## The transport, minimal
|
||||
|
||||
- Bytes move over **iroh** (already fixed). One **ordered, reliable stream per
|
||||
edge** is the whole mechanism. Striping, chunk hashing/verification, and resume
|
||||
are **deferred**.
|
||||
- We are **not** building on the existing `crates/datastore/src/streams/` module —
|
||||
treated as not-ready; design fresh.
|
||||
|
||||
## The transfer actors — a reusable primitive
|
||||
|
||||
- An edge's ends are **two actor types: `Tx` (send) and `Rx` (receive)**, one per
|
||||
edge-end. They are a **general blob-moving primitive** — no notion of role,
|
||||
compute, or the graph.
|
||||
- Each is **pre-told the blob's `capacity`** (a `BlobSpec`; the per-chunk byte
|
||||
ceiling — `dtype`/`shape` live in the role layer above, not the transport) and owns
|
||||
**zero-copy (de)serialization**: Tx views the producer's buffer as bytes (no
|
||||
transform); Rx views landed bytes back as a typed value. Rx **pre-allocates its
|
||||
landing buffer** from `capacity` at setup; each chunk's actual `extent ≤ capacity`
|
||||
varies per step and rides the wire as a length-prefix.
|
||||
|
||||
## Edge establishment — orchestrator-direct (contract #1)
|
||||
|
||||
- **No derived/hashed addresses, no gossip discovery, no polling.** The
|
||||
**orchestrator owns placement and wires edges directly** — it hands each end
|
||||
everything it needs. Addresses stay runtime-assigned (random); identities are
|
||||
*handed over*, never computed or discovered.
|
||||
- **The data plane is addressed by `(node_id, edge_id)`.** The orchestrator hands the
|
||||
`Tx` its consumer's stable `node_id` (known from placement) in the provision
|
||||
message; iroh resolves the live path from it, so no static transport mapping can
|
||||
rot. There is no peer-to-peer endpoint exchange.
|
||||
- **Per-node `Provisioner`** spawns the local `Tx`/`Rx`. The node's **edge service**
|
||||
(the ALPN-aware `IrohDriver`) demuxes incoming streams to the right `Rx` by the
|
||||
run-global **`edge_id`** at the head of the stream — not a separate `Listener`
|
||||
actor; the demux is a tokio task on the existing endpoint.
|
||||
- **No inter-end handshake; race-free by a single barrier.** The two ends never
|
||||
exchange a message — each is fully equipped at provisioning, which fans out **in
|
||||
parallel** (no Tx-before-Rx ordering). Race-freedom is one barrier: a node acks
|
||||
`Provisioned` only after its `Rx` ends have **registered** their landing, and the
|
||||
orchestrator injects the prompt only after **every** node has acked — so no stream
|
||||
can arrive before its `Rx` is registered, with no per-edge ordering.
|
||||
- **The establishment "exchange" is just the `edge_id` stream preamble (Tx→Rx).** No
|
||||
`EndpointOffer`, no `Ready`, no `EdgeReady`, no `tx_addr` relay. READY is a local
|
||||
terminal state: `Tx` is ready on spawn; `Rx` is ready once it has pre-allocated and
|
||||
registered its landing buffer.
|
||||
- **Kickoff, not broadcast.** The orchestrator is just another participant; once all
|
||||
nodes are `Provisioned` it injects the driving prompt into role0 on its own
|
||||
outbound edge. Every other node derives its own state from arriving data.
|
||||
- **Asymmetry:** `Tx` never needs `Rx`'s actor address — and now neither end needs
|
||||
the other's; they are coupled only by the shared `edge_id`.
|
||||
|
||||
## Still deferred (unchanged stance)
|
||||
|
||||
Churn/failure policy, teardown, the fan-in **join**, and the per-chunk zero-copy
|
||||
baton (contract #4) remain out of scope. (The **start signal** is no longer here —
|
||||
it's decided: there is none; see "Kickoff, not broadcast" above.)
|
||||
|
||||
---
|
||||
|
||||
# Blob streaming & host allocation
|
||||
|
||||
Detailing blob streaming + host allocation (contract #4). Full flow in
|
||||
`BLOB_STREAMING.md` (draft).
|
||||
|
||||
## The host substrate — one sparse arena per node
|
||||
- Blob bytes live on the host in a single `memfd` arena, mapped by **both** the
|
||||
node process and the Python GPU worker.
|
||||
- The arena is reserved **big and sparse** (lazy tmpfs backing) and mapped **once**;
|
||||
the mapping is never moved. Edges are **regions sub-allocated** from it and
|
||||
returned on teardown, so topology is **dynamic without touching the fd**.
|
||||
|
||||
## The slot handoff
|
||||
- Each edge owns a **ring of N `capacity`-sized slots** (default 2). A slot is owned
|
||||
at any instant by exactly one of {iroh, GPU worker}; ownership passes by **signal,
|
||||
two per direction**, over the existing stdin/stdout pipe. Actors never touch a
|
||||
payload byte; the node process is the sole authority on slot state.
|
||||
|
||||
## The GPU boundary
|
||||
- tinygrad reads/writes slots **in place** via a `memoryview` (`copyin`/`copyout`);
|
||||
a blob **never enters Python's heap**. The host↔device DMA is the worker's only
|
||||
copy.
|
||||
|
||||
## Streaming
|
||||
- **One long-lived iroh uni-stream per edge**; `edge_id` preamble once; then
|
||||
length-prefixed chunks `[extent: u32][extent bytes]`, so the **prefix is the
|
||||
frame** (each chunk's `extent ≤ capacity` varies per step; the slot is sized once
|
||||
to `capacity`). Refines establishment's per-call `open_uni` into a persistent
|
||||
stream, and its single landing buffer into the ring.
|
||||
|
||||
## Deferred to their own passes
|
||||
- Host-pinning + removing tinygrad's CUDA `copyin` bounce (perf).
|
||||
|
||||
---
|
||||
|
||||
# Activation stream transport — the iroh ↔ swactor boundary
|
||||
|
||||
How activation tensors actually cross a READY edge, and how the swactor actors,
|
||||
the iroh driver, and the GPU worker are wired to move them. Full spec in
|
||||
`STREAM_TRANSPORT.md`. Scoped to activations (not gossip, not weights).
|
||||
|
||||
## The decision in one line
|
||||
- **One persistent uni-stream per edge; the bytes ride it in place, in the arena;
|
||||
swactor passes only slot indices, never bytes.**
|
||||
|
||||
## Stream shape — persistent, length-framed
|
||||
- **One long-lived uni-stream per edge**, not a stream per tensor. `edge_id`
|
||||
preamble once; then back-to-back length-prefixed tensors. Because each tensor's
|
||||
size varies per step (prefill many rows, decode one), each rides a fixed-width
|
||||
`u32` `extent` prefix — `[extent][extent bytes]`, `extent ≤ capacity` — and **the
|
||||
prefix *is* the frame**; the slot is sized once to `capacity`. Stream-per-message
|
||||
was rejected: it pays a task spawn + alloc + a `max_concurrent_uni_streams` slot
|
||||
per tensor and buys nothing.
|
||||
- **QUIC owns reliability.** No app-level fragmentation (the ring already pipelines
|
||||
a whole-tensor object) and no striping (one connection over one path shares a
|
||||
single congestion window — striping needs multipath we don't have).
|
||||
|
||||
## Zero-copy — bytes never enter an actor
|
||||
- Bytes live in the **shared arena** from the worker's `copyout` to the far
|
||||
worker's `copyin`, moved **in place**: the wire `read_exact`/`write_all`s arena
|
||||
slots directly. Huge tensors are never copied into a `Vec` or an actor message.
|
||||
- **swactor moves slot indices (`usize`), not bytes.** The only payload-byte
|
||||
touchers are the **GPU worker** and a per-edge **byte-pump task**.
|
||||
|
||||
## Roles — tokio stays behind the driver wall
|
||||
- **Driver** owns the endpoint, the connection cache, the `edge_id` demux, and
|
||||
**spawns/owns the byte-pump tasks** — the one place tokio lives, async byte
|
||||
readers and writers.
|
||||
- **Reads are tokio-native** — `read_exact`/`write_all` only advance when polled on
|
||||
the runtime — so a byte-pump *task* is unavoidable while iroh is the transport.
|
||||
|
||||
## MVP
|
||||
- **MVP = one dedicated byte-pump task per edge-end**, driver-owned. The
|
||||
**actor↔driver contract is slot-indices-in, slot-indices-up**, so the pump
|
||||
*mechanism* is a driver-internal detail. Toward removing tokio, it can later
|
||||
collapse to one-task-per-connection or to the node loop polling the stream
|
||||
futures — a driver refactor that **touches no actor**.
|
||||
- **No single-threaded-tick assumption:** all cross-thread traffic is `deliver_raw`
|
||||
+ the slot channels + actor isolation, so the design survives a multi-threaded
|
||||
runtime.
|
||||
|
||||
## Deferred
|
||||
- The swactor ↔ GPU-worker pipe **mechanism** (async-Python rework) — its
|
||||
`ready/consumed/filled/drained` signals are fixed here, the transport is not.
|
||||
|
||||
---
|
||||
|
||||
# Remaining orchestration decisions before implementation
|
||||
|
||||
The first concrete workload is **sharded inference of large models across
|
||||
prosumer GPUs**. Do not prematurely generalize this into a broad graph IR. The
|
||||
next spec layer should describe only the workload/role shape needed for that use
|
||||
case: model partitioning, role boundaries, edge object specs, weight/shard
|
||||
ownership, and the runtime sequence for prefill/decode.
|
||||
|
||||
The **orchestrator remains the authority** for resource provisioning, placement,
|
||||
and run staging. Nodes do not need to advertise capabilities after boot as part
|
||||
of this design; the orchestrator provisions the pool and already knows the
|
||||
resource inventory it is placing onto.
|
||||
|
||||
The missing spec surface is therefore:
|
||||
- how the orchestrator decides roles and placement from model shape plus
|
||||
provisioned hardware,
|
||||
- what a role provisioning message contains,
|
||||
- how model weights/shards are assigned, fetched, loaded, and declared ready,
|
||||
- what execution semantics the first inference path guarantees,
|
||||
- and what behavioral contracts are required for reliable tests.
|
||||
|
||||
Observability should be added when the descriptive specs are converted into
|
||||
behavioral contracts for testing. The goal is not just prose architecture, but
|
||||
testable run behavior: provisioned, loaded, ready, object produced/consumed,
|
||||
completed, faulted, and torn down.
|
||||
1397
GPU_WORKER_INTERFACE_SPEC.md
Normal file
1397
GPU_WORKER_INTERFACE_SPEC.md
Normal file
File diff suppressed because it is too large
Load diff
518
ORCHESTRATION_SPEC.md
Normal file
518
ORCHESTRATION_SPEC.md
Normal file
|
|
@ -0,0 +1,518 @@
|
|||
# GGUF Pipeline Orchestration - MVP Specification
|
||||
|
||||
**Status:** draft buildout specification.
|
||||
|
||||
**Relationship to other documents.** `DESIGN_DIRECTIVES.md` remains steering
|
||||
context. `RING_BACKPRESSURE_SPEC.md` defines edge establishment, object records,
|
||||
rings, pumps, and teardown. `GPU_WORKER_INTERFACE_SPEC.md` defines worker
|
||||
startup, shard/weight binding, and `ExecuteStep`. This document defines the
|
||||
missing layer above them: how the orchestrator plans and stages one linear GGUF
|
||||
pipeline inference run.
|
||||
|
||||
This document is intentionally not a general graph specification.
|
||||
|
||||
---
|
||||
|
||||
## 1. Scope
|
||||
|
||||
The MVP workload is pipeline-parallel inference from a GGUF model:
|
||||
|
||||
```text
|
||||
orchestrator --tokens--> stage 0 --activations--> stage 1 --activations-->
|
||||
... --activations--> stage N-1 --tokens--> orchestrator
|
||||
```
|
||||
|
||||
The orchestrator is a control participant and token endpoint. It does not run GPU
|
||||
compute. GPU compute happens only inside provisioned stages.
|
||||
|
||||
This spec covers:
|
||||
|
||||
- building a linear stage plan from a GGUF model and a provisioned GPU pool
|
||||
- assigning GGUF shard/layer ranges to stages
|
||||
- assigning run-scoped edge ids
|
||||
- provisioning each stage with the facts it needs
|
||||
- defining the readiness barrier before prompt injection
|
||||
- defining the orchestrator and stage FSMs
|
||||
- defining security and correctness guarantees for the MVP behavior
|
||||
|
||||
This spec does not cover:
|
||||
|
||||
- arbitrary graph execution
|
||||
- automatic placement optimization
|
||||
- batching, speculative decoding, or continuous serving
|
||||
- failure recovery by re-placement
|
||||
- detailed ring, pump, or worker internals already specified elsewhere
|
||||
- behavioral test contracts; those come after the full system shape is drafted
|
||||
|
||||
---
|
||||
|
||||
## 2. Core Responsibilities
|
||||
|
||||
The orchestrator owns:
|
||||
|
||||
- run ids
|
||||
- stage count and stage order
|
||||
- GGUF shard/layer assignment
|
||||
- edge id assignment
|
||||
- stage provisioning
|
||||
- the global readiness barrier
|
||||
- prompt injection
|
||||
- final token consumption
|
||||
- EOS and `max_tokens` stop policy
|
||||
- run-level fault and teardown
|
||||
|
||||
Each stage owns:
|
||||
|
||||
- loading its assigned GGUF shard/layer range
|
||||
- configuring its local GPU worker
|
||||
- establishing its local edge ends
|
||||
- converting loaded inbound objects into local `ExecuteStep` calls
|
||||
- producing the next object on its outbound edge
|
||||
- reporting readiness and faults to the orchestrator
|
||||
|
||||
The ring and worker specs own the byte movement and GPU worker command details.
|
||||
This spec only decides which stages and edges exist and what sequence of control
|
||||
events makes the run progress.
|
||||
|
||||
---
|
||||
|
||||
## 3. Run Plan
|
||||
|
||||
The orchestrator builds one `RunPlan` before provisioning:
|
||||
|
||||
```rust
|
||||
struct RunPlan {
|
||||
run_id: RunId,
|
||||
model: GgufModelPlan,
|
||||
stages: Vec<StagePlan>,
|
||||
edges: Vec<EdgePlan>,
|
||||
max_tokens: u32,
|
||||
}
|
||||
|
||||
struct GgufModelPlan {
|
||||
model_id: String,
|
||||
gguf_source: GgufSource,
|
||||
num_layers: u32,
|
||||
hidden_dim: u32,
|
||||
dtype_family: DTypeFamily,
|
||||
dtype_width_bytes: u32,
|
||||
max_seq_len: u32,
|
||||
eos_token_id: u32,
|
||||
}
|
||||
```
|
||||
|
||||
`gguf_source` may identify a whole GGUF file, a pre-split shard collection, or a
|
||||
cache key. The orchestration contract is the assigned layer range. Whether the
|
||||
node reads only part of a whole GGUF file or receives a physically pre-split
|
||||
artifact is a local loading detail.
|
||||
|
||||
Each stage receives a contiguous layer range:
|
||||
|
||||
```rust
|
||||
struct StagePlan {
|
||||
run_id: RunId,
|
||||
stage_index: u32,
|
||||
stage_count: u32,
|
||||
node_id: NodeId,
|
||||
gguf_source: GgufSource,
|
||||
layer_start: u32,
|
||||
layer_end_exclusive: u32,
|
||||
inbound_edge: EdgeId,
|
||||
outbound_edge: EdgeId,
|
||||
}
|
||||
```
|
||||
|
||||
For stage `0`, `inbound_edge` is the token edge from the orchestrator. For the
|
||||
last stage, `outbound_edge` is the token edge back to the orchestrator. Interior
|
||||
edges carry activations.
|
||||
|
||||
Each edge has exactly one producer and one consumer:
|
||||
|
||||
```rust
|
||||
struct EdgePlan {
|
||||
run_id: RunId,
|
||||
edge_id: EdgeId,
|
||||
kind: EdgeKind,
|
||||
producer: EdgeEndpoint,
|
||||
consumer: EdgeEndpoint,
|
||||
object_spec: ObjectSpec,
|
||||
ring_spec: RingSpec,
|
||||
}
|
||||
|
||||
enum EdgeKind {
|
||||
TokenIn,
|
||||
Activation,
|
||||
TokenOut,
|
||||
}
|
||||
|
||||
enum EdgeEndpoint {
|
||||
Orchestrator { node_id: NodeId },
|
||||
Stage { node_id: NodeId, stage_index: u32 },
|
||||
}
|
||||
```
|
||||
|
||||
The orchestrator assigns all `edge_id`s. Stage code never derives edge ids from
|
||||
names, layer ranges, peer ids, or hashes.
|
||||
|
||||
---
|
||||
|
||||
## 4. Stage Provisioning Message
|
||||
|
||||
The orchestrator sends one provision message to each stage node:
|
||||
|
||||
```rust
|
||||
ProvisionStage {
|
||||
run_id: RunId,
|
||||
stage_index: u32,
|
||||
stage_count: u32,
|
||||
gguf_source: GgufSource,
|
||||
layer_start: u32,
|
||||
layer_end_exclusive: u32,
|
||||
inbound: InboundEdgeProvision,
|
||||
outbound: OutboundEdgeProvision,
|
||||
model: StageModelFacts,
|
||||
}
|
||||
|
||||
struct InboundEdgeProvision {
|
||||
edge_id: EdgeId,
|
||||
kind: EdgeKind,
|
||||
object_spec: ObjectSpec,
|
||||
ring_spec: RingSpec,
|
||||
}
|
||||
|
||||
struct OutboundEdgeProvision {
|
||||
edge_id: EdgeId,
|
||||
kind: EdgeKind,
|
||||
consumer_node_id: NodeId,
|
||||
object_spec: ObjectSpec,
|
||||
ring_spec: RingSpec,
|
||||
}
|
||||
|
||||
struct StageModelFacts {
|
||||
model_id: String,
|
||||
hidden_dim: u32,
|
||||
dtype_family: DTypeFamily,
|
||||
dtype_width_bytes: u32,
|
||||
max_seq_len: u32,
|
||||
}
|
||||
```
|
||||
|
||||
The inbound edge is established locally as a receive edge. The outbound edge is
|
||||
established locally as a send edge to `consumer_node_id`. For the last stage,
|
||||
`consumer_node_id` is the orchestrator node.
|
||||
|
||||
The node-local stage controller translates this provision message into the lower
|
||||
level operations:
|
||||
|
||||
```text
|
||||
configure worker for assigned stage
|
||||
load/bind assigned GGUF shard or layer range
|
||||
provision receive edge for inbound.edge_id
|
||||
provision send edge for outbound.edge_id
|
||||
report StageReady when all required local work is complete
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Orchestrator FSM
|
||||
|
||||
The orchestrator has one run-level FSM:
|
||||
|
||||
```text
|
||||
Planning
|
||||
build RunPlan
|
||||
validate layer ranges and edge ids
|
||||
-> Provisioning
|
||||
|
||||
Provisioning
|
||||
send ProvisionStage to every stage node
|
||||
create local token producer for token-in edge
|
||||
create local token consumer for token-out edge
|
||||
-> WaitingReady
|
||||
|
||||
WaitingReady
|
||||
on StageReady for every stage and local token endpoints ready
|
||||
-> Running
|
||||
on StageFault or timeout
|
||||
-> Faulted
|
||||
|
||||
Running
|
||||
inject prompt token object on token-in edge, sequence 0
|
||||
consume token objects from token-out edge in sequence order
|
||||
after token sequence k:
|
||||
if EOS or max_tokens reached -> Completed
|
||||
else inject token object sequence k + 1 on token-in edge
|
||||
on StageFault, edge fault, local token endpoint fault, or timeout
|
||||
-> Faulted
|
||||
|
||||
Completed
|
||||
stop injecting tokens
|
||||
finalize output text
|
||||
-> TearingDown
|
||||
|
||||
Faulted
|
||||
stop injecting tokens
|
||||
record one run-level failure reason
|
||||
-> TearingDown
|
||||
|
||||
TearingDown
|
||||
send StopRun to all provisioned stages
|
||||
tear down local token endpoints
|
||||
wait for StageStopped from every stage or timeout
|
||||
-> Done
|
||||
|
||||
Done
|
||||
terminal
|
||||
```
|
||||
|
||||
There is no broadcast start message. The start signal is the first token object
|
||||
written by the orchestrator after the readiness barrier.
|
||||
|
||||
---
|
||||
|
||||
## 6. Stage FSM
|
||||
|
||||
Each provisioned stage has one node-local stage controller. It is control-path
|
||||
only: it watches local worker and edge events and issues worker commands. It does
|
||||
not move payload bytes.
|
||||
|
||||
```text
|
||||
Unprovisioned
|
||||
on ProvisionStage from authorized orchestrator
|
||||
validate run_id and stage assignment
|
||||
-> Preparing
|
||||
|
||||
Preparing
|
||||
configure local worker for assigned GGUF range
|
||||
start GGUF shard/range load and bind
|
||||
establish inbound receive edge
|
||||
establish outbound send edge
|
||||
when worker configured, shard bound, and both edge ends ready
|
||||
emit StageReady
|
||||
-> Ready
|
||||
on any required setup failure
|
||||
emit StageFault
|
||||
-> Faulted
|
||||
|
||||
Ready
|
||||
on inbound ObjectLoaded(sequence = s)
|
||||
if s is the next expected sequence
|
||||
issue ExecuteStep for sequence s
|
||||
-> Executing
|
||||
else
|
||||
emit StageFault(sequence violation)
|
||||
-> Faulted
|
||||
on StopRun
|
||||
-> Stopping
|
||||
|
||||
Executing
|
||||
worker runs exactly one step for the loaded inbound object
|
||||
worker writes the output object to outbound edge with the same sequence
|
||||
on StepCompleted
|
||||
release any per-step input handle that is no longer needed
|
||||
-> Ready
|
||||
on StepFailed or output fault
|
||||
emit StageFault
|
||||
-> Faulted
|
||||
|
||||
Faulted
|
||||
reject new run work
|
||||
wait for StopRun
|
||||
-> Stopping
|
||||
|
||||
Stopping
|
||||
stop local edges
|
||||
release per-run device objects
|
||||
stop or reset worker according to local policy
|
||||
emit StageStopped
|
||||
-> Stopped
|
||||
|
||||
Stopped
|
||||
terminal for this run
|
||||
```
|
||||
|
||||
The controller is the component that decides when `ExecuteStep` is called. The
|
||||
orchestrator does not issue per-stage execute commands during the run. Once the
|
||||
prompt object is injected, stage execution is driven by object arrival and local
|
||||
readiness.
|
||||
|
||||
---
|
||||
|
||||
## 7. Execution Semantics
|
||||
|
||||
Sequence `0` is prefill.
|
||||
|
||||
```text
|
||||
orchestrator writes prompt token object sequence 0
|
||||
stage 0 executes prefill over prompt tokens
|
||||
stage 0 writes activation sequence 0
|
||||
each interior stage executes prefill over activation sequence 0
|
||||
last stage executes prefill and writes token sequence 0
|
||||
orchestrator consumes token sequence 0
|
||||
```
|
||||
|
||||
Decode sequences are `1..`:
|
||||
|
||||
```text
|
||||
orchestrator writes one-token object sequence k
|
||||
stage 0 executes decode for sequence k
|
||||
each downstream stage executes decode for sequence k
|
||||
last stage writes token sequence k
|
||||
orchestrator consumes token sequence k
|
||||
```
|
||||
|
||||
The orchestrator sends sequence `k + 1` only after consuming token sequence `k`
|
||||
and deciding the run should continue.
|
||||
|
||||
For every stage:
|
||||
|
||||
- the inbound object sequence is the output object sequence
|
||||
- one active `ExecuteStep` per stage is allowed in the MVP
|
||||
- a stage cannot execute before its assigned GGUF shard/range is loaded and bound
|
||||
- a stage cannot execute before its inbound object is loaded
|
||||
- a stage cannot produce to an edge that is not ready
|
||||
|
||||
The last stage samples or otherwise produces token ids as part of its GPU worker
|
||||
step. The orchestrator consumes those token ids, accumulates output, applies EOS
|
||||
and `max_tokens`, and writes the next token object only when continuing.
|
||||
|
||||
---
|
||||
|
||||
## 8. Control Messages And Events
|
||||
|
||||
These are schematic message shapes, not final Rust APIs.
|
||||
|
||||
Orchestrator to stage:
|
||||
|
||||
```rust
|
||||
ProvisionStage { ... }
|
||||
|
||||
StopRun {
|
||||
run_id: RunId,
|
||||
reason: StopReason,
|
||||
}
|
||||
```
|
||||
|
||||
Stage to orchestrator:
|
||||
|
||||
```rust
|
||||
StageReady {
|
||||
run_id: RunId,
|
||||
stage_index: u32,
|
||||
node_id: NodeId,
|
||||
}
|
||||
|
||||
StageFault {
|
||||
run_id: RunId,
|
||||
stage_index: u32,
|
||||
node_id: NodeId,
|
||||
reason: StageFaultReason,
|
||||
}
|
||||
|
||||
StageStopped {
|
||||
run_id: RunId,
|
||||
stage_index: u32,
|
||||
node_id: NodeId,
|
||||
}
|
||||
```
|
||||
|
||||
Optional setup progress events may exist for diagnostics, but `StageReady`,
|
||||
`StageFault`, and `StageStopped` are the only required run-level events in this
|
||||
draft.
|
||||
|
||||
---
|
||||
|
||||
## 9. Object Specs
|
||||
|
||||
Token edges carry token objects. The prompt token object may contain multiple
|
||||
token ids for prefill. Decode token objects contain one token id.
|
||||
|
||||
Activation edges carry activation objects with runtime extent bounded by model
|
||||
shape:
|
||||
|
||||
```text
|
||||
max_extent = max_seq_len * hidden_dim * dtype_width_bytes
|
||||
```
|
||||
|
||||
The object record and ring behavior are defined by `RING_BACKPRESSURE_SPEC.md`.
|
||||
This orchestration spec only requires that all stage plans for a run agree on the
|
||||
model facts used to build those object specs.
|
||||
|
||||
---
|
||||
|
||||
## 10. Security Model
|
||||
|
||||
Nodes are trusted. The system does not attempt trustless verification,
|
||||
adversarial tensor validation, Sybil defense, or incentive enforcement.
|
||||
|
||||
The orchestrator is the authority for run topology. A stage accepts run
|
||||
provisioning only from the authorized orchestrator for its node.
|
||||
|
||||
Stages reject:
|
||||
|
||||
- unknown `run_id`
|
||||
- stale `run_id`
|
||||
- duplicate provisioning for an already-active run unless explicitly stopped
|
||||
- edge ids not present in the provision message
|
||||
- peer rewiring requests from another stage
|
||||
|
||||
`edge_id`s are run-scoped capabilities for wiring and demux. They are not a
|
||||
cryptographic trust boundary between trusted nodes, but a stage must still reject
|
||||
objects and stream setup that do not match its active run plan.
|
||||
|
||||
The orchestrator may tear down a run at any time. Stages must treat `StopRun` for
|
||||
their active `run_id` as authoritative.
|
||||
|
||||
---
|
||||
|
||||
## 11. Correctness Guarantees
|
||||
|
||||
Layer assignment:
|
||||
|
||||
- stage layer ranges are contiguous
|
||||
- stage layer ranges do not overlap
|
||||
- the union of stage layer ranges covers the intended GGUF block range
|
||||
- every stage has exactly one assigned range
|
||||
|
||||
Edge assignment:
|
||||
|
||||
- every `edge_id` is unique within a run
|
||||
- every edge has exactly one producer and one consumer
|
||||
- token-in is produced by the orchestrator and consumed by stage `0`
|
||||
- token-out is produced by stage `N - 1` and consumed by the orchestrator
|
||||
- activation edge `i` is produced by stage `i` and consumed by stage `i + 1`
|
||||
|
||||
Readiness:
|
||||
|
||||
- the orchestrator does not inject prompt tokens before every stage reports
|
||||
`StageReady`
|
||||
- a stage does not report `StageReady` before its worker, shard/range binding,
|
||||
inbound edge, and outbound edge are ready
|
||||
|
||||
Execution:
|
||||
|
||||
- prefill is sequence `0`
|
||||
- decode sequences are strictly increasing
|
||||
- a stage executes sequence `s` only after loading inbound object sequence `s`
|
||||
- a stage output uses the same sequence as its input
|
||||
- the orchestrator injects sequence `s + 1` only after consuming token sequence
|
||||
`s`
|
||||
|
||||
Termination:
|
||||
|
||||
- each run has one terminal outcome: completed, faulted, or torn down
|
||||
- after a run faults, the orchestrator stops injecting new token objects
|
||||
- teardown is sent to every stage that was provisioned for the run
|
||||
|
||||
---
|
||||
|
||||
## 12. Deferred
|
||||
|
||||
- placement optimization
|
||||
- physical GGUF shard format
|
||||
- multiple concurrent runs on one stage chain
|
||||
- batching and speculative decoding
|
||||
- direct stage-to-stage token feedback that bypasses the orchestrator
|
||||
- warm reuse policy across prompts
|
||||
- re-placement after node failure
|
||||
- behavioral test matrix and observability schema
|
||||
1522
RING_BACKPRESSURE_SPEC.md
Normal file
1522
RING_BACKPRESSURE_SPEC.md
Normal file
File diff suppressed because it is too large
Load diff
Loading…
Reference in a new issue