DaisyChain-Infer / docs /ARCHITECTURE.md
Quazim0t0's picture
DaisyChain-Infer: project files + guide
30bafb7 verified
|
Raw
History Blame Contribute Delete
8.15 kB
# Architecture
How a page load becomes one stage of a model, and how a token gets produced by
several machines that each hold only part of it.
## The pieces
| File | Role |
|---|---|
| `server.js` | WebSocket **signaling** + static hosting. Never sees a weight, an activation, a prompt, or a token. |
| `public/safetensors.js` | header parsing, dtype widening, byte-range coalescing. |
| `public/hf.js` | the Hub client β€” range reads, gated-repo handling, token redaction. |
| `public/arch.js` | `config.json` -> a normalized spec, and the tensor names to fetch. |
| `public/tokenizer.js` | byte-level BPE, read from the repo's own `tokenizer.json`. |
| `public/shard.js` | the **plan**, per-stage tensor sets, and weight-layout handling. |
| `public/infer.js` | the forward pass, split into `embed` / `runLayers` / `readout`. |
| `public/wire.js` | the binary protocol and the routing decisions, as pure functions. |
| `public/app.js` | the WebRTC mesh, assignments, the token prompt, and the token loop. |
| `public/verified_core.js` | the **verified INT8 units** β€” unchanged from DaisyChain-Web. |
| `public/webgpu.js` | the WGSL kernels and their **exact init gates** β€” unchanged. |
| `public/*.bin` | the units as lookup tables β€” unchanged. |
The last three being unchanged is deliberate. The arithmetic is copied, not
reimplemented, which is what makes the split checkable against the unsplit path
with `!==` instead of a tolerance.
## Reading a model without downloading it
A safetensors file is `[u64 headerLen][JSON header][tensor bytes]`, and the
header gives every tensor's exact byte range. So loading a model here means
fetching `config.json`, the tokenizer, and the weight *headers* β€” a few tens of
KB regardless of whether the model is 100 MB or 100 GB. After that, every
tensor's location is known and nothing else has been transferred.
`arch.js` turns the config into a normalized spec:
```
family, layers, hidden, heads, kvHeads, headDim, inter, vocab, maxPos,
norm ('rms'|'ln'), normEps, act ('silu'|'gelu'), gated, rope, ropeTheta,
tie, qkvFused, bias, weightLayout ('in_out'|'out_in')
```
Everything downstream works off that spec and never asks which family a model
came from. Unknown architectures are refused by name β€” silently treating one
block shape as another produces confident nonsense.
Two details that are silent when wrong, and so are decided by evidence rather
than assumption:
- **Weight layout.** `torch.nn.Linear` stores `(out, in)`; GPT-2's `Conv1D`
stores `(in, out)`, which is already the `k x n` the GEMM wants. A wrong
transpose does not throw. The layout comes from the spec and is applied once,
at load.
- **Bias presence.** Qwen2.5 ships q/k/v biases and SmolLM does not, and
neither says so in `config.json`. Bias is therefore decided by what the
weight file actually contains, not by a config flag.
## The plan
Whoever loads the model is the **head**: it owns the embedding table and so
does both ends of the pass. It orders devices itself-first, then by peer id,
and apportions blocks by **measured** capacity β€” each device times a real GEMM
through its own live kernel and reports GEMMs/sec, the same self-calibrating
idea as `cluster.py`'s `capacity_score`, except what is being balanced is
layers per device rather than batch per device.
Apportionment is largest-remainder, so it is a pure function of the capacity
report: every device derives the same plan from the same inputs, and the plan
never has to be trusted, only compared. A device that ends up with zero blocks
is dropped from the ring unless it is the head β€” an empty hop is pure latency.
`unpackAssign` refuses any plan whose stages do not cover every layer exactly
once, and any plan whose stage 0 is not the head. A gap there would not throw
at runtime; it would generate fluent text with a layer missing. The repo id is
validated too β€” it is interpolated into a Hub URL, so a peer must not be able
to point this device at an arbitrary path.
## Fetching a slice
Each device receives an assignment (model id, revision, spec, plan, its own
layer range) and then fetches **its own tensors itself**, with its own
credentials. Weights never travel between peers, which means:
- no device needs to hold the whole model, not even the head;
- a token is never sent over the wire β€” each device is prompted separately;
- adjacent byte ranges are coalesced, so a stage costs roughly one request
rather than nine per layer.
## One token
1. The **head** embeds the last `T` tokens (plus learned positions, for GPT-2)
and runs its own layers.
2. It sends the hidden state to stage 1. Each stage runs its layers and passes
the result on.
3. The **last** stage sends the state back to the head, addressed `RETURN`.
4. The head applies the final norm and `lm_head` β€” for the last position only,
which at a 150k-token vocabulary turns the largest GEMM in the model into a
single row β€” picks a token, and starts the next lap.
The head is both stage 0 and the terminus, so the return leg has its own
address rather than a stage index. Reading it as "stage 0, your turn" was a
real bug: the head re-ran its own layers and the lap never closed.
## The wire
All messages are binary. Sentinels continue DaisyChain-Web's numbering (it uses
βˆ’2β€¦βˆ’8) so a client pointed at the wrong server sees an unknown tag rather than
a valid-looking message of the wrong kind.
| Sentinel | Message |
|---|---|
| `-5` | fragment β€” large messages chunked at 48 KB |
| `-20` | hello: measured capacity, kernel probe hash, backend |
| `-21` | assignment β€” model id + your layer range (**no weights**) |
| `-22` | ready β€” this stage has fetched and loaded its layers |
| `-23` | activation hop |
| `-24` | token emitted (so every device can watch the output) |
| `-25` | run finished |
Activation format:
`[i32 ACT][i32 seq][i32 tokenIdx][i32 nextIndex][u32 actHash][u32 modelHash][f32 hidden]`
The two hashes answer different questions. `actHash` asks whether the payload
arrived intact. `modelHash` asks whether this activation belongs to the model I
hold a slice of β€” not hypothetical, since reloading the head with a different
model leaves stages out there still holding the old one. It is derived from the
repo id, revision and tensor index rather than the weights, because no device
reads all of them.
## Backends
Unchanged from DaisyChain-Web: DP4A hardware int8 dot β†’ LUT compute shader β†’
CPU mirror, each **exact-gated at init** and demoted on any mismatch. The CPU
mirror is not an approximation β€” it produces the same bits, which is what lets
a phone on CPU and a desktop on WebGPU sit in the same ring.
Attention runs per head through the same verified block GEMM, because real
models have head geometries (grouped-query, non-uniform head dims) that the
trainer's fused uniform-head kernels do not cover. Every product still goes
through the units.
There is no plain-float path. If the units fail to load, the device does not
compute.
## What the pipeline changes about verification
In data-parallel training every peer computes the same thing, so broken
arithmetic surfaces as a diverging replica. In a pipeline each stage computes
something different and nobody repeats it β€” there is no replica to compare
against, and a wrong middle stage yields a confident wrong answer.
What still works: the **kernel probe** (same seeded GEMM everywhere, so it is
comparable even when the real work is not), the init gates, and the live-shape
audit β€” all of which run per device regardless of what that device is computing.
What is new: activation hashes, the model fingerprint on every hop, and the
**differential check** β€” the head fetching the whole model and re-running the
prompt locally, then comparing token ids. It is the only instrument that can
prove a distributed answer right rather than merely self-consistent, and it is
offered rather than assumed because it needs a model one device can hold.