Quiz / LLM Serving / 전체 문항
GitHub

LLM Serving 전체 문항

vLLM의 배칭, KV 캐시, 추론 최적화. 문항과 선택지별 해설을 글 순서대로 나열했습니다.

51문항 · 글 9
직접 풀어보기 →

Prefill과 Decode로 이해하는 LLM 추론 과정

원문 읽기 →
개념 확인구조계산
{
  "id": "cmpl-7f2a91c4",
  "model": "meta-llama/Llama-3.1-8B-Instruct",
  "usage": {
    "prompt_tokens": 2048,
    "completion_tokens": 300,
    "total_tokens": 2348
  }
}

vLLM 서버가 이 응답을 완성하기까지 모델의 forward pass는 몇 번 실행되었습니까? prefix caching, chunked prefill, speculative decoding은 모두 꺼져 있습니다.

  1. 301회. 프롬프트용 pass 1회에 출력 토큰 300개분을 더합니다.

    prefill을 출력과 별개의 비용으로 세면 이 답이 나옵니다. 하지만 prefill은 KV Cache만 만들고 끝나는 것이 아니라 마지막 토큰 위치의 출력, 즉 첫 번째 생성 토큰까지 함께 내놓습니다. 첫 토큰이 이미 나왔으므로 decode 스텝은 299번만 더 돌면 됩니다.

  2. 2,348회. 프롬프트든 출력이든 토큰 하나에 pass 하나가 대응합니다.

    생성이 토큰 단위라는 사실을 입력에까지 확장한 오해입니다. 프롬프트 토큰들은 이미 전부 주어져 있어 순서대로 처리할 이유가 없고, Transformer는 2,048개를 한 번의 pass에서 병렬로 밀어 넣습니다. 순차 처리가 강제되는 쪽은 아직 존재하지 않는 토큰을 만들어내야 하는 출력뿐입니다.

  3. 정답300회. prefill이 내놓는 첫 토큰까지 세면 출력 토큰 수와 같아집니다.

    prefill 1회가 첫 토큰을 내고, 남은 299개를 decode 스텝 299번이 하나씩 채웁니다. 합치면 정확히 completion_tokens와 같은 300회입니다. 출력 토큰 수만 알면 프롬프트 길이와 무관하게 pass 횟수가 정해집니다.

  4. 프롬프트가 길수록 pass도 늘어나므로 출력 토큰 수만으로는 정해지지 않습니다.

    프롬프트가 길면 느려지는 것은 맞지만, 늘어나는 것은 pass 횟수가 아니라 prefill 한 번의 무게입니다. 프롬프트가 한 스텝의 토큰 예산 안에 들어가는 한 prefill은 pass 1회이고, 대신 그 1회가 처리하는 행렬이 커집니다. 그래서 프롬프트 길이는 pass 횟수가 아니라 TTFT에 반영됩니다.

해설

forward pass 횟수는 출력 토큰 수와 같습니다. prefill이 프롬프트 2,048개를 한 번의 pass에서 병렬로 처리하면서 KV Cache와 첫 번째 출력 토큰을 동시에 내놓고, 남은 299개를 decode 스텝 299번이 하나씩 채웁니다. 프롬프트 길이는 pass 횟수가 아니라 prefill 한 번의 연산량, 즉 TTFT 쪽으로 반영됩니다. 입력 토큰은 아무리 길어도 pass 한 번 안에서 병렬로 흡수되지만, 출력 토큰은 하나마다 모델 전체를 통과하는 pass를 요구합니다. 같은 total_tokens라도 입력이 긴 요청과 출력이 긴 요청의 GPU 점유 시간이 크게 다른 이유가 여기 있습니다. 요금표가 입력 토큰과 출력 토큰에 다른 단가를 매기는 것도 같은 비대칭에서 나옵니다.

이 내용 다시 읽기 →
상황 적용계산
A100 80GB SXM 한 장에 Llama 3.1 8B를 bf16으로 올려 서빙합니다. 이 카드의 HBM2e 대역폭은 약 2.0 TB/s입니다. 지금 서버에 들어온 요청은 하나뿐이라 배치 크기가 1이고, 프롬프트가 짧아 prefill 시간은 무시할 만합니다.

decode 스텝 하나에 걸리는 시간의 이론적 하한과, 거기서 나오는 초당 생성 토큰 수의 상한은 대략 얼마입니까?

  1. 정답약 8ms, 초당 약 125토큰.

    8B 파라미터를 bf16으로 담으면 파라미터당 2바이트라 약 16GB이고, 이를 2.0TB/s로 나누면 스텝당 약 8ms가 나옵니다. 1초를 8ms로 나누면 초당 약 125토큰입니다. 실제 측정치는 KV Cache 읽기와 커널 오버헤드 때문에 이보다 낮게 나옵니다.

  2. 약 16ms, 초당 약 62토큰.

    파라미터당 4바이트, 즉 fp32로 계산했을 때 나오는 값입니다. bf16은 지수부 8비트와 가수부 7비트에 부호 1비트를 더해 총 16비트, 파라미터당 2바이트입니다. 정밀도를 절반으로 줄이는 것이 곧 읽을 데이터를 절반으로 줄이는 것이라는 점이 memory-bound 단계에서 결정적입니다.

  3. 약 4ms, 초당 약 250토큰.

    파라미터 수 8B를 그대로 8GB로 읽으면 이 값이 나옵니다. 파라미터 개수와 바이트 수는 정밀도를 곱해야 이어지고, bf16에서는 개수의 2배가 바이트 수입니다. 이 환산을 건너뛰면 필요한 GPU 메모리도 절반으로 잘못 잡게 됩니다.

  4. A100의 bf16 연산 성능(약 312 TFLOPS)까지 알아야 계산됩니다.

    배치 1의 decode에서는 연산 성능이 상한을 정하지 않으므로 이 값은 식에 등장하지 않습니다. 스텝당 실제로 하는 일은 토큰 1개를 위해 가중치 전체를 HBM에서 한 번 훑는 것이고, 연산 유닛은 그 데이터를 기다리며 대부분 놉니다. 연산 성능이 상한을 정하는 쪽은 수천 토큰을 한꺼번에 미는 prefill입니다.

해설

배치 1의 생성 속도 상한은 가중치 크기를 메모리 대역폭으로 나눈 값이 정합니다. 16GB를 2.0TB/s로 나누면 스텝당 약 8ms, 초당 약 125토큰이고, 이 식에 GPU의 연산 성능은 들어가지 않습니다. decode 스텝의 연산 강도가 극단적으로 낮아서, 데이터를 나르는 시간이 계산하는 시간을 완전히 덮어버리기 때문입니다. 실제 측정치는 KV Cache 읽기와 커널 효율 때문에 이 상한보다 낮게 나옵니다. 반대로 이 값을 크게 웃도는 수치가 보고되었다면 배치가 1이 아니거나, speculative decoding처럼 스텝 하나에서 토큰을 여러 개 확정하는 기법이 걸려 있는 것입니다. 모델 크기와 GPU 대역폭만 알면 암산으로 나오는 값이라, 벤치마크 결과가 물리적으로 말이 되는지 판단하는 기준선으로 쓸 수 있습니다. 가중치를 줄이는 기법이 배치 1 생성 속도에 곧바로 반영되는 것도 같은 이유입니다.

이 내용 다시 읽기 →
분석과 판단진단운영
INFO 07-21 14:02:11 [loggers.py:127] Engine 000: Avg prompt throughput: 0.0 tokens/s, Avg generation throughput: 941.3 tokens/s, Running: 32 reqs, Waiting: 0 reqs, GPU KV cache usage: 38.4%, Prefix cache hit rate: 0.0%
INFO 07-21 14:02:21 [loggers.py:127] Engine 000: Avg prompt throughput: 3276.8 tokens/s, Avg generation throughput: 402.7 tokens/s, Running: 33 reqs, Waiting: 0 reqs, GPU KV cache usage: 51.2%, Prefix cache hit rate: 0.0%
INFO 07-21 14:02:31 [loggers.py:127] Engine 000: Avg prompt throughput: 0.0 tokens/s, Avg generation throughput: 938.6 tokens/s, Running: 33 reqs, Waiting: 0 reqs, GPU KV cache usage: 54.5%, Prefix cache hit rate: 0.0%

이 서버는 처리량을 노려 max_num_batched_tokens를 32768로 올려 두었습니다.

기존 32개 요청의 토큰 간 지연(ITL)
  14:02:01 ~ 11   p50 34ms   최대    58ms
  14:02:11 ~ 21   p50 35ms   최대 5,720ms
  14:02:21 ~ 31   p50 35ms   최대    61ms

14:02:11 ~ 21 구간에서만 기존 32개 요청의 ITL 꼬리가 5.7초까지 벌어졌습니다. 로그에서 원인으로 지목할 수 있는 것은 무엇입니까?

  1. KV Cache 공간이 모자라 기존 요청 일부가 선점되었다가 다시 스케줄되었습니다.

    선점은 실제로 ITL을 크게 튀게 하는 원인이지만 여기서는 조건이 맞지 않습니다. KV cache usage가 51.2%로 절반 남짓이고 Waiting도 0이라 메모리 압박의 흔적이 없습니다. 선점이 일어났다면 Waiting에 밀려난 요청이 잡히고 별도의 preemption 경고가 함께 찍혔을 것입니다.

  2. 정답새 요청의 prefill이 compute-bound라 같은 스텝의 decode들이 함께 멈췄습니다.

    32k 토큰의 행렬 연산을 한 번에 수행하는 prefill은 토큰 1개짜리 decode 스텝보다 백 배 이상 무겁고, 한 스텝에 묶인 요청들은 가장 무거운 작업이 끝나야 다 함께 다음 스텝으로 갑니다. 그 정지 구간이 기존 요청들의 ITL 꼬리로 찍힙니다. prompt throughput이 그 10초에만 3,276 tokens/s로 뛰었다가 다시 0으로 돌아온 것이 prefill이 지나간 자국입니다.

  3. 긴 프롬프트 때문에 attention이 훑을 KV Cache가 커져 decode 스텝이 느려졌습니다.

    새 요청의 KV Cache가 커지면 그 요청이 낀 스텝의 어텐션 시간이 늘어나는 것은 맞습니다. 하지만 그 부담은 요청이 살아 있는 동안 계속 남으므로 지연도 그 뒤로 유지되어야 하는데, 다음 구간에서 ITL 꼬리가 61ms로 되돌아왔습니다. 한 구간에만 나타나고 사라졌다는 사실이 지속적 부담이 아니라 일회성 사건을 가리킵니다.

  4. 배치가 32개에서 33개로 늘면서 스텝당 가중치 읽기가 그만큼 나뉘어 느려졌습니다.

    방향이 반대입니다. decode는 배치를 키워도 가중치를 여전히 한 번만 읽으므로 스텝 시간이 요청 수에 비례해 늘지 않고, 그래서 배칭이 거의 공짜 점심이 됩니다. 실제로 Running이 33으로 유지된 다음 구간에서 ITL이 원래대로 돌아온 것이 배치 증가가 원인이 아님을 보여줍니다.

해설

성격이 정반대인 두 워크로드가 같은 스텝에 섞이면 가벼운 쪽이 무거운 쪽을 기다립니다. 32k 토큰 prefill은 수천 개 토큰의 행렬 연산을 한꺼번에 미는 compute-bound 작업이라, 토큰 1개만 처리하는 memory-bound decode보다 백 배 이상 오래 걸립니다. 그동안 기존 요청들은 토큰을 하나도 내지 못하고 그 정지 시간이 ITL 꼬리로 드러납니다. 로그에서 generation throughput이 941에서 402로 반 넘게 떨어졌다가 다시 938로 회복한 것이 같은 사건의 다른 얼굴이고, prompt throughput이 그 구간에만 3,276로 뛴 것은 한 요청이 32k 토큰을 밀어 넣었다는 계산과 맞습니다. KV cache usage가 절반에 그치고 Waiting이 0이므로 메모리 압박이나 선점은 후보에서 빠지고, 다음 구간에 지표가 전부 원상복구된 것이 일회성 간섭임을 확정합니다. 긴 prefill을 토큰 예산 단위로 잘라 decode 스텝 사이에 끼워 넣는 chunked prefill이 정확히 이 지점을 공략합니다.

이 내용 다시 읽기 →
상황 적용확장면접예측
bf16으로 서빙하던 모델을 W4A16(가중치는 4비트로 압축, 활성값은 16비트 유지)으로 바꿔 같은 하드웨어에서 다시 측정했습니다. 동시 요청 1건에서는 초당 생성 토큰이 2배 가까이 올랐습니다. 그런데 동시 요청을 128건까지 올리자 전체 처리량 이득이 거의 남지 않았고, 긴 프롬프트 요청의 TTFT는 bf16일 때보다 오히려 나빠졌습니다.

이 결과를 가장 잘 설명하는 것은 무엇입니까?

  1. 4비트 가중치의 정확도 손실 탓에 출력이 길어져 스텝 수가 늘어난 것입니다.

    정확도 저하는 실제로 일어날 수 있지만 그것이 TTFT를 나쁘게 만들지는 못합니다. TTFT는 첫 토큰이 나오기까지의 시간이라 출력 길이와 무관하게 결정됩니다. 품질 문제와 속도 문제는 원인도 측정 지점도 다릅니다.

  2. 4비트 커널의 최적화가 덜 된 문제이며 배치 크기나 단계와는 무관합니다.

    커널 구현이 원인이라면 배치 1에서도 이득이 없었어야 하는데 그쪽에서는 2배 가까이 빨라졌습니다. 같은 커널이 조건에 따라 이득과 손해를 오간다는 것은 병목이 옮겨갔다는 뜻입니다. 무엇이 병목인지 묻지 않고 구현 탓으로 돌리면 배치 크기라는 진짜 변수를 놓치게 됩니다.

  3. 가중치가 작아진 만큼 KV Cache 예산이 늘어나서, 배치당 메모리 접근량이 함께 늘어난 것입니다.

    가중치가 줄면 KV Cache에 쓸 공간이 늘어나는 것은 맞지만, 여유 공간이 생겼다고 요청 하나의 KV Cache가 저절로 커지지는 않습니다. KV Cache 크기는 시퀀스 길이와 모델 구조가 정하고, 여기서는 동시 요청 수를 고정한 채 비교했습니다. 게다가 이 설명으로는 TTFT가 나빠진 이유를 댈 수 없습니다.

  4. 정답가중치 축소는 memory-bound 구간에서만 속도가 되고, 연산 병목에서는 역양자화 비용만 남습니다.

    배치가 작을 때의 decode는 가중치를 읽어오는 시간이 사실상 전부라, 읽을 양이 4분의 1이 되면 그만큼 스텝이 짧아집니다. 반면 긴 프롬프트의 prefill과 배치가 큰 decode는 연산이 병목이라 읽기량 감소가 값을 하지 못합니다. W4A16은 활성값이 16비트 그대로여서 곱셈 직전에 4비트 가중치를 되돌리는 역양자화가 매 연산에 붙고, 그 비용이 이 구간에서는 순손실로 남습니다.

해설

가중치만 줄이는 양자화는 병목이 메모리 대역폭일 때만 속도로 환산됩니다. 배치가 작을 때의 decode가 정확히 그 조건이라, 읽을 가중치가 4분의 1이 되면 스텝 시간도 함께 짧아집니다. 역양자화와 KV Cache 읽기가 그대로 남기 때문에 이론치인 4배까지는 가지 못하고 2배 안팎에 머무는 것이 보통입니다. 그런데 W4A16은 활성값이 16비트 그대로라 실제 행렬 곱은 여전히 16비트로 돌아야 하고, 압축된 가중치를 곱셈 직전에 되돌리는 역양자화가 매번 따라붙습니다. 연산이 병목인 구간, 즉 긴 프롬프트의 prefill이나 배치가 충분히 커진 decode에서는 줄어든 읽기량이 값을 못 하고 이 역양자화 비용만 남습니다. vLLM 문서가 INT4 가중치 양자화를 QPS가 낮은 워크로드용으로 안내하는 이유가 여기 있습니다. 처리량이 목표라면 활성값까지 8비트로 내려 저정밀 텐서 코어를 실제로 돌리는 W8A8 계열이 맞는 선택이고, 두 방식의 우열은 배치 크기를 축으로 뒤집힙니다. 어떤 양자화를 고를지는 정확도 손실만이 아니라 자기 트래픽이 어느 병목에 서 있는지가 정합니다.

이 내용 다시 읽기 →
상황 적용계산구조
$ vllm serve Qwen/Qwen3-32B --dtype bfloat16 --max-model-len 16384 \
      --gpu-memory-utilization 0.90 --no-enable-prefix-caching

INFO  Device: NVIDIA H200 SXM 141GB | HBM3e 4.8 TB/s | BF16 dense 989 TFLOPS
INFO  Model weights: 32.8B params x 2 bytes = 65.6 GB
INFO  GPU KV cache size: 58.0 GB  (토큰당 0.26 MB = 64층 x KV헤드 8 x head_dim 128 x K/V 2개 x 2 bytes)

decode-only 스윕 (컨텍스트 512토큰 고정, prefill 제외)
  batch   step time   generation
      1     17.2 ms      58 tok/s
      8     17.6 ms     455 tok/s
     32     18.4 ms   1,739 tok/s
     64     19.6 ms   3,265 tok/s

실제 서비스 트래픽: 요청당 평균 컨텍스트 8,000토큰

배치를 1에서 64로 64배 키우는 동안 스텝 시간은 17.2ms에서 19.6ms로 14%밖에 늘지 않았습니다. 이 결과의 원인과, 실제 서비스 트래픽에서 배치를 더 키우지 못하게 먼저 막아서는 제약을 옳게 짝지은 것은 무엇입니까?

  1. 정답스텝당 가중치 65.6GB 읽기가 배치와 무관한 상수이기 때문이며, 연산 한계인 배치 206보다 앞선 27요청에서 KV Cache 풀이 막습니다.

    decode 스텝이 하는 일의 대부분은 가중치 65.6GB를 HBM에서 한 번 훑어오는 것이고, 이 비용은 배치가 1이든 64든 똑같습니다. 반면 배치를 키우려면 요청 수만큼 KV Cache가 동시에 살아 있어야 해서, 58GB 풀을 8,000토큰 요청 하나분인 2.1GB로 나누면 27요청 남짓에서 자리가 동납니다.

  2. KV Cache 읽기가 배치와 무관한 상수이기 때문이며, 배치에 비례해 늘어난 가중치 읽기가 4.8TB/s를 먼저 포화시킵니다.

    상수와 변수를 맞바꾼 설명입니다. 가중치는 배치에 묶인 요청들이 전부 같은 것을 쓰므로 스텝당 한 번만 읽히고, KV Cache는 요청마다 내용이 달라 읽을 양이 배치에 비례해 늘어납니다. 스텝 시간이 그나마 14% 오른 것도 커진 KV Cache 쪽 몫이지 가중치 쪽이 아닙니다.

  3. 배치가 KV Cache를 한 벌로 공유해 읽을 양이 늘지 않기 때문이며, 배치가 아니라 컨텍스트 상한 16,384가 먼저 걸립니다.

    가중치는 배치가 공유하지만 KV Cache는 공유되지 않습니다. 요청마다 지금까지 본 토큰이 다르니 K와 V도 달라지고, prefix caching까지 꺼져 있어 요청 수만큼의 캐시를 따로 들고 있어야 합니다. 컨텍스트 상한은 요청 하나가 얼마나 길어질 수 있는지를 정할 뿐, 평균 8,000토큰 트래픽에서 동시 요청 수를 묶는 것은 58GB 풀입니다.

  4. 배치 64에서도 연산 유닛이 데이터를 기다리며 놀고 있기 때문이며, 연산 강도가 206에 닿는 배치에서 연산이 먼저 막습니다.

    연산에 여유가 남았다는 관찰은 맞지만 한계를 잘못 짚었습니다. bf16 decode의 연산 강도는 배치 크기와 같은 값이라 206 근처에서 연산이 포화하는 것은 사실이나, 배치 206을 띄우려면 KV Cache만 430GB가 넘게 필요해 141GB 카드로는 닿을 수 없습니다. 이 서버에서 먼저 걸리는 쪽은 언제나 메모리입니다.

해설

배치를 키워도 스텝 시간이 거의 그대로인 이유는 decode 스텝의 지배적 비용이 배치와 무관한 상수이기 때문입니다. 스텝마다 하는 일은 가중치 65.6GB를 HBM에서 연산 유닛으로 한 번 옮기는 것이고, 그 한 번의 이동으로 배치에 묶인 요청 전부의 다음 토큰이 나옵니다. 읽은 바이트당 연산량, 즉 연산 강도가 정확히 배치 크기와 같아지므로 989 TFLOPS를 4.8TB/s로 나눈 206에 닿기 전까지는 병목이 여전히 대역폭이고, 그래서 배치가 64배가 되는 동안 처리량만 58에서 3,265 tok/s로 뛰었습니다. 다만 이 공짜 점심에는 계산서가 붙습니다. 배치에 들어온 요청 수만큼 KV Cache가 동시에 살아 있어야 하는데, 8,000토큰 컨텍스트면 요청당 2.1GB라 58GB 풀은 27요청 남짓에서 바닥납니다. 연산 한계인 배치 206까지 가려면 캐시만 430GB가 넘게 필요하니, 이 서버에서 배치를 정하는 것은 연산 성능이 아니라 KV Cache에 남은 자리입니다. 처리량이 목표일 때 더 좋은 GPU보다 PagedAttention이나 GQA, KV Cache 양자화처럼 캐시를 아끼는 수단을 먼저 보게 되는 이유가 여기 있습니다.

이 내용 다시 읽기 →

KV Cache가 LLM 서빙을 바꾸는 방식

원문 읽기 →
개념 확인구조
문서 임베딩 서버에서는 GPU 메모리를 '가중치 + 배치 하나가 잠시 쓰는 활성값'으로 계산합니다. 활성값은 배치가 끝나면 회수되므로, 동시 요청을 늘려도 상주 메모리는 거의 변하지 않습니다. 같은 계산법으로 LLM 서버가 감당할 동시 요청 수를 잡으면 실제보다 훨씬 큰 값이 나옵니다.

이 계산법이 LLM 서버에서 어긋나는 이유는 무엇입니까?

  1. 모델 가중치가 훨씬 커서 애초에 남는 메모리가 적기 때문입니다.

    가중치는 모델을 올리는 순간 정해지는 고정분이라 동시 요청 수를 따라 늘지 않습니다. 더 작은 모델로 바꾸면 여유 공간은 커지지만, 요청이 늘수록 메모리가 차오르는 성질 자체는 그대로 남습니다. 요청 수에 따라 변하는 몫이 무엇인지가 질문의 핵심입니다.

  2. 정답요청이 점유하는 메모리가 forward 한 번이 아니라 그 요청의 수명에 묶이기 때문입니다.

    임베딩은 forward 한 번으로 벡터가 나오고 끝나서, 처리가 끝난 요청이 메모리에 남길 것이 없습니다. LLM은 토큰을 하나씩 만드는 내내 그 요청의 K, V가 필요하므로 생성이 끝날 때까지 자리를 지키고, 토큰이 늘 때마다 조금씩 더 자랍니다. 동시 요청 수가 곧 상주하는 캐시의 개수가 됩니다.

  3. decode가 계산 집약적이라 GPU 연산 코어가 먼저 포화되기 때문입니다.

    방향이 반대입니다. decode는 토큰 하나당 연산량이 적은 대신 가중치와 캐시를 메모리에서 읽어와야 해서 대역폭에 먼저 묶입니다. 연산 코어가 남기 때문에 오히려 배치를 키워 처리량을 올리려 하고, 그때 걸리는 벽이 연산이 아니라 메모리입니다.

  4. 요청마다 입력 길이가 달라 배치 패딩으로 버려지는 공간이 크기 때문입니다.

    길이가 제각각인 요청을 한 배치로 묶을 때 생기는 낭비는 실재하는 문제입니다. 다만 그 낭비를 완전히 없애도 진행 중인 요청들의 캐시가 동시에 상주해야 한다는 사실은 남습니다. 상한을 만드는 것은 버려지는 몫이 아니라 상주하는 몫입니다.

해설

문서 임베딩 서버의 메모리 사용량은 배치 하나를 처리하는 순간에만 올랐다가 끝나면 회수됩니다. 요청과 요청 사이에 서버가 붙들고 있어야 할 상태가 없기 때문입니다. LLM은 다릅니다. 요청 하나가 토큰을 100개 만든다면 그 100번의 스텝 내내 해당 요청의 K, V가 GPU에 있어야 하고, 토큰이 늘 때마다 캐시도 함께 자랍니다. 그래서 KV Cache는 속도를 위한 캐시라기보다 그 요청의 진행 상태에 가깝습니다. 일반적인 캐시는 비워도 다음 요청이 다시 계산하면 그만이지만, KV Cache를 비우면 진행 중이던 요청이 프롬프트부터 전부 다시 계산해야 하고 그동안 그 요청의 생성은 멈춥니다. 동시 요청 수의 상한은 연산 능력이 아니라 가중치를 뺀 나머지 메모리를 몇 개의 요청이 나눠 가질 수 있는가로 정해지고, 자리가 다 차면 새 요청은 대기하거나 진행 중이던 요청이 밀려납니다.

이 내용 다시 읽기 →
상황 적용계산
{
  "llama-2-13b": {
    "num_hidden_layers": 40,
    "num_attention_heads": 40,
    "num_key_value_heads": 40,
    "hidden_size": 5120
  },
  "llama-3.1-70b": {
    "num_hidden_layers": 80,
    "num_attention_heads": 64,
    "num_key_value_heads": 8,
    "hidden_size": 8192
  }
}

