見出し画像

同じGPUで結論が4回ひっくり返った — ベンチマークに残すべきは、数字より条件だった Cuda/Vulkan/ROCmで検証

ROCm 27.5 tok/s、Vulkan 36.5 tok/s。この2行を見たとき、「Strix Halo の ROCm は Vulkan に負けている」という書き出しを頭の中で組み立てていました。同じマシン、同じモデル、同じ問題セット。数字は出ている。あとは考察を書くだけ、のはずでした。

結論から言うと、この比較はそもそも成立していませんでした。しかもこの後、同じ種類の落とし穴にさらに3回落ちます。都合4回、結論がひっくり返った。速いGPUを選ぶ話ではなく、測った条件を結果と一緒に残しておかないとベンチマークは平気で嘘をつくという話です。

測定台 — 1台に3つのGPUを載せる

測定機は GMKtec の EVO-X2。Ryzen AI Max+ 395、いわゆる Strix Halo です。ここに RTX 5090 と RTX 3090 を外付けし、APU内蔵の Radeon 8060S と合わせて3GPU。llama.cpp を CUDA / Vulkan / ROCm の3ビルドで用意してあるので、実際に動く組み合わせは6通りになります。

測るのは自作の `llmbench` です。SWE-Bench 風に、バグ報告とソースを LLM に渡し、返ってきたパッチを隠しテストで検証して Resolved 判定とコード品質を採点する。既定の40問を各3試行、1環境あたり120生成。モデルは Ornith-1.0-9B-Q6_K、CUDA は 13.3。

8060S は専用VRAMを持たず、統合メモリの一部をグラフィックス側に転換して使います。AMD の公式ブログによれば Variable Graphics Memory で最大96GBまで振り替えられる仕様で、実機でも 96.0GB を持つデバイスとして見えていました。9BのQ6_Kなら3台とも全レイヤーが載ります。

反転1 — バックエンドは ROCm、でもレイヤーは1枚も載っていなかった

冒頭の 27.5 と 36.5 に戻ります。原因は llama-server の起動引数でした。`--device ROCm0 ... -ngl 0`。

`-ngl 0` は「GPUにオフロードするレイヤー数がゼロ」という意味です。バックエンドは ROCm と表示されるのに、レイヤーが1枚もGPUに載っていない状態でした。ここは正確に書いておきたいのですが、`-ngl 0` = 完全なCPU実行、ではありません。llama.cpp メンテナの ggerganov は Discussion #10200 で、0レイヤーでも大きな行列積など一部の演算はGPU側に流れ続けることがあると説明していますし、slaren も CUDA ビルドでは `CUDA_Host` バッファがピン留めメモリとして使われうると補足しています。GPUを完全に切り離すわけではない。

それでも、計算の本体がCPU側で走っていたことは動きません。私が比べていたのは ROCm と Vulkan ではなく、「ほぼCPU」と「GPU」でした。27.5 対 36.5 は、そりゃそうなる。

厄介なのは、これが実行中のサーバに聞いても分からないことです。llama.cpp の `/props` が返すのは `default_generation_settings`(n_ctx など)、`total_slots`、`model_path`、`build_info` あたりで、`-ngl` にも `--device` にも相当するフィールドがない。APIから取れないなら、外から読むしかない。そこで `llmbench` に `env.py` を新設し、`/proc/<pid>/cmdline` で起動引数を、`/proc/<pid>/maps` のロード済みライブラリ(`libcudart` / `libamdhip64` / `libvulkan` など)で実際にリンクされているバックエンドを取ることにしました。root権限は不要です。Ollama なら `/api/ps` の `size_vram` と `size` が使えますが、この比を「GPUオフロード率」と定義した公式の一文は無く、コミュニティで通用している解釈だという但し書きは要ります。

事故の出どころも判明しました。CodeRouter 側の examples 設定に「CPU のみ」というプロファイルがあり、GPUビルドを選んでいても継承でそれを掴めてしまう構造だったのです。名前を「CPU のみ(GPU未使用・低速)」に変えました。プロファイル名が危険の説明になっていなかった、というだけの話です。

