← back to the showcase

TAKT showcase: training sweep of a spiking neural network (a BindsNET model in Rust)

A typical BindsNET job is a parameter sweep that trains a recurrent spiking network with STDP. It is written once in BindsNET and once in Rust, and the Rust program is built at four levels: the original build, level 0 (build tuning), level 1 (automatic proven optimization) and level 2 (an engineer's work). Every program prints exactly what the BindsNET script prints, to the bit. This package has ready programs for every level, the source of the original version, the BindsNET script, the data generator, the expected output and the measurements.

What the benchmark computes

A synthetic market-like return stream (trend and volatility regimes, 5,000 steps of 1 ms) is population-coded into 64 binary spike trains. They drive a reservoir of 128 leaky integrate-and-fire neurons, each with a refractory period of 3 steps, a lower voltage bound and a spike trace. The reservoir has fixed random recurrent connections (10% density, 80% excitatory and 20% inhibitory neurons), so every neuron's input depends on the reservoir's own spikes one step earlier. The input weights are trained online by STDP (BindsNET PostPre, weights clamped to [0, 1.5]).

The sweep trains 24 networks from the same initial weights:

For every run the program prints the spike count and 64-bit digests of the spike raster (every spike of every neuron at every step), the trained weights, the final voltages and the final traces, all taken as float32 bit patterns. Over the sweep the reservoir fires 1,370,445 spikes; depending on the setting, 3–14% of the neurons fire per step.

tools/bindsnet_reference.py is the same job written with BindsNET 0.3.3: Input → LIFNodes, a Connection trained by PostPre, a recurrent Connection and a Monitor, run with PyTorch on the CPU in one thread. The Rust programs print exactly what it prints.

Measurement (TAKT rig)

AMD Threadripper PRO 5975WX (Zen 3). Each program ran in an isolated VM on dedicated cores of one CCX, which was reserved for the measurement (2 of its 16 logical CPUs were outside our control). We counted user-mode cycles (vPMU) over 9 interleaved rounds and took the median. These are exactly the files in bin/. Speedups are relative to level 0, the build tuning anyone gets for free.

Level Build Cycles, M Time, ms Speedup vs level 0, cycles Speedup vs level 0, time
Baseline cargo build --release 3,525.0 805 1.06× 1.07×
Level 0 LTO, codegen-units 1, target-cpu x86-64-v3 3,742.6 865 1.00× 1.00×
Level 1 recipe + automatic proven transformation 962.4 222 3.89× 3.90×
Level 2 recipe + an engineer's work 237.0 60.7 15.8× 14.2×

On this code, level 0 is 6% slower than the plain release build. Measured against the plain build, level 1 is 3.66× faster by cycles and 3.63× faster by time, level 2 is 14.9× and 13.3×.

Level 1 was run on source/ with the same input. The optimizer found a transformation it could prove admissible. bin/snn-bench-level1 is the result, built with the level 0 recipe.

Comparison with BindsNET

We also ran BindsNET on the same machine: on the host (not in the VM), on core 25 of the same reserved CCX, in the same session. It used PyTorch 2.11.0 on the CPU with torch.set_num_threads(1); we took the median of 5 runs. The 24 training runs take 24.0 s; the whole process, including Python start-up, takes 26.9 s. On that core in the same session, level 0 takes 0.848 s, level 1 takes 234 ms and level 2 takes 60.9 ms, so level 2 is 395× faster than the BindsNET sweep.

BindsNET spends most of that time on per-step Python and PyTorch dispatch for small tensors, not on arithmetic. This comparison shows what moving the same job to a compiled program gains. The level speedups in the table above are a separate measure.

Running

python3 tools/gen_data.py data                          # the measured input (creates data/)
TAKT_INPUTS=data bin/snn-bench-level2 > out.txt
cmp out.txt expected_stdout.txt                         # identical to the byte
for l in base level0 level1 level2; do TAKT_INPUTS=data bin/snn-bench-$l | sha256sum; done
python3 tools/bindsnet_reference.py data --out ref.txt  # needs bindsnet and torch (about 30 s)
cmp ref.txt expected_stdout.txt

tools/gen_data.py needs only Python 3. It writes spikes.bin, weights.bin and sweep.txt, and its options set other seeds, sizes, parameter grids and model constants.

The programs run on Linux x86-64 (glibc). Levels 0–2 are built for x86-64-v3 (AVX2, FMA, BMI2: Intel Haswell and newer, AMD Zen and newer). FMA is not used in the computation.

Equivalence

All four programs print byte-identical output, equal to BindsNET's. - Malformed inputs. On 5 malformed inputs, all programs fail with the same exit code and output. - Scope. The match with BindsNET covers PyTorch's own float32 kernels. One of them needs a note. PyTorch computes the decay factors exp(−1/τ) with MKL's vector exp, which for about 1.4% of arguments differs in the last bit from the correctly rounded value. The Rust programs use the C library's expf. For every τ used above, the two agree, and every output line prints the decay factor, so a difference would be visible. Another PyTorch build or CPU can change PyTorch's own results.

Rebuilding the original

cd source
RUSTFLAGS="--remap-path-prefix=$HOME/.rustup=. --remap-path-prefix=$HOME/.cargo=." \
  cargo +1.96.0 build --release --locked
strip --strip-all -o snn-bench-base target/release/bench
CARGO_PROFILE_RELEASE_LTO=fat CARGO_PROFILE_RELEASE_CODEGEN_UNITS=1 \
RUSTFLAGS="-C target-cpu=x86-64-v3 --remap-path-prefix=$HOME/.rustup=. --remap-path-prefix=$HOME/.cargo=." \
  cargo +1.96.0 build --release --locked --target-dir target-l0
strip --strip-all -o snn-bench-level0 target-l0/release/bench

With Rust 1.96.0 from rustup, this reproduces bin/snn-bench-base and bin/snn-bench-level0 bit for bit on our machine. The program has no dependencies.

Contents

The sources of levels 1 and 2 are not published: we deliver builds.

Source text: README.md

Telegram