ds4-server 기본값 하나가 MoE 프리필을 절반으로 묶고 있었다

DeepSeek V4 Flash 를 로컬에서 서빙하는데 프롬프트를 다시 계산하는 데 이상하게 오래 걸렸다. 원인은 실행 옵션 하나, --mixed-prefill-quantum 의 기본값 128 이었다. 2048 로 올리면 프리필이 2.2배 빨라진다. 합성 프로브와 실제 코딩 에이전트 양쪽에서 확인했다.

다만 조건이 붙는다. 이 설정은 여러 요청이 동시에 돌 때만 적용된다. 이 문서는 그 조건, 효과의 크기, 원인, 값 고르는 법, 잃는 것을 순서대로 적는다. 측정은 Apple M3 Ultra 512GB 한 대, 엔진은 antirez/ds4 의 ds4-server 다.

한 줄 요약

1. 언제 켜지는 설정인가

소스를 보면 이 값은 조건부로만 쓰인다.

bool generation_active = s->active_generations > 0;   /* 서버 전역 카운터 */
return generation_active ? s->mixed_prefill_quantum : 2048;

프리필이 도는 그 순간 토큰을 뽑고 있는 요청이 하나라도 있어야 설정값이 쓰인다. 없으면 하드코딩된 2048 이다. 그래서 요청을 하나씩 보내고 답을 기다리는 방식이면 프리필과 디코딩이 시간축에서 겹칠 일이 없어 이 설정이 무시된다.

표 1. 디코딩 부하 유무에 따른 같은 구간(5,964 토큰) 측정.

조건quantum 128quantum 2048판정
디코딩 부하 없음279.3 tok/s279.4 / 279.5 / 279.7차이 없음
다른 세션 3개가 디코딩 중220.3508.92.31배
거의 끝나가는 디코딩 하나230.3520.62.26배

첫 줄이 결정적이다. 네 번을 재서 소수점 첫째 자리까지 같다. 셋째 줄은 문턱을 알려준다. 프로브가 도는 동안 디코딩 로그가 19줄밖에 안 찍힌, 거의 끝나가는 디코딩 하나만 있어도 3세션일 때와 결과가 같다. 문턱은 3세션이 아니라 1세션이고, 그 하나가 얼마나 활발한지는 상관없다.

실제로 얼마나 동시에 도나

서버 메트릭을 15초 간격으로 찍어 프리필이 돌던 시간 중 다른 요청이 디코딩 중이던 비율을 셌다.

표 2. 사용 방식별 동시 실행 비율.

사용 방식동시 실행표본
에이전트 하나로 순서대로1.4%151분, 218칸
에이전트 셋을 동시에20 ~ 37%4회 실행

터미널 창을 두 개 이상 띄우거나 서브에이전트를 병렬로 돌리면 켜진다. 사람 수가 아니라 동시에 진행 중인 요청이 서로 다른 단계에 있느냐의 문제다.

2. 얼마나 빨라지는가

그림 1. 나머지 조건을 고정하고 quantum 만 바꾼 결과. 2048 에서 이미 천장의 91% 에 닿는다.

점선은 교대를 아예 하지 않을 때의 상한 559 tok/s. 가로축은 로그 눈금. 다른 세션 3개가 디코딩하는 상태에서 2만 토큰 콜드 프리필, 3회 반복 편차 0.2% 미만.

표 3. 합성 프로브 전체 결과.

quantum프리필상한 대비
128 (기본)220.3 tok/s39%
512332.960%
1024426.076%
2048 (권장)508.291%
4096542.397%
8192 이상552 ~ 55999 ~ 100%

진짜 에이전트로 다시 재면

위는 프롬프트를 직접 만들어 던지고 부하는 더미 세션으로 흉내낸 것이다. 실제 작업에서도 성립하는지 따로 확인했다. 읽기와 bash 도구를 가진 코딩 에이전트 셋이 실제 Rust 코드베이스(68파일 2.8만 줄)에서 각자 다른 분석 과제를 수행하게 했다. 컨텍스트는 알아서 17.8만 토큰까지 자랐고 도구 호출은 44~82회였다.

