問題の概要:vLLMバッチ推論でのスループット低下とリソース未使用
vLLMは大規模言語モデル(LLM)の推論を高速化するためのライブラリですが、デフォルト設定のままバッチ推論を実行すると、期待したほどのスループット(単位時間あたりの処理トークン数)が得られないことがあります。具体的には、GPU利用率が低いまま(例:30-50%)停滞したり、リクエストの処理待ちが発生してスループットが頭打ちになる現象が観察されます。
よく遭遇する具体的な状況としては、複数のプロンプトをリストで渡して推論を実行しても、レスポンスが返ってくるまでに時間がかかり、サーバーのリソース(GPUメモリ、CUDAコア)が十分に活用されていないように感じられるケースです。エラーメッセージが直接出るわけではないため、パフォーマンス上の「課題」として認識されることが多いです。
# デフォルト設定での実行例。スループットが期待より低い可能性がある。
from vllm import LLM, SamplingParams
llm = LLM(model="meta-llama/Llama-2-7b-chat-hf")
prompts = ["日本の首都は?", "AIとは何ですか?", "Pythonのメリットは?"] * 10 # 30リクエスト
sampling_params = SamplingParams(temperature=0.8, top_p=0.95)
outputs = llm.generate(prompts, sampling_params) # ここで処理がもたつく
原因の解説:vLLMの内部メカニズムとボトルネック
vLLMのスループットが最大化されない主な原因は、以下の3つの要素のバランスが取れていないことにあります。
1. バッチサイズとスケジューリングの限界
vLLMは継続バッチング(Iterative Batching)やPagedAttentionといった技術で効率化を図っていますが、同時に処理できるリクエスト数(バッチサイズ)は、GPUメモリ容量と各リクエストの長さ(コンテキスト長)によって動的に決まります。デフォルト設定では、メモリ割り当てが保守的であるため、利用可能なメモリを最大限に使わず、結果として小さなバッチサイズで処理され、GPUの計算リソースが遊んでしまうのです。
2. ブロックサイズとメモリフラグメンテーション
vLLMはKVキャッシュを「ブロック」単位で管理します。デフォルトのブロックサイズ(16)がモデルのアーキテクチャやプロンプト長に対して最適でない場合、メモリ内部に無駄な隙間(フラグメンテーション)が生じ、効率的なバッチ形成の妨げになります。
3. エンジンパラメータの非最適化
LLMエンジン初期化時のパラメータ(max_num_batched_tokens, max_num_seqs等)は、ハードウェアとワークロードに合わせてチューニングする必要があります。これらの値が低すぎると、処理の並列性に制限がかかり、スループットの天井を作り出してしまいます。
解決方法:スループット最大化のためのステップバイステップチューニング
以下、実践的なチューニング手順を説明します。環境はNVIDIA A100 80GB GPUを想定しています。
ステップ1: ベースラインの計測
まず、現在のスループットを計測します。vLLMには組み込みのベンチマークツールが用意されています。
# ベンチマークの実行(例:Llama-2-7b)
python -m vllm.entrypoints.api_server
--model meta-llama/Llama-2-7b-chat-hf
--port 8000
& # バックグラウンドでサーバー起動
# 別ターミナルでベンチマーククライアントを実行
python -m vllm.benchmarks.serve_throughput
--backend vllm-http
--endpoint http://localhost:8000/generate
--dataset ShareGPT_V3_unfiltered_cleaned_split.json
--num-prompts 1000
# 出力される「Throughput:」の値(tok/s)を記録
ステップ2: メモリ利用率の最大化とブロックサイズの調整
LLMクラスの初期化時に、メモリ関連のパラメータを調整します。最も効果的なのはgpu_memory_utilizationとblock_sizeです。
from vllm import LLM, SamplingParams
# チューニング例
llm = LLM(
model="meta-llama/Llama-2-7b-chat-hf",
gpu_memory_utilization=0.9, # デフォルトは0.9だが、安全なら0.95まで上げられる
block_size=32, # デフォルト16。モデルや長いコンテキストに合わせて8, 16, 32を試す
enable_prefix_caching=True, # プロンプトプレフィックスが共通なら有効化で高速化
swap_space=4, # GPUメモリ不足時、CPUスワップを使うGB数。OOM回避に有効。
)
注意点: gpu_memory_utilizationを高くしすぎると、Out Of Memory (OOM)エラーが発生するリスクがあります。徐々に値を上げてテストしましょう。
ステップ3: エンジンコアパラメータのチューニング
バッチ処理能力を直接制御するパラメータを調整します。これらの値は、GPUメモリ容量とモデルサイズに強く依存します。
llm = LLM(
model="meta-llama/Llama-2-7b-chat-hf",
max_num_batched_tokens=4096, # 単一ステップで処理する最大トークン数。増やすとバッチ効率↑。
max_num_seqs=256, # 同時処理可能なシーケンス数(リクエスト数)。増やすと並列性↑。
max_model_len=4096, # モデルがサポートする最大長。必要以上に長いとメモリを圧迫。
# 推奨: max_num_batched_tokens ≈ max_num_seqs * 平均出力トークン数 を目安に調整
)
ステップ4: 推論パラメータとリクエスト形式の最適化
生成時のパラメータもスループットに影響します。特に、出力トークン数が長くなると処理時間が直線的に増加します。
sampling_params = SamplingParams(
temperature=0.0, # 確定的な出力の方が処理が速い場合がある
top_p=1.0,
max_tokens=512, # 必要最小限の長さに設定。無駄に長くしない。
skip_special_tokens=True, # 不要なトークン処理を省く
)
# プロンプトは可能な限り事前にリスト化し、一括投入する
# 悪い例: forループで1つずつgenerate()を呼ぶ
# 良い例: 全てのプロンプトをリストで一度に渡す
batch_prompts = [f"質問: {q} 回答:" for q in question_list]
outputs = llm.generate(batch_prompts, sampling_params)
ステップ5: 高度な機能とトラブルシューティング
さらに性能を追求する場合や、エラーが発生した場合の対応です。
Tensor並列の利用(マルチGPU):
llm = LLM(model="meta-llama/Llama-2-70b-chat-hf",
tensor_parallel_size=4) # 4枚のGPUでモデルを分割
「CUDA out of memory」エラーへの対処:
# エラーメッセージ例: RuntimeError: CUDA out of memory.
# 1. gpu_memory_utilizationを下げる (0.8など)
# 2. max_num_batched_tokens や max_num_seqs を減らす
# 3. swap_space を増やしてCPUメモリを活用する (例: swap_space=8)
# 4. モデルを量子化して読み込む (例: dtype="half" または quantization="awq")
llm = LLM(model="meta-llama/Llama-2-7b-chat-hf",
dtype="half",
quantization="awq",
gpu_memory_utilization=0.85)
コード例・コマンド例:チューニング済みの完全なスクリプト
以下は、上記のチューニングを全て反映した実践的なバッチ推論スクリプトの例です。
#!/usr/bin/env python3
"""
vLLM 高スループットバッチ推論スクリプト
"""
import time
from vllm import LLM, SamplingParams
def main():
# 1. チューニング済みLLMエンジンの初期化
print("モデルをロード中...")
llm = LLM(
model="meta-llama/Llama-2-7b-chat-hf",
dtype="half",
gpu_memory_utilization=0.92,
block_size=32,
max_num_batched_tokens=8192,
max_num_seqs=512,
swap_space=4,
enable_prefix_caching=True,
)
# 2. サンプリングパラメータの設定
sampling_params = SamplingParams(
temperature=0.1,
top_p=0.9,
max_tokens=256,
skip_special_tokens=True,
)
# 3. バッチプロンプトの準備(シミュレーション)
num_prompts = 1000
prompts = [f"以下の文章を要約してください: {'AIは重要です。' * 50}"] * num_prompts
# 4. 推論実行とパフォーマンス計測
print(f"{len(prompts)} プロンプトの推論を開始...")
start_time = time.time()
outputs = llm.generate(prompts, sampling_params, use_tqdm=True)
elapsed_time = time.time() - start_time
# 5. 結果の集計
total_tokens = sum(len(out.outputs[0].token_ids) for out in outputs)
throughput = total_tokens / elapsed_time
print(f"n=== パフォーマンス結果 ===")
print(f"総処理時間: {elapsed_time:.2f} 秒")
print(f"生成総トークン数: {total_tokens}")
print(f"スループット: {throughput:.2f} tok/s")
print(f"最初の3件の回答:")
for i, out in enumerate(outputs[:3]):
print(f" {i+1}: {out.outputs[0].text[:100]}...")
if __name__ == "__main__":
main()
まとめ・補足情報
vLLMのバッチ推論スループットを最大化するには、「メモリ利用の最大化」「バッチ形成効率の最適化」「ハードウェアリソースとのマッチング」の3点が鍵となります。デフォルト設定は汎用性を重視しているため、特定のモデルとハードウェア環境では必ずしも最適ではありません。
チューニングの際は、以下の順序でパラメータを調整することをお勧めします:
1. gpu_memory_utilization (0.9 → 0.95)
2. block_size (16 → 8, 32)
3. max_num_batched_tokens と max_num_seqs
4. 量子化(dtype="half", quantization="awq")の適用
また、本番環境では、vllm.entrypoints.api_serverを起動し、複数クライアントから非同期リクエストを送ることで、より現実的な負荷でのスループット測定を行うべきです。vLLMは開発が活発なため、定期的に公式GitHubを確認し、新しい最適化機能(例:推論中のバッチ動的スケジューリングの改良)をチェックすることを忘れないでください。適切なチューニングにより、デフォルト設定比で2〜5倍のスループット向上も十分に期待できるでしょう。