最近、「Hugging Faceのリーダーボードでは高性能だったのに、いざ自社サーバーで動かしてみるとレスポンスが遅くて使い物にならない」という課題に直面するケースが多く見受けられます。新しいLLM(大規模言語モデル)が次々と公開される昨今、AI開発の現場では常に「どのモデルを採用すべきか」という選択が求められます。
しかし、公開されているベンチマークスコアは、あくまで特定の条件下での結果に過ぎません。実務環境——特定のアクティビティ、制限されたGPUリソース、独自のドメインデータ——において、そのモデルが本当に力を発揮するかどうかは、実際に測ってみるまで分からないのが実情です。
そこで今回は、既存のベンチマークツールに頼るだけでなく、PythonとTransformersを用いて、自社の要件に合わせた「自作ベンチマークスクリプト」を構築する手法について、技術的な観点から深掘りして解説します。
ブラックボックス化された評価ツールではなく、計測ロジックそのものを自分で実装することで、推論速度(レイテンシ)、スループット、そしてVRAM消費量の正確なトレードオフを把握できるようになります。これは、エンジニアとして信頼性の高いAIプロダクトを設計するために不可欠なスキルです。
具体的なコード例を交えながら、計測のためのスクリプト構築手順を見ていきます。
なぜ「自作ベンチマーク」が意思決定に不可欠なのか
AI開発の現場において、モデル選定はプロジェクトの成否を分ける重要なフェーズです。しかし、多くのケースで公開されている「Open LLM Leaderboard」などのスコアのみを頼りに意思決定が行われています。ここに大きな落とし穴があります。
Open LLM Leaderboardと実務パフォーマンスの乖離
公開されているリーダーボードは、モデルの「一般的な知識能力」や「推論能力」を測るための標準化されたテストセット(MMLUやGSM8Kなど)に基づいています。これらはモデルの「賢さ」を比較するには有用ですが、「実運用での速さ」や「リソース効率」については何も語ってくれません。
例えば、スコアが数ポイント高いモデルであっても、推論にかかる時間が2倍になれば、リアルタイム性が求められるチャットボットサービスでは採用できません。また、パラメータ数が同じでも、アーキテクチャの違い(例えばMixture of ExpertsかDenseモデルか)によって、GPUメモリの消費挙動や並列処理効率は大きく異なります。
汎用ベンチマークが見落とす「自社特有の制約条件」
開発環境は千差万別です。最新のNVIDIA H100を使える環境もあれば、コスト制約からT4やA10G、あるいはコンシューマー向けのRTX 4090で推論サーバーを構築しなければならない環境もあるでしょう。
汎用的なベンチマーク結果は、往々にしてハイエンドな環境で計測されています。しかし、メモリ帯域幅やCUDAコア数が異なる環境では、ボトルネックになる箇所が変わります。例えば、メモリ帯域が狭いGPUでは、計算量よりもデータの転送速度が律速となり、小さなモデルでも意外と速度が出ないという現象が起こり得ます。
自社のインフラ環境で実際に動かし、その挙動を数値化することなしに、最適なモデルを選定することは不可能です。
ブラックボックス化を防ぐ評価プロセスの透明性
既存のベンチマークツール(例えば lm-evaluation-harness など)は非常に優秀ですが、内部でどのような前処理が行われているか、プロンプトがどのように構成されているかが完全には把握しづらい場合があります。
自作スクリプトを組む最大のメリットは、計測プロセスが完全に透明になることです。どのタイミングでタイマーを回し始めたか、メモリの計測範囲はどこか、入力トークン長は正確にいくつだったか。これら全てをコントロール下に置くことで、結果に対する「なぜ?」を論理的に説明できるようになります。
エンジニアとして、ステークホルダーに「なぜこのモデルを選んだのか」を説明する際、「ベンチマークスコアが高かったから」という他人の言葉ではなく、「我々の環境とデータで検証した結果、コスト対効果が最も高かったから」という客観的なデータで語れるようにすることが重要です。
計測指標の定義:エンジニアが見るべき3つの次元
コードを書く前に、何を測るべきか、その指標(KPI)を明確に定義します。漠然と「速さ」と言っても、用途によって見るべき数字が異なります。
パフォーマンス指標:TTFT (Time To First Token) と TPS (Tokens Per Second)
リアルタイムアプリケーションにおいて最も重要なのが TTFT (Time To First Token) です。これは、ユーザーがリクエストを送ってから、最初の文字(トークン)が表示されるまでの時間です。人間は0.2秒〜1秒程度の遅延で「遅い」と感じ始めると言われています。ストリーミング生成を行う場合、このTTFTがUX(ユーザー体験)の質を決定づけます。
一方、バッチ処理や要約タスクなど、結果がすべて生成されるまでユーザーを待たせないケースでは、TPS (Tokens Per Second)、つまり1秒間に何トークン生成できるかというスループットが重要になります。
この2つはトレードオフの関係になることがあります。バッチサイズ(一度に処理するリクエスト数)を上げればTPSは向上しますが、個々のリクエストのTTFTは悪化する傾向にあります。自作スクリプトでは、この相関関係を可視化します。
リソース指標:ピークVRAM使用量とGPU稼働率
クラウドでGPUインスタンスを借りる場合、VRAM容量はコストに直結します。24GBのVRAMに収まるモデルなら安価なインスタンスで済みますが、25GB必要なら、上位の高価なインスタンスを借りるか、複数枚のGPUを用意する必要があります。
ここで重要なのは、モデルの重み(Weights)だけでなく、推論時のKVキャッシュや計算用バッファを含めた「ピーク時のメモリ使用量」を測ることです。特に長いコンテキスト(入力文章)を扱う場合、KVキャッシュがVRAMを圧迫し、OOM(Out Of Memory)エラーを引き起こす原因となります。
品質指標:特定ドメインデータに対する正答率と類似度
最後に、出力の品質です。汎用的な「日本語能力」ではなく、自社の業務データ(例えば、社内マニュアルに基づくQ&Aや、特定のフォーマットへのJSON変換など)に対して、どれだけ正確に答えられるかを測ります。
これら3つの次元(速度、リソース、品質)を総合的に評価して初めて、ビジネスに適したモデル選定が可能になります。
PythonとTransformersによる計測環境の構築
それでは、実際に計測環境の構築手順を解説します。正確なベンチマークを行うためには、ベースとなるライブラリの選定と環境設定が重要です。
必須ライブラリの選定とバージョン管理
今回は、デファクトスタンダードである transformers ライブラリを中心に、以下の構成を使用します。
- PyTorch: 深層学習フレームワークの基盤。CUDAのバージョンと整合性が取れていることが必須です。
- Transformers: モデルのロードと推論実行に使用。
- Accelerate: 大規模モデルのロードや、マルチGPU環境での実行を簡素化するために使用します。
- Optimum: (オプション)ONNX RuntimeやTensorRTなどの最適化ランタイムを使用する場合に必要ですが、今回はベースライン計測のためPyTorchネイティブを中心とします。
# 必要なライブラリのインストール例
# pip install torch transformers accelerate numpy pandas
import torch
import time
import numpy as np
from transformers import AutoModelForCausalLM, AutoTokenizer
# バージョン確認(ログに残すことが重要)
print(f"PyTorch version: {torch.__version__}")
print(f"CUDA version: {torch.version.cuda}")
if torch.cuda.is_available():
print(f"GPU: {torch.cuda.get_device_name(0)}")
再現性を担保するための乱数シードと環境固定
ベンチマークで最も恐れるべきは「計測のたびに結果が変わる」ことです。LLMの生成プロセスにはランダム性が含まれるため、これを制御する必要があります。
import random
import os
def set_seed(seed=42):
random.seed(seed)
np.random.seed(seed)
torch.manual_seed(seed)
torch.cuda.manual_seed_all(seed)
# 決定論的な動作を強制する場合(速度は若干落ちる可能性があります)
torch.backends.cudnn.deterministic = True
torch.backends.cudnn.benchmark = False
set_seed(42)
また、GPUの状態も重要です。他のプロセスがGPUを使っていないか nvidia-smi コマンドなどで事前に確認し、可能な限りクリーンな状態で計測を開始してください。
計測用ダミーデータと実データの準備
まずは純粋な速度を測るために、固定長のダミーデータを用意します。これにより、入力トークン長による速度変化を厳密に測定できます。
def generate_dummy_prompt(tokenizer, num_tokens):
# 語彙サイズの中からランダムにトークンIDを選んでプロンプトを作成
input_ids = torch.randint(0, tokenizer.vocab_size, (1, num_tokens)).to("cuda")
return input_ids
実装フェーズ1:推論レイテンシとスループットの精密計測
ここからが本記事の核となる実装部分です。PythonでGPUの処理時間を正確に測るには、少しコツがいります。
CUDAイベントを使用した正確な時間計測の実装
Python標準の time.time() はCPU上の時間を計測します。しかし、CUDA(GPU)の処理はCPUと非同期に行われます。つまり、Pythonのコードが次の行に進んでいても、GPU上ではまだ計算が続いている場合があるのです。
正確な実行時間を計測するには、torch.cuda.Event を使用し、さらに torch.cuda.synchronize() でCPUとGPUの同期を取る必要があります。
def measure_inference_speed(model, tokenizer, input_ids, max_new_tokens=100):
# 計測用のCUDAイベントを作成
start_event = torch.cuda.Event(enable_timing=True)
end_event = torch.cuda.Event(enable_timing=True)
# 生成パラメータの設定
gen_kwargs = {
"max_new_tokens": max_new_tokens,
"do_sample": False, # ベンチマークでは決定論的にするためGreedy Search推奨
"pad_token_id": tokenizer.pad_token_id,
}
# 計測開始前にGPUを同期
torch.cuda.synchronize()
start_event.record()
# 推論実行
with torch.no_grad():
output = model.generate(input_ids, **gen_kwargs)
# 計測終了を記録し、同期を待つ
end_event.record()
torch.cuda.synchronize()
# 経過時間をミリ秒で取得
elapsed_time_ms = start_event.elapsed_time(end_event)
elapsed_time_sec = elapsed_time_ms / 1000
# 生成されたトークン数を計算(入力分を引く)
generated_tokens = output.shape[1] - input_ids.shape[1]
# TPS (Tokens Per Second) の算出
tps = generated_tokens / elapsed_time_sec
return elapsed_time_sec, tps, generated_tokens
このコードスニペットの重要な点は、start_event.record() と end_event.record() の間で処理を挟み、最後に必ず torch.cuda.synchronize() を呼んでいる点です。これを忘れると、計測時間が極端に短く(あるいは不正確に)なり、誤った判断につながります。
GPUのWarmupと計測ループの設計
GPUには「コールドスタート」と呼ばれる現象があります。最初の数回の実行は、CUDAカーネルのコンパイルやメモリ確保のオーバーヘッドにより、通常よりも時間がかかります。
正確なベンチマークのためには、本計測の前に数回、空回し(Warmup)を行う必要があります。
# Warmup
print("Warming up...")
for _ in range(3):
measure_inference_speed(model, tokenizer, input_ids, max_new_tokens=20)
# 本計測
print("Benchmarking...")
latencies = []
tps_list = []
for _ in range(10):
time_sec, tps, _ = measure_inference_speed(model, tokenizer, input_ids, max_new_tokens=100)
latencies.append(time_sec)
tps_list.append(tps)
print(f"Average Latency: {np.mean(latencies):.4f} sec")
print(f"Average TPS: {np.mean(tps_list):.2f} tokens/sec")
generate()関数のフックによるトークン生成速度の可視化
さらに踏み込んで、TTFT(最初の1トークンが出るまでの時間)を計測するには、generate 関数内部の挙動を追う必要がありますが、簡易的には Streamer クラスを使って計測することが可能です。
Hugging Face Transformersには TextIteratorStreamer が用意されており、これを使うことでトークンが生成されるたびにコールバックを受け取ることができます。最初のトークンが到着した時刻と、リクエスト開始時刻の差分を取れば、TTFTが算出できます。
実装フェーズ2:メモリ消費量のリアルタイムモニタリング
速度と同じくらい重要なのが、メモリ(VRAM)の消費量です。これを把握せずにデプロイすると、ユーザーが増えた瞬間にサーバーがダウンするリスクがあります。
PyTorchのメモリ管理APIを活用したピークメモリ計測
PyTorchには、GPUメモリの使用状況を詳細に追跡するAPIが備わっています。特に torch.cuda.max_memory_allocated() は、ある時点からのピークメモリ使用量を返してくれるため、ベンチマークに最適です。
def measure_peak_memory(model_func, *args, kwargs):
# メモリ統計をリセット
torch.cuda.reset_peak_memory_stats()
torch.cuda.empty_cache()
# 実行前のメモリ
pre_memory = torch.cuda.memory_allocated() / (1024 3) # GB単位
# 関数の実行
result = model_func(*args, kwargs)
# ピークメモリの取得
max_memory = torch.cuda.max_memory_allocated() / (1024 3) # GB単位
return result, max_memory, pre_memory
# 使用例
_, peak_mem, base_mem = measure_peak_memory(
measure_inference_speed, model, tokenizer, input_ids
)
print(f"Base Memory (Model Weights): {base_mem:.2f} GB")
print(f"Peak Memory (Inference): {peak_mem:.2f} GB")
print(f"Memory Overhead (KV Cache + Activation): {peak_mem - base_mem:.2f} GB")
モデルロード時 vs 推論時のメモリ変動の追跡
上記のコードを実行すると、「モデルをロードした直後のメモリ量」と「推論を実行して一番メモリを使った瞬間のメモリ量」の差分が分かります。この差分(オーバーヘッド)は、主に KVキャッシュ と計算用の一時バッファによるものです。
コンテキスト長(入力トークン数 + 生成トークン数)が長くなればなるほど、このKVキャッシュは肥大化します。自作スクリプトでは、入力トークン数を 512, 1024, 2048, 4096... と段階的に増やしながら計測を行うことで、「自社のサーバーで扱える最大コンテキスト長」 の限界点を見極めることができます。
量子化(Quantization)適用時の削減効果測定
最近では、モデルを4bitや8bitに量子化してVRAMを節約する手法が一般的です(BitsAndBytesなど)。この自作スクリプトを使えば、量子化によって実際にどれくらいメモリが削減され、その代償としてどれくらい速度が低下する(あるいは向上する)のかを、定量的に評価できます。
「理論上は半分になるはず」ではなく、「実測値として12GBから6.5GBに減った」というデータこそが、インフラ選定の確かな根拠となります。
実装フェーズ3:自社データセットを用いた精度評価の統合
速度とメモリがクリアできたら、最後は「品質」です。一般的なリーダーボードのスコアではなく、自社のユースケースに適した評価を行います。
カスタムデータセットの読み込みとプロンプト構築
例えば、社内ドキュメントに基づくRAG(検索拡張生成)システムを作る場合、評価データセットは「質問」と「理想的な回答(Ground Truth)」のペアになります。
import pandas as pd
# 評価データの読み込み(例: CSVファイル)
# columns: ['question', 'reference_answer']
eval_data = pd.read_csv("my_company_qa_dataset.csv")
def build_prompt(question):
# 実際に使用するプロンプトテンプレートを適用
return f"以下の質問に簡潔に答えてください。\n質問: {question}\n回答:"
正解データとの比較ロジック
生成された回答と、正解データを比較します。完全一致(Exact Match)は生成タスクでは稀なので、ここでは簡易的な指標として、キーワードの含有率や、文字レベルの類似度(Levenshtein距離など)を使用することが多いです。より高度な評価には、LLM自体を審査員にする「LLM-as-a-Judge」という手法もありますが、まずはベースラインとしてPythonスクリプトで完結する評価指標を実装します。
from difflib import SequenceMatcher
def calculate_similarity(a, b):
return SequenceMatcher(None, a, b).ratio()
results = []
for index, row in eval_data.iterrows():
input_text = build_prompt(row['question'])
input_ids = tokenizer(input_text, return_tensors="pt").input_ids.to("cuda")
# 推論実行
output_ids = model.generate(input_ids, max_new_tokens=100)
generated_text = tokenizer.decode(output_ids[0], skip_special_tokens=True)
# プロンプト部分を除去して回答のみ抽出
answer_text = generated_text[len(input_text):]
# スコア計算
score = calculate_similarity(answer_text, row['reference_answer'])
results.append(score)
print(f"Average Accuracy Score: {np.mean(results):.4f}")
結果の自動ログ出力とCSVレポート生成
これらの計測結果(速度、メモリ、精度)は、ログとして保存することが重要です。実験条件(モデル名、量子化設定、日時)と共にCSVファイルに出力しておけば、後でExcelやPandasを使って比較分析が容易になります。
result_log = {
"model_name": "llama-3-8b-instruct",
"quantization": "4bit",
"avg_latency": np.mean(latencies),
"avg_tps": np.mean(tps_list),
"peak_memory_gb": peak_mem,
"accuracy_score": np.mean(results),
"timestamp": time.strftime("%Y%m%d-%H%M%S")
}
# ここでCSVに追記する処理を実装
スクリプトの運用:CI/CDへの組み込みと継続的評価
ベンチマークスクリプトが完成したら、それを個人のPCの中だけで眠らせておくのはもったいないことです。開発プロセスの一部として組み込むことで、チーム全体の資産になります。
MLOpsの一環としてのベンチマーク
開発が進むにつれて、プロンプトのテンプレートが変わったり、使用するライブラリのバージョンが上がったりします。そのたびに手動で計測するのは手間です。
GitHub ActionsやGitLab CIなどのCI/CDツールと連携させ、コードがマージされるたび、あるいは毎晩特定の時間に、このベンチマークスクリプトを自動実行するフローを構築することが推奨されます。これにより、「先週のアップデートで急に推論速度が落ちた」といったリグレッション(退行)を即座に検知できます。
経時的な性能変化の追跡
自動計測されたデータをGrafanaなどのダッシュボードで可視化すれば、パフォーマンスの推移が一目瞭然になります。「精度を1%上げるために、速度が20%犠牲になったが、これは許容範囲か?」といった高度な議論が、チーム内で自然と行われるようになるでしょう。
次のステップ:推論エンジンの最適化への拡張
今回はPyTorchネイティブでの実装を紹介しましたが、実運用では vLLM や Text Generation Inference (TGI) といった、より高度な推論エンジンを使用することが一般的になりつつあります。
今回作成したスクリプトの考え方(入力データの準備、計測のタイミング、指標の定義)は、エンジンが変わってもそのまま通用します。エンジンのAPIを叩く部分だけを差し替えれば、vLLMとPyTorchの性能比較も簡単に行えます。
まとめ
今回は、PythonとTransformersを用いて、自社環境に最適化されたLLMベンチマークスクリプトを構築する方法について解説しました。
- 汎用スコアを過信せず、自社のインフラとデータで測ることの重要性
- TTFT、TPS、VRAMという3つの重要指標の定義
- CUDA同期処理を含めた正確な計測実装テクニック
- 精度評価を含めた総合的なパイプラインの構築
これらを実践することで、「なんとなく良さそうなモデル」ではなく、「数値に基づいた確信のあるモデル」が残るはずです。計測はエンジニアリングの基本であり、改善のスタートラインです。
もちろん、今回紹介したのは基礎的なベンチマーク手法です。実際のプロジェクトでは、さらに深い最適化(カーネルレベルのチューニングや、分散推論の構成など)が必要になる場面も多々あります。
正確な計測手法を確立し、信頼性の高いAI開発を進めていくことが重要です。
コメント