16장. vLLM 서빙 벤치마킹과 RAG 최적화
KV 캐시와 PagedAttention
도서관 사물함을 생각해 봅니다. 이용자가 올 때마다 짐이 얼마나 될지 몰라 가장 큰 사물함을 통째로 내주면, 대부분 반쯤 빈 채로 자리만 차지합니다. 작은 칸을 여러 개 두고 짐이 늘 때마다 한 칸씩 더 내주면 같은 벽에 훨씬 많은 사람이 짐을 둘 수 있습니다.
왼쪽은 요청마다 최대 길이만큼 KV 캐시를 미리 잡는 방식으로, 실제로 쓰지 않는 자리가 많이 남습니다. 오른쪽은 vLLM 의 PagedAttention 으로, KV 캐시를 16토큰쯤의 작은 쪽(블록)으로 나눠 글이 길어질 때마다 한 쪽씩 더 잡습니다. 큰 사물함이 통째로 잡는 방식, 작은 칸이 쪽입니다. 같은 GPU 메모리에 더 많은 요청을 동시에 올릴 수 있어 처리량이 늘어납니다.
두 방식 견주기
| 통째로 잡기 | 쪽으로 나눠 잡기 | |
|---|---|---|
| 잡는 크기 | 요청마다 최대 길이 | 쓴 만큼(쪽 단위로 올림) |
| 남는 자리 | 많다 | 마지막 쪽의 빈칸뿐 |
| 동시에 올릴 요청 | 적다 | 많다 |
| 덤 | 없음 | 앞부분이 같은 요청끼리 쪽을 함께 쓴다(프리픽스 캐시) |
남는 자리 계산하기
import math
max_len, page = 4096, 16 # 최대 길이와 쪽 하나의 토큰 수
used = [180, 650, 90, 1200, 300, 75, 420, 260] # 요청 여덟 개가 실제로 쓴 토큰 수(예시)
whole = max_len * len(used) # 통째로 잡으면
paged = sum(math.ceil(u / page) * page for u in used) # 쪽으로 잡으면(쪽 단위로 올림)
print("실제로 쓴 토큰:", sum(used))
print(f"통째로 잡은 자리: {whole:,} (쓴 몫 {sum(used) / whole:.1%})")
print(f"쪽으로 잡은 자리: {paged:,} (쓴 몫 {sum(used) / paged:.1%})")코드에 나오는 함수
| 코드 | 쉬운 뜻 |
|---|---|
math.ceil(u / page) | ceil 은 ceiling(천장)에서 온 이름이다. 소수점이 있으면 위로 올린다. 180 ÷ 16 = 11.25 이면 12쪽이다 |
math.ceil(u / page) * page | 쪽 수에 쪽의 크기를 곱한다. 실제로 잡히는 자리 수다 |
max_len * len(used) | 요청마다 최대 길이 4096 을 통째로 잡았을 때의 자리 수다 |
{whole:,} | 세 자리마다 쉼표를 찍어 출력한다 |
{... :.1%} | 비율을 백분율로 바꿔 소수 첫째 자리까지 적는다 |
이 코드가 뜻하는 것
도서관 사물함 이야기를 숫자로 옮긴 것입니다. 이용자 여덟 명에게 큰 사물함을 하나씩 주는 경우와, 작은 칸을 필요한 만큼만 주는 경우의 빈자리를 셉니다. 여기서 이용자가 요청, 짐이 토큰, 작은 칸이 16토큰짜리 쪽입니다.
| 줄 | 하는 일 | 쉽게 말하면 |
|---|---|---|
used = [...] | 요청 여덟 개가 실제로 쓴 토큰 수(예시) | 이용자마다 실제 짐의 양 |
whole = ... | 4096 × 8 = 32,768 자리 | 모두에게 큰 사물함을 통째로 준다 |
paged = ... | 쪽 단위로 올려 더한 3,232 자리 | 짐만큼 작은 칸을 준다 |
print(...) 세 줄 | 쓴 토큰 3175, 쓴 몫 9.7% 와 98.2% | 큰 사물함은 열에 아홉이 비고, 작은 칸은 거의 꽉 찬다 |
같은 짐(3175토큰)을 두는 데 통째로 잡으면 열 배쯤 넓은 자리가 듭니다. 그 남는 자리에 다른 요청을 올릴 수 있으니, 쪽으로 나누면 동시에 처리할 요청 수가 늘어납니다.
작게 줄여서 눈으로 보기
쪽 단위로 올리면 빈칸이 얼마나 남는지 요청 하나씩 봅니다. 토큰 수는 설명용 숫자입니다.
import math
page = 16 # 쪽 하나에 토큰 16개
for used in [1, 16, 17, 35]: # 요청 하나가 쓴 토큰 수(설명용 숫자)
pages = math.ceil(used / page)
print(f"{used:2d}토큰 → 쪽 {pages}개 = 자리 {pages * page}칸, 빈칸 {pages * page - used}") 1토큰 → 쪽 1개 = 자리 16칸, 빈칸 15
16토큰 → 쪽 1개 = 자리 16칸, 빈칸 0
17토큰 → 쪽 2개 = 자리 32칸, 빈칸 15
35토큰 → 쪽 3개 = 자리 48칸, 빈칸 13| 결과 | 읽는 법 |
|---|---|
1토큰 → 쪽 1개 | 토큰이 하나라도 쪽 하나를 잡는다. 빈칸 15 |
16토큰 → 쪽 1개, 빈칸 0 | 쪽이 딱 맞게 찼다 |
17토큰 → 쪽 2개 | 한 토큰이 넘쳐 쪽을 하나 더 잡는다 |
35토큰 → 쪽 3개, 빈칸 13 | 빈칸은 늘 마지막 쪽에만 생기고 15칸을 넘지 않는다 |
주의할 점
- KV 캐시는 글 길이와 동시 요청 수에 비례해 커집니다(15장). 쪽으로 나눠도 GPU 메모리를 넘는 만큼은 올릴 수 없습니다.
- vLLM 은 시작할 때 GPU 메모리의 정한 몫(
--gpu-memory-utilization)을 KV 캐시용으로 미리 잡습니다. 같은 GPU 를 다른 프로그램과 나눠 쓰면 이 값을 낮춥니다.