Skip to content
Published on

vLLM 완벽 가이드 — PagedAttention부터 프로덕션 최적화까지

Authors
  • Name
    Twitter
vLLM Optimization

들어가며

LLM 추론 서빙은 비용과 성능의 균형이 핵심입니다. GPU 메모리의 60~80%를 차지하는 KV Cache를 효율적으로 관리하지 않으면, 비싼 GPU를 비효율적으로 사용하게 됩니다. vLLM은 UC Berkeley에서 개발한 오픈소스 LLM 추론 엔진으로, PagedAttention이라는 혁신적 메모리 관리 기법으로 이 문제를 해결합니다.

KV Cache 문제 이해

왜 KV Cache가 문제인가

# Transformer 추론 시 KV Cache 크기 계산
def kv_cache_size_gb(
    num_layers: int,
    num_heads: int,
    head_dim: int,
    seq_len: int,
    batch_size: int,
    dtype_bytes: int = 2  # float16
) -> float:
    """
    KV Cache 메모리 = 2 * L * H * D * S * B * dtype
    (2 = K와 V 각각)
    """
    total_bytes = 2 * num_layers * num_heads * head_dim * seq_len * batch_size * dtype_bytes
    return total_bytes / (1024**3)

# Llama 3 70B 예시
print(kv_cache_size_gb(
    num_layers=80,
    num_heads=64,  # GQA: 8 KV heads
    head_dim=128,
    seq_len=4096,
    batch_size=1
))
# → 약 5.2GB per request!

# batch_size=32이면?
# → 약 166GB — A100 80GB 2장으로도 부족!

기존 방식의 낭비

기존 KV Cache 할당 (연속 메모리):

Request 1: [████████████░░░░░░░░]  실제 1024 토큰, 4096 예약
Request 2: [██████░░░░░░░░░░░░░░]  실제 512 토큰, 4096 예약
Request 3: [████████████████░░░░]  실제 3072 토큰, 4096 예약

총 예약: 4096 * 3 = 12,288 슬롯
실제 사용: 4,608 슬롯
낭비율: 62.5%!

PagedAttention

핵심 아이디어

OS의 가상 메모리(Virtual Memory) 페이징 개념을 KV Cache에 적용합니다:

PagedAttention KV Cache 관리:

논리 블록 (Logical Blocks):
Request 1: [B0][B1][B2][B3]

물리 블록 (Physical Blocks - GPU 메모리):
┌────┬────┬────┬────┬────┬────┬────┬────┐
B0B2B5B1B3B6B4B7R1R1R2R1R1R2R2FREE└────┴────┴────┴────┴────┴────┴────┴────┘

페이지 테이블:
Request 1: [00, 13, 21, 34]
Request 2: [02, 15, 26]
# PagedAttention 핵심 구조
class PagedAttentionManager:
    def __init__(self, num_blocks: int, block_size: int, num_heads: int, head_dim: int):
        self.block_size = block_size  # 예: 16 tokens per block
        self.num_blocks = num_blocks

        # 물리 블록 풀 (GPU 메모리에 미리 할당)
        self.k_cache = torch.zeros(
            num_blocks, block_size, num_heads, head_dim,
            dtype=torch.float16, device='cuda'
        )
        self.v_cache = torch.zeros_like(self.k_cache)

        # 프리 리스트
        self.free_blocks = list(range(num_blocks))

    def allocate_block(self) -> int:
        """물리 블록 하나 할당"""
        if not self.free_blocks:
            raise MemoryError("No free blocks")
        return self.free_blocks.pop()

    def free_block(self, block_id: int):
        """블록 반환"""
        self.free_blocks.append(block_id)

    def append_token(self, request_id: int, key: torch.Tensor, value: torch.Tensor):
        """토큰 추가 — 블록이 꽉 차면 새 블록 할당"""
        page_table = self.page_tables[request_id]
        last_block = page_table[-1]
        slot_in_block = self.token_counts[request_id] % self.block_size

        if slot_in_block == 0 and self.token_counts[request_id] > 0:
            # 새 블록 필요
            new_block = self.allocate_block()
            page_table.append(new_block)
            last_block = new_block

        self.k_cache[last_block, slot_in_block] = key
        self.v_cache[last_block, slot_in_block] = value
        self.token_counts[request_id] += 1

