gnu.or.kr
AI 인프라

8GB Jetson Orin Nano에서 메모리보다 큰 MoE를 SSD로 스트리밍하기

8GB급 엣지 기기에서 메모리보다 큰 모델을 돌리는 방법으로 SSD 스트리밍이 주목받고 있다. Jetson Orin Nano Super에서 메모리의 2.7배인 Qwen3.6-35B-A3B를 llama.cpp mmap 스트리밍으로 돌려 보며, 이 방식이 실제로 어디까지 쓸 만하고 속도가 어디서 막히는지 확인했다.

심근우 (Gnu Shim)수정 2026년 10월 28일llama.cppJetsonsLLMMoEmmapNVMe오프로드통합 메모리Qwen

TL;DR — Jetson Orin Nano Super 8GB(MAXN_SUPER)에서 측정한 sLLM 속도

모델 파일 크기 실행 방식 decode (tok/s) prefill (tok/s)
Gemma 4 E2B Q4_K_M 2.9 GiB 메모리 상주 29.6 916
Gemma 4 E4B Q4_K_M 4.6 GiB 메모리 상주 15.3 534
Qwen3.5-9B IQ4_XS 4.8 GiB 메모리 상주 13.6 384
Gemma 4 26B-A4B MXFP4 15.4 GiB SSD 스트리밍(mmap) 2.6 9.7
Qwen3.6-35B-A3B MXFP4 20.2 GiB SSD 스트리밍(mmap) 2.3~2.6 (-t 6)
2.9~3.1 (-t 12)
3.0

llama-bench 3회 평균. 덴스는 pp512/tg128, MoE는 pp128/tg64이며, 35B-A3B decode는 측정 세션별 범위다.

모든 추론을 클라우드에 맡길 수는 없다. 네트워크 왕복 없이 응답해야 하는 기기, 데이터를 밖으로 보낼 수 없는 현장, 연결이 끊길 수 있는 환경에서는 모델이 기기 안에서 돌아야 한다. 그런 기기의 메모리는 몇 GB이고, 거기에 들어가는 sLLM은 큰 모델보다 능력이 떨어진다.

그래서 메모리보다 큰 모델을 작은 기기에서 돌리려는 시도가 모델과 런타임 양쪽에서 이어지고 있다. 모델 쪽에서는 토큰마다 일부 expert만 쓰는 MoE, 그리고 가속기 밖에 두는 임베딩 테이블(Gemma 4의 PLE, Qwen3.8-Flash-Next의 n-gram 임베딩)이 늘었다. 런타임 쪽에서는 가중치를 SSD에 두고 필요한 부분만 읽는 스트리밍이 나왔다. Apple의 LLM in a flash, Flash-MoE에 이어, 최근에는 SSD expert 스트리밍을 전제로 휴대폰급 메모리를 겨냥한 Edge0-35B-A3B-preview 같은 모델 배포도 등장했다.

이 글은 그중 가장 기본형인 llama.cpp의 mmap 스트리밍이 실제 엣지 보드에서 어디까지 되는지 측정한 기록이다. 보드는 OS가 보는 메모리가 7.4 GiB인 Jetson Orin Nano Super 8GB, 모델은 Edge0의 기반이기도 한 Qwen3.6-35B-A3B(MXFP4, 20 GiB)다. 모델 크기는 메모리의 약 2.7배다. Edge0의 prerouter 같은 전용 기법은 쓰지 않았다. 비교 기준으로 메모리에 들어가는 덴스 sLLM 3종도 측정했다.

결과적으로 35B-A3B는 decode 1~3 tok/s로 동작했다. NVMe는 동시 I/O 1~2개 수준으로만 쓰였고, decode 속도는 NVMe 처리량과 함께 움직였다. 읽기를 개선하려는 다섯 가지 시도 중 같은 조건에서 일관되게 빨라진 것은 계산 스레드를 코어 수보다 많이 준 -t 12(세션별 +14~34%) 하나였다.

배경: 토큰당 읽는 바이트와 오프로드

배치 크기 1인 decode의 상한은 대략 메모리 대역폭을 토큰당 읽는 바이트로 나눈 값이고, 오프로드 기법은 그 바이트를 어느 계층에서 읽느냐를 바꾼다.

