M4 Max에서 DeepSeek V4.1 돌리기, 외장 SSD 읽기 지연을 쫓다가 가중치를 나눠 배치하기까지
외장 SSD 스트리밍 중 한 레이어 읽기가 42초씩 걸리는 현상을 추적했고, 340GiB 모델을 내장 가중치 152GiB와 외장 Engram 189GiB로 나눠 prefill을 26% 끌어올렸습니다.
M4 Max(통합 메모리 128GiB)에서 DeepSeek V4.1 Flash Q2(340.6GiB)를 DwarfStar의 Metal SSD 스트리밍으로 돌린 일주일 동안의 기록입니다. 절반은 벤치마크보다 저장장치 쪽 병목을 추적한 이야기입니다. 원인을 끝까지 밝히지는 못했지만, 추적하다 찾은 배치 방법으로 성능은 실제로 올라갔습니다.
외장 SSD에 전부 둘 때 생긴 지연
내장 SSD가 512GB라 340GiB 모델이 들어가지 않았고, 처음에는 USB4 외장(2TB)에 모델을 전부 두고 스트리밍했습니다. 8K 기준 prefill 68.5, 생성 10.5 tok/s였습니다. 그런데 반복해서 실행하면 읽기 처리량이 28 MiB/s까지 떨어지고, 한 레이어를 읽는 데 42초가 걸리는 일이 생겼습니다.
의심 가는 원인을 하나씩 확인했습니다.
| 의심 | 확인 방법 | 결과 |
|---|---|---|
| 다른 추론 서버의 간섭 | 모두 끄고 다시 실행 | 또 발생 |
| 연결 자체가 느림 | 캐시를 우회해 8MiB 읽기 | 4,077 MiB/s로 정상 |
| SSD 발열 | 식힌 뒤 다시 실행 | 느린 구간 또 발생, 단독 원인 아님 |
| 오래 읽으면 느려짐 | 추론 없이 파일 전체를 두 번 읽기 | 3,400 MiB/s 유지 |
| GPU가 읽기 요청을 늦게 보냄 | 레이어별 시간 분해 | 아래 표, 해당 없음 |
지연이 생긴 실행에서 layer 32(3.74GiB 읽기)의 시간을 나눠 보면 이렇습니다.
| 구간 | 정상 실행 | 지연 실행 |
|---|---|---|
| 읽기 worker 최대 경과 | 1.09초 | 42.17초 |
| join 대기 | 0.79초 | 41.85초 |
| GPU 제출·완료 대기 | 0.33초 | 0.43초 |
GPU 쪽은 그대로이고 읽기를 준비하는 단계만 느려졌습니다. 남은 후보는 USB 브리지나 컨트롤러, OS 캐시와 가상 메모리의 압박, 접근 패턴인데 어느 것인지는 확정하지 못했습니다. 그래서 원인을 피해 가는 방법을 찾았습니다.
자주 읽는 부분만 내장으로
이 모델의 340GiB는 성격이 다른 두 부분으로 되어 있습니다. 매 레이어 읽는 일반 가중치가 약 152GiB이고, 필요한 행만 드문드문 읽는 Engram이 약 189GiB입니다. 같은 데이터를 48회씩 읽어 비교하니 내장이 외장보다 1.54배 빨랐습니다. 그렇다면 가중치만 내장에 두면 됩니다.
내장에서 20GiB를 정리하고, 승인을 받아 쓰지 않는 모델 하나를 지웠습니다. 그리고 가중치만 담고 Engram 부분은 sparse hole로 비워 둔 파일을 내장에 만들었습니다. 논리 크기는 340.6GiB, 실제 차지하는 공간은 151.8GiB입니다. Engram은 외장에 있는 원본 파일에서 원래 위치 그대로 읽습니다. 같은 입력으로 원래 경로와 비교해 다음 토큰 logit 전체와 greedy 생성 결과가 일치하는 것을 확인했습니다.
| 반복 | 8K 추가 prefill | 생성 |
|---|---|---|
| 외장 전체 (기준) | 117.4초 프로파일 | 11.76 tok/s |
| 분리 배치 1회 | 149.53 tok/s | 13.43 tok/s |
| 분리 배치 2회 | 147.12 tok/s | 13.39 tok/s |
이전 외장 측정보다 prefill이 약 26%, 생성이 약 14% 올랐습니다. 날짜, 캐시, 온도를 맞추지 않은 전후 비교라 이 배수를 일반화하지는 않습니다. 분리 배치로 바꾼 뒤로 42초 지연은 다시 나오지 않았습니다.
sparse hole 파일은 혼자서는 모델 구실을 못 합니다. 일반 복사 도구로 옮기면 비워 둔 부분을 실제 용량으로 채워 버릴 수 있고, 외장 원본이 없으면 Engram을 읽지 못합니다. 실행 스크립트와 한 묶음으로 관리하고 있습니다.
32K에서 128K까지
분리 배치로 한 세션 안에서 32K씩 늘려 가며 각 지점에서 128토큰을 생성했습니다.
| 전체 문맥 | 추가 prefill | 생성 |
|---|---|---|
| 32K | 364.68 tok/s | 1.47 tok/s* |
| 64K | 348.62 tok/s | 10.79 tok/s |
| 96K | 315.17 tok/s | 11.09 tok/s |
| 128K | 286.41 tok/s | 10.77 tok/s |
*첫 32K의 1.47은 워밍업 때문으로 보입니다. 따로 다시 재면 6.67, 짧은 인사로 워밍업한 뒤에는 11.2~11.8이 나왔습니다. 그래도 측정값이라 표에서 빼지 않았습니다. 전 구간 swap은 1MiB 수준이었고 오류는 없었습니다. 긴 문맥에서도 생성이 약 11 tok/s로 유지된 점은 EVO-X2에서 같은 모델을 돌린 결과(6~8 tok/s)와 다릅니다. 하드웨어와 실행 경로가 달라 직접 비교는 하지 않습니다.
이미 있던 읽기 중첩의 효과
CUDA 쪽에 레이어 읽기 중첩 커밋이 올라온 김에 Metal을 확인해 보니, 다음 레이어 가중치를 현재 레이어 연산과 겹쳐 읽는 기능이 이미 구현돼 있었습니다. 시험용 빌드에 이 기능을 끄는 스위치를 달고 on, off, off, on 순서로 쟀습니다. 8K prefill이 1.92배 차이 났고, 80개 레이어의 join 대기 합계는 켰을 때 23초, 껐을 때 69초였습니다. 네 번 모두 logit과 생성 문장이 똑같았습니다.
새로 찾은 개선은 아니고 원래 있던 최적화의 효과를 잰 결과입니다. 이 값을 알고 있으면 나중에 이 기능이 망가졌을 때 바로 알아챌 수 있습니다.
이 모델은 상주해야 할 부분 150GiB와 드문드문 읽는 부분 190GiB로 되어 있어서, 메모리보다 큰 모델을 돌리는 문제가 결국 어느 저장장치에 무엇을 둘지의 문제가 됐습니다.
같은 모델을 AMD EVO-X2에서 잰 결과는 별도 글에 있습니다.