反転2 — CUDA0 は RTX 5090 であって、3090ではない

条件を揃えて測り直したら、今度はレポートが「RTX 3090」と表示しました。実際に動いていたのは 5090 です。

原因は、CUDA のデバイス番号を nvidia-smi の並びで引いていたこと。NVIDIA の CUDA Programming Guide には、`CUDA_DEVICE_ORDER` の既定値が `FASTEST_FIRST`、つまり簡易なヒューリスティックで速い順に並べる、と書かれています。一方 nvidia-smi のマニュアルは、インデックスを「ドライバが返す自然な列挙順の0始まりの番号」と説明したうえで、列挙順は再起動をまたいだ一貫性すら保証しないので UUID か PCIバスID を使え、と明記している。

この2つから言えるのは「両者が一致する保証はどこにもない」までです。そして実機では、nvidia-smi の GPU0 が 3090、CUDA0 は 5090 でした。速い順に並んだ側を、別の順序で引いていたわけです。

さらに面倒なのが、IDの名前空間がビルドごとに独立していること。同じ RTX 3090 が、CUDAビルドでは `CUDA1`、Vulkanビルドでは `Vulkan0` になります。番号も順序も揃わない。

正解は、起動に使った実行ファイル自身に `--list-devices` を聞くことでした。出力は `ID: 名前 (総容量 MiB, 空き MiB free)` の形式です。3バックエンドで共通だと書いた一次情報は見つけられませんでしたが、今回 CUDA / ROCm / Vulkan すべてで同形式であることを実機で確認しました。デバイス名の解決は経路1本で済んでいます。

反転3 — 使っていないはずのGPUにも、載っているように見えた

`--device CUDA0`(5090)を指定して走らせているのに、nvidia-smi を見ると 3090 側にも同じPIDが 256MiB で並んでいました。「指定していないGPUにも分割ロードされている」と、あやうく書くところでした。

内訳を見れば話は早く、5090に7650MiB、3090に256MiB。3090側はCUDAコンテキストの確保だけで、モデルの重みは1バイトも乗っていません。実質的な分割ではない。

これはツールを直すというより、ツールに何を書かせないかの話でした。VRAMの占有バイト数を見たまま「分割ロード」と解釈させない。オフロード率は起動引数と `/api/ps` 由来の値から決める。観測を素直に信じすぎると、計測ツールのほうが先に嘘をつく。

条件を固定して、6環境を測り直す

3回転んだところで、比較の土台そのものを作り直しました。CodeRouter のプロファイルは、Vulkan が ctx 131072、ROCm が 32768 と、そもそも条件が揃っていなかったのです。どのビルドを選んでも設定を上書きしない「ベンチ比較用(条件固定)」プロファイルを新設しました。llama.cpp の `--ctx-size` は全スロットの合計で、実装上は `n_ctx_slot = n_ctx / n_parallel`。ggerganov 自身が「本来はKVキャッシュサイズと呼ぶべきだった」と認めている挙動なので、`--parallel` とセットで固定しないと意味がない。ベンチ用のコマンドテンプレートに `--model` と `--output` が抜けていて、スイープがそもそも動かなかったのも直しました。

自分の設定に「Vulkan は `--no-mmap`」と書いてあったのも、この機会に根拠を探しました。README にも docs/build.md にも、そんな記述はない。むしろ Issue #18317 では、build 7516 以降で Vulkan + `--no-mmap` が O_DIRECT 由来のアラインメント衝突でロードに失敗すると報告されていて、回避策は「mmap を使う」、つまり `--no-mmap` を付けないことでした。影響ハードには Strix Halo も挙がっています。必須どころか、時期によっては壊れる側だった。ただの思い込みです。

そうして揃えた条件が、`-ngl 99` / `n_ctx 16384` / `--parallel 1` / `--threads 8`。6環境すべて同一です。結果が図1。

