본문 바로가기

LLM

vLLM, SGLang이 빠른 이유 — Continuous Batching 원리와 실전

반응형

LLM 서빙 서버를 직접 구축하면 처음에 이런 상황이 생겨요.

# 단순하게 구현한 LLM 서버
@app.post("/generate")
async def generate(request):
    output = model.generate(request.prompt)
    return output

요청 하나하나를 순서대로 처리해요. GPU 사용률 확인해보면 이래요.

nvidia-smi:
GPU 사용률: 15~30%

GPU 자원의 70~85%를 낭비하고 있어요. Continuous Batching이 이걸 해결해요.


LLM 추론의 두 단계

이해하려면 LLM이 어떻게 토큰을 생성하는지 알아야 해요.

Prefill 단계 (입력 처리):
"안녕하세요, 오늘 날씨는" → 한번에 병렬 처리
→ 계산 집약적 (compute-bound)
→ 첫 번째 토큰 나오기까지 걸리는 시간 = TTFT

Decode 단계 (출력 생성):
"맑습" → "니" → "다" → "." → ...
→ 토큰 하나씩 순차 생성
→ 메모리 대역폭 집약적 (memory-bound)
→ 토큰당 생성 시간 = TPOT

핵심은 Decode 단계에서 이전 토큰들의 Key-Value 값을 매번 재계산하지 않도록 KV Cache에 저장한다는 거예요.


기존 방식의 문제 — Static Batching

방법 1: No Batching (요청 하나씩 처리)

요청 A: "안녕하세요" → 처리 중...
(완료)
요청 B: "날씨 알려줘" → 처리 시작
(완료)
요청 C: 대기 중...

GPU가 한 번에 하나만 처리해요. GPU 사용률 15%.

방법 2: Static Batching

여러 요청을 묶어서 한번에 처리해요.

배치 구성:
요청 A: "안녕?" → 출력 3토큰 예정
요청 B: "오늘 날씨 어때?" → 출력 50토큰 예정
요청 C: "한국 역사 설명해줘" → 출력 200토큰 예정

처리:
T=1:  A[1] B[1] C[1]  ← 세 요청 동시 처리
T=2:  A[2] B[2] C[2]
T=3:  A[3] B[3] C[3]  ← A 완료, 근데 B, C 아직 진행 중
T=4:  ---- B[4] C[4]  ← A 슬롯 비어있는데 새 요청 못 들어옴
...
T=50: ---- ---- C[50] ← B 완료, C만 남음
...
T=200:---- ---- C[200] ← 배치 전체 완료, 다음 배치 시작

A가 3토큰만에 끝났는데 C(200토큰) 끝날 때까지 A 슬롯이 비어요.

결과: GPU 사용률 30~40%, 패딩 오버헤드 60~80%


Continuous Batching — 이터레이션 레벨 스케줄링

Static Batching은 배치 단위로 스케줄링해요. Continuous Batching은 토큰 생성 단계(iteration) 단위로 스케줄링해요.

T=1:  [A] [B] [C] [D]  ← 4개 동시 처리
T=2:  [A] [B] [C] [D]
T=3:  [A] [B] [C] [D]  ← A 완료
T=3:  [E] [B] [C] [D]  ← 즉시 E 투입! (Static이면 못 함)
T=4:  [E] [B] [C] [D]
T=10: [E] [B] [F] [D]  ← C 완료, F 즉시 투입
...

슬롯이 비자마자 새 요청이 들어와요. GPU가 항상 가득 차 있어요.


실제 성능 비교

Llama 3.3 70B, H100 GPU, 128 동시 요청:

Static Batching:     ~100 tok/s
Continuous Batching: ~2,380 tok/s

→ 약 23배 처리량 향상

처리량뿐만 아니라 레이턴시도 개선돼요.

Static Batching:
짧은 요청도 긴 요청 끝날 때까지 대기
→ p95 레이턴시 높음

Continuous Batching:
짧은 요청은 빨리 끝내고 바로 반환
→ p50/p95 레이턴시 모두 낮아짐

