Concepts.
Hub, nodes, replicas, trust levels, and pipeline-sharded devices: how Relay decides where a model runs, and how much VRAM it reserves to run it.
0.1.0-alpha.N → -beta.N → -rc.N → 0.1.0 ladder. The wire protocol between hub and node, the on-disk state layout and the CLI surface can all change without a deprecation period; /v1 tracks the OpenAI shape and is the most stable thing here. Nothing is accredited or covered by a compatibility promise. The measurements further down are runs on two specific machines — a record of what those two cards did, not a benchmark to size a deployment against.Vocabulary
| Term | Meaning |
|---|---|
| hub (host) | The machine running relay serve. Owns every model file, serves /v1, runs the scheduler and the tunnel terminator. |
| node | A machine lending VRAM over one outbound tunnel; it holds only a cache of files the hub pushed. Run it two ways: relay up in a terminal, or relay-tray — the same agent with a notification-area icon instead of a console. The tray build ships for Windows and Linux (built for the GUI subsystem on Windows, so it opens no console window); on a system with no notification area it refuses to start and tells you to use relay up instead. |
| pool | Hub plus its connected nodes, and the VRAM they add up to. |
| replica | A whole-model llama-server on one device (host or node). Independent, full speed. |
| shard | One model split by layer across the hub and one or more nodes via llama.cpp's RPC backend. One logical device, turn-taking. |
| trust level | A property of a node, not of a placement: replicas (the default) or shard-only. The operator sets it — on the enrollment token (relay token --shard-only) or afterwards (relay node set ID --trust replicas|shard-only) — and the node never chooses its own. A replicas node may run a whole-model replica and therefore sees the plaintext prompt and completion for requests routed to it. A shard-only node may only ever run a pipeline-sharded slice, and sees activations for the layers it holds, never a full request. This is enforced in placement, not advisory: see step 3 below. |
The hub owns every model
Weights live on the hub's disk only. Nodes never fetch from the internet; the hub pushes a hash-verified copy of a GGUF into a node's cache before it can run anything, and that cache is a copy the hub granted, not a source of truth. Nodes are capacity, not independent inference servers.
client ──/v1──▶ HUB ─────────────────────────────────────────────────────
│ scheduler: replica (host) │ replica (node) │ shard
│ store: models, nodes, replicas, shards, files
│ llama-server (host replica / shard head)
│ tunnel terminator ── one yamux session per node
│ streams: control │ rpc │ http │ file
▼
NODE (relay up / relay-tray)
│ ggml-rpc-server ← rpc streams (shard stage)
│ llama-server ← http streams (replica)
│ model cache ← file streams (hub pushes weights)
│ availability: idle/busy/paused, schedule, tray togglePlacement order: replica first, shard as fallback
For a request naming model M, Ensure tries, in order:
| Step | Condition |
|---|---|
| 0. Existing replica | A ready, still-viable replica of M exists → route to the one with the fewest in-flight requests. Unviable ones (node gone, paused, unhealthy) are pruned first. |
| 1. Host replica | Host free VRAM (clamped by RELAY_VRAM_RESERVE_MB) ≥ need_fit → run on the hub. The host runs one llama-server per resident model and admits a new one on the fit-check alone. This step never evicts a resident to make room — doing so would prefer the host to a node purely because a request named a model the host also happens to fit. Eviction is the retry below, not part of the fit. |
| 2. Host-only stop | M is marked host-only (relay model set NAME --host-only) → placement returns here. Steps 3 and 4 are never attempted, and the eviction retry will not evict on a node or unwind a shard for it either. A host-only model's own host residency is still evictable when idle and unpinned; host-only is a privacy control, not a residency one. |
| 3. Node replica | Trust gate first: the candidate set is narrowed to nodes whose trust level is replicas, so a shard-only node is filtered out of every whole-replica path. Then some connected, unpaused, replicas-trusted node with free VRAM ≥ need_fit, tried largest-free first → push the file if the node lacks it, start its llama-server, proxy over an HTTP stream. |
| 4. Shard | Σ free VRAM across ≤ RELAY_PIPELINE_DEPTH devices ≥ need_served → layer-split via --rpc backends. A shard-only node is a full participant here, lending its whole free VRAM exactly like a replicas-trusted one. If the host can't hold even one layer, the plan would be a one-node shard anyway, so a full node replica is placed instead — but that substitution runs the same trust gate as step 3 on its candidates, so it only ever lands on a replicas-trusted node. A shard-only node that could hold the whole model is placed as an actual one-node shard, never converted to a replica. |
| Retry — evict to fit | Every step above failed → evict idle, unpinned residents that are in the way (host, then node, then the current shard residency, largest-relief first) and run steps 1-4 once more. A single retry, not a loop: a second failure means the pool genuinely has no room. There is no cooldown against evict-thrash, so two models that each fit alone but not together will evict each other on alternating requests; relay model set NAME --pin is the lever — a pinned model is never listed as evictable. |
| 5. Unavailable | None of the above → 503 model_unavailable naming need, host free, best node free (scoped to replica-eligible nodes only), and pool aggregate. A host-only model gets its own reason instead, naming only the host's free VRAM — a node's is memory that model can never use. |
A replica is torn down when its node disconnects or pauses. On the pause path the hub also tells the node to stop its llama-server (30s timeout) so the card is actually released; on reconnect, a node reporting a replica the hub no longer expects is told to stop it.
Shard device order: the hub's GPU runs last
Within a shard, the pipeline stages are ordered so the hub's own GPU carries the last layers, not the first — the planner's free-VRAM/throughput split runs as normal, then hostStageLast moves whichever stage landed on the host to the end of the sequence and renumbers every stage's layer range to match.
The output layer and sampling always run on whichever device holds the last stage, so putting the hub there means the ~600 KB logits vector at the vocabulary size this pool uses never has to cross the network on every generated token — only a hidden state does. Measured on this pool's own fingerprint, that reordering alone raised a 14B shard's generation throughput 32-40% and was the difference between serving one of four concurrent clients and serving all four.
How much VRAM a replica needs
KV = kv_bytes_per_token × ctx × slots × kv_scale
overhead = weights × 3% + 384 MB + KV × 5%
need = weights + KV + overhead
ctx = RELAY_CONTEXT_SIZE, or min(RELAY_REPLICA_CTX, RELAY_CONTEXT_SIZE) when RELAY_REPLICA_CTX > 0
slots = RELAY_PARALLEL_SLOTS
kv_scale = 1.0 (f16) · 0.5 (q8_0) · 0.28 (q4_0) ← RELAY_KV_CACHE_TYPEweights is the registered required_vram_mb, falling back to the file size. The KV term is zero when the GGUF header gave no per-token estimate. The breakdown is logged at debug level on every fit-check.
RELAY_CONTEXT_SIZE × RELAY_PARALLEL_SLOTS, and RELAY_KV_CACHE_TYPE is the sanctioned way to shrink the honest number. A positive RELAY_REPLICA_CTX re-enables the old under-reservation for a deployment that knows its traffic never saturates every slot at full context. KV-cache quantization
RELAY_KV_CACHE_TYPE sets the K/V cache precision (--cache-type-k/--cache-type-v) for every llama-server the daemon launches or asks a node to launch: host replicas, node replicas, and shards. f16 is llama-server's own default. q8_0 halves the KV footprint at close to no quality loss; q4_0 roughly quarters it at a measurable cost. Because the fit-check uses the same scale, quantizing the cache is the intended lever for a model whose weights fit a node but whose full-precision KV reservation pushes it a few hundred MB over.
llama.cpp needs flash attention to run a quantized V cache, so a quantized type forces --flash-attn on unless RELAY_FLASH_ATTN says otherwise. The type in effect is reported as kv_cache_type in /status.
Mixture-of-experts placement
A GGUF header can carry expert_count and expert_used_count: how many experts each layer holds, and how many of them a single token is routed to. Both zero means dense — every model that predates this field — so a dense model's plan is unchanged. A model counts as MoE only when both are set: Qwen3-30B-A3B holds 128 experts per layer and routes each token to 8, so a sixteenth of each layer's expert weights is read for any given token and the rest sit idle.
A dense shard (step 4 above) moves a whole layer — attention and every expert alike — to whichever device the pipeline plan assigned that layer to, so every token passing through a remote layer pays a crossing. llama.cpp also fuses a layer's experts into one tensor per projection (blk.N.ffn_gate_exps.weight holds all 128), so there is no tensor name that selects a range of expert indices — placement can move a layer's whole expert block to a device, never a slice of its experts. That is the split this implements: for an MoE model, the daemon reuses the exact layer boundaries the shard's pipeline plan already committed to and emits an extra --override-tensor per remote stage, naming only that layer range's expert tensors. Attention, the router, and the embedding are never named in an override, so they stay wherever --device/--tensor-split already put them — on the host for every layer, since those tensors are read on every token regardless of routing. The result: a dense shard pays a crossing per token per layer boundary, while this pays a crossing only when a token's router sends it to an expert whose block lives on a remote device.
The layer ranges come from the shard's own pipeline plan, not from PlanExperts's proportional division by free VRAM — disagreeing with the layers llama.cpp was actually told to load would pin tensors to a device that doesn't hold them. A remote stage the daemon can't resolve to a device name is left out of the override set entirely rather than emitted malformed, and any error building the overrides falls back to a plain layer split with a logged warning — never a placement that pins tensors somewhere wrong.
Replicas scale linearly; sharding is turn-taking
Measured on two machines over Tailscale (3 ms RTT): a GTX 1060 6 GB hub and an RTX 4070 SUPER 12 GB node.
| Measurement | Result |
|---|---|
| Same 4.4 GB model, host-only vs. sharded across both | 30.3 → 22.5 tok/s (−26%) |
| Aggregate throughput, 1→8 concurrent streams, sharded | 18.4 → 22.6 tok/s (+23%, nearly flat) |
| Same, host-only single card | 30.1 → 38.4 tok/s; host-only wins at every concurrency level |
| Prompt processing, sharded | ~10× faster than the same split's generation speed |
| Cold load, before fixes → after re-plan + cache fixes | 92 s → 3 s (host-only) |
Why sharding does not scale with users: llama.cpp's layer split executes the whole batch through stage 1, then stage 2. Devices take turns; nothing overlaps during decode. This is the inference engine's own scheduler, not something the hub can work around. Sharding is valuable for exactly one case, a model no single device can hold, and the wrong tool for serving many people, which wants replicas: whole-model instances, each a full-speed independent stream. Five machines that can each hold a model means five full-speed concurrent streams.
A live two-machine run confirms the shape at larger scale: Qwen3-14B Q4_K_M (8.9 GB) split [15 25] across the GTX 1060 and RTX 4070 SUPER loaded cold in 4 m 06 s at 8.4 tok/s; Qwen3-32B IQ3_XS (13.4 GB, fits on neither card alone) split [24 40] loaded in 4 m 57 s at 9.4 tok/s, with a measured network share of 7% once the initial weight push had finished.
Availability means the pool can host it right now
A model is available iff the current pool can place it. A node leaving that drops the pool below what a model needs makes that model unavailable, and /v1 says so explicitly (503 model_unavailable, naming the shortfall) rather than silently degrading to CPU.
Pausing is the machine owner's act, from the tray (or an opt-in --auto-pause GPU-busy detector, off by default because a desktop's own CUDA contexts made it flap). A paused node is treated as offline for placement, and the pool re-plans around it either way.