두 모델을 모두 bf16으로 서빙합니다. head_dim은 hidden_size를 num_attention_heads로 나눈 값입니다. 토큰 하나가 차지하는 KV Cache 크기를 비교하면 어떻게 됩니까?

  1. 70B 쪽이 약 2배 큽니다. 레이어가 80개로 두 배입니다.

    레이어 수만 보면 그렇게 보이지만 KV 크기는 레이어 수와 KV head 수를 함께 곱해서 정해집니다. 70B는 레이어가 2배인 대신 KV head가 40개에서 8개로 5분의 1이라, 두 항을 곱하면 오히려 2.5분의 1이 됩니다.

  2. 70B 쪽이 약 5.3배 큽니다. 파라미터 수 비에 비례합니다.

    파라미터 수는 가중치가 차지하는 고정분을 정할 뿐 토큰당 KV 크기와 직접 연결되지 않습니다. KV 크기를 정하는 값은 레이어 수, KV head 수, head_dim, dtype 네 가지뿐입니다. FFN 폭을 넓혀 파라미터를 늘린 모델은 KV가 전혀 늘지 않습니다.

  3. 두 모델이 거의 같습니다. head_dim이 128로 동일합니다.

    head_dim이 두 모델 모두 128인 것은 맞습니다. 5120을 40으로 나눠도, 8192를 64로 나눠도 128입니다. 다만 head_dim은 곱해지는 네 항 중 하나일 뿐이고, 레이어 수와 KV head 수가 두 모델에서 다르므로 결과도 갈립니다.

  4. 정답13B 쪽이 약 2.5배 큽니다. 70B는 KV head가 8개뿐입니다.

    토큰당 KV는 2 × 레이어 수 × KV head 수 × head_dim × dtype 바이트입니다. 13B는 2 × 40 × 40 × 128 × 2로 819,200바이트, 즉 800 KiB이고 70B는 2 × 80 × 8 × 128 × 2로 327,680바이트, 즉 320 KiB입니다. 파라미터가 5배 넘게 큰 모델이 토큰당 KV는 오히려 2.5분의 1입니다.

해설

토큰 하나의 KV 크기는 2 × 레이어 수 × KV head 수 × head_dim × dtype 바이트로 결정됩니다. 맨 앞의 2는 K와 V를 각각 한 벌씩 저장하기 때문에 붙습니다. 이 식에 파라미터 총량은 들어가지 않는데, 파라미터의 상당 부분을 차지하는 FFN이 KV를 전혀 남기지 않기 때문입니다. num_key_value_heads가 num_attention_heads와 같으면 query head마다 K, V를 따로 두는 MHA이고, 13B가 그렇습니다. 70B는 query head 64개가 KV head 8개를 나눠 쓰는 GQA라 같은 head_dim에서도 KV가 8분의 1로 줄어들고, 그 결과 파라미터가 5배 넘게 큰 쪽이 토큰당 KV는 더 작습니다. 슬라이딩 윈도우는 여기서 한 걸음 더 나가, 해당 레이어가 최근 윈도우 길이만큼만 캐시를 들고 있게 해서 컨텍스트가 길어져도 그 레이어의 KV는 더 자라지 않게 만듭니다. 모델을 고를 때 가중치 크기만 보고 KV 여유 공간을 짐작하면 이 두 장치의 효과를 통째로 놓치게 됩니다.

이 내용 다시 읽기 →
분석과 판단확장운영예측
# 두 서비스 모두 같은 GPU, 같은 모델, 같은 vLLM 옵션으로 기동했고 기동 로그도 동일합니다
# max_num_seqs는 두 서비스 모두 충분히 크게 잡아 KV 풀이 유일한 상한입니다
INFO [kv_cache_utils.py] GPU KV cache size: 262,144 tokens
INFO [kv_cache_utils.py] Maximum concurrency for 32,768 tokens per request: 8.00x

# 실제 트래픽 (평균 프롬프트 + 평균 출력)
서비스 A     2,000 + 300 토큰
서비스 B    24,000 + 300 토큰

두 서비스가 실제로 동시에 처리하는 요청 수는 각각 대략 몇 개입니까?

  1. 두 서비스 모두 8개 안팎입니다. 로그가 상한을 8.00x로 못박았습니다.

    그 줄은 모든 요청이 max_model_len인 32,768 토큰을 꽉 채웠다고 가정했을 때의 값입니다. vLLM은 KV 풀의 블록 수를 그런 요청 하나가 쓰는 블록 수로 나눠 이 숫자를 계산합니다. 실제 요청이 그보다 짧으면 동시 처리 수는 8을 훨씬 넘어갑니다.

  2. 두 서비스 모두 870개 안팎입니다. 새로 자라는 것은 출력 300 토큰뿐입니다.

    생성 중에 새로 자라는 양이 300 토큰으로 같은 것은 맞습니다. 하지만 prefill이 만들어낸 프롬프트 몫의 K, V가 사라지는 것이 아니라 응답이 끝날 때까지 그대로 상주합니다. 점유량을 정하는 것은 늘어난 양이 아니라 상주하는 총량입니다.

  3. 정답A는 약 114개, B는 약 11개입니다. 프롬프트의 KV도 끝까지 남아 있습니다.

    요청 하나가 붙드는 토큰 수는 A가 약 2,300, B가 약 24,300입니다. 262,144 토큰짜리 풀을 나누면 A는 약 114개, B는 약 11개가 됩니다. 기동 로그가 같아도 실제 동시 처리 수는 트래픽의 평균 컨텍스트 길이가 정합니다.

  4. A는 약 57개, B는 약 5개입니다. 풀의 절반은 prefill 작업 공간으로 남겨둡니다.

    KV 풀은 전부 KV 저장에 쓰입니다. prefill이 쓰는 중간 활성값은 풀이 아니라 시작할 때 따로 떼어둔 활성값 몫에서 나가므로, 풀을 절반만 세어야 할 이유가 없습니다. 262,144 토큰은 그대로 요청들에 나눠 줄 수 있는 양입니다.

해설

GPU KV cache size는 KV 풀 전체가 담을 수 있는 토큰 수이고, Maximum concurrency는 모든 요청이 max_model_len을 꽉 채웠다고 가정한 최악의 경우입니다. vLLM은 풀의 블록 수를 그런 요청 하나가 차지하는 블록 수로 나눠 이 값을 뽑으므로, 실제 트래픽이 짧으면 동시 처리 수는 로그의 숫자를 크게 웃돕니다. 뒤집어 말하면 두 줄 모두 트래픽에 대해서는 아무것도 말해주지 않기 때문에, 같은 로그를 보고도 실제 동시 처리 수는 서비스마다 달라집니다. 요청 하나가 붙드는 양은 프롬프트와 출력을 합친 총 토큰 수에 정비례합니다. prefill로 계산한 프롬프트의 K, V는 매 decode 스텝의 attention이 참조해야 하므로 응답이 끝나기 전에는 회수되지 않습니다. 그래서 점유량을 정하는 것은 프롬프트 길이만도 출력 길이만도 아닌 둘을 합친 총 토큰 수이고, 2,300 대 24,300이라 요청당 점유량이 약 10.6배 벌어집니다. 용량 산정을 max_model_len이 아니라 실제 트래픽의 평균 컨텍스트 길이로 해야 이 차이를 배포 전에 잡을 수 있습니다. 같은 이유로 max_model_len만 크게 잡아두면 기동 로그의 concurrency 값은 작아지지만 실제 처리량은 그대로입니다.

이 내용 다시 읽기 →
상황 적용구조계산
# 같은 GPU, 같은 모델, 배치 1, KV Cache를 켠 상태에서 decode만 측정했습니다
# 레이어 32, KV head 8, head_dim 128, bf16이라 토큰당 KV는 128 KiB입니다
# 가중치 16 GiB는 컨텍스트 길이와 무관하게 스텝마다 전부 읽습니다

컨텍스트     스텝당 KV 읽기     스텝당 읽기 총량     출력 토큰당 지연
   4,000         0.49 GiB          16.5 GiB           10.3 ms
  32,000         3.91 GiB          19.9 GiB           12.4 ms
 128,000        15.63 GiB          31.6 GiB           19.8 ms

컨텍스트가 길어질수록 출력 토큰당 지연이 함께 늘어납니다. 이 결과에 대한 설명으로 옳은 것은 무엇입니까?

  1. 정답새 K, V를 계산하는 몫은 컨텍스트와 무관하게 토큰 하나로 고정되고, 쌓인 캐시를 훑는 몫만 컨텍스트에 비례해 커집니다.

    캐시가 없애주는 것은 앞선 토큰의 K, V를 매 스텝 다시 계산하는 낭비까지입니다. 새로 계산하는 K, V는 컨텍스트가 32배가 되어도 새 토큰 하나 분량 그대로인 반면, attention이 읽어야 하는 캐시는 0.49 GiB에서 15.63 GiB로 정확히 32배 늘어납니다.

  2. 컨텍스트가 길어질수록 스텝마다 읽는 가중치 양도 함께 늘어나서, 총 읽기가 두 항 모두에서 커진 결과입니다.

    가중치 16 GiB는 컨텍스트 길이와 무관하게 매 스텝 전부 읽는다고 표에 명시되어 있습니다. 총 읽기에서 KV 몫을 빼면 세 줄 모두 16 GiB로 같습니다. 컨텍스트를 따라 변한 항은 KV 읽기 하나뿐입니다.

  3. 캐시가 제구실을 했다면 지연이 고정이어야 하므로, 앞선 토큰의 K, V를 다시 계산하고 있다는 신호입니다.

    캐시가 정상이어도 attention은 스텝마다 지금까지 쌓인 K, V를 전부 훑어야 하므로 지연이 고정될 수 없습니다. 게다가 K, V를 다시 계산한다면 늘어나는 것은 연산량이지 캐시에서 읽어오는 바이트가 아닌데, 표에서 커진 항은 KV 읽기입니다.

  4. 컨텍스트가 32배 커지는 동안 지연은 2배 미만이므로, 캐시를 훑는 몫은 컨텍스트에 로그로 자랍니다.

    지연이 1.9배에 그친 것은 맞지만 이유가 다릅니다. 컨텍스트와 무관한 가중치 16 GiB가 총 읽기의 바닥을 깔고 있어 전체 증가율이 눌린 것입니다. 캐시를 훑는 몫만 떼어 보면 0.49 GiB에서 15.63 GiB로 32배, 즉 컨텍스트에 정비례합니다.

해설

KV Cache가 없애는 것은 앞선 토큰의 K, V를 매 스텝 다시 계산하는 낭비입니다. 캐시를 쓰면 스텝마다 새로 계산하는 K, V가 새 토큰 하나로 고정되고, 그래서 생성 전체의 K, V 계산량이 O(n²)에서 O(n)으로 내려갑니다. 하지만 캐시가 선형으로 만들어주는 범위는 거기까지입니다. attention은 캐시가 있어도 현재 Query를 지금까지 쌓인 K 전부와 곱하고 V 전부를 가중합해야 하므로, 이 몫만은 컨텍스트가 길어질수록 함께 무거워집니다. decode가 메모리 대역폭에 먼저 묶이는 단계라서 이 부담은 스텝마다 읽어야 하는 캐시 바이트로 그대로 드러납니다. 컨텍스트가 4,000에서 128,000으로 32배가 될 때 KV 읽기가 0.49 GiB에서 15.63 GiB로 똑같이 32배 늘어난 것이 그 몫입니다. 그런데도 지연이 1.9배만 늘어 보이는 이유는 컨텍스트와 무관한 가중치 16 GiB가 총 읽기의 바닥을 깔고 있기 때문입니다. 컨텍스트가 128,000을 넘어서면 KV 몫이 가중치를 앞지르고, 그때부터는 토큰당 지연이 컨텍스트 길이를 거의 그대로 따라갑니다. 배치를 키우면 이 균형이 더 빨리 기웁니다. 가중치는 배치 전체가 한 번 읽어 나눠 쓰지만 캐시는 요청마다 자기 것을 읽어야 해서, 동시 요청이 늘수록 KV 몫만 배수로 불어납니다. "KV Cache를 켰으니 토큰당 비용은 컨텍스트와 무관하다"는 짐작이 어긋나는 지점이 여기입니다.

이 내용 다시 읽기 →

vLLM의 핵심 원리 PagedAttention 파헤치기

원문 읽기 →
개념 확인계산
프롬프트 180토큰짜리 요청이 들어와 41토큰을 생성하고 <EOS>로 끝났습니다. 블록 크기는 기본값 16토큰입니다.

이 요청이 끝나는 시점에 점유하고 있는 물리 블록 수와, 마지막 블록에 남은 빈칸 수는 각각 몇 개입니까?

  1. 블록 14개를 쓰고 마지막 블록에 13칸이 빕니다.

    221을 16으로 나눈 나머지 13은 마지막 블록에 들어찬 칸 수이지 빈칸 수가 아닙니다. 빈칸은 16에서 그 값을 뺀 3칸입니다. 채워진 칸과 빈칸을 뒤집어 세는 실수가 자주 나옵니다.

  2. 정답블록 14개를 쓰고 마지막 블록에 3칸이 빕니다.

    전체 토큰은 180 더하기 41로 221개이고, 16으로 나누면 13.8이므로 블록 14개가 필요합니다. 14개는 224칸이니 224에서 221을 빼면 빈칸은 3칸입니다. 앞의 13개 블록은 모두 꽉 차 있습니다.

  3. 블록 15개를 쓰고 마지막 블록에 7칸이 빕니다.

    프롬프트와 출력이 각각 자기 블록에서 시작한다고 보면 12개와 3개로 나뉘어 15개가 되고, 출력 쪽 마지막 블록에 7칸이 빕니다. 실제로는 논리 블록이 시퀀스 전체에 걸쳐 왼쪽부터 순서대로 채워지므로, 프롬프트의 마지막 토큰과 첫 출력 토큰이 같은 블록 안에 나란히 들어갑니다.

  4. 요청의 max_tokens 값에 따라 정해져 221토큰만으로는 알 수 없습니다.

    max_tokens만큼 미리 잡아두는 것은 연속 할당 방식의 동작입니다. 블록은 앞 블록이 다 찬 뒤에야 하나씩 새로 붙으므로, 점유량은 실제로 만들어진 토큰 수만으로 정해집니다.

해설

블록 수는 전체 토큰 수를 블록 크기로 나눠 올림한 값이고, 빈칸은 그 결과에 블록 크기를 곱한 값에서 토큰 수를 뺀 값입니다. 나머지 연산으로 나오는 값은 마지막 블록에 채워진 칸 수라서, 그대로 빈칸이라고 읽으면 답이 뒤집힙니다. 논리 블록은 프롬프트 구간과 출력 구간을 구분하지 않고 시퀀스 처음부터 왼쪽에서 오른쪽으로 이어서 채워집니다. 그래서 프롬프트의 끝과 출력의 시작이 한 블록을 나눠 쓰고, 경계에서 블록이 하나 더 생기는 일이 없습니다. 빈칸이 생길 수 있는 자리도 항상 마지막 블록 하나뿐입니다. 시퀀스가 아무리 길어져도 이 낭비는 블록 크기 미만으로 제한되고, 블록 크기 16에서는 시퀀스당 최대 15칸입니다. 시퀀스가 길수록 전체에서 이 15칸이 차지하는 비중은 작아집니다.

이 내용 다시 읽기 →
상황 적용함정면접
팀 위키에 이렇게 적혀 있습니다. "vLLM은 PagedAttention으로 요청당 KV Cache 크기를 줄이기 때문에, 같은 GPU에 더 많은 요청을 담을 수 있다."

이 문장의 결론은 맞습니다. 그렇게 되는 실제 이유는 무엇입니까?

  1. 어텐션 커널이 블록 단위로 KV를 읽어 계산이 빨라진 만큼 요청을 더 받게 된 것입니다.

    PagedAttention 커널은 블록 테이블을 따라가며 흩어진 블록을 모아 읽으므로, 연속 배열을 그대로 훑는 커널보다 어텐션 자체는 오히려 조금 느립니다. 동시에 올라가는 요청 수를 늘린 것은 커널 속도가 아니라 점유 방식의 변화입니다.

  2. KV를 16토큰 단위로 묶어 저장하면서 형식이 조밀해져 실제 바이트 수가 줄어듭니다.

    블록으로 나누는 것은 저장 위치를 쪼개는 일이지 값을 압축하는 일이 아닙니다. 같은 토큰의 K와 V는 블록에 담기든 연속 배열에 담기든 바이트 수가 같습니다. 저장 바이트를 실제로 줄이는 것은 KV 양자화 같은 별개의 기법입니다.

  3. 정답토큰당 KV 크기는 그대로이고, 아직 쓰지 않는 자리를 붙잡지 않게 된 것입니다.

    토큰 하나가 차지하는 K와 V의 크기는 레이어 수, 헤드 수, 헤드 차원, 자료형으로 정해지고 PagedAttention은 그중 어느 것도 건드리지 않습니다. 달라진 것은 아직 생성하지 않은 토큰의 자리를 미리 잡아두지 않는다는 점입니다. 100토큰을 만든 요청은 100토큰치만 점유하고 나머지는 다른 요청이 씁니다.

  4. 요청이 끝나기 전에도 앞쪽 블록을 반납해 KV를 돌려 쓰게 된 것입니다.

    앞쪽 블록은 뒤에 생성되는 토큰의 어텐션이 계속 읽어야 하므로, 윈도우 밖을 보지 않는 sliding window 레이어가 아니라면 생성 도중에 내놓을 수 없습니다. 생성 중에 KV를 내려놓는 것은 선점될 때 재계산을 택하는 별개의 동작이고, 낭비가 사라진 이유가 아닙니다.

해설

PagedAttention은 KV Cache를 작게 만드는 기술이 아니라 한정된 KV 공간을 남김없이 쓰게 하는 기술입니다. 토큰 하나의 KV 크기는 모델 구조와 자료형이 정하고, 블록으로 나눈다고 그 값이 달라지지 않습니다. 달라지는 것은 점유 방식입니다. 연속 할당은 요청이 앞으로 쓸지도 모르는 최대 길이만큼을 처음부터 붙잡아, 실제로 쓰는 양과 점유하는 양이 크게 벌어집니다. 블록 단위로 필요할 때만 붙이면 그 둘이 거의 같아지고, 남는 공간은 곧바로 다른 요청 몫이 됩니다. 그래서 요청당 KV는 그대로인데도 같은 GPU에 동시에 올라가는 요청 수가 늘어납니다. 연속 할당 시스템에서 KV 공간 중 실제 토큰 상태를 담는 데 쓰인 비율은 논문 프로파일링에서 20.4%에서 38.2%에 그쳤고, 나머지는 예약과 단편화로 비어 있었습니다.

이 내용 다시 읽기 →
상황 적용진단구조
[kv-alloc] total 8192 slots | in use 7292 | free 900
[kv-alloc] free runs: 384, 256, 160, 100
[sched]    req-2041 needs 512 contiguous slots
[sched]    req-2041 REJECTED: no contiguous run large enough

빈 자리가 900칸 남았는데 512칸짜리 요청이 거절됐습니다. 같은 상태에서 KV를 16토큰 블록으로 관리하도록 바꾸면 이 요청은 어떻게 되고, 그렇게 되는 이유는 무엇입니까?

  1. 정답수용됩니다. 필요한 블록 32개를 흩어진 빈 블록에서 모아 주면 됩니다.

    모든 블록의 크기가 같으므로 빈 블록 하나는 어떤 요청의 어떤 논리 블록에도 들어갑니다. 블록 풀로 보면 남은 900칸은 빈 블록 56개이고, 512토큰은 블록 32개이니 거기서 32개를 꺼내 블록 테이블에 적어두면 그만입니다. 물리적으로 이어져 있을 필요가 전혀 없습니다.

  2. 여전히 거절됩니다. 어텐션 계산에 연속된 KV가 필요하다는 조건은 그대로입니다.

    그 조건을 깨는 것이 PagedAttention 커널입니다. 블록 테이블을 따라가며 흩어진 블록을 읽어 어텐션을 계산하므로 KV가 물리적으로 이어져 있지 않아도 됩니다. 대신 블록 테이블을 참조하고 블록마다 접근을 나누는 만큼 어텐션 커널 자체는 조금 느려집니다.

  3. 빈 조각을 앞으로 밀어 모으는 압축이 먼저 돌고 나서야 수용됩니다.

    압축은 KV Cache에 현실적인 선택이 아닙니다. 옮겨야 할 데이터가 기가바이트 단위인데 그 이동이 매 스텝 지연에 그대로 얹히기 때문입니다. 블록 단위 관리는 조각을 모으는 대신 조각난 채로 쓸 수 있게 만들어 압축이 필요한 상황 자체를 없앱니다.

  4. 수용됩니다. 흩어진 900칸을 이어 붙여 512칸짜리 연속 구간으로 되돌릴 수 있습니다.

    블록 단위 관리는 조각을 이어 붙이지 않습니다. 애초에 연속 구간을 만들 필요를 없애는 것이 요점이고, 흩어진 블록은 흩어진 채로 블록 테이블에 등록됩니다. 이어 붙여야 한다는 발상은 여전히 연속 할당의 사고에 머물러 있습니다.

해설

외부 단편화는 요청마다 요구하는 덩어리 크기가 다를 때만 생깁니다. 크기가 제각각이면 어느 요청에도 맞지 않는 어중간한 빈틈이 쌓이고, 총량은 충분한데 연속된 자리가 없어 거절하는 상황이 벌어집니다. 블록 단위 관리는 이 전제 자체를 없앱니다. 모든 블록이 같은 크기라 빈 블록 하나는 언제나 어떤 요청에든 들어가고, 못 쓰는 조각이라는 개념이 성립하지 않습니다. 이 성질은 블록 크기를 얼마로 잡든 유지되므로, 외부 단편화는 줄어드는 것이 아니라 사라집니다. 남는 낭비는 시퀀스의 마지막 블록에 생기는 빈칸뿐이고, 이것은 블록 안쪽에 생기는 내부 단편화입니다. 조각을 한데 모으는 압축도 이론적으로는 답이지만, 옮겨야 할 KV가 너무 커서 서빙에서는 선택지가 되지 못합니다.

이 내용 다시 읽기 →
분석과 판단확장운영예측
# 평균 프롬프트 150토큰, 평균 출력 50토큰
# 요청의 상당수가 같은 시스템 프롬프트로 시작
vllm serve $MODEL --block-size 32

블록 크기를 기본값 16에서 32로 올렸습니다. 이 워크로드에서 무엇이 나빠집니까?

  1. 블록마다 크기가 달라져 어중간한 빈틈이 쌓이는 외부 단편화가 되살아납니다.

    블록 크기를 32로 올려도 풀 안의 모든 블록은 여전히 서로 같은 크기입니다. 크기가 제각각일 때만 어느 요청에도 맞지 않는 빈틈이 생기므로, 큰 블록만 있는 풀에서도 빈 블록은 어떤 요청에든 들어갑니다. 커진 블록이 만드는 낭비는 블록 안쪽에 갇히는 내부 단편화입니다.

  2. 공통 시스템 프롬프트의 KV가 요청마다 따로 저장되어 접두어 공유 자체가 동작하지 않습니다.

    블록 크기를 키워도 여러 요청의 블록 테이블이 같은 물리 블록을 가리키는 구조는 그대로라 공유는 계속 동작합니다. 달라지는 것은 공유가 성립하는 입도이고, 32의 배수까지만 맞아떨어져 재사용 구간이 짧아질 뿐입니다.

  3. 물리 블록 풀 전체 크기가 절반으로 줄어 담을 수 있는 총 토큰 수가 줄어듭니다.

    풀 전체 크기는 GPU 메모리에서 모델 가중치와 활성화가 쓸 몫을 뺀 나머지로 정해지고, 블록 크기는 그 공간을 몇 조각으로 자를지만 정합니다. 블록 개수가 절반이 되는 대신 블록 하나가 두 배 커지므로 담을 수 있는 총 토큰 수는 거의 그대로입니다.

  4. 정답마지막 블록의 빈칸이 커지고 접두어 공유도 32토큰 단위로만 맞습니다.

    평균 200토큰짜리 시퀀스는 32토큰 블록 7개, 즉 224칸을 잡고 24칸을 남깁니다. 블록 크기가 16이었을 때는 13개, 즉 208칸을 잡고 8칸만 남겼습니다. 게다가 블록 공유는 블록이 통째로 일치할 때 성립하므로, 공통 시스템 프롬프트도 32의 배수까지만 재사용됩니다.

해설

블록 크기는 GPU 병렬성과 내부 단편화 사이의 절충입니다. 블록이 너무 작으면 커널이 한 번에 처리할 KV가 적어 GPU를 다 채우지 못하고 블록 테이블도 길어집니다. 블록이 너무 크면 시퀀스당 마지막 블록에 남는 빈칸이 커지고, 블록 단위로만 성립하는 공유의 입도가 거칠어져 재사용이 맞아떨어질 확률이 떨어집니다. 낭비의 상한이 블록 크기에서 1을 뺀 값이라, 블록을 키운 만큼 시퀀스 하나가 버릴 수 있는 최대 토큰 수도 함께 커집니다. 시퀀스가 블록보다 짧은 워크로드에서는 이 절충이 특히 나쁘게 기웁니다. 블록 크기를 바꿔가며 측정한 실험에서도 긴 대화 트레이스는 16부터 128까지 비슷했지만, 시퀀스가 짧은 트레이스에서는 큰 블록이 성능을 뚜렷하게 떨어뜨렸습니다. 16이 기본값인 이유는 GPU를 효율적으로 쓸 만큼 크면서 대부분의 워크로드에서 내부 단편화가 눈에 띄지 않을 만큼 작기 때문입니다.

