Everything on this page is measured, paired (same cell, same seed,
same machine), and reproducible from a script in scripts/. Where an
idea did not pay, it is written down as not having paid — a negative
result that stops the next person re-implementing it is worth as much
as a speedup.
Measurement conditions for the 2026-08-25 CPU numbers: NVIDIA GH200 480GB host (aarch64, 64 cores), numpy 1.21.5, single-threaded, with unrelated GPU jobs resident on the device. GPU numbers carry their own date and conditions.
Contents:
- Where the time goes
- The CPU backend
- Search-core hot loops
- The orbit layer
- What did not pay
- GPU-side roadmap
A representative campaign run — LNS ruin/recreate rounds at a fixed M,
then a shell-neighbourhood LAHC polish — profiled with cProfile on
the CPU backend (scripts/profile_search.py --backend cpu):
cell K5(8,3) |X|=390,625 M=395 25 LNS rounds + 10 s shell polish
ncalls tottime cumtime function
6901 4.262 4.750 dp._balls (ball offsets)
23591 1.221 1.221 numpy ufunc reduce
3717 1.086 7.004 dp.ball_gather
1589 0.648 0.648 protocol._critical (whole-space role mask)
240 0.517 0.934 dp._axis_hamming (the transform)
9 0.360 0.360 search.py:272 <listcomp> (greedy heap build)
The shape is the finding: on the CPU backend the search core's own
Python is a rounding error (the heap build is the only search-core
line in the top ten, at 3.6%), and 70% of the clock is exact ball
gathers — _balls plus the mask it indexes. The optimisations below
follow that, not intuition.
The same workload on the GPU backend, at K_3(8,2)
(scripts/profile_search.py --backend gpu), says the same thing more
bluntly:
6.55 s wall, 332 polish moves (55.3 mv/s)
ncalls tottime function
692 2.868 gpu.ball_update (4.1 ms/call)
705 2.835 gpu.ball_gather (4.0 ms/call)
105 0.438 gpu.uncovered (4.2 ms/call)
11411 0.011 numpy dispatch (everything the search core does)
99% of the clock is inside the ctypes calls and 2% is the search core, so there is no Python lever on this path either.
Caveat, stated because it changes what the numbers mean. These GPU figures were taken with an unrelated tenant holding the device at 100% utilisation, so ~4 ms per call is queueing, not launch overhead (a launch is tens of microseconds on an idle device). On this cell the CPU backend runs the same polish at 2,200 mv/s — 40× the contended GPU — which says nothing about the GPU and everything about sharing it. Re-take these on an idle device before drawing any conclusion about the plugin.
coldcore.dp.NumpyDPBackend runs the CUDA plugin's own axis DP in
numpy: O(n |X| (R+1)) instead of the reference's O(|X|²). Full
tables and the end-to-end comparison are in
benchmarks.md;
the headline, on the gain transform:
| cell | |X| | ref | cpu | speedup |
|---|---|---|---|---|
| K_3(8,2) | 6,561 | 400 ms | 0.48 ms | 831× |
| K_2(12,2) | 4,096 | 238 ms | 0.59 ms | 404× |
| K_2(16,3) | 65,536 | 2,816 ms | 13.3 ms | 212× |
and cells the reference refuses outright (K_3(12,3), K_5(8,3), K_7(8,3), torus 64³) now run at 1.7–460 ms per transform.
Reproduce: python3 scripts/bench_backends.py --json docs/bench_backends.json
ball_gather needs the coverage role (deficient / critical) of every
word in each candidate's ball. Two exact ways to get it:
- build the whole-space role mask (one
O(|X|)pass) and gather bytes from it; - gather the
cntvalues at the ball and apply the predicate there — noO(|X|)pass, but adtype-sized gather.
Measured on this machine (|X| = 390,625, ball 4,065, batch 216): the byte gather runs at ~0.85 ns/element against ~2.8 ns for the value gather, and the mask pass costs ~1.0 ns/element of |X|. So the mask pays only once the batch touches more than about half the space — and the polish's inner loop is a single-word loss query, which touches one ball. Scanning 390k words to answer a question about 4k of them was pure waste.
Picking the cheaper path per call (_MASK_BREAKEVEN, identical answers
either way, gated by tests/test_dp_backend.py::test_both_gather_paths_agree):
| cell | mask always | cost-adaptive | gain |
|---|---|---|---|
| K_5(8,3) | 227.0 mv/s | 259.5 mv/s | +14.3% |
| K_7(8,3) | 40.4 mv/s | 44.0 mv/s | +8.9% |
| K_3(8,2) | 2,078 mv/s | 2,200 mv/s | +5.9% |
| K_3(12,3) | 284.8 mv/s | 300.5 mv/s | +5.5% |
(mv/s = shell-polish moves per second, 8 s of polish, same seed, same
move semantics — the move counts differ only because more moves fit in
the same wall clock.)
Two changes, both provably decision-neutral:
- greedy heap build.
greedy_fillturned a threshold extraction of up to 2¹⁸ map entries into a heap with a Python loop doing numpy scalar indexing per element — 0.36 s of a 9.9 s run, the only search-core line in the profile's top ten. One.tolist()per array and azipproduce the identical list of tuples in the identical order. _shell1. The polish's distance-1 shell was rebuilt fromnp.arangeper axis on every move. The per-axis displacement tables are a function of the space alone, so they are built once.
Neither may move a decision, and that is enforced rather than asserted:
tests/test_search_determinism.py freezes SHA-256 digests of whole
move traces — greedy/peel/every ruin operator, the shell polish run to
feasibility, the two-swap endgame, and the orbit layer — taken from the
pre-optimisation core at commit 1710fb7. The pass sequences are
deliberately clock-free (fixed round counts; a polish that
terminates on uncov == 0 rather than on a budget), because a
time-budgeted pass runs more rounds on a faster machine and its trace
is then a stopwatch reading, not a decision record.
SymmetricSearcher's greedy probes an orbit by adding it, reading the
exact deficit back, and — usually — dropping it again. On the
bench_symmetry cells plus larger many-orbit cells
(scripts/bench_orbit.py, clock-free: partition + greedy + peel + 30
fixed ruin/recreate rounds), that probe was the whole cost.
Two changes, both exactly value-preserving:
- the code array is materialised on demand. A probe used to splice the point list twice (concatenate+sort on the way in, set-difference on the way out) although nothing between the two reads it — only its length, which is now tracked as an integer.
_undo_add/_undo_drop.ball_updateis its own exact inverse, so the deficit after undoing a probe is known: it is the value from before, and the probe already measured the difference. Reading it back off the field was a whole-space scan to learn a number already in hand.
Measured (best of 3, --rounds 30, CPU backend, identical move traces —
pinned by tests/test_search_determinism.py's orbit digests):
| cell | orbits | before | after | speedup |
|---|---|---|---|---|
| K_2(14,3) / shift | 8,192 | 17,632 ms | 14,912 ms | 1.18× |
| K_5(6,2) / shift | 3,125 | 4,719 ms | 3,715 ms | 1.27× |
| K_3(8,2) / shift | 2,187 | 2,468 ms | 2,091 ms | 1.18× |
| K_3(8,2) / diag | 2,187 | 1,307 ms | 1,099 ms | 1.19× |
| lee 11³ / translate | 121 | 113.4 ms | 92.6 ms | 1.22× |
| K_4(4,1) | 64 | 31.0 ms | 25.9 ms | 1.20× |
| K_2^3(8,1) (µ=3) | 64 | 32.2 ms | 28.3 ms | 1.14× |
| K_3(5,1) | 81 | 24.3 ms | 20.8 ms | 1.17× |
| K_2(6,1) | 32 | 22.8 ms | 19.9 ms | 1.15× |
| K_2^2(6,1) (µ=2) | 16 | 12.8 ms | 11.3 ms | 1.13× |
| torus 27×27 | 81 | 10.9 ms | 9.0 ms | 1.21× |
| K_4^2(4,1) (µ=2) | 16 | 9.0 ms | 7.6 ms | 1.18× |
| K_3(4,1) | 27 | 7.2 ms | 6.1 ms | 1.18× |
| torus 11×11 | 11 | 2.50 ms | 2.08 ms | 1.20× |
| king 9×9 | 9 | 0.44 ms | 0.41 ms | 1.07× |
| lee 5×5 | 5 | 0.39 ms | 0.38 ms | 1.03× |
| all 16 cells | 26,393 ms | 22,041 ms | 1.20× |
The two cells that barely move are the ones whose greedy accepts every orbit on the first pass (1 probe): there is no probe overhead to remove.
Reproduce: python3 scripts/bench_orbit.py --json docs/bench_orbit.json
- A symmetric prefilter for
ball_gather. Coverage is symmetric, so a candidate has zero gain unless it lies in some deficient word's ball; when few words are deficient, walking those balls settles the whole batch. Implemented, measured, removed: in the shell polish the gather happens after the incumbent has been dropped, so the deficient set is notuncov(≈ 5) butuncov + loss(w)(≈ 200) — the same order as the candidate batch. Measured difference on K_5(8,3), K_3(12,3), K_3(8,2): none, in either direction. - Block-structured ball offsets. The ball is a union of blocks that
are cartesian products of per-position delta sets, so the offsets are
an outer sum and the random gathers collapse by ~16×. Prototyped:
1,968 µs → 1,378 µs (1.4×), not the 3× the gather count suggested,
because the cost is the
(candidates × ball)output array's memory traffic and not the gathers. Not worth the complexity; noted here so the next person does not re-derive it. - int32 contribution tables. Accumulating ball offsets in int32 and widening once measured 1,968 µs → 1,979 µs in one run and 2,030 → 1,259 µs in another — i.e. inside the noise of a machine with other tenants. Not taken.
- Vectorising the orbit-layer field folds (
gain_bounds/loss_bounds/ the greedy heap build over orbits). Done — they are cleaner and allocate less — but measured on their own the total over the bench cells moved from 25.66 s to 25.98 s, i.e. not at all: at |X| ≤ 2¹⁴ those folds are dwarfed by the transform they follow. The 1.20× in section 4 came entirely from cutting calls (the lazy code array and the arithmetic undo), not from vectorising them. - Search-core micro-allocations on the GPU path. The obvious
candidates — the per-move one-element
np.arrayforball_update,np.isinagainst the incumbent, the LAHC list bookkeeping — are all below the noise floor: the GPU profile above puts everything numpy does at 0.011 s of 6.55 s. Preallocating scratch there would be optimising 0.2% of the run.
CUDA kernels were deliberately not touched in this pass (the device was fully occupied by an unrelated tenant, and a kernel change measured under contention is not a measurement). The opportunities below are carried over from benchmarks.md and the covering paper's Appendix A, in the order their evidence supports:
- Persistent-CTA transforms for LPDDR-resident cells. Pinned-ATS
streaming runs at ~72 GB/s effective and is insensitive to grid
size, which says the axis passes are bandwidth-bound and latency
is hidden — but the layer layout still re-streams every plane per
axis. Fusing the last two axis passes would remove one full pass
over
(R+1)·|X|words. Measure against the K_8(10,4) 0.143 s HBM baseline and the K_10(10,5) 73–110 s LPDDR baseline. - A gather that does not re-derive
contribper block. The split-block geometry already gave 29–37× on single-word updates; the remaining per-block cost isbuild_contrib, which is identical for every block working on the same word. Shared-memory reuse across the split is a pure win if the block count per word is > 1. map_extractwithout the global atomic. The extraction kernel serialises on oneatomicAddslot counter; a two-pass count-then-scatter (or per-block sub-allocation) is the standard fix and matters atthr = 1, which is what the polish's pool refresh uses.- µ-fold
ball_gatherfusion. Withmu > 1and a plugin lackingdct_ball_gather_mu, the binding sumsmuseparate literal-level gathers. Every shipped plugin has the fused kernel; the fallback is there for old builds and should be measured before anyone relies on it for a µ-fold campaign. - The owner-trick loss pass remains an honest negative in the
dense covering regime (17 ms → 28 ms at K_8(9,4), 145 ms → 669 ms at
K_8(10,4)); it is worth revisiting only for problems with
M·|ball| ≪ n·R·q^n.
python3 scripts/bench_backends.py --json docs/bench_backends.json # §2
python3 scripts/profile_search.py --backend cpu --cell 'K5(8,3)' # §1
python3 scripts/bench_orbit.py --json docs/bench_orbit.json # §4
python3 -m pytest tests/test_search_determinism.py # §3 gate