16장. vLLM 서빙 벤치마킹과 RAG 최적화
연속 배치
놀이공원의 기차를 생각해 봅니다. 열 칸이 다 찰 때까지 기다렸다가 출발하고, 한 바퀴를 다 돌아야 모두 내린다면 짧게 타려던 사람도 끝까지 묶여 있습니다. 내리는 사람이 생길 때마다 그 자리에 다음 사람을 바로 태우는 순환 열차라면 빈자리가 거의 없습니다.
왼쪽은 요청을 하나씩 처리해 기다리는 줄이 길어지는 방식입니다. 가운데 정적 배치는 묶음을 한 번에 처리하지만 가장 긴 요청이 끝날 때까지 나머지 자리가 놀고 있습니다. 오른쪽 연속 배치는 토큰 한 걸음마다 끝난 요청을 빼고 기다리던 요청을 넣습니다. 기차의 칸이 배치의 자리, 한 바퀴가 묶음 하나의 생성입니다.
세 방식 견주기
| 방식 | 처리량 | 짧은 요청의 지연 | 쓰는 곳 |
|---|---|---|---|
| 하나씩 | 낮다 | 앞 요청이 끝날 때까지 기다린다 | 이 PC 의 Ollama 기본 설정(실습 2) |
| 정적 배치 | 높다 | 묶음의 가장 긴 요청만큼 기다린다 | generate 에 여러 문장을 넣을 때(실습 3) |
| 연속 배치 | 가장 높다 | 자기 길이만큼만 | vLLM, TGI, SGLang |
걸음 수 흉내 내기
outs = [20, 200, 30, 25, 180, 40, 15, 35] # 요청 여덟 개의 출력 토큰 수(예시)
slots = 4 # 한 번에 함께 계산할 수 있는 자리
# 정적 배치: 넷씩 묶고, 묶음마다 가장 긴 요청만큼 걸음을 간다
static = sum(max(outs[i:i + slots]) for i in range(0, len(outs), slots))
# 연속 배치: 걸음마다 자리 넷을 채우고, 끝난 요청은 빼고 다음 요청을 넣는다
waiting, running, steps = list(outs), [], 0
while waiting or running:
while waiting and len(running) < slots:
running.append(waiting.pop(0))
running = [r - 1 for r in running if r > 1] # 한 걸음 가면 남은 토큰이 하나 준다
steps += 1
print("하나씩:", sum(outs), "걸음 · 정적 배치:", static, "걸음 · 연속 배치:", steps, "걸음")코드에 나오는 함수
| 코드 | 쉬운 뜻 |
|---|---|
slots = 4 | slot 은 "자리, 홈"이라는 뜻이다. 한 걸음에 함께 계산할 수 있는 요청 수다 |
outs[i:i + slots] | 요청을 앞에서부터 넷씩 잘라 한 묶음으로 만든다 |
max(...) | 묶음 안에서 가장 긴 요청의 토큰 수를 고른다. 정적 배치는 이만큼 걸음을 간다 |
waiting.pop(0) | 기다리는 줄의 맨 앞 요청을 꺼낸다 |
[r - 1 for r in running if r > 1] | 자리에 있는 요청마다 남은 토큰을 하나씩 줄이고, 이번 걸음에 끝난 요청(남은 것이 1이던 요청)은 뺀다 |
steps += 1 | 걸음 수를 하나 센다. 한 걸음은 모든 자리가 토큰 하나씩 만드는 계산 한 번이다 |
이 코드가 뜻하는 것
놀이공원 기차를 숫자로 돌려 본 것입니다. 손님 여덟 명이 타려는 바퀴 수가 저마다 다르고, 기차 자리는 넷입니다. 여기서 손님이 요청, 탈 바퀴 수가 출력 토큰 수, 기차가 한 바퀴 도는 것이 한 걸음입니다.
| 줄 | 하는 일 | 쉽게 말하면 |
|---|---|---|
sum(outs) | 545걸음 | 한 명씩 태워 차례로 돈다 |
static = ... | 묶음 둘의 가장 긴 요청 200 + 180 = 380걸음 | 넷이 다 내릴 때까지 다음 손님을 태우지 않는다 |
while waiting or running: | 200걸음 | 내리는 사람이 생기면 그 자리에 바로 다음 사람을 태운다 |
연속 배치의 200걸음은 가장 긴 요청(200토큰)의 길이와 같습니다. 짧은 요청들이 긴 요청 옆 빈자리를 번갈아 채웠기 때문에, 가장 긴 요청이 끝나는 때에 모두 끝났습니다. 비유와 다른 점은, 실제 엔진에서는 묶음이 클수록 한 걸음의 시간도 조금씩 길어진다는 것입니다.
작게 줄여서 눈으로 보기
요청 셋, 자리 둘로 줄여서 걸음마다 누가 자리에 있는지 찍어 봅니다. 숫자는 설명용입니다.
waiting = [("가", 3), ("나", 1), ("다", 2)] # (요청 이름, 남은 토큰 수) - 설명용
running, step = [], 0
while waiting or running:
while waiting and len(running) < 2: # 자리는 둘
running.append(waiting.pop(0))
step += 1
print(f"{step}걸음: 자리에 {[name for name, _ in running]}")
running = [(name, left - 1) for name, left in running if left > 1]1걸음: 자리에 ['가', '나']
2걸음: 자리에 ['가', '다']
3걸음: 자리에 ['가', '다']| 결과 | 읽는 법 |
|---|---|
1걸음: 자리에 ['가', '나'] | 앞의 둘이 먼저 자리에 앉는다. 나는 토큰 하나라 이 걸음에 끝난다 |
2걸음: 자리에 ['가', '다'] | 나가 비운 자리에 기다리던 다가 바로 들어온다 |
3걸음: 자리에 ['가', '다'] | 가(3토큰)와 다(2토큰)가 함께 끝난다. 모두 3걸음이다 |
정적 배치였다면 가·나 묶음이 3걸음, 다 혼자 2걸음으로 모두 5걸음이 걸렸을 것입니다.
주의할 점
- 배치가 커지면 처리량은 늘지만 요청 하나의 지연도 조금씩 늘어납니다. 묶을수록 한 걸음에 계산할 것이 많아지기 때문입니다.
- 이 PC 의 Ollama 는 기본 설정에서 요청을 하나씩 처리했습니다(실습 2). Ollama 에도 함께 처리할 요청 수를 정하는 환경 변수
OLLAMA_NUM_PARALLEL이 있지만, 늘린 만큼 KV 캐시 메모리가 더 듭니다. 이 PC 에서는 서버 설정을 바꾸지 않았습니다.