그림 2. 실제 에이전트 작업의 프리필 속도. 순서를 뒤집어도 두 무리가 서로 넘나들지 않는다.

분모는 프리필이 실제로 돌던 시간(요청 구간의 합집합)이다. 작업량이 조건마다 다르므로 총 소요 시간 대신 이 값으로 비교한다.

표 4. 실제 에이전트 작업 4회.

구간요청신규 프리필계산 시간프리필 속도도구 호출
1회차 q204847139,599485초287.744
2회차 q204848190,262722초263.545
1회차 q12855214,2701,735초123.552
2회차 q12887252,2632,025초124.682

합치면 273.2 대 124.1, 2.20배다. 합성 프로브의 2.31배와 같은 자리다.

계산 시간의 분모는 요청 구간의 합집합이다. 에이전트가 셋이라 프리필이 동시에 도는 구간이 생기는데, 각 요청의 소요를 단순히 더하면 그 구간을 두 번 센다. q128 쪽이 더 많이 겹쳐서(최대 1.14배 중복) 단순 합산으로는 느린 쪽 속도가 실제보다 더 낮게 나온다.

작업량도 조건마다 다르다. 도구 호출이 44회에서 82회까지 벌어지고 신규 프리필도 14만에서 25만까지 흩어진다. 에이전트가 매번 다른 경로를 타기 때문이고 quantum 탓이 아니다. 그래서 총 소요 시간을 그대로 비교하지 않는다.

느려지는 것은 프리필뿐이다

이 설정이 무엇을 건드리고 무엇을 안 건드리는지는 두 작업을 따로 재면 분명해진다. 각자가 실제로 돌던 시간으로 나눈 값이다.

표 5. 작업별 속도와 벽시계 점유.

프리필 속도프리필 점유디코딩 속도디코딩 점유
quantum 2048273.2 t/s52%29.2 t/s59%
quantum 128124.190%21.648%
배율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 2048604초685초1,289초
quantum 1281,330초926초2,256초
배율2.20배1.35배1.75배

두 작업이 차례로 붙는다고 가정한 값이다. 실제로는 20~37% 구간에서 동시에 돌므로 실제 소요는 이보다 짧다. 어느 실행 쌍도 프리필과 디코딩 양쪽 작업량을 동시에 맞추지 못해 이보다 정확하게 좁히지는 못했다.

이 설정의 값어치는 작업에서 프리필이 차지하는 비중에 비례한다. 도구 결과를 계속 밀어 넣는 코딩 에이전트처럼 읽는 양이 쓰는 양보다 훨씬 많으면 크고, 짧은 질문에 긴 답을 쓰는 작업이라면 거의 없다.

컨텍스트가 길어지면

그림 4. 실제 작업의 요청 하나하나. 가로축은 그 요청이 시작한 컨텍스트 위치.

점 크기는 신규 토큰 수, 굵은 선은 구간 중앙값. 두 무리가 전 구간에서 갈라져 있고 둘 다 오른쪽으로 갈수록 내려간다.

표 7. 컨텍스트 위치별 배율.

컨텍스트q2048q128배율
0 ~ 3만296 tok/s1382.14배
3만 ~ 6만2821192.37배
6만 ~ 10만2541202.12배
10만 이상2251002.25배

절대 속도는 컨텍스트가 길어질수록 떨어지지만 배율은 2.1~2.4 로 거의 변하지 않는다.

단, 요청이 동시에 돌지 않는 조건에서는 다르다. 프로브 하나만 계산하고 나머지가 디코딩만 하는 합성 조건에서는 컨텍스트 2만 2.31배, 17.7만 1.66배로 배율이 줄었다. 실제 에이전트 환경에서는 프리필끼리도 부딪히고, 그 경합이 작은 quantum 을 더 크게 깎아내려 줄어드는 몫을 메우는 것으로 보인다. 그것만 분리해 확인하지는 못했다.

