【vLLM】バッチ推論のスループット最大化!よくあるエラーとチューニング手法

問題の概要: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_utilizationblock_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_tokensmax_num_seqs
4. 量子化(dtype="half", quantization="awq")の適用

また、本番環境では、vllm.entrypoints.api_serverを起動し、複数クライアントから非同期リクエストを送ることで、より現実的な負荷でのスループット測定を行うべきです。vLLMは開発が活発なため、定期的に公式GitHubを確認し、新しい最適化機能(例:推論中のバッチ動的スケジューリングの改良)をチェックすることを忘れないでください。適切なチューニングにより、デフォルト設定比で2〜5倍のスループット向上も十分に期待できるでしょう。

この記事は役に立ちましたか?