問題の概要:ローカルLLMの推論速度が遅い、比較できない
ローカル環境でLarge Language Model (LLM) を実行する際、多くの開発者が直面する課題は「推論速度の定量的な評価」です。具体的な問題としては、「同じハードウェアでモデルAとモデルB、どちらが速いのか判断できない」、「プロンプトの変更や量子化ビット数を下げた際の速度向上効果を数値で確認できない」、「公式が発表する性能値(tokens/sec)を自分の環境で再現できない」といったケースが挙げられます。感覚的な「体感速度」ではなく、再現性のある客観的な指標がなければ、モデル選択や最適化の判断材料を失うことになります。
原因の解説:ベンチマーク測定の複雑さと環境依存性
ローカルLLMの推論速度が単純に比較・測定できない主な原因は以下の3点です。
1. 測定指標の多様性
「推論速度」と言っても、測定する指標によって意味が異なります。主要な指標としては、Time to First Token (TTFT)(最初のトークンが生成されるまでの時間)、生成スループット(1秒あたりに生成されるトークン数、tokens/sec)、そしてエンドツーエンドのレイテンシ(プロンプト投入から最終トークン出力までの総時間)があります。用途によって重要視する指標は変わるため、測定対象を明確にしないと意味のある比較はできません。
2. ハードウェアとソフトウェアスタックの依存性
速度はCPU/GPUの性能、メモリ帯域幅、ストレージI/O(特にGGUFモデルのロード時)、そしてLLMを実行するバックエンド(llama.cpp, vLLM, Ollama, Transformersなど)に大きく依存します。また、CUDAバージョンやドライバ、量子化フォーマット(GPTQ, AWQ, GGUF)も性能に直結します。環境が異なれば結果も全く異なるため、同一環境下での比較が原則です。
3. ウォームアップとキャッシュの影響
LLMの推論エンジンは初回実行時にモデルをメモリにロードし、内部キャッシュを構築するため、2回目以降の実行の方が高速になることが一般的です。ベンチマークでは、このウォームアップ実行を無視すると、実際の持続的パフォーマンスを過大評価してしまうリスクがあります。
解決方法:体系的なベンチマーク手法とツール活用
再現性と公平性のあるベンチマークを行うためのステップバイステップの手法を紹介します。
ステップ1: 測定環境の固定と記録
まず、ベンチマークを行うハードウェア・ソフトウェア環境を全て記録します。以下のコマンドで主要情報を取得できます。
# ハードウェア情報の確認 (Linux例)
nvidia-smi # GPU情報(あれば)
lscpu # CPU情報
free -h # メモリ情報
# ソフトウェア情報の確認
python --version
pip list | grep -E "(torch|transformers|llama-cpp-python)" # 主要ライブラリバージョン
ステップ2: ベンチマークツールの選択と実行
手動で計測スクリプトを書くことも可能ですが、標準化されたツールを使うことでより正確で比較可能な結果が得られます。以下に主要ツールを紹介します。
1. llama.cpp 組み込みベンチマーク
llama.cppで実行されるGGUFフォーマットのモデルに対して、標準化されたベンチマークを実行できます。
# 基本的なベンチマーク実行 (例: 7Bパラメータモデル、Q4_K_M量子化)
./main -m ./models/llama-2-7b.Q4_K_M.gguf -n 512 -e -p "Once upon a time"
# より詳細なベンチマークモード (-ngl はGPUレイヤ数、-t はスレッド数)
./main -m ./models/llama-2-7b.Q4_K_M.gguf -n 1024 -e -p "The future of AI is" --repeat_penalty 1.0 -c 2048 -b 512 -t 8 -ngl 40
# 出力例の一部:
# llama_print_timings: load time = XXXX ms
# llama_print_timings: sample time = YYY ms / ZZ runs ( AA ms per token)
# llama_print_timings: prompt eval time = BBB ms / WW tokens ( CC ms per token)
# llama_print_timings: eval time = DDD ms / VV runs ( EE ms per token)
# llama_print_timings: total time = FFF ms
出力から、プロンプト評価(処理)速度、トークン生成速度、総合時間が分かります。eval time と生成トークン数から tokens/sec を計算できます。
2. LM Evaluation Harness (lm-eval-harness)
EleutherAIが開発する評価フレームワークです。推論速度だけでなく、精度タスクも同時に評価できます。
# インストール
pip install lm-evaluation-harness
# シンプルな生成タスクでのベンチマーク実行例
lm_eval --model hf
--model_args pretrained=meta-llama/Llama-2-7b-hf
--tasks hellaswag
--device cuda:0
--batch_size 1
--limit 10 # タスク数を制限して速度測定
実行後、タスクの結果とともに処理時間がレポートされます。
3. カスタム計測スクリプト (Transformersライブラリ使用)
特定のユースケースに合わせた詳細な測定が必要な場合、Pythonでスクリプトを書くのが有効です。
import time
import torch
from transformers import AutoTokenizer, AutoModelForCausalLM
# モデルとトークナイザーのロード
model_name = "meta-llama/Llama-2-7b-chat-hf"
tokenizer = AutoTokenizer.from_pretrained(model_name)
model = AutoModelForCausalLM.from_pretrained(
model_name,
torch_dtype=torch.float16,
device_map="auto"
)
prompt = "日本の首都は"
inputs = tokenizer(prompt, return_tensors="pt").to(model.device)
# ウォームアップ実行(計測対象外)
_ = model.generate(**inputs, max_new_tokens=1)
# 実際の計測:Time to First Token (TTFT)
start_time = time.perf_counter()
with torch.no_grad():
outputs = model.generate(**inputs, max_new_tokens=100)
first_token_time = time.perf_counter() - start_time
print(f"TTFT: {first_token_time:.3f} seconds")
# トークン生成スループットの計測
total_new_tokens = outputs.shape[1] - inputs['input_ids'].shape[1]
start_time = time.perf_counter()
with torch.no_grad():
outputs = model.generate(**inputs, max_new_tokens=100)
total_time = time.perf_counter() - start_time
tokens_per_second = total_new_tokens / total_time
print(f"生成スループット: {tokens_per_second:.2f} tokens/sec")
print(f"総生成時間 ({total_new_tokens} tokens): {total_time:.3f} seconds")
ステップ3: ベンチマーク条件の標準化
公平な比較のため、以下の条件を統一して測定します。
- プロンプト長: 入力トークン数を固定(例: 128トークン)
- 生成トークン数: 出力トークン数を固定(例: 512トークン)
- 生成パラメータ: temperature, top_p, repetition_penalty を固定
- 測定回数: 複数回実行(例: 5回)して平均値を採用し、外れ値を除外
- コンテキスト長: モデルの最大コンテキスト長を明記
コード例・コマンド例:総合的なベンチマークレポート生成
複数のモデルを連続でテストし、結果をCSVに出力する実用的なスクリプト例です。
import csv
import time
import torch
from transformers import AutoTokenizer, AutoModelForCausalLM
def benchmark_model(model_name, prompt, max_new_tokens=100, runs=3):
"""単一モデルのベンチマークを実行"""
print(f"n=== Benchmarking: {model_name} ===")
# ロード(時間計測)
load_start = time.time()
tokenizer = AutoTokenizer.from_pretrained(model_name)
model = AutoModelForCausalLM.from_pretrained(
model_name,
torch_dtype=torch.float16,
device_map="auto",
low_cpu_mem_usage=True
)
load_time = time.time() - load_start
print(f"モデルロード時間: {load_time:.2f}s")
inputs = tokenizer(prompt, return_tensors="pt").to(model.device)
# ウォームアップ
_ = model.generate(**inputs, max_new_tokens=2)
results = []
for i in range(runs):
start_time = time.perf_counter()
with torch.no_grad():
outputs = model.generate(**inputs,
max_new_tokens=max_new_tokens,
do_sample=False)
end_time = time.perf_counter()
total_time = end_time - start_time
total_tokens = outputs.shape[1]
new_tokens = total_tokens - inputs['input_ids'].shape[1]
tokens_per_sec = new_tokens / total_time
results.append({
'run': i+1,
'total_time': total_time,
'new_tokens': new_tokens,
'tokens_per_sec': tokens_per_sec
})
print(f"Run {i+1}: {tokens_per_sec:.2f} tokens/sec, Time: {total_time:.2f}s")
# 平均値計算
avg_tps = sum(r['tokens_per_sec'] for r in results) / runs
avg_time = sum(r['total_time'] for r in results) / runs
print(f"平均: {avg_tps:.2f} tokens/sec, 平均時間: {avg_time:.2f}s")
# メモリ解放
del model, tokenizer
torch.cuda.empty_cache()
return {
'model_name': model_name,
'load_time': load_time,
'avg_tokens_per_sec': avg_tps,
'avg_generation_time': avg_time,
'runs': runs
}
# 複数モデルをベンチマーク
models_to_test = [
"microsoft/phi-2",
"google/gemma-2b",
# 他のモデルを追加
]
prompt_text = "機械学習において、過学習を防ぐための主要な手法は"
all_results = []
for model_name in models_to_test:
try:
result = benchmark_model(model_name, prompt_text, max_new_tokens=50, runs=3)
all_results.append(result)
except Exception as e:
print(f"モデル {model_name} のベンチマークに失敗: {e}")
# 結果をCSVに保存
with open('llm_benchmark_results.csv', 'w', newline='') as csvfile:
fieldnames = ['model_name', 'load_time', 'avg_tokens_per_sec', 'avg_generation_time', 'runs']
writer = csv.DictWriter(csvfile, fieldnames=fieldnames)
writer.writeheader()
for res in all_results:
writer.writerow(res)
print("nベンチマーク完了。結果を 'llm_benchmark_results.csv' に保存しました。")
まとめ・補足情報
ローカルLLMの推論速度ベンチマークは、モデル選択、ハードウェア投資判断、プロンプトエンジニアリングの効果測定において不可欠な作業です。重要なポイントを以下にまとめます。
ベンチマーク実施のベストプラクティス
1. 目的に合わせた指標の選択: 対話型アプリケーションではTTFTが、バッチ処理ではスループットが重要です。
2. 環境変数の制御: 測定中は他の重いプロセスを停止し、CPU/GPUのクロックブーストが安定するよう数回のウォームアップを行います。
3. 量子化の影響測定: 同じモデルでも量子化ビット数(4bit, 8bit, 16bit)やフォーマット(GGUF, GPTQ)で速度と精度は大きく変わるため、トレードオフを計測します。
4. コンテキスト長の影響: 長いコンテキストを扱う場合、コンテキスト長を増やした時の速度低下(KVキャッシュの影響)も測定すべきです。
よくある落とし穴とエラーメッセージ
エラー: “CUDA out of memory”
→ ベンチマーク中に発生する場合、max_new_tokensを減らすか、バッチサイズを1に設定し、torch.cuda.empty_cache()を各測定前に実行します。
問題: 測定結果のバラつきが大きい
→ 電力設定、サーマルスロットリング、バックグラウンドプロセスの影響が考えられます。測定回数を増やし(10回以上)、最高値と最低値を除外した中央値を使う方法もあります。
注意点: 公式ベンチマークとの差異
→ 公式発表値は最適化された専用環境(高性能GPU、NVLink接続、カスタムカーネル)で測定されていることが多いです。ローカル環境ではそれより低い値になるのが一般的です。
最終的に、ベンチマークは絶対的な性能値というより、同一環境内での相対比較ツールとして活用するのが現実的です。定期的にベンチマークを実行し、ドライバやライブラリの更新による性能変化も把握することで、ローカルLLM開発の生産性を大きく向上させることができます。