3. 왜 그런가

원인은 이 모델이 MoE 라는 사실에 있다. 먼저 디코딩이 프리필의 몫을 빼앗는 것인지부터 확인했다. 각 자원을 단독 최대치로 나눠 시간 점유를 계산하면 된다.

표 8. 디코딩이 쓴 시간을 빼고 나면 프리필이 얼마나 나와야 하는가.

quantum디코딩 점유기대치실측차이
20489.3%507509없음
12815.2%474220절반 이하

2048 은 예측과 실측이 일치한다. 교과서적인 시분할이다. 128 은 디코딩이 쓴 시간을 다 빼줘도 474 가 나와야 하는데 220 이다. 디코딩이 뺏은 것이 아니라 프리필이 자기 차례에 제 속도를 못 낸다.

전문가당 토큰 수

DeepSeek V4 Flash 는 전문가 256개 중 토큰마다 6개를 쓴다. 한 청크가 깨우는 고유 전문가의 기대값은 256 × (1 - (1 - 6/256)^Q) 다.

그림 5. 디코딩 스텝을 뺀 순수 프리필의 토큰당 비용. 전문가당 48토큰에서 평평해진다.

전문가당 토큰 = Q × 6 / 256. 가로축은 로그 눈금.

표 9. 청크 크기별 전문가 사용 양상.

quantum고유 전문가전문가당 토큰토큰당 비용2048 대비
128243.7 / 256 (95%)3.03.22 ms1.71배
512256122.671.42배
1024256242.181.16배
2048256481.881.00
4096 이상25696 이상1.79 ~ 1.800.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 였다. 대역폭도 디퀀트도 병목이 아니다.

4. 어떤 값을 쓸 것인가

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 (기본)33.22 ms0.58초너무 작다
1024242.182.40초스트리밍을 사람이 볼 때
2048481.884.02초권장
4096 이상96 이상1.797.6초 이상디코딩이 완전히 멎는다

후보는 2의 거듭제곱으로 한정한다. 역산식이 내놓는 값(예: 36토큰 → 1536)을 그대로 쓰면 엔진의 그래프 경계에 걸려 손해를 본다. 역산은 어느 2의 거듭제곱이 목표에 가까운지 고르는 데 쓰고 거기서 반올림한다. 이유는 다음 절에 있다.

5. 무엇을 잃는가

대가는 하나다. 프리필이 도는 동안 다른 세션의 디코딩이 더 확실히 멈춘다. 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 과 무관하게 공정성을 지킨다.

4096 경계에 걸리는 파편

엔진은 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,695367.0 t/s기준
30727회1,211354.5-3.4%
25608회1,059348.3-5.1%

호출을 1.6배로 늘려도 5% 다. 파편은 실재하지만 작다. --prefill-chunk 를 올리면 없앨 수 있으나 컨텍스트 버퍼가 8192 에서 +26 GiB, 16384 에서 +74 GiB 늘어난다. 5% 를 사자고 낼 값이 아니다. 그리고 경계를 올려도 청크 자체는 커지지 않는다. 서버가 한 번에 넘기는 양이 quantum 으로 묶여 있어 경계는 쪼개는 자리만 민다.

6. 적용

실행 스크립트에 한 줄을 더한다.

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

되돌리려면 그 줄을 지우고 재시작한다. 상태를 남기지 않는 변경이다.

적용 전에 따져볼 것

7. 근거

공개 담론과도 대조했다. 이 설정을 다룬 글은 찾지 못했고 기본값 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 디렉터리를 써서 앞 조건의 체크포인트를 물려받지 않게 했다. 실제 에이전트에서는 그럴 수 없으므로 실효 처리량으로 비교한다.


측정은 Apple M3 Ultra 512GB 한 대에서 했다. 엔진은 antirez/ds4 의 ds4-server, 모델은 DeepSeek V4 Flash MXFP4 다. 영어판은 moe-prefill-quantum-en.