Quazim0t0 commited on
Commit
0b05d88
·
1 Parent(s): 868b423

Date the 2026-10-22 README additions and finish the scheduling pass.

Browse files

Each doc now opens with an Added 2026-10-22 list, and the new sentences in the body are marked the same way. The chunk path is chosen by the assignment, the lap budget uses the whole plan, a prefix record is restored on load, and only a planned peer aborts a run.

Files changed (8) hide show
  1. GUIDE.md +40 -6
  2. README.md +51 -11
  3. docs/ARCHITECTURE.md +48 -1
  4. docs/GETTING_STARTED.md +26 -8
  5. public/app.js +51 -27
  6. public/infer.js +8 -11
  7. public/shard.js +45 -6
  8. test_pipeline.js +18 -2
GUIDE.md CHANGED
@@ -11,6 +11,21 @@ where available, the identical math on CPU where not.
11
  **This runs locally.** It is a project you clone and start on your own
12
  machines; there is no hosted deployment, and it is not a Space.
13
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
14
  ```bash
15
  npm install
16
  npm start # http://localhost:8788
@@ -48,6 +63,21 @@ Running SmolLM-135M across three devices, each one downloads and holds
48
  135–243 MB of a 513 MB model. The activation moving between them is tens of KB.
49
  That asymmetry is why this works.
50
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
51
  ## The ring
52
 
53
  ```
@@ -124,7 +154,7 @@ Four things close that:
124
 
125
  1. **The kernel probe**, which matters more here than in the trainer — the same
126
  seeded GEMM on every device, so it stays comparable even when the real work
127
- is not. A stage whose probe disagrees is flagged before it is given layers.
128
  2. **Activation integrity hashes** on every hop, plus the model fingerprint, so
129
  a stage still holding a slice of a *different* model refuses the hop instead
130
  of silently mixing two models.
@@ -145,6 +175,9 @@ architecture families, across several uneven splits including a head that keeps
145
  no layers. Two cases are mutation checks — a stage that silently drops a layer,
146
  and stages applied out of order — both of which *must* fail the comparison.
147
  Without those, a test where both sides call the same code proves nothing.
 
 
 
148
 
149
  This was also confirmed against a real model: SmolLM-135M's 30 layers split
150
  `[10,10,10]`, `[1,14,15]`, `[0,15,15]` and `[5,5,5,5,5,5]` all produced token
@@ -179,13 +212,14 @@ behaviour; it was checked.
179
  - **Latency, not bandwidth, is the cost.** Every token pays one round trip per
180
  stage. More stages buy capacity, not speed — expect tokens/sec to *fall* as
181
  you add devices.
182
- - **No KV cache.** Every token re-runs the whole window, which is also what
183
- keeps the split bit-comparable against an unsplit run. Context length is the
184
- dominant per-token cost.
 
185
  - **f32 in memory.** Weights are widened from F16/BF16 on load, so a stage
186
  costs 4 bytes per parameter it holds.
187
- - **The head is a single point of failure**, and a stage that drops stalls the
188
- ring; press Generate again to re-plan around whoever is still connected.
189
  - **No authentication of activations.** A malicious stage that runs correct
190
  math but returns a crafted activation is not caught by any of this — the
191
  verification proves the *computation* is right on every honest device. Peers
 
11
  **This runs locally.** It is a project you clone and start on your own
12
  machines; there is no hosted deployment, and it is not a Space.
13
 
14
+ ## Added 2026-10-22
15
+
16
+ The rest of this file is the previous guide. This list, and every later note marked *(added 2026-10-22)*, is new.
17
+
18
+ - Compute cores from DaisyChain-Train `a28437c`. Non-finite activations are refused.
19
+ - A disagreeing kernel probe is left out of the plan.
20
+ - Lap budget is the plan, each stage at its own rate. Above 120 seconds per token the run stops before download.
21
+ - A second prompt waits.
22
+ - If the browser reports enough RAM for the whole model, the turn stays on the head.
23
+ - A 64-token prefix hash is kept per conversation and read back on load. The full-window pass still recomputes it.
24
+ - Long multi-device prompts use even chunks of about 16 tokens, two in flight, with per-stage KV. Verify reruns that path.
25
+ - A repeated suffix is drafted and scored in one lap on the full-window path.
26
+ - One weight range stays in flight while the previous body is widened to f32.
27
+ - A dropped peer or a stalled lap is replayed once. Weights still come from the Hub.
28
+
29
  ```bash
30
  npm install
31
  npm start # http://localhost:8788
 
63
  135–243 MB of a 513 MB model. The activation moving between them is tens of KB.
64
  That asymmetry is why this works.
65
 
66
+ ## Scheduling *(added 2026-10-22)*
67
+
68
+ The compute units are the live DaisyChain-Train browser cores (Hub revision `a28437c`, 20 October 2026). A non-finite activation throws `NonFiniteError` and is not forwarded.
69
+
70
+ - A peer whose kernel probe disagrees is left out of the plan.
71
+ - Layers are still split by measured GEMMs/sec.
72
+ - Above **120 seconds** per estimated token, the run stops before any weight download.
73
+ - A second prompt waits. The turn already generating keeps the ring.
74
+ - If the browser reports enough RAM for the whole model, the turn stays on the head. Unknown RAM uses the ring.
75
+ - A **64-token** prefix hash is recorded per conversation, keyed by the first block, so two chats do not erase each other. The full-window pass still recomputes it.
76
+ - A prompt longer than **32 tokens** on more than one device is prefetched in even chunks of about **16** tokens, two in flight, with per-stage KV. That path is not the full-window GEMM. Verify reruns the chunked path.
77
+ - On the full-window path, a repeated suffix is drafted and scored in one lap. Matching drafts are kept, plus the first disagreement.
78
+ - One weight range stays in flight while the previous body is widened to f32.
79
+ - A dropped peer or a stalled lap is replayed **once** from the start. Weights still come from the Hub.
80
+
81
  ## The ring
82
 
83
  ```
 
154
 
155
  1. **The kernel probe**, which matters more here than in the trainer — the same
156
  seeded GEMM on every device, so it stays comparable even when the real work
157
+ is not. A stage whose probe disagrees is left out of the plan. *(added 2026-10-22)*
158
  2. **Activation integrity hashes** on every hop, plus the model fingerprint, so
159
  a stage still holding a slice of a *different* model refuses the hop instead
160
  of silently mixing two models.
 
175
  no layers. Two cases are mutation checks — a stage that silently drops a layer,
176
  and stages applied out of order — both of which *must* fail the comparison.
177
  Without those, a test where both sides call the same code proves nothing.
178
+ *(added 2026-10-22)* The same file also checks a dropped probe, a refused non-finite activation,
179
+ a chunked prefill across a split, and a lookahead draft across a split. The
180
+ chunked path is compared to itself, not to the full-window GEMM.
181
 
182
  This was also confirmed against a real model: SmolLM-135M's 30 layers split
183
  `[10,10,10]`, `[1,14,15]`, `[0,15,15]` and `[5,5,5,5,5,5]` all produced token
 
212
  - **Latency, not bandwidth, is the cost.** Every token pays one round trip per
213
  stage. More stages buy capacity, not speed — expect tokens/sec to *fall* as
214
  you add devices.
215
+ - **The full-window path has no KV cache.** *(added 2026-10-22, replaces "No KV cache".)* Every token re-runs the window, which
216
+ is what keeps that path bit-comparable against an unsplit run. Context length
217
+ is the dominant per-token cost. A long multi-device prompt uses chunked
218
+ prefill instead, and that is a different number.
219
  - **f32 in memory.** Weights are widened from F16/BF16 on load, so a stage
220
  costs 4 bytes per parameter it holds.
221
+ - **The head is a single point of failure.** *(added 2026-10-22.)* A stage that drops is removed and the
222
+ prompt is replayed once. If that also fails, press Generate again.
223
  - **No authentication of activations.** A malicious stage that runs correct
224
  math but returns a crafted activation is not caught by any of this — the
225
  verification proves the *computation* is right on every honest device. Peers
