Join the conversation

Join the community of Machine Learners and AI enthusiasts.

Sign Up
Quazim0t0ย 
posted an update 5 days ago
Post
4948
๐ŸŒผ DaisyChain-Web: train a language model with friends or by yourself with multiple devices, in the browser, no install

Open a webpage, share a room link, and every device that joins becomes part of the training cluster. Phones, laptops, old PCs: they connect peer-to-peer over WebRTC and train one shared transformer together, entirely in the browser.

What's actually happening under the hood:

๐Ÿง  A mini transformer LM trains on FineWeb-Edu, streamed live from the HuggingFace Hub. Each device pulls its own slice (data parallelism), tokenized with our 16.5k-token Spikewhale tokenizer
โšก Every single multiply runs through verified INT8 neural units, no float fallback. On WebGPU browsers it uses the GPU's DP4A integer dot-product hardware, admitted only after proving bit-identical results against the verified units, with a 3ร—INT8 fast-accurate scheme (CUTLASS's 3xTF32 trick, ported to 8-bit)
๐Ÿ”’ Devices average gradients every step under a sync guard: a per-step roster protocol plus weight-hash verification keeps every device's model bit-identical. If anything drifts, training stops instead of silently forking
๐Ÿ“Š Live logs show exactly what every device contributes, step by step
๐Ÿ’พ When you're done: test generations right on the page, download a checkpoint, or grab the inference kit, a single self-contained HTML file with the weights baked in that runs generations offline, anywhere
Works solo too. Every extra device just grows the effective batch.

๐Ÿ‘‰ Try it: Quazim0t0/DaisyChain-Web
๐Ÿ›  Training framework: DaisyChainAI/DaisyChain-Train

Updates:
- Block-scaled INT8 quantization
- Batched attention GEMM
- Fused dequant+ReLU epilogue
- Weight-tied unembedding
- WebSocket relay fallback
- Server keepalive ping/pong every 30s
- disconnected-state redial
- Visibility/network-change reconnect

The headline is "train with friends in the browser". The real story is the sync guard.

Averaging gradients across random phones and laptops is the easy part. Deciding you would rather stop than silently fork is the hard call, and most projects don't make it. The reflex is a tolerance check on the averaged weights, and that is exactly where correctness illusions hide.

We built a 26-op GPU kernel corpus to measure that. 10 of the ops are LLM-style buggy variants, and they pass torch.allclose(kernel, ref) on one shape, one dtype, one seed. A flash-attention kernel missing the acc*alpha rescale on the running-max update still passes. Tolerance oracles are blind to whole bug classes. A weight hash isn't.

So the DP4A admission gate is the part I want to understand. Does a device prove bit-identical once when it joins, or does it keep getting re-checked while it runs? Heterogeneous fleets are where drift shows up late, not at startup.

ยท

As always, I am appreciative of your comments. They always make me check my work.

Short answer: once, at startup. Longer answer: I went and re-read my own gates instead of answering from memory, and it's worse than I would have told you.

The one gate I had that was actually exact (a plain != check, no tolerance, since int8 x int8 into int32 has no rounding to hide in) was sitting on a kernel my transformer stopped calling when I switched to block-scaled quantization. It still ran at boot. It still passed. It still printed "HW verified vs units" in the UI. It was verifying a code path that does no work. That's worse than having no gate, because it looks like coverage.

Why my live kernels only had tolerance checks is the part I think you'll appreciate. Fusing the dequant epilogue into the kernel is what destroyed the exact oracle. The int32 accumulator gets multiplied by the block scales in f32 inside the kernel and never comes back to the host, so there was nothing exact left to compare against. I bought throughput and paid for it in observability, and I didn't notice the invoice until you asked.

The fix was to emit two variants of each shader from the same source string, where the only difference is the final write (one does the epilogue, one dumps the raw int32). Same indexing, same instruction, so gating the verify variant actually gates the real kernel. Then the surprising bit: once I made my JS mirror round the way WGSL rounds (f32 after the int cast, f32 after each multiply, instead of doing the whole chain in f64), the epilogue passed an exact check too, on real hardware. The tolerance was never covering hardware variance. It was covering a bug in my own mirror.

But the part you were really getting at is the one I had backwards. I'd been thinking of the weight hash as a partial backstop on kernel correctness. It isn't one at all. Weights only depend on the gradient bytes everyone receives, so if one device has a broken kernel it broadcasts a bad gradient, everybody averages the same bad bytes, and every replica stays bit identical and perfectly happy. There is no kernel disagreement the weight hash can detect. It's not a weak check on that axis, it just isn't a check on that axis.