이 내용 다시 읽기 →
분석과 판단확장구조계산
블록 크기는 16토큰입니다. 같은 시스템 프롬프트로 시작하는 요청 쌍이 둘 있고, 각 쌍은 공통 구간이 놓인 논리 블록을 전부 같은 물리 블록에 매핑해 나눠 쓰는 중입니다. 그래서 그 물리 블록들의 참조 카운트는 모두 2입니다. 쌍 P: 두 요청의 토큰 열이 앞에서 100개까지 같고, 101번째 토큰부터 갈라집니다. 쌍 Q: 두 요청의 토큰 열이 앞에서 96개까지 같고, 97번째 토큰부터 갈라집니다. 이제 네 요청이 각자 갈라지는 첫 토큰의 KV를 씁니다.

이 쓰기로 각 쌍에서 실제로 복사되는 물리 블록은 몇 개입니까?

  1. 쌍 P에서 7개, 쌍 Q에서 6개가 복사되어 모두 13개가 됩니다.

    공통 구간이 차지한 블록 수를 그대로 센 값입니다. 100토큰은 블록 7개, 96토큰은 블록 6개를 씁니다. 갈라짐이 공유를 통째로 끝낸다는 전제가 틀렸습니다. 앞쪽 블록들은 이미 확정된 토큰의 K와 V만 담고 있어 양쪽 요청이 읽기만 하므로, 값이 달라질 일이 없어 한 벌로 계속 공유됩니다. 복사가 필요한 자리는 두 요청이 서로 다른 값을 써 넣으려는 블록뿐입니다.

  2. 쌍 P와 쌍 Q 양쪽에서 각각 1개씩, 모두 2개가 복사됩니다.

    갈라지는 순간 자체를 복사의 방아쇠로 본 답입니다. 복사를 부르는 것은 갈라짐이 아니라 이미 공유 중인 블록 안으로 쓰기가 들어오는 일입니다. 96은 16의 배수라 쌍 Q는 마지막 공유 블록까지 꽉 차 있고, 97번째 토큰은 그 블록이 아니라 아직 매핑이 없는 다음 논리 블록으로 갑니다. 꽉 찬 블록은 이후 아무도 덧쓰지 않으니 복사할 이유가 없습니다.

  3. 정답쌍 P에서 1개가 복사되고, 쌍 Q에서는 한 개도 복사되지 않습니다.

    100을 16으로 나누면 나머지가 4라, 쌍 P의 마지막 공유 블록은 4칸만 차 있습니다. 101번째 토큰이 그 블록의 다섯 번째 칸으로 들어가는데 참조 카운트가 2이므로 복사가 일어나고, 그 순간 원래 블록의 참조 카운트가 1로 내려가 나머지 한 요청은 제자리에 씁니다. 96은 16으로 나누어떨어지므로 쌍 Q의 공유 블록은 모두 꽉 차 있고, 97번째 토큰은 아직 없던 논리 블록으로 가니 두 요청이 빈 블록을 하나씩 새로 받습니다.

  4. 쌍 P에서 2개가 복사되고, 쌍 Q에서는 한 개도 복사되지 않습니다.

    갈라지는 두 요청이 각자 한 벌씩 복사한다고 본 값입니다. 먼저 쓴 요청이 새 블록을 받아가면서 원래 블록을 놓아주기 때문에 그 블록의 참조 카운트는 1로 내려가고, 뒤에 쓰는 요청은 더 이상 공유 상태가 아닌 그 블록에 그대로 덧씁니다. 그래서 두 요청이 동시에 갈라져도 실제 복사는 한 번에 그칩니다.

해설

블록 테이블이 논리 블록과 물리 블록 사이에 한 겹의 간접 참조를 놓아준 덕분에, 서로 다른 요청의 블록 테이블이 같은 물리 블록을 가리키는 일이 가능해집니다. 공통 프롬프트의 KV를 한 벌만 두고 여러 요청이 나눠 쓰는 것이 여기서 나옵니다. 복사가 필요해지는 조건은 두 가지가 겹칠 때뿐입니다. 쓰려는 블록의 참조 카운트가 1보다 크고, 그 블록에 아직 빈 칸이 남아 쓰기가 그 안으로 들어갈 때입니다. 그래서 판정은 공통 접두사 길이를 블록 크기로 나눈 나머지 하나로 끝납니다. 나머지가 0이 아니면 마지막 공유 블록이 덜 찬 상태이고 다음 토큰이 그 안으로 들어가니 그 블록 하나가 복사됩니다. 나머지가 0이면 공유 블록이 전부 꽉 차 있어 다음 토큰은 매핑이 아직 없는 논리 블록으로 가고, 각 요청이 빈 블록을 하나씩 새로 받으므로 복사가 아예 생기지 않습니다. 어느 쪽이든 복사되는 양은 블록 하나를 넘지 않고, 이미 꽉 찬 앞쪽 블록들은 읽기만 하는 자리라 끝까지 공유된 채 남습니다. 공유하던 요청이 셋 이상이어도 셈은 같습니다. 먼저 쓴 요청이 새 블록으로 옮겨가며 참조 카운트를 하나씩 낮추다가 마지막 한 요청이 남은 블록을 그대로 물려받으므로, 복사 횟수는 요청 수에서 1을 뺀 만큼입니다.

이 내용 다시 읽기 →

Continuous Batching과 Chunked Prefill 완전 이해

원문 읽기 →
개념 확인구조계산
# 서버 기동 인자
--max-num-batched-tokens 4096  --max-num-seqs 64  --max-model-len 32768

# 어느 스텝 직전의 큐 상태
running : A, B, C, D, E   (다섯 요청 모두 decode 단계, 각 1토큰 필요)
waiting : X               (프롬프트 12,000토큰, 아직 한 토큰도 계산 안 됨)

vLLM V1 스케줄러가 이번 스텝에 만들어낼 {요청ID: 토큰 수} 스케줄은 무엇입니까?

  1. 정답A부터 E에 1토큰씩 주고, 남은 4,091토큰을 전부 X의 첫 청크로 씁니다.

    스케줄러는 running 큐를 먼저 훑어 예산을 채우고, 남은 예산으로 waiting 큐를 채웁니다. decode 다섯 개가 5토큰을 가져가면 4,091토큰이 남고, X의 12,000토큰은 그 안에 안 들어가니 남은 만큼만 잘려 들어갑니다. 이 잘라 넣기가 chunked prefill입니다.

  2. X의 첫 청크에 4,096토큰을 주고, A부터 E는 다음 스텝으로 밀립니다.

    waiting 큐를 먼저 보는 순서였다면 긴 요청이 들어올 때마다 진행 중인 요청의 토큰이 멈춥니다. V1은 running 큐를 먼저 훑기 때문에 그 일이 생기지 않습니다.

  3. A부터 E에 1토큰씩 주고, 남은 4,091토큰은 놀린 채 X를 다음 스텝으로 미룹니다.

    예산에 통째로 안 들어가면 대기시키는 것은 chunked prefill이 없을 때의 동작입니다. 프롬프트가 12,000토큰인데 예산이 4,096이면 X는 영원히 시작하지 못합니다. 잘라서라도 넣는 것이 V1 스케줄러의 기본 동작입니다.

  4. A부터 E에 1토큰씩 주고, 그와 별개로 X의 첫 청크에 4,096토큰을 씁니다.

    예산은 그 스텝의 forward pass에 밀어 넣는 토큰 전부에 걸리는 상한입니다. decode 토큰도 같은 배치에 실리므로 예산에서 함께 차감됩니다. decode가 몇 개 안 되면 차이가 작지만, 동시 요청이 수백 개면 이 몫이 예산에서 무시 못 할 자리를 차지합니다.

해설

한 스텝의 스케줄은 running 큐를 먼저 채우고 남은 예산을 waiting 큐로 넘기는 두 단계로 만들어집니다. running 큐의 요청은 대부분 decode라 요청당 1토큰씩만 쓰므로, 동시 요청이 수백 개가 아닌 이상 예산의 대부분이 그대로 남습니다. 그 남은 몫이 새 요청의 prefill 청크 크기가 되고, 프롬프트가 그보다 길면 딱 남은 만큼만 잘려 들어갑니다. max_num_batched_tokens가 곧 청크 크기의 상한 역할을 하는 이유가 여기에 있습니다. 한 가지 더 짚을 것은, 한 번 시작한 요청은 prefill이 끝나지 않았어도 running 큐로 옮겨진다는 점입니다. X의 두 번째 청크부터는 waiting 큐가 아니라 running 큐를 훑는 단계에서 잡히므로, 이미 시작한 prefill이 새로 도착한 요청보다 먼저 예산을 가져갑니다. running과 waiting을 가르는 기준은 prefill이냐 decode냐가 아니라 이미 시작했느냐입니다.

이 내용 다시 읽기 →
상황 적용연결 문항진단
# 서버 기동 인자
--max-num-batched-tokens 8192  --max-num-seqs 256

$ vllm bench serve --request-rate inf --max-concurrency 16
============ Serving Benchmark Result ============
Successful requests:                          900
Mean TTFT (ms):                            902.50
Median ITL (ms):                            11.83
P99 ITL (ms):                              231.44
==================================================

# 트래픽: 프롬프트 5,000~9,000토큰, 출력 200~400토큰
# 엔진 로그: Running 16 reqs, Avg prompt throughput 13,280 tokens/s,
#            GPU KV cache usage 63.4%, Preemptions 항목은 한 번도 찍히지 않음

median ITL은 12ms 수준인데 P99만 231ms로 튑니다. 사용자들은 토큰이 잘 나오다가 주기적으로 한 박자씩 멈춘다고 말합니다. 이 순간 스케줄러 안에서 무슨 일이 벌어지고 있습니까?

  1. 긴 prefill이 그 스텝을 독점해 진행 중인 요청의 decode가 아예 빠집니다.

    chunked prefill이 없던 시절의 동작입니다. V1 스케줄러는 running 큐의 decode부터 예산에 채우므로 decode가 스텝에서 통째로 밀려나는 일은 없습니다. 빠지는 것이 아니라 함께 실린 채로 스텝이 길어집니다.

  2. KV 블록이 모자라 선점이 일어나고, 선점된 요청이 재계산되는 동안 토큰이 멈춥니다.

    선점이라면 엔진 로그에 Preemptions 항목이 나타나는데 여기에는 그 흔적이 없습니다. 선점은 특정 요청의 진행분을 0으로 되돌리는 사건이라 231ms보다 훨씬 큰 공백을 만듭니다.

  3. 매 스텝 배치를 새로 구성하는 스케줄링 비용이 스텝 사이에 끼어들어 간격이 벌어집니다.

    스케줄 결정은 요청 수만큼의 항목을 담은 딕셔너리를 만드는 정도의 일이라 밀리초 단위 지연을 만들지 않습니다. 매 스텝 배치를 다시 짜는 것은 continuous batching의 비용이 아니라 이득의 원천입니다.

  4. 정답prefill 청크를 크게 실은 스텝이 decode까지 함께 태우면서 그 스텝만 길어집니다.

    예산이 8,192이면 새 요청의 prefill 청크도 그 크기까지 커집니다. compute-bound인 prefill 수천 토큰이 실린 스텝은 decode만 있는 스텝보다 한 자릿수 이상 오래 걸립니다. decode는 그 스텝에도 함께 실려 토큰을 만들지만, 그 한 스텝이 길어진 만큼 ITL이 그대로 벌어집니다.

해설

ITL 꼬리는 그 스텝에 무엇이 함께 실렸는가로 결정됩니다. decode만 있는 스텝은 요청당 1토큰씩만 계산하니 memory-bound로 짧게 끝나지만, prefill 청크가 함께 실린 스텝은 수천 토큰을 병렬로 계산하는 compute-bound 작업이 됩니다. 같은 배치의 decode 요청들은 그 스텝이 끝나야 다음 토큰을 받으므로, 스텝 길이가 그대로 ITL이 됩니다. chunked prefill은 decode를 스텝에서 밀어내지 않는다는 점에서 head-of-line blocking을 없앤 것이 맞지만, 스텝 길이의 출렁임까지 없애주지는 않습니다. 프롬프트가 길고 요청이 꾸준히 들어오는 트래픽에서는 이런 스텝이 규칙적으로 섞이므로, 평균은 멀쩡한데 P99만 튀는 모양이 나옵니다.

이 내용 다시 읽기 →
상황 적용연결 문항운영

예산 8,192, 동시 요청 상한 256으로 띄운 이 서버를 동시 요청 16개로 부하 시험하는 중입니다. ITL 꼬리를 먼저 잡기로 했고 TTFT가 다소 나빠지는 것은 감수합니다. 어느 인자를 어느 방향으로 움직입니까?

  1. max_num_seqs를 256에서 64로 낮춥니다.

    동시 요청이 16개인 부하에서 상한을 64로 내려도 배치 구성은 그대로입니다. 상한이 실제 배치를 제약하지 않으니 꼬리도 처리량도 움직이지 않고, 배치가 컸다 해도 decode 몫이 줄면 prefill 청크가 커질 자리만 넓어집니다.

  2. 정답max_num_batched_tokens를 8,192에서 1,024로 낮춥니다.

    이 값이 곧 한 스텝에 실릴 수 있는 prefill 청크의 상한입니다. 1,024로 내리면 8,000토큰짜리 프롬프트가 여덟 스텝에 나뉘어 들어가고, 한 스텝이 떠안는 추가 연산이 8분의 1로 줄어 ITL 꼬리가 그만큼 눌립니다.

  3. max_num_batched_tokens를 8,192에서 32,768로 올립니다.

    prefill을 더 빨리 끝내면 끊김도 빨리 끝난다는 직관인데 방향이 반대입니다. 청크가 커지면 그 스텝 하나가 더 길어져 꼬리는 더 높아집니다. 이 방향은 TTFT와 처리량을 우선할 때 고르는 쪽입니다.

  4. max_num_seqs를 256에서 1,024로 올립니다.

    배치를 키우면 GPU를 더 쓴다는 생각이지만, ITL 꼬리의 원인인 prefill 청크 크기와는 무관합니다. 블록 풀이 늘지 않는 한 실제 배치 크기도 따라 오르지 않습니다.

해설

두 인자는 서로 다른 축을 잡습니다. max_num_batched_tokens는 한 스텝의 길이를 정하고, max_num_seqs는 동시에 올라가는 요청 수를 정합니다. ITL 꼬리는 특정 스텝이 유독 길어져서 생기는 현상이니 스텝 길이 쪽 인자를 건드려야 합니다. 동시 요청 수를 줄이는 것은 decode가 예산에서 차지하는 몫을 줄이는 일이라 예산이 수천 단위일 때는 꼬리에 거의 영향을 주지 못하고, 실제 배치가 상한보다 작은 상황이라면 아무것도 바뀌지 않습니다. 방향도 함께 봐야 합니다. 이 값을 키우면 prefill을 한 번에 많이 삼켜 TTFT와 처리량이 좋아지고, 줄이면 스텝이 짧아져 ITL이 매끄러워집니다. 손잡이 하나가 두 지표를 반대로 움직이므로, 무엇을 SLO로 삼을지 먼저 정하지 않으면 조정이 왕복만 하게 됩니다.

이 내용 다시 읽기 →
분석과 판단연결 문항함정
# max-num-batched-tokens 8,192 → 1,024 적용 후 같은 트래픽으로 재측정

                                     변경 전    변경 후
Running (avg, reqs)                    16.0      16.0
Median ITL (ms)                        11.83     13.10
P99 ITL (ms)                          231.44     44.30
Mean TTFT (ms)                        902.50   2610.40
Avg prompt throughput (tok/s)       13280.0   11390.0

ITL 꼬리는 목표대로 잡혔고 TTFT가 늘어난 것도 예상한 대가입니다. 그런데 프롬프트 처리량까지 14% 떨어졌습니다. 같은 GPU로 같은 프롬프트를 처리하는데 총량이 줄어든 가장 큰 이유는 무엇입니까?

  1. 행렬 곱 연산량은 어떻게 잘라도 같으므로, 이 하락은 측정 잡음으로 봐야 합니다.

    선형 계층의 곱셈 연산량이 자르는 방식과 무관하다는 것까지는 맞습니다. 하지만 달라지는 것이 연산량 말고도 있습니다. attention이 읽어야 하는 KV의 양과 커널의 효율이 함께 움직이고, 14%는 잡음으로 넘길 크기가 아닙니다.

  2. 청크를 처리할 때마다 KV Cache 블록을 새로 할당하는 비용이 붙어 쌓입니다.

    블록 할당은 요청이 필요로 하는 블록 수만큼 일어나고, 그 총량은 프롬프트 길이가 정합니다. 청크를 몇 개로 자르든 결국 같은 수의 블록을 할당하므로 이 비용은 거의 변하지 않습니다.

  3. 정답청크마다 앞선 청크들의 KV를 다시 읽어야 하고, 작은 청크는 GPU 이용률도 낮습니다.

    한 청크의 attention은 자기 토큰뿐 아니라 앞서 계산해둔 모든 청크의 KV를 읽어야 합니다. N개로 자르면 첫 청크의 KV는 N-1번, 두 번째는 N-2번 다시 읽히므로 HBM 읽기가 청크 수를 따라 불어납니다. 여기에 청크가 작아져 행렬이 작아지면 커널 효율까지 떨어져 두 손해가 겹칩니다.

  4. 예산이 작아진 만큼 running decode가 가져가는 몫의 비중이 커져 prefill 자리가 줄었습니다.

    방향은 맞지만 크기가 안 맞습니다. Running이 16개면 decode 몫은 예산의 0.2%(8,192일 때)에서 1.6%(1,024일 때)로 오를 뿐이라 prefill에 남는 자리는 1.4%p 남짓 줄어듭니다. 관측된 14% 하락을 이 몫으로 설명할 수 없습니다.

해설

chunked prefill의 대가는 TTFT 하나가 아니라 prefill 자체의 효율입니다. 손해는 두 갈래로 옵니다. 청크가 작아지면 한 번에 미는 행렬이 작아져 GPU 이용률이 떨어지고, 동시에 청크 하나의 attention이 앞선 모든 청크의 KV를 다시 읽어야 해서 HBM 트래픽이 청크 수를 따라 불어납니다. 곱셈 연산량 자체는 어떻게 자르든 같은데도 시간이 늘어나는 이유가 이 둘입니다. 논문의 측정으로는 전체 실행 시간을 좌우하는 쪽이 앞의 GPU 이용률이고, KV 재읽기는 attention이 전체에서 차지하는 몫이 작아 상대적으로 덜 아픕니다. 그래서 예산을 낮추는 조정은 ITL을 사면서 TTFT와 프롬프트 처리량을 함께 지불하는 거래입니다. 적정선은 트래픽의 프롬프트 길이 분포와 SLO에 따라 달라지므로, 한쪽 지표만 보고 내리지 말고 양쪽을 같이 재면서 찾아야 합니다.

이 내용 다시 읽기 →
분석과 판단확장진단운영
# max-num-seqs 256 → 1024, 같은 트래픽으로 재측정

[변경 전]
Engine 000: Avg prompt throughput: 2884.6 tokens/s,
            Avg generation throughput: 1341.7 tokens/s,
            Running: 256 reqs, Waiting: 514 reqs,
            GPU KV cache usage: 86.0%, Prefix cache hit rate: 5.1%

[변경 후]
Engine 000: Avg prompt throughput: 2410.2 tokens/s,
            Avg generation throughput: 1138.9 tokens/s,
            Running: 297 reqs, Waiting: 592 reqs, Preemptions: 23,
            GPU KV cache usage: 100.0%, Prefix cache hit rate: 5.3%

동시 요청 상한을 네 배로 올렸는데 Running은 297에서 더 오르지 않고, 생성 처리량은 오히려 15% 떨어졌습니다. 무슨 일이 벌어진 것입니까?

  1. 정답블록이 감당 못 하는 만큼 요청이 들어왔다 선점되고, 그 요청이 prefill을 다시 밟습니다.

    max_num_seqs는 배치에 올릴 요청 수의 상한일 뿐이고 실제 천장은 KV 블록 풀입니다. 상한을 올리면 스케줄러가 블록이 남아 있는 동안 waiting 큐에서 요청을 계속 끌어오고, 그렇게 풀이 마른 다음 스텝에 running 큐의 decode가 새 블록을 못 받는 순간 running 큐의 가장 뒤 요청이 선점됩니다. 선점된 요청은 계산해둔 토큰 수가 0으로 되돌아가므로, 그 재계산이 GPU 시간을 먹어 유효 처리량이 깎입니다.

  2. 선점된 요청의 KV가 CPU 메모리로 스왑됐다 돌아오느라 PCIe 전송이 병목이 됐습니다.

    V1의 선점은 스왑이 아니라 재계산입니다. 블록을 풀에 반납하고 그 요청을 waiting 큐 맨 앞에 되돌려 놓을 뿐이라, KV가 CPU로 오가는 경로 자체가 없습니다. 손해는 전송이 아니라 버린 계산을 다시 하는 데서 옵니다.

  3. prefix cache hit rate가 5%대에 머물러 재계산이 늘어난 것입니다.

    히트율은 변경 전 5.1%, 변경 후 5.3%로 사실상 그대로입니다. 두 측정에서 달라지지 않은 값은 달라진 결과의 원인이 될 수 없습니다. 히트율이 오르면 요청당 점유가 줄어 도움이 되기는 하지만, 지금 Running을 297에 묶고 있는 것은 100%에 붙은 KV 풀입니다.

  4. 배치가 커지면서 decode의 attention 비용이 요청 수의 제곱으로 늘어 스텝이 느려졌습니다.

    요청들의 attention은 서로 독립이라 비용은 요청 수에 비례해 늘 뿐 제곱이 되지 않습니다. 게다가 decode는 memory-bound라 배치를 키우면 요청당 비용이 오히려 줄어듭니다. 배치를 키우는 것 자체는 처리량에 유리하고, 문제는 블록 없이 키운 데 있습니다.

해설

동시 요청 수의 진짜 상한은 max_num_seqs가 아니라 KV 블록 풀의 크기입니다. 이 인자는 이보다 많이 올리지 말라는 선을 그을 뿐이라, 블록이 부족한 상태에서 값만 키우면 스케줄러는 감당 못 할 요청까지 running 큐에 올렸다가 되돌리는 일을 반복합니다. vLLM V1의 선점은 재계산 방식이라 선점된 요청의 진도가 0으로 초기화되고, 그 요청은 waiting 큐 맨 앞으로 가서 prefill부터 다시 시작합니다. prefix caching이 켜져 있으면 반납한 블록이 아직 캐시에 남아 일부를 건너뛸 수도 있지만, 캐시가 이미 압박받는 상황이라 기대하기 어렵습니다. 이미 한 계산을 버리고 다시 하는 만큼이 그대로 처리량 손실입니다. 게다가 선점이 일어난 스텝에서는 waiting 큐에서 새 요청을 아예 받지 않으므로, 선점이 잦아지면 신규 요청의 진입까지 느려집니다. 그래서 Preemptions가 꾸준히 오르는 상황의 처방은 상한을 더 올리는 것이 아니라, 메모리나 병렬도를 늘려 블록을 더 확보하거나 상한을 도로 내려 선점을 없애는 쪽입니다.

이 내용 다시 읽기 →
개념 확인구조계산
요청 네 개를 한 배치로 묶어 돌리는 서버가 있습니다. 배치를 통째로 시작해 통째로 끝내는 static batching 방식이라, 배치가 도는 동안에는 먼저 끝난 요청의 자리에 대기 중인 요청을 넣지 못합니다. 배치 크기는 4로 고정이고, 대기 큐에는 다음 배치를 기다리는 요청이 계속 쌓여 있습니다. 프롬프트 계산(prefill)은 네 요청이 첫 스텝에 함께 끝냈고, 그 뒤로는 매 스텝마다 아직 끝나지 않은 요청이 토큰을 하나씩 만듭니다. 이번 배치에 묶인 네 요청의 출력 토큰 수는 다음과 같습니다. - R1: 40토큰 - R2: 90토큰 - R3: 120토큰 - R4: 350토큰 네 요청 모두 자기 출력 길이만큼의 decode 스텝을 쓴 뒤 종료 조건에 걸렸습니다.

이 배치의 decode 구간에서 토큰을 하나도 만들지 못한 채 낭비된 슬롯은 몇 스텝분입니까? 슬롯 하나가 한 스텝 동안 비어 있으면 1스텝분으로 셉니다.

  1. 정답800스텝분입니다. 먼저 끝난 세 요청이 남긴 공백을 배치가 끝날 때까지 합한 값입니다.

    배치는 가장 긴 R4가 350스텝을 채울 때까지 돌고, 그동안 네 슬롯이 붙들려 있으므로 슬롯 예산은 1,400스텝분입니다. 실제로 토큰이 나온 몫은 40 더하기 90 더하기 120 더하기 350으로 600스텝분이고, 나머지 800스텝분이 빈자리입니다. 각 요청이 남긴 공백을 따로 더해도 310 더하기 260 더하기 230으로 같은 값이 나옵니다.

  2. 1,050스텝분입니다. R4를 제외한 세 슬롯이 350스텝 내내 비어 있던 셈입니다.

    세 슬롯도 각자 자기 출력 길이만큼은 토큰을 만들었습니다. R1은 40스텝, R2는 90스텝, R3은 120스텝을 실제로 일했으므로 그 250스텝분은 낭비가 아닙니다. 이 값은 세 슬롯이 처음부터 아무 일도 하지 않았다고 볼 때 나오는 수치입니다.

  3. 310스텝분입니다. R1이 끝난 뒤부터 배치 종료까지 빈자리가 남아 있던 스텝 수입니다.

    빈자리가 하나라도 있던 스텝의 개수는 310이 맞지만, 묻는 것은 슬롯 단위의 낭비량입니다. 41번째 스텝에는 빈 슬롯이 하나뿐이지만 121번째 스텝부터는 셋이 비어 있으므로, 스텝 수만 세면 여러 슬롯이 동시에 노는 구간이 한 번으로 접힙니다.

  4. 1,400스텝분입니다. 배치가 끝날 때까지 네 슬롯이 묶여 있던 시간 전체입니다.

    1,400은 이 배치가 붙들고 있던 슬롯 예산의 총량이고, 그 안에는 실제로 토큰을 만든 600스텝분이 함께 들어 있습니다. 점유한 시간 전부를 낭비로 세면 배치가 한 일까지 낭비로 계산하는 셈이 됩니다.

