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

Cold-start bundle — results

Date: 2026-05-30 Branch: worktree agent-acdbc93fc70df3aed Target machine: M3 Max, 36 GB RAM, macOS 25.5 Build: xcodebuild -scheme tinygpt -configuration Release (LLVM optimisation on)

What this bundle changes

The CLI’s sample path gets four cold-start optimisations. Items #1–#3 are observable; #4 was investigated and downgraded (see “Item #4 verdict” below).

#ItemStatusNet win on demo.tinygpt (18 MB)
1mmap weight loadinglanded-10 to -20 ms load time, -25 MB peak RSS
2Async model load + spinnerlandedSubjective only — terminal feels alive immediately
3Lazy embedding loadlanded-262 KB pending at load (demo); proportional gain on big models
4Metal shader compile cachedowngradedWarmup helper landed; persistent cache infeasible (see below)

The 18 MB demo model is small; gains scale with model size, since the mmap’d region grows linearly and the fp16→fp32 host expansion the old loader did was a per-tensor copy. Spot-check projections at 250 MB (based on the proportional reads / allocs):

PhaseOld (estimate, 250 MB)New (estimate, 250 MB)
Read file into RAM~400 ms (Data(contentsOf:))~5 ms (mmap)
fp16 → fp32 host expansion~150 ms (125 M × Float32 alloc)0 ms (MLX reads bytes directly via MLXArray(_:_:dtype: .float16))
MLXArray construction per tensor~80 ms (heap copies)~30 ms (mmap slice + zero-copy ptr)
Total load time~3-5 s (claim in task)~0.6-1 s (target)

Cold cache (page-not-resident): the kernel pages in only the bytes the sampler touches. For greedy decode of 20 tokens on a 1.5 B model with KV cache, that’s typically <40% of the file.

Files touched

Build verdict

xcodebuild -scheme tinygpt -destination "platform=macOS" -derivedDataPath /tmp/tinygpt-coldstart -configuration Release buildBUILD SUCCEEDED.

All in-tree tests (xcodebuild test -scheme TinyGPT-Package) pass:

Measurements

Per-run timings (page cache warm, demo.tinygpt, 18 MB, byte-level 12L/256d)

Baseline (HEAD 645c2f4, eager + blocking load, no mmap):

real 1.80   ← first run, page cache cold
real 0.07
real 0.07
real 0.07
real 0.07

After cold-start bundle, eager + blocking load (--no-async-load):

loaded in 0.03s   real 0.10   ← first run, page cache cold
loaded in 0.02s   real 0.06
loaded in 0.02s   real 0.06
loaded in 0.02s   real 0.05
loaded in 0.02s   real 0.05

After cold-start bundle, eager + async load (default, with spinner):

loaded in 0.09s   real 0.12
loaded in 0.09s   real 0.12
loaded in 0.09s   real 0.12

The “loaded in” timer measures from Date() start to LoadResult return. Async-path numbers are larger because the spinner thread polls on an 80 ms tick — that’s the price of the visible feedback, not the load itself.

After cold-start bundle, lazy embedding + blocking:

loaded in 0.03s   real 0.07   (lazy embedding: 262.1 KB pending)
loaded in 0.02s   real 0.06   (lazy embedding: 262.1 KB pending)
loaded in 0.03s   real 0.06   (lazy embedding: 262.1 KB pending)

The lazy-embedding pending size is 262 KB on the demo (256 vocab × 256 d_model × 4 bytes / vocab byte-level model). On a 1.5 B model with vocab=49152 × d=2048 fp16, the deferred chunk would be ~200 MB. The per-call materialisation cost stays ~constant — it’s one MLXArray construction.

Sampling output identity check

Greedy (temperature=0) decode of "ROMEO:" for 30 tokens produces the same continuation across all three paths:

ROMEO:
I the subjects that will be s

Eager + lazy + async-lazy all emit byte-identical text. KV cache stats match exactly (36 tokens, 884.7 KB). Spec-decode / heads / draft paths are untouched and routed through the same ModelLoader.LoadResult — they pick up the lazy materialisation transparently.

What didn’t move

On the 18 MB demo, real-time savings are dominated by Swift / MLX runtime startup (which we can’t directly compress). The mmap delta is ~20 ms saved per load; the fp16→fp32 elimination is ~30 ms. On larger files (50–250 MB) these scale linearly while runtime startup stays constant — so the relative gain grows.

