DeepSeek V4 Flash 를 로컬에서 서빙하는데 프롬프트를 다시 계산하는 데
이상하게 오래 걸렸다. 원인은 실행 옵션 하나, --mixed-prefill-quantum 의
기본값 128 이었다. 2048 로 올리면 프리필이 2.2배 빨라진다. 합성 프로브와
실제 코딩 에이전트 양쪽에서 확인했다.
다만 조건이 붙는다. 이 설정은 여러 요청이 동시에 돌 때만 적용된다. 이 문서는 그 조건, 효과의 크기, 원인, 값 고르는 법, 잃는 것을 순서대로 적는다. 측정은 Apple M3 Ultra 512GB 한 대, 엔진은 antirez/ds4 의 ds4-server 다.
한 줄 요약
--mixed-prefill-quantum 2048
을 넣는다. 프리필이 2.2배 빨라지고, 같은 일을 시켰을 때 걸리는 시간이 약 1.75배 줄어든다.소스를 보면 이 값은 조건부로만 쓰인다.
bool generation_active = s->active_generations > 0; /* 서버 전역 카운터 */ return generation_active ? s->mixed_prefill_quantum : 2048;
프리필이 도는 그 순간 토큰을 뽑고 있는 요청이 하나라도 있어야 설정값이 쓰인다. 없으면 하드코딩된 2048 이다. 그래서 요청을 하나씩 보내고 답을 기다리는 방식이면 프리필과 디코딩이 시간축에서 겹칠 일이 없어 이 설정이 무시된다.
표 1. 디코딩 부하 유무에 따른 같은 구간(5,964 토큰) 측정.
| 조건 | quantum 128 | quantum 2048 | 판정 |
|---|---|---|---|
| 디코딩 부하 없음 | 279.3 tok/s | 279.4 / 279.5 / 279.7 | 차이 없음 |
| 다른 세션 3개가 디코딩 중 | 220.3 | 508.9 | 2.31배 |
| 거의 끝나가는 디코딩 하나 | 230.3 | 520.6 | 2.26배 |
첫 줄이 결정적이다. 네 번을 재서 소수점 첫째 자리까지 같다. 셋째 줄은 문턱을 알려준다. 프로브가 도는 동안 디코딩 로그가 19줄밖에 안 찍힌, 거의 끝나가는 디코딩 하나만 있어도 3세션일 때와 결과가 같다. 문턱은 3세션이 아니라 1세션이고, 그 하나가 얼마나 활발한지는 상관없다.
서버 메트릭을 15초 간격으로 찍어 프리필이 돌던 시간 중 다른 요청이 디코딩 중이던 비율을 셌다.
표 2. 사용 방식별 동시 실행 비율.
| 사용 방식 | 동시 실행 | 표본 |
|---|---|---|
| 에이전트 하나로 순서대로 | 1.4% | 151분, 218칸 |
| 에이전트 셋을 동시에 | 20 ~ 37% | 4회 실행 |
터미널 창을 두 개 이상 띄우거나 서브에이전트를 병렬로 돌리면 켜진다. 사람 수가 아니라 동시에 진행 중인 요청이 서로 다른 단계에 있느냐의 문제다.
그림 1. 나머지 조건을 고정하고 quantum 만 바꾼 결과. 2048 에서 이미 천장의 91% 에 닿는다.
점선은 교대를 아예 하지 않을 때의 상한 559 tok/s. 가로축은 로그 눈금. 다른 세션 3개가 디코딩하는 상태에서 2만 토큰 콜드 프리필, 3회 반복 편차 0.2% 미만.
표 3. 합성 프로브 전체 결과.
| quantum | 프리필 | 상한 대비 |
|---|---|---|
| 128 (기본) | 220.3 tok/s | 39% |
| 512 | 332.9 | 60% |
| 1024 | 426.0 | 76% |
| 2048 (권장) | 508.2 | 91% |
| 4096 | 542.3 | 97% |
| 8192 이상 | 552 ~ 559 | 99 ~ 100% |
위는 프롬프트를 직접 만들어 던지고 부하는 더미 세션으로 흉내낸 것이다. 실제 작업에서도 성립하는지 따로 확인했다. 읽기와 bash 도구를 가진 코딩 에이전트 셋이 실제 Rust 코드베이스(68파일 2.8만 줄)에서 각자 다른 분석 과제를 수행하게 했다. 컨텍스트는 알아서 17.8만 토큰까지 자랐고 도구 호출은 44~82회였다.
그림 2. 실제 에이전트 작업의 프리필 속도. 순서를 뒤집어도 두 무리가 서로 넘나들지 않는다.
분모는 프리필이 실제로 돌던 시간(요청 구간의 합집합)이다. 작업량이 조건마다 다르므로 총 소요 시간 대신 이 값으로 비교한다.
표 4. 실제 에이전트 작업 4회.
| 구간 | 요청 | 신규 프리필 | 계산 시간 | 프리필 속도 | 도구 호출 |
|---|---|---|---|---|---|
| 1회차 q2048 | 47 | 139,599 | 485초 | 287.7 | 44 |
| 2회차 q2048 | 48 | 190,262 | 722초 | 263.5 | 45 |
| 1회차 q128 | 55 | 214,270 | 1,735초 | 123.5 | 52 |
| 2회차 q128 | 87 | 252,263 | 2,025초 | 124.6 | 82 |
합치면 273.2 대 124.1, 2.20배다. 합성 프로브의 2.31배와 같은 자리다.
계산 시간의 분모는 요청 구간의 합집합이다. 에이전트가 셋이라 프리필이 동시에 도는 구간이 생기는데, 각 요청의 소요를 단순히 더하면 그 구간을 두 번 센다. q128 쪽이 더 많이 겹쳐서(최대 1.14배 중복) 단순 합산으로는 느린 쪽 속도가 실제보다 더 낮게 나온다.
작업량도 조건마다 다르다. 도구 호출이 44회에서 82회까지 벌어지고 신규 프리필도 14만에서 25만까지 흩어진다. 에이전트가 매번 다른 경로를 타기 때문이고 quantum 탓이 아니다. 그래서 총 소요 시간을 그대로 비교하지 않는다.
이 설정이 무엇을 건드리고 무엇을 안 건드리는지는 두 작업을 따로 재면 분명해진다. 각자가 실제로 돌던 시간으로 나눈 값이다.
표 5. 작업별 속도와 벽시계 점유.
| 프리필 속도 | 프리필 점유 | 디코딩 속도 | 디코딩 점유 | |
|---|---|---|---|---|
| quantum 2048 | 273.2 t/s | 52% | 29.2 t/s | 59% |
| quantum 128 | 124.1 | 90% | 21.6 | 48% |
| 배율 | 2.20배 | 1.35배 |
디코딩 쪽 1.35배는 이 설정이 디코딩을 빠르게 해서가 아니다. 프리필이 오래 돌수록 그 위에 디코딩이 동시에 도는 일이 늘고, 동시에 돌면 GPU 를 나눠 쓰니 디코딩도 느려진다. 동시에 돈 비율이 q2048 은 20~27%, q128 은 32~37% 였다. 프리필이 느린 것의 부작용이지 별도 효과가 아니다.
인과는 한 방향이다.
프리필이 2.2배 느리다 → 같은 양을 계산하는 데 2.2배 시간이 든다 → 그 시간이 벽시계를 먹는다(점유 52%에서 90%로) → 디코딩할 차례가 줄고 동시에 돌 때는 디코딩도 느려진다 → 작업이 오래 걸린다.
출발점은 프리필 하나이고 나머지 숫자는 전부 그 파생이다.
위 숫자들은 요청 단위 집계다. 같은 작업을 서버 메트릭 쪽에서 15초 간격으로 찍으면 이렇게 생겼다.
그림 3. 네 번의 실행. 같은 과제를 주었는데 걸린 시간이 17분에서 44분까지 벌어진다.
실행 하나마다 위가 프리필, 아래가 디코딩이다. 가로축은 네 실행이 공유한다. 회색 음영은 둘이 동시에 돌던 구간.
세 가지가 눈에 띈다.
프리필이 2.20배 빠르다고 작업이 2.20배 빨리 끝나지는 않는다. 벽시계에는 디코딩 시간도 들어 있고 그쪽은 이 설정이 건드리지 못한다.
표 6. 같은 일(신규 프리필 16.5만, 디코딩 2만)을 시켰다고 환산한 것. 각 조건에서 실측한 속도를 쓴다.
| 설정 | 프리필 | 디코딩 | 합계 |
|---|---|---|---|
| quantum 2048 | 604초 | 685초 | 1,289초 |
| quantum 128 | 1,330초 | 926초 | 2,256초 |
| 배율 | 2.20배 | 1.35배 | 1.75배 |
두 작업이 차례로 붙는다고 가정한 값이다. 실제로는 20~37% 구간에서 동시에 돌므로 실제 소요는 이보다 짧다. 어느 실행 쌍도 프리필과 디코딩 양쪽 작업량을 동시에 맞추지 못해 이보다 정확하게 좁히지는 못했다.
이 설정의 값어치는 작업에서 프리필이 차지하는 비중에 비례한다. 도구 결과를 계속 밀어 넣는 코딩 에이전트처럼 읽는 양이 쓰는 양보다 훨씬 많으면 크고, 짧은 질문에 긴 답을 쓰는 작업이라면 거의 없다.
그림 4. 실제 작업의 요청 하나하나. 가로축은 그 요청이 시작한 컨텍스트 위치.
점 크기는 신규 토큰 수, 굵은 선은 구간 중앙값. 두 무리가 전 구간에서 갈라져 있고 둘 다 오른쪽으로 갈수록 내려간다.
표 7. 컨텍스트 위치별 배율.
| 컨텍스트 | q2048 | q128 | 배율 |
|---|---|---|---|
| 0 ~ 3만 | 296 tok/s | 138 | 2.14배 |
| 3만 ~ 6만 | 282 | 119 | 2.37배 |
| 6만 ~ 10만 | 254 | 120 | 2.12배 |
| 10만 이상 | 225 | 100 | 2.25배 |
절대 속도는 컨텍스트가 길어질수록 떨어지지만 배율은 2.1~2.4 로 거의 변하지 않는다.
단, 요청이 동시에 돌지 않는 조건에서는 다르다. 프로브 하나만 계산하고 나머지가 디코딩만 하는 합성 조건에서는 컨텍스트 2만 2.31배, 17.7만 1.66배로 배율이 줄었다. 실제 에이전트 환경에서는 프리필끼리도 부딪히고, 그 경합이 작은 quantum 을 더 크게 깎아내려 줄어드는 몫을 메우는 것으로 보인다. 그것만 분리해 확인하지는 못했다.
원인은 이 모델이 MoE 라는 사실에 있다. 먼저 디코딩이 프리필의 몫을 빼앗는 것인지부터 확인했다. 각 자원을 단독 최대치로 나눠 시간 점유를 계산하면 된다.
표 8. 디코딩이 쓴 시간을 빼고 나면 프리필이 얼마나 나와야 하는가.
| quantum | 디코딩 점유 | 기대치 | 실측 | 차이 |
|---|---|---|---|---|
| 2048 | 9.3% | 507 | 509 | 없음 |
| 128 | 15.2% | 474 | 220 | 절반 이하 |
2048 은 예측과 실측이 일치한다. 교과서적인 시분할이다. 128 은 디코딩이 쓴 시간을 다 빼줘도 474 가 나와야 하는데 220 이다. 디코딩이 뺏은 것이 아니라 프리필이 자기 차례에 제 속도를 못 낸다.
DeepSeek V4 Flash 는 전문가 256개 중 토큰마다 6개를 쓴다. 한 청크가 깨우는 고유 전문가의
기대값은 256 × (1 - (1 - 6/256)^Q) 다.
그림 5. 디코딩 스텝을 뺀 순수 프리필의 토큰당 비용. 전문가당 48토큰에서 평평해진다.
전문가당 토큰 = Q × 6 / 256. 가로축은 로그 눈금.
표 9. 청크 크기별 전문가 사용 양상.
| quantum | 고유 전문가 | 전문가당 토큰 | 토큰당 비용 | 2048 대비 |
|---|---|---|---|---|
| 128 | 243.7 / 256 (95%) | 3.0 | 3.22 ms | 1.71배 |
| 512 | 256 | 12 | 2.67 | 1.42배 |
| 1024 | 256 | 24 | 2.18 | 1.16배 |
| 2048 | 256 | 48 | 1.88 | 1.00 |
| 4096 이상 | 256 | 96 이상 | 1.79 ~ 1.80 | 0.95 ~ 0.96 |
여기가 핵심이다. quantum 128 에서도 이미 전문가의 95%를 읽는다. 읽는 양은 2048 과 거의 같은데 각 전문가에서 3행짜리 행렬곱만 한다. 2048 이면 같은 가중치를 읽어 놓고 48행을 계산한다. 가중치를 다 끌어와 놓고 계산을 거의 안 하니 산술강도가 바닥이고 연산기가 논다. 덜 읽어서 빠른 것이 아니라 같은 만큼 읽고 덜 쓰기 때문에 토큰당 비용이 1.7배가 된다.
토큰당 비용이 전문가당 48토큰에서 정확히 평평해지는 것도 이 해석과 맞는다. 그 지점부터는 전문가별 행렬곱이 충분히 커져 효율이 포화한다. 우연히도 그것이 quantum 2048 이다.
하드웨어 문제는 배제했다. Metal 커널을 직접 짜서 재보니 순차 읽기 690 GB/s (공표 819 대비 84%), MoE 의 흩어진 접근을 흉내낸 13.3MB 조각 300개 무작위 읽기 671 GB/s, MXFP4 를 풀어 스케일을 곱하고 누적하는 연산까지 붙여도 672 GB/s 였다. 대역폭도 디퀀트도 병목이 아니다.
2048 이 좋은 값인 것은 2의 거듭제곱이어서가 아니라 모델 구조에서 나오는 수이기 때문이다. 효율을 정하는 것이 전문가당 토큰 수이므로 목표를 정하고 역산하면 된다.
전문가당 토큰 = quantum × n_expert_used / n_expert
역산: quantum = 목표 × n_expert / n_expert_used
= 목표 × 256 / 6 (DeepSeek V4 Flash)
다른 MoE 모델도 같은 식이다. 전문가 128개에 토큰당 8개를 쓰는 모델이라면 전문가당
48토큰은 48 × 128 / 8 = 768 이다.
고를 때 보는 축은 둘이다. 위로 가면 토큰당 비용이 바닥(1.79 ms)에 가까워지지만 2048 에서 이미 바닥 대비 5% 안쪽이라 얻을 것이 거의 없다. 아래로 가면 프리필이 도는 동안 다른 세션의 토큰 간 간격이 짧아진다. 2048 이면 4초에 한 번, 1024 면 2.4초에 한 번 나온다.
표 10. 후보값.
| quantum | 전문가당 토큰 | 토큰당 비용 | 토큰 간 간격 | |
|---|---|---|---|---|
| 128 (기본) | 3 | 3.22 ms | 0.58초 | 너무 작다 |
| 1024 | 24 | 2.18 | 2.40초 | 스트리밍을 사람이 볼 때 |
| 2048 | 48 | 1.88 | 4.02초 | 권장 |
| 4096 이상 | 96 이상 | 1.79 | 7.6초 이상 | 디코딩이 완전히 멎는다 |
후보는 2의 거듭제곱으로 한정한다. 역산식이 내놓는 값(예: 36토큰 → 1536)을 그대로 쓰면 엔진의 그래프 경계에 걸려 손해를 본다. 역산은 어느 2의 거듭제곱이 목표에 가까운지 고르는 데 쓰고 거기서 반올림한다. 이유는 다음 절에 있다.
대가는 하나다. 프리필이 도는 동안 다른 세션의 디코딩이 더 확실히 멈춘다. quantum 이 클수록 양보하는 횟수가 줄기 때문이다.
그림 6. quantum 을 올리면 프리필은 오르고 그동안의 디코딩은 내려간다.
회색 띠가 사람이 멈췄다고 느끼는 영역이다. 정상 디코딩이 세션당 11~12 tok/s 이므로 이 구간은 어느 값에서든 멈춘 상태다.
이것이 실질적인 대가인지는 따져봐야 한다. 정상 속도가 11~12 tok/s 인데 1.77 도 1.08 도 사람 눈에는 멈춘 것이다. 그리고 그 멈춤이 112초에서 49초로 짧아진다. 같은 벽시계 112초 동안 옆 세션 3개가 뽑는 토큰은 약 600개에서 약 2,300개로 오히려 늘어난다. 멈춘 동안 조금 더 나오느냐가 아니라 얼마나 빨리 끝나느냐가 결과를 가른다.
동시 요청이 밀리는 일은 없었다. 다른 세션 3개가 디코딩하는 상태에서 2만 토큰 콜드 프리필 3개를 동시에 던지니 어느 설정에서든 세 요청이 0.5초 안에 함께 끝났다(q128 447.5/447.7/448.0초, q2048 152.8/153.0/153.2초). 프리필 슬롯 라운드로빈이 quantum 과 무관하게 공정성을 지킨다.
엔진은 GPU 실행을 prefill_cap(기본 4096) 경계에 맞춰 자른다.
const uint32_t mod = pos0 % g->prefill_cap;
if (mod != 0) {
const uint32_t to_boundary = g->prefill_cap - mod;
if (to_boundary < local_cap) local_cap = to_boundary;
}
quantum 이 4096 을 정수로 나누지 못하면 경계마다 요청이 두 조각으로 쪼개진다. 그래서 1536 같은 중간값은 손해다. 1024(427)와 2048(509) 사이 로그 보간선보다 7~8% 아래에 앉는다.
다만 여기에는 조건이 있다. 프롬프트를 0 부터 계산할 때만 2의 거듭제곱이 파편을 면한다. 실제 요청은 이전 컨텍스트 끝에서 이어붙이고 그 위치는 임의의 수다. 이 서버의 실제 트래픽 23건 중 4096 의 배수에서 시작한 것은 2건(9%)이었다.
시작이 4096 의 배수 2048, 2048, 2048, 2048, ... 시작 % 4096 = 1539 2048, 509, 1539, 2048, 509, 1539, ... (스스로 정렬되지 않는다)
그 손해가 얼마인지는 따로 쟀다. --prefill-chunk 로 경계를 좁혀 GPU 호출
횟수만 늘리고 나머지를 고정했다.
표 11. 호출 횟수만 바꿨을 때. 컨텍스트 8.2만, 8,478 토큰 구간.
| 경계 | GPU 호출 | 평균 청크 | 속도 | 차이 |
|---|---|---|---|---|
| 4096 (기본) | 5회 | 1,695 | 367.0 t/s | 기준 |
| 3072 | 7회 | 1,211 | 354.5 | -3.4% |
| 2560 | 8회 | 1,059 | 348.3 | -5.1% |
호출을 1.6배로 늘려도 5% 다. 파편은 실재하지만 작다. --prefill-chunk 를
올리면 없앨 수 있으나 컨텍스트 버퍼가 8192 에서 +26 GiB, 16384 에서 +74 GiB
늘어난다. 5% 를 사자고 낼 값이 아니다. 그리고 경계를 올려도 청크 자체는 커지지 않는다.
서버가 한 번에 넘기는 양이 quantum 으로 묶여 있어 경계는 쪼개는 자리만 민다.
실행 스크립트에 한 줄을 더한다.
exec /opt/ds4/ds4-server \ ... --batched-session 6 \ --mixed-prefill-quantum 2048
확인은 서버 시작 로그에서 한다. 같은 인자를 두 번 지정하면 뒤가 이기므로, 값이 안 먹는 것처럼 보이면 먼저 이 줄을 본다.
ds4-server: batched mode enabled resident_sessions=6 prefill_quantum=2048 mixed_prefill_quantum=2048
되돌리려면 그 줄을 지우고 재시작한다. 상태를 남기지 않는 변경이다.
공개 담론과도 대조했다. 이 설정을 다룬 글은 찾지 못했고 기본값 128 의 근거도 못 찾았다.
다만 주변 사실이 결과를 받쳐준다. 같은 M3 Ultra 512GB 에서 같은 모델을 돌린 공개 벤치가
2k 입력에서 560 tok/s 로, 이 실험의 상한 559 와 사실상 같다. llama.cpp 진영은
ubatch 2048 이 프리필이 가장 빠르다는 쪽으로 의견이 모여 있고, Sarathi 논문은
dense 모델에서 chunk 128 이 프리필을 2배 이상 느리게 하고 256~512 에서는 손실이 20%와
10% 이내라고 보고했다. 다른 모델 다른 GPU 인데 같은 자리에서 비슷한 폭으로 무너진다.
반대 진영도 있다. vLLM 의 chunked prefill 처럼 일부러 잘게 쪼개 디코딩 중인 세션을 살리는 설계다. 기본값 128 은 그 진영의 값이다. 다만 그 문헌이 실제로 쓰는 청크는 8K 근처이지 128 이 아니다.
표 12. 주장별 근거 상태.
| 주장 | 근거 | 상태 |
|---|---|---|
| 기본값 128 은 상한의 39% | 8개 값 3회 반복, 편차 0.2% 미만 | 확인 |
| 상한은 약 559 tok/s | 교대 없는 측정 + 공개 벤치 560 일치 | 확인 |
| 디코딩이 없으면 적용 안 됨 | 같은 구간 4회, 279.3 대 279.4/279.5/279.7 | 확인 |
| 디코딩이 하나만 있어도 적용됨 | 거의 꺼져가는 디코딩 1개에서 230 대 521 | 확인 |
| 실제 에이전트 작업 2.20배 | 2회 반복 순서 반전, 구간 합집합 기준 | 확인 |
| end-to-end 약 1.75배 | 작업량 환산값, 두 작업이 차례로 붙는다고 가정 | 추정 |
| 원인은 MoE 전문가당 토큰 수 | 대역폭 실측으로 대안 가설 배제 + 48토큰에서 포화 | 확인 |
| 파편 비용 약 5% | 호출 1.6배에 348.3 대 367.0 | 확인 |
| 2048 이 적절한 지점 | 프리필 91% + 디코딩 대가 | 판단 |
| 동시 실행이 배율을 유지시킨다 | 정황 일치, 분리 실험 없음 | 가설 |
합성 프로브: 디코딩 세션 3개가 계속 글을 쓰는 상태에서 2만 토큰짜리 새 프롬프트를 하나 던진다. 맨 앞에 고유한 문자열을 넣어 디스크 KV 캐시가 붙지 못하게 했다. 조건마다 서버를 재시작하며(7~9초, 모델이 페이지 캐시에 남아 다시 읽지 않는다) 인자만 바꿨다.
실제 에이전트: 읽기와 bash 도구를 가진 코딩 에이전트 셋을 15초 간격으로 띄운다. 같은 순간에 시작하면 전부 프리필만 하다가 전부 디코딩으로 넘어가서 정작 재려는 상황이 안 생긴다. 서버 로그에 구간 마커를 직접 써 넣고 그 사이의 요청을 센다.
검증: 조건마다 서버 시작 로그에서 실제 적용된 값을 읽어 요청한 값과 다르면 무효로 표시한다. 이 검증 없이 돌렸다가, 실행 스크립트 안에서 같은 인자가 두 번 지정되는 바람에 설정이 조용히 무시된 채로 측정된 적이 있다.
작업량 동일성: 합성 실험은 어시스턴트 턴을 고정 문자열로 넣고 출력량을
max_tokens 로 강제해 글자 단위까지 맞췄고, 조건마다 빈 디스크 KV 디렉터리를
써서 앞 조건의 체크포인트를 물려받지 않게 했다. 실제 에이전트에서는 그럴 수 없으므로
실효 처리량으로 비교한다.