So there's a canonical probe now. A fixed seeded GEMM run through each device's live kernel, hashed on the raw int32 accumulator (exact on every backend), sent with every gradient and re-run every 25 steps. Same input plus correct kernels means the same number whether you're on a phone GPU or a CPU fallback. My CPU mirror and my GPU DP4A path both come out to 2186402845, which is honestly the first evidence I've had that those two agree. There's also a random cell audit that re-checks live GEMMs against the units mid run at the real shapes, since toy shapes at boot were never going to catch thermal or shape dependent drift.

Then I did the thing your corpus implies and mutation tested my own gate. Five injected bugs (dropped batch stride, short K loop, missing column scale, dropped ReLU, wrong rounding) all rejected. The old allclose gate passes the rounding one that the exact gate catches, so your point reproduced itself in my own code. Live fault injection: 1.08e-7 drift, an order of magnitude below the old tolerance, caught mid run at 256x32x32.

The dead gate is the finding, not the fix. A gate sitting on a code path that does no work is the correctness illusion in its purest form. Green, cheap, measuring nothing, and printing "HW verified" while it does it. Same shape as our buggy variants passing allclose on one shape and one seed: the check runs, the check is blind, the UI says verified.

One thing I want to push on, because I think you have it one level too optimistic.

The canonical probe checks agreement, not correctness. Every device compiles the same WGSL source string. So a bug in that string is bit-identical on a phone GPU and on the CPU fallback, and 2186402845 comes back matching everywhere, cheerfully. What the probe really catches is hardware, driver and backend divergence. That is worth catching. But it is blind to exactly the bug class your mutation test caught, because a mutation in shared source propagates to every replica in the fleet. It is the same argument you just made about the weight hash, one layer up.

Which puts all the weight on the mirror. That is differential testing, and it is strictly stronger than a tolerance oracle, but its power is bounded by how independent the two implementations are. You just made the JS mirror round the way WGSL rounds. Right call for that bug, and it bought you a real exact check. It also spends independence. Every time the oracle gets tuned to agree with the thing it is checking, it gets a little weaker. This time the rounding bug was in the mirror. Next time it is in the shader, and a mirror that has been shaped to match will nod along.

So when the two disagree, what decides which one is wrong? Is the WGSL spec the referee, or is the shader?

ยท

Last time I thought the weight hash covered kernel correctness. It doesn't, it covers replica agreement. Now you're pointing out the probe covers backend agreement, and I called that correctness too. Same mistake, one floor up. I keep looking at two things matching and reading it as two things being right.

The referee question is the good one, and the answer is: me. Which is not a great answer.

In my defense, for about one second, the rounding fix wasn't quite "shader disagreed so I changed the mirror." The WGSL spec says f32 rounds after every op, and my mirror was doing the whole chain in f64 and rounding once at the end. So, the spec is what told me the mirror was wrong. The GPU just happened to agree. But nothing in the repo enforced that. It was me, with a red test in front of me, wanting it green. The difference between "spec says the mirror is wrong" and "shader says the mirror is wrong" lived entirely in my head, and I know which one I'd reach for on a bad day.

One small pushback. The mul8 table isn't a reimplementation, it falls out of the Python side as a trained artifact, and the DP4A kernel never touches it. So, table vs hardware is a real differential test with a referee neither side wrote. That part I think survives. What doesn't survive is the loop around it. The indexing, the strides, the accumulation, all of that came out of the same head as the shader on the same afternoon. Two implementations, one brain, same blind spots. You're right and I've got nothing.

So, I went looking for something that doesn't need a second implementation at all, and it turns out you can just use properties. A zero row of A has to give a zero row out. Permute the rows of A and the output rows permute the same way. A batched call has to match running each one on its own. None of that comes from anyone's version of the thing, it comes from what the thing is. And when one fails there's no referee argument to have. It's just wrong.

They bite, too. The batch one catches a dropped stride with nothing to compare against. The permutation one catches swapped rows. And a bug sitting in shared WGSL, the exact thing the probe can't see, can't hide behind agreement, because every replica breaks the property at the same moment.

Catch is they're partial. My zero-row property walks straight past the row-swap bug that the permutation one catches. You only cover what you thought to write down, which is your original point again, one floor further down. This building has a lot of floors.

Next up is your actual tiebreaker: an IEEE-754 oracle done in exact integer math, straight from the standard, so the spec is something that runs instead of something I remembered right once.

Doesn't fix the author problem though. I wrote the kernel, the mirror, and the properties. That's what an outside corpus does that nothing in my repo can. Asking again in case it got lost in the noise the first time.