README.md CHANGED
@@ -14,6 +14,23 @@ tags:
14
  **Part of DaisyChain on 🤗 Hugging Face → https://huggingface.co/DaisyChainAI**
15
  **Live Space:** https://huggingface.co/spaces/Quazim0t0/DaisyChain-Infer
16
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
17
  ---
18
 
19
  > **In plain terms:** point it at a Hugging Face model, open the page on two or
@@ -64,6 +81,23 @@ Running SmolLM-135M across three devices, each downloads and holds 135–243 MB
64
  of a 513 MB model, and the activation moving between them is tens of KB. That
65
  asymmetry is why this works.
66
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
67
  ## The ring
68
 
69
  ```
@@ -145,7 +179,7 @@ Four things close that:
145
 
146
  1. **The kernel probe**, which matters more here than in the trainer — the same
147
  seeded GEMM on every device, so it stays comparable even when the real work is
148
- not. A stage whose probe disagrees is flagged before it is given layers.
149
  2. **Activation integrity hashes** on every hop, plus the model fingerprint, so a
150
  stage still holding a slice of a *different* model refuses the hop instead of
151
  silently mixing two models.
@@ -161,6 +195,10 @@ architecture families, across several uneven splits including a head that keeps
161
  no layers. Two cases are mutation checks — a stage that silently drops a layer,
162
  and stages applied out of order — both of which *must* fail the comparison.
163
  Without those, a test where both sides call the same code proves nothing.
 
 
 
 
164
 
165
  Confirmed on a real model too: SmolLM-135M's 30 layers split `[10,10,10]`,
166
  `[1,14,15]`, `[0,15,15]` and `[5,5,5,5,5,5]` all produced token sequences
@@ -198,13 +236,13 @@ public/safetensors.js header parsing, exact BF16/F16 widening, range coalesc
198
  public/hf.js Hub client: range reads, gated repos, token redaction
199
  public/arch.js config.json -> normalized spec; per-family tensor names
200
  public/tokenizer.js byte-level BPE from the repo's own tokenizer.json
201
- public/shard.js the plan, per-stage tensor sets, weight-layout handling
202
- public/infer.js forward pass: embed / runLayers / readout
203
  public/wire.js binary protocol + routing, as pure functions
204
- public/app.js WebRTC mesh, assignments, credentials, token loop
205
- public/verified_core.js the verified INT8 units (unchanged from DaisyChain-Web)
206
- public/webgpu.js WGSL kernels + exact gates (unchanged)
207
- public/*.bin the units as lookup tables (unchanged)
208
  test_pipeline.js split vs unsplit, bit-exact, both families
209
  test_wire.js protocol round-trips, refusals, ring routing
210
  test_loader.js safetensors, dtype widening, BPE
@@ -216,12 +254,14 @@ docs/ GETTING_STARTED, ARCHITECTURE
216
  - **Latency, not bandwidth, is the cost.** Every token pays one round trip per
217
  stage. More stages buy capacity, not speed — expect tokens/sec to *fall* as you
218
  add devices.
219
- - **No KV cache.** Every token re-runs the whole window, which is also what keeps
220
- the split bit-comparable against an unsplit run.
 
 
221
  - **f32 in memory.** Weights are widened from F16/BF16 on load, so a stage costs
222
  4 bytes per parameter it holds.
223
- - **The head is a single point of failure**, and a stage that drops stalls the
224
- ring; press Generate again to re-plan around whoever is still connected.
225
  - **No authentication of activations.** A malicious stage that runs correct math
226
  but returns a crafted activation is not caught by any of this. Peers see each
227
  other's IPs, and the head sees your prompt. Ring with people you trust.
 
14
  **Part of DaisyChain on 🤗 Hugging Face → https://huggingface.co/DaisyChainAI**
15
  **Live Space:** https://huggingface.co/spaces/Quazim0t0/DaisyChain-Infer
16
 
17
+ ## Added 2026-10-22
18
+
19
+ The rest of this file is the previous README. Everything in this list, and every later note marked *(added 2026-10-22)*, is new.
20
+
21
+ - Compute cores copied from live DaisyChain-Train, Hub revision `a28437c` (20 October 2026). Lookup tables unchanged.
22
+ - A non-finite activation throws `NonFiniteError` and is not forwarded. The same cores refuse an int32 accumulator that would wrap, and a non-finite gradient before DaisyAdam.
23
+ - A peer whose kernel probe disagrees is left out of the plan.
24
+ - Layers stay split by measured GEMMs/sec, so a slower device gets fewer layers.
25
+ - Lap budget uses `estimatePlanMs`: each stage's share of the model, at that stage's own rate, after the plan is final. Above 120 seconds per token the run stops before any download.
26
+ - A second prompt is queued. The turn already generating keeps the ring.
27
+ - If `navigator.deviceMemory` says the whole model fits in a quarter of that RAM, the turn stays on the head. Unknown memory uses the ring.
28
+ - A 64-token prefix hash is kept per conversation, keyed by the first block, written to IndexedDB, and read back when the page loads. Two chats do not wipe each other. The full-window pass still recomputes a matched prefix. Oldest record drops at 32 MB.
29
+ - Prompt longer than 32 tokens, more than one device: even chunks of about 16 tokens, two in flight, per-stage KV. Not bit-identical to the full-window GEMM. Verify reruns this path. The assignment field `pass` is `chunk` or `window`. Stages do not guess from the activation length.
30
+ - Full-window path: a repeated suffix is drafted and scored in one lap (`lookaheadFrame`). Matches are kept, plus the first disagreement.
31
+ - One weight range stays in flight while the previous body is widened to f32.
32
+ - A planned peer other than the head dropping, or a stalled lap, replays the prompt once. Weights still come from the Hub.
33
+
34
  ---
35
 
36
  > **In plain terms:** point it at a Hugging Face model, open the page on two or
 
81
  of a 513 MB model, and the activation moving between them is tens of KB. That
82
  asymmetry is why this works.
83
 
84
+ ## Scheduling *(added 2026-10-22)*
85
+
86
+ The compute units are the live DaisyChain-Train browser cores (Hub revision `a28437c`, 20 October 2026), not the older snapshot this repo shipped with. A non-finite activation throws `NonFiniteError` before any layer runs, and the next stage does not forward it. The same cores refuse an int32 accumulator that would wrap, and they refuse a non-finite gradient before DaisyAdam. The lookup tables did not change.
87
+
88
+ On top of that, the head does a few things the old loop did not:
89
+
90
+ - A peer whose kernel probe disagrees is left out of the plan, not only warned.
91
+ - Layers are still split by measured GEMMs/sec, so a slower device gets fewer layers.
92
+ - Before any weight download, `estimatePlanMs` sums each stage's share at that stage's own rate. Above **120 seconds** per token the run stops. *(added 2026-10-22)*
93
+ - A second prompt while a turn is generating is queued. The turn already on the ring finishes first.
94
+ - If `navigator.deviceMemory` says the whole model fits in a quarter of that RAM, the turn runs on the head and the ring is not used. Unknown memory uses the ring. A split that the head can hold only adds a hop.
95
+ - A **64-token** block hash is kept per conversation, keyed by the first block, in memory and in IndexedDB when the browser has it. Two chats do not wipe each other's record. The oldest record is dropped at **32 MB**. The full-window pass still recomputes. A prefix hit is logged and then computed again, because reusing K and V would change the per-column int8 scales.
96
+ - With more than one device and a prompt longer than **32 tokens**, prefill goes out in even chunks of about **16** tokens, two chunks in flight. A short tail is not left as its own hop. Each stage keeps K and V for its own layers. Decode stays one new token per lap. This path is not bit-identical to one full-window GEMM. Verify reruns the same chunked path.
97
+ - On the full-window path, a repeated suffix proposes the tokens that followed the earlier copy (prompt lookup, no second model). One lap scores that span. Matching drafts are kept, plus the first token that disagreed. If nothing repeats, the lap still produces one token.
98
+ - Weight fetches keep one HTTP range in flight while the previous body is widened to f32. The bytes are the same.
99
+ - If a peer disconnects or a lap stalls, the head drops that peer, replans, and reruns the prompt **once** from the start. Weights still come from the Hub, not from another device.
100
+
101
  ## The ring
102
 
103
  ```
 
