Colab T4 と CPU で 9つの組み合わせ を計測。速度も VRAM もエンジンで大きく変わり、計測ノイズにも散々ハマった
ハイライト
- 速度はエンジンで激変する: 同じ4bit量子化(Q4)でも、エンジンが違うだけでdecode速度に 2.60倍 の差(ONNX CUDA: 115.87 tok/s vs llama.cpp: 44.64 tok/s)。量子化レベルもまたいだ最大差は 5.29 倍(115.87 vs llama.cpp BF16 21.91)。
- VRAMの「予約」と「実使用」の罠: vLLMは約11.7GBを固定予約する(設定で可変)のに対し、llama.cpp (Q4) は実使用1.81GB。エンジンの設計思想で要求環境が全く異なる。
- vLLMは「CUDAグラフ有効化」が鍵: 適切に設定すれば速度が約2倍(52→92 tok/s)に伸びるポテンシャルがある。
-
セッション間で速度が最大 1.8 倍ブレた。クロック固定 (
-lgc/-lmc) で改善を試みたが原因は特定しきれず。Colab 単発の数値は信用せず複数セッションの中央値で見るべき、という教訓が残った - 1.2Bサイズのリアルな限界: エンジン間の出力品質に大差はないが、「すもももももももものうち」の果物を数えられないなど、小型モデル特有の壁がある。
- 個人的には汎用1.2Bモデルをそのまま使うより、ファインチューニングして用途特化させた方が現実的
はじめに
エッジ推論用に小型モデルを使いたい用途で、Liquid AI の LFM2.5-1.2B-Instruct を vLLM、llama.cpp、ONNX Runtime の3エンジンで動かして、Colab T4 と Colab CPU で計測してみました。
最初は単純な速度比較のつもりが、計測してみると意外と落とし穴だらけで、最終的に 9 個のデータポイント までやることになったので、知見を共有します。
計測対象
| 項目 | 値 |
|---|---|
| モデル | LiquidAI/LFM2.5-1.2B-Instruct (1.17B params, dense) |
| エンジン | vLLM 0.22.1 / llama.cpp 0.3.15 / ONNX Runtime 1.26 |
| GPU | Colab T4 (CUDA 13.0 driver / 70W cap) |
| CPU | Colab CPU runtime (Intel Xeon @ 2.20GHz × 2 logical) |
| プロンプト | 短答 (P1), 推論トラップ (P2/P3), 長文要約 (P4_4k/P4_8k), 純デコード (P5), ツール呼び出し (P6) |
各エンジンの「モデルをロードする核」はこれだけ。あとは生成を回すだけです。
# llama.cpp (GGUF) … n_gpu_layers=0 にすれば CPU 推論
from llama_cpp import Llama
llm = Llama(model_path="LFM2.5-1.2B-Instruct-Q4_K_M.gguf", n_gpu_layers=99)
# vLLM (safetensors) … enforce_eager=False で CUDAグラフ有効
from vllm import LLM
llm = LLM(model="LiquidAI/LFM2.5-1.2B-Instruct", dtype="float16", enforce_eager=False)
# ONNX Runtime … providers を CPUExecutionProvider にすれば CPU 推論
import onnxruntime as ort
sess = ort.InferenceSession("onnx/model_q4.onnx", providers=["CUDAExecutionProvider"])
計測の方針
各エンジンで2系統に分けて測りました。
- 系統A(挙動測定): 実サンプリング・自然停止、N_ROUNDS=5、TTFT を見る
- 系統B(速度プローブ): greedy + EOS抑制で 256トークン強制生成、純デコード tok/s を厳密比較
系統B は「全エンジンでちょうど256トークン=完全に同じ仕事量」にするのがキモ。エンジンごとに EOS 抑制の書き方が違います:
# vLLM : SamplingParams(temperature=0, max_tokens=256, ignore_eos=True)
# llama.cpp : create_completion(..., logit_bias={eos_id: -1e9}) # EOSの確率を潰す
# ONNX : set_search_options(min_length=入力長+256, max_length=入力長+256)
これは 「同じプロンプトでも出力長が違うと比較できない」 問題を避けるためです。speed probe で 完全に同じ仕事量 で各エンジンを測る、というのがポイント。
結果1: 速度ランキング
9 組み合わせの DECODE_PROBE 純デコード tok/s で並べると(各値は n=5 試行の中央値。平均ではない):
| 順位 | エンジン | 量子化 | ハード | decode tok/s |
|---|---|---|---|---|
| 🥇 1 | ONNX Runtime CUDA EP | q4 | T4 | 115.87 |
| 🥈 2 | vLLM (CUDAグラフ有効) | fp16 | T4 | 62.96 ※ |
| 🥉 3 | ONNX Runtime CUDA EP | fp16 | T4 | 59.06 |
| 4 | llama.cpp | Q4_K_M | T4 | 44.64 |
| 5 | llama.cpp | BF16 | T4 | 21.91 |
| 6 | llama.cpp | Q4_K_M | CPU | 8.33 |
| 7 | llama.cpp | BF16 | CPU | 3.04 |
| 8 | ONNX Runtime CPU EP | q4 | CPU | 2.19 |
| 9 | ONNX Runtime CPU EP | fp16 | CPU | 2.11 |
※ vLLM はセッション間のブレが極端に大きく、28.93〜91.62 tok/s(最良 91.62、クロック固定セッションは 62.25〜64.39)。表の 62.96 は全セッションの中央値。原因は特定できず(後述の「計測の落とし穴」参照)。他8エンジンは n=5 の変動係数 数%以内で安定。
同一量子化で揃えたエンジン比較(こちらが公平)
異なる量子化レベルを混ぜると「エンジンの差」ではなく「量子化の差」も入ってしまうので、同じ量子化で並べた方がフェアです。
4bit (q4 / Q4_K_M) 同士:
| エンジン | ハード | decode tok/s |
|---|---|---|
| ONNX Runtime CUDA | T4 | 115.87 |
| llama.cpp | T4 | 44.64 |
| llama.cpp | CPU | 8.33 |
| ONNX Runtime CPU | CPU | 2.19 |
→ 同じ4bit・同じT4で ONNX が llama.cpp の 2.60 倍。
fp16 / BF16 同士:
| エンジン | ハード | decode tok/s |
|---|---|---|
| ONNX Runtime CUDA | T4 | 59.06 |
| vLLM (graphs) | T4 | 62.96(28.93〜91.62 と不安定) |
| llama.cpp (BF16) | T4 | 21.91 |
| llama.cpp (BF16) | CPU | 3.04 |
| ONNX Runtime CPU | CPU | 2.11 |
→ 16bit 同士では、vLLM(中央値 62.96) と ONNX CUDA fp16(59.06) が拮抗。vLLM は最良時 91.62 まで出るがブレが大きく、「vLLM が安定して最速」とは言えなかった。
発見1: ONNX CUDA q4 が(数値上は)最速
正直、計測前は 「vLLM が最速だろう」 と予想していました。ところが ONNX Runtime CUDA EP に4bit専用カーネル(MatMulNBits)があり、T4 のメモリ帯域を効率的に節約 → 115.87 tok/s で安定してトップ(n=5 の変動係数 数%以内)でした。
ただし注意点:
- ONNX CUDA q4 は4bit量子化されているので、fp16 で動かした vLLM/ONNX との単純なエンジン比較にはならない(量子化の効果が混ざる)
- vLLM で同じ4bit比較をしたかったが、LFM2.5-1.2B-Instruct の vLLM 対応量子化リポジトリ(AWQ/GPTQ等)が公式に無く、今回は検証できず(bitsandbytes NF4 は後述の通り Colab 環境で動かず断念)
なので「ONNX が vLLM より速い」と結論づけるのは早計で、正確には 「4bit ONNX が fp16 vLLM より数値上は速かった」 です。
発見2: vLLM は CUDAグラフ有効化で速くなるが、ブレが大きく定量化しきれず
最初 enforce_eager=True (CUDAグラフ無効) で測ったら 52.01 tok/s。enforce_eager=False (CUDAグラフ有効) にしたセッションで最良 91.62 tok/s が出たので「2倍だ」と思ったのですが、同じ graphs 設定で別セッションを回すと 28.93〜64.39 とバラバラでした(後述の通り原因特定できず)。
CUDAグラフが効く理屈(小型モデルの batch=1 は per-token の CPU側カーネル起動オーバーヘッドが律速なので、グラフ化でそれを潰すと効く)は正しいはずですが、Colab T4 のセッションノイズが大きすぎて「何倍速くなった」と定量化できませんでした。vLLM を測るなら CUDAグラフは有効にすべき、という定性的結論に留めます。
発見3: llama.cpp は GPU では他エンジンの 1/3〜1/2
llama.cpp は CPU 推論で有名ですが、GPU 推論では ONNX/vLLM に大きく負けます。GGML CUDA カーネルは Ampere 以降の TensorCore 前提の最適化が中心 → T4 (Turing, sm_75) では旧式パスに落ちるのが大きいと推測。
発見4: CPU エンジンで 4 倍差
同じ Colab CPU (Xeon × 2) で:
- llama.cpp Q4_K_M = 8.33 tok/s
- ONNX Runtime CPU q4 = 2.19 tok/s
→ 3.80 倍の差。llama.cpp の GGML CPU カーネルは AVX2/AVX-512 専用にチューンされており、K-quants の dequant が効率的。「CPU で LLM 動かすなら llama.cpp 一択」 が定量的に裏付けられました。
結果2: VRAM / RAM 使用量
速度と並んで見てほしいのがメモリ使用量です。ただし「予約」と「実使用」を区別する必要があります。
T4 GPU 上の VRAM(peak_vram)
| エンジン | 量子化 | VRAM (MiB) | VRAM (GiB) | 種別 |
|---|---|---|---|---|
| llama.cpp | Q4_K_M | 1856 ⭐ | 1.81 | 実使用 |
| llama.cpp | BF16 | 3242 | 3.17 | 実使用 |
| ONNX Runtime CUDA | q4 | 3648 | 3.56 | 実使用 |
| ONNX Runtime CUDA | fp16 | 4674 | 4.56 | 実使用 |
| vLLM | fp16 | 11986(固定) | 11.71 | 予約(モデル実体 2.2GiB) |
vLLM の VRAM は「固定の予約」(変動しない)
ここは誤解しやすいポイント。vLLM の peak_vram は セッション内で完全に一定(n=5 で min=max)、セッション間でも 11866〜11986 MiB とほぼ動きません(速度は1.8倍ブレたのに VRAM は動かない)。犯人はこの引数1つ:
# この 0.85 が「11986 MiB 固定予約」の正体(GPUメモリの85%をKVキャッシュ用に先取り)
LLM(model="LiquidAI/LFM2.5-1.2B-Instruct", gpu_memory_utilization=0.85)
# 下げれば予約VRAMも比例して減る。モデル本体は 2.2 GiB しかない
-
gpu_memory_utilization=0.85で GPUメモリの85%を起動時に先取り予約(KVキャッシュ用) - 推論内容によらない固定値
-
設定で可変(
gpu_memory_utilizationを下げれば予約VRAMも比例して減る) -
実際のモデル本体は 2.2 GiB(vLLM の
Model loading took 2.2 GiBログより)
つまり「vLLM は 12GiB 食う」と読むのは半分間違いで、正しくは 「12GiB を KVキャッシュ用に予約する設定になっている」。長コンテキストやバッチ並列を活かす用途ではこの予約が活きますが、シングルリクエストの対話用途では予約しすぎなので gpu_memory_utilization を下げるべき。
実使用量で見ると llama.cpp Q4 が最小
予約方式の vLLM を除いて「実際にどれだけ使うか」で見ると、llama.cpp Q4 = 1856 MiB (1.81 GiB) が最小。LFM2.5-1.2B なら 4GB の弱いGPUでも余裕で載ります。エッジGPU(RTX 3050 Mobile等)を意識するなら llama.cpp Q4。
CPU 上の RAM 使用量
| エンジン | 量子化 | host_ram_used_max | 備考 |
|---|---|---|---|
| llama.cpp | Q4_K_M | 3.19 GB | mmap 利用 |
| llama.cpp | BF16 | 3.12 GB | mmap 利用 |
| ONNX Runtime CPU | q4 | 5.31 GB | 全量メモリ展開 |
| ONNX Runtime CPU | fp16 | 8.44 GB ⚠️ | Colab CPU(12.7GB) で危険水域 |
ONNX CPU fp16 の 8.44GB は Colab CPU runtime (12.7GB RAM) で 70% 占有。実際、計測中に何度か RAM 警告が出ました。
mmap RSS の罠
llama.cpp は GGUF を mmap で直読みするため、peak_ram_mb(psutil の RSS)で測ると mmap領域がカウントされず過小評価されます。CSV 上は Q4=3.19GB、BF16=3.12GB とほぼ同じに見えますが、重みは Q4(0.7GB) と BF16(2.4GB) で全然違う。→ メモリ測定は信用ならない、モデルファイルサイズで判断すべし。
計測の落とし穴: セッション間で速度が 1.8 倍ブレた(原因は特定できず)
これが今回最大の方法論的学びです。そして正直に言うと、原因は最後まで特定できませんでした。
症状
vLLM (CUDAグラフ有効) を複数セッション回したところ、decode が 28.93 〜 91.62 tok/s と激しくブレました(同一設定、6セッション)。シリコン個体差は普通 ±10〜15% なので、これは説明がつかない。
試したこと(と、外れた仮説)
-
「GPUクロックが固定されていないのでは」 →
nvidia-smi -lgc 1590,1590でグラフィッククロックを固定 - それでもブレる → 「メモリクロックでは」 と疑い、
-lmc 5001,5001で固定 +clocks.memを記録するよう計測を追加 - ところが、メモリクロックを 5000MHz に固定したセッションでも decode は 63.66→64.39→62.25→28.93 とブレた → メモリクロック説は棄却
- CPU側のテレメトリ(host_cpu% / proc_cpu%)も記録 → 遅いセッションは host CPU 使用率が高く、推論プロセスの CPU% が低い傾向がうっすら見えた
結局わかったこと
- グラフィッククロックもメモリクロックも 70W 電力上限も固定/同一にしても、decode はまだブレる
- 遅い試行は「GPUがフルに回せていない=ホスト側(サンプリング/Python/プロセス間通信)で詰まっている」兆候があり、Colab の共有VMの一時的なCPUコンテンションが残差ノイズの正体ではないか、と推測している
- しかし断定はできない。Colab の共有環境で再現性高く切り分けるのは難しかった
教訓
- Colab の単発セッションの数値は信用するな。同じ設定でも 1.8 倍ブレうる
- 複数セッション回して中央値/レンジで報告するしかない(実際この記事の数値も複数セッションを見て決めた)
- クロック固定 (
-lgc/-lmc) はやる価値はあるが万能ではない
# やる価値はあるが、これだけでは安定しきらない
nvidia-smi -pm 1
nvidia-smi -lgc 1590,1590
nvidia-smi -lmc 5001,5001 # T4 では "not supported" で素通りすることも
おまけ: 真の拘束は「クロック」ではなく「電力」
クロックを 1590MHz に固定しても、T4 は 70W の電力上限 に当たるとクロックを勝手に下げます。
- llama.cpp はメモリ律速で 58W → 1590MHz 維持
- vLLM は演算密度高く 68W → 70W cap 張り付き → 1050MHz まで絞られる
つまり 「同一クロック条件で比較」は物理的に不可能で、代わりに 「同一電力 (70W) 下での比較」 が自動的に成立しているとも言えます。
出力品質について(手短に)
人間目視でも出力をいくつか見ましたが、特定の癖以外、品質に大きな差は感じませんでした。1.2B モデルとして「できることはできる、できないことはできない」がエンジン横断でほぼ揃う印象。出力品質はそもそも担保されたものではないので、以下は触れた範囲で目立った個別事象として軽く挙げるに留めます。
共通の弱点
- P2「すもももももももものうちで果物を表す単語はいくつ?」: 全エンジン(モデル)失敗。1.2B は「すもも」「もも」を文字として認識・カウントできず、りんご/ぶどう/みかんなどの幻覚を出す。サイズ依存の限界で、8B以上ならどうなるか興味あり。
- P5「1から200まで順に書いて」: 200まで実際に書いたエンジンは無し。llama.cpp は「50で止まる」、vLLM/ONNX は「1, 2, 3...200」と省略記号で省く。同じモデルでもサンプリングの微妙な差で挙動が変わる、というエンジン差。
エンジン固有の問題(実装由来 = 自分のバグ)
- ONNX Runtime fp16: 日本語で文字化け頻発(例:「摂氏」→「��氏」)。これは私の素実装の問題で、1トークンずつ decode しているため UTF-8 のバイト境界で破綻している。バッファリングすれば直る。
-
ONNX Runtime: チャットテンプレの停止トークン
<|im_end|>が出力に漏れる。私の実装がhf_tok.eos_token_idだけ判定し、LFM2.5 固有の<|im_end|>を拾えていなかったため。
つまり ONNX の品質問題は「モデルの癖」ではなく自分のアダプタ実装の未熟が主因。直してから評価すべきでした。
ツール呼び出し(P6)
ここだけ目に見える差があったので事実だけ:
| エンジン | 出力 |
|---|---|
| llama.cpp Q4 (T4) |
[get_weather(city="東京", unit="celsius")] のみ。簡潔。 |
| llama.cpp BF16 | 上記 + 英語の説明文 |
| vLLM fp16 | 「申し訳ありませんが…」+ json ブロック + 「ご理解いただけますでしょうか」 |
| ONNX CUDA q4 |
json ブロックのみ(関数名 name キー無し) |
| ONNX CUDA fp16 | 「摂氏」文字化け + 関数呼び出しを誤解 |
ツール呼び出しはパーサが読む前提なので、余計な前置きや <|im_end|> の漏れは実用上ノイズ。ただプロンプトの書き方次第で変わるし、出力品質はもともと担保されていないので、ここから「どのエンジンが賢い」と結論づけるつもりはありません。
ハード×量子化の効果
| ハード | BF16→Q4 の倍率 |
|---|---|
| T4 GPU (llama.cpp) | 2.04x (BF16:21.91 → Q4:44.64) |
| CPU (llama.cpp) | 2.74x (BF16:3.04 → Q4:8.33) |
CPUの方が量子化効果が大きい。CPU はメモリ帯域が GPU より遥かに狭い (DDR4 と GDDR6 で1桁違う) ので、重みサイズ削減の恩恵が直接効く。CPU 推論をやるなら Q4 必須で、BF16 はほぼ実用外(3.04 tok/s では待てない)。
個人的見解: ファインチューニングの必要性
ここからは私見です。
1.2B 汎用モデルの素のままはキツい
- 文字レベル推論が壊滅的(P2 で全失敗)
- 言語維持力が不安定(P4_4k で英語要約になることがある)
-
ツール呼び出しの記法が定まらない(モデルが学習しているはずの LFM 公式記法
<|tool_call_start|>が出てこなかった)
これらは推論エンジンや量子化を変えても根本的には改善しません。モデルそのものの限界。
用途特化ファインチューニングの方が現実的
エッジで 1.2B クラスを使う典型ユースケース(チャットボット、ツール呼び出し、要約・抽出、分類)は、汎用モデル + プロンプトエンジニアリング より、用途特化データで LoRA ファインチューニングしたほうが品質も体感速度も安定すると考えています。
なぜなら:
- 応答パターンを揃えられる(「申し訳ありませんが」を抑制等)
- ツール呼び出し記法を強制できる
- 出力を短く・構造化することで、生成トークンが減り体感速度も上がる
- 不要な多言語能力を犠牲にして、日本語精度を上げることもできる
汎用モデルは「色々できる」ように学習されているがゆえに、特定用途では多芸貧乏になりがち。小型モデルほどその傾向が顕著、というのが触ってみた実感です。
推論エンジンの選び方(個人的見解)
| 用途 | 推奨エンジン | 理由 |
|---|---|---|
| エッジ・低VRAM | llama.cpp Q4_K_M | 実VRAM最小 1856 MiB (1.81 GiB) |
| CPUのみ | llama.cpp CPU Q4 | CPU エンジンで 3.80 倍速い |
| サーバ・並列スループット | vLLM fp16+graphs | バッチ並列・KVキャッシュ予約が活きる |
| GPUで4bitを速く | ONNX CUDA q4 | MatMulNBits が効く(実装は要丁寧化) |
おわりに
やってよかったこと
- 2系統計測 (挙動 + 固定長プローブ): 出力長依存のノイズを回避
- 複数セッションで中央値: Colab のノイズに気付けた
- 1セッション=1条件: 状態の持ち越しによる交絡を排除
反省点
- vLLM のセッション間ブレの原因を特定しきれなかった(クロックも電力も固定したが残った)
-
ONNX 実装の品質バグ(文字化け、
<|im_end|>漏れ)を計測前に直すべきだった -
vLLM bitsandbytes NF4 量子化を試したかったが、Colab T4 (CUDA13) で bitsandbytes が
libnvJitLink.so.13を見つけられず断念。4bit での vLLM 比較が宿題
次にやりたいこと
- LFM2.5-8B-A1B で同じテスト → P2「すもも」をサイズで解けるか
- L4 / A100 にスケール → vLLM/ONNX が更に伸びるか
- 特定タスクへのファインチューニング → 1.2B を実用品質に押し上げる
- AWQ/GPTQ 等の高精度4bit量子化(vLLM で4bit比較するため)
まとめ
- エンジン選択の影響は大きい:同じ4bit・同じT4でも 2.60 倍、VRAM(実使用)も最小 1856 MiB〜最大 4674 MiB
- 1.2B 汎用モデルは限界あり、用途特化ファインチューニングが現実解