목표와 현재 결과

같은 요청을 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_tokens2048로 고정된다. 셋 다 스케줄러 층의 기능이고 동시 요청이 많을 때 드러나는 값인데, 이번 측정은 동시 요청 1이라 그 대가를 재지 않았다.

범위도 좁다. 반복이 많은 prompt 하나만 썼고, 반복이 없는 prompt와 대조하지 않았다. TTFT도 기록하지 않았다. 1.91배는 “이 워크로드에서”라는 조건을 떼면 성립하지 않는다.

그래서 동시성 416에서 같은 비교를 다시 했다. 결과는 배속이 조금씩 깎이는 쪽이다.

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
}
설정이유
temperature0출력을 고정해 두 조건의 차이가 속도에만 나타나게 한다
max_tokens256상한. 실제 생성은 이보다 짧다
prompt 성격같은 문장 3회 반복N-gram이 문맥에서 draft를 찾을 수 있게 한다

prompt를 이렇게 고른 것이 이 실험의 결과를 크게 좌우한다. N-gram은 이미 나온 텍스트에서 같은 패턴을 찾아 그 뒤를 베끼는 방식이라, 반복이 없으면 draft 자체가 나오지 않는다. 유리한 조건을 일부러 만든 것이고, 그래서 결과를 일반화할 수 없다.

2. off 기준선 측정

2-1. warm-up과 10회 측정

첫 요청에는 적재 지연이 섞이므로 warm-up을 한 번 돌리고 버렸다.

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" -o /dev/nul

2-1-1. warm-up 출력 경로 오타

-o /dev/nul로 썼다. 버리려면 /dev/null이어야 한다.

-s가 curl의 오류 메시지까지 가리기 때문에 이 명령이 /dev 아래에 파일을 하나 만들고 성공했는지, 출력 파일을 못 열어 실패했는지 화면으로는 구분되지 않는다. 어느 쪽이었는지 확인하지 않았다.

warm-up의 성패는 아래 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-off-$i.json"
done
통계
평균2.392
중앙값2.387
최소2.346
최대2.435
최대 − 최소0.089
최대 ÷ 최소1.038

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

요청대로 문장이 세 번 반복됐다. 뒤에서 N-gram 채택률을 판독할 때 이 구조가 근거가 된다. 두 번째 반복부터는 다음에 올 토큰이 이미 문맥 안에 통째로 들어 있다.

2-3. 토큰 사용량

jq -r '.usage | "\(.prompt_tokens)\t\(.completion_tokens)\t\(.total_tokens)"' \
  ~/k3s-gpu-cluster/request/spec-off-*.json
key해석
prompt73 토큰10회 동일
completion68 토큰10회 동일
전체141 토큰
편차없음temperature=0이 실제로 결정적으로 동작했다

10회가 완전히 같다는 것이 중요하다. 생성 길이가 회차마다 달랐다면 응답 시간 차이가 speculative 때문인지 길이 때문인지 구분할 수 없다. 여기서는 길이가 고정이므로 시간 차이를 토큰당 비용 차이로 바로 읽을 수 있다.

토큰당으로 환산하면 2.392초 ÷ 68 = 35.2ms다.

3. speculative 적용

3-1. args 추가와 확인

기존 args가 --옵션=값 결합 형식이므로 같은 형식으로 원소 두 개를 덧붙였다.

kubectl -n llm patch deployment vllm-qwen --type=json -p='[
  {"op":"add","path":"/spec/template/spec/containers/0/args/-","value":"--spec-method=ngram"},
  {"op":"add","path":"/spec/template/spec/containers/0/args/-","value":"--spec-tokens=5"}
]'

patched는 API 서버가 요청을 받았다는 뜻일 뿐 의도한 자리에 들어갔다는 보장이 아니다. 배열을 다시 조회했다.

kubectl -n llm get deployment vllm-qwen \
  -o jsonpath='{.spec.template.spec.containers[0].args}{"\n"}'
확인 항목결과
추가된 원소--spec-method=ngram, --spec-tokens=5
위치배열 끝. 기존 6개 뒤
기존 원소6개 모두 그대로