decode는 토큰 하나를 만들 때마다 활성 가중치를 메모리에서 한 번씩 읽는다. 연산보다 메모리 읽기가 오래 걸리므로 TG 상한은 대략 메모리 대역폭 ÷ 토큰당 읽는 바이트다. prefill은 여러 토큰이 같은 가중치를 공유하는 행렬곱이라 연산 한계에 더 가깝다.

그래서 메모리가 작은 기기에서는 파라미터 수보다 두 값이 더 직접적인 기준이 된다. 메모리에 상주해야 하는 바이트와 토큰마다 읽어야 하는 바이트다. 최근 모델 설계는 둘 중 하나 이상을 줄인다.

방식 예 줄이는 값 대가
Sparse MoE(소수 expert만 활성) Gemma 4 26B-A4B, Qwen3.6-35B-A3B 토큰당 읽는 바이트 전체 가중치는 어딘가에 저장해야 하고, 토큰마다 읽는 expert가 흩어진다
가속기 밖 임베딩 테이블 Gemma 4 E2B/E4B의 PLE, Qwen3.8-Flash-Next의 n-gram 임베딩 가속기에 올리는 바이트 저장 용량이 유효 파라미터보다 크다. E2B는 추론 시 2.3B처럼 동작하지만 디스크에서는 5.1B다 [3]
하이브리드 선형 어텐션 Qwen3.5 소형 dense 0.8B~9B(Gated DeltaNet과 full attention 3:1) [4] KV cache full attention 레이어의 KV는 여전히 컨텍스트에 비례한다
Sliding window + full attention Gemma 4 E2B/E4B(window 512) KV cache 위와 같다
저비트 포맷 MXFP4, IQ4_XS, Q4_K_M 두 값 모두 품질 손실

상주 바이트가 메모리를 넘으면 가중치 일부를 더 느린 계층에 둬야 한다. 기법마다 토큰당 계층 경계를 넘는 것이 다르다.

기법 가중치 위치 연산 위치 토큰마다 경계를 넘는 것 예
레이어 오프로드 일부 레이어를 system RAM에 해당 레이어는 CPU 활성값(작음) llama.cpp -ngl N
UVA 가중치 오프로드 pinned system RAM GPU GPU 커널이 interconnect 너머의 가중치를 직접 읽음 vLLM --cpu-offload-gb [5]
prefetch 오프로드 system RAM GPU 다음 레이어 그룹을 비동기 H2D 복사로 미리 가져옴 vLLM offload_group_size [5]
MoE expert 오프로드 routed expert를 system RAM에 expert는 CPU, attention과 shared는 GPU 활성값 llama.cpp --cpu-moe, -ot [1]
SSD 스트리밍 SSD(OS page cache 또는 전용 캐시) CPU 또는 GPU 캐시에 없는 바이트(SSD 읽기) llama.cpp mmap, LLM in a flash [6], Flash-MoE [2], Edge0 [8]

SSD 스트리밍 사례들은 읽는 양을 줄이거나 읽기를 연산 뒤에 숨기는 데 집중한다. LLM in a flash는 최근 활성화된 뉴런을 재사용해 전송량을 줄이고, 연속된 큰 덩어리로 묶어 읽어 DRAM의 최대 2배 크기 모델을 실행했다 [6]. Flash-MoE는 Qwen3.5-397B-A17B의 expert를 SSD에서 병렬 pread로 읽어 48 GB M3 Max에서 4.4 tok/s를 냈다고 보고했다 [2]. Edge0-35B-A3B-preview는 Qwen3.6-35B-A3B를 int4로 양자화해 체크포인트 전체를 저장 장치에 두고, 라우팅된 expert만 읽는다 [8]. 모델 카드에 따르면 토큰당 활성 expert를 4개로 줄였고, 다음 단계의 라우팅을 미리 예측하는 prerouter로 읽기를 연산과 겹쳐 decode 처리량을 최대 59% 올렸다. Mac mini M4 Pro 24 GB에서 decode 14.9~17.7 tok/s, 최대 활성 메모리 2.9 GiB를 보고했다. 세 사례 모두 하드웨어가 달라 이번에 재현하지 않았다.

llama.cpp의 mmap 스트리밍은 이런 장치가 없는 기본형이다. 모델 파일을 메모리에 매핑해 두고, 접근한 페이지가 page cache에 없으면 OS가 page fault로 읽는다. 무엇을 언제 얼마나 읽을지는 커널의 fault 처리와 readahead가 정한다.