Copy-on-Write로 Prefix 공유

# 여러 요청이 같은 시스템 프롬프트를 공유하는 경우
# Copy-on-Write로 메모리 절약

system_prompt = "You are a helpful assistant..."
# 시스템 프롬프트의 KV Cache 블록을 공유

# Request 1: system_prompt + "What is Python?"
# Request 2: system_prompt + "Explain Docker"
# Request 3: system_prompt + "How to use Git?"

# 공유 블록:
# [System Block 0] ← 3개 요청이 공유 (ref_count=3)
# [System Block 1] ← 3개 요청이 공유 (ref_count=3)
# [System Block 2] ← 3개 요청이 공유 (ref_count=3)

# 개별 블록:
# [R1 Block 3] [R2 Block 3] [R3 Block 3] ← 각자 고유

# 메모리 절약: 시스템 프롬프트 블록을 3번 복사하지 않음!

Continuous Batching

# 기존 Static Batching
# 모든 요청이 끝날 때까지 기다림
def static_batching(requests):
    """
    R1: ████████████ (완료)
    R2: ████████████████████ (완료)
    R3: ████ (완료, but 대기...)
                              ↑ 여기서야 새 배치 시작
    """
    max_len = max(r.output_len for r in requests)
    for step in range(max_len):
        # 이미 끝난 요청도 GPU를 점유
        outputs = model.forward(batch)
    return outputs


# vLLM의 Continuous Batching
def continuous_batching(scheduler):
    """
    Step 1: [R1, R2, R3] → 모두 처리
    Step 2: [R1, R2, R3] → R3 완료! → 빈 자리에 R4 투입
    Step 3: [R1, R2, R4] → R1 완료! → R5 투입
    Step 4: [R5, R2, R4] → ...

    GPU 활용률: ~95% (vs Static의 ~50-60%)
    """
    while requests_exist():
        # 완료된 요청 제거, 새 요청 추가
        batch = scheduler.schedule()

        # Prefill과 Decode를 분리하여 처리
        prefill_batch = [r for r in batch if r.is_prefill]
        decode_batch = [r for r in batch if r.is_decode]

        if prefill_batch:
            model.forward(prefill_batch, mode="prefill")
        if decode_batch:
            model.forward(decode_batch, mode="decode")

vLLM 설치 및 기본 사용

설치

# pip 설치
pip install vllm

# CUDA 12.1+ 필요, PyTorch 2.4+
# GPU: Compute Capability 7.0+ (V100, A100, H100, L40S 등)

Offline Inference

from vllm import LLM, SamplingParams

# 모델 로드
llm = LLM(
    model="meta-llama/Llama-3.1-8B-Instruct",
    dtype="auto",
    max_model_len=8192,
    gpu_memory_utilization=0.9,
    tensor_parallel_size=1,
)

# 샘플링 파라미터
sampling_params = SamplingParams(
    temperature=0.7,
    top_p=0.9,
    max_tokens=1024,
    repetition_penalty=1.1,
)

# 배치 추론
prompts = [
    "Kubernetes Pod의 생명주기를 설명해주세요.",
    "Docker와 Podman의 차이점은?",
    "Redis 캐싱 전략에 대해 알려주세요.",
]

outputs = llm.generate(prompts, sampling_params)
for output in outputs:
    print(f"Prompt: {output.prompt[:50]}...")
    print(f"Generated: {output.outputs[0].text[:100]}...")
    print(f"Tokens/s: {len(output.outputs[0].token_ids) / output.metrics.finished_time:.1f}")
    print()

