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 / リクエスト!

# 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:
        """物理ブロックを1つ割り当て"""
        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によるプレフィックス共有

# 複数のリクエストが同じシステムプロンプトを共有する場合
# 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: ████ (完了、しかし待機中...)
                              ↑ ここでようやく新しいバッチ開始
    """
    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など)

オフライン推論

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. 量子化

# 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は1つのレイヤーを複数の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環境でのプロダクションデプロイも容易です。

参考資料