실증 환경: Jetson Orin Nano Super 8GB

통합 메모리 8GB와 PCIe NVMe를 갖춘 이 보드는 메모리보다 큰 모델의 SSD 스트리밍을 엣지 크기에서 시험하기에 맞다.

OS가 보는 메모리가 7.4 GiB라 4-bit 7~9B 덴스 모델이 들어가는 상한에 가깝고, 20 GiB짜리 35B-A3B는 메모리의 약 2.7배다. NVMe가 PCIe x4로 붙어 있어 SSD 스트리밍을 같은 보드에서 바로 시험할 수 있다.

항목 값
보드 Jetson Orin Nano Developer Kit Super(P3767-0005), GUI 끔(multi-user.target)
CPU Cortex-A78AE 6코어, 최대 1728 MHz
GPU Ampere, 최대 1020 MHz. DLA 없음
메모리 LPDDR5 8GB(OS에서 보이는 용량 7.4 GiB), 공칭 102 GB/s, GPU 읽기 실측 81.4 GB/s
저장 장치 MSI M450 500GB NVMe, PCIe Gen3 x4 링크(8.0 GT/s ×4 확인)
소프트웨어 JetPack 6.2.3(L4T R36.5.2), CUDA 12.6, 전력 모드 MAXN_SUPER
추론 엔진 llama.cpp b1-19ff817(SM87 CUDA 빌드)

GPU 읽기 대역폭은 1 GiB 버퍼를 읽는 커널을 20회 연속 실행해 측정한 최댓값이다.

Jetson Orin Nano의 메모리 계층CPU와 GPU가 7.4 GiB LPDDR5를 공유한다. CUDA0, CPU_Mapped, CUDA_Host, page cache는 모두 같은 DRAM 안의 구역이다. GPU 읽기 대역폭은 81.4 GB/s로 측정됐다. DRAM 아래의 NVMe에는 20 GiB짜리 35B-A3B 파일이 있고, decode 중 NVMe 처리량은 0.13~0.71 GB/s였다.LPDDR5 7.4 GiB — CPU와 GPU가 공유GPUCPU 6코어CUDA0GPU가 쓰는 가중치 · KV cacheCPU_Mappedmmap한 가중치 · 회수 가능CUDA_Hostpinned 메모리 · 회수 불가page cacheNVMe에서 읽어 둔 expert 페이지GPU 읽기 실측 81.4 GB/spage fault · readahead로 읽기decode 중 0.13~0.71 GB/s, 동시 I/O 1~2개NVMe, PCIe Gen3 x435B-A3B GGUF 20 GiB · 1 MiB 순차 읽기 2.5 GB/s네 구역은 모두 같은 DRAM이다. 용량 경계는 DRAM과 NVMe 사이 하나다.
네 구역은 크기 비율이 아니라 종류를 나타낸다. NVMe 처리량은 35B-A3B decode 구간의 요청별 평균 범위다.

이 보드는 CPU와 GPU가 같은 LPDDR5를 쓴다. 그래서 앞의 오프로드 표에서 위쪽 세 기법은 메모리를 비우지 못한다. ’CPU로 오프로드’해도 같은 DRAM 안에서 연산 주체와 그 페이지를 회수할 수 있는지만 바뀐다. 남는 계층 경계는 DRAM과 NVMe 사이 하나다. 메모리보다 큰 MoE를 돌리는 수단은 MoE expert 오프로드와 mmap 스트리밍의 조합이다. mmap으로 매핑한 가중치는 회수 가능한 page cache로 잡히므로 RAM보다 큰 파일도 로드되지만, pinned 메모리처럼 회수할 수 없는 경로를 타면 로드가 실패한다.

구성할 때 주의할 점

Super 모드는 모드 이름이 아니라 GPU 주파수 테이블로 확인한다. MAXN_SUPER는 GPU 상한을 624.75 MHz에서 1020 MHz로, CPU 상한을 1510.4 MHz에서 1728 MHz로, 메모리 대역폭 이론치를 68 GB/s에서 102 GB/s로 올린다. 이 보드는 JetPack 6.2.3으로 올린 것만으로는 Super가 열리지 않았다. nvpmodel -m 2로 MAXN_SUPER를 지정하면 nvpmodel -q는 MAXN_SUPER를 보고했지만, GPU 주파수 테이블은 여전히 624.75 MHz에서 끝났다. nvpmodel은 테이블 최대치 아래로 상한을 씌울 뿐 없는 주파수를 만들지 못한다. 보드 신원(TNSPEC)을 super로 바꾸고 super 부트로더 캡슐을 적용한 뒤에야 테이블이 1020 MHz까지 늘었다. NVIDIA의 공식 안내는 x86 호스트에서 Super 구성으로 다시 플래시하는 것이고, 이 온디바이스 전환은 비공식 절차다. 적용 여부는 /sys/class/devfreq/17000000.gpu/available_frequencies로 확인한다.

