Field notes / Experiment guide
Running thousands of ants without changing the answer
Can a graphics processor accelerate a large census while returning exactly the CPU’s classifications and end-state statistics?
New to these ants? Start here.
An ant moves on a grid of coloured squares. Each colour tells it to turn left (L) or right (R). It advances that square to the next colour, then steps forward. A rule such as RLR is the list of turns, starting at colour zero. On a blank grid the same rule always makes the same trajectory.
Territory means every cell visited so far. Its boundary consists of visited cells with an unvisited neighbour. A highway is a repeating pattern of motion that carries the ant away. A tether means repeated returns to a small home region.
Growing like the cube root of time means taking eight times as many steps to double a linear size. The rotor walker is a comparison model that follows and rotates arrows stored on cells. A seed makes a random starting pattern reproducible; the ant itself can still move deterministically.
We distinguish c, the chance of discovering a cell per boundary step, from kappa (κ), time at the boundary normalized by perimeter divided by area. Explaining their values is harder than using them to account for growth.
The idea under test
Many independent, long ant trajectories should fill GPU lanes efficiently, provided every output agrees with the CPU oracle.
What we learned
The compared CSVs are byte-identical. The recorded large census workload runs several times faster than the six-thread CPU baseline.
What this leaves open
The timing sample is small and hardware-specific. A single serial ant does not gain that batch speedup, and short jobs have substantial overhead.
Read the evidence and reproduction recipe ↗
Inside the experiment
Methods, registered predictions, exact commands and saved analysis. These are the working research documents; expand them for the detail behind the introduction.
Methods and original experiment record
GPU ant sweep: a reusable CUDA utility for (rule, seed) batches
Status: working (2026-10-16): correct and benchmarked; extension ideas at the end. Goal: a re-usable CUDA utility that runs
thousands of independent (rule, seed) ants at once, with output provably identical to the CPU
oracle chain reference -> fast -> lean.
Question
Can one RTX 4000 Ada (20 GB, sm_89) beat sweep_lean on 6 CPU threads for census-style sweeps
at the same grid size and step budget (so classification semantics are unchanged), and by how much?
Design (decided before any benchmark)
- Job =
(rule, bg_seed: Option<u64>, max_steps).bg_seed = Noneis the census instrument (blank background, identical tosweep/sweep_lean).Some(s)is the repo's existing seeded meaning: i.i.d. uniform background coloursext::bg_colour(s, x, y, n)(walkers::mix64). The ant itself is deterministic, so the seed only enters through the background. - Virtual
GRID x GRIDgrid per ant through a tile directory (64x64-byte tiles, tile ids from a global pool, popped withatomicAdd, freed when the job retires). A dense 8192^2 u8 grid is 64 MB per ant (~300 ants in 20 GB); tiles make thousands of resident ants fit with unchanged escape semantics. - Headings are logged 2 bits/step into a per-lane ring; the CPU rebuilds the position trail backwards
from the final state and runs the unchanged
detect::find_highway/exit_confirms. Escape test and bounding box are kept in registers. - Kernel launches are sliced (default 2^20 steps) to stay under the WDDM watchdog, the host refills finished lanes between slices, and results are written by job index, so output does not depend on scheduling or on how many lanes ran.
- Pool exhaustion retires the lane, requeues the job and halves the active-lane cap; a job that fails alone is a hard error (never a silently wrong row).
- Toolchain:
cudarc(dynamic loading, NVRTC at runtime, no nvcc, no sudo). Behind the cargo featurecuda, socargo test --releasewithout it is unchanged.
Predictions (registered 2026-10-16, before any timing)
P1 (correctness). gpu_sweep CSV is byte-identical to sweep_lean (same MIN MAX MAX_STEPS GRID)
on lengths 2..=10 at 1e6 steps, and GPU RunResult equals fast::run on every field in the
proptests (including escapes at every edge, mid-run slice boundaries, tile crossings).
P2 (speed). Aggregate throughput of 3-8x sweep_lean at 6 threads on the same census slice.
Falsified if < 2x. Reasoning: memory-latency-bound dependent loads, many resident warps, but
tile lookups, ring writes and straggler lanes cost something.
P3 (scaling). Throughput rises with lane count and saturates somewhere between 2k and 8k lanes.
Use it
One-time setup in WSL (no sudo, no CUDA toolkit; NVRTC comes from a pip wheel, the driver is the Windows one):
uv pip install --target ~/cuda-libs nvidia-cuda-nvrtc-cu12==12.9.86
source experiments/2026-10-16-gpu-ant-sweep/scripts/env.sh # LD_LIBRARY_PATH, CARGO_TARGET_DIR=~/turmite-target
CLI, same arguments and CSV as sweep_lean (byte-identical when no seeds are used):
cargo run --release --features cuda --bin gpu_sweep -- 2 12 100000000 8192 --lanes 4096 --stats results/x.json > out.csv
printf 'RRLLLRLLLRRR\nRL 7\n' | gpu_sweep - - 1000000 # stdin lines: RULE or RULE SEED
gpu_sweep 2 10 1000000 --bg-seeds 1..8 # every rule x background seeds 1..=8 (adds a bg_seed column)
Library (--features cuda), results are index-aligned with the jobs and independent of lanes/slices/batch:
use turmite::gpu::{GpuConfig, GpuSweeper, Job};
let mut sw = GpuSweeper::new(GpuConfig { lanes: 4096, ..GpuConfig::default() })?;
let results: Vec<turmite::fast::RunResult> = sw.run(&jobs, |done| eprintln!("{done}"))?;
Seed semantics: the ant is deterministic, so a "seed" can only be the random background of ext::bg_colour
(the repo's existing seeded instrument); None is the blank census background. Turn noise, several ants,
turmites with states and the probe statistics (visited set, perimeter, windows) are not ported.
Limits: 1..=128 colours; a job whose footprint exceeds the whole tile pool is an error; if you hold two
sweepers, give each an explicit pool_tiles (the default grabs all free VRAM).
Tuning: lanes should be at least about 4000 (device throughput at 1e8 steps rose 2.5e9 -> 4.7e9 -> 7.3e9
steps/s for 1024 -> 2048 -> 4096 lanes and had not saturated); --slice only needs to keep one launch under ~1 s.
Results (2026-10-16)
Tests (source scripts/env.sh; cargo test --release --features cuda --test gpu --test onset): all pass. They compare
every RunResult field with fast::run over four rigs (grid 64 = one tile, ragged grid 100, a 16-tile pool
that forces requeues, 64 lanes), compare seeded backgrounds against a dense CPU reference, check known
highways (Langton's ant period 104) and check the error paths. tests/onset.rs checks src/onset.rs.
Benchmarks (scripts/bench.sh, scripts/summarise_bench.py, logs and bench_summary.json in results/),
census lengths 2..=12 = 4083 rules, grid 8192, sweep_lean on 6 rayon threads, GPU = RTX 4000 Ada,
wall time of the whole process (includes ~1 s GPU setup, CPU highway-onset replay and CSV), one run each:
| steps | sweep_lean |
GPU 1024 lanes | 2048 | 4096 | CSV vs sweep_lean |
|---|---|---|---|---|---|
| 1e6 | 67.7 s | 5.8 s (11.7x) | n/a | 5.7 s (11.8x); 8192 lanes: 6.0 s (11.3x) | identical in every run |
| 1e8 | 287.3 s | 154.4 s (1.9x) | 86.2 s (3.3x) | 59.0 s (4.9x) | identical in every run |
Also: gpu_sweep 2 10 1000000 vs sweep_lean (1013 rules) byte-identical in the first check.
Against the registered predictions:
- P1 holds. Every GPU CSV compared (13 runs) is byte-identical to
sweep_lean; all proptests pass. - P2 (3-8x, falsified if < 2x): holds at 1e8 steps (4.9x at 4096 lanes), exceeded at 1e6 steps (11.8x), but the 1e6
figure is mostly not stepping. At 1e6 steps
sweep_leanspends its time on per-rule overhead (clearing and scanning a 67 MB grid, rebuilding a 250k-position trail); the GPU kernel itself needed ~0.4 s for 3.8e9 steps. The 1e8 figure is the fair stepping comparison. At 1024 lanes the 1e8 result is 1.9x, below the 2x line, but that is an under-filled device, not the registered configuration. - P3 (saturation between 2k and 8k lanes): not confirmed. At 1e8 steps throughput was still rising linearly at 4096 lanes, and this slice has only 4083 jobs so more lanes cannot be tested here. At 1e6 steps the curve is flat from 1024 lanes, but there the run is limited by setup, detection and onset replay, not by lanes.
What this does not show: variance (one run per cell, no repeats), behaviour with more jobs than lanes at large
budgets (pool pressure, oom_requeues was 0 in every benchmark), or speed on rules with n > 12.
The tail of a sweep (last few ants running alone) lowers the average device throughput; a sweep with many more
jobs than lanes should come closer to the steady rate (not measured).
Contamination (reported, not used)
A first 1e6 benchmark and the first 1e8 sweep_lean run overlapped the 2026-10-16-kappa-leadrun-fresh probe on all
cores (lean read 179.4 s and 374.8 s). Those numbers are discarded; the table above is from re-runs on an idle machine.
The probe's own output is unaffected (seed-determined), its wall time may be inflated.
Open / next
- (Done 2026-10-16/19: the probe port exists as
gpu_probe, see../2026-10-16-gpu-probe/; it was byte-identical toprobeand ran about 5.8x faster atprobe 27 8192, with the caveat in its README.) When to choose which GPU tool and how to time them safely:../README.md("Using the GPU sweeper") andAGENTS.md("GPU (CUDA) tools"). - Original item, kept for the record: port the probe statistics (visited flag, area, perimeter, gyration moments, octave windows) to the kernel: that is
what the
probe 27 8192runs need (stepping-bound, ~3e11 steps per 2400 runs). Needs its own oracle test againstsrc/probe.rs. - Onset replay is still CPU (9 s of 59 s at 1e8): could be a second GPU pass.
- Overlap the onset replay with the sweep and stream CSV rows; stop paying NVRTC compile on each launch (cache the PTX).
Analysis notebook: notebook.ipynb
No saved outputs. This render shows the saved narrative and code; cells have not been executed for publication. Download notebook
GPU ant sweep: a CUDA batch runner for (rule, seed) ants
Question and why it matters
Can one RTX 4000 Ada run thousands of independent ants at once, returning exactly what the CPU oracle chain (reference -> fast -> lean) returns, and how much faster is it than sweep_lean on 6 cores? Census-style sweeps are the bottleneck of the project's larger questions. Links: README.md, NOTEBOOK.md, code in src/gpu/, src/bin/gpu_sweep.rs, tests in tests/gpu.rs, tests/onset.rs.
Assumptions and definitions
- A job's seed is the random-background seed of
ext::bg_colour;Noneis the blank census background (the ant itself is deterministic). Not turn noise. - At most 128 colours. A job larger than the whole tile pool is an error (never a wrong row).
- Speedups are whole-process wall time against
sweep_leanon 6 rayon threads, one run per cell (variance not measured).
Provenance
Prints the git commit, date and a SHA-256 for every input file. Inputs are the committed benchmark logs and their summary.
Code
import hashlib, json, subprocess, datetime
from pathlib import Path
ROOT = next(p for p in [Path.cwd(), *Path.cwd().parents] if (p / "AGENTS.md").exists())
EXP = next(p for p in [Path.cwd(), *Path.cwd().parents] if (p / "NOTEBOOK.md").exists())
INPUTS = [EXP / "results" / f for f in ["bench_L2-12-1e6.log", "bench_L2-12-1e8.log", "bench_summary.json"]]
assert all(p.exists() for p in INPUTS), [p for p in INPUTS if not p.exists()]
sha = subprocess.run(["git", "rev-parse", "--short", "HEAD"], cwd=ROOT, capture_output=True, text=True).stdout.strip()
print("git", sha, "| run", datetime.datetime.now().isoformat(timespec="seconds"), "| no randomness in this notebook")
for p in INPUTS:
print(hashlib.sha256(p.read_bytes()).hexdigest()[:16], p.relative_to(ROOT))
Data and method
scripts/bench.sh MIN MAX STEPS TAG "LANES..." runs sweep_lean once and gpu_sweep at each lane count on the same census slice (lengths 2..=12, 4083 rules, grid 8192) and cmps the CSVs; scripts/summarise_bench.py turns the logs into bench_summary.json. The heavy computation is src/gpu/ant.cu (one thread per ant, a virtual 8192x8192 grid as 64x64 tiles from a global pool, headings logged 2 bits/step) plus unchanged CPU highway detection.
Code
summary = json.loads((EXP / "results" / "bench_summary.json").read_text())
for tag, d in summary.items():
print(f"{tag}: sweep_lean {d['lean_wall_s']:.1f} s")
for r in d["gpu"]:
print(f" {r['lanes']:>5} lanes {r['wall_s']:6.1f} s {r['speedup_vs_lean']:5.2f}x device path {r['device_path_steps_per_s']:.2e} steps/s CSV {r['csv']}")
Results
Correctness. Every GPU CSV compared is byte-identical to sweep_lean's, and the proptests agree with fast::run on every RunResult field (grid of exactly one tile, ragged grid, forced pool starvation and requeue, many lanes) and with a dense CPU reference for seeded backgrounds. It does not show agreement for rules with more than 12 colours beyond the unit tests, nor for step budgets above 1e8.
Speed. At 1e8 steps the GPU is 4.9x faster than sweep_lean at 4096 lanes and its throughput was still rising linearly with lanes. At 1e6 steps it is 11.8x, but there sweep_lean is dominated by per-rule overhead, so that figure is not a stepping comparison. It does not show saturation (only 4083 jobs), variance, or behaviour with many more jobs than lanes.
Caveats and controls
- Contamination: a first 1e6 run and the first 1e8 lean run overlapped another experiment's probe on all cores; those numbers were discarded and re-measured on an idle machine (see
README.md). - Registered predictions: P1 (identical output) held; P2 (3-8x, fail if <2x) held at 1e8, exceeded at 1e6 for the reason above; P3 (saturation at 2k-8k lanes) was not testable with 4083 jobs.
- Highway onset replay (CPU) is 9 s of 59 s at 1e8 steps.
Next steps
Port the probe statistics (visited set, perimeter, gyration, octave windows) so probe 27 8192 batches can use the GPU; a GPU onset pass; cache the NVRTC PTX.
Read the dated lab log
Lab notebook: GPU ant sweep
Append-only. Newest entry last. Never edit an old entry; correct it with a new one
(corrects: <timestamp>). Tags: intent, assumption, observation, interpretation,
surprise, decision, dead-end, question, deviation. See experiments/README.md.
2026-10-16 · d4fd349 · intent
User goal (/goal): "A re-usable CUDA-enabled utility to sweep runs thousands of independent (rule, seed) ants".
Build a feature-gated CUDA module + gpu_sweep binary whose output matches sweep_lean byte for byte,
test it against the CPU oracles, then measure speed against sweep_lean at 6 threads. Prediction P2
(3-8x) is registered in the README before timing.
2026-10-16 · d4fd349 · observation
Environment: nvidia-smi shows RTX 4000 Ada (20 GB, driver 582.08) + Quadro P1000; WSL sees both
(nvidia-smi -L). No nvcc, sudo needs a password. uv pip install --target ~/cuda-libs nvidia-cuda-nvrtc-cu12
gave NVRTC 12.9.86 and a scratch crate using cudarc 0.19.10 (driver + nvrtc + dynamic-loading) ran a kernel on device 0.
2026-10-16 · d4fd349 · decision
"Seed" has no meaning for the blank-background census ant (deterministic). The only existing seeded
instrument in the repo is the random background (ext::bg_colour). So a job's seed is that background
seed (None = blank). Considered porting the noisy-turn stream too; deferred (needs a per-lane RNG and
the CPU oracle in ext.rs has several ants/state; out of scope for this utility).
2026-10-16 · d4fd349 · assumption
Number of colours <= 128 (u8 cell, rule bits in 4 x u32), same bound family as lean (< 100). Check: host asserts.
2026-10-16 20:0x (+13) · d4fd349 · observation
Correctness first: gpu_sweep 2 10 1000000 vs sweep_lean 2 10 1000000 (1013 rules, grid 8192): cmp says
byte-identical. cargo test --release --features cuda --test gpu: 6/6 pass (proptest vs fast::run over
4 rigs: one-tile grid 64, ragged grid 100, tight pool of 16 tiles, 64 lanes; seeded background vs a dense CPU
reference using ext::bg_colour; known highways; error paths). pool_pressure_requeues_and_stays_correct
asserts oom_requeues > 0, so the requeue path is really exercised.
2026-10-16 20:0x (+13) · d4fd349 · surprise
tests/gpu.rs first failed because each rig auto-sized its tile pool to all free VRAM and the first one took
everything ("no room for a tile pool"). Fixed in the tests (explicit pool_tiles); the library default stays
"all free VRAM", so anyone holding two sweepers must set pool_tiles. Documented in the README.
2026-10-16 20:1x (+13) · d4fd349 · deviation
Contamination: the kappa-leadrun probe (experiments/2026-10-16-kappa-leadrun-fresh, started ~19:45, finished
20:07:29) was running on all 6 cores during my first 1e6 benchmark and during the first ~85 s of the 1e8 lean
baseline. I did not know it was running. Those lean timings (179.4 s at 1e6, 374.8 s at 1e8) are NOT used. I
re-ran both lean baselines alone on an idle machine. The probe's output is unaffected (it depends only on seeds);
its own wall time may be inflated for the overlapping minutes.
2026-10-16 20:1x (+13) · d4fd349 · observation
Profile of gpu_sweep 2 12 1000000 (4083 jobs): setup 1.2-1.4 s, device path 3.8-4.6 s of which the kernel
itself ~0.4 s (blocked-on-device 0.38 s for 3.8e9 steps), and onset replays 8.4 s on the CPU (a fresh 67 MB
grid per highway job). Added src/onset.rs (scratch-reusing replay, tests/onset.rs checks it against
highway_onset_flat, Langton's onset 9976): onset time 8.4 s -> 5.5 s, CSV still identical.
Clean 1e8 lean baseline: 287.3 s (results/bench_L2-12-1e8.log).
2026-10-16 20:4x (+13) · d4fd349 · observation
Clean benchmarks (idle machine; results/bench_L2-12-1e6.log, bench_L2-12-1e8.log, bench_summary.json from
scripts/summarise_bench.py): lengths 2..=12 (4083 rules). 1e6 steps: lean 67.7 s, GPU 5.7 s at 4096 lanes (11.8x).
1e8 steps: lean 287.3 s, GPU 154.4 / 86.2 / 59.0 s at 1024 / 2048 / 4096 lanes (1.9 / 3.3 / 4.9x). All CSVs identical.
oom_requeues = 0 in every benchmark.
2026-10-16 20:4x (+13) · d4fd349 · interpretation
P2 holds at 1e8 (4.9x, inside 3-8x). The 1e6 number (11.8x) mostly reflects sweep_lean's per-rule overhead, not
stepping (kernel ~0.4 s for 3.8e9 steps), so it should not be quoted as the speedup. Throughput scaled ~linearly
in lanes up to 4096 at 1e8, so P3 (saturation by 2k-8k) is untested here: the slice has only 4083 jobs.
Confidence moderate: one run per cell. Alternative: the 1e8 gain at 4096 lanes is partly tail-limited (last ants alone).
2026-10-16 20:4x (+13) · d4fd349 · decision
Left out of this version: turn noise, multi-ant / turmite states, probe statistics (the active kappa-leadrun
experiment needs probe, not sweep; asked about, answered no), and a GPU onset pass. Listed under "Open / next".