flybrain/services/flysim/crates/flybrain-core
acamilo 660c3cf00d
Some checks failed
ci / node 22 (test + typecheck) (push) Has been cancelled
ci / rust stable (cargo test --workspace --release) (push) Has been cancelled
ci / infra/tests/lint.sh (push) Has been cancelled
ci / playwright apps/stage (allowed to fail) (push) Has been cancelled
flybrain v0.4.0: public tree (history retained privately)
2026-09-21 15:09:46 +00:00
..
cuda flybrain v0.4.0: public tree (history retained privately) 2026-09-21 15:09:46 +00:00
examples flybrain v0.4.0: public tree (history retained privately) 2026-09-21 15:09:46 +00:00
src flybrain v0.4.0: public tree (history retained privately) 2026-09-21 15:09:46 +00:00
tests flybrain v0.4.0: public tree (history retained privately) 2026-09-21 15:09:46 +00:00
Cargo.toml flybrain v0.4.0: public tree (history retained privately) 2026-09-21 15:09:46 +00:00
README.md flybrain v0.4.0: public tree (history retained privately) 2026-09-21 15:09:46 +00:00

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_json needs its float_roundtrip feature. Without it the fast float parser can land 1 ulp away from JSON.parse, which first showed up as a bogus fround(exp(-1/25)) mismatch before any kernel code was involved. It is pinned in the workspace manifest with a comment.
  • JSON.stringify is reimplemented (src/json.rs) because the dataset fingerprint hashes JSON.stringify(meta). serde_json writes 1.0 where JavaScript writes 1, and key order has to survive the circuit-role merge. Parsing still goes through serde_json with preserve_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 to t: the same (weight, gain) pairs in the same order.
  • membrane[t] depends on nothing else. Each step reads only membrane[t]'s previous value, adds weights[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-v783 and 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:

  1. Plasticity::gain(edge) stopped reading a 10.8 MB array. It was a dense Vec<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. Interleaving targets and weights into 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.

  2. Propagation sharded by target range (above). Correct but invisible until 3, because rayon's fan-out ate it.

  3. 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.

  4. 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.

  5. plasticity.observe stopped calling exp 7,800 times a tick. Instrumenting the loops showed 5,312 slot visits and 3,894 pairings per tick, two exp calls 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, because ms is an integer tick counter and touched and lastSpikeMs only ever hold an ms. 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 to exp, 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.

  6. observe sharded 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 observe 5.4 47.8 80.2 178.0 1.4 312.9 3.21
    sharded observe 5.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 lastSpikeMs and 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 compact lastSpikeMs (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.ts also accepts the prototype MotorDecoder states (versions undefined, 2 and 3). Nothing on the Rust side has ever written one, and the port was commissioned without them. import_state takes a version 4 DecoderState and rejects anything else with Invalid 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. Here calibrated is a bool and rng is an i32, 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.
  • ms is an f64, as it is in JavaScript, rather than a u64 converted at each use. It is only ever incremented by one and compared or subtracted, and ms - lastSpike has to be the same f64 subtraction 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 Error newtype in src/error.rs.
  • rates and the decoder's records are insertion-ordered maps (src/ordered.rs), because their key order is observable: it decides JSON.stringify order in a checkpoint manifest, and rates order 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.