상태: 초안 (2026-10-27), 실측. 8GB급 노트북 GPU(Ada 세대) 한 장, vLLM 0.31.0, Qwen3-Reranker-0.6B 로 2026-10-27 에 쟀습니다. 같은 세 기능의 원리와 1.7B 모델의 첫 측정은 「LLM KV 캐시 해부」 2편과 「토스 ML/AI Infra 준비」 14편에 있어, 이 글은 그 글들이 하지 않은 것(반복 측정, 기능을 끈 기준선, 청크 끄기, cache_salt)만 다룹니다. 2026-10-27 추가. 같은 날 vLLM 0.32.0 으로 같은 시험을 다시 돌린 결과를 6-1절에 더했습니다. 0.31.0 수치는 그대로 두었습니다. 작성 규칙: 실측 데이터가 기본이고, 못 잰 것은 「미실측」, 확인하지 못한 것은 「미확인」으로 표시합니다.
목차
- 1. 왜 다시 재는가
- 2. 환경과 방법
- 3. 연속 배칭: 끄면 처리량이 1/10 이 된다
- 4. chunked prefill: 디코드가 멈추는 시간은 청크 크기에 비례한다
- 5. 접두사 캐시: 반복해도 18.9ms
- 6. cache_salt: 같은 접두사라도 테넌트끼리 나누지 않기
- 6-1. 2026-10-27 재측정: 0.32.0 에서도 같은가
- 7. 운영에서 가져갈 것
- 8. 확인하지 못한 것
- 참고
1. 왜 다시 재는가
PagedAttention, 연속 배칭, 접두사 캐시의 원리는 서빙 엔진 안의 KV 캐시에서, 1.7B 모델로 처음 잰 값은 작은 모델로 vLLM 직접 재 보기에서 다뤘습니다. 그 측정에는 세 가지 빈틈이 있었습니다.
- 조건마다 한 번씩만 재서 흩어짐을 몰랐습니다.
- 연속 배칭은 끌 수 없어서 「없으면 얼마인가」를 보이지 못했습니다.
- 청크를 아예 끈 경우와, 여러 사용자가 캐시를 나눠 쓸 때의 격리(cache_salt)를 보지 않았습니다.
이번에는 모든 조건을 서너 번 반복하고, 첫 번째 묶음은 예열로 따로 떼어 중앙값에서 뺐습니다. 기능을 끈 서버를 같은 날 같은 파드에서 바로 이어 띄웠습니다.
2. 환경과 방법
- GPU: 8GB급 노트북 GPU(Ada 세대) 한 장, 드라이버 595.99. 컨테이너 메모리 한도 4GiB.
- vLLM 0.31.0 공식 컨테이너(PyTorch 2.13.0, CUDA 13.0), 어텐션 백엔드는 로그상 FLASH_ATTN.
- 모델: Qwen3-Reranker-0.6B, bf16, 가중치 1.12GiB. 28층, KV 헤드 8개, 헤드 차원 128 이라 토큰 하나의 KV 는 2 × 28 × 8 × 128 × 2바이트 = 114,688바이트입니다. 로그의 「KV 4.3GiB 에 40,208토큰」과 맞습니다.
- 공통 서버 옵션:
--max-model-len 4096 --gpu-memory-utilization 0.80. 그 밖은 기본값입니다. 이 판은 GPU 메모리가 70GB 미만이면 서버의max_num_batched_tokens기본값을 2,048,max_num_seqs를 256 으로 잡습니다(소스vllm/engine/arg_utils.py에서 확인). - 클라이언트: 같은 파드 안에서 aiohttp 로 스트리밍 요청을 보내 첫 청크 도착까지(TTFT)와 청크 사이 간격(ITL)을 쟀습니다. 입력은 30개 영어 단어를 무작위로 이은 글이고, 출력은
ignore_eos로 길이를 고정했습니다. - 반복: 부하 시험은 동시성마다 4묶음(첫 묶음 예열)을 보내 남은 3묶음의 중앙값을, 접두사 시험은 새 접두사 5개로 각각 차가운 요청 1번과 데운 요청 4번을 보냈습니다.
3. 연속 배칭: 끄면 처리량이 1/10 이 된다
연속 배칭 자체를 끄는 옵션은 없습니다. 대신 --max-num-seqs 1 로 한 걸음에 한 요청만 돌게 하면 「요청을 하나씩 처리하는 서버」가 됩니다. 입력 256토큰, 출력 128토큰 요청을 동시성 c 로 2c 개씩 보냈습니다.
| 서버 | 동시 요청 | 처리량(tok/s) | TTFT 중앙 | ITL 중앙 | ITL p99 |
|---|---|---|---|---|---|
한 번에 하나(--max-num-seqs 1) |
1 | 174.4 | 18.9ms | 5.6ms | 6.2ms |
| 한 번에 하나 | 16 | 174.6 | 11,008ms | 5.6ms | 6.5ms |
| 기본 | 1 | 174.2 | 18.8ms | 5.6ms | 6.7ms |
| 기본 | 4 | 593.8 | 50.7ms | 6.4ms | 7.5ms |
| 기본 | 16 | 1,683.1 | 131.0ms | 8.2ms | 9.4ms |
| 기본 | 64 | 2,899.3 | 272.2ms | 16.6ms | 90.6ms |
세 묶음의 처리량은 서로 0.3% 안에서 같았습니다(동시 16 기본: 1,681.3, 1,683.1, 1,685.4).
- 한 요청만 도는 서버는 동시성을 올려도 처리량이 그대로입니다. 디코드 한 걸음은 가중치 전체를 메모리에서 읽는 일이 대부분이라, 요청 하나를 돌리든 열여섯을 돌리든 걸음 시간이 크게 다르지 않습니다. 기본 서버에서 동시 16 의 토큰 간격은 8.2ms 로 혼자일 때(5.6ms)보다 1.5배 길어졌을 뿐인데 처리량은 9.6배가 되었습니다.
- 대가는 기다림입니다. 한 번에 하나인 서버에서 동시 16 요청의 첫 토큰 중앙값은 11초였습니다. 앞 요청이 끝날 때까지 줄을 섰기 때문입니다.
- 동시 64 에서는 ITL p99 가 90.6ms 로 튀었습니다. 새 요청의 프리필이 디코드 걸음 사이에 끼기 때문이고, 다음 절의 청크 크기가 이 꼬리를 정합니다.
4. chunked prefill: 디코드가 멈추는 시간은 청크 크기에 비례한다
디코드 요청 하나(900토큰)가 도는 동안 약 3,700토큰짜리 입력을 다섯 번 끼워 넣고, 끼워 넣은 구간에서 디코드 토큰 간격의 최댓값을 쟀습니다.
| 서버 | 한 걸음 입력 토큰 상한 | 끼운 구간 최대 ITL(5회) | 그 밖의 ITL 중앙 | 끼운 입력의 TTFT 중앙 |
|---|---|---|---|---|
--max-num-batched-tokens 512 |
512 | 35.6ms(35.5, 35.5, 35.6, 35.6, 35.6) | 5.7ms | 232.0ms |
| 기본 | 2,048 | 109.8ms(108.3, 108.6, 109.1, 109.2, 109.8) | 5.7ms | 216.2ms |
--no-enable-chunked-prefill --max-num-batched-tokens 4096 |
4,096(청크 없음) | 197.6ms(191.8, 195.7, 197.1, 197.4, 197.6) | 5.8ms | 214.1ms |
이 GPU 에서 프리필 속도는 3,200토큰을 174.7ms 에 처리하는 정도, 곧 초당 약 18,000토큰입니다. 2,048토큰 청크 하나는 약 111ms, 3,700토큰 전체는 약 200ms 가 걸린다는 계산이 표의 최대 ITL 과 맞습니다. 다섯 번의 값이 1ms 안팎으로 모여 있어 우연이 아닙니다.
- 작은 청크는 디코드를 지키고 프리필을 조금 늦춥니다. 512토큰 청크는 끼운 입력의 TTFT 를 216ms 에서 232ms 로 7% 늘리는 대신, 디코드의 최대 정지를 3분의 1로 줄였습니다.
- 청크를 끄면 V0 시절의 동작에 가까워집니다. 이때는
max_num_batched_tokens가max_model_len이상이어야 서버가 뜹니다(문서의 경고와 같음). - 동시 16 부하(입력 256토큰)에서 512토큰 청크는 처리량을 바꾸지 않았고(1,682.7 대 1,683.1 tok/s), TTFT 중앙은 131.0ms 에서 86.3ms 로 줄었지만 ITL p99 는 9.4ms 에서 22.9ms 로 늘었습니다. 짧은 입력이 많은 부하에서는 청크 크기가 「어느 꼬리를 줄일 것인가」를 고르는 손잡이일 뿐 공짜 개선이 아닙니다. 이 차이의 원인은 스케줄 기록을 보지 않아 미확인입니다.
1.7B 모델로 잰 이전 값(512토큰 69ms, 2,048토큰 227ms)과 모양이 같고 크기는 모델 계산량만큼 작아졌습니다.
5. 접두사 캐시: 반복해도 18.9ms
3,000단어(약 3,200토큰)의 공통 접두사에 요청마다 다른 32단어를 붙여 보냈습니다. 접두사를 바꿔 다섯 묶음을 돌렸습니다.
| 서버 | 차가운 첫 요청 TTFT(5개 접두사) | 데운 요청 TTFT(20번) |
|---|---|---|
| 기본(접두사 캐시 켬) | 중앙 174.7ms(174.6~175.3) | 중앙 18.9ms(최소 18.0, 최대 19.9) |
--no-enable-prefix-caching |
중앙 173.9ms, 서버 첫 요청만 236.0ms | 중앙 173.8ms(최소 173.0, 최대 178.2) |
데운 요청은 3,200토큰 중 마지막 블록과 꼬리만 계산하므로 9.2배 빨라졌습니다. 접두사 캐시를 끈 서버의 맨 첫 요청이 236ms 로 다른 차가운 요청보다 60ms 느렸는데, 이것이 서버를 띄운 뒤의 예열 몫입니다. 측정에서 첫 값을 따로 떼어야 하는 이유입니다.
캐시가 블록 단위라는 점도 기억해 둡니다. vLLM 은 가득 찬 블록(기본 16토큰)만 해시해서 저장하므로, 공통 부분이 블록 경계에서 끊기면 마지막 조각은 다시 계산합니다. 해시는 0.11 부터 기본이 sha256 이고 --prefix-caching-hash-algo 로 바꿀 수 있습니다.
6. cache_salt: 같은 접두사라도 테넌트끼리 나누지 않기
접두사 캐시에는 보안 문제가 하나 있습니다. 다른 사용자가 보낸 문서와 같은 앞부분을 보내 보고, 첫 토큰이 빠르면 「누군가 이 문서를 이미 보냈다」는 것을 알 수 있습니다. 시간으로 정보를 빼내는 타이밍 공격입니다. vLLM 은 요청에 cache_salt 를 달면 그 값을 첫 블록의 해시에 섞어, 같은 소금끼리만 캐시를 나눠 쓰게 합니다.
--enable-prompt-tokens-details 로 응답에 캐시된 토큰 수가 나오게 하고, 같은 3,000단어 접두사를 소금을 바꿔 가며 다섯 묶음 보냈습니다.
한 묶음은 같은 접두사로 다섯 요청입니다. 소금 a 로 두 번, 소금 b 로 두 번, 소금 없이 한 번 보냈습니다.
| 순서 | cache_salt | 캐시된 토큰(응답의 cached_tokens) |
TTFT 중앙(2~5번째 묶음) |
|---|---|---|---|
| 1 | tenant-a | 0 | 173.9ms |
| 2 | tenant-a | 3,184 또는 3,200 | 17.9ms |
| 3 | tenant-b | 0 | 173.7ms |
| 4 | tenant-b | 3,184 또는 3,200 | 18.3ms |
| 5 | 없음 | 0 | 173.9ms |
- 같은 소금끼리는 3,200토큰 가운데 블록 경계까지(16의 배수인 3,184 또는 3,200토큰)를 다시 썼습니다.
- 소금이 다르면 글자 하나 다르지 않은 접두사라도 캐시를 쓰지 못했고, 시간도 차가운 요청과 같았습니다. 소금 없는 요청 역시 소금 단 요청의 캐시를 쓰지 못했습니다.
- 맨 첫 묶음의 첫 요청은 228.5ms, 그다음 요청은 27.6ms 로 다른 묶음보다 느렸습니다. 서버를 띄운 뒤 첫 요청의 예열 몫입니다.
cached_tokens 는 서버를 --enable-prompt-tokens-details 로 띄워야 응답에 나옵니다. 끄면 이 필드가 비어 있어, 캐시 적중을 시간으로만 짐작해야 합니다. 서버 전체의 적중은 /metrics 의 vllm:prefix_cache_hits_total 과 vllm:prefix_cache_queries_total 로 봅니다.
6-1. 2026-10-27 재측정: 0.32.0 에서도 같은가
같은 GPU, 같은 재순위 모델, 같은 클라이언트로 vLLM 0.32.0 공식 컨테이너에서 다시 쟀습니다(「2026-10-27 0.31.0 측정 → 0.32.0 재측정」).
| 항목 | 0.31.0 | 0.32.0 |
|---|---|---|
| 처리량, 동시 1 | 174.2 tok/s | 174.2 tok/s |
| 처리량, 동시 16(기본) | 1,683.1 tok/s | 1,683.6 tok/s |
| 처리량, 동시 16(한 번에 하나) | 174.6 tok/s | 174.3 tok/s |
| 처리량, 동시 64 | 2,899.3 tok/s | 2,899.7 tok/s |
| 3,700토큰이 끼어들 때 디코드 최대 정지(기본 청크) | 109.8ms | 108.2ms |
| 3,200토큰 접두사 첫 토큰(차가움 → 데움) | 174.7 → 18.9ms | 175.2 → 18.9ms |
모든 값이 1.5% 안에서 같았습니다. 0.32.0 릴리스 노트의 큰 변화(Model Runner V2 확대, 캐스케이드 어텐션 제거, max_tokens 를 문맥에서 미리 빼지 않음 등)는 이 작은 모델의 기본 부하에서는 숫자를 바꾸지 않았습니다. 대화용 Qwen3-0.6B(같은 구조)도 동시 1·16·64 에서 174.1, 1,678.0, 2,898.7 tok/s 로 같았습니다.
7. 운영에서 가져갈 것
- 동시 요청이 많은 서비스라면 연속 배칭이 처리량의 대부분을 만듭니다.
max_num_seqs를 낮추면 KV 를 아끼는 대신 이 이득을 직접 깎습니다. - 디코드의 꼬리 지연이 중요하면
max_num_batched_tokens를 줄이고, 긴 입력의 TTFT 가 중요하면 키웁니다. 이 GPU 에서 청크 하나의 시간은 대략 청크 토큰 수를 초당 18,000으로 나눈 값이었습니다. 자기 GPU 의 프리필 속도를 한 번 재 두면 청크 크기를 지연 목표에서 거꾸로 고를 수 있습니다. - 여러 고객이 한 엔진을 나눠 쓰면 고객 단위로
cache_salt를 답니다. 같은 고객 안의 재사용은 그대로 남고, 고객 사이의 타이밍 누출만 막습니다. - 측정할 때는 서버를 띄운 뒤의 첫 요청을 빼고, 같은 조건을 여러 번 돌려 흩어짐을 함께 적습니다. 이번에도 첫 요청 하나가 다른 값보다 35% 느렸습니다.
8. 확인하지 못한 것
- 큰 모델, 여러 GPU, 실제 대화 데이터(접두사 길이와 재사용 간격이 섞인 부하)에서의 값은 미실측입니다.
- 512토큰 청크에서 짧은 입력 부하의 ITL p99 가 늘어난 원인은 스케줄 기록을 보지 않아 미확인입니다.
- 0.28.0 에서 큰 GPU 의 기본
max_num_batched_tokens가 8,192 에서 16,384 로 올랐다는 릴리스 노트는 확인했지만, 그 크기의 GPU 에서의 동작은 미실측입니다.
참고
- Kwon 외, 「Efficient Memory Management for Large Language Model Serving with PagedAttention」, SOSP 2023. 같은 지연에서 FasterTransformer·Orca 대비 처리량 2~4배(초록 기준). https://arxiv.org/abs/2309.06180
- Yu 외, 「Orca: A Distributed Serving System for Transformer-Based Generative Models」, OSDI 2022. https://www.usenix.org/conference/osdi22/presentation/yu
- Agrawal 외, 「Taming Throughput-Latency Tradeoff in LLM Inference with Sarathi-Serve」, arXiv 2403.02310. 꼬리 지연 제약에서 vLLM 대비 단일 A100 의 Mistral-7B 서빙 용량 2.6배(초록 기준). https://arxiv.org/abs/2403.02310
- vLLM 문서, Optimization and Tuning(Chunked Prefill), Automatic Prefix Caching 설계(cache_salt): https://docs.vllm.ai/en/latest/configuration/optimization.html, https://docs.vllm.ai/en/latest/design/prefix_caching.html
- 이전 측정: 서빙 엔진 안의 KV 캐시, 작은 모델로 vLLM 직접 재 보기