전력과 메모리 예산. MAXN_SUPER에서 CPU 6코어와 GPU를 동시에 90초 부하했을 때 보드 입력 전력은 최대 21.5 W, tj는 71.1°C였고 GPU 클럭은 1020 MHz에서 내려가지 않았다. GUI를 끄면 유휴 메모리 사용량은 1 GB 안팎이다. L4T 기본 zram 스왑 3.7 GiB는 압축된 RAM이라 물리 메모리를 쓴다.

메모리보다 큰 모델은 로드 경로를 직접 지정해야 한다. 이 빌드의 --load-mode auto는 iGPU에서 load_mode = none으로 로드하고, GPU 밖 가중치를 pinned CUDA_Host에 올린다. 메모리에 들어가는 모델은 이래도 문제가 없지만, 35B-A3B는 초기 설정 과정에서 다음 순서로 실패와 우회를 거쳤다.

  1. pinned 할당 실패(NvMap ENOMEM)
  2. --no-host를 준 뒤에는 ARM용 CPU_REPACK의 18 GB malloc 실패
  3. --load-mode mmap --no-host --no-repack --cpu-moe 조합에서 로드

llama.cpp는 --mmap/--no-mmap을 --load-mode로 통합하고 있다 [7].

기준선: 메모리에 들어가는 덴스 sLLM

메모리에 들어가는 덴스 3종은 대역폭 상한의 54~77% 속도를 냈다.

모델 GPU 가중치 버퍼 tg128 (tok/s) 대역폭 상한 효율 pp512 (tok/s) 토큰당 KV
Gemma 4 E2B Q4_K_M 1.37 GiB 29.6 55.1 54% 916 6 KiB
Gemma 4 E4B Q4_K_M 2.82 GiB 15.3 26.9 57% 534 16 KiB
Qwen3.5-9B IQ4_XS 4.27 GiB 13.6 17.8 77% 384 32 KiB

상한은 실측 GPU 읽기 대역폭 81.4 GB/s를 GPU 가중치 버퍼 크기로 나눈 값이고, 속도는 llama-bench 3회 평균이다. Gemma 두 모델의 효율이 낮은 원인은 확인하지 않았다. E2B에서 GPU 밖에 남은 1.71 GiB(PLE 등 임베딩)는 행 조회(GET_ROWS)로만 읽혔다.

컨텍스트 여유는 토큰당 KV가 정했다. Qwen3.5-9B는 선형 어텐션 레이어가 KV를 쓰지 않는데도 full attention 레이어의 KV가 E4B의 두 배다. 9B는 32K에서 KV 1 GiB가 할당됐지만 fit 검사 실패 메시지가 났고(로드는 계속됨), 128K는 KV 할당에 실패했다. E4B는 128K에서 KV 2 GiB가 할당됐고 같은 메시지가 났다. 긴 입력으로 생성까지 해 보지는 않았다.

35B-A3B를 SSD에서 스트리밍할 때

토큰당 expert 바이트의 25~47%를 NVMe에서 읽었고, NVMe는 동시 I/O 1~2개 수준으로만 쓰였다.

MoE 레이어 하나에서 expert 가중치를 읽는 경로attention과 shared expert는 GPU에서 계산한다. router가 routed expert 256개 중 8개를 고르면 CPU 계산 스레드가 up, gate, down 순서로 계산한다. expert 가중치는 mmap된 파일이라 page cache에 있으면 바로 읽고, 없으면 major fault로 NVMe에서 읽는다. 토큰당 expert 바이트 594 MB 중 146~279 MB가 NVMe에서 왔다.토큰 1개 · 레이어 1개 (40회 반복)GPUattention · shared expert · routerCPU고른 expert 8개(256개 중)를 계산up → gate → down, 연산마다 스레드 barriermmap된 expert 가중치 페이지에 접근page cache에 있음DRAM에서 바로 읽는다없음 → major faultNVMe에서 70~84 KiB씩expert 바이트: 토큰당 594 MB (17.71 GiB × 8/256)그중 NVMe에서 146~279 MB (25~47%)
--cpu-moe와 mmap 조합에서 토큰 하나가 레이어 하나를 지나는 경로. router는 GPU에 남고 routed expert 가중치만 CPU 쪽 mmap 영역에 있다.