해설

static batching이 버리는 것은 GPU 시간 자체가 아니라 배치 안의 자리입니다. 배치를 통째로 시작해 통째로 끝내므로, 배치의 수명은 가장 긴 요청인 R4의 350스텝으로 정해집니다. 네 슬롯이 그만큼 붙들려 1,400스텝분을 점유하는데 토큰이 나온 몫은 600스텝분뿐이라, 슬롯 예산의 57%가 빈자리로 흘러갑니다. R1은 40스텝 만에 답을 다 만들고도 남은 310스텝 동안 자리만 지키고, 대기 큐의 요청은 그 자리가 비어 있는 것을 뻔히 두고도 들어가지 못합니다. 낭비의 크기는 배치에 섞인 출력 길이의 편차가 좌우합니다. 네 요청이 모두 350토큰을 만들었다면 낭비는 0이고, 편차가 벌어질수록 빈자리는 커집니다. continuous batching은 여기서 배치 구성의 단위를 요청 전체가 아니라 한 스텝으로 낮춥니다. 매 스텝이 끝날 때마다 스케줄러가 배치를 다시 짜므로, R1이 40스텝에 끝나면 41번째 스텝부터 대기 요청이 그 자리를 이어받습니다. 배치의 수명을 가장 긴 요청에 묶어두던 제약이 사라지면서 빈자리를 안고 도는 스텝도 함께 사라집니다.

이 내용 다시 읽기 →

Prefix Caching과 RadixAttention으로 보는 vLLM과 SGLang

원문 읽기 →
개념 확인구조
RAG 서비스가 프롬프트를 이렇게 조립합니다. 요청 1 = [문서 청크 A 700토큰] + [공통 지시문 400토큰] + [질문] 요청 2 = [문서 청크 B 700토큰] + [공통 지시문 400토큰] + [질문] 공통 지시문 400토큰은 두 요청이 토큰 하나까지 동일하고, 문서 청크 A와 B는 첫 토큰부터 다릅니다.

요청 1이 끝난 뒤 요청 2가 도착했습니다. 요청 2가 얻는 캐시 히트는 어느 정도입니까?

  1. 공통 지시문 400토큰이 히트합니다. 같은 토큰열의 KV는 어디에 놓여도 같습니다.

    토큰 하나의 KV는 자기 자신과 앞선 토큰들 전부에 의존합니다. 앞에 문서 청크 A가 온 지시문과 문서 청크 B가 온 지시문은 토큰 id만 같을 뿐 KV 값이 다릅니다. 이걸 재사용하면 결과가 조용히 틀어집니다.

  2. 공통 지시문 중 16토큰 블록 경계에 맞아떨어지는 구간만 히트합니다.

    블록 경계는 이미 시작된 히트 구간이 어디서 끊기는지를 정하는 규칙입니다. 프롬프트 중간에서 매칭을 새로 시작하게 해주는 장치가 아닙니다. 조회는 항상 맨 앞 블록부터 순서대로 진행됩니다.

  3. 정답히트가 없습니다. 재사용은 프롬프트 맨 앞에서부터 이어지는 구간에서만 일어납니다.

    첫 블록의 토큰이 다르므로 첫 조회부터 미스가 나고, 그 뒤는 볼 것도 없이 전량 prefill입니다. 이런 조립 순서라면 문서 청크를 뒤로, 공통 지시문을 앞으로 옮기는 것만으로 400토큰이 캐시에 걸립니다.

  4. 두 요청에 같은 cache_salt를 주면 공통 지시문 400토큰이 히트합니다.

    cache_salt는 캐시를 나누는 장치이지 붙이는 장치가 아닙니다. 같은 salt는 재사용 자격을 열어줄 뿐이고, 토큰열이 다르면 키는 여전히 다릅니다. salt를 맞춰도 이 두 요청은 서로의 블록을 볼 수 없습니다.

해설

재사용 단위가 하필 접두사인 이유는 어텐션이 인과적이기 때문입니다. 각 토큰의 KV는 자기 자신과 앞선 토큰들에만 의존하므로, 앞부분이 완전히 같으면 그 구간의 KV도 같은 값입니다. 반대로 앞이 다르면 뒤가 아무리 같아도 KV는 달라집니다. 그래서 프롬프트를 조립할 때는 모든 요청이 공유하는 고정 블록을 앞에, 요청마다 달라지는 부분을 뒤에 두는 것이 캐시 관점의 정답입니다. RAG처럼 검색 결과를 프롬프트에 끼워 넣는 구조는 이 순서를 거꾸로 잡기 쉽습니다. 검색 문서를 맨 앞에 붙이면 요청마다 첫 블록이 달라져 뒤에 붙은 공통 지시문이 통째로 캐시 밖으로 밀려납니다.

이 내용 다시 읽기 →
상황 적용계산
$ vllm serve meta-llama/Llama-3.1-8B-Instruct --block-size 16

# 요청 1: [시스템 프롬프트 1,530토큰] + [질문 A 60토큰]   처리 완료
# 요청 2: [시스템 프롬프트 1,530토큰] + [질문 B 60토큰]   방금 도착
# 질문 A와 질문 B는 첫 토큰부터 다르다

요청 1의 블록이 모두 캐시에 남아 있을 때, 요청 2의 prefill에서 실제로 계산되는 토큰은 몇 개입니까?

  1. 60토큰입니다.

    시스템 프롬프트 1,530토큰이 통째로 히트한다고 본 값입니다. 1,530은 16으로 나누어떨어지지 않아 마지막 10토큰이 블록을 채우지 못합니다. 채워지지 않은 블록은 해시가 매겨지지 않아 캐시 대상이 아닙니다.

  2. 정답70토큰입니다.

    1,530을 16으로 나누면 완전한 블록이 95개이고 1,520토큰까지가 히트입니다. 남는 10토큰은 질문 B의 앞 6토큰과 한 블록에 섞여 들어가는데 그 블록은 요청 1의 것과 내용이 다릅니다. 1,590에서 1,520을 뺀 70토큰이 계산 대상입니다.

  3. 76토큰입니다.

    미완성 블록 하나를 16토큰으로 통째로 세고 질문 60토큰을 더한 값입니다. 실제로 재계산되는 것은 블록의 크기가 아니라 히트 경계 뒤에 남은 토큰 수입니다. 여기서는 시스템 프롬프트의 잔여분이 16이 아니라 10토큰입니다.

  4. 1,590토큰입니다.

    프롬프트 뒷부분이 다르면 해시 사슬 전체가 달라진다고 본 값인데, 사슬은 앞에서 뒤로 흐릅니다. 뒤쪽 블록이 앞쪽 블록의 키에 영향을 주는 경로는 없습니다. 앞이 같으면 앞부분 블록의 키는 그대로 같습니다.

해설

히트 토큰 수는 공유 접두사 길이를 block_size의 배수로 내림한 값입니다. 여기서는 1,530을 16의 배수로 내림해 1,520토큰이고, 총 1,590토큰 중 나머지 70토큰만 계산합니다. 가득 찬 블록에만 해시를 매기는 규칙 때문에 경계에서 항상 조금씩 잘리지만, 손해는 접두사 길이와 무관하게 block_size 미만으로 고정됩니다. 16토큰 블록이라면 최악이 15토큰이고, 접두사가 길수록 이 손해의 비중은 계속 작아집니다. 반대로 접두사가 아주 짧아 손해 비율이 커지는 경우라면 애초에 캐시로 아낄 절대량 자체가 작아 문제가 되지 않습니다. 블록 크기를 키우면 관리 단위는 줄지만 경계에서 버리는 최대 토큰 수가 그만큼 늘어나는 맞교환 관계가 됩니다.

이 내용 다시 읽기 →
분석과 판단구조함정
block_size = 16. 아래 두 프롬프트를 순서대로 보냅니다. 프롬프트 P = [시스템 지침 16토큰] + [문서 X 16토큰] + [공통 안내문 16토큰] + ... 프롬프트 Q = [시스템 지침 16토큰] + [공통 안내문 16토큰] + ... 시스템 지침과 공통 안내문은 두 프롬프트가 토큰 하나까지 같고, Q에는 문서 X가 빠져 있습니다. P를 먼저 처리한 뒤, P의 블록이 아직 캐시에 남아 있는 상태에서 Q가 도착합니다.

vLLM이 블록 해시를 만들 때 입력에서 부모 블록의 해시를 빼고 그 블록의 토큰만 넣는다면, 이 상황에서 무슨 일이 벌어집니까?

  1. 캐시 히트가 아예 나지 않습니다. 블록마다 독립적인 키를 갖게 되어 접두사를 대조할 수단이 사라집니다.

    해시 방식은 접두사를 공유하지 못한다는 흔한 오해입니다. 부모 해시를 빼면 매칭이 사라지는 것이 아니라 너무 헐거워집니다. 문제는 히트가 안 나는 쪽이 아니라 나면 안 될 곳에서 나는 쪽입니다.

  2. 해시 충돌 확률이 올라가지만 SHA-256을 쓰므로 실질적인 문제는 없습니다.

    충돌은 서로 다른 입력이 우연히 같은 해시값을 갖는 사건입니다. 여기서는 입력 자체가 완전히 같아지므로 해시 함수를 아무리 강한 것으로 바꿔도 같은 값이 나옵니다. 해시 품질이 아니라 무엇을 해시에 넣느냐의 문제입니다.

  3. 접두사 매칭은 그대로지만 블록마다 부모를 거슬러 대조해야 해서 조회 비용이 제곱이 됩니다.

    부모 포인터를 따로 두고 매번 거슬러 올라가는 설계도 가능하지만, 그것은 vLLM이 고른 길이 아닙니다. 부모 해시를 키 안에 접어 넣었기 때문에 맵 조회 한 번이 그 블록까지의 접두사 전체를 보증합니다. 이 문항의 가정은 조회 비용이 아니라 판정의 정확성을 무너뜨립니다.

  4. 정답Q의 두 번째 블록이 P의 세 번째 블록과 같은 키를 갖게 되어, 엉뚱한 KV를 재사용합니다.

    토큰만 해시하면 키에 위치도 문맥도 남지 않습니다. Q의 블록 0은 시스템 지침이라 정상 히트하고, 이어지는 블록 1도 공통 안내문의 해시가 이미 캐시에 있으니 그대로 히트합니다. 그런데 걸린 물리 블록은 P에서 세 번째 자리에, 문서 X를 앞에 두고 계산된 KV입니다.

해설

부모 해시를 키에 넣는 이유는 해시값 하나로 접두사 전체를 보증하기 위해서입니다. 어떤 블록의 키가 같다는 것은 그 블록의 토큰이 같고 부모의 키도 같다는 뜻이고, 부모의 키가 같다는 것을 다시 위로 펼치면 결국 처음부터 여기까지 모든 토큰이 같다는 진술이 됩니다. 그래서 vLLM은 해시에서 물리 블록으로 가는 맵 하나만 들고도 최장 공유 접두사를 찾아냅니다. 조회는 앞에서부터 순서대로 진행하고 처음 미스가 난 지점에서 멈추는데, 이 구조가 부모 해시와 맞물려 사고를 막습니다. 부모 해시를 빼면 키가 위치 정보를 잃기 때문에, 조회가 중간에 멈추더라도 앞쪽 블록들이 우연히 다 걸리는 경로에서는 히트 사슬이 끊기지 않은 채 자리만 밀립니다. 이 문항처럼 중간 구간이 빠진 프롬프트가 정확히 그 경로이고, 이때 재사용되는 KV는 다른 문맥에서 계산된 값이라 출력이 조용히 달라집니다. 남는 것은 접두사 캐시가 아니라 문맥을 무시하는 블록 캐시이고, 그때부터 히트는 성능이 아니라 오답을 만듭니다.

이 내용 다시 읽기 →
분석과 판단진단운영
$ python3 -m sglang.launch_server --model-path Qwen/Qwen3-8B \
      --schedule-policy lpm

[14:02:11] Prefill batch. #new-seq: 12, #cached-token: 9344, token usage: 0.31, #queue-req: 37
[14:07:44] Prefill batch. #new-seq: 12, #cached-token: 9120, token usage: 0.33, #queue-req: 41
... 트래픽 급증 ...
[14:31:05] Prefill batch. #new-seq: 12, #cached-token: 1088, token usage: 0.34, #queue-req: 216
[14:31:09] Prefill batch. #new-seq: 12, #cached-token: 992,  token usage: 0.35, #queue-req: 244

평소에는 재사용이 잘 되다가 대기 요청이 몰리자 #cached-token이 급감했습니다. 지정해둔 lpm이 이 구간에서 무력해진 직접적인 원인은 무엇입니까?

  1. 정답대기 큐가 일정 길이를 넘는 동안 SGLang이 lpm을 건너뛰고 FCFS로 내려가기 때문입니다.

    대기 큐가 128개를 넘으면 스케줄러가 그 스케줄링에서 활성 정책을 FCFS로 바꿉니다. 큐 전체를 트리에 대조해 매치 길이를 구하고 정렬하는 비용이, 큐가 길어지면 히트율로 버는 것보다 커지기 때문입니다. 로그의 대기 수가 37, 41에서 216, 244로 뛰면서 그 경계를 넘어섰습니다.

  2. 요청이 몰리면 radix tree가 가득 차 evict가 잦아져 매치할 노드가 남지 않기 때문입니다.

    트리가 좁아지면 히트가 줄어드는 것은 맞지만, 로그의 KV 사용률은 두 구간이 0.31에서 0.35로 비슷해 캐시가 포화되지 않았습니다. 이 시나리오에서 설명해야 하는 것은 캐시 인지 정렬을 켜둔 값이 왜 무력해졌는가입니다. 정렬이 계속 돌고 있다면 같은 접두사끼리 묶어 evict 압력 자체를 줄여줍니다.

  3. lpm은 radix cache가 켜져 있어야 동작하는데 부하가 높으면 자동으로 꺼지기 때문입니다.

    전제의 앞 절은 맞습니다. radix cache를 끄면 캐시 인지 정책은 FCFS로 강등됩니다. 하지만 radix cache는 기본으로 켜져 있고 부하에 따라 스스로 꺼지지 않습니다. 부하에 반응하는 것은 캐시가 아니라 정렬 쪽입니다.

  4. 큐가 길어지면 정렬이 도착 순서를 심하게 흐트러뜨려, 공정성 보호가 정렬을 막기 때문입니다.

    정렬이 도착 순서를 흐트러뜨려 접두사가 겹치지 않는 요청을 뒤로 미는 것은 실제 부작용입니다. 다만 런타임에 정책을 되돌리는 스위치는 공정성 지표가 아니라 대기 큐 길이입니다. 공정성은 이 정책을 쓸지 말지 판단할 때 볼 사항이지 엔진이 감시하는 값이 아닙니다.

해설

두 엔진의 진짜 차이는 자료구조가 아니라 캐시를 아는 스케줄링입니다. 도착 순서대로만 처리하면 접두사 X 팀과 Y 팀이 번갈아 들어올 때 서로를 밀어내는 캐시 스래싱이 생기고, 대기 요청을 매치 길이 순으로 정렬하면 같은 접두사끼리 몰아서 처리해 이 낭비가 사라집니다. 그런데 이 정렬은 공짜가 아닙니다. 매 스케줄링마다 대기 큐 전체를 트리에 대조해야 하므로 큐 길이에 비례해 비용이 붙고, 어느 지점을 넘으면 히트율로 버는 것보다 더 잃습니다. SGLang이 큐가 길어질 때 조용히 FCFS로 내려가는 것도, 기본 정책이 여전히 FCFS인 것도 같은 이유입니다. 강등 조건은 하나가 아닙니다. 큐 길이는 매 스케줄링마다 재판정되는 런타임 조건이고, radix cache 자체를 끄면 초기화 시점에 이미 FCFS로 내려갑니다. 운영에서 이 옵션의 효과를 판단하려면 히트율만이 아니라 그때의 대기 큐 길이를 함께 기록해야, 정책이 실제로 적용된 구간을 골라낼 수 있습니다.

이 내용 다시 읽기 →
상황 적용구조운영
요청 하나가 방금 응답을 마쳤습니다. 이 요청의 프롬프트는 [모든 사용자가 공유하는 시스템 프롬프트 1,600토큰] + [이 요청만의 질문 320토큰]이었고, 종료와 함께 붙들고 있던 블록을 한꺼번에 반납했습니다. 그중 프롬프트 몫이 120개입니다(시스템 프롬프트 100개, 질문 20개). 이 블록을 함께 쓰는 다른 요청은 지금 없습니다. 곧이어 접두사가 전혀 겹치지 않는 요청들이 몰려와 빈 블록을 계속 꺼내 갑니다.

반납된 프롬프트 블록 120개 중 캐시 내용이 먼저 지워지는 쪽은 어디이고, 그렇게 되는 이유는 무엇입니까?

  1. 시스템 프롬프트 쪽입니다. 가장 먼저 만들어져 목록에 오래 머문 블록입니다.

    기준은 만들어진 시각이 아니라 마지막으로 쓰인 시각입니다. 게다가 반납 순서를 뒤에서 앞으로 잡아둔 덕분에 앞쪽 블록이 목록의 뒤로 밀려 오히려 늦게 재활용됩니다. 공유될 가능성이 큰 블록을 오래 남기려고 일부러 그 방향으로 설계했습니다.

  2. 반납하는 순간 120개 블록의 내용이 함께 지워집니다. 빈 블록은 캐시가 아닙니다.

    반납된 블록은 빈 목록에 들어가지만 안에 든 KV와 어떤 토큰들의 것인지 알려주는 꼬리표는 그대로 남습니다. 다음 요청이 같은 접두사로 시작하면 계산을 건너뛰고 그 블록을 다시 가리키기만 하면 됩니다. prefix caching이 캐시 전용 메모리를 따로 떼지 않고도 성립하는 이유입니다.

  3. 정답질문 쪽입니다. 반납이 접두사의 끝부터 진행되어 할당 지점에 더 가깝습니다.

    할당은 빈 목록의 머리에서 꺼내고, 반납은 접두사의 끝쪽 블록부터 목록에 넣습니다. 그래서 요청 고유의 질문 블록이 머리 쪽에 놓여 먼저 재활용되고, 여러 요청이 공유할 가능성이 높은 앞쪽 블록은 뒤로 밀려 오래 살아남습니다.

  4. 정해진 순서가 없습니다. 빈 목록에서 아무 블록이나 하나 꺼내 씁니다.

    임의로 꺼내면 어떤 블록이 살아남을지 통제할 수 없어, 모두가 쓰는 시스템 프롬프트가 한 번 쓰이고 사라지는 일이 생깁니다. vLLM은 빈 목록을 이중 연결 리스트로 관리해 머리에서 꺼내고, 히트가 난 블록은 목록 중간에서 즉시 빼냅니다.

해설

prefix caching은 캐시를 위해 메모리를 따로 떼어두지 않습니다. 반납되어 빈 목록에 들어간 블록의 내용을 지우지 않고 두는 것뿐이라, 새 요청이 블록을 필요로 하면 언제든 내어줍니다. 그래서 어떤 블록을 먼저 내어줄지가 곧 캐시 정책이 됩니다. 할당은 목록의 머리에서 꺼내고 그 순간 안에 있던 내용이 지워지므로, 머리에 가까울수록 수명이 짧습니다. 반납을 접두사의 끝에서 앞 방향으로 진행하면 요청 고유의 뒷부분이 머리 쪽에 놓이고 공유 가능성이 높은 앞부분이 뒤로 밀립니다. 여기에 히트가 난 블록은 목록에서 빠지고 참조 카운트가 올라간다는 규칙이 겹치면, 모두가 쓰는 시스템 프롬프트 블록은 요청이 올 때마다 다시 목록 밖으로 나가 사실상 GPU에 상주하게 됩니다. 캐시 적중을 별도 정책으로 판단하지 않고 자료구조의 순서만으로 만들어낸 설계입니다.

이 내용 다시 읽기 →
분석과 판단구조계산
$ vllm serve Qwen/Qwen3-8B --block-size 16

$ python3 -m sglang.launch_server --model-path Qwen/Qwen3-8B \
      --page-size 16

# 두 서버에 같은 트래픽을 흘린다
# 요청 1 = [공유 시스템 프롬프트 2,010토큰] + [질문 A 80토큰]   처리 완료
# 요청 2 = [공유 시스템 프롬프트 2,010토큰] + [질문 B 80토큰]   방금 도착
# 질문 A와 질문 B는 첫 토큰부터 다르고, 요청 1이 남긴 캐시는 양쪽 모두 그대로다

요청 2의 캐시 히트는 두 엔진에서 각각 몇 토큰까지 이어지며, 그 결과를 그렇게 만드는 것은 무엇입니까?

  1. 정답매칭 입도를 정하는 것은 트리냐 해시맵이냐가 아니라 페이지 크기입니다. 두 엔진 모두 매치 길이를 16의 배수로 내림하므로 2,000토큰에서 함께 끊깁니다.

    vLLM은 가득 찬 블록에만 해시를 매기고, SGLang은 트리를 타기 전에 키를 페이지 크기의 배수로 잘라냅니다. 두 규칙 모두 매치 길이를 16으로 내림하는 같은 연산이라, 2,010에서 완전한 페이지 125개인 2,000토큰까지가 히트고 남는 10토큰은 양쪽 다 다시 계산합니다.

  2. radix tree는 노드를 임의 지점에서 쪼갤 수 있어 페이지 크기에 매이지 않습니다. SGLang은 2,010토큰까지 가고 vLLM만 2,000토큰에서 끊깁니다.

    노드 분할이 자유로운 것은 페이지 크기가 1일 때의 이야기입니다. 16으로 띄우면 SGLang은 매칭을 시작하기도 전에 키를 페이지 경계까지 잘라내고 매치 길이도 페이지 크기의 배수로 내려 보고합니다. 2,010은 이 정렬이 없을 때, 즉 기본값 1로 띄웠을 때 나오는 값입니다.

  3. 두 엔진 모두 경계에 걸친 페이지를 통째로 버리고 그 앞까지 물러납니다. 완전한 페이지 124개에 해당하는 1,984토큰에서 함께 끊깁니다.

    버려지는 것은 페이지를 채우지 못한 나머지 10토큰뿐입니다. 125번째 페이지는 2,000토큰째에서 이미 가득 찼으므로 정상적으로 해시가 매겨지고 그대로 히트합니다. 1,984는 가득 찬 마지막 페이지까지 함께 버렸다고 본 값이고, 어느 엔진도 그렇게 동작하지 않습니다.

  4. SGLang은 페이지 크기가 1을 넘으면 radix tree를 접고 블록 해시맵으로 갈아탑니다. 자료구조가 같아져 2,000토큰에서 함께 끊깁니다.

    끊기는 지점은 맞지만 이유가 사실이 아닙니다. 페이지 크기는 트리에 넣고 빼는 키의 정렬 단위를 바꿀 뿐이고, SGLang은 어떤 값에서도 radix tree를 그대로 씁니다. 자료구조는 여전히 트리와 해시맵으로 다른데 결과만 같아진다는 것이 이 상황의 요점입니다.

해설

두 엔진의 자료구조는 끝까지 다릅니다. 그런데도 같은 지점에서 끊기는 이유는 매칭 입도를 정하는 것이 자료구조가 아니라 페이지 크기라는 하나의 값이기 때문입니다. vLLM은 블록이 가득 찬 순간에만 해시를 매기므로 히트가 block_size의 배수에서 끝나고, SGLang은 트리를 타기 전에 키를 page_size의 배수로 잘라내고 매치 길이도 같은 단위로 내려 보고합니다. 둘 다 결국 공유 접두사 길이를 그 값으로 내림하는 같은 연산이라, 2,010토큰을 16으로 내림한 2,000토큰이 양쪽의 답입니다. 흔히 인용되는 토큰 단위 대 16토큰 단위라는 대비는 여기서 무너집니다. 그것은 트리와 해시맵의 차이가 아니라 SGLang의 page_size 기본값이 1이라서 생긴 차이일 뿐이고, 같은 SGLang도 일부 GPU 백엔드에서는 기본값을 64로 잡습니다. 거꾸로 보면 vLLM의 해시 사슬은 부모 해시를 키에 접어 넣어 경로 전체를 키에 새기므로, 엣지 길이를 16토큰으로 고정한 radix tree를 포인터 대신 해시맵으로 구현한 것과 같습니다. 최장 공유 접두사를 찾고 공유되는 앞쪽을 오래 남기며 참조 카운트로 사용 중인 구간을 지키는 동작까지 두 엔진이 같으니, 남는 선택지는 트레이드오프의 어느 쪽에 기본값을 둘 것인가입니다. 최대 15토큰을 다시 계산할 것인가, 잘게 쪼갠 페이지의 관리 비용을 낼 것인가. 두 엔진이 실제로 갈라지는 지점은 이 캐시 구조가 아니라 캐시를 알고 스케줄을 짜는가에 있습니다.

