16장. vLLM 서빙 벤치마킹과 RAG 최적화
프리필과 디코드
식당에서 주문서를 받으면 요리사는 먼저 주문서 전체를 한 번에 훑어 재료를 꺼냅니다. 그다음에는 접시에 하나씩 담아 나갑니다. LLM 의 생성도 두 단계입니다. 입력 전체를 한꺼번에 읽는 프리필과, 토큰을 하나씩 이어 쓰는 디코드입니다.
왼쪽 프리필은 입력 토큰을 모두 한 번에 계산하므로 GPU 의 계산 능력을 꽉 씁니다. 오른쪽 디코드는 토큰 하나를 낼 때마다 가중치 전체를 메모리에서 다시 읽어야 해서, 계산보다 메모리를 읽는 속도에 묶입니다(15장). 주문서 훑기가 프리필, 한 접시씩 담기가 디코드입니다.
두 단계 견주기
| 프리필 | 디코드 | |
|---|---|---|
| 하는 일 | 입력 토큰을 한꺼번에 계산해 KV 캐시를 채운다 | 토큰을 하나씩 만든다 |
| 시간을 정하는 것 | 입력 길이 | 출력 길이 |
| 묶이는 곳 | GPU 계산 | 메모리 읽기 |
| 사용자가 느끼는 것 | 첫 글자가 늦게 나온다(TTFT) | 글이 천천히 나온다(TPOT) |
시간 어림하기
def gen_seconds(prompt_tokens, output_tokens, prefill_speed, decode_speed):
"""프리필은 입력 토큰을 빨리, 디코드는 출력 토큰을 하나씩 처리한다고 보고 시간을 어림한다"""
prefill = prompt_tokens / prefill_speed
decode = output_tokens / decode_speed
return round(prefill, 2), round(decode, 2)
# 속도는 예시 숫자다(프리필이 디코드보다 열 배쯤 빠르다고 둔다)
for p, o in [(100, 50), (1500, 50), (100, 500)]:
print(f"입력 {p:5d} · 출력 {o:4d} → (프리필 초, 디코드 초) =", gen_seconds(p, o, prefill_speed=1000, decode_speed=50))위 예시의 속도는 설명을 위한 숫자입니다. 이 PC 의 실제 값은 실습 1에서 잽니다.
코드에 나오는 함수
| 코드 | 쉬운 뜻 |
|---|---|
gen_seconds(prompt_tokens, output_tokens, prefill_speed, decode_speed) | gen 은 generation(생성)의 줄임말이다. 입력·출력 토큰 수와 두 단계의 빠르기로 걸릴 시간을 어림하는 함수다 |
prompt_tokens / prefill_speed | 입력 토큰 수를 1초에 읽는 토큰 수로 나눈다. 프리필에 걸리는 초다 |
output_tokens / decode_speed | 출력 토큰 수를 1초에 쓰는 토큰 수로 나눈다. 디코드에 걸리는 초다 |
round(prefill, 2) | 소수 둘째 자리까지 반올림한다 |
prefill_speed=1000 | 1초에 입력 1000토큰을 읽는다고 둔다(예시 숫자) |
decode_speed=50 | 1초에 출력 50토큰을 쓴다고 둔다(예시 숫자) |
이 코드가 뜻하는 것
식당에서 주문서는 눈으로 한 번에 훑지만, 접시는 하나씩 담아야 합니다. 주문서가 길어지면 훑는 시간이 조금 늘고, 접시가 많아지면 담는 시간이 크게 늡니다. 여기서 주문서의 줄 수가 입력 토큰, 담을 접시 수가 출력 토큰입니다.
| 줄 | 하는 일 | 쉽게 말하면 |
|---|---|---|
입력 100 · 출력 50 | 프리필 0.1초, 디코드 1.0초 | 짧은 주문서, 접시 50개 |
입력 1500 · 출력 50 | 프리필 1.5초, 디코드 1.0초 | 주문서가 15배 길어져 훑는 시간이 늘었다 |
입력 100 · 출력 500 | 프리필 0.1초, 디코드 10.0초 | 접시가 10배 많아져 담는 시간이 10배다 |
이 예시의 빠르기에서는 같은 토큰 수라도 출력 쪽이 훨씬 비쌉니다. 입력 1500토큰은 1.5초인데, 출력 500토큰은 10초가 걸립니다. 그래서 답 길이를 줄이는 것이 전체 시간을 줄이는 데 가장 잘 듣습니다.
작게 줄여서 눈으로 보기
KV 캐시에 칸이 어떻게 쌓이는지 걸음으로 봅니다. 토큰은 설명을 위해 지어낸 것입니다.
prompt = ["연차", "는", "며칠", "인가요"] # 입력 토큰 넷(설명을 위해 지어낸 토큰)
answer = ["15", "일", "입니다"] # 모델이 쓸 답 토큰 셋(지어낸 것)
kv = list(prompt) # 프리필: 입력 넷을 한 걸음에 모두 KV 캐시에 넣는다
print(f"프리필 1걸음 → KV 캐시 {len(kv)}칸")
for step, tok in enumerate(answer, start=1): # 디코드: 한 걸음에 토큰 하나씩
kv.append(tok)
print(f"디코드 {step}걸음 → '{tok}' 추가, KV 캐시 {len(kv)}칸")프리필 1걸음 → KV 캐시 4칸
디코드 1걸음 → '15' 추가, KV 캐시 5칸
디코드 2걸음 → '일' 추가, KV 캐시 6칸
디코드 3걸음 → '입니다' 추가, KV 캐시 7칸| 결과 | 읽는 법 |
|---|---|
프리필 1걸음 → KV 캐시 4칸 | 입력 토큰 넷을 한 걸음에 모두 넣었다 |
디코드 1걸음부터 3걸음 | 답 토큰을 걸음마다 하나씩 더한다. 답이 길수록 걸음이 는다 |
KV 캐시 7칸 | 입력 4 + 출력 3 이다. 다음 토큰을 만들 때 앞의 칸을 다시 계산하지 않고 꺼내 쓴다 |
주의할 점
- 입력이 길면 첫 토큰이 늦고, 출력이 길면 전체가 늦습니다. RAG 는 검색한 조각을 붙여 입력을 길게 만들므로 프리필 시간이 커집니다.
- 답을 짧게 하라는 지시는 디코드 시간을 줄이는 가장 쉬운 방법입니다(실습 6).