trap_hunt, palette_bench and room_escape ran the frame through NeuralAgent::tick, which installs a frame and its rewards after the next ticks: one frame behind the stream, with the ratchet observed without the objective signal and a rollback that never re-observed the scene. They now restore the way the stream restores and run LegacyFrame::transition and ::boundary, looking in through FrameObserver; FLY_TRACE works in each of them. The stub-readout drivers of rom_macros_mode, rom_catch and scene_probe run LegacyFrame::execute and ::stub_advance, which are the same calls they made, in the same order. |
||
|---|---|---|
| .. | ||
| cuda | ||
| examples | ||
| src | ||
| tests | ||
| Cargo.toml | ||
| README.md | ||
flybrain-core
The neural core of packages/brain, ported to Rust and bit-exact with it under the default
configuration: the dataset loader, the 1-ms LIF kernel, reward-modulated plasticity, the
population readout, the agent loop and the checkpoint envelope.
The TypeScript library stays the reference implementation and the oracle
(docs/verification.md). This crate is not allowed to be close to it.
Layout
| Module | Ports |
|---|---|
dataset |
dataset/format.ts, dataset/load-node.ts |
rng |
model/rng.ts |
retina |
model/retina.ts |
lif |
model/lif.ts |
plasticity |
model/plasticity.ts |
decoder, decoder::gameboy |
readout/decoder.ts, readout/presets/gameboy.ts |
agent |
agent/agent.ts |
envelope |
agent/envelope.ts |
version |
model/version.ts |
jsmath, json, ordered |
JavaScript semantics the port needs (see below) |
bitset, pool |
nothing — the index and threading machinery the kernel's speed rests on |
What bit-exactness required
Three classes of difference between Rust and JavaScript had to be handled explicitly. All three
are in src/jsmath.rs.
Storage rounding. JavaScript computes in f64 and rounds to f32 only when a value is stored
into a Float32Array. Every such store in the kernel is written as one f64 expression with a
single as f32, which is the IEEE round-to-nearest-even a Float32Array store performs. Nothing
in the kernel computes in f32, and nothing uses mul_add: contracting
membrane * decay + baseline into an FMA would keep a wider intermediate and change the result.
NaN handling. f64::min and f64::max return the non-NaN operand; Math.min and Math.max
propagate NaN. js_min and js_max follow JavaScript, and are used for the membrane floor, the
trace clamp, the gain clamp and the retina's pixel clamp.
Transcendentals. Math.exp and Math.tanh in V8 are the fdlibm ports in
src/base/ieee754.cc, not the platform libm, and the difference is not cosmetic: a 1-ulp change in
exp(-dt/20) can move a Float32 gain, which moves a membrane, which moves a spike, after which the
two implementations have nothing in common. Measured against V8 (golden/math.flygold):
| Function | Arguments checked | f64::_ (glibc) |
libm::_ |
This crate |
|---|---|---|---|---|
exp |
exp(-k/5000), k in 0..=200,000 |
19,758 differ, 1 ulp | exact | exact (libm::exp) |
exp |
exp(-dt/20), dt in 1..=100 |
10 differ, 1 ulp | exact | exact (libm::exp) |
tanh |
1,400,000 seeded doubles | 7,095 differ, ≤ 3 ulp | 139,693 differ, ≤ 3 ulp | exact |
libm::exp is the MUSL port of the same fdlibm __ieee754_exp V8 carries, so exp comes straight
from libm. libm::tanh is a different algorithm and is in fact further from V8 than glibc's, so
jsmath::tanh transcribes fdlibm's s_tanh.c wrapper — which is a thin shell around expm1, and
libm::expm1 is bit-identical to V8's. The golden test re-checks all of this on every run,
including a SHA-256 over exp(-k/5000) for k in 0..2,000,000.
Two smaller things in the same family:
serde_jsonneeds itsfloat_roundtripfeature. Without it the fast float parser can land 1 ulp away fromJSON.parse, which first showed up as a bogusfround(exp(-1/25))mismatch before any kernel code was involved. It is pinned in the workspace manifest with a comment.JSON.stringifyis reimplemented (src/json.rs) because the dataset fingerprint hashesJSON.stringify(meta).serde_jsonwrites1.0where JavaScript writes1, and key order has to survive the circuit-role merge. Parsing still goes throughserde_jsonwithpreserve_order; only the writer and the number formatting (ryu-js) are ours.
Observed divergence from the oracle
None. Every golden scenario compares with exact equality — integer arrays, RNG state,
spike times, membranes, refractory counters, traces, gains, touched timestamps, rates, digests and
per-frame button masks alike — and not one value differs by one ulp. The task this port was
commissioned under allowed a documented tolerance of up to 2 ulp for traces and gains if
transcendentals forced it; that allowance is unused, and the comparison helpers in
tests/common/mod.rs report ulp distance only so that a future failure says immediately whether
it is a rounding difference or a real divergence.
Golden tests
packages/brain/tools/golden.ts writes ../../golden/*.flygold; regenerate with:
npx tsx packages/brain/tools/golden.ts # all of them
npx tsx packages/brain/tools/golden.ts platformer # one, leaving the rest byte-identical
Golden files are the library's own checkpoint envelope with the magic FLYGOLD1 — a JSON manifest
plus named binary chunks — so array values are carried verbatim rather than reformatted, the files
stay small, and every golden test also exercises the Rust envelope port.
| Scenario | Covers |
|---|---|
math |
the transcendental arguments the kernel produces, on their own |
toy |
the four-neuron fixture over 3,000 ms; every array compared entry by entry at three dumps |
agent |
the 4,096-neuron synthetic connectome through NeuralAgent for 600 frames; every button mask, digests at frames 200 and 400, full arrays at 600 |
real |
data/fafb-v783 for 200 ms; the fingerprint, role order, plastic-edge selection, topology hash, per-millisecond spike counts, digests and 256 sampled values |
versions |
kernel and plasticity version strings for the default configuration and every individual numeric parameter |
platformer |
the platformer decoder preset over 4,000 quantized steps: every preset field, every button mask, and the final decoder state. Rates, clock, boot flags and clear points travel as chunks, so the Rust side decodes the oracle's input rather than a reconstruction of it |
Scenario fixtures travel inside the golden file — the toy connectome as manifest JSON, the synthetic connectome as chunks — rather than being re-derived in Rust. Only the seeded RGBA frame generator is reimplemented here, and each manifest carries a SHA-256 of its frame pool so a mistake there fails as itself instead of as a mystery state divergence.
Deterministic parallelism
The rule: no result may depend on the thread count. golden_toy, golden_agent and
golden_real each replay their whole scenario at several thread counts and byte-compare the
exported state against the sequential run, so this is a test and not an intention. golden_real
covers 1, 2, 3, 7 and 16 — an even split of the neuron range, two odd ones, and more workers than
the machine has cores — and golden_toy, whose fixture has four neurons, goes to 16 so that the
degenerate partition with empty shards is covered too.
Three phases of a tick are parallel. All three are partitioned into fixed contiguous index ranges derived from nothing but the dataset and the worker count, and every argument below is about order, not about locking — no value is ever written by two workers.
The neuron sweep, partitioned by neuron index. Each range writes its spikes into its own slice of a scratch buffer and the slices are compacted in range order, so the spike array is the sequential one for every thread count. Each neuron's update reads only its own membrane, refractory counter and baseline.
Spike propagation, partitioned by target range. This is the one that needs an argument.
Each worker owns a disjoint, contiguous slice of membrane and walks the whole spike list,
applying the edges of each spiked source that land inside its own range. Claim: for every target,
the sequence of additions is exactly the sequential one.
- The sequential kernel visits
(source, edge)in source-major order, and within a source in ascending CSR slot order. - A worker visits the same spike list in the same order and the same rows in the same order, and applies a subsequence of that same edge sequence — the edges whose target is in its range. A subsequence preserves the relative order of every pair of elements it keeps.
- Restrict that subsequence to a single target
t. It is the sequential sequence restricted tot: the same(weight, gain)pairs in the same order. membrane[t]depends on nothing else. Each step reads onlymembrane[t]'s previous value, addsweights[e] * gain(e) * synapseScale, applies the floor clamp and rounds once to f32. Same operands in the same order means the same bits at every step, including where the clamp bites.- Targets in different shards never interact, and every target is in exactly one shard.
Note what the argument does not need: the shards need not be balanced, the worker count need not be stable, and a source need not hit a given target at most once. It needs only that every worker traverse the spike list and each row in the sequential order.
Two details ride on top of it and change nothing:
- The shard bounds are balanced by in-degree, not by neuron count, because in-degree runs from
0 to 5,080 on
fafb-v783and an even index split leaves one worker several times behind the rest. A different split only moves which worker applies an edge. - When every CSR row is target-ascending —
fafb-v783's are; the synthetic golden fixtures' are not, so it is checked at plan time rather than assumed — a worker binary-searches its window in the row instead of filtering per edge, so each edge is read by exactly one worker rather than by all of them. Same edges, same order.
plasticity.observe, partitioned by slot range, on the same argument as propagation with
the slots in place of the targets.
Each worker owns a disjoint, contiguous run of plastic slots — their traces and touched entries
— and walks the whole spike list, writing only the pairings whose slot is in its own range. The
sequential walk visits the spike list in order and, per spiking neuron, its arriving slots and then
its leaving slots, each in ascending slot order; a shard writes a subsequence of that same write
sequence, which restricted to one slot is that slot's sequential sequence. A trace write depends on
nothing but that slot's own previous value and touch stamp, so the lazy decay, the [-1, 1] clamp
and the single rounding to f32 all happen at the same points with the same operands. One slot can be
written twice in a tick — once as its target's incoming pairing and once as its source's outgoing
one — and that order is inside one worker's range, so it is preserved too.
lastSpikeMs is read and never written during the phase (the kernel stamps this tick's spikes
after observe returns), so the shards need no ordering between them at all. Each per-neuron slot
list is built in ascending slot order, so a shard binary-searches its window in the list instead of
testing every slot; the lists that matter are the post-role neurons' and they run to thousands of
entries.
Everything else stays sequential in the original order: the 300 noise draws, the visual and stimulation drive, the spike stamp, role counting and the rate EMAs.
The workers are a small pool of our own (src/pool.rs), not rayon and not a process-wide default,
so a host that embeds the core keeps control of its own threads. SweepPlan::sequential() and
SweepPlan::with_threads(1) build no pool at all and run every phase inline.
Why not rayon, and why not a target-indexed edge list
Both were tried.
Rayon's per-tick join was the dominant cost rather than a rounding error on it: a 1-ms tick has
two short parallel phases, so a brain-second is two thousand fan-outs, and the sweep alone measured
152 us/tick at one thread and 559 at eight. The replacement is one thread per worker, spawned once,
parked on a generation counter — a dispatch publishes a &dyn Fn(usize), runs index 0 on the
calling thread and drains a running count. Workers spin for about a microsecond and then yield
before parking on a condvar, because the gap between two phases is one sequential phase, a few
microseconds, and a futex round trip is a large fraction of that.
The plan of record was a target-indexed (CSC) edge list, so each worker could scan its own targets' incoming edges and test each source against a spiked bitset. That is the wrong shape for this kernel and was not built. It reads every one of the 2,700,513 incoming entries every tick — 21.6 MB of streaming per tick, 21.6 GB/s at real time — where the source-major walk reads only the 33,500 edges a spike actually crossed. It would also have cost 21.6 MB of resident memory and made the single-threaded path several times slower, since one core cannot stream 21.6 MB in 456 us. Sharding the source-major walk by target range gives the same disjoint-writes property with no extra structure and no extra traffic: each edge is still read exactly once in total.
Performance
Ryzen 7 5800X3D (8 cores / 16 threads), data/fafb-v783, warm-up 2,500 ms then 300 frames of noise
input through NeuralAgent. brain ms / wall ms is the real-time factor; 1.0 is break-even for a
live stream.
Measure it the way these numbers were measured — best of five rounds, pinned to one hardware thread per physical core — because SMT siblings sharing a core make a thread-count sweep meaningless and a single round on a loaded machine moves by 10%:
RUSTFLAGS="-C target-cpu=native" cargo build --release --examples
taskset -c 0,2,4,6,8,10,12,14 ./target/release/examples/bench
| threads | before | after | after, x86-64-v2 |
|---|---|---|---|
| sequential | 1.426 | 1.896 | 1.848 |
| 1 | 1.294 | 1.889 | 1.844 |
| 2 | 1.411 | 2.571 | 2.455 |
| 4 | 1.362 | 3.301 | 3.226 |
| 6 | 1.284 | 3.551 | 3.439 |
"Before" is this crate at the merge of feat/flysim-core, measured the same way rather than quoted
from the table it used to print. The third column is the default cargo profile, which targets
x86-64-v2 for the host's Haswell Xeon rather than the build host; it is the binary that actually
ships, and it costs 2-3%.
cargo run --release --example ablate prints where a tick goes. The split is read off
LifNetwork::profile, which laps an Instant between the five phases; five clock reads is about
125 ns against a tick of 150 us and up, and the tool prints the instrumented sum beside the
uninstrumented wall time so a reader can confirm they agree.
| us/tick | drive | sweep | observe | propagate | rates | sum | x real |
|---|---|---|---|---|---|---|---|
| 1 thread | 3.2 | 169.3 | 79.5 | 288.2 | 1.4 | 541.5 | 1.85 |
| 2 threads | 4.6 | 89.3 | 79.8 | 228.7 | 1.4 | 403.8 | 2.48 |
| 4 threads | 5.4 | 48.9 | 82.4 | 178.7 | 1.4 | 316.8 | 3.24 |
| 6 threads | 5.7 | 35.8 | 83.6 | 164.9 | 1.4 | 291.4 | 3.37 |
| 8 threads | 6.4 | 31.9 | 91.2 | 165.6 | 1.6 | 296.7 | 3.35 |
What moved, in the order it was done, each step gated on the golden tests:
-
Plasticity::gain(edge)stopped reading a 10.8 MB array. It was a denseVec<i32>of "plastic slot, or -1" with one entry per base edge, read once per propagated edge. A presence bitset (338 KB) plus a prefix-popcount table (169 KB) answers "not plastic" — almost every answer — from one word, and a rank in that set is the old slot number. Propagation 452 -> 305 us/tick, sequential real time 1.43x -> 1.66x. Interleavingtargetsandweightsinto one 8-byte array was measured too and made no difference against 10% run-to-run noise while costing 21.6 MB, so it is not kept: a median row of 11 edges is one cache line of each either way. -
Propagation sharded by target range (above). Correct but invisible until 3, because rayon's fan-out ate it.
-
The persistent pool. The change that mattered: 1.66x -> 1.77x sequential and 1.21x -> 2.47x at four threads, because the sweep finally scaled — 152 to 53 us/tick.
-
The sweep blocked eight neurons at a time, so the common case (no refractory neuron and no spike anywhere in the block, about three blocks in four) is branchless and vectorizes. Neutral at one thread and 27% better at eight, which is the useful finding: at one thread the sweep is bandwidth-bound, not compute-bound. It touches 1.25 MB of membrane, baseline and refractory per tick, all of it dirtied by the previous tick's scattered propagation, and 1.25 MB / 167 us is 7.5 GB/s, one core's share. In an edge-free network, where membrane stays cache-resident, the same change takes the sweep from 132 to 41 us/tick.
-
plasticity.observestopped callingexp7,800 times a tick. Instrumenting the loops showed 5,312 slot visits and 3,894 pairings per tick, twoexpcalls each — one for the spike-pair term, one for the trace's lazy decay — and every argument is an integer number of milliseconds over a fixed time constant, becausemsis an integer tick counter andtouchedandlastSpikeMsonly ever hold anms. Tabulating them (101 entries for the pairing window, 1,024 for trace gaps, each built with the identical expression) took observe from 128.5 to 81.2 us/tick. This is a lookup that returns identical bits, not an approximation: a non-integer or out-of-range argument falls through toexp, and a unit test checks both paths bit for bit at three time constants.The per-neuron "has plastic edges" bit went in at the same time and bought nothing measurable on this dataset. The neurons that spike and carry plastic edges are the ones that spike often, so the test passes 218 times per tick out of 1,729 spikes and the loops behind it were the cost. It stays because it is free, and because a dataset with a less concentrated mushroom body needs it.
-
observesharded by slot range (2026-09-16,perf/simloop-main-thread). The 84 us above was the last phase that did not move with the thread count, and by the time the sim loop was profiled as a whole it was a quarter of the sim thread's serial work (infra/docs/simloop-profile.md). Four threads pinned to four physical cores,FLYSIM_ROUNDS=3:us/tick drive sweep observe propagate rates sum x real sequential observe5.4 47.8 80.2 178.0 1.4 312.9 3.21 sharded observe5.4 48.5 32.6 174.7 1.4 262.7 3.84 Sharding it does cost something elsewhere in principle — every worker now touches
lastSpikeMsand the trace arrays, so propagation's private-cache footprint grows — but it is below this box's run-to-run noise in both directions (propagation moved by -0.6 and +0.2 ms per game frame in two same-trajectory pairs). A compactlastSpikeMs(below) would shrink it either way.
What is left
One fan-out per tick instead of three. A tick now dispatches the pool three times, and a
brain-second is 3,000 fan-outs; workers spin about a microsecond and then park, so every dispatch
that finds them parked costs the dispatching thread a futex wake per worker. On four whole cores
they stay spinning and the third dispatch is free, but on three cores shared with the service's own
tokio and store threads they park, and the third dispatch costs the sim thread about what the
shorter observe saves it (infra/docs/simloop-profile.md section 6). observe and propagation
can share one dispatch: they write disjoint arrays (traces/touched against membrane), neither
reads what the other writes, and propagation does not read lastSpikeMs at all — which is why the
spike stamp and the role tally already sit between the two phases rather than inside the edge walk.
Moving the stamp to after a fused dispatch puts a tick back to two fan-outs and changes no order:
the stamp writes the same ms per spiking neuron either way, and nothing in the fused phase reads
it.
Propagation is now three quarters of a four-thread tick and scales only 1.7x across four workers,
which is the next target: 33,500 scattered read-modify-writes into a 557 KB array per tick, under a
static in-degree split that assumes spiking is spread evenly across target ranges when it is not.
After that, observe's remaining cost is memory rather than arithmetic — 5,312 random reads of a
1.1 MB lastSpikeMs plus scattered trace writes — and the fix would be a compact lastSpikeMs
over the 5,000 neurons that carry a plastic edge, which would shrink what every observe shard now
pulls into its own cache.
Soak
examples/soak.rs runs 60 s of brain time per thread count in one unbroken run with no best-of,
and reports the slowest single frame and the resident set: a stream drops on its worst frame rather
than on its mean, and 16.74 ms is the frame budget.
taskset -c 0,2,4,6,8,10,12,14 ./target/release/examples/soak
| threads | brain ms / wall ms | frames/s | worst frame ms | RSS |
|---|---|---|---|---|
| 1 | 1.847 | 110.3 | 14.11 | 39 MiB |
| 2 | 2.433 | 145.3 | 10.84 | 39 MiB |
| 4 | 3.101 | 185.2 | 7.48 | 39 MiB |
| 6 | 3.319 | 198.2 | 10.22 | 39 MiB |
The sustained figures sit 2-6% under the best-of-five ones and resident memory does not move, which
is what the run is there to show. 39 MiB is the whole process: the connectome is 16.2 MB of
targets and weights plus 557 KB of indptr, and the derived indices add 506 KB for the
plastic-edge bitset and 34 KB for the two per-neuron ones.
The streaming gate
docs/stream-mvp-plan.md's G0 gate asks for >= 1.0x on the host's six cores, and puts that
E5-2660 v3 at roughly half this box's single-thread speed. At 3.44x on six cores in the shipping
x86-64-v2 profile, that projects to about 1.7x. The gate is met with room; the previous
measurement projected to 0.65x and did not meet it.
Deviations from the TypeScript library
Each of these is a deliberate narrowing, not an accident.
- Legacy decoder checkpoints are not supported.
readout/decoder.tsalso accepts the prototypeMotorDecoderstates (versionsundefined, 2 and 3). Nothing on the Rust side has ever written one, and the port was commissioned without them.import_statetakes a version 4DecoderStateand rejects anything else withInvalid decoder version. - Checks that the type system already makes are not repeated at runtime. The oracle validates
typeof calibrated === 'boolean',Number.isInteger(state.rng)and similar, because a JavaScript caller can pass anything. Herecalibratedis aboolandrngis ani32, so those cases are unrepresentable. Every check over a value — finiteness, ranges, lengths, array bounds, the plasticity radius, the fatigue interval, the remainder interval — is reproduced. msis anf64, as it is in JavaScript, rather than au64converted at each use. It is only ever incremented by one and compared or subtracted, andms - lastSpikehas to be the samef64subtraction the oracle performs.- Error messages are reproduced verbatim rather than restructured into a Rust error taxonomy,
because the oracle's tests match on them and they are part of the behaviour being ported. Hence
the single
Errornewtype insrc/error.rs. ratesand the decoder's records are insertion-ordered maps (src/ordered.rs), because their key order is observable: it decidesJSON.stringifyorder in a checkpoint manifest, andratesorder is the network's tracked-role order.
Build profile
../../.cargo/config.toml targets x86-64-v2 by default, for the host's Haswell Xeon rather than the
build host. Rust performs no floating-point contraction or reassociation at any target-cpu level,
so this does not affect bit-exactness — which the golden tests confirm, since they run under it.
The three examples opt into the build host with RUSTFLAGS="-C target-cpu=native"; the Performance
table above reports both, and the difference is 2-3%.
The one codegen setting this crate does depend on is inline(always) on propagate_shard. Its
unsharded caller passes constant bounds, and only after inlining does the shard-window search fold
away; leaving that to the inliner's judgement measured 8% slower.