이 내용 다시 읽기 →

서빙을 위한 양자화 가이드 FP8, AWQ, GPTQ

원문 읽기 →
개념 확인구조

AWQ로 양자화한 W4A16 체크포인트를 H100에서 서빙합니다. 4비트 가중치를 받은 텐서코어가 저정밀 연산 경로도 함께 타 준다고 기대해도 됩니까?

  1. 됩니다. 저장 형식이 4비트이므로 커널도 4비트 경로를 그대로 씁니다.

    저장 형식과 연산 형식은 별개입니다. W4A16 커널은 행렬곱 직전에 가중치를 16비트로 복원하므로, 텐서코어에 들어가는 두 피연산자는 모두 16비트입니다.

  2. 됩니다. H100은 FP8 텐서코어를 갖추고 있어 4비트 가중치를 FP8로 승격해 받습니다.

    FP8 경로도 두 피연산자가 모두 FP8일 때 열립니다. 가중치를 FP8로 올려도 활성값이 16비트로 남으면 조건을 채우지 못하고, W4A16 커널은 애초에 가중치를 16비트로 복원합니다. 참고로 Hopper 텐서코어에는 INT4 경로 자체가 없습니다. Turing과 Ampere, Ada에는 있지만 Hopper에서 빠졌습니다.

  3. 정답안 됩니다. 활성값이 16비트라 행렬곱은 16비트로 복원해서 수행됩니다.

    W4A16의 4는 저장 비트일 뿐입니다. 저정밀 경로는 두 피연산자가 같은 저정밀 형식일 때만 열리는데 활성값이 16비트로 남아 있으므로, 가중치를 16비트로 되돌려 맞춘 뒤 계산합니다. 이득은 가중치 메모리와 스텝당 읽기량에서만 나옵니다.

  4. 안 됩니다. AWQ가 중요 채널을 16비트로 남겨두어 커널이 혼합 정밀도로 돌기 때문입니다.

    AWQ는 중요 채널을 16비트로 남기는 대신 그 채널의 가중치를 미리 키우고 활성값을 줄이는 스케일링으로 같은 보호 효과를 냅니다. 혼합 정밀도가 하드웨어에서 비효율적이라 그 길을 고른 것이고, 커널이 받는 가중치는 균일한 4비트입니다. 경로가 막히는 이유는 혼합 정밀도가 아니라 활성값이 16비트라는 데 있습니다.

해설

W4A16에서 4는 디스크와 GPU 메모리에 담기는 형식이고 연산은 16비트입니다. 양자화가 건드릴 수 있는 자원은 셋인데, 가중치가 차지하는 메모리와 매 스텝 읽는 양은 저장 형식만 줄여도 얻어지지만 행렬곱 처리량은 그렇지 않습니다. GPU 텐서코어의 저정밀 경로는 두 피연산자가 모두 같은 저정밀 형식일 때만 열리기 때문입니다. 활성값이 16비트로 남아 있으면 가중치를 16비트로 되돌려 맞출 수밖에 없고, 복원 자체도 공짜가 아닙니다. 그래서 W4A16의 이득은 메모리와 읽기량 둘에 국한되고 연산 상한은 원본과 같습니다. 활성값까지 8비트로 내리는 W8A8은 셋을 다 얻는 대신 압축 배율이 절반입니다. 이 비대칭이 배치 크기에 따라 승부가 갈리는 구도의 출발점입니다.

이 내용 다시 읽기 →
상황 적용예측운영
같은 8B 모델, H100 80GB x1, 입력 1K / 출력 256 토큰

concurrency=1     요청당 출력 tok/s
  bf16              105
  W4A16 (AWQ)       215
  FP8 (W8A8)        146

concurrency=128   전체 출력 tok/s
  bf16               3,140
  W4A16 (AWQ)        2,660
  FP8 (W8A8)         4,860

concurrency 1에서 bf16의 두 배를 내던 W4A16이 concurrency 128에서는 bf16보다도 낮습니다. 이 역전을 만든 것은 무엇입니까?

  1. 정답가중치 행렬곱의 병목이 읽기에서 연산으로 넘어가 복원 비용만 남았습니다.

    continuous batching은 여러 요청의 연산을 한 행렬곱으로 묶습니다. 읽어온 가중치 하나로 배치만큼의 연산을 하게 되니 가중치당 연산량이 배치에 비례해 늘고, 이르면 배치 수십 토큰 수준에서 이 행렬곱이 연산 병목으로 넘어갑니다. 그 구간에서 4비트 읽기 이점은 쓸 곳이 없고 16비트로 되돌리는 비용만 그대로 남습니다.

  2. 어텐션도 배치와 함께 compute-bound로 바뀌어 복원 경로가 어텐션까지 늦췄습니다.

    어텐션은 요청마다 자기 몫의 KV Cache를 따로 읽어야 해서 배치가 커져도 memory-bound로 남습니다. 게다가 W4A16이 양자화한 대상은 가중치이고 어텐션은 KV를 읽는 연산이라, 가중치 복원 경로와 어텐션은 서로 다른 자리에 있습니다.

  3. 4비트에 붙는 그룹 스케일 오버헤드 때문에 실제 압축률이 4배에 못 미쳤습니다.

    그룹 스케일 때문에 실효 압축이 4배가 아니라 3.5배 남짓인 것은 맞지만, 그 손실은 배치와 무관하게 항상 같은 크기입니다. concurrency 1에서 두 배가 나왔다는 것 자체가 읽기 이점이 살아 있었다는 증거이므로, 배치에 따라 달라지는 다른 요인을 찾아야 합니다.

  4. KV 예산이 모자라 요청이 선점되고 재계산이 반복됐습니다.

    가중치가 가장 작은 W4A16이 세 방식 중 KV 예산이 가장 큽니다. 선점이 원인이었다면 가중치가 가장 무거운 bf16 쪽이 먼저 무너졌어야 합니다.

해설

W4A16과 W8A8 중 무엇이 빠른가에는 고정된 답이 없고 배치 크기가 정합니다. 배치가 작으면 서빙 시간의 대부분이 memory-bound한 decode에 쓰이고, 매 스텝 읽는 가중치가 1/4인 쪽이 그대로 이깁니다. 배치가 커지면 continuous batching이 여러 요청을 한 행렬곱으로 묶으면서 읽어온 가중치 하나당 연산량이 배치만큼 늘고, 가중치 행렬곱의 병목이 읽기에서 연산으로 옮겨갑니다. 이 구간의 W4A16은 읽기 이점을 쓸 곳이 없어진 채 16비트 복원 비용만 지불하므로 원본보다 뒤처질 수 있습니다. 반면 W8A8은 활성값까지 8비트라 저정밀 텐서코어의 연산 상한 자체가 두 배여서 여기서 앞섭니다. 다만 이 역전은 가중치 행렬곱 안에서만 벌어지는 일이고, KV를 읽는 어텐션은 배치가 커져도 memory-bound로 남습니다. 그래서 컨텍스트가 길어 KV 읽기 비중이 큰 워크로드일수록 W8A8의 우위는 좁아집니다.

이 내용 다시 읽기 →
분석과 판단진단함정
$ vllm serve Qwen3-32B-FP8-dynamic --max-model-len 8192   # A100 80GB x1
INFO  Detected quantization method: compressed-tensors (fp8)
INFO  Model weights take 30.50 GiB
INFO  GPU KV cache size: 144,384 tokens
INFO  Graph capturing finished

# 같은 장비, 같은 모델의 bf16 체크포인트
INFO  Model weights take 61.00 GiB
INFO  GPU KV cache size: 19,456 tokens

# 벤치마크 1 (concurrency=1, 출력 128 토큰)
                  bf16          FP8
TPOT p50          38.4 ms       21.6 ms

# 벤치마크 2 (입력 4K x 32요청, prefill 위주)
                  bf16          FP8
prefill 처리량    2,280 tok/s   2,210 tok/s

에러도 경고도 없이 뜨고 KV 예산은 일곱 배 넘게 늘었습니다. 저배치 TPOT도 절반 가까이 줄었는데 prefill 처리량만 오히려 조금 낮습니다. 원인은 무엇입니까?

  1. 사전 양자화 체크포인트인데 --quantization fp8을 주지 않아 양자화가 적용되지 않았습니다.

    체크포인트 설정에 양자화 방식이 적혀 있어 vLLM이 별도 플래그 없이 인식합니다. 로그의 감지 줄과 가중치 메모리가 절반으로 줄어든 사실이 양자화가 실제로 적용됐다는 증거입니다.

  2. prefill이 아직 memory-bound 구간이라 연산 정밀도가 처리량에 닿지 않습니다.

    prefill은 수천 토큰을 한 번에 미는 단계라 배치 1에서도 compute-bound입니다. FP8 연산이 실제로 돌았다면 prefill 처리량에서 가장 먼저 차이가 났어야 합니다. 이 장비에서는 배치를 더 키워도 FP8 연산 경로가 열리지 않으므로 격차 자체가 생기지 않습니다.

  3. --kv-cache-dtype fp8을 함께 주지 않아 어텐션이 여전히 16비트로 돌고 있습니다.

    KV Cache 양자화는 가중치 양자화와 별개 자원이고, 켜더라도 1차 효과는 KV 예산이지 가중치 행렬곱의 처리량이 아닙니다. 로그에서 KV 예산이 이미 크게 늘어난 것은 가중치가 줄어 남은 메모리가 KV로 갔기 때문입니다.

  4. 정답A100에 FP8 텐서코어가 없어 연산은 16비트로 하는 weight-only 모드로 폴백했습니다.

    FP8 행렬곱은 compute capability 8.9 이상, 즉 Ada Lovelace와 Hopper부터 열립니다. Ampere인 A100에서 vLLM은 거부하는 대신 FP8 가중치를 16비트로 복원해 계산하는 W8A16 경로로 내려가므로, 메모리 절감은 그대로 얻지만 연산 이득은 없습니다.

해설

돌아간다와 의도한 이득을 얻는다는 다른 문제입니다. FP8 행렬곱은 compute capability 8.9 이상에서만 열리고 Ampere에는 그 텐서코어가 없어서, vLLM은 FP8 체크포인트를 거부하는 대신 가중치만 8비트로 두고 연산은 16비트로 하는 weight-only 경로로 내려갑니다. 양자화가 줄이는 세 자원 중 가중치 메모리와 스텝당 읽기량은 저장 형식만으로 얻어집니다. 그래서 KV 예산이 실제로 늘고, 배치가 작아 읽기가 병목인 decode에서는 TPOT까지 절반 가까이 줄어듭니다. 사라지는 것은 연산 정밀도 이득 하나뿐이라, compute-bound인 prefill에서만 개선이 나타나지 않고 복원 비용 때문에 오히려 조금 뒤처집니다. 로그에 에러도 경고도 남지 않아 두 구간을 나눠 재보기 전에는 알아채기 어렵습니다. Ampere에서 활성값까지 양자화하려면 INT8 W8A8이 유일한 길이고, 그게 번거로우면 W4A16이 자연스러운 선택입니다. 반대로 Blackwell에서는 vLLM의 INT8 W8A8 경로를 쓸 수 없어 FP8로 가야 합니다. 양자화 형식은 GPU 세대를 확정한 다음에 고르는 순서가 맞습니다.

이 내용 다시 읽기 →
상황 적용진단
L40S 48GB x1, --kv-cache-dtype fp8 적용 전후

                        before          after
GPU KV cache size       88,064 tok      176,128 tok
max concurrency         10.75x          21.50x
TPOT p50 (입력 1K)      31.4 ms         31.2 ms
attention backend       FlashAttention 2 → FlashInfer (fp8 KV 지원 백엔드로 자동 전환)

KV 예산은 두 배가 됐는데 TPOT은 그대로입니다. 이 결과를 어떻게 읽어야 합니까?

  1. 예산만 늘었을 뿐 스텝마다 읽는 KV 바이트는 그대로입니다. 블록당 토큰 수가 16개로 고정이기 때문입니다.

    PagedAttention 블록의 크기를 정하는 것은 토큰 수가 아니라 토큰당 바이트입니다. 같은 16토큰 블록이 절반 크기가 되므로 스텝당 읽는 KV 바이트도 실제로 절반이 됩니다. 그대로인 것은 읽는 양이 아니라 그 절감이 지연에 드러날 조건입니다.

  2. 정답정상입니다. 1차 효과는 KV 예산이고 어텐션 가속은 백엔드와 컨텍스트에 달려 있습니다.

    FP8 KV는 기본적으로 저장 형식입니다. 쿼리까지 FP8로 양자화해 어텐션을 저정밀 도메인에서 계산하는 것은 특정 세대의 백엔드에서만 일어나고, Ada인 L40S가 넘어간 백엔드는 KV를 복원해 16비트로 계산합니다. 그 백엔드에서도 이득은 컨텍스트가 길어져 KV 읽기 비중이 커질수록 드러납니다.

  3. 스케일 calibration을 하지 않아 기본값 1.0이 쓰였고 그래서 속도 이득이 상쇄됐습니다.

    스케일은 품질에 걸리는 값입니다. 기본 스케일 1.0은 분포가 큰 모델에서 outlier가 잘려 정확도를 떨어뜨릴 수 있지만, 속도를 되돌려놓지는 않습니다.

  4. KV 예산이 늘어난 만큼 배치가 자동으로 커져서 요청당 지연이 상쇄됐습니다.

    늘어난 것은 태울 수 있는 요청 수의 상한이지 실제로 태워진 요청 수가 아닙니다. 같은 부하에서 전후를 잰 수치라 배치는 그대로이고, 예산 상한 자체는 지연에 직접 관여하지 않습니다.

해설

KV Cache 양자화는 어텐션이 빨라지는 기능이 아니라 KV 예산이 두 배가 되는 기능으로 이해하는 편이 안전합니다. 같은 예산에 두 배의 토큰이 들어가므로 동시에 올려둘 수 있는 요청 수나 감당 가능한 컨텍스트 길이가 그만큼 늘고, 이 효과는 백엔드와 무관하게 항상 나옵니다. 어텐션 연산까지 저정밀로 도는지는 백엔드에 달려 있습니다. 쿼리까지 FP8로 양자화해 어텐션 행렬곱을 FP8로 계산하는 백엔드는 최신 세대에 한정되고, 그 외 백엔드는 FP8을 저장 형식으로만 쓰고 커널 안에서 복원해 16비트로 계산합니다. 여기에 걸림돌이 하나 더 있습니다. fp8 KV를 아예 받지 않는 백엔드도 있어서 이 옵션을 켜면 vLLM이 백엔드를 갈아탈 수 있고, 그러면 어텐션 커널이 바뀌는 만큼 다른 성능 특성도 함께 달라집니다. 저정밀 어텐션이 도는 경우에도 이득은 컨텍스트 길이에 비례합니다. 스텝마다 읽는 KV가 충분히 많아야 절반으로 줄인 효과가 지연에 드러나므로, 입력이 1K 수준인 워크로드에서는 개선이 보이지 않는 것이 정상입니다. 품질 쪽에서 따로 챙길 것은 스케일인데, 기본값 1.0은 분포가 큰 모델에서 outlier를 잘라낼 수 있어 calibration으로 K와 V의 스케일을 체크포인트에 구워두는 편이 안전합니다.

이 내용 다시 읽기 →
분석과 판단구조면접
같은 모델 패밀리에서 dense 12B와 dense 31B에는 4비트 QAT 체크포인트가 공식 배포됩니다. 같은 패밀리의 dense 2B급과 4B급 모델에도 4비트 QAT 체크포인트가 배포됩니다. dense 12B의 FFN 내부 차원은 15,360입니다. 그런데 총 파라미터가 26B급인 MoE 모델(활성 파라미터 4B, expert 하나의 내부 차원 704)에는 4비트 체크포인트가 없고 8비트 weight-only가 권장됩니다.

MoE 쪽만 4비트에서 빠진 이유로 가장 정확한 것은 무엇입니까?

  1. 활성 파라미터가 4B뿐이라 실질 크기가 작고, 작은 모델일수록 4비트를 못 견딥니다.

    모델이 작을수록 양자화에 민감한 경향은 있지만 여기서 기준이 되는 것은 활성 파라미터 수가 아닙니다. 문제는 양자화가 실제로 적용되는 단위, 즉 개별 expert 행렬의 크기에 있습니다.

  2. top-k 라우터의 로짓이 4비트로 뭉개져 expert 선택이 흔들리기 때문입니다.

    라우터는 파라미터 비중이 극히 작아 보통 높은 정밀도로 남겨두는 부분이라 4비트로 내릴 대상이 아닙니다. 손실이 생기는 곳은 라우터가 아니라 expert의 FFN 행렬입니다.

  3. 정답expert 하나의 행렬이 작아 양자화 오차를 흡수할 파라미터와 스케일 그룹이 부족합니다.

    양자화는 행렬을 채널이나 그룹 단위로 나눠 스케일을 따로 잡습니다. 내부 차원이 704밖에 안 되면 한 행렬에 담기는 그룹 수와 파라미터 수가 적어 스케일 하나가 넓은 분포를 떠맡고, 오차를 상쇄해줄 이웃도 적어 출력에 그대로 실립니다.

  4. MoE는 QAT 추가 학습에서 수렴이 느려 같은 스텝 수로 4비트를 회복하지 못합니다.

    학습 예산을 늘려 해결되는 문제였다면 4비트가 통째로 빠지는 대신 더 오래 학습한 버전이 나왔을 것입니다. 4비트가 제외되고 8비트가 권장된 것은 학습 난이도가 아니라 형식 자체의 한계를 가리킵니다.

해설

몇 비트까지 안전한가는 모델 크기가 아니라 행렬의 모양이 정합니다. 양자화는 텐서를 통째로 다루지 않고 채널이나 일정 개수씩의 그룹으로 나눠 스케일을 따로 잡습니다. 행렬이 크면 그룹이 많아 스케일이 각 구간의 분포에 잘 맞고, 오차도 여러 파라미터에 흩어져 출력에서 상쇄될 여지가 생깁니다. 반대로 내부 차원이 수백 수준인 작은 행렬은 그룹 수 자체가 적어 스케일 하나가 담당하는 분포가 넓어지고, 상쇄해줄 이웃도 적어 오차가 출력에 그대로 실립니다. expert를 잘게 쪼개는 fine-grained MoE는 총 파라미터가 커도 개별 행렬은 작아서, 같은 패밀리의 dense 모델이 견디는 비트 수를 감당하지 못합니다. 그 대안이 8비트 weight-only인데, 표현 가능한 값이 4비트의 열여섯 배라 같은 그룹 수로도 오차가 훨씬 작습니다. 양자화 형식을 고를 때 파라미터 총량만 보면 이 차이를 놓칩니다.

이 내용 다시 읽기 →
상황 적용구조면접

GPTQ와 AWQ는 둘 다 재학습 없이 W4A16 체크포인트를 만듭니다. 반올림만으로는 무너지는 품질을 두 방식이 각각 어떤 방법으로 지키는지 바르게 짝지은 것은 무엇입니까?

  1. GPTQ는 활성값 통계로 중요 채널을 골라 스케일로 보호하고, AWQ는 Hessian으로 나머지 가중치를 갱신합니다.

    두 방식의 역할이 뒤바뀐 서술입니다. 활성값 통계로 중요 채널을 골라 스케일링으로 보호하는 쪽이 AWQ이고, Hessian으로 아직 양자화하지 않은 가중치를 갱신하는 쪽이 GPTQ입니다. AWQ에는 오차를 되돌리는 재구성 과정 자체가 없어서 Hessian을 계산할 일이 없습니다.

  2. GPTQ는 가중치 크기 상위 1%를, AWQ는 활성값 크기 상위 1%를 각각 16비트로 남겨둡니다.

    AWQ 논문이 실험으로 확인한 사실이지만 채택한 방법은 아닙니다. 큰 활성값과 만나는 1% 채널을 16비트로 남기면 품질이 크게 회복되는데, 혼합 정밀도는 시스템 구현을 어렵게 만들어서 그 채널의 가중치를 미리 키우고 활성값을 같은 배수로 줄이는 스케일링으로 대체했습니다. GPTQ에는 가중치 크기로 채널을 고르는 단계 자체가 없습니다.

  3. GPTQ는 calibration 데이터 없이 반올림 경계만 조정하고, AWQ는 양자화를 시뮬레이션하는 추가 학습으로 스케일을 배웁니다.

    GPTQ는 calibration 데이터를 흘려 각 레이어의 입력 통계를 모으는 데서 출발합니다. 그 통계가 없으면 갱신량을 정할 Hessian이 나오지 않습니다. AWQ도 학습을 하지 않습니다. 활성값 통계를 미리 모아 스케일을 정할 뿐이고, 학습 과정에 양자화를 끼워 넣는 것은 PTQ가 아니라 QAT입니다.

  4. 정답GPTQ는 아직 양자화하지 않은 가중치를 Hessian으로 갱신해 오차를 흡수하고, AWQ는 활성값이 큰 채널을 스케일링으로 보호합니다.

    GPTQ가 지키려는 것은 가중치 값이 아니라 레이어 출력입니다. 한 열을 양자화할 때마다 거기서 생긴 출력 오차를 상쇄하는 방향으로 남은 가중치를 옮기고, 그 이동량은 입력 통계의 2차 정보인 Hessian이 정합니다. AWQ는 오차를 사후에 되돌리는 대신 다치면 안 되는 채널을 미리 지키는데, 그 채널을 가중치 자신의 크기가 아니라 곱해지는 활성값의 크기로 고릅니다.

해설

두 방식은 무엇을 보고 중요도를 정하는가와 오차를 어떻게 다루는가에서 갈립니다. GPTQ는 오차를 흡수시키는 쪽입니다. 보존 대상을 가중치 값이 아니라 레이어 출력으로 잡고, calibration 데이터로 각 레이어의 입력 통계를 모은 뒤 가중치를 한 열씩 양자화합니다. 한 열을 내릴 때마다 생긴 출력 오차를 상쇄하도록 아직 손대지 않은 나머지 가중치를 옮기는데, 그 이동량을 입력 통계의 2차 정보인 Hessian이 정합니다. 역전파도 재학습도 없이 한 번 훑는 것으로 끝나 175B 규모도 GPU 몇 시간이면 처리됩니다. AWQ는 중요한 것을 애초에 지키는 쪽입니다. 출발점은 모든 가중치가 똑같이 중요하지 않다는 관찰인데, 중요도의 기준이 가중치 자신의 크기가 아니라 그 가중치에 곱해지는 활성값의 크기라는 것이 핵심입니다. 활성값이 큰 채널 1% 남짓을 16비트로 남기면 품질이 크게 회복되지만 형식이 섞인 데이터 타입은 시스템 구현을 어렵게 만듭니다. 그래서 그 채널의 가중치를 미리 키우고 대응하는 활성값을 같은 배수로 나누는 등가 변환으로 같은 보호 효과를 냅니다. 결과물은 형식이 섞이지 않은 균일한 4비트입니다. 이 차이는 준비물에도 나타납니다. 오차를 되돌리는 재구성이 없는 AWQ는 GPTQ보다 훨씬 적은 calibration 데이터로 더 나은 결과를 내고, calibration 분포가 바뀌어도 덜 흔들립니다. 다만 어느 쪽이 낫다고 단정하기는 어렵습니다. AWQ 논문은 GPTQ가 calibration 데이터에 과적합될 수 있다고 지적하지만, 양자화 범위의 상한을 조정하는 clipping까지 튜닝하면 GPTQ가 앞선다는 대규모 평가도 있습니다.

이 내용 다시 읽기 →
상황 적용운영함정
H100 80GB x1, 30B dense 모델 (Hugging Face에 FP8 체크포인트 없음)
vLLM 문서가 제시하는 FP8의 기대치: 메모리 절반, 처리량 최대 1.6배

$ vllm serve Model-30B --quantization fp8
INFO  Model weights take 27.94 GiB        # bf16으로 띄우면 55.88 GiB
INFO  GPU KV cache size: 224,928 tokens   # bf16으로 띄우면 72,096 tokens

벤치마크 (concurrency=64, 입력 1K / 출력 256 토큰)
                    bf16          --quantization fp8
전체 출력 처리량    2,980 tok/s   3,210 tok/s
TPOT p50            21.5 ms       19.9 ms

