5
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

vLLMの高速推論を実施した話【HLE】

Last updated at Posted at 2025-10-09

1.はじめに

松尾研LLM開発コンペ2025に参加した際の知見共有として、vLLMの高速推論についての知見を共有させていただきます。

2. 参加コンペの概要と本記事の立ち位置

参加コンペの概要

今回のコンペは、

オープンモデルの事後学習を通じて

を目指すことを目的としたものです。

本記事の立ち位置

本記事は、以下について記載します。

  • vLLMの基本的なパラメータについて
  • 学習したモデルをvLLMで推論する際の高速化のノウハウ
  • マルチGPU

ただし、HLEの推論についてにフォーカスした説明となります。

3. 推論と評価フロー

はじめに、HLEの推論と評価フローを簡単に紹介します。

推論フロー

  1. 学習したモデルをvLLMでサーバーとしてサーブする
  2. vLLMサーバーに対して、「HLEの問題」と「システムプロンプト」をリクエストする
  3. 学習モデルの回答をレスポンスとして取得する

評価フロー

評価は学習と別のLLMが担当します。理由は、「推論結果が意味的に正しいかを自動で評価したいから」です。

例えば、

回答が「2」だとしたとき、学習モデルが「sqrt(4)、2.0、two」と回答しても意味的には同じなので正解としたい

と考える人が多いかと思います(少なくとも自分はそうです。)。

これを実現するために、評価にLLMを使用する手法をとっています。

上記を実現するフローは下記のようになります。

  1. 評価用のモデルをvLLMでサーブする(論文では、こちらにo3のモデルを使用。今回はAPIキーの使用制限の観点からローカルモデルを使用)
  2. vLLMサーバーに対して、「推論の結果」、「HLEの問題」および「正解」をリクエストする
  3. 評価モデルの評価

4. 実行環境

  • 1ノードあたりのGPU:H200 x 8

  • ノード数:3

5. 推論時の課題と解決方法

課題

課題はシンプルで 「推論にとにかく時間がかかる」 という点です。

時間としては、Qwen3 32Bで8時間程度かかるような状況です。

解決方法

パラメータを変更することで解決が可能です。

具体的には、下記のようにすることで推論時間の短縮を達成しました。

  • max-model-len(モデルが扱える最長トークン数):下げる
  • tensor-parallel-size:上げる
  • 推論側における同時接続数:KV-cacheのログを見ながらなるべく限界まで上げる

各パラメータの詳しい説明は次節で説明したいです

6. 各パラメータの詳細と何故高速化に寄与したか

6.1 max-model-len

vLLMが使用するコンテキスト長となります。

こちらを小さくすることで速度が改善される理由は下記です。

  • vLLMはmax-model-lenで指定したトークン長分のKVキャッシュとして確保する
  • この値を小さくすると、KVキャッシュ用のメモリを節約できる
  • 結果として、より多くのリクエストを並列処理することが出来るようになるため、速度が向上する

という形となります。

このパラメータを決めるためには、以下の2パラメータを決める必要があります。

  • モデル全体で扱うトークン長(max-model-lenとして指定)
    • 入力のトークン長 + max_completion_tokens
  • モデルが出力するトークン長(max_completion_tokensとしてOpenAIクライアントで指定)
    • モデルの入力として入るトークン長から逆算する

まずは、今回のHLEのトークン長()は下記のようなコードで調査しました。

import numpy as np
import transformers
import matplotlib.pyplot as plt
import seaborn as sns
from huggingface_hub import login
from datasets import load_dataset

# google colabを使用する場合に使用
from google.colab import userdata
login(userdata.get('HF_TOKEN'))

SYSTEM_EXACT_ANSWER = "Your response should be in the following format:\nExplanation: {your explanation for your final answer}\nExact Answer: {your succinct, final answer}\nConfidence: {your confidence score between 0% and 100% for your answer}"

SYSTEM_MC = "Your response should be in the following format:\nExplanation: {your explanation for your answer choice}\nAnswer: {your chosen answer}\nConfidence: {your confidence score between 0% and 100% for your answer}"


def encode_question(tokenizer, question, answer_type):
  system_prompt = SYSTEM_EXACT_ANSWER if answer_type == 'exact_match' else SYSTEM_MC
  messages = [
      {"role": 'system', "content": system_prompt},
      {"role": "user", "content": question}
  ]
  text = tokenizer.apply_chat_template(messages, tokenize=False, add_generation_prompt=True, enable_thinking=True)
  model_inputs = tokenizer(text)
  return model_inputs


