# Making a 166,000-neuron brain bit-exact on any GPU

> Source: <https://dev.to/alexandros_titonis_43d6ea/making-a-166000-neuron-brain-bit-exact-on-any-gpu-59p2>
> Published: 2026-10-18 21:12:18+00:00

*How we got a spiking fruit-fly connectome to produce identical spikes on NVIDIA, AMD and a CPU, so strangers' browsers can run it and every answer can be checked.*

*Disclosure: fly.ai is funded by a memecoin ($FLYAI); nothing described here needs the token, and the code is MIT-licensed.*

fly.ai simulates the complete central nervous system of an adult male fruit fly: the MaleCNS v1.0 connectome, **166,700 neurons and 25.6 million connections**, as a network of leaky integrate-and-fire neurons. Each step, every neuron leaks a little, adds up input from the neurons that fired into it, and spikes and resets if it crosses threshold.

We wanted volunteers to run that brain for us from a browser tab: open a page, lend a GPU, and the network works through a grid of experiments. That raises a problem that sounds boring and turns out to be the whole design: **how do you check a stranger's answer?**

The obvious way to check a volunteer is to re-run a sample of their jobs on the server and compare. For that to work, an honest machine must produce *exactly* the server's result. Not close. Exactly.

Floating point can't promise that across GPU drivers:

`a*b + c` into a single FMA with one rounding instead of two. Whether it does depends on the driver.
For most numerical work, a different last bit is noise. For a spiking network it's fatal. Somewhere among 166,700 neurons, one voltage sits a hair under threshold on one GPU and a hair over on another. That neuron spikes a step early, its targets get input a step early, and within a few steps the two spike trains have nothing to do with each other. Two honest GPUs would disagree, and the checker would zero both.

So we stopped using floats.

Compute jobs run the connectome entirely in integers, in a module called `fixed.ts`. The representation is fixed-point:

| voltage | Q16 (1.0 = 65536); spike at ≥ 65536, reset to 0 | 
| synapse | Q20 weight; inputs summed with 32-bit wraparound | 
| update | `v ← mulshift16(v, decay) + mulshift16(input, gain·4096) + tonic + drive + noise` | 
| noise | `lowbias32(neuron, step key)` under a threshold | 
| record | each spike adds a keyed hash of (neuron, step) into two 32-bit sums | 

A few of these choices deserve a closer look.

Voltage is a 32-bit integer where 65536 means 1.0, the firing threshold. Synaptic weights get more fractional bits (Q20), because each weight is a synapse count normalized so a neuron's inputs add up to 1. Spread across many inputs, individual weights are small, so they get finer resolution than the voltage.

The two scales meet in the update. Summed input is in Q20; multiplying it by the gain expressed in Q12 (`gain·4096`) and shifting right by 16 lands the result back in Q16, the voltage's scale. Decay works the same way: a Q16 multiplier on a Q16 voltage, shifted by 16.

This is the trick that kills the summation-order problem. Integer addition modulo 2³² is associative and commutative. If every input to a neuron is added with 32-bit wraparound, the final sum is the same whatever order the threads add in. A CPU walking a neuron's inputs in one order and a GPU walking them in another produce the same bits.

Every operation in the integer engine wraps at 32 bits exactly the way WGSL does, so the CPU (WebAssembly) and the GPU (WebGPU) implement literally the same arithmetic.

The model needs noise: a neuron fires spontaneously now and then. A shared random number generator is a disaster for parallel determinism, since the sequence of draws depends on scheduling. Instead, each neuron's noise is a hash: `lowbias32` of the neuron index and a per-step key, compared against a threshold. Every neuron draws independently, there's no state to share, and any machine can compute neuron 91,204's noise at step 600 without knowing anything else.

The answer a volunteer sends back has to capture the whole run, not just a few counts. Every spike adds a keyed hash of (neuron, step) into two 32-bit sums. Because they're sums, they don't depend on the order spikes are recorded in, which matters when thousands of neurons fire in the same step on parallel threads. Two different spike trains colliding in both sums by accident is vanishingly unlikely, and a cheater can't aim for a known value, because the hashes are never published.

