Date the 2026-10-22 README additions and finish the scheduling pass.
Browse filesEach 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.
- GUIDE.md +40 -6
- README.md +51 -11
- docs/ARCHITECTURE.md +48 -1
- docs/GETTING_STARTED.md +26 -8
- public/app.js +51 -27
- public/infer.js +8 -11
- public/shard.js +45 -6
- 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
|
| 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 |
-
- **
|
| 183 |
-
keeps
|
| 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**
|
| 188 |
-
|
| 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
|
| 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,
|
| 202 |
-
public/infer.js forward pass
|
| 203 |
public/wire.js binary protocol + routing, as pure functions
|
| 204 |
-
public/app.js WebRTC mesh, assignments,
|
| 205 |
-
public/verified_core.js the verified INT8 units (
|
| 206 |
-
public/webgpu.js WGSL kernels + exact gates (
|
| 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 |
-
- **
|
| 220 |
-
|
|
|
|
|
|
|
| 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**
|
| 224 |
-
|
| 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
|
| 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
|
| 105 |
-
|
| 106 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
- **"
|
| 131 |
-
|
|
|
|
|
|
|
|
|
|
| 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 |
-
- **"
|
| 143 |
-
|
| 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 |
-
|
| 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
|
|
|
|
| 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(`
|
| 781 |
-
`
|
| 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(
|
| 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(
|
| 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,
|
| 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 |
-
|
|
|
|
|
|
|
| 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 |
-
|
| 876 |
-
const
|
| 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 <
|
| 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,
|
| 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 (
|
| 312 |
-
const piece = ids.slice(lo,
|
| 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 |
-
|
| 392 |
-
const
|
| 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 <
|
| 398 |
let x = embed(head, win);
|
| 399 |
for (const S of stages) x = await runLayers(S, x);
|
| 400 |
-
const rows = draftRows(T,
|
| 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 |
-
//
|
| 190 |
-
//
|
| 191 |
function preferLocal(promptTokens, peerCount, fits) {
|
| 192 |
-
return !!(fits && peerCount > 0 && promptTokens > 0
|
| 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 |
-
|
| 268 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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),
|
| 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(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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]);
|