같은 서버(-c 4096, -t 6)에 21~32토큰 프롬프트 5종을 보내고, 요청마다 /sys/block/nvme0n1/stat의 변화량을 기록했다. cold 조건은 요청 전에 모델 파일 페이지를 posix_fadvise(DONTNEED)로 비운 경우다.

  • 토큰당 expert 읽기량: routed expert 17.71 GiB × 8/256 = 594 MB를 토큰마다 읽는다. 실제 디스크 읽기는 decode 토큰당 146~279 MB였다(10회). 바이트 기준으로 25~47%가 NVMe에서, 나머지는 page cache에서 왔다. warm 5회의 decode 읽기량은 같은 프롬프트의 cold 1토큰 요청 읽기량을 빼서 추정했다. readahead 때문에 실제 필요량보다 많게 잡혔을 수 있다.
  • 요청 간 캐시 효과 없음: prefill에서 이미 6.6~7.9 GiB를 읽는데 page cache는 3.1~3.6 GiB다. 다음 요청까지 남는 캐시가 없어 cold와 warm의 decode 평균이 2.00과 1.99 tok/s로 같았다.
  • I/O 형태: decode 중 평균 I/O 크기는 약 70~75 KiB, 동시에 처리 중인 I/O는 평균 1.0~1.4개였다(8회). 같은 파일에 대한 fio 랜덤 읽기의 QD1~2 수준이다.
decode 중 NVMe 처리량과 장치 처리량decode 중 NVMe 처리량은 6스레드 451 MB/s(동시 I/O 1.28), 12스레드 583 MB/s(1.67). 같은 파일을 fio로 읽으면 64 KiB QD1 200, QD2 465, QD4 778, 128 KiB QD8 1,618, 1 MiB 순차 2,515 MB/s.01,0002,000MB/sdecode 중 실측 (35B-A3B)-t 6 · 동시 I/O 1.28451-t 12 · 동시 I/O 1.67583fio, 같은 모델 파일64 KiB 랜덤 · QD120064 KiB 랜덤 · QD246564 KiB 랜덤 · QD4778128 KiB 랜덤 · QD81,6181 MiB 순차2,515
decode 막대는 같은 세션에서 요청 5개의 평균. fio는 같은 모델 파일을 읽었다. decode의 평균 I/O 크기는 72~84 KiB라 64 KiB fio와 블록 크기가 정확히 같지는 않다.

장치는 QD8에서 1.6 GB/s, 순차 읽기에서 2.5 GB/s까지 낼 수 있었지만 decode 중에는 요청 평균 0.13~0.71 GB/s만 쓰였다. 같은 프롬프트를 반복해도 decode 속도가 최대 2.1배 달랐고(1.13 → 2.38 tok/s), 서버 측정 18회 전체로는 0.9~2.7 tok/s였다. 이 편차는 프롬프트보다 회차별 NVMe 처리량과 함께 움직였다.

decode 속도와 NVMe 처리량요청 8회의 decode 구간 NVMe 처리량(131~487 MB/s)과 decode 속도(0.94~2.43 tok/s). 상관계수 0.90. 같은 프롬프트가 207 MB/s에서 1.13 tok/s, 466 MB/s에서 2.38 tok/s였다.decode tok/s01230200400600decode 구간 NVMe 처리량 (MB/s)1.13같은 프롬프트 2.38r = 0.90 (n = 8)
I/O 추적을 붙인 요청 8회. 점선은 같은 프롬프트의 두 회차다.

decode 속도와 NVMe 처리량의 상관계수는 0.90이었다. 처리량이 회차마다 달라지는 원인은 확인하지 못했다.

decode 시간은 어디에 쓰이나

decode 중 서버 스레드 상태를 5 ms 간격으로 샘플링했다(요청 2회, 각 63토큰). 계산 스레드 6개는 모두 비슷한 비율로 시간을 썼다.