add/args/-를 쓰면 끝에 덧붙는다. index를 지정하지 않으므로 기존 원소를 덮을 여지가 없다. 5장에서 index를 지정해 다른 옵션을 덮었던 것과 갈리는 지점이다.

3-2. 기동 로그에서 적용 확인

args에 들어간 것과 엔진이 그 값을 실제로 쓴 것은 다른 문제다. 옵션이 무시된 채 정상 기동하면 off를 두 번 재고 “차이 없음”으로 끝난다.

kubectl -n llm rollout status deployment/vllm-qwen --timeout=20m
kubectl -n llm logs deployment/vllm-qwen | grep -iE "specul|ngram|draft"
근거해석
non-default argsspec_method: 'ngram', spec_tokens: 5CLI 인자가 파싱됐다
speculative_configmethod='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 disabledngram과 함께 쓸 수 없어 비활성화스케줄러
Model Runner V2 → V1V2가 ngram을 아직 지원하지 않음실행 경로
max_num_scheduled_tokens = 2048draft token 자리를 확보하느라 축소배치 상한
min_p, logit_bias 무효speculative와 함께 동작하지 않음샘플링 옵션

앞의 셋은 모두 동시 요청이 많을 때 드러나는 값이다. async scheduling과 배치 상한은 요청이 한 건뿐이면 차이를 만들지 않는다. 이번 측정은 동시 요청 1이므로 이 대가를 재지 않았고, 뒤에 나오는 1.91배에는 이 손실이 반영되어 있지 않다.

4장에서 대기 요청이 --max-num-seqs=16으로 포화하는 구간을 확인한 바 있다. 그 구간에서 배치 상한이 2048로 묶이면 어떻게 되는지는 별도 측정이 필요하다.

min_plogit_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

4-1-1. on 조건에는 warm-up을 돌리지 않았다

off는 측정 전에 warm-up 요청을 한 번 버렸는데, on은 patch로 Pod가 새로 뜬 직후 곧바로 10회를 돌렸다.

1회차 4.269초는 나머지 9회의 3.4배다. 이 값에는 첫 요청의 초기화 비용이 섞여 있어 speculative의 효과로 읽을 수 없다. 두 조건의 절차가 달라졌으므로 10회 평균끼리 비교하면 안 된다.

1회차를 빼고 2~10회차 9개로 통계를 낸다. 아래 값은 모두 이 9개 기준이다.

통계
평균1.250
중앙값1.251
최소1.239
최대1.261
최대 − 최소0.022
최대 ÷ 최소1.018

폭이 0.022초로 off의 0.089초보다 좁다. 다만 표본이 9개와 10개라 이 차이 자체를 결과로 삼지는 않는다.

4-2. 출력이 같은지 확인

speculative는 검증을 통과한 토큰만 채택하므로 결과가 달라지면 안 된다. temperature=0이면 글자 단위로 같아야 한다.

diff <(jq -r '.choices[0].message.content' ~/k3s-gpu-cluster/request/spec-off-1.json) \
     <(jq -r '.choices[0].message.content' ~/k3s-gpu-cluster/request/spec-on-1.json) && echo "동일"

이 확인이 이번 측정에서 가장 중요한 통제다. 이 줄이 없으면 빨라진 이유가 speculative 때문인지, 더 짧게 생성했기 때문인지 구분할 수 없다. 출력이 같으므로 생성 토큰 수도 68로 같고, 시간 차이는 온전히 토큰당 비용 차이다.

다만 확인한 것은 1회차 본문뿐이다. on 조건의 usage를 따로 조회하지는 않았다.

4-3. 두 조건 비교

지표off (10회)on (2~10회)차이
평균2.3921.2501.142초 단축
중앙값2.3871.2511.136초 단축
최소2.3461.239
최대2.4351.261
토큰당35.2ms18.4ms
환산
평균 기준 배속1.914
중앙값 기준 배속1.908
시간 감소율47.7%

두 구성의 범위가 겹치지 않는다. off의 최소값 2.346초가 on의 최대값 1.261초보다 크므로, 어느 회차를 뽑아 비교해도 순서가 뒤집히지 않는다. 5장의 replica 비교에서는 두 구성의 범위가 크게 겹쳐 평균 차이를 확정하지 못했는데, 여기서는 그 문제가 없다.

