본문 바로가기
C.W.K.
Stream
Lesson 01 of 06 · published

디스크의 파일에서 통합 메모리 페이지로

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

Level 0스펙 시트 훑는 사람
0 XP0/91 lessons0/19 achievements
0/100 XP to next level100 XP to go0% complete
"모델은 페이지가 wired될 때 로드된 거야. 그전의 전부는 약속이야."

같은 바이트의 세 가지 상태

디스크의 체크포인트는 safetensors 파일이야. 모든 텐서와 그 바이트 범위를 이름 붙인 JSON 헤더, 그다음 날것의 바이트. 맥에서 그걸 로드하는 건 다른 메모리로의 복사가 아니야. 다른 메모리가 없으니까. 하지만 뚜렷이 다른 세 상태고, 그걸 지켜보는 게 커널 쪽에서 통합 메모리를 보는 제일 분명한 방법이야. 매핑됨: MLX가 파일을 메모리 매핑해서, 텐서는 바이트 하나 안 움직이고도 주소로 존재해. 파일이 페이지 캐시에 있으면(따뜻한 로드) 그 주소들은 이미 풀 안의 페이지를 가리켜. 건드려짐: 첫 순전파가 모든 가중치를 읽으면서 아직 상주하지 않은 페이지를 폴트로 끌어와. 페이지 캐시에서면 나노초, 차가우면 SSD에서 메모리 트랙의 절벽만 한 시간에. wired됨: GPU가 쓰는 페이지는 커널이 내보내지 못하게 고정돼. 권장 작업 집합이 말하는 메모리가 이거고, vm_stat이 직접 보고해.

office에서 실측

코드 블록은 27B를 로드하기 전, load() 뒤, 첫 순전파 뒤에 vm_stat을 찍어(따뜻한 로드. 파일이 페이지 캐시에 있었어).

순간WiredActiveFree일어난 일증거
29.28 GiB199.50 GiB79.51 GiB기계의 평소 상태: 엔진들, 캐시들, 나머지 전부실측, office, 2026-09-15
load() 뒤 — 3.71 s27.02 GiB213.25 GiB52.91 GiB매핑됨: 파일 페이지가 active로 집계되고, GPU를 위해 wired된 건 아직 없음실측
첫 순전파 뒤 — 1.27 s43.41 GiB198.31 GiB51.60 GiB건드려지고 wired됨: wired +16.4 GiB, 모델의 14.1 GiB(MLX 자체 집계)에 작업 버퍼실측

wired 칸을 읽어. load()에선 거의 안 움직이고(다른 게 풀리면서 2기가바이트쯤 내려가) 첫 순전파에서 모델 크기만큼 뛰어. 눈에 보이게 된 지연 로딩이야. MLX의 load가 4초 안에 돌아오는 건 바이트를 약속했지 읽진 않았기 때문이고, 읽는 값은 첫 토큰이 치러. 실험 스크립트가 뭘 재기 전에 워밍업 생성을 돌리는 이유고, 어떤 런타임에서든 모델의 '로드 시간'이 사실 숫자 둘인 이유야.

차가운 로드, 그리고 풀이 구해주지 않는 것

위의 어느 것도 SSD를 안 건드렸어. office가 그날 앞서 파일을 읽었고 512 GB 메모리는 페이지 캐시를 많이 유지하니까. 차가운 로드는 가운데 디스크 읽기가 낀 같은 세 상태야. Air의 실측 2.85 GB/s면 16 GB에 6초쯤, Studio의 SSD면 2초쯤. 통합 메모리는 호스트 메모리에서 장치 메모리로의 복사를 없애. 디스크에서 메모리로의 읽기는 안 없애고, wired 페이지를 공짜로 만들지도 않아. 그게 메모리 트랙의 스왑 절벽이 말하는 페이지고, GPU 트랙의 요청자 규율은 나머지 모두를 위해 풀의 충분한 부분을 wired 안 된 채로 남기는 거야.

Code

file_to_pages.py — 로드 전, 지연 로드 뒤, 첫 순전파 뒤의 vm_stat·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
safetensors 헤더는 파일 앞머리의 평범한 JSON이야·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

가진 모델(풀이 담을 수 있는 크기)로 네 맥에서 file_to_pages.py를 돌려. wired 수치 셋과 시간 둘을 기록해. 그다음 재부팅하거나 파일을 캐시에서 떨어뜨리고(RAM보다 큰 파일을 복사하거나, 그냥 하루 기다려) 차갑게 다시 돌려. 첫 순전파가 얼마나 걸렸고, 메모리 트랙에서 잰 네 SSD 대역폭과 맞아?
Hint
차가운 첫 순전파 시간 ≈ 파일 바이트 ÷ SSD GB/s, 더하기 따뜻한 수치. 훨씬 길면 읽기가 순차가 아니라 랜덤이었거나(작은 텐서 여럿) 페이지 캐시가 다른 메모리와 싸우고 있던 거야. 더 짧으면 파일이 생각만큼 차갑지 않았던 거고.

Progress

Progress is local-only — sign in to sync across devices.
이 페이지에서 버그를 발견하셨거나 피드백이 있으세요?문제 신고

댓글 0

🔔 답글 알림 (로그인 필요)
로그인댓글을 남기려면 로그인해 주세요.

아직 댓글이 없어요. 첫 댓글을 남겨보세요.