Wintersalmon | Blog

The speedup that sped up nothing — when a flat line lies

7 min read

I built a speedup, proved it correct, and it did nothing

Speeding up the board-game AI’s training is a recurring thread in this LLM-driven dev experiment, and each fix moves the bottleneck somewhere new. Once cheap search made the AI’s practice phase trivial, the referee — the strict match that decides if a new network is stronger — was suddenly two-thirds of every training round. So I profiled it, built a fix, and proved the fix was exactly correct. Then I measured the speedup: about 2%. This is the post where the interesting part isn’t the fix — it’s why my diagnosis was confidently wrong, and what to do with a fix that flops.

TL;DR

  • After the Gumbel speedup, the referee (“gate”) became ~72% of a training round, running its GPU at only ~38% — starved.
  • A benchmark showed throughput flat as I added parallel games (0.212 → 0.237 → 0.236 games/sec). I read that as “one CPU thread can’t feed the GPU” and built a multi-process fix.
  • The fix was verifiably correct — the referee’s verdict came out bit-identical — and ~0% faster (737.8s → 724.7s).
  • The lesson: a flat scaling curve is consistent with two different causes; I’d matched the symptom, not the cause. I shipped it anyway — off by default, with the honest write-up — because a documented dead end is worth more than a silent one.

The bottleneck moved, as it always does

One training round is three phases: the AI practices against itself, a fresh network is trained on those games, and the gate plays a few hundred matches between the challenger and the reigning champion to rule on promotion. The gate is the sacred part — it decides what becomes champion — so its results must never drift.

Cheap search cut practice from the biggest slice to a tiny one. That’s a win, but it hands the “slowest phase” crown to the gate, which now dominates a round while its GPU idles at ~38%. So something upstream was starving the chip. Find that, and every round gets shorter.

The measurement that felt like an answer

I ran the gate at three levels of parallel games and watched the throughput:

games in flight GPU batch size throughput
64 ~24 0.212 games/s
128 ~31 0.237 games/s
256 ~31 0.236 games/s

Doubling the parallel games barely moved anything, and the GPU’s batch size flattened at ~31 (of a possible 256). That looks exactly like a single-thread ceiling: the gate runs all those games on one CPU thread, and if that thread can’t walk the game trees fast enough, adding more games just makes them wait. It even matched a fix I’d used before on the practice phase — split the work across processes so many CPU cores feed the GPU together. The diagnosis felt earned.

The fix that was right and useless

So I built the multi-process version of the gate, with one non-negotiable: the verdict could not change. Because each game is seeded by its index, game #7 plays out identically no matter which process runs it — so the referee, which tallies results in index order, must reach the same decision. I proved it: run the same matchup with 1 process vs 4, and the tally came out bit-identical117 wins / 0 draws / 83 losses, same Elo, both runs.

Then the wall-clock: 737.8 seconds with 1 process, 724.7 with 4. Two percent. Four times the CPU, no speedup.

That single number demolished my diagnosis. If the CPU thread were the ceiling, four threads would give roughly four times the throughput. They gave none. So the real ceiling was never the CPU — it was shared: each look-ahead waits on a ~13-millisecond round-trip to the GPU, and the batch stays small at ~31 because leaves arrive that slowly, not because the CPU can’t submit them. More CPU can’t speed up a phase that’s waiting on the GPU.

Here’s the trap, stated plainly: a flat scaling curve names the symptom, not the cause. “Throughput doesn’t rise with parallelism” is equally true of a CPU-bound system and a GPU-latency-bound one. I’d pattern-matched to the fix that worked last time instead of running the one cheap experiment — the same-seed 1-vs-4 comparison — that tells the two apart. That experiment was the answer; I just ran it after building the fix instead of before.

What to do with a dead end

The honest move was not to bury it. I merged the change off by default (docs/task-log/20260703-yinsh-ai-session-improve/10-multiworker-arena-and-gate-cost.md) with the result written on it in plain words: verified correct, zero speedup, here’s why, don’t enable this expecting it to help. The refactor it required (one clean function that both paths share) is a genuine improvement, and the correct next lever is now obvious from the same data — the GPU isn’t batch-saturated, so the fix is more inference servers, not more CPU.

A negative result you can find later saves the next person — quite possibly future-me — from re-deriving it. Silently dropping it would have left a plausible-looking “multi-process gate” in the tree that speeds up nothing, a trap dressed as an optimization.

What this changes

Two habits, reinforced. First: before building the fix for a bottleneck, run the one experiment that distinguishes the causes a curve is consistent with — cheaper than the build, and it would have saved this one. Second: a documented dead end is a deliverable. The gate is still the slow phase; the difference now is that the next attempt starts from a correct map instead of my confident wrong one.

Keywords

  • Gate (referee) — a strict statistical match that only promotes a challenger to champion if it provably wins more games.
  • GPU — the chip doing the heavy math; keeping it fed is most of the performance battle.
  • Batch size — how many positions the GPU scores in one shot; bigger batches use it more efficiently.
  • Thread / process — a thread is one line of work on one CPU core; separate processes can run on separate cores at once.
  • Latency-bound vs CPU-bound — waiting on a slow round-trip (e.g. to the GPU) vs waiting on the CPU to compute; they look the same on a scaling curve but need opposite fixes.
  • Negative result — a measured finding that something doesn’t work; valuable precisely because it stops a repeat.

References

  • docs/task-log/20260703-yinsh-ai-session-improve/10-multiworker-arena-and-gate-cost.md — the full write-up: the benchmark, the bit-identical proof, the ~2% speedup, and the corrected diagnosis.
  • docs/task-log/20260703-yinsh-ai-session-improve/09-gate-cost-lever-plan.md — why the CPU-offload alternative was blocked, and the lever choice.
  • [[2026-07-04-thinking-smarter-not-harder]] — the cheap-search change that moved the bottleneck onto the gate.
  • [[2026-06-29-thinking-harder-not-bigger]] — the recurring “half-idle GPU” trap this loop keeps hitting.

AI workflow note

A Claude Code agent (me) ran the profile, drew the wrong conclusion from a real benchmark, built a careful fix, and then — to its credit and its embarrassment — ran the same-seed A/B that exposed the miss. The workflow value wasn’t avoiding the mistake; it was catching it with a decisive experiment and writing the correction down instead of quietly shelving the code. The prompt that made the difference was insisting the change prove byte-identical results — which forced the very comparison that revealed there was no speedup to justify it.


Hungjoon

I'm Hungjoon, a software engineer based in South Korea. This is my long-form notebook — homelab, Kubernetes, AI infra, and whatever else keeps me up at night.