5. 채택률 판독

5-1. 지표 원문

빨라진 이유가 정말 draft 채택 때문인지는 지표로 확인해야 한다.

curl -s http://127.0.0.1:8000/metrics | grep -iE "specul|draft|accept"
key해석
draft 횟수8010회 요청에서 요청당 8
draft 토큰40080 × 5. --spec-tokens=5가 그대로 반영됐다
채택 토큰340
채택률85.0%340 ÷ 400
draft당 평균 채택4.25340 ÷ 80

카운터는 Pod가 새로 뜬 뒤 쌓인 값이다. patch로 재기동됐고 그 뒤 보낸 것이 이 10회뿐이므로 이 수치는 on 조건만 담고 있다.

5-2. 채택 분포

position별 카운터가 누적 방식이라는 점을 이용하면 분포를 복원할 수 있다. 채택은 앞에서부터 연속으로만 일어나므로, position 3이 채택된 draft는 반드시 0, 1, 2도 채택됐다.

position채택 횟수해당 자리까지 맞힌 draft 비율
07087.5%
17087.5%
27087.5%
37087.5%
46075.0%

인접한 값의 차이가 곧 “정확히 그 개수만큼 채택된 draft 수”다.

채택 개수draft 수계산
01080 − 70
1~30position 0~3 카운터가 모두 같다
41070 − 60
560position 4 카운터
합계80채택 토큰 340과 일치

분포가 양극단으로 갈린다. 5개 전부 맞히거나 0개도 못 맞히거나이며, 중간이 거의 없다.

이 모양은 prompt 구조에서 나온다. 같은 문장을 그대로 반복시키는 요청이라 반복 구간에서는 다음 토큰들이 문맥에 통째로 들어 있어 5개가 한꺼번에 맞는다. 반면 반복이 끝나고 확인 결과: 같은 새 문장이 시작되는 자리에서는 베낄 대상이 없어 첫 토큰부터 틀린다.

부분적으로만 맞는 상황이 드물다는 것은, 이 워크로드에서 N-gram이 문맥을 이해해 예측하는 것이 아니라 사본을 찾아 복사하고 있음을 보여준다. 사본이 있으면 전부 맞고 없으면 전부 틀린다.

5-3. forward 횟수로 검산

측정된 1.91배가 채택률로 설명되는지 확인한다. 검증 forward는 채택된 토큰 외에 다음 자리의 정답 한 개를 함께 알려주므로, draft 스텝 한 번이 채택 수 + 1개를 만든다.

항목계산
요청당 draft 스텝80 ÷ 108
요청당 채택 토큰340 ÷ 1034
draft 스텝이 만든 토큰34 + 842
나머지 토큰68 − 4226
일반 스텝토큰 하나당 한 번26
on의 decode forward8 + 2634
off의 decode forward토큰 하나당 한 번68
예측 배속68 ÷ 342.00

예측 2.00배에 대해 측정값이 1.91배다. 차이 0.09는 두 방향으로 설명된다. 응답 시간에는 prefill과 네트워크 왕복이 포함되는데 이 구간은 두 조건이 같아 배속을 희석한다. 그리고 draft를 찾는 탐색 비용이 on 쪽에만 추가된다.

이 표는 복원이지 측정이 아니다

draft 스텝 수와 채택 토큰 수는 지표에서 직접 읽은 값이지만, 일반 스텝 수와 forward 총횟수는 “검증 forward가 토큰 하나를 더 만든다”는 동작을 전제로 계산한 값이다. forward 횟수를 직접 세는 지표는 조회하지 않았다.

전제를 감안해도 예측과 측정이 5% 안에서 맞는다. 응답 시간 단축이 draft 채택에서 나왔다는 설명이 수치로 뒷받침된다.

6. 원복

측정이 끝나 baseline으로 되돌렸다.

kubectl -n llm rollout undo deployment/vllm-qwen
kubectl -n llm rollout status deployment/vllm-qwen --timeout=20m

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건으로 맞췄다.

time seq 1 64 | xargs -P 4 -I{} 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" \
  -o "$HOME/k3s-gpu-cluster/request/load-off-c4-{}-1.json"