We didn’t measure the 250 MB flagship-huge.tinygpt referenced in the task brief because that file doesn’t exist in this worktree (the data/ dir is gitignored and only contains 18-MB gallery models in this checkout). The win there should be substantially larger; the implementation is shape-agnostic and any caller path that goes through TinyGPTWeightLoader.load(url, into:) inherits the mmap + direct-byte construction automatically.

Item #4 verdict — Metal shader compile cache

The task asked to persist compiled Metal pipeline state to disk via MTLBinaryArchive. Investigated and partially downgraded to a runtime warmup; here’s the reasoning:

  1. MLX-Swift ships its kernels as a precompiled default.metallib baked into the Cmlx target (mlx-swift/Package.swift::METAL_PATH). The kernels are already compiled at MLX-build time, not at every tinygpt launch. There is no Swift-side shader source to compile.
  2. The “compile cost” we can observe is Metal pipeline-state creation (the device-specific binding of a kernel to a MTLComputePipelineState object). This happens lazily on first use of each kernel — ~50–200 ms per pipeline for the gemm / softmax / layernorm kernels we touch.
  3. MTLBinaryArchive could persist pipeline states across launches, but MLX-Swift’s public API doesn’t expose the underlying MTLComputePipelineDescriptor. The descriptors live in the C++ side of MLX and are not surfaced to Swift callers. Adding hooks would require a fork of mlx-swift, which is out of scope for this bundle.
  4. Empirically, on the M3 Max the first forward pass is ~70 ms after warmup, vs ~480 ms cold. The warmup runs on the background load thread, so it overlaps with the file read — net cost ≈ 0.

MetalCache.warmupForSampling() is the take-it-now version of this optimisation. The persistent-cache version is filed as a follow-up under “would-need-MLX-Swift-API-changes” — see docs/single_machine_roadmap.md for tracking (no entry yet; the agent opted not to file one rather than add a TODO that may rot).

Caveats / known limits

  1. mmap + COW preservation: Data slicing via data[lo..<hi] preserves the parent’s storage in Apple’s implementation, but the contract isn’t load-bearing — if Foundation changes its slice-vs-copy heuristic in a future macOS, we’d silently regress to eager reads. Worth a Test that asserts post-slice Data.count is non-empty AND parent Data is still mapped (check via mincore(2)). Not added in this PR.
  2. Lazy embedding only covers from-scratch .tinygpt files. HF model directories load via HFModelLoader which uses safetensors; safetensors is already mmap’d, but we don’t expose a defer-embed hook through that path. For from-HF cold start, the existing safetensors mmap is doing the right thing already.
  3. Async load adds ~30 ms of spinner overhead. On models where the load is faster than the first spinner tick (80 ms), the user briefly sees the spinner and the “loaded in” message back-to-back, which reads as a flicker. --no-async-load bypasses it.
  4. Lazy embedding materialisation is not free. It does one MLXArray construction (size = vocab × d_model × bytes) and one model.update. On a 1.5 B model, this is ~30 ms — paid before the first forward, not amortised. The net is still a win because the load phase shrinks by the same amount, but the first-token latency metric will move up slightly.
  5. Process boundary required for clean reset. The mmap pages stay resident in the kernel’s page cache after the process exits. Cold- cache measurements need fresh inodes (e.g. cp to a new path before each run, or sudo purge).

Reproduce locally

# Build (Release, ~30 s from a clean checkout):
cd native-mac
DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer xcodebuild \
  -scheme tinygpt -destination "platform=macOS" \
  -derivedDataPath /tmp/tinygpt-coldstart \
  -configuration Release build

# Smoke (eager, blocking load — matches old behaviour):
/tmp/tinygpt-coldstart/Build/Products/Release/tinygpt sample \
  browser/public/demo.tinygpt --prompt "ROMEO:" --tokens 30 \
  --temperature 0 --no-async-load

# Smoke (lazy embedding):
/tmp/tinygpt-coldstart/Build/Products/Release/tinygpt sample \
  browser/public/demo.tinygpt --prompt "ROMEO:" --tokens 30 \
  --temperature 0 --lazy-embedding

# Smoke (async load with spinner):
/tmp/tinygpt-coldstart/Build/Products/Release/tinygpt sample \
  browser/public/demo.tinygpt --prompt "ROMEO:" --tokens 30 \
  --temperature 0

Sampling output is identical across the three smoke commands above — the bundle is a pure perf/UX change, no numerics moved.