# == トークン長の計算 ==
# トークナイザーのロード
tokenizer = transformers.AutoTokenizer.from_pretrained("Qwen/Qwen3-32B")
# データセットのロード
dataset = load_dataset("cais/hle", split="test")
# 今回のコンペでは画像が対象外なので除外
dataset = dataset.filter(lambda item: item['image'] == "")
dataset = dataset.to_pandas()
# トークン長を計算
token_length_list = [
    len(encode_question(tokenizer, row['question'], row['answer_type'])['input_ids'])
    for _, row  in dataset.iterrows()
]
dataset['token_length'] = token_length_list

# == 問題のカテゴリごとにトークン長を描画 ==
# 外れ値無しとありで描画する
fig, axes = plt.subplots(ncols=2, figsize=(12, 6))
ax = sns.boxplot(
    data=dataset,
    x='category',
    y='token_length',
    ax=axes[0],
    showfliers=False
)
ax.set_xticklabels(ax.get_xticklabels(), rotation=90)
ax.set_title('w/o outlier')
# 外れ値ありとして描画
ax = sns.boxplot(
    data=dataset,
    x='category',
    y='token_length',
    ax=axes[1],
)
ax.set_xticklabels(ax.get_xticklabels(), rotation=90)
ax.set_title('w outlier')
plt.show()

描画結果は下記です。

image.png

上記の結果から、入力は2,048程度と仮定しました(長すぎる問題は捨てることにした。)。

出力に関しては、max_completion_tokens=14,336として設定したので、max-model-len=14,336 + 2,048 = 16,384としました。

※出力の求め方は少し決め打ち感がありましたので、下記のような方法で実施するとよかったかもしれないです。

  1. max-model-lenをかなり長くする(130,000程度など)
  2. max_completion_tokensも長くする(130,000 - 50,000 = 80,000など)
  3. 実際に出力されたトークン長を計算する

この状況で同時接続数を調整することで8時間程度から1時間程度で推論することが可能となりました。

余談ですが、実際のチャットモデルをホストする際の運用としては、 「ユーザーがやり取りした際のトークン数のログなどからmax-model-lenを適切に指定する」 という方法が有効化と思います。そのため、消費プロンプト数を監視しておくことが重要かと感じました。

6.2 tensor-parallel-size

tensor-parallel-sizeとは

tensor-parallel-sizeは、1つのモデルを何台のGPUに分散して実行するかを指定するパラメータです。

今回は8台のH200を使用していたため、--tensor-parallel-size=8として設定しました。

高速化への効果

実測として、tensor-parallel-sizeを上げることでバッチ推論時の速度が向上しました。

具体的には:

  • バッチサイズを100程度まで増やすと速度が向上 => 1時間15分程度
  • バッチサイズ180付近で最高速度に到達 => 45分程度
  • それ以上増やすと極端に速度が低下

速くなった理由

詳細な原因分析はできていませんが、以下の要因が考えられます:

1. メモリ帯域幅の並列化

  • 1GPUでは1つのメモリ帯域に制約される
  • 8GPUでは8つのメモリ帯域を並列利用できる
  • バッチ推論では大量のデータ転送が発生するため、帯域幅の並列化が効果的に働いた可能性

2. KVキャッシュの分散

  • バッチサイズが大きいとKVキャッシュが大量のメモリを消費
  • 1GPUではモデル重み + KVキャッシュでメモリが逼迫
  • 8GPUに分散することで各GPUのメモリに余裕が生まれ高速化に至った

3. 計算の並列化

  • 大きな行列演算を複数GPUで分割して並列計算することで高速化に至った

バッチサイズ180以上で速度が急落するのは、おそらくメモリ容量の上限に達し、スワップや再計算が発生したためと考えられます。

実用上のポイント

  • バッチサイズは徐々に増やしながら、速度とメモリ使用量を監視する
    • サーバーモードで起動するとログにKV cacheの使用率などが記録されるのでお勧めです
  • nvidia-smiなどでGPUメモリの使用率を確認しながら最適値を探す
  • 今回の環境(32B, H200×8)では、バッチサイズ160-170程度が安定して高速だった