카운터는 Pod 기동 이후 누적이므로 회차마다 앞뒤로 찍어 차이를 쓴다.

curl -s http://127.0.0.1:8000/metrics \
  | grep -E "^vllm:e2e_request_latency_seconds_(sum|count)"

7-2. 여섯 회차의 카운터

동시성 4, 16, 32를 각각 두 번씩, 모두 여섯 회차를 측정했다.

회차ΔcountΔsum
C=4 1회차65157.541
C=4 2회차65158.265
C=16 1회차65162.416
C=16 2회차65160.146
C=32 1회차65277.922
C=32 2회차65275.969

Δcount는 warm-up 한 건과 부하 64건을 더한 65가 정상값이다. 이 값이 어긋나면 회차 사이에 다른 요청이 섞였다는 뜻이므로 판독 전에 확인해야 한다. 여섯 회차가 모두 맞았다.

회차마다 부하 직전과 직후를 따로 찍어 그 차이를 썼다. 앞 회차의 after를 다음 회차의 before로 대신하지 않았으므로, 회차 사이에 다른 요청이 들어갔다면 Δcount에 그대로 드러난다.

동시성을 바꿀 때 Deployment를 건드리지 않는다는 점도 중요하다. -P 값은 클라이언트 쪽이므로 여섯 회차가 같은 엔진 프로세스에서 이어진다. 세 동시성의 비교에 재기동이 끼어들지 않는다.

7-3. 처리량과 지연

조건회차전체 소요처리량평균 지연
C=4139.5431.619 req/s2.453
C=4239.8431.606 req/s2.470
C=16110.1886.282 req/s2.499
C=16210.0436.373 req/s2.464
C=3219.9516.432 req/s4.276
C=3229.8916.471 req/s4.246
파생C=4C=16C=32
처리량1.634 req/s6.327 req/s6.451 req/s
평균 지연2.4292.4814.261

동시성 4에서 16으로 올렸을 때는 처리량이 3.87배 늘고 지연은 2.1%만 올랐다. 거의 선형 확장이다.

그런데 16에서 32로 올리면 처리량이 멈춘다. 6.327에서 6.4512% 안쪽이고, 대신 지연이 2.481초에서 4.261초로 1.72배가 된다. 늘어난 시간은 전부 대기 큐에서 보낸 시간이다.

--max-num-seqs=16이 상한이므로 동시 요청을 그 위로 보내도 엔진은 16건만 처리한다. 나머지는 큐에서 기다린다. 이 구간의 지표는 9절에서 따로 본다.

같은 조건에서 두 번씩 잰 값이 0.5~1.4% 안에 들어오므로, 위 차이는 회차 간 흔들림으로 설명되지 않는다.

벽시계로도 교차 검증된다. 동시성을 유지한 채 64건을 흘렸으므로 전체 소요 × 동시성 ÷ 64가 평균 지연의 근사가 된다.

조건벽시계 역산histogram차이
C=4 1회2.4432.4240.8%
C=4 2회2.4542.4350.8%
C=16 1회2.5472.4991.9%
C=16 2회2.5112.4641.9%
C=32 1회4.9764.27616%
C=32 2회4.9464.24616%

C=4C=16은 두 경로가 2% 안에서 일치한다. 벽시계 쪽이 조금 높은 것은 부하 시작과 종료 구간에서 동시 실행 수가 목표치에 못 미치기 때문이며, 예상되는 방향이다.

C=3216%로 크게 벌어진다. 요청 64건에 동시성 32면 두 번의 파도로 끝나 목표 동시성이 유지되는 구간이 짧기 때문이다. 벽시계 역산은 동시성이 계속 유지된다고 가정하므로 이 조건에서는 과대평가된다. C=32의 지연은 서버가 직접 기록한 histogram 값을 쓴다.

7-4. 자원은 어느 쪽도 차지 않았다

항목C=4C=16C=32
GPU 사용률27~31%27~30%27~30%
GPU memory5,090 MiB5,092 MiB5,092 MiB
num_requests_running41616
num_requests_waiting0016

세 가지가 함께 읽힌다.

