Accelerating blobs: the GPU port

Post 9 — infrastructure: a JAX port of the blob genome simulator, correctness-gated against the locked CPU numerics, roofline-analyzed on an A100. One number up front: an evolution generation (96 worlds × 2,500 tu) drops from ~14 CPU-core-hours to 14 minutes — 57× per core, $0.005 per world at pod prices. Total development bill: $5.71.

Why this exists. Every campaign in this series ran on CPU cores: pop-96 evolution generations take overnight batches, and the roadmap wants 512²–1024² worlds (16–64× today's area) for macro-scale ecology. The kernel is seven lines of math; it deserves to run at memory bandwidth. This post documents the port others should be able to use: the batching idea, the correctness gates (and why this program is paranoid about them), the roofline, what optimizations actually did, and the throughput table.

The kernel (what we're porting)

Every blob world in this series — from the single M0 blob to the evolved predator–prey ecologies — is one genome-parameterized PDE system (post 7): n_act cubic activator fields ui plus n_chan linear relaxation channels xc, integrated with a pseudo-spectral IMEX scheme at dt=0.02, dx=0.5, periodic:

per step (op order is part of the contract):
 1. reaction with OLD u:  Ru = λu − u³ + k₁ − K·x (− bilinear vertices)
                Rx = (W·g(u−u₀) − x)/τ
 2. F ← F + dt·R,   then activator noise (amplitude 2·10⁻³·√dt)
 3. exact diffusion in k-space: F ← irfft2( rfft2(F) · e−D k² dt )

On CPU (numpy + scipy.fft, one core) a 12-field 256² world costs 5.3 ms/step ≈ 0.44 ms per field-step, half FFT, half pointwise. A T=5000 assay run is ~17 minutes; a pop-96 generation at T=2500 is ~14 core-hours. The workloads we care about: (a) populations of small worlds (3–14 fields, 256², 125k–2M sequential steps — evolution's shape), (b) single large worlds (512²–1024²).

The batching idea: a generation is one tensor

Sequential steps can't be parallelized — dt is physics (post 2's lattice-pinning lesson, and the A5 trap below). What CAN be parallelized is everybody else's world. The port packs an entire population into one tensor:

F : (B, nf_max, N, N) — B worlds × padded field count × grid
per-field parameters (λ, k₁, u₀, D, 1/τ, thresholds) : (B, nf_max) tensors
per-world couplings W, K : (B, nc, na) / (B, na, nc) — reaction = two einsums
diffusion: ONE batched rfft2 over B·nf fields, multiply precomputed e−Dk²dt, irfft2

Ragged worlds (a 3-field M0 next to a 14-field ecology) are padded, not masked: padding slots get λ=k₁=u₀=0, zero W rows/K columns, D=0, 1/τ=0. A zero-initialized padded field then stays exactly zero forever — inertness is by parameter construction, verified bitwise in the test suite. No cross-world term exists anywhere (reactions are per-world einsums, FFTs are per-field), so a NaN blow-up in one world cannot touch its neighbors — this matters because evolution searches expect some genomes to explode.

The whole step is one jax.jit program; 250 steps (one record interval) run device-side in a lax.fori_loop per launch. Per-world threefry noise keys are folded on the absolute step index, so a chunked run is bit-identical to an unchunked one — same trick the CPU v2 assay uses for adaptive horizons, and it survives on GPU (gate below). f32 is the production dtype (matching the locked CPU assay); f64 is a flag away for parity testing.

The correctness gates (the paranoid part)

The cautionary tale — the A5-dt trap (post 8, program log). The A5 blob pair under IMEX at dt=0.02 slides through its true bond minimum d*=15.7, hits the 14.4 replication saddle, and replicates at ~2600 tu. Pure integrator artifact — reproduced independently in two CPU engines; dt≤0.005 freezes the pair at d*=15.71 indefinitely. The lesson: integrator changes can silently alter statics, and "the movie looks the same" is not a gate. A GPU port must reproduce the physics and the known artifacts — same equations + same dt = same wrong answer. A port that accidentally "fixes" the trap has different numerics.

Bitwise identity across backends is impossible (different FFT algorithms, different reduction orders), so the gates are defined at three levels, locked in probes/blobs/gpu/GATES.md before the benchmark battery ran:

gatedefinitionresult (A100)
F64 trajectoryall 7 ground-truth worlds, f64, noise=0, bit-identical ICs, T=100 tu (5,000 steps): rel-L2 field error < 10⁻⁵ vs the locked CPU kernel, single AND batched-7 modes PASS — worst 7.9·10⁻¹³ (8 orders inside the gate)
Padding / determinismpadded slots exactly 0; solo-vs-padded trajectories bitwise (CPU) / ≤10⁻⁵ (GPU: cuFFT picks different kernels per batch shape — measured last-bit reassociation only); same-shape reruns bitwise PASS (5.9·10⁻⁷ rel-L2 after 200 steps across batch shapes; bitwise same-shape)
Chunked continuationadvance(25)×4 == advance(100) bitwise, noise on (absolute-step key folds)PASS — bitwise on GPU too
Bond anchorsA4s pair dt=0.02 → d*=15.40±0.5%; A5 pair dt=0.005 → d*=15.70±0.5%; A5 pair dt=0.02 → must REPLICATE (the trap reproduces) PASS — 15.3952, 15.7215, replicated at t=2250 (CPU: ~2600)
Descriptor parityfull locked assay (T=5000, f32, working noise) × 7 worlds × seeds 1–3 on GPU, scored with the LOCKED metrics_v1; per-world interest inside the CPU 3-seed band (band, rank, and drift criteria declared in GATES.md) 6/7 PASS; mv3 3-seed FAIL → investigated (below) → parity confirmed 8v8; mean drift −0.9 points (gate ≤2.0)
descriptor parity

The honest negative: mv3 and what 3 seeds can't tell you

The machine-v3 world (engine+cargo, the top scorer) failed its 3-seed band: GPU seeds 2/3 scored 31/35 against a CPU band [39.8, 46.5]. Per the locked protocol — investigate, never re-roll — we ran seeds 4–8 on BOTH backends (pre-registered, all reported). Verdict: mv3 is seed-bimodal on both engines — a low mode (soup collapses to ~8 blobs, "constant", interest 31–39) and a high mode (12–37 blobs, "switch/oscillator", interest 41–52). The CPU's original 3 seeds all happened to land in the high mode; the band was an underestimate of the world's own seed noise. Over 8v8 seeds: CPU 40.0±4.8 vs GPU 40.4±7.1, Mann-Whitney p=0.88, drift +0.45 points. Parity holds; the methodological lesson — 3-seed acceptance bands are the wrong estimator for switch-regime worlds — is recorded in GATES.md for the next metrics lock.

The roofline: where the kernel actually sits

Counting flops (2 real FFTs ≈ 5N²log₂N² + ~15 flops/px reaction + spectrum multiply) against minimum bytes (state in/out + spectrum in/out + E), the step's arithmetic intensity is 2.4–3.3 flop/byte — far left of the A100's 12.5 F/B ridge. This kernel is bandwidth-bound by construction: no tensor-core trick will help it (and we pin einsums to full FP32 precision anyway — on A100, XLA would otherwise silently use TF32 tensor cores, a ~10⁻³-level numerics change that the gates would catch).

roofline

Measured points sit at 25–30% of nominal peak bandwidth under the minimum-traffic model — i.e. ~35–40% under the as-executed model once cuFFT's separate row/column passes and the standalone E-multiply kernel are counted (each stage is its own read+write; XLA cannot fuse into cuFFT custom calls). The residual gap is transform-size efficiency: 256² batched real FFTs are small transforms, and cuFFT's achievable fraction at that size is well below STREAM bandwidth. The practical readings:

throughput

The optimization log (tried / kept / rejected)

optimizationverdictevidence
batched population tensor, one jitKEPT 14.8 → 5.0–5.6 µs/field-step (2.6–3×). Batching cuFFT + pointwise across worlds is THE win.
device-side chunk loop (lax.fori_loop, 250 steps/launch) KEPTpython-loop stepping at B=96 costs +8% (5.26 vs 4.86 ms/step) ≈ 0.4 ms/step of host dispatch; that fixed cost would dominate at B=1 (0.13 ms/step total). Chunking also removes 250 host syncs per record.
donate_argnums buffer reuseKEPT no speed change; halves device memory, enabling 224-world batches.
precomputed e−Dk²dt tensorKEPT (wash) recomputing exp on the fly: −1.6% (within noise). Precompute keeps the f64-computed-then-cast convention from the CPU kernel — an exactness argument, not a speed one.
reaction fusion (one XLA fusion vs forced barrier)KEPT unfused +10% (5.35 vs 4.85 ms/step). XLA already fuses the entire pointwise stage; don't break it.
CUDA graphs (XLA command buffers incl. CUSTOM_CALL) REJECTEDB=1: +6% slower; B=96: ±0.0%. cuFFT custom calls fragment the capture; XLA's default launch pipeline is already near-optimal here. Honest negative.
activator-only device pulls at record pointsKEPT pop-96 wall 1,696 → 859 s (2.0×). Blob tracking needs only u-fields 25× more often than the full state; pulling channels every record was half the assay cost. Biggest single win after batching itself.
threaded host record path + async overlapKEPT part of the same 2×: CPU tracking of record k overlaps GPU stepping of chunk k+1 (JAX async dispatch). 16 threads vs 8: wash (396 vs 403 w/h) — now bound by per-world labeling work.

The headline table

All numbers measured on the rented pod (A100 40GB SXM4, $1.99/h, lambdalabs; jax 0.4.38/cuda12). CPU equivalents measured on this project's M-series laptop core running the locked pipeline — the actual engine every prior post used. "With records" = the full locked assay pipeline (blob tracking, patch stats, snapshots), not bare stepping.

workloadCPU (1 core, measured)A100 (measured, with records)speedup$ per eval @ $1.99/h
(i) evolution generation: pop-96 × T=2500, ~9-field worlds, 256²513 s/world → 7.0 worlds/h/core (~13.7 core-h/generation) 858 s/generation → 403 worlds/h57× per core $0.005/world ($0.47/generation)
(ii) 512² single world, T=10,000 (500k steps) 2.60 h264 s35×$0.15
(iii) 1024² single world, T=10,00011.9 h 1,171 s37×$0.65

Context: the entire phase-5b deepsearch (6 generations × 96 worlds) that took the CPU program days of staggered batches would take ~90 minutes and ~$3 on one A100. The 1024² world — 64× today's assay area, the "macro-ecology" regime the roadmap couldn't afford — is a 20-minute, 65-cent object. The single-world numbers are pull-limited by the record pipeline, not stepping (pure stepping: 512² = 0.24 ms/step, 1024² = 0.91); a future device-side tracker could roughly double (ii)/(iii).

Production contact: the record path (2026-08-29 update)

The benchmark met production and lost. When the first real evolution campaign ran on this port (blobkit 0.3.x: the batched ladder driving full assays, not tracking-only populations), throughput came in at 38–46 worlds/hour against a measured sim-only ceiling of ~189. The gap was not the kernel: an instrumented production run split the wall as record tracking 2299 s (cumulative) vs dispatch 493 s, battery 356 s, pull 40 s — and the record path ran at ~1.5 effective threads (GIL-serialized blob_list_fast, ~6.4 ms per field per record point, 85% of record cost). The 396 w/h headline above is real but describes a tracking-only workload; the full assay pays for science the tracker skips.

Two plausible fixes died by benchmark before any code shipped: nf-bucketed packing (padding waste was free capacity — the GPU was not FLOP-bound at B=32) and bigger batches (B-scaling is flat: 78/71/75/76 µs/world-step at B=8/32/64/96; cuFFT at 256² already fills an H100 at small B). Parallelizing record extraction into a spawn pool gated bitwise but bought only 1.10× — the rung loop still waited for records between chunks. Amdahl, textbook.

So the record math moved onto the device. A fast experiment loop on real evolved states (minutes per question, one question per experiment) settled the design in a day:

experimentverdictevidence
naive min-neighbor label propagationREJECTED correct partitions but 496 sweeps on real labyrinths — right math, wrong algorithm.
sweep + pointer-jumpingREJECTED stalls on 3 of 11 real fields (unions land at pixels; jumps only compress).
scatter-min root merging (label equivalence; jnp .at[].min is the scatter)KEPT converges in 4 outer rounds on all real fields and a pathological serpentine (diameter ~N²/8); partitions exactly match the locked host labeler; 0.12 ms/field labels-only.
segment stats on device (dense rank + scatter-add + scatter-max + trig moments)KEPT 7.6× per field end-to-end (1.58 vs 12.0 ms) incl. the tiny per-blob row pull; blob sets/areas/peaks exact, sums within 5.5e-7 (f32 accumulation; f64 variant gates at ~1e-12).
step/stats multi-stream overlapUNNECESSARY XLA serializes the two jits (overlap ratio 1.00 measured) — but device stats cost only 8.5% of chunk stepping (34.9 vs 410 ms per 32-lane×128-field REC point), so the simple in-loop fold wins. Honest negative that simplified the design.
async record apply (host)KEPT records/battery processed while the device steps the next chunk; barrier only at rung decision points — assay decisions bitwise identical; ×4.54 on the record microbench.

Parity policy (inherited from the noise-stream precedent): integer and max-reduction outputs — partitions, blob counts, areas, peaks — must be exact; floating-point sums use f64 device accumulation behind a 1e-12 tolerance gate, because bitwise equality of scattered sums is fundamentally unattainable (fp non-associativity meets GPU scatter order).

Method note. Every claim in this chapter is a row in a versioned benchmark suite that now ships with the package ( perf/bench.py: kernel / assay-mix / generation tiers, frozen workload hashes, one-command version comparison). The rule that produced the honest negatives above: no performance claim without a benchmark row.

How to run it

probes/blobs/gpu/  (no repo-wide install; JAX cpu works out of the box, cuda12 wheel on pods)
├ blobgpu/   core.py (stepper) · packing.py (genome→tensors) · soup.py (assay driver) · anchors.py
├ tests/     gate_f64.py · gate_anchor.py · gate_parity.py · test_{padding,chunking,determinism}.py
├ bench/     bench_step.py · bench_headline.py · roofline.py · make_figs.py
└ GATES.md  the locked gate definitions + status
# single world, drop-in for the locked assay (same record dict as soup_sim_v2):
from blobgpu.soup import run_soup_gpu
rec = run_soup_gpu(genome, T=5000.0, seed=1)  # → metrics_v1.full_battery(rec)

# a population as one tensor (evolution's shape):
from blobgpu.soup import init_soup_gpu_batch, advance_gpu_batch, snapshot_rec_gpu
SS = init_soup_gpu_batch([(g, seed) for g, seed in jobs])  # pads to (B, nf_max, N, N)
advance_gpu_batch(SS, 2500.0)   # chunk-safe, adaptive-T continuation OK
recs = [snapshot_rec_gpu(S) for S in SS["worlds"]]

# the gates (run before trusting ANY kernel change):
python tests/gate_f64.py && python tests/gate_anchor.py
python tests/gate_parity.py run  # then: gate_parity.py score (locked metrics_v1)

The v2 assay integration is a deliberate hook, not a rewire: run_soup_gpu() / the batch drivers produce byte-compatible records (they reuse soup_sim_v2's own IC builder and record functions — same numpy RNG, bit-identical seeded soups), so a future evolution run swaps backends with one import. The live CPU v2 campaign keeps its engine untouched.

Scope & bill. Development + full battery: one A100 pod, 2.9 h, $5.71 (target was <$50). Everything here is one GPU; the batched design has no cross-world terms, so multi-GPU sharding of a population is embarrassingly parallel when someone needs pop-1000. The obvious next step — a device-side blob labeler to un-bottleneck the record path — is bounded by measurement, not speculation: removing all record cost would give 1.4× on (i) (858 vs 608 s step-only) and 2.2–2.6× on (ii)/(iii).
Series: index · previous: Bounded structures