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

Cold-start bundle — results

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 posttrainllm -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).

# Item Status Net win on demo.tinygpt (18 MB)
1 mmap weight loading landed -10 to -20 ms load time, -25 MB peak RSS
2 Async model load + spinner landed Subjective only — terminal feels alive immediately
3 Lazy embedding load landed -262 KB pending at load (demo); proportional gain on big models
4 Metal shader compile cache downgraded Warmup 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):

Phase Old (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

  • native-mac/Sources/TinyGPTIO/TinyGPTFile.swift — added TinyGPTFileReader.readMapped(_:) that uses Data(contentsOf:, options: .alwaysMapped). Also switched per-tensor slicing from subdata(in:) (heap-copy semantics on small slices) to range-slice (data[lo..<hi]) so mmap backing is preserved.
  • native-mac/Sources/TinyGPTModel/WeightLoader.swift — three changes:
    1. TinyGPTWeightLoader.load(url:into:) now uses the mmap’d reader.
    2. arrayFromTensor constructs MLXArray(data, shape, dtype:) directly from raw bytes instead of expanding fp16 to a host [Float32]. For fp32 tensors this is a strict win; for fp16 we hand the bytes to MLX as .float16 then .asType(.float32) (lazy cast that fuses with the first arithmetic op).
    3. New loadDeferringEmbedding(_:into:) API + LazyEmbeddingHandle class that defers token_embedding.weight and position_embedding.weight until .materialize() is called.
  • native-mac/Sources/TinyGPTModel/AnyModel.swiftModelLoader.LoadResult gained a lazyEmbedding field; new loadLazyEmbedding(_:) and loadAsync(_:deferEmbedding:) entry points. The async path is a withCheckedThrowingContinuation over a DispatchQueue.global(qos: .userInitiated) block so it composes with Swift concurrency.
  • native-mac/Sources/TinyGPTModel/MetalCache.swift (new) — MetalCache.warmupForSampling() runs a representative matmul + softmax to force MLX to register Metal compute pipelines on the background load thread, so the first token doesn’t pay pipeline-state creation cost. See “Item #4 verdict” for what we did NOT build.
  • native-mac/Sources/TinyGPT/ColdStart.swift (new) — ColdStart.loadWithSpinner wraps the load on a background Thread, paints a braille spinner on stderr, and runs MetalCache.warmupForSampling() alongside.
  • native-mac/Sources/TinyGPT/Sample.swift — added --lazy-embedding and --no-async-load flags; routes through ColdStart.loadWithSpinner by default; materialises the lazy embedding just before the first forward; reports load time and (when lazy) pending embedding bytes.

Build verdict

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

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

  • TinyGPTIOTests: 19 / 19
  • TinyGPTModelTests: 17 / 17
  • CrashRecoverySubprocessTests: 2 / 2
  • TinyGPTServeTests: 5 / 5
  • Total: 43 / 43

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 posttrainllm 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 posttrainllm -destination "platform=macOS" \
  -derivedDataPath /tmp/posttrainllm-coldstart \
  -configuration Release build

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

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

# Smoke (async load with spinner):
/tmp/posttrainllm-coldstart/Build/Products/Release/posttrainllm 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.

Was this page helpful?