SSD로 스트리밍하는 MoE는 왜 느렸을까: 8GB Jetson Orin Nano 2부
1부에서 메모리의 2.7배인 Qwen3.6-35B-A3B를 SSD 스트리밍으로 돌렸을 때 decode가 3 tok/s 안팎에 머문 데는 두 가지 병목이 있었습니다. 첫째, CPU가 expert를 계산하는 동안 GPU가 쉬는 탓에 governor가 GPU를 최저 클럭(306 MHz)에 묶어 두었습니다. 둘째, expert 가중치를 page fault로 읽는 동안 계산 스레드가 멈춰 SSD를 동시 I/O 1~2개로만 쓰고 있었습니다. 2부에서는 두 병목을 GPU 클럭 하한과 사용자 공간 expert 캐시로 풀어 가는 과정과, 이 방법이 통하지 않는 조건을 정리했습니다.
왜 느렸나 — 병목은 두 가지였습니다.
- GPU 클럭: CPU가 expert를 계산하는 동안 GPU가 쉬어 사용률이 36%로 보이자, governor가 decode 내내 GPU를 최저 306 MHz에 두었습니다. 토큰마다 반드시 지나는 GPU 구간이 그만큼 길어졌습니다.
- 읽기 경로: expert 가중치를 page fault로 읽는 동안 계산 스레드가 멈추고, 나머지 스레드도 동기화 지점에서 기다렸습니다. SSD는 동시 I/O 1~2개로만 쓰였습니다.
TL;DR — 두 병목을 차례로 풀었을 때의 단계별 속도입니다. Qwen3.6-35B-A3B(MXFP4, 20 GiB)를 Jetson Orin Nano Super 8GB에서 SSD 스트리밍으로 돌렸습니다.
| 단계 | 바꾼 것 | decode (tok/s) | ① 대비 | prefill | NVMe 읽기 |
|---|---|---|---|---|---|
| ① 기본 | mmap 스트리밍, --cpu-moe, -t 12 |
2.73 | ×1.00 | 6.7 s | 784 GiB |
| ② GPU 하한 | GPU 최저 클럭을 1020 MHz로 | 3.37 | ×1.24 | 6.7 s | 781 GiB |
| ③ expert 캐시 | ② + 2 GiB LRU 캐시, O_DIRECT, 읽기·계산 겹치기, -t 6 |
4.39 | ×1.61 | 3.4 s | 583 GiB |
| ④ 순서·스레드 | ③ + gate 먼저 읽기, -t 4 |
4.96 | ×1.82 | 3.5 s | 583 GiB |
실제 프롬프트 12개를 서버에 보내 256토큰씩 greedy로 생성한 평균이고, NVMe 읽기는 12요청 합계입니다. 네 단계 모두 생성 문장이 12개 다 같았고, wikitext-2 perplexity는 캐시 유무와 관계없이 6.2344였습니다. 같은 설정을 메모리에 들어가는 모델에 적용하면 효과가 없었고, gpt-oss-20b에서는 캐시가 오히려 느렸습니다.
들어가며
1부에서는 OS가 보는 메모리가 7.4 GiB인 Jetson Orin Nano Super에서 20 GiB짜리 Qwen3.6-35B-A3B를 llama.cpp mmap 스트리밍으로 돌렸습니다. 모델은 돌아갔지만 decode는 1~3 tok/s였고, NVMe는 장치가 낼 수 있는 처리량의 일부만 쓰이고 있었습니다. 읽기 방식을 바꾼 패치 네 가지는 모두 계산 스레드를 코어보다 많이 준 -t 12를 넘지 못했습니다. 무엇이 decode를 막는지는 확인하지 못한 채 1부를 마쳤습니다.
2부는 그 질문을 이어서 추적한 기록입니다. 원인을 찾기 전에 1부의 측정부터 다시 확인했는데, 그 과정에서 측정 방식과 설명에 잘못된 부분 세 가지를 발견했습니다. 측정을 바로잡은 뒤에는 decode 시간을 NVMe 읽기에 비례하는 부분과 고정된 부분으로 나눴습니다. 고정된 부분에서는 GPU 클럭 문제를, 읽기에 비례하는 부분에서는 page cache 경로의 한계를 찾았습니다.
이 글에서는 두 원인에 각각 어떻게 대응했는지, 그 과정에서 무엇이 예상과 달랐는지를 다룹니다. 마지막에는 같은 설정을 다른 모델 다섯 종에 적용한 대조 실험을 통해, 이 개선이 어떤 조건에서만 통하는지 정리합니다.
1부의 측정부터 다시 확인했습니다
1부의 MoE 수치는 무작위 토큰으로 정해진 라우팅과 최저 클럭에 묶인 GPU에서 잰 값이었고, expert 연산 순서도 잘못 설명했습니다.
llama-bench의 decode 측정은 MoE 라우팅을 대표하지 못했습니다. llama-bench는 decode 입력 토큰을 std::rand() % n_vocab로 만듭니다. dense 모델에서는 문제가 되지 않지만, MoE는 토큰에 따라 고르는 expert가 달라집니다. 무작위 토큰열이 고르는 expert는 실제 문장과 다르고, expert 재사용과 page cache 적중률도 그만큼 달라집니다. 그래서 2부의 수치는 모두 실제 프롬프트로 다시 쟀습니다. 1부 TL;DR의 35B-A3B decode와 llama-bench로 잰 실험 결과는 2부 수치와 직접 비교하지 않습니다.
decode 중 GPU는 최저 클럭이었습니다. 1부에서는 CPU와 GPU에 동시에 부하를 주는 시험에서 GPU가 1020 MHz를 유지하는 것만 확인했고, MoE decode 중의 클럭은 보지 않았습니다. 다시 재 보니 추론 중 GPU 클럭 샘플 5,761개 중 5,726개(99.4%)가 최저인 306 MHz였습니다. 1부의 MoE 수치는 모두 이 상태에서 잰 값입니다.
GPU는 토큰마다 짧게 일하고 쉬기를 반복하며, 추론 중 평균 사용률은 36%였습니다. governor는 이 정도 사용률로는 클럭을 올리지 않았습니다. 최저 클럭을 1020 MHz로 올리면 같은 작업이 더 빨리 끝나 사용률이 20%로 내려갑니다. 이 관찰을 어떻게 해석했는지는 아래 GPU 클럭 절에서 다룹니다.
expert 연산은 up → gate → down이 아니라 gate → up → down 순서로 실행됩니다. 1부에서는 본문과 도표에 up → gate → down 순서로 적었고, 비동기 로더도 이 전제로 만들었습니다. up 연산이 시작될 때 gate와 down을 미리 읽게 한 것입니다. llama.cpp는 그래프를 만들 때 up 노드를 먼저 만들지만, ggml은 실행 순서를 SwiGLU(gate, up)의 입력 순서로 정해 gate를 먼저 실행합니다. 뒤에서 다룰 캐시의 적중 통계가 예상과 다르게 나오면서 이 순서를 확인했습니다. 결국 1부의 비동기 로더가 미리 읽게 한 gate는 이미 계산이 끝난 뒤였고, 실제로 앞당겨진 것은 down뿐이었습니다. 1부에서 이 로더가 효과를 내지 못한 이유 중 하나일 수 있지만, 고친 로더로 다시 재지는 않았습니다.
실험 환경과 측정 방법
모든 비교는 같은 프롬프트 12개를 서버로 생성한 decode 속도로 했고, 생성 문장이 바뀌지 않는 것을 정확성 조건으로 두었습니다.
보드와 소프트웨어는 1부와 같습니다. Jetson Orin Nano Super 8GB(MAXN_SUPER, JetPack 6.2.3), llama.cpp b1-19ff817을 썼습니다. 패치는 원래 빌드와 분리한 사본에 넣고 환경변수로 켜고 껐습니다.
| 항목 | 설정 |
|---|---|
| 모델 | Qwen3.6-35B-A3B MXFP4(40개 레이어, 레이어당 expert 256개 중 8개 사용) |
| 서버 옵션 | --load-mode mmap --no-host --no-repack -c 4096 -b 512 -ub 512 -ngl 99 --no-op-offload --cpu-moe |
| 프롬프트 | 한국어 6개, 영어 6개(21~33토큰), 요청마다 256토큰 greedy 생성, 프롬프트 캐시 끔 |
| 측정 지표 | 서버가 보고하는 요청별 decode·prefill 속도, 요청 전후 /sys/block/nvme0n1/stat의 읽기량 |
| 정확성 조건 | 생성 문장이 기준 실행과 글자 단위로 같을 것, wikitext-2 perplexity(512토큰 8청크)가 같을 것 |
| 시작 상태 | 서버를 띄우기 전에 모델 파일의 page cache를 비움 |
캐시를 켠 실행은 MemorySwapMax=0인 systemd scope 안에서 돌렸습니다. 캐시 메모리가 zram 스왑으로 밀려나지 않게 하려는 설정입니다. 각 설정은 한 번씩 돌렸습니다. 단계 비교와 모델 대조는 프롬프트 12개, 세부 조정 스윕과 예측 실험은 시간을 줄이려고 앞 프롬프트 4개로 쟀습니다.
decode 시간은 고정 비용과 읽기 비용으로 나뉘었습니다
요청별 decode 시간은 토큰당 약 193 ms의 고정 부분과 NVMe 읽기 1 MiB당 0.64 ms의 비례 부분으로 설명됐습니다.
decode가 읽기에 얼마나 묶여 있는지 보려고, 토큰당 읽는 양을 바꿔 가며 같은 프롬프트 12개를 돌렸습니다. top-k 8(기본)과, GGUF의 expert_used_count를 덮어써 토큰당 expert를 절반만 쓰게 한 top-k 4입니다. 요청마다 NVMe 읽기량을 생성 토큰 수로 나눈 값과 토큰당 decode 시간을 함께 놓으면, 두 설정의 점 24개가 한 직선에 놓입니다.
직선의 기울기는 NVMe에서 1 MiB를 더 읽을 때마다 늘어나는 시간입니다. 절편 193 ms는 읽기가 없어도 남는 시간입니다. 이 조건에서는 NVMe 읽기를 모두 없애도 decode가 5.2 tok/s를 넘을 수 없다는 뜻입니다. 고정 부분에는 GPU의 attention과 shared expert 계산, CPU의 expert 계산, 그리고 둘 사이의 동기화가 들어 있습니다.
top-k 4는 읽기를 줄여 decode가 3.89 tok/s까지 올랐습니다. 하지만 wikitext-2 perplexity가 6.23에서 7.58로 21.5% 나빠져 채택하지 않았습니다. top-k 6은 6.52(+4.5%)였습니다. 모델의 계산량을 바꾸지 않고 두 부분을 각각 줄이는 쪽으로 방향을 잡았고, 먼저 고정 부분부터 살펴봤습니다.
GPU는 일하는데 클럭은 최저였습니다
--cpu-moe에서는 GPU가 토큰마다 짧게 나눠 일해서 governor가 클럭을 최저로 두었습니다. 최저 클럭을 1020 MHz로 올린 것만으로 decode가 1.24배가 됐습니다.
고정 부분의 내용을 보려고 decode 중 GPU 사용률, GPU 클럭, CPU 클럭을 20 ms 간격으로 기록했습니다. 앞의 정정 절에서 본 것처럼 추론 중 GPU 사용률은 평균 36%였고, 클럭 샘플의 99.4%가 306 MHz였습니다.
Jetson의 GPU 클럭은 nvhost_podgov governor가 최근 구간의 GPU 사용률을 보고 정합니다. --cpu-moe로 돌리면 토큰 하나를 만드는 동안 GPU(attention, shared expert, router)와 CPU(routed expert)가 40개 레이어에 걸쳐 번갈아 일합니다. GPU가 쉬는 동안 CPU가 일하므로 GPU 사용률은 낮게 보이고, governor는 클럭을 올릴 이유가 없다고 판단합니다. 그러나 GPU 구간은 토큰마다 반드시 지나는 경로에 있습니다. GPU 구간이 느려지면 CPU가 그만큼 기다리므로, 사용률이 낮아도 GPU 클럭은 토큰당 시간에 그대로 반영됩니다.
이 판단을 확인하려고 devfreq의 min_freq만 1020 MHz로 올렸습니다. decode는 2.73에서 3.37 tok/s로 1.24배가 됐고, 12요청 모두 빨라졌으며 생성 문장은 같았습니다.
| 설정 | 추론 중 클럭 분포 | 추론 중 GPU 사용률 | 추론 중 보드 전력 | decode (12요청) | 요청 간 변동계수 | 토큰당 에너지 |
|---|---|---|---|---|---|---|
| governor 기본값 | 306 MHz 99.4% | 36.0% | 8.05 W | 2.73 tok/s | 10.0% | 2.95 J |
| 최저 클럭 1020 MHz | 1020 MHz 100% | 19.8% | 9.63 W | 3.37 tok/s | 10.5% | 2.86 J |
전력은 tegrastats의 VDD_IN을 추론 구간에서 평균한 값이고, 토큰당 에너지는 이 전력을 decode 속도로 나눈 값입니다. 하한을 걸면 전력은 20% 늘지만 decode도 그만큼 빨라져, 토큰당 에너지는 거의 같았습니다.
사용률과 토큰 시간을 곱하면 토큰당 GPU가 일한 시간을 어림할 수 있습니다. 같은 계측 실행에서 기본값은 토큰당 385 ms 중 약 139 ms(36%), 하한을 건 경우는 296 ms 중 약 59 ms(19.8%)였습니다. GPU 시간이 약 80 ms 줄었고 토큰 시간은 89 ms 줄어, decode 단축분의 대부분이 GPU 구간에서 나왔다고 봅니다. sysfs 사용률은 드라이버가 평균한 값이고 prefill 구간도 섞여 있어 어림값입니다. 요청 간 편차도 그대로였습니다. 클럭 분포·사용률·전력은 별도 계측 실행에서 쟀고, 기본값 쪽은 그 실행이 2요청뿐이라 대략적인 비교입니다.
하한을 거는 방법 고르기
클럭을 올리는 방법은 여러 가지였습니다. 요구사항은 세 가지로 정했습니다. expert를 CPU로 계산하는 MoE 추론 중에는 GPU가 1020 MHz로 돌 것, 그 밖의 작업에는 governor의 기본 동작을 바꾸지 않을 것, llama.cpp와 실행 스크립트를 고치지 않을 것입니다.
| 방법 | 결과 또는 판단 |
|---|---|
jetson_clocks(CPU·GPU·메모리 클럭을 모두 최대로 고정) |
두 차례 12요청 평균은 3.29, 3.63 tok/s로 비슷했지만, 요청 간 변동계수가 25.8%와 20.7%로 하한만 건 경우(10.5%)의 두 배를 넘었습니다. 클럭이 상시 고정되는 것도 요구사항에 맞지 않았습니다. |
| GPU에 더미 부하를 걸어 governor가 클럭을 올리게 하기 | 측정하지 않고 제외했습니다. Jetson GPU는 프로세스가 다르면 시분할로 번갈아 실행하므로 더미 커널이 추론 커널을 지연시킬 수 있고, 일을 하지 않는 연산에 전력을 씁니다. |
추론 프로세스를 주기적으로 확인해 min_freq 조절 |
가능합니다. 다만 프로세스가 떠 있는지만 보면 서버가 놀고 있는 시간에도 하한이 걸리고, 사용률이나 page fault까지 보려면 확인 주기만큼 늦게 반응합니다. |
eBPF uprobe로 llama.cpp 호출을 감지해 min_freq 조절 |
채택했습니다. |
공개한 도구: jetson-moe-gpu-stabilizer
채택한 방식은 jetson-moe-gpu-stabilizer로 정리해 MIT 라이선스로 공개했습니다. 이 글의 측정은 공개 전 버전으로 했고, 감지 방식과 1020 MHz 하한, 1초 유지 시간은 같습니다. 동작은 다음과 같습니다.
- bpftrace로
libllama.so의llama_decode,llama_process,llama_synchronize진입과 반환에 uprobe를 겁니다. 최근 llama-server와 llama-bench는 비동기 함수llama_process를 부르고 작업이llama_synchronize에서 끝날 수 있어서, 세 함수를 모두 추론 활동으로 봅니다. - 명령줄에
--cpu-moe,-cmoe,--n-cpu-moe,-ncmoe중 하나가 있는 프로세스만 셉니다. 요청 없이 떠 있는 서버나 dense 모델에는 반응하지 않습니다. - 첫 호출에서 GPU devfreq의
min_freq를 올립니다. 기본값은 지원하는 최고 주파수(이 보드에서는 1020 MHz)입니다. 진행 중인 호출 없이 1초가 지나면, 그리고 서비스가 끝날 때 최저 주파수로 되돌립니다.max_freq, governor, CPU·메모리 클럭은 건드리지 않습니다. - 5초마다 대상 라이브러리를 다시 확인합니다. uprobe는 이전 파일에 묶여 있으므로 llama.cpp를 다시 빌드하면 새 라이브러리에 다시 붙입니다.
설치 전에 --check로 찾은 devfreq 노드와 라이브러리를 확인할 수 있습니다. 이 단계는 root가 필요하지 않습니다.
git clone https://github.com/gnu-gnu/jetson-moe-gpu-stabilizer
cd jetson-moe-gpu-stabilizer
python3 moe-gpu-stabilizer --check
sudo ./install.sh
journalctl -u moe-gpu-stabilizer -f
--cpu-moe 서버에 요청을 보내면 로그에 floor 1020 MHz (pid … inference)가 찍히고, 생성이 끝나고 약 1초 뒤 floor 306 MHz (idle)가 찍힙니다. 하한 값, 유지 시간, 추적할 라이브러리 경로, MoE 판정 플래그는 /etc/default/moe-gpu-stabilizer에서 바꿀 수 있습니다.
이 방식의 절충점도 분명합니다. root 권한의 상주 서비스와 bpftrace가 필요하고, 외부에 공개된 함수 이름으로 찾기 때문에 llama.cpp만 감지합니다. expert를 CPU에 두었는지를 명령줄 플래그로 판단하므로, --override-tensor(-ot)처럼 다른 방식으로 expert를 CPU에 배치한 프로세스는 그 플래그를 설정에 추가하지 않으면 감지하지 못합니다. 뒤의 대조 실험에서 gpt-oss-20b의 하한 없는 기준을 잴 때는 이 점을 이용했습니다. jetson_clocks처럼 min_freq를 쓰는 다른 도구와 함께 쓰면 서로 값을 덮어쓰므로 같이 쓰지 않습니다. 확인한 환경은 Orin Nano Super 8GB와 JetPack 6.2.3 하나뿐입니다.
page cache 대신 expert 캐시를 직접 만들었습니다
page cache 경로에서는 읽는 단위와 시점을 커널이 정해 동시 I/O가 1~2개에 머물렀습니다. expert 단위 LRU 캐시를 직접 만들어 읽기와 계산을 겹치자, decode가 하한만 건 상태보다 1.30배, 이후 순서와 스레드를 조정하자 1.47배가 됐습니다.
기존 읽기 경로의 한계
1부의 mmap 경로에서는 계산 스레드가 expert 가중치 페이지에 처음 접근하는 순간 page fault가 나고, 커널이 그 부분을 읽는 동안 스레드가 멈춥니다. 계산 스레드는 연산마다 barrier로 동기화하므로, 한 스레드가 멈추면 나머지도 기다립니다. 읽기 크기는 커널의 fault 처리와 readahead가 정하고, 동시에 진행되는 읽기는 멈춘 스레드 수만큼입니다. 1부에서 스레드를 12개로 늘렸을 때도 동시 I/O가 1.3~1.4개에서 1.7개로 늘면서 decode가 함께 빨라졌습니다.
1부의 패치 네 가지는 이 경로를 유지한 채 읽기를 앞당기거나(MADV_WILLNEED, 비동기 로더), 계산 전에 몰아서 읽거나(병렬 pread), GPU 쪽 캐시로 옮겼습니다(내장 --moe-cache-mib). 어느 쪽도 “무엇을 언제 얼마나 동시에 읽을지”를 직접 정하지는 못했습니다. 그래서 이번에는 expert 가중치를 page cache 밖의 전용 메모리에 두고, 읽기와 교체를 직접 관리하는 방식을 택했습니다.
무엇을 캐시할지는 router 기록으로 정했습니다
구현에 앞서 캐시 정책을 시뮬레이션으로 골랐습니다. 프롬프트 12개를 처리하는 동안 router가 레이어마다 고른 expert 번호를 기록하고, 그 기록을 캐시 모델에 재생했습니다. 캐시 단위는 레이어 하나의 expert 하나, 즉 그 expert의 up·gate·down 가중치 묶음입니다. 크기는 레이어에 따라 1.75~2.20 MiB이고, 40개 레이어 × 256개로 모두 10,240개입니다.
처음 검토한 안은 자주 쓰이는 expert를 메모리에 고정해 두는 방식이었습니다. 구현이 단순하고 교체 비용이 없기 때문입니다. 이 안을 LRU와 비교했습니다. 고정 배치는 앞 요청 6개에서 자주 쓰인 expert를 캐시 크기만큼 고르고, 평가는 두 정책 모두 뒤 요청 6개의 decode로 했습니다.
같은 크기에서 LRU가 고정 배치보다 훨씬 적게 읽었습니다. 2 GiB에서 LRU는 토큰당 186 MiB, 고정 배치는 376 MiB였습니다. 프롬프트마다 쓰는 expert가 달라서, 전체 빈도보다 최근에 쓴 expert가 다음 토큰에서 다시 쓰일 가능성이 더 높았던 것으로 해석했습니다. 0.5 GiB에서 LRU가 전혀 효과가 없는 것은 캐시가 토큰 하나가 40개 레이어에서 읽는 expert(약 567 MiB)보다 작아, 다시 쓰이기 전에 밀려나기 때문입니다.
크기는 2 GiB로 정했습니다. 3 GiB에서는 zram 스왑이 폭주하다 서버가 메모리 부족으로 종료됐고, 그래서 스왑을 막고 2 GiB를 썼습니다. 시뮬레이션에서 보듯 메모리가 더 있다면 캐시를 키울수록 읽기는 계속 줄어듭니다.
처음 만든 캐시는 page cache보다 느렸습니다
처음 구현은 단순했습니다. llama.cpp CPU 백엔드의 mul_mat_id(expert 행렬곱)에서, 고른 expert가 캐시에 없으면 계산 스레드들이 나눠서 읽은 뒤 계산했습니다. 읽기에는 page cache를 거치지 않는 O_DIRECT pread를 썼습니다. 같은 바이트가 캐시와 page cache에 두 번 올라가지 않게 하려는 선택이었습니다.
| 조정 (프롬프트 4개, ② GPU 하한만 적용한 상태 3.61 tok/s) | decode (tok/s) |
|---|---|
단위 전체를 읽은 뒤 계산, -t 12 |
3.21 |
128 KiB 조각으로 나눠 읽은 뒤 계산, -t 12 |
3.54 |
같은 방식, -t 6 |
3.75 |
전용 I/O 스레드로 읽고 계산과 겹치기, I/O 4개, -t 6 |
4.37 |
첫 구현은 하한만 건 상태(3.61)보다 오히려 느렸습니다. 읽기를 조각으로 나누고 스레드 수를 맞춰도 4% 빨라지는 데 그쳤습니다. 1부의 병렬 pread와 같은 문제로 봤습니다. 레이어의 읽기가 모두 끝나야 계산을 시작하니, page fault 경로에서는 저절로 겹치던 읽기와 계산이 직렬로 바뀐 것입니다.
그래서 읽기와 계산을 분리했습니다.
- 슬롯과 교체: 2 GiB를 단위 크기별 슬롯 1,150개로 나눕니다(전체 단위의 11%). 계산 스레드 0이 고른 expert를 찾고, 없으면 가장 오래 쓰이지 않은 슬롯을 비웁니다.
- 읽기: 비운 슬롯에 넣을 바이트를 128 KiB 조각으로 나눠 큐에 넣고, 전용 I/O 스레드가 O_DIRECT로 읽습니다. 동시 I/O 수는 커널의 readahead가 아니라 I/O 스레드 수가 정합니다.
- 겹치기: 슬롯과 가중치 종류마다 남은 조각 수를 원자적 카운터로 둡니다. 계산 스레드는 캐시에 있던 expert를 먼저 계산하고, 늦게 온 expert는 자기가 맡은 조각이 도착했는지만 확인하고 계산합니다. 레이어 전체의 읽기를 기다리지 않습니다.
겹치기를 넣자 하한 대비 21%가 빨라졌습니다. I/O 스레드는 2/3/4/6/8개 중 4개(4.37 tok/s)가 가장 좋았습니다. 6개(3.71)와 8개(3.80)에서는 다시 느려졌고, 8개에서는 일부 프롬프트가 2.7 tok/s대로 떨어졌습니다. 코어 6개를 계산 스레드 6개와 나눠 쓰면서 생긴 경합으로 보입니다.
prefill의 expert 호출도 같은 캐시 경로를 탑니다. 여러 I/O 스레드가 병렬로 읽은 덕분에 prefill이 6.7초에서 3.4초로 줄었습니다.
생성 문장이 실행마다 달라졌습니다
겹치기를 처음 넣었을 때 속도는 올랐지만, 같은 프롬프트의 생성 문장이 실행마다 달라졌습니다. greedy 생성이므로 연산 결과가 같다면 문장도 같아야 합니다. 원인을 좁히려고 조건을 하나씩 바꿔 봤습니다.
| 확인한 조건 | 결과 | 판단 |
|---|---|---|
| 겹치기 + 계산 순서 재배치, 4회 반복 | 4회 모두 문장이 다름 | 재현됨 |
| 원래 순서로 계산하거나, 읽기가 모두 끝난 뒤 계산 | 문장이 같음 | 겹치기와 재배치가 함께 있을 때만 발생 |
| O_DIRECT 대신 일반 버퍼 읽기 | 여전히 다름 | DMA 쓰기 가시성 문제는 아님 |
| 실행을 느리게 만드는 디버그 모드 | 문장이 같음 | 타이밍에 따라 발생 |
이 밖에도 두 가지 가설을 코드로 배제했습니다. 계산 순서가 바뀌면 부동소수점 합산 순서가 바뀔 수 있지만, expert마다 출력 행이 따로라 합산 순서는 달라지지 않습니다. 순서에 민감할 수 있는 tiled 계산 경로는 ARM 빌드에서 쓰이지 않습니다.
원인은 계산 순서를 정하는 코드에 있었습니다. 각 expert가 준비됐는지를 두 번에 나눠 확인해 “바로 계산할 목록”과 “나중에 계산할 목록”을 만들었는데, 그 사이에 읽기가 끝난 expert는 두 목록 어디에도 들어가지 않았습니다. 그 expert는 계산되지 않았고, 출력 자리에는 이전 토큰의 값이 남았습니다. 이 문제는 재배치를 할 때만 생기고, 읽기가 아주 짧은 구간에 끝날 때만 드러나며, 디버그 모드가 타이밍을 바꾸면 사라졌습니다. 준비 여부를 expert마다 한 번만 확인하도록 고친 뒤로는 모든 실행에서 문장이 기준과 같습니다. 이후 모든 조정은 “12개 문장 일치”를 통과해야 결과로 인정했습니다.
gate가 먼저 실행된다는 것을 알게 됐습니다
뒤에서 다룰 예측 실험을 하던 중, 캐시 적중 통계가 예측을 켜든 끄든 같게 나왔습니다. 원인을 따라가 보니 레이어 안의 실제 실행 순서가 gate → up → down이었습니다. 캐시 코드는 up이 먼저라고 가정하고 미스가 나면 up 조각부터 큐에 넣었습니다. 그래서 먼저 실행되는 gate 연산이 자기 조각보다 up 조각이 먼저 읽히기를 기다리고 있었습니다.
미스가 나면 지금 실행 중인 종류의 조각을 먼저 넣도록 고치자, 다른 조건이 같은 상태에서 4.37에서 4.64 tok/s로 6% 빨라졌습니다.
계산 스레드는 줄일수록 빨랐습니다
| 계산 스레드 (프롬프트 4개, I/O 4개) | -t 6 |
-t 5 |
-t 4 |
-t 3 |
|---|---|---|---|---|
| decode (tok/s) | 4.64 | 4.76 | 4.96 | 4.85 |
계산 스레드는 줄일수록 빨라지다가 3개에서 다시 느려졌습니다. page cache 경로에서는 -t 12처럼 코어보다 많이 주는 쪽이 빨랐으니 정반대입니다. 원인은 다음과 같이 추정합니다. page cache 경로에서는 fault로 멈춘 스레드가 많아야 동시 I/O가 늘었습니다. 캐시 경로에서는 읽기를 I/O 스레드가 맡으므로, 남는 코어가 I/O 스레드와 읽기 완료 처리에 쓰이는 편이 낫습니다. 다만 계산 스레드가 3개면 계산 자체가 모자랍니다. 스레드별 CPU 시간을 나눠 재지는 않았습니다.
프롬프트 12개로 다시 재면 -t 5가 4.81, -t 4가 4.96 tok/s입니다. 요청별로 봐도 ④의 가장 느린 요청(4.66)이 ②의 가장 빠른 요청(4.15)보다 빠릅니다. NVMe 읽기는 12요청 합계 781 GiB에서 583 GiB로 25% 줄었습니다.
이 캐시에는 대가도 있습니다. 2 GiB는 page cache와 달리 커널이 회수할 수 없는 고정 메모리이고, 스왑으로 밀려나지 않도록 실행 환경을 따로 묶어야 합니다. 읽기와 계산을 겹치는 코드는 앞의 버그처럼 정확성을 깨뜨리기 쉬워서, 문장 일치 검사 없이 속도만 보고 판단하기 어렵습니다.
다음 층 expert 예측은 대기를 줄였지만 전체로는 느려졌습니다
다음 층의 expert를 80% 맞혔고 읽기 대기 시간도 3분의 1까지 줄었지만, 코어 6개에서는 추가 읽기와 예측 계산 비용이 이득보다 컸습니다.
1부에서 소개한 Edge0의 prerouter는 다음 단계의 라우팅을 미리 예측해 읽기를 연산 뒤에 숨깁니다. 같은 효과를 별도 학습 없이 근사해 봤습니다. 레이어 L의 FFN 입력에 레이어 L+1의 router 가중치를 곱해, 다음 레이어가 고를 expert를 미리 점치는 방식입니다. 잔차 연결 때문에 이웃한 레이어의 hidden state가 비슷하다는 가정에 기댄 방법입니다. 먼저 실제 decode의 hidden state를 기록해 정확도와 효과를 오프라인으로 확인했습니다.
| 항목 | 값 |
|---|---|
| 다음 층의 실제 top-8 중 예측 top-8에 든 비율 | 80.5% |
| 같은 비율, 예측 top-12 / top-16 | 91.2% / 94.8% |
| 두 층 뒤(L+2) 예측 top-8 | 74.1% |
| 시뮬레이션(2 GiB LRU + 예측 top-8 미리 읽기): 기다려야 하는 읽기 | 182 → 64 MiB/토큰 |
| 같은 시뮬레이션: 전체 읽기 / 미리 읽은 것 중 쓰인 비율 | 182 → 244 MiB/토큰 / 68% |
시뮬레이션에서는 계산이 기다려야 하는 읽기가 65% 줄고, 대신 전체 읽기가 34% 늘었습니다. 유망하다고 판단해 실제로 구현했습니다. 레이어 L의 expert 호출이 시작되면 I/O 스레드 하나가 그 입력에 레이어 L+1의 router 가중치를 곱해 top-8을 고르고, 그중 캐시에 없는 expert를 낮은 우선순위 큐로 읽습니다.
프롬프트 4개, -t 5 |
decode (tok/s) | 읽기 대기 누적 | 미리 읽은 것 중 쓰인 비율 |
|---|---|---|---|
| 예측 없음, I/O 4 | 4.76 | 102.5 s | — |
| 예측 top-8, I/O 4 | 4.40 | 61.5 s | 76.9% |
| 예측 top-8, I/O 6 | 4.52 | 33.0 s | 76.9% |
대기 시간은 최대 3분의 1로 줄었고, 미리 읽은 것의 77%가 실제로 쓰여 시뮬레이션 예상(68%)보다 높았습니다. 그런데도 decode는 5~8% 느려졌습니다. 예측 계산에 토큰당 약 11.6 ms가 들었고, 읽는 양은 204 GiB에서 264 GiB로 29% 늘었습니다. 예측 계산을 SIMD 내적으로 바꿔도 4.35에서 4.38 tok/s로 차이가 없었습니다. 계산, I/O, 예측이 같은 코어 6개를 나눠 쓰는 구조에서 예측 계산과 늘어난 읽기 처리 비용이 대기 감소분보다 컸던 것으로 추정합니다. CPU 시간을 항목별로 나눠 재지는 않았습니다. 이 방식은 채택하지 않았습니다.
Edge0 모델 카드가 보고한 이득(decode 처리량 최대 59%)은 토큰당 expert 4개와 int4 가중치를 쓰는 모델에서 나온 값이고, 측정 하드웨어도 다릅니다. 이 보드에서는 I/O 하나당 CPU 비용을 줄이는 io_uring과, 확신이 높은 예측만 미리 읽는 필터를 아직 시험하지 않았습니다.
다른 모델에서도 통할까요
GPU 하한은 expert 가중치를 SSD에서 읽어 CPU로 계산하는 MoE 구성에서만 효과가 있었고, expert 캐시는 그중에서도 35B-A3B에서만 효과가 있었습니다.
35B-A3B 한 모델의 결과만으로는 이 개선이 어디까지 일반적인지 알 수 없습니다. 효과가 없을 것으로 예상되는 모델에서도 실제로 없는지 확인하려고, 같은 네 단계를 모델 다섯 종에 그대로 적용했습니다.
- 메모리에 들어가는 dense 3종: Gemma 4 E2B, E4B, Qwen3.5-9B
- 메모리에 들어가는 MoE: LFM2-8B-A1B(Q4_K_M 4.7 GiB)
- 메모리보다 큰 MoE: gpt-oss-20b(21B, 활성 3.6B, MXFP4 11.3 GiB). 35B-A3B처럼
--cpu-moe로 expert를 CPU 쪽 mmap에 두었습니다.
프롬프트는 같은 12개를 chat 엔드포인트로 보냈고(thinking 끔), 생성 문장은 ① 기본과 비교했습니다. 메모리에 들어가는 모델에는 앞의 하한 서비스가 반응하지 않으므로, CPU에 expert를 하나도 두지 않는 --n-cpu-moe 0을 붙여 하한을 걸었습니다. gpt-oss-20b의 ① 기본은 --cpu-moe 대신 같은 배치를 만드는 -ot 패턴으로 실행해 하한이 걸리지 않게 했습니다.
메모리에 들어가는 모델 4종은 어느 단계에서도 ±0.5% 안이었습니다. GPU가 모든 연산을 맡으니 사용률이 높아, 하한이 없어도 governor가 decode 중 GPU를 1018~1020 MHz로 돌리고 있었습니다. expert 캐시는 CPU 쪽에 expert 가중치가 없으면 초기화되지 않으므로 dense 모델과 LFM2 모두에서 켜지지 않았고, 계산 스레드 수도 결과를 바꾸지 않았습니다. MoE라는 사실만으로는 효과가 없었고, expert 가중치를 SSD에서 읽어 CPU로 계산하는 구성에서만 효과가 났습니다. 두 조건은 각각 다른 개선에 대응합니다. GPU 하한은 CPU가 expert를 계산하는 동안 GPU가 쉬어 사용률이 낮게 보이는 구조 때문에, expert 캐시는 가중치를 SSD에서 읽어 오는 구조 때문에 필요했습니다. 이 보드는 CPU와 GPU가 같은 메모리를 쓰므로, --cpu-moe가 가중치를 GPU 메모리에서 CPU 메모리로 옮겨 메모리를 비우는 것은 아닙니다.
gpt-oss-20b에서는 GPU 하한이 35B-A3B보다 크게 통했지만, 캐시는 손해였습니다. 기본 상태에서 decode 중 GPU 클럭은 평균 321 MHz였고, 하한을 걸자 2.86에서 3.97 tok/s(×1.39)가 됐습니다. 캐시를 더하면 2.72(-t 6)와 2.69(-t 4)로, 하한만 건 상태는 물론 기본 상태보다도 느렸습니다.
| gpt-oss-20b | decode (tok/s) | NVMe 읽기 (12요청) | prefill 평균 |
|---|---|---|---|
| ② GPU 하한 (page cache) | 3.97 | 436 GiB | 13.8 s |
④ + 캐시, -t 4 |
2.69 | 1,923 GiB | 9.7 s |
gpt-oss-20b는 레이어당 expert가 32개로 적은 대신 하나가 큽니다. 캐시 단위가 12.6 MiB라 2 GiB에는 전체 768개 중 162개(21%)만 들어갑니다. 캐시 미스율은 51%였고 NVMe 읽기는 4.4배로 늘었습니다. 35B-A3B는 전체의 11%만 담고도 미스율이 34%였습니다. 차이는 라우팅이 소수의 expert에 얼마나 몰리는지에서 온 것으로 추정하지만, gpt-oss-20b의 router 기록으로 확인하지는 않았습니다. page cache는 2 GiB로 제한되지 않고 남는 메모리를 모두 쓰므로, 이 모델에서는 그쪽이 더 많이 맞혔다고 봅니다. 반면 prefill은 캐시 쪽이 빨랐습니다. 병렬 O_DIRECT 읽기의 이점이 decode보다 prefill에서 크게 나타난 것으로 보입니다.
이 대조 실험에서 캐시 코드의 버그도 하나 드러났습니다. 처음 gpt-oss-20b에 캐시를 켰을 때 출력이 GGGG…로 깨지고, 두 번째 요청에서 서버가 오류를 냈습니다. 캐시 슬롯은 “expert 조각 크기 + 4 KiB”만큼 잡았는데, 이는 조각 크기가 4 KiB의 배수라는 가정에 기댄 것이었습니다. 35B-A3B의 조각은 4 KiB 배수였지만 gpt-oss-20b의 조각은 4,406,400바이트입니다. 그래서 4 KiB 단위로 정렬해 읽는 O_DIRECT의 마지막 페이지가 다음 조각을 덮어썼습니다. 슬롯 크기를 “조각 크기를 4 KiB로 올림한 값 + 4 KiB”로 고친 뒤 문장이 ①과 같아졌고, 위 gpt-oss-20b 수치는 고친 뒤의 값입니다. 35B-A3B는 조각이 4 KiB 배수라 이 수정으로 바뀌는 것이 없습니다.
마치며
이 보드에서 35B-A3B의 decode는 2.73에서 4.96 tok/s까지 올라왔습니다. 다만 두 개선 모두 “expert 가중치를 SSD에서 읽어 CPU로 계산하는 MoE”라는 조건에 묶여 있고, 캐시는 그 안에서도 모델에 따라 결과가 갈렸습니다.
돌아보면 방향을 바꾼 것은 새 기법보다 측정을 다시 확인한 단계였습니다. 1부에서 읽기 패치의 효과를 따지는 동안 GPU는 최저 클럭으로 돌고 있었고, 벤치마크는 실제와 다른 라우팅으로 측정되고 있었으며, 비동기 로더는 잘못된 실행 순서를 전제로 만들어졌습니다. decode 시간을 고정 부분과 읽기 부분으로 나누고 나서야 두 부분을 각각 겨냥할 수 있었습니다.
측정한 범위 안에서는 다음과 같이 판단합니다.
- 메모리보다 큰 MoE를
--cpu-moe로 돌린다면 GPU 클럭부터 확인할 만합니다. decode 중/sys/class/devfreq/17000000.gpu/cur_freq가 최저값에 머문다면, 최저 클럭만 올려도 이번 두 모델에서 1.24~1.39배였습니다. 메모리에 들어가는 모델에는 필요하지 않았습니다. - expert 캐시는 적용 전에 모델별로 확인해야 합니다. expert가 작고 많은 35B-A3B에서는 하한 대비 1.47배였지만, expert가 크고 적은 gpt-oss-20b에서는 0.68배였습니다. router 기록으로 캐시 크기별 읽기량을 시뮬레이션하거나, page cache 경로와 같은 프롬프트로 비교해 보고 정하는 편이 안전합니다.
- 계산 스레드 수의 최적값은 읽기 경로에 따라 반대였습니다. page cache 경로에서는
-t 12, 캐시 경로에서는-t 4였습니다.
남은 문제도 있습니다. ④에서도 계산 스레드는 여전히 읽기를 기다립니다. 이 대기를 더 줄이려면 I/O 하나당 CPU 비용을 낮추는 io_uring을 먼저 시험해야 하고, 그래야 다음 층 예측처럼 읽기를 늘리는 방식이 6코어에서 이득을 낼 여지가 생깁니다. 또 Edge0처럼 토큰당 expert 수를 줄이도록 다시 학습한 모델이 같은 보드와 같은 캐시에서 얼마나 달라지는지도 확인할 필요가 있습니다.
데이터와 범위
공개 자료는 본문 표와 도표의 원자료이며, 결론은 2026년 10월에 측정한 보드 한 대와 실험 패치를 얹은 llama.cpp 빌드 하나에 한정됩니다.
- 35B-A3B 단계별 요청 12개의 decode, prefill, NVMe 읽기, 문장 일치 여부
- top-k 8/4 요청별 NVMe 읽기와 decode 시간
- GPU governor 클럭 분포와 전력
- 캐시 크기별 시뮬레이션
- 캐시 조정 스윕
- 다음 층 예측 정확도, 시뮬레이션, 실측
- 모델 6종 대조 실험
- perplexity
반복 횟수가 적습니다. 각 설정은 한 번씩 돌렸고, 프롬프트 4개로 잰 조정 결과는 12개로 다시 재면 달라질 수 있습니다. 품질은 생성 문장 일치와 wikitext-2 512토큰 8청크 perplexity로만 확인했습니다. perplexity는 겹치기 구현 단계에서 쟀고, ④ 설정은 생성 문장 일치로 확인했습니다. 캐시와 예측 패치는 원인을 가르기 위한 실험 구현이므로, 같은 아이디어를 더 잘 구현하면 결과가 달라질 수 있습니다.