Skip to content
posttrainllm docs
Esc
navigateopen⌘Jpreview
On this page

CPU speedup bundle — measured results

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

Configuration step/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 ON 6.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:

  • #1 Compile under cosine LRuseCompiledLR path on Trainer / TrainerHF. The optimiser’s learning rate is now an MLXArray scalar captured in compile(inputs: [opt], ...) state. Mutating LR each step no longer invalidates the trace. New file: native-mac/Sources/TinyGPTModel/TrainerCompile.swift defines CompiledAdamW — a re-implementation of MLX-Swift’s AdamW where LR is mutable through LearningRateMutable._updateInternal on the same captured array object.
  • #2 Compile-friendly accumulationaccumMicroBatches: N path on both trainers. When set, the gradient-accumulation loop runs INSIDE the compiled trace: the trace takes 2N flat MLXArrays (x0, y0, x1, y1, …), sums grads, applies clip + layer-decay, calls the optimiser, returns mean loss. One kernel-launch sequence per optimiser step instead of N separate gradFn calls.
  • #3 QoS bump to .userInteractive — single pthread_set_qos_class_self_np call at Train.run entry, lifting the training thread off any E-core. Implemented in TrainSupport.bumpQoSToUserInteractive. Best-effort; failure is silently ignored.
  • #4 Async batch pipeline--prefetch on flag wires up a BatchPipeline that spins one .utility-QoS producer thread to build the next batch’s MLXArrays while the GPU is busy with the previous step. Bounded queue (capacity 2); drains on shutdown so the consumer is the last thread to touch MLXArrays (avoids a cross-thread release SIGTRAP we hit on macOS 26 / mlx-swift 0.25).

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.