OpenAI 호환 서버

# vLLM 서버 실행 (OpenAI API 호환)
vllm serve meta-llama/Llama-3.1-8B-Instruct \
  --host 0.0.0.0 \
  --port 8000 \
  --max-model-len 8192 \
  --gpu-memory-utilization 0.9 \
  --tensor-parallel-size 2 \
  --enable-prefix-caching \
  --max-num-seqs 256

# API 호출 (OpenAI SDK 호환)
curl http://localhost:8000/v1/chat/completions \
  -H "Content-Type: application/json" \
  -d '{
    "model": "meta-llama/Llama-3.1-8B-Instruct",
    "messages": [
      {"role": "system", "content": "You are a helpful assistant."},
      {"role": "user", "content": "vLLM이란 무엇인가요?"}
    ],
    "temperature": 0.7,
    "max_tokens": 512
  }'
# Python SDK
from openai import OpenAI

client = OpenAI(
    base_url="http://localhost:8000/v1",
    api_key="dummy"  # vLLM은 인증 불필요
)

response = client.chat.completions.create(
    model="meta-llama/Llama-3.1-8B-Instruct",
    messages=[
        {"role": "user", "content": "Hello!"}
    ],
    stream=True
)

for chunk in response:
    if chunk.choices[0].delta.content:
        print(chunk.choices[0].delta.content, end="")

병렬화 전략

Tensor Parallelism

# 4 GPU에 모델 분산
vllm serve meta-llama/Llama-3.1-70B-Instruct \
  --tensor-parallel-size 4 \
  --dtype bfloat16

Pipeline Parallelism

# 8 GPU: 4-way TP × 2-way PP
vllm serve meta-llama/Llama-3.1-405B-Instruct \
  --tensor-parallel-size 4 \
  --pipeline-parallel-size 2

성능 최적화 팁

1. GPU 메모리 활용률

# 기본 0.9 (90%), 더 공격적으로 설정 가능
--gpu-memory-utilization 0.95

# KV Cache 블록 수 확인
# 로그: "# GPU blocks: 12345, # CPU blocks: 0"

2. Prefix Caching

# 시스템 프롬프트가 동일한 요청이 많을 때 효과적
--enable-prefix-caching

3. Quantization

# AWQ 양자화 모델 사용
vllm serve TheBloke/Llama-3.1-70B-AWQ \
  --quantization awq \
  --dtype auto

# GPTQ
vllm serve TheBloke/Llama-3.1-70B-GPTQ \
  --quantization gptq

# FP8 (H100에서 최적)
vllm serve meta-llama/Llama-3.1-70B-Instruct \
  --quantization fp8

4. Speculative Decoding

# 작은 모델로 추측 → 큰 모델로 검증
vllm serve meta-llama/Llama-3.1-70B-Instruct \
  --speculative-model meta-llama/Llama-3.1-8B-Instruct \
  --num-speculative-tokens 5

벤치마크

# vLLM 벤치마크 도구
python -m vllm.entrypoints.openai.api_server \
  --model meta-llama/Llama-3.1-8B-Instruct &

# ShareGPT 데이터셋으로 벤치마크
python benchmarks/benchmark_serving.py \
  --backend vllm \
  --model meta-llama/Llama-3.1-8B-Instruct \
  --dataset-name sharegpt \
  --num-prompts 1000 \
  --request-rate 10
벤치마크 결과 예시 (A100 80GB, Llama-3.1-8B):

| 메트릭           | vLLM    | TGI     | 순수 HF |
|-----------------|---------|---------|---------|
| Throughput      | 2,400   | 1,800   | 400     |
| (tokens/s)      |         |         |         |
| TTFT p50 (ms)   | 45      | 60      | 200     |
| TTFT p99 (ms)   | 120     | 180     | 500     |
| ITL p50 (ms)    | 8       | 10      | 25      |
| Max Batch Size  | 256     | 128     | 16      |
| Memory Util.    | 95%     | 85%     | 60%     |