메모리는 문서가 말한 대로 절반이 됐는데 처리량은 1.08배에 그쳤습니다. 이 결과를 가장 정확하게 설명한 것은 무엇입니까?

  1. 정답온라인 양자화는 텐서 하나에 스케일 하나를 잡고 활성값 스케일을 매 스텝 다시 계산합니다. 구조상 지연 개선 폭이 제한적입니다.

    온라인 양자화는 로드 시점에 Linear 가중치를 텐서 단위 스케일 하나로 내리고, 활성값은 매 스텝 최댓값과 최솟값을 재서 스케일을 새로 만듭니다. vLLM 문서도 이 모드에서는 지연 개선이 제한적이라고 못박아둡니다. 저장 형식만으로 결정되는 가중치 메모리와 KV 예산은 기대치대로 나오고 어긋나는 것은 처리량과 지연뿐인데, 로그가 정확히 그 모양입니다.

  2. 이 플래그는 가중치만 8비트로 두는 옵션이라 활성값이 16비트로 남습니다. 피연산자가 어긋나 FP8 행렬곱 경로가 열리지 않았습니다.

    온라인 양자화는 활성값까지 8비트로 내리는 W8A8입니다. 활성값 스케일을 매 스텝 동적으로 잡을 뿐 16비트로 남겨두지 않으므로, FP8 텐서코어가 있는 H100에서는 FP8 행렬곱이 실제로 돌아갑니다. 가중치만 8비트로 남고 연산이 16비트가 되는 것은 FP8 텐서코어가 없는 Ampere에서 폴백할 때의 이야기입니다.

  3. 온라인 양자화는 가중치 스케일까지 매 스텝 다시 계산합니다. 그 비용이 FP8 행렬곱으로 번 시간을 대부분 되돌려놓았습니다.

    가중치는 서버가 뜰 때 한 번 양자화되고 스케일도 그때 정해져 그대로 고정됩니다. 매 스텝 다시 계산되는 것은 활성값 스케일뿐입니다. 30B 가중치를 스텝마다 다시 훑는다면 1.08배가 아니라 bf16보다 크게 느려졌을 것입니다.

  4. 온라인 양자화의 FP8 커널은 prefill 구간에만 적용됩니다. 출력 토큰을 뽑는 decode가 16비트로 돌아 처리량이 거의 그대로입니다.

    8비트로 내린 Linear 레이어는 prefill과 decode를 가리지 않고 같은 커널로 돌아갑니다. 단계에 따라 연산 정밀도가 갈리는 경로는 vLLM에 없습니다. decode가 16비트로 돌았다면 TPOT이 bf16과 같아야 하는데 로그에서는 21.5ms에서 19.9ms로 줄었습니다.

해설

vLLM에서 FP8을 쓰는 길은 두 갈래이고 둘의 차이는 스케일에 있습니다. 이 로그가 쓴 온라인 양자화는 서빙 시작 시점에 Linear 가중치를 즉석에서 FP8로 내리는데, 스케일을 텐서 하나에 값 하나로 뭉뚱그려 잡습니다. 활성값 쪽은 아예 미리 정해둔 값이 없어서 매 스텝 값의 범위를 재서 스케일을 새로 만듭니다. 준비물이 없다는 것이 이 방식의 장점이고, 체크포인트가 아직 없는 모델을 당장 메모리에 맞춰 띄워야 할 때 쓸 만합니다. 대신 스케일 단위가 거친 데다 매 스텝 계산이 붙어서 지연 개선 폭은 원래 제한적입니다. 그래도 저장 형식만으로 결정되는 것은 온전히 얻습니다. 가중치 메모리가 절반이 되고 줄어든 27.94GiB가 거의 그대로 KV 예산으로 넘어가 동시에 올려둘 수 있는 요청 수가 늘어납니다. 처리량과 지연까지 문서의 기대치에 가깝게 끌어올리려면 llm-compressor로 가중치를 채널 단위 스케일로 미리 양자화한 체크포인트를 만드는 쪽이 권장 경로입니다. 스케일이 채널마다 따로 잡히고 그 값이 체크포인트에 구워져 나오므로 같은 8비트로도 분포를 더 촘촘히 담습니다. 이렇게 만든 체크포인트는 별도 플래그 없이 그냥 로드하면 되고, vLLM이 설정에서 양자화 방식을 읽어 알아서 인식합니다. 정리하면 온라인 양자화는 메모리가 급할 때 바로 꺼내 쓰는 수단이고, 처리량이 목표라면 미리 만든 체크포인트가 기본 선택입니다.

이 내용 다시 읽기 →

Speculative Decoding으로 LLM 추론 속도 높이기

원문 읽기 →
개념 확인함정

팀원이 도입을 반대하며 “작은 draft 모델이 그린 토큰이 출력에 섞이니 품질이 조금은 떨어질 것”이라고 말합니다. rejection sampling이 실제로 보장하는 것은 무엇입니까?

  1. draft가 과하게 뽑아온 토큰의 확률을 낮춰 품질 저하를 무시할 만한 수준으로 억제합니다.

    억제가 아니라 복원입니다. 깎아낸 확률이 거절 시 재샘플링 분포로 정확히 돌아가기 때문에 오차가 조금 남는 것이 아니라 0입니다. 근사와 정확의 차이를 정도의 차이로 읽으면 이 기법을 품질과 속도의 교환으로 오해하게 됩니다.

  2. 수락률이 충분히 높을 때에만 target 단독 출력과 같은 분포를 얻을 수 있습니다.

    수락률은 속도에만 관여합니다. 수락률이 0.9든 0.1이든 최종 분포는 똑같이 target과 일치하고, 낮은 수락률이 깎는 것은 iteration당 확정 토큰 수뿐입니다. 수락률이 아주 낮으면 draft 비용만 얹혀 느려지지만 그때도 출력의 품질은 변하지 않습니다.

  3. 정답최종 토큰의 확률분포가 target 단독으로 뽑을 때와 정확히 같습니다.

    수락 규칙과 거절 시 재샘플링을 합치면 최종 토큰이 target의 분포에서 뽑힌 것과 수학적으로 같아진다는 것이 원 논문의 정리입니다. 보장 대상은 분포이므로 문장은 매번 달라질 수 있습니다. greedy(temperature 0)라면 이론적으로는 문장까지 같지만, 그것도 부동소수점 연산 순서까지 같다는 가정 아래의 이야기입니다.

  4. 같은 프롬프트에 대해 speculative decoding을 끈 서버와 항상 같은 문장이 나옵니다.

    분포가 같다는 것과 문장이 같다는 것은 다릅니다. temperature가 0보다 크면 sampling의 무작위성 때문에 문장은 당연히 달라지고, 이것은 speculative decoding과 무관하게 원래 그렇습니다. greedy에서도 부동소수점 연산 순서 차이로 아주 드물게 갈릴 수 있어서 vLLM 문서는 하드웨어 수치 정밀도 한계 안에서의 무손실이라고 적어둡니다.

해설

보장되는 것은 출력 분포의 동일성이고 개별 문장의 동일성이 아닙니다. draft가 뽑아온 토큰 x는 min(1, p(x)/q(x))의 확률로 수락되고, 거절이 나면 max(0, p - q)를 정규화한 분포에서 다시 뽑습니다. 앞에서 깎아낸 확률이 뒤의 재샘플링 분포로 정확히 돌아가기 때문에 두 경로를 합친 결과가 target의 분포 p와 일치합니다. 그래서 수락률이 낮아도 품질은 그대로이고, 낮은 수락률이 깎는 것은 속도뿐입니다. 문장이 달라지는 것은 sampling에 원래 있던 무작위성 때문이지 draft의 취향이 섞여서가 아닙니다. 실무에서는 검증 forward가 여러 위치를 한꺼번에 계산하면서 연산 순서가 달라져 미세한 수치 차이가 날 수 있는데, 이것은 배치 크기가 바뀔 때 언제나 생기는 종류의 오차이지 이 기법이 새로 들여온 손실이 아닙니다. 그래서 도입 판단에서 볼 것은 품질 저하 여부가 아니라 수락률과 배치 크기입니다.

이 내용 다시 읽기 →
상황 적용계산
draft 모델이 다음 위치에 “때문”이라는 토큰을 제안했습니다. draft가 이 토큰에 준 확률 q = 0.5 target이 이 토큰에 준 확률 p = 0.2

rejection sampling 규칙에 따라 target이 이 토큰을 수락할 확률은 얼마입니까?

  1. 정답40%입니다. q가 p보다 클 때는 target 확률을 draft 확률로 나눈 비율만큼만 통과시킵니다.

    q가 p보다 크므로 수락 확률은 min(1, p/q) = 0.2 / 0.5 = 0.4입니다. 이 토큰이 최종 출력에 등장할 확률은 뽑혀올 확률 0.5에 통과율 0.4를 곱한 0.2로, target이 준 확률과 정확히 같아집니다. draft가 과하게 뽑아오는 만큼만 깎아내는 것이 이 비율의 의미입니다.

  2. 100%입니다. 두 확률의 비가 0.5 나누기 0.2로 1을 넘으므로 그대로 통과시킵니다.

    무조건 수락하는 것은 p가 q 이상일 때입니다. 여기서는 draft가 target보다 이 토큰을 2.5배 자주 뽑아오므로 그대로 통과시키면 draft의 취향이 출력에 그대로 남습니다. 비율을 q/p로 뒤집어 계산하면 2.5가 나와 1로 잘리는데, 분자와 분모를 바꿔 쓴 경우입니다.

  3. 20%입니다. target이 그 토큰에 준 확률이 곧 통과 비율입니다.

    p 자체를 통과율로 쓰면 최종 빈도가 0.5 × 0.2 = 0.1이 되어 target이 원한 0.2의 절반으로 떨어집니다. 깎는 방향은 맞지만 과하게 깎는 것입니다. 통과율은 target의 확률이 아니라 target과 draft 확률의 비입니다.

  4. 60%입니다. 두 확률의 비를 1에서 뺀 값이 통과 비율입니다.

    1 - p/q = 0.6은 수락이 아니라 거절 확률입니다. 이 값을 통과율로 쓰면 최종 빈도가 0.5 × 0.6 = 0.3이 되어 target의 0.2보다 오히려 높아집니다. draft가 과하게 뽑아오는 토큰을 더 통과시키는 셈이라 규칙의 목적과 정반대입니다.

해설

수락 확률은 min(1, p/q)이므로 0.2 / 0.5 = 0.4, 즉 40%입니다. 이 비율의 목적은 draft가 과하게 뽑아오는 만큼을 정확히 깎아내는 것입니다. 토큰이 최종 출력에 나올 확률은 draft가 뽑아올 확률 q에 통과율을 곱한 값이므로, 통과율을 p/q로 두면 q에 p/q를 곱해 p가 되어 target의 확률로 되돌아옵니다. p가 q 이상인 토큰은 draft가 오히려 덜 뽑아오는 쪽이라 깎을 것이 없으므로 무조건 수락합니다. 거절이 났을 때 target의 원래 분포 p에서 그냥 다시 뽑으면 이미 수락으로 통과한 몫이 이중으로 더해져 균형이 깨지므로, max(0, p - q)를 정규화한 분포에서 다시 뽑습니다. 깎아낸 확률이 이쪽으로 정확히 돌아가기 때문에 두 경로를 합친 최종 분포가 p와 일치합니다. 계산 자체는 나눗셈 한 번이지만 p와 q 중 어느 쪽이 분자인지를 놓치면 규칙의 방향이 통째로 뒤집힙니다.

이 내용 다시 읽기 →
상황 적용계산
draft 토큰 수를 3개로 잡고(num_speculative_tokens = 3) 서빙 중입니다. 측정 결과 draft 토큰 하나가 수락될 확률은 평균 0.5였습니다.

이 설정에서 검증 forward 한 번이 확정하는 토큰 수의 기댓값은 얼마입니까? 토큰마다 수락 여부는 서로 독립이라고 봅니다.

  1. 약 1.5토큰입니다. draft 3개 중 평균적으로 수락되는 개수입니다.

    수락 확률과 draft 수를 곱한 값인데 두 가지가 빠졌습니다. 하나는 거절이 나도 target이 그 자리에서 토큰 하나를 다시 뽑아 주므로 최소 1토큰은 확정된다는 점이고, 다른 하나는 앞에서 거절이 나면 뒤쪽 draft가 버려지므로 수락된 개수가 3번의 시도 합이 아니라 앞에서부터 이어진 연속 성공 횟수라는 점입니다.

  2. 2토큰입니다. 수락 확률 0.5에서 이 기법이 낼 수 있는 천장값입니다.

    1 나누기 (1 빼기 0.5)로 얻는 2는 draft 토큰 수를 무한히 늘렸을 때의 극한입니다. draft 수가 유한하면 그 값에서 수락 확률의 거듭제곱 항이 빠지므로 천장보다 작아지고, 3개일 때는 약 1.875로 천장의 94% 지점입니다. 천장이 2라는 사실 자체가 draft를 길게 잡는 이득에 한계가 있다는 뜻입니다.

  3. 약 2.5토큰입니다. 평균 수락 개수에 target이 뽑는 1개를 더한 값입니다.

    1을 더하는 구조는 맞지만 평균 수락 개수가 1.5가 아닙니다. 앞에서 거절이 나면 뒤는 통째로 버려지므로 수락은 앞에서부터 연속으로만 쌓입니다. 실제 평균 수락 개수는 0.5 + 0.25 + 0.125로 약 0.875이고 여기에 1을 더하면 약 1.9가 됩니다.

  4. 정답약 1.9토큰입니다. 앞에서 한 번 거절되면 뒤의 draft는 전부 버려집니다.

    원 논문의 식에 대입하면 (1 - 0.5^4) / (1 - 0.5) = 0.9375 / 0.5 = 1.875입니다. 손으로 세도 같습니다. 0개 수락(확률 0.5)이면 1토큰, 1개(0.25)면 2토큰, 2개(0.125)면 3토큰, 3개 전부(0.125)면 보너스까지 4토큰이므로 0.5 + 0.5 + 0.375 + 0.5 = 1.875입니다.

해설

기댓값은 1.875토큰입니다. 원 논문의 식은 수락 확률을 α, draft 토큰 수를 γ라 할 때 (1 - α^(γ+1)) / (1 - α)이고, 여기에 0.5와 3을 넣으면 (1 - 0.0625) / 0.5가 되어 이 값이 나옵니다. 식을 외우지 않아도 구조만 알면 셀 수 있습니다. 앞에서부터 k개가 수락되고 그다음에서 거절될 확률은 α의 k제곱에 (1 - α)를 곱한 값이고, 거절 위치에서 target이 토큰 하나를 다시 뽑아 주므로 확정 토큰은 k + 1개입니다. draft가 전부 수락된 경우에만 target이 이미 계산해둔 그다음 위치의 분포에서 하나를 더 뽑아 γ + 1개가 됩니다. 그래서 확정 토큰 수는 최소 1개, 최대 γ + 1개 사이입니다. draft 토큰 수를 키우면 기댓값도 오르지만 α^(γ+1)이 빠르게 0으로 가면서 1 / (1 - α)라는 천장에 막히는 반면 draft 생성 비용은 γ에 비례해 계속 늘어나므로, draft를 무한정 길게 잡는 것은 손해로 돌아섭니다.

이 내용 다시 읽기 →
상황 적용진단운영
# 동일 모델, 동일 GPU, speculative decoding on/off 비교
# 지표는 TPOT(토큰 간 지연) 중앙값

부하        spec off    spec on     변화
QPS 1       38 ms       24 ms       1.6배 빠름
QPS 6       41 ms       36 ms       1.1배 빠름
QPS 16      55 ms       86 ms       1.6배 느림

# spec on 구간의 acceptance rate는 세 부하 모두 0.74로 동일 (draft는 EAGLE-3 speculator)
# KV Cache 사용률은 세 부하 모두 60% 미만, 선점 0건

낮은 부하에서 분명히 이득이던 speculative decoding이 피크 트래픽에서는 오히려 손해가 됩니다. 가장 정확한 설명은 무엇입니까?

  1. 피크 시간대에 유입되는 프롬프트의 도메인과 temperature가 달라져 draft의 예측이 어긋나고, 버려지는 draft 연산이 늘어난 것입니다.

    수락률이 세 부하 모두 0.74로 같으므로 draft의 적중은 변하지 않았습니다. 문제는 draft가 얼마나 맞히느냐가 아니라 맞힌 것을 검증할 여유 연산이 남아 있느냐입니다. 같은 수락률에서 결과가 뒤집혔다는 사실 자체가 원인이 draft 쪽이 아니라는 증거입니다.

  2. 정답배치가 커지며 decode가 compute-bound에 가까워져 검증에 쓰던 유휴 연산이 남지 않기 때문입니다.

    이 기법의 이득은 memory-bound인 decode가 남기는 유휴 연산에서 나옵니다. continuous batching이 여러 요청을 한 배치로 묶으면 그 유휴 연산은 이미 다른 요청의 처리량으로 쓰이고 있어서, 거절된 draft의 계산이 공짜가 아니라 남의 자원을 뺏은 순손실이 됩니다. 게다가 요청마다 draft를 여러 개 얹으면 검증 forward가 다루는 토큰 수 자체가 몇 배로 늘어 포화 시점이 앞당겨집니다.

  3. KV Cache가 부족해져 요청 선점과 재계산이 발생했고 그 비용이 지연으로 나타난 것입니다.

    높은 부하에서 선점이 늘어날 수는 있지만 그것은 speculative decoding을 껐을 때도 똑같이 일어납니다. 여기서 설명해야 하는 것은 같은 부하에서 켠 쪽만 더 느려진 차이입니다. draft 토큰이 KV 블록을 조금 더 쓰는 것만으로 1.6배의 역전이 만들어지지는 않습니다.

  4. draft 모델이 GPU 메모리를 점유해 target이 쓸 수 있는 배치 크기가 줄어든 것입니다.

    draft 가중치가 KV Cache 예산을 조금 깎는 것은 맞지만, 지문의 세 부하 모두 KV 사용률이 60% 미만이고 선점이 0건이라 배치 크기를 제약하지 않았습니다. 여기서 뒤집힌 것은 담을 수 있는 요청 수가 아니라 검증에 쓸 연산 여유입니다. EAGLE 계열처럼 decoder layer 하나 남짓인 모듈은 점유량 자체도 작습니다.

해설

이득의 원천이 사라졌기 때문입니다. speculative decoding은 계산의 총량을 줄이지 않습니다. 거절된 draft의 계산이 전부 버려지므로 총량은 오히려 늘고, 대신 걸리는 시간을 줄입니다. 이 거래는 decode가 memory-bound라서 연산 유닛이 놀고 있을 때만 성립합니다. 그런데 continuous batching은 이미 그 유휴 연산을 여러 요청의 처리량으로 바꾸고 있어서, 두 기법은 같은 자원을 두고 경쟁하는 관계입니다. 배치가 커져 GPU가 포화되면 버려지는 draft 계산은 다른 요청이 쓸 수 있었던 자원을 낭비한 비용으로 성격이 바뀝니다. 그래서 이 기법은 낮은 부하에서 지연을 줄이는 도구이지 처리량을 올리는 도구가 아닙니다. 트래픽이 출렁이는 서비스라면 동시성 구간별로 draft 길이를 다르게 선언해 한가할 때만 이득을 취하고 피크에는 speculation이 자동으로 물러나게 두는 것이 안전합니다.

이 내용 다시 읽기 →
분석과 판단예측운영
같은 모델을 H100 8장 노드에 같은 방식으로 올리고 두 서비스를 부하 시험했습니다. 두 경우 모두 vLLM 지표에서 실행 배치(running batch)가 64로 유지되고 선점이 0건인 것을 확인했으며, 수락률도 0.7 남짓으로 비슷했습니다. A: 평균 프롬프트 1K 토큰인 챗봇. speculative decoding을 켜면 TPOT 중앙값이 느려집니다. B: 평균 프롬프트 32K 토큰인 문서 분석. speculative decoding을 켜도 TPOT 중앙값이 여전히 빠릅니다.

같은 배치 크기인데 B에서만 이득이 남습니다. 이 차이를 만드는 것은 무엇입니까?

  1. 프롬프트가 길수록 draft가 참고할 문맥이 많아져 수락률이 오르고 그만큼 이득이 커집니다.

    수락률이 워크로드를 타는 것은 맞지만 그것으로는 배치를 키웠을 때의 차이를 설명하지 못합니다. 수락률이 아무리 높아도 GPU가 연산으로 포화되면 버려지는 draft 계산이 순손실이 되어 이득은 사라집니다. B가 버티는 이유는 포화 지점이 뒤로 밀린 것이지 draft가 더 잘 맞혀서가 아닙니다.

  2. 긴 프롬프트는 prefill이 오래 걸려 전체 지연에서 decode 비중이 작아지고 손해가 가려집니다.

    prefill이 길면 TTFT가 커지는 것은 맞지만 speculative decoding이 손대는 구간은 decode입니다. 손해가 가려진 것뿐이라면 decode 구간만 따로 재는 순간 드러나야 하는데, 실제로는 그 구간 자체가 여전히 빨라집니다. 평균에 묻힌 것이 아니라 이득이 실재하는 상황입니다.

  3. 정답스텝마다 읽는 KV가 배치와 컨텍스트 길이의 곱으로 늘어, 가중치 읽기를 나눠 얻은 이득을 덮습니다.

    가중치는 배치를 키워도 읽는 양이 늘지 않아 요청당 부담이 줄지만, 어텐션이 매 스텝 읽어야 하는 KV Cache는 배치 크기와 컨텍스트 길이 양쪽에 비례해 늘어납니다. 컨텍스트가 충분히 길면 KV 읽기가 가중치 읽기를 압도해서 연산이 아니라 메모리 대역폭이 병목으로 남습니다. 검증에 쓸 유휴 연산이 계속 남아 있으니 이 기법의 전제가 그대로 유지됩니다.

  4. 긴 컨텍스트에서는 KV Cache 때문에 배치에 실제로 담기는 요청 수가 줄어 사실상 낮은 부하가 됩니다.

    지문에서 실행 배치가 64로 유지되고 선점이 0건임을 확인했으므로 배치가 덜 찬 상태가 아닙니다. 이 설명은 큰 배치에서 이득이 남았다는 관찰이 아니라 큰 배치가 애초에 만들어지지 않았다는 주장이라 관찰과 어긋납니다. 배치를 실제로 채운 상태에서도 이득이 남는 이유는 병목의 종류가 바뀌었기 때문입니다.

해설

decode를 memory-bound에 붙잡아 두는 것이 가중치 읽기 하나가 아니기 때문입니다. 배치를 키우면 가중치는 한 번 읽어 여러 요청이 나눠 쓰므로 요청당 가중치 읽기 부담이 줄고 decode는 compute-bound 쪽으로 이동합니다. 반면 어텐션이 매 스텝 읽어야 하는 KV Cache는 배치 크기와 시퀀스 길이의 곱에 비례해 커집니다. 컨텍스트가 충분히 길면 이 KV 읽기가 가중치 읽기를 압도해서 배치를 키워도 decode가 memory-bound에 머물고, 연산 유닛이 계속 놀고 있으니 검증은 여전히 거의 공짜입니다. 모델과 하드웨어 조합마다 이 성질이 나타나기 시작하는 임계 시퀀스 길이가 존재하고, 그 너머에서는 배치를 키울수록 이득이 오히려 커질 수 있다는 것이 MagicDec의 분석입니다. 다만 draft도 같은 KV 부담을 지므로 draft가 보는 컨텍스트 창을 고정해 KV 읽기를 줄이는 것이 이 구간에서 draft를 값싸게 유지하는 방법입니다. 배치 크기 하나만 보고 speculation을 끌지 말지 결정하면 이런 워크로드에 남아 있는 이득을 통째로 놓칩니다. 판단 기준은 동시 요청 수가 아니라 지금 decode의 병목이 무엇인지입니다.

이 내용 다시 읽기 →
상황 적용운영함정
사내 문서 RAG 서비스를 vLLM으로 서빙합니다. 검색된 문서 청크 6K 토큰을 프롬프트에 붙이고 근거 문장을 인용하면서 답하게 하는 형식입니다. - 응답 500건을 표본으로 재보니 생성된 4-gram의 78%가 같은 요청의 프롬프트 안에 그대로 있었습니다. - 트래픽은 초당 1건 수준이고 GPU 연산 사용률은 낮습니다. 이번 과제는 토큰 간 지연을 줄이는 것입니다. - target은 공개 모델을 사내 데이터로 파인튜닝한 32B입니다. 이 모델에 맞는 EAGLE speculator 체크포인트는 공개된 것이 없고, 이번 분기에 speculator를 학습할 일정도 없습니다. - 베이스 모델 제작자는 MTP 모듈을 배포하지 않았습니다. - 사내에 같은 tokenizer와 vocabulary를 쓰는 0.6B 모델이 있어 draft로 붙일 수는 있습니다.

이 서비스에서 speculative decoding을 어떤 방식으로 먼저 켜야 하며, 그 판단의 근거는 무엇입니까?

  1. 사내 0.6B 모델을 draft로 붙입니다. 별도 draft 모델은 target의 hidden state를 이어받아 다음 토큰을 그리므로 문자열 매칭보다 수락률이 구조적으로 높습니다.

    vocabulary가 같으니 붙이는 것 자체는 가능하지만 근거로 든 사실이 틀렸습니다. target의 hidden state를 입력으로 받는 것은 EAGLE 계열의 speculator이고, 별도 draft 모델은 target과 무관하게 자기 가중치로만 예측합니다. 그래서 vLLM 실측에서도 낮은 부하 기준 draft 모델 방식은 최대 1.5배에 그쳐 요약 데이터셋에서 최대 2.8배를 낸 n-gram에 밀렸습니다.

  2. 정답n-gram 방식을 켭니다. 최근 토큰열을 프롬프트에서 찾아 그 뒤를 draft로 쓰므로, 출력 4-gram의 78%가 프롬프트에 있는 이 워크로드에서 잘 맞습니다.

    n-gram은 모델도 체크포인트도 필요 없어서 지문이 건 제약을 전부 비껴갑니다. draft 비용 c가 사실상 0이라 E / (γc + 1)의 분모가 1로 남고 수락률이 그대로 이득이 됩니다. 프롬프트를 얼마나 복사하는지가 4-gram 중복률 78%로 이미 관측되어 있으므로 적중을 기대할 근거도 지문 안에 있습니다.

  3. EAGLE-3을 켭니다. speculator는 target 가중치에서 hidden state를 받아 그 자리에서 만들어지므로, 체크포인트 없이 method만 지정하면 켜집니다.

    hidden state를 이어받는다는 설명은 맞지만 그것이 체크포인트를 면제해주지는 않습니다. EAGLE의 speculator는 따로 학습된 모듈이라 `--speculative-config`의 `model`에 그 체크포인트를 지정해야 켜집니다. 지문의 target은 파인튜닝 모델이라 공개 speculator가 없고 학습 일정도 없으므로 이번에는 고를 수 없습니다.

  4. MTP를 켭니다. `"method": "mtp"`는 모델에 내장된 예측 모듈을 쓰는 설정이라 어떤 모델에서든 추가 준비 없이 동작합니다.

    MTP는 모델 제작자가 함께 학습해 배포한 모듈이 있어야 성립합니다. DeepSeek처럼 체크포인트에 내장했거나 Gemma 4처럼 별도로 배포한 경우에만 쓸 수 있는데, 지문의 베이스 모델 제작자는 그 모듈을 배포하지 않았습니다. 준비물이 없다는 말은 쓸 수 있는 모델에서 따로 챙길 것이 없다는 뜻이지 아무 모델에나 켤 수 있다는 뜻이 아닙니다.