6.3 推論側における同時接続数

vLLMサーバーに対する同時接続数(並列リクエスト数)を調整することも重要です。

KV-cacheのログを確認しながら調整することで、GPUリソースを最大限活用できます。

具体的には下記のようなログが出力されます

# GPU KV cache usageに注目する
Engine 000: Avg prompt throughput: 199.0 tokens/s, Avg generation throughput: 5374.7 tokens/s, Running: 99 reqs, Waiting: 0 reqs, GPU KV cache usage: 12.2%, Prefix cache hit rate: 19.4%

この使用率を見ながら:

  • 使用率が低い(50%以下など) → 同時接続数を増やす
  • 使用率が高い(90%以上など) → 同時接続数を減らす

という調整を行いました。

今回の環境では、同時接続数を調整することでスループットが向上し、全体の推論時間短縮に貢献しました。

余談ですが、下記にログを解析した際のコードとグラフ化した画像を添付しておきます。

import re

import matplotlib.pyplot as plt
import pandas as pd


def parse_vllm_log(log_file_path):
    """
    vLLMログファイルからスループット情報を抽出してDataFrameを作成
    
    Args:
        log_file_path (str): ログファイルのパス
    
    Returns:
        pd.DataFrame: 抽出されたデータのDataFrame
    """
    
    # 正規表現パターン
    pattern = r'INFO (\d{2}-\d{2} \d{2}:\d{2}:\d{2}) \[loggers\.py:\d+\] Engine \d+: Avg prompt throughput: ([\d.]+) tokens/s, Avg generation throughput: ([\d.]+) tokens/s, Running: (\d+) reqs, Waiting: (\d+) reqs, GPU KV cache usage: ([\d.]+)%, Prefix cache hit rate: ([\d.]+)%'
    
    data = []
    
    try:
        with open(log_file_path, 'r', encoding='utf-8') as file:
            for line_num, line in enumerate(file, 1):
                match = re.search(pattern, line)
                if match:
                    timestamp_str = match.group(1)
                    avg_prompt_throughput = float(match.group(2))
                    avg_generation_throughput = float(match.group(3))
                    running = int(match.group(4))
                    waiting = int(match.group(5))
                    gpu_kv_cache_usage = float(match.group(6))
                    prefix_cache_hit_rate = float(match.group(7))
                    
                    data.append({
                        'timestamp': timestamp_str,
                        'avg_prompt_throughput': avg_prompt_throughput,
                        'avg_generation_throughput': avg_generation_throughput,
                        'running': running,
                        'waiting': waiting,
                        'gpu_kv_cache_usage': gpu_kv_cache_usage,
                        'prefix_cache_hit_rate': prefix_cache_hit_rate,
                        'line_number': line_num
                    })
                    
    except FileNotFoundError:
        print(f"ファイルが見つかりません: {log_file_path}")
        return pd.DataFrame()
    except Exception as e:
        print(f"ログ解析中にエラーが発生しました: {e}")
        return pd.DataFrame()
    
    if not data:
        print("該当するログエントリが見つかりませんでした")
        return pd.DataFrame()
    
    # DataFrameを作成
    df = pd.DataFrame(data)
    
    # タイムスタンプを datetime型に変換(年は2024年と仮定)
    df['datetime'] = pd.to_datetime('2024-' + df['timestamp'], format='%Y-%m-%d %H:%M:%S')
    
    # 経過時間を計算(最初のログからの経過秒数)
    df['elapsed_seconds'] = (df['datetime'] - df['datetime'].iloc[0]).dt.total_seconds()
    
    return df