프로덕션 배포

Kubernetes 배포

apiVersion: apps/v1
kind: Deployment
metadata:
  name: vllm-server
spec:
  replicas: 1
  selector:
    matchLabels:
      app: vllm
  template:
    metadata:
      labels:
        app: vllm
    spec:
      containers:
        - name: vllm
          image: vllm/vllm-openai:latest
          command:
            - python3
            - -m
            - vllm.entrypoints.openai.api_server
            - --model=meta-llama/Llama-3.1-8B-Instruct
            - --tensor-parallel-size=1
            - --gpu-memory-utilization=0.9
            - --max-model-len=8192
            - --enable-prefix-caching
          ports:
            - containerPort: 8000
          resources:
            limits:
              nvidia.com/gpu: 1
              memory: 32Gi
            requests:
              nvidia.com/gpu: 1
              memory: 24Gi
          readinessProbe:
            httpGet:
              path: /health
              port: 8000
            initialDelaySeconds: 120
            periodSeconds: 10
          volumeMounts:
            - name: model-cache
              mountPath: /root/.cache/huggingface
      volumes:
        - name: model-cache
          persistentVolumeClaim:
            claimName: model-cache-pvc
---
apiVersion: v1
kind: Service
metadata:
  name: vllm-service
spec:
  selector:
    app: vllm
  ports:
    - port: 80
      targetPort: 8000
  type: ClusterIP

퀴즈

Q1. PagedAttention의 핵심 아이디어는 무엇인가요?

OS의 가상 메모리 페이징 개념을 KV Cache에 적용합니다. 연속 메모리 할당 대신 작은 블록 단위로 KV Cache를 관리하여 메모리 단편화와 낭비를 해소합니다.

Q2. Continuous Batching이 Static Batching보다 효율적인 이유는?

요청이 완료되면 즉시 배치에서 제거하고 새 요청을 추가합니다. Static Batching은 가장 긴 요청이 끝날 때까지 모든 슬롯을 점유하여 GPU가 낭비됩니다.

Q3. Prefix Caching은 어떤 상황에서 효과적인가요?

동일한 시스템 프롬프트를 사용하는 여러 요청이 있을 때 효과적입니다. 공통 접두사의 KV Cache를 공유하여 중복 계산과 메모리를 절약합니다.

Q4. Tensor Parallelism과 Pipeline Parallelism의 차이는?

Tensor Parallelism은 하나의 레이어를 여러 GPU에 분할하고, Pipeline Parallelism은 서로 다른 레이어를 다른 GPU에 배치합니다. TP는 레이턴시를, PP는 처리량을 최적화합니다.

Q5. Speculative Decoding의 원리는?

작은 모델이 여러 토큰을 빠르게 생성(추측)하고, 큰 모델이 이를 한번에 검증합니다. 검증된 토큰만 채택하여, 품질을 유지하면서 속도를 높입니다.

Q6. gpu-memory-utilization 파라미터는 무엇을 제어하나요?

KV Cache에 할당할 GPU 메모리의 비율입니다. 0.9이면 전체 GPU 메모리의 90%까지 KV Cache로 사용하여 더 많은 동시 요청을 처리할 수 있습니다.

Q7. Llama 3.1 70B를 A100 80GB에서 서빙하려면 최소 몇 장의 GPU가 필요한가요?

FP16 기준 약 140GB이므로 최소 2장이 필요합니다. AWQ/GPTQ 4bit 양자화를 사용하면 1장으로도 가능합니다.

마무리

vLLM은 PagedAttention, Continuous Batching, 다양한 병렬화 전략을 통해 LLM 추론의 표준 도구로 자리잡았습니다. OpenAI API 호환 서버를 제공하여 기존 코드 변경 없이 도입할 수 있고, Kubernetes 환경에서의 프로덕션 배포도 용이합니다.

참고 자료