RTX 5090      CUDA     163.1 tok/s  (100%)   latency  6.9s
RTX 5090      Vulkan   145.4 tok/s  ( 89%)   latency  6.8s
RTX 3090      Vulkan    70.7 tok/s  ( 43%)   latency 11.4s
RTX 3090      CUDA      68.1 tok/s  ( 42%)   latency 13.1s
Radeon 8060S  ROCm      28.2 tok/s  ( 17%)   latency 32.0s
Radeon 8060S  Vulkan    27.7 tok/s  ( 17%)   latency 27.9s

図2は、同じGPUの上でバックエンドだけを入れ替えた比較です。5090 では Vulkan がそのGPUの最速比 89%、3090 では逆に CUDA が 96%、8060S では Vulkan が 98%。GPUを跨いだ差が数倍あるのに対して、バックエンドの差は最大でも1割ほどしかありません。

品質側はもっとはっきりしていました(図4)。Resolved率の環境間の幅は5.0ポイント、pass@1 が6.7ポイント、Combined が6.1。本来一致すべき指標が、サンプリングのブレの範囲で揺れているだけです。40問中9問が環境によって割れ、逆に t020(演算子の優先順位と括弧を無視する電卓)は全6環境で落ちました。ハードは速度を変えますが、賢さは変えない。

帯域との関係も見ておきました(図5)。RTX 5090 は 1792 GB/s、NVIDIA の RTX Blackwell アーキテクチャ白書に載っている値です。RTX 3090 は Ampere GA102 白書で 936 GB/s、Radeon 8060S は AMD の公式ブログで 256 GB/s。カタログ上 5090 は 3090 の1.91倍ですが、各GPUの最速値どうしの実測は2.31倍。逆に 3090 と 8060S は帯域比3.66倍に対して実測2.51倍でした。トークン生成が帯域律速なのは確かでも、帯域比は目安であって係数ではありません。

反転4 — 「NVIDIAでVulkanは遅い」も、嘘だった

ここからが本題です。条件を揃えた最初の確認として、1タスクだけ流して速度を見ていました。5090 で CUDA 126.6 tok/s、Vulkan 71.8 tok/s。Vulkan は CUDA比57%、3090 でも74%。「NVIDIA GPU で Vulkan を選ぶ理由はない」と、もう一度書きかけた。

40問×3試行に増やしたら、同じ 5090 で89%になりました。3090 にいたっては104%、つまり Vulkan のほうが速い。図3が、この単発と本測定の反転です。57%→89%、74%→104%。同じマシン、同じビルド、同じモデルで、比が20〜30ポイント動いた。

犯人は分散でした。同じ 5090 の CUDA で、149.5 tok/s と 126.6 tok/s、18%ぶれた値を実際に踏んでいます。1タスクの計測は、その1回がどこに落ちたかで結論が決まる。分散を結論として読んでいたわけです。

そして、この結果は近年の報告とも整合していました。llama.cpp の README のバックエンド表は、CUDA が NVIDIA向け、HIP が AMD向け、Vulkan がベンダー非依存と役割を書くだけで、性能の優劣には触れていません。Issue #17273(A100、2025年11月)には CUDA がトークン生成で1.21〜1.31倍という報告がありますが、bug-unconfirmed のまま stale でクローズされています。NVIDIA の開発者フォーラムに投稿された DGX Spark GB10 の測定(2026年3〜4月、個人のテスター)では、プロンプト処理は常に Vulkan が下、トークン生成はほぼ同等で一部は Vulkan が上、2026-03-26 のビルドではプロンプト処理でも逆転した例まで出ています。

今回の測定はほぼトークン生成です。「Vulkan は遅いのが定説」と書いていたら、2026年の実態から外れたことを書くところでした。

4回とも、転んだ場所は同じだった

反転1は起動引数、反転2はデバイス番号、反転3はVRAMの内訳、反転4は試行回数。バラバラに見えて、全部「数字だけを見て、その数字が生まれた条件を見ていなかった」です。

