Skip to main content

Benchmarks

Benchmarks are published with their unfavourable results intact, including approaches that were measured and abandoned. Knowing which techniques do not pay off is as useful when choosing an execution path as knowing which do.

All GPU numbers are an NVIDIA T4, the weakest GPU available on Modal's free tier, and should improve on better hardware. The full write-ups are in benchmarks/README.md.

PYTHONPATH=. python benchmarks/memory_scaling.py [--time]
PYTHONPATH=. python benchmarks/parallel_scan.py
python -m modal run benchmarks/gpu/run_modal.py --bench {memory,parallel,network,reset,shd}
python -m modal run benchmarks/gpu/run_torch_comparison.py --suite speed

Against other frameworks

Every framework installed side by side and run in one container on one GPU, each in its own subprocess so no library is measured while another holds device memory. Identical model, identical input arrays, same optimizer, loss and dtype: Linear(128,128) -> LIF -> Linear(128,128) -> LIF -> Linear(128,20) -> LI, Adam 5e-4, cross-entropy on the time-integrated readout with 0.3 label smoothing, fp32, 20 epochs at batch 256, T=256.

frameworktraining timepeak memory
SpikingJelly 0.0.0.0.14, multi-step + CuPy6.02 s792.1 MB
jaxpike, unroll8.12 s324.5 MB
jaxpike, unroll_checkpointed11.07 s64.2 MB
jaxpike, unroll_parallel15.80 s288.1 MB
Norse 1.1.0252.21 s737.3 MB
SpikingJelly, Torch backend260.62 s696.3 MB
snnTorch 1.0.0347.18 s675.8 MB

jaxpike figures are steady state; compilation costs a further 14–19 s, paid once per shape.

Accuracy over five seeds, same protocol, 100 epochs:

modelmeansdrange
jaxpike, unroll0.75320.02920.7075 – 0.7822
SpikingJelly, multi-step + CuPy0.67540.02050.6440 – 0.6938
jaxpike, unroll_parallel (reset-free)0.61350.01890.5874 – 0.6392

Seed-to-seed spread is about ±0.02, so a single run cannot be distinguished from its own variance. The SpikingJelly row is not a like-for-like accuracy comparison: its neuron learns one shared tau where jaxpike learns a per-neuron beta, a concession made so the speed comparison would exercise its fastest path.

Three concessions are made to the other frameworks deliberately, so that a favourable result cannot be dismissed: their leaky readout is evaluated in closed form rather than looped, SpikingJelly runs its fastest documented path with the CuPy backend verified at runtime rather than assumed, and each framework keeps its native decay parameterization.

Anything that steps through time in a Python loop is 31–43× slower. SpikingJelly's fused CuPy kernel is 1.35× faster than the best jaxpike path and is not beaten. Ablation locates the whole gap: 83% of a jaxpike training step is the neuron time loop and 71% is the backward pass, while the hoisted Dense layers already compile to a single large matrix multiply.

Reduced precision

A T4 has fp16 tensor cores an fp32 network never touches, so mixed precision is the obvious lever. Measured, with membrane state kept in fp32 throughout:

configurationfp32fp16 weightsspeedup
isolated GEMM, hidden 1280.559 ms0.266 ms2.10×
isolated GEMM, hidden 5127.712 ms1.483 ms5.20×
training step, hidden 12811.998 ms11.522 ms1.04×
training step, hidden 51247.477 ms42.405 ms1.12×

A 5.2× faster matrix multiply buys 12% of a training step, because the neuron time loop is 83% of it and gains nothing. Casting per call is slower than fp32 at hidden 128 — the cast costs more than the tensor cores save — so a naive .astype(float16) is a regression.

How memory is measured

jax.jit(fn).lower(...).compile().memory_analysis().temp_size_in_bytes — XLA's own peak scratch allocation for the compiled executable. This matters because the SNN benchmarking literature reports speed and stays quiet about memory, which is where the largest differences actually are.

It is deterministic, has no allocator noise, and works on a CPU-only laptop. Two limits: it is XLA's plan rather than an observed high-water mark, and it excludes arguments and outputs. It is the right instrument for comparing how two implementations scale against each other, and the wrong one for predicting whether a model fits on a given GPU. The CPU and GPU measurements agree to within 0.6%, which is what makes the laptop numbers trustworthy.

Memory: rematerialization

Forward+backward through a 4-layer network, batch 8, on a T4.

Tnaivecheckpointedreductiontime cost
1005.4 MB910 KB6.0×1.29×
50026.5 MB1.6 MB16.6×1.00×
1,00052.8 MB2.0 MB26.4×1.15×
2,500131.9 MB3.5 MB37.2×1.10×
5,000263.8 MB3.9 MB67.0×1.09×

Naive memory is almost perfectly linear in T — bytes per step vary by only 1.03× across a 50× range of sequence lengths — which confirms the O(T·B·N) claim. The saving grows with T because checkpointed memory scales as sqrt(T).

A practical consequence: pure-JAX jax.checkpoint captures most of the available memory win in about fifteen lines, so hand-written fused kernels would have to justify themselves on bandwidth and speed rather than memory, and be benchmarked against this path rather than the naive scan.

Parallel-in-time

Isolated reset-free membrane, batch 8, 128 neurons:

Tsequentialassociativespeedupspikes
1280.0018 s0.0004 s4.35×exact match
5120.0068 s0.0002 s33.62×exact match
2,0480.0255 s0.0003 s83.68×exact match
8,1920.1032 s0.0009 s119.10×exact match