첫째, GPU 사용률이 세 조건에서 모두 같다. 동시성을 여덟 배 올려 처리량이 네 배 가까이 늘었는데도 29% 근처에 머문다. 큐가 꽉 찬 C=32에서도 31%를 넘지 않는다. 연산이 남는데 요청이 밀려 있다는 뜻이며, 4장에서 동시성 32일 때 31.8%였던 것과도 이어진다. 이 워크로드에서 GPU 연산은 한 번도 제약이 된 적이 없다.

둘째, num_requests_waitingC=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%     대기 0
C=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회차6587.1942,6002,21085.0%
C=4 2회차6990.6282,7602,34685.0%
C=16 1회차6589.9822,6002,21085.0%
C=16 2회차6591.4782,6002,21085.0%

여기서도 회차마다 부하 직전과 직후를 따로 찍어 그 차이를 썼다. Δcount65에서 벗어난 회차는 하나뿐이다.

8-1-1. C=4 2회차만 요청이 4건 더 들어갔다

Δcount69로, 다른 세 회차의 65보다 4 많다. warm-up 한 건과 부하 64건이면 65여야 한다.

어디서 들어온 4건인지 이 기록으로는 특정되지 않는다. 다만 Δdraft ÷ Δcount40으로 다른 회차와 같아 성격이 같은 요청이며, 평균 지연도 1.313초로 나머지와 어긋나지 않는다. 판독에는 쓰되 그 회차의 절대값은 참고로만 본다.

8-2. off와 나란히 놓으면

조건전체 소요처리량평균 지연
off C=439.1751.634 req/s2.429
on C=421.2153.017 req/s1.327
off C=1610.1166.327 req/s2.481
on C=165.75111.130 req/s1.396

각 조건 두 회차의 평균이다. 회차 간 흔들림은 0.5~1.5%였다.

동시성처리량 배속지연 배속
11.911.91
41.851.83
161.761.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회차 배속
41.8501.843
161.7851.733

C=4는 두 회차가 0.4% 안에서 같고, C=16은 두 값 모두 C=4보다 낮다.

6절을 만든 가설이 부분적으로 맞았다. speculative를 켤 때 함께 꺼지는 기능들이 부하 구간에서 이득을 잠식한다는 예상이었고, 실제로 잠식이 관측됐다. 다만 크기가 작다. 동시성을 16배 올리는 동안 1.91배가 1.76배로 내려갔을 뿐 이득이 사라지거나 뒤집히지는 않았다.

8-4. 채택률이 그대로라 원인이 갈린다

배속이 줄었을 때 원인은 두 갈래다. draft가 덜 맞았거나, 아니면 draft는 그대로인데 다른 층에서 비용이 늘었거나.

조건채택률요청당 draft 토큰
C=185.0%40
C=485.0%40
C=1685.0%40

세 동시성에서 소수점까지 같다. draft는 시퀀스마다 독립으로 만들어지므로 옆에 몇 건이 더 돌든 영향을 받지 않는다.

그러므로 8-3의 감소는 draft 품질과 무관하다. 남는 설명은 스케줄러 층이며, 3-3에서 확인한 async scheduling 비활성화와 배치 상한 축소가 후보다. 둘 중 어느 쪽이 얼마나 기여했는지는 이번 측정으로 나누지 못했다.

이 구분이 6절 전체의 목적이었다. 채택률을 함께 기록하지 않았다면 “부하를 걸면 speculative가 덜 듣는다”까지만 남고 그 이유를 draft 탓으로 오해할 수 있었다.

8-5. 자원 쪽 변화

조건GPU 사용률GPU memoryrunningwaiting
off C=425~29%5,090 MiB40
on C=425~31%5,186 MiB40
off C=1626~29%5,092 MiB160
on C=1627~41%5,546 MiB160

두 가지가 눈에 띈다.

메모리가 동시성에 비례해 늘어난다. 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 측정은 동시성 416이었고 대기 요청이 계속 0이었다. 배속이 1.91에서 1.76으로 내려온 방향은 확인했지만, 그 곡선이 어디까지 가는지는 대기가 실제로 쌓이는 구간을 재야 알 수 있다.

7절에서 off의 C=32를 이미 잡아 두었으므로, 같은 동시성에서 on을 두 회차 재고 둘을 맞대면 된다.

9-1. 큐는 두 조건에서 같은 모양이다