def plot_vllm_metrics(df, exp_name, axes, save_path=None):
    """
    vLLMメトリクスの時系列グラフを作成
    
    Args:
        df (pd.DataFrame): parse_vllm_log()で作成されたDataFrame
        save_path (str, optional): グラフを保存するパス
    """
    
    if df.empty:
        print("グラフ化するデータがありません")
        return

    # Prompt Throughput
    axes[0, 0].plot(df['elapsed_seconds'], df['avg_prompt_throughput'], '-', alpha=0.7, label=exp_name)
    axes[0, 0].set_title('Avg Prompt Throughput')
    axes[0, 0].set_ylabel('tokens/s')
    axes[0, 0].grid(True, alpha=0.3)
    axes[0, 0].set_ylim(0, 1000)
    axes[0, 0].legend()
    
    # Generation Throughput
    axes[0, 1].plot(df['elapsed_seconds'], df['avg_generation_throughput'], '-', alpha=0.7)
    axes[0, 1].set_title('Avg Generation Throughput')
    axes[0, 1].set_ylabel('tokens/s')
    axes[0, 1].grid(True, alpha=0.3)
    
    # GPU KV Cache Usage
    axes[0, 2].plot(df['elapsed_seconds'], df['gpu_kv_cache_usage'], '-', alpha=0.7)
    axes[0, 2].set_title('GPU KV Cache Usage')
    axes[0, 2].set_ylabel('%')
    axes[0, 2].grid(True, alpha=0.3)
    
    # Running Requests
    axes[1, 0].plot(df['elapsed_seconds'], df['running'], alpha=0.7)
    axes[1, 0].set_title('Running Requests')
    axes[1, 0].set_ylabel('reqs')
    axes[1, 0].set_xlabel('Elapsed Time (seconds)')
    axes[1, 0].grid(True, alpha=0.3)
    
    # Waiting Requests
    axes[1, 1].plot(df['elapsed_seconds'], df['waiting'], alpha=0.7)
    axes[1, 1].set_title('Waiting Requests')
    axes[1, 1].set_ylabel('reqs')
    axes[1, 1].set_xlabel('Elapsed Time (seconds)')
    axes[1, 1].grid(True, alpha=0.3)
    
    # Prefix Cache Hit Rate
    axes[1, 2].plot(df['elapsed_seconds'], df['prefix_cache_hit_rate'], alpha=0.7)
    axes[1, 2].set_title('Prefix Cache Hit Rate')
    axes[1, 2].set_ylabel('%')
    axes[1, 2].set_xlabel('Elapsed Time (seconds)')
    axes[1, 2].grid(True, alpha=0.3)
    
    plt.tight_layout()
    
    if save_path:
        plt.savefig(save_path, dpi=300, bbox_inches='tight')
        print(f"グラフを保存しました: {save_path}")

# データのロード
num_worker_100_df = parse_vllm_log('./data/345605/vllm_predict.log')
num_worker_120_df = parse_vllm_log('./data/345280/vllm_predict.log')
num_worker_140_df = parse_vllm_log('./data/349372/vllm_predict.log')
num_worker_160_df = parse_vllm_log('./data/355189/vllm_predict.log')
num_worker_180_df = parse_vllm_log('./data/358420/vllm_predict.log')

fig, axes = plt.subplots(2, 3, figsize=(18, 10))
fig.suptitle('vLLM Performance Metrics Over Time', fontsize=16)

plot_vllm_metrics(num_worker_100_df, axes=axes, exp_name='num worker=100')
plot_vllm_metrics(num_worker_120_df, axes=axes, exp_name='num worker=120')
plot_vllm_metrics(num_worker_140_df, axes=axes, exp_name='num worker=140')
plot_vllm_metrics(num_worker_160_df, axes=axes, exp_name='num worker=160')
plot_vllm_metrics(num_worker_180_df, axes=axes, exp_name='num worker=180')
plt.show()

プロット結果は下記です。

image.png

vLLMを使用している場合は参考になるかもしれません。

7. まとめ

vLLMでの高速推論のために、以下のパラメータ調整が有効でした:

パラメータ 設定方針 効果
max-model-len 必要最小限に抑える KVキャッシュのメモリ節約、並列処理数向上
tensor-parallel-size GPU台数に合わせて設定 バッチ推論の高速化
同時接続数 KVキャッシュログを見て調整 GPUリソースの最大活用

これらの調整により、Qwen3 32Bでの推論時間を8時間から1時間程度に短縮することができました。

実際のチューニングでは:

  1. まずmax-model-lenを適切に設定
  2. tensor-parallel-sizeをGPU台数に設定
  3. KVキャッシュのログを見ながら同時接続数とバッチサイズを調整

という順序で進めるのが効果的でした。


本プロジェクトは、国立研究開発法人新エネルギー・産業技術総合開発機構(以下「NEDO」)の「日本語版医療特化型LLMの社会実装に向けた安全性検証・実証」における基盤モデルの開発プロジェクトの一環として行われます

5
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
5
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?