- Authors

- Name
- Youngju Kim
- @fjvbn20031
- LLM推論の2つのフェーズ:PrefillとDecode
- KVキャッシュ:アテンションのメモリジレンマ
- PagedAttention(vLLM):仮想メモリがLLMを救う
- 連続バッチング(Continuous Batching):スループットの最大化
- 量子化(Quantization):精度とメモリ・速度のトレードオフ
- 投機的デコーディング(Speculative Decoding):タダ飯は存在する
- テンソル並列性とパイプライン並列性
- vLLM vs TGI vs TensorRT-LLM:フレームワーク比較
- プロダクションLLMサービングスタック
LLM推論の2つのフェーズ:PrefillとDecode
LLMがテキストを生成するプロセスは、根本的に異なる2つのフェーズに分かれる。この違いを理解しなければ最適化は不可能だ。
Phase 1: PREFILL(入力処理)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
入力: "フランスの首都はどこですか?"
└─ 全11トークンを同時処理
動作:
- 全入力トークンを並列処理(大規模行列乗算!)
- 各トークンのQ、K、Vを計算
- KVキャッシュを作成(後で再利用するK、Vを保存)
- 最初の出力トークンを生成
特性:
- GPU演算: コンピュートバウンド(行列×行列)
- GPU使用率: 高い ✅
- レイテンシ指標: TTFT(Time To First Token)
Phase 2: DECODE(トークン生成)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
生成: "パリ" → "です" → "。" → ...
動作:
- 一度に1トークンずつ順次生成
- 新しいトークンのQを計算してキャッシュ済みK、Vとアテンション
- すべてのモデル重みをトークンごとに読み込む必要がある
特性:
- GPU演算: メモリバウンド(行列×ベクトル)
- GPU使用率: 低い(多くの場合5〜20%!)
- スループット指標: TBT(Time Between Tokens)
これがLLMサービング最適化が難しい根本的な理由:
2つのフェーズが全く異なるボトルネックを持つ!
実際の数値で確認する:
import torch
import time
from transformers import AutoModelForCausalLM, AutoTokenizer
def measure_llm_phases(model_name="meta-llama/Llama-3.2-1B"):
model = AutoModelForCausalLM.from_pretrained(
model_name, torch_dtype=torch.float16, device_map="cuda"
)
tokenizer = AutoTokenizer.from_pretrained(model_name)
prompt = "トランスフォーマーアーキテクチャについて詳しく説明してください:"
inputs = tokenizer(prompt, return_tensors="pt").to("cuda")
input_len = inputs["input_ids"].shape[1]
# Prefill時間の測定
torch.cuda.synchronize()
t0 = time.perf_counter()
with torch.no_grad():
_ = model(**inputs)
torch.cuda.synchronize()
t_prefill = time.perf_counter() - t0
# Decode時間の測定
t0 = time.perf_counter()
with torch.no_grad():
generated = model.generate(
inputs["input_ids"],
max_new_tokens=50,
do_sample=False
)
torch.cuda.synchronize()
t_total = time.perf_counter() - t0
t_decode = t_total - t_prefill
n_new = generated.shape[1] - input_len
print(f"入力トークン数: {input_len}")
print(f"Prefill時間(TTFT): {t_prefill*1000:.1f}ms")
print(f"生成トークン数: {n_new}")
print(f"Decode時間: {t_decode*1000:.1f}ms")
print(f"トークンあたり時間: {t_decode/n_new*1000:.1f}ms/token")
# Llama-1B、H100の場合:
# Prefill: ~5ms(入力長に線形)
# Decode: ~3ms/token(モデルサイズに比例)
KVキャッシュ:アテンションのメモリジレンマ
KVキャッシュなしでは何が起こるか?
KVキャッシュなしの自己回帰生成:
Step 1: [token_1] → token_2を生成
- token_1のQ1、K1、V1を計算
- アテンション: Q2 × [K1]^T
- 演算: 1^2 = 1回の内積
Step 2: [token_1, token_2] → token_3を生成
- K1、V1を再計算(無駄!)
- Q3、K2、K3を計算
- アテンション: Q3 × [K1,K2,K3]^T
- 演算: 3^2 = 9回の内積
N番目のトークン: O(N^2)演算
L個のトークン生成合計: O(L^3)演算
100トークン: 1,000,000回の内積
1000トークン: 10億回の内積
KVキャッシュ:以前の計算を再利用
import torch
import torch.nn as nn
import math
class AttentionWithKVCache(nn.Module):
def __init__(self, d_model, n_heads):
super().__init__()
self.n_heads = n_heads
self.d_k = d_model // n_heads
self.W_q = nn.Linear(d_model, d_model, bias=False)
self.W_k = nn.Linear(d_model, d_model, bias=False)
self.W_v = nn.Linear(d_model, d_model, bias=False)
self.W_o = nn.Linear(d_model, d_model, bias=False)
# KVキャッシュストレージ
self.k_cache = None # (batch, heads, past_len, d_k)
self.v_cache = None
def forward(self, x, use_cache=True):
batch, seq, d = x.shape
Q = self.W_q(x).view(batch, seq, self.n_heads, self.d_k).transpose(1,2)
K = self.W_k(x).view(batch, seq, self.n_heads, self.d_k).transpose(1,2)
V = self.W_v(x).view(batch, seq, self.n_heads, self.d_k).transpose(1,2)
if use_cache and self.k_cache is not None:
# キャッシュに新しいK、Vを追加
K = torch.cat([self.k_cache, K], dim=2)
V = torch.cat([self.v_cache, V], dim=2)
if use_cache:
self.k_cache = K.detach()
self.v_cache = V.detach()
# QはステップのトークンのみQは現在のトークンのみ; K、Vは全シーケンス
scale = math.sqrt(self.d_k)
scores = torch.matmul(Q, K.transpose(-2,-1)) / scale
weights = torch.softmax(scores, dim=-1)
output = torch.matmul(weights, V)
return output.transpose(1,2).contiguous().view(batch, seq, d)
def compute_kv_cache_bytes(seq_len, n_layers, n_kv_heads, head_dim,
batch_size, dtype_bytes=2):
"""KVキャッシュのメモリ使用量(バイト)。2はKとV両方のため。"""
return 2 * n_layers * n_kv_heads * head_dim * seq_len * batch_size * dtype_bytes
# Llama 3.1 70B(GQA使用: KVヘッド8個、Qヘッド64個):
size = compute_kv_cache_bytes(
seq_len=4096, n_layers=80, n_kv_heads=8,
head_dim=128, batch_size=1, dtype_bytes=2
)
print(f"KVキャッシュ(Llama-70B、seq=4096、batch=1): {size/1e9:.1f} GB")
# 結果: ~6.7 GB per リクエスト
# batch=32: ~214 GB → H100(80GB)1台には収まらない!
メモリの断片化問題
従来のKVキャッシュ割り当て(max_seq_lenを事前確保):
┌───────────────────────────────────────────────────────┐
│ リクエスト1: 現在長=100、確保=512 │
│ ████████████░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ │
│ [使用: 100] [無駄: 412スロット = 80%!] │
├───────────────────────────────────────────────────────┤
│ リクエスト2: 現在長=50、確保=512 │
│ ██████░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ │
│ [使用: 50] [無駄: 462スロット = 90%!] │
├───────────────────────────────────────────────────────┤
│ リクエスト3: 現在長=300、確保=512 │
│ █████████████████████████████████████░░░░░░░░ │
│ [使用: 300] [無駄: 212スロット = 41%!] │
└───────────────────────────────────────────────────────┘
合計確保: 3 × 512 = 1536スロット
合計使用: 450スロット
無駄: 1086スロット = 71%
典型的な実際のGPUメモリ使用率: 20〜40%
PagedAttention(vLLM):仮想メモリがLLMを救う
コアアイデア:OSの仮想メモリをLLMに適用
Kwonら(UC Berkeley、2023年)の洞察:「OSが数十年前にメモリ断片化問題を解決した。同じ考え方をKVキャッシュに適用しよう!」
OSの仮想メモリの教訓:
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
仮想アドレス → ページテーブル → 物理アドレス
プロセスは連続した仮想空間を見る
実際の物理メモリは不連続でよい
→ 断片化なし、効率的なRAM使用
PagedAttentionの類比:
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
仮想KVスロット → ブロックテーブル → 物理ブロック
シーケンスは連続した仮想スロットを見る
実際のGPUブロックは不連続でよい
→ ほぼゼロの断片化、効率的なGPUメモリ使用
PagedAttentionのメモリレイアウト:
GPUメモリを固定サイズのブロックに分割(デフォルト: 16トークン):
┌─────────────────────────────────────────────────────────┐
│ 物理KVキャッシュブロック │
│ Block 0: [tok0–15] Block 1: [tok16–31] │
│ Block 2: [tok32–47] Block 3: [tok48–63] │
│ Block 4: [tok64–79] Block 5: FREE │
│ Block 6: FREE Block 7: FREE │
└─────────────────────────────────────────────────────────┘
ブロックテーブル(OSのページテーブルと同じ役割):
┌──────────┬──────────────────────────────────────────────┐
│ リクエスト│ 仮想ブロック → 物理ブロックのマッピング │
├──────────┼──────────────────────────────────────────────┤
│ Req 1 │ virt[0]→phys[0]、virt[1]→phys[2] │
│ │ (トークン0-15: ブロック0、32-47: ブロック2) │
├──────────┼──────────────────────────────────────────────┤
│ Req 2 │ virt[0]→phys[1]、virt[1]→phys[3] │
│ │ (トークン0-15: ブロック1、16-31: ブロック3) │
└──────────┴──────────────────────────────────────────────┘
主な特性:
- ブロックはシーケンスが成長するにつれてオンデマンドで割り当て
- 内部断片化 < 1ブロック = 最大15スロット(事実上ゼロ)
- ブロックは複数リクエスト間で共有可能!(共通プレフィックス時)
# vLLMでPagedAttentionを使う:
from vllm import LLM, SamplingParams
import time
def benchmark_vllm():
llm = LLM(
model="meta-llama/Llama-3.2-8B-Instruct",
gpu_memory_utilization=0.9,
max_model_len=8192,
block_size=16,
max_num_seqs=256,
)
prompts = [
"短い質問:Pythonとは?",
"中程度の質問: " + "機械学習の歴史について詳しく説明してください。" * 5,
"長い質問: " + "Transformerをゼロから実装する方法は?" * 10,
] * 20 # 様々な長さの60個のリクエスト
params = SamplingParams(temperature=0.0, max_tokens=100)
t0 = time.perf_counter()
outputs = llm.generate(prompts, params)
elapsed = time.perf_counter() - t0
total_tokens = sum(len(o.outputs[0].token_ids) for o in outputs)
print(f"リクエスト数: {len(prompts)}")
print(f"生成トークン総数: {total_tokens}")
print(f"処理時間: {elapsed:.1f}秒")
print(f"スループット: {total_tokens/elapsed:.0f} tokens/s")
# メモリ効率の改善:
# 従来方式: GPUメモリの20〜40%のみ実際のKVキャッシュに使用
# PagedAttention: GPUメモリの95%以上を実際のKVキャッシュに使用
# 結果: 同じGPUで2〜3倍のリクエストを同時処理可能!
プレフィックスキャッシング:共通プロンプトの共有
# vLLMのプレフィックスキャッシング:
llm = LLM(
model="meta-llama/Llama-3.2-8B-Instruct",
enable_prefix_caching=True,
)
# 長いシステムプロンプトを共有する複数のリクエスト:
system = "あなたは熟練したソフトウェアエンジニアです。" * 100
requests = [
system + "質問: 二分探索木とは?",
system + "質問: ガベージコレクションとは?",
system + "質問: ACIDプロパティとは?",
]
# systemプロンプト部分のKVキャッシュが3つのリクエストで共有される!
# Prefillコスト: 3回ではなく1回のみ実行(3倍の節約!)
# RAGパイプラインでコンテキストが繰り返される場合に大きく効果的
連続バッチング(Continuous Batching):スループットの最大化
静的バッチングの問題
静的バッチング(Static Batching):
GPUが処理するバッチ:
Step 1: [Req1: 処理中] [Req2: 処理中] [Req3: 処理中]
Step 2: [Req1: 処理中] [Req2: 完了! ] [Req3: 処理中]
Step 3: [Req1: 処理中] [ 待機/無駄 ] [Req3: 処理中] ← GPUの無駄!
Step 4: [Req1: 完了! ] [ 待機/無駄 ] [Req3: 処理中] ← GPUの無駄!
新しいリクエストはバッチ全体が完了するまで待機
GPU無駄率: 多くの場合50%以上
連続バッチング:毎トークンステップで動的スケジューリング
連続バッチング(イテレーションレベルスケジューリング):
Step 1: [Req1] [Req2] [Req3]
Step 2: [Req1] [Req2] [Req3]
Step 3: [Req1] [Req4] [Req3] ← Req2完了 → Req4を即座に挿入!
Step 4: [Req5] [Req4] [Req3] ← Req1完了 → Req5を即座に挿入!
Step 5: [Req5] [Req4] [Req6] ← Req3完了 → Req6を即座に挿入!
GPUは常に最大使用率!
スループット向上: 静的バッチングの2〜4倍
from vllm.engine.async_llm_engine import AsyncLLMEngine
from vllm.engine.arg_utils import AsyncEngineArgs
import asyncio
async def run_continuous_batching_server():
engine_args = AsyncEngineArgs(
model="meta-llama/Llama-3.2-8B-Instruct",
max_num_seqs=256,
max_num_batched_tokens=8192,
)
engine = AsyncLLMEngine.from_engine_args(engine_args)
async def generate_one(prompt, req_id):
from vllm import SamplingParams
params = SamplingParams(temperature=0.7, max_tokens=200)
async for output in engine.generate(prompt, params, request_id=req_id):
if output.finished:
return output.outputs[0].text
# 複数のリクエストを非同期で同時処理(連続バッチングが自動適用)
results = await asyncio.gather(
generate_one("量子もつれを説明してください。", "r1"),
generate_one("Pythonでクイックソートを書いてください。", "r2"),
generate_one("フランス革命を要約してください。", "r3"),
)
for r in results:
print(r[:80])
量子化(Quantization):精度とメモリ・速度のトレードオフ
なぜ量子化が必要か?
LLMのメモリ使用量(FP16基準):
Llama 3.1 8B: 16 GB
Llama 3.1 70B: 140 GB
Llama 3.1 405B: 810 GB
一般的なGPUメモリ:
RTX 4090: 24 GB → 8Bでも厳しい
A100 80GB: → 70Bは不可能(単体)
H100 80GB: → 70Bは不可能(単体)
H100 ×8(640GB): → 70B可能、405B不可
量子化によるメモリ節約:
FP16 (16ビット): 基準
INT8 (8ビット): 50%節約、~1%精度損失
INT4 (4ビット): 75%節約、~2〜3%精度損失
INT3 (3ビット): 81%節約、注意が必要
INT2 (2ビット): 88%節約、通常使用不可レベル
Post-Training Quantization: INT8
from transformers import AutoModelForCausalLM, BitsAndBytesConfig
import torch
# INT8量子化(LLM.int8() - Dettmersら、2022年):
config_int8 = BitsAndBytesConfig(
load_in_8bit=True,
llm_int8_skip_modules=["lm_head"], # 出力ヘッドはFP16に維持
)
model_int8 = AutoModelForCausalLM.from_pretrained(
"meta-llama/Llama-3.1-70B-Instruct",
quantization_config=config_int8,
device_map="auto",
)
# 70Bモデル: 140GB(FP16)→ 70GB(INT8)、~1%精度損失
# LLM.int8()の核心的な革新:
# 問題: 特定のチャンネルに外れ値(outlier)があると量子化品質が低下
# 解決: 混合精度分解(Mixed-precision decomposition)
# - 外れ値のあるチャンネルはFP16に維持
# - 残りはINT8に量子化
# → ほぼ損失なしの品質でメモリ50%節約
4ビット量子化:NF4とGPTQ
# NF4量子化(QLoRA論文、Dettmersら2023年):
config_4bit = BitsAndBytesConfig(
load_in_4bit=True,
bnb_4bit_compute_dtype=torch.float16, # 計算はFP16で
bnb_4bit_quant_type="nf4", # NormalFloat4形式
bnb_4bit_use_double_quant=True, # 量子化スケールも量子化!
)
model_4bit = AutoModelForCausalLM.from_pretrained(
"meta-llama/Llama-3.1-70B-Instruct",
quantization_config=config_4bit,
device_map="auto",
)
# 70B: 140GB → 35GB、~2〜3%精度損失
# NF4とは?
# ニューラルネットワークの重みは正規分布に近似的に従う
# NF4は標準正規分布の等確率区間に対応する16個のコードポイントを使用
# 各コードポイントが同じ確率質量をカバー → 量子化誤差を最小化
# GPTQ(Frantarら、2022年)- レイヤーごとの最適化量子化:
from auto_gptq import AutoGPTQForCausalLM
model_gptq = AutoGPTQForCausalLM.from_quantized(
"TheBloke/Llama-2-70B-GPTQ",
device="cuda:0",
use_triton=True,
)
# GPTQは各レイヤーのヘッシアン行列を使って量子化誤差を最小化
# INT4手法の中で最も高い精度を持つ傾向がある
AWQ:活性化を考慮した重み量子化
# AWQ(Linら、2023年)の核心的な洞察:
# すべての重みが等しく重要ではない!
# ~1%の「顕著な(salient)」チャンネルが大きな活性化を生成する
# これらをINT4に量子化すると品質が急激に低下する
# AWQの解決策:
# 1. キャリブレーションデータで各チャンネルの活性化統計を取得
# 2. 顕著なチャンネルの重みスケールを調整(per-channel scaling)
# 3. すべてをINT4に量子化 - スケーリングが誤差を吸収
from awq import AutoAWQForCausalLM
from transformers import AutoTokenizer
model_path = "meta-llama/Llama-3.1-8B-Instruct"
quant_path = "llama-3.1-8b-awq"
model = AutoAWQForCausalLM.from_pretrained(model_path)
tokenizer = AutoTokenizer.from_pretrained(model_path)
quant_config = {
"zero_point": True,
"q_group_size": 128,
"w_bit": 4,
"version": "GEMM"
}
model.quantize(tokenizer, quant_config=quant_config)
model.save_quantized(quant_path)
# AWQ vs GPTQ比較:
# AWQ: 推論が速い(手動最適化のCUDA/Tritonカーネル)
# メモリ: FP16の約25%
# 精度: GPTQとほぼ同等か若干低い
# GPTQ: 精度が高い(ヘッシアンベースの最適化)
# 推論速度: AWQと同等
# メモリ: AWQと同じ
量子化性能比較表
Llama 3.1 70B量子化比較(H100 1台):
┌──────────┬──────────┬────────────┬──────────┬───────────────────┐
│ 形式 │ メモリ │ スループット│ MMLU │ 必要なハードウェア│
├──────────┼──────────┼────────────┼──────────┼───────────────────┤
│ FP16 │ 140 GB │ 基準 │ 80.9% │ H100 ×8 │
│ BF16 │ 140 GB │ +5% │ 80.9% │ H100 ×8 │
│ INT8 │ 70 GB │ +10% │ 80.2% │ H100 ×2 │
│ GPTQ-4b │ 36 GB │ +30% │ 79.8% │ H100 ×1 │
│ AWQ-4b │ 36 GB │ +35% │ 79.5% │ H100 ×1 │
│ GGUF-Q4 │ 38 GB │ CPU可能 │ 79.1% │ CPU or H100 ×1 │
└──────────┴──────────┴────────────┴──────────┴───────────────────┘
投機的デコーディング(Speculative Decoding):タダ飯は存在する
アイデア:小さなモデルが下書き、大きなモデルが検証
標準デコーディング:
70Bモデルが1トークン生成 = 1 forward pass = ~10ms
100トークン = ~1000ms = 1秒
投機的デコーディング(Leviathan ら、2023年):
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Step 1: ドラフトモデル(7B)がKトークンを素早く生成
"パリ" "は" "フランスの" "首都"
4トークン、~2ms(7Bモデル)
Step 2: 大型モデル(70B)がK個のトークンを1回のforward passで検証!
4つのドラフトトークンを並列処理 → ~10ms
(通常は1トークンのコストと同じ)
Step 3: 各ドラフトトークンを検証:
"パリ" ✅ "は" ✅ "フランスの" ✅ "首都" ❌
→ 3トークン受け入れ、1トークン拒否
Step 4: 拒否された位置以降を大型モデルで再生成
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
結果: ~12msで3トークン生成(標準: 30ms)
速度向上: 2.5倍(受け入れ率に依存、通常70〜90%)
品質損失: ゼロ(大型モデルが最終仲裁者)
import torch
from transformers import AutoModelForCausalLM, AutoTokenizer
def speculative_decode(
target_model,
draft_model,
input_ids,
max_new_tokens=100,
K=4,
temperature=1.0,
):
"""
投機的デコーディング: ドラフトモデルがKトークンを提案、
ターゲットモデルが1回のforward passで全て検証。
ターゲットモデルのみを使った場合と完全に同じ分布を保証。
"""
generated = input_ids.clone()
while generated.shape[1] < input_ids.shape[1] + max_new_tokens:
# フェーズ1: ドラフトモデルでK個の候補を生成
draft_ids = []
draft_probs = []
ctx = generated.clone()
for _ in range(K):
with torch.no_grad():
out = draft_model(ctx)
logits = out.logits[:, -1, :] / max(temperature, 1e-5)
probs = torch.softmax(logits, dim=-1)
tok = torch.multinomial(probs, 1)
draft_ids.append(tok)
draft_probs.append(probs[0, tok[0, 0]])
ctx = torch.cat([ctx, tok], dim=1)
# フェーズ2: ターゲットモデルでK個の位置を同時に検証
candidate = torch.cat([generated] + draft_ids, dim=1)
with torch.no_grad():
tgt_out = target_model(candidate)
tgt_logits = tgt_out.logits[:, len(generated[0])-1:-1, :]
tgt_probs = torch.softmax(tgt_logits / max(temperature, 1e-5), dim=-1)
# フェーズ3: 受け入れ/拒否の決定
n_accepted = 0
for i in range(K):
token_id = draft_ids[i][0, 0].item()
p_target = tgt_probs[0, i, token_id].item()
p_draft = draft_probs[i].item()
# 受け入れ確率: min(1, p_target / p_draft)
accept_p = min(1.0, p_target / max(p_draft, 1e-8))
if torch.rand(1).item() < accept_p:
generated = torch.cat([generated, draft_ids[i]], dim=1)
n_accepted += 1
else:
# 拒否: 調整済みターゲット分布から再サンプリング
adjusted = tgt_probs[0, i].clone()
adjusted[token_id] = max(0.0, adjusted[token_id] - p_draft)
adjusted = adjusted / adjusted.sum().clamp(min=1e-8)
new_tok = torch.multinomial(adjusted.unsqueeze(0), 1)
generated = torch.cat([generated, new_tok], dim=1)
break
if n_accepted == K:
# 全て受け入れ: ターゲットモデルのボーナストークンも追加
bonus_logits = tgt_out.logits[:, -1, :] / max(temperature, 1e-5)
bonus_probs = torch.softmax(bonus_logits, dim=-1)
bonus_tok = torch.multinomial(bonus_probs, 1)
generated = torch.cat([generated, bonus_tok], dim=1)
return generated
# 実際の速度向上(A100、Llama-2 70B + Llama-2 7Bドラフト):
# K=4: 2.3倍速度向上、受け入れ率~80%
# K=8: 2.7倍速度向上、受け入れ率~75%
テンソル並列性とパイプライン並列性
テンソル並列性:レイヤーをGPUに分散
テンソル並列性(Shoeybiら、2019年 - Megatron-LM):
70Bモデル、8 GPU、64 Attentionヘッド:
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
GPU 0: ヘッド 0〜7
GPU 1: ヘッド 8〜15
GPU 2: ヘッド 16〜23
GPU 3: ヘッド 24〜31
GPU 4: ヘッド 32〜39
GPU 5: ヘッド 40〜47
GPU 6: ヘッド 48〜55
GPU 7: ヘッド 56〜63
各GPUが担当するヘッドを独立して計算
最後にAll-Reduceで結果を合算
通信コスト:
Attentionレイヤーごとに1回のAll-Reduce
FFNレイヤーごとに1回のAll-Reduce
NVLink(H100): 900 GB/s → テンソル並列性が効果的
PCIe: 64 GB/s → TP>2は遅すぎる
import torch
import torch.distributed as dist
def tensor_parallel_linear(x, W_local, rank, world_size):
"""
列並列線形層(W出力次元に沿って分割)。
x: (batch, seq, d_model) -- 全GPUに複製
W_local: (d_model, d_out//world_size) -- 各GPUが1シャードを保持
"""
out_local = x @ W_local # (batch, seq, d_out//world_size)
# All-Gatherで全出力を各GPUに再構成
out_list = [torch.zeros_like(out_local) for _ in range(world_size)]
dist.all_gather(out_list, out_local)
out_full = torch.cat(out_list, dim=-1)
return out_full
def tensor_parallel_linear_row(x_local, W_local, rank, world_size):
"""
行並列線形層: xは既にGPU間でシャード済み。
"""
partial = x_local @ W_local # (batch, seq, d_out) -- 部分和
dist.all_reduce(partial, op=dist.ReduceOp.SUM)
return partial
パイプライン並列性:レイヤーを順次配置
パイプライン並列性:
70Bモデル、80レイヤー、4 GPU:
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
GPU 0: レイヤー 0〜19 (埋め込み + 最初の20トランスフォーマー)
GPU 1: レイヤー 20〜39
GPU 2: レイヤー 40〜59
GPU 3: レイヤー 60〜79 + LMヘッド
マイクロバッチングでパイプラインバブルを隠す:
パイプラインバブル比率 = (p - 1) / (m + p - 1)
p = パイプラインステージ数
m = マイクロバッチ数
→ mを大きくするほどバブルが減少し効率向上
vLLM vs TGI vs TensorRT-LLM:フレームワーク比較
LLMサービングフレームワーク比較(2025年現在):
┌───────────────────┬────────────────────────────────────────────────┐
│ フレームワーク │ vLLM │
├───────────────────┼────────────────────────────────────────────────┤
│ 開発者 │ UC Berkeley / vLLMチーム │
│ 主要技術 │ PagedAttention、連続バッチング │
│ 量子化サポート │ AWQ、GPTQ、INT8、FP8 │
│ スループット │ ★★★★☆ 高い │
│ TTFTレイテンシ │ ★★★☆☆ 中程度 │
│ 使いやすさ │ ★★★★★ 非常に簡単(Pythonネイティブ) │
│ カスタマイズ性 │ ★★★☆☆ 中程度 │
│ ライセンス │ Apache 2.0 │
│ 特徴 │ 最も活発なOSSコミュニティ、OpenAI互換API │
└───────────────────┴────────────────────────────────────────────────┘
┌───────────────────┬────────────────────────────────────────────────┐
│ フレームワーク │ TGI(Text Generation Inference) │
├───────────────────┼────────────────────────────────────────────────┤
│ 開発者 │ Hugging Face │
│ 主要技術 │ 連続バッチング、FlashAttention │
│ 量子化サポート │ GPTQ、AWQ、bitsandbytes │
│ スループット │ ★★★☆☆ 中程度 │
│ TTFTレイテンシ │ ★★★☆☆ 中程度 │
│ 使いやすさ │ ★★★★☆ 簡単(Dockerファースト) │
│ カスタマイズ性 │ ★★★★☆ 高い │
│ ライセンス │ HFOIL(商用利用に注意) │
│ 特徴 │ HFエコシステムとの自然な統合 │
└───────────────────┴────────────────────────────────────────────────┘
┌───────────────────┬────────────────────────────────────────────────┐
│ フレームワーク │ TensorRT-LLM │
├───────────────────┼────────────────────────────────────────────────┤
│ 開発者 │ NVIDIA │
│ 主要技術 │ TensorRT最適化、インフライトバッチング │
│ 量子化サポート │ INT8、INT4、FP8、SmoothQuant、AWQ │
│ スループット │ ★★★★★ 最高(NVIDIA GPU専用) │
│ TTFTレイテンシ │ ★★★★★ 最低 │
│ 使いやすさ │ ★★☆☆☆ 複雑(C++重視) │
│ カスタマイズ性 │ ★★☆☆☆ 難しい │
│ ライセンス │ Apache 2.0 │
│ 特徴 │ NVIDIA GPU上で最高のraw性能 │
└───────────────────┴────────────────────────────────────────────────┘
┌───────────────────┬────────────────────────────────────────────────┐
│ フレームワーク │ llama.cpp / Ollama │
├───────────────────┼────────────────────────────────────────────────┤
│ 開発者 │ ggerganov / Ollama Inc. │
│ 主要技術 │ GGUF量子化、CPU+GPUハイブリッド │
│ 量子化サポート │ Q2〜Q8(GGUF形式) │
│ スループット │ ★★☆☆☆ 低い(CPU使用時) │
│ TTFTレイテンシ │ ★★☆☆☆ 高い │
│ 使いやすさ │ ★★★★★ 最も簡単 │
│ カスタマイズ性 │ ★★☆☆☆ 限定的 │
│ ライセンス │ MIT │
│ 特徴 │ ローカル開発、CPU推論、デモ環境に最適 │
└───────────────────┴────────────────────────────────────────────────┘
選択ガイド
vLLMを選ぶ場合:
- プロダクションサービング、Pythonチーム、オープンソース重視
- OpenAI互換APIのドロップイン代替が必要
- 最新機能とコミュニティサポートを重視
TGIを選ぶ場合:
- HuggingFaceエコシステムとの深い統合が必要
- Dockerファーストのデプロイ文化
- SSEストリーミングが必要
TensorRT-LLMを選ぶ場合:
- NVIDIA GPU上で最高のraw性能が必要
- C++/CUDAツールに慣れたチームがある
- エンタープライズプロダクション環境
Ollama/llama.cppを選ぶ場合:
- ローカル開発・プロトタイピング
- CPU推論が必要
- 最大限のシンプルさを重視
プロダクションLLMサービングスタック
# プロダクションvLLMサーバーの起動設定:
import subprocess
cmd = [
"python", "-m", "vllm.entrypoints.openai.api_server",
"--model", "meta-llama/Llama-3.1-8B-Instruct",
"--tensor-parallel-size", "2",
"--gpu-memory-utilization", "0.9",
"--max-model-len", "8192",
"--max-num-seqs", "256",
"--max-num-batched-tokens", "8192",
"--quantization", "awq",
"--enable-prefix-caching",
"--block-size", "16",
"--port", "8000",
]
# クライアント側からの呼び出し:
from openai import OpenAI
client = OpenAI(base_url="http://localhost:8000/v1", api_key="token")
response = client.chat.completions.create(
model="meta-llama/Llama-3.1-8B-Instruct",
messages=[
{"role": "system", "content": "あなたは役立つAIアシスタントです。"},
{"role": "user", "content": "PythonのGILとは何ですか?"},
],
temperature=0.0,
max_tokens=500,
stream=True,
)
for chunk in response:
if chunk.choices[0].delta.content:
print(chunk.choices[0].delta.content, end="", flush=True)
LLMサービングの最適化は、ハードウェア、アルゴリズム、システムソフトウェアが交差する複雑な分野だ。PagedAttentionがOS設計から借用し、FlashAttentionが数値線形代数のタイリング原理を再発見したように、コンピュータサイエンスの古い知恵が新しい形で再発見され続けている。各技術が解こうとしている根本的な問題を理解することで、新しい最適化が登場するたびに素早く習得し適用できるようになる。