179
 
180
  1. **The kernel probe**, which matters more here than in the trainer — the same
181
  seeded GEMM on every device, so it stays comparable even when the real work is
182
+ not. A stage whose probe disagrees is left out of the plan. *(added 2026-10-22)*
183
  2. **Activation integrity hashes** on every hop, plus the model fingerprint, so a
184
  stage still holding a slice of a *different* model refuses the hop instead of
185
  silently mixing two models.
 
195
  no layers. Two cases are mutation checks — a stage that silently drops a layer,
196
  and stages applied out of order — both of which *must* fail the comparison.
197
  Without those, a test where both sides call the same code proves nothing.
198
+ *(added 2026-10-22)* The same file checks that a disagreeing probe is dropped, that a non-finite
199
+ activation is refused, that a chunked prefill matches across a split, and that
200
+ a lookahead draft matches across a split. The chunked path is compared to
201
+ itself, not to the full-window GEMM.
202
 
203
  Confirmed on a real model too: SmolLM-135M's 30 layers split `[10,10,10]`,
204
  `[1,14,15]`, `[0,15,15]` and `[5,5,5,5,5,5]` all produced token sequences
 
236
  public/hf.js Hub client: range reads, gated repos, token redaction
237
  public/arch.js config.json -> normalized spec; per-family tensor names
238
  public/tokenizer.js byte-level BPE from the repo's own tokenizer.json
239
+ public/shard.js the plan, probe filter, lap estimate, prefix index, drafts (added 2026-10-22)
240
+ public/infer.js forward pass, chunked prefill, lookahead readout (added 2026-10-22)
241
  public/wire.js binary protocol + routing, as pure functions