Whole network forward+backward (Dense 128→512 → LinearLIF → Dense 512→512 → LinearLIF, batch 16):

Ttrain seqtrain parspeedupparallel scratch
1280.0114 s0.0012 s9.4×25.8 MB
2,0480.1365 s0.0079 s17.3×489.0 MB
8,1920.5415 s0.0249 s21.7×1.9 GB

A real SHD training epoch, wall clock including transfer, optimizer and evaluation:

timestepsbatchsequentialparallelspeedup
2501283.9 s1.5 s2.6×
1,0006423.7 s10.0 s2.4×

These three numbers measure different things and only the last describes a training run. The 119× is an isolated membrane and the 17–22× is an isolated forward/backward pass; an epoch also contains host-to-device transfer, matrix multiplies, the optimizer and evaluation, none of which parallelizing time touches. On the framework comparison above, at T=256 with batch 256, unroll_parallel is slower than unroll — the time axis is not the bottleneck at that shape.

The parallel path also costs memory — it materializes the full [T, B, N] tensor, 1.9 GB at T=8192 where checkpointing needs a few megabytes. The two techniques currently sit at opposite ends of a tradeoff rather than composing.

On CPU the associative scan is 3–5× slower, and that is expected: it performs roughly 2× the total work in exchange for O(log T) depth instead of O(T). That is a losing trade on a handful of cores and a winning one on thousands of lanes.

Negative result: reset-based parallel-in-time

Reset makes the recurrence nonlinear, so the associative scan does not apply. The candidate tested was a chunked fixed point — sequential across chunks with an exact carry, and within each chunk iterate guess spikes → solve the resulting linear system in parallel → recompute spikes until the spike train stops changing. The fixed point is provably the exact sequential answer.

T=2048, batch 16, 512 neurons, T4. Sequential scan: 0.0216 s.

chunkiterations/chunkwrong spikestimevs sequential
85.00 / 16.7M0.0242 s0.92×
3211.04 / 16.7M0.0224 s0.99×
12832.62 / 16.7M0.0330 s0.68×
512109.52 / 16.7M0.1152 s0.19×

It does not pay off. The best configuration is 0.99× — a wash. The reason is visible in the table: iterations grow roughly linearly with chunk size, so every bit of parallelism a larger chunk buys is spent immediately on more sequential passes. There is no window where the trade wins.

Two simpler schemes were tried first and fail outright. Plain Jacobi iteration over the whole sequence oscillates: from "no resets" it produces too many spikes, applying all those resets produces none, and it 2-cycles forever. Damping (λ = 0.3/0.5/0.7) and a sigmoid-annealing homotopy both converge to "predict no spikes."

Parallel-in-time is therefore a reset-free feature. The schemes above cover the obvious approaches to the reset case; none of them wins.

The handful of wrong spikes are float32 noise rather than a defect: 4 in 16.7 million, all at threshold crossings where two summation orders straddle the boundary by ~1e-7. This generalizes — bit-exact spike comparison is fragile near threshold whenever reduction order changes, so compare with a tolerance or compare rates.

Spiking Heidelberg Digits

Network: Dense(700,256) → LinearLIF → Dense(256,256) → LinearLIF → Dense(256,20) → LeakyIntegrator, max-membrane readout, AdamW.

validationtest
jaxpike, feedforward + augmentation0.8880.626
jaxpike, recurrent + augmentation0.8390.696
Cramer et al. 2020, feedforward reference~0.48
Cramer et al. 2020, recurrent reference~0.71

Feedforward comfortably beats its published baseline; recurrent lands just under.

Protocol note, because it changes the numbers. These runs hold out 10% of train as validation, pick the epoch by validation accuracy, and report test once at that epoch. Reporting the best test accuracy across epochs instead selects the epoch on the test set, which inflates the figure by 0.5 to 4 points depending on the run.

The protocol is also what makes the next result visible. Recurrence is worth +7.0 points on test while scoring lower on validation (0.839 against 0.888). Validation is drawn from the training pool, but SHD's test set holds out entire speakers — so recurrence buys speaker generalization specifically, rather than raw capacity. A test-selected protocol never looks at validation and so hides the distinction entirely.

e-prop

Peak scratch bytes for one gradient, 2-layer network:

TBPTTe-propratio
100210,9843,12868×
5001,046,1843,128335×
1,0002,090,0243,128668×
4,0008,354,0243,1282671×

The e-prop column is identical at every length. Gradient alignment against BPTT, and the two sources of approximation, are covered in Online learning.

Two pitfalls these runs surfaced

Both are easy to hit when benchmarking any SNN, in this library or another, and both distort results rather than failing outright.

Keep the dataset off the accelerator. A loader returning device arrays pins the whole dataset in GPU memory: SHD at 1000 timesteps needs 22.8 GB in float32, which exhausts a 16 GB card before training starts. Transfer one batch at a time.

Store binary spikes as uint8, not float32. Doing so cuts SHD from 22.8 GB to 5.3 GB and per-batch PCIe traffic by 4×. With float32 storage the T=1000 comparison measured 1.56× where the true figure was 2.4×, because the input pipeline had become the bottleneck and was masking the compute win.

What is not benchmarked yet

Fused Pallas kernels. Two findings above set a high bar for them: jax.checkpoint already captures most of the memory win, and fusing does not save T kernel launches, because lax.scan already lowers to a single XLA while-loop. The remaining wins are HBM traffic and residual storage, so the bar for adopting a kernel path is 2× over unroll_checkpointed rather than over the naive scan.