decode 중 계산 스레드의 시간 배분스레드별로 실행 37~45%, I/O 대기 15~22%, 대기 38~45%. 하나 이상의 스레드가 I/O 대기 중인 시간은 decode 시간의 40~45%.스레드 하나의 시간 중 비율 (스레드 6개의 범위)0%25%50%75%100%실행 (R)37~45%I/O 대기 (D)15~22%대기 (S)38~45%1개 이상 D 상태40~45% (decode 시간 기준)S에는 GPU 연산 대기와 barrier 대기가 섞여 있다
/proc의 스레드 상태 샘플. 진한 막대는 스레드 6개 중 최솟값, 옅은 부분은 최댓값까지다.

스레드 하나가 I/O를 기다린 시간은 15~22%였지만, 하나 이상의 스레드가 I/O 대기인 시간은 decode의 40~45%였다. CPU 계산 스레드는 연산(op)마다 barrier로 동기화되므로, 한 스레드가 fault로 멈추면 나머지도 그 스레드를 기다린다. 대기(S)에는 GPU 쪽 연산(attention, shared expert)을 기다리는 구간과 barrier 대기가 섞여 있고, 이번 측정으로는 둘을 나누지 못했다. 별도 5회 측정에서 프로세스 CPU 사용량은 약 2코어분, 그중 31~42%가 커널 시간이었고, 토큰당 major fault는 2,500~4,000건이었다.

expert 읽기 개선 실험

다섯 가지 시도 중 같은 조건에서 decode를 일관되게 높인 것은 패치 없이 스레드를 과다 할당한 -t 12 하나였다.

시험한 방법은 스레드 과다 할당, MADV_WILLNEED, 병렬 pread, 내장 GPU expert 캐시, 비동기 expert 로더다. 패치는 원래 빌드와 분리한 사본에 넣고 환경변수로 켜고 껐다. 측정 세션마다 기준값이 달랐으므로(-t 6 2.26~2.60, -t 12 2.89~3.06 tok/s) 비교는 같은 세션 안에서만 한다.

expert 읽기 개선 실험의 decode 변화같은 세션 기준값 대비 decode 변화. -t 12는 -t 6 대비 +27%. MADV_WILLNEED 6스레드 +3%(차이 없음). pread 128K는 6스레드 -14%, 12스레드 -26%로 편차가 컸다. readahead를 끈 pread 128K는 6스레드 +9%, 12스레드 -6%. pread 512K는 -9%, -21%. 비동기 로더는 6스레드 +7%, 12스레드 -7~+2%. GPU expert 캐시는 12스레드에서 -43~-48%.6스레드12스레드막대: ±표준편차-60-40-200+20+40기준값 대비 decode 변화 (%)-t 12 (기준 -t 6)MADV_WILLNEEDpread 128Kpread 128K + RANDOMpread 512K + RANDOM비동기 로더 I/O×4비동기 로더 I/O×8비동기 로더 I/O×16GPU 캐시 512MGPU 캐시 1GGPU 캐시 2G
같은 세션·같은 스레드 수의 패치 없는 기준값 대비 decode 변화. -t 12 행만 기준이 -t 6이다. -t 12, MADV_WILLNEED, GPU 캐시는 서버 측정(요청 5~10회), 나머지는 llama-bench tg64 3회.

스레드 과다 할당. 코어는 6개지만 계산 스레드를 9~12개로 주면, 일부 스레드가 fault로 멈춘 동안 다른 스레드가 실행되어 동시에 진행되는 fault가 늘어날 것으로 예상했다. llama-bench tg64는 4/6/9/12스레드에서 2.46 / 2.40 / 3.03 / 3.06 tok/s였다. 서버 측정에서 12스레드는 6스레드보다 decode 27%, NVMe 처리량 29%, 동시 I/O 31%, prefill 33% 높았고, 토큰당 NVMe 읽기는 200 MB와 201 MB로 같았다. 다섯 차례 세션에서 개선폭은 +14~34%였다.

서버 측정 decode tok/s NVMe 처리량 동시 I/O 평균 I/O 크기 토큰당 NVMe 읽기
기준(-t 6) 2.33 ± 0.29 485 MB/s 1.38 73.5 KiB 212 MB
MADV_WILLNEED(-t 6) 2.39 ± 0.21 423 MB/s 0.91 90.0 KiB 180 MB
기준(-t 6, 별도 세션) 2.31 ± 0.30 451 MB/s 1.28 71.9 KiB 200 MB
스레드 과다 할당(-t 12) 2.94 ± 0.19 583 MB/s 1.67 84.2 KiB 201 MB

