← All experiments

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 = None is the census instrument (blank background, identical to sweep/sweep_lean). Some(s) is the repo's existing seeded meaning: i.i.d. uniform background colours ext::bg_colour(s, x, y, n) (walkers::mix64). The ant itself is deterministic, so the seed only enters through the background.
  • Virtual GRID x GRID grid per ant through a tile directory (64x64-byte tiles, tile ids from a global pool, popped with atomicAdd, 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 feature cuda, so cargo test --release without 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_lean spends 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 to probe and ran about 5.8x faster at probe 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") and AGENTS.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 8192 runs need (stepping-bound, ~3e11 steps per 2400 runs). Needs its own oracle test against src/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; None is 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_lean on 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
In [ ]:
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
In [ ]:
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".

2026-10-16-gpu-ant-sweep · Source revision ba38590b33e3

Original source ↗ · Input hashes

Introductions summarize the evidence; the expandable record preserves the original predictions, methods and caveats. Figures and notebooks are published as saved. Publication runs no simulations or notebook cells.