Prefill vs Decode 분리 문제

Continuous Batching에는 한 가지 문제가 있어요.

상황:
슬롯 1: Decode 중 (매 스텝 1토큰 생성)
슬롯 2: Decode 중
슬롯 3: Decode 중
새 요청 E 도착 → Prefill 필요

Prefill은 수백 토큰을 한번에 처리 → 연산량 폭발
→ 기존 Decode 요청들 레이턴시 급증 (TTFT 스파이크)

이걸 해결하는 게 Chunked Prefill이에요.

Chunked Prefill:
새 요청 Prefill을 작은 청크로 쪼개서 처리
→ "안녕하세요, 오늘" (청크 1) → Decode 요청들과 함께 처리
→ "날씨는 어때요?" (청크 2) → 다음 스텝에서 처리
→ Decode 레이턴시 영향 최소화

SGLang에서 Continuous Batching 사용하기

SGLang은 기본으로 Continuous Batching + Chunked Prefill이 켜져 있어요.

# 기본 실행 (Continuous Batching 자동 적용)
python -m sglang.launch_server \
  --model-path meta-llama/Llama-3.3-70B-Instruct \
  --port 30000

# 성능 튜닝 옵션
python -m sglang.launch_server \
  --model-path meta-llama/Llama-3.3-70B-Instruct \
  --port 30000 \
  --max-running-requests 256 \    # 동시 처리 최대 요청 수
  --chunked-prefill-size 512 \    # Prefill 청크 크기
  --mem-fraction-static 0.9       # KV Cache에 GPU 메모리 90% 할당

실제 사용:

from openai import OpenAI

client = OpenAI(
    base_url="http://localhost:30000/v1",
    api_key="sglang"
)

# 동시에 여러 요청 보내기 (Continuous Batching이 자동 처리)
import asyncio

async def send_request(prompt):
    response = client.chat.completions.create(
        model="meta-llama/Llama-3.3-70B-Instruct",
        messages=[{"role": "user", "content": prompt}]
    )
    return response.choices[0].message.content

# 100개 동시 요청 → SGLang이 Continuous Batching으로 처리
tasks = [send_request(f"질문 {i}") for i in range(100)]
results = await asyncio.gather(*tasks)

주요 지표 이해하기

TTFT (Time To First Token):
→ 요청 보내고 첫 토큰 받을 때까지
→ Prefill 시간에 영향받음
→ 낮을수록 좋음

TPOT (Time Per Output Token):
→ 토큰 하나 생성하는 데 걸리는 시간
→ Decode 속도
→ 낮을수록 좋음

Throughput (tok/s):
→ 초당 전체 토큰 생성량
→ 높을수록 좋음

GPU 사용률:
→ nvidia-smi로 확인
→ 90% 이상이 목표

Static vs Dynamic vs Continuous 한눈에 비교

Static Dynamic Continuous

스케줄링 단위 배치 배치 토큰(iteration)
GPU 사용률 30~40% 50~60% 85~95%
짧은 요청 레이턴시 높음 중간 낮음
처리량 낮음 중간 높음 (최대 23x)
LLM 적합성 나쁨 보통 최적

실무에서 알아야 할 것

✅ Continuous Batching을 쓰면 좋은 경우:
- LLM 서빙 (거의 항상)
- 요청 길이가 다양한 경우
- 트래픽이 높은 프로덕션

❌ Static Batching이 더 나은 경우:
- 오프라인 배치 처리 (모든 요청 길이 동일)
- 임베딩 생성
- 이미지 생성 (Stable Diffusion 등)
  → 출력 크기가 고정이라 Static으로 충분

⚙️ 실무 튜닝 포인트:
--max-running-requests: 높을수록 처리량↑ 메모리↑
--chunked-prefill-size: 클수록 TTFT↑ Decode 안정성↑
--mem-fraction-static: 높을수록 KV Cache↑ OOM 위험↑

 

반응형