MADV_WILLNEED. CPU mul_mat_id에서 router가 고른 expert 구간에 madvise(MADV_WILLNEED)를 걸었다(6스레드, 2라운드 교차, 각 10회). 평균 I/O 크기가 커지고 토큰당 읽기량은 15% 줄었지만, 동시 I/O도 1.38에서 0.91로 줄었고 decode 속도는 차이가 없었다.

병렬 pread. Flash-MoE의 방식을 llama.cpp 안에서 근사했다. 고른 expert 구간 중 page cache에 없는 페이지를 mincore로 찾아, 모든 계산 스레드가 나눠 pread로 읽은 뒤 계산한다. 실행 전체의 NVMe 읽기량은 19~25% 줄었다. fd의 readahead를 끈(POSIX_FADV_RANDOM) 128 KiB 조건은 6스레드에서 9% 빨라졌지만 12스레드에서는 6% 느려졌고, readahead를 켜 두면 회차마다 속도가 크게 흔들렸다. 이 구조는 “모두 읽은 뒤 계산”하므로 page fault 경로에서 겹치던 읽기와 계산이 더 이상 겹치지 않는다. 12스레드에서는 이 직렬화 비용이 읽기량을 줄인 이득보다 컸던 것으로 추정한다.

내장 GPU expert 캐시. 이 빌드의 --moe-cache-mib는 고른 expert를 GPU 캐시로 올려 GPU에서 계산한다. 캐시 512/1024/2048 MiB에서 decode는 1.50 / 1.54 / 1.66 tok/s로 캐시를 끈 경우(2.89)보다 43~48% 느렸다. 동시 I/O가 0.44~0.47개로 떨어지고 토큰당 NVMe 읽기는 193 MB에서 267~293 MB로 늘었다. 읽기량이 캐시 크기에 비례하지 않아 page cache 몫을 가져간 영향만으로는 설명되지 않는다. 통합 메모리에서는 이 캐시도 page cache와 같은 DRAM을 쓰면서 회수되지 않는다.

비동기 expert 로더. 레이어 안의 expert 연산은 up → gate → down 순서다. up 연산이 시작되면 고른 expert의 gate와 down 구간을 백그라운드 I/O 스레드가 page cache에 올리게 했다. 6스레드에서는 7% 빨라졌지만, 12스레드에서는 I/O 스레드 4/8/16개 모두 차이가 표준편차 안이었다. 레이어 하나에 드는 시간이 8~10 ms로 짧아 미리 읽을 여유가 크지 않았을 수 있다. 이 로더는 같은 레이어 안에서 이미 정해진 라우팅만 앞당겨 읽는다. Edge0의 prerouter는 다음 단계의 라우팅을 예측해 더 일찍 읽기 시작한다는 점이 다르다.

무엇이 확인됐고, 다음은 무엇인가

메모리의 2.7배인 MoE는 기본형 SSD 스트리밍으로도 돌아가지만, 1~3 tok/s는 대화형으로 쓰기 어렵고 장치 대역폭을 다 쓰기 전에 멈췄다.

-t 12로 동시 I/O를 늘리자 decode와 NVMe 처리량이 같은 폭으로 올랐다. 반면 읽기량을 줄이거나 읽기 시점을 같은 레이어 안에서 앞당긴 패치는 -t 12를 넘지 못했다. 두 관찰을 함께 설명하는 원인은 확인하지 못했다. 후보는 GPU 대기와 스레드 동기화, page fault 처리에 드는 커널 시간, 그리고 각 패치의 구현 방식이다. 12스레드에서도 동시 I/O는 1.67개라 장치에는 여유가 남아 있었다.

이번 측정 범위 안에서의 판단은 다음과 같다.

  • 이 보드에서 35B-A3B를 mmap으로 돌린다면 계산 스레드를 코어보다 많이(-t 9~12) 준다(2.3~2.6 → 2.9~3.1 tok/s). 26B-A4B에는 측정하지 않았다.
  • 응답 지연이 중요하면 메모리에 들어가는 덴스 모델이 현실적이다. 가장 빠른 것은 Gemma 4 E2B(29.6 tok/s), 가장 큰 것은 Qwen3.5-9B(13.6 tok/s)였다.