The one place floats sneak back in is setup: the weight table and the decay constant come from `Math.exp`, and `Math.exp` can differ in the last bit between JavaScript engines. So the clients don't compute them. The server ships those numbers in `/api/model`, and every engine starts from the same integers.

Quantizing a brain could quietly turn it into a different brain. So `npm run validate` compares the integer engine with the float engine (the one the 3-D fly world uses) over 6 conditions × 4 seeds:

They agree statistically, not spike for spike. That's the honest framing: the integer brain is a slightly different simulation of the same wiring, and on every measure we checked it behaves like the original. What it adds is that the integer brain agrees with *itself*, bit for bit, everywhere.

The WebGPU engine runs the integer brain for up to 32 jobs at once. Each simulated step is two compute passes, one invocation per neuron:

The batching trick is in the first pass. `fired[j]` isn't a boolean; it's a 32-bit mask with one bit per brain. Each neuron walks its incoming synapses, and for each input one 32-bit read says in which of the 32 brains that input fired. Most neurons are silent most of the time, so an input that's silent in all 32 brains costs a single read and nothing else.

A final pass totals each brain's record and spike counts, and only that small result is read back to the CPU. The heavy state never leaves the GPU.

Each job is 15 simulated seconds: 5 at rest, then 10 with a sensory channel driven (one of the eye channels LPLC2, LC4, LPLC1 or LC10a, or touch, on either side), recording spikes per motor group in both windows. The grid covers 4 drive strengths, 3 gains, 3 tonic levels and 3 seeds, plus undriven controls: 1,107 jobs per round, with a new round of seeds added whenever the queue runs low.

Benchmarked in headless Chrome on one laptop with two GPUs:

| jobs/hour | CPU, 1 thread | GPU, 1 job | GPU, batch 16 | GPU, batch 32 | 
|---|---|---|---|---|
| RTX 4060 Laptop | 579 | 1,144 | 9,278 | **11,129** (19× a CPU thread) | 
| Radeon 890M (integrated) | 514–583 | 216–355 | 2,121–2,929 | 3,263–3,881 (5.6–7.6×) | 

And the part this whole design exists for: **four very different jobs gave identical hashes and spike counts on the CPU, on both GPUs, and at scattered positions in a batch of 32.** NVIDIA and AMD agree bit for bit.

In real mining from the live page at batch 32, the RTX 4060 ran 640 jobs in 5 minutes, at 124–162 jobs a minute (the spread is likely thermal throttling). The server checked 13 of those answers; all matched. The Radeon 890M ran 65.7 jobs a minute; the server re-ran 9, and all matched.

Phones run it too, on CPU threads or WebGPU where the browser supports it. Each CPU thread holds about 350 MB, so phones get at most 2 threads, and GPU batches start at 8.

Once every honest machine produces the same bits, checking becomes cheap and statistical instead of expensive and exhaustive:

None of that works with floats. With exact integers, a single mismatch is proof of a wrong answer, not a disagreement between drivers.

The result is a volunteer compute network where anyone's browser can run a full fly connectome and nobody has to be trusted. Checked work is credited as points to the miner's wallet. And because the brain is just integer arithmetic in a page, the same network can run other work too: buyers' WebAssembly programs, GPU shaders and text embeddings.

The neuron model is simple: point neurons, one global parameter set, no dendrites, no graded neurons, a rough sign rule for transmitters. None of it is validated against recordings from real flies. Bit-exactness makes the simulation *reproducible*; it doesn't make it *right*. And the float engine elsewhere in the project still agrees with the integer one only statistically.

But for the narrow goal, many untrusted machines running the same 166,700-neuron brain and getting the same answer, integers turned out to be the whole solution.

`pip install flybrain`
`fixed.ts` and the WebGPU engine: `mine/`)
*Connectome: MaleCNS v1.0 by FlyEM (HHMI Janelia), the University of Cambridge, the MRC Laboratory of Molecular Biology and Google Research, used under CC BY 4.0. The neuron model is adapted from Fly64 by Jessica Paquette.*

*Originally published on [Medium](https://medium.com/@alextitonis/making-a-166-000-neuron-brain-bit-exact-on-any-gpu-e1df60adaf00).*
