はじめに
普段GPUクラウドの提案をしていると、「推論基盤には何を使えばいいですか?」という相談を受けることがあります。
そこでよく名前が挙がるのが Ollama と vLLM です。
Ollamaはコマンド一発でモデルを動かせる手軽さから、検証や個人利用でよく使われています。
一方のvLLMは、PagedAttentionや継続的バッチ処理(continuous batching)といった仕組みを備え、本番運用のスループットを重視した推論サーバーとして採用されるケースが増えています。
「結局どっちが速いんやろうか」というのは、以前から気になっていた疑問でした。
そこで今回は、前回のCPU VM/GPU VM比較と同じFPT CLOUDのH200 GPU VM上で、Gemma 4を使ってOllamaとvLLMの推論性能を実測比較してみました。
ここで紹介する数値は、あくまで個人の検証環境で計測したものです。
モデルサイズやプロンプト、VMスペックが変われば数値も変わってきます。あくまで一つの目安として見ていただければと思います。
この記事でわかること
- OllamaとvLLMの立ち位置の違い
- 単一リクエストの応答速度を、精度・API・計測条件を揃えたうえでフェアに比較する方法
- 「同じBF16精度・単一リクエスト」という条件下で、実際にどれくらいの速度差が出るのか
超ざっくり言うと、「vLLMは本番のスループット重視」という評判の一部分、単一リクエストの生成速度だけを条件を揃えて実測してみた記事です(同時リクエスト時の比較は今回のスコープ外です)。
検証環境
今回は FPT CLOUD のGPU VM(H200 1基)を使って、推論エンジンだけをOllamaとvLLMに差し替えて比較します。FPT CLOUDはFPT Smart Cloud(FPTスマートクラウドジャパン株式会社)が提供するGPUクラウド基盤で、今回検証に使ったのはH200を1基搭載したGPU VMです。なお、FPT CLOUDではこの他にMetal Cloud(ベアメタル、H200×8基構成)の払い出しも可能です。
GPU VM
| 項目 | 内容 |
|---|---|
| タイプ | NVIDIA H200 SXM5 |
| vCPU | 24 |
| メモリ | 192GB |
| GPU | NVIDIA H200(GPUメモリ 143,771MiB ≒ 約140GB) |
| ストレージ | 200GB(/パーティション193GB、使用139GB/空き54GB) |
| OS | Ubuntu 24.04 |
| ドライバー | 575.57.08(CUDA 12.9) |
利用モデル
Ollama・vLLMともに Gemma 4 12B を使用します。精度を揃える理由は後述しますが、結論から言うと両エンジンともBF16(16bit)で揃えています。
| エンジン | モデル指定 |
|---|---|
| Ollama | gemma4:12b-it-bf16 |
| vLLM | google/gemma-4-12B-it(素の重み、BF16) |
Gemma 4はhead_dim=256とglobal_head_dim=512が混在する特殊な構造のため、vLLM側ではFlashAttentionやFlashInferが使えず、TRITON_ATTNバックエンドへの強制フォールバックが発生します。これはモデル側の制約で環境側では変更できません。今回の数値は、このTRITON_ATTNバックエンドでの結果です。将来vLLM側の最適化が進めば、この数字も変わる可能性があります。
OllamaとvLLMって何が違うのか
簡単に整理しておきます。
| 項目 | Ollama | vLLM |
|---|---|---|
| 位置づけ | ローカル・検証向けの手軽な実行環境 | 本番運用を意識した高スループット推論サーバー |
| バックエンド | llama.cpp | PyTorch + 独自の推論エンジン |
| バッチ処理 | 基本はリクエストごとに逐次処理 | 継続的バッチ処理(continuous batching)に対応 |
| API | 独自API(OpenAI互換APIも一部提供) | OpenAI互換APIを標準提供 |
| 得意分野 | 単発の応答速度、導入の手軽さ | 同時多数リクエスト時のスループット |
一般的には「Ollamaは手軽さ、vLLMはスループット」と言われています。この「スループット」という評判は、本来は同時多数リクエスト時の話です。今回検証するのは単一リクエストの生成速度のみなので、この定説を全面的に検証するものではない点は先にお断りしておきます。
検証条件を揃えるまでのすったもんだ
正直に言うと、最初は「Ollamaとデフォルト設定のvLLMを動かして、速い方書いたらええやろ」くらいの軽い気持ちでした。今回はその軽い気持ちがどう崩れていったか、という話を先にします。ここを飛ばして数字だけ出しても、たぶん誰の心にも残らないので。
vLLMを一から立てようとして、まるまる1日溶かした
vLLMをpipで一からビルドしようとしたら、依存関係でバグって推論がまともに動きませんでした。CUDAのバージョン、PyTorchのバージョン、この組み合わせ探しだけで1日が消えました。結局この日は成果ゼロです。
翌日、方針を変えて公式のDockerイメージ(vllm/vllm-openai)に乗り換えました。ビルドで消耗するくらいなら、動作確認済みの箱をそのまま使う方が早い。当たり前のことなんですが、環境構築に意地を張っていた1日は本当に無駄でした。
「速い方」を素朴に比べたら、量子化の差を見ていただけだった
Dockerで動くようになって、最初にvLLMで計測した数値をOllamaと並べたとき、一瞬「あれ、思ったより差がついてるやん」と思いました。ですが冷静に見直すと、比較していた2つのモデルは中身がまったく別物でした。
- Ollamaで何も考えずに
ollama pull gemma4:12bとすると、ダウンロードされるのはQ4_K_M(4bit量子化) - vLLMで
google/gemma-4-12B-itをそのまま指定すると、取得されるのはBF16(16bit)
これ、速い方が量子化されている方なのは当たり前です。エンジンの実装差でも何でもなく、単に計算量が4倍近く違うものを比べていただけでした。ここに気づかずそのまま記事にしていたら、後で恥をかくところでした。
対策として、Ollama側もデフォルトのQ4_K_Mではなくgemma4:12b-it-bf16を明示的に指定し、両エンジンともBF16で統一しています。ちょうどGoogleが公式にBF16タグを配布していたので、自分で量子化する手間もかかりませんでした。
GPUメモリの計算を見誤って、また1日溶かすところだった
BF16に揃えたところで、次はOllama側の計測に進もうとしたときの話です。「vLLMコンテナはまだ動いてるけど、H200は約140GBあるしメモリはまだ余ってるやろ」と軽く考えていました。
これが甘かったです。今回使ったH200はnvidia-smiで確認すると143,771MiB(約140GB)ですが、vLLMは起動オプションに--gpu-memory-utilization 0.90を指定していました。これは「モデルの重みが使っている量」ではなく、起動した瞬間にGPUメモリの90%(約126GB)をKVキャッシュ用として丸ごと確保してしまう設定です。
つまり残りは約14GBしかなく、そこにOllamaの24GBモデルを乗せようとすればほぼ確実にメモリ不足エラーで落ちます。ここに気づかずステップを進めていたら、環境構築の1日に続いて、また丸1日を計測エラーの原因調査に溶かしていたと思います。
--gpu-memory-utilizationは「今使っているメモリ量」ではなく「確保する上限」です。モデル自体が小さくても、この割合分は起動時点で専有されるので注意してください。
そもそも2つの推論エンジンが同じGPUを取り合った状態で計測しても、お互いの数値が不安定になるだけでフェアな比較にはなりません。というわけで、計測中は片方のコンテナを止め、1エンジンがGPUを専有した状態で計測することにしました。
# vLLM計測中はOllamaを、Ollama計測中はvLLMを止める
docker stop gemma4-vllm
APIエンドポイントも意外な落とし穴だった
vLLM側で/v1/completions(生テキスト補完用のAPI)を叩いたとき、出力が崩れることがありました。Gemma 4 12B ITはinstruction-tuned済みのモデルなので、素の補完APIとは相性が良くないようです。/v1/chat/completionsに変えたら安定しました。Ollama側もこれに合わせて/api/chatを使い、両エンジンとも「チャット形式」のAPIで揃えています。
先輩の指摘で、もう一つの見落としに気づいた
一通り書き終えて先輩に見てもらったところ、「生成AIの速度比較なら、TTFT(Time to First Token)・ITL(Inter Token Latency)・TPS(Tokens per Second)で見る方がフェアですよ」という指摘をもらいました。
言われてみれば当然です。それまで見ていた「生成トークン数÷全体時間」という値は、プロンプトを読み込む時間と実際に文章を生成する時間がひとまとまりになった粗い平均でした。しかもvLLMは約260トークン、Ollamaは500トークンと生成量自体が違ったので、この粗い平均のままではフェアな比較になっていませんでした。
TTFT・ITL・TPSを測るため、ストリーミングでレスポンスを受け取りながらチャンクごとの時刻を記録するPythonスクリプトを書きました。実装している途中で気づいたのですが、Ollamaのchat APIにはthinkというオプションがあり、これを明示的にfalseにしないとGemma 4は思考(reasoning)モードで動くようです。これまでの計測ではこのオプションを指定していなかったので、Ollama側だけ生成トークン数が毎回500ぴったり(上限に張り付き)になっていた謎の答えが、ここでようやく見えました。
両エンジンとも、独立したウォームアップコマンドを1回挟んでから3回計測しています。
というわけで、条件がもう一つ増えます。
Ollamaの思考(think)モードを明示的に無効化する
{"model":"gemma4:12b-it-bf16","messages":[{"role":"user","content":"..."}],"stream":true,"think":false,"options":{"num_predict":500}}
これでOllamaもvLLMと同じように、生成の途中でも文章の終わりを示すトークンに到達すれば自発的に停止するようになりました。
残りの条件
ここまでに比べると地味ですが、以下も揃えています。
- プロンプトは
生成AIについて500文字で説明してくださいで共通 - 生成トークン数の上限(
max_tokens/num_predict)はどちらも500に設定(思考モードを切ったことで、実際にはEOSで早めに止まることが多い) - 初回リクエストはモデルロードのオーバーヘッドが乗るため、ウォームアップを1回挟んでから本計測を3回実施
- ストリーミングでレスポンスを受け取り、チャンクごとの時刻からTTFT(初回チャンクまでの時間)・ITL(チャンク間の平均間隔)・TPS(decodeフェーズのトークン/秒)を算出
最終的に揃えた条件まとめ
- 精度: 両エンジンともBF16
- GPU: 計測中は片方のコンテナを停止し、専有状態で計測
- API: 両エンジンともチャット形式のエンドポイントを使用(ストリーミング)
- Ollamaの思考(think)モード: 明示的に無効化
- プロンプト・
max_tokens/num_predict: 共通(500) - ウォームアップ1回 → 本計測3回の平均
- TTFT・ITL・TPSの3指標で比較
Ollamaのデフォルト(Q4_K_M)とvLLMのデフォルト(BF16)を素のまま比較した場合にどうなるかも気になるところですが、それは条件が異なる別の比較になるので、本記事では扱いません。
検証結果
生データ
まずは加工前の生データです。
vLLM(BF16) 計測スクリプトと生データ
# /tmp/bench_vllm.py
import requests, time, json
url = "http://localhost:8000/v1/chat/completions"
payload = {
"model": "google/gemma-4-12B-it",
"messages": [{"role": "user", "content": "生成AIについて500文字で説明してください"}],
"max_tokens": 500,
"stream": True
}
start = time.perf_counter()
first_token_time = None
token_times = []
resp = requests.post(url, json=payload, stream=True)
for line in resp.iter_lines():
if not line:
continue
line = line.decode("utf-8")
if not line.startswith("data: "):
continue
data = line[len("data: "):]
if data.strip() == "[DONE]":
break
chunk = json.loads(data)
delta = chunk["choices"][0].get("delta", {})
if "content" in delta and delta["content"]:
now = time.perf_counter()
if first_token_time is None:
first_token_time = now
token_times.append(now)
end = time.perf_counter()
if first_token_time is None:
print("エラー: contentが一度も返ってきませんでした")
else:
ttft = first_token_time - start
total_tokens = len(token_times)
gen_time = end - first_token_time
itl = gen_time / (total_tokens - 1) if total_tokens > 1 else 0
tps = (total_tokens - 1) / gen_time if gen_time > 0 else 0
print(f"TTFT: {ttft*1000:.1f} ms")
print(f"Total tokens (chunks): {total_tokens}")
print(f"ITL (avg): {itl*1000:.2f} ms/token")
print(f"TPS (decode phase): {tps:.1f} tok/s")
print(f"Total time: {end-start:.2f}s")
# ウォームアップ
python3 /tmp/bench_vllm.py > /dev/null
# 本計測 3回
for i in 1 2 3; do
echo "=== 試行 $i ==="
python3 /tmp/bench_vllm.py
done
=== 試行 1 ===
TTFT: 27.5 ms
Total tokens (chunks): 281
ITL (avg): 9.79 ms/token
TPS (decode phase): 102.2 tok/s
Total time: 2.77s
=== 試行 2 ===
TTFT: 27.2 ms
Total tokens (chunks): 281
ITL (avg): 9.77 ms/token
TPS (decode phase): 102.3 tok/s
Total time: 2.76s
=== 試行 3 ===
TTFT: 26.8 ms
Total tokens (chunks): 285
ITL (avg): 9.77 ms/token
TPS (decode phase): 102.4 tok/s
Total time: 2.80s
Ollama(BF16, think無効) 計測スクリプトと生データ
# /tmp/bench_ollama.py
import requests, time, json
url = "http://localhost:11434/api/chat"
payload = {
"model": "gemma4:12b-it-bf16",
"messages": [{"role": "user", "content": "生成AIについて500文字で説明してください"}],
"stream": True,
"think": False,
"options": {"num_predict": 500}
}
start = time.perf_counter()
first_token_time = None
token_times = []
resp = requests.post(url, json=payload, stream=True)
for line in resp.iter_lines():
if not line:
continue
chunk = json.loads(line.decode("utf-8"))
content = chunk.get("message", {}).get("content", "")
if content:
now = time.perf_counter()
if first_token_time is None:
first_token_time = now
token_times.append(now)
if chunk.get("done"):
break
end = time.perf_counter()
if first_token_time is None:
print("エラー: contentが一度も返ってきませんでした")
else:
ttft = first_token_time - start
total_tokens = len(token_times)
gen_time = end - first_token_time
itl = gen_time / (total_tokens - 1) if total_tokens > 1 else 0
tps = (total_tokens - 1) / gen_time if gen_time > 0 else 0
print(f"TTFT: {ttft*1000:.1f} ms")
print(f"Total tokens (chunks): {total_tokens}")
print(f"ITL (avg): {itl*1000:.2f} ms/token")
print(f"TPS (decode phase): {tps:.1f} tok/s")
print(f"Total time: {end-start:.2f}s")
スクリプトを書いて最初に動かした1回は実質的なウォームアップとして扱っていましたが、vLLM側と完全に同じ手順に揃えるため、独立したウォームアップコマンドを挟んで測り直しました。
# ウォームアップ
python3 /tmp/bench_ollama.py > /dev/null
# 本計測 3回
for i in 1 2 3; do
echo "=== 試行 $i ==="
python3 /tmp/bench_ollama.py
done
=== 試行 1 ===
TTFT: 544.3 ms
Total tokens (chunks): 252
ITL (avg): 11.36 ms/token
TPS (decode phase): 88.0 tok/s
Total time: 3.40s
=== 試行 2 ===
TTFT: 538.7 ms
Total tokens (chunks): 265
ITL (avg): 11.36 ms/token
TPS (decode phase): 88.1 tok/s
Total time: 3.54s
=== 試行 3 ===
TTFT: 557.9 ms
Total tokens (chunks): 256
ITL (avg): 11.36 ms/token
TPS (decode phase): 88.0 tok/s
Total time: 3.45s
TTFT・ITL・TPS(3回平均)
両エンジンとも、独立したウォームアップコマンドを1回実行してから3回計測し、その平均を採用しています。
| 指標 | vLLM(BF16) | Ollama(BF16, think無効) |
|---|---|---|
| TTFT | 約27.2ms(3回平均) | 約547.0ms(3回平均) |
| ITL(トークン間隔) | 約9.78ms/token(3回平均) | 約11.36ms/token(3回平均) |
| TPS(decodeフェーズ) | 約102.3 tok/s(3回平均) | 約88.0 tok/s(3回平均) |
TTFTは約20倍、TPSは約1.16倍の差です。グラフにするとTTFTの差の大きさが一目で分かります。
TTFT = first_token_time - start(最初のチャンクを受信するまでの時間)、ITL = (end - first_token_time) / (tokens - 1)(トークン間の平均間隔)、TPS = (tokens - 1) / (end - first_token_time)(decodeフェーズのトークン/秒)として、両スクリプトとも共通のロジックで算出しています。
考察
TTFTの差が圧倒的に大きい
TTFT・ITL・TPSに分けて比較すると、一番差が出たのはTTFTでした。約27.2ms(vLLM)と約547.0ms(Ollama)で、およそ20倍の差です。一方でdecodeフェーズのTPSは約102.3 tok/s(vLLM)と約88.0 tok/s(Ollama)で、約1.16倍とTTFTほどの差はありません。
つまり、単一リクエストの体感速度の差は「文章を生成する速さ」よりも「最初の1文字が出てくるまでの遅さ」の方が支配的だったということです。vLLMはPagedAttentionによるプロンプト処理の最適化が単一リクエストでもTTFTに効いている一方、Ollama(llama.cppバックエンド)はこの部分の最適化が相対的に弱い、と捉えるのが妥当だと思います。
vLLMには無視できないウォームアップコストがある
今回の計測でも、ウォームアップなしの初回リクエストはTTFTが1.8秒近くかかりました(CUDAグラフのキャプチャやカーネルコンパイルが走るためです)。これは常時起動してリクエストを受け続けるサーバー用途であれば無視できるコストですが、リクエストの都度エンジンを起動するような使い方だと、逆にvLLMの方が不利になります。今回の結果は、あくまで「起動済みのサーバーに単発リクエストを投げた場合」の数字である点は明記しておきます。
生成トークン数の謎が解けた
以前の計測ではOllama側だけ生成トークン数が毎回500(上限)に張り付き、vLLMは248〜267で自発的に停止するという違いがあり、原因を保留にしていました。今回thinkオプションを明示的に無効化したところ、Ollamaも253〜261トークンで自発的に停止するようになりました。Gemma 4はOllama上でデフォルトだと思考モードが有効になっており、そのぶん生成が長引いていた可能性が高いです。この点は先輩の指摘がなければ気づかないままでした。
ただし、これは「vLLMの本領」を測った数字ではない
正直に書いておくと、この結果はvLLMが本来評価されている場面(同時多数リクエスト時の継続的バッチ処理)を検証したものではありません。今回計測したのは単一リクエストのみで、継続的バッチ処理といったvLLMの強みは、同時リクエスト数が増えたときにこそ効いてくる仕組みです。
プリセールスの現場感覚と照らし合わせると
「検証や個人利用ならOllama、本番ならvLLM」という住み分けは、今回の単一リクエストの数字だけを見ても、速度の面で覆るものではありませんでした。むしろOllamaが検証用途で選ばれる理由は速度ではなく手軽さそのものであり、vLLMが本番採用される理由は今回計測していない同時リクエスト時のスループットにある、という従来の住み分け自体は今回の結果とも矛盾しません。
まとめ
環境構築で1日溶かし、量子化の違いに気づかず一度は変な数字を出しかけ、GPUメモリの計算を見誤ってまた1日溶かしかけ、指標の取り違えに気づき、先輩の指摘でthinkモードの見落としに気づき、最後はウォームアップの非対称にも気づく。今回はそんな感じで、数字にたどり着くまでの方が長かった検証でした。
それでも最終的には、同じBF16精度・同じプロンプトという条件を揃えたうえで、TTFT・ITL・TPSという3つの指標で見ることができました。TTFTは約27.2ms(vLLM)対約547.0ms(Ollama)でおよそ20倍、decodeのTPSは約102.3 tok/s対約88.0 tok/sでおよそ1.16倍という結果です。単一リクエストの体感速度は、生成そのものの速さよりもTTFTの差に大きく引っ張られていました。
同時リクエスト時の比較は、今回は時間の都合で見送りました。ここはvLLMが本来強みを発揮する領域なので、また機会を作って検証してみようと思います。
おわりに
今回の検証は、FPT CLOUDのGPU VM(H200 1基)上で実施しました。GPU 1基を占有して使えるので、今回のようにOllama/vLLMを切り替えながらの検証や、GPUメモリを目一杯使うような推論エンジンの比較にも向いています。もっとGPUを積んだ環境が必要な場合は、Metal Cloud(ベアメタル、H200×8基構成)という選択肢もあります。
GPU基盤の選定や推論エンジンの使い分けで悩んでいる方は、FPT AI FACTORYもぜひ選択肢の一つとして検討してみてください。