다음 실험은 다음 레이어의 라우팅을 예측해 더 일찍 읽기 시작하는 방식이다. Edge0의 prerouter가 보고한 이득이 이 보드의 NVMe와 스레드 구조에서도 나오는지가 남은 질문이다. 그 전에 decode 대기 시간 중 GPU 대기, 동기화, fault 처리의 비중을 나눠 봐야 어느 쪽을 겨냥할지 정할 수 있다.

부록: 초기 측정 재검증

이 보드의 초기 측정값은 llama-cli에 짧은 한국어 프롬프트를 넣어 1회 생성한 값이었다. 같은 바이너리와 같은 GGUF로 llama-bench를 3회 반복해 다시 측정했다(덴스는 pp512/tg128, MoE는 pp128/tg64, 평균 ± 표준편차).

모델 실행 방식 PP: 초기 → 재측정 TG: 초기 → 재측정
Gemma 4 E2B Q4_K_M 메모리 상주 94.7 → 916 ± 42 27.6 → 29.6 ± 0.3
Gemma 4 E4B Q4_K_M 메모리 상주 74.4 → 534 ± 9 14.9 → 15.3 ± 0.0
Qwen3.5-9B IQ4_XS 메모리 상주 102.3 → 384 ± 9 13.4 → 13.6 ± 0.0
Gemma 4 26B-A4B MXFP4 NVMe mmap, --cpu-moe 4.0 → 9.7 ± 0.1 2.6 → 2.61 ± 0.12
Qwen3.6-35B-A3B MXFP4 NVMe mmap, --cpu-moe 2.9~4.4 → 3.0 ± 0.7 2.2~2.6 → 2.25 ± 0.67
  • TG는 일치했다. 차이는 0.4~7.1%였다.
  • PP는 처리량으로 쓸 수 없다. 수십 토큰짜리 프롬프트로 잰 값이라 E2B 기준 pp512보다 9.7배 낮고, 모델 간 순서도 바뀐다. 초기 값으로는 Qwen3.5-9B의 PP가 덴스 3종 중 가장 높았지만, pp512 기준으로는 가장 느리다.
  • 덴스 모델에는 초기 측정의 플래그 조합이 필요 없다. 기본 플래그로 돌려도 TG 차이는 1% 안팎이었고, PP는 E4B가 15%, 9B가 7% 더 높았다.
  • Gemma 4의 thinking은 --reasoning-budget 0으로 꺼지지 않았다. template의 enable_thinking이 기본값에서 켜져 있어 추론 과정이 답 본문에 섞였다. --chat-template-kwargs '{"enable_thinking":false}'를 주면 답만 냈다.
  • 초기 측정의 NVMe 최고치 868 MB/s는 prefill 구간 값이다. decode가 아니라 prefill 구간 처리량(서버 기동 직후 2회를 빼면 864~926 MB/s)과 같은 범위다.

데이터와 범위

공개 자료는 본문 표와 도표의 집계값이며, 결론은 2026년 10월에 측정한 보드 한 대와 llama.cpp 빌드 하나에 한정된다.

반복 횟수가 적다. llama-bench는 조건당 3회, 서버 측정은 프롬프트 4~5종을 1~2라운드 돌렸다. 35B-A3B는 세션마다 기준값이 달랐다. 실험 패치는 원인 분리용 최소 구현이라, 같은 아이디어를 더 잘 구현하면 결과가 달라질 수 있다. 품질은 측정하지 않았다.

참고

  1. Performant local MoE CPU inference with GPU acceleration in llama.cpp (HF blog), MoE Hybrid GPU and CPU Offloading with llama.cpp
  2. danveloper/flash-moe 소개, Simon Willison, Anemll/flash-moe
  3. Gemma core docs, AI Engineer: Gemma 4 deep dive
  4. Qwen 3.5 Small Models (InsiderLLM), Trilogy AI deep dive
  5. vLLM 소스 vllm/config/offload.py, vllm/model_executor/offloader/uva.py(커밋 7a64fb8 기준)
  6. LLM in a flash, arXiv 2312.11514
  7. llama.cpp completion README 미러
  8. Edge0/Edge0-35B-A3B-preview 모델 카드