この作法を最も厳しく制度化しているのが MLPerf でしょう。Inference のルールには "Replicability is mandatory"、そして "Results that cannot be replicated are not valid results." と書かれています。再現できない結果は、有効な結果ではない。だから提出には `system_desc_id.json` によるシステム構成の申告が必須で、ホストCPUの型番、アクセラレータの型番と搭載メモリ量、フレームワーク、CUDA や cuDNN を含むソフトウェアスタック、OS までが必須項目になっていて、submission_checker がその存在を機械的に検査します。数字を出す資格として、条件の開示を先に要求している。

条件を残さないと何が起きるかの実例が、ollama/ollama の Issue #15601 です。同一ハード・同一モデル・同一フラグで、Ollama v0.20.5 が約34 tok/s、スタンドアロンの llama.cpp b8765 が52〜56 tok/s。約56%の差です。原因は、Ollama が内蔵する llama.cpp が b7437(2025年12月16日)で凍結されていて、その後にマージされた PR #19625(Vulkan の Wave32 flash attention)と PR #20551(AMDでグラフィックスキューを使う)が入っていなかったこと。GPUもモデルもフラグも同じで、違うのはビルドだけ。それで56%動きます。「どのビルドで測ったか」を書いていない tok/s は、この幅を丸ごと内包している。

もうひとつ、身近な落とし穴を。llama-bench の CSV / JSON / SQL 出力には `build_commit`、`backends`、`n_gpu_layers`、`gpu_info`、そして標準偏差の `stddev_ts` まで入っています。ところが markdown 出力は model / size / params / backend / ngl / test / t/s だけ。ビルドの識別子も、ばらつきも落ちる。そしてブログや issue に貼られるのは、たいてい markdown のほうです。ツールは条件を持っているのに、人間が持ち出すときに捨てている。

だから `llmbench` の `env.py` は、ホスト(CPU / GPU / RAM / OS)と推論バックエンドの構成(量子化、GPUオフロード率、n_ctx)を results.json と report.md の両方へ自動で書くようにしました。macOS は sysctl と system_profiler、Linux は /proc と nvidia-smi、rocm-smi/lspci から拾います。起動コマンドは全文を残しますが `--api-key` の類は伏せる。`compare` にはハードウェア比較モードを足して、同一モデルで環境違いを検出したら tok/s を主役に切り替え、量子化・`-ngl`・`n_ctx`・並列数のどれかがずれていたら名指しで警告します。今回の4回の反転は、全部この警告に落とし込める種類のものです。

収集は全面 best-effort。例外を投げない、追加依存を持ち込まない、秘匿値を含めない、の3つを不変条件にしました。環境記録が原因でベンチ本体が止まったら本末転倒です。テストは188件。実装も検証も記事化も、Cowork のセッションで司令塔+サブエージェント(Opus が調査と執筆、Sonnet が定型調査)の分担で回しています。

数字の隣に、条件は書いてありますか

4回ひっくり返って手元に残ったのは、GPUの序列ではなく記録のフォーマットのほうでした。5090が速いのは測る前から知っていた。知らなかったのは、自分の測定が「何を測っていたのか」を後から確認する手段を持っていなかったことです。

これは私だけの話ではないはずです。手元の tok/s に、ビルドのコミットハッシュは付いていますか。`-ngl` と `n_ctx` と `--parallel` は控えてありますか。その数字が何回の試行の何なのか、平均か中央値か一発か、書いてありますか。3か月後の自分が同じ数字を再現できますか。

もし1つでも「書いていない」があるなら、その数字は今日の自分にしか通じない数字です。私は4回それをやりました。あと何回やるのか、正直まだ分かりません。

同じ穴に落ちた方、「うちはこう記録している」という運用をお持ちの方がいたら、コメントで教えてください。特に、Vulkan と CUDA の比が皆さんの環境でどう出るかは知りたいところです。

リンク
https://github.com/zephel01/swe-bench/
https://github.com/zephel01/CodeRouter

#ローカルLLM #llamacpp #ベンチマーク #GPU #RTX5090 #RTX3090 #StrixHalo #ROCm #Vulkan #機械学習 #個人開発

いいなと思ったら応援しよう!

zephel01 サーバー代とコーヒー代になります☕ 役に立ったら応援よろしくお願いします!