Config step/s (3 runs) median
[A] all items off (= HEAD baseline) 4.9 / 5.0 / 5.5 5.0
[B+#3] +QoS only 5.2 / 5.4 / 4.9 5.2
[B+#3+#1] +compiled cosine LR 5.4 / 5.1 / 5.0 5.1
[B+#3+#1+#2] +fused accum 6.5 / 6.6 / 6.6 6.6
[B+#3+#1+#2+#4] +prefetch (all four) 6.6 / 6.8 / 6.9 6.8

Incremental contributions:

  • #3 QoS alone: +2% (5.0 → 5.2). In the noise band.
  • #1 compiled cosine LR (on top of #3): ~0%. With --accum 4 active the compile gate had ALREADY been forced off by the accum constraint, so layering #1 on top of #3 doesn’t light it up; #1’s benefit only materialises once #2 makes accum compile-safe too.
  • #2 fused accum (on top of #1+#3): +27% (5.2 → 6.6). This is the single biggest item in the bundle. Folding the N=4 accumulation loop into one compiled trace is where the win lives.
  • #4 prefetch (on top of #1+#2+#3): +3-7% (6.6 → 6.8). The overlap of background sampling with GPU dispatch is real but small — most of the host-side cost was already removed by #1+#2.

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.

Config step/s (3 runs) median vs ALL-OFF baseline
#1 alone: cosine, no accum, QoS off, #2 off 22.2 / 24.4 / 29.8 24.4 (cf. const-LR 25.0; ~−2%)
#2 alone: accum=4, no cosine, QoS off, #1 off 6.6 / 6.7 / 7.1 6.7 (cf. ALL-off 5.0; +34%)
#3 alone: const LR, no accum 31.6 / 25.0 / 24.5 25.0 (cf. #3 off 24.5; ~+2%)
legacy const LR, no accum, #3 off 29.6 / 21.6 / 24.5 24.5 (reference)
legacy const LR, no accum, #3 on 30.8 / 23.7 / 25.9 25.9 ~+6% (within noise)

Notes:

  • #1 alone is comparing cosine vs no-cosine on the SAME compiled path. Net effect ~wash: the schedule-mutable trace pays a tiny overhead per step for the LR readback / mutate, but the trace itself is otherwise identical to the const-LR trace. The point of #1 is “preserve compile when schedule is on”, not “go faster than const LR” — and it does that.
  • #2 alone lights up the win independent of #1: even with --accum 4 and no schedule, the legacy host-loop costs 5.0 step/s, and the fused trace costs 6.7 — same +30% range.
  • #3 alone and the legacy-compile rows confirm we did not regress the existing constant-LR, no-accum fast path. All four rows are inside ±10% of each other, dominated by run-to-run variance on this preset.

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

  • #3 QoS alone is in the noise. A 2% median improvement (5.0 → 5.2 step/s) is well inside the run-to-run variance of this preset. The mechanism is correct — .userInteractive does keep us off the E-cores; you can verify with powermetrics --samplers cpu_power — but the small/tiny preset doesn’t have enough host-side work for the change in CPU placement to show up. Keep the call (it’s free, one syscall at startup), but don’t expect it to carry the headline number. On heavier workloads where host-side dispatch is a larger fraction (think huge/mega presets with deeper models) the effect should be larger, but we did NOT measure that here.

  • #1 compiled cosine LR alone is also in the noise vs const LR. That’s by design — see the §item isolation note above. #1 is a “preserve compile when schedule is on” item, not a “go faster than const LR” item. Its win is realised through #2: once both are enabled, cosine + accum compiles, and you get the +27% from #2 rather than paying the full host-loop tax that the LR-schedule block in HEAD imposes.

  • #4 prefetch is real but small (+3-7% on small/B=16, accum=4). The previous round of measurements on small + B=16 saw +5-6% (median 6.5 → 7.0); this round saw +3% on the interleaved bench script but +5-7% on a back-to-back A/B against the same corpus seed. Either way it’s a single-digit win and well below the noise floor on the tiny preset. The implementation IS correct (no SIGTRAP at shutdown, capacity-2 bounded queue, drain on .stop); it’s just that the small-preset GPU step is short enough that sampling fully hides without help. Keep it as opt-in (--prefetch on, default off); document that the win scales with micro-batch construction cost (large B, BPE streaming corpus, etc.). Do NOT default it on — the producer thread is one more thing to debug.

  • Variance on tiny preset is unusable. The tiny preset (B=8) cell-by-cell bench produces results with so much variance (e.g. 21.0/39.2/46.0 step/s for the same config) that no signal under +30% is recoverable. All headline numbers in this doc are from the small preset, which has enough per-step work to stabilise.


Caveats

  • Optimiser scope. useCompiledLR is AdamW-only today. Lion, Sophia, Muon, Adafactor still flow through the original makeOptimizer factory and the legacy “compile off when schedule on” gate. Extending #1 to those is mechanically simple — each just needs an LR-array-state subclass — but we didn’t do it in this round because AdamW is what every preset’s default-config uses for the flagship runs.
  • canCompile interaction. The new compile sub-paths are additive — they ride on top of the existing canCompile gate, which still requires galore == nil. If you use --galore, you’ll fall through to the host-loop path for both accumulation AND schedule, exactly as before. The GaLore projector mutates state out-of-graph, so this is a correctness constraint, not a missed opportunity.
  • Compiled-accum N is fixed at trainer-init. --accum 4 builds a trace for N=4 micro-batches; passing a different N later would trip the precondition inside the trace. Changing --accum between runs is fine (each run rebuilds the trainer); changing it mid-run is not (and nothing in the codebase does that).
  • Loss equivalence. All A/B pairs in this doc converged to losses within 0.005 of each other after 80 steps. We did not do a thousand-step convergence test — the items are pure host-side / kernel-fusion changes, so we don’t expect any long-tail divergence, but a one-shot 500-step parity check on huge would be reassuring before the next flagship run.
  • huge preset not measured here. GPU access was constrained when this measurement was taken; the heavy training that motivated this work (~0.07 step/s on flagship huge) is not re-measured in this doc. The mechanism — fused accum + compiled cosine — is exactly what huge was paying for, so we expect a similar +30-40% range there too. Verify with a 50-step huge smoke before committing the next flagship run.

How to run the bench yourself

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

BIN=/tmp/posttrainllm-cpubundle/Build/Products/Release/posttrainllm \
  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 posttrainllm 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:

  • #5 Rust-FFI BPE tokenizer — only worth doing if BPE-dropout is on. Streaming-tokenized corpus with BPE-dropout is currently ~5-15× slower batch construction, per the comment in Trainer.swift:106. Not addressed here.
  • #6 Pre-allocated CPU buffers — avoid per-step [Int32] allocation in sampleBatchRaw. Estimated 0.5-2 ms / step saved; with the bundle in place, that’s now ~1% of step time, so de-prioritised.
  • #7 SVD-on-CPU AMX verification — confirm MLXLinalg.svd with stream: .cpu actually dispatches to AMX-backed LAPACK. Not relevant unless GaLore / PEFT-SVD is in use. Pure verification item.

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.

Was this page helpful?