242
+ public/app.js WebRTC mesh, assignments, queue, replay, token loop (added 2026-10-22)
243
+ public/verified_core.js the verified INT8 units (copied from live DaisyChain-Train, 2026-10-22)
244
+ public/webgpu.js WGSL kernels + exact gates (copied from live DaisyChain-Train, 2026-10-22)
245
+ public/*.bin the units as lookup tables (unchanged; same as Train)
246
  test_pipeline.js split vs unsplit, bit-exact, both families
247
  test_wire.js protocol round-trips, refusals, ring routing
248
  test_loader.js safetensors, dtype widening, BPE
 
254
  - **Latency, not bandwidth, is the cost.** Every token pays one round trip per
255
  stage. More stages buy capacity, not speed — expect tokens/sec to *fall* as you
256
  add devices.
257
+ - **The full-window path has no KV cache.** *(added 2026-10-22, replaces "No KV cache".)* Every token re-runs the window, which
258
+ is what keeps that path bit-comparable against an unsplit run. A long prompt on
259
+ a multi-device ring uses the chunked path instead, and that path is a different
260
+ number.
261
  - **f32 in memory.** Weights are widened from F16/BF16 on load, so a stage costs
262
  4 bytes per parameter it holds.
263
+ - **The head is a single point of failure.** *(added 2026-10-22.)* A stage that drops is removed and the
264
+ prompt is replayed once. If that also fails, press Generate again.
265
  - **No authentication of activations.** A malicious stage that runs correct math
266
  but returns a crafted activation is not caught by any of this. Peers see each
267
  other's IPs, and the head sees your prompt. Ring with people you trust.
docs/ARCHITECTURE.md CHANGED
@@ -3,6 +3,18 @@
3
  How a page load becomes one stage of a model, and how a token gets produced by
4
  several machines that each hold only part of it.
5
 
 
 
 
 
 
 
 
 
 
 
 
 
6
  ## The pieces
7
 
8
  | File | Role |
@@ -68,6 +80,17 @@ Apportionment is largest-remainder, so it is a pure function of the capacity
68
  report: every device derives the same plan from the same inputs, and the plan
69
  never has to be trusted, only compared. A device that ends up with zero blocks
70
  is dropped from the ring unless it is the head — an empty hop is pure latency.
 
 
 
 
 
 
 
 
 
 
 
71
 
72
  `unpackAssign` refuses any plan whose stages do not cover every layer exactly
73
  once, and any plan whose stage 0 is not the head. A gap there would not throw
@@ -84,7 +107,9 @@ credentials. Weights never travel between peers, which means:
84
  - no device needs to hold the whole model, not even the head;
85
  - a token is never sent over the wire — each device is prompted separately;
86
  - adjacent byte ranges are coalesced, so a stage costs roughly one request
87
- rather than nine per layer.
 
 
88
 
89
  ## One token
90
 
@@ -101,6 +126,28 @@ The head is both stage 0 and the terminus, so the return leg has its own
101
  address rather than a stage index. Reading it as "stage 0, your turn" was a
102
  real bug: the head re-ran its own layers and the lap never closed.
103
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
104
  ## The wire
105
 
106
  All messages are binary. Sentinels continue DaisyChain-Web's numbering (it uses
 
3
  How a page load becomes one stage of a model, and how a token gets produced by
4
  several machines that each hold only part of it.
5
 
6
+ ## Added 2026-10-22
7
+
8
+ The rest of this file is the previous architecture note. This list is what changed.
9
+
10
+ - A disagreeing kernel probe is removed before apportionment (`ringMembers`).
11
+ - `estimatePlanMs` is the lap budget: each stage's share at that stage's rate. Above 120000 ms the run is refused.
12
+ - Assignments carry `pass`: `chunk` or `window`. Stages do not infer the path from the row count.
13
+ - The next weight range is fetched while the previous body is widened to f32.
14
+ - Full-window laps can score a prompt-lookup draft of at most 4 tokens in one window.
15
+ - Prompts longer than 32 tokens on more than one stage use even chunks of about 16, two in flight, with per-stage KV. Not the full-window GEMM.
16
+ - A second prompt is queued. A dropped planned peer or a stalled lap replays the prompt once. A non-finite hidden state is not forwarded.
17
+
18
  ## The pieces
19
 
20
  | File | Role |
 
80
  report: every device derives the same plan from the same inputs, and the plan
81
  never has to be trusted, only compared. A device that ends up with zero blocks
82
  is dropped from the ring unless it is the head — an empty hop is pure latency.
83
+ *(added 2026-10-22)* A peer whose kernel probe hash disagrees with the head is removed before that
84
+ apportionment (`ringMembers`). The head is always kept.
85
+
86
+ *(added 2026-10-22)* Before the download, `estimatePlanMs` sums each stage's share of
87
+ `estimateLapMs` at that stage's own GEMM rate. A slow stage with a real share
88
+ of the layers raises the figure. It is a budget check. Above 120000 ms the
89
+ head refuses the run. It is not a latency model.
90
+
91
+ *(added 2026-10-22)* The assignment may set `pass` to `chunk` or `window`. Stages must not guess
92
+ the path from the activation length: a chunk can be exactly `T` rows, and
93
+ treating it as a full window would drop the KV the other stages kept.
94
 
95
  `unpackAssign` refuses any plan whose stages do not cover every layer exactly
96
  once, and any plan whose stage 0 is not the head. A gap there would not throw
 
107
  - no device needs to hold the whole model, not even the head;
108
  - a token is never sent over the wire — each device is prompted separately;
109
  - adjacent byte ranges are coalesced, so a stage costs roughly one request
110
+ rather than nine per layer;
111
+ - *(added 2026-10-22)* the next range is requested while the previous body is widened to f32.
112
+ The bytes do not change.
113
 
114
  ## One token
115
 
 
126
  address rather than a stage index. Reading it as "stage 0, your turn" was a
127
  real bug: the head re-ran its own layers and the lap never closed.
128
 
129
+ *(added 2026-10-22)* Two other ways a lap can start:
130
+
131
+ - **Full window, with a draft.** If a suffix of the token ids already occurred
132
+ earlier, the head appends the tokens that followed that copy (at most 4) and
133
+ reads logits at each of those rows plus one. Matches are kept. The first
134
+ disagreement is kept too. An empty draft is one new token, the same window
135
+ as before. `runLayers` still sees the whole window, so this path stays on
136
+ the bit-exact GEMM.
137
+ - **Chunked prefill.** Prompt longer than 32 tokens, and more than one stage.
138
+ Even chunks of about 16 tokens, two in flight. A short tail is folded into
139
+ those chunks so it is not its own hop. `runLayersChunk` appends K and V on the stage
140
+ that owns those layers and attends only the new rows. Decode after that is
141
+ one new token per lap on the same cache. Per-column int8 scales move as the
142
+ cache grows, so this is not bit-identical to the full-window GEMM. A split
143
+ of the chunked path matches itself. `tokenIdx` is the chunk index, and a
144
+ stage applies chunks in order even if a later chunk arrives first.
145
+
146
+ A second prompt while a run is in progress is queued. If the activation wait
147
+ fails or a peer channel closes, the head drops that peer, rebuilds the plan,
148
+ and reruns the prompt once. A non-finite hidden state throws inside
149
+ `runLayers` / `runLayersChunk` and is not forwarded.
150
+
151
  ## The wire
152
 
153
  All messages are binary. Sentinels continue DaisyChain-Web's numbering (it uses
docs/GETTING_STARTED.md CHANGED
@@ -3,6 +3,14 @@
3
  This is a local project. There is no hosted version — you clone it and run it
4
  on your own machines.
5
 
 
 
 
 
 
 
 
 
6
  ## Run the server
7
 
8
  ```bash
@@ -87,7 +95,7 @@ The first person in is the **host** and approves each device individually.
87
  | Setting | Default | What it does |
88
  |---|---|---|
89
  | Tokens | 60 | how many to generate |
90
- | Context length | 64 | window size; the main cost per token (there is no KV cache) |
91
  | Temperature ÷100 | 0 | 0 = greedy, deterministic and therefore checkable |
92
  | Seed | 1234 | drives sampling above temperature 0, so runs stay reproducible |
93
  | Verify | off | afterwards, re-run with every layer here and compare |
@@ -101,9 +109,16 @@ few layers and no vocabulary.
101
  stages: each one adds a round trip per token. More stages buy capacity, not
102
  speed.
103
  - **ring hops** — stages × tokens.
104
- - **kernel probe** (in the log) — the same number on every honest device,
105
- whatever its backend. A peer reporting a different one is computing different
106
- arithmetic, and the log says so.
 
 
 
 
 
 
 
107
  - **my slice** — which layers this device holds and how much memory they take.
108
 
109
  ## Verify
@@ -127,8 +142,11 @@ npm test
127
 
128
  ## When it goes wrong
129
 
130
- - **"the ring stalled at token N"** — a stage went quiet. Press Generate again
131
- to re-plan around whoever is still connected.
 
 
 
132
  - **"unsupported architecture"** — the model is not Llama-style or
133
  GPT-2-style.
134
  - **"tokenizer type … is not supported"** — the repo uses SentencePiece or
@@ -139,8 +157,8 @@ npm test
139
  a slice; refused rather than accepted, since that defeats the point.
140
  - **"REFUSED activation … belongs to model X"** — a stage still holds a slice
141
  of a previous model. Reload that device.
142
- - **"⚠ … disagrees with this device's kernel probe"** — that device's
143
- arithmetic differs from yours. Do not give it layers.
144
  - **out of memory** — the stage's slice does not fit. Add devices, or pick a
145
  smaller model; the plan shows the per-device cost before you start.
146
 
 
3
  This is a local project. There is no hosted version — you clone it and run it
4
  on your own machines.
5
 
6
+ ## Added 2026-10-22
7
+
8
+ The rest of this file is the previous getting-started note. This list is what changed.
9
+
10
+ - Context length still defaults to 64. A prompt longer than 32 tokens on a multi-device ring uses even chunked prefill. If this device can hold the whole model, the ring is not used.
11
+ - New log lines: lap estimate for the whole plan, queued prompts, overlap prefill, draft span. A disagreeing probe is left out of the plan.
12
+ - New failures: "a peer left the ring" (one replay), "REFUSED activation" for a non-finite hop, "refusing the run" when the lap estimate exceeds 120 seconds, "stays out of the plan: kernel probe".
13
+
14
  ## Run the server
15
 
16
  ```bash
 
95
  | Setting | Default | What it does |
96
  |---|---|---|
97
  | Tokens | 60 | how many to generate |
98
+ | Context length | 64 | *(added 2026-10-22)* full-window size; the main cost per token on that path. A prompt longer than 32 tokens on a multi-device ring uses even chunked prefill instead. If this device can hold the whole model, the ring is not used |
99
  | Temperature ÷100 | 0 | 0 = greedy, deterministic and therefore checkable |
100
  | Seed | 1234 | drives sampling above temperature 0, so runs stay reproducible |
101
  | Verify | off | afterwards, re-run with every layer here and compare |
 
109
  stages: each one adds a round trip per token. More stages buy capacity, not
110
  speed.
111
  - **ring hops** — stages × tokens.
112
+ - **kernel probe** (in the log) — *(added 2026-10-22)* the same number on every device that is
113
+ allowed into the plan. A peer reporting a different one is left out.
114
+ - **lap estimate** — *(added 2026-10-22)* milliseconds per token for the plan that will actually
115
+ run, each stage at its own rate. Above 120 seconds the run is refused
116
+ before the download.
117
+ - **queued** — *(added 2026-10-22)* a prompt typed while a turn is generating. It starts when the
118
+ ring is free.
119
+ - **overlap prefill** — *(added 2026-10-22)* chunks of about 16, two in flight, per-stage KV. Not the
120
+ full-window GEMM. Verify repeats this path.
121
+ - **draft span** — *(added 2026-10-22)* a repeated suffix scored in one lap on the full-window path.
122
  - **my slice** — which layers this device holds and how much memory they take.
123
 
124
  ## Verify
 
142
 
143
  ## When it goes wrong
144
 
145
+ - *(added 2026-10-22)* **"a peer left the ring"** — the head replans and replays the prompt once.
146
+ If the replay also fails, press Generate again.
147
+ - *(added 2026-10-22)* **"REFUSED activation"** — a non-finite hop. It is not forwarded.
148
+ - *(added 2026-10-22)* **"refusing the run: estimated … ms per token"** — the lap budget of 120
149
+ seconds was exceeded. Add a faster device or lower the context length.
150
  - **"unsupported architecture"** — the model is not Llama-style or
151
  GPT-2-style.
152
  - **"tokenizer type … is not supported"** — the repo uses SentencePiece or
 
157
  a slice; refused rather than accepted, since that defeats the point.
158
  - **"REFUSED activation … belongs to model X"** — a stage still holds a slice
159
  of a previous model. Reload that device.
160
+ - *(added 2026-10-22)* **"stays out of the plan: kernel probe"** — that device's arithmetic
161
+ differs. It is not given layers.
162
  - **out of memory** — the stage's slice does not fit. Add devices, or pick a
163
  smaller model; the plan shows the per-device cost before you start.
164
 
public/app.js CHANGED
@@ -426,7 +426,7 @@ function cleanupPeer(id) {
426
  const dc = chans.get(id);
427
  if (dc && !dc.isRelay) chans.delete(id); // keep a working relay channel
428
  for (const k of fragIn.keys()) if (k.startsWith(id + ":")) fragIn.delete(k);
429
- if (running) peerDropped = true;
430
  caps.delete(id);
431
  wake();
432
  }
@@ -559,8 +559,30 @@ async function loadRepo() {
559
  }
560
 
561
  const prefixIndex = Shard.makePrefixIndex(64);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
562
  const kvSpill = Shard.makeKvSpill(32 * 1048576);
563
- const CHAT = "chat";
564
  const LAP_BUDGET_MS = 120000;
565
 
566
  function memberList() {
@@ -612,6 +634,7 @@ async function loadMyStage(assign, st) {
612
  `${assign.repo} weights`);
613
  const w = Shard.stageWeights(s, st, got, Arch);
614
  stageObj = Infer.makeStage(s, st, w, kernelCtx(), assign.ctx || ctxLen());
 
615
  spec = s;
616
  ui.myShard.textContent = `${st.head ? "head + " : ""}layers ${st.lo}–${st.hi - 1} · ${mb.toFixed(0)} MB resident`;
617
  return stageObj;
@@ -692,9 +715,7 @@ async function onAct(peerId, buf) {
692
  const kind = Wire.classifyAct(nextIndex, me ? me.index : -99);
693
  if (kind === "return") { actIn.set(`${seq}:${tokenIdx}`, hidden); wake(); return; }
694
  if (kind === "other" || !me || !stageObj) return;
695
- const rows = hidden.length / spec.hidden;
696
- const chunked = Number.isInteger(rows) && rows !== stageObj.T;
697
- if (chunked) {
698
  if (!stageObj.inbox || stageObj.boxSeq !== seq) {
699
  stageObj.inbox = new Map();
700
  stageObj.boxSeq = seq;
@@ -763,25 +784,27 @@ async function generate(isReplay) {
763
  const promptText = ui.prompt.value || "The";
764
  let idsPreview = [...Tokenizer.encode(tok, promptText)];
765
  if (!idsPreview.length) idsPreview = [0];
766
- const lapMs = Shard.estimateLapMs(spec, T, myCapacity);
767
- log(`lap estimate ${Math.round(lapMs)} ms/token on this device's capacity (budget ${LAP_BUDGET_MS} ms).`);
768
- if (lapMs > LAP_BUDGET_MS) {
769
- throw new Error(`refusing the run: estimated ${Math.round(lapMs)} ms per token exceeds ${LAP_BUDGET_MS} ms. ` +
770
- `Add a faster device or lower the context length.`);
771
- }
772
  const fits = Shard.headCanHold(spec, repoInfo.tensors, navigator.deviceMemory || 0);
773
  const local = Shard.preferLocal(idsPreview.length, plan.length - 1, fits);
774
- const matched = prefixIndex.matchedBlocks(CHAT, idsPreview);
 
775
  if (matched) {
776
  log(`prefix index: ${matched} block(s) of 64 tokens match this chat. ` +
777
  `The window is still recomputed so the token ids stay bit-identical to a full pass.`);
778
  }
779
  if (local) {
780
- log(`short prompt (${idsPreview.length} tokens) fits on this device ` +
781
- `(${(navigator.deviceMemory)} GB reported). Running here. The ring is not used for this turn.`);
782
  plan = [{ id: myId, index: 0, lo: 0, hi: spec.layers, head: true, capacity: myCapacity, backend: compute.backend }];
783
  renderPlan();
784
  }
 
 
 
 
 
 
 
785
  if (plan.length > 1) {
786
  log(`plan: ${spec.layers} layers over ${plan.length} device(s) — ` +
787
  plan.map(s => `${s.id === myId ? "me" : nmeOf(s.id)}:${s.hi - s.lo}`).join(", "));
@@ -789,6 +812,7 @@ async function generate(isReplay) {
789
  await sendTo(plan[i].id, Wire.packAssign({
790
  repo: repoInfo.repo, revision: repoInfo.revision, spec, plan,
791
  mine: i, fingerprint: repoInfo.fingerprint, ctx: T,
 
792
  }));
793
  log("waiting for every stage to fetch its layers…");
794
  const ok = await waitFor(() => readyStages.size >= plan.length - 1, 600000);
@@ -804,17 +828,17 @@ async function generate(isReplay) {
804
  const nTok = +ui.ntok.value, temp = +ui.temp.value / 100;
805
  const opts = { temperature: temp, topK: 40, rng: Infer.mulberry32(+ui.seed.value | 0) };
806
  let ids = idsPreview.slice();
807
- prefixIndex.note(CHAT, ids);
808
  // The bytes kept here are the prefix-block hashes, not layer KV. Reusing
809
  // K and V would change per-column int8 scales, so the pass still recomputes.
810
  const hashBytes = new Uint8Array(new Uint32Array(prefixIndex.blocks(ids)).buffer);
811
- if (!kvSpill.put(CHAT, hashBytes)) log("prefix record is larger than the spill cap; this chat is not retained.");
812
  if (typeof indexedDB !== "undefined") {
813
  const open = indexedDB.open("daisychain-infer", 1);
814
  open.onupgradeneeded = () => open.result.createObjectStore("prefix");
815
  open.onsuccess = () => {
816
  const db = open.result;
817
- db.transaction("prefix", "readwrite").objectStore("prefix").put(hashBytes, CHAT);
818
  };
819
  }
820
  const promptLen = ids.length;
@@ -822,12 +846,14 @@ async function generate(isReplay) {
822
  const tStart = performance.now();
823
  let hops = 0;
824
 
825
- const useChunks = plan.length > 1 && ids.length > 32;
 
 
826
  let chunkedIds = null;
827
  if (useChunks) {
828
- log(`overlap prefill: ${ids.length} prompt tokens, chunks of 16, 2 in flight. ` +
829
- `Each stage keeps KV for its own layers. This is not the full-window GEMM.`);
830
  const chunks = Shard.prefillChunks(ids.length, 16, 2);
 
 
831
  const span = { cache: [], pos0: 0, li: 0 };
832
  const hop = async (index, hidden) => {
833
  await sendTo(plan[1].id, packAct(seq, index, plan[1].index, hidden));
@@ -872,13 +898,10 @@ async function generate(isReplay) {
872
 
873
  if (!useChunks) {
874
  for (let produced = 0; produced < nTok && !abortRun; ) {
875
- let draft = Shard.promptLookupDraft(ids, 4);
876
- const seqIds = ids.concat(draft);
877
- const shown = seqIds.slice(-T);
878
- const shownDraft = Math.min(draft.length, Math.max(0, shown.length - 1));
879
- draft = draft.slice(draft.length - shownDraft);
880
  const win = new Int32Array(T);
881
- for (let i = 0; i < shown.length; i++) win[T - shown.length + i] = shown[i];
882
  let x = Infer.embed(stageObj, win);
883
  x = await Infer.runLayers(stageObj, x);
884
  if (auditFailure) { log(`KERNEL AUDIT FAILED: ${auditFailure} — stopping`); break; }
@@ -891,7 +914,7 @@ async function generate(isReplay) {
891
  if (!ok) throw new Error("stalled");
892
  x = actIn.get(key); actIn.delete(key);
893
  }
894
- const rows = Infer.draftRows(T, shown.length, draft.length);
895
  const picked = (await Infer.logitsAt(stageObj, x, rows)).map(l => Infer.pickToken(l, opts));
896
  const got = Shard.acceptDraft(picked, draft);
897
  if (draft.length) log(`draft span ${draft.length}, accepted ${Math.max(0, got.length - 1)} before the bonus token. One lap.`);
@@ -1031,6 +1054,7 @@ function kernelCtx() {
1031
 
1032
  await measureCapacity();
1033
  log(`measured capacity: ${myCapacity.toFixed(0)} verified GEMMs/sec — this decides how many layers this device takes`);
 
1034
 
1035
  ui.me.textContent = deviceName;
1036
  if (!hfToken) setToken(null); // keep a token adopted from the fragment
 
426
  const dc = chans.get(id);
427
  if (dc && !dc.isRelay) chans.delete(id); // keep a working relay channel
428
  for (const k of fragIn.keys()) if (k.startsWith(id + ":")) fragIn.delete(k);
429
+ if (running && plan && plan.some(s => s.id === id && s.id !== myId)) peerDropped = true;
430
  caps.delete(id);
431
  wake();
432
  }
 
559
  }
560
 
561
  const prefixIndex = Shard.makePrefixIndex(64);
562
+ function restorePrefixes() {
563
+ if (typeof indexedDB === "undefined") return;
564
+ const open = indexedDB.open("daisychain-infer", 1);
565
+ open.onupgradeneeded = () => {
566
+ if (!open.result.objectStoreNames.contains("prefix")) open.result.createObjectStore("prefix");
567
+ };
568
+ open.onsuccess = () => {
569
+ const db = open.result;
570
+ if (!db.objectStoreNames.contains("prefix")) return;
571
+ const cur = db.transaction("prefix").objectStore("prefix").openCursor();
572
+ cur.onsuccess = () => {
573
+ const row = cur.result;
574
+ if (!row) return;
575
+ const bytes = row.value;
576
+ if (bytes && bytes.byteLength >= 4) {
577
+ const u32 = new Uint32Array(bytes.buffer, bytes.byteOffset, (bytes.byteLength / 4) | 0);
578
+ prefixIndex.restore(String(row.key), Array.from(u32));
579
+ kvSpill.put(String(row.key), bytes);
580
+ }
581
+ row.continue();
582
+ };
583
+ };
584
+ }
585
  const kvSpill = Shard.makeKvSpill(32 * 1048576);
 
586
  const LAP_BUDGET_MS = 120000;
587
 
588
  function memberList() {
 
634
  `${assign.repo} weights`);
635
  const w = Shard.stageWeights(s, st, got, Arch);
636
  stageObj = Infer.makeStage(s, st, w, kernelCtx(), assign.ctx || ctxLen());
637
+ stageObj.pass = assign.pass === "chunk" ? "chunk" : "window";
638
  spec = s;
639
  ui.myShard.textContent = `${st.head ? "head + " : ""}layers ${st.lo}–${st.hi - 1} · ${mb.toFixed(0)} MB resident`;
640
  return stageObj;
 
715
  const kind = Wire.classifyAct(nextIndex, me ? me.index : -99);
716
  if (kind === "return") { actIn.set(`${seq}:${tokenIdx}`, hidden); wake(); return; }
717
  if (kind === "other" || !me || !stageObj) return;
718
+ if (stageObj.pass === "chunk") {
 
 
719
  if (!stageObj.inbox || stageObj.boxSeq !== seq) {
720
  stageObj.inbox = new Map();
721
  stageObj.boxSeq = seq;
 
784
  const promptText = ui.prompt.value || "The";
785
  let idsPreview = [...Tokenizer.encode(tok, promptText)];
786
  if (!idsPreview.length) idsPreview = [0];
 
 
 
 
 
 
787
  const fits = Shard.headCanHold(spec, repoInfo.tensors, navigator.deviceMemory || 0);
788
  const local = Shard.preferLocal(idsPreview.length, plan.length - 1, fits);
789
+ const chatKey = String((prefixIndex.blocks(idsPreview)[0]) || "empty");
790
+ const matched = prefixIndex.matchedBlocks(chatKey, idsPreview);
791
  if (matched) {
792
  log(`prefix index: ${matched} block(s) of 64 tokens match this chat. ` +
793
  `The window is still recomputed so the token ids stay bit-identical to a full pass.`);
794
  }
795
  if (local) {
796
+ log(`this device can hold the model (${navigator.deviceMemory} GB reported). ` +
797
+ `Running here. The ring is not used for this turn.`);
798
  plan = [{ id: myId, index: 0, lo: 0, hi: spec.layers, head: true, capacity: myCapacity, backend: compute.backend }];
799
  renderPlan();
800
  }
801
+ const lapMs = Shard.estimatePlanMs(spec, T, plan);
802
+ log(`lap estimate ${Math.round(lapMs)} ms/token across ${plan.length} stage(s) (budget ${LAP_BUDGET_MS} ms).`);
803
+ if (lapMs > LAP_BUDGET_MS) {
804
+ throw new Error(`refusing the run: estimated ${Math.round(lapMs)} ms per token exceeds ${LAP_BUDGET_MS} ms. ` +
805
+ `Add a faster device or lower the context length.`);
806
+ }
807
+ const willChunk = plan.length > 1 && idsPreview.length > 32;
808
  if (plan.length > 1) {
809
  log(`plan: ${spec.layers} layers over ${plan.length} device(s) — ` +
810
  plan.map(s => `${s.id === myId ? "me" : nmeOf(s.id)}:${s.hi - s.lo}`).join(", "));
 
812
  await sendTo(plan[i].id, Wire.packAssign({
813
  repo: repoInfo.repo, revision: repoInfo.revision, spec, plan,
814
  mine: i, fingerprint: repoInfo.fingerprint, ctx: T,
815
+ pass: willChunk ? "chunk" : "window",
816
  }));
817
  log("waiting for every stage to fetch its layers…");
818
  const ok = await waitFor(() => readyStages.size >= plan.length - 1, 600000);
 
828
  const nTok = +ui.ntok.value, temp = +ui.temp.value / 100;
829
  const opts = { temperature: temp, topK: 40, rng: Infer.mulberry32(+ui.seed.value | 0) };
830
  let ids = idsPreview.slice();
831
+ prefixIndex.note(chatKey, ids);
832
  // The bytes kept here are the prefix-block hashes, not layer KV. Reusing
833
  // K and V would change per-column int8 scales, so the pass still recomputes.
834
  const hashBytes = new Uint8Array(new Uint32Array(prefixIndex.blocks(ids)).buffer);
835
+ if (!kvSpill.put(chatKey, hashBytes)) log("prefix record is larger than the spill cap; this chat is not retained.");
836
  if (typeof indexedDB !== "undefined") {
837
  const open = indexedDB.open("daisychain-infer", 1);
838
  open.onupgradeneeded = () => open.result.createObjectStore("prefix");
839
  open.onsuccess = () => {
840
  const db = open.result;
841
+ db.transaction("prefix", "readwrite").objectStore("prefix").put(hashBytes, chatKey);
842
  };
843
  }
844
  const promptLen = ids.length;
 
846
  const tStart = performance.now();
847
  let hops = 0;
848
 
849
+ stageObj.pass = willChunk ? "chunk" : "window";
850
+ actIn.clear();
851
+ const useChunks = willChunk;
852
  let chunkedIds = null;
853
  if (useChunks) {
 
 
854
  const chunks = Shard.prefillChunks(ids.length, 16, 2);
855
+ log(`overlap prefill: ${ids.length} prompt tokens as [${chunks.chunks.map(c => c.hi - c.lo).join("+")}], ` +
856
+ `${chunks.window} in flight. Each stage keeps KV for its own layers. This is not the full-window GEMM.`);
857
  const span = { cache: [], pos0: 0, li: 0 };
858
  const hop = async (index, hidden) => {
859
  await sendTo(plan[1].id, packAct(seq, index, plan[1].index, hidden));
 
898
 
899
  if (!useChunks) {
900
  for (let produced = 0; produced < nTok && !abortRun; ) {
901
+ const frame = Shard.lookaheadFrame(ids, T, 4);
902
+ const draft = frame.draft;
 
 
 
903
  const win = new Int32Array(T);
904
+ for (let i = 0; i < frame.win.length; i++) win[T - frame.win.length + i] = frame.win[i];
905
  let x = Infer.embed(stageObj, win);
906
  x = await Infer.runLayers(stageObj, x);
907
  if (auditFailure) { log(`KERNEL AUDIT FAILED: ${auditFailure} — stopping`); break; }
 
914
  if (!ok) throw new Error("stalled");
915
  x = actIn.get(key); actIn.delete(key);
916
  }
917
+ const rows = Infer.draftRows(T, frame.shown, draft.length);
918
  const picked = (await Infer.logitsAt(stageObj, x, rows)).map(l => Infer.pickToken(l, opts));
919
  const got = Shard.acceptDraft(picked, draft);
920
  if (draft.length) log(`draft span ${draft.length}, accepted ${Math.max(0, got.length - 1)} before the bonus token. One lap.`);
 
1054
 
1055
  await measureCapacity();
1056
  log(`measured capacity: ${myCapacity.toFixed(0)} verified GEMMs/sec — this decides how many layers this device takes`);
1057
+ restorePrefixes();
1058
 
1059
  ui.me.textContent = deviceName;
1060
  if (!hfToken) setToken(null); // keep a token adopted from the fragment
public/infer.js CHANGED
@@ -308,11 +308,11 @@
308
  const spans = stages.map(() => ({ cache: [], pos0: 0, li: 0 }));
309
  const size = Math.max(1, chunkSize | 0);
310
  let last = null;
311
- for (let lo = 0; lo < ids.length; lo += size) {
312
- const piece = ids.slice(lo, Math.min(ids.length, lo + size));
313
- let x = embedRows(stages[0], piece, lo);
314
  for (let s = 0; s < stages.length; s++) {
315
- spans[s].pos0 = lo;
316
  x = await runLayersChunk(stages[s], x, spans[s]);
317
  }
318
  last = x;
@@ -388,16 +388,13 @@
388
  const maxDraft = opts && opts.maxDraft != null ? opts.maxDraft : 4;
389
  let produced = 0;
390
  while (produced < nNew) {
391
- let draft = SH.promptLookupDraft(ids, maxDraft);
392
- const seqIds = ids.concat(draft);
393
- const shown = seqIds.slice(-T);
394
- const shownDraft = Math.min(draft.length, Math.max(0, shown.length - 1));
395
- draft = draft.slice(draft.length - shownDraft);
396
  const win = new Int32Array(T);
397
- for (let i = 0; i < shown.length; i++) win[T - shown.length + i] = shown[i];
398
  let x = embed(head, win);
399
  for (const S of stages) x = await runLayers(S, x);
400
- const rows = draftRows(T, shown.length, draft.length);
401
  const picked = [];
402
  for (const logits of await logitsAt(head, x, rows)) picked.push(pickToken(logits, opts));
403
  const got = SH.acceptDraft(picked, draft);
 
308
  const spans = stages.map(() => ({ cache: [], pos0: 0, li: 0 }));
309
  const size = Math.max(1, chunkSize | 0);
310
  let last = null;
311
+ for (const ch of SH.prefillChunks(ids.length, size, 1).chunks) {
312
+ const piece = ids.slice(ch.lo, ch.hi);
313
+ let x = embedRows(stages[0], piece, ch.lo);
314
  for (let s = 0; s < stages.length; s++) {
315
+ spans[s].pos0 = ch.lo;
316
  x = await runLayersChunk(stages[s], x, spans[s]);
317
  }
318
  last = x;
 
388
  const maxDraft = opts && opts.maxDraft != null ? opts.maxDraft : 4;
389
  let produced = 0;
390
  while (produced < nNew) {
391
+ const frame = SH.lookaheadFrame(ids, T, maxDraft);
392
+ const draft = frame.draft;
 
 
 
393
  const win = new Int32Array(T);
394
+ for (let i = 0; i < frame.win.length; i++) win[T - frame.win.length + i] = frame.win[i];
395
  let x = embed(head, win);
396
  for (const S of stages) x = await runLayers(S, x);
397
+ const rows = draftRows(T, frame.shown, draft.length);
398
  const picked = [];
399
  for (const logits of await logitsAt(head, x, rows)) picked.push(pickToken(logits, opts));
400
  const got = SH.acceptDraft(picked, draft);
public/shard.js CHANGED
@@ -163,6 +163,21 @@
163
  // count below is the MAC volume of one window through every layer, divided
164
  // by that unit. It is there to refuse a split that cannot produce a token
165
  // in `budgetMs`, before any weight download.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
166
  function estimateLapMs(spec, T, gemmPerSec) {
167
  const unit = 32 * 64 * 64;
168
  const C = spec.hidden;
@@ -186,10 +201,10 @@
186
  return bytes < deviceGB * 0.25 * 1073741824;
187
  }
188
 
189
- // A short prompt stays on the head when the head can hold every layer.
190
- // The ring still exists for prompts that do not fit that rule.
191
  function preferLocal(promptTokens, peerCount, fits) {
192
- return !!(fits && peerCount > 0 && promptTokens > 0 && promptTokens <= 64);
193
  }
194
 
195
  // Rolling FNV blocks over token ids. This is the approximate prefix index:
@@ -212,6 +227,7 @@
212
  blockSize: B,
213
  blocks,
214
  note(session, ids) { sessions.set(session, blocks(ids)); },
 
215
  matchedBlocks(session, ids) {
216
  const prev = sessions.get(session);
217
  if (!prev) return 0;
@@ -260,12 +276,23 @@
260
  // Prompt chunks for a pipelined prefill. `window` is how many chunks may be
261
  // in flight across the ring at once. Decode stays one token, one lap:
262
  // the next token is not known until the lap returns.
 
 
263
  function prefillChunks(nTokens, chunkSize, inFlight) {
264
  const size = Math.max(1, chunkSize | 0);
265
  const window = Math.max(1, inFlight | 0);
 
 
 
266
  const chunks = [];
267
- for (let lo = 0; lo < nTokens; lo += size)
268
- chunks.push({ index: chunks.length, lo, hi: Math.min(nTokens, lo + size) });
 
 
 
 
 
 
269
  return { chunks, window, chunkSize: size };
270
  }
271
 
@@ -295,6 +322,17 @@
295
 
296
  // picked[i] proposes draft[i]. picked has one extra entry: the token at the
297
  // first position that rejected, which is always kept.
 
 
 
 
 
 
 
 
 
 
 
298
  function acceptDraft(picked, draft) {
299
  const out = [];
300
  let i = 0;
@@ -306,7 +344,8 @@
306
  const api = { planStages, stageBytes, stageWeights, toKN, transposeEmb,
307
  fnv1a, hashF32, modelFingerprint, ringMembers, estimateLapMs,
308
  headCanHold, preferLocal, makePrefixIndex, makeKvSpill,
309
- prefillChunks, promptLookupDraft, acceptDraft };
 
310
  if (typeof module !== "undefined" && module.exports) module.exports = api;
311
  else root.Shard = api;
312
  })(typeof self !== "undefined" ? self : this);
 
163
  // count below is the MAC volume of one window through every layer, divided
164
  // by that unit. It is there to refuse a split that cannot produce a token
165
  // in `budgetMs`, before any weight download.
166
+ // Serial time of one lap on this plan. Each stage pays for the layers it
167
+ // holds, at its own measured rate. A slow stage with a real share of the
168
+ // layers dominates. The head with no layers still pays a small share for
169
+ // the embedding and the readout.
170
+ function estimatePlanMs(spec, T, stages) {
171
+ const layers = Math.max(spec.layers, 1);
172
+ let ms = 0;
173
+ for (const s of stages) {
174
+ const owned = Math.max(0, (s.hi | 0) - (s.lo | 0));
175
+ const share = owned > 0 ? owned / layers : (s.head ? 0.05 : 0);
176
+ ms += estimateLapMs(spec, T, s.capacity || 0) * share;
177
+ }
178
+ return ms;
179
+ }
180
+
181
  function estimateLapMs(spec, T, gemmPerSec) {
182
  const unit = 32 * 64 * 64;
183
  const C = spec.hidden;
 
201
  return bytes < deviceGB * 0.25 * 1073741824;
202
  }
203
 
204
+ // If the head can hold every layer, the ring only adds a hop. Run here.
205
+ // Unknown memory leaves `fits` false, so the ring is used rather than guessed.
206
  function preferLocal(promptTokens, peerCount, fits) {
207
+ return !!(fits && peerCount > 0 && promptTokens > 0);
208
  }
209
 
210
  // Rolling FNV blocks over token ids. This is the approximate prefix index:
 
227
  blockSize: B,
228
  blocks,
229
  note(session, ids) { sessions.set(session, blocks(ids)); },
230
+ restore(session, hashes) { sessions.set(session, hashes.slice()); },
231
  matchedBlocks(session, ids) {
232
  const prev = sessions.get(session);
233
  if (!prev) return 0;
 
276
  // Prompt chunks for a pipelined prefill. `window` is how many chunks may be
277
  // in flight across the ring at once. Decode stays one token, one lap:
278
  // the next token is not known until the lap returns.
279
+ // Chunks are as even as the token count allows. A fixed size leaves a short
280
+ // tail, and the tail still pays a full hop while the other stages wait.
281
  function prefillChunks(nTokens, chunkSize, inFlight) {
282
  const size = Math.max(1, chunkSize | 0);
283
  const window = Math.max(1, inFlight | 0);
284
+ const n = Math.max(1, Math.ceil(Math.max(nTokens, 1) / size));
285
+ const base = Math.floor(nTokens / n);
286
+ let rem = nTokens - base * n;
287
  const chunks = [];
288
+ let lo = 0;
289
+ for (let i = 0; i < n; i++) {
290
+ const len = base + (rem > 0 ? 1 : 0);
291
+ if (rem > 0) rem--;
292
+ if (len < 1) break;
293
+ chunks.push({ index: chunks.length, lo, hi: lo + len });
294
+ lo += len;
295
+ }
296
  return { chunks, window, chunkSize: size };
297
  }
298
 
 
322
 
323
  // picked[i] proposes draft[i]. picked has one extra entry: the token at the
324
  // first position that rejected, which is always kept.
325
+ // One description of the window both the ring and the local check run.
326
+ // `win` is the token ids placed at the right edge of a context of length T.
327
+ function lookaheadFrame(ids, T, maxDraft) {
328
+ let draft = promptLookupDraft(ids, maxDraft);
329
+ const seqIds = ids.concat(draft);
330
+ const shown = seqIds.slice(-T);
331
+ const shownDraft = Math.min(draft.length, Math.max(0, shown.length - 1));
332
+ draft = draft.slice(draft.length - shownDraft);
333
+ return { draft, shown: shown.length, win: shown.slice() };
334
+ }
335
+
336
  function acceptDraft(picked, draft) {
337
  const out = [];
338
  let i = 0;
 
344
  const api = { planStages, stageBytes, stageWeights, toKN, transposeEmb,
345
  fnv1a, hashF32, modelFingerprint, ringMembers, estimateLapMs,
346
  headCanHold, preferLocal, makePrefixIndex, makeKvSpill,
347
+ prefillChunks, promptLookupDraft, acceptDraft, lookaheadFrame,
348
+ estimatePlanMs };
349
  if (typeof module !== "undefined" && module.exports) module.exports = api;
350
  else root.Shard = api;
351
  })(typeof self !== "undefined" ? self : this);
test_pipeline.js CHANGED
@@ -207,6 +207,12 @@ const GPT2 = Arch.fromConfig({
207
  const fast = Shard.estimateLapMs(spec, 8, 1000);
208
  assert.ok(slow > fast, "a longer window on a slower device must cost more");
209
  assert.ok(fast > 0);
 
 
 
 
 
 
210
  });
211
 
212
  await t("a short prompt stays local only when the head can hold the model", async () => {
@@ -215,7 +221,7 @@ const GPT2 = Arch.fromConfig({
215
  assert.strictEqual(Shard.headCanHold(spec, tensors, 0), false);
216
  assert.strictEqual(Shard.headCanHold(spec, tensors, 8), true);
217
  assert.strictEqual(Shard.preferLocal(16, 2, true), true);
218
- assert.strictEqual(Shard.preferLocal(128, 2, true), false);
219
  assert.strictEqual(Shard.preferLocal(16, 2, false), false);
220
  assert.strictEqual(Shard.preferLocal(16, 0, true), false);
221
  });
@@ -223,8 +229,13 @@ const GPT2 = Arch.fromConfig({
223
  await t("prefix blocks match a continuation and the spill evicts the oldest block", async () => {
224
  const idx = Shard.makePrefixIndex(4);
225
  const a = [1, 2, 3, 4, 5, 6, 7, 8];
 
 
 
226
  idx.note("chat", a);
 
227
  assert.strictEqual(idx.matchedBlocks("chat", a.concat([9])), 2);
 
228
  assert.strictEqual(idx.matchedBlocks("chat", [9, 9, 9, 9]), 0);
229
  const spill = Shard.makeKvSpill(12);
230
  assert.strictEqual(spill.put("a", new Uint8Array(8)), true);
@@ -237,7 +248,12 @@ const GPT2 = Arch.fromConfig({
237
  await t("prefill chunks, prompt-lookup drafts, and accept keep a bonus token", async () => {
238
  const c = Shard.prefillChunks(40, 16, 2);
239
  assert.strictEqual(c.chunks.length, 3);
240
- assert.deepStrictEqual([c.chunks[2].lo, c.chunks[2].hi], [32, 40]);
 
 
 
 
 
241
  assert.strictEqual(c.window, 2);
242
  const ids = [1, 2, 3, 9, 1, 2, 3];
243
  assert.deepStrictEqual(Shard.promptLookupDraft(ids, 4), [9]);
 
207
  const fast = Shard.estimateLapMs(spec, 8, 1000);
208
  assert.ok(slow > fast, "a longer window on a slower device must cost more");
209
  assert.ok(fast > 0);
210
+ const solo = Shard.estimatePlanMs(spec, 32, [{ lo: 0, hi: spec.layers, head: true, capacity: 1000 }]);
211
+ const ring = Shard.estimatePlanMs(spec, 32, [
212
+ { lo: 0, hi: 3, head: true, capacity: 1000 },
213
+ { lo: 3, hi: spec.layers, head: false, capacity: 10 },
214
+ ]);
215
+ assert.ok(ring > solo, "a slow stage must raise the lap estimate");
216
  });
217
 
218
  await t("a short prompt stays local only when the head can hold the model", async () => {
 
221
  assert.strictEqual(Shard.headCanHold(spec, tensors, 0), false);
222
  assert.strictEqual(Shard.headCanHold(spec, tensors, 8), true);
223
  assert.strictEqual(Shard.preferLocal(16, 2, true), true);
224
+ assert.strictEqual(Shard.preferLocal(128, 2, true), true);
225
  assert.strictEqual(Shard.preferLocal(16, 2, false), false);
226
  assert.strictEqual(Shard.preferLocal(16, 0, true), false);
227
  });
 
229
  await t("prefix blocks match a continuation and the spill evicts the oldest block", async () => {
230
  const idx = Shard.makePrefixIndex(4);
231
  const a = [1, 2, 3, 4, 5, 6, 7, 8];
232
+ const kept = [1, 2, 3, 4, 5];
233
+ idx.restore("kept", idx.blocks(kept).slice(0, 1));
234
+ assert.strictEqual(idx.matchedBlocks("kept", kept.concat([6])), 1);
235
  idx.note("chat", a);
236
+ idx.note("other", [9, 9, 9, 9, 8]);
237
  assert.strictEqual(idx.matchedBlocks("chat", a.concat([9])), 2);
238
+ assert.strictEqual(idx.matchedBlocks("other", [9, 9, 9, 9, 1]), 1);
239
  assert.strictEqual(idx.matchedBlocks("chat", [9, 9, 9, 9]), 0);
240
  const spill = Shard.makeKvSpill(12);
241
  assert.strictEqual(spill.put("a", new Uint8Array(8)), true);
 
248
  await t("prefill chunks, prompt-lookup drafts, and accept keep a bonus token", async () => {
249
  const c = Shard.prefillChunks(40, 16, 2);
250
  assert.strictEqual(c.chunks.length, 3);
251
+ assert.deepStrictEqual(c.chunks.map(x => x.hi - x.lo), [14, 13, 13]);
252
+ assert.strictEqual(c.chunks[2].hi, 40);
253
+ assert.strictEqual(c.chunks[0].lo, 0);
254
+ const frame = Shard.lookaheadFrame([1, 2, 3, 9, 1, 2, 3], 16, 4);
255
+ assert.deepStrictEqual(frame.draft, [9]);
256
+ assert.strictEqual(frame.win.length, 8);
257
  assert.strictEqual(c.window, 2);
258
  const ids = [1, 2, 3, 9, 1, 2, 3];
259
  assert.deepStrictEqual(Shard.promptLookupDraft(ids, 4), [9]);