해설

draft를 고를 때 먼저 볼 것은 준비물과 워크로드의 성격입니다. 지문의 target은 파인튜닝 모델이라 EAGLE speculator 체크포인트가 없고 학습 일정도 없으며, 베이스 모델은 MTP 모듈을 배포하지 않았습니다. 남은 두 갈래 중 n-gram은 모델을 하나도 띄우지 않고 프롬프트 문자열 매칭만으로 draft를 만들기 때문에 지금 바로 켤 수 있고, 출력 4-gram의 78%가 프롬프트 안에 있다는 관측이 적중률을 기대할 근거가 됩니다. 매칭 창은 `prompt_lookup_min`과 `prompt_lookup_max`로 조절합니다. 숫자로 보면 차이가 분명합니다. 수락률 α가 0.7이고 draft 토큰 γ가 4개일 때 iteration당 기대 확정 토큰은 (1 - 0.7^5) / 0.3으로 약 2.77이고, n-gram은 c가 0에 가까워 이 값이 거의 그대로 속도 이득이 됩니다. 같은 α를 내는 0.6B draft 모델을 c = 0.02로 두면 2.77 / (4 × 0.02 + 1)로 약 2.57까지 내려갑니다(가정값으로 감을 잡는 계산입니다). 별도 draft 모델은 vocabulary가 같아야 한다는 제약이 있어 후보를 찾는 수고도 따로 듭니다. 다만 n-gram은 복사가 적은 워크로드에서는 거의 맞히지 못하므로 자유 서술형 트래픽이 섞이면 이득이 사라지고, 부하가 올라 GPU가 포화되면 방식과 무관하게 손해로 뒤집힙니다. 켠 다음 로그의 `SpecDecoding metrics`에서 acceptance length를 확인하고, 필요하면 동시성 구간별로 draft 길이를 선언해 피크에는 물러나게 두는 것이 안전합니다.

이 내용 다시 읽기 →

vLLM 실전 서빙 가이드

원문 읽기 →
개념 확인운영함정
# 개발용 기동 스크립트를 그대로 배포 파이프라인에 복사했습니다
vllm serve Qwen3-14B \
  --enforce-eager \
  --max-model-len 16384

개발 중에 붙였던 --enforce-eager가 프로덕션 기동 스크립트에 그대로 남았습니다. 이 서버가 계속 지불하게 되는 비용은 무엇입니까?

  1. prefill을 청크로 잘라 넣는 스케줄링이 함께 꺼져 첫 토큰 지연이 커집니다.

    chunked prefill은 스케줄러가 토큰 예산을 어떻게 채울지에 대한 결정이고, 이 플래그는 정해진 배치를 실행할 때 커널을 그래프로 재생할지만 정합니다. 서로 다른 층에 있는 인자라 한쪽을 끈다고 다른 쪽이 따라 꺼지지 않습니다.

  2. KV Cache 풀이 그래프가 쓰던 몫만큼 줄어 동시 처리 가능한 요청 수가 떨어집니다.

    방향이 반대입니다. 그래프를 캡처하지 않으면 시작 시 그 몫을 예산에서 미리 뺄 필요가 없어 KV 풀은 오히려 커집니다. 메모리가 정말 빠듯할 때 이 플래그가 최후 수단이 되는 이유가 그것입니다.

  3. 정답decode 스텝마다 작은 커널을 하나씩 GPU에 올리는 CPU 오버헤드를 계속 냅니다.

    decode 한 스텝은 작은 커널 수십에서 수백 개의 연속이라, 커널이 실제로 도는 시간보다 커널을 올리는 CPU 비용의 비중이 큽니다. 그래프 캡처는 이 시퀀스를 한 번 찍어두고 이후에는 올리는 과정 없이 통째로 재생하는 것이므로, 끄면 매 스텝 그 비용을 다시 지불합니다. 대신 얻는 것은 기동 시간 단축과 그래프가 쓰던 메모리뿐입니다.

  4. PagedAttention 대신 연속 할당 방식 attention이 쓰여 KV 메모리 단편화가 생깁니다.

    PagedAttention은 KV를 블록 단위로 할당하고 참조하는 설계라 실행 모드와 무관하게 항상 동작합니다. eager로 돌아도 블록 할당과 공유는 그대로입니다.

해설

이 플래그가 바꾸는 것은 커널을 어떻게 실행하느냐 한 가지입니다. 켜면 시작할 때 컴파일과 그래프 캡처를 모두 건너뛰어 기동이 수십 초 빨라지고, 그 대가로 decode 스텝마다 커널 launch 오버헤드를 정면으로 지불합니다. decode는 토큰 하나를 만드는 단계라 커널 하나하나가 작고, 그래서 커널 사이의 CPU 비용이 상대적으로 두드러집니다. 모델이 작고 GPU가 빠를수록 GPU가 한 스텝을 처리하는 시간이 짧아져 이 오버헤드가 차지하는 비중은 더 커집니다. 모델을 자주 다시 띄우는 개발 루프에서는 이 교환이 이득이지만, 한 번 띄우고 오래 도는 서비스에서는 손해만 누적됩니다. 시작 로그에서 그래프 캡처 줄이 사라졌는지 보면 이 플래그가 걸려 있는지 바로 드러납니다.

이 내용 다시 읽기 →
상황 적용계산운영
ValueError: Free memory on device cuda:0 (23.74/79.15 GiB) on startup is less
than desired GPU memory utilization (0.92, 72.82 GiB). Decrease GPU memory
utilization or reduce GPU memory used by other processes.

80GB GPU 한 장에 vLLM 인스턴스 하나가 --gpu-memory-utilization 0.7로 이미 떠 있습니다. 첫 인스턴스를 그대로 두고 두 번째를 같은 GPU에 띄우려면 --gpu-memory-utilization을 얼마까지 줄 수 있습니까? 메모리 검사를 통과하는 가장 큰 값을 고르십시오.

  1. 0.15로 잡습니다.

    이 값이면 11.87 GiB만 요구해 시작은 하지만 상한은 아닙니다. 남은 23.74 GiB를 절반만 쓰는 셈이라 쓸 수 있었던 KV 풀을 그만큼 버립니다. 상한을 묻는 문항이므로 뜨는 값 중 가장 큰 것을 골라야 합니다.

  2. 정답0.29로 잡습니다.

    요구량은 카드 전체 79.15 GiB에 비율을 곱한 값이므로, 여유 23.74 GiB를 79.15로 나눈 0.2999가 상한입니다. 0.29면 22.95 GiB를 요구해 검사를 통과하고 남은 여유를 거의 다 씁니다. 비율이 여유 메모리 기준이 아니라 전체 용량 기준이라는 점이 이 나눗셈의 분모를 정합니다.

  3. 0.45로 잡습니다.

    한 GPU에 둘을 올릴 때 절반씩 나눠 주는 관행에서 나온 값이지만, 여기서는 첫 인스턴스가 이미 0.7을 쓰고 있어 대칭이 깨졌습니다. 0.45는 35.62 GiB를 요구해 남은 23.74 GiB를 넘으므로 같은 에러가 다시 납니다.

  4. 0.62로 잡습니다.

    부족분을 상한으로 착각한 값입니다. 요구량 72.82와 여유 23.74의 차이 49.08 GiB가 전체의 0.62인데, 그것은 기본값에서 내려야 할 폭이지 상한 자체가 아닙니다. 0.92에서 0.62를 빼면 상한 0.30 근처가 나옵니다.

해설

이 값은 남은 여유 메모리의 비율이 아니라 카드 전체 용량에 곱하는 비율입니다. vLLM은 시작할 때 전체 메모리에 이 비율을 곱한 만큼을 자기 몫으로 요구하고, 그 시점의 여유 메모리가 요구량보다 적으면 줄여 잡는 대신 시작을 포기합니다. 79.15 GiB 카드에서 0.7로 뜬 인스턴스가 55.41 GiB를 붙들고 있으니 남은 것은 23.74 GiB인데, 기본값 0.92는 72.82 GiB를 요구합니다. 그래서 한 GPU에 여러 인스턴스를 올릴 때는 비율의 합이 1 아래가 되도록 사람이 직접 나눠 줘야 합니다. 같은 성질이 vLLM이 아닌 프로세스에도 적용되어, 학습 잡이나 죽지 않은 노트북 커널이 남아 있으면 평소 잘 뜨던 설정이 갑자기 실패합니다. 에러 메시지가 여유 메모리와 요구량을 함께 찍어주므로, 둘의 차이를 카드 전체 용량으로 나누면 비율을 얼마나 내려야 하는지 그 자리에서 계산됩니다.

이 내용 다시 읽기 →
분석과 판단계산함정
(EngineCore pid=51234) INFO Available KV cache memory: 22.75 GiB
(EngineCore pid=51234) INFO GPU KV cache size: 212,992 tokens
(EngineCore pid=51234) INFO Maximum concurrency for 32,768 tokens per request: 6.50x

토큰당 KV가 112 KiB인 모델을 GPU 한 장에 띄운 기동 로그입니다. 실제 요청은 대부분 8K 이하인데 6.50x가 낮아 보여, 다른 인자는 그대로 두고 --max-model-len만 16384로 내려 다시 띄웁니다. 이 세 줄은 어떻게 바뀝니까?

  1. 세 줄이 모두 두 배가 됩니다. KV 풀도 요청 하나가 감당하는 몫도 함께 늘어납니다.

    KV 풀 크기는 예산에서 가중치와 활성값 피크, 그래프 몫을 뺀 나머지로 정해지고 그 계산에 max_model_len은 들어가지 않습니다. 앞의 두 줄은 애초에 이 인자를 보지 않습니다.

  2. 첫 줄은 그대로이고 둘째 줄과 셋째 줄이 각각 두 배가 됩니다.

    둘째 줄은 최대 동시성에 max_model_len을 곱해 토큰으로 되돌린 값입니다. 기준 길이가 절반이 되면 동시성이 두 배가 되고 곱하는 길이가 절반이 되어 둘이 정확히 상쇄되므로, 담을 수 있는 토큰 총량은 그대로입니다.

  3. 앞의 두 줄은 그대로이고 셋째 줄만 3.25x로 절반이 됩니다.

    방향이 반대입니다. 최대 동시성은 풀의 블록 수를 요청 하나가 쓰는 블록 수로 나눈 값이라 기준 길이는 분모에 있습니다. 분모가 절반이 되면 몫은 두 배가 됩니다. 절반이 되려면 max_model_len을 두 배로 올려야 합니다.

  4. 정답앞의 두 줄은 그대로이고 셋째 줄만 13.00x로 두 배가 됩니다.

    풀은 22.75 GiB, 212,992 토큰 그대로입니다. 최대 동시성은 풀의 블록 수를 최대 길이 요청 하나가 쓰는 블록 수로 나눈 값이라, 분모가 절반이 되면서 표기만 두 배가 됩니다. 실제로 담을 수 있는 토큰은 하나도 늘지 않았습니다.

해설

최대 동시성은 측정값이 아니라 나눗셈 결과입니다. 풀의 총 블록 수를 max_model_len 길이 요청 하나가 점유할 블록 수로 나눈 값이라, 기준 길이를 절반으로 내리면 분모만 절반이 되어 표기가 두 배로 뜁니다. 반대로 첫 줄의 KV 몫은 gpu_memory_utilization이 정한 예산에서 가중치와 활성값을 뺀 나머지라 이 인자와 무관합니다. 둘째 줄은 그 동시성에 다시 max_model_len을 곱해 토큰으로 되돌린 값이어서, 동시성이 두 배가 되는 만큼 곱하는 길이가 절반이 되어 212,992 토큰 그대로입니다. 22.75 GiB를 토큰당 112 KiB로 나눈 값과 같은 수이고, 길이가 블록 크기의 배수인 동안에는 두 계산이 일치합니다. 그러니 숫자가 올랐다고 서버가 더 많은 토큰을 들게 된 것이 아니라, 최악의 요청을 무엇으로 상정할지를 바꾼 것입니다. 그래도 내려 잡을 이유는 따로 있습니다. 시작할 때 최대 길이 요청 하나가 풀에 들어가는지 검사하므로 긴 컨텍스트 모델은 이 검사 때문에 기동 자체가 실패하고, 용량 계획의 기준선도 오지 않을 요청에 맞춰지기 때문입니다.

이 내용 다시 읽기 →
상황 적용구조예측
attention head 64개, KV head 4개인 GQA 모델을 GPU 8장짜리 노드에서 서빙합니다. 가중치는 한 장에도 들어가지만 KV 풀을 더 키우려고 tensor-parallel-size를 4에서 8로 올렸습니다.

TP=4일 때와 비교해 KV Cache에는 무슨 일이 일어납니까?

  1. 정답GPU당 KV head가 1개 아래로 내려가지 않아 같은 head를 두 GPU가 복제합니다.

    KV head 4개를 GPU 8장에 나누면 장당 0.5개가 되어야 하는데 head는 쪼갤 수 없습니다. vLLM은 장당 최소 1개를 보장하도록 같은 head를 여러 GPU에 복제합니다. TP=4에서도 장당 1개였으므로 토큰당 GPU가 지는 KV 크기는 조금도 줄지 않았고, 늘어난 절반은 중복 사본입니다.

  2. KV head 4개가 8로 나누어떨어지지 않아 시작할 때 divisibility 에러가 납니다.

    시작 시 나누어떨어지는지 검사하는 대상은 attention head 수입니다. 64는 8로 나누어떨어지므로 이 검사는 통과하고, KV head는 검사 대상이 아니라 복제로 처리됩니다.

  3. GPU당 KV head가 절반이 되어 토큰당 KV 크기가 줄고 풀 용량이 두 배가 됩니다.

    TP를 올릴 때 흔히 기대하는 그림이지만 KV head 수가 TP보다 적어지는 순간부터 성립하지 않습니다. 장당 1개에서 더 내려갈 자리가 없어 토큰당 KV 크기는 고정되고, 늘어나는 것은 가중치가 더 쪼개져 생긴 여유뿐입니다.

  4. KV Cache는 TP로 분할되지 않으므로 GPU마다 전체 KV를 그대로 들고 있습니다.

    KV Cache는 KV head 단위로 함께 분할됩니다. head 수가 넉넉한 모델이라면 TP를 올린 만큼 GPU당 KV가 정확히 줄어들고, 문제가 되는 것은 나눌 head가 모자란 경우뿐입니다.

해설

TP는 attention을 head 단위로 나누므로 KV Cache도 KV head를 따라 나뉘고, 나눌 head보다 GPU가 많아지면 나눗셈이 아니라 복제가 됩니다. GPU당 KV head 수는 전체 KV head를 TP로 나누되 최소 1로 잘리는 값이라, KV head 4개인 모델에서는 TP를 4에서 8로 올려도 장당 1개 그대로입니다. 그 결과 토큰당 GPU가 지는 KV 크기가 줄지 않고, 두 장씩 짝을 이뤄 같은 KV를 두 벌 들고 있게 됩니다. 풀이 조금 커지기는 하는데 그것은 head가 더 쪼개져서가 아니라 가중치가 8분할되어 자리가 남았기 때문이고, 그 대가로 레이어마다 오가는 all-reduce에 참여하는 GPU가 두 배로 늘어납니다. 그래서 GQA로 KV head를 몇 개 두지 않는 모델에서 TP를 KV head 수 위로 올리는 구간은 이득이 급격히 꺾입니다. TP 값을 고를 때 attention head 수의 약수인지만 확인하고 KV head 수를 보지 않으면 이 벽을 모른 채 지나치게 됩니다. TP 값을 바꿔가며 기동 로그의 KV cache size를 비교하면 어디서 증가가 멈추는지 바로 드러납니다.

이 내용 다시 읽기 →
상황 적용확장면접운영
사내 문서 파서에 vLLM을 붙였습니다. 문서 종류마다 뽑을 필드가 달라서 요청을 만들 때 JSON 스키마를 매번 새로 조립해 보내고, 그래서 같은 스키마가 두 번 오는 일이 거의 없습니다.

이 워크로드에서 structured output의 비용은 어디에 어떤 모습으로 나타납니까?

  1. 컴파일이 API 서버 프로세스에서 일어나 토큰화와 CPU를 다투고, 진행 중인 응답의 스트리밍이 끊깁니다.

    컴파일은 API 서버가 아니라 EngineCore 쪽 스레드 풀에서 돕니다. V1이 API 서버로 밀어낸 CPU 작업은 토큰화와 스트리밍이고, 스키마를 상태 기계로 바꾸는 일은 스케줄러가 문법을 필요로 하는 쪽에서 시작됩니다. 다른 요청의 스트리밍은 영향을 받지 않습니다.

  2. 컴파일된 문법이 GPU 메모리에 쌓여 KV 풀을 잠식하고 동시성이 점점 떨어집니다.

    문법 상태 기계와 그 캐시는 CPU 쪽에 있습니다. 캐시에는 크기 상한이 있어 넘치면 오래된 항목이 밀려날 뿐이고, KV 풀은 시작할 때 확정된 크기를 그대로 유지합니다.

  3. 정답새 스키마를 만난 요청만 문법 컴파일이 끝날 때까지 스케줄에서 빠져 기다립니다.

    컴파일은 엔진 루프가 아닌 별도 스레드에서 돌고, 그 요청은 문법이 준비될 때까지 대기 상태로 두었다가 준비되면 다시 스케줄 후보에 넣습니다. 배치의 나머지 요청은 영향 없이 진행되므로 비용은 처리량이 아니라 그 요청 하나의 첫 토큰 지연으로 드러납니다. 스키마가 매번 달라 캐시가 듣지 않으면 모든 요청이 이 지연을 냅니다.

  4. 스케줄러 루프가 컴파일이 끝날 때까지 멈춰 배치 전체의 첫 토큰이 함께 밀립니다.

    스케줄러 루프를 붙잡는 구조였다면 무거운 스키마 하나가 서버 전체를 세울 수 있어 프로덕션에 쓸 수 없습니다. 컴파일을 루프 밖으로 빼고 해당 요청만 대기시키는 것이 이 설계의 요점입니다.

해설

structured output의 비용은 decode가 아니라 스키마 하나당 한 번 치르는 컴파일에 있습니다. 스키마를 문법 상태 기계로 바꾸는 작업은 엔진 루프 밖의 스레드 풀에서 돌고, 그 사이 해당 요청은 문법 대기 상태로 스케줄 후보에서 빠져 있다가 준비되면 합류합니다. 그래서 다른 요청의 처리량은 지켜지고 비용은 그 요청의 첫 토큰 지연으로만 나타납니다. 컴파일 결과는 캐시되므로 스키마 종류가 손에 꼽는 서비스는 각 스키마의 첫 요청만 이 값을 치르지만, 요청마다 스키마를 새로 조립하면 캐시가 한 번도 맞지 않아 모든 요청이 매번 냅니다. decode 중의 마스크 계산은 CPU가 GPU를 기다리는 시간에 처리되어 별도 비용이 거의 없고, 그래서 지표에서도 TTFT만 올라가고 토큰 간격은 멀쩡해 보입니다. 그래서 먼저 손댈 곳은 스키마의 가짓수입니다. 필드 조합마다 새 스키마를 만드는 대신 선택 필드를 포함한 공통 스키마 몇 개로 정규화하면 캐시가 다시 살아납니다.

이 내용 다시 읽기 →
상황 적용운영함정
# 같은 이미지, 같은 명령으로 네 노드에 기동했습니다.
# 스케줄러 두 인자는 어디에도 지정하지 않았습니다.
$ vllm serve Qwen3-32B --max-model-len 16384
# 트래픽은 네 노드 모두 같습니다. 프롬프트 4,000토큰 안팎, 출력 200토큰 안팎.

[node-a] device NVIDIA A100-SXM4-80GB, total memory 79.15 GiB
         SchedulerConfig(max_num_batched_tokens=2048, max_num_seqs=256)
[node-b] device NVIDIA H100 80GB HBM3, total memory 79.65 GiB
         SchedulerConfig(max_num_batched_tokens=8192, max_num_seqs=1024)
[node-c] device NVIDIA L40S, total memory 44.99 GiB
         SchedulerConfig(max_num_batched_tokens=2048, max_num_seqs=256)
[node-d] device AMD Instinct MI300X, total memory 192.00 GiB
         SchedulerConfig(max_num_batched_tokens=8192, max_num_seqs=1024)

네 노드가 같은 명령으로 떴는데 스케줄러 기본값이 두 갈래로 갈렸습니다. 네 줄을 모두 설명하는 분기 규칙은 무엇입니까?

  1. 정답메모리 70GiB 이상과 이름에 a100이 없다는 두 조건을 함께 만족할 때만 큰 값을 씁니다.

    node-a는 79.15 GiB로 용량 조건을 통과하고도 이름 때문에 작은 값을 받았고, node-c는 이름 조건을 통과하고도 44.99 GiB라 용량에서 걸렸습니다. 같은 2048과 256이 서로 다른 이유로 나온 것입니다. A100만 이름으로 따로 뺀 근거는 큰 예산이 A100에서 오히려 처리량을 떨어뜨린 측정 결과입니다.

  2. 메모리 용량 하나로 갈립니다. 70GiB를 넘으면 큰 값, 못 넘으면 작은 값을 씁니다.

    이 규칙이면 79.15 GiB인 node-a도 큰 값을 받아야 하는데 2048과 256으로 떴습니다. node-b와의 용량 차이는 0.5 GiB뿐이라 임계선으로 둘을 가를 수도 없습니다. 용량은 두 조건 중 하나일 뿐입니다.

  3. NVIDIA Hopper 이상 세대면 큰 값을 쓰고, 그보다 앞선 세대면 작은 값을 씁니다.

    node-d는 Hopper도 NVIDIA도 아닌데 8192와 1024를 받았습니다. 분기는 아키텍처 세대나 compute capability를 조회하지 않고, 보고된 메모리 용량과 장치 이름 문자열 두 가지만 봅니다.

  4. 장치 이름만 봅니다. 이름에 a100이 들어가면 작은 값, 아니면 큰 값을 씁니다.

    node-c의 이름에는 a100이 없는데도 2048과 256을 받았습니다. 이름은 큰 값으로 가기 위한 두 조건 중 하나이고, 44.99 GiB인 node-c는 그 앞의 용량 조건에서 먼저 걸립니다.

해설

이 기본값은 카드의 성능에서 유도한 공식이 아니라 측정으로 그은 선입니다. 큰 값으로 가려면 보고된 메모리가 70GiB 이상이면서 장치 이름에 a100이 들어가지 않아야 하고, 둘 중 하나라도 어긋나면 작은 값으로 떨어집니다. node-a와 node-c가 같은 값을 받은 이유가 서로 다른 것이 이 규칙의 성격을 보여줍니다. A100이 이름으로 빠져 있는 것은 용량이 모자라서가 아니라, 큰 예산을 주자 처리량이 오히려 떨어진다는 벤치마크가 나와 예외로 못 박았기 때문입니다. 그래서 카드의 스펙 시트를 보고 내 기본값을 유추하려 들면 A100에서 반드시 틀리고, 기동 로그에 찍힌 값을 확인하는 편이 빠릅니다. 값이 갈리면 같은 트래픽도 다르게 흐릅니다. 프롬프트 4,000토큰은 node-b에서 한 스텝에 통째로 들어가지만 node-a에서는 두 번에 나뉘어 들어가므로, 클라이언트 코드가 같아도 첫 토큰 지연과 토큰 간격의 모양이 노드마다 달라집니다. 예산을 키우면 prefill이 빨리 끝나 첫 토큰이 당겨지는 대신 스텝이 무거워져 진행 중인 요청의 토큰 간격이 벌어지고, 줄이면 반대가 됩니다. 조정할 때 볼 신호는 정해져 있습니다. KV가 모자라 preemption 로그가 잦으면 두 인자 중 하나를 낮추고, 큰 GPU에 작은 모델을 올려 처리량을 더 뽑고 싶으면 8192보다 크게 잡습니다.

이 내용 다시 읽기 →

LLM 서빙 성능 지표와 튜닝

원문 읽기 →
상황 적용연결 문항진단
# Qwen3-32B, H100 2장 TP=2, --max-num-seqs 256
# 입력 4096 / 출력 512, --goodput ttft:1000 tpot:60
conc   req/s   goodput   out tok/s   MeanTTFT   P99TTFT   MeanTPOT   MeanITL   P99ITL
  24    1.84      1.84       941.8       178ms     402ms     25.2ms    25.1ms   58.3ms
  48    3.08      3.05      1577.0       362ms     856ms     29.8ms    29.7ms   74.2ms
  72    3.61      0.14      1848.3      3010ms    6420ms     33.1ms    33.0ms   88.6ms
  96    3.58      0.00      1833.0      9740ms   18350ms     33.4ms    33.3ms   91.4ms