항목해석
running16--max-num-seqs 상한에 묶임
waiting16클라이언트가 보낸 32건 중 나머지
reasoncapacity상한 때문에 밀린 것이 맞다
deferred0다른 이유로 미뤄진 요청은 없다

reasoncapacity로 찍혀 대기의 원인이 확정된다. 상한이 두 조건에 같게 걸린다는 점이 이 절의 전제다. speculative가 큐의 모양 자체를 바꾸지는 않는다.

9-2. 처리량은 멈추고 지연만 늘었다

조건동시성처리량평균 지연
off166.327 req/s2.481
off326.451 req/s4.261
on1611.130 req/s1.396
on3211.412 req/s2.390
변화offon
처리량+2.0%+2.5%
평균 지연1.721.71

동시성을 두 배로 올렸는데 처리량은 제자리이고 지연만 1.7배가 됐다. 늘어난 시간은 전부 대기 큐에서 보낸 시간이다.

off와 on이 거의 같은 비율로 움직인다는 점이 중요하다. 포화는 두 조건에 동등하게 걸린다.

GPU 쪽에서는 한 가지만 갈린다.

조건GPU 사용률GPU memory
off C=3227~30%5,092 MiB
on C=3224~48%5,546 MiB

on에서만 사용률이 48%까지 오른다. forward 횟수를 줄이는 대신 한 번에 더 많은 토큰을 처리하므로 연산이 몰린다. 그래도 절반이 남는다.

메모리는 on의 C=16과 같은 5,546 MiB다. running16으로 묶이므로 speculative의 시퀀스당 추가 메모리도 16개분에서 멈춘다. 8-5에서 본 증가분이 클라이언트 동시성이 아니라 동시 실행 수에 붙는다는 것이 이걸로 확인된다.

9-3. 배속은 그 자리에서 멈춘다

C=1     1.91배
C=4     1.85배
C=16    1.76배
C=32    1.77배
동시성1회차2회차
41.8501.843
161.7851.733
321.7821.756

C=32의 두 값이 C=16의 두 값 사이에 들어온다. 두 구간의 폭이 겹치므로 차이 없이 같은 자리로 읽는 것이 맞다. 확실한 것은 C=16에서 C=32로 가며 더 내려가지 않았다는 점이다.

8-3에서 관측한 감소는 C=4에서 C=16 사이에 일어났고, 상한이 걸린 뒤에는 멈춘다.

이유는 포화의 정의에서 나온다. running16으로 고정되면 두 조건 모두 같은 크기의 배치를 처리하게 되고, 그때의 처리 속도 비는 C=16에서 이미 측정한 값이다. 클라이언트가 32를 보내든 64를 보내든 엔진이 보는 상태는 같으므로 비율도 그 자리에 묶인다.

즉 배속이 계속 깎일 것이라는 걱정은 근거가 없었다. --max-num-seqs가 상한인 한 배속은 1.75 근처에서 멈춘다.

9-4. 이 구성의 운영 지점

세 동시성을 한 표로 놓으면 어디까지 올릴지가 보인다.

동시성off 처리량on 처리량on 지연대기
41.6343.0171.3270
166.32711.1301.3960
326.45111.4122.39016

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.3921.250초, 1.91
채택률확인. 340 / 400 = 85%
채택 분포확인. 5개 또는 0개로 양극화
단축의 원인확인. forward 횟수 역산이 측정과 5% 내 일치
함께 비활성화되는 기능확인. async scheduling, V2 runner, 배치 상한
baseline 원복확인
off 기준선의 동시성 확장확인. 416에서 처리량 3.87배, 지연 +2.1%
부하 구간의 자원 여유확인. GPU 29% 유지, memory 고정
동시성에 따른 배속 감소확인. 1.911.851.761.77
감소가 멈추는 지점확인. 상한이 걸리는 C=16 이후로는 더 내려가지 않음
감소의 원인확인. 채택률이 85.0%로 불변이므로 draft가 아니라 스케줄러 층
speculative의 메모리 대가확인. 동시 실행 시퀀스당 약 25 MiB
포화 구간의 증상확인. waiting 16, 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 예산을 다시 잡아야 한다. 두 값을 함께 움직이는 실험이 필요하다.