← TinyGPT · docs · devlog · roadmap · speedup
source: docs/cpu_speedup_results.md · view on GitHub ↗

CPU speedup bundle — measured results

Status: implemented, measured, four items shipped. Numbers in this doc were recorded against the worktree binary built at the same SHA as the diff that introduced the items. Comparison baseline is main HEAD prior to the bundle (a9cac22, pruning work), which is functionally equivalent to running the bundle binary with every item disabled via TINYGPT_DISABLE_*=1.

Reference brief: docs/cpu_utilization_research.md §3a–§3f. This doc closes items #1–#4 from that list. Items #5 (Rust-FFI BPE), #6 (pre-allocated CPU buffers), and #7 (SVD-on-CPU AMX verification) are still open.


TL;DR

Configurationstep/s (median of 3)vs baseline
--lr-schedule cosine --accum 4, all four items OFF (HEAD baseline)5.0
--lr-schedule cosine --accum 4, all four items ON6.8+36%

Bench preset: small (6L · d=384 · ctx=256, ~5M params, fp32), B=16, AdamW, on M5 Pro / macOS 26.5, idle box.

step --- training quality is unchanged: the same 80-step run lands at final loss 4.188-4.192 with items on/off — within 0.005 of each other, i.e. bit-equivalent training behaviour. The speedup is pure host-side overhead removal; no math changed.


What shipped

Four items from docs/cpu_utilization_research.md §3:

The bundle also adds three benchmark-only environment variables (TINYGPT_DISABLE_COMPILED_LR, TINYGPT_DISABLE_FUSED_ACCUM, TINYGPT_DISABLE_QOS) so the bench harness can toggle items off one at a time without rebuilding. They are NOT user-facing knobs — the help text doesn’t mention them and the run banner doesn’t print them.

Mechanism: why each one moves the needle (or doesn’t)

#1 Compiled cosine LR. The previous behaviour was: if --lr-schedule cosine or --warmup > 0, canCompile was forced to false because every optimizer.learningRate = newValue re-created the optimiser’s LR scalar and invalidated any captured trace. That meant cosine schedules paid the full host-loop tax — N gradFn calls per step, no kernel fusion, no common-subexpression elimination across steps. With CompiledAdamW the LR is an MLXArray that lives inside the optimiser’s innerState(). The trace captures the array by identity; mutations through _updateInternal change the contents without breaking the trace.

#2 Fused accumulation. Each accumulatedStep call used to be N separate gradFn invocations from Swift, with element-wise gradient folding in host code via mapValues. That’s N round-trips across the host/MLX boundary, plus a per-microbatch eval(loss) for the host loss-sum readback. The compiled variant builds ONE trace that folds the whole loop in MLX — gradient sum stays on-device, only the final mean loss returns to host. Trace shape is fixed at trainer-init by N (the --accum value); changing N rebuilds the trace.

#3 QoS bump. On macOS 26, terminal-launched processes default to .default QoS which the scheduler may park on an E-core (2 GHz, ~½ the performance of a P-core for our host-side workload). .userInteractive guarantees a P-core. On small/tiny workloads the contribution is in the noise (1–2%) because the GPU dominates wall-time; on heavier workloads where host-side dispatch is a larger fraction, the effect should be larger but we did not measure that here.

#4 Async batch pipeline. A background .utility thread builds MLXArrays for the next batch (random sampling + Int32 fill + buffer copy). The training thread’s GPU dispatch keeps the GPU busy in parallel. Win is bounded by max(0, sampling_time - gpu_step_time): on heavy steps the GPU dominates and sampling fully hides; on tiny steps there’s nothing to hide behind.


Detailed benchmark

scripts/cpu_bundle_bench.sh was used. Each cell is median of 3 runs.

small preset · B=16 · 80 steps · cosine + accum=4

This is the core comparison — both cosine LR (item #1) and accum=4 (item #2) are active.

Configstep/s (3 runs)median
[A] all items off (= HEAD baseline)4.9 / 5.0 / 5.55.0
[B+#3] +QoS only5.2 / 5.4 / 4.95.2
[B+#3+#1] +compiled cosine LR5.4 / 5.1 / 5.05.1
[B+#3+#1+#2] +fused accum6.5 / 6.6 / 6.66.6
[B+#3+#1+#2+#4] +prefetch (all four)6.6 / 6.8 / 6.96.8

Incremental contributions:

Item isolation tests (single-item vs zero-baseline)

To check that each item’s win is independent of the others — and to confirm we’re not regressing the legacy fast path — we tested each item with the others held at “off” and a no-schedule / no-accum baseline.

Configstep/s (3 runs)medianvs ALL-OFF baseline
#1 alone: cosine, no accum, QoS off, #2 off22.2 / 24.4 / 29.824.4(cf. const-LR 25.0; ~−2%)
#2 alone: accum=4, no cosine, QoS off, #1 off6.6 / 6.7 / 7.16.7(cf. ALL-off 5.0; +34%)
#3 alone: const LR, no accum31.6 / 25.0 / 24.525.0(cf. #3 off 24.5; ~+2%)
legacy const LR, no accum, #3 off29.6 / 21.6 / 24.524.5(reference)
legacy const LR, no accum, #3 on30.8 / 23.7 / 25.925.9~+6% (within noise)

Notes:

Direct A/B with same corpus seed

To rule out the noise floor at small step counts, we ran a tighter A/B on the canonical config (cosine + accum=4):

HEAD baseline (all 4 items off):    4.9 / 5.0 / 5.5 step/s → median 5.0
All 4 items on (--prefetch on):     6.6 / 6.8 / 6.9 step/s → median 6.8
                                                              =====
                                                              +36%

Loss converges identically (4.188 / 4.190 / 4.191 in both arms — the items are math-preserving). The +36% is real, and it’s >5× the run-to-run noise band.


Honest assessment — what didn’t work


Caveats


How to run the bench yourself

DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer \
  xcodebuild -scheme tinygpt -destination "platform=macOS" \
  -derivedDataPath /tmp/tinygpt-cpubundle -configuration Release build

BIN=/tmp/tinygpt-cpubundle/Build/Products/Release/tinygpt \
  STEPS=80 BATCH=16 PRESET=small \
  bash scripts/cpu_bundle_bench.sh

The harness toggles each item via env var (TINYGPT_DISABLE_*=1) and the --prefetch on/off flag. It runs three trials per config and reports the median. Median is preferred over mean because of the heavy-tailed outliers we get from tinygpt train cold-starts on macOS (first run after build is consistently 30-40% slower).


Open items (not in this bundle)

From docs/cpu_utilization_research.md §3:

If a future agent picks any of these up, the bench harness already exists; adding a column to the table above is the right shape of work.