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

Gradient checkpointing — worked example

This doc captures the measurements from wiring custom gradient checkpointing into the Mac training path (Tier 1.7 of the single-machine roadmap). The numbers below were collected on an M5 Pro with 48 GB unified memory, MLX-Swift 0.25, fp32 training, Metal backend.

TL;DR

tinygpt train --grad-checkpoint wraps every TransformerBlock forward in an MLX CustomFunction whose VJP re-runs the block forward at backward time. The block’s intermediate activations are not retained across the outer backward, so per-block activation memory drops at the cost of one extra forward per block at backward time.

The wins land at the scales where activations actually dominate memory (large models or long contexts at non-trivial batch sizes):

ConfigBctxPeak no-ckptPeak with ckptΔ memorystep/s no-ckptstep/s with ckptΔ speed
tiny (4L · d=128)8128161 MB189 MB+17%142.7126.3-11%
huge (12L · d=256)82561611 MB1438 MB-11%18.616.4-12%
huge (12L · d=256)410243938 MB3223 MB-18%4.83.9-19%
mega (24L · d=512)210245800 MB4300 MB-26%2.12.0-5%
behemoth (32L · d=1024)1102411230 MB8848 MB-21%1.31.2-8%
behemoth (32L · d=1024)2102416747 MB11749 MB-30%0.80.7-12%
behemoth (32L · d=1024)3102422019 MB14777 MB-33%0.50.50%
behemoth (32L · d=1024)4102427679 MB17816 MB-36%0.40.3-25%

Two patterns are visible:

  1. Tiny models are a net loss. When the block forward is small enough that the CustomFunction trace + recompute overhead dominates the savings, checkpointing adds memory (the recompute trace itself has to live somewhere). The flag is opt-in for exactly this reason — it’s tuned for models too big to train without it.

  2. At behemoth scale the savings are substantial — going from 28 GB peak down to 18 GB peak (a 10 GB headroom recovery) at B=4 ctx=1024 trades for a 25% step-time hit. That’s roughly the “30% extra compute, ~√L activation memory” trade the literature advertises (Chen et al. 2016, “Training Deep Nets with Sublinear Memory Cost”), though MLX’s recompute overhead is a little less than the theoretical ~33% in practice — the kernels for our transformer blocks are well-batched, and the CustomFunction path inherits MLX’s lazy-eval batching.

How it works in code

MLX-Swift (as of 0.25) doesn’t expose a first-class mlx.checkpoint primitive. The mechanism we use instead:

// Pseudocode of GradCheckpoint.wrap
let cf = CustomFunction {
    Forward { inputs in
        let x = inputs[0]
        let params = unflatten(Array(inputs.dropFirst()), keys)
        block.update(parameters: params)
        return [block.rawForward(x)]
    }
    VJP { primals, cotangents in
        // re-execute forward inside vjp() so gradients exist
        let (_, grads) = MLX.vjp(sameForward, primals: primals,
                                 cotangents: cotangents)
        return grads
    }
}
return cf([x] + flatParams)

The block’s trainable parameters are threaded through the CustomFunction as declared inputs, mirroring how valueAndGrad injects them as primal inputs to the gradient transform. The block’s @ModuleInfo slots are re-slotted with the input tracers via block.update(parameters:) before each invocation — safe under MLX-Swift’s tracing model because each training step retraces (or replays a compiled trace) with fresh tracers.

See native-mac/Sources/TinyGPTModel/GradCheckpoint.swift for the real implementation and inline comments.

What’s NOT done

Reproducing the numbers

The test corpus is the first 5 MB of /tmp/fineweb-edu-500M.txt (any UTF-8 text works; activations don’t depend on token statistics beyond shape). Each measurement is from a 2-5 step run — long enough to amortise compile-trace cost but short enough to iterate quickly.

# Baseline (no checkpoint)
tinygpt train --preset behemoth --steps 3 --corpus /tmp/corpus.txt --batch 3

# With checkpoint
tinygpt train --preset behemoth --steps 3 --corpus /tmp/corpus.txt --batch 3 --grad-checkpoint

The “memory:” line at end of training reports peak GPU memory, active memory, and cache memory as measured by MLX.Memory.peakMemory (reset at training start so the number reflects training only, not model construction).

Honest caveats