DaisyChain-Infer: project files + guide
Browse filesLayer-sharded peer-to-peer inference. Each device downloads only its own
layers from the Hub via safetensors byte ranges, so no device holds the
whole model -- this pools memory, which DaisyChain-Train explicitly cannot.
Loads any Llama-style or GPT-2-style .safetensors repo. Verified INT8 units,
WGSL kernels and their exact gates carried over from DaisyChain-Web unchanged.
47 checks: bit-exact split equivalence (both families, with mutation checks),
wire protocol refusals, ring routing, safetensors/dtype/BPE loader.
- GUIDE.md +197 -0
- LICENSE +21 -0
- README.md +247 -0
- docs/ARCHITECTURE.md +160 -0
- docs/GETTING_STARTED.md +152 -0
- package.json +16 -0
- public/app.js +770 -0
- public/arch.js +153 -0
- public/hf.js +155 -0
- public/index.html +252 -0
- public/infer.js +268 -0
- public/luts_meta.json +1 -0
- public/mul_lut.bin +3 -0
- public/relu_lut.bin +3 -0
- public/requant_lut.bin +3 -0
- public/safetensors.js +128 -0
- public/shard.js +155 -0
- public/tokenizer.js +0 -0
- public/traincore.js +99 -0
- public/verified_core.js +496 -0
- public/webgpu.js +996 -0
- public/wire.js +171 -0
- server.js +257 -0
- test_loader.js +179 -0
- test_pipeline.js +193 -0
- test_wire.js +209 -0
GUIDE.md
ADDED
|
@@ -0,0 +1,197 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# ❄ DaisyChain-Infer — run one model across your devices
|
| 2 |
+
|
| 3 |
+
Part of **DaisyChain** → https://huggingface.co/DaisyChainAI
|
| 4 |
+
|
| 5 |
+
Point it at a **Hugging Face model**, open the page on two or more devices, and
|
| 6 |
+
each one takes a slice of the model's layers. A token is produced by passing
|
| 7 |
+
the hidden state around the ring, peer-to-peer over WebRTC. Every multiply runs
|
| 8 |
+
through the same **verified INT8 units** as the rest of DaisyChain — WebGPU
|
| 9 |
+
where available, the identical math on CPU where not.
|
| 10 |
+
|
| 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
|
| 17 |
+
```
|
| 18 |
+
|
| 19 |
+
Built from [DaisyChain-Train](https://huggingface.co/DaisyChainAI/DaisyChain-Train)
|
| 20 |
+
and [DaisyChain-Web](https://huggingface.co/spaces/Quazim0t0/DaisyChain-Web) —
|
| 21 |
+
the verified units, the WGSL kernels and their exact gates, and the WebRTC mesh
|
| 22 |
+
are carried over unchanged.
|
| 23 |
+
|
| 24 |
+
---
|
| 25 |
+
|
| 26 |
+
## What is actually new here
|
| 27 |
+
|
| 28 |
+
DaisyChain-Train is explicit about its limit: it **pools compute, not memory**.
|
| 29 |
+
Every node holds a full replica, so a model bigger than one machine cannot be
|
| 30 |
+
trained, and chaining five laptops does not give you one big machine.
|
| 31 |
+
|
| 32 |
+
Inference is where that limit can be lifted, because a forward pass is a chain:
|
| 33 |
+
layer `l` needs layer `l-1`'s **output**, never its **weights**.
|
| 34 |
+
|
| 35 |
+
And because safetensors gives every tensor an exact byte range, **each device
|
| 36 |
+
downloads only its own layers, straight from the Hub**. No device — not even
|
| 37 |
+
the one driving the run — ever holds the whole model.
|
| 38 |
+
|
| 39 |
+
| | DaisyChain-Train | DaisyChain-Infer |
|
| 40 |
+
|---|---|---|
|
| 41 |
+
| What is split | the **batch** | the **model** |
|
| 42 |
+
| What crosses the wire | gradients (whole-model sized, every step) | hidden states (`T×hidden` floats, per hop) |
|
| 43 |
+
| Every device holds | the entire model | its own layers only |
|
| 44 |
+
| Pools | compute | **memory** |
|
| 45 |
+
| More devices means | more throughput | a **bigger model fits** |
|
| 46 |
+
|
| 47 |
+
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 |
+
```
|
| 54 |
+
┌──────────────── hidden state (T×hidden f32) ─────────────┐
|
| 55 |
+
▼ │
|
| 56 |
+
┌─────────┐ ┌─────────┐ ┌─────────┐ │
|
| 57 |
+
│ stage 0 │───────▶│ stage 1 │───────▶│ stage 2 │───────────────┘
|
| 58 |
+
│ HEAD │ │ layers │ │ layers │
|
| 59 |
+
│ embed │ │ 10–19 │ │ 20–29 │
|
| 60 |
+
│ layers │ └─────────┘ └─────────┘
|
| 61 |
+
│ 0–9 │
|
| 62 |
+
│ lm_head │◀── the returning state becomes the next token
|
| 63 |
+
└─────────┘
|
| 64 |
+
```
|
| 65 |
+
|
| 66 |
+
It is a **ring, not a line**, and weight tying forces that. When `lm_head` is
|
| 67 |
+
tied to the embedding table — as it is in most small models — the largest
|
| 68 |
+
tensor is needed at *both* ends: to embed the prompt and to produce the logits.
|
| 69 |
+
Copying it to the last stage would hand back most of the memory just pooled, so
|
| 70 |
+
the last stage returns its hidden state to the head, which owns the embedding
|
| 71 |
+
**once** and does both ends.
|
| 72 |
+
|
| 73 |
+
A consequence worth stating plainly: a middle stage receives layer weights
|
| 74 |
+
only. It never gets the embedding table, so **it never sees the vocabulary** —
|
| 75 |
+
it passes floats it cannot interpret.
|
| 76 |
+
|
| 77 |
+
## Models it can run
|
| 78 |
+
|
| 79 |
+
Any Hugging Face repo with `.safetensors` weights whose architecture is:
|
| 80 |
+
|
| 81 |
+
- **Llama-style** — Llama, Mistral, Qwen2/2.5, SmolLM, TinyLlama
|
| 82 |
+
(RMSNorm, RoPE, grouped-query attention, SwiGLU)
|
| 83 |
+
- **GPT-2-style** — LayerNorm with bias, learned positions, fused QKV, GELU
|
| 84 |
+
|
| 85 |
+
Verified working end to end: `HuggingFaceTB/SmolLM-135M`,
|
| 86 |
+
`openai-community/gpt2`, `Qwen/Qwen2.5-0.5B`.
|
| 87 |
+
|
| 88 |
+
Anything else is **refused by name**, not approximated. Treating an unknown
|
| 89 |
+
architecture as a known one produces fluent, confident, wrong output, which is
|
| 90 |
+
the failure this project spends its verification budget making impossible. The
|
| 91 |
+
same applies to tokenizers: byte-level BPE is implemented, and a
|
| 92 |
+
SentencePiece/Unigram repo is rejected rather than tokenized approximately.
|
| 93 |
+
|
| 94 |
+
Practical ceiling: weights are held as f32, so budget ~4 bytes per parameter
|
| 95 |
+
across the group, and remember the head also carries the embedding table.
|
| 96 |
+
|
| 97 |
+
## The token
|
| 98 |
+
|
| 99 |
+
Gated or private models need a Hugging Face token. You are asked for one
|
| 100 |
+
**once**, when a request actually fails for want of it — not up front.
|
| 101 |
+
|
| 102 |
+
It is held in memory for that tab and nowhere else: **not** localStorage, not
|
| 103 |
+
sessionStorage, not a cookie, not the URL, never written to the log, and
|
| 104 |
+
**never sent to another device**. Each device is asked for its own, because
|
| 105 |
+
each device fetches its own layers. Reloading the tab forgets it, and there is
|
| 106 |
+
a *Forget token* button.
|
| 107 |
+
|
| 108 |
+
## Verification
|
| 109 |
+
|
| 110 |
+
The trainer's stack carries over unchanged: exact init gates on every kernel on
|
| 111 |
+
every boot, the continuous random-cell audit at live shapes, and the
|
| 112 |
+
cross-device kernel probe.
|
| 113 |
+
|
| 114 |
+
But a pipeline moves the risk somewhere those instruments cannot reach:
|
| 115 |
+
|
| 116 |
+
> In data-parallel **training**, every peer computes the same thing, so a
|
| 117 |
+
> device with broken arithmetic shows up as a diverging replica. In a
|
| 118 |
+
> **pipeline**, each stage computes something *different* and nobody else
|
| 119 |
+
> repeats it. There is no replica to compare against. A wrong middle stage
|
| 120 |
+
> produces a fluent, confident, wrong answer, and no consistency check
|
| 121 |
+
> anywhere in the system would notice.
|
| 122 |
+
|
| 123 |
+
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.
|
| 131 |
+
3. **Structural validation** of every assignment — a plan that does not cover
|
| 132 |
+
each layer exactly once is refused, because it would still generate fluent
|
| 133 |
+
text with a layer missing.
|
| 134 |
+
4. **The differential check** (tick *Verify*). The driving device re-runs the
|
| 135 |
+
identical prompt with every layer locally and compares token ids. It is the
|
| 136 |
+
only instrument that can show a distributed answer is *right* rather than
|
| 137 |
+
merely self-consistent.
|
| 138 |
+
|
| 139 |
+
```bash
|
| 140 |
+
npm test # pipeline equivalence + wire protocol + loader
|
| 141 |
+
```
|
| 142 |
+
|
| 143 |
+
`test_pipeline.js` asserts that splitting changes **no bit**, for *both*
|
| 144 |
+
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
|
| 151 |
+
sequences identical to the single-device run.
|
| 152 |
+
|
| 153 |
+
`test_loader.js` builds safetensors files and reads them back, checks BF16/F16
|
| 154 |
+
widening is exact (including subnormals and signed zero), and round-trips the
|
| 155 |
+
BPE tokenizer. `test_wire.js` round-trips the protocol and refuses the
|
| 156 |
+
malformed messages that would otherwise produce plausible wrong answers — a
|
| 157 |
+
plan with a gap, a crafted repo id, a one-ulp flip deep in an activation, a
|
| 158 |
+
`-0` flipped to `+0`.
|
| 159 |
+
|
| 160 |
+
### One bug, and what it cost to find
|
| 161 |
+
|
| 162 |
+
The first live two-device run stalled. The head is both stage 0 *and* the
|
| 163 |
+
ring's terminus, so an activation addressed to index 0 meant "start the lap"
|
| 164 |
+
outbound and "the lap is finished" inbound. The head read the return leg as its
|
| 165 |
+
own turn, re-ran its own layers, forwarded again, and the lap never closed.
|
| 166 |
+
|
| 167 |
+
Every number was correct. Every message round-tripped. **Neither test suite
|
| 168 |
+
could see it** — the pipeline test calls the stages in order itself, and the
|
| 169 |
+
codec test only checks bytes. The defect lived in the *route*, precisely the
|
| 170 |
+
category DaisyChain-Web's own self-corpus writeup identified as needing
|
| 171 |
+
different instruments rather than better oracles. The fix gave the return leg
|
| 172 |
+
its own address, and the routing decision moved out of the event handler into a
|
| 173 |
+
pure function so `walkLap` in `test_wire.js` can walk a lap and assert it
|
| 174 |
+
visits each stage once and terminates. That test fails against the old
|
| 175 |
+
behaviour; it was checked.
|
| 176 |
+
|
| 177 |
+
## Honest limits
|
| 178 |
+
|
| 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
|
| 192 |
+
see each other's IPs, and the head sees your prompt. Run rings with people
|
| 193 |
+
you trust.
|
| 194 |
+
|
| 195 |
+
---
|
| 196 |
+
|
| 197 |
+
**License:** MIT · **Author:** Dean Byrne (Quazim0t0) · **Org:** DaisyChainAI
|
LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
MIT License
|
| 2 |
+
|
| 3 |
+
Copyright (c) 2026 Dean Byrne (Quazim0t0) / DaisyChainAI
|
| 4 |
+
|
| 5 |
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
| 6 |
+
of this software and associated documentation files (the "Software"), to deal
|
| 7 |
+
in the Software without restriction, including without limitation the rights
|
| 8 |
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
| 9 |
+
copies of the Software, and to permit persons to whom the Software is
|
| 10 |
+
furnished to do so, subject to the following conditions:
|
| 11 |
+
|
| 12 |
+
The above copyright notice and this permission notice shall be included in all
|
| 13 |
+
copies or substantial portions of the Software.
|
| 14 |
+
|
| 15 |
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
| 16 |
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
| 17 |
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
| 18 |
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
| 19 |
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
| 20 |
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
| 21 |
+
SOFTWARE.
|
README.md
ADDED
|
@@ -0,0 +1,247 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
license: mit
|
| 3 |
+
tags:
|
| 4 |
+
- distributed-inference
|
| 5 |
+
- pipeline-parallel
|
| 6 |
+
- webgpu
|
| 7 |
+
- webrtc
|
| 8 |
+
- int8
|
| 9 |
+
- old-hardware
|
| 10 |
+
---
|
| 11 |
+
|
| 12 |
+
# ❄ DaisyChain-Infer — run one model across several devices
|
| 13 |
+
|
| 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
|
| 20 |
+
> more devices, and each one holds only a **slice of the model's layers**. A
|
| 21 |
+
> token is produced by passing the hidden state around the ring, peer-to-peer.
|
| 22 |
+
> The group can run a model that **no single device could hold**.
|
| 23 |
+
> Before you rely on it, read [Honest limits](#honest-limits).
|
| 24 |
+
|
| 25 |
+
This repo is the **project and the guide**. Clone it and run it on your own
|
| 26 |
+
machines:
|
| 27 |
+
|
| 28 |
+
```bash
|
| 29 |
+
npm install
|
| 30 |
+
npm start # http://localhost:8788
|
| 31 |
+
npm test # 47 checks: pipeline equivalence, wire protocol, loader
|
| 32 |
+
```
|
| 33 |
+
|
| 34 |
+
Full walkthrough: **[docs/GETTING_STARTED.md](docs/GETTING_STARTED.md)** ·
|
| 35 |
+
Internals: **[docs/ARCHITECTURE.md](docs/ARCHITECTURE.md)** ·
|
| 36 |
+
The guide as one page: **[GUIDE.md](GUIDE.md)**
|
| 37 |
+
|
| 38 |
+
---
|
| 39 |
+
|
| 40 |
+
## What this adds to DaisyChain
|
| 41 |
+
|
| 42 |
+
DaisyChain-Train is explicit about its limit: it **pools compute, not memory**.
|
| 43 |
+
Every node holds a full replica, so a model bigger than one machine cannot be
|
| 44 |
+
trained, and chaining five laptops does not give you one big machine.
|
| 45 |
+
|
| 46 |
+
Inference is where that limit can be lifted, because a forward pass is a chain:
|
| 47 |
+
layer `l` needs layer `l-1`'s **output**, never its **weights**. So the layers
|
| 48 |
+
live on different machines and the activation travels instead.
|
| 49 |
+
|
| 50 |
+
And because safetensors gives every tensor an exact byte range, **each device
|
| 51 |
+
fetches only its own layers straight from the Hub**. No device — not even the
|
| 52 |
+
one driving the run — ever holds the whole model. That is what makes the pooling
|
| 53 |
+
real rather than a redistribution of something one machine already had to load.
|
| 54 |
+
|
| 55 |
+
| | DaisyChain-Train | DaisyChain-Infer |
|
| 56 |
+
|---|---|---|
|
| 57 |
+
| What is split | the **batch** | the **model** |
|
| 58 |
+
| What crosses the wire | gradients (whole-model sized, every step) | hidden states (`T×hidden` floats, per hop) |
|
| 59 |
+
| Every device holds | the entire model | its own layers only |
|
| 60 |
+
| Pools | compute | **memory** |
|
| 61 |
+
| More devices means | more throughput | a **bigger model fits** |
|
| 62 |
+
|
| 63 |
+
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 |
+
```
|
| 70 |
+
┌──────────────── hidden state (T×hidden f32) ─────────────┐
|
| 71 |
+
▼ │
|
| 72 |
+
┌─────────┐ ┌─────────┐ ┌─────────┐ │
|
| 73 |
+
│ stage 0 │───────▶│ stage 1 │───────▶│ stage 2 │───────────────┘
|
| 74 |
+
│ HEAD │ │ layers │ │ layers │
|
| 75 |
+
│ embed │ │ 10–19 │ │ 20–29 │
|
| 76 |
+
│ layers │ └─────────┘ └─────────┘
|
| 77 |
+
│ 0–9 │
|
| 78 |
+
│ lm_head │◀── the returning state becomes the next token
|
| 79 |
+
└─────────┘
|
| 80 |
+
```
|
| 81 |
+
|
| 82 |
+
It is a **ring, not a line**, and weight tying forces that. When `lm_head` is
|
| 83 |
+
tied to the embedding table — as it is in most small models — the largest tensor
|
| 84 |
+
is needed at *both* ends: to embed the prompt and to produce the logits. Copying
|
| 85 |
+
it to the last stage would hand back most of the memory just pooled, so the last
|
| 86 |
+
stage returns its hidden state to the head, which owns the embedding **once**.
|
| 87 |
+
|
| 88 |
+
A consequence worth stating plainly: a middle stage receives layer weights only.
|
| 89 |
+
It never gets the embedding table, so **it never sees the vocabulary** — it
|
| 90 |
+
passes floats it cannot interpret.
|
| 91 |
+
|
| 92 |
+
## Models it can run
|
| 93 |
+
|
| 94 |
+
Any Hugging Face repo with `.safetensors` weights whose architecture is:
|
| 95 |
+
|
| 96 |
+
- **Llama-style** — Llama, Mistral, Qwen2/2.5, SmolLM, TinyLlama
|
| 97 |
+
(RMSNorm, RoPE, grouped-query attention, SwiGLU)
|
| 98 |
+
- **GPT-2-style** — LayerNorm with bias, learned positions, fused QKV, GELU
|
| 99 |
+
|
| 100 |
+
Verified end to end: `HuggingFaceTB/SmolLM-135M`, `openai-community/gpt2`,
|
| 101 |
+
`Qwen/Qwen2.5-0.5B`.
|
| 102 |
+
|
| 103 |
+
Anything else is **refused by name**, not approximated. Treating an unknown
|
| 104 |
+
architecture as a known one produces fluent, confident, wrong output — the
|
| 105 |
+
failure this project spends its verification budget making impossible. The same
|
| 106 |
+
applies to tokenizers: byte-level BPE is implemented, and a SentencePiece or
|
| 107 |
+
WordPiece repo is rejected rather than tokenized approximately.
|
| 108 |
+
|
| 109 |
+
Two details that are silent when wrong, and so are decided by evidence rather
|
| 110 |
+
than assumption:
|
| 111 |
+
|
| 112 |
+
- **Weight layout.** `torch.nn.Linear` stores `(out, in)`; GPT-2's `Conv1D`
|
| 113 |
+
stores `(in, out)`, which is already the `k×n` the GEMM wants. A wrong
|
| 114 |
+
transpose does not throw.
|
| 115 |
+
- **Bias presence.** Qwen2.5 ships q/k/v biases and SmolLM does not, and neither
|
| 116 |
+
says so in `config.json`. Bias is decided by what the weight file contains.
|
| 117 |
+
|
| 118 |
+
## Credentials
|
| 119 |
+
|
| 120 |
+
Public models need nothing. For gated or private ones, the **local** build asks
|
| 121 |
+
for a read token once, when a request actually fails for want of it — held in
|
| 122 |
+
memory for that tab only, never written to storage, never logged, never put in a
|
| 123 |
+
URL, and **never sent to another device** (each device authenticates itself,
|
| 124 |
+
because each fetches its own layers).
|
| 125 |
+
|
| 126 |
+
The hosted Space instead offers **Sign in with Hugging Face** and has no
|
| 127 |
+
paste-a-token box at all, because typing a personal access token into a page you
|
| 128 |
+
do not control is a bad habit even when the code is honest.
|
| 129 |
+
|
| 130 |
+
## Verification
|
| 131 |
+
|
| 132 |
+
The trainer's stack carries over unchanged: exact init gates on every kernel on
|
| 133 |
+
every boot, the continuous random-cell audit at live shapes, and the cross-device
|
| 134 |
+
kernel probe.
|
| 135 |
+
|
| 136 |
+
But a pipeline moves the risk somewhere those instruments cannot reach:
|
| 137 |
+
|
| 138 |
+
> In data-parallel **training**, every peer computes the same thing, so a device
|
| 139 |
+
> with broken arithmetic shows up as a diverging replica. In a **pipeline**, each
|
| 140 |
+
> stage computes something *different* and nobody else repeats it. There is no
|
| 141 |
+
> replica to compare against. A wrong middle stage produces a fluent, confident,
|
| 142 |
+
> wrong answer, and no consistency check anywhere in the system would notice.
|
| 143 |
+
|
| 144 |
+
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.
|
| 152 |
+
3. **Structural validation** of every assignment — a plan that does not cover
|
| 153 |
+
each layer exactly once is refused, because it would still generate fluent
|
| 154 |
+
text with a layer missing.
|
| 155 |
+
4. **The differential check** — the head re-running the identical prompt with
|
| 156 |
+
every layer locally and comparing token ids. The only instrument that can show
|
| 157 |
+
a distributed answer is *right* rather than merely self-consistent.
|
| 158 |
+
|
| 159 |
+
`test_pipeline.js` asserts that splitting changes **no bit**, for both
|
| 160 |
+
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
|
| 167 |
+
identical to the single-device run.
|
| 168 |
+
|
| 169 |
+
`test_loader.js` builds safetensors files and reads them back, checks BF16/F16
|
| 170 |
+
widening is exact (including subnormals and signed zero), and round-trips the BPE
|
| 171 |
+
tokenizer. `test_wire.js` round-trips the protocol and refuses the malformed
|
| 172 |
+
messages that would otherwise produce plausible wrong answers — a plan with a
|
| 173 |
+
gap, a crafted repo id, a one-ulp flip deep in an activation, a `-0` flipped to
|
| 174 |
+
`+0`.
|
| 175 |
+
|
| 176 |
+
### One bug, and what it cost to find
|
| 177 |
+
|
| 178 |
+
The first live two-device run stalled. The head is both stage 0 *and* the ring's
|
| 179 |
+
terminus, so an activation addressed to index 0 meant "start the lap" outbound
|
| 180 |
+
and "the lap is finished" inbound. The head read the return leg as its own turn,
|
| 181 |
+
re-ran its own layers, forwarded again, and the lap never closed.
|
| 182 |
+
|
| 183 |
+
Every number was correct. Every message round-tripped. **Neither test suite could
|
| 184 |
+
see it** — the pipeline test calls the stages in order itself, and the codec test
|
| 185 |
+
only checks bytes. The defect lived in the *route*, precisely the category
|
| 186 |
+
DaisyChain-Web's own self-corpus writeup identified as needing different
|
| 187 |
+
instruments rather than better oracles. The fix gave the return leg its own
|
| 188 |
+
address, and the routing decision moved out of the event handler into a pure
|
| 189 |
+
function so `walkLap` in `test_wire.js` can walk a lap and assert it visits each
|
| 190 |
+
stage once and terminates. That test fails against the old behaviour; it was
|
| 191 |
+
checked.
|
| 192 |
+
|
| 193 |
+
## Layout
|
| 194 |
+
|
| 195 |
+
```
|
| 196 |
+
server.js signaling + static host + OAuth code exchange
|
| 197 |
+
public/safetensors.js header parsing, exact BF16/F16 widening, range coalescing
|
| 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
|
| 211 |
+
docs/ GETTING_STARTED, ARCHITECTURE
|
| 212 |
+
```
|
| 213 |
+
|
| 214 |
+
## Honest limits
|
| 215 |
+
|
| 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.
|
| 228 |
+
- **Proof of concept**, not a hardened service.
|
| 229 |
+
|
| 230 |
+
---
|
| 231 |
+
|
| 232 |
+
**License:** MIT · **Author:** Dean Byrne (Quazim0t0) · **Org:** DaisyChainAI
|
| 233 |
+
|
| 234 |
+
Built on [DaisyChain-Train](https://huggingface.co/DaisyChainAI/DaisyChain-Train)
|
| 235 |
+
and [DaisyChain-Web](https://huggingface.co/spaces/Quazim0t0/DaisyChain-Web).
|
| 236 |
+
|
| 237 |
+
## Citation
|
| 238 |
+
|
| 239 |
+
```bibtex
|
| 240 |
+
@misc{byrne2026daisychaininfer,
|
| 241 |
+
title = {DaisyChain-Infer: Layer-Sharded Peer-to-Peer Inference in the Browser},
|
| 242 |
+
author = {Byrne, Dean (Quazim0t0)},
|
| 243 |
+
year = {2026},
|
| 244 |
+
howpublished = {\url{https://huggingface.co/DaisyChainAI/DaisyChain-Infer}},
|
| 245 |
+
note = {Run one model across several devices; pools memory, not just compute}
|
| 246 |
+
}
|
| 247 |
+
```
|
docs/ARCHITECTURE.md
ADDED
|
@@ -0,0 +1,160 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Architecture
|
| 2 |
+
|
| 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 |
|
| 9 |
+
|---|---|
|
| 10 |
+
| `server.js` | WebSocket **signaling** + static hosting. Never sees a weight, an activation, a prompt, or a token. |
|
| 11 |
+
| `public/safetensors.js` | header parsing, dtype widening, byte-range coalescing. |
|
| 12 |
+
| `public/hf.js` | the Hub client — range reads, gated-repo handling, token redaction. |
|
| 13 |
+
| `public/arch.js` | `config.json` -> a normalized spec, and the tensor names to fetch. |
|
| 14 |
+
| `public/tokenizer.js` | byte-level BPE, read from the repo's own `tokenizer.json`. |
|
| 15 |
+
| `public/shard.js` | the **plan**, per-stage tensor sets, and weight-layout handling. |
|
| 16 |
+
| `public/infer.js` | the forward pass, split into `embed` / `runLayers` / `readout`. |
|
| 17 |
+
| `public/wire.js` | the binary protocol and the routing decisions, as pure functions. |
|
| 18 |
+
| `public/app.js` | the WebRTC mesh, assignments, the token prompt, and the token loop. |
|
| 19 |
+
| `public/verified_core.js` | the **verified INT8 units** — unchanged from DaisyChain-Web. |
|
| 20 |
+
| `public/webgpu.js` | the WGSL kernels and their **exact init gates** — unchanged. |
|
| 21 |
+
| `public/*.bin` | the units as lookup tables — unchanged. |
|
| 22 |
+
|
| 23 |
+
The last three being unchanged is deliberate. The arithmetic is copied, not
|
| 24 |
+
reimplemented, which is what makes the split checkable against the unsplit path
|
| 25 |
+
with `!==` instead of a tolerance.
|
| 26 |
+
|
| 27 |
+
## Reading a model without downloading it
|
| 28 |
+
|
| 29 |
+
A safetensors file is `[u64 headerLen][JSON header][tensor bytes]`, and the
|
| 30 |
+
header gives every tensor's exact byte range. So loading a model here means
|
| 31 |
+
fetching `config.json`, the tokenizer, and the weight *headers* — a few tens of
|
| 32 |
+
KB regardless of whether the model is 100 MB or 100 GB. After that, every
|
| 33 |
+
tensor's location is known and nothing else has been transferred.
|
| 34 |
+
|
| 35 |
+
`arch.js` turns the config into a normalized spec:
|
| 36 |
+
|
| 37 |
+
```
|
| 38 |
+
family, layers, hidden, heads, kvHeads, headDim, inter, vocab, maxPos,
|
| 39 |
+
norm ('rms'|'ln'), normEps, act ('silu'|'gelu'), gated, rope, ropeTheta,
|
| 40 |
+
tie, qkvFused, bias, weightLayout ('in_out'|'out_in')
|
| 41 |
+
```
|
| 42 |
+
|
| 43 |
+
Everything downstream works off that spec and never asks which family a model
|
| 44 |
+
came from. Unknown architectures are refused by name — silently treating one
|
| 45 |
+
block shape as another produces confident nonsense.
|
| 46 |
+
|
| 47 |
+
Two details that are silent when wrong, and so are decided by evidence rather
|
| 48 |
+
than assumption:
|
| 49 |
+
|
| 50 |
+
- **Weight layout.** `torch.nn.Linear` stores `(out, in)`; GPT-2's `Conv1D`
|
| 51 |
+
stores `(in, out)`, which is already the `k x n` the GEMM wants. A wrong
|
| 52 |
+
transpose does not throw. The layout comes from the spec and is applied once,
|
| 53 |
+
at load.
|
| 54 |
+
- **Bias presence.** Qwen2.5 ships q/k/v biases and SmolLM does not, and
|
| 55 |
+
neither says so in `config.json`. Bias is therefore decided by what the
|
| 56 |
+
weight file actually contains, not by a config flag.
|
| 57 |
+
|
| 58 |
+
## The plan
|
| 59 |
+
|
| 60 |
+
Whoever loads the model is the **head**: it owns the embedding table and so
|
| 61 |
+
does both ends of the pass. It orders devices itself-first, then by peer id,
|
| 62 |
+
and apportions blocks by **measured** capacity — each device times a real GEMM
|
| 63 |
+
through its own live kernel and reports GEMMs/sec, the same self-calibrating
|
| 64 |
+
idea as `cluster.py`'s `capacity_score`, except what is being balanced is
|
| 65 |
+
layers per device rather than batch per device.
|
| 66 |
+
|
| 67 |
+
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
|
| 74 |
+
at runtime; it would generate fluent text with a layer missing. The repo id is
|
| 75 |
+
validated too — it is interpolated into a Hub URL, so a peer must not be able
|
| 76 |
+
to point this device at an arbitrary path.
|
| 77 |
+
|
| 78 |
+
## Fetching a slice
|
| 79 |
+
|
| 80 |
+
Each device receives an assignment (model id, revision, spec, plan, its own
|
| 81 |
+
layer range) and then fetches **its own tensors itself**, with its own
|
| 82 |
+
credentials. Weights never travel between peers, which means:
|
| 83 |
+
|
| 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 |
+
|
| 91 |
+
1. The **head** embeds the last `T` tokens (plus learned positions, for GPT-2)
|
| 92 |
+
and runs its own layers.
|
| 93 |
+
2. It sends the hidden state to stage 1. Each stage runs its layers and passes
|
| 94 |
+
the result on.
|
| 95 |
+
3. The **last** stage sends the state back to the head, addressed `RETURN`.
|
| 96 |
+
4. The head applies the final norm and `lm_head` — for the last position only,
|
| 97 |
+
which at a 150k-token vocabulary turns the largest GEMM in the model into a
|
| 98 |
+
single row — picks a token, and starts the next lap.
|
| 99 |
+
|
| 100 |
+
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
|
| 107 |
+
−2…−8) so a client pointed at the wrong server sees an unknown tag rather than
|
| 108 |
+
a valid-looking message of the wrong kind.
|
| 109 |
+
|
| 110 |
+
| Sentinel | Message |
|
| 111 |
+
|---|---|
|
| 112 |
+
| `-5` | fragment — large messages chunked at 48 KB |
|
| 113 |
+
| `-20` | hello: measured capacity, kernel probe hash, backend |
|
| 114 |
+
| `-21` | assignment — model id + your layer range (**no weights**) |
|
| 115 |
+
| `-22` | ready — this stage has fetched and loaded its layers |
|
| 116 |
+
| `-23` | activation hop |
|
| 117 |
+
| `-24` | token emitted (so every device can watch the output) |
|
| 118 |
+
| `-25` | run finished |
|
| 119 |
+
|
| 120 |
+
Activation format:
|
| 121 |
+
`[i32 ACT][i32 seq][i32 tokenIdx][i32 nextIndex][u32 actHash][u32 modelHash][f32 hidden]`
|
| 122 |
+
|
| 123 |
+
The two hashes answer different questions. `actHash` asks whether the payload
|
| 124 |
+
arrived intact. `modelHash` asks whether this activation belongs to the model I
|
| 125 |
+
hold a slice of — not hypothetical, since reloading the head with a different
|
| 126 |
+
model leaves stages out there still holding the old one. It is derived from the
|
| 127 |
+
repo id, revision and tensor index rather than the weights, because no device
|
| 128 |
+
reads all of them.
|
| 129 |
+
|
| 130 |
+
## Backends
|
| 131 |
+
|
| 132 |
+
Unchanged from DaisyChain-Web: DP4A hardware int8 dot → LUT compute shader →
|
| 133 |
+
CPU mirror, each **exact-gated at init** and demoted on any mismatch. The CPU
|
| 134 |
+
mirror is not an approximation — it produces the same bits, which is what lets
|
| 135 |
+
a phone on CPU and a desktop on WebGPU sit in the same ring.
|
| 136 |
+
|
| 137 |
+
Attention runs per head through the same verified block GEMM, because real
|
| 138 |
+
models have head geometries (grouped-query, non-uniform head dims) that the
|
| 139 |
+
trainer's fused uniform-head kernels do not cover. Every product still goes
|
| 140 |
+
through the units.
|
| 141 |
+
|
| 142 |
+
There is no plain-float path. If the units fail to load, the device does not
|
| 143 |
+
compute.
|
| 144 |
+
|
| 145 |
+
## What the pipeline changes about verification
|
| 146 |
+
|
| 147 |
+
In data-parallel training every peer computes the same thing, so broken
|
| 148 |
+
arithmetic surfaces as a diverging replica. In a pipeline each stage computes
|
| 149 |
+
something different and nobody repeats it — there is no replica to compare
|
| 150 |
+
against, and a wrong middle stage yields a confident wrong answer.
|
| 151 |
+
|
| 152 |
+
What still works: the **kernel probe** (same seeded GEMM everywhere, so it is
|
| 153 |
+
comparable even when the real work is not), the init gates, and the live-shape
|
| 154 |
+
audit — all of which run per device regardless of what that device is computing.
|
| 155 |
+
|
| 156 |
+
What is new: activation hashes, the model fingerprint on every hop, and the
|
| 157 |
+
**differential check** — the head fetching the whole model and re-running the
|
| 158 |
+
prompt locally, then comparing token ids. It is the only instrument that can
|
| 159 |
+
prove a distributed answer right rather than merely self-consistent, and it is
|
| 160 |
+
offered rather than assumed because it needs a model one device can hold.
|
docs/GETTING_STARTED.md
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Getting started with DaisyChain-Infer
|
| 2 |
+
|
| 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
|
| 9 |
+
npm install
|
| 10 |
+
npm start # http://localhost:8788
|
| 11 |
+
```
|
| 12 |
+
|
| 13 |
+
Open it in two tabs to try the ring on one machine, or on two devices to try it
|
| 14 |
+
for real.
|
| 15 |
+
|
| 16 |
+
> **HTTPS matters.** WebGPU and cross-device WebRTC need a *secure context*:
|
| 17 |
+
> `localhost` or HTTPS. Plain `http://192.168.x.x` from another device will not
|
| 18 |
+
> get WebGPU and may not connect at all. For real multi-device runs on your own
|
| 19 |
+
> network, put it behind a tunnel (`cloudflared`, `ngrok`) or a local TLS
|
| 20 |
+
> reverse proxy.
|
| 21 |
+
|
| 22 |
+
## Pick a model
|
| 23 |
+
|
| 24 |
+
Type any Hugging Face repo id — `owner/name` — and press **Load**.
|
| 25 |
+
|
| 26 |
+
Works with `.safetensors` repos whose architecture is:
|
| 27 |
+
|
| 28 |
+
- **Llama-style**: Llama, Mistral, Qwen2/2.5, SmolLM, TinyLlama
|
| 29 |
+
- **GPT-2-style**: GPT-2 and close relatives
|
| 30 |
+
|
| 31 |
+
Good ones to start with:
|
| 32 |
+
|
| 33 |
+
| Repo | Size | Notes |
|
| 34 |
+
|---|---|---|
|
| 35 |
+
| `HuggingFaceTB/SmolLM-135M` | 513 MB f32 | 30 layers — splits nicely |
|
| 36 |
+
| `openai-community/gpt2` | 523 MB f32 | the GPT-2 path |
|
| 37 |
+
| `Qwen/Qwen2.5-0.5B` | 942 MB f32 | GQA 14/2, and it ships QKV biases |
|
| 38 |
+
|
| 39 |
+
Loading reads only `config.json`, `tokenizer.json` and the weight **headers** —
|
| 40 |
+
a few tens of KB, whatever the model's size. No weights move until you press
|
| 41 |
+
Generate, and then each device fetches only its own layers.
|
| 42 |
+
|
| 43 |
+
Weights are held as **f32**, so budget ~4 bytes per parameter across the group.
|
| 44 |
+
The ring plan shows exactly how many MB each device will download and hold.
|
| 45 |
+
|
| 46 |
+
An unsupported architecture or tokenizer is refused with a message naming what
|
| 47 |
+
it found. That is deliberate: approximating one would produce fluent, confident,
|
| 48 |
+
wrong text.
|
| 49 |
+
|
| 50 |
+
## Gated or private models
|
| 51 |
+
|
| 52 |
+
You are asked for a Hugging Face token **once**, and only when a request
|
| 53 |
+
actually fails for want of one. Create a **read** token at
|
| 54 |
+
huggingface.co/settings/tokens.
|
| 55 |
+
|
| 56 |
+
The token is held in memory for that tab and nowhere else — not localStorage,
|
| 57 |
+
not sessionStorage, not a cookie, not the URL, never logged, and **never sent
|
| 58 |
+
to another device**. Each device is prompted for its own, because each device
|
| 59 |
+
downloads its own layers. Reloading the tab forgets it, and *Forget token*
|
| 60 |
+
clears it immediately.
|
| 61 |
+
|
| 62 |
+
For a gated model, accept its licence on the model page first — a token alone
|
| 63 |
+
will not get past a licence you have not accepted.
|
| 64 |
+
|
| 65 |
+
## Rooms
|
| 66 |
+
|
| 67 |
+
Devices on one network group automatically (by public IP, Snapdrop-style). To
|
| 68 |
+
include devices on other networks, everyone opens:
|
| 69 |
+
|
| 70 |
+
```
|
| 71 |
+
https://<host>/?room=MY-SECRET-CODE
|
| 72 |
+
```
|
| 73 |
+
|
| 74 |
+
The first person in is the **host** and approves each device individually.
|
| 75 |
+
|
| 76 |
+
## Run it
|
| 77 |
+
|
| 78 |
+
1. Wait until the devices see each other in the peer list.
|
| 79 |
+
2. On **one** device, load the model. That device becomes the **head**: it
|
| 80 |
+
holds the embedding table, embeds your prompt, and turns the returning
|
| 81 |
+
hidden state back into words.
|
| 82 |
+
3. Check the **ring plan** — who holds which layers, and how much each will
|
| 83 |
+
download.
|
| 84 |
+
4. Type a prompt and press **Generate**. Each device fetches its layers (this
|
| 85 |
+
takes a moment the first time), reports ready, and the ring starts.
|
| 86 |
+
|
| 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 |
|
| 94 |
+
|
| 95 |
+
Every device shows the token stream as it arrives — including ones holding a
|
| 96 |
+
few layers and no vocabulary.
|
| 97 |
+
|
| 98 |
+
## Reading the numbers
|
| 99 |
+
|
| 100 |
+
- **tokens/sec** — the whole ring's rate. Expect it to *fall* as you add
|
| 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
|
| 110 |
+
|
| 111 |
+
Tick **Verify** before generating. Afterwards the head downloads the whole
|
| 112 |
+
model and re-runs the identical prompt locally, then compares token ids:
|
| 113 |
+
|
| 114 |
+
```
|
| 115 |
+
VERIFIED: the distributed run and the single-device run produced identical token ids.
|
| 116 |
+
```
|
| 117 |
+
|
| 118 |
+
In a pipeline nothing recomputes anything, so this is the only check that can
|
| 119 |
+
show a distributed answer is *right* rather than merely self-consistent. It
|
| 120 |
+
needs a model one device can hold, which is why it is optional.
|
| 121 |
+
|
| 122 |
+
Without a browser:
|
| 123 |
+
|
| 124 |
+
```bash
|
| 125 |
+
npm test
|
| 126 |
+
```
|
| 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
|
| 135 |
+
WordPiece; only byte-level BPE is implemented.
|
| 136 |
+
- **401 / 403** — the model needs a token, or the token lacks access. For
|
| 137 |
+
gated repos, accept the licence on the model page first.
|
| 138 |
+
- **"the server ignored the byte range"** — the whole file came back instead of
|
| 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 |
+
|
| 147 |
+
## More
|
| 148 |
+
|
| 149 |
+
- [ARCHITECTURE.md](ARCHITECTURE.md) — the ring, the plan, the wire protocol.
|
| 150 |
+
- The parent projects:
|
| 151 |
+
[DaisyChain-Train](https://huggingface.co/DaisyChainAI/DaisyChain-Train) ·
|
| 152 |
+
[DaisyChain-Web](https://huggingface.co/spaces/Quazim0t0/DaisyChain-Web)
|
package.json
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"name": "daisychain-infer",
|
| 3 |
+
"version": "1.0.0",
|
| 4 |
+
"description": "Run one Hugging Face model across several devices — layer-sharded pipeline inference in the browser, peer-to-peer over WebRTC, computed through verified INT8 units. Runs locally.",
|
| 5 |
+
"private": true,
|
| 6 |
+
"main": "server.js",
|
| 7 |
+
"scripts": {
|
| 8 |
+
"start": "node server.js",
|
| 9 |
+
"test": "node test_pipeline.js && node test_wire.js && node test_loader.js"
|
| 10 |
+
},
|
| 11 |
+
"dependencies": {
|
| 12 |
+
"ws": "^8.18.0"
|
| 13 |
+
},
|
| 14 |
+
"license": "MIT",
|
| 15 |
+
"author": "Dean Byrne (Quazim0t0)"
|
| 16 |
+
}
|
public/app.js
ADDED
|
@@ -0,0 +1,770 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// DaisyChain-Infer client: connect P2P, take a slice of a model straight from
|
| 2 |
+
// the Hub, and run one token loop around the ring.
|
| 3 |
+
//
|
| 4 |
+
// The mesh (signaling, WebRTC, fragmentation, rooms, reconnect) is
|
| 5 |
+
// DaisyChain-Web's, because those are the parts already proven against real
|
| 6 |
+
// phones and real NATs. Everything from "the model" down is new.
|
| 7 |
+
"use strict";
|
| 8 |
+
|
| 9 |
+
const STUN = [
|
| 10 |
+
{ urls: "stun:stun.l.google.com:19302" },
|
| 11 |
+
{ urls: "turn:openrelay.metered.ca:80", username: "openrelayproject", credential: "openrelayproject" },
|
| 12 |
+
{ urls: "turn:openrelay.metered.ca:443", username: "openrelayproject", credential: "openrelayproject" },
|
| 13 |
+
{ urls: "turns:openrelay.metered.ca:443?transport=tcp", username: "openrelayproject", credential: "openrelayproject" },
|
| 14 |
+
];
|
| 15 |
+
let rtcConfig = { iceServers: STUN };
|
| 16 |
+
|
| 17 |
+
const ui = {};
|
| 18 |
+
for (const id of ["status", "backend", "me", "peers", "log", "requests", "roomInfo", "roomCode",
|
| 19 |
+
"copyLink", "model", "tokenizer", "plan", "myShard", "prompt", "genBtn",
|
| 20 |
+
"stopBtn", "out", "tps", "tokcount", "hops", "temp", "vtemp", "ntok", "vntok",
|
| 21 |
+
"seed", "verify", "verifyRow", "ringState", "repo", "revision", "loadRepo",
|
| 22 |
+
"modelState", "ctxLen", "vctxLen", "tokenState", "clearToken",
|
| 23 |
+
"tokModal", "tokInput", "tokOk", "tokCancel", "tokWhy",
|
| 24 |
+
"tokPasteRow", "tokSignIn", "tokSignInBtn",
|
| 25 |
+
"lobby", "createRoom", "joinRoom", "joinCode"])
|
| 26 |
+
ui[id] = document.getElementById(id);
|
| 27 |
+
|
| 28 |
+
function log(m) { ui.log.textContent = `${new Date().toLocaleTimeString()} ${redact(m)}\n` + ui.log.textContent; }
|
| 29 |
+
function setStatus(s) { ui.status.textContent = s; }
|
| 30 |
+
|
| 31 |
+
// ---- the token --------------------------------------------------------------
|
| 32 |
+
// Held in a closure variable and nowhere else. Not localStorage, not
|
| 33 |
+
// sessionStorage, not a cookie, not the URL, not the log, and never on the
|
| 34 |
+
// wire — each device authenticates itself, so a peer never needs anyone
|
| 35 |
+
// else's credentials. Reloading the page is meant to lose it.
|
| 36 |
+
let hfToken = null, tokenSource = "";
|
| 37 |
+
let oauthAvailable = false;
|
| 38 |
+
function redact(s) { return hfToken ? String(s).split(hfToken).join("hf_***") : String(s); }
|
| 39 |
+
function haveToken() { return !!hfToken; }
|
| 40 |
+
function setToken(t, source) {
|
| 41 |
+
hfToken = t || null;
|
| 42 |
+
tokenSource = t ? (source || "entered") : "";
|
| 43 |
+
ui.tokenState.textContent = hfToken
|
| 44 |
+
? `${tokenSource} · held in memory for this tab only`
|
| 45 |
+
: (oauthAvailable ? "not signed in" : "none");
|
| 46 |
+
ui.clearToken.style.display = hfToken ? "" : "none";
|
| 47 |
+
}
|
| 48 |
+
function clearToken() { setToken(null); log("token cleared from memory"); }
|
| 49 |
+
|
| 50 |
+
// A hosted deployment hands the OAuth token back in the URL *fragment*, which
|
| 51 |
+
// browsers never send to a server. Take it, then strip it from the address bar
|
| 52 |
+
// and from history immediately — a credential sitting in a visible URL is one
|
| 53 |
+
// screenshot away from being shared.
|
| 54 |
+
function adoptTokenFromFragment() {
|
| 55 |
+
if (!location.hash || location.hash.length < 2) return;
|
| 56 |
+
const h = new URLSearchParams(location.hash.slice(1));
|
| 57 |
+
const t = h.get("hf"), err = h.get("oauth_error");
|
| 58 |
+
if (t || err) history.replaceState(null, "", location.pathname + location.search);
|
| 59 |
+
if (err) { log(`sign-in failed: ${err}`); return; }
|
| 60 |
+
if (t) { setToken(t, "signed in with Hugging Face"); log("signed in with Hugging Face — token held in memory for this tab only"); }
|
| 61 |
+
}
|
| 62 |
+
|
| 63 |
+
// One-time prompt. Resolves to a token string, or null if the user declines.
|
| 64 |
+
// Deliberately a modal rather than a stored setting: the moment a credential
|
| 65 |
+
// becomes ambient, it becomes something you forget you granted.
|
| 66 |
+
// Where OAuth is available — a deployment the user does not control — the
|
| 67 |
+
// paste box is not offered at all. Asking someone to type a personal access
|
| 68 |
+
// token into a page served by a third party is a bad pattern even when the
|
| 69 |
+
// code is honest, because the user cannot verify that it is. Signing in hands
|
| 70 |
+
// over a scoped, expiring token instead, and it is the only path shown there.
|
| 71 |
+
let tokenPrompt = null;
|
| 72 |
+
function requestToken(why) {
|
| 73 |
+
if (tokenPrompt) return tokenPrompt;
|
| 74 |
+
ui.tokWhy.textContent = why || "This model needs a Hugging Face token to download.";
|
| 75 |
+
ui.tokPasteRow.style.display = oauthAvailable ? "none" : "";
|
| 76 |
+
ui.tokSignIn.style.display = oauthAvailable ? "" : "none";
|
| 77 |
+
ui.tokOk.style.display = oauthAvailable ? "none" : ""; // nothing to submit
|
| 78 |
+
ui.tokInput.value = "";
|
| 79 |
+
ui.tokModal.style.display = "flex";
|
| 80 |
+
if (!oauthAvailable) ui.tokInput.focus();
|
| 81 |
+
tokenPrompt = new Promise((resolve) => {
|
| 82 |
+
const done = (val) => {
|
| 83 |
+
ui.tokModal.style.display = "none";
|
| 84 |
+
ui.tokInput.value = ""; // do not leave it in the DOM
|
| 85 |
+
ui.tokOk.onclick = null; ui.tokCancel.onclick = null;
|
| 86 |
+
ui.tokInput.onkeydown = null; ui.tokSignInBtn.onclick = null;
|
| 87 |
+
tokenPrompt = null;
|
| 88 |
+
resolve(val);
|
| 89 |
+
};
|
| 90 |
+
ui.tokSignInBtn.onclick = () => {
|
| 91 |
+
// full-page redirect: we come back with the token in the fragment
|
| 92 |
+
log("redirecting to Hugging Face to sign in…");
|
| 93 |
+
location.href = "auth/login";
|
| 94 |
+
};
|
| 95 |
+
ui.tokOk.onclick = () => {
|
| 96 |
+
const v = ui.tokInput.value.trim();
|
| 97 |
+
if (!v) return done(null);
|
| 98 |
+
setToken(v, "entered by hand");
|
| 99 |
+
log("token accepted — kept in memory for this tab only, never sent to peers");
|
| 100 |
+
done(v);
|
| 101 |
+
};
|
| 102 |
+
ui.tokCancel.onclick = () => { log("token request declined"); done(null); };
|
| 103 |
+
ui.tokInput.onkeydown = (e) => { if (e.key === "Enter") ui.tokOk.onclick(); if (e.key === "Escape") ui.tokCancel.onclick(); };
|
| 104 |
+
});
|
| 105 |
+
return tokenPrompt;
|
| 106 |
+
}
|
| 107 |
+
|
| 108 |
+
// Run an HF call, and if it fails for want of credentials ask once, then retry.
|
| 109 |
+
async function withAuth(fn, what) {
|
| 110 |
+
try { return await fn(hfToken); }
|
| 111 |
+
catch (e) {
|
| 112 |
+
if (e && e.kind === "auth") {
|
| 113 |
+
const gated = e.status === 403 ? " It is gated or private" : "";
|
| 114 |
+
const t = await requestToken(oauthAvailable
|
| 115 |
+
? `${what} needs Hugging Face access.${gated}${gated ? " — for a gated model, accept its licence on the model page first." : ""}`
|
| 116 |
+
: `${what} needs a Hugging Face token.${gated}. Create a READ token at huggingface.co/settings/tokens.`);
|
| 117 |
+
if (!t) throw new Error(`${what}: cancelled — no token provided`);
|
| 118 |
+
return fn(t);
|
| 119 |
+
}
|
| 120 |
+
throw e;
|
| 121 |
+
}
|
| 122 |
+
}
|
| 123 |
+
|
| 124 |
+
// ---- identity ---------------------------------------------------------------
|
| 125 |
+
const ADJ = ["Frosted", "Silver", "Pale", "Winter", "Hollow", "Quiet", "Drifting", "Little",
|
| 126 |
+
"Glacier", "Misty", "Northern", "Still", "Brave", "Snowlit", "Amber"];
|
| 127 |
+
const NOUN = ["Fox", "Hare", "Owl", "Elk", "Marten", "Sparrow", "Otter", "Deer",
|
| 128 |
+
"Ptarmigan", "Pine", "Birch", "Wren", "Fawn", "Moth", "Lynx"];
|
| 129 |
+
const deviceName = ADJ[Math.floor(Math.random() * ADJ.length)] + " " +
|
| 130 |
+
NOUN[Math.floor(Math.random() * NOUN.length)];
|
| 131 |
+
|
| 132 |
+
let myId = null, compute = null, ws = null, L = null, wasDenied = false;
|
| 133 |
+
const pcs = new Map(), chans = new Map(), names = new Map();
|
| 134 |
+
const caps = new Map();
|
| 135 |
+
|
| 136 |
+
// ---- model state ------------------------------------------------------------
|
| 137 |
+
let repoInfo = null; // {repo, revision, spec, tensors, fingerprint} — head only
|
| 138 |
+
let spec = null, plan = null, myStage = null, stageObj = null;
|
| 139 |
+
let tok = null; // tokenizer, loaded from the same repo
|
| 140 |
+
let modelHash = 0;
|
| 141 |
+
let running = false, abortRun = false;
|
| 142 |
+
let probeHash = 0, auditFailure = null;
|
| 143 |
+
const readyStages = new Set();
|
| 144 |
+
|
| 145 |
+
function nmeOf(id) { return names.get(id) || id; }
|
| 146 |
+
function room() { return new URLSearchParams(location.search).get("room"); }
|
| 147 |
+
function updatePeers() {
|
| 148 |
+
if (!names.size) { ui.peers.textContent = "(none yet — you can still run solo)"; return; }
|
| 149 |
+
ui.peers.textContent = [...names.entries()]
|
| 150 |
+
.map(([id, n]) => `${n} ${chans.has(id) ? "✓" : "(connecting…)"}`).join(", ");
|
| 151 |
+
}
|
| 152 |
+
function ctxLen() { return +ui.ctxLen.value; }
|
| 153 |
+
|
| 154 |
+
// ---- signaling + WebRTC -----------------------------------------------------
|
| 155 |
+
function connectSignaling() {
|
| 156 |
+
const proto = location.protocol === "https:" ? "wss" : "ws";
|
| 157 |
+
const params = new URLSearchParams();
|
| 158 |
+
params.set("name", deviceName);
|
| 159 |
+
if (room()) params.set("room", room());
|
| 160 |
+
ws = new WebSocket(`${proto}://${location.host}/?${params}`);
|
| 161 |
+
ws.onopen = () => setStatus(room() ? `connected — private room "${room()}"` : "connected — grouping with devices on your network");
|
| 162 |
+
ws.onclose = () => {
|
| 163 |
+
if (wasDenied) return;
|
| 164 |
+
setStatus("signaling disconnected — reconnecting…");
|
| 165 |
+
setTimeout(connectSignaling, 3000);
|
| 166 |
+
};
|
| 167 |
+
ws.onmessage = async (ev) => {
|
| 168 |
+
const msg = JSON.parse(ev.data);
|
| 169 |
+
if (msg.type === "welcome") {
|
| 170 |
+
myId = msg.id;
|
| 171 |
+
if (msg.room) log(`group: ${msg.room.startsWith("net:") ? "your network" : "private room " + msg.room.replace("room:", "")}`);
|
| 172 |
+
if (msg.host) { log("you host this room — joiners wait for your approval"); setStatus(`hosting private room "${room()}"`); }
|
| 173 |
+
else if (room()) setStatus(`accepted into private room "${room()}"`);
|
| 174 |
+
for (const p of msg.peers) { names.set(p.id, p.name); if (!chans.has(p.id)) initiatePeer(p.id); }
|
| 175 |
+
updatePeers();
|
| 176 |
+
} else if (msg.type === "waiting") {
|
| 177 |
+
setStatus("knocking — waiting for the room's host to let you in…");
|
| 178 |
+
} else if (msg.type === "denied") {
|
| 179 |
+
wasDenied = true; setStatus("the host declined your request to join"); log("join request declined by the host");
|
| 180 |
+
} else if (msg.type === "host") {
|
| 181 |
+
log("the host left — you are now the host of this room"); setStatus(`hosting private room "${room()}"`);
|
| 182 |
+
} else if (msg.type === "join-request") {
|
| 183 |
+
addJoinRequest(msg.id, msg.name);
|
| 184 |
+
} else if (msg.type === "peer-joined") {
|
| 185 |
+
names.set(msg.id, msg.name); updatePeers(); log(`${msg.name} joined`);
|
| 186 |
+
} else if (msg.type === "peer-left") {
|
| 187 |
+
log(`${nmeOf(msg.id)} left`); cleanupPeer(msg.id); names.delete(msg.id); caps.delete(msg.id); updatePeers(); renderPlan();
|
| 188 |
+
} else if (msg.type === "signal") {
|
| 189 |
+
await onSignal(msg.from, msg.data);
|
| 190 |
+
} else if (msg.type === "ping") {
|
| 191 |
+
ws.send(JSON.stringify({ type: "pong" }));
|
| 192 |
+
} else if (msg.type === "relay") {
|
| 193 |
+
onWire(msg.from, bufFromB64(msg.data));
|
| 194 |
+
}
|
| 195 |
+
};
|
| 196 |
+
}
|
| 197 |
+
function signal(to, data) { ws.send(JSON.stringify({ type: "signal", to, data })); }
|
| 198 |
+
|
| 199 |
+
function addJoinRequest(id, name) {
|
| 200 |
+
const row = document.createElement("div");
|
| 201 |
+
row.className = "joinrow";
|
| 202 |
+
const who = document.createElement("span");
|
| 203 |
+
who.textContent = `❄ ${name} wants to join`; // textContent: names come off the wire
|
| 204 |
+
const btn = (label, allow) => {
|
| 205 |
+
const b = document.createElement("button");
|
| 206 |
+
b.textContent = label;
|
| 207 |
+
b.className = allow ? "small" : "small danger";
|
| 208 |
+
b.onclick = () => {
|
| 209 |
+
ws.send(JSON.stringify({ type: "admit", id, allow }));
|
| 210 |
+
row.remove();
|
| 211 |
+
log(allow ? `you let ${name} in` : `you declined ${name}`);
|
| 212 |
+
};
|
| 213 |
+
return b;
|
| 214 |
+
};
|
| 215 |
+
row.append(who, btn("Accept", true), btn("Deny", false));
|
| 216 |
+
ui.requests.appendChild(row);
|
| 217 |
+
}
|
| 218 |
+
|
| 219 |
+
function newPC(peerId) {
|
| 220 |
+
const pc = new RTCPeerConnection(rtcConfig);
|
| 221 |
+
pc.onicecandidate = (e) => { if (e.candidate) signal(peerId, { candidate: e.candidate }); };
|
| 222 |
+
pc.onconnectionstatechange = () => {
|
| 223 |
+
if (pc.connectionState === "failed") { cleanupPeer(peerId); scheduleReconnect(peerId); }
|
| 224 |
+
if (pc.connectionState === "disconnected")
|
| 225 |
+
setTimeout(() => {
|
| 226 |
+
if (pcs.get(peerId) === pc && pc.connectionState === "disconnected") {
|
| 227 |
+
log(`${nmeOf(peerId)} connection did not recover — redialing`);
|
| 228 |
+
cleanupPeer(peerId); scheduleReconnect(peerId);
|
| 229 |
+
}
|
| 230 |
+
}, 4000);
|
| 231 |
+
};
|
| 232 |
+
pcs.set(peerId, pc);
|
| 233 |
+
return pc;
|
| 234 |
+
}
|
| 235 |
+
function b64FromBuf(buf) {
|
| 236 |
+
const u = new Uint8Array(buf); let s = "";
|
| 237 |
+
for (let i = 0; i < u.length; i += 0x8000) s += String.fromCharCode(...u.subarray(i, i + 0x8000));
|
| 238 |
+
return btoa(s);
|
| 239 |
+
}
|
| 240 |
+
function bufFromB64(s) { return Uint8Array.from(atob(s), c => c.charCodeAt(0)).buffer; }
|
| 241 |
+
function makeRelayChannel(peerId) {
|
| 242 |
+
return {
|
| 243 |
+
isRelay: true,
|
| 244 |
+
get readyState() { return ws && ws.readyState === 1 && names.has(peerId) ? "open" : "closed"; },
|
| 245 |
+
get bufferedAmount() { return ws ? ws.bufferedAmount : 0; },
|
| 246 |
+
send(buf) { ws.send(JSON.stringify({ type: "relay", to: peerId, data: b64FromBuf(buf) })); },
|
| 247 |
+
close() { chans.delete(peerId); },
|
| 248 |
+
};
|
| 249 |
+
}
|
| 250 |
+
const reconnectTimers = new Map();
|
| 251 |
+
function scheduleReconnect(peerId, attempt = 0) {
|
| 252 |
+
if (!names.has(peerId) || chans.has(peerId) || reconnectTimers.has(peerId)) return;
|
| 253 |
+
if (attempt >= 5) {
|
| 254 |
+
if (names.has(peerId)) {
|
| 255 |
+
log(`no direct WebRTC path to ${nmeOf(peerId)} after 5 attempts — relaying activations through the server instead`);
|
| 256 |
+
chans.set(peerId, makeRelayChannel(peerId));
|
| 257 |
+
updatePeers(); wake();
|
| 258 |
+
}
|
| 259 |
+
return;
|
| 260 |
+
}
|
| 261 |
+
const delay = 1500 * Math.pow(2, attempt);
|
| 262 |
+
reconnectTimers.set(peerId, setTimeout(() => {
|
| 263 |
+
reconnectTimers.delete(peerId);
|
| 264 |
+
if (!names.has(peerId) || chans.has(peerId)) return;
|
| 265 |
+
if (+myId.slice(1) > +peerId.slice(1)) {
|
| 266 |
+
log(`reconnecting to ${nmeOf(peerId)} (attempt ${attempt + 1})…`);
|
| 267 |
+
cleanupPeer(peerId); initiatePeer(peerId);
|
| 268 |
+
}
|
| 269 |
+
setTimeout(() => scheduleReconnect(peerId, attempt + 1), 6000);
|
| 270 |
+
}, delay));
|
| 271 |
+
}
|
| 272 |
+
function initiatePeer(peerId) {
|
| 273 |
+
const pc = newPC(peerId);
|
| 274 |
+
setupChannel(peerId, pc.createDataChannel("daisy"));
|
| 275 |
+
pc.createOffer().then(o => pc.setLocalDescription(o)).then(() => signal(peerId, { sdp: pc.localDescription }));
|
| 276 |
+
}
|
| 277 |
+
const pendingCand = new Map();
|
| 278 |
+
async function onSignal(from, data) {
|
| 279 |
+
let pc = pcs.get(from);
|
| 280 |
+
if (data.sdp) {
|
| 281 |
+
if (!pc) { pc = newPC(from); pc.ondatachannel = (e) => setupChannel(from, e.channel); }
|
| 282 |
+
await pc.setRemoteDescription(data.sdp);
|
| 283 |
+
for (const c of pendingCand.get(from) || []) try { await pc.addIceCandidate(c); } catch (e) {}
|
| 284 |
+
pendingCand.delete(from);
|
| 285 |
+
if (data.sdp.type === "offer") {
|
| 286 |
+
const ans = await pc.createAnswer(); await pc.setLocalDescription(ans);
|
| 287 |
+
signal(from, { sdp: pc.localDescription });
|
| 288 |
+
}
|
| 289 |
+
} else if (data.candidate) {
|
| 290 |
+
if (pc && pc.remoteDescription) { try { await pc.addIceCandidate(data.candidate); } catch (e) {} }
|
| 291 |
+
else { if (!pendingCand.has(from)) pendingCand.set(from, []); pendingCand.get(from).push(data.candidate); }
|
| 292 |
+
}
|
| 293 |
+
}
|
| 294 |
+
function setupChannel(peerId, dc) {
|
| 295 |
+
dc.binaryType = "arraybuffer";
|
| 296 |
+
dc.onopen = () => { chans.set(peerId, dc); updatePeers(); log(`connected to ${nmeOf(peerId)}`); sendHello(peerId); };
|
| 297 |
+
dc.onclose = () => { chans.delete(peerId); updatePeers(); wake(); scheduleReconnect(peerId); };
|
| 298 |
+
dc.onmessage = (e) => onWire(peerId, e.data);
|
| 299 |
+
}
|
| 300 |
+
function cleanupPeer(id) {
|
| 301 |
+
const pc = pcs.get(id); if (pc) pc.close();
|
| 302 |
+
pcs.delete(id); chans.delete(id); pendingCand.delete(id);
|
| 303 |
+
for (const k of fragIn.keys()) if (k.startsWith(id + ":")) fragIn.delete(k);
|
| 304 |
+
wake();
|
| 305 |
+
}
|
| 306 |
+
|
| 307 |
+
// ---- fragmentation ----------------------------------------------------------
|
| 308 |
+
const FRAG_SENTINEL = Wire.FRAG, FRAG_CHUNK = 48 * 1024, DC_MAXBUF = 4 * 1024 * 1024;
|
| 309 |
+
let fragSeq = 1;
|
| 310 |
+
const fragIn = new Map();
|
| 311 |
+
function dcDrain(dc) {
|
| 312 |
+
return new Promise((res) => {
|
| 313 |
+
if (dc.bufferedAmount <= DC_MAXBUF || dc.readyState !== "open") return res();
|
| 314 |
+
const t = setInterval(() => {
|
| 315 |
+
if (dc.bufferedAmount <= DC_MAXBUF || dc.readyState !== "open") { clearInterval(t); res(); }
|
| 316 |
+
}, 50);
|
| 317 |
+
});
|
| 318 |
+
}
|
| 319 |
+
async function dcSend(dc, buf) {
|
| 320 |
+
if (buf.byteLength <= FRAG_CHUNK) {
|
| 321 |
+
await dcDrain(dc);
|
| 322 |
+
if (dc.readyState === "open") dc.send(buf);
|
| 323 |
+
return;
|
| 324 |
+
}
|
| 325 |
+
const id = fragSeq++, src = new Uint8Array(buf);
|
| 326 |
+
const total = Math.ceil(src.length / FRAG_CHUNK);
|
| 327 |
+
for (let s = 0; s < total; s++) {
|
| 328 |
+
await dcDrain(dc);
|
| 329 |
+
if (dc.readyState !== "open") return;
|
| 330 |
+
const part = src.subarray(s * FRAG_CHUNK, Math.min((s + 1) * FRAG_CHUNK, src.length));
|
| 331 |
+
const msg = new ArrayBuffer(16 + part.length);
|
| 332 |
+
new Int32Array(msg, 0, 4).set([FRAG_SENTINEL, id, s, total]);
|
| 333 |
+
new Uint8Array(msg, 16).set(part);
|
| 334 |
+
dc.send(msg);
|
| 335 |
+
}
|
| 336 |
+
}
|
| 337 |
+
function onFragment(peerId, buf) {
|
| 338 |
+
const [, id, seq, total] = new Int32Array(buf, 0, 4);
|
| 339 |
+
const key = peerId + ":" + id;
|
| 340 |
+
let st = fragIn.get(key);
|
| 341 |
+
if (!st) { st = { id, parts: [], got: 0, total }; fragIn.set(key, st); }
|
| 342 |
+
st.parts[seq] = new Uint8Array(buf, 16).slice(0);
|
| 343 |
+
st.got++;
|
| 344 |
+
if (st.got < st.total) return null;
|
| 345 |
+
fragIn.delete(key);
|
| 346 |
+
let len = 0; for (const p of st.parts) len += p.length;
|
| 347 |
+
const out = new Uint8Array(len);
|
| 348 |
+
let off = 0; for (const p of st.parts) { out.set(p, off); off += p.length; }
|
| 349 |
+
return out.buffer;
|
| 350 |
+
}
|
| 351 |
+
function sendTo(peerId, buf) {
|
| 352 |
+
const dc = chans.get(peerId);
|
| 353 |
+
if (!dc || dc.readyState !== "open") return Promise.resolve(false);
|
| 354 |
+
return dcSend(dc, buf).then(() => true);
|
| 355 |
+
}
|
| 356 |
+
function broadcast(buf) {
|
| 357 |
+
return Promise.all([...chans.values()].filter(dc => dc.readyState === "open").map(dc => dcSend(dc, buf)));
|
| 358 |
+
}
|
| 359 |
+
|
| 360 |
+
// ---- hello / capability -----------------------------------------------------
|
| 361 |
+
function sendHello(peerId) {
|
| 362 |
+
return sendTo(peerId, Wire.packHello(myCapacity, probeHash, compute ? compute.backend : "?"));
|
| 363 |
+
}
|
| 364 |
+
function onHello(peerId, buf) {
|
| 365 |
+
let h;
|
| 366 |
+
try { h = Wire.unpackHello(buf); } catch (e) { log(`bad hello from ${nmeOf(peerId)}: ${e.message}`); return; }
|
| 367 |
+
caps.set(peerId, { capacity: h.capacity, backend: h.backend, probe: h.probeHash });
|
| 368 |
+
// The kernel probe matters MORE in a pipeline than in the trainer. There,
|
| 369 |
+
// every peer computes the same thing, so bad arithmetic shows up as a
|
| 370 |
+
// diverging replica. Here each stage computes something different and nobody
|
| 371 |
+
// repeats it — a broken middle stage would corrupt every token invisibly.
|
| 372 |
+
// The probe is the one value that stays comparable when the work is not.
|
| 373 |
+
if (probeHash && h.probeHash && h.probeHash !== probeHash)
|
| 374 |
+
log(`⚠ ${nmeOf(peerId)} disagrees with this device's kernel probe (${h.probeHash} vs ${probeHash}). ` +
|
| 375 |
+
`Their arithmetic differs from ours — do not put them in the ring.`);
|
| 376 |
+
renderPlan();
|
| 377 |
+
}
|
| 378 |
+
|
| 379 |
+
// ---- capacity ---------------------------------------------------------------
|
| 380 |
+
let myCapacity = 1;
|
| 381 |
+
async function measureCapacity() {
|
| 382 |
+
const m = 32, k = 64, n = 64;
|
| 383 |
+
const X = new Float32Array(m * k), W = new Float32Array(k * n);
|
| 384 |
+
for (let i = 0; i < X.length; i++) X[i] = Math.sin(i) * 0.5;
|
| 385 |
+
for (let i = 0; i < W.length; i++) W[i] = Math.cos(i) * 0.5;
|
| 386 |
+
await Verified.vgemmBlock(X, W, { m, k, n, batch: 1 }, L, compute.bgemm, null);
|
| 387 |
+
const t0 = performance.now();
|
| 388 |
+
let it = 0;
|
| 389 |
+
while (performance.now() - t0 < 300) { await Verified.vgemmBlock(X, W, { m, k, n, batch: 1 }, L, compute.bgemm, null); it++; }
|
| 390 |
+
myCapacity = it / ((performance.now() - t0) / 1000);
|
| 391 |
+
return myCapacity;
|
| 392 |
+
}
|
| 393 |
+
|
| 394 |
+
// ---- loading a model from the Hub -------------------------------------------
|
| 395 |
+
// Only the header and config are read here: a few tens of KB, regardless of how
|
| 396 |
+
// large the model is. Weights are fetched per stage, later, by each device.
|
| 397 |
+
async function loadRepo() {
|
| 398 |
+
const repo = ui.repo.value.trim();
|
| 399 |
+
const revision = (ui.revision.value.trim() || "main");
|
| 400 |
+
if (!/^[\w.-]+\/[\w.-]+$/.test(repo)) { log(`"${repo}" is not a valid repo id (expected owner/name)`); return; }
|
| 401 |
+
ui.loadRepo.disabled = true;
|
| 402 |
+
ui.modelState.textContent = "reading config…";
|
| 403 |
+
try {
|
| 404 |
+
const cfg = await withAuth((t) => HF.getJSON(repo, "config.json", revision, t), `${repo}/config.json`);
|
| 405 |
+
const s = Arch.fromConfig(cfg);
|
| 406 |
+
ui.modelState.textContent = "reading tokenizer…";
|
| 407 |
+
const tokJson = await withAuth((t) => HF.getJSON(repo, "tokenizer.json", revision, t), `${repo}/tokenizer.json`);
|
| 408 |
+
tok = Tokenizer.build(tokJson);
|
| 409 |
+
ui.tokenizer.textContent = tok.name;
|
| 410 |
+
ui.modelState.textContent = "reading weight index…";
|
| 411 |
+
const { tensors } = await withAuth((t) => HF.readIndex(repo, revision, t, (m) => { ui.modelState.textContent = m; }),
|
| 412 |
+
`${repo} weights`);
|
| 413 |
+
const fingerprint = Shard.modelFingerprint(repo, revision, tensors);
|
| 414 |
+
repoInfo = { repo, revision, spec: s, tensors, fingerprint };
|
| 415 |
+
spec = s; modelHash = fingerprint;
|
| 416 |
+
const totalMB = [...tensors.values()].reduce((a, t) => a + t.elems * 4, 0) / 1048576;
|
| 417 |
+
ui.model.textContent =
|
| 418 |
+
`${repo} · ${s.family}-style · ${s.layers} layers · hidden ${s.hidden} · ` +
|
| 419 |
+
`heads ${s.heads}${s.kvHeads !== s.heads ? "/" + s.kvHeads + " kv" : ""} · vocab ${s.vocab}`;
|
| 420 |
+
ui.modelState.textContent = `${totalMB.toFixed(0)} MB in f32 · fingerprint ${fingerprint.toString(16)}`;
|
| 421 |
+
log(`model ready: ${repo}@${revision} — ${s.layers} layers, ${totalMB.toFixed(0)} MB total in f32. ` +
|
| 422 |
+
`Nothing has been downloaded yet beyond headers; each device will fetch only its own layers.`);
|
| 423 |
+
if (s.maxPos < ctxLen()) log(`⚠ this model's max position is ${s.maxPos} — lower the context length`);
|
| 424 |
+
ui.genBtn.disabled = false;
|
| 425 |
+
ui.verifyRow.style.display = "";
|
| 426 |
+
renderPlan();
|
| 427 |
+
} catch (e) {
|
| 428 |
+
ui.modelState.textContent = "failed";
|
| 429 |
+
log(`could not load ${repo}: ${redact(e.message)}`);
|
| 430 |
+
}
|
| 431 |
+
ui.loadRepo.disabled = false;
|
| 432 |
+
}
|
| 433 |
+
|
| 434 |
+
function buildPlan() {
|
| 435 |
+
if (!repoInfo) return null;
|
| 436 |
+
const list = [{ id: myId, capacity: myCapacity, backend: compute.backend }];
|
| 437 |
+
for (const id of [...chans.keys()].sort((a, b) => +a.slice(1) - +b.slice(1))) {
|
| 438 |
+
const c = caps.get(id);
|
| 439 |
+
if (c) list.push({ id, capacity: c.capacity, backend: c.backend });
|
| 440 |
+
}
|
| 441 |
+
return Shard.planStages(repoInfo.spec, list);
|
| 442 |
+
}
|
| 443 |
+
function renderPlan() {
|
| 444 |
+
if (!plan) {
|
| 445 |
+
const known = 1 + [...chans.keys()].filter(id => caps.has(id)).length;
|
| 446 |
+
ui.plan.textContent = repoInfo ? `${known} device(s) ready — press Generate to shard and run`
|
| 447 |
+
: "load a model to see how it would be split";
|
| 448 |
+
return;
|
| 449 |
+
}
|
| 450 |
+
ui.plan.textContent = plan.map(s => {
|
| 451 |
+
const who = s.id === myId ? "me" : nmeOf(s.id);
|
| 452 |
+
const part = s.hi > s.lo ? `layers ${s.lo}–${s.hi - 1}` : "—";
|
| 453 |
+
let mb = "";
|
| 454 |
+
if (repoInfo) mb = " · " + (Shard.stageBytes(repoInfo.spec, repoInfo.tensors, s, Arch) / 1048576).toFixed(0) + " MB";
|
| 455 |
+
return `${s.index}. ${who}${s.head ? " (head: embed + lm_head)" : ""} · ${part}${mb}` +
|
| 456 |
+
(readyStages.has(s.index) ? " ✓" : "");
|
| 457 |
+
}).join("\n");
|
| 458 |
+
}
|
| 459 |
+
|
| 460 |
+
// Fetch this device's own layers. This is the only place weight bytes move,
|
| 461 |
+
// and they move from the Hub to here — never from a peer.
|
| 462 |
+
async function loadMyStage(assign, st) {
|
| 463 |
+
const s = assign.spec;
|
| 464 |
+
ui.myShard.textContent = "reading index…";
|
| 465 |
+
const { tensors } = await withAuth((t) => HF.readIndex(assign.repo, assign.revision, t), `${assign.repo} weights`);
|
| 466 |
+
const want = Arch.tensorsFor(s, tensors, st).map(n => tensors.get(n));
|
| 467 |
+
const mb = want.reduce((a, t) => a + t.elems * 4, 0) / 1048576;
|
| 468 |
+
log(`fetching my slice from the Hub: ${want.length} tensors, ${mb.toFixed(0)} MB ` +
|
| 469 |
+
`(the other ${s.layers - (st.hi - st.lo)} layers are never downloaded here)`);
|
| 470 |
+
const got = await withAuth((t) => HF.fetchTensors(assign.repo, assign.revision, t, want,
|
| 471 |
+
(p) => { ui.myShard.textContent = `downloading ${p}`; }),
|
| 472 |
+
`${assign.repo} weights`);
|
| 473 |
+
const w = Shard.stageWeights(s, st, got, Arch);
|
| 474 |
+
stageObj = Infer.makeStage(s, st, w, kernelCtx(), assign.ctx || ctxLen());
|
| 475 |
+
spec = s;
|
| 476 |
+
ui.myShard.textContent = `${st.head ? "head + " : ""}layers ${st.lo}–${st.hi - 1} · ${mb.toFixed(0)} MB resident`;
|
| 477 |
+
return stageObj;
|
| 478 |
+
}
|
| 479 |
+
|
| 480 |
+
async function onAssign(peerId, buf) {
|
| 481 |
+
if (running) { log(`ignored an assignment from ${nmeOf(peerId)} mid-run`); return; }
|
| 482 |
+
let a;
|
| 483 |
+
try { a = Wire.unpackAssign(buf); } catch (e) { log(`REFUSED assignment from ${nmeOf(peerId)}: ${e.message}`); return; }
|
| 484 |
+
plan = a.plan; spec = a.spec; modelHash = a.fingerprint;
|
| 485 |
+
myStage = plan[a.mine];
|
| 486 |
+
log(`assigned by ${nmeOf(peerId)}: ${a.repo}@${a.revision}, layers ${myStage.lo}–${myStage.hi - 1} ` +
|
| 487 |
+
`of ${a.spec.layers} (stage ${a.mine} of ${plan.length})`);
|
| 488 |
+
renderPlan();
|
| 489 |
+
try {
|
| 490 |
+
// tokenizer only matters on the head, but loading it is cheap and lets a
|
| 491 |
+
// middle stage show the token stream
|
| 492 |
+
if (!tok) {
|
| 493 |
+
try { tok = Tokenizer.build(await withAuth((t) => HF.getJSON(a.repo, "tokenizer.json", a.revision, t), "tokenizer")); }
|
| 494 |
+
catch (e) { log(`(no tokenizer here: ${redact(e.message)} — this stage will show ids, not text)`); }
|
| 495 |
+
}
|
| 496 |
+
await loadMyStage(a, myStage);
|
| 497 |
+
await sendTo(peerId, Wire.packReady(a.mine, true, "loaded"));
|
| 498 |
+
log(`ready — holding layers ${myStage.lo}–${myStage.hi - 1}. The rest of the model is not on this device.`);
|
| 499 |
+
setStatus("in the ring — waiting for work");
|
| 500 |
+
} catch (e) {
|
| 501 |
+
await sendTo(peerId, Wire.packReady(a.mine, false, redact(e.message)));
|
| 502 |
+
log(`could not take my slice: ${redact(e.message)}`);
|
| 503 |
+
}
|
| 504 |
+
}
|
| 505 |
+
function onReady(peerId, buf) {
|
| 506 |
+
const r = Wire.unpackReady(buf);
|
| 507 |
+
if (r.ok) { readyStages.add(r.stageIndex); log(`${nmeOf(peerId)} is ready (stage ${r.stageIndex})`); }
|
| 508 |
+
else log(`${nmeOf(peerId)} could not load stage ${r.stageIndex}: ${r.note}`);
|
| 509 |
+
renderPlan(); wake();
|
| 510 |
+
}
|
| 511 |
+
|
| 512 |
+
// ---- the ring ---------------------------------------------------------------
|
| 513 |
+
// One token = one lap. The head embeds the window and hands the hidden state
|
| 514 |
+
// to stage 1; each stage runs its own layers and hands the result on; the last
|
| 515 |
+
// stage returns it to the head, which turns it into a token.
|
| 516 |
+
//
|
| 517 |
+
// The payload is T x hidden floats — tens of KB. The weights it stands in for
|
| 518 |
+
// are hundreds of megabytes. That asymmetry is the whole reason this works,
|
| 519 |
+
// and it makes a run latency-bound rather than bandwidth-bound.
|
| 520 |
+
let runSeq = 0;
|
| 521 |
+
const waiters = new Set();
|
| 522 |
+
function wake() { for (const w of waiters) w(); }
|
| 523 |
+
function waitFor(pred, timeoutMs) {
|
| 524 |
+
return new Promise((resolve) => {
|
| 525 |
+
const t0 = Date.now();
|
| 526 |
+
let timer = null;
|
| 527 |
+
const check = () => {
|
| 528 |
+
const ok = pred();
|
| 529 |
+
if (ok || Date.now() - t0 > timeoutMs) { waiters.delete(check); clearInterval(timer); resolve(!!ok); }
|
| 530 |
+
};
|
| 531 |
+
waiters.add(check);
|
| 532 |
+
timer = setInterval(check, 200);
|
| 533 |
+
check();
|
| 534 |
+
});
|
| 535 |
+
}
|
| 536 |
+
const actIn = new Map();
|
| 537 |
+
|
| 538 |
+
function packAct(seq, tokenIdx, nextIndex, hidden) {
|
| 539 |
+
return Wire.packAct(seq, tokenIdx, nextIndex, hidden, Shard.hashF32, modelHash);
|
| 540 |
+
}
|
| 541 |
+
async function onAct(peerId, buf) {
|
| 542 |
+
let a;
|
| 543 |
+
try { a = Wire.unpackAct(buf, Shard.hashF32); }
|
| 544 |
+
catch (e) { log(`activation from ${nmeOf(peerId)} rejected: ${e.message}`); return; }
|
| 545 |
+
const { seq, tokenIdx, nextIndex, modelHash: mh, hidden } = a;
|
| 546 |
+
if (mh !== modelHash) {
|
| 547 |
+
log(`REFUSED activation from ${nmeOf(peerId)}: it belongs to model ${mh.toString(16)}, this device ` +
|
| 548 |
+
`holds a slice of ${modelHash.toString(16)}. Mixing two models would produce confident nonsense.`);
|
| 549 |
+
return;
|
| 550 |
+
}
|
| 551 |
+
const me = plan && plan.find(s => s.id === myId);
|
| 552 |
+
const kind = Wire.classifyAct(nextIndex, me ? me.index : -99);
|
| 553 |
+
if (kind === "return") { actIn.set(`${seq}:${tokenIdx}`, hidden); wake(); return; }
|
| 554 |
+
if (kind === "other" || !me || !stageObj) return;
|
| 555 |
+
const t0 = performance.now();
|
| 556 |
+
let x;
|
| 557 |
+
try { x = await Infer.runLayers(stageObj, hidden); }
|
| 558 |
+
catch (e) { log(`stage failed: ${redact(e.message)}`); return; }
|
| 559 |
+
if (auditFailure) { log("KERNEL AUDIT FAILED mid-hop — refusing to pass on a value I cannot vouch for"); return; }
|
| 560 |
+
const hop = Wire.routeAfter(plan, me.index);
|
| 561 |
+
ui.ringState.textContent = `layers ${me.lo}–${me.hi - 1} in ${(performance.now() - t0).toFixed(0)} ms → ` +
|
| 562 |
+
`${hop.to === myId ? "me" : nmeOf(hop.to)}${hop.isReturn ? " (return)" : ""}`;
|
| 563 |
+
await sendTo(hop.to, packAct(seq, tokenIdx, hop.address, x));
|
| 564 |
+
}
|
| 565 |
+
|
| 566 |
+
async function generate() {
|
| 567 |
+
if (running) return;
|
| 568 |
+
if (!repoInfo) { log("load a model first — the device that loads it drives the ring"); return; }
|
| 569 |
+
if (!tok) { log("no tokenizer loaded"); return; }
|
| 570 |
+
running = true; abortRun = false; auditFailure = null;
|
| 571 |
+
ui.genBtn.disabled = true; ui.stopBtn.disabled = false;
|
| 572 |
+
readyStages.clear();
|
| 573 |
+
try {
|
| 574 |
+
const T = Math.min(ctxLen(), repoInfo.spec.maxPos);
|
| 575 |
+
plan = buildPlan(); modelHash = repoInfo.fingerprint; spec = repoInfo.spec;
|
| 576 |
+
renderPlan();
|
| 577 |
+
if (plan.length > 1) {
|
| 578 |
+
log(`plan: ${spec.layers} layers over ${plan.length} device(s) — ` +
|
| 579 |
+
plan.map(s => `${s.id === myId ? "me" : nmeOf(s.id)}:${s.hi - s.lo}`).join(", "));
|
| 580 |
+
for (let i = 1; i < plan.length; i++)
|
| 581 |
+
await sendTo(plan[i].id, Wire.packAssign({
|
| 582 |
+
repo: repoInfo.repo, revision: repoInfo.revision, spec, plan,
|
| 583 |
+
mine: i, fingerprint: repoInfo.fingerprint, ctx: T,
|
| 584 |
+
}));
|
| 585 |
+
log("waiting for every stage to fetch its layers…");
|
| 586 |
+
const ok = await waitFor(() => readyStages.size >= plan.length - 1, 600000);
|
| 587 |
+
if (!ok) { log("not every stage reported ready — generating anyway will stall, so stopping here."); throw new Error("stages not ready"); }
|
| 588 |
+
} else {
|
| 589 |
+
log(`solo run — this device will hold all ${spec.layers} layers`);
|
| 590 |
+
}
|
| 591 |
+
myStage = plan[0];
|
| 592 |
+
ui.myShard.textContent = "loading my slice…";
|
| 593 |
+
await loadMyStage({ repo: repoInfo.repo, revision: repoInfo.revision, spec, ctx: T }, myStage);
|
| 594 |
+
|
| 595 |
+
const seq = ++runSeq;
|
| 596 |
+
const nTok = +ui.ntok.value, temp = +ui.temp.value / 100;
|
| 597 |
+
const opts = { temperature: temp, topK: 40, rng: Infer.mulberry32(+ui.seed.value | 0) };
|
| 598 |
+
let ids = [...Tokenizer.encode(tok, ui.prompt.value || "The")];
|
| 599 |
+
if (!ids.length) ids = [0];
|
| 600 |
+
const promptLen = ids.length;
|
| 601 |
+
ui.out.textContent = ui.prompt.value;
|
| 602 |
+
const tStart = performance.now();
|
| 603 |
+
let hops = 0;
|
| 604 |
+
|
| 605 |
+
for (let n = 0; n < nTok && !abortRun; n++) {
|
| 606 |
+
const win = new Int32Array(T);
|
| 607 |
+
const tail = ids.slice(-T);
|
| 608 |
+
for (let i = 0; i < tail.length; i++) win[T - tail.length + i] = tail[i];
|
| 609 |
+
let x = Infer.embed(stageObj, win);
|
| 610 |
+
x = await Infer.runLayers(stageObj, x);
|
| 611 |
+
if (auditFailure) { log(`KERNEL AUDIT FAILED: ${auditFailure} — stopping`); break; }
|
| 612 |
+
if (plan.length > 1) {
|
| 613 |
+
await sendTo(plan[1].id, packAct(seq, n, plan[1].index, x));
|
| 614 |
+
hops += plan.length;
|
| 615 |
+
const key = `${seq}:${n}`;
|
| 616 |
+
const ok = await waitFor(() => actIn.has(key), 60000);
|
| 617 |
+
if (!ok) { log(`the ring stalled at token ${n + 1}: no activation came back. Press Generate again to re-plan.`); break; }
|
| 618 |
+
x = actIn.get(key); actIn.delete(key);
|
| 619 |
+
}
|
| 620 |
+
const id = Infer.pickToken(await Infer.readout(stageObj, x), opts);
|
| 621 |
+
ids.push(id);
|
| 622 |
+
ui.out.textContent = Tokenizer.decode(tok, ids);
|
| 623 |
+
ui.tokcount.textContent = String(n + 1);
|
| 624 |
+
ui.hops.textContent = String(hops);
|
| 625 |
+
ui.tps.textContent = ((n + 1) / ((performance.now() - tStart) / 1000)).toFixed(2);
|
| 626 |
+
broadcast(Wire.packToken(seq, id, ids.length));
|
| 627 |
+
await new Promise(r => setTimeout(r, 0));
|
| 628 |
+
}
|
| 629 |
+
// The differential check. In a pipeline nothing recomputes anything, so
|
| 630 |
+
// this is the only instrument that can show a distributed answer is RIGHT
|
| 631 |
+
// rather than merely self-consistent. It costs a full local run, which is
|
| 632 |
+
// only possible when this device can hold the whole model — so it is
|
| 633 |
+
// offered, not assumed.
|
| 634 |
+
if (ui.verify.checked && !abortRun && plan.length > 1) {
|
| 635 |
+
log("verifying: fetching the whole model here and re-running the same prompt…");
|
| 636 |
+
const all = { id: myId, index: 0, lo: 0, hi: spec.layers, head: true };
|
| 637 |
+
const solo = await loadMyStage({ repo: repoInfo.repo, revision: repoInfo.revision, spec, ctx: T }, all);
|
| 638 |
+
const ref = await Infer.generateLocal([solo], Tokenizer.encode(tok, ui.prompt.value || "The"), nTok,
|
| 639 |
+
{ temperature: temp, topK: 40, rng: Infer.mulberry32(+ui.seed.value | 0) });
|
| 640 |
+
const same = ref.length === ids.length && ref.every((v, i) => v === ids[i]);
|
| 641 |
+
log(same ? "VERIFIED: the distributed run and the single-device run produced identical token ids."
|
| 642 |
+
: `MISMATCH: the ring and this device disagree. Distributed "${Tokenizer.decode(tok, ids).slice(0, 60)}" ` +
|
| 643 |
+
`vs local "${Tokenizer.decode(tok, ref).slice(0, 60)}". Check the log for probe warnings.`);
|
| 644 |
+
}
|
| 645 |
+
if (!abortRun) log(`done — ${ids.length - promptLen} token(s) in ${((performance.now() - tStart) / 1000).toFixed(1)} s ` +
|
| 646 |
+
`over ${plan.length} stage(s)`);
|
| 647 |
+
broadcast(Wire.packDone(seq));
|
| 648 |
+
} catch (e) {
|
| 649 |
+
log(`run failed: ${redact(e.message)}`);
|
| 650 |
+
console.error(e);
|
| 651 |
+
}
|
| 652 |
+
running = false;
|
| 653 |
+
ui.genBtn.disabled = false; ui.stopBtn.disabled = true;
|
| 654 |
+
ui.ringState.textContent = "idle";
|
| 655 |
+
}
|
| 656 |
+
|
| 657 |
+
function onToken(peerId, buf) {
|
| 658 |
+
const { id } = Wire.unpackToken(buf);
|
| 659 |
+
if (tok) ui.out.textContent += Tokenizer.decode(tok, [id]);
|
| 660 |
+
else ui.out.textContent += ` ${id}`;
|
| 661 |
+
ui.tokcount.textContent = String(+(ui.tokcount.textContent || 0) + 1);
|
| 662 |
+
}
|
| 663 |
+
|
| 664 |
+
function onWire(peerId, buf) {
|
| 665 |
+
const tag = Wire.tagOf(buf);
|
| 666 |
+
if (tag === Wire.FRAG) { const whole = onFragment(peerId, buf); if (whole) onWire(peerId, whole); return; }
|
| 667 |
+
if (tag === Wire.HELLO) return onHello(peerId, buf);
|
| 668 |
+
if (tag === Wire.ASSIGN) return void onAssign(peerId, buf);
|
| 669 |
+
if (tag === Wire.READY) return onReady(peerId, buf);
|
| 670 |
+
if (tag === Wire.ACT) return void onAct(peerId, buf);
|
| 671 |
+
if (tag === Wire.TOKEN) return onToken(peerId, buf);
|
| 672 |
+
if (tag === Wire.DONE) { ui.ringState.textContent = "idle"; return; }
|
| 673 |
+
}
|
| 674 |
+
|
| 675 |
+
// ---- kernels ----------------------------------------------------------------
|
| 676 |
+
const gemmAudit = {
|
| 677 |
+
cells: 12,
|
| 678 |
+
due: () => true,
|
| 679 |
+
fail: (msg) => { if (!auditFailure) { auditFailure = msg; log(`KERNEL AUDIT FAILED: ${msg}`); } },
|
| 680 |
+
};
|
| 681 |
+
function kernelCtx() {
|
| 682 |
+
return { L, bgemm: compute.bgemm || null, att: compute.att || null,
|
| 683 |
+
mlp: compute.mlp || null, audit: gemmAudit };
|
| 684 |
+
}
|
| 685 |
+
|
| 686 |
+
// ---- boot -------------------------------------------------------------------
|
| 687 |
+
(async function () {
|
| 688 |
+
try {
|
| 689 |
+
const mode = await (await fetch("mode")).json();
|
| 690 |
+
if (mode.rtc) rtcConfig = mode.rtc;
|
| 691 |
+
oauthAvailable = !!mode.oauth;
|
| 692 |
+
// Room-first deployments (the hosted Space sets DAISY_FORCE_ROOMS): there
|
| 693 |
+
// is no LAN auto-grouping between strangers. Two people behind the same
|
| 694 |
+
// CGNAT or on the same campus network share a public IP, and without this
|
| 695 |
+
// they would be dropped into one ring together — able to see each other's
|
| 696 |
+
// addresses and each other's activations. So a visitor with no room code
|
| 697 |
+
// chooses: create their own room, or join one by code.
|
| 698 |
+
if (mode.forceRooms && !room()) {
|
| 699 |
+
for (const c of document.querySelectorAll(".card")) if (c !== ui.lobby) c.style.display = "none";
|
| 700 |
+
ui.lobby.style.display = "";
|
| 701 |
+
ui.createRoom.onclick = () => {
|
| 702 |
+
const code = (ADJ[Math.floor(Math.random() * ADJ.length)] + "-" +
|
| 703 |
+
NOUN[Math.floor(Math.random() * NOUN.length)] + "-" +
|
| 704 |
+
(100 + Math.floor(Math.random() * 900))).toLowerCase();
|
| 705 |
+
location.href = "?room=" + code;
|
| 706 |
+
};
|
| 707 |
+
const join = () => {
|
| 708 |
+
const code = ui.joinCode.value.trim().toLowerCase();
|
| 709 |
+
if (code) location.href = "?room=" + encodeURIComponent(code);
|
| 710 |
+
};
|
| 711 |
+
ui.joinRoom.onclick = join;
|
| 712 |
+
ui.joinCode.onkeydown = (e) => { if (e.key === "Enter") join(); };
|
| 713 |
+
return; // wait for the choice
|
| 714 |
+
}
|
| 715 |
+
} catch (e) {} // no /mode: local defaults
|
| 716 |
+
adoptTokenFromFragment(); // returning from an OAuth sign-in
|
| 717 |
+
|
| 718 |
+
// Same rule as the trainer: the verified units are mandatory. No float path.
|
| 719 |
+
try {
|
| 720 |
+
L = await Compute.loadLUTs();
|
| 721 |
+
if (!(L.mul instanceof Int16Array) || L.mul.length !== 65536) throw new Error("mul8 LUT malformed");
|
| 722 |
+
if (L.mul[((7 & 0xFF) * 256) + (-3 & 0xFF)] !== -21) throw new Error("mul8 LUT self-test failed (7 × -3 ≠ -21)");
|
| 723 |
+
compute = await Compute.initCompute(L);
|
| 724 |
+
} catch (e) {
|
| 725 |
+
setStatus("NEURAL UNITS UNAVAILABLE — inference disabled");
|
| 726 |
+
ui.backend.textContent = "unavailable";
|
| 727 |
+
log(`FATAL: verified neural units failed to load (${e.message}). This build only computes through the units.`);
|
| 728 |
+
return;
|
| 729 |
+
}
|
| 730 |
+
ui.backend.textContent = `${compute.backend.toUpperCase()} — ${compute.label}`;
|
| 731 |
+
probeHash = await Compute.kernelProbe(compute, L);
|
| 732 |
+
log(`kernel probe: ${probeHash} — every honest device gets this same number, on any backend`);
|
| 733 |
+
|
| 734 |
+
await measureCapacity();
|
| 735 |
+
log(`measured capacity: ${myCapacity.toFixed(0)} verified GEMMs/sec — this decides how many layers this device takes`);
|
| 736 |
+
|
| 737 |
+
ui.me.textContent = deviceName;
|
| 738 |
+
if (!hfToken) setToken(null); // keep a token adopted from the fragment
|
| 739 |
+
if (oauthAvailable) log("this deployment uses Hugging Face sign-in for gated models — no token is ever typed into this page");
|
| 740 |
+
for (const [el, out] of [[ui.temp, ui.vtemp], [ui.ntok, ui.vntok], [ui.ctxLen, ui.vctxLen]])
|
| 741 |
+
el.oninput = () => out.textContent = el.value;
|
| 742 |
+
ui.temp.oninput(); ui.ntok.oninput(); ui.ctxLen.oninput();
|
| 743 |
+
|
| 744 |
+
if (room()) {
|
| 745 |
+
ui.roomInfo.style.display = "";
|
| 746 |
+
ui.roomCode.textContent = room();
|
| 747 |
+
ui.copyLink.onclick = async () => {
|
| 748 |
+
try { await navigator.clipboard.writeText(location.href); ui.copyLink.textContent = "Copied!"; }
|
| 749 |
+
catch { ui.copyLink.textContent = location.href; }
|
| 750 |
+
setTimeout(() => ui.copyLink.textContent = "Copy invite link", 2000);
|
| 751 |
+
};
|
| 752 |
+
}
|
| 753 |
+
ui.loadRepo.onclick = loadRepo;
|
| 754 |
+
ui.repo.onkeydown = (e) => { if (e.key === "Enter") loadRepo(); };
|
| 755 |
+
ui.clearToken.onclick = clearToken;
|
| 756 |
+
ui.genBtn.onclick = generate;
|
| 757 |
+
ui.stopBtn.onclick = () => { abortRun = true; log("stopping after the current token"); };
|
| 758 |
+
|
| 759 |
+
updatePeers(); renderPlan();
|
| 760 |
+
connectSignaling();
|
| 761 |
+
document.addEventListener("visibilitychange", () => {
|
| 762 |
+
if (!document.hidden && (!ws || ws.readyState > 1)) { log("page woke — reconnecting"); connectSignaling(); }
|
| 763 |
+
});
|
| 764 |
+
if (navigator.connection && navigator.connection.addEventListener)
|
| 765 |
+
navigator.connection.addEventListener("change", () => {
|
| 766 |
+
if (!ws || ws.readyState > 1) { log("network changed — reconnecting"); connectSignaling(); }
|
| 767 |
+
});
|
| 768 |
+
setStatus("ready — load a model to drive, or wait to be given a slice");
|
| 769 |
+
log(`ready — this device is "${deviceName}", computing through verified units on ${compute.backend.toUpperCase()}`);
|
| 770 |
+
})();
|
public/arch.js
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// config.json -> a normalized model spec, and the tensor names to fetch.
|
| 2 |
+
//
|
| 3 |
+
// "Load any model off HuggingFace" is not a file-format problem, it is an
|
| 4 |
+
// architecture problem: the weights are easy to read, but a Llama block and a
|
| 5 |
+
// GPT-2 block are different computations. This file is where that difference
|
| 6 |
+
// is confined. Everything downstream — sharding, the ring, the kernels —
|
| 7 |
+
// works off the normalized spec and never asks what family a model came from.
|
| 8 |
+
//
|
| 9 |
+
// Supported families, and what each one needs from the forward pass:
|
| 10 |
+
//
|
| 11 |
+
// llama / mistral / qwen2 / smollm / tinyllama RMSNorm, RoPE, GQA, SwiGLU
|
| 12 |
+
// gpt2 / gpt2-style LayerNorm+bias, learned
|
| 13 |
+
// positions, fused QKV, GELU
|
| 14 |
+
//
|
| 15 |
+
// Anything else is refused BY NAME with a clear message. Silently treating an
|
| 16 |
+
// unknown architecture as a known one would produce fluent, confident, wrong
|
| 17 |
+
// output — the exact failure this project spends its verification budget on
|
| 18 |
+
// making impossible.
|
| 19 |
+
(function (root) {
|
| 20 |
+
"use strict";
|
| 21 |
+
|
| 22 |
+
const LLAMA_LIKE = new Set(["llama", "mistral", "qwen2", "qwen3", "smollm", "tinyllama", "olmo", "phi3"]);
|
| 23 |
+
const GPT2_LIKE = new Set(["gpt2", "gpt_neo"]);
|
| 24 |
+
|
| 25 |
+
function pick(cfg, keys, dflt) {
|
| 26 |
+
for (const k of keys) if (cfg[k] != null) return cfg[k];
|
| 27 |
+
return dflt;
|
| 28 |
+
}
|
| 29 |
+
|
| 30 |
+
function fromConfig(cfg) {
|
| 31 |
+
const type = String(cfg.model_type || "").toLowerCase();
|
| 32 |
+
const family = LLAMA_LIKE.has(type) ? "llama" : GPT2_LIKE.has(type) ? "gpt2" : null;
|
| 33 |
+
if (!family)
|
| 34 |
+
throw new Error(`unsupported architecture "${cfg.model_type || "unknown"}". ` +
|
| 35 |
+
`This build implements Llama-style (RMSNorm/RoPE/GQA/SwiGLU) and GPT-2-style blocks. ` +
|
| 36 |
+
`Running a different block shape through these kernels would produce confident nonsense, so it is refused.`);
|
| 37 |
+
|
| 38 |
+
const hidden = pick(cfg, ["hidden_size", "n_embd"], 0);
|
| 39 |
+
const layers = pick(cfg, ["num_hidden_layers", "n_layer"], 0);
|
| 40 |
+
const heads = pick(cfg, ["num_attention_heads", "n_head"], 0);
|
| 41 |
+
const vocab = pick(cfg, ["vocab_size"], 0);
|
| 42 |
+
if (!hidden || !layers || !heads || !vocab)
|
| 43 |
+
throw new Error("config.json is missing hidden_size / num_hidden_layers / num_attention_heads / vocab_size");
|
| 44 |
+
|
| 45 |
+
const kvHeads = family === "llama" ? pick(cfg, ["num_key_value_heads"], heads) : heads;
|
| 46 |
+
const headDim = pick(cfg, ["head_dim"], Math.floor(hidden / heads));
|
| 47 |
+
if (heads * headDim !== hidden && family === "gpt2")
|
| 48 |
+
throw new Error(`head geometry does not divide the hidden size (${heads} heads x ${headDim} != ${hidden})`);
|
| 49 |
+
|
| 50 |
+
const spec = {
|
| 51 |
+
family, modelType: type, hidden, layers, heads, kvHeads, headDim, vocab,
|
| 52 |
+
inter: pick(cfg, ["intermediate_size", "n_inner"], family === "gpt2" ? 4 * hidden : 4 * hidden),
|
| 53 |
+
maxPos: pick(cfg, ["max_position_embeddings", "n_positions", "n_ctx"], 2048),
|
| 54 |
+
normEps: pick(cfg, ["rms_norm_eps", "layer_norm_epsilon", "layer_norm_eps"], 1e-5),
|
| 55 |
+
norm: family === "llama" ? "rms" : "ln",
|
| 56 |
+
act: family === "llama" ? "silu" : "gelu",
|
| 57 |
+
gated: family === "llama", // SwiGLU has a gate branch; GELU MLP does not
|
| 58 |
+
rope: family === "llama",
|
| 59 |
+
ropeTheta: pick(cfg, ["rope_theta"], 10000),
|
| 60 |
+
tie: pick(cfg, ["tie_word_embeddings"], family === "gpt2"),
|
| 61 |
+
qkvFused: family === "gpt2", // GPT-2 packs q,k,v in one c_attn
|
| 62 |
+
bias: family === "gpt2", // Llama-likes are bias-free
|
| 63 |
+
// GPT-2's Conv1D stores (in, out) — already the k x n our GEMM wants.
|
| 64 |
+
// torch.nn.Linear stores (out, in) and must be transposed at load.
|
| 65 |
+
weightLayout: family === "gpt2" ? "in_out" : "out_in",
|
| 66 |
+
};
|
| 67 |
+
if (spec.gated && !cfg.intermediate_size)
|
| 68 |
+
throw new Error("a gated MLP needs intermediate_size in config.json");
|
| 69 |
+
if (spec.kvHeads && spec.heads % spec.kvHeads !== 0)
|
| 70 |
+
throw new Error(`num_attention_heads (${spec.heads}) is not a multiple of num_key_value_heads (${spec.kvHeads})`);
|
| 71 |
+
return spec;
|
| 72 |
+
}
|
| 73 |
+
|
| 74 |
+
// ---- tensor names -----------------------------------------------------------
|
| 75 |
+
// Returned as { role: name }. Roles are what the forward pass asks for, so a
|
| 76 |
+
// new family only has to be named here, never plumbed through.
|
| 77 |
+
function layerTensors(spec, i) {
|
| 78 |
+
if (spec.family === "llama") {
|
| 79 |
+
const p = `model.layers.${i}.`;
|
| 80 |
+
// The q/k/v biases are listed but OPTIONAL, and that is not a detail:
|
| 81 |
+
// Qwen2.5 ships them while SmolLM does not, and neither says so in
|
| 82 |
+
// config.json (`attention_bias` is simply absent from Qwen's). Trusting
|
| 83 |
+
// the config would silently drop three bias vectors per layer — a model
|
| 84 |
+
// that still generates fluent text, just not the right text. So bias is
|
| 85 |
+
// decided by what the weight file actually contains.
|
| 86 |
+
return {
|
| 87 |
+
nrm1: p + "input_layernorm.weight",
|
| 88 |
+
nrm2: p + "post_attention_layernorm.weight",
|
| 89 |
+
Wq: p + "self_attn.q_proj.weight", bq: p + "self_attn.q_proj.bias",
|
| 90 |
+
Wk: p + "self_attn.k_proj.weight", bk: p + "self_attn.k_proj.bias",
|
| 91 |
+
Wv: p + "self_attn.v_proj.weight", bv: p + "self_attn.v_proj.bias",
|
| 92 |
+
Wo: p + "self_attn.o_proj.weight", bo: p + "self_attn.o_proj.bias",
|
| 93 |
+
Wgate: p + "mlp.gate_proj.weight", Wup: p + "mlp.up_proj.weight", Wdown: p + "mlp.down_proj.weight",
|
| 94 |
+
};
|
| 95 |
+
}
|
| 96 |
+
const p = `h.${i}.`; // gpt2 (also seen as transformer.h.N)
|
| 97 |
+
return {
|
| 98 |
+
nrm1: p + "ln_1.weight", nrm1b: p + "ln_1.bias",
|
| 99 |
+
nrm2: p + "ln_2.weight", nrm2b: p + "ln_2.bias",
|
| 100 |
+
Wqkv: p + "attn.c_attn.weight", bqkv: p + "attn.c_attn.bias",
|
| 101 |
+
Wo: p + "attn.c_proj.weight", bo: p + "attn.c_proj.bias",
|
| 102 |
+
Wfc: p + "mlp.c_fc.weight", bfc: p + "mlp.c_fc.bias",
|
| 103 |
+
Wdown: p + "mlp.c_proj.weight", bdown: p + "mlp.c_proj.bias",
|
| 104 |
+
};
|
| 105 |
+
}
|
| 106 |
+
function headTensors(spec) {
|
| 107 |
+
if (spec.family === "llama")
|
| 108 |
+
return { emb: "model.embed_tokens.weight", nrmF: "model.norm.weight",
|
| 109 |
+
lmHead: spec.tie ? null : "lm_head.weight" };
|
| 110 |
+
return { emb: "wte.weight", pos: "wpe.weight",
|
| 111 |
+
nrmF: "ln_f.weight", nrmFb: "ln_f.bias",
|
| 112 |
+
lmHead: spec.tie ? null : "lm_head.weight" };
|
| 113 |
+
}
|
| 114 |
+
|
| 115 |
+
// Repos differ on whether GPT-2 tensors carry a "transformer." prefix, and
|
| 116 |
+
// some Llama exports drop "model.". Rather than guessing, resolve each name
|
| 117 |
+
// against the tensors the file actually contains.
|
| 118 |
+
function resolver(available) {
|
| 119 |
+
const has = (n) => available.has(n);
|
| 120 |
+
return function resolve(name, optional) {
|
| 121 |
+
if (name == null) return null;
|
| 122 |
+
if (has(name)) return name;
|
| 123 |
+
for (const alt of ["transformer." + name, name.replace(/^model\./, ""), "model." + name,
|
| 124 |
+
name.replace(/^transformer\./, "")])
|
| 125 |
+
if (has(alt)) return alt;
|
| 126 |
+
if (optional) return null;
|
| 127 |
+
throw new Error(`the weights do not contain "${name}" — this repo's layout is not one this build recognises`);
|
| 128 |
+
};
|
| 129 |
+
}
|
| 130 |
+
|
| 131 |
+
// Every tensor one stage needs: its layers, plus the head's pieces if it is
|
| 132 |
+
// the head. This is the list that becomes byte ranges.
|
| 133 |
+
function tensorsFor(spec, available, st) {
|
| 134 |
+
const resolve = resolver(available);
|
| 135 |
+
const want = [];
|
| 136 |
+
const add = (n, opt) => { const r = resolve(n, opt); if (r) want.push(r); };
|
| 137 |
+
if (st.head) {
|
| 138 |
+
const h = headTensors(spec);
|
| 139 |
+
add(h.emb); add(h.pos, true); add(h.nrmF, true); add(h.nrmFb, true); add(h.lmHead, true);
|
| 140 |
+
}
|
| 141 |
+
for (let l = st.lo; l < st.hi; l++) {
|
| 142 |
+
const t = layerTensors(spec, l);
|
| 143 |
+
// bias roles (b*) are optional everywhere: present in the file or not
|
| 144 |
+
// applied at all. Weight roles (W*, nrm*) are required.
|
| 145 |
+
for (const [role, n] of Object.entries(t)) add(n, /^b/.test(role));
|
| 146 |
+
}
|
| 147 |
+
return want;
|
| 148 |
+
}
|
| 149 |
+
|
| 150 |
+
const api = { fromConfig, layerTensors, headTensors, tensorsFor, resolver, LLAMA_LIKE, GPT2_LIKE };
|
| 151 |
+
if (typeof module !== "undefined" && module.exports) module.exports = api;
|
| 152 |
+
else root.Arch = api;
|
| 153 |
+
})(typeof self !== "undefined" ? self : this);
|
public/hf.js
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// Hugging Face Hub client — fetch only the bytes this device needs.
|
| 2 |
+
//
|
| 3 |
+
// Two rules shape this file.
|
| 4 |
+
//
|
| 5 |
+
// 1. NOTHING is downloaded whole. config.json and the tokenizer are small and
|
| 6 |
+
// fetched normally; the weights are read with HTTP range requests against
|
| 7 |
+
// the byte offsets in the safetensors header. A device that owns four
|
| 8 |
+
// layers transfers four layers.
|
| 9 |
+
//
|
| 10 |
+
// 2. The token is asked for ONCE, held in memory, and never written anywhere.
|
| 11 |
+
// Not localStorage, not sessionStorage, not a cookie, not a URL, not the
|
| 12 |
+
// log, and above all never onto the wire — peers each authenticate
|
| 13 |
+
// themselves. A page reload is meant to lose it. See requestToken() in
|
| 14 |
+
// app.js for the prompt; this file only ever receives it as an argument.
|
| 15 |
+
(function (root) {
|
| 16 |
+
"use strict";
|
| 17 |
+
|
| 18 |
+
let ST; // Safetensors — resolved per environment at the end
|
| 19 |
+
const HUB = "https://huggingface.co";
|
| 20 |
+
|
| 21 |
+
// A token that reaches a log or a peer is a leaked credential, so anything
|
| 22 |
+
// that formats an error goes through here first. Cheap, and it means a
|
| 23 |
+
// careless template literal cannot undo rule 2.
|
| 24 |
+
function redact(s, token) {
|
| 25 |
+
if (!token) return s;
|
| 26 |
+
return String(s).split(token).join("hf_***");
|
| 27 |
+
}
|
| 28 |
+
|
| 29 |
+
function headers(token, extra) {
|
| 30 |
+
const h = Object.assign({}, extra || {});
|
| 31 |
+
if (token) h["Authorization"] = "Bearer " + token;
|
| 32 |
+
return h;
|
| 33 |
+
}
|
| 34 |
+
|
| 35 |
+
function fileUrl(repo, file, revision) {
|
| 36 |
+
return `${HUB}/${repo}/resolve/${encodeURIComponent(revision || "main")}/${file}`;
|
| 37 |
+
}
|
| 38 |
+
|
| 39 |
+
// Turn the Hub's status codes into something a person can act on. 401/403 on
|
| 40 |
+
// a public-looking repo almost always means "gated, accept the licence" or
|
| 41 |
+
// "private, needs a token", and those need different actions from the user,
|
| 42 |
+
// so they are not collapsed into one message.
|
| 43 |
+
class HFError extends Error {
|
| 44 |
+
constructor(msg, kind, status) { super(msg); this.kind = kind; this.status = status; }
|
| 45 |
+
}
|
| 46 |
+
function statusError(res, repo, file, hasToken) {
|
| 47 |
+
const where = `${repo}/${file}`;
|
| 48 |
+
if (res.status === 401)
|
| 49 |
+
return new HFError(hasToken
|
| 50 |
+
? `${where}: the token was rejected (401). Check it is a valid READ token and has not been revoked.`
|
| 51 |
+
: `${where} needs authentication (401).`, "auth", 401);
|
| 52 |
+
if (res.status === 403)
|
| 53 |
+
return new HFError(hasToken
|
| 54 |
+
? `${where}: this token does not have access (403). If the model is gated, accept its licence on the model page first.`
|
| 55 |
+
: `${where} is gated or private (403).`, "auth", 403);
|
| 56 |
+
if (res.status === 404)
|
| 57 |
+
return new HFError(`${where} not found (404). Check the repo id, and that this model ships .safetensors weights.`, "missing", 404);
|
| 58 |
+
return new HFError(`${where}: HTTP ${res.status}`, "http", res.status);
|
| 59 |
+
}
|
| 60 |
+
|
| 61 |
+
async function getJSON(repo, file, revision, token) {
|
| 62 |
+
const res = await fetch(fileUrl(repo, file, revision), { headers: headers(token) });
|
| 63 |
+
if (!res.ok) throw statusError(res, repo, file, !!token);
|
| 64 |
+
try { return await res.json(); }
|
| 65 |
+
catch (e) { throw new HFError(`${repo}/${file} is not valid JSON`, "parse", res.status); }
|
| 66 |
+
}
|
| 67 |
+
|
| 68 |
+
// Range read. The Hub redirects to a CDN that honours Range and answers 206;
|
| 69 |
+
// a 200 here means the whole file came back, which for a multi-hundred-MB
|
| 70 |
+
// weights file is exactly the outcome this project exists to avoid — so it
|
| 71 |
+
// is treated as an error rather than silently accepted.
|
| 72 |
+
async function getRange(repo, file, revision, token, start, end) {
|
| 73 |
+
const res = await fetch(fileUrl(repo, file, revision), {
|
| 74 |
+
headers: headers(token, { Range: `bytes=${start}-${end - 1}` }),
|
| 75 |
+
});
|
| 76 |
+
if (res.status === 200)
|
| 77 |
+
throw new HFError(`${repo}/${file}: the server ignored the byte range and returned the whole file — refusing it.`, "norange", 200);
|
| 78 |
+
if (!res.ok && res.status !== 206) throw statusError(res, repo, file, !!token);
|
| 79 |
+
return res.arrayBuffer();
|
| 80 |
+
}
|
| 81 |
+
|
| 82 |
+
// ---- what a repo contains ---------------------------------------------------
|
| 83 |
+
// Sharded repos carry model.safetensors.index.json mapping tensor -> shard.
|
| 84 |
+
// Single-file repos just have model.safetensors. Try the index first: its
|
| 85 |
+
// absence is the normal case and a 404 is cheap.
|
| 86 |
+
async function listWeightFiles(repo, revision, token) {
|
| 87 |
+
try {
|
| 88 |
+
const idx = await getJSON(repo, "model.safetensors.index.json", revision, token);
|
| 89 |
+
const files = [...new Set(Object.values(idx.weight_map || {}))];
|
| 90 |
+
if (!files.length) throw new HFError("weight index lists no shards", "parse", 200);
|
| 91 |
+
return { files, weightMap: idx.weight_map };
|
| 92 |
+
} catch (e) {
|
| 93 |
+
if (e.kind === "auth") throw e; // don't mask a token problem
|
| 94 |
+
return { files: ["model.safetensors"], weightMap: null };
|
| 95 |
+
}
|
| 96 |
+
}
|
| 97 |
+
|
| 98 |
+
// Read just the header of each weight shard: two requests per shard, a few
|
| 99 |
+
// tens of KB, and afterwards we know every tensor's exact byte range without
|
| 100 |
+
// having touched a single weight.
|
| 101 |
+
async function readIndex(repo, revision, token, onProgress) {
|
| 102 |
+
const { files } = await listWeightFiles(repo, revision, token);
|
| 103 |
+
const tensors = new Map();
|
| 104 |
+
for (const f of files) {
|
| 105 |
+
if (onProgress) onProgress(`reading ${f} header…`);
|
| 106 |
+
const head = await getRange(repo, f, revision, token, 0, 8);
|
| 107 |
+
const dv = new DataView(head);
|
| 108 |
+
const lo = dv.getUint32(0, true), hi = dv.getUint32(4, true);
|
| 109 |
+
if (hi !== 0) throw new HFError(`${f}: implausible header length — not a safetensors file`, "parse", 200);
|
| 110 |
+
const full = await getRange(repo, f, revision, token, 0, 8 + lo);
|
| 111 |
+
const parsed = ST.parseHeader(full);
|
| 112 |
+
for (const [name, t] of parsed.tensors) tensors.set(name, Object.assign({ file: f }, t));
|
| 113 |
+
}
|
| 114 |
+
return { files, tensors };
|
| 115 |
+
}
|
| 116 |
+
|
| 117 |
+
// Fetch a specific set of tensors as f32, coalescing adjacent byte ranges.
|
| 118 |
+
// This is the only function that moves real weight data.
|
| 119 |
+
async function fetchTensors(repo, revision, token, tensors, onProgress) {
|
| 120 |
+
const out = new Map();
|
| 121 |
+
const byFile = new Map();
|
| 122 |
+
for (const t of tensors) {
|
| 123 |
+
if (!byFile.has(t.file)) byFile.set(t.file, []);
|
| 124 |
+
byFile.get(t.file).push(t);
|
| 125 |
+
}
|
| 126 |
+
let done = 0;
|
| 127 |
+
const total = tensors.reduce((a, t) => a + t.bytes, 0);
|
| 128 |
+
for (const [file, list] of byFile) {
|
| 129 |
+
for (const run of ST.coalesce(list)) {
|
| 130 |
+
const buf = await getRange(repo, file, revision, token, run.start, run.end);
|
| 131 |
+
for (const t of run.tensors) {
|
| 132 |
+
const off = t.start - run.start;
|
| 133 |
+
out.set(t.name, ST.toF32(t.dtype, new Uint8Array(buf, off, t.bytes)));
|
| 134 |
+
done += t.bytes;
|
| 135 |
+
if (onProgress) onProgress(`${(done / 1048576).toFixed(1)} / ${(total / 1048576).toFixed(1)} MB`);
|
| 136 |
+
}
|
| 137 |
+
}
|
| 138 |
+
}
|
| 139 |
+
return out;
|
| 140 |
+
}
|
| 141 |
+
|
| 142 |
+
// A cheap probe so the UI can ask for a token only when one is actually
|
| 143 |
+
// needed, instead of demanding credentials up front for public models.
|
| 144 |
+
async function needsAuth(repo, revision) {
|
| 145 |
+
try {
|
| 146 |
+
const res = await fetch(fileUrl(repo, "config.json", revision), { method: "HEAD" });
|
| 147 |
+
return res.status === 401 || res.status === 403;
|
| 148 |
+
} catch (e) { return false; }
|
| 149 |
+
}
|
| 150 |
+
|
| 151 |
+
const api = { HUB, HFError, fileUrl, getJSON, getRange, listWeightFiles, readIndex,
|
| 152 |
+
fetchTensors, needsAuth, redact };
|
| 153 |
+
if (typeof module !== "undefined" && module.exports) { ST = require("./safetensors.js"); module.exports = api; }
|
| 154 |
+
else { ST = root.Safetensors; root.HF = api; }
|
| 155 |
+
})(typeof self !== "undefined" ? self : this);
|
public/index.html
ADDED
|
@@ -0,0 +1,252 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!doctype html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="utf-8">
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
| 6 |
+
<title>DaisyChain-Infer — run one model across your devices</title>
|
| 7 |
+
<style>
|
| 8 |
+
/* Winter palette: pale snow-light, deep glacier dark. Blues and slates
|
| 9 |
+
throughout, with a cold cyan accent that stays legible on both. */
|
| 10 |
+
:root {
|
| 11 |
+
--page-bg: #eef3f8;
|
| 12 |
+
--page-tint: #dce6f0;
|
| 13 |
+
--card-bg: #f9fbfd;
|
| 14 |
+
--card-border: rgba(90, 122, 154, 0.28);
|
| 15 |
+
--text: #16232e;
|
| 16 |
+
--text-soft: #55708a;
|
| 17 |
+
--accent: #2f6f96;
|
| 18 |
+
--accent-deep: #1d4b6b;
|
| 19 |
+
--panel-bg: linear-gradient(135deg, #24455e 0%, #14283a 100%);
|
| 20 |
+
--panel-num: #eaf4fb;
|
| 21 |
+
--panel-label: #8fb4cd;
|
| 22 |
+
--btn: linear-gradient(135deg, #35789e 0%, #1d4b6b 100%);
|
| 23 |
+
--btn-hover: linear-gradient(135deg, #4189af 0%, #255a7f 100%);
|
| 24 |
+
--danger: #9a3d3d;
|
| 25 |
+
--warn: #a2472f;
|
| 26 |
+
--track: rgba(90, 122, 154, 0.20);
|
| 27 |
+
--shadow: 0 2px 12px rgba(30, 60, 90, 0.10);
|
| 28 |
+
}
|
| 29 |
+
@media (prefers-color-scheme: dark) {
|
| 30 |
+
:root {
|
| 31 |
+
--page-bg: #0c141d;
|
| 32 |
+
--page-tint: #101c27;
|
| 33 |
+
--card-bg: #14212d;
|
| 34 |
+
--card-border: rgba(140, 180, 210, 0.22);
|
| 35 |
+
--text: #dce9f3;
|
| 36 |
+
--text-soft: #8faec7;
|
| 37 |
+
--accent: #6fb5da;
|
| 38 |
+
--accent-deep: #9fd0e8;
|
| 39 |
+
--panel-bg: linear-gradient(135deg, #16303f 0%, #0a1620 100%);
|
| 40 |
+
--panel-num: #e6f3fb;
|
| 41 |
+
--panel-label: #7ea9c4;
|
| 42 |
+
--btn: linear-gradient(135deg, #2c6688 0%, #17394f 100%);
|
| 43 |
+
--btn-hover: linear-gradient(135deg, #37789d 0%, #1e4a66 100%);
|
| 44 |
+
--danger: #b3554f;
|
| 45 |
+
--warn: #e0a07f;
|
| 46 |
+
--track: rgba(140, 180, 210, 0.18);
|
| 47 |
+
--shadow: 0 2px 14px rgba(0, 0, 0, 0.35);
|
| 48 |
+
}
|
| 49 |
+
}
|
| 50 |
+
* { box-sizing: border-box; }
|
| 51 |
+
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
| 52 |
+
max-width: 760px; margin: 0 auto; padding: 24px 18px 48px; line-height: 1.5;
|
| 53 |
+
background: linear-gradient(180deg, var(--page-tint) 0%, var(--page-bg) 340px);
|
| 54 |
+
color: var(--text); }
|
| 55 |
+
h1 { margin: 0 0 2px; font-size: 1.7rem; letter-spacing: .2px; }
|
| 56 |
+
.sub { color: var(--text-soft); margin: 0 0 18px; font-size: .92rem; }
|
| 57 |
+
.sub code, code { background: rgba(47, 111, 150, .12); padding: 1px 5px; border-radius: 4px;
|
| 58 |
+
font-family: 'Cascadia Code', 'Courier New', monospace; font-size: .9em; }
|
| 59 |
+
.card { background: var(--card-bg); border: 1px solid var(--card-border); border-radius: 12px;
|
| 60 |
+
padding: 14px 16px; margin: 12px 0; box-shadow: var(--shadow); }
|
| 61 |
+
.lbl { color: var(--text-soft); font-size: 11px; font-weight: 800; letter-spacing: 1.5px;
|
| 62 |
+
text-transform: uppercase; margin-bottom: 8px; }
|
| 63 |
+
.row { display: flex; justify-content: space-between; gap: 12px; padding: 3px 0; font-size: .95rem; }
|
| 64 |
+
.k { color: var(--text-soft); } .v { font-weight: 700; text-align: right; word-break: break-word; }
|
| 65 |
+
.device { font-family: 'Cascadia Code', 'Courier New', monospace; font-size: 1.5rem; font-weight: 700;
|
| 66 |
+
color: var(--accent-deep); }
|
| 67 |
+
.panel { background: var(--panel-bg); border-radius: 10px; padding: 12px 16px; text-align: center; flex: 1; }
|
| 68 |
+
.panel .num { font-family: 'Cascadia Code', 'Courier New', monospace; font-size: 1.7rem;
|
| 69 |
+
font-weight: 700; color: var(--panel-num); }
|
| 70 |
+
.panel .cl { color: var(--panel-label); font-size: 10px; text-transform: uppercase; letter-spacing: 1.2px; }
|
| 71 |
+
button { background: var(--btn); color: #eef6fb; border: 0; border-radius: 8px; padding: 11px 24px;
|
| 72 |
+
font-weight: 700; font-size: .98rem; letter-spacing: .3px; cursor: pointer;
|
| 73 |
+
box-shadow: 0 2px 8px rgba(20, 45, 70, 0.22); transition: .15s; }
|
| 74 |
+
button:hover:not(:disabled) { background: var(--btn-hover); }
|
| 75 |
+
button:disabled { background: #7d8f9e; opacity: .5; cursor: not-allowed; box-shadow: none; }
|
| 76 |
+
button.small { padding: 6px 14px; font-size: .85rem; }
|
| 77 |
+
button.danger { background: var(--danger); }
|
| 78 |
+
pre { background: rgba(20, 45, 70, 0.05); border-radius: 8px; padding: 10px; max-height: 180px; overflow: auto;
|
| 79 |
+
font-size: .78rem; color: var(--text-soft); white-space: pre-wrap; margin: 0;
|
| 80 |
+
font-family: 'Cascadia Code', 'Courier New', monospace; }
|
| 81 |
+
@media (prefers-color-scheme: dark) { pre { background: rgba(0, 0, 0, 0.28); } }
|
| 82 |
+
.note { color: var(--text-soft); font-size: .82rem; }
|
| 83 |
+
.note b { color: var(--warn); }
|
| 84 |
+
.slbl { display: flex; justify-content: space-between; font-size: .92rem; margin: 10px 0 4px; font-weight: 600; }
|
| 85 |
+
.sval { font-family: 'Cascadia Code', 'Courier New', monospace; color: var(--accent); font-weight: 700; }
|
| 86 |
+
input[type=range] { width: 100%; accent-color: var(--accent); }
|
| 87 |
+
input[type=text], input[type=number], input[type=password] {
|
| 88 |
+
padding: 10px 12px; border-radius: 8px; border: 1px solid var(--card-border);
|
| 89 |
+
background: rgba(255,255,255,.55); color: inherit;
|
| 90 |
+
font-family: 'Cascadia Code', 'Courier New', monospace; font-size: .92rem; }
|
| 91 |
+
@media (prefers-color-scheme: dark) {
|
| 92 |
+
input[type=text], input[type=number], input[type=password] { background: rgba(0,0,0,.25); }
|
| 93 |
+
}
|
| 94 |
+
input:focus { outline: 2px solid var(--accent); outline-offset: 1px; }
|
| 95 |
+
#out { background: rgba(20, 45, 70, 0.05); border-radius: 8px; padding: 12px; min-height: 96px;
|
| 96 |
+
max-height: 340px; overflow: auto; white-space: pre-wrap; word-break: break-word;
|
| 97 |
+
font-family: 'Cascadia Code', 'Courier New', monospace; font-size: .9rem; color: var(--text); }
|
| 98 |
+
@media (prefers-color-scheme: dark) { #out { background: rgba(0, 0, 0, 0.28); } }
|
| 99 |
+
.joinrow { display: flex; gap: 8px; align-items: center; justify-content: space-between;
|
| 100 |
+
margin-top: 10px; flex-wrap: wrap; }
|
| 101 |
+
.modal { position: fixed; inset: 0; background: rgba(8, 18, 28, .62); backdrop-filter: blur(3px);
|
| 102 |
+
display: none; align-items: center; justify-content: center; padding: 20px; z-index: 50; }
|
| 103 |
+
.modal .box { background: var(--card-bg); border: 1px solid var(--card-border); border-radius: 12px;
|
| 104 |
+
padding: 20px; max-width: 460px; width: 100%; box-shadow: 0 10px 40px rgba(0,0,0,.35); }
|
| 105 |
+
.modal h3 { margin: 0 0 8px; font-size: 1.1rem; }
|
| 106 |
+
.modal input { width: 100%; margin: 12px 0 6px; }
|
| 107 |
+
.modal .acts { display: flex; gap: 8px; justify-content: flex-end; margin-top: 12px; }
|
| 108 |
+
</style>
|
| 109 |
+
</head>
|
| 110 |
+
<body>
|
| 111 |
+
<h1>❄ DaisyChain-Infer</h1>
|
| 112 |
+
<p class="sub">Run <b>one model across several devices</b>. Each device downloads and holds only its <b>own layers</b> — straight from Hugging Face, never from a peer — and passes the hidden state around the ring over WebRTC. The group can run a model that no single device could hold. Every multiply goes through the verified INT8 units. Runs locally: <code>npm start</code>.</p>
|
| 113 |
+
|
| 114 |
+
<!-- Shown only when the deployment sets DAISY_FORCE_ROOMS (the hosted Space).
|
| 115 |
+
Without it, visitors with no room code are grouped by public IP — which
|
| 116 |
+
on a public URL would put strangers behind the same CGNAT in one ring. -->
|
| 117 |
+
<div class="card" id="lobby" style="display:none;text-align:center">
|
| 118 |
+
<div class="lbl">❄ Get started</div>
|
| 119 |
+
<p class="note" style="margin:0 0 12px">Create a room and invite your own devices, or join a room someone shared with you. Rings are private by invitation — you approve every device that joins.</p>
|
| 120 |
+
<button id="createRoom">Create a room</button>
|
| 121 |
+
<div style="display:flex;gap:8px;justify-content:center;margin-top:12px;flex-wrap:wrap">
|
| 122 |
+
<input type="text" id="joinCode" placeholder="room code" spellcheck="false">
|
| 123 |
+
<button id="joinRoom" style="padding:10px 18px">Join</button>
|
| 124 |
+
</div>
|
| 125 |
+
</div>
|
| 126 |
+
|
| 127 |
+
<div class="card">
|
| 128 |
+
<div class="lbl">❄ This device</div>
|
| 129 |
+
<div class="device" id="me">—</div>
|
| 130 |
+
<div class="row" style="margin-top:8px"><span class="k">Status</span><span class="v" id="status">starting…</span></div>
|
| 131 |
+
<div class="row"><span class="k">Compute</span><span class="v" id="backend">detecting…</span></div>
|
| 132 |
+
<div class="row"><span class="k">My slice</span><span class="v" id="myShard">none yet</span></div>
|
| 133 |
+
<div class="row"><span class="k">HF token</span><span class="v" id="tokenState">none</span></div>
|
| 134 |
+
<div style="text-align:right"><button id="clearToken" class="small danger" style="display:none">Forget token</button></div>
|
| 135 |
+
</div>
|
| 136 |
+
|
| 137 |
+
<div class="card">
|
| 138 |
+
<div class="lbl">🜂 Devices in your group</div>
|
| 139 |
+
<div class="row"><span class="v" id="peers" style="text-align:left">(none yet)</span></div>
|
| 140 |
+
<div id="roomInfo" style="display:none;margin-top:8px">
|
| 141 |
+
<div class="row"><span class="k">Room</span><span class="v" id="roomCode"></span></div>
|
| 142 |
+
<div style="text-align:center;margin-top:6px"><button id="copyLink" class="small">Copy invite link</button></div>
|
| 143 |
+
<p class="note" style="margin:.5rem 0 0;text-align:center">Open the link on your other devices — you approve each one before it joins.</p>
|
| 144 |
+
</div>
|
| 145 |
+
<div id="requests"></div>
|
| 146 |
+
</div>
|
| 147 |
+
|
| 148 |
+
<div class="card">
|
| 149 |
+
<div class="lbl">🗻 Model</div>
|
| 150 |
+
<div style="display:flex;gap:8px;flex-wrap:wrap">
|
| 151 |
+
<input type="text" id="repo" value="HuggingFaceTB/SmolLM-135M" spellcheck="false"
|
| 152 |
+
placeholder="owner/name" style="flex:2;min-width:200px">
|
| 153 |
+
<input type="text" id="revision" value="main" spellcheck="false" placeholder="revision" style="flex:0 0 90px">
|
| 154 |
+
<button id="loadRepo">Load</button>
|
| 155 |
+
</div>
|
| 156 |
+
<div class="row" style="margin-top:10px"><span class="v" id="model" style="text-align:left">no model loaded</span></div>
|
| 157 |
+
<div class="row"><span class="k">Status</span><span class="v" id="modelState">—</span></div>
|
| 158 |
+
<div class="row"><span class="k">Tokenizer</span><span class="v" id="tokenizer">—</span></div>
|
| 159 |
+
<p class="note" style="margin:.6rem 0 0">Any Hugging Face repo with <code>.safetensors</code> weights whose architecture is <b>Llama-style</b> (Llama, Mistral, Qwen2, SmolLM, TinyLlama) or <b>GPT-2-style</b>. Loading reads only <code>config.json</code>, the tokenizer, and the weight <i>headers</i> — a few tens of KB, whatever the model's size. Gated or private repos will ask for a token, once.</p>
|
| 160 |
+
</div>
|
| 161 |
+
|
| 162 |
+
<div class="card">
|
| 163 |
+
<div class="lbl">⛓ Ring plan</div>
|
| 164 |
+
<pre id="plan" style="max-height:140px">load a model to see how it would be split</pre>
|
| 165 |
+
<p class="note" style="margin:.5rem 0 0">Layers are apportioned by <b>measured</b> speed — each device times a real GEMM through its own kernel. The MB figure is what that device will actually download and hold.</p>
|
| 166 |
+
</div>
|
| 167 |
+
|
| 168 |
+
<div class="card">
|
| 169 |
+
<div class="lbl">🎚 Generation</div>
|
| 170 |
+
<label class="slbl">Tokens <span class="sval" id="vntok">60</span></label>
|
| 171 |
+
<input type="range" id="ntok" min="10" max="400" step="10" value="60">
|
| 172 |
+
<label class="slbl">Context length <span class="sval" id="vctxLen">64</span></label>
|
| 173 |
+
<input type="range" id="ctxLen" min="16" max="512" step="16" value="64">
|
| 174 |
+
<p class="note" style="margin:.3rem 0 .6rem">There is no KV cache — every token re-runs the whole window, so context length is the main cost per token.</p>
|
| 175 |
+
<label class="slbl">Temperature ÷100 <span class="sval" id="vtemp">0</span></label>
|
| 176 |
+
<input type="range" id="temp" min="0" max="150" step="5" value="0">
|
| 177 |
+
<p class="note" style="margin:.3rem 0 .6rem">0 = greedy, which is deterministic and therefore checkable. Above 0, sampling follows the seed so runs stay reproducible.</p>
|
| 178 |
+
<label class="slbl">Seed</label>
|
| 179 |
+
<input type="number" id="seed" value="1234" style="width:140px">
|
| 180 |
+
<div class="row" id="verifyRow" style="display:none;margin-top:10px">
|
| 181 |
+
<label class="note" style="display:flex;gap:8px;align-items:center;cursor:pointer">
|
| 182 |
+
<input type="checkbox" id="verify"> Verify afterwards against a single-device run
|
| 183 |
+
<span>(downloads the whole model here — only sensible for small ones)</span>
|
| 184 |
+
</label>
|
| 185 |
+
</div>
|
| 186 |
+
</div>
|
| 187 |
+
|
| 188 |
+
<div class="card">
|
| 189 |
+
<div class="lbl">✎ Prompt</div>
|
| 190 |
+
<div style="display:flex;gap:8px;flex-wrap:wrap">
|
| 191 |
+
<input type="text" id="prompt" value="The quick brown fox" spellcheck="false" style="flex:1;min-width:180px">
|
| 192 |
+
<button id="genBtn" disabled>Generate</button>
|
| 193 |
+
<button id="stopBtn" disabled class="danger">Stop</button>
|
| 194 |
+
</div>
|
| 195 |
+
<div id="out" style="margin-top:10px"></div>
|
| 196 |
+
<div style="display:flex;gap:8px;margin-top:10px">
|
| 197 |
+
<div class="panel"><div class="num" id="tps">—</div><div class="cl">tokens / sec</div></div>
|
| 198 |
+
<div class="panel"><div class="num" id="tokcount">0</div><div class="cl">tokens</div></div>
|
| 199 |
+
<div class="panel"><div class="num" id="hops">0</div><div class="cl">ring hops</div></div>
|
| 200 |
+
</div>
|
| 201 |
+
<div class="row" style="margin-top:8px"><span class="k">Ring</span><span class="v" id="ringState">idle</span></div>
|
| 202 |
+
</div>
|
| 203 |
+
|
| 204 |
+
<div class="card">
|
| 205 |
+
<div class="lbl">❋ Log</div>
|
| 206 |
+
<pre id="log"></pre>
|
| 207 |
+
</div>
|
| 208 |
+
|
| 209 |
+
<p class="note">Needs a secure context (localhost or HTTPS) for WebGPU and cross-device WebRTC. No WebGPU? The identical verified INT8 units run on CPU — slower, but a CPU device and a GPU device produce the same bits, so they can share a ring. There is no plain-float path.</p>
|
| 210 |
+
<p class="note"><b>Heads up:</b> peers connect directly, so devices in your ring can see each other's IP address. A middle stage never receives the embedding table or the vocabulary, but it does see the hidden states passing through — and the head sees your prompt and output. Run rings with devices and people you trust. Proof of concept, not a hardened service.</p>
|
| 211 |
+
|
| 212 |
+
<!-- One-time credential prompt, shown only when a request actually fails for
|
| 213 |
+
want of one. Never stored: not localStorage, not sessionStorage, not a
|
| 214 |
+
cookie, and never sent to a peer — each device authenticates itself.
|
| 215 |
+
Reloading the tab forgets it.
|
| 216 |
+
|
| 217 |
+
On a hosted deployment the paste box is hidden entirely and only
|
| 218 |
+
"Sign in with Hugging Face" is offered: typing a personal access token
|
| 219 |
+
into a page you do not control is a bad habit even when the code is
|
| 220 |
+
honest, because you cannot verify that it is. -->
|
| 221 |
+
<div class="modal" id="tokModal">
|
| 222 |
+
<div class="box">
|
| 223 |
+
<h3>❄ Hugging Face access</h3>
|
| 224 |
+
<p class="note" id="tokWhy">This model needs a token to download.</p>
|
| 225 |
+
<div id="tokSignIn" style="display:none;margin:14px 0 6px">
|
| 226 |
+
<button id="tokSignInBtn" style="width:100%">Sign in with Hugging Face</button>
|
| 227 |
+
<p class="note" style="margin:.6rem 0 0">You are redirected to huggingface.co and back. This page receives a <b>scoped, expiring</b> token — read access to repositories, nothing else — held in memory for this tab only. No token is ever typed into this page, and none is sent to other devices.</p>
|
| 228 |
+
</div>
|
| 229 |
+
<div id="tokPasteRow">
|
| 230 |
+
<input type="password" id="tokInput" placeholder="hf_…" spellcheck="false" autocomplete="off">
|
| 231 |
+
<p class="note" style="margin:0">Kept <b>in memory for this tab only</b>: never written to disk or storage, never put in a URL, never logged, and never sent to other devices — each device is asked for its own. Create a <b>read</b> token at huggingface.co/settings/tokens.</p>
|
| 232 |
+
</div>
|
| 233 |
+
<div class="acts">
|
| 234 |
+
<button id="tokCancel" class="small danger">Cancel</button>
|
| 235 |
+
<button id="tokOk" class="small">Use token</button>
|
| 236 |
+
</div>
|
| 237 |
+
</div>
|
| 238 |
+
</div>
|
| 239 |
+
|
| 240 |
+
<script src="traincore.js"></script>
|
| 241 |
+
<script src="verified_core.js"></script>
|
| 242 |
+
<script src="safetensors.js"></script>
|
| 243 |
+
<script src="hf.js"></script>
|
| 244 |
+
<script src="arch.js"></script>
|
| 245 |
+
<script src="tokenizer.js"></script>
|
| 246 |
+
<script src="shard.js"></script>
|
| 247 |
+
<script src="wire.js"></script>
|
| 248 |
+
<script src="infer.js"></script>
|
| 249 |
+
<script src="webgpu.js"></script>
|
| 250 |
+
<script src="app.js"></script>
|
| 251 |
+
</body>
|
| 252 |
+
</html>
|
public/infer.js
ADDED
|
@@ -0,0 +1,268 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// Forward-only transformer, split at layer boundaries, for real HF models.
|
| 2 |
+
//
|
| 3 |
+
// Every matrix product goes through the verified INT8 units — the same
|
| 4 |
+
// block-scaled quantize -> exact LUT/DP4A multiply -> exact int32 accumulate ->
|
| 5 |
+
// pinned f32 epilogue as the rest of DaisyChain. Nothing here computes in
|
| 6 |
+
// plain float except the elementwise parts that have no matmul in them
|
| 7 |
+
// (norms, softmax, RoPE, the activation), exactly as the trainer does it.
|
| 8 |
+
//
|
| 9 |
+
// The pass is cut into three callable pieces so they can run on different
|
| 10 |
+
// machines:
|
| 11 |
+
//
|
| 12 |
+
// embed(ids) head stage: token (+ learned position) embedding
|
| 13 |
+
// runLayers(x) any stage: its own contiguous layers
|
| 14 |
+
// readout(x) head stage: final norm + lm_head -> logits
|
| 15 |
+
//
|
| 16 |
+
// Architecture differences live in the spec (see arch.js), never in branches
|
| 17 |
+
// scattered through the maths: `spec.norm`, `spec.gated`, `spec.rope`,
|
| 18 |
+
// `spec.kvHeads`. A Llama block and a GPT-2 block take the same path here.
|
| 19 |
+
(function (root) {
|
| 20 |
+
"use strict";
|
| 21 |
+
|
| 22 |
+
let TC, V, SH;
|
| 23 |
+
|
| 24 |
+
// ---- norms ------------------------------------------------------------------
|
| 25 |
+
// RMSNorm (Llama): x * w / sqrt(mean(x^2) + eps) — no mean subtraction.
|
| 26 |
+
// LayerNorm (GPT-2): (x - mu) / sigma * w + b.
|
| 27 |
+
// Both are elementwise after a row reduction, so there is no matmul to send
|
| 28 |
+
// through the units, and both run in f64-accumulated JS — which IEEE requires
|
| 29 |
+
// to be exactly rounded, so every device agrees.
|
| 30 |
+
function rmsNorm(x, rows, C, w, eps) {
|
| 31 |
+
const y = new Float32Array(rows * C);
|
| 32 |
+
for (let r = 0; r < rows; r++) {
|
| 33 |
+
let s = 0;
|
| 34 |
+
for (let j = 0; j < C; j++) { const v = x[r * C + j]; s += v * v; }
|
| 35 |
+
const inv = 1 / Math.sqrt(s / C + eps);
|
| 36 |
+
for (let j = 0; j < C; j++) y[r * C + j] = x[r * C + j] * inv * w[j];
|
| 37 |
+
}
|
| 38 |
+
return y;
|
| 39 |
+
}
|
| 40 |
+
function layerNorm(x, rows, C, w, b, eps) {
|
| 41 |
+
const y = new Float32Array(rows * C);
|
| 42 |
+
for (let r = 0; r < rows; r++) {
|
| 43 |
+
let mu = 0;
|
| 44 |
+
for (let j = 0; j < C; j++) mu += x[r * C + j];
|
| 45 |
+
mu /= C;
|
| 46 |
+
let v = 0;
|
| 47 |
+
for (let j = 0; j < C; j++) { const d = x[r * C + j] - mu; v += d * d; }
|
| 48 |
+
const inv = 1 / Math.sqrt(v / C + eps);
|
| 49 |
+
for (let j = 0; j < C; j++) y[r * C + j] = (x[r * C + j] - mu) * inv * w[j] + (b ? b[j] : 0);
|
| 50 |
+
}
|
| 51 |
+
return y;
|
| 52 |
+
}
|
| 53 |
+
function norm(spec, x, rows, C, w, b) {
|
| 54 |
+
return spec.norm === "rms" ? rmsNorm(x, rows, C, w, spec.normEps)
|
| 55 |
+
: layerNorm(x, rows, C, w, b, spec.normEps);
|
| 56 |
+
}
|
| 57 |
+
|
| 58 |
+
// ---- activations ------------------------------------------------------------
|
| 59 |
+
const silu = (v) => v / (1 + Math.exp(-v));
|
| 60 |
+
// tanh-approximate GELU: what GPT-2 was trained with, so it is the correct
|
| 61 |
+
// function here rather than an approximation of the erf form.
|
| 62 |
+
function gelu(v) {
|
| 63 |
+
return 0.5 * v * (1 + Math.tanh(0.7978845608028654 * (v + 0.044715 * v * v * v)));
|
| 64 |
+
}
|
| 65 |
+
|
| 66 |
+
// ---- RoPE -------------------------------------------------------------------
|
| 67 |
+
// Rotary embeddings applied per head, in the half-split layout HF uses:
|
| 68 |
+
// dims [0, hd/2) pair with [hd/2, hd). Positions are absolute, and because
|
| 69 |
+
// there is no KV cache every token re-runs the whole window, so position i
|
| 70 |
+
// is simply the row index.
|
| 71 |
+
function ropeTables(hd, theta, maxT) {
|
| 72 |
+
const half = hd >> 1;
|
| 73 |
+
const cos = new Float32Array(maxT * half), sin = new Float32Array(maxT * half);
|
| 74 |
+
for (let p = 0; p < maxT; p++)
|
| 75 |
+
for (let i = 0; i < half; i++) {
|
| 76 |
+
const f = p / Math.pow(theta, (2 * i) / hd);
|
| 77 |
+
cos[p * half + i] = Math.cos(f);
|
| 78 |
+
sin[p * half + i] = Math.sin(f);
|
| 79 |
+
}
|
| 80 |
+
return { cos, sin, half };
|
| 81 |
+
}
|
| 82 |
+
// x is rows x (nHeads*hd), rows = T; rotate each head in place.
|
| 83 |
+
function applyRope(x, T, nHeads, hd, rope, posOffset) {
|
| 84 |
+
const half = rope.half, stride = nHeads * hd;
|
| 85 |
+
for (let t = 0; t < T; t++) {
|
| 86 |
+
const p = (posOffset || 0) + t;
|
| 87 |
+
for (let h = 0; h < nHeads; h++) {
|
| 88 |
+
const o = t * stride + h * hd;
|
| 89 |
+
for (let i = 0; i < half; i++) {
|
| 90 |
+
const c = rope.cos[p * half + i], s = rope.sin[p * half + i];
|
| 91 |
+
const a = x[o + i], b = x[o + half + i];
|
| 92 |
+
x[o + i] = a * c - b * s;
|
| 93 |
+
x[o + half + i] = b * c + a * s;
|
| 94 |
+
}
|
| 95 |
+
}
|
| 96 |
+
}
|
| 97 |
+
}
|
| 98 |
+
|
| 99 |
+
// ---- verified matmul --------------------------------------------------------
|
| 100 |
+
// X is m x k, W is k x n (row-major). Weights are stored k x n at load time
|
| 101 |
+
// (see loadStage), so nothing is transposed per call.
|
| 102 |
+
async function vmm(X, W, m, k, n, ctx, bias) {
|
| 103 |
+
const out = await V.vgemmBlock(X, W, { m, k, n, batch: 1 }, ctx.L, ctx.bgemm, ctx.audit);
|
| 104 |
+
if (bias) for (let i = 0; i < m; i++) for (let j = 0; j < n; j++) out[i * n + j] += bias[j];
|
| 105 |
+
return out;
|
| 106 |
+
}
|
| 107 |
+
|
| 108 |
+
// ---- one layer --------------------------------------------------------------
|
| 109 |
+
async function layerForward(S, x, ly) {
|
| 110 |
+
const { spec, ctx } = S, C = spec.hidden, T = S.T;
|
| 111 |
+
const hd = spec.headDim, nH = spec.heads, nKV = spec.kvHeads;
|
| 112 |
+
const qDim = nH * hd, kvDim = nKV * hd;
|
| 113 |
+
|
| 114 |
+
// ---- attention
|
| 115 |
+
const h1 = norm(spec, x, T, C, ly.nrm1, ly.nrm1b);
|
| 116 |
+
let q, k, v;
|
| 117 |
+
if (spec.qkvFused) {
|
| 118 |
+
// GPT-2 packs q,k,v into one (C x 3C) projection — one GEMM, then split.
|
| 119 |
+
const qkv = await vmm(h1, ly.Wqkv, T, C, 3 * C, ctx, ly.bqkv);
|
| 120 |
+
q = new Float32Array(T * C); k = new Float32Array(T * C); v = new Float32Array(T * C);
|
| 121 |
+
for (let t = 0; t < T; t++) {
|
| 122 |
+
q.set(qkv.subarray(t * 3 * C, t * 3 * C + C), t * C);
|
| 123 |
+
k.set(qkv.subarray(t * 3 * C + C, t * 3 * C + 2 * C), t * C);
|
| 124 |
+
v.set(qkv.subarray(t * 3 * C + 2 * C, t * 3 * C + 3 * C), t * C);
|
| 125 |
+
}
|
| 126 |
+
} else {
|
| 127 |
+
[q, k, v] = await Promise.all([
|
| 128 |
+
vmm(h1, ly.Wq, T, C, qDim, ctx, ly.bq),
|
| 129 |
+
vmm(h1, ly.Wk, T, C, kvDim, ctx, ly.bk),
|
| 130 |
+
vmm(h1, ly.Wv, T, C, kvDim, ctx, ly.bv),
|
| 131 |
+
]);
|
| 132 |
+
}
|
| 133 |
+
if (spec.rope) { applyRope(q, T, nH, hd, S.rope, 0); applyRope(k, T, nKV, hd, S.rope, 0); }
|
| 134 |
+
|
| 135 |
+
// Grouped-query attention: several query heads share one kv head. Rather
|
| 136 |
+
// than materialising repeated kv (memory this project does not have to
|
| 137 |
+
// spare), the head index is mapped when reading.
|
| 138 |
+
const group = nH / nKV;
|
| 139 |
+
const scale = 1 / Math.sqrt(hd);
|
| 140 |
+
const ctxOut = new Float32Array(T * qDim);
|
| 141 |
+
// Scores and context are the two attention GEMMs. They are small per head
|
| 142 |
+
// (T x hd and T x T), and the fused kernels in webgpu.js expect the
|
| 143 |
+
// trainer's uniform-head layout, so at general head geometry they are run
|
| 144 |
+
// through the same verified block GEMM per head — every product still goes
|
| 145 |
+
// through the units, and the CPU mirror and GPU kernel agree bit-for-bit.
|
| 146 |
+
for (let h = 0; h < nH; h++) {
|
| 147 |
+
const kvh = Math.floor(h / group);
|
| 148 |
+
const qh = new Float32Array(T * hd), kh = new Float32Array(T * hd), vh = new Float32Array(T * hd);
|
| 149 |
+
for (let t = 0; t < T; t++) {
|
| 150 |
+
qh.set(q.subarray(t * qDim + h * hd, t * qDim + h * hd + hd), t * hd);
|
| 151 |
+
kh.set(k.subarray(t * kvDim + kvh * hd, t * kvDim + kvh * hd + hd), t * hd);
|
| 152 |
+
vh.set(v.subarray(t * kvDim + kvh * hd, t * kvDim + kvh * hd + hd), t * hd);
|
| 153 |
+
}
|
| 154 |
+
const khT = TC.transpose(kh, T, hd); // hd x T
|
| 155 |
+
const scores = await V.vgemmBlock(qh, khT, { m: T, k: hd, n: T, batch: 1 }, ctx.L, ctx.bgemm, ctx.audit);
|
| 156 |
+
const a = new Float32Array(T * T); // causal softmax
|
| 157 |
+
for (let i = 0; i < T; i++) {
|
| 158 |
+
let mx = -Infinity;
|
| 159 |
+
for (let j = 0; j <= i; j++) mx = Math.max(mx, scores[i * T + j] * scale);
|
| 160 |
+
let z = 0;
|
| 161 |
+
for (let j = 0; j <= i; j++) { const e = Math.exp(scores[i * T + j] * scale - mx); a[i * T + j] = e; z += e; }
|
| 162 |
+
for (let j = 0; j <= i; j++) a[i * T + j] /= z;
|
| 163 |
+
}
|
| 164 |
+
const oh = await V.vgemmBlock(a, vh, { m: T, k: T, n: hd, batch: 1 }, ctx.L, ctx.bgemm, ctx.audit);
|
| 165 |
+
for (let t = 0; t < T; t++) ctxOut.set(oh.subarray(t * hd, t * hd + hd), t * qDim + h * hd);
|
| 166 |
+
}
|
| 167 |
+
const attn = await vmm(ctxOut, ly.Wo, T, qDim, C, ctx, ly.bo);
|
| 168 |
+
const x2 = new Float32Array(T * C);
|
| 169 |
+
for (let i = 0; i < x2.length; i++) x2[i] = x[i] + attn[i];
|
| 170 |
+
|
| 171 |
+
// ---- MLP
|
| 172 |
+
const h2 = norm(spec, x2, T, C, ly.nrm2, ly.nrm2b);
|
| 173 |
+
let hid;
|
| 174 |
+
if (spec.gated) {
|
| 175 |
+
// SwiGLU: down(silu(gate(x)) * up(x))
|
| 176 |
+
const [g, u] = await Promise.all([
|
| 177 |
+
vmm(h2, ly.Wgate, T, C, spec.inter, ctx, null),
|
| 178 |
+
vmm(h2, ly.Wup, T, C, spec.inter, ctx, null),
|
| 179 |
+
]);
|
| 180 |
+
hid = g;
|
| 181 |
+
for (let i = 0; i < hid.length; i++) hid[i] = silu(hid[i]) * u[i];
|
| 182 |
+
} else {
|
| 183 |
+
hid = await vmm(h2, ly.Wfc, T, C, spec.inter, ctx, ly.bfc);
|
| 184 |
+
for (let i = 0; i < hid.length; i++) hid[i] = gelu(hid[i]);
|
| 185 |
+
}
|
| 186 |
+
const down = await vmm(hid, ly.Wdown, T, spec.inter, C, ctx, ly.bdown);
|
| 187 |
+
const out = new Float32Array(T * C);
|
| 188 |
+
for (let i = 0; i < out.length; i++) out[i] = x2[i] + down[i];
|
| 189 |
+
return out;
|
| 190 |
+
}
|
| 191 |
+
|
| 192 |
+
// ---- stage ------------------------------------------------------------------
|
| 193 |
+
function makeStage(spec, st, w, ctx, T) {
|
| 194 |
+
const S = { spec, st, w, ctx, T: T || Math.min(spec.maxPos, 128) };
|
| 195 |
+
if (spec.rope) S.rope = ropeTables(spec.headDim, spec.ropeTheta, S.T + 1);
|
| 196 |
+
return S;
|
| 197 |
+
}
|
| 198 |
+
|
| 199 |
+
function embed(S, ids) {
|
| 200 |
+
const { spec } = S, C = spec.hidden, T = S.T;
|
| 201 |
+
const x = new Float32Array(T * C);
|
| 202 |
+
for (let i = 0; i < T; i++) {
|
| 203 |
+
const id = ids[i];
|
| 204 |
+
for (let j = 0; j < C; j++) x[i * C + j] = S.w.emb[id * C + j];
|
| 205 |
+
}
|
| 206 |
+
if (S.w.pos) // GPT-2 learned positions
|
| 207 |
+
for (let i = 0; i < T; i++)
|
| 208 |
+
for (let j = 0; j < C; j++) x[i * C + j] += S.w.pos[i * C + j];
|
| 209 |
+
return x;
|
| 210 |
+
}
|
| 211 |
+
|
| 212 |
+
async function runLayers(S, x) {
|
| 213 |
+
for (const ly of S.w.layers) x = await layerForward(S, x, ly);
|
| 214 |
+
return x;
|
| 215 |
+
}
|
| 216 |
+
|
| 217 |
+
// Only the last position's logits are needed to pick the next token, and at
|
| 218 |
+
// a 150k-token vocabulary that turns the largest GEMM in the model into a
|
| 219 |
+
// single row — the biggest single saving in the ring.
|
| 220 |
+
async function readout(S, x) {
|
| 221 |
+
const { spec, ctx } = S, C = spec.hidden, T = S.T;
|
| 222 |
+
const y = norm(spec, x, T, C, S.w.nrmF, S.w.nrmFb);
|
| 223 |
+
const last = y.subarray((T - 1) * C, T * C);
|
| 224 |
+
return vmm(last, S.w.lmHead, 1, C, spec.vocab, ctx, null);
|
| 225 |
+
}
|
| 226 |
+
|
| 227 |
+
// ---- sampling ---------------------------------------------------------------
|
| 228 |
+
function mulberry32(a) { return function () { a |= 0; a = a + 0x6D2B79F5 | 0; let t = Math.imul(a ^ a >>> 15, 1 | a); t = t + Math.imul(t ^ t >>> 7, 61 | t) ^ t; return ((t ^ t >>> 14) >>> 0) / 4294967296; }; }
|
| 229 |
+
function pickToken(logits, opts) {
|
| 230 |
+
const o = opts || {}, temp = o.temperature ?? 0, vocab = logits.length;
|
| 231 |
+
if (!temp) {
|
| 232 |
+
let best = 0, bv = -Infinity;
|
| 233 |
+
for (let j = 0; j < vocab; j++) if (logits[j] > bv) { bv = logits[j]; best = j; }
|
| 234 |
+
return best;
|
| 235 |
+
}
|
| 236 |
+
const k = Math.min(o.topK || 40, vocab);
|
| 237 |
+
const idx = Array.from({ length: vocab }, (_, i) => i).sort((a, b) => logits[b] - logits[a]).slice(0, k);
|
| 238 |
+
let mx = -Infinity;
|
| 239 |
+
for (const i of idx) mx = Math.max(mx, logits[i] / temp);
|
| 240 |
+
let z = 0;
|
| 241 |
+
const p = idx.map(i => { const e = Math.exp(logits[i] / temp - mx); z += e; return e; });
|
| 242 |
+
let r = (o.rng || Math.random)() * z;
|
| 243 |
+
for (let i = 0; i < idx.length; i++) { r -= p[i]; if (r <= 0) return idx[i]; }
|
| 244 |
+
return idx[idx.length - 1];
|
| 245 |
+
}
|
| 246 |
+
|
| 247 |
+
// ---- single-device reference ------------------------------------------------
|
| 248 |
+
async function generateLocal(stages, ids, nTokens, opts) {
|
| 249 |
+
const head = stages[0], T = head.T;
|
| 250 |
+
const out = [...ids];
|
| 251 |
+
for (let n = 0; n < nTokens; n++) {
|
| 252 |
+
const win = new Int32Array(T);
|
| 253 |
+
const tail = out.slice(-T);
|
| 254 |
+
for (let i = 0; i < tail.length; i++) win[T - tail.length + i] = tail[i];
|
| 255 |
+
let x = embed(head, win);
|
| 256 |
+
for (const S of stages) x = await runLayers(S, x);
|
| 257 |
+
out.push(pickToken(await readout(head, x), opts));
|
| 258 |
+
}
|
| 259 |
+
return out;
|
| 260 |
+
}
|
| 261 |
+
|
| 262 |
+
const api = { makeStage, embed, runLayers, readout, pickToken, generateLocal,
|
| 263 |
+
rmsNorm, layerNorm, norm, ropeTables, applyRope, silu, gelu, mulberry32 };
|
| 264 |
+
if (typeof module !== "undefined" && module.exports) {
|
| 265 |
+
TC = require("./traincore.js"); V = require("./verified_core.js"); SH = require("./shard.js");
|
| 266 |
+
module.exports = api;
|
| 267 |
+
} else { TC = root.TrainCore; V = root.Verified; SH = root.Shard; root.Infer = api; }
|
| 268 |
+
})(typeof self !== "undefined" ? self : this);
|
public/luts_meta.json
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
{"mul": [256, 256], "requant": 65536, "relu": 256, "shift": 8}
|
public/mul_lut.bin
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:5cecff7e22049d0083ad9ee36dcf0695222c61621bacfd5a401c7b133abe892d
|
| 3 |
+
size 131072
|
public/relu_lut.bin
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:2acb03ba7520467636273208563f8e733494748f4aa5ac2dba89d9560050da79
|
| 3 |
+
size 256
|
public/requant_lut.bin
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:173444ecfa293433329a333289983a665c481d913e9fd1c2778b55380ca4dd31
|
| 3 |
+
size 65536
|
public/safetensors.js
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// safetensors, read a piece at a time.
|
| 2 |
+
//
|
| 3 |
+
// The format is what makes this project's memory pooling honest. A file is:
|
| 4 |
+
//
|
| 5 |
+
// [u64 headerLen][JSON header][ tensor bytes, back to back ]
|
| 6 |
+
//
|
| 7 |
+
// and the header gives every tensor's exact byte range. So a device does not
|
| 8 |
+
// need the model — it needs its own layers, and it can ask the CDN for exactly
|
| 9 |
+
// those bytes. Nothing else is ever downloaded, decoded, or held.
|
| 10 |
+
//
|
| 11 |
+
// That is the difference between "we split the model across devices" and
|
| 12 |
+
// "one device downloads the model and hands out pieces". Only the first one
|
| 13 |
+
// actually lets a group run something none of them could hold.
|
| 14 |
+
(function (root) {
|
| 15 |
+
"use strict";
|
| 16 |
+
|
| 17 |
+
// ---- dtype decoding ---------------------------------------------------------
|
| 18 |
+
// Everything becomes f32, because that is what the verified units quantize
|
| 19 |
+
// from. F16 and BF16 are the common storage formats and both widen exactly —
|
| 20 |
+
// no rounding, no device-dependent behaviour, so a CPU device and a GPU
|
| 21 |
+
// device still agree bit-for-bit after loading.
|
| 22 |
+
const BYTES = { F64: 8, F32: 4, F16: 2, BF16: 2, I64: 8, I32: 4, I16: 2, I8: 1, U8: 1, BOOL: 1 };
|
| 23 |
+
|
| 24 |
+
// BF16 is the top 16 bits of an f32 — widening is a shift, exactly.
|
| 25 |
+
function bf16ToF32(u16, out) {
|
| 26 |
+
const u32 = new Uint32Array(out.buffer, out.byteOffset, out.length);
|
| 27 |
+
for (let i = 0; i < u16.length; i++) u32[i] = u16[i] << 16;
|
| 28 |
+
return out;
|
| 29 |
+
}
|
| 30 |
+
// F16 -> F32 by hand rather than via Float16Array, which is not everywhere
|
| 31 |
+
// yet. Subnormals and inf/nan are handled explicitly; every f16 has an exact
|
| 32 |
+
// f32 representation, so this is a widening, not a conversion.
|
| 33 |
+
function f16ToF32(u16, out) {
|
| 34 |
+
for (let i = 0; i < u16.length; i++) {
|
| 35 |
+
const h = u16[i], s = (h & 0x8000) >> 15, e = (h & 0x7C00) >> 10, f = h & 0x03FF;
|
| 36 |
+
let v;
|
| 37 |
+
if (e === 0) v = f === 0 ? 0 : Math.pow(2, -14) * (f / 1024);
|
| 38 |
+
else if (e === 0x1F) v = f === 0 ? Infinity : NaN;
|
| 39 |
+
else v = Math.pow(2, e - 15) * (1 + f / 1024);
|
| 40 |
+
out[i] = s ? -v : v;
|
| 41 |
+
}
|
| 42 |
+
return out;
|
| 43 |
+
}
|
| 44 |
+
|
| 45 |
+
function toF32(dtype, bytes) {
|
| 46 |
+
const n = bytes.byteLength / BYTES[dtype];
|
| 47 |
+
if (dtype === "F32") {
|
| 48 |
+
// may be unaligned inside a range response — copy rather than view
|
| 49 |
+
const out = new Float32Array(n);
|
| 50 |
+
new Uint8Array(out.buffer).set(bytes);
|
| 51 |
+
return out;
|
| 52 |
+
}
|
| 53 |
+
const out = new Float32Array(n);
|
| 54 |
+
if (dtype === "F16" || dtype === "BF16") {
|
| 55 |
+
const u16 = new Uint16Array(n);
|
| 56 |
+
new Uint8Array(u16.buffer).set(bytes);
|
| 57 |
+
return dtype === "BF16" ? bf16ToF32(u16, out) : f16ToF32(u16, out);
|
| 58 |
+
}
|
| 59 |
+
if (dtype === "F64") {
|
| 60 |
+
const f64 = new Float64Array(n);
|
| 61 |
+
new Uint8Array(f64.buffer).set(bytes);
|
| 62 |
+
for (let i = 0; i < n; i++) out[i] = f64[i];
|
| 63 |
+
return out;
|
| 64 |
+
}
|
| 65 |
+
throw new Error(`unsupported tensor dtype ${dtype} — weights must be F32, F16, BF16 or F64`);
|
| 66 |
+
}
|
| 67 |
+
|
| 68 |
+
// ---- header ----------------------------------------------------------------
|
| 69 |
+
// The first 8 bytes are a u64 length. It is read as two u32s because a JS
|
| 70 |
+
// number cannot hold a u64 — but a header that genuinely needed the high word
|
| 71 |
+
// would be gigabytes, so a nonzero high word means the file is not what it
|
| 72 |
+
// claims and is refused rather than truncated into something plausible.
|
| 73 |
+
function parseHeader(buf) {
|
| 74 |
+
const dv = new DataView(buf);
|
| 75 |
+
const lo = dv.getUint32(0, true), hi = dv.getUint32(4, true);
|
| 76 |
+
if (hi !== 0) throw new Error("safetensors header length is implausibly large — not a safetensors file");
|
| 77 |
+
if (lo <= 0 || lo > buf.byteLength - 8) throw new Error("safetensors header is truncated");
|
| 78 |
+
let json;
|
| 79 |
+
try { json = JSON.parse(new TextDecoder().decode(new Uint8Array(buf, 8, lo))); }
|
| 80 |
+
catch (e) { throw new Error("safetensors header is not valid JSON — not a safetensors file"); }
|
| 81 |
+
const dataStart = 8 + lo;
|
| 82 |
+
const tensors = new Map();
|
| 83 |
+
for (const [name, t] of Object.entries(json)) {
|
| 84 |
+
if (name === "__metadata__") continue;
|
| 85 |
+
if (!t || !t.dtype || !Array.isArray(t.shape) || !Array.isArray(t.data_offsets))
|
| 86 |
+
throw new Error(`safetensors header entry "${name}" is malformed`);
|
| 87 |
+
const [s, e] = t.data_offsets;
|
| 88 |
+
const elems = t.shape.reduce((a, b) => a * b, 1);
|
| 89 |
+
const want = elems * (BYTES[t.dtype] || 0);
|
| 90 |
+
if (BYTES[t.dtype] && e - s !== want)
|
| 91 |
+
throw new Error(`tensor "${name}": header claims ${e - s} bytes for a ${t.shape.join("x")} ${t.dtype} (expected ${want})`);
|
| 92 |
+
tensors.set(name, { name, dtype: t.dtype, shape: t.shape, elems,
|
| 93 |
+
start: dataStart + s, end: dataStart + e, bytes: e - s });
|
| 94 |
+
}
|
| 95 |
+
return { tensors, dataStart, metadata: json.__metadata__ || {} };
|
| 96 |
+
}
|
| 97 |
+
|
| 98 |
+
// How much a set of tensors will cost to hold, before fetching any of it.
|
| 99 |
+
// Reported in the UI so a device can see whether its slice fits BEFORE it
|
| 100 |
+
// spends the bandwidth finding out.
|
| 101 |
+
function f32Bytes(tensors) {
|
| 102 |
+
let n = 0;
|
| 103 |
+
for (const t of tensors) n += t.elems * 4;
|
| 104 |
+
return n;
|
| 105 |
+
}
|
| 106 |
+
|
| 107 |
+
// ---- coalescing -------------------------------------------------------------
|
| 108 |
+
// A stage's tensors are contiguous in the file far more often than not
|
| 109 |
+
// (safetensors writes them in the order they were registered, which follows
|
| 110 |
+
// layer order). Merging adjacent ranges turns ~9 requests per layer into
|
| 111 |
+
// roughly one per stage. Gaps below `slack` are downloaded and discarded
|
| 112 |
+
// because one extra request costs more than a few unused kilobytes.
|
| 113 |
+
function coalesce(tensors, slack) {
|
| 114 |
+
const gap = slack == null ? 1 << 20 : slack;
|
| 115 |
+
const sorted = [...tensors].sort((a, b) => a.start - b.start);
|
| 116 |
+
const runs = [];
|
| 117 |
+
for (const t of sorted) {
|
| 118 |
+
const last = runs[runs.length - 1];
|
| 119 |
+
if (last && t.start - last.end <= gap) { last.end = Math.max(last.end, t.end); last.tensors.push(t); }
|
| 120 |
+
else runs.push({ start: t.start, end: t.end, tensors: [t] });
|
| 121 |
+
}
|
| 122 |
+
return runs;
|
| 123 |
+
}
|
| 124 |
+
|
| 125 |
+
const api = { BYTES, parseHeader, toF32, coalesce, f32Bytes, f16ToF32, bf16ToF32 };
|
| 126 |
+
if (typeof module !== "undefined" && module.exports) module.exports = api;
|
| 127 |
+
else root.Safetensors = api;
|
| 128 |
+
})(typeof self !== "undefined" ? self : this);
|
public/shard.js
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// Model sharding — the piece DaisyChain-Train does not have.
|
| 2 |
+
//
|
| 3 |
+
// DaisyChain-Train pools COMPUTE: every node holds a full replica, so a model
|
| 4 |
+
// bigger than one machine cannot be trained. Inference is where that limit can
|
| 5 |
+
// be lifted, because a forward pass is a chain: layer l needs layer l-1's
|
| 6 |
+
// OUTPUT, never its WEIGHTS. So the layers live on different machines and the
|
| 7 |
+
// activation travels instead.
|
| 8 |
+
//
|
| 9 |
+
// And because safetensors gives every tensor an exact byte range, each device
|
| 10 |
+
// fetches only its own layers straight from the Hub. No device — not even the
|
| 11 |
+
// head — ever holds the whole model. That is what makes the pooling real
|
| 12 |
+
// rather than a redistribution of something one machine already had to load.
|
| 13 |
+
//
|
| 14 |
+
// The ring shape is forced by weight tying. When lm_head is tied to the
|
| 15 |
+
// embedding table, the largest tensor in the model is needed at BOTH ends: to
|
| 16 |
+
// embed the prompt and to produce the logits. Copying it to the last stage
|
| 17 |
+
// would hand back most of the memory just pooled, so the last stage returns
|
| 18 |
+
// its hidden state to the head, which owns the embedding once and does both
|
| 19 |
+
// ends of the pass.
|
| 20 |
+
(function (root) {
|
| 21 |
+
"use strict";
|
| 22 |
+
|
| 23 |
+
// ---- the plan ---------------------------------------------------------------
|
| 24 |
+
// Stage 0 is the HEAD: embeddings, final norm, lm_head. Every stage may also
|
| 25 |
+
// own a contiguous run of layers, apportioned by MEASURED capacity — the
|
| 26 |
+
// same self-calibrating idea as cluster.py's capacity_score, except what is
|
| 27 |
+
// balanced is layers per device rather than batch per device.
|
| 28 |
+
//
|
| 29 |
+
// Largest-remainder apportionment, so the plan is a pure function of the
|
| 30 |
+
// capacity report: every device derives the same plan from the same inputs
|
| 31 |
+
// and the plan never has to be trusted, only compared.
|
| 32 |
+
function planStages(spec, caps) {
|
| 33 |
+
const n = caps.length;
|
| 34 |
+
if (n < 1) throw new Error("no devices to plan across");
|
| 35 |
+
const total = caps.reduce((a, d) => a + Math.max(1e-6, d.capacity), 0);
|
| 36 |
+
const exact = caps.map(d => spec.layers * Math.max(1e-6, d.capacity) / total);
|
| 37 |
+
const floor = exact.map(Math.floor);
|
| 38 |
+
let left = spec.layers - floor.reduce((a, b) => a + b, 0);
|
| 39 |
+
const order = exact.map((e, i) => [e - floor[i], i]).sort((a, b) => b[0] - a[0] || a[1] - b[1]);
|
| 40 |
+
for (let i = 0; i < order.length && left > 0; i++, left--) floor[order[i][1]]++;
|
| 41 |
+
const stages = [];
|
| 42 |
+
let lo = 0;
|
| 43 |
+
for (let i = 0; i < n; i++) {
|
| 44 |
+
const hi = lo + floor[i];
|
| 45 |
+
stages.push({ id: caps[i].id, index: i, lo, hi, head: i === 0,
|
| 46 |
+
backend: caps[i].backend, capacity: caps[i].capacity });
|
| 47 |
+
lo = hi;
|
| 48 |
+
}
|
| 49 |
+
// A device with no layers is only worth a hop if it is the head, which has
|
| 50 |
+
// real work (embed + unembed). Anyone else empty is dropped.
|
| 51 |
+
return stages.filter(s => s.head || s.hi > s.lo).map((s, i) => ({ ...s, index: i }));
|
| 52 |
+
}
|
| 53 |
+
|
| 54 |
+
// What a stage will cost to hold, computed from the file's own header BEFORE
|
| 55 |
+
// any weight bytes move — so a device can see whether its slice fits before
|
| 56 |
+
// spending the bandwidth finding out.
|
| 57 |
+
function stageBytes(spec, available, st, ArchMod) {
|
| 58 |
+
const names = ArchMod.tensorsFor(spec, available, st);
|
| 59 |
+
let n = 0;
|
| 60 |
+
for (const name of names) n += available.get(name).elems * 4; // f32 in memory
|
| 61 |
+
return n;
|
| 62 |
+
}
|
| 63 |
+
|
| 64 |
+
// ---- arranging fetched tensors into what the forward pass wants -------------
|
| 65 |
+
// Two shape conventions have to be reconciled here, and getting it wrong is
|
| 66 |
+
// silent: torch.nn.Linear stores weights (out, in) while the GEMM wants
|
| 67 |
+
// (in, out), whereas GPT-2's Conv1D already stores (in, out). A wrong
|
| 68 |
+
// transpose does not throw — it produces a model that generates confident
|
| 69 |
+
// nonsense — so the layout comes from the spec and is applied once, at load.
|
| 70 |
+
function toKN(w, outDim, inDim, layout) {
|
| 71 |
+
if (layout === "in_out") return w; // already k x n
|
| 72 |
+
const out = new Float32Array(w.length);
|
| 73 |
+
for (let o = 0; o < outDim; o++)
|
| 74 |
+
for (let i = 0; i < inDim; i++) out[i * outDim + o] = w[o * inDim + i];
|
| 75 |
+
return out;
|
| 76 |
+
}
|
| 77 |
+
|
| 78 |
+
function stageWeights(spec, st, got, ArchMod) {
|
| 79 |
+
const resolve = ArchMod.resolver(new Map([...got.keys()].map(k => [k, true])));
|
| 80 |
+
const pick = (name, opt) => { const r = resolve(name, opt); return r ? got.get(r) : null; };
|
| 81 |
+
const C = spec.hidden, L = spec.weightLayout;
|
| 82 |
+
const w = { layers: [] };
|
| 83 |
+
|
| 84 |
+
if (st.head) {
|
| 85 |
+
const h = ArchMod.headTensors(spec);
|
| 86 |
+
w.emb = pick(h.emb); // (vocab, C) — used as a lookup, not a GEMM
|
| 87 |
+
w.pos = pick(h.pos, true);
|
| 88 |
+
w.nrmF = pick(h.nrmF, true);
|
| 89 |
+
w.nrmFb = pick(h.nrmFb, true);
|
| 90 |
+
// lm_head as k x n = (C, vocab). When tied, that is embᵀ.
|
| 91 |
+
const lm = pick(h.lmHead, true);
|
| 92 |
+
w.lmHead = lm ? toKN(lm, spec.vocab, C, L) : transposeEmb(w.emb, spec.vocab, C);
|
| 93 |
+
if (!w.nrmF) w.nrmF = new Float32Array(C).fill(1); // a model without a final norm weight
|
| 94 |
+
}
|
| 95 |
+
|
| 96 |
+
const qDim = spec.heads * spec.headDim, kvDim = spec.kvHeads * spec.headDim;
|
| 97 |
+
for (let l = st.lo; l < st.hi; l++) {
|
| 98 |
+
const t = ArchMod.layerTensors(spec, l);
|
| 99 |
+
const ly = { nrm1: pick(t.nrm1), nrm1b: pick(t.nrm1b, true),
|
| 100 |
+
nrm2: pick(t.nrm2), nrm2b: pick(t.nrm2b, true) };
|
| 101 |
+
if (spec.qkvFused) {
|
| 102 |
+
ly.Wqkv = toKN(pick(t.Wqkv), 3 * C, C, L);
|
| 103 |
+
ly.bqkv = pick(t.bqkv, true);
|
| 104 |
+
} else {
|
| 105 |
+
ly.Wq = toKN(pick(t.Wq), qDim, C, L); ly.bq = pick(t.bq, true);
|
| 106 |
+
ly.Wk = toKN(pick(t.Wk), kvDim, C, L); ly.bk = pick(t.bk, true);
|
| 107 |
+
ly.Wv = toKN(pick(t.Wv), kvDim, C, L); ly.bv = pick(t.bv, true);
|
| 108 |
+
}
|
| 109 |
+
ly.Wo = toKN(pick(t.Wo), C, qDim, L); ly.bo = pick(t.bo, true);
|
| 110 |
+
if (spec.gated) {
|
| 111 |
+
ly.Wgate = toKN(pick(t.Wgate), spec.inter, C, L);
|
| 112 |
+
ly.Wup = toKN(pick(t.Wup), spec.inter, C, L);
|
| 113 |
+
} else {
|
| 114 |
+
ly.Wfc = toKN(pick(t.Wfc), spec.inter, C, L); ly.bfc = pick(t.bfc, true);
|
| 115 |
+
}
|
| 116 |
+
ly.Wdown = toKN(pick(t.Wdown), C, spec.inter, L); ly.bdown = pick(t.bdown, true);
|
| 117 |
+
w.layers.push(ly);
|
| 118 |
+
}
|
| 119 |
+
return w;
|
| 120 |
+
}
|
| 121 |
+
function transposeEmb(emb, vocab, C) {
|
| 122 |
+
const out = new Float32Array(emb.length);
|
| 123 |
+
for (let v = 0; v < vocab; v++)
|
| 124 |
+
for (let c = 0; c < C; c++) out[c * vocab + v] = emb[v * C + c];
|
| 125 |
+
return out;
|
| 126 |
+
}
|
| 127 |
+
|
| 128 |
+
// ---- hashes -----------------------------------------------------------------
|
| 129 |
+
// FNV-1a over raw bytes, identical to the function DaisyChain-Web hashes
|
| 130 |
+
// replicas with, so hashes stay comparable across the projects.
|
| 131 |
+
function fnv1a(bytes) {
|
| 132 |
+
let h = 0x811c9dc5;
|
| 133 |
+
for (let i = 0; i < bytes.length; i++) { h ^= bytes[i]; h = Math.imul(h, 0x01000193); }
|
| 134 |
+
return h >>> 0;
|
| 135 |
+
}
|
| 136 |
+
function hashF32(a) { return fnv1a(new Uint8Array(a.buffer, a.byteOffset, a.byteLength)); }
|
| 137 |
+
|
| 138 |
+
// The model fingerprint every stage repeats in its status. Derived from the
|
| 139 |
+
// repo id, revision and the tensor index — NOT from the weights, because no
|
| 140 |
+
// device reads all of them. It answers "are we all running the same model?",
|
| 141 |
+
// which is the question that matters when stages hold disjoint pieces.
|
| 142 |
+
function modelFingerprint(repo, revision, tensors) {
|
| 143 |
+
const parts = [repo, revision || "main"];
|
| 144 |
+
for (const name of [...tensors.keys()].sort()) {
|
| 145 |
+
const t = tensors.get(name);
|
| 146 |
+
parts.push(`${name}:${t.dtype}:${t.shape.join("x")}:${t.start}:${t.end}`);
|
| 147 |
+
}
|
| 148 |
+
return fnv1a(new TextEncoder().encode(parts.join("|")));
|
| 149 |
+
}
|
| 150 |
+
|
| 151 |
+
const api = { planStages, stageBytes, stageWeights, toKN, transposeEmb,
|
| 152 |
+
fnv1a, hashF32, modelFingerprint };
|
| 153 |
+
if (typeof module !== "undefined" && module.exports) module.exports = api;
|
| 154 |
+
else root.Shard = api;
|
| 155 |
+
})(typeof self !== "undefined" ? self : this);
|
public/tokenizer.js
ADDED
|
Binary file (5.7 kB). View file
|
|
|
public/traincore.js
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// Shared training math — pure JS. Used as the WebGPU fallback in the browser,
|
| 2 |
+
// and unit-tested directly in Node. Model: linear regression Y = X @ W (MSE).
|
| 3 |
+
// The GEMMs here are exactly what the WebGPU compute shader replaces.
|
| 4 |
+
(function (root) {
|
| 5 |
+
"use strict";
|
| 6 |
+
|
| 7 |
+
// C(m×n) = A(m×k) @ B(k×n), all Float32Array row-major
|
| 8 |
+
function matmul(A, B, m, k, n) {
|
| 9 |
+
const C = new Float32Array(m * n);
|
| 10 |
+
for (let i = 0; i < m; i++) {
|
| 11 |
+
for (let p = 0; p < k; p++) {
|
| 12 |
+
const a = A[i * k + p];
|
| 13 |
+
if (a === 0) continue;
|
| 14 |
+
const bo = p * n, co = i * n;
|
| 15 |
+
for (let j = 0; j < n; j++) C[co + j] += a * B[bo + j];
|
| 16 |
+
}
|
| 17 |
+
}
|
| 18 |
+
return C;
|
| 19 |
+
}
|
| 20 |
+
|
| 21 |
+
function transpose(A, rows, cols) {
|
| 22 |
+
const T = new Float32Array(rows * cols);
|
| 23 |
+
for (let i = 0; i < rows; i++)
|
| 24 |
+
for (let j = 0; j < cols; j++) T[j * rows + i] = A[i * cols + j];
|
| 25 |
+
return T;
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
// Forward + loss + gradient for one shard.
|
| 29 |
+
// X: n×din, W: din×dout, y: n×dout
|
| 30 |
+
// gradW = (2/n) * Xᵀ @ (X@W - y) (din×dout)
|
| 31 |
+
// matmulFn lets the browser swap in the WebGPU GEMM (same signature as matmul).
|
| 32 |
+
function forwardLossGrad(X, y, W, n, din, dout, matmulFn) {
|
| 33 |
+
const mm = matmulFn || matmul;
|
| 34 |
+
const pred = mm(X, W, n, din, dout); // n×dout (GEMM)
|
| 35 |
+
const resid = new Float32Array(n * dout);
|
| 36 |
+
let loss = 0;
|
| 37 |
+
for (let i = 0; i < n * dout; i++) {
|
| 38 |
+
const r = pred[i] - y[i];
|
| 39 |
+
resid[i] = r; loss += r * r;
|
| 40 |
+
}
|
| 41 |
+
loss /= (n * dout);
|
| 42 |
+
const Xt = transpose(X, n, din); // din×n
|
| 43 |
+
const g = mm(Xt, resid, din, n, dout); // din×dout (GEMM)
|
| 44 |
+
const scale = 2 / n;
|
| 45 |
+
for (let i = 0; i < g.length; i++) g[i] *= scale;
|
| 46 |
+
return { pred, loss, gradW: g };
|
| 47 |
+
}
|
| 48 |
+
|
| 49 |
+
function applyGrad(W, gradAvg, lr) {
|
| 50 |
+
for (let i = 0; i < W.length; i++) W[i] -= lr * gradAvg[i];
|
| 51 |
+
}
|
| 52 |
+
|
| 53 |
+
// average a list of gradient Float32Arrays (equal weight)
|
| 54 |
+
// ORDER MATTERS: float addition is not associative, so every replica MUST
|
| 55 |
+
// pass the gradients in the same order (the leader's roster order) or their
|
| 56 |
+
// averages differ in the last bits and the weights fork. Never self-first.
|
| 57 |
+
function averageGrads(grads) {
|
| 58 |
+
const out = new Float32Array(grads[0].length);
|
| 59 |
+
for (const g of grads) for (let i = 0; i < g.length; i++) out[i] += g[i];
|
| 60 |
+
for (let i = 0; i < out.length; i++) out[i] /= grads.length;
|
| 61 |
+
return out;
|
| 62 |
+
}
|
| 63 |
+
|
| 64 |
+
// DaisyAdam — Adam with bias correction, applied to the cluster-averaged
|
| 65 |
+
// gradient. State is a pure function of the gradient sequence, so every peer
|
| 66 |
+
// that averages the same gradients keeps bit-identical moments: no optimizer
|
| 67 |
+
// state ever crosses the wire. Momentum also smooths the noisy STE gradients
|
| 68 |
+
// coming out of the verified INT8 units.
|
| 69 |
+
function makeAdam(dim, opts) {
|
| 70 |
+
const o = opts || {};
|
| 71 |
+
const lr = o.lr ?? 0.02, b1 = o.beta1 ?? 0.9, b2 = o.beta2 ?? 0.999, eps = o.eps ?? 1e-8;
|
| 72 |
+
const m = new Float32Array(dim), v = new Float32Array(dim);
|
| 73 |
+
let t = 0;
|
| 74 |
+
return {
|
| 75 |
+
name: `adam(lr=${lr})`,
|
| 76 |
+
// returns the update u; caller does W[i] -= u[i]
|
| 77 |
+
step(g) {
|
| 78 |
+
t++;
|
| 79 |
+
const c1 = 1 - Math.pow(b1, t), c2 = 1 - Math.pow(b2, t);
|
| 80 |
+
const u = new Float32Array(dim);
|
| 81 |
+
for (let i = 0; i < dim; i++) {
|
| 82 |
+
m[i] = b1 * m[i] + (1 - b1) * g[i];
|
| 83 |
+
v[i] = b2 * v[i] + (1 - b2) * g[i] * g[i];
|
| 84 |
+
u[i] = lr * (m[i] / c1) / (Math.sqrt(v[i] / c2) + eps);
|
| 85 |
+
}
|
| 86 |
+
return u;
|
| 87 |
+
},
|
| 88 |
+
// snapshot/restore the moments — this is what lets a device that joins
|
| 89 |
+
// mid-run become bit-identical with the group (weights alone are not
|
| 90 |
+
// enough; Adam's m/v/t must match too)
|
| 91 |
+
getState() { return { m: Float32Array.from(m), v: Float32Array.from(v), t }; },
|
| 92 |
+
setState(s) { m.set(s.m); v.set(s.v); t = s.t; },
|
| 93 |
+
};
|
| 94 |
+
}
|
| 95 |
+
|
| 96 |
+
const api = { matmul, transpose, forwardLossGrad, applyGrad, averageGrads, makeAdam };
|
| 97 |
+
if (typeof module !== "undefined" && module.exports) module.exports = api;
|
| 98 |
+
else root.TrainCore = api;
|
| 99 |
+
})(typeof self !== "undefined" ? self : this);
|
public/verified_core.js
ADDED
|
@@ -0,0 +1,496 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// Verified INT8 compute — the emulated GPU logic, in the browser.
|
| 2 |
+
// A layer's forward runs THROUGH the units: quantize -> LUT multiply -> requant
|
| 3 |
+
// -> optional ReLU -> dequant. Backward is a straight-through estimator (the
|
| 4 |
+
// integer path has no gradient), so ordinary float weights still learn.
|
| 5 |
+
// Same units as the Python/Docker DaisyChain; here they're lookup tables.
|
| 6 |
+
(function (root) {
|
| 7 |
+
"use strict";
|
| 8 |
+
|
| 9 |
+
let TC; // TrainCore (matmul/transpose) — resolved per environment at the end
|
| 10 |
+
|
| 11 |
+
function quantize(X) {
|
| 12 |
+
let mx = 0; for (let i = 0; i < X.length; i++) { const a = Math.abs(X[i]); if (a > mx) mx = a; }
|
| 13 |
+
const scale = Math.max(mx / 127, 1e-8);
|
| 14 |
+
const q = new Int8Array(X.length);
|
| 15 |
+
for (let i = 0; i < X.length; i++) { let v = Math.round(X[i] / scale); q[i] = v < -128 ? -128 : v > 127 ? 127 : v; }
|
| 16 |
+
return { q, scale };
|
| 17 |
+
}
|
| 18 |
+
|
| 19 |
+
// int8 matmul via the verified multiply LUT: acc(m×n) = sum_k mulLUT[Xq,Wq]
|
| 20 |
+
function lutMatmulJS(Xq, Wq, m, k, n, L) {
|
| 21 |
+
const C = new Int32Array(m * n), mul = L.mul;
|
| 22 |
+
for (let i = 0; i < m; i++) {
|
| 23 |
+
for (let p = 0; p < k; p++) {
|
| 24 |
+
const au = (Xq[i * k + p] & 0xFF) * 256, wo = p * n, co = i * n;
|
| 25 |
+
for (let j = 0; j < n; j++) C[co + j] += mul[au + (Wq[wo + j] & 0xFF)];
|
| 26 |
+
}
|
| 27 |
+
}
|
| 28 |
+
return C;
|
| 29 |
+
}
|
| 30 |
+
|
| 31 |
+
// ---- 3xINT8 fast-accurate GEMM --------------------------------------------
|
| 32 |
+
// The CUTLASS example-27 "3xTF32" scheme, ported to the verified units:
|
| 33 |
+
// split each float into a coarse int8 part plus an int8-quantized residual,
|
| 34 |
+
// run three EXACT LUT GEMMs (hi·hi, hi·lo, lo·hi), drop the negligible
|
| 35 |
+
// lo·lo, and recombine. Same big/small decomposition NVIDIA uses to recover
|
| 36 |
+
// near-fp32 accuracy from TF32 tensor cores — here it recovers ~14-bit
|
| 37 |
+
// accuracy from the 8-bit units, at 3× the unit ops. Every product still
|
| 38 |
+
// goes through the verified mul8 LUT.
|
| 39 |
+
function quantize2(X) {
|
| 40 |
+
const hi = quantize(X);
|
| 41 |
+
const r = new Float32Array(X.length);
|
| 42 |
+
for (let i = 0; i < X.length; i++) r[i] = X[i] - hi.q[i] * hi.scale;
|
| 43 |
+
const lo = quantize(r);
|
| 44 |
+
return { hi, lo };
|
| 45 |
+
}
|
| 46 |
+
function combine3(hh, hl, lh, x, w, len) {
|
| 47 |
+
const out = new Float32Array(len);
|
| 48 |
+
const shh = x.hi.scale * w.hi.scale, shl = x.hi.scale * w.lo.scale, slh = x.lo.scale * w.hi.scale;
|
| 49 |
+
for (let i = 0; i < len; i++) out[i] = hh[i] * shh + hl[i] * shl + lh[i] * slh;
|
| 50 |
+
return out;
|
| 51 |
+
}
|
| 52 |
+
function lutMatmul3JS(Xf, Wf, m, k, n, L) { // sync, CPU LUT path
|
| 53 |
+
const x = quantize2(Xf), w = quantize2(Wf);
|
| 54 |
+
return combine3(lutMatmulJS(x.hi.q, w.hi.q, m, k, n, L),
|
| 55 |
+
lutMatmulJS(x.hi.q, w.lo.q, m, k, n, L),
|
| 56 |
+
lutMatmulJS(x.lo.q, w.hi.q, m, k, n, L), x, w, m * n);
|
| 57 |
+
}
|
| 58 |
+
async function lutMatmul3(Xf, Wf, m, k, n, L, matmulInt8) { // any backend
|
| 59 |
+
const x = quantize2(Xf), w = quantize2(Wf);
|
| 60 |
+
const mm = matmulInt8 || lutMatmulJS;
|
| 61 |
+
const [hh, hl, lh] = await Promise.all([
|
| 62 |
+
mm(x.hi.q, w.hi.q, m, k, n, L),
|
| 63 |
+
mm(x.hi.q, w.lo.q, m, k, n, L),
|
| 64 |
+
mm(x.lo.q, w.hi.q, m, k, n, L),
|
| 65 |
+
]);
|
| 66 |
+
return combine3(hh, hl, lh, x, w, m * n);
|
| 67 |
+
}
|
| 68 |
+
|
| 69 |
+
// ---- block-scaled verified GEMM (CUTLASS ex. 67/81 blockwise scaling) ------
|
| 70 |
+
// Per-ROW scales for the activations and per-COLUMN scales for the weights:
|
| 71 |
+
// the integer math through the mul8 LUT is completely unchanged — only the
|
| 72 |
+
// dequant uses rs[row]·cs[col] instead of one tensor-wide product, so a single
|
| 73 |
+
// outlier no longer crushes the quantization resolution of every other
|
| 74 |
+
// row/column. One LUT pass at this granularity beats the per-tensor 3-pass.
|
| 75 |
+
function quantizeRows(X, rows, cols) {
|
| 76 |
+
const q = new Int8Array(rows * cols), s = new Float32Array(rows);
|
| 77 |
+
for (let r = 0; r < rows; r++) {
|
| 78 |
+
let mx = 0;
|
| 79 |
+
for (let c = 0; c < cols; c++) { const a = Math.abs(X[r * cols + c]); if (a > mx) mx = a; }
|
| 80 |
+
const sc = Math.max(mx / 127, 1e-8); s[r] = sc;
|
| 81 |
+
for (let c = 0; c < cols; c++) {
|
| 82 |
+
const v = Math.round(X[r * cols + c] / sc);
|
| 83 |
+
q[r * cols + c] = v < -128 ? -128 : v > 127 ? 127 : v;
|
| 84 |
+
}
|
| 85 |
+
}
|
| 86 |
+
return { q, s };
|
| 87 |
+
}
|
| 88 |
+
function quantizeCols(W, rows, cols) {
|
| 89 |
+
const q = new Int8Array(rows * cols), s = new Float32Array(cols);
|
| 90 |
+
for (let c = 0; c < cols; c++) {
|
| 91 |
+
let mx = 0;
|
| 92 |
+
for (let r = 0; r < rows; r++) { const a = Math.abs(W[r * cols + c]); if (a > mx) mx = a; }
|
| 93 |
+
s[c] = Math.max(mx / 127, 1e-8);
|
| 94 |
+
}
|
| 95 |
+
for (let r = 0; r < rows; r++)
|
| 96 |
+
for (let c = 0; c < cols; c++) {
|
| 97 |
+
const v = Math.round(W[r * cols + c] / s[c]);
|
| 98 |
+
q[r * cols + c] = v < -128 ? -128 : v > 127 ? 127 : v;
|
| 99 |
+
}
|
| 100 |
+
return { q, s };
|
| 101 |
+
}
|
| 102 |
+
|
| 103 |
+
// ---- B2B MLP chain (CUTLASS ex. 13 two-GEMM fusion + ex. 23 epilogue
|
| 104 |
+
// reduction), respecced for cross-device exactness ---------------------------
|
| 105 |
+
// The MLP is the one back-to-back GEMM pair with no layernorm/softmax between
|
| 106 |
+
// (ReLU is already fused in the epilogue), so the intermediate h1 can be
|
| 107 |
+
// quantized ON the GPU and fed straight to the second GEMM. Two rules make
|
| 108 |
+
// that fleet-safe:
|
| 109 |
+
// 1. The per-row |max| (ex. 23) uses only comparisons — exact on any
|
| 110 |
+
// hardware, order-independent — and comes back to JS as ~1KB.
|
| 111 |
+
// 2. Scale DERIVATION (two divisions) stays in JS f64, which IEEE requires
|
| 112 |
+
// to be exactly rounded and is therefore identical on every device.
|
| 113 |
+
// WGSL division is only 2.5 ULP — a fork waiting to happen — but WGSL
|
| 114 |
+
// multiply/add are correctly rounded and floor/clamp are exact. So the
|
| 115 |
+
// quantize step is respecced from round(x / scale) to
|
| 116 |
+
// floor(f32(x * invScale) + 0.5) — floor(x+0.5) IS Math.round's tie
|
| 117 |
+
// rule — and the fround-stepped mirror below is bit-identical to the
|
| 118 |
+
// GPU kernel, which is exact-gated against it at init.
|
| 119 |
+
// NOTE this changes which int8 a value on a rounding boundary lands on
|
| 120 |
+
// (≤1 step) vs quantizeRows, so old and new builds cannot co-train — the
|
| 121 |
+
// divergence guard stops such mixed groups by design.
|
| 122 |
+
function rowAbsMax(X, rows, cols) {
|
| 123 |
+
const mx = new Float32Array(rows);
|
| 124 |
+
for (let r = 0; r < rows; r++) {
|
| 125 |
+
let m = 0;
|
| 126 |
+
for (let c = 0; c < cols; c++) { const a = Math.abs(X[r * cols + c]); if (a > m) m = a; }
|
| 127 |
+
mx[r] = m;
|
| 128 |
+
}
|
| 129 |
+
return mx;
|
| 130 |
+
}
|
| 131 |
+
function scalesFromAbsMax(mx) { // f64 divisions: exactly rounded, device-identical
|
| 132 |
+
const scale = new Float32Array(mx.length), inv = new Float32Array(mx.length);
|
| 133 |
+
for (let i = 0; i < mx.length; i++) {
|
| 134 |
+
scale[i] = Math.max(mx[i] / 127, 1e-8);
|
| 135 |
+
inv[i] = 1 / scale[i]; // recip of the STORED f32 scale
|
| 136 |
+
}
|
| 137 |
+
return { scale, inv };
|
| 138 |
+
}
|
| 139 |
+
function quantizeRowsInv(X, rows, cols, inv) { // bit-exact mirror of the GPU quantize kernel
|
| 140 |
+
const q = new Int8Array(rows * cols);
|
| 141 |
+
for (let r = 0; r < rows; r++) {
|
| 142 |
+
const iv = inv[r];
|
| 143 |
+
for (let c = 0; c < cols; c++) {
|
| 144 |
+
const n = Math.floor(f32(f32(X[r * cols + c] * iv) + 0.5));
|
| 145 |
+
q[r * cols + c] = n < -128 ? -128 : n > 127 ? 127 : n;
|
| 146 |
+
}
|
| 147 |
+
}
|
| 148 |
+
return q;
|
| 149 |
+
}
|
| 150 |
+
// the chained MLP: X @ W1 -> ReLU (fused) -> absmax -> quantize -> @ W2.
|
| 151 |
+
// d = { m, k, h, n }; gpuMlp (from webgpu.js) runs both GEMMs + the
|
| 152 |
+
// on-GPU quantize with one tiny absmax readback between; without it the CPU
|
| 153 |
+
// mirror chain runs — SAME math, so mixed GPU/CPU fleets stay bit-identical.
|
| 154 |
+
async function vmlpBlock(Xf, W1f, W2f, d, L, gpuMlp, audit) {
|
| 155 |
+
const x = quantizeRows(Xf, d.m, d.k);
|
| 156 |
+
const w1 = quantizeCols(W1f, d.k, d.h);
|
| 157 |
+
const w2 = quantizeCols(W2f, d.h, d.n);
|
| 158 |
+
if (gpuMlp) {
|
| 159 |
+
const r = await gpuMlp(x.q, w1.q, w2.q, x.s, w1.s, w2.s, d);
|
| 160 |
+
if (audit && audit.due()) {
|
| 161 |
+
// audit BOTH live GEMMs: gemm1 against the units directly; gemm2 by
|
| 162 |
+
// reconstructing its exact operand through the proven quantize mirror
|
| 163 |
+
const bad1 = auditTile(x.q, w1.q, x.s, w1.s, { m: d.m, k: d.k, n: d.h, relu: true }, r.h1, L, audit.cells);
|
| 164 |
+
if (bad1) { audit.fail("mlp gemm1: " + bad1); return r; }
|
| 165 |
+
const sc = scalesFromAbsMax(rowAbsMax(r.h1, d.m, d.h));
|
| 166 |
+
const hq = quantizeRowsInv(r.h1, d.m, d.h, sc.inv);
|
| 167 |
+
const bad2 = auditTile(hq, w2.q, sc.scale, w2.s, { m: d.m, k: d.h, n: d.n }, r.out, L, audit.cells);
|
| 168 |
+
if (bad2) audit.fail("mlp gemm2: " + bad2);
|
| 169 |
+
}
|
| 170 |
+
return r;
|
| 171 |
+
}
|
| 172 |
+
const h1 = bgemmJS(x.q, w1.q, x.s, w1.s, { m: d.m, k: d.k, n: d.h, batch: 1, relu: true }, L);
|
| 173 |
+
const sc = scalesFromAbsMax(rowAbsMax(h1, d.m, d.h));
|
| 174 |
+
const hq = quantizeRowsInv(h1, d.m, d.h, sc.inv);
|
| 175 |
+
const out = bgemmJS(hq, w2.q, sc.scale, w2.s, { m: d.m, k: d.h, n: d.n, batch: 1 }, L);
|
| 176 |
+
return { h1, out };
|
| 177 |
+
}
|
| 178 |
+
|
| 179 |
+
// ---- epilogue mirror -------------------------------------------------------
|
| 180 |
+
// BIT-EXACT mirror of the WGSL epilogue `f32(s) * a * b`. WGSL rounds to f32
|
| 181 |
+
// after the int->float conversion and after EACH multiply; plain JS would do
|
| 182 |
+
// the whole chain in f64 and round once, which differs in the last ulp. That
|
| 183 |
+
// last-ulp gap is what used to force a tolerance into the kernel gates —
|
| 184 |
+
// mirroring the rounding exactly is what lets the gates compare with `!==`.
|
| 185 |
+
const f32 = Math.fround;
|
| 186 |
+
function epi(s, a, b) { return f32(f32(f32(s) * a) * b); }
|
| 187 |
+
|
| 188 |
+
// f32 equality at the BIT level: `!==` says -0 === 0, but replicas are
|
| 189 |
+
// compared by hashing raw bytes, so an audit that can't see the sign of zero
|
| 190 |
+
// could pass a device that later forks the fleet. (Real ISAs have non-IEEE
|
| 191 |
+
// modes that flush -0 to +0 — e.g. RDNA2 output modifiers / legacy muls.)
|
| 192 |
+
const _fb = new Float32Array(1), _ub = new Uint32Array(_fb.buffer);
|
| 193 |
+
function bitDiff(a, b) { _fb[0] = a; const u = _ub[0]; _fb[0] = b; return u !== _ub[0]; }
|
| 194 |
+
|
| 195 |
+
// CPU mirror of the fused GPU kernel: batched int8 GEMM through the LUT with
|
| 196 |
+
// the epilogue (block dequant + optional ReLU) applied before returning —
|
| 197 |
+
// exactly what the WGSL kernel does on-device. d.acc=true returns the raw
|
| 198 |
+
// int32 accumulator instead (the exact oracle the fused kernel normally hides).
|
| 199 |
+
function bgemmJS(Xq, Wq, rs, cs, d, L) {
|
| 200 |
+
const { m, k, n } = d, batch = d.batch || 1, relu = !!d.relu, mul = L.mul;
|
| 201 |
+
const raw = !!d.acc;
|
| 202 |
+
const out = raw ? new Int32Array(batch * m * n) : new Float32Array(batch * m * n);
|
| 203 |
+
const acc = new Int32Array(n);
|
| 204 |
+
for (let bz = 0; bz < batch; bz++) {
|
| 205 |
+
const xo = bz * m * k, wo = bz * k * n, oo = bz * m * n, co = bz * n;
|
| 206 |
+
for (let i = 0; i < m; i++) {
|
| 207 |
+
acc.fill(0);
|
| 208 |
+
const xrow = xo + i * k;
|
| 209 |
+
for (let p = 0; p < k; p++) {
|
| 210 |
+
const au = (Xq[xrow + p] & 0xFF) * 256, wrow = wo + p * n;
|
| 211 |
+
for (let j = 0; j < n; j++) acc[j] += mul[au + (Wq[wrow + j] & 0xFF)];
|
| 212 |
+
}
|
| 213 |
+
const orow = oo + i * n;
|
| 214 |
+
if (raw) { for (let j = 0; j < n; j++) out[orow + j] = acc[j]; continue; }
|
| 215 |
+
const rscale = rs[bz * m + i];
|
| 216 |
+
for (let j = 0; j < n; j++) {
|
| 217 |
+
const v = epi(acc[j], rscale, cs[co + j]);
|
| 218 |
+
out[orow + j] = relu && v < 0 ? 0 : v;
|
| 219 |
+
}
|
| 220 |
+
}
|
| 221 |
+
}
|
| 222 |
+
return out;
|
| 223 |
+
}
|
| 224 |
+
|
| 225 |
+
// Recompute a handful of RANDOM output cells of a live GEMM through the LUT
|
| 226 |
+
// mirror and compare against what the kernel produced. Sampling cells instead
|
| 227 |
+
// of whole matrices makes this cheap enough to run continuously, at the real
|
| 228 |
+
// shapes training uses — not once at boot on toy inputs.
|
| 229 |
+
// STRATIFIED sampling. Uniformly random cells are the wrong instrument for
|
| 230 |
+
// the bugs that actually occur here: a bounds-guard off-by-one or a pack-tail
|
| 231 |
+
// padding bug lives on the LAST row/column, and uniform sampling finds that
|
| 232 |
+
// with probability ~1/n per cell — at the 16512-wide logits GEMM, never. So
|
| 233 |
+
// the first cells are the structurally dangerous ones (corners, last row,
|
| 234 |
+
// last column, last batch) chosen deterministically, and the remainder are
|
| 235 |
+
// random interior cells that catch diffuse bugs. Same principle as poisoning
|
| 236 |
+
// the buffer pool: construct the dangerous case, don't wait to land on it.
|
| 237 |
+
function auditTile(Xq, Wq, rs, cs, d, got, L, nCells) {
|
| 238 |
+
const { m, k, n } = d, batch = d.batch || 1, relu = !!d.relu, mul = L.mul;
|
| 239 |
+
const N = nCells || 8;
|
| 240 |
+
const edges = [[0, m - 1, n - 1], [0, 0, n - 1], [0, m - 1, 0], [0, 0, 0],
|
| 241 |
+
[batch - 1, m - 1, n - 1], [batch - 1, 0, 0]];
|
| 242 |
+
for (let t = 0; t < N; t++) {
|
| 243 |
+
let bz, i, j;
|
| 244 |
+
if (t < edges.length) { bz = edges[t][0]; i = edges[t][1]; j = edges[t][2]; }
|
| 245 |
+
else { bz = (Math.random() * batch) | 0; i = (Math.random() * m) | 0; j = (Math.random() * n) | 0; }
|
| 246 |
+
let acc = 0;
|
| 247 |
+
const xrow = bz * m * k + i * k, wo = bz * k * n;
|
| 248 |
+
for (let p = 0; p < k; p++) acc += mul[(Xq[xrow + p] & 0xFF) * 256 + (Wq[wo + p * n + j] & 0xFF)];
|
| 249 |
+
let v = epi(acc, rs[bz * m + i], cs[bz * n + j]);
|
| 250 |
+
if (relu && v < 0) v = 0;
|
| 251 |
+
const idx = (bz * m + i) * n + j;
|
| 252 |
+
if (bitDiff(got[idx], v))
|
| 253 |
+
return `GEMM audit failed at [b${bz},${i},${j}] shape ${m}x${k}x${n}: kernel ${Object.is(got[idx], -0) ? "-0" : got[idx]} vs units ${Object.is(v, -0) ? "-0" : v}`;
|
| 254 |
+
}
|
| 255 |
+
return null;
|
| 256 |
+
}
|
| 257 |
+
|
| 258 |
+
// ---- exact mirror of the split-K f32 GEMM ----------------------------------
|
| 259 |
+
// The f32 backward GEMM was the last kernel gated by a TOLERANCE (allclose at
|
| 260 |
+
// 1e-3) — and this project's own gate mutation test shows allclose waving
|
| 261 |
+
// through real bugs. The reason was real though: split-K accumulates in a
|
| 262 |
+
// different ORDER than a naive reference, so bit-equality against the naive
|
| 263 |
+
// one is impossible. The fix is the same as the epilogue mirror: reproduce
|
| 264 |
+
// the kernel's order exactly, then compare with `!==`.
|
| 265 |
+
// partials: for z in 0..S-1, sum p in [z*ks, min(k,(z+1)*ks)) in order
|
| 266 |
+
// reduce: sum the S partials in ascending z
|
| 267 |
+
// `fma` selects the rounding schedule for `s + a*b`: WGSL PERMITS a compiler
|
| 268 |
+
// to contract that into a fused multiply-add (one rounding) instead of two.
|
| 269 |
+
// Which one the device does is a fact about the device, so the gate tries
|
| 270 |
+
// both and reports which matches rather than assuming.
|
| 271 |
+
function fgemmMirror(A, Bm, d, fma) {
|
| 272 |
+
const { m, k, n } = d, transA = !!d.transA;
|
| 273 |
+
const S = k > 4096 ? Math.min(16, Math.ceil(k / 2048)) : 1;
|
| 274 |
+
const ks = Math.ceil(k / S);
|
| 275 |
+
const out = new Float32Array(m * n);
|
| 276 |
+
for (let row = 0; row < m; row++)
|
| 277 |
+
for (let col = 0; col < n; col++) {
|
| 278 |
+
let acc = 0; // reduce pass, ascending z
|
| 279 |
+
for (let z = 0; z < S; z++) {
|
| 280 |
+
const p0 = z * ks, p1 = Math.min(k, p0 + ks);
|
| 281 |
+
let s = 0; // one partial, in order
|
| 282 |
+
for (let p = p0; p < p1; p++) {
|
| 283 |
+
const a = transA ? A[p * m + row] : A[row * k + p];
|
| 284 |
+
s = fma ? f32(s + a * Bm[p * n + col]) // single rounding
|
| 285 |
+
: f32(s + f32(a * Bm[p * n + col]));
|
| 286 |
+
}
|
| 287 |
+
acc = f32(acc + s);
|
| 288 |
+
}
|
| 289 |
+
out[row * n + col] = acc;
|
| 290 |
+
}
|
| 291 |
+
return out;
|
| 292 |
+
}
|
| 293 |
+
|
| 294 |
+
// ---- live audits for the fused attention kernels ---------------------------
|
| 295 |
+
// The attention kernels had exact INIT gates but nothing at live shapes —
|
| 296 |
+
// the exact gap the GEMM audit exists to close, left open on the kernels with
|
| 297 |
+
// the trickiest indexing (head-strided gather, scatter write-back). These
|
| 298 |
+
// recompute individual output cells from the units, stratified like
|
| 299 |
+
// auditTile: last/first token pair, last head, last channel first, then
|
| 300 |
+
// random. Cost is hd (or T) multiply-adds per cell.
|
| 301 |
+
function auditAttScores(qq, kq, qs, ks, d, got, L, nCells) {
|
| 302 |
+
const { B, T, heads, hd } = d, C = heads * hd, mul = L.mul, raw = !!d.acc;
|
| 303 |
+
const N = nCells || 8;
|
| 304 |
+
const edges = [[B - 1, heads - 1, T - 1, T - 1], [0, 0, 0, 0],
|
| 305 |
+
[0, heads - 1, T - 1, 0], [B - 1, 0, 0, T - 1]];
|
| 306 |
+
for (let t = 0; t < N; t++) {
|
| 307 |
+
let bi, h, ti, tj;
|
| 308 |
+
if (t < edges.length) { bi = edges[t][0]; h = edges[t][1]; ti = edges[t][2]; tj = edges[t][3]; }
|
| 309 |
+
else { bi = (Math.random() * B) | 0; h = (Math.random() * heads) | 0;
|
| 310 |
+
ti = (Math.random() * T) | 0; tj = (Math.random() * T) | 0; }
|
| 311 |
+
const bz = bi * heads + h;
|
| 312 |
+
const qo = (bi * T + ti) * C + h * hd, ko = (bi * T + tj) * C + h * hd;
|
| 313 |
+
let acc = 0;
|
| 314 |
+
for (let p = 0; p < hd; p++) acc += mul[(qq[qo + p] & 0xFF) * 256 + (kq[ko + p] & 0xFF)];
|
| 315 |
+
const v = raw ? acc : epi(acc, qs[(bi * T + ti) * heads + h], ks[(bi * T + tj) * heads + h]);
|
| 316 |
+
const idx = (bz * T + ti) * T + tj;
|
| 317 |
+
if (raw ? got[idx] !== v : bitDiff(got[idx], v))
|
| 318 |
+
return `att.scores audit failed at [b${bi},h${h},${ti},${tj}] B${B}T${T}H${heads}d${hd}: kernel ${got[idx]} vs units ${v}`;
|
| 319 |
+
}
|
| 320 |
+
return null;
|
| 321 |
+
}
|
| 322 |
+
function auditAttCtx(aq, vq, as, vs, d, got, L, nCells) {
|
| 323 |
+
const { B, T, heads, hd } = d, C = heads * hd, mul = L.mul, raw = !!d.acc;
|
| 324 |
+
const N = nCells || 8;
|
| 325 |
+
const edges = [[B - 1, heads - 1, T - 1, hd - 1], [0, 0, 0, 0],
|
| 326 |
+
[0, heads - 1, T - 1, 0], [B - 1, 0, 0, hd - 1]];
|
| 327 |
+
for (let t = 0; t < N; t++) {
|
| 328 |
+
let bi, h, ti, j;
|
| 329 |
+
if (t < edges.length) { bi = edges[t][0]; h = edges[t][1]; ti = edges[t][2]; j = edges[t][3]; }
|
| 330 |
+
else { bi = (Math.random() * B) | 0; h = (Math.random() * heads) | 0;
|
| 331 |
+
ti = (Math.random() * T) | 0; j = (Math.random() * hd) | 0; }
|
| 332 |
+
const bz = bi * heads + h, ao = (bz * T + ti) * T;
|
| 333 |
+
let acc = 0;
|
| 334 |
+
for (let tj = 0; tj < T; tj++)
|
| 335 |
+
acc += mul[(aq[ao + tj] & 0xFF) * 256 + (vq[(bi * T + tj) * C + h * hd + j] & 0xFF)];
|
| 336 |
+
const v = raw ? acc : epi(acc, as[bz * T + ti], vs[(bi * heads + h) * hd + j]);
|
| 337 |
+
const idx = (bi * T + ti) * C + h * hd + j;
|
| 338 |
+
if (raw ? got[idx] !== v : bitDiff(got[idx], v))
|
| 339 |
+
return `att.ctx audit failed at [b${bi},h${h},${ti},${j}] B${B}T${T}H${heads}d${hd}: kernel ${got[idx]} vs units ${v}`;
|
| 340 |
+
}
|
| 341 |
+
return null;
|
| 342 |
+
}
|
| 343 |
+
|
| 344 |
+
// block-scaled verified GEMM, float in → float out.
|
| 345 |
+
// d = { m, k, n, batch=1, relu=false }; X is (batch·m)×k, W is batch×(k×n)
|
| 346 |
+
// gpuBgemm (from webgpu.js) runs the batched kernel with the fused epilogue;
|
| 347 |
+
// without it the CPU LUT mirror runs. Every product goes through the units.
|
| 348 |
+
async function vgemmBlock(Xf, Wf, d, L, gpuBgemm, audit) {
|
| 349 |
+
const { m, k, n } = d, batch = d.batch || 1;
|
| 350 |
+
const x = quantizeRows(Xf, batch * m, k);
|
| 351 |
+
let wq, ws;
|
| 352 |
+
if (batch === 1) {
|
| 353 |
+
const w = quantizeCols(Wf, k, n); wq = w.q; ws = w.s;
|
| 354 |
+
} else {
|
| 355 |
+
wq = new Int8Array(batch * k * n); ws = new Float32Array(batch * n);
|
| 356 |
+
for (let bz = 0; bz < batch; bz++) {
|
| 357 |
+
const w = quantizeCols(Wf.subarray(bz * k * n, (bz + 1) * k * n), k, n);
|
| 358 |
+
wq.set(w.q, bz * k * n); ws.set(w.s, bz * n);
|
| 359 |
+
}
|
| 360 |
+
}
|
| 361 |
+
if (gpuBgemm) {
|
| 362 |
+
const out = await gpuBgemm(x.q, wq, x.s, ws, d);
|
| 363 |
+
// continuous re-verification at LIVE shapes: the boot gate only ever saw
|
| 364 |
+
// toy inputs, so sample a few real cells against the units as we go
|
| 365 |
+
if (audit && audit.due()) {
|
| 366 |
+
const bad = auditTile(x.q, wq, x.s, ws, d, out, L, audit.cells);
|
| 367 |
+
if (bad) audit.fail(bad);
|
| 368 |
+
}
|
| 369 |
+
return out;
|
| 370 |
+
}
|
| 371 |
+
return bgemmJS(x.q, wq, x.s, ws, d, L);
|
| 372 |
+
}
|
| 373 |
+
|
| 374 |
+
// ---- gather-fused attention through the units (CUTLASS ex. 36/52) ----------
|
| 375 |
+
// The kernels read q/k/v/ctx directly in their natural BT×C layout with
|
| 376 |
+
// head-strided indexing — no JS gather copies, no kᵀ transpose, and the
|
| 377 |
+
// context write scatters straight back into BT×C. Quantization stays
|
| 378 |
+
// block-scaled: q/k/a per (token,head) row, v per (head,channel) column.
|
| 379 |
+
// The (BT·heads)×hd row view of q/k IS the contiguous buffer, so
|
| 380 |
+
// quantizeRows(q, BT·heads, hd) gives per-(token,head) scales for free.
|
| 381 |
+
function quantizeHeadCols(v, B, T, heads, hd) { // per (batch,head,channel) column
|
| 382 |
+
const C = heads * hd;
|
| 383 |
+
const q = new Int8Array(B * T * C), s = new Float32Array(B * heads * hd);
|
| 384 |
+
for (let bi = 0; bi < B; bi++)
|
| 385 |
+
for (let h = 0; h < heads; h++)
|
| 386 |
+
for (let j = 0; j < hd; j++) {
|
| 387 |
+
let mx = 0;
|
| 388 |
+
for (let ti = 0; ti < T; ti++) {
|
| 389 |
+
const a = Math.abs(v[(bi * T + ti) * C + h * hd + j]);
|
| 390 |
+
if (a > mx) mx = a;
|
| 391 |
+
}
|
| 392 |
+
const sc = Math.max(mx / 127, 1e-8);
|
| 393 |
+
s[(bi * heads + h) * hd + j] = sc;
|
| 394 |
+
for (let ti = 0; ti < T; ti++) {
|
| 395 |
+
const idx = (bi * T + ti) * C + h * hd + j;
|
| 396 |
+
const w = Math.round(v[idx] / sc);
|
| 397 |
+
q[idx] = w < -128 ? -128 : w > 127 ? 127 : w;
|
| 398 |
+
}
|
| 399 |
+
}
|
| 400 |
+
return { q, s };
|
| 401 |
+
}
|
| 402 |
+
// scores S[bz,ti,tj] = q_row(bi,ti,h) · k_row(bi,tj,h), every product via the LUT
|
| 403 |
+
// d.acc=true returns the raw int32 accumulator (exact oracle for the kernel gate)
|
| 404 |
+
function attScoresJS(qq, kq, qs, ks, d, L) {
|
| 405 |
+
const { B, T, heads, hd } = d, C = heads * hd, mul = L.mul, raw = !!d.acc;
|
| 406 |
+
const out = raw ? new Int32Array(B * heads * T * T) : new Float32Array(B * heads * T * T);
|
| 407 |
+
for (let bi = 0; bi < B; bi++) for (let h = 0; h < heads; h++) {
|
| 408 |
+
const bz = bi * heads + h;
|
| 409 |
+
for (let ti = 0; ti < T; ti++) {
|
| 410 |
+
const qo = (bi * T + ti) * C + h * hd, rscale = qs[(bi * T + ti) * heads + h];
|
| 411 |
+
for (let tj = 0; tj < T; tj++) {
|
| 412 |
+
const ko = (bi * T + tj) * C + h * hd;
|
| 413 |
+
let acc = 0;
|
| 414 |
+
for (let p = 0; p < hd; p++) acc += mul[(qq[qo + p] & 0xFF) * 256 + (kq[ko + p] & 0xFF)];
|
| 415 |
+
out[(bz * T + ti) * T + tj] = raw ? acc : epi(acc, rscale, ks[(bi * T + tj) * heads + h]);
|
| 416 |
+
}
|
| 417 |
+
}
|
| 418 |
+
}
|
| 419 |
+
return out;
|
| 420 |
+
}
|
| 421 |
+
// ctx[(bi,ti),(h,j)] = Σ_tj a[bz,ti,tj]·v[(bi,tj),(h,j)] — scatter fused into BT×C
|
| 422 |
+
function attCtxJS(aq, vq, as, vs, d, L) {
|
| 423 |
+
const { B, T, heads, hd } = d, C = heads * hd, mul = L.mul, raw = !!d.acc;
|
| 424 |
+
const out = raw ? new Int32Array(B * T * C) : new Float32Array(B * T * C);
|
| 425 |
+
for (let bi = 0; bi < B; bi++) for (let h = 0; h < heads; h++) {
|
| 426 |
+
const bz = bi * heads + h;
|
| 427 |
+
for (let ti = 0; ti < T; ti++) {
|
| 428 |
+
const ao = (bz * T + ti) * T, rscale = as[bz * T + ti];
|
| 429 |
+
for (let j = 0; j < hd; j++) {
|
| 430 |
+
let acc = 0;
|
| 431 |
+
for (let tj = 0; tj < T; tj++)
|
| 432 |
+
acc += mul[(aq[ao + tj] & 0xFF) * 256 + (vq[(bi * T + tj) * C + h * hd + j] & 0xFF)];
|
| 433 |
+
out[(bi * T + ti) * C + h * hd + j] = raw ? acc : epi(acc, rscale, vs[(bi * heads + h) * hd + j]);
|
| 434 |
+
}
|
| 435 |
+
}
|
| 436 |
+
}
|
| 437 |
+
return out;
|
| 438 |
+
}
|
| 439 |
+
|
| 440 |
+
// one verified layer forward; returns float out (+ cache for STE backward).
|
| 441 |
+
// Every product goes through the verified INT8 multiply (mul8 LUT) with exact
|
| 442 |
+
// int32 accumulation — i.e. an emulated INT8 tensor-core GEMM — then dequant.
|
| 443 |
+
async function linearFwd(X, W, m, k, n, L, useRelu, matmulInt8) {
|
| 444 |
+
const xq = quantize(X), wq = quantize(W);
|
| 445 |
+
const acc = await (matmulInt8 || lutMatmulJS)(xq.q, wq.q, m, k, n, L); // verified multiply
|
| 446 |
+
const dq = xq.scale * wq.scale;
|
| 447 |
+
const out = new Float32Array(m * n);
|
| 448 |
+
const mask = useRelu ? new Uint8Array(m * n) : null;
|
| 449 |
+
for (let i = 0; i < m * n; i++) {
|
| 450 |
+
let v = acc[i] * dq;
|
| 451 |
+
if (useRelu) { if (v > 0) mask[i] = 1; else v = 0; }
|
| 452 |
+
out[i] = v;
|
| 453 |
+
}
|
| 454 |
+
return { out, mask };
|
| 455 |
+
}
|
| 456 |
+
|
| 457 |
+
// 2-layer MLP: X→H (relu) →dout. Forward through verified units, MSE loss.
|
| 458 |
+
async function forward(X, y, W1, W2, D, L, matmulInt8) {
|
| 459 |
+
const { n, din, h, dout } = D;
|
| 460 |
+
const l1 = await linearFwd(X, W1, n, din, h, L, true, matmulInt8);
|
| 461 |
+
const l2 = await linearFwd(l1.out, W2, n, h, dout, L, false, matmulInt8);
|
| 462 |
+
const resid = new Float32Array(n * dout); let loss = 0;
|
| 463 |
+
for (let i = 0; i < resid.length; i++) { const r = l2.out[i] - y[i]; resid[i] = r; loss += r * r; }
|
| 464 |
+
loss /= resid.length;
|
| 465 |
+
return { loss, resid, z1: l1.out, mask1: l1.mask };
|
| 466 |
+
}
|
| 467 |
+
|
| 468 |
+
// STE backward (verified matmul treated as float X@W). Returns flat [gW1, gW2].
|
| 469 |
+
function backward(X, W1, W2, fwd, D) {
|
| 470 |
+
const { n, din, h, dout } = D;
|
| 471 |
+
const { resid, z1, mask1 } = fwd;
|
| 472 |
+
const s = 2 / n;
|
| 473 |
+
const dout_ = new Float32Array(resid.length);
|
| 474 |
+
for (let i = 0; i < resid.length; i++) dout_[i] = resid[i] * s;
|
| 475 |
+
const mm = TC.matmul, tr = TC.transpose;
|
| 476 |
+
const gW2 = mm(tr(z1, n, h), dout_, h, n, dout); // z1ᵀ @ dout
|
| 477 |
+
const dz1 = mm(dout_, tr(W2, h, dout), n, dout, h); // dout @ W2ᵀ
|
| 478 |
+
for (let i = 0; i < dz1.length; i++) if (!mask1[i]) dz1[i] = 0; // relu grad
|
| 479 |
+
const gW1 = mm(tr(X, n, din), dz1, din, n, h); // Xᵀ @ dz1
|
| 480 |
+
const g = new Float32Array(gW1.length + gW2.length);
|
| 481 |
+
g.set(gW1, 0); g.set(gW2, gW1.length);
|
| 482 |
+
return g;
|
| 483 |
+
}
|
| 484 |
+
|
| 485 |
+
function splitApply(W1, W2, gAvg, lr) {
|
| 486 |
+
for (let i = 0; i < W1.length; i++) W1[i] -= lr * gAvg[i];
|
| 487 |
+
for (let j = 0; j < W2.length; j++) W2[j] -= lr * gAvg[W1.length + j];
|
| 488 |
+
}
|
| 489 |
+
|
| 490 |
+
const api = { quantize, quantize2, quantizeRows, quantizeCols, quantizeHeadCols, lutMatmulJS, lutMatmul3JS, lutMatmul3,
|
| 491 |
+
bgemmJS, vgemmBlock, auditTile, epi, attScoresJS, attCtxJS, linearFwd, forward, backward, splitApply,
|
| 492 |
+
rowAbsMax, scalesFromAbsMax, quantizeRowsInv, vmlpBlock, bitDiff,
|
| 493 |
+
auditAttScores, auditAttCtx, fgemmMirror };
|
| 494 |
+
if (typeof module !== "undefined" && module.exports) { TC = require("./traincore.js"); module.exports = api; }
|
| 495 |
+
else { TC = root.TrainCore; root.Verified = api; }
|
| 496 |
+
})(typeof self !== "undefined" ? self : this);
|
public/webgpu.js
ADDED
|
@@ -0,0 +1,996 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// WebGPU INT8 matmul via the verified multiply LUT — the emulated GPU logic
|
| 2 |
+
// running on the browser's GPU. Automatic CPU fallback (same LUT) for machines
|
| 3 |
+
// without WebGPU (e.g. old PCs via Supermium). initCompute() returns
|
| 4 |
+
// { backend, label, matmulInt8(Xq, Wq, m, k, n, L) -> Int32Array }
|
| 5 |
+
// matching Verified.lutMatmulJS, so the trainer is device-blind.
|
| 6 |
+
//
|
| 7 |
+
// High-throughput path: when the browser exposes WGSL's
|
| 8 |
+
// packed_4x8_integer_dot_product feature, we use dot4I8Packed — it compiles to
|
| 9 |
+
// the GPU's DP4A/INT8 dot-product hardware (the same units tensor-core INT8
|
| 10 |
+
// paths are built on): 4 exact int8 MACs per instruction, int32 accumulation,
|
| 11 |
+
// and 4× less memory traffic from packing. Because int8×int8→int32 is exact,
|
| 12 |
+
// it is bit-identical to the verified mul8 LUT — and we PROVE that at init by
|
| 13 |
+
// cross-checking random matmuls against the LUT before trusting it. If the
|
| 14 |
+
// hardware ever disagrees with the units, we fall back to the LUT shader.
|
| 15 |
+
(function (root) {
|
| 16 |
+
"use strict";
|
| 17 |
+
|
| 18 |
+
const WGSL_LUT = `
|
| 19 |
+
@group(0) @binding(0) var<storage, read> Xq : array<i32>; // int8 byte per elem
|
| 20 |
+
@group(0) @binding(1) var<storage, read> Wq : array<i32>;
|
| 21 |
+
@group(0) @binding(2) var<storage, read> lut : array<i32>; // 65536 signed products
|
| 22 |
+
@group(0) @binding(3) var<storage, read_write> C : array<i32>;
|
| 23 |
+
@group(0) @binding(4) var<uniform> dims : vec3<u32>; // m, k, n
|
| 24 |
+
@compute @workgroup_size(8, 8)
|
| 25 |
+
fn main(@builtin(global_invocation_id) gid : vec3<u32>) {
|
| 26 |
+
let m = dims.x; let k = dims.y; let n = dims.z;
|
| 27 |
+
let row = gid.x; let col = gid.y;
|
| 28 |
+
if (row >= m || col >= n) { return; }
|
| 29 |
+
var s : i32 = 0;
|
| 30 |
+
for (var p = 0u; p < k; p = p + 1u) {
|
| 31 |
+
let au = u32(Xq[row * k + p] & 255);
|
| 32 |
+
let bu = u32(Wq[p * n + col] & 255);
|
| 33 |
+
s = s + lut[au * 256u + bu];
|
| 34 |
+
}
|
| 35 |
+
C[row * n + col] = s;
|
| 36 |
+
}`;
|
| 37 |
+
|
| 38 |
+
// ---- B2B MLP chain kernels (CUTLASS ex. 13 + 23) ---------------------------
|
| 39 |
+
// ROWMAX: per-row |max| of a GEMM's f32 output, fused into the same command
|
| 40 |
+
// encoder (ex. 23 epilogue reduction). Non-negative f32 bit patterns order
|
| 41 |
+
// like u32, so atomicMax on bitcast(abs(v)) computes an EXACT max, in any
|
| 42 |
+
// execution order, on any hardware — nothing here can round.
|
| 43 |
+
const WGSL_ROWMAX = `
|
| 44 |
+
@group(0) @binding(0) var<storage, read> O : array<f32>;
|
| 45 |
+
@group(0) @binding(1) var<storage, read_write> MX : array<atomic<u32>>;
|
| 46 |
+
@group(0) @binding(2) var<uniform> dims : vec4<u32>; // m, n, _, _
|
| 47 |
+
@compute @workgroup_size(8, 8, 1)
|
| 48 |
+
fn main(@builtin(global_invocation_id) gid : vec3<u32>) {
|
| 49 |
+
let m = dims.x; let n = dims.y;
|
| 50 |
+
let row = gid.x; let col = gid.y;
|
| 51 |
+
if (row >= m || col >= n) { return; }
|
| 52 |
+
atomicMax(&MX[row], bitcast<u32>(abs(O[row * n + col])));
|
| 53 |
+
}`;
|
| 54 |
+
// QUANT: h1 (f32, still on the GPU) -> int8 by MULTIPLY with a JS-computed
|
| 55 |
+
// inverse scale. floor(f32(x*inv)+0.5) uses only ops WGSL guarantees exact
|
| 56 |
+
// or correctly rounded (mul, add, floor, clamp) — division is 2.5 ULP and
|
| 57 |
+
// never runs on the GPU. Bit-identical to Verified.quantizeRowsInv, and
|
| 58 |
+
// exact-gated against it at init. pack=true emits 4 bytes per u32 for the
|
| 59 |
+
// DP4A kernel; pack=false emits one i32 per element for the LUT kernel.
|
| 60 |
+
const WGSL_QUANT = (pack) => `
|
| 61 |
+
@group(0) @binding(0) var<storage, read> H : array<f32>;
|
| 62 |
+
@group(0) @binding(1) var<storage, read> inv : array<f32>; // per row
|
| 63 |
+
@group(0) @binding(2) var<storage, read_write> Q : array<${pack ? "u32" : "i32"}>;
|
| 64 |
+
@group(0) @binding(3) var<uniform> dims : vec4<u32>; // m, k, kw, _
|
| 65 |
+
@compute @workgroup_size(64)
|
| 66 |
+
fn main(@builtin(global_invocation_id) gid : vec3<u32>) {
|
| 67 |
+
let m = dims.x; let k = dims.y; let kw = dims.z;
|
| 68 |
+
let idx = gid.x;
|
| 69 |
+
${pack ? `
|
| 70 |
+
if (idx >= m * kw) { return; }
|
| 71 |
+
let row = idx / kw;
|
| 72 |
+
var acc : u32 = 0u;
|
| 73 |
+
for (var b = 0u; b < 4u; b = b + 1u) {
|
| 74 |
+
let c = (idx % kw) * 4u + b;
|
| 75 |
+
var q : i32 = 0;
|
| 76 |
+
if (c < k) {
|
| 77 |
+
let v = clamp(floor(H[row * k + c] * inv[row] + 0.5), -128.0, 127.0);
|
| 78 |
+
q = i32(v);
|
| 79 |
+
}
|
| 80 |
+
acc = acc | ((u32(q) & 255u) << (8u * b));
|
| 81 |
+
}
|
| 82 |
+
Q[idx] = acc;` : `
|
| 83 |
+
if (idx >= m * k) { return; }
|
| 84 |
+
let row = idx / k;
|
| 85 |
+
let v = clamp(floor(H[idx] * inv[row] + 0.5), -128.0, 127.0);
|
| 86 |
+
Q[idx] = i32(v);`}
|
| 87 |
+
}`;
|
| 88 |
+
|
| 89 |
+
// NOTE: the un-batched DP4A matmul that used to live here was removed. It was
|
| 90 |
+
// the only kernel with an exact gate, but the transformer stopped calling it
|
| 91 |
+
// when the block-scaled path landed — so it sat here passing its own gate
|
| 92 |
+
// while verifying nothing that ran. A gate on a kernel nobody calls is worse
|
| 93 |
+
// than no gate: it reads like coverage. The batched kernels below are the
|
| 94 |
+
// ones training uses, and they now carry that exact gate instead.
|
| 95 |
+
|
| 96 |
+
// Batched block-scaled GEMM with a FUSED EPILOGUE (CUTLASS ex. 05/24 + 12):
|
| 97 |
+
// grid z = batch index, so ALL attention heads run in ONE dispatch, and the
|
| 98 |
+
// epilogue (block dequant rs·cs + optional ReLU) happens before the data
|
| 99 |
+
// leaves the GPU — f32 out, no int32 readback, no second pass in JS.
|
| 100 |
+
// Each kernel is emitted in two variants from ONE source string: the live one
|
| 101 |
+
// (fused epilogue, f32 out) and a `verify` one that writes the raw int32
|
| 102 |
+
// accumulator instead. The indexing — the part that actually goes wrong — is
|
| 103 |
+
// textually identical, so gating the verify variant genuinely gates the live
|
| 104 |
+
// kernel, and the comparison is EXACT (int8xint8->int32 has no rounding to
|
| 105 |
+
// hide in) instead of an allclose that whole bug classes walk through.
|
| 106 |
+
const OUT_DECL = (v, b) => `@group(0) @binding(${b}) var<storage, read_write> O : array<${v ? "i32" : "f32"}>;`;
|
| 107 |
+
|
| 108 |
+
const WGSL_BG_LUT = (verify) => `
|
| 109 |
+
@group(0) @binding(0) var<storage, read> Xq : array<i32>; // int8 byte per elem
|
| 110 |
+
@group(0) @binding(1) var<storage, read> Wq : array<i32>;
|
| 111 |
+
@group(0) @binding(2) var<storage, read> lut : array<i32>;
|
| 112 |
+
@group(0) @binding(3) var<storage, read> rs : array<f32>; // per (batch,row)
|
| 113 |
+
@group(0) @binding(4) var<storage, read> cs : array<f32>; // per (batch,col)
|
| 114 |
+
${OUT_DECL(verify, 5)}
|
| 115 |
+
@group(0) @binding(6) var<uniform> dims : vec4<u32>; // m, k, n, flags(1=relu)
|
| 116 |
+
@compute @workgroup_size(8, 8, 1)
|
| 117 |
+
fn main(@builtin(global_invocation_id) gid : vec3<u32>) {
|
| 118 |
+
let m = dims.x; let k = dims.y; let n = dims.z;
|
| 119 |
+
let row = gid.x; let col = gid.y; let bz = gid.z;
|
| 120 |
+
if (row >= m || col >= n) { return; }
|
| 121 |
+
var s : i32 = 0;
|
| 122 |
+
let xo = (bz * m + row) * k;
|
| 123 |
+
let wo = bz * k * n + col;
|
| 124 |
+
for (var p = 0u; p < k; p = p + 1u) {
|
| 125 |
+
let au = u32(Xq[xo + p] & 255);
|
| 126 |
+
let bu = u32(Wq[wo + p * n] & 255);
|
| 127 |
+
s = s + lut[au * 256u + bu];
|
| 128 |
+
}
|
| 129 |
+
${verify ? `O[(bz * m + row) * n + col] = s;` : `
|
| 130 |
+
var v = f32(s) * rs[bz * m + row] * cs[bz * n + col];
|
| 131 |
+
if ((dims.w & 1u) == 1u && v < 0.0) { v = 0.0; }
|
| 132 |
+
O[(bz * m + row) * n + col] = v;`}
|
| 133 |
+
}`;
|
| 134 |
+
|
| 135 |
+
// same fused/batched kernel on the DP4A hardware path (Wᵀ packed per batch)
|
| 136 |
+
const WGSL_BG_DP4 = (verify) => `
|
| 137 |
+
@group(0) @binding(0) var<storage, read> Xp : array<u32>;
|
| 138 |
+
@group(0) @binding(1) var<storage, read> Wp : array<u32>; // per-batch Wᵀ, packed
|
| 139 |
+
@group(0) @binding(2) var<storage, read> rs : array<f32>;
|
| 140 |
+
@group(0) @binding(3) var<storage, read> cs : array<f32>;
|
| 141 |
+
${OUT_DECL(verify, 4)}
|
| 142 |
+
@group(0) @binding(5) var<uniform> dims : vec4<u32>; // m, kw, n, flags
|
| 143 |
+
@compute @workgroup_size(8, 8, 1)
|
| 144 |
+
fn main(@builtin(global_invocation_id) gid : vec3<u32>) {
|
| 145 |
+
let m = dims.x; let kw = dims.y; let n = dims.z;
|
| 146 |
+
let row = gid.x; let col = gid.y; let bz = gid.z;
|
| 147 |
+
if (row >= m || col >= n) { return; }
|
| 148 |
+
var s : i32 = 0;
|
| 149 |
+
let xo = (bz * m + row) * kw;
|
| 150 |
+
let wo = (bz * n + col) * kw;
|
| 151 |
+
for (var p = 0u; p < kw; p = p + 1u) {
|
| 152 |
+
s = s + dot4I8Packed(Xp[xo + p], Wp[wo + p]);
|
| 153 |
+
}
|
| 154 |
+
${verify ? `O[(bz * m + row) * n + col] = s;` : `
|
| 155 |
+
var v = f32(s) * rs[bz * m + row] * cs[bz * n + col];
|
| 156 |
+
if ((dims.w & 1u) == 1u && v < 0.0) { v = 0.0; }
|
| 157 |
+
O[(bz * m + row) * n + col] = v;`}
|
| 158 |
+
}`;
|
| 159 |
+
|
| 160 |
+
// Gather-fused attention (CUTLASS ex. 36/52): kernels index q/k/v directly in
|
| 161 |
+
// their BT×C layout (head-strided) — no gather copies, no kᵀ transpose — and
|
| 162 |
+
// the ctx kernel scatters straight back into BT×C. int8×int8→i32 is exact, so
|
| 163 |
+
// these are bit-identical to the LUT mirrors (proved at init before use).
|
| 164 |
+
const WGSL_ATT_SCORES = (verify) => `
|
| 165 |
+
@group(0) @binding(0) var<storage, read> Q : array<i32>; // int8 per elem, BT×C
|
| 166 |
+
@group(0) @binding(1) var<storage, read> K : array<i32>;
|
| 167 |
+
@group(0) @binding(2) var<storage, read> qs : array<f32>; // per (token,head)
|
| 168 |
+
@group(0) @binding(3) var<storage, read> ks : array<f32>;
|
| 169 |
+
${OUT_DECL(verify, 4)}
|
| 170 |
+
@group(0) @binding(5) var<uniform> dims : vec4<u32>; // T, heads, hd, _
|
| 171 |
+
@compute @workgroup_size(8, 8, 1)
|
| 172 |
+
fn main(@builtin(global_invocation_id) gid : vec3<u32>) {
|
| 173 |
+
let T = dims.x; let heads = dims.y; let hd = dims.z;
|
| 174 |
+
let ti = gid.x; let tj = gid.y; let bz = gid.z;
|
| 175 |
+
if (ti >= T || tj >= T) { return; }
|
| 176 |
+
let bi = bz / heads; let h = bz % heads;
|
| 177 |
+
let C = heads * hd;
|
| 178 |
+
let qo = (bi * T + ti) * C + h * hd;
|
| 179 |
+
let ko = (bi * T + tj) * C + h * hd;
|
| 180 |
+
var s : i32 = 0;
|
| 181 |
+
for (var p = 0u; p < hd; p = p + 1u) { s = s + Q[qo + p] * K[ko + p]; }
|
| 182 |
+
${verify ? `O[(bz * T + ti) * T + tj] = s;`
|
| 183 |
+
: `O[(bz * T + ti) * T + tj] = f32(s) * qs[(bi * T + ti) * heads + h] * ks[(bi * T + tj) * heads + h];`}
|
| 184 |
+
}`;
|
| 185 |
+
const WGSL_ATT_CTX = (verify) => `
|
| 186 |
+
@group(0) @binding(0) var<storage, read> A : array<i32>; // int8, BH×T×T
|
| 187 |
+
@group(0) @binding(1) var<storage, read> V : array<i32>; // int8, BT×C
|
| 188 |
+
@group(0) @binding(2) var<storage, read> as_ : array<f32>; // per (bz,row)
|
| 189 |
+
@group(0) @binding(3) var<storage, read> vs : array<f32>; // per (batch,head,chan)
|
| 190 |
+
${OUT_DECL(verify, 4)} // BT×C (scatter fused)
|
| 191 |
+
@group(0) @binding(5) var<uniform> dims : vec4<u32>; // T, heads, hd, _
|
| 192 |
+
@compute @workgroup_size(8, 8, 1)
|
| 193 |
+
fn main(@builtin(global_invocation_id) gid : vec3<u32>) {
|
| 194 |
+
let T = dims.x; let heads = dims.y; let hd = dims.z;
|
| 195 |
+
let ti = gid.x; let j = gid.y; let bz = gid.z;
|
| 196 |
+
if (ti >= T || j >= hd) { return; }
|
| 197 |
+
let bi = bz / heads; let h = bz % heads;
|
| 198 |
+
let C = heads * hd;
|
| 199 |
+
let ao = (bz * T + ti) * T;
|
| 200 |
+
var s : i32 = 0;
|
| 201 |
+
for (var tj = 0u; tj < T; tj = tj + 1u) { s = s + A[ao + tj] * V[(bi * T + tj) * C + h * hd + j]; }
|
| 202 |
+
${verify ? `O[(bi * T + ti) * C + h * hd + j] = s;`
|
| 203 |
+
: `O[(bi * T + ti) * C + h * hd + j] = f32(s) * as_[bz * T + ti] * vs[(bi * heads + h) * hd + j];`}
|
| 204 |
+
}`;
|
| 205 |
+
|
| 206 |
+
// Split-K f32 GEMM (CUTLASS ex. 06) for the STE BACKWARD only — the backward
|
| 207 |
+
// was always float (the integer path has no gradient); this just moves that
|
| 208 |
+
// exact float math off the JS thread. Split-K matters for dlnf: M=256, N=32,
|
| 209 |
+
// K=16512 — 8k outputs with a huge inner loop would idle the GPU, so slices
|
| 210 |
+
// of K run on separate workgroups and a second tiny pass reduces partials.
|
| 211 |
+
const WGSL_FGEMM = `
|
| 212 |
+
@group(0) @binding(0) var<storage, read> A : array<f32>;
|
| 213 |
+
@group(0) @binding(1) var<storage, read> Bm : array<f32>;
|
| 214 |
+
@group(0) @binding(2) var<storage, read_write> P : array<f32>; // S partials
|
| 215 |
+
@group(0) @binding(3) var<uniform> dims : vec4<u32>; // m, k, n, flags(bit0=transA, rest=S)
|
| 216 |
+
@compute @workgroup_size(8, 8, 1)
|
| 217 |
+
fn main(@builtin(global_invocation_id) gid : vec3<u32>) {
|
| 218 |
+
let m = dims.x; let k = dims.y; let n = dims.z;
|
| 219 |
+
let transA = (dims.w & 1u) == 1u;
|
| 220 |
+
let S = dims.w >> 1u;
|
| 221 |
+
let row = gid.x; let col = gid.y; let z = gid.z;
|
| 222 |
+
if (row >= m || col >= n) { return; }
|
| 223 |
+
let ks = (k + S - 1u) / S;
|
| 224 |
+
let p0 = z * ks;
|
| 225 |
+
let p1 = min(k, p0 + ks);
|
| 226 |
+
var s : f32 = 0.0;
|
| 227 |
+
for (var p = p0; p < p1; p = p + 1u) {
|
| 228 |
+
let a = select(A[row * k + p], A[p * m + row], transA);
|
| 229 |
+
s = s + a * Bm[p * n + col];
|
| 230 |
+
}
|
| 231 |
+
P[(z * m + row) * n + col] = s;
|
| 232 |
+
}`;
|
| 233 |
+
const WGSL_FREDUCE = `
|
| 234 |
+
@group(0) @binding(0) var<storage, read> P : array<f32>;
|
| 235 |
+
@group(0) @binding(1) var<storage, read_write> O : array<f32>;
|
| 236 |
+
@group(0) @binding(2) var<uniform> dims : vec4<u32>; // mn, S, _, _
|
| 237 |
+
@compute @workgroup_size(64)
|
| 238 |
+
fn main(@builtin(global_invocation_id) gid : vec3<u32>) {
|
| 239 |
+
let mn = dims.x; let S = dims.y;
|
| 240 |
+
let i = gid.x;
|
| 241 |
+
if (i >= mn) { return; }
|
| 242 |
+
var s : f32 = 0.0;
|
| 243 |
+
for (var z = 0u; z < S; z = z + 1u) { s = s + P[z * mn + i]; }
|
| 244 |
+
O[i] = s;
|
| 245 |
+
}`;
|
| 246 |
+
|
| 247 |
+
async function loadLUTs(base) {
|
| 248 |
+
base = base || "";
|
| 249 |
+
const [mulB, reqB, reluB, meta] = await Promise.all([
|
| 250 |
+
fetch(base + "mul_lut.bin").then(r => r.arrayBuffer()),
|
| 251 |
+
fetch(base + "requant_lut.bin").then(r => r.arrayBuffer()),
|
| 252 |
+
fetch(base + "relu_lut.bin").then(r => r.arrayBuffer()),
|
| 253 |
+
fetch(base + "luts_meta.json").then(r => r.json()),
|
| 254 |
+
]);
|
| 255 |
+
return { mul: new Int16Array(mulB), requant: new Int8Array(reqB),
|
| 256 |
+
relu: new Int8Array(reluB), shift: meta.shift };
|
| 257 |
+
}
|
| 258 |
+
|
| 259 |
+
// pack a row-major int8 matrix (rows×cols) into u32 words of 4 bytes along
|
| 260 |
+
// cols, zero-padded to kw words per row (zeros contribute 0 to the dot)
|
| 261 |
+
function packRows(Q, rows, cols, kw) {
|
| 262 |
+
const out = new Uint32Array(rows * kw);
|
| 263 |
+
const bytes = new Uint8Array(out.buffer);
|
| 264 |
+
for (let r = 0; r < rows; r++)
|
| 265 |
+
for (let c = 0; c < cols; c++) bytes[(r * kw * 4) + c] = Q[r * cols + c] & 0xFF;
|
| 266 |
+
return out;
|
| 267 |
+
}
|
| 268 |
+
function transposeI8(Q, rows, cols) {
|
| 269 |
+
const out = new Int8Array(rows * cols);
|
| 270 |
+
for (let r = 0; r < rows; r++) for (let c = 0; c < cols; c++) out[c * rows + r] = Q[r * cols + c];
|
| 271 |
+
return out;
|
| 272 |
+
}
|
| 273 |
+
|
| 274 |
+
// f32 gate equality AT THE BIT LEVEL. JS `!==` says -0 === 0, but the fleet
|
| 275 |
+
// compares devices by hashing raw bit patterns (FNV over the byte buffer), so
|
| 276 |
+
// a kernel that flushes -0 to +0 — real hardware has instructions that do
|
| 277 |
+
// exactly this (RDNA2 output modifiers and DX9-legacy multiplies are
|
| 278 |
+
// documented as "not IEEE compatible: -0 is flushed to +0") — would pass a
|
| 279 |
+
// `!==` gate and still fork the weights at the sync guard. The gates must
|
| 280 |
+
// compare the same thing the hash sees: the bits.
|
| 281 |
+
const _fb = new Float32Array(1), _ub = new Uint32Array(_fb.buffer);
|
| 282 |
+
function bitDiff(a, b) { _fb[0] = a; const u = _ub[0]; _fb[0] = b; return u !== _ub[0]; }
|
| 283 |
+
|
| 284 |
+
async function initCompute(L) {
|
| 285 |
+
const cpu = { backend: "cpu", label: "CPU (JS)",
|
| 286 |
+
matmulInt8: (Xq, Wq, m, k, n, LL) => root.Verified.lutMatmulJS(Xq, Wq, m, k, n, LL) };
|
| 287 |
+
if (!(root.navigator && navigator.gpu)) return cpu;
|
| 288 |
+
try {
|
| 289 |
+
const adapter = await navigator.gpu.requestAdapter();
|
| 290 |
+
if (!adapter) return cpu;
|
| 291 |
+
const device = await adapter.requestDevice();
|
| 292 |
+
const info = adapter.info || {};
|
| 293 |
+
const gpuName = info.description || info.vendor || "WebGPU";
|
| 294 |
+
|
| 295 |
+
// LUT pipeline (always built — the fallback and the verification oracle)
|
| 296 |
+
const lutModule = device.createShaderModule({ code: WGSL_LUT });
|
| 297 |
+
const lutPipe = device.createComputePipeline({ layout: "auto", compute: { module: lutModule, entryPoint: "main" } });
|
| 298 |
+
const lut32 = new Int32Array(L.mul); // widen int16 -> int32
|
| 299 |
+
const lutBuf = device.createBuffer({ size: lut32.byteLength, usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST });
|
| 300 |
+
device.queue.writeBuffer(lutBuf, 0, lut32);
|
| 301 |
+
const mkPipe = (code) => device.createComputePipeline({ layout: "auto",
|
| 302 |
+
compute: { module: device.createShaderModule({ code }), entryPoint: "main" } });
|
| 303 |
+
// The verify variant doesn't reference the scale buffers, so `layout:auto`
|
| 304 |
+
// would strip those bindings and the bind group would silently mismatch.
|
| 305 |
+
// An EXPLICIT layout keeps both variants binding-compatible — which is the
|
| 306 |
+
// point: they must differ only in the final write, nothing else.
|
| 307 |
+
const mkLayout = (spec) => device.createBindGroupLayout({
|
| 308 |
+
entries: spec.map((t, i) => ({ binding: i, visibility: GPUShaderStage.COMPUTE,
|
| 309 |
+
buffer: { type: t === "u" ? "uniform" : t === "rw" ? "storage" : "read-only-storage" } })) });
|
| 310 |
+
const mkPipeL = (code, layout) => device.createComputePipeline({
|
| 311 |
+
layout: device.createPipelineLayout({ bindGroupLayouts: [layout] }),
|
| 312 |
+
compute: { module: device.createShaderModule({ code }), entryPoint: "main" } });
|
| 313 |
+
const bgLutLayout = mkLayout(["r", "r", "r", "r", "r", "rw", "u"]);
|
| 314 |
+
const bgDp4Layout = mkLayout(["r", "r", "r", "r", "rw", "u"]);
|
| 315 |
+
const attLayout = mkLayout(["r", "r", "r", "r", "rw", "u"]);
|
| 316 |
+
const rowmaxLayout = mkLayout(["r", "rw", "u"]);
|
| 317 |
+
const quantLayout = mkLayout(["r", "r", "rw", "u"]);
|
| 318 |
+
const rowmaxPipe = mkPipeL(WGSL_ROWMAX, rowmaxLayout);
|
| 319 |
+
const quantI32Pipe = mkPipeL(WGSL_QUANT(false), quantLayout);
|
| 320 |
+
const quantPackPipe = mkPipeL(WGSL_QUANT(true), quantLayout);
|
| 321 |
+
// live + verify variants, compiled from the same source (see WGSL_* above)
|
| 322 |
+
const bgLutPipe = mkPipeL(WGSL_BG_LUT(false), bgLutLayout), bgLutVPipe = mkPipeL(WGSL_BG_LUT(true), bgLutLayout);
|
| 323 |
+
const scoresPipe = mkPipeL(WGSL_ATT_SCORES(false), attLayout), scoresVPipe = mkPipeL(WGSL_ATT_SCORES(true), attLayout);
|
| 324 |
+
const ctxPipe = mkPipeL(WGSL_ATT_CTX(false), attLayout), ctxVPipe = mkPipeL(WGSL_ATT_CTX(true), attLayout);
|
| 325 |
+
|
| 326 |
+
// gather-fused attention kernels. The gate runs the VERIFY variant of the
|
| 327 |
+
// same source and compares the int32 accumulator with `!==` — exact, no
|
| 328 |
+
// tolerance — then checks the fused epilogue against a bit-exact JS mirror
|
| 329 |
+
// of the WGSL rounding. Swept over several shapes, incl. odd/ragged ones,
|
| 330 |
+
// because head-strided addressing is where these kernels can go wrong.
|
| 331 |
+
let att = { scores: (qq, kq, qs, ks, d) => gpuAttScores(device, d.acc ? scoresVPipe : scoresPipe, qq, kq, qs, ks, d),
|
| 332 |
+
ctx: (aq, vq, as, vs, d) => gpuAttCtx(device, d.acc ? ctxVPipe : ctxPipe, aq, vq, as, vs, d) };
|
| 333 |
+
try {
|
| 334 |
+
for (const d0 of [{ B: 2, T: 8, heads: 2, hd: 8 }, { B: 1, T: 32, heads: 2, hd: 16 },
|
| 335 |
+
{ B: 3, T: 7, heads: 3, hd: 5 }, { B: 2, T: 33, heads: 4, hd: 8 }]) {
|
| 336 |
+
const nQ = d0.B * d0.T * d0.heads * d0.hd;
|
| 337 |
+
const qq = new Int8Array(nQ), kq = new Int8Array(nQ), vq = new Int8Array(nQ);
|
| 338 |
+
for (let i = 0; i < nQ; i++) { qq[i] = (Math.random() * 256 - 128) | 0; kq[i] = (Math.random() * 256 - 128) | 0; vq[i] = (Math.random() * 256 - 128) | 0; }
|
| 339 |
+
const qs = Float32Array.from({ length: d0.B * d0.T * d0.heads }, () => Math.random() + 0.5);
|
| 340 |
+
const ks = Float32Array.from({ length: d0.B * d0.T * d0.heads }, () => Math.random() + 0.5);
|
| 341 |
+
const aq = new Int8Array(d0.B * d0.heads * d0.T * d0.T);
|
| 342 |
+
for (let i = 0; i < aq.length; i++) aq[i] = (Math.random() * 127) | 0;
|
| 343 |
+
const as = Float32Array.from({ length: d0.B * d0.heads * d0.T }, () => Math.random() + 0.5);
|
| 344 |
+
const vs = Float32Array.from({ length: d0.B * d0.heads * d0.hd }, () => Math.random() + 0.5);
|
| 345 |
+
const dv = { ...d0, acc: true };
|
| 346 |
+
const [accS, accC, hwS, hwC] = await Promise.all([
|
| 347 |
+
att.scores(qq, kq, qs, ks, dv), att.ctx(aq, vq, as, vs, dv),
|
| 348 |
+
att.scores(qq, kq, qs, ks, d0), att.ctx(aq, vq, as, vs, d0)]);
|
| 349 |
+
const refAccS = root.Verified.attScoresJS(qq, kq, qs, ks, dv, L);
|
| 350 |
+
const refAccC = root.Verified.attCtxJS(aq, vq, as, vs, dv, L);
|
| 351 |
+
for (let i = 0; i < refAccS.length; i++) if (accS[i] !== refAccS[i]) throw new Error(`scores accumulator mismatch @${i} shape ${JSON.stringify(d0)}`);
|
| 352 |
+
for (let i = 0; i < refAccC.length; i++) if (accC[i] !== refAccC[i]) throw new Error(`ctx accumulator mismatch @${i} shape ${JSON.stringify(d0)}`);
|
| 353 |
+
const refS = root.Verified.attScoresJS(qq, kq, qs, ks, d0, L);
|
| 354 |
+
const refC = root.Verified.attCtxJS(aq, vq, as, vs, d0, L);
|
| 355 |
+
for (let i = 0; i < refS.length; i++) if (bitDiff(hwS[i], refS[i])) throw new Error(`scores epilogue mismatch @${i}`);
|
| 356 |
+
for (let i = 0; i < refC.length; i++) if (bitDiff(hwC[i], refC[i])) throw new Error(`ctx epilogue mismatch @${i}`);
|
| 357 |
+
}
|
| 358 |
+
} catch (e) {
|
| 359 |
+
console.warn("fused attention kernels failed verification — using CPU LUT mirrors:", e.message);
|
| 360 |
+
att = null;
|
| 361 |
+
}
|
| 362 |
+
|
| 363 |
+
// split-K f32 GEMM for the STE backward (self-tested vs JS float matmul)
|
| 364 |
+
const fPipes = { gemm: mkPipe(WGSL_FGEMM), reduce: mkPipe(WGSL_FREDUCE) };
|
| 365 |
+
let fgemm = (A, Bm, d) => gpuFgemm(device, fPipes, A, Bm, d);
|
| 366 |
+
let fgemm2 = (A, B1, d1, B2, d2) => gpuFgemm2(device, fPipes, A, B1, d1, B2, d2);
|
| 367 |
+
let fgemmFma = false; // set by the gate below
|
| 368 |
+
try {
|
| 369 |
+
const m0 = 7, k0 = 4500, n0 = 5; // k big enough to exercise split-K
|
| 370 |
+
const A = Float32Array.from({ length: m0 * k0 }, () => Math.random() - 0.5);
|
| 371 |
+
const Bm = Float32Array.from({ length: k0 * n0 }, () => Math.random() - 0.5);
|
| 372 |
+
// EXACT gate, replacing an allclose. The mirror reproduces split-K's
|
| 373 |
+
// partition and accumulation order, so bit-equality is achievable and
|
| 374 |
+
// is the honest bar. WGSL may contract `s + a*b` into an FMA, so try
|
| 375 |
+
// both rounding schedules and record which the device implements —
|
| 376 |
+
// if NEITHER matches, the f32 backward is not bit-reproducible here
|
| 377 |
+
// and must not run, because its gradients set the weights the whole
|
| 378 |
+
// fleet is hashed against.
|
| 379 |
+
const dG = { m: m0, k: k0, n: n0 };
|
| 380 |
+
const hw = await fgemm(A, Bm, dG);
|
| 381 |
+
const mStep = root.Verified.fgemmMirror(A, Bm, dG, false);
|
| 382 |
+
const mFma = root.Verified.fgemmMirror(A, Bm, dG, true);
|
| 383 |
+
let okStep = true, okFma = true;
|
| 384 |
+
for (let i = 0; i < hw.length; i++) {
|
| 385 |
+
if (bitDiff(hw[i], mStep[i])) okStep = false;
|
| 386 |
+
if (bitDiff(hw[i], mFma[i])) okFma = false;
|
| 387 |
+
if (!okStep && !okFma) break;
|
| 388 |
+
}
|
| 389 |
+
if (!okStep && !okFma) throw new Error("fgemm matches neither rounding schedule — not bit-reproducible");
|
| 390 |
+
fgemmFma = !okStep; // remember what this device does
|
| 391 |
+
console.info(`split-K f32 GEMM is bit-exact (${okStep ? "two-rounding" : "fused"} multiply-add schedule)`);
|
| 392 |
+
// The shared-operand pair must equal the two calls it replaces, EXACTLY:
|
| 393 |
+
// same buffer, same shader, same arithmetic, so bit-level equality is
|
| 394 |
+
// the honest bar (not a tolerance). Both index patterns of A are
|
| 395 |
+
// exercised — transA on one leg, plain on the other, the live shapes.
|
| 396 |
+
const kT = 300, mT = 9, nT = 6; // A is mT x kT, used both ways
|
| 397 |
+
const A2 = Float32Array.from({ length: mT * kT }, () => Math.random() - 0.5);
|
| 398 |
+
const Bt = Float32Array.from({ length: mT * nT }, () => Math.random() - 0.5);
|
| 399 |
+
const Bn = Float32Array.from({ length: kT * nT }, () => Math.random() - 0.5);
|
| 400 |
+
const dT = { m: kT, k: mT, n: nT, transA: true }, dN = { m: mT, k: kT, n: nT };
|
| 401 |
+
const [f1, f2] = await fgemm2(A2, Bt, dT, Bn, dN);
|
| 402 |
+
const [r1, r2] = [await fgemm(A2, Bt, dT), await fgemm(A2, Bn, dN)];
|
| 403 |
+
for (let i = 0; i < r1.length; i++) if (bitDiff(f1[i], r1[i])) throw new Error("fgemm2 transA leg differs");
|
| 404 |
+
for (let i = 0; i < r2.length; i++) if (bitDiff(f2[i], r2[i])) throw new Error("fgemm2 plain leg differs");
|
| 405 |
+
} catch (e) {
|
| 406 |
+
console.warn("split-K f32 GEMM failed verification — backward stays in JS:", e.message);
|
| 407 |
+
fgemm = null; fgemm2 = null;
|
| 408 |
+
}
|
| 409 |
+
|
| 410 |
+
// Shared exact gate for a bgemm implementation. Sweeps shapes (including
|
| 411 |
+
// ragged ones and a k long enough to matter), compares the int32
|
| 412 |
+
// accumulator from the verify variant with `!==`, then compares the fused
|
| 413 |
+
// f32 epilogue against the bit-exact JS mirror. Returns null if clean.
|
| 414 |
+
async function gateBgemm(bgFn) {
|
| 415 |
+
for (const d0 of [{ m: 5, k: 9, n: 6, batch: 3, relu: true },
|
| 416 |
+
{ m: 32, k: 64, n: 32, batch: 1, relu: false },
|
| 417 |
+
{ m: 7, k: 253, n: 5, batch: 2, relu: true },
|
| 418 |
+
{ m: 1, k: 4, n: 1, batch: 1, relu: false },
|
| 419 |
+
{ m: 17, k: 33, n: 9, batch: 1, relu: true }]) {
|
| 420 |
+
const Xq = new Int8Array(d0.batch * d0.m * d0.k), Wq = new Int8Array(d0.batch * d0.k * d0.n);
|
| 421 |
+
for (let i = 0; i < Xq.length; i++) Xq[i] = (Math.random() * 256 - 128) | 0;
|
| 422 |
+
for (let i = 0; i < Wq.length; i++) Wq[i] = (Math.random() * 256 - 128) | 0;
|
| 423 |
+
const rs = Float32Array.from({ length: d0.batch * d0.m }, () => Math.random() + 0.5);
|
| 424 |
+
const cs = Float32Array.from({ length: d0.batch * d0.n }, () => Math.random() + 0.5);
|
| 425 |
+
const shape = `${d0.m}x${d0.k}x${d0.n}b${d0.batch}`;
|
| 426 |
+
const accHw = await bgFn(Xq, Wq, rs, cs, { ...d0, acc: true });
|
| 427 |
+
const accRef = root.Verified.bgemmJS(Xq, Wq, rs, cs, { ...d0, acc: true }, L);
|
| 428 |
+
for (let i = 0; i < accRef.length; i++)
|
| 429 |
+
if (accHw[i] !== accRef[i]) return `accumulator mismatch @${i} (${shape}): ${accHw[i]} vs ${accRef[i]}`;
|
| 430 |
+
const hw = await bgFn(Xq, Wq, rs, cs, d0);
|
| 431 |
+
const ref = root.Verified.bgemmJS(Xq, Wq, rs, cs, d0, L);
|
| 432 |
+
for (let i = 0; i < ref.length; i++)
|
| 433 |
+
if (bitDiff(hw[i], ref[i])) return `epilogue mismatch @${i} (${shape}): ${hw[i]} vs ${ref[i]}`;
|
| 434 |
+
}
|
| 435 |
+
return null;
|
| 436 |
+
}
|
| 437 |
+
|
| 438 |
+
// Poison the pool with large-magnitude residue at the gate's own shapes,
|
| 439 |
+
// so a re-gate runs on RECYCLED (non-zero) buffers. See the dirty-buffer
|
| 440 |
+
// gate note below the MLP gate for why this matters.
|
| 441 |
+
async function poisonBgemm(bgFn) {
|
| 442 |
+
for (const d0 of [{ m: 5, k: 9, n: 6, batch: 3, relu: true },
|
| 443 |
+
{ m: 32, k: 64, n: 32, batch: 1, relu: false },
|
| 444 |
+
{ m: 7, k: 253, n: 5, batch: 2, relu: true },
|
| 445 |
+
{ m: 1, k: 4, n: 1, batch: 1, relu: false },
|
| 446 |
+
{ m: 17, k: 33, n: 9, batch: 1, relu: true }]) {
|
| 447 |
+
const Xq = new Int8Array(d0.batch * d0.m * d0.k).fill(127);
|
| 448 |
+
const Wq = new Int8Array(d0.batch * d0.k * d0.n).fill(127);
|
| 449 |
+
const rs = new Float32Array(d0.batch * d0.m).fill(1e4);
|
| 450 |
+
const cs = new Float32Array(d0.batch * d0.n).fill(1e4);
|
| 451 |
+
await bgFn(Xq, Wq, rs, cs, d0);
|
| 452 |
+
await bgFn(Xq, Wq, rs, cs, { ...d0, acc: true });
|
| 453 |
+
}
|
| 454 |
+
}
|
| 455 |
+
const gateBgemmDirty = async (bgFn) => { await poisonBgemm(bgFn); return gateBgemm(bgFn); };
|
| 456 |
+
|
| 457 |
+
const bgLut = (Xq, Wq, rs, cs, d) => gpuBgemmLUT(device, d.acc ? bgLutVPipe : bgLutPipe, lutBuf, Xq, Wq, rs, cs, d);
|
| 458 |
+
// the LUT bgemm is the fallback AND the oracle's shader twin — gate it too
|
| 459 |
+
const lutBad = (await gateBgemm(bgLut)) || (await gateBgemmDirty(bgLut));
|
| 460 |
+
if (lutBad) { console.warn("LUT bgemm shader failed verification — CPU mirrors only:", lutBad); return cpu; }
|
| 461 |
+
|
| 462 |
+
// B2B MLP chain gate (CUTLASS ex. 13+23): run the WHOLE chain — gemm1 +
|
| 463 |
+
// ReLU + on-GPU rowmax + on-GPU quantize + gemm2 — against the pure-JS
|
| 464 |
+
// mirror chain, exact `!==` on both h1 and the final output. Sweeps
|
| 465 |
+
// ragged shapes; h not a multiple of 4 exercises the pack-tail padding.
|
| 466 |
+
const MLP_SHAPES = [{ m: 6, k: 8, h: 12, n: 5 }, { m: 5, k: 16, h: 6, n: 3 },
|
| 467 |
+
{ m: 17, k: 33, h: 10, n: 9 }, { m: 32, k: 64, h: 128, n: 32 }];
|
| 468 |
+
async function gateMlp(mlpFn, sweepOnly) {
|
| 469 |
+
for (const d0 of MLP_SHAPES) {
|
| 470 |
+
const rnd = (len) => Float32Array.from({ length: len }, () => Math.random() * 2 - 1);
|
| 471 |
+
const Xf = rnd(d0.m * d0.k), W1 = rnd(d0.k * d0.h), W2 = rnd(d0.h * d0.n);
|
| 472 |
+
const hw = await root.Verified.vmlpBlock(Xf, W1, W2, d0, L, mlpFn, null);
|
| 473 |
+
const ref = await root.Verified.vmlpBlock(Xf, W1, W2, d0, L, null, null);
|
| 474 |
+
const shape = `${d0.m}x${d0.k}x${d0.h}x${d0.n}`;
|
| 475 |
+
for (let i = 0; i < ref.h1.length; i++)
|
| 476 |
+
if (bitDiff(hw.h1[i], ref.h1[i])) return `h1 mismatch @${i} (${shape}): ${hw.h1[i]} vs ${ref.h1[i]}`;
|
| 477 |
+
for (let i = 0; i < ref.out.length; i++)
|
| 478 |
+
if (bitDiff(hw.out[i], ref.out[i])) return `out mismatch @${i} (${shape}): ${hw.out[i]} vs ${ref.out[i]}`;
|
| 479 |
+
}
|
| 480 |
+
if (sweepOnly) return null; // dirty re-gate: sweep is the point, skip the respec hunt
|
| 481 |
+
// DISCRIMINATING case: the sweep above passes vacuously if no value
|
| 482 |
+
// lands on a rounding boundary — the old round(x/scale) spec would
|
| 483 |
+
// pass it too. So hunt (in fast JS) for an input where the two specs
|
| 484 |
+
// actually disagree, then check the GPU sides with the RESPEC. This
|
| 485 |
+
// gates the gate: a pass must be something the old spec would fail.
|
| 486 |
+
const V = root.Verified;
|
| 487 |
+
const d1 = { m: 16, k: 32, h: 64, n: 16 };
|
| 488 |
+
const rnd1 = (len) => Float32Array.from({ length: len }, () => Math.random() * 4 - 2);
|
| 489 |
+
for (let t = 0; t < 800; t++) {
|
| 490 |
+
const Xf = rnd1(d1.m * d1.k), W1 = rnd1(d1.k * d1.h), W2 = rnd1(d1.h * d1.n);
|
| 491 |
+
const x = V.quantizeRows(Xf, d1.m, d1.k), w1 = V.quantizeCols(W1, d1.k, d1.h);
|
| 492 |
+
const h1 = V.bgemmJS(x.q, w1.q, x.s, w1.s, { m: d1.m, k: d1.k, n: d1.h, batch: 1, relu: true }, L);
|
| 493 |
+
const sc = V.scalesFromAbsMax(V.rowAbsMax(h1, d1.m, d1.h));
|
| 494 |
+
const qNew = V.quantizeRowsInv(h1, d1.m, d1.h, sc.inv);
|
| 495 |
+
const qOld = V.quantizeRows(h1, d1.m, d1.h).q;
|
| 496 |
+
let boundary = false;
|
| 497 |
+
for (let i = 0; i < qNew.length; i++) if (qNew[i] !== qOld[i]) { boundary = true; break; }
|
| 498 |
+
if (!boundary) continue;
|
| 499 |
+
const w2 = V.quantizeCols(W2, d1.h, d1.n);
|
| 500 |
+
const gpu = await mlpFn(x.q, w1.q, w2.q, x.s, w1.s, w2.s, d1);
|
| 501 |
+
const refNew = V.bgemmJS(qNew, w2.q, sc.scale, w2.s, { m: d1.m, k: d1.h, n: d1.n, batch: 1 }, L);
|
| 502 |
+
const refOld = V.bgemmJS(qOld, w2.q, sc.scale, w2.s, { m: d1.m, k: d1.h, n: d1.n, batch: 1 }, L);
|
| 503 |
+
let eqNew = true, eqOld = true;
|
| 504 |
+
for (let i = 0; i < refNew.length; i++) { if (bitDiff(gpu.out[i], refNew[i])) eqNew = false; if (bitDiff(gpu.out[i], refOld[i])) eqOld = false; }
|
| 505 |
+
if (!eqNew) return "discriminating boundary case: GPU chain does not match the respec mirror";
|
| 506 |
+
if (eqOld) return "discriminating boundary case: GPU chain matches the OLD quantize spec";
|
| 507 |
+
return null; // proven: respec, not merely gate-compatible
|
| 508 |
+
}
|
| 509 |
+
console.warn("B2B gate: no rounding-boundary input found in 800 trials — respec discrimination unproven this boot (sweep still exact)");
|
| 510 |
+
return null;
|
| 511 |
+
}
|
| 512 |
+
// FMA-contraction note for the quantize kernel: WGSL permits a compiler
|
| 513 |
+
// to contract `H*inv + 0.5` into a hardware FMA (e.g. RDNA2 V_FMA_F32 —
|
| 514 |
+
// ONE rounding instead of two). This CANNOT change the quantized int8:
|
| 515 |
+
// adding 0.5 is exact except at binade crossings, and there the
|
| 516 |
+
// double-rounding anomaly only moves the value within the same integer
|
| 517 |
+
// cell (the RNE tie parity resolves both schedules to the same side of
|
| 518 |
+
// the integer), so floor() sees no difference. Verified empirically in
|
| 519 |
+
// test_b2b.js: 48M draws targeted at binade edges, 1.9M last-ulp
|
| 520 |
+
// fused-vs-stepped differences, ZERO floor-visible. The `+0.5` respec is
|
| 521 |
+
// contraction-immune by construction — `round(x/scale)` was not.
|
| 522 |
+
// ---- DIRTY-BUFFER GATE ----------------------------------------------
|
| 523 |
+
// A pooled buffer is NOT zero-initialized, so any kernel that assumes
|
| 524 |
+
// zeros (the rowmax atomicMax accumulator does) is right on step one and
|
| 525 |
+
// wrong on step two. That is a STATE bug: no single call is wrong, the
|
| 526 |
+
// sequence is — the family an oracle cannot reach.
|
| 527 |
+
//
|
| 528 |
+
// MEASURED, and it corrected the assumption that motivated this code:
|
| 529 |
+
// the existing sweep ALREADY catches it. Deleting the clearBuffer and
|
| 530 |
+
// re-running showed the plain gate failing at the SECOND shape, because
|
| 531 |
+
// the sweep's own shapes recycle each other's buffers (same power-of-2
|
| 532 |
+
// bucket) and an uncleared max only grows. So the suite was never
|
| 533 |
+
// blind — it had incidental dirty coverage nobody designed.
|
| 534 |
+
//
|
| 535 |
+
// Incidental is the problem. It relies on the sweep having >= 2 shapes,
|
| 536 |
+
// on them colliding in one bucket, and on the residue exceeding the real
|
| 537 |
+
// value. Change the shape list and the coverage silently evaporates.
|
| 538 |
+
// The poison below makes it deliberate: run first at 1e4 magnitude so
|
| 539 |
+
// the residue dominates ANY value the gate can produce, release it, then
|
| 540 |
+
// sweep again. Detection stops depending on ordering luck and covers the
|
| 541 |
+
// first shape too. Cheap by design — the re-gate skips the 800-trial
|
| 542 |
+
// respec hunt, which has nothing to do with buffer state.
|
| 543 |
+
async function poisonPool(mlpFn) {
|
| 544 |
+
// same shapes as the gate => same pool buckets => the gate's next
|
| 545 |
+
// acquisition is exactly one of these poisoned buffers (free list is LIFO)
|
| 546 |
+
const big = (len) => Float32Array.from({ length: len }, () => (Math.random() * 2 - 1) * 1e4);
|
| 547 |
+
for (const d0 of MLP_SHAPES)
|
| 548 |
+
await root.Verified.vmlpBlock(big(d0.m * d0.k), big(d0.k * d0.h), big(d0.h * d0.n), d0, L, mlpFn, null);
|
| 549 |
+
}
|
| 550 |
+
async function gateMlpDirty(mlpFn) {
|
| 551 |
+
await poisonPool(mlpFn);
|
| 552 |
+
return gateMlp(mlpFn, true); // sweep only, on recycled non-zero buffers
|
| 553 |
+
}
|
| 554 |
+
|
| 555 |
+
const lutMlpEnv = { dp4: false, gemm: bgLutPipe, rowmax: rowmaxPipe, quant: quantI32Pipe, lutBuf };
|
| 556 |
+
let mlpLut = (xq, w1q, w2q, xs, w1s, w2s, d) => gpuMlpChain(device, lutMlpEnv, xq, w1q, w2q, xs, w1s, w2s, d);
|
| 557 |
+
const mlpLutBad = (await gateMlp(mlpLut)) || (await gateMlpDirty(mlpLut));
|
| 558 |
+
if (mlpLutBad) { console.warn("B2B MLP chain (LUT) failed verification — MLP stays on the CPU mirror chain:", mlpLutBad); mlpLut = null; }
|
| 559 |
+
|
| 560 |
+
const viaLUT = { backend: "webgpu", label: `${gpuName} (LUT shader · exact-gated)`,
|
| 561 |
+
matmulInt8: (Xq, Wq, m, k, n) => gpuMatmulLUT(device, lutPipe, lutBuf, Xq, Wq, m, k, n),
|
| 562 |
+
bgemm: bgLut, att, fgemm, fgemm2, mlp: mlpLut };
|
| 563 |
+
|
| 564 |
+
// DP4A pipeline — only if the WGSL feature exists AND its batched kernel
|
| 565 |
+
// reproduces the verified units exactly across the shape sweep
|
| 566 |
+
if (!(navigator.gpu.wgslLanguageFeatures && navigator.gpu.wgslLanguageFeatures.has("packed_4x8_integer_dot_product")))
|
| 567 |
+
return viaLUT;
|
| 568 |
+
const bgDp4Pipe = mkPipeL(WGSL_BG_DP4(false), bgDp4Layout), bgDp4VPipe = mkPipeL(WGSL_BG_DP4(true), bgDp4Layout);
|
| 569 |
+
const bg = (Xq, Wq, rs, cs, d) => gpuBgemmDP4(device, d.acc ? bgDp4VPipe : bgDp4Pipe, Xq, Wq, rs, cs, d);
|
| 570 |
+
const dp4Bad = (await gateBgemm(bg)) || (await gateBgemmDirty(bg));
|
| 571 |
+
if (dp4Bad) {
|
| 572 |
+
console.warn("batched DP4A disagreed with the verified units — using LUT bgemm:", dp4Bad);
|
| 573 |
+
return viaLUT;
|
| 574 |
+
}
|
| 575 |
+
const dp4MlpEnv = { dp4: true, gemm: bgDp4Pipe, rowmax: rowmaxPipe, quant: quantPackPipe };
|
| 576 |
+
let mlpDp4 = (xq, w1q, w2q, xs, w1s, w2s, d) => gpuMlpChain(device, dp4MlpEnv, xq, w1q, w2q, xs, w1s, w2s, d);
|
| 577 |
+
const mlpDp4Bad = (await gateMlp(mlpDp4)) || (await gateMlpDirty(mlpDp4));
|
| 578 |
+
if (mlpDp4Bad) { console.warn("B2B MLP chain (DP4A) failed verification — using the LUT chain:", mlpDp4Bad); mlpDp4 = mlpLut; }
|
| 579 |
+
|
| 580 |
+
// Both backends are exact-gated bit-identical, so which one runs is a
|
| 581 |
+
// free choice — and an init race that timed them was tried and REMOVED.
|
| 582 |
+
// Measured on this NVIDIA part they tie on the shipped float path (DP4A's
|
| 583 |
+
// JS packing cancels its dot-throughput win at these sizes), so the race
|
| 584 |
+
// bought nothing, cost ~40 ms of init, and made the backend vary between
|
| 585 |
+
// page loads — which silently invalidated three separate A/B benchmarks
|
| 586 |
+
// before it was noticed. A knob that changes what you are measuring is
|
| 587 |
+
// worse than a fixed choice. DP4A ships: it wins the int8-backward mode
|
| 588 |
+
// by ~12% and ties elsewhere.
|
| 589 |
+
return { backend: "webgpu", label: `${gpuName} (DP4A int8 dot · exact-gated vs units)`,
|
| 590 |
+
bgemm: bg, att, fgemm, fgemm2, mlp: mlpDp4 };
|
| 591 |
+
} catch (e) { console.warn("WebGPU init failed, CPU fallback:", e); return cpu; }
|
| 592 |
+
}
|
| 593 |
+
|
| 594 |
+
// shared dispatch/readback plumbing
|
| 595 |
+
async function runPass(device, pipeline, entries, m, n) {
|
| 596 |
+
const bytesC = m * n * 4;
|
| 597 |
+
const bufC = mk(device, bytesC, GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_SRC);
|
| 598 |
+
const bind = device.createBindGroup({ layout: pipeline.getBindGroupLayout(0),
|
| 599 |
+
entries: entries(bufC) });
|
| 600 |
+
const enc = device.createCommandEncoder();
|
| 601 |
+
const pass = enc.beginComputePass();
|
| 602 |
+
pass.setPipeline(pipeline); pass.setBindGroup(0, bind);
|
| 603 |
+
pass.dispatchWorkgroups(Math.ceil(m / 8), Math.ceil(n / 8)); pass.end();
|
| 604 |
+
const read = mk(device, bytesC, GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ);
|
| 605 |
+
enc.copyBufferToBuffer(bufC, 0, read, 0, bytesC);
|
| 606 |
+
device.queue.submit([enc.finish()]);
|
| 607 |
+
await read.mapAsync(GPUMapMode.READ);
|
| 608 |
+
const out = new Int32Array(read.getMappedRange(0, bytesC).slice(0)); // pooled: map only the logical range
|
| 609 |
+
read.unmap();
|
| 610 |
+
return { out, bufC, read };
|
| 611 |
+
}
|
| 612 |
+
|
| 613 |
+
async function gpuMatmulLUT(device, pipeline, lutBuf, Xq, Wq, m, k, n) {
|
| 614 |
+
const X32 = Int32Array.from(Xq), W32 = Int32Array.from(Wq); // byte -> i32
|
| 615 |
+
const bufX = up(device, X32, GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST);
|
| 616 |
+
const bufW = up(device, W32, GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST);
|
| 617 |
+
const bufD = up(device, new Uint32Array([m, k, n, 0]), GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST);
|
| 618 |
+
const r = await runPass(device, pipeline, (bufC) => [
|
| 619 |
+
{ binding: 0, resource: { buffer: bufX } }, { binding: 1, resource: { buffer: bufW } },
|
| 620 |
+
{ binding: 2, resource: { buffer: lutBuf } }, { binding: 3, resource: { buffer: bufC } },
|
| 621 |
+
{ binding: 4, resource: { buffer: bufD } } ], m, n);
|
| 622 |
+
release(device, [bufX, bufW, bufD, r.bufC, r.read]);
|
| 623 |
+
return r.out;
|
| 624 |
+
}
|
| 625 |
+
|
| 626 |
+
// attention kernels: int8 (widened i32) in, f32 out, strided head indexing
|
| 627 |
+
async function gpuAttScores(device, pipeline, qq, kq, qs, ks, d) {
|
| 628 |
+
const { B, T, heads } = d;
|
| 629 |
+
const bufQ = up(device, Int32Array.from(qq), GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST);
|
| 630 |
+
const bufK = up(device, Int32Array.from(kq), GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST);
|
| 631 |
+
const bufQs = up(device, qs, GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST);
|
| 632 |
+
const bufKs = up(device, ks, GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST);
|
| 633 |
+
const bufD = up(device, new Uint32Array([d.T, d.heads, d.hd, 0]), GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST);
|
| 634 |
+
const r = await runBgPass(device, pipeline, (bufO) => [
|
| 635 |
+
{ binding: 0, resource: { buffer: bufQ } }, { binding: 1, resource: { buffer: bufK } },
|
| 636 |
+
{ binding: 2, resource: { buffer: bufQs } }, { binding: 3, resource: { buffer: bufKs } },
|
| 637 |
+
{ binding: 4, resource: { buffer: bufO } }, { binding: 5, resource: { buffer: bufD } } ], T, T, B * heads, d.acc);
|
| 638 |
+
release(device, [bufQ, bufK, bufQs, bufKs, bufD, r.bufO, r.read]);
|
| 639 |
+
return r.out;
|
| 640 |
+
}
|
| 641 |
+
async function gpuAttCtx(device, pipeline, aq, vq, as, vs, d) {
|
| 642 |
+
const { B, T, heads, hd } = d;
|
| 643 |
+
const bufA = up(device, Int32Array.from(aq), GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST);
|
| 644 |
+
const bufV = up(device, Int32Array.from(vq), GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST);
|
| 645 |
+
const bufAs = up(device, as, GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST);
|
| 646 |
+
const bufVs = up(device, vs, GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST);
|
| 647 |
+
const bufD = up(device, new Uint32Array([T, heads, hd, 0]), GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST);
|
| 648 |
+
const r = await runBgPass(device, pipeline, (bufO) => [
|
| 649 |
+
{ binding: 0, resource: { buffer: bufA } }, { binding: 1, resource: { buffer: bufV } },
|
| 650 |
+
{ binding: 2, resource: { buffer: bufAs } }, { binding: 3, resource: { buffer: bufVs } },
|
| 651 |
+
{ binding: 4, resource: { buffer: bufO } }, { binding: 5, resource: { buffer: bufD } } ], T, hd, B * heads, d.acc);
|
| 652 |
+
release(device, [bufA, bufV, bufAs, bufVs, bufD, r.bufO, r.read]);
|
| 653 |
+
return r.out;
|
| 654 |
+
}
|
| 655 |
+
|
| 656 |
+
// split-K f32 GEMM (backward): partial pass + reduce pass
|
| 657 |
+
async function gpuFgemm(device, pipes, A, Bm, d) {
|
| 658 |
+
const { m, k, n } = d, transA = d.transA ? 1 : 0;
|
| 659 |
+
const S = k > 4096 ? Math.min(16, Math.ceil(k / 2048)) : 1;
|
| 660 |
+
const bufA = up(device, A, GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST);
|
| 661 |
+
const bufB = up(device, Bm, GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST);
|
| 662 |
+
const bufP = mk(device, S * m * n * 4, GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_SRC);
|
| 663 |
+
const bufD1 = up(device, new Uint32Array([m, k, n, transA | (S << 1)]), GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST);
|
| 664 |
+
const bufO = mk(device, m * n * 4, GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_SRC);
|
| 665 |
+
const bufD2 = up(device, new Uint32Array([m * n, S, 0, 0]), GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST);
|
| 666 |
+
const enc = device.createCommandEncoder();
|
| 667 |
+
let pass = enc.beginComputePass();
|
| 668 |
+
pass.setPipeline(pipes.gemm);
|
| 669 |
+
pass.setBindGroup(0, device.createBindGroup({ layout: pipes.gemm.getBindGroupLayout(0), entries: [
|
| 670 |
+
{ binding: 0, resource: { buffer: bufA } }, { binding: 1, resource: { buffer: bufB } },
|
| 671 |
+
{ binding: 2, resource: { buffer: bufP } }, { binding: 3, resource: { buffer: bufD1 } } ] }));
|
| 672 |
+
pass.dispatchWorkgroups(Math.ceil(m / 8), Math.ceil(n / 8), S); pass.end();
|
| 673 |
+
pass = enc.beginComputePass();
|
| 674 |
+
pass.setPipeline(pipes.reduce);
|
| 675 |
+
pass.setBindGroup(0, device.createBindGroup({ layout: pipes.reduce.getBindGroupLayout(0), entries: [
|
| 676 |
+
{ binding: 0, resource: { buffer: bufP } }, { binding: 1, resource: { buffer: bufO } },
|
| 677 |
+
{ binding: 2, resource: { buffer: bufD2 } } ] }));
|
| 678 |
+
pass.dispatchWorkgroups(Math.ceil(m * n / 64)); pass.end();
|
| 679 |
+
const read = mk(device, m * n * 4, GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ);
|
| 680 |
+
enc.copyBufferToBuffer(bufO, 0, read, 0, m * n * 4);
|
| 681 |
+
device.queue.submit([enc.finish()]);
|
| 682 |
+
await read.mapAsync(GPUMapMode.READ);
|
| 683 |
+
const out = new Float32Array(read.getMappedRange(0, m * n * 4).slice(0)); // pooled: logical range only
|
| 684 |
+
read.unmap();
|
| 685 |
+
release(device, [bufA, bufB, bufP, bufD1, bufO, bufD2, read]);
|
| 686 |
+
return out;
|
| 687 |
+
}
|
| 688 |
+
|
| 689 |
+
// SHARED-OPERAND fused f32 GEMM pair. The two embedding-gradient GEMMs both
|
| 690 |
+
// consume dlogits (BT x vocab). With the 16512-token vocab that operand is
|
| 691 |
+
// ~17 MB, and running them as two independent calls uploaded it TWICE and
|
| 692 |
+
// paid two submits and two map round trips — profiling put the pair at 55%
|
| 693 |
+
// of the whole step. Here A goes up ONCE, both GEMM+reduce chains ride one
|
| 694 |
+
// command encoder, one submit covers both, and the two readbacks are mapped
|
| 695 |
+
// concurrently. The shader reads A as either A[row*k+p] or A[p*m+row]
|
| 696 |
+
// (transA), so ONE flat buffer serves both index patterns — nothing about
|
| 697 |
+
// the arithmetic changes, which is why the gradients stay bit-identical.
|
| 698 |
+
async function gpuFgemm2(device, pipes, A, B1, d1, B2, d2) {
|
| 699 |
+
const SU = GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST;
|
| 700 |
+
const UU = GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST;
|
| 701 |
+
const bufA = up(device, A, SU); // the shared 17 MB operand: ONE upload
|
| 702 |
+
const enc = device.createCommandEncoder();
|
| 703 |
+
const legs = [];
|
| 704 |
+
for (const [Bm, d] of [[B1, d1], [B2, d2]]) {
|
| 705 |
+
const { m, k, n } = d, transA = d.transA ? 1 : 0;
|
| 706 |
+
const S = k > 4096 ? Math.min(16, Math.ceil(k / 2048)) : 1;
|
| 707 |
+
const bufB = up(device, Bm, SU);
|
| 708 |
+
const bufP = mk(device, S * m * n * 4, GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_SRC);
|
| 709 |
+
const bufD1 = up(device, new Uint32Array([m, k, n, transA | (S << 1)]), UU);
|
| 710 |
+
const bufO = mk(device, m * n * 4, GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_SRC);
|
| 711 |
+
const bufD2 = up(device, new Uint32Array([m * n, S, 0, 0]), UU);
|
| 712 |
+
let pass = enc.beginComputePass();
|
| 713 |
+
pass.setPipeline(pipes.gemm);
|
| 714 |
+
pass.setBindGroup(0, device.createBindGroup({ layout: pipes.gemm.getBindGroupLayout(0), entries: [
|
| 715 |
+
{ binding: 0, resource: { buffer: bufA } }, { binding: 1, resource: { buffer: bufB } },
|
| 716 |
+
{ binding: 2, resource: { buffer: bufP } }, { binding: 3, resource: { buffer: bufD1 } } ] }));
|
| 717 |
+
pass.dispatchWorkgroups(Math.ceil(m / 8), Math.ceil(n / 8), S); pass.end();
|
| 718 |
+
pass = enc.beginComputePass();
|
| 719 |
+
pass.setPipeline(pipes.reduce);
|
| 720 |
+
pass.setBindGroup(0, device.createBindGroup({ layout: pipes.reduce.getBindGroupLayout(0), entries: [
|
| 721 |
+
{ binding: 0, resource: { buffer: bufP } }, { binding: 1, resource: { buffer: bufO } },
|
| 722 |
+
{ binding: 2, resource: { buffer: bufD2 } } ] }));
|
| 723 |
+
pass.dispatchWorkgroups(Math.ceil(m * n / 64)); pass.end();
|
| 724 |
+
const bytes = m * n * 4;
|
| 725 |
+
const read = mk(device, bytes, GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ);
|
| 726 |
+
enc.copyBufferToBuffer(bufO, 0, read, 0, bytes);
|
| 727 |
+
legs.push({ read, bytes, bufs: [bufB, bufP, bufD1, bufO, bufD2] });
|
| 728 |
+
}
|
| 729 |
+
device.queue.submit([enc.finish()]); // ONE submit for both GEMMs
|
| 730 |
+
await Promise.all(legs.map((l) => l.read.mapAsync(GPUMapMode.READ)));
|
| 731 |
+
const outs = legs.map((l) => {
|
| 732 |
+
const o = new Float32Array(l.read.getMappedRange(0, l.bytes).slice(0));
|
| 733 |
+
l.read.unmap();
|
| 734 |
+
return o;
|
| 735 |
+
});
|
| 736 |
+
release(device, [bufA, ...legs.flatMap((l) => [...l.bufs, l.read])]);
|
| 737 |
+
return outs;
|
| 738 |
+
}
|
| 739 |
+
|
| 740 |
+
// fused batched dispatch: f32 out, epilogue done on-device (raw=true reads the
|
| 741 |
+
// verify variant's int32 accumulator instead)
|
| 742 |
+
async function runBgPass(device, pipeline, entries, m, n, batch, raw) {
|
| 743 |
+
const bytesO = batch * m * n * 4;
|
| 744 |
+
const bufO = mk(device, bytesO, GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_SRC);
|
| 745 |
+
const bind = device.createBindGroup({ layout: pipeline.getBindGroupLayout(0), entries: entries(bufO) });
|
| 746 |
+
const enc = device.createCommandEncoder();
|
| 747 |
+
const pass = enc.beginComputePass();
|
| 748 |
+
pass.setPipeline(pipeline); pass.setBindGroup(0, bind);
|
| 749 |
+
pass.dispatchWorkgroups(Math.ceil(m / 8), Math.ceil(n / 8), batch); pass.end();
|
| 750 |
+
const read = mk(device, bytesO, GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ);
|
| 751 |
+
enc.copyBufferToBuffer(bufO, 0, read, 0, bytesO);
|
| 752 |
+
device.queue.submit([enc.finish()]);
|
| 753 |
+
await read.mapAsync(GPUMapMode.READ);
|
| 754 |
+
const buf = read.getMappedRange(0, bytesO).slice(0); // pooled: logical range only
|
| 755 |
+
const out = raw ? new Int32Array(buf) : new Float32Array(buf);
|
| 756 |
+
read.unmap();
|
| 757 |
+
return { out, bufO, read };
|
| 758 |
+
}
|
| 759 |
+
|
| 760 |
+
async function gpuBgemmLUT(device, pipeline, lutBuf, Xq, Wq, rs, cs, d) {
|
| 761 |
+
const { m, k, n } = d, batch = d.batch || 1, flags = d.relu ? 1 : 0;
|
| 762 |
+
const bufX = up(device, Int32Array.from(Xq), GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST);
|
| 763 |
+
const bufW = up(device, Int32Array.from(Wq), GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST);
|
| 764 |
+
const bufR = up(device, rs, GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST);
|
| 765 |
+
const bufS = up(device, cs, GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST);
|
| 766 |
+
const bufD = up(device, new Uint32Array([m, k, n, flags]), GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST);
|
| 767 |
+
const r = await runBgPass(device, pipeline, (bufO) => [
|
| 768 |
+
{ binding: 0, resource: { buffer: bufX } }, { binding: 1, resource: { buffer: bufW } },
|
| 769 |
+
{ binding: 2, resource: { buffer: lutBuf } }, { binding: 3, resource: { buffer: bufR } },
|
| 770 |
+
{ binding: 4, resource: { buffer: bufS } }, { binding: 5, resource: { buffer: bufO } },
|
| 771 |
+
{ binding: 6, resource: { buffer: bufD } } ], m, n, batch, d.acc);
|
| 772 |
+
release(device, [bufX, bufW, bufR, bufS, bufD, r.bufO, r.read]);
|
| 773 |
+
return r.out;
|
| 774 |
+
}
|
| 775 |
+
|
| 776 |
+
async function gpuBgemmDP4(device, pipeline, Xq, Wq, rs, cs, d) {
|
| 777 |
+
const { m, k, n } = d, batch = d.batch || 1, flags = d.relu ? 1 : 0;
|
| 778 |
+
const kw = Math.ceil(k / 4);
|
| 779 |
+
const Xp = packRows(Xq, batch * m, k, kw); // rows are (batch·m)
|
| 780 |
+
const Wp = new Uint32Array(batch * n * kw); // per-batch Wᵀ, packed
|
| 781 |
+
for (let bz = 0; bz < batch; bz++) {
|
| 782 |
+
const wt = transposeI8(Wq.subarray(bz * k * n, (bz + 1) * k * n), k, n);
|
| 783 |
+
Wp.set(packRows(wt, n, k, kw), bz * n * kw);
|
| 784 |
+
}
|
| 785 |
+
const bufX = up(device, Xp, GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST);
|
| 786 |
+
const bufW = up(device, Wp, GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST);
|
| 787 |
+
const bufR = up(device, rs, GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST);
|
| 788 |
+
const bufS = up(device, cs, GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST);
|
| 789 |
+
const bufD = up(device, new Uint32Array([m, kw, n, flags]), GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST);
|
| 790 |
+
const r = await runBgPass(device, pipeline, (bufO) => [
|
| 791 |
+
{ binding: 0, resource: { buffer: bufX } }, { binding: 1, resource: { buffer: bufW } },
|
| 792 |
+
{ binding: 2, resource: { buffer: bufR } }, { binding: 3, resource: { buffer: bufS } },
|
| 793 |
+
{ binding: 4, resource: { buffer: bufO } }, { binding: 5, resource: { buffer: bufD } } ], m, n, batch, d.acc);
|
| 794 |
+
release(device, [bufX, bufW, bufR, bufS, bufD, r.bufO, r.read]);
|
| 795 |
+
return r.out;
|
| 796 |
+
}
|
| 797 |
+
|
| 798 |
+
// B2B MLP chain: gemm1 (ReLU fused) + rowmax in one encoder, a 4·m-byte
|
| 799 |
+
// absmax readback, then quantize + gemm2 in a second encoder. h1 comes back
|
| 800 |
+
// because the STE backward needs it, but it never goes UP again — gemm2's
|
| 801 |
+
// left operand is produced and consumed entirely on the GPU.
|
| 802 |
+
async function gpuMlpChain(device, env, xq, w1q, w2q, xs, w1s, w2s, d) {
|
| 803 |
+
const { m, k, h, n } = d, dp4 = !!env.dp4;
|
| 804 |
+
const SU = GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST;
|
| 805 |
+
let bufX, bufW1, bufW2, kw1, hw;
|
| 806 |
+
if (dp4) {
|
| 807 |
+
kw1 = Math.ceil(k / 4); hw = Math.ceil(h / 4);
|
| 808 |
+
bufX = up(device, packRows(xq, m, k, kw1), SU);
|
| 809 |
+
bufW1 = up(device, packRows(transposeI8(w1q, k, h), h, k, kw1), SU);
|
| 810 |
+
bufW2 = up(device, packRows(transposeI8(w2q, h, n), n, h, hw), SU);
|
| 811 |
+
} else {
|
| 812 |
+
kw1 = k; hw = h;
|
| 813 |
+
bufX = up(device, Int32Array.from(xq), SU);
|
| 814 |
+
bufW1 = up(device, Int32Array.from(w1q), SU);
|
| 815 |
+
bufW2 = up(device, Int32Array.from(w2q), SU);
|
| 816 |
+
}
|
| 817 |
+
const bufRs = up(device, xs, SU), bufCs1 = up(device, w1s, SU), bufCs2 = up(device, w2s, SU);
|
| 818 |
+
const bufH = mk(device, m * h * 4, GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_SRC);
|
| 819 |
+
// rowmax accumulates with atomicMax and NEEDS zeros. A fresh buffer was
|
| 820 |
+
// zero-initialized; a POOLED buffer is not — cleared in the encoder below.
|
| 821 |
+
const bufMX = mk(device, m * 4, GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_SRC | GPUBufferUsage.COPY_DST);
|
| 822 |
+
const UU = GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST;
|
| 823 |
+
const bufD1 = up(device, new Uint32Array([m, kw1, h, 1]), UU); // flags=1: fused ReLU
|
| 824 |
+
const bufDM = up(device, new Uint32Array([m, h, 0, 0]), UU);
|
| 825 |
+
const gemmBind = (bufA, bufB, bufR, bufC, bufO, bufD) => device.createBindGroup({
|
| 826 |
+
layout: env.gemm.getBindGroupLayout(0),
|
| 827 |
+
entries: (dp4 ? [bufA, bufB, bufR, bufC, bufO, bufD]
|
| 828 |
+
: [bufA, bufB, env.lutBuf, bufR, bufC, bufO, bufD])
|
| 829 |
+
.map((b, i) => ({ binding: i, resource: { buffer: b } })) });
|
| 830 |
+
const enc1 = device.createCommandEncoder();
|
| 831 |
+
enc1.clearBuffer(bufMX, 0, m * 4); // pooled buffer: zero the atomicMax accumulator
|
| 832 |
+
let pass = enc1.beginComputePass();
|
| 833 |
+
pass.setPipeline(env.gemm);
|
| 834 |
+
pass.setBindGroup(0, gemmBind(bufX, bufW1, bufRs, bufCs1, bufH, bufD1));
|
| 835 |
+
pass.dispatchWorkgroups(Math.ceil(m / 8), Math.ceil(h / 8), 1); pass.end();
|
| 836 |
+
pass = enc1.beginComputePass();
|
| 837 |
+
pass.setPipeline(env.rowmax);
|
| 838 |
+
pass.setBindGroup(0, device.createBindGroup({ layout: env.rowmax.getBindGroupLayout(0), entries: [
|
| 839 |
+
{ binding: 0, resource: { buffer: bufH } }, { binding: 1, resource: { buffer: bufMX } },
|
| 840 |
+
{ binding: 2, resource: { buffer: bufDM } } ] }));
|
| 841 |
+
pass.dispatchWorkgroups(Math.ceil(m / 8), Math.ceil(h / 8), 1); pass.end();
|
| 842 |
+
const readH = mk(device, m * h * 4, GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ);
|
| 843 |
+
const readM = mk(device, m * 4, GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ);
|
| 844 |
+
enc1.copyBufferToBuffer(bufH, 0, readH, 0, m * h * 4);
|
| 845 |
+
enc1.copyBufferToBuffer(bufMX, 0, readM, 0, m * 4);
|
| 846 |
+
device.queue.submit([enc1.finish()]);
|
| 847 |
+
await Promise.all([readH.mapAsync(GPUMapMode.READ), readM.mapAsync(GPUMapMode.READ)]);
|
| 848 |
+
const h1 = new Float32Array(readH.getMappedRange(0, m * h * 4).slice(0)); readH.unmap();
|
| 849 |
+
// the atomicMax'ed u32 bit patterns ARE the f32 |max| values
|
| 850 |
+
const mx = new Float32Array(readM.getMappedRange(0, m * 4).slice(0)); readM.unmap();
|
| 851 |
+
// scale derivation in JS f64 — exactly rounded, identical on every device
|
| 852 |
+
// (WGSL division is 2.5 ULP, which is why it never runs on the GPU)
|
| 853 |
+
const sc = root.Verified.scalesFromAbsMax(mx);
|
| 854 |
+
const bufInv = up(device, sc.inv, SU), bufHs = up(device, sc.scale, SU);
|
| 855 |
+
const bufQ = mk(device, m * hw * 4, GPUBufferUsage.STORAGE);
|
| 856 |
+
const bufDQ = up(device, new Uint32Array([m, h, hw, 0]), UU);
|
| 857 |
+
const bufD2 = up(device, new Uint32Array([m, hw, n, 0]), UU);
|
| 858 |
+
const bufO = mk(device, m * n * 4, GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_SRC);
|
| 859 |
+
const enc2 = device.createCommandEncoder();
|
| 860 |
+
pass = enc2.beginComputePass();
|
| 861 |
+
pass.setPipeline(env.quant);
|
| 862 |
+
pass.setBindGroup(0, device.createBindGroup({ layout: env.quant.getBindGroupLayout(0), entries: [
|
| 863 |
+
{ binding: 0, resource: { buffer: bufH } }, { binding: 1, resource: { buffer: bufInv } },
|
| 864 |
+
{ binding: 2, resource: { buffer: bufQ } }, { binding: 3, resource: { buffer: bufDQ } } ] }));
|
| 865 |
+
pass.dispatchWorkgroups(Math.ceil((m * (dp4 ? hw : h)) / 64)); pass.end();
|
| 866 |
+
pass = enc2.beginComputePass();
|
| 867 |
+
pass.setPipeline(env.gemm);
|
| 868 |
+
pass.setBindGroup(0, gemmBind(bufQ, bufW2, bufHs, bufCs2, bufO, bufD2));
|
| 869 |
+
pass.dispatchWorkgroups(Math.ceil(m / 8), Math.ceil(n / 8), 1); pass.end();
|
| 870 |
+
const readO = mk(device, m * n * 4, GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ);
|
| 871 |
+
enc2.copyBufferToBuffer(bufO, 0, readO, 0, m * n * 4);
|
| 872 |
+
device.queue.submit([enc2.finish()]);
|
| 873 |
+
await readO.mapAsync(GPUMapMode.READ);
|
| 874 |
+
const out = new Float32Array(readO.getMappedRange(0, m * n * 4).slice(0)); readO.unmap();
|
| 875 |
+
release(device, [bufX, bufW1, bufW2, bufRs, bufCs1, bufCs2, bufH, bufMX, bufD1, bufDM,
|
| 876 |
+
bufInv, bufHs, bufQ, bufDQ, bufD2, bufO, readH, readM, readO]);
|
| 877 |
+
return { h1, out };
|
| 878 |
+
}
|
| 879 |
+
|
| 880 |
+
// ---- buffer pool ------------------------------------------------------------
|
| 881 |
+
// Every dispatch used to create and destroy its buffers — ~19 create/destroy
|
| 882 |
+
// pairs per MLP chain call, per layer, per step. Creation cost is pure driver
|
| 883 |
+
// overhead, and at these GEMM sizes it is a large share of the step. The pool
|
| 884 |
+
// recycles buffers by (usage, size-bucket): same bytes uploaded, same regions
|
| 885 |
+
// read, ZERO math change — the kernels only ever index inside the logical
|
| 886 |
+
// dims, so the stale tail of a bucketed buffer is never read. The one
|
| 887 |
+
// exception is a buffer a kernel expects ZEROED (the rowmax accumulator):
|
| 888 |
+
// pooled buffers are NOT fresh, so that one is cleared explicitly in the
|
| 889 |
+
// encoder (see gpuMlpChain). Correctness is enforced where it always was:
|
| 890 |
+
// the exact init gates, the live audit, and the per-step probe hash all run
|
| 891 |
+
// through these pooled paths.
|
| 892 |
+
const _pools = new WeakMap(); // device -> Map(key -> free list)
|
| 893 |
+
function _poolOf(device) {
|
| 894 |
+
let p = _pools.get(device);
|
| 895 |
+
if (!p) { p = new Map(); _pools.set(device, p); }
|
| 896 |
+
return p;
|
| 897 |
+
}
|
| 898 |
+
function mk(device, size, usage) {
|
| 899 |
+
let cap = 256; while (cap < size) cap *= 2;
|
| 900 |
+
const key = usage + ":" + cap;
|
| 901 |
+
const list = _poolOf(device).get(key);
|
| 902 |
+
if (list && list.length) return list.pop();
|
| 903 |
+
const b = device.createBuffer({ size: cap, usage });
|
| 904 |
+
b._poolKey = key;
|
| 905 |
+
return b;
|
| 906 |
+
}
|
| 907 |
+
function up(device, arr, usage) {
|
| 908 |
+
const b = mk(device, Math.max(16, arr.byteLength), usage);
|
| 909 |
+
device.queue.writeBuffer(b, 0, arr);
|
| 910 |
+
return b;
|
| 911 |
+
}
|
| 912 |
+
function release(device, bufs) {
|
| 913 |
+
const p = _poolOf(device);
|
| 914 |
+
for (const b of bufs) {
|
| 915 |
+
if (!b || !b._poolKey) { if (b) b.destroy(); continue; }
|
| 916 |
+
let list = p.get(b._poolKey);
|
| 917 |
+
if (!list) { list = []; p.set(b._poolKey, list); }
|
| 918 |
+
if (list.length < 64) list.push(b); else b.destroy();
|
| 919 |
+
}
|
| 920 |
+
}
|
| 921 |
+
|
| 922 |
+
// ---- canonical kernel probe -------------------------------------------------
|
| 923 |
+
// The weight hash CANNOT catch a device whose kernel is wrong: weights only
|
| 924 |
+
// depend on the gradient bytes everyone receives, so a fleet averaging one
|
| 925 |
+
// device's bad gradient stays bit-identical and perfectly happy. This is the
|
| 926 |
+
// check that can. Every device runs the SAME seeded int8 GEMM through its own
|
| 927 |
+
// live kernel (verify variant -> raw int32 accumulator, exact on every
|
| 928 |
+
// backend — GPU DP4A, GPU LUT, CPU mirror alike) and hashes the result. Same
|
| 929 |
+
// input + correct kernels => same hash, regardless of hardware. A device that
|
| 930 |
+
// disagrees is computing different arithmetic than the rest of the fleet.
|
| 931 |
+
const PROBE = { m: 24, k: 96, n: 24, batch: 2 };
|
| 932 |
+
function probeInputs() {
|
| 933 |
+
let seed = 0x5EED; // fixed: identical on every device
|
| 934 |
+
const rnd = () => { seed = (Math.imul(seed, 1103515245) + 12345) & 0x7fffffff; return seed / 0x7fffffff; };
|
| 935 |
+
const { m, k, n, batch } = PROBE;
|
| 936 |
+
const Xq = new Int8Array(batch * m * k), Wq = new Int8Array(batch * k * n);
|
| 937 |
+
for (let i = 0; i < Xq.length; i++) Xq[i] = Math.round(rnd() * 254 - 127);
|
| 938 |
+
for (let i = 0; i < Wq.length; i++) Wq[i] = Math.round(rnd() * 254 - 127);
|
| 939 |
+
const rs = Float32Array.from({ length: batch * m }, () => 1);
|
| 940 |
+
const cs = Float32Array.from({ length: batch * n }, () => 1);
|
| 941 |
+
return { Xq, Wq, rs, cs, d: { ...PROBE, acc: true } };
|
| 942 |
+
}
|
| 943 |
+
// ---- transcendental probe ---------------------------------------------------
|
| 944 |
+
// The kernel probe covers int8 GEMM arithmetic. It does NOT cover the JS
|
| 945 |
+
// transcendentals, and ECMA-262 does not require Math.exp/log/cos/sin to be
|
| 946 |
+
// correctly rounded — it calls them "implementation-approximated", so V8,
|
| 947 |
+
// SpiderMonkey and JSC are all permitted to return different bits.
|
| 948 |
+
//
|
| 949 |
+
// One use of them can fork a fleet: weight INIT. Peers build their own
|
| 950 |
+
// starting weights from a shared seed rather than broadcasting them
|
| 951 |
+
// (Box-Muller, so Math.log/cos/sin), and one ulp of engine disagreement
|
| 952 |
+
// means peers begin from different models. The per-step uses (softmax, loss)
|
| 953 |
+
// cannot fork anything — every peer averages the same received gradient
|
| 954 |
+
// BYTES, so a differently-rounded local gradient is still a gradient the
|
| 955 |
+
// whole group then agrees on.
|
| 956 |
+
//
|
| 957 |
+
// This hashes the exact f64 bits of those four functions over a fixed grid,
|
| 958 |
+
// so a browser whose math library differs is IDENTIFIED instead of showing
|
| 959 |
+
// up later as an unexplained weight-hash mismatch. Cost: microseconds, once.
|
| 960 |
+
// Detection, not correction — pinning the algorithms would be a fleet-wide
|
| 961 |
+
// numerics change, and is only worth doing if this ever reports a mismatch.
|
| 962 |
+
function mathProbe() {
|
| 963 |
+
const buf = new ArrayBuffer(8), dv = new DataView(buf);
|
| 964 |
+
let h = 0x811c9dc5;
|
| 965 |
+
const eat = (x) => {
|
| 966 |
+
dv.setFloat64(0, x);
|
| 967 |
+
for (let i = 0; i < 8; i++) { h ^= dv.getUint8(i); h = Math.imul(h, 0x01000193); }
|
| 968 |
+
};
|
| 969 |
+
for (let i = 1; i <= 400; i++) {
|
| 970 |
+
const u = i / 401; // (0,1): the Box-Muller domain
|
| 971 |
+
eat(Math.log(u));
|
| 972 |
+
eat(Math.cos(2 * Math.PI * u));
|
| 973 |
+
eat(Math.sin(2 * Math.PI * u));
|
| 974 |
+
eat(Math.sqrt(-2 * Math.log(u))); // the exact composite randn uses
|
| 975 |
+
eat(Math.exp(-u * 12)); // the softmax domain
|
| 976 |
+
}
|
| 977 |
+
return h >>> 0;
|
| 978 |
+
}
|
| 979 |
+
|
| 980 |
+
async function kernelProbe(compute, L) {
|
| 981 |
+
const { Xq, Wq, rs, cs, d } = probeInputs();
|
| 982 |
+
const out = compute && compute.bgemm
|
| 983 |
+
? await compute.bgemm(Xq, Wq, rs, cs, d)
|
| 984 |
+
: root.Verified.bgemmJS(Xq, Wq, rs, cs, d, L);
|
| 985 |
+
let h = 0x811c9dc5; // FNV-1a over the exact int32 results
|
| 986 |
+
const b = new Uint8Array(out.buffer, out.byteOffset, out.byteLength);
|
| 987 |
+
for (let i = 0; i < b.length; i++) { h ^= b[i]; h = Math.imul(h, 0x01000193); }
|
| 988 |
+
// fold in the transcendental hash: same question ("is your arithmetic the
|
| 989 |
+
// same as mine?"), same broadcast slot, no wire-format change
|
| 990 |
+
const mh = mathProbe();
|
| 991 |
+
for (let i = 0; i < 4; i++) { h ^= (mh >>> (i * 8)) & 0xFF; h = Math.imul(h, 0x01000193); }
|
| 992 |
+
return h >>> 0;
|
| 993 |
+
}
|
| 994 |
+
|
| 995 |
+
root.Compute = { initCompute, loadLUTs, kernelProbe, mathProbe };
|
| 996 |
+
})(self);
|
public/wire.js
ADDED
|
@@ -0,0 +1,171 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// The wire protocol, as pure functions.
|
| 2 |
+
//
|
| 3 |
+
// Kept out of app.js on purpose: a codec that only exists inside a browser
|
| 4 |
+
// event handler cannot be tested, and the failures this protocol can have are
|
| 5 |
+
// precisely the ones that are invisible at runtime. A misread offset does not
|
| 6 |
+
// throw — it yields a plausible number, and a plausible number in an
|
| 7 |
+
// activation is a slightly wrong sentence nobody can attribute to anything.
|
| 8 |
+
// So the codec lives here and test_wire.js round-trips it.
|
| 9 |
+
//
|
| 10 |
+
// Sentinels continue DaisyChain-Web's numbering (it uses -2..-8), so a client
|
| 11 |
+
// pointed at the wrong server sees an unknown tag rather than a valid-looking
|
| 12 |
+
// message of the wrong kind.
|
| 13 |
+
(function (root) {
|
| 14 |
+
"use strict";
|
| 15 |
+
|
| 16 |
+
const FRAG = -5, // fragment of a large message (shared with DaisyChain-Web)
|
| 17 |
+
HELLO = -20, // capability report
|
| 18 |
+
ASSIGN = -21, // model identity + which layers you own (no weights)
|
| 19 |
+
READY = -22, // a stage has fetched its layers and is in the ring
|
| 20 |
+
ACT = -23, // hidden state moving to the next stage
|
| 21 |
+
TOKEN = -24, // a token was emitted
|
| 22 |
+
DONE = -25; // run finished
|
| 23 |
+
|
| 24 |
+
// The address of the head's return leg. Not a stage index: the head is both
|
| 25 |
+
// stage 0 and the terminus, so "next = 0" is ambiguous — outbound it means
|
| 26 |
+
// "stage 0, run your blocks", inbound it means "the lap is done". They need
|
| 27 |
+
// distinct addresses or the head re-runs its own blocks and the lap never
|
| 28 |
+
// closes. Negative, so it can never collide with a real stage index.
|
| 29 |
+
const RETURN = -1;
|
| 30 |
+
|
| 31 |
+
const enc = new TextEncoder(), dec = new TextDecoder();
|
| 32 |
+
|
| 33 |
+
function tagOf(buf) { return new Int32Array(buf, 0, 1)[0]; }
|
| 34 |
+
|
| 35 |
+
// ---- hello -----------------------------------------------------------------
|
| 36 |
+
function packHello(capacity, probeHash, backend) {
|
| 37 |
+
const nb = enc.encode(String(backend || "?").slice(0, 32));
|
| 38 |
+
const buf = new ArrayBuffer(16 + nb.length);
|
| 39 |
+
new Int32Array(buf, 0, 1)[0] = HELLO;
|
| 40 |
+
new Float32Array(buf, 4, 1)[0] = capacity;
|
| 41 |
+
new Uint32Array(buf, 8, 1)[0] = probeHash >>> 0;
|
| 42 |
+
new Int32Array(buf, 12, 1)[0] = nb.length;
|
| 43 |
+
new Uint8Array(buf, 16).set(nb);
|
| 44 |
+
return buf;
|
| 45 |
+
}
|
| 46 |
+
function unpackHello(buf) {
|
| 47 |
+
const n = new Int32Array(buf, 12, 1)[0];
|
| 48 |
+
if (n < 0 || 16 + n > buf.byteLength) throw new Error("hello: bad backend length");
|
| 49 |
+
return { capacity: new Float32Array(buf, 4, 1)[0],
|
| 50 |
+
probeHash: new Uint32Array(buf, 8, 1)[0],
|
| 51 |
+
backend: dec.decode(new Uint8Array(buf, 16, n)) };
|
| 52 |
+
}
|
| 53 |
+
|
| 54 |
+
// ---- assignment -------------------------------------------------------------
|
| 55 |
+
// Weights do NOT travel between peers. The head sends each device the model's
|
| 56 |
+
// identity and which layers it owns; the device then fetches exactly those
|
| 57 |
+
// tensors from the Hub itself, with its own credentials. So this message is
|
| 58 |
+
// small, and a token never crosses the wire.
|
| 59 |
+
//
|
| 60 |
+
// [i32 ASSIGN][utf8 JSON]
|
| 61 |
+
//
|
| 62 |
+
// JSON rather than packed ints because the payload is a model spec whose
|
| 63 |
+
// fields differ by architecture. It is validated on arrival — a malformed
|
| 64 |
+
// assignment must fail here, not three layers deep in a GEMM.
|
| 65 |
+
function packAssign(a) {
|
| 66 |
+
const bytes = enc.encode(JSON.stringify(a));
|
| 67 |
+
const buf = new ArrayBuffer(4 + bytes.length);
|
| 68 |
+
new Int32Array(buf, 0, 1)[0] = ASSIGN;
|
| 69 |
+
new Uint8Array(buf, 4).set(bytes);
|
| 70 |
+
return buf;
|
| 71 |
+
}
|
| 72 |
+
function unpackAssign(buf) {
|
| 73 |
+
let a;
|
| 74 |
+
try { a = JSON.parse(dec.decode(new Uint8Array(buf, 4))); }
|
| 75 |
+
catch (e) { throw new Error("assignment: payload is not valid JSON"); }
|
| 76 |
+
if (!a || typeof a.repo !== "string" || !a.repo)
|
| 77 |
+
throw new Error("assignment: no repo id");
|
| 78 |
+
if (!/^[\w.-]+\/[\w.-]+$/.test(a.repo))
|
| 79 |
+
throw new Error(`assignment: "${a.repo}" is not a valid repo id`);
|
| 80 |
+
if (!a.spec || !a.spec.layers || !a.spec.hidden)
|
| 81 |
+
throw new Error("assignment: incomplete model spec");
|
| 82 |
+
if (!Array.isArray(a.plan) || !a.plan.length)
|
| 83 |
+
throw new Error("assignment: no plan");
|
| 84 |
+
// A plan that does not cover every layer exactly once still generates
|
| 85 |
+
// fluent text — with a layer missing. It has to be refused at the door.
|
| 86 |
+
let cover = 0;
|
| 87 |
+
for (const s of a.plan) {
|
| 88 |
+
if (typeof s.lo !== "number" || typeof s.hi !== "number" || s.hi < s.lo || s.lo < 0 || s.hi > a.spec.layers)
|
| 89 |
+
throw new Error("assignment: stage range out of bounds");
|
| 90 |
+
cover += s.hi - s.lo;
|
| 91 |
+
}
|
| 92 |
+
if (cover !== a.spec.layers)
|
| 93 |
+
throw new Error(`assignment: stages cover ${cover} of ${a.spec.layers} layers`);
|
| 94 |
+
if (!a.plan[0].head) throw new Error("assignment: stage 0 must be the head");
|
| 95 |
+
if (typeof a.mine !== "number" || a.mine < 0 || a.mine >= a.plan.length)
|
| 96 |
+
throw new Error("assignment: no valid stage index for this device");
|
| 97 |
+
return a;
|
| 98 |
+
}
|
| 99 |
+
|
| 100 |
+
// ---- activation ------------------------------------------------------------
|
| 101 |
+
// [i32 ACT, seq, tokenIdx, nextIndex][u32 actHash, modelHash][f32 hidden]
|
| 102 |
+
// actHash is an integrity check on the payload; modelHash answers a different
|
| 103 |
+
// question — whether this activation belongs to the model I hold a slice of.
|
| 104 |
+
// Two rings running at once on one device is not hypothetical: reload the
|
| 105 |
+
// head with a different checkpoint and the old stages are still out there.
|
| 106 |
+
function packAct(seq, tokenIdx, nextIndex, hidden, hashF32, modelHash) {
|
| 107 |
+
const buf = new ArrayBuffer(24 + hidden.byteLength);
|
| 108 |
+
new Int32Array(buf, 0, 4).set([ACT, seq, tokenIdx, nextIndex]);
|
| 109 |
+
new Uint32Array(buf, 16, 2).set([hashF32(hidden) >>> 0, modelHash >>> 0]);
|
| 110 |
+
new Float32Array(buf, 24).set(hidden);
|
| 111 |
+
return buf;
|
| 112 |
+
}
|
| 113 |
+
function unpackAct(buf, hashF32) {
|
| 114 |
+
if (buf.byteLength < 24) throw new Error("act: truncated header");
|
| 115 |
+
const iv = new Int32Array(buf, 0, 4);
|
| 116 |
+
const [actHash, modelHash] = new Uint32Array(buf, 16, 2);
|
| 117 |
+
const hidden = new Float32Array(buf.slice(24));
|
| 118 |
+
if (hashF32 && (hashF32(hidden) >>> 0) !== actHash) throw new Error("act: payload failed its integrity hash");
|
| 119 |
+
return { seq: iv[1], tokenIdx: iv[2], nextIndex: iv[3], actHash, modelHash, hidden };
|
| 120 |
+
}
|
| 121 |
+
|
| 122 |
+
// ---- routing ---------------------------------------------------------------
|
| 123 |
+
// Where an activation goes after this stage, and how to read one that arrives.
|
| 124 |
+
// These are two lines each and they live here, as pure functions, only
|
| 125 |
+
// because getting them wrong is invisible everywhere else: the math is
|
| 126 |
+
// correct, the bytes are correct, and the lap simply never closes. Neither a
|
| 127 |
+
// numeric oracle nor a codec round-trip can see that — it is a property of
|
| 128 |
+
// the ROUTE, so it needs something that can walk one.
|
| 129 |
+
function routeAfter(plan, myIndex) {
|
| 130 |
+
const isLast = myIndex === plan.length - 1;
|
| 131 |
+
const next = isLast ? plan[0] : plan[myIndex + 1];
|
| 132 |
+
return { to: next.id, address: isLast ? RETURN : next.index, isReturn: isLast };
|
| 133 |
+
}
|
| 134 |
+
function classifyAct(nextIndex, myIndex) {
|
| 135 |
+
if (nextIndex === RETURN) return "return"; // the lap is finished
|
| 136 |
+
if (nextIndex === myIndex) return "mine"; // run my blocks, pass it on
|
| 137 |
+
return "other"; // not addressed to me
|
| 138 |
+
}
|
| 139 |
+
|
| 140 |
+
// ---- token / done ----------------------------------------------------------
|
| 141 |
+
function packToken(seq, id, total) {
|
| 142 |
+
const buf = new ArrayBuffer(16);
|
| 143 |
+
new Int32Array(buf).set([TOKEN, seq, id, total]);
|
| 144 |
+
return buf;
|
| 145 |
+
}
|
| 146 |
+
function unpackToken(buf) {
|
| 147 |
+
const iv = new Int32Array(buf, 0, 4);
|
| 148 |
+
return { seq: iv[1], id: iv[2], total: iv[3] };
|
| 149 |
+
}
|
| 150 |
+
function packDone(seq) { return new Int32Array([DONE, seq]).buffer; }
|
| 151 |
+
|
| 152 |
+
// ---- ready ------------------------------------------------------------------
|
| 153 |
+
function packReady(stageIndex, ok, note) {
|
| 154 |
+
const bytes = enc.encode(JSON.stringify({ stageIndex, ok: !!ok, note: String(note || "").slice(0, 300) }));
|
| 155 |
+
const buf = new ArrayBuffer(4 + bytes.length);
|
| 156 |
+
new Int32Array(buf, 0, 1)[0] = READY;
|
| 157 |
+
new Uint8Array(buf, 4).set(bytes);
|
| 158 |
+
return buf;
|
| 159 |
+
}
|
| 160 |
+
function unpackReady(buf) {
|
| 161 |
+
try { return JSON.parse(dec.decode(new Uint8Array(buf, 4))); }
|
| 162 |
+
catch (e) { throw new Error("ready: payload is not valid JSON"); }
|
| 163 |
+
}
|
| 164 |
+
|
| 165 |
+
const api = { FRAG, HELLO, ASSIGN, READY, ACT, TOKEN, DONE, RETURN, tagOf,
|
| 166 |
+
packHello, unpackHello, packAssign, unpackAssign, packReady, unpackReady,
|
| 167 |
+
packAct, unpackAct, packToken, unpackToken, packDone,
|
| 168 |
+
routeAfter, classifyAct };
|
| 169 |
+
if (typeof module !== "undefined" && module.exports) module.exports = api;
|
| 170 |
+
else root.Wire = api;
|
| 171 |
+
})(typeof self !== "undefined" ? self : this);
|
server.js
ADDED
|
@@ -0,0 +1,257 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// DaisyChain-Infer signaling + static host.
|
| 2 |
+
//
|
| 3 |
+
// Deliberately tiny. What it does NOT do is the point: it introduces peers and
|
| 4 |
+
// relays WebRTC handshakes, and never sees a weight, an activation, a prompt,
|
| 5 |
+
// or a generated token. Those move directly between the devices in the ring,
|
| 6 |
+
// and weights come from the Hugging Face CDN straight to each browser.
|
| 7 |
+
//
|
| 8 |
+
// The one exception is the OAuth code exchange below, which by construction
|
| 9 |
+
// has to handle an access token for a few milliseconds. That is called out
|
| 10 |
+
// rather than glossed: see the comment on /auth/callback.
|
| 11 |
+
//
|
| 12 |
+
// Only dependency: `ws`. Run: npm install && npm start
|
| 13 |
+
const http = require("http");
|
| 14 |
+
const fs = require("fs");
|
| 15 |
+
const path = require("path");
|
| 16 |
+
const crypto = require("crypto");
|
| 17 |
+
const { WebSocketServer } = require("ws");
|
| 18 |
+
|
| 19 |
+
// ---- rooms -------------------------------------------------------------------
|
| 20 |
+
// Snapdrop-style grouping: same public IP -> same room, so devices on your own
|
| 21 |
+
// network find each other with no code. ?room=CODE crosses networks, gated by
|
| 22 |
+
// host approval.
|
| 23 |
+
function clientIP(req) {
|
| 24 |
+
const xff = req.headers["x-forwarded-for"];
|
| 25 |
+
if (xff) return xff.split(",")[0].trim();
|
| 26 |
+
return (req.socket.remoteAddress || "unknown").replace(/^::ffff:/, "");
|
| 27 |
+
}
|
| 28 |
+
function roomFor(req) {
|
| 29 |
+
const u = new URL(req.url, "http://x");
|
| 30 |
+
const code = u.searchParams.get("room");
|
| 31 |
+
if (code) return "room:" + code;
|
| 32 |
+
const h = crypto.createHash("sha256").update(clientIP(req)).digest("hex").slice(0, 10);
|
| 33 |
+
return "net:" + h;
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
const PORT = process.env.PORT || 8788;
|
| 37 |
+
const PUB = path.join(__dirname, "public");
|
| 38 |
+
const TYPES = { ".html": "text/html", ".js": "text/javascript",
|
| 39 |
+
".css": "text/css", ".json": "application/json" };
|
| 40 |
+
|
| 41 |
+
// ---- OAuth (hosted deployments) ----------------------------------------------
|
| 42 |
+
// On a Hugging Face Space with `hf_oauth: true`, these are injected by the
|
| 43 |
+
// platform. When they are absent — a local run — OAuth is simply off and the
|
| 44 |
+
// page falls back to asking for a token directly, which is safe there because
|
| 45 |
+
// the page is served from the user's own machine.
|
| 46 |
+
const OA = {
|
| 47 |
+
clientId: process.env.OAUTH_CLIENT_ID,
|
| 48 |
+
clientSecret: process.env.OAUTH_CLIENT_SECRET,
|
| 49 |
+
scopes: process.env.OAUTH_SCOPES || "read-repos",
|
| 50 |
+
provider: (process.env.OPENID_PROVIDER_URL || "https://huggingface.co").replace(/\/$/, ""),
|
| 51 |
+
spaceHost: process.env.SPACE_HOST || "",
|
| 52 |
+
};
|
| 53 |
+
const oauthOn = !!(OA.clientId && OA.clientSecret);
|
| 54 |
+
|
| 55 |
+
// One-time `state` values, so a callback cannot be replayed or forged. Short
|
| 56 |
+
// TTL, and consumed on use.
|
| 57 |
+
const states = new Map(); // state -> expiry ms
|
| 58 |
+
function newState() {
|
| 59 |
+
const s = crypto.randomBytes(24).toString("base64url");
|
| 60 |
+
states.set(s, Date.now() + 10 * 60 * 1000);
|
| 61 |
+
return s;
|
| 62 |
+
}
|
| 63 |
+
function takeState(s) {
|
| 64 |
+
const exp = states.get(s);
|
| 65 |
+
if (exp === undefined) return false;
|
| 66 |
+
states.delete(s); // single use
|
| 67 |
+
return exp > Date.now();
|
| 68 |
+
}
|
| 69 |
+
setInterval(() => {
|
| 70 |
+
const now = Date.now();
|
| 71 |
+
for (const [s, exp] of states) if (exp <= now) states.delete(s);
|
| 72 |
+
}, 60000);
|
| 73 |
+
|
| 74 |
+
function redirectUri(req) {
|
| 75 |
+
const host = OA.spaceHost || req.headers.host;
|
| 76 |
+
const proto = OA.spaceHost || /^(localhost|127\.|\[::1\])/.test(host || "") === false ? "https" : "http";
|
| 77 |
+
return `${proto}://${host}/auth/callback`;
|
| 78 |
+
}
|
| 79 |
+
|
| 80 |
+
function oauthLogin(req, res) {
|
| 81 |
+
const state = newState();
|
| 82 |
+
const u = new URL(OA.provider + "/oauth/authorize");
|
| 83 |
+
u.searchParams.set("client_id", OA.clientId);
|
| 84 |
+
u.searchParams.set("redirect_uri", redirectUri(req));
|
| 85 |
+
u.searchParams.set("response_type", "code");
|
| 86 |
+
u.searchParams.set("scope", OA.scopes);
|
| 87 |
+
u.searchParams.set("state", state);
|
| 88 |
+
res.writeHead(302, { Location: u.toString() });
|
| 89 |
+
res.end();
|
| 90 |
+
}
|
| 91 |
+
|
| 92 |
+
// The token exchange needs the client secret, so it MUST happen here rather
|
| 93 |
+
// than in the browser — which means this server briefly holds an access token.
|
| 94 |
+
// It is never written to disk, never logged, and never kept: it is handed to
|
| 95 |
+
// the browser in the URL *fragment*, which browsers do not send to servers and
|
| 96 |
+
// which the page strips from history immediately on arrival. That is the
|
| 97 |
+
// smallest exposure the OAuth flow allows; it is not zero, and the Space's
|
| 98 |
+
// README says so.
|
| 99 |
+
async function oauthCallback(req, res, url) {
|
| 100 |
+
const code = url.searchParams.get("code");
|
| 101 |
+
const state = url.searchParams.get("state");
|
| 102 |
+
const err = url.searchParams.get("error");
|
| 103 |
+
const bail = (msg) => {
|
| 104 |
+
res.writeHead(302, { Location: "/#oauth_error=" + encodeURIComponent(msg) });
|
| 105 |
+
res.end();
|
| 106 |
+
};
|
| 107 |
+
if (err) return bail(err);
|
| 108 |
+
if (!code || !state) return bail("missing code or state");
|
| 109 |
+
if (!takeState(state)) return bail("state was not recognised — start the sign-in again");
|
| 110 |
+
try {
|
| 111 |
+
const body = new URLSearchParams({
|
| 112 |
+
grant_type: "authorization_code",
|
| 113 |
+
code,
|
| 114 |
+
redirect_uri: redirectUri(req),
|
| 115 |
+
client_id: OA.clientId,
|
| 116 |
+
client_secret: OA.clientSecret,
|
| 117 |
+
});
|
| 118 |
+
const r = await fetch(OA.provider + "/oauth/token", {
|
| 119 |
+
method: "POST",
|
| 120 |
+
headers: { "Content-Type": "application/x-www-form-urlencoded", Accept: "application/json" },
|
| 121 |
+
body,
|
| 122 |
+
});
|
| 123 |
+
if (!r.ok) return bail(`token exchange failed (HTTP ${r.status})`);
|
| 124 |
+
const j = await r.json();
|
| 125 |
+
if (!j.access_token) return bail("no access token in the response");
|
| 126 |
+
res.writeHead(302, { Location: "/#hf=" + encodeURIComponent(j.access_token) });
|
| 127 |
+
res.end();
|
| 128 |
+
} catch (e) {
|
| 129 |
+
bail("token exchange error"); // deliberately not e.message
|
| 130 |
+
}
|
| 131 |
+
}
|
| 132 |
+
|
| 133 |
+
// keepalive: proxies close idle WebSockets, and a ring can sit idle between
|
| 134 |
+
// prompts far longer than a training run ever does
|
| 135 |
+
setInterval(() => {
|
| 136 |
+
for (const room of rooms.values())
|
| 137 |
+
for (const [, v] of room.peers) {
|
| 138 |
+
if (v.ws.isAlive === false) { v.ws.terminate(); continue; }
|
| 139 |
+
v.ws.isAlive = false;
|
| 140 |
+
send(v.ws, { type: "ping" });
|
| 141 |
+
}
|
| 142 |
+
}, 30000);
|
| 143 |
+
|
| 144 |
+
const server = http.createServer((req, res) => {
|
| 145 |
+
const url = new URL(req.url, "http://x");
|
| 146 |
+
let p = decodeURIComponent(url.pathname);
|
| 147 |
+
|
| 148 |
+
if (p === "/mode") {
|
| 149 |
+
let rtc = null;
|
| 150 |
+
try { if (process.env.DAISY_RTC_CONFIG) rtc = JSON.parse(process.env.DAISY_RTC_CONFIG); } catch (e) {}
|
| 151 |
+
res.writeHead(200, { "Content-Type": "application/json", "Cache-Control": "no-store" });
|
| 152 |
+
return res.end(JSON.stringify({
|
| 153 |
+
// DAISY_FORCE_ROOMS=1 (set on the hosted Space): no LAN auto-grouping, so
|
| 154 |
+
// strangers who happen to share a public IP — same CGNAT, same campus,
|
| 155 |
+
// same office — are never put in one ring. Each visitor makes their own
|
| 156 |
+
// room and invites devices by link.
|
| 157 |
+
forceRooms: !!process.env.DAISY_FORCE_ROOMS,
|
| 158 |
+
// When OAuth is available the page does not offer a paste-a-token box at
|
| 159 |
+
// all: on a deployment the user does not control, signing in is the only
|
| 160 |
+
// credential path offered.
|
| 161 |
+
oauth: oauthOn,
|
| 162 |
+
rtc,
|
| 163 |
+
}));
|
| 164 |
+
}
|
| 165 |
+
if (p === "/auth/login" || p === "/oauth/login") {
|
| 166 |
+
if (!oauthOn) { res.writeHead(404); return res.end("oauth is not configured"); }
|
| 167 |
+
return oauthLogin(req, res);
|
| 168 |
+
}
|
| 169 |
+
// HF registers /auth/callback for Spaces; the alias costs nothing and saves a
|
| 170 |
+
// broken deploy if the platform ever hands back the other spelling.
|
| 171 |
+
if (p === "/auth/callback" || p === "/oauth/callback") {
|
| 172 |
+
if (!oauthOn) { res.writeHead(404); return res.end("oauth is not configured"); }
|
| 173 |
+
return void oauthCallback(req, res, url);
|
| 174 |
+
}
|
| 175 |
+
|
| 176 |
+
if (p === "/") p = "/index.html";
|
| 177 |
+
const file = path.join(PUB, path.normalize(p));
|
| 178 |
+
if (!file.startsWith(PUB)) { res.writeHead(403); return res.end(); }
|
| 179 |
+
fs.readFile(file, (err, data) => {
|
| 180 |
+
if (err) { res.writeHead(404); return res.end("not found"); }
|
| 181 |
+
res.writeHead(200, { "Content-Type": TYPES[path.extname(file)] || "application/octet-stream" });
|
| 182 |
+
res.end(data);
|
| 183 |
+
});
|
| 184 |
+
});
|
| 185 |
+
|
| 186 |
+
const wss = new WebSocketServer({ server });
|
| 187 |
+
const rooms = new Map();
|
| 188 |
+
let nextId = 1;
|
| 189 |
+
function send(ws, obj) { if (ws.readyState === 1) ws.send(JSON.stringify(obj)); }
|
| 190 |
+
|
| 191 |
+
wss.on("connection", (ws, req) => {
|
| 192 |
+
const roomId = roomFor(req);
|
| 193 |
+
const name = (new URL(req.url, "http://x").searchParams.get("name") || "").slice(0, 40) || ("p" + nextId);
|
| 194 |
+
const id = "p" + (nextId++);
|
| 195 |
+
ws.peerId = id; ws.roomId = roomId;
|
| 196 |
+
if (!rooms.has(roomId)) rooms.set(roomId, { peers: new Map(), host: null, pending: new Map() });
|
| 197 |
+
const room = rooms.get(roomId);
|
| 198 |
+
const isPrivate = roomId.startsWith("room:");
|
| 199 |
+
|
| 200 |
+
function admit(pid, peer) {
|
| 201 |
+
const roster = [...room.peers.entries()].map(([qid, v]) => ({ id: qid, name: v.name }));
|
| 202 |
+
send(peer.ws, { type: "welcome", id: pid, room: roomId, peers: roster, host: room.host === pid });
|
| 203 |
+
for (const [, v] of room.peers) send(v.ws, { type: "peer-joined", id: pid, name: peer.name });
|
| 204 |
+
room.peers.set(pid, peer);
|
| 205 |
+
}
|
| 206 |
+
|
| 207 |
+
if (isPrivate && room.peers.size === 0) room.host = id;
|
| 208 |
+
if (isPrivate && room.host !== id) {
|
| 209 |
+
room.pending.set(id, { ws, name });
|
| 210 |
+
send(ws, { type: "waiting" });
|
| 211 |
+
const h = room.peers.get(room.host);
|
| 212 |
+
if (h) send(h.ws, { type: "join-request", id, name });
|
| 213 |
+
} else {
|
| 214 |
+
admit(id, { ws, name });
|
| 215 |
+
}
|
| 216 |
+
|
| 217 |
+
ws.on("message", (buf) => {
|
| 218 |
+
let msg; try { msg = JSON.parse(buf); } catch { return; }
|
| 219 |
+
if (msg.type === "signal" && msg.to && room.peers.has(id)) {
|
| 220 |
+
const target = room.peers.get(msg.to);
|
| 221 |
+
if (target) send(target.ws, { type: "signal", from: id, data: msg.data });
|
| 222 |
+
} else if (msg.type === "pong") {
|
| 223 |
+
ws.isAlive = true;
|
| 224 |
+
} else if (msg.type === "relay" && msg.to && room.peers.has(id)) {
|
| 225 |
+
// last-resort path when two devices cannot form a direct WebRTC route.
|
| 226 |
+
// Note this DOES put activations through the server — the UI says so.
|
| 227 |
+
const target = room.peers.get(msg.to);
|
| 228 |
+
if (target) send(target.ws, { type: "relay", from: id, data: msg.data });
|
| 229 |
+
} else if (msg.type === "admit" && id === room.host) {
|
| 230 |
+
const p = room.pending.get(msg.id);
|
| 231 |
+
if (!p) return;
|
| 232 |
+
room.pending.delete(msg.id);
|
| 233 |
+
if (msg.allow) admit(msg.id, p);
|
| 234 |
+
else { send(p.ws, { type: "denied" }); p.ws.close(); }
|
| 235 |
+
}
|
| 236 |
+
});
|
| 237 |
+
|
| 238 |
+
ws.on("close", () => {
|
| 239 |
+
room.pending.delete(id);
|
| 240 |
+
if (room.peers.delete(id))
|
| 241 |
+
for (const [, v] of room.peers) send(v.ws, { type: "peer-left", id });
|
| 242 |
+
if (room.host === id) {
|
| 243 |
+
room.host = room.peers.keys().next().value ?? null;
|
| 244 |
+
const h = room.peers.get(room.host);
|
| 245 |
+
if (h) {
|
| 246 |
+
send(h.ws, { type: "host" });
|
| 247 |
+
for (const [pid, p] of room.pending) send(h.ws, { type: "join-request", id: pid, name: p.name });
|
| 248 |
+
}
|
| 249 |
+
}
|
| 250 |
+
if (room.peers.size === 0 && room.pending.size === 0) rooms.delete(roomId);
|
| 251 |
+
});
|
| 252 |
+
});
|
| 253 |
+
|
| 254 |
+
server.listen(PORT, () =>
|
| 255 |
+
console.log(`DaisyChain-Infer on http://localhost:${PORT}` +
|
| 256 |
+
(oauthOn ? " (HF OAuth enabled)" : "") +
|
| 257 |
+
(process.env.DAISY_FORCE_ROOMS ? " (rooms forced)" : "")));
|
test_loader.js
ADDED
|
@@ -0,0 +1,179 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// The loader: safetensors parsing, dtype widening, range coalescing, and BPE.
|
| 2 |
+
//
|
| 3 |
+
// This is the layer where "load any model off HuggingFace" is either true or
|
| 4 |
+
// quietly false. Every failure here is silent by nature — a misread offset
|
| 5 |
+
// gives plausible floats, a wrong dtype gives plausible floats, a
|
| 6 |
+
// half-implemented tokenizer gives plausible text. So the checks are exact,
|
| 7 |
+
// and several are built by CONSTRUCTING a file and reading it back rather than
|
| 8 |
+
// by trusting a fixture.
|
| 9 |
+
//
|
| 10 |
+
// node test_loader.js
|
| 11 |
+
const assert = require("assert");
|
| 12 |
+
const ST = require("./public/safetensors.js");
|
| 13 |
+
const Tok = require("./public/tokenizer.js");
|
| 14 |
+
const Arch = require("./public/arch.js");
|
| 15 |
+
|
| 16 |
+
let failed = 0;
|
| 17 |
+
function t(name, fn) {
|
| 18 |
+
try { fn(); console.log(` ok ${name}`); }
|
| 19 |
+
catch (e) { failed++; console.log(` FAIL ${name}\n ${e.message}`); }
|
| 20 |
+
}
|
| 21 |
+
|
| 22 |
+
console.log("\nDaisyChain-Infer — loader\n");
|
| 23 |
+
|
| 24 |
+
// Build a real safetensors buffer so the parser is tested against the format,
|
| 25 |
+
// not against our own idea of it.
|
| 26 |
+
function buildFile(entries) {
|
| 27 |
+
let offset = 0;
|
| 28 |
+
const header = {};
|
| 29 |
+
const chunks = [];
|
| 30 |
+
for (const [name, dtype, shape, bytes] of entries) {
|
| 31 |
+
header[name] = { dtype, shape, data_offsets: [offset, offset + bytes.length] };
|
| 32 |
+
chunks.push(bytes);
|
| 33 |
+
offset += bytes.length;
|
| 34 |
+
}
|
| 35 |
+
const hj = Buffer.from(JSON.stringify(header), "utf8");
|
| 36 |
+
const out = Buffer.alloc(8 + hj.length + offset);
|
| 37 |
+
out.writeUInt32LE(hj.length, 0); out.writeUInt32LE(0, 4);
|
| 38 |
+
hj.copy(out, 8);
|
| 39 |
+
let o = 8 + hj.length;
|
| 40 |
+
for (const c of chunks) { Buffer.from(c).copy(out, o); o += c.length; }
|
| 41 |
+
return out.buffer.slice(out.byteOffset, out.byteOffset + out.length);
|
| 42 |
+
}
|
| 43 |
+
|
| 44 |
+
t("parses a header and locates every tensor", () => {
|
| 45 |
+
const a = Buffer.from(new Float32Array([1, 2, 3, 4]).buffer);
|
| 46 |
+
const b = Buffer.from(new Float32Array([5, 6]).buffer);
|
| 47 |
+
const buf = buildFile([["w.a", "F32", [2, 2], a], ["w.b", "F32", [2], b]]);
|
| 48 |
+
const { tensors } = ST.parseHeader(buf);
|
| 49 |
+
assert.strictEqual(tensors.size, 2);
|
| 50 |
+
const ta = tensors.get("w.a");
|
| 51 |
+
assert.deepStrictEqual(ta.shape, [2, 2]);
|
| 52 |
+
assert.strictEqual(ta.elems, 4);
|
| 53 |
+
assert.deepStrictEqual([...ST.toF32("F32", new Uint8Array(buf, ta.start, ta.bytes))], [1, 2, 3, 4]);
|
| 54 |
+
const tb = tensors.get("w.b");
|
| 55 |
+
assert.deepStrictEqual([...ST.toF32("F32", new Uint8Array(buf, tb.start, tb.bytes))], [5, 6]);
|
| 56 |
+
});
|
| 57 |
+
|
| 58 |
+
// A shape that disagrees with the byte range means every subsequent tensor
|
| 59 |
+
// would be read at the wrong offset — plausible floats all the way down.
|
| 60 |
+
t("a header whose shape contradicts its byte range is refused", () => {
|
| 61 |
+
const a = Buffer.from(new Float32Array([1, 2, 3, 4]).buffer);
|
| 62 |
+
const buf = buildFile([["w", "F32", [9, 9], a]]);
|
| 63 |
+
assert.throws(() => ST.parseHeader(buf), /expected/);
|
| 64 |
+
});
|
| 65 |
+
|
| 66 |
+
t("junk in place of a header is refused, not guessed at", () => {
|
| 67 |
+
const junk = Buffer.alloc(64); junk.writeUInt32LE(16, 0);
|
| 68 |
+
assert.throws(() => ST.parseHeader(junk.buffer.slice(0, 64)), /not valid JSON|not a safetensors/);
|
| 69 |
+
const big = Buffer.alloc(16); big.writeUInt32LE(0, 0); big.writeUInt32LE(7, 4);
|
| 70 |
+
assert.throws(() => ST.parseHeader(big.buffer.slice(0, 16)), /implausibly large/);
|
| 71 |
+
});
|
| 72 |
+
|
| 73 |
+
// BF16 is the top 16 bits of an f32, so widening must be EXACT — not close.
|
| 74 |
+
t("BF16 widens exactly (it is the high half of an f32)", () => {
|
| 75 |
+
const vals = [1, -1, 0.5, -0.5, 2, 100, -0.0078125, 0];
|
| 76 |
+
const u16 = new Uint16Array(vals.length);
|
| 77 |
+
const f = new Float32Array(1), u = new Uint32Array(f.buffer);
|
| 78 |
+
for (let i = 0; i < vals.length; i++) { f[0] = vals[i]; u16[i] = u[0] >>> 16; }
|
| 79 |
+
const out = ST.bf16ToF32(u16, new Float32Array(vals.length));
|
| 80 |
+
for (let i = 0; i < vals.length; i++)
|
| 81 |
+
assert.strictEqual(out[i], vals[i], `bf16 round-trip changed ${vals[i]} to ${out[i]}`);
|
| 82 |
+
});
|
| 83 |
+
|
| 84 |
+
t("F16 widens exactly, including subnormals and signed zero", () => {
|
| 85 |
+
// every f16 has an exact f32 value, so this is a widening with no error
|
| 86 |
+
const cases = [[0x3C00, 1], [0xBC00, -1], [0x3800, 0.5], [0x0000, 0], [0x8000, -0],
|
| 87 |
+
[0x0001, Math.pow(2, -24)], [0x7BFF, 65504]];
|
| 88 |
+
const u16 = Uint16Array.from(cases.map(c => c[0]));
|
| 89 |
+
const out = ST.f16ToF32(u16, new Float32Array(cases.length));
|
| 90 |
+
cases.forEach(([, want], i) =>
|
| 91 |
+
assert.ok(Object.is(out[i], want), `f16 ${i}: got ${out[i]}, want ${want}`));
|
| 92 |
+
});
|
| 93 |
+
|
| 94 |
+
// Coalescing is a bandwidth optimisation, and a wrong one would hand a tensor
|
| 95 |
+
// the wrong bytes. It must merge only what is genuinely adjacent, and the
|
| 96 |
+
// per-tensor offsets must still land correctly inside a merged run.
|
| 97 |
+
t("range coalescing merges neighbours and keeps offsets correct", () => {
|
| 98 |
+
const mk = (start, end) => ({ start, end, bytes: end - start, elems: (end - start) / 4, dtype: "F32", name: `t${start}` });
|
| 99 |
+
const runs = ST.coalesce([mk(0, 100), mk(100, 200), mk(10_000_000, 10_000_100)], 1024);
|
| 100 |
+
assert.strictEqual(runs.length, 2, "adjacent ranges were not merged, or a distant one was");
|
| 101 |
+
assert.deepStrictEqual([runs[0].start, runs[0].end], [0, 200]);
|
| 102 |
+
assert.strictEqual(runs[0].tensors.length, 2);
|
| 103 |
+
assert.deepStrictEqual([runs[1].start, runs[1].end], [10_000_000, 10_000_100]);
|
| 104 |
+
// a tensor's data must be recoverable from its offset within the merged run
|
| 105 |
+
const t2 = runs[0].tensors[1];
|
| 106 |
+
assert.strictEqual(t2.start - runs[0].start, 100);
|
| 107 |
+
});
|
| 108 |
+
|
| 109 |
+
t("stage byte cost is computed from the header alone", () => {
|
| 110 |
+
const spec = Arch.fromConfig({ model_type: "llama", hidden_size: 64, num_hidden_layers: 4,
|
| 111 |
+
num_attention_heads: 4, num_key_value_heads: 4, intermediate_size: 128, vocab_size: 100 });
|
| 112 |
+
const names = new Map();
|
| 113 |
+
const add = (n, elems) => names.set(n, { name: n, elems, bytes: elems * 2, dtype: "BF16", shape: [elems] });
|
| 114 |
+
const h = Arch.headTensors(spec);
|
| 115 |
+
add(h.emb, 100 * 64); add(h.nrmF, 64);
|
| 116 |
+
for (let l = 0; l < 4; l++) {
|
| 117 |
+
const lt = Arch.layerTensors(spec, l);
|
| 118 |
+
add(lt.nrm1, 64); add(lt.nrm2, 64);
|
| 119 |
+
add(lt.Wq, 64 * 64); add(lt.Wk, 64 * 64); add(lt.Wv, 64 * 64); add(lt.Wo, 64 * 64);
|
| 120 |
+
add(lt.Wgate, 128 * 64); add(lt.Wup, 128 * 64); add(lt.Wdown, 64 * 128);
|
| 121 |
+
}
|
| 122 |
+
const Shard = require("./public/shard.js");
|
| 123 |
+
const mid = Shard.stageBytes(spec, names, { lo: 1, hi: 2, head: false }, Arch);
|
| 124 |
+
const head = Shard.stageBytes(spec, names, { lo: 0, hi: 1, head: true }, Arch);
|
| 125 |
+
assert.strictEqual(mid, (4 * 64 * 64 + 3 * 128 * 64 + 2 * 64) * 4, "middle stage cost is wrong");
|
| 126 |
+
assert.ok(head > mid, "the head must cost more — it holds the embedding table");
|
| 127 |
+
});
|
| 128 |
+
|
| 129 |
+
// ---- tokenizer --------------------------------------------------------------
|
| 130 |
+
// A tiny byte-level BPE built by hand, so encode/decode are checked against a
|
| 131 |
+
// known merge table rather than against themselves.
|
| 132 |
+
const TOKJSON = (() => {
|
| 133 |
+
const vocab = {};
|
| 134 |
+
let id = 0;
|
| 135 |
+
// single byte-level characters for the printable ASCII we use
|
| 136 |
+
for (const ch of "ĠabcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ.,!") vocab[ch] = id++;
|
| 137 |
+
vocab["ab"] = id++; vocab["abc"] = id++; vocab["Ġth"] = id++; vocab["Ġthe"] = id++;
|
| 138 |
+
const merges = ["a b", "ab c", "Ġ t h", "Ġth e"].map(m => m.replace(/^(\S+) (\S+)$/, "$1 $2"));
|
| 139 |
+
return { model: { type: "BPE", vocab, merges: ["a b", "ab c", "Ġt h", "Ġth e"] },
|
| 140 |
+
added_tokens: [{ id: id++, content: "<|end|>" }] };
|
| 141 |
+
})();
|
| 142 |
+
|
| 143 |
+
t("byte-level BPE round-trips text exactly", () => {
|
| 144 |
+
const tk = Tok.build(TOKJSON);
|
| 145 |
+
for (const s of ["abc", "abc abc", "the", " the", "hello world", "a,b!c."]) {
|
| 146 |
+
const ids = Tok.encode(tk, s);
|
| 147 |
+
assert.strictEqual(Tok.decode(tk, ids), s, `"${s}" did not round-trip (got "${Tok.decode(tk, ids)}")`);
|
| 148 |
+
}
|
| 149 |
+
});
|
| 150 |
+
|
| 151 |
+
t("BPE actually applies its merges", () => {
|
| 152 |
+
const tk = Tok.build(TOKJSON);
|
| 153 |
+
// "abc" must become the single merged token, not three characters
|
| 154 |
+
assert.deepStrictEqual([...Tok.encode(tk, "abc")], [tk.vocab["abc"]], "merges were not applied");
|
| 155 |
+
assert.deepStrictEqual([...Tok.encode(tk, "ab")], [tk.vocab["ab"]]);
|
| 156 |
+
});
|
| 157 |
+
|
| 158 |
+
t("added/special tokens stay whole and are hidden on decode", () => {
|
| 159 |
+
const tk = Tok.build(TOKJSON);
|
| 160 |
+
const ids = [...Tok.encode(tk, "abc<|end|>abc")];
|
| 161 |
+
assert.ok(ids.includes(tk.added.get("<|end|>")), "the special token was split up");
|
| 162 |
+
assert.strictEqual(Tok.decode(tk, ids), "abcabc", "special tokens should not appear in output text");
|
| 163 |
+
assert.ok(Tok.decode(tk, ids, true).includes("<|end|>"), "keepSpecial should show them");
|
| 164 |
+
});
|
| 165 |
+
|
| 166 |
+
t("a non-BPE tokenizer is refused rather than approximated", () => {
|
| 167 |
+
assert.throws(() => Tok.build({ model: { type: "Unigram", vocab: {} } }), /not supported/);
|
| 168 |
+
assert.throws(() => Tok.build({ model: { type: "WordPiece", vocab: {} } }), /not supported/);
|
| 169 |
+
});
|
| 170 |
+
|
| 171 |
+
t("the byte table covers all 256 bytes and is a bijection", () => {
|
| 172 |
+
const { b2u, u2b } = Tok.byteTables();
|
| 173 |
+
assert.strictEqual(b2u.size, 256, "byte->unicode table is incomplete");
|
| 174 |
+
assert.strictEqual(u2b.size, 256, "unicode->byte table is incomplete");
|
| 175 |
+
for (let b = 0; b < 256; b++) assert.strictEqual(u2b.get(b2u.get(b)), b, `byte ${b} does not round-trip`);
|
| 176 |
+
});
|
| 177 |
+
|
| 178 |
+
console.log(failed ? `\n${failed} failure(s)\n` : "\nall loader checks passed\n");
|
| 179 |
+
process.exit(failed ? 1 : 0);
|
test_pipeline.js
ADDED
|
@@ -0,0 +1,193 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// The central claim of this project, as a test that runs.
|
| 2 |
+
//
|
| 3 |
+
// Splitting a model across machines is only safe if the split changes nothing.
|
| 4 |
+
// That is checkable here in a way it usually is not, because the pieces are
|
| 5 |
+
// deterministic: run a prompt with every layer on one "device", then again
|
| 6 |
+
// with the layers handed out across N simulated stages passing hidden states
|
| 7 |
+
// between them, and compare.
|
| 8 |
+
//
|
| 9 |
+
// The comparison is EXACT — bit patterns, not a tolerance. A correct split is
|
| 10 |
+
// not merely close to the unsplit result, it is identical, because the ring
|
| 11 |
+
// never re-derives anything; it only moves f32 arrays. A tolerance here would
|
| 12 |
+
// hide exactly the bugs worth finding: dropping one layer out of twelve moves
|
| 13 |
+
// the logits far less than you would guess, and still reads as fluent text.
|
| 14 |
+
//
|
| 15 |
+
// node test_pipeline.js
|
| 16 |
+
const assert = require("assert");
|
| 17 |
+
const Shard = require("./public/shard.js");
|
| 18 |
+
const Infer = require("./public/infer.js");
|
| 19 |
+
const Arch = require("./public/arch.js");
|
| 20 |
+
|
| 21 |
+
// The LUT is exactly a*b for int8 — what the verified mul8 unit is proven to
|
| 22 |
+
// reproduce. Rebuilding it here keeps the test fixture-free without weakening
|
| 23 |
+
// it, since the unit's whole claim is that it equals this.
|
| 24 |
+
const L = (() => {
|
| 25 |
+
const mul = new Int16Array(65536);
|
| 26 |
+
for (let a = 0; a < 256; a++)
|
| 27 |
+
for (let b = 0; b < 256; b++) mul[a * 256 + b] = (a > 127 ? a - 256 : a) * (b > 127 ? b - 256 : b);
|
| 28 |
+
return { mul };
|
| 29 |
+
})();
|
| 30 |
+
const ctx = () => ({ L, bgemm: null, att: null, mlp: null, audit: null }); // CPU mirror
|
| 31 |
+
|
| 32 |
+
function mulberry32(a) { return function () { a |= 0; a = a + 0x6D2B79F5 | 0; let t = Math.imul(a ^ a >>> 15, 1 | a); t = t + Math.imul(t ^ t >>> 7, 61 | t) ^ t; return ((t ^ t >>> 14) >>> 0) / 4294967296; }; }
|
| 33 |
+
function rnd(n, seed, scale) {
|
| 34 |
+
const r = mulberry32(seed), o = new Float32Array(n);
|
| 35 |
+
for (let i = 0; i < n; i++) o[i] = (r() * 2 - 1) * (scale || 1);
|
| 36 |
+
return o;
|
| 37 |
+
}
|
| 38 |
+
|
| 39 |
+
// Build a weights map keyed exactly as the real repos key them, so the test
|
| 40 |
+
// exercises the same name resolution and layout handling the loader uses.
|
| 41 |
+
function makeWeights(spec) {
|
| 42 |
+
const got = new Map();
|
| 43 |
+
const C = spec.hidden, qDim = spec.heads * spec.headDim, kvDim = spec.kvHeads * spec.headDim;
|
| 44 |
+
let seed = 7;
|
| 45 |
+
const put = (n, len, scale) => got.set(n, rnd(len, seed++, scale ?? 0.08));
|
| 46 |
+
const h = Arch.headTensors(spec);
|
| 47 |
+
put(h.emb, spec.vocab * C, 0.05);
|
| 48 |
+
if (h.pos) put(h.pos, spec.maxPos * C, 0.02);
|
| 49 |
+
put(h.nrmF, C, 1);
|
| 50 |
+
got.set(h.nrmF, new Float32Array(C).fill(1));
|
| 51 |
+
if (spec.norm === "ln") got.set(h.nrmFb, new Float32Array(C));
|
| 52 |
+
if (!spec.tie) put(h.lmHead, spec.vocab * C, 0.05);
|
| 53 |
+
for (let l = 0; l < spec.layers; l++) {
|
| 54 |
+
const t = Arch.layerTensors(spec, l);
|
| 55 |
+
got.set(t.nrm1, new Float32Array(C).fill(1));
|
| 56 |
+
got.set(t.nrm2, new Float32Array(C).fill(1));
|
| 57 |
+
if (spec.norm === "ln") { got.set(t.nrm1b, new Float32Array(C)); got.set(t.nrm2b, new Float32Array(C)); }
|
| 58 |
+
if (spec.qkvFused) { put(t.Wqkv, C * 3 * C); got.set(t.bqkv, new Float32Array(3 * C)); }
|
| 59 |
+
else { put(t.Wq, qDim * C); put(t.Wk, kvDim * C); put(t.Wv, kvDim * C); }
|
| 60 |
+
put(t.Wo, C * qDim);
|
| 61 |
+
if (spec.qkvFused) got.set(t.bo, new Float32Array(C));
|
| 62 |
+
if (spec.gated) { put(t.Wgate, spec.inter * C); put(t.Wup, spec.inter * C); }
|
| 63 |
+
else { put(t.Wfc, spec.inter * C); got.set(t.bfc, new Float32Array(spec.inter)); }
|
| 64 |
+
put(t.Wdown, C * spec.inter);
|
| 65 |
+
if (!spec.gated) got.set(t.bdown, new Float32Array(C));
|
| 66 |
+
}
|
| 67 |
+
return got;
|
| 68 |
+
}
|
| 69 |
+
|
| 70 |
+
function stagesFor(spec, got, splits, T) {
|
| 71 |
+
const plan = [];
|
| 72 |
+
let lo = 0;
|
| 73 |
+
splits.forEach((n, i) => { plan.push({ id: "p" + i, index: i, lo, hi: lo + n, head: i === 0 }); lo += n; });
|
| 74 |
+
assert.strictEqual(lo, spec.layers, "splits must cover every layer exactly once");
|
| 75 |
+
return plan.map(st => Infer.makeStage(spec, st, Shard.stageWeights(spec, st, got, Arch), ctx(), T));
|
| 76 |
+
}
|
| 77 |
+
|
| 78 |
+
async function ringGenerate(spec, stages, ids, nTok, T) {
|
| 79 |
+
const head = stages[0];
|
| 80 |
+
const out = [...ids];
|
| 81 |
+
for (let n = 0; n < nTok; n++) {
|
| 82 |
+
const win = new Int32Array(T);
|
| 83 |
+
const tail = out.slice(-T);
|
| 84 |
+
for (let i = 0; i < tail.length; i++) win[T - tail.length + i] = tail[i];
|
| 85 |
+
let x = Infer.embed(head, win);
|
| 86 |
+
for (const S of stages) x = await Infer.runLayers(S, x); // the "hop"
|
| 87 |
+
out.push(Infer.pickToken(await Infer.readout(head, x), { temperature: 0 }));
|
| 88 |
+
}
|
| 89 |
+
return out;
|
| 90 |
+
}
|
| 91 |
+
|
| 92 |
+
function bitsEqual(a, b) {
|
| 93 |
+
if (a.length !== b.length) return false;
|
| 94 |
+
const ua = new Uint32Array(a.buffer, a.byteOffset, a.length);
|
| 95 |
+
const ub = new Uint32Array(b.buffer, b.byteOffset, b.length);
|
| 96 |
+
for (let i = 0; i < ua.length; i++) if (ua[i] !== ub[i]) return false;
|
| 97 |
+
return true;
|
| 98 |
+
}
|
| 99 |
+
|
| 100 |
+
// Two architectures, because the whole point of arch.js is that the ring does
|
| 101 |
+
// not care which one it is running. Llama-style exercises RMSNorm/RoPE/GQA/
|
| 102 |
+
// SwiGLU; GPT-2-style exercises LayerNorm+bias, fused QKV and GELU.
|
| 103 |
+
const LLAMA = Arch.fromConfig({
|
| 104 |
+
model_type: "llama", hidden_size: 64, num_hidden_layers: 6, num_attention_heads: 4,
|
| 105 |
+
num_key_value_heads: 2, intermediate_size: 128, vocab_size: 128,
|
| 106 |
+
max_position_embeddings: 64, rms_norm_eps: 1e-5, tie_word_embeddings: true,
|
| 107 |
+
});
|
| 108 |
+
const GPT2 = Arch.fromConfig({
|
| 109 |
+
model_type: "gpt2", n_embd: 64, n_layer: 6, n_head: 4, vocab_size: 128,
|
| 110 |
+
n_positions: 64, n_inner: 128, layer_norm_epsilon: 1e-5,
|
| 111 |
+
});
|
| 112 |
+
|
| 113 |
+
(async function () {
|
| 114 |
+
let failed = 0;
|
| 115 |
+
const t = (name, fn) => fn().then(
|
| 116 |
+
() => console.log(` ok ${name}`),
|
| 117 |
+
(e) => { failed++; console.log(` FAIL ${name}\n ${e.message}`); });
|
| 118 |
+
|
| 119 |
+
console.log("\nDaisyChain-Infer — pipeline equivalence\n");
|
| 120 |
+
|
| 121 |
+
const T = 16, PROMPT = [5, 11, 42, 7];
|
| 122 |
+
|
| 123 |
+
for (const [label, spec] of [["llama-style", LLAMA], ["gpt2-style", GPT2]]) {
|
| 124 |
+
console.log(` ${label}: ${spec.layers} layers, hidden ${spec.hidden}, ` +
|
| 125 |
+
`heads ${spec.heads}/${spec.kvHeads}, ${spec.gated ? "SwiGLU" : "GELU"}, ` +
|
| 126 |
+
`${spec.norm.toUpperCase()}, rope=${spec.rope}`);
|
| 127 |
+
const got = makeWeights(spec);
|
| 128 |
+
const ref = await ringGenerate(spec, stagesFor(spec, got, [spec.layers], T), PROMPT, 8, T);
|
| 129 |
+
|
| 130 |
+
await t(`${label}: a stage boundary changes no bit of the hidden state`, async () => {
|
| 131 |
+
const whole = stagesFor(spec, got, [spec.layers], T);
|
| 132 |
+
const split = stagesFor(spec, got, [2, 2, 2], T);
|
| 133 |
+
const win = new Int32Array(T);
|
| 134 |
+
for (let i = 0; i < PROMPT.length; i++) win[T - PROMPT.length + i] = PROMPT[i];
|
| 135 |
+
const a = await Infer.runLayers(whole[0], Infer.embed(whole[0], win));
|
| 136 |
+
let b = Infer.embed(split[0], win);
|
| 137 |
+
for (const S of split) b = await Infer.runLayers(S, b);
|
| 138 |
+
assert.ok(bitsEqual(a, b), "hidden states diverged across the split");
|
| 139 |
+
});
|
| 140 |
+
|
| 141 |
+
for (const splits of [[3, 3], [1, 2, 3], [0, 3, 3], [2, 1, 1, 1, 1], [1, 1, 1, 1, 1, 1]]) {
|
| 142 |
+
await t(`${label}: generation is identical when split ${JSON.stringify(splits)}`, async () => {
|
| 143 |
+
const out = await ringGenerate(spec, stagesFor(spec, got, splits, T), PROMPT, 8, T);
|
| 144 |
+
assert.deepStrictEqual(out, ref, `split ${splits} produced different tokens`);
|
| 145 |
+
});
|
| 146 |
+
}
|
| 147 |
+
|
| 148 |
+
// Mutation checks. Without these, a test where both sides call the same
|
| 149 |
+
// code proves nothing at all.
|
| 150 |
+
await t(`${label}: a stage that silently skips a layer IS caught`, async () => {
|
| 151 |
+
const stages = stagesFor(spec, got, [2, 2, 2], T);
|
| 152 |
+
stages[1].w.layers.pop();
|
| 153 |
+
const out = await ringGenerate(spec, stages, PROMPT, 8, T);
|
| 154 |
+
assert.notDeepStrictEqual(out, ref, "dropping a whole layer changed nothing — the check is blind");
|
| 155 |
+
});
|
| 156 |
+
await t(`${label}: stages applied out of order ARE caught`, async () => {
|
| 157 |
+
const stages = stagesFor(spec, got, [2, 2, 2], T);
|
| 158 |
+
const out = await ringGenerate(spec, [stages[0], stages[2], stages[1]], PROMPT, 8, T);
|
| 159 |
+
assert.notDeepStrictEqual(out, ref, "layer order did not matter — the check is blind");
|
| 160 |
+
});
|
| 161 |
+
}
|
| 162 |
+
|
| 163 |
+
// A wrong transpose does not throw; it yields a model that generates
|
| 164 |
+
// confident nonsense. So the layout conversion gets its own check.
|
| 165 |
+
await t("weight layout conversion is a real transpose, and in_out is a no-op", async () => {
|
| 166 |
+
const w = Float32Array.from([1, 2, 3, 4, 5, 6]); // (out=2, in=3)
|
| 167 |
+
const kn = Shard.toKN(w, 2, 3, "out_in");
|
| 168 |
+
assert.deepStrictEqual([...kn], [1, 4, 2, 5, 3, 6], "out_in did not transpose to k x n");
|
| 169 |
+
assert.strictEqual(Shard.toKN(w, 2, 3, "in_out"), w, "in_out must pass through untouched");
|
| 170 |
+
});
|
| 171 |
+
|
| 172 |
+
await t("architecture detection maps real configs, and refuses unknown ones", async () => {
|
| 173 |
+
assert.strictEqual(Arch.fromConfig({ model_type: "qwen2", hidden_size: 896, num_hidden_layers: 24,
|
| 174 |
+
num_attention_heads: 14, num_key_value_heads: 2, intermediate_size: 4864, vocab_size: 151936 }).family, "llama");
|
| 175 |
+
assert.strictEqual(Arch.fromConfig({ model_type: "gpt2", n_embd: 768, n_layer: 12,
|
| 176 |
+
n_head: 12, vocab_size: 50257 }).family, "gpt2");
|
| 177 |
+
assert.throws(() => Arch.fromConfig({ model_type: "mamba", hidden_size: 8, num_hidden_layers: 1,
|
| 178 |
+
num_attention_heads: 1, vocab_size: 8 }), /unsupported architecture/);
|
| 179 |
+
assert.throws(() => Arch.fromConfig({ model_type: "llama" }), /missing/);
|
| 180 |
+
});
|
| 181 |
+
|
| 182 |
+
await t("planning is deterministic and capacity-weighted", async () => {
|
| 183 |
+
const caps = [{ id: "p1", capacity: 100 }, { id: "p2", capacity: 300 }, { id: "p3", capacity: 200 }];
|
| 184 |
+
const a = Shard.planStages(LLAMA, caps), b = Shard.planStages(LLAMA, caps);
|
| 185 |
+
assert.deepStrictEqual(a, b, "planning is not deterministic");
|
| 186 |
+
assert.strictEqual(a.reduce((s, x) => s + (x.hi - x.lo), 0), LLAMA.layers, "plan does not cover every layer");
|
| 187 |
+
assert.ok(a[1].hi - a[1].lo > a[0].hi - a[0].lo, "the faster device did not get more layers");
|
| 188 |
+
assert.ok(a[0].head, "stage 0 must be the head");
|
| 189 |
+
});
|
| 190 |
+
|
| 191 |
+
console.log(failed ? `\n${failed} failure(s)\n` : "\nall pipeline checks passed\n");
|
| 192 |
+
process.exit(failed ? 1 : 0);
|
| 193 |
+
})();
|
test_wire.js
ADDED
|
@@ -0,0 +1,209 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// Protocol round-trips, and the malformed messages the protocol must refuse.
|
| 2 |
+
//
|
| 3 |
+
// The reason this file exists separately from test_pipeline.js: the pipeline
|
| 4 |
+
// test proves the MATH survives being split. It cannot see a protocol bug,
|
| 5 |
+
// because it never encodes anything — it hands Float32Arrays between stages
|
| 6 |
+
// directly. Every real failure of a distributed system lives in the gap those
|
| 7 |
+
// two tests leave for each other, which is exactly what DaisyChain-Web's own
|
| 8 |
+
// self-corpus writeup concluded after a stalled roster gradient turned out to
|
| 9 |
+
// be a protocol bug that no data oracle could fire on.
|
| 10 |
+
//
|
| 11 |
+
// node test_wire.js
|
| 12 |
+
const assert = require("assert");
|
| 13 |
+
const Wire = require("./public/wire.js");
|
| 14 |
+
const Shard = require("./public/shard.js");
|
| 15 |
+
|
| 16 |
+
const SPEC = { family: "llama", layers: 6, hidden: 64, heads: 4, kvHeads: 2, vocab: 128 };
|
| 17 |
+
const PLAN = [{ id: "p1", index: 0, lo: 0, hi: 2, head: true },
|
| 18 |
+
{ id: "p7", index: 1, lo: 2, hi: 4, head: false },
|
| 19 |
+
{ id: "p3", index: 2, lo: 4, hi: 6, head: false }];
|
| 20 |
+
function assign(over) { return Object.assign({ repo: "owner/model", revision: "main", spec: SPEC, plan: PLAN, mine: 1, fingerprint: 123 }, over || {}); }
|
| 21 |
+
|
| 22 |
+
function bitsEqual(a, b) {
|
| 23 |
+
if (a.length !== b.length) return false;
|
| 24 |
+
const ua = new Uint32Array(a.buffer, a.byteOffset, a.length);
|
| 25 |
+
const ub = new Uint32Array(b.buffer, b.byteOffset, b.length);
|
| 26 |
+
for (let i = 0; i < ua.length; i++) if (ua[i] !== ub[i]) return false;
|
| 27 |
+
return true;
|
| 28 |
+
}
|
| 29 |
+
|
| 30 |
+
let failed = 0;
|
| 31 |
+
function t(name, fn) {
|
| 32 |
+
try { fn(); console.log(` ok ${name}`); }
|
| 33 |
+
catch (e) { failed++; console.log(` FAIL ${name}\n ${e.message}`); }
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
console.log("\nDaisyChain-Infer — wire protocol\n");
|
| 37 |
+
|
| 38 |
+
t("hello round-trips, including the backend string", () => {
|
| 39 |
+
const b = Wire.packHello(1234.5, 0xDEADBEEF, "webgpu");
|
| 40 |
+
assert.strictEqual(Wire.tagOf(b), Wire.HELLO);
|
| 41 |
+
const h = Wire.unpackHello(b);
|
| 42 |
+
assert.strictEqual(Math.fround(1234.5), h.capacity);
|
| 43 |
+
assert.strictEqual(h.probeHash, 0xDEADBEEF);
|
| 44 |
+
assert.strictEqual(h.backend, "webgpu");
|
| 45 |
+
});
|
| 46 |
+
|
| 47 |
+
t("assignment round-trips the model identity and the plan", () => {
|
| 48 |
+
const a = Wire.unpackAssign(Wire.packAssign(assign()));
|
| 49 |
+
assert.strictEqual(a.repo, "owner/model");
|
| 50 |
+
assert.strictEqual(a.revision, "main");
|
| 51 |
+
assert.strictEqual(a.mine, 1);
|
| 52 |
+
assert.strictEqual(a.spec.layers, 6);
|
| 53 |
+
assert.deepStrictEqual(a.plan.map(s2 => [s2.lo, s2.hi]), [[0, 2], [2, 4], [4, 6]]);
|
| 54 |
+
});
|
| 55 |
+
|
| 56 |
+
// An assignment carries no weights by design — each device fetches its own
|
| 57 |
+
// layers from the Hub. This asserts the message stays small no matter how big
|
| 58 |
+
// the model is, which is what keeps a token off the wire and a peer from ever
|
| 59 |
+
// shipping weights to another peer.
|
| 60 |
+
t("an assignment carries no weight data", () => {
|
| 61 |
+
const buf = Wire.packAssign(assign({ spec: Object.assign({}, SPEC, { hidden: 4096, layers: 80, vocab: 128000 }) }));
|
| 62 |
+
assert.ok(buf.byteLength < 2048, `assignment is ${buf.byteLength} bytes — it must never carry weights`);
|
| 63 |
+
});
|
| 64 |
+
|
| 65 |
+
// The failure this rejects produces a plausible answer rather than an error:
|
| 66 |
+
// a plan missing a layer still generates fluent text.
|
| 67 |
+
t("a plan that does not cover every layer is refused", () => {
|
| 68 |
+
const gap = [{ id: "p1", lo: 0, hi: 2, head: true }, { id: "p2", lo: 3, hi: 6, head: false }];
|
| 69 |
+
assert.throws(() => Wire.unpackAssign(Wire.packAssign(assign({ plan: gap }))), /cover 5 of 6/);
|
| 70 |
+
const over = [{ id: "p1", lo: 0, hi: 4, head: true }, { id: "p2", lo: 2, hi: 6, head: false }];
|
| 71 |
+
assert.throws(() => Wire.unpackAssign(Wire.packAssign(assign({ plan: over }))), /cover 8 of 6/);
|
| 72 |
+
const oob = [{ id: "p1", lo: 0, hi: 9, head: true }];
|
| 73 |
+
assert.throws(() => Wire.unpackAssign(Wire.packAssign(assign({ plan: oob }))), /out of bounds/);
|
| 74 |
+
});
|
| 75 |
+
|
| 76 |
+
t("an assignment whose stage 0 is not the head is refused", () => {
|
| 77 |
+
const bad = [{ id: "p1", lo: 0, hi: 3, head: false }, { id: "p2", lo: 3, hi: 6, head: true }];
|
| 78 |
+
assert.throws(() => Wire.unpackAssign(Wire.packAssign(assign({ plan: bad }))), /stage 0 must be the head/);
|
| 79 |
+
});
|
| 80 |
+
|
| 81 |
+
// A repo id is interpolated straight into a Hub URL, so it is validated rather
|
| 82 |
+
// than trusted: a peer should not be able to point this device at an arbitrary
|
| 83 |
+
// path by sending a crafted assignment.
|
| 84 |
+
t("a malformed repo id is refused", () => {
|
| 85 |
+
for (const repo of ["", "nope", "../../etc/passwd", "owner/name/extra", "owner/na me"])
|
| 86 |
+
assert.throws(() => Wire.unpackAssign(Wire.packAssign(assign({ repo }))), /repo id/, `accepted "${repo}"`);
|
| 87 |
+
});
|
| 88 |
+
|
| 89 |
+
t("an assignment with no valid stage index for this device is refused", () => {
|
| 90 |
+
assert.throws(() => Wire.unpackAssign(Wire.packAssign(assign({ mine: 9 }))), /stage index/);
|
| 91 |
+
assert.throws(() => Wire.unpackAssign(Wire.packAssign(assign({ mine: -1 }))), /stage index/);
|
| 92 |
+
});
|
| 93 |
+
|
| 94 |
+
t("ready round-trips", () => {
|
| 95 |
+
const r = Wire.unpackReady(Wire.packReady(2, true, "loaded"));
|
| 96 |
+
assert.strictEqual(r.stageIndex, 2);
|
| 97 |
+
assert.strictEqual(r.ok, true);
|
| 98 |
+
const bad = Wire.unpackReady(Wire.packReady(1, false, "out of memory"));
|
| 99 |
+
assert.strictEqual(bad.ok, false);
|
| 100 |
+
assert.strictEqual(bad.note, "out of memory");
|
| 101 |
+
});
|
| 102 |
+
|
| 103 |
+
t("activation round-trips and carries both hashes", () => {
|
| 104 |
+
const hidden = new Float32Array(16 * 32);
|
| 105 |
+
for (let i = 0; i < hidden.length; i++) hidden[i] = Math.cos(i) * 0.5;
|
| 106 |
+
const buf = Wire.packAct(7, 3, 2, hidden, Shard.hashF32, 0x5150);
|
| 107 |
+
const a = Wire.unpackAct(buf, Shard.hashF32);
|
| 108 |
+
assert.strictEqual(a.seq, 7);
|
| 109 |
+
assert.strictEqual(a.tokenIdx, 3);
|
| 110 |
+
assert.strictEqual(a.nextIndex, 2);
|
| 111 |
+
assert.strictEqual(a.modelHash, 0x5150);
|
| 112 |
+
assert.ok(bitsEqual(a.hidden, hidden), "hidden state changed on the wire");
|
| 113 |
+
});
|
| 114 |
+
|
| 115 |
+
// Corruption in an activation is the quietest failure in the whole system:
|
| 116 |
+
// the next stage happily consumes any float array of the right length.
|
| 117 |
+
t("a corrupted activation is caught by its hash", () => {
|
| 118 |
+
const hidden = new Float32Array(64).fill(1.5);
|
| 119 |
+
const buf = Wire.packAct(1, 0, 1, hidden, Shard.hashF32, 9);
|
| 120 |
+
new Float32Array(buf, 24)[10] = 1.5000001; // one ulp, deep in the payload
|
| 121 |
+
assert.throws(() => Wire.unpackAct(buf, Shard.hashF32), /integrity hash/);
|
| 122 |
+
});
|
| 123 |
+
|
| 124 |
+
t("-0 in an activation survives, and is distinguished from +0", () => {
|
| 125 |
+
// The parent project learned this the hard way: `!==` says -0 === 0, but the
|
| 126 |
+
// hash sees the bits. An activation carrying -0 must arrive as -0, and a
|
| 127 |
+
// flip to +0 must not slip past the integrity check.
|
| 128 |
+
const hidden = new Float32Array(8);
|
| 129 |
+
hidden[3] = -0;
|
| 130 |
+
const buf = Wire.packAct(1, 0, 1, hidden, Shard.hashF32, 9);
|
| 131 |
+
const a = Wire.unpackAct(buf, Shard.hashF32);
|
| 132 |
+
assert.ok(Object.is(a.hidden[3], -0), "-0 did not survive the wire");
|
| 133 |
+
new Float32Array(buf, 24)[3] = 0;
|
| 134 |
+
assert.throws(() => Wire.unpackAct(buf, Shard.hashF32), /integrity hash/);
|
| 135 |
+
});
|
| 136 |
+
|
| 137 |
+
t("token and done round-trip", () => {
|
| 138 |
+
const tk = Wire.unpackToken(Wire.packToken(4, 1337, 42));
|
| 139 |
+
assert.deepStrictEqual([tk.seq, tk.id, tk.total], [4, 1337, 42]);
|
| 140 |
+
assert.strictEqual(Wire.tagOf(Wire.packDone(9)), Wire.DONE);
|
| 141 |
+
});
|
| 142 |
+
|
| 143 |
+
// ---- routing ---------------------------------------------------------------
|
| 144 |
+
// These exist because of a real bug, and they are the shape of test that would
|
| 145 |
+
// have caught it. The head is both stage 0 and the ring's terminus, so the
|
| 146 |
+
// returning activation was addressed to index 0 and every stage read that as
|
| 147 |
+
// "stage 0, run your blocks" — the head re-ran its own layers and forwarded
|
| 148 |
+
// again, and the lap never closed. Every number involved was correct. Every
|
| 149 |
+
// message round-tripped. The pipeline test could not see it because it calls
|
| 150 |
+
// the stages in order itself, and the codec test could not see it because the
|
| 151 |
+
// bytes were fine. The defect was in the ROUTE, so the check has to walk one.
|
| 152 |
+
function walkLap(plan) {
|
| 153 |
+
// Simulate one lap: start at stage 0, follow routeAfter/classifyAct, and
|
| 154 |
+
// record who runs. Returns the sequence of stage indices that computed.
|
| 155 |
+
const ran = [];
|
| 156 |
+
let addr = 0, guard = 0;
|
| 157 |
+
for (;;) {
|
| 158 |
+
if (++guard > 100) throw new Error("lap did not terminate — the ring is cycling");
|
| 159 |
+
let actor = null;
|
| 160 |
+
for (const s of plan) if (Wire.classifyAct(addr, s.index) === "mine") {
|
| 161 |
+
if (actor !== null) throw new Error(`two stages both claim address ${addr}`);
|
| 162 |
+
actor = s.index;
|
| 163 |
+
}
|
| 164 |
+
if (actor === null) {
|
| 165 |
+
if (Wire.classifyAct(addr, 0) !== "return") throw new Error(`address ${addr} is claimed by nobody`);
|
| 166 |
+
return ran; // lap closed at the head
|
| 167 |
+
}
|
| 168 |
+
ran.push(actor);
|
| 169 |
+
addr = Wire.routeAfter(plan, actor).address;
|
| 170 |
+
}
|
| 171 |
+
}
|
| 172 |
+
|
| 173 |
+
t("a lap visits every stage exactly once and terminates at the head", () => {
|
| 174 |
+
for (const n of [1, 2, 3, 5, 8]) {
|
| 175 |
+
const plan = Array.from({ length: n }, (_, i) => ({ id: "p" + i, index: i, lo: i, hi: i + 1, head: i === 0 }));
|
| 176 |
+
assert.deepStrictEqual(walkLap(plan), plan.map(s => s.index),
|
| 177 |
+
`a ${n}-stage ring did not visit each stage exactly once in order`);
|
| 178 |
+
}
|
| 179 |
+
});
|
| 180 |
+
|
| 181 |
+
t("the return leg is addressed differently from stage 0", () => {
|
| 182 |
+
const plan = [{ id: "p0", index: 0 }, { id: "p1", index: 1 }];
|
| 183 |
+
const last = Wire.routeAfter(plan, 1);
|
| 184 |
+
assert.strictEqual(last.to, "p0", "the last stage must hand back to the head");
|
| 185 |
+
assert.ok(last.isReturn, "the last hop must be flagged as the return leg");
|
| 186 |
+
assert.notStrictEqual(last.address, 0,
|
| 187 |
+
"the return leg is addressed as stage 0 — the head will re-run its own blocks and the lap will never close");
|
| 188 |
+
assert.strictEqual(Wire.classifyAct(last.address, 0), "return");
|
| 189 |
+
assert.strictEqual(Wire.classifyAct(0, 0), "mine"); // outbound, same number, different meaning
|
| 190 |
+
});
|
| 191 |
+
|
| 192 |
+
t("a single-stage ring returns to itself immediately", () => {
|
| 193 |
+
const solo = [{ id: "p0", index: 0 }];
|
| 194 |
+
const hop = Wire.routeAfter(solo, 0);
|
| 195 |
+
assert.ok(hop.isReturn && hop.to === "p0");
|
| 196 |
+
assert.deepStrictEqual(walkLap(solo), [0]);
|
| 197 |
+
});
|
| 198 |
+
|
| 199 |
+
t("every sentinel is distinct and cannot collide with a step number", () => {
|
| 200 |
+
const tags = [Wire.FRAG, Wire.HELLO, Wire.ASSIGN, Wire.READY, Wire.ACT, Wire.TOKEN, Wire.DONE];
|
| 201 |
+
assert.strictEqual(new Set(tags).size, tags.length, "two sentinels share a value");
|
| 202 |
+
for (const t2 of tags) assert.ok(t2 < 0, `sentinel ${t2} is not negative — it could be mistaken for data`);
|
| 203 |
+
// DaisyChain-Web uses -2..-8; ours start at -20 so a misdirected client sees
|
| 204 |
+
// an unknown tag rather than a valid message of the wrong kind.
|
| 205 |
+
for (const t2 of tags) assert.ok(t2 === Wire.FRAG || t2 <= -20, `sentinel ${t2} overlaps DaisyChain-Web's range`);
|
| 206 |
+
});
|
| 207 |
+
|
| 208 |
+
console.log(failed ? `\n${failed} failure(s)\n` : "\nall wire checks passed\n");
|
| 209 |
+
process.exit(failed ? 1 : 0);
|