# 동시성 72를 넘긴 뒤로 같은 양상이 이어졌고, 아래는 동시성 96 구간의 엔진 로그
Avg prompt throughput: 15074.2 tokens/s, Avg generation throughput: 1829.6 tokens/s,
Running: 62 reqs, Waiting: 34 reqs, Preemptions: 2, GPU KV cache usage: 99.3%,
Prefix cache hit rate: 0.0%

동시성을 48에서 72로 올렸을 때 이 서버가 닿은 천장은 무엇입니까?

  1. 대역폭 천장입니다. 스텝마다 읽는 가중치와 KV가 늘어 토큰 간격이 길어졌습니다.

    간격이 길어지기는 했지만 폭이 맞지 않습니다. Mean ITL은 29.7ms에서 33.0ms로 11% 올랐을 뿐이고, Mean TPOT도 33.1ms로 SLO 60ms에는 한참 못 미칩니다. 대역폭이 병목이면 악화가 TTFT가 아니라 토큰 간격에 먼저 크게 나타나야 합니다.

  2. 정답용량 천장입니다. KV 풀이 차서 요청이 선점되고 나머지는 큐에서 기다립니다.

    GPU KV cache usage가 99.3%이고, 직전 로깅 구간의 Preemptions가 0이 아닙니다. 이 값은 누적이 아니라 구간 카운트라 크기보다 0이 아니라는 사실이 신호입니다. 선점은 KV 공간이 모자라 진행 중인 요청을 내렸다는 뜻이고, 자리를 못 잡은 요청 34개가 큐에서 대기하면서 TTFT가 178ms에서 9.7초로 뛰었습니다. goodput이 3.05에서 0.14로 무너진 것이 그 결과입니다.

  3. 배치 상한입니다. max_num_seqs 256에 걸려 더 이상 요청을 못 태우고 있습니다.

    Running이 62에서 멈췄는데 이는 상한 256의 4분의 1도 되지 않습니다. 배치 상한이 원인이라면 Running이 256에서 평평해져야 하고 Preemptions도 생기지 않습니다. 지금 배치 크기를 정하는 것은 인자가 아니라 KV 풀입니다.

  4. 연산 천장입니다. GPU 연산이 포화해 처리량이 더 오르지 않습니다.

    연산이 포화하면 스텝 자체가 길어져 ITL과 TPOT이 크게 나빠지고, KV 공간과는 무관하므로 Preemptions는 0으로 남습니다. 여기서는 반대로 간격은 거의 그대로인 채 대기열과 선점만 늘었습니다.

해설

동시성 72에서 닿은 것은 용량 천장입니다. 두 천장은 증상이 다릅니다. 대역폭 천장은 스텝이 무거워지는 것이라 토큰 간격, 즉 ITL과 TPOT이 먼저 나빠집니다. 용량 천장은 KV 풀이 차는 것이라 진행 중인 요청이 선점되고 나머지가 큐에 쌓여 TTFT가 먼저 나빠집니다. 이 스윕에서 Mean ITL은 25.1ms에서 33.3ms로 33% 오르는 데 그쳤지만 Mean TTFT는 178ms에서 9.7초로 50배 넘게 뛰었습니다. Preemptions가 0이 아니라는 것은 KV 공간이 모자라 진행 중인 요청을 내리고 다시 계산했다는 뜻이라 용량 천장의 가장 직접적인 신호이고, Running이 62에서 멈춘 것은 배치 상한이 아니라 풀이 배치 크기를 정하고 있다는 뜻입니다. 전체 처리량이 3.61에서 3.58로 오히려 내려간 것도 선점된 요청을 다시 prefill하는 비용 때문입니다. 처리량 곡선만 보면 knee를 지나 완만해진 것처럼 보이지만, SLO를 함께 센 goodput은 동시성 72에서 이미 무너져 있습니다.

이 내용 다시 읽기 →
상황 적용연결 문항운영
앞 문항에서 판별한 천장이 원인이라는 전제 아래, 동시성 72의 goodput을 되살려야 합니다. GPU는 H100 2장 그대로 쓰고, 워크로드의 입력 4096 / 출력 512도 그대로입니다. 카드를 더 붙이거나 인스턴스를 복제하는 선택지는 이번에 없습니다.

어느 인자를 어느 방향으로 조정해야 합니까?

  1. `max_num_batched_tokens`를 8192 이상으로 올려 prefill 처리량을 키웁니다.

    TTFT가 나쁜 것은 맞지만 원인이 prefill 속도가 아니라 큐 대기입니다. 자리가 없어 못 들어간 요청은 prefill을 아무리 빨리 돌려도 순서가 오지 않습니다. 오히려 스텝당 prefill 조각이 커져 진행 중인 요청의 토큰 간격만 나빠지고 KV 공간은 한 바이트도 늘지 않습니다.

  2. `max_num_seqs`를 256에서 512로 올려 더 많은 요청을 동시에 태웁니다.

    배치 상한에는 애초에 걸려 있지 않았습니다. Running이 62에서 멈춘 것은 KV가 없어서이지 256이라는 숫자 때문이 아닙니다. 상한만 키우면 스케줄러가 공간이 없는데도 요청을 더 올리려 해 선점 횟수가 늘어납니다.

  3. `max_model_len`을 4608로 낮춰 요청당 KV 사용량을 줄입니다.

    max_model_len은 요청 하나가 쓸 수 있는 길이의 상한을 막는 인자이고 풀 크기를 바꾸지 않습니다. 이 워크로드는 이미 4096과 512를 합쳐 4608을 쓰고 있으므로 상한을 4608로 내려도 실제 사용량은 한 토큰도 줄지 않습니다. 상한 근처까지 쓰는 긴 요청이 섞여 있을 때만 효과가 있습니다.

  4. 정답`gpu_memory_utilization`을 올리고 KV Cache를 FP8로 바꿔 풀을 키웁니다.

    용량 천장에 직접 대응하는 두 손잡이입니다. 다만 크기가 다릅니다. 기본값이 이미 0.92이므로 예약 비율을 0.95로 올려도 풀은 7% 남짓 늘어 Running이 62에서 66으로 갈 뿐이고, 동시성 72를 태우는 것은 토큰당 바이트를 절반으로 줄이는 KV FP8입니다. 둘을 함께 걸면 같은 풀에 두 배가 담겨 큐 대기와 선점이 동시에 줄어듭니다.

해설

용량 천장의 처방은 풀을 키우거나 요청당 사용량을 줄이는 둘 중 하나입니다. `gpu_memory_utilization` 상향은 남는 VRAM을 그대로 풀로 돌리는 가장 싼 손잡이이고, KV Cache를 FP8로 저장하면 토큰당 바이트가 절반이 되어 같은 풀에 두 배가 들어갑니다. 그래도 모자라면 TP를 늘려 카드를 더 붙이거나 인스턴스를 복제합니다. 반대로 배치 쪽 인자를 올리는 것은 이 상황을 악화시킵니다. `max_num_seqs` 상향은 공간이 없는데 요청만 더 올리려는 시도라 선점을 늘리고, `max_num_batched_tokens` 상향은 prefill이 느려서 TTFT가 나쁠 때 쓰는 손잡이라 큐 대기가 원인인 여기서는 진행 중인 요청의 간격만 벌립니다. `max_model_len` 하향은 요청 하나의 상한을 막는 것이지 풀을 키우는 것이 아니어서, 실제로 그 상한 근처까지 쓰는 요청이 들어올 때만 값이 있습니다. 지표가 같은 방향으로 나빠 보여도 원인이 큐인지 스텝인지에 따라 손잡이가 갈리고, 그 구분을 해주는 것이 KV 사용률과 선점 횟수입니다.

이 내용 다시 읽기 →
상황 적용진단함정
# Qwen3-32B, H100 4장 TP=4, 입력 2048 / 출력 512
# vllm bench serve --max-concurrency 96 --num-prompts 480 --goodput ttft:2000 tpot:50
============ Serving Benchmark Result ============
Successful requests:                     480
Failed requests:                         0
Maximum request concurrency:             96
Benchmark duration (s):                  88.10
Total input tokens:                      983040
Total generated tokens:                  245760
Request throughput (req/s):              5.45
Request goodput (req/s):                 5.41
Output token throughput (tok/s):         2789.56
Peak output token throughput (tok/s):    3120.44
Peak concurrent requests:                96
Total token throughput (tok/s):          13947.79
---------------Time to First Token----------------
Mean TTFT (ms):                          612.40
Median TTFT (ms):                        548.10
P99 TTFT (ms):                           1893.55
-----Time per Output Token (excl. 1st token)------
Mean TPOT (ms):                          33.28
Median TPOT (ms):                        32.90
P99 TPOT (ms):                           41.60
---------------Inter-token Latency----------------
Mean ITL (ms):                           33.22
Median ITL (ms):                         30.15
P99 ITL (ms):                            88.70
==================================================

goodput은 전체 처리량의 99%가 SLO를 통과했다고 말하는데, 사용자들은 답변이 나오다가 중간에 뚝뚝 멈춘다고 합니다. 이 표에서 그 증상의 근거가 되는 것은 무엇입니까?

  1. 정답P99 ITL이 평균의 2.7배인데, 요청 단위 평균인 TPOT은 그 순간을 511개 간격에 희석합니다.

    ITL은 간격 하나하나의 분포라 길게 멈칫한 순간이 그대로 남고, TPOT은 요청 하나의 평균이라 그 몇 번이 나머지 간격에 묻힙니다. 그래서 TPOT P99는 41.60ms로 SLO 50ms 안쪽인데 ITL P99만 88.70ms로 튀었습니다. 사용자가 체감하는 것은 요청 평균이 아니라 눈앞에서 멈춘 그 순간입니다.

  2. P99 TTFT가 1.9초로 높아, 첫 응답이 늦은 것을 사용자가 끊김으로 느낀 것입니다.

    TTFT는 요청을 보낸 뒤 첫 토큰이 도착할 때까지의 시간이라 생성이 시작된 다음의 멈칫과는 다른 구간입니다. 첫 반응이 느린 것과 나오다가 멈추는 것은 사용자도 구분해서 말하는 별개의 증상입니다.

  3. Peak output token throughput이 평균보다 12% 높아, 서버가 주기적으로 스로틀링됐습니다.

    peak는 벤치 구간 중 가장 좋았던 순간의 값이라 평균보다 높은 것이 정상이고, 오히려 두 값이 같으면 측정이 이상한 것입니다. 이 지표는 서버 전체의 토큰 총량이라 개별 요청의 간격에 대해 아무것도 말해주지 않습니다.

  4. Median TPOT과 Mean TPOT이 거의 같아 분포가 정상이므로, 원인은 서버가 아니라 클라이언트입니다.

    두 값이 가깝다는 것은 요청들끼리 비교했을 때 평균이 고르다는 뜻일 뿐입니다. 한 요청 안에서 간격이 어떻게 흔들렸는지는 요청 단위로 평균을 낸 뒤라 이 지표가 아예 볼 수 없고, 그것을 보려고 따로 두는 지표가 ITL입니다.

해설

TPOT과 ITL은 같은 구간을 재지만 집계 단위가 달라 서로 다른 것을 잡아냅니다. TPOT은 요청 하나의 평균이라 511개 간격 중 몇 개가 길어져도 나머지에 묻히고, ITL은 간격 하나하나를 그대로 남겨 가장 느린 순간을 드러냅니다. TPOT P99가 가장 느린 요청을 찾는 지표라면 ITL P99는 가장 느린 순간을 찾는 지표인 셈입니다. 이 표에서 간격이 튄 원인은 chunked prefill입니다. vLLM은 진행 중인 요청의 decode에 새 요청의 prefill을 조각내 섞는데, prefill이 많이 실린 스텝은 무거워져 그 순간의 간격만 벌어집니다. 함정은 `--goodput`이 받는 키가 ttft와 tpot과 e2el 셋뿐이라는 점입니다. itl은 goodput 조건으로 걸 수 없어서 이 증상은 SLO 판정을 구조적으로 통과해버립니다. 잡으려면 ITL 분위수를 TPOT과 따로 감시해야 합니다. `--percentile-metrics`의 기본값에 itl이 이미 들어 있어 표에는 찍히지만 goodput 판정에는 못 들어가므로, `max_num_batched_tokens`를 낮춰 스텝당 prefill 양을 줄이는 쪽으로 손을 대야 합니다.

이 내용 다시 읽기 →
분석과 판단계산
Qwen3-30B-A3B(전체 30.5B, 토큰당 활성 3.3B)를 BF16 KV Cache로 서빙합니다. config.json의 값은 num_hidden_layers 48, num_attention_heads 32, num_key_value_heads 4, head_dim 128입니다. 기동 로그에 찍힌 KV 풀 크기는 24GiB입니다.

요청 하나가 컨텍스트 8,192토큰을 쓴다면, 이 서버는 요청을 몇 개까지 동시에 올려둘 수 있습니까?

  1. 약 4개입니다.

    어텐션 head 32개로 계산하면 나오는 값입니다. GQA에서 KV Cache는 Query head가 아니라 KV head 수만큼만 저장하므로 32가 아니라 4를 곱해야 합니다. 이 모델은 Query head 8개가 KV head 하나를 공유하고, 그 여덟 배가 여기서 갈립니다.

  2. 약 16개입니다.

    컨텍스트 8,192를 입력과 출력 양쪽에 각각 잡아 요청당 16,384토큰으로 본 결과입니다. 지문의 8,192는 요청 하나가 쓰는 컨텍스트 전체이고, 블록 단위 할당은 외부 단편화를 없애고 내부 낭비를 블록 하나 미만으로 묶으므로 여기서 절반이 사라질 자리도 없습니다.

  3. 정답약 32개입니다.

    토큰당 KV는 2(K와 V) × 48레이어 × 4 KV head × 128 head_dim × 2byte로 96KiB입니다. 24GiB를 96KiB로 나누면 262,144토큰이고, 8,192로 나누면 32개입니다. 전체가 30.5B든 활성이 3.3B든 이 계산에는 들어오지 않습니다.

  4. 약 64개입니다.

    K와 V 중 한쪽만 세어 식 맨 앞의 2를 빠뜨리면 나오는 값입니다. 어텐션은 이전 토큰들의 Key와 Value를 둘 다 보관해야 하므로 토큰당 크기는 두 배가 됩니다. 참고로 KV를 FP8로 저장했다면 토큰당 48KiB로 줄어 실제로 이 숫자가 됩니다.

해설

토큰당 KV 크기는 2(K와 V) × 레이어 수 × KV head 수 × head_dim × 정밀도 바이트로 정해집니다. 여기서는 2 × 48 × 4 × 128 × 2byte = 96KiB이고, 24GiB를 나누면 262,144토큰, 8,192토큰짜리 요청으로는 32개입니다. 이 식에 파라미터 수는 등장하지 않습니다. MoE가 바꾸는 것은 FFN이고 어텐션은 그대로라, 전체 30.5B든 활성 3.3B든 토큰당 KV는 dense와 같은 공식으로 나옵니다. 자주 어긋나는 자리는 두 곳인데, GQA에서 Query head가 아니라 KV head 수를 써야 한다는 것과 K와 V를 둘 다 세야 한다는 것입니다. 파라미터 수가 걸리는 곳은 KV가 아니라 가중치 쪽이고, MoE에서는 활성분이 아니라 전체 30.5B가 VRAM에 올라가 이 24GiB 풀을 남기고 남은 자리를 차지합니다. 풀을 키우지 않고 동시 요청 수를 늘리려면 KV를 FP8로 저장해 토큰당 48KiB로 줄이는 방법이 있고, 그러면 같은 24GiB에 64개가 들어갑니다.

이 내용 다시 읽기 →
분석과 판단확장진단운영
# H200 8장 노드
vllm serve deepseek-ai/DeepSeek-V3 \
  --tensor-parallel-size 1 \
  --data-parallel-size 8 \
  --enable-expert-parallel

# 증상: 동시성을 올려도 처리량이 기대만큼 오르지 않는다.
# 스텝마다 8장의 연산 활용률이 40%대와 90%대로 갈리고,
# 낮은 쪽에 있는 카드는 계속 바뀐다.

가장 그럴듯한 원인과 vLLM이 제공하는 대응은 무엇입니까?

  1. DP 복제본마다 KV 풀 크기가 달라 카드별 배치가 벌어진 것입니다. `--data-parallel-size`를 낮춰야 합니다.

    DP 랭크들은 같은 크기의 KV 풀을 잡고, 스케줄러도 대기 요청 수를 보고 랭크를 고르므로 배치가 이렇게 갈리지 않습니다. 무엇보다 노는 카드가 계속 바뀐다는 것은 요청 배분이 아니라 스텝 단위로 달라지는 무언가를 가리킵니다.

  2. 정답토큰이 일부 expert로 쏠려 그 expert를 맡은 카드만 바쁜 것입니다. `--enable-eplb`로 인기 expert의 사본을 늘립니다.

    MoE는 토큰이 expert들에 고르게 흩어진다는 가정으로 학습되지만 실제 트래픽에서는 분포가 크게 치우칩니다. EP에서는 가장 많은 토큰을 받은 랭크가 스텝의 완료 시점을 정하고 나머지는 기다립니다. vLLM은 `--enable-eplb`와 `--eplb-config`의 `num_redundant_experts`로 부하가 큰 expert의 사본을 여러 랭크에 두어 이 쏠림을 흩습니다.

  3. all-to-all 통신이 병목이라 카드들이 서로를 기다리는 것입니다. `--tensor-parallel-size`를 8로 올려 통신을 없앱니다.

    EP 크기는 TP 크기와 DP 크기의 곱이라 TP를 8로 바꿔도 8장 노드에서는 EP가 그대로 8이고 all-to-all도 그대로 남습니다. 거기에 TP의 all-reduce가 스텝마다 더해지니 통신은 줄기는커녕 늡니다.

  4. expert마다 가중치 크기가 달라 카드별 읽기 시간이 벌어진 것입니다. EP를 끄고 TP만 쓰는 편이 낫습니다.

    한 모델 안의 expert는 모두 같은 모양이라 랭크가 들고 있는 가중치의 바이트 수는 같습니다. 불균형은 가중치 크기가 아니라 그 expert로 흘러가는 토큰 수에서 생깁니다. TP는 expert 하나하나를 잘게 쪼개므로 연산 효율이 EP보다 떨어집니다.

해설

EP는 GPU마다 expert를 통째로 나눠 맡기므로 한 스텝의 완료 시점을 가장 많은 토큰을 받은 랭크가 정합니다. 라우터가 특정 expert를 편애하면 그 카드만 바쁘고 나머지는 all-to-all 결과를 기다리며 놀게 되고, 어떤 expert가 인기 있는지는 배치마다 바뀌므로 노는 카드도 계속 바뀝니다. vLLM은 `--enable-eplb`와 `--eplb-config`로 이 쏠림을 다룹니다. `num_redundant_experts`를 주면 랭크마다 여분 슬롯이 생겨 부하가 큰 expert의 사본을 여러 랭크에 둘 수 있고, 각 랭크는 (전체 expert 수 + 여분 수) ÷ EP 랭크 수만큼을 들게 됩니다. 대가는 VRAM이라 DeepSeek-V3 기준으로 랭크당 여분 expert 하나가 약 2.4GB를 더 씁니다. EP 크기를 정하는 별도 인자는 없고 TP 크기와 DP 크기의 곱이 곧 EP 크기이므로, 8장 노드에서 TP와 DP를 어떻게 나누든 EP는 8입니다. 그래서 TP로 갈아타는 것은 all-to-all을 없애지 못하고 all-reduce만 더하는 선택이 됩니다.

이 내용 다시 읽기 →
상황 적용계산진단
# Qwen3-32B(총 32.8B 파라미터)를 BF16 가중치로 서빙합니다.
# A100 80GB SXM 4장 TP=4, NVLink 연결. 카드 한 장의 HBM 대역폭은 2,039GB/s입니다.
# vllm bench serve --random-input-len 1024 --random-output-len 256
conc   req/s   out tok/s   MeanTTFT   MeanTPOT   MeanITL
   1    0.26        66.2      145ms    14.60ms   14.59ms
   8    1.92       491.9      320ms    15.07ms   15.05ms
  32    6.36      1628.4      780ms    16.67ms   16.63ms
  64   10.24      2621.8     1450ms    18.82ms   18.77ms

# 동시성 64 구간의 엔진 로그 (--max-num-seqs 256)
Avg prompt throughput: 10487.3 tokens/s, Avg generation throughput: 2619.4 tokens/s,
Running: 64 reqs, Waiting: 0 reqs, Preemptions: 0, GPU KV cache usage: 10.1%,
Prefix cache hit rate: 0.0%

사용자 한 명이 받는 생성 속도를 초당 130토큰으로 올려달라는 요구가 들어왔습니다. 이 구성의 상한은 얼마이고, 요구를 받을 수 있습니까?

  1. 정답상한은 초당 약 124토큰이라 서빙 인자로는 받을 수 없고, FP8 양자화나 카드 추가로 상한 자체를 올려야 합니다.

    합산 대역폭 4 × 2,039GB/s = 8,156GB/s를 BF16 가중치 32.8B × 2byte = 65.6GB로 나누면 초당 124토큰입니다. 동시성 1의 TPOT 14.60ms는 초당 68.5토큰이라 상한의 55%이고, 목표인 130토큰은 상한 자체를 넘습니다. 읽을 바이트를 줄이거나 합산 대역폭을 키우는 것 말고는 손댈 곳이 없습니다.

  2. 상한은 초당 약 31토큰입니다. TP는 가중치를 나눠 들 뿐 대역폭을 합쳐주지는 않으므로 요구를 받을 수 없습니다.

    카드 한 장의 2,039GB/s를 전체 가중치 65.6GB로 나눈 값입니다. TP=4에서는 카드마다 가중치를 4분의 1씩 나눠 들고 동시에 읽으므로 대역폭도 4장을 합산해서 칩니다. 게다가 이 서버는 동시성 1에서 이미 초당 68.5토큰을 내고 있어, 상한이 31토큰이라면 측정치가 상한의 두 배라는 모순이 됩니다.

  3. 상한은 초당 약 249토큰이라 여유가 있고, `max_num_seqs`를 낮춰 배치를 줄이면 요구를 받을 수 있습니다.

    파라미터 수 32.8로 그대로 나눠 BF16의 2byte를 빠뜨린 값입니다. 8,156 ÷ 32.8 = 249는 FP8로 양자화했을 때의 상한이고 지금은 스텝마다 65.6GB를 읽습니다. 배치를 줄이면 스텝당 KV 읽기가 줄어 TPOT이 동시성 1의 14.60ms까지 돌아가지만, 그 값이 곧 초당 68.5토큰이라 130토큰에는 닿지 못합니다.

  4. 상한은 초당 약 497토큰이라 여유가 있고, `max_num_batched_tokens`를 낮추면 요구를 받을 수 있습니다.

    합산 대역폭 8,156GB/s를 카드당 몫 16.4GB로 나눠 4를 두 번 적용한 값입니다. 카드당 대역폭에는 카드당 가중치를, 합산 대역폭에는 전체 가중치를 맞춰야 하고 어느 쪽으로 세어도 답은 124토큰입니다. `max_num_batched_tokens` 하향은 스텝에 섞이는 prefill 조각을 줄여 간격을 고르게 하는 손잡이라 가중치 읽기가 정하는 상한은 건드리지 못합니다.

해설

대역폭 천장은 합산 메모리 대역폭을 스텝당 읽어야 하는 가중치 크기로 나눈 값입니다. 여기서는 4 × 2,039GB/s = 8,156GB/s를 32.8B × 2byte = 65.6GB로 나눠 초당 124토큰이고, forward pass 한 번이 토큰 하나를 만드는 한 커널을 아무리 최적화해도 요청 하나는 이 선을 넘지 못합니다. 한 번의 가중치 읽기로 여러 토큰을 확정하는 speculative decoding만이 예외입니다. 측정된 68.5토큰(TPOT 14.60ms)은 상한의 55%로, 공개 측정에서 4×A100 기준으로 보고되는 MBU와 같은 수준이라 커널 쪽에 남은 여유도 크지 않습니다. 스윕도 같은 이야기를 합니다. 동시성을 1에서 64로 올리자 전체 처리량은 66에서 2,622 tok/s로 뛰었지만 요청 하나의 속도는 68.5에서 53.1토큰으로 오히려 내려갔습니다. 배칭은 한 번의 가중치 읽기로 여러 요청의 토큰을 만드는 장치이지 요청 하나를 빠르게 하는 장치가 아니고, 배치가 커진 만큼 스텝에 실리는 KV 읽기가 늘어 TPOT이 14.60ms에서 18.82ms로 밀립니다. 로그는 용량 쪽 원인을 지웁니다. Waiting이 0이고 Preemptions도 0이며 KV 사용률이 10.1%라 풀에는 자리가 남아 있으니, 지금 속도를 정하는 것은 KV 공간이 아니라 스텝마다 읽어야 하는 65.6GB입니다. 그래서 처방이 갈립니다. 가중치를 FP8로 줄이면 읽을 바이트가 절반이라 상한이 초당 249토큰이 되고, TP를 8로 늘려 합산 대역폭을 16,312GB/s로 만들어도 상한은 초당 248토큰이 됩니다. 다만 TP 확대는 스텝마다 all-reduce가 끼므로 카드들이 NVLink로 묶였는지부터 확인해야 합니다.

이 내용 다시 읽기 →

이 페이지는 문항 품질을 점검하는 용도로도 씁니다. 한 화면에서 전부 훑어야 중복된 문항, 쏠린 난이도, 소거법으로 풀리는 선택지가 보입니다.

Thank You for Visiting My Blog, Have a Good Day 😆