같은 요청을 speculative decoding을 끈 상태와 켠 상태에서 각각 10회씩 보내 응답 시간을 비교했다. draft는 별도 모델을 쓰지 않는 N-gram 방식이다.
평균 응답 시간이 2.392초에서 1.250초로 줄었다. 1.91배 빠르다. 같은 모델, 같은 요청, 같은 생성 조건이며 출력 내용도 동일하다.
이 결과가 우연이 아니라는 근거는 지표에 있다. draft 400토큰 중 340개가 채택돼 채택률이 85%였고, 이 채택률에서 역산한 forward 횟수 감소가 2.0배로 측정된 1.91배와 맞는다. 채택 분포는 5개 전부 아니면 0개로 갈리는 양극단이었다. 같은 문장을 그대로 반복시키는 요청이라 N-gram이 통째로 맞히거나 통째로 틀린다.
대가도 로그에 남았다. speculative를 켜면 async scheduling이 꺼지고, model runner가 V2에서 V1으로 내려가며, max_num_scheduled_tokens가 2048로 고정된다. 셋 다 스케줄러 층의 기능이고 동시 요청이 많을 때 드러나는 값인데, 이번 측정은 동시 요청 1이라 그 대가를 재지 않았다.
범위도 좁다. 반복이 많은 prompt 하나만 썼고, 반복이 없는 prompt와 대조하지 않았다. TTFT도 기록하지 않았다. 이 1.91배는 “이 워크로드에서”라는 조건을 떼면 성립하지 않는다.
그래서 동시성 4와 16에서 같은 비교를 다시 했다. 결과는 배속이 조금씩 깎이는 쪽이다.
C=1 1.91배C=4 1.85배C=16 1.76배C=32 1.77배
대가가 실제로 있었지만 작았고, 계속 커지지도 않았다.C=4에서 C=16 사이에 4.7% 깎인 뒤 C=32에서는 더 내려가지 않았다. 대기 요청이 16으로 쌓여 --max-num-seqs가 실제 제약이 된 구간인데도 그렇다. 상한이 걸리면 두 조건이 같은 크기의 배치를 처리하게 되므로 비율이 그 자리에 묶인다.
원인도 갈렸다. 채택률이 네 동시성에서 85.0%로 소수점까지 같으므로 draft 품질은 그대로다. 남는 설명은 speculative를 켤 때 함께 꺼지는 스케줄러 층의 기능들이며, 그 안에서 어느 쪽이 얼마나 기여했는지는 나누지 못했다.
운영 지점도 나왔다. 동시성 16이 이 구성의 포화점이고, 그 위로는 처리량이 늘지 않고 지연만 붙는다. on으로 초당 11.1건이 이 GPU 한 장의 처리 능력이다.
1. 고정 요청 파일 준비
off와 on이 같은 입력을 써야 비교가 성립한다. 파일로 고정했다.
mkdir -p ~/k3s-gpu-cluster/request
{ "model": "qwen2.5-3b", "messages": [ {"role": "user", "content": "다음 문장을 그대로 세 번 반복한 뒤, 각 반복이 동일한지 확인해줘: Kubernetes에서 GPU를 사용하려면 RuntimeClass와 Device Plugin이 필요하다."} ], "max_tokens": 256, "temperature": 0}
설정
값
이유
temperature
0
출력을 고정해 두 조건의 차이가 속도에만 나타나게 한다
max_tokens
256
상한. 실제 생성은 이보다 짧다
prompt 성격
같은 문장 3회 반복
N-gram이 문맥에서 draft를 찾을 수 있게 한다
prompt를 이렇게 고른 것이 이 실험의 결과를 크게 좌우한다. N-gram은 이미 나온 텍스트에서 같은 패턴을 찾아 그 뒤를 베끼는 방식이라, 반복이 없으면 draft 자체가 나오지 않는다. 유리한 조건을 일부러 만든 것이고, 그래서 결과를 일반화할 수 없다.
10회가 0.089초 폭 안에 들어온다. 5장의 replica 비교에서 최대와 최소가 5.29배까지 벌어졌던 것과 대조된다. 그때는 16건을 백그라운드로 동시에 띄웠고 여기서는 순차로 한 건씩 보냈다. 동시성이 없으면 측정이 이만큼 안정된다는 뜻이며, 이 폭이 좁아야 뒤에서 두 조건의 차이를 노이즈와 구분할 수 있다.
2-2. 응답 내용 확인
수치만 보면 오류 응답도 정상으로 보인다. 본문을 열어 실제 생성 결과인지 확인했다.
jq -r '.choices[0].message.content' ~/k3s-gpu-cluster/request/spec-off-1.json | head -5
deployment "vllm-qwen" successfully rolled out(APIServer pid=1) INFO 08-04 05:01:48 [api_utils.py:273] non-default args: {'model_tag': 'Qwen/Qwen2.5-3B-Instruct-AWQ', 'model': 'Qwen/Qwen2.5-3B-Instruct-AWQ', 'max_model_len': 4096, 'enforce_eager': True, 'served_model_name': ['qwen2.5-3b'], 'gpu_memory_utilization': 0.22, 'max_num_seqs': 16, 'spec_method': 'ngram', 'spec_tokens': 5}(APIServer pid=1) WARNING 08-04 05:02:13 [vllm.py:1077] Async scheduling not supported with ngram-based speculative decoding and will be disabled.(APIServer pid=1) WARNING 08-04 05:02:13 [vllm.py:1718] max_num_scheduled_tokens is set to 2048 based on the speculative decoding settings. This may lead to suboptimal performance. Consider increasing max_num_batched_tokens to accommodate the additional draft token slots, or decrease num_speculative_tokens or max_num_seqs.(APIServer pid=1) WARNING 08-04 05:02:13 [vllm.py:584] Model Runner V2 does not yet support ngram/ngram_gpu speculative decoding; using the V1 model runner instead.(EngineCore pid=127) INFO 08-04 05:02:31 [core.py:116] Initializing a V1 LLM engine (v0.26.0) with config: model='Qwen/Qwen2.5-3B-Instruct-AWQ', speculative_config=SpeculativeConfig(method='ngram', model=None, num_spec_tokens=5), ...(EngineCore pid=127) WARNING 08-04 05:02:32 [__init__.py:204] min_p and logit_bias parameters won't work with speculative decoding.(EngineCore pid=127) INFO 08-04 05:02:36 [gpu_model_runner.py:5274] Loading drafter model...
근거
값
해석
non-default args
spec_method: 'ngram', spec_tokens: 5
CLI 인자가 파싱됐다
speculative_config
method='ngram', model=None, num_spec_tokens=5
엔진이 설정 객체를 실제로 구성했다
model=None
별도 draft 모델 없음
N-gram은 모델을 쓰지 않는다
두 줄을 모두 봐야 한다. non-default args는 인자가 들어왔다는 뜻이고, speculative_config는 엔진이 그 값으로 설정을 만들었다는 뜻이다. 앞줄만 있고 뒷줄이 없으면 인자는 받았지만 무시된 경우다.
Loading drafter model...이라는 줄이 나오지만 model=None이므로 실제로 적재되는 모델은 없다. 로그 문구가 draft model 방식과 공유될 뿐이다.
--spec-tokens가 설정 객체의 num_spec_tokens로 들어간 것도 여기서 확인된다.
3-3. 함께 꺼진 것들
같은 로그에 경고 세 줄이 붙었다. 이것이 speculative를 켜는 대가다.
경고
내용
성격
Async scheduling disabled
ngram과 함께 쓸 수 없어 비활성화
스케줄러
Model Runner V2 → V1
V2가 ngram을 아직 지원하지 않음
실행 경로
max_num_scheduled_tokens = 2048
draft token 자리를 확보하느라 축소
배치 상한
min_p, logit_bias 무효
speculative와 함께 동작하지 않음
샘플링 옵션
앞의 셋은 모두 동시 요청이 많을 때 드러나는 값이다. async scheduling과 배치 상한은 요청이 한 건뿐이면 차이를 만들지 않는다. 이번 측정은 동시 요청 1이므로 이 대가를 재지 않았고, 뒤에 나오는 1.91배에는 이 손실이 반영되어 있지 않다.
4장에서 대기 요청이 --max-num-seqs=16으로 포화하는 구간을 확인한 바 있다. 그 구간에서 배치 상한이 2048로 묶이면 어떻게 되는지는 별도 측정이 필요하다.
min_p와 logit_bias는 이번 요청이 쓰지 않는 옵션이라 영향이 없었다. 다만 이 둘을 쓰는 서비스라면 speculative와 양립하지 않는다는 제약이 된다.
4. on 조건 측정
4-1. 10회 측정
for i in $(seq 1 10); do curl -s http://127.0.0.1:8000/v1/chat/completions \ -H "Content-Type: application/json" \ -d @"$HOME/k3s-gpu-cluster/request/spec-request.json" \ -w " total=%{time_total}\n" \ -o "$HOME/k3s-gpu-cluster/request/spec-on-$i.json"done
이 확인이 이번 측정에서 가장 중요한 통제다. 이 줄이 없으면 빨라진 이유가 speculative 때문인지, 더 짧게 생성했기 때문인지 구분할 수 없다. 출력이 같으므로 생성 토큰 수도 68로 같고, 시간 차이는 온전히 토큰당 비용 차이다.
다만 확인한 것은 1회차 본문뿐이다. on 조건의 usage를 따로 조회하지는 않았다.
4-3. 두 조건 비교
지표
off (10회)
on (2~10회)
차이
평균
2.392초
1.250초
1.142초 단축
중앙값
2.387초
1.251초
1.136초 단축
최소
2.346초
1.239초
최대
2.435초
1.261초
토큰당
35.2ms
18.4ms
환산
값
평균 기준 배속
1.914배
중앙값 기준 배속
1.908배
시간 감소율
47.7%
두 구성의 범위가 겹치지 않는다. off의 최소값 2.346초가 on의 최대값 1.261초보다 크므로, 어느 회차를 뽑아 비교해도 순서가 뒤집히지 않는다. 5장의 replica 비교에서는 두 구성의 범위가 크게 겹쳐 평균 차이를 확정하지 못했는데, 여기서는 그 문제가 없다.
# HELP vllm:spec_decode_num_drafts_total Number of spec decoding drafts.# TYPE vllm:spec_decode_num_drafts_total countervllm:spec_decode_num_drafts_total{engine="0",model_name="qwen2.5-3b"} 80.0# HELP vllm:spec_decode_num_draft_tokens_total Number of draft tokens.# TYPE vllm:spec_decode_num_draft_tokens_total countervllm:spec_decode_num_draft_tokens_total{engine="0",model_name="qwen2.5-3b"} 400.0# HELP vllm:spec_decode_num_accepted_tokens_total Number of accepted tokens.# TYPE vllm:spec_decode_num_accepted_tokens_total countervllm:spec_decode_num_accepted_tokens_total{engine="0",model_name="qwen2.5-3b"} 340.0# HELP vllm:spec_decode_num_accepted_tokens_per_pos_total Accepted tokens per draft position.# TYPE vllm:spec_decode_num_accepted_tokens_per_pos_total countervllm:spec_decode_num_accepted_tokens_per_pos_total{engine="0",model_name="qwen2.5-3b",position="0"} 70.0vllm:spec_decode_num_accepted_tokens_per_pos_total{engine="0",model_name="qwen2.5-3b",position="1"} 70.0vllm:spec_decode_num_accepted_tokens_per_pos_total{engine="0",model_name="qwen2.5-3b",position="2"} 70.0vllm:spec_decode_num_accepted_tokens_per_pos_total{engine="0",model_name="qwen2.5-3b",position="3"} 70.0vllm:spec_decode_num_accepted_tokens_per_pos_total{engine="0",model_name="qwen2.5-3b",position="4"} 60.0
_created gauge는 counter의 생성 시각이라 판독에 쓰지 않았다.
key
값
해석
draft 횟수
80
10회 요청에서 요청당 8회
draft 토큰
400
80 × 5. --spec-tokens=5가 그대로 반영됐다
채택 토큰
340
채택률
85.0%
340 ÷ 400
draft당 평균 채택
4.25개
340 ÷ 80
카운터는 Pod가 새로 뜬 뒤 쌓인 값이다. patch로 재기동됐고 그 뒤 보낸 것이 이 10회뿐이므로 이 수치는 on 조건만 담고 있다.
5-2. 채택 분포
position별 카운터가 누적 방식이라는 점을 이용하면 분포를 복원할 수 있다. 채택은 앞에서부터 연속으로만 일어나므로, position 3이 채택된 draft는 반드시 0, 1, 2도 채택됐다.
position
채택 횟수
해당 자리까지 맞힌 draft 비율
0
70
87.5%
1
70
87.5%
2
70
87.5%
3
70
87.5%
4
60
75.0%
인접한 값의 차이가 곧 “정확히 그 개수만큼 채택된 draft 수”다.
채택 개수
draft 수
계산
0개
10
80 − 70
1~3개
0
position 0~3 카운터가 모두 같다
4개
10
70 − 60
5개
60
position 4 카운터
합계
80
채택 토큰 340과 일치
분포가 양극단으로 갈린다.5개 전부 맞히거나 0개도 못 맞히거나이며, 중간이 거의 없다.
이 모양은 prompt 구조에서 나온다. 같은 문장을 그대로 반복시키는 요청이라 반복 구간에서는 다음 토큰들이 문맥에 통째로 들어 있어 5개가 한꺼번에 맞는다. 반면 반복이 끝나고 확인 결과: 같은 새 문장이 시작되는 자리에서는 베낄 대상이 없어 첫 토큰부터 틀린다.
부분적으로만 맞는 상황이 드물다는 것은, 이 워크로드에서 N-gram이 문맥을 이해해 예측하는 것이 아니라 사본을 찾아 복사하고 있음을 보여준다. 사본이 있으면 전부 맞고 없으면 전부 틀린다.
5-3. forward 횟수로 검산
측정된 1.91배가 채택률로 설명되는지 확인한다. 검증 forward는 채택된 토큰 외에 다음 자리의 정답 한 개를 함께 알려주므로, draft 스텝 한 번이 채택 수 + 1개를 만든다.
항목
계산
값
요청당 draft 스텝
80 ÷ 10
8회
요청당 채택 토큰
340 ÷ 10
34개
draft 스텝이 만든 토큰
34 + 8
42개
나머지 토큰
68 − 42
26개
일반 스텝
토큰 하나당 한 번
26회
on의 decode forward
8 + 26
34회
off의 decode forward
토큰 하나당 한 번
68회
예측 배속
68 ÷ 34
2.00배
예측 2.00배에 대해 측정값이 1.91배다. 차이 0.09는 두 방향으로 설명된다. 응답 시간에는 prefill과 네트워크 왕복이 포함되는데 이 구간은 두 조건이 같아 배속을 희석한다. 그리고 draft를 찾는 탐색 비용이 on 쪽에만 추가된다.
이 표는 복원이지 측정이 아니다
draft 스텝 수와 채택 토큰 수는 지표에서 직접 읽은 값이지만, 일반 스텝 수와 forward 총횟수는 “검증 forward가 토큰 하나를 더 만든다”는 동작을 전제로 계산한 값이다. forward 횟수를 직접 세는 지표는 조회하지 않았다.
전제를 감안해도 예측과 측정이 5% 안에서 맞는다. 응답 시간 단축이 draft 채택에서 나왔다는 설명이 수치로 뒷받침된다.
Warning: resource deployments/vllm-qwen was previously managed with 'kubectl apply'. Rolling back will not update the kubectl.kubernetes.io/last-applied-configuration annotation, which may cause unexpected behavior on future 'kubectl apply' operations. Consider using 'kubectl apply' with your previous configuration file instead.deployment.apps/vllm-qwen rolled backWaiting for deployment "vllm-qwen" rollout to finish: 0 out of 1 new replicas have been updated...deployment "vllm-qwen" successfully rolled out
last-applied-configuration 경고가 5장과 동일하게 나왔다. 선언형 apply로 관리되는 리소스를 명령형 rollout undo로 되돌릴 때 생기며, 이후 같은 manifest로 apply하면 되돌린 내용이 다시 덮일 수 있다. 이번에는 speculative 설정을 없애는 것이 목적이라 manifest 쪽이 이미 baseline과 같아 문제가 되지 않았다.
7. 동시성을 올린 off 기준선
앞의 측정은 모두 동시 요청 1이었다. 그런데 speculative를 켜면 3-3에서 본 대로 async scheduling이 꺼지고 배치 상한이 2048로 축소된다. 셋 다 여러 요청을 함께 처리할 때 쓰이는 기능이라, 요청 하나만 흘리는 조건에서는 그 대가가 드러날 수 없다.
부하 구간에서 다시 비교하기로 하고, 먼저 speculative를 끈 상태의 기준선을 동시성 4, 16, 32에서 잡았다. 32는 --max-num-seqs=16을 넘기는 값이라 대기가 쌓이는 구간까지 포함된다.
7-1. 측정 방식을 바꾼 이유
5장에서 curl을 백그라운드로 띄워 개별 응답 시간을 모았다가, 그 퍼짐이 서버 때문인지 프로세스가 뜨는 순서 때문인지 구분되지 않아 결과로 쓰지 못했다. 여기서는 개별 시간을 아예 쓰지 않는다.
지표
구하는 법
클라이언트 영향
처리량
64 ÷ 전체 소요
전체를 한 번만 재므로 없음
평균 지연
histogram Δsum ÷ Δcount
서버가 기록한 값이라 없음
동시 실행 수는 xargs -P로 고정하고 time으로 파이프라인 전체를 잰다. 회차당 요청은 64건으로 맞췄다.
Δcount는 warm-up 한 건과 부하 64건을 더한 65가 정상값이다. 이 값이 어긋나면 회차 사이에 다른 요청이 섞였다는 뜻이므로 판독 전에 확인해야 한다. 여섯 회차가 모두 맞았다.
회차마다 부하 직전과 직후를 따로 찍어 그 차이를 썼다. 앞 회차의 after를 다음 회차의 before로 대신하지 않았으므로, 회차 사이에 다른 요청이 들어갔다면 Δcount에 그대로 드러난다.
동시성을 바꿀 때 Deployment를 건드리지 않는다는 점도 중요하다. -P 값은 클라이언트 쪽이므로 여섯 회차가 같은 엔진 프로세스에서 이어진다. 세 동시성의 비교에 재기동이 끼어들지 않는다.
7-3. 처리량과 지연
조건
회차
전체 소요
처리량
평균 지연
C=4
1
39.543초
1.619 req/s
2.453초
C=4
2
39.843초
1.606 req/s
2.470초
C=16
1
10.188초
6.282 req/s
2.499초
C=16
2
10.043초
6.373 req/s
2.464초
C=32
1
9.951초
6.432 req/s
4.276초
C=32
2
9.891초
6.471 req/s
4.246초
파생
C=4
C=16
C=32
처리량
1.634 req/s
6.327 req/s
6.451 req/s
평균 지연
2.429초
2.481초
4.261초
동시성 4에서 16으로 올렸을 때는 처리량이 3.87배 늘고 지연은 2.1%만 올랐다. 거의 선형 확장이다.
그런데 16에서 32로 올리면 처리량이 멈춘다.6.327에서 6.451로 2% 안쪽이고, 대신 지연이 2.481초에서 4.261초로 1.72배가 된다. 늘어난 시간은 전부 대기 큐에서 보낸 시간이다.
--max-num-seqs=16이 상한이므로 동시 요청을 그 위로 보내도 엔진은 16건만 처리한다. 나머지는 큐에서 기다린다. 이 구간의 지표는 9절에서 따로 본다.
같은 조건에서 두 번씩 잰 값이 0.5~1.4% 안에 들어오므로, 위 차이는 회차 간 흔들림으로 설명되지 않는다.
벽시계로도 교차 검증된다. 동시성을 유지한 채 64건을 흘렸으므로 전체 소요 × 동시성 ÷ 64가 평균 지연의 근사가 된다.
조건
벽시계 역산
histogram
차이
C=4 1회
2.443초
2.424초
0.8%
C=4 2회
2.454초
2.435초
0.8%
C=16 1회
2.547초
2.499초
1.9%
C=16 2회
2.511초
2.464초
1.9%
C=32 1회
4.976초
4.276초
16%
C=32 2회
4.946초
4.246초
16%
C=4와 C=16은 두 경로가 2% 안에서 일치한다. 벽시계 쪽이 조금 높은 것은 부하 시작과 종료 구간에서 동시 실행 수가 목표치에 못 미치기 때문이며, 예상되는 방향이다.
C=32만 16%로 크게 벌어진다. 요청 64건에 동시성 32면 두 번의 파도로 끝나 목표 동시성이 유지되는 구간이 짧기 때문이다. 벽시계 역산은 동시성이 계속 유지된다고 가정하므로 이 조건에서는 과대평가된다. C=32의 지연은 서버가 직접 기록한 histogram 값을 쓴다.
2초 간격 샘플 중 대표값이다. 부하 종료 직전 구간에서 사용률과 running이 떨어지는 샘플이 섞이는데 요청이 빠지는 구간이다.
항목
C=4
C=16
C=32
GPU 사용률
27~31%
27~30%
27~30%
GPU memory
5,090 MiB
5,092 MiB
5,092 MiB
num_requests_running
4
16
16
num_requests_waiting
0
0
16
세 가지가 함께 읽힌다.
첫째, GPU 사용률이 세 조건에서 모두 같다. 동시성을 여덟 배 올려 처리량이 네 배 가까이 늘었는데도 29% 근처에 머문다. 큐가 꽉 찬 C=32에서도 31%를 넘지 않는다. 연산이 남는데 요청이 밀려 있다는 뜻이며, 4장에서 동시성 32일 때 31.8%였던 것과도 이어진다. 이 워크로드에서 GPU 연산은 한 번도 제약이 된 적이 없다.
둘째, num_requests_waiting이 C=32에서 처음으로 16이 된다. --max-num-seqs=16이 상한이라 C=16까지는 상한에 정확히 닿을 뿐 넘지 않았고, 32를 보내자 절반이 큐로 밀렸다. 4장에서 동시성 32로 올렸을 때 대기가 16으로 포화했던 것이 여기서 재현된다.
셋째, GPU memory가 동시성과 무관하게 5,09x MiB로 고정이다. KV cache가 예산만큼 미리 잡히기 때문이며, 23,034 × 0.22 = 5,067 MiB라는 계산과 맞는다. 부하가 늘어도 메모리가 늘지 않는다는 뜻이라 메모리는 확장 판단의 신호가 되지 못한다.
7-5. 이 기준선이 뜻하는 것
off만 놓고 봐도 이 구성의 한계가 드러난다.
C=4 → C=16 처리량 3.87배 지연 +2.1% 대기 0C=16 → C=32 처리량 +2% 지연 1.72배 대기 16
16이 포화점이다. 그 아래에서는 동시성을 올린 만큼 처리량이 따라오지만, 넘어서면 처리량은 멈추고 지연만 붙는다. 상한을 결정하는 것은 GPU가 아니라 --max-num-seqs이며, 그 근거가 C=32에서도 GPU가 31%를 넘지 않는다는 관측이다.
speculative를 켜면 이 그림이 어떻게 바뀌는지가 다음 두 절이다. 함께 꺼지는 기능들이 스케줄러 층에 있으므로, 대가가 있다면 여유가 없는 C=32에서 가장 크게 드러날 것으로 예상했다.
8. 동시성 구간의 on 비교
7절과 같은 조건에서 speculative만 켜고 다시 측정했다.
8-1. 네 회차의 카운터
회차
Δcount
Δsum
Δdraft
Δaccepted
채택률
C=4 1회차
65
87.194
2,600
2,210
85.0%
C=4 2회차
69
90.628
2,760
2,346
85.0%
C=16 1회차
65
89.982
2,600
2,210
85.0%
C=16 2회차
65
91.478
2,600
2,210
85.0%
여기서도 회차마다 부하 직전과 직후를 따로 찍어 그 차이를 썼다. Δcount가 65에서 벗어난 회차는 하나뿐이다.
8-1-1. C=4 2회차만 요청이 4건 더 들어갔다
Δcount가 69로, 다른 세 회차의 65보다 4 많다. warm-up 한 건과 부하 64건이면 65여야 한다.
어디서 들어온 4건인지 이 기록으로는 특정되지 않는다. 다만 Δdraft ÷ Δcount가 40으로 다른 회차와 같아 성격이 같은 요청이며, 평균 지연도 1.313초로 나머지와 어긋나지 않는다. 판독에는 쓰되 그 회차의 절대값은 참고로만 본다.
8-2. off와 나란히 놓으면
조건
전체 소요
처리량
평균 지연
off C=4
39.175초
1.634 req/s
2.429초
on C=4
21.215초
3.017 req/s
1.327초
off C=16
10.116초
6.327 req/s
2.481초
on C=16
5.751초
11.130 req/s
1.396초
각 조건 두 회차의 평균이다. 회차 간 흔들림은 0.5~1.5%였다.
동시성
처리량 배속
지연 배속
1
1.91배
1.91배
4
1.85배
1.83배
16
1.76배
1.78배
8-3. 배속이 동시성에 따라 줄어든다
C=1 1.91배C=4 1.85배C=16 1.76배
C=1에서 C=16까지 8.1%가 깎였다. 회차 간 흔들림이 1.5% 안쪽이므로 이 감소는 노이즈로 설명되지 않는다.
회차별로 따로 봐도 같다.
동시성
1회차 배속
2회차 배속
4
1.850배
1.843배
16
1.785배
1.733배
C=4는 두 회차가 0.4% 안에서 같고, C=16은 두 값 모두 C=4보다 낮다.
6절을 만든 가설이 부분적으로 맞았다. speculative를 켤 때 함께 꺼지는 기능들이 부하 구간에서 이득을 잠식한다는 예상이었고, 실제로 잠식이 관측됐다. 다만 크기가 작다. 동시성을 16배 올리는 동안 1.91배가 1.76배로 내려갔을 뿐 이득이 사라지거나 뒤집히지는 않았다.
8-4. 채택률이 그대로라 원인이 갈린다
배속이 줄었을 때 원인은 두 갈래다. draft가 덜 맞았거나, 아니면 draft는 그대로인데 다른 층에서 비용이 늘었거나.
조건
채택률
요청당 draft 토큰
C=1
85.0%
40
C=4
85.0%
40
C=16
85.0%
40
세 동시성에서 소수점까지 같다. draft는 시퀀스마다 독립으로 만들어지므로 옆에 몇 건이 더 돌든 영향을 받지 않는다.
그러므로 8-3의 감소는 draft 품질과 무관하다. 남는 설명은 스케줄러 층이며, 3-3에서 확인한 async scheduling 비활성화와 배치 상한 축소가 후보다. 둘 중 어느 쪽이 얼마나 기여했는지는 이번 측정으로 나누지 못했다.
이 구분이 6절 전체의 목적이었다. 채택률을 함께 기록하지 않았다면 “부하를 걸면 speculative가 덜 듣는다”까지만 남고 그 이유를 draft 탓으로 오해할 수 있었다.
8-5. 자원 쪽 변화
조건
GPU 사용률
GPU memory
running
waiting
off C=4
25~29%
5,090 MiB
4
0
on C=4
25~31%
5,186 MiB
4
0
off C=16
26~29%
5,092 MiB
16
0
on C=16
27~41%
5,546 MiB
16
0
두 가지가 눈에 띈다.
메모리가 동시성에 비례해 늘어난다. off는 동시성과 무관하게 5,09x MiB로 고정인데, on은 C=4에서 +96 MiB, C=16에서 +454 MiB다. 시퀀스 하나당 약 25 MiB꼴이다. draft 토큰을 담을 자리가 동시 실행 시퀀스마다 필요한 것으로 보이지만, vLLM 내부 할당을 직접 확인하지는 않았다.
KV cache가 예산만큼 미리 잡혀 부하로는 움직이지 않는다는 7-4의 관측과 대비된다. speculative는 그 바깥에서 메모리를 더 쓴다.
GPU 사용률이 on C=16에서만 올라간다. 다른 세 조건이 29%를 넘지 않는데 여기서만 40~41% 샘플이 나온다. speculative는 forward 횟수를 줄이는 대신 한 번의 forward에서 더 많은 토큰을 처리하므로, 동시 시퀀스가 많을수록 연산이 몰린다.
waiting은 네 조건 모두 0이다. 동시성 16이 --max-num-seqs 상한과 같아 닿기만 하고 넘지 않았다는 7-4의 조건이 on에서도 그대로다.
9. 포화 구간에서 배속이 어떻게 되는가
8절의 on 측정은 동시성 4와 16이었고 대기 요청이 계속 0이었다. 배속이 1.91에서 1.76으로 내려온 방향은 확인했지만, 그 곡선이 어디까지 가는지는 대기가 실제로 쌓이는 구간을 재야 알 수 있다.
7절에서 off의 C=32를 이미 잡아 두었으므로, 같은 동시성에서 on을 두 회차 재고 둘을 맞대면 된다.
reason이 capacity로 찍혀 대기의 원인이 확정된다. 상한이 두 조건에 같게 걸린다는 점이 이 절의 전제다. speculative가 큐의 모양 자체를 바꾸지는 않는다.
9-2. 처리량은 멈추고 지연만 늘었다
조건
동시성
처리량
평균 지연
off
16
6.327 req/s
2.481초
off
32
6.451 req/s
4.261초
on
16
11.130 req/s
1.396초
on
32
11.412 req/s
2.390초
변화
off
on
처리량
+2.0%
+2.5%
평균 지연
1.72배
1.71배
동시성을 두 배로 올렸는데 처리량은 제자리이고 지연만 1.7배가 됐다. 늘어난 시간은 전부 대기 큐에서 보낸 시간이다.
off와 on이 거의 같은 비율로 움직인다는 점이 중요하다. 포화는 두 조건에 동등하게 걸린다.
GPU 쪽에서는 한 가지만 갈린다.
조건
GPU 사용률
GPU memory
off C=32
27~30%
5,092 MiB
on C=32
24~48%
5,546 MiB
on에서만 사용률이 48%까지 오른다. forward 횟수를 줄이는 대신 한 번에 더 많은 토큰을 처리하므로 연산이 몰린다. 그래도 절반이 남는다.
메모리는 on의 C=16과 같은 5,546 MiB다. running이 16으로 묶이므로 speculative의 시퀀스당 추가 메모리도 16개분에서 멈춘다. 8-5에서 본 증가분이 클라이언트 동시성이 아니라 동시 실행 수에 붙는다는 것이 이걸로 확인된다.
9-3. 배속은 그 자리에서 멈춘다
C=1 1.91배C=4 1.85배C=16 1.76배C=32 1.77배
동시성
1회차
2회차
4
1.850배
1.843배
16
1.785배
1.733배
32
1.782배
1.756배
C=32의 두 값이 C=16의 두 값 사이에 들어온다. 두 구간의 폭이 겹치므로 차이 없이 같은 자리로 읽는 것이 맞다. 확실한 것은 C=16에서 C=32로 가며 더 내려가지 않았다는 점이다.
8-3에서 관측한 감소는 C=4에서 C=16 사이에 일어났고, 상한이 걸린 뒤에는 멈춘다.
이유는 포화의 정의에서 나온다.running이 16으로 고정되면 두 조건 모두 같은 크기의 배치를 처리하게 되고, 그때의 처리 속도 비는 C=16에서 이미 측정한 값이다. 클라이언트가 32를 보내든 64를 보내든 엔진이 보는 상태는 같으므로 비율도 그 자리에 묶인다.
즉 배속이 계속 깎일 것이라는 걱정은 근거가 없었다. --max-num-seqs가 상한인 한 배속은 1.75 근처에서 멈춘다.
9-4. 이 구성의 운영 지점
세 동시성을 한 표로 놓으면 어디까지 올릴지가 보인다.
동시성
off 처리량
on 처리량
on 지연
대기
4
1.634
3.017
1.327초
0
16
6.327
11.130
1.396초
0
32
6.451
11.412
2.390초
16
16이 이 구성의 운영 지점이다. 그 위로는 처리량이 늘지 않고 지연만 붙는다. C=4에서 C=16까지는 지연이 1.327초에서 1.396초로 5%만 오르는데, C=32에서는 2.390초로 뛴다.
처리량을 더 올리려면 동시 요청을 늘릴 것이 아니라 --max-num-seqs를 높여야 한다. 다만 그 값을 올리면 KV cache 요구도 함께 늘어 --gpu-memory-utilization 예산을 다시 잡아야 하며, 이번 측정 범위 밖이다.
10. 확인된 범위
항목
결과
--spec-method=ngram 적용
확인. speculative_config로 엔진 구성까지
출력 동일성
확인. temperature=0에서 off와 일치
응답 시간 단축
확인. 2.392 → 1.250초, 1.91배
채택률
확인. 340 / 400 = 85%
채택 분포
확인. 5개 또는 0개로 양극화
단축의 원인
확인. forward 횟수 역산이 측정과 5% 내 일치
함께 비활성화되는 기능
확인. async scheduling, V2 runner, 배치 상한
baseline 원복
확인
off 기준선의 동시성 확장
확인. 4 → 16에서 처리량 3.87배, 지연 +2.1%
부하 구간의 자원 여유
확인. GPU 29% 유지, memory 고정
동시성에 따른 배속 감소
확인. 1.91 → 1.85 → 1.76 → 1.77배
감소가 멈추는 지점
확인. 상한이 걸리는 C=16 이후로는 더 내려가지 않음
감소의 원인
확인. 채택률이 85.0%로 불변이므로 draft가 아니라 스케줄러 층
speculative의 메모리 대가
확인. 동시 실행 시퀀스당 약 25 MiB
포화 구간의 증상
확인. waiting16, reason=capacity, 처리량 정체에 지연만 1.7배
이 구성의 운영 지점
확인. 동시성 16, on 기준 초당 11.1건
아래는 이번 측정에서 확인하지 않았다.
- 배속 감소가 async scheduling 때문인지 배치 상한 때문인지의 구분- --max-num-seqs를 16보다 올렸을 때의 처리량과 필요한 memory 예산- 반복이 없는 prompt에서의 채택률과 응답 시간- TTFT. 전체 응답 시간만 측정했다- --spec-tokens 값을 5가 아닌 값으로 바꿨을 때의 차이- prompt lookup 범위 조정. 평탄화 flag로 노출되지 않아 기본값을 썼다- 동시 실행 시퀀스당 25 MiB가 정확히 어느 버퍼인지- on 조건 1회차의 4.269초 중 초기화 비용이 얼마인지
첫 항목이 남은 것 중 가장 크다. 원인이 스케줄러 층이라는 것까지는 채택률 불변으로 확정했지만, 3-3의 세 가지 중 어느 쪽이 얼마나 기여했는지는 나누지 못했다. 기동 경고가 방법을 알려준다. max_num_batched_tokens를 직접 크게 주면 배치 상한만 원복되므로, 그 조건에서 배속이 회복되면 원인은 배치 상한이고 그대로면 async scheduling이다.
둘째 항목은 처리량을 더 올리려 할 때 바로 부딪히는 값이다. 9-4에서 확인한 대로 동시 요청을 늘려서는 11.1 req/s를 넘지 못하므로 --max-num-seqs를 올려야 하는데, 그러면 KV cache 요구가 함께 늘어 --gpu-memory-utilization 예산을 다시 잡아야 한다. 두 값을 함께 움직이는 실험이 필요하다.