Skip to content
C.W.K.
Stream
Lesson 01 of 06 · published

From a File on Disk to Unified Memory Pages

~14 min · journey, safetensors, mmap, page-cache, wired-memory, measured

Level 0Spec-Sheet Skimmer
0 XP0/91 lessons0/19 achievements
0/100 XP to next level100 XP to go0% complete
"A model is loaded when its pages are wired. Everything before that is a promise."

Three States of the Same Bytes

A checkpoint on disk is a safetensors file: a JSON header naming every tensor and its byte range, then the raw bytes. Loading it on a Mac is not a copy into a different memory — there is no different memory — but it is three distinct states, and watching them is the clearest way to see unified memory from the kernel's side. Mapped: MLX memory-maps the file, so the tensors exist as addresses without any bytes having moved; if the file is in the page cache (a warm load) those addresses already point at pages in the pool. Touched: the first forward pass reads every weight, faulting in any page not yet resident — from the page cache in nanoseconds, from the SSD in the memory track's cliff-sized time if cold. Wired: pages the GPU is using are pinned so the kernel cannot evict them; this is the memory the recommended working set is talking about, and vm_stat reports it directly.

Measured on office

The code block snapshots vm_stat before loading the 27B, after load(), and after the first forward pass (a warm load; the file was in the page cache):

MomentWiredActiveFreeWhat happenedEvidence
before29.28 GiB199.50 GiB79.51 GiBthe machine's normal state: engines, caches, everything elsemeasured, office, 2026-09-15
after load() — 3.71 s27.02 GiB213.25 GiB52.91 GiBmapped: the file's pages counted as active, nothing wired for the GPU yetmeasured
after first forward — 1.27 s43.41 GiB198.31 GiB51.60 GiBtouched and wired: +16.4 GiB wired, the model's 14.1 GiB (MLX's own count) plus working buffersmeasured

Read the wired column: it barely moves at load() (it dips by two gigabytes, something else being released) and jumps by the model's size at the first forward. That is lazy loading made visible — MLX's load returns in under four seconds because it has promised the bytes, not read them; the first token pays for the reading. It is also why the lab script runs a warm-up generation before timing anything, and why a model's "load time" in any runtime is really two numbers.

Cold Loads, and What the Pool Does Not Save You From

Nothing above touched the SSD, because office had read the file earlier in the day and 512 GB of memory keeps a lot of page cache. A cold load is the same three states with a disk read in the middle: 16 GB at the Air's measured 2.85 GB/s is about six seconds, and at a Studio's SSD a couple. Unified memory removes the copy from host memory to device memory; it does not remove the read from disk to memory, and it does not make the wired pages free — they are the pages the memory track's swap cliff is about, and the GPU track's claimant discipline is about leaving enough of the pool unwired for everyone else.

Code

file_to_pages.py — vm_stat before load, after lazy load, after the first forward·python
#!/usr/bin/env python3
"""Watch a checkpoint become memory. vm_stat before, after load (lazy: mapped,
not yet touched), and after the first forward pass (pages touched and wired
for the GPU). Sizes in GiB from 16 KB pages."""
import subprocess, time
import mlx.core as mx
from mlx_lm import load

PAGE = 16384


def vm() -> dict:
    out = subprocess.run(["vm_stat"], capture_output=True, text=True).stdout
    return {line.split(":")[0].strip(): int(line.split(":")[1].strip().rstrip("."))
            for line in out.splitlines()[1:] if ":" in line}


def gib(pages: int) -> float:
    return pages * PAGE / 2**30


before = vm()
t = time.perf_counter(); model, tok = load("mlx-community/Qwen3.5-27B-4bit"); t_load = time.perf_counter() - t
after_load = vm()
prompt = mx.array(tok.apply_chat_template([{"role": "user", "content": "hi"}], add_generation_prompt=True))[None]
t = time.perf_counter(); mx.eval(model(prompt)); t_first = time.perf_counter() - t
after_first = vm()

for name, snap in (("before", before), ("after load()", after_load), ("after first forward", after_first)):
    print(f"{name:20} wired {gib(snap['Pages wired down']):7.2f} GiB   active {gib(snap['Pages active']):7.2f} GiB   free {gib(snap['Pages free']):7.2f} GiB")
print(f"\nload() {t_load:.2f}s (lazy: mapped, not read)   first forward {t_first:.2f}s (pages touched, wired)")
print(f"MLX active memory after first forward: {mx.get_active_memory()/2**30:.2f} GiB")

# office, M3 Ultra, 2026-09-15 (warm):
# before               wired  29.28   active 199.50   free 79.51
# after load()         wired  27.02   active 213.25   free 52.91     load() 3.71s
# after first forward  wired  43.41   active 198.31   free 51.60     first forward 1.27s; MLX active 14.09 GiB
The safetensors header is plain JSON at the front of the file·bash
# first 8 bytes: header length; then the JSON header naming every tensor and its byte range
f=~/.cache/huggingface/hub/models--mlx-community--Qwen3.5-27B-4bit/snapshots/*/model-00001-of-*.safetensors
python3 - "$f" <<'PY'
import json, struct, sys, glob
path = glob.glob(sys.argv[1])[0]
with open(path, "rb") as fh:
    n = struct.unpack("<Q", fh.read(8))[0]
    header = json.loads(fh.read(n))
print(f"{len(header)} tensors in this shard; header {n/1024:.0f} KB")
name, meta = next((k, v) for k, v in header.items() if k != "__metadata__")
print(name, meta["dtype"], meta["shape"], "bytes", meta["data_offsets"][1] - meta["data_offsets"][0])
PY
# office, 2026-09-15: 848 tensors in this shard; header 102 KB
# language_model.model.embed_tokens.biases BF16 [248320, 80] bytes 39731200

External links

Exercise

Run file_to_pages.py on your Mac with a model you have (and a size your pool holds). Record the three wired figures and the two times. Then reboot or drop the file from cache (copy a file larger than RAM, or simply wait a day) and run it again cold: how long did the first forward take, and does it match your SSD's measured bandwidth from the memory track?
Hint
Cold first-forward time ≈ file bytes ÷ SSD GB/s, plus the warm figure. If it is much longer, the read was random rather than sequential (many small tensors) or the page cache was fighting other memory; if it is shorter, the file was not as cold as you thought.

Progress

Progress is local-only — sign in to sync across devices.
Spotted a bug or have feedback on this page?Report an Issue

Comments 0

🔔 Reply notifications (sign in)
Sign inPlease sign in to comment.

No comments yet — be the first.