You don't need my corpus to run. You need the bug list to have a different author.

Runnable was never the ask anyway. Our 26 ops are Triton and numpy, yours is WGSL in a browser, nothing crosses that gap. What crosses is the taxonomy. 10 of the 26 are LLM-style buggy variants, and each one is a meta.json plus an fp64 reference plus the kernel, so the bug is specified rather than just compiled: acc= where it should be acc+=, attention missing the 1/sqrt(D) scale, flash-attention missing the acc*alpha rescale on the running-max update. That is an afternoon of WGSL. The bugs came out of my head, not yours, which is the entire value.

But the better use is not the one you are reaching for. Don't point them at your kernel. Point them at your properties.

The zero-row, permutation and batch relations are the strongest thing in this thread, because they fall out of what the thing is and nobody gets to argue the referee. You already named the catch yourself: you only cover what you thought to write down. So measure it. Run 10 bugs you did not write through the property suite. Every one the properties wave through is a hole with a name on it, instead of a feeling you have about your own coverage.

That turns "partial" into a number. Mutation-score the oracle, not the kernel. That is exactly what we did to torch.allclose, and allclose scored badly.

Specs are here if you want them: https://huggingface.co/datasets/dipankarsarkar/gpuemu-corpus

The IEEE-754 integer oracle is the right next build and it will fix rounding. Does it fix indexing? A spec oracle tells you what one op should return. Your dropped batch stride was never wrong about arithmetic. How many of your bugs live in the math, and how many live in the loop around it?

ยท

Late reply. The work got done before the words did.

Corpus went in. Properties scored 0 out of 4.

Best failure: your multiple-of-128 bug zeroed the whole output and every property passed, because zero permutes fine, preserves zero rows, and decomposes across batches. My suite could be aced by doing nothing. Worse: both mutants I wrote myself were structural, so my mutation test had the same author problem as my kernel. You called that one from across the room.

Added non-triviality and sensitivity. Now 2/4, and the split is clean: 2/2 on loop bugs, 0/2 on math bugs. Differential gate 4/4. So properties are a loop-bug detector, full stop. There is no property that catches a uniform 2x without a reference. I stopped looking.

The oracle got built. BigInt, straight from the standard, no fround inside. Agrees with the mirror on 500k inputs, rejects my old round-once mirror on 34% of them. It caught a bug on its first run: mine, in its own harness. (a < 0) misses -0. Even the referee needed a referee.

Your closing question: no, the spec oracle does not fix indexing, and here's the tally you asked for. This month's bugs: a dead gate, a stripped binding, a sum that rounded once where it should round three times, and a gradient that reached the leader but not one follower and parked a live fleet for hours. That last one isn't math or indexing. It's delivery. Almost none of my bugs live where the oracles look. The oracles just make that claim checkable instead of comfortable.

One more, in your key: new GPU quantize kernel passed its exact gate, then I realized the old spec would have passed too, since random data never hits a rounding boundary. Searched 4000 inputs for one where the specs disagree, found it, GPU sided with the new spec. Mutation-score the gate. Allclose all the way down.

test_corpus.js has your bug names in the output. Partial is a number now. It was worse than the feeling, which is the point.

Your 0/2 on math bugs is not a gap in the suite. It is a theorem, and it means you stopped one step early.

Every property you wrote is an invariance. Permute the rows, rows permute. Split the batch, results split. Zero row in, zero row out. Each one says f commutes with some transform. A uniform 2x is multiplication by a scalar, and a scalar commutes with all of them. f(2A,B) and 2f(A,B) both come out 4AB. It passes because it has to. Add ten more invariances and it passes those too. Your non-triviality and sensitivity checks do not help either: 2AB is still nonzero and still moves when the input moves.

I ran your four against a uniform 2x. All four green. That is why the split came out a clean 2/2 and 0/2 instead of landing somewhere messy in between.

So invariances cannot see magnitude. Not in practice. Ever.

What sees magnitude is an anchor. A point where the spec pins an exact value instead of a relation.

matmul(A, I) = A. No reference, no second implementation, straight out of what matmul is. A uniform 2x hands back 2A and dies on the spot. ones(1,D) @ ones(D,1) = D, same deal. Both catch what all four of yours wave through.

That is still property testing. It is just the other half, and it is the half you do not have. Invariances pin shape. Anchors pin scale. You need both.

Now the harder one. You scored your suite against my bugs, and my bugs are kernel math and kernel loops, because that is the paper I was writing. Your own tally for this month: a dead gate, a stripped binding, a sum that rounded once where it should round three times, and a gradient that reached the leader but not one follower. One of those four lives in my corpus. Three do not.

So 2/4 is a real number measured against the wrong population. It tells you how your oracles do on my bugs. It says nothing about the gradient that parked a live fleet for hours, and that is the one that cost you the afternoon.

You already said it: almost none of your bugs live where the oracles look.

You have four bugs from this month with names and fixes. What does the corpus of your own bugs score?

ยท

You called it, and funnily enough I got there a few hours before your message landed. Two anchors shipped: relu output can't go negative, and at unit scales the output IS the integer dot product. Same species as your matmul(A, I) = A. Your corpus went 0/4 to 2/4 to 4/4, and the last two took exactly what you said: stop writing relations, start pinning values. The c*out argument is a theorem and I stopped fighting it.

Then I ran your harder question, because it was the right one. My four July bugs, scored against everything I have:

properties 0/2. Both data bugs are the theorem again. The stripped binding is a per-column scalar, the round-once sum is a last-ulp schedule change, and my shiny new anchor sits at unit scales, which is precisely where both are invisible. The exact differential gate catches both.

But that's only half the corpus. The dead gate is a bug in a checker. No oracle sees it, only mutation-testing the gate does. The roster stall is a bug in the protocol. Every value on every peer was correct, so no data oracle CAN fire. Caught it with a liveness sim: drop one gradient asymmetrically, old protocol parks forever, repair protocol finishes with identical weights.

So, the honest answer to "what does your own corpus score" is: my oracles go 2/4, and the other two needed instruments that aren't oracles at all. The population defines the instrument. Your bugs taught me anchors. Mine taught me to test the tests and the protocol. It's test_selfcorpus.js in the repo now, red bar and all, so the next time I feel good about a scoreboard I can check whose bugs it was measured on.

2/4, and the other two off the oracle axis entirely. That is the result, and it is cleaner than either of us guessed a week ago.

Look at what the four bugs sorted into. Shape wants an invariance. Scale wants an anchor. A dead gate wants you to mutate the gate. A parked fleet wants a liveness sim. Four bugs, four instruments, and only the first two are oracles at all.

So the map is not oracle versus no-oracle. It is: what is the smallest thing you can pin so a class of wrong has nowhere to sit? An anchor pins a value. Mutation testing pins the gate's power to tell correct from mutant. A liveness sim pins a fixed point in time. Every one trades "looks right" for "must equal this," each on a different axis.

And the tower closes. You mutation-test the gate. Can you mutation-test the mutation gate? Yes, same operation, so it is its own fixed point. That is why "test the test" does not regress forever. Liveness never joins the tower, it lives on the time axis, which is exactly why the roster stall was invisible to every data oracle and cost you the afternoon.

The forward move: you now have a triage rule, not a postmortem. New bug lands, you ask shape, scale, checker, or liveness before you write a line. And your own data hands you the prior: if it cost you the afternoon and no data oracle fired, bet liveness. The math bugs are cheap and loud. The delivery bugs hide.

When the next one lands, which bin does it fall in before you know the fix?

ยท

Two landed yesterday while I was building a GPU buffer pool, and I got to bin them live.

Bug one: pooled readbacks came back bucket-sized instead of logical-sized and a Float32Array.set exploded on the first step. Shape. Loud, cheap, dead in five minutes. Your prior holds: the math-adjacent ones announce themselves.

Bug two is the interesting one. Pooled buffers are not zero initialized, and the rowmax kernel accumulates with atomicMax assuming zeros. Here is the trap: every buffer is FRESH on its first acquisition, and the init gates run first. So the gates pass on virgin zeroed memory, then step two reuses a dirty buffer and the scales go quietly wrong. A gate that provably cannot see the bug class, not because it is weak but because it runs at the wrong time. That is not shape, scale, checker, or liveness. It is state: wrong only after reuse. Fifth bin, or liveness's little sibling, since both live on the time axis and both are invisible to anything that checks a single call once.

I caught it in review, and the continuous audit would have caught it live, because it recomputes random cells of the real GEMMs mid-training, so it samples the dirty steps and not just the virgin one. The instrument for the state axis already existed. I just had not named the axis it was covering.

So, before the fix: if it is loud, bet shape or scale and relax. If it is quiet and the init gates are green, bet the time axis, state or liveness, and reach for whatever samples repeatedly instead of once. And your tower note carries: the audit that owns the state axis is itself mutation-tested, a planted wrong cell must trip it, so it joins the fixed point with the rest.