EVO-X2 x Qwen3.8-Flash-Next番外編4 入力文書と読み込み速度の関係(実測編) の続き。
前回の番外編4では、実文書を入力できるように改造したllama-benchで、実文書6種類+元のランダムトークンの7種類の入力でPP速度に差があることを確認した。
その時に、Vulkan版のPP速度差がROCm版に比べて大きいのが気になった。
入力読み込み時に差が出るということは、原因はFlash Attention関係かな?とチャッピー様に相談したら、チャッピー様は止めてくれるどころか調べ方を色々提案してくれて、またまた沼にハマった。
以降、Prompt Processing(入力を読み込む処理)をPP、Token Generation(出力生成)をTGと表記する。どちらも単位はtok/sで、高いほど速い。
結論
- Vulkan版で、日本語文学とC/C++コードのPP速度が違う主な要因は、Flash Attention(FA)のGPU処理時間差に対応していた。コンテキスト長64kのGPU演算時間を合算すると、FAだけで約32.9秒の差があった。
- 同じFAシェーダー・同じ層・同じKV長でRadeon GPU Profiler(RGP)を比較しても、FA本体は文学約311~314ms、コード約76ms。約4.1倍の差を2組の計測で確認した。ただし、これは64k全体ではなくKV=49,152でのFA 1ノードの比較。
- Vulkanが使うmaskの分類結果を調べると、16×64のタイルを丸ごとスキップできる割合は文学 35.68%、コード 75.03% だった。入力によって実際に処理するタイルが大きく違うことが、FA時間差の有力な説明になりそう。
- maskタイル数の違いだけで4.1倍の差を完全に説明できたわけでも、ROCmとの違いをすべて解明できたわけでもない。
ソース、Windowsビルド、検証スクリプトなどはここで公開している。
https://github.com/ariera-j/llama.cpp-evox2-windows
今回の調査はinvestigation/r5-vulkan-off-fa-dispatch-20261010ブランチで実施した。
検証環境
速度に関係するところだけ先に。
| 項目 | 内容 |
|---|---|
| PC | GMKtec NucBox EVO-X2 |
| APU | AMD Ryzen AI MAX+ 395 / Radeon 8060S |
| RAM | 128GB UMA |
| UMA Frame Buffer | 96GB |
| OS | Windows 11 Pro 26H2(ビルド26300.9550) |
| メインモデル | Unsloth Qwen3.8-Flash-Next UD-IQ3_XXS |
| モデル配置 | PLE16変換版 |
| KV cache | f16 |
| batch / ubatch | 2048 / 1024 |
| MTP | OFF |
| Vulkan compiler | LLVM / Clang 20.1.8 |
| ROCm | 10.0.0 / TheRock (ビルド時) |
| ROCm compiler | AMD Clang 23.0.0 |
| GPU target | gfx1151 |
| 入力長 | 64k:61,789 / 128k:126,253 tokens |
今回は、前回の7種類のうち、コンテキスト128kで最速だったC/C++コード(llama.cppソースコードの一部)と、最遅だった日本語文学(『吾輩は猫である』)の2つを検証対象にした。以降のGPU内部の調査は主にコンテキスト64kで行っている。
前回の64kのPP結果から、この2種類だけ抜き出しておく。
Vulkanはコードの方が明らかに速いのに、ROCmでは逆に文学の方が少し速い。
| 64k、61,789 tokens | Vulkan QSA union OFF | ROCm |
|---|---|---|
| 日本語文学 | 285.479 tok/s | 404.027 tok/s |
| C/C++コード | 329.465 tok/s | 394.669 tok/s |
最初に用語説明
- Attentionとquery、KV:Attention は、いま処理しているトークン(query)が、過去のトークン(Key/Value、略してKV)のどれを参考にするかを計算する処理。入力が長いほど過去のKVが増えるので、重い処理になる。「KV=49,152」は、過去49,152トークン分のKVが溜まった時点。
- Flash Attention(FA)とタイル:AttentionをGPU上で効率よく計算する実装。大きな計算を小さなブロック(タイル)に分け、メモリ転送などを減らす。
- Sparse Attention / QSA:すべての過去トークンを同じように参照するのではなく、必要な位置を選んでAttentionを計算する方式。QSAはQwen Sparse Attentionで、今回のモデルでは選択した過去の位置に対応するmaskを作る。
-
QSAのmask:参照してよい位置は基本的に
0、参照させない位置は-infなどで表す。QSAが「どこを参照するか」を決め、FAはそのmaskを見てAttentionを計算する。QSAとFAは同じものではない。 - VulkanとROCm:どちらもAMD GPU上で計算するために使えるバックエンドだが、GPUへ渡すプログラムの実装は異なる。GPU上で動く関数をVulkan では 「シェーダー」(.comp ファイル)、ROCm/HIPでは 「カーネル」 と呼ぶことが多い。dispatch は、そのGPU関数を起動すること。
まずはSparse FAをオフにしてみる
VulkanのFA実装には、条件によってSparse FAの経路を選ぶ仕組みがある。選んだ部分だけをまとめて扱って計算量を減らすための経路で、高速化の仕組みだけど、今使っているGPU、GPUドライバ、モデルなどとの組み合わせの問題で逆に遅くなっている可能性もある。
これはソースを変更せずにGGML_VK_FA_SPARSE_DISABLEという環境変数でオフにできるので、最初に試してみた。
| 64k、Vulkan QSA union OFF(b11535) | 通常(環境変数なし) | Sparse FA無効(=1) |
|---|---|---|
| 日本語文学 | 286.158 tok/s | 285.796 tok/s |
| C/C++コード | 330.443 tok/s | 330.384 tok/s |
64kでPPを測ってみたら、Sparse FAの環境変数あり/なしで速度はほとんど変わらなかった。前回の64k Vulkan QSA union OFFの結果とも近い値だったので、結果として前回の結果を再確認しただけになった。
注意点として、この環境変数は0を指定しても、変数が存在するだけでSparse FAが無効になる仕様。通常側の測定では環境変数自体を削除している。
この時点では「Sparse FAが原因ではなさそう」と考えた。後で診断ログを入れて分かったのだけど、そもそも今回の64k PPではSparse FAは選ばれていなかった。
時間がかかっている処理を調べるため、診断ログを出力できるように改造
次は、何に時間がかかっているかを調べるため、FAの経路選択・呼び出し回数・KV区間などをCSVに書き出す機能を追加した。主にggml/src/ggml-vulkan/ggml-vulkan.cppに集計・CSV出力処理を追加し、カウンタの定義はggml-vulkan-types.hにも加えている。
環境変数GGML_VK_FA_DISPATCH_DIAG=1で有効になって、GPUで実行した時間ではなく、CPU側でどのFA経路を選択したかを記録するようにした。
診断機能を入れたVulkan b11551で、64kのC/C++コードと日本語文学を読み込ませて集計CSVを比較してみたところ、2つのCSVはバイト単位で完全一致した。
| 診断項目 | 両入力での結果 | 意味 |
|---|---|---|
| FA calls | 732回 | 12層 × 61マイクロバッチ(60×1,024+末尾349トークン) |
| 選ばれたFA経路 | FA_COOPMAT1 |
Cooperative Matrixを使うVulkan側のFA経路 |
sparse |
0 | Sparse FAは選択されていない |
mask_opt |
1 | maskタイルの最適化が有効 |
Br / Bc |
16 / 64 | maskやFA処理で用いるquery側/KV側のブロック寸法 |
split_k |
1 | 今回の呼び出しではKV分割を追加しない設定 |
KV区間・n_kv_maxの集計 |
一致 | KV長の分布や、maskに関係する参照範囲の上限も同じ |
これで、コードの時だけ別のFAシェーダーへ分岐していた、という可能性はほぼ消えた。
ただし、ここで一致したのはFAの呼び出し条件であって、maskの中身ではない。同じプログラムを呼んでいても、GPU内で実際に何回計算するかは違うかもしれない。
Vulkan GPU performance loggerで調べてみる
診断CSVでは差が分からなかったので、次は第10回でも使ったVulkan GPU performance loggerで調べてみる。環境変数でGGML_VK_PERF_LOGGER=1にすると、GPU演算ごとの時間をログ出力してくれるもの。これは自作ではなく元のllama.cppにある機能。
ログ出力自体にオーバーヘッドがあるため、この状態でのPP速度は通常の速度指標には使用しない。今回はコンテキスト長64k、61マイクロバッチの全ログを合計して比較した。
| GPU演算の種類(61ブロック合算) | 日本語文学 | C/C++コード | コード-文学 |
|---|---|---|---|
FLASH_ATTN_EXT(FA) |
102.460秒 | 69.565秒 | −32.895秒 |
MoE MUL_MAT_ID*
|
40.790秒 | 45.240秒 | +4.450秒 |
LIGHTNING_INDEXER |
1.168秒 | 1.170秒 | +0.002秒 |
| その他GPU演算 | 72.819秒 | 71.726秒 | −1.093秒 |
| GPU演算合計 | 217.237秒 | 187.701秒 | −29.537秒 |
MoE(Mixture of Experts) は入力に応じて使う専門家(Expert)を選ぶ仕組み。MUL_MAT_ID*はそのExpertの行列演算に関係する。LIGHTNING_INDEXERはQSAで参照位置を選ぶための処理に関係する。
C/C++コードはFA部分では32.90秒速く、MoE部分では4.45秒遅い。GPU演算合計では29.54秒速い。
次に、FA時間をKV区間で分けてみた。
| KV区間 | ブロック数 | 日本語文学のFA | C/C++コードのFA | 文学-コード |
|---|---|---|---|---|
| 1~16k | 16 | 4.594秒 | 4.388秒 | +0.207秒 |
| 17~32k | 16 | 16.636秒 | 16.624秒 | +0.013秒 |
| 33~48k | 16 | 36.779秒 | 26.929秒 | +9.849秒 |
| 49~60k | 12 | 43.465秒 | 21.051秒 | +22.414秒 |
| 最後の349トークン | 1 | 0.986秒 | 0.573秒 | +0.412秒 |
※この秒数は区間内の複数マイクロバッチを合計した値で、1回のFA呼び出し時間ではない。
KV 17~32kではほとんど同じなのに、33kを超えたあたりから差が開き、49~60kで大きくなる。今回QSA union(第8回で入れた高速化)はずっとOFFなので、32kを境にunionがONへ切り替わったわけではない。
このあたりで止めておけばよかったのだけど、Flash Attention自体はVulkanでもROCmでもやっているはずなのに、Vulkanだけ差が大きい理由が気になって、Flash Attentionの具体的な処理を調べることにした。
Radeon Developer Panelで調べてみる
AMDのRadeon Developer Panel / Radeon GPU Profilerを使って、GPU演算の中身と時間をさらに詳しく調べてみる。こちらも第10回で一度使っている。
llama-cliやllama-benchの1回分の全GPUイベントを取ろうとするととんでもないデータ量になるので、実行中の一部分を指定してデータを取る必要があるのだけど、この位置合わせが意外と大変だった。位置合わせの話は本題から外れるので付録に書いておく。
何度か試した結果、VulkanのFAにEVOX2_FA_MAINやEVOX2_FA_MASK_OPTというラベルを付け、RGPのAuto CaptureにDispatch rangeで位置を指定する方法で、同じ層・同じKV長・同じシェーダーの比較ができた。
条件はVulkan QSA union OFF、KV=49152、N=1024、Qcur-19 (view) (permuted)、Br=16 / Bc=64、n_kv_max=2051。RGPで見えたFA本体はflash_attn_f32_f16_aligned_cm1、mask前処理はfa_mask_opt。同じ条件で各入力を2回採取した。
| 同一FAノードのGPU実行時間 | 文学1回目 | 文学2回目 | コード1回目 | コード2回目 |
|---|---|---|---|---|
| FA本体 | 314.071ms | 310.968ms | 75.840ms | 75.990ms |
| mask前処理 | 3.667ms | 3.729ms | 3.892ms | 3.908ms |
| FAノード合計 | 317.739ms | 314.697ms | 79.733ms | 79.898ms |
FA本体は、文学/コード比で4.141倍、4.092倍。2回ともほぼ4.1倍の差があった。
一方、mask前処理は文学とコードでほぼ同じ(コードが少し遅い)。つまりmaskを準備する処理ではなく、準備したmaskを使ってAttentionを計算するFA本体で大きな差が出ている。
※スクリーンショットの右側のタイムグラフの右端は、文学が約540,000,000μs、コードが約260,000,000μsで縮尺が違うので、実際はグラフを並べた見た目よりも差がおおきい。
先ほどのVulkan GPU performance loggerでの秒単位の数字は64k入力の全61マイクロバッチ、複数層を合計したもの。ここのms単位の数字はKV=49152時点の1つのFAノード。違う範囲を測っているので、単純に足したり同じ倍率が全体へ適用されるわけではない。
シェーダーもKV長も同じなのに、FA本体の速度がここまで違う。この差はどこから来るのかをチャッピー様にソースで調べてもらった。
ソースコードから分かるVulkanのmask処理
ソースを調べると、鍵になりそうなのは次の2つだった。
-
flash_attn_mask_opt.comp:FAに渡すQSA maskを16 query × 64 KVの長方形ごとに調べ、状態を小さな分類情報(2-bit)にまとめる。 -
flash_attn_cm1.comp:FA本体。QSA maskの分類情報を読み、タイル全体が参照不可なら、そのタイルについてcontinueしてK/Vの読み出しや演算を飛ばす。
maskの分類は大きく次のようになっている。
| 分類 | mask内の状態 | FA本体での扱い |
|---|---|---|
ALL_NEG_INF |
16×64のすべてが参照不可(-inf) |
タイル全体をスキップ可能 |
ALL_ZERO |
16×64のすべてがmask 0
|
maskによる制限がないタイル |
| Mixed/Other | 有効・無効が混在するなど | タイル全体は一括スキップできず、内部で追加の判定が必要になり得る |
ここで重要なのは、FAのシェーダーが同じでも、入力によって作られるmaskは同じとは限らないということ。
RGPで見たfa_mask_optの時間は小さかったけど、これはmaskを分類する前処理の時間。スキップするかどうかの判断を使って実際にK/Vを処理するのは後段のFA本体なので、FA本体の実行時間に大きな差が出ても不思議ではない。
maskのタイルを数える
ソースコードの調査結果から、Attentionを計算するときに、タイルを丸ごとスキップできるか、1タイル内に無効/有効が混在しているかで処理時間が変わりそう、ということで、GPU側で生成された2-bit分類結果を読み出してCSVに書き出す処理を入れてみた。
今回実装した診断コードは、GGML_VK_FA_MASK_DIAG=1で有効になって、GPUが作ったQSA mask分類情報を指定したFA呼び出しで1回だけコピーして読み出し出力するもの。Vulkan b11571を使い、RGPと同じKV=49152 / Qcur-19を対象に測った。
N=1024とBr=16からquery側は64行、KV=49152とBc=64からKV側は768列。したがって分類したmaskタイルは64×768=49,152個になる。
| mask分類(KV=49,152、FA 1ノード) | 日本語文学 | C/C++コード |
|---|---|---|
ALL_NEG_INF(丸ごとスキップ可能) |
17,538(35.68%) | 36,881(75.03%) |
ALL_ZERO(全部mask 0) |
3 | 92 |
| Mixed/Other | 31,611 | 12,179 |
| Invalid | 0 | 0 |
| 総タイル数 | 49,152 | 49,152 |
非ALL_NEG_INFタイル |
31,614 | 12,271 |
C/C++コードの方が、丸ごとスキップできるタイルが19,343個も多かった。しかも、一部の行だけが極端に違ったのではなく、64行すべてでコードの方がスキップ可能タイル数が多い。行ごとの中央値は文学279個、コード577個(各行768タイル)だった。
ここで言う75.03%は「入力トークンの75%を捨てている」という意味ではない。16×64のmaskタイルのうち、タイル単位でまとめて計算を飛ばせる部分が75.03%だった、という意味。
では、これだけでFA本体の約4.1倍の差を説明できるかというと、そう単純でもない。スキップされないタイル数の比は31,614 / 12,271 ≒ 2.58倍で、RGPのFA本体時間比約4.1倍とは一致しない。各タイルの配置、GPUのworkgroupやwaveの実行効率、混在タイル内部での判定、メモリアクセスなども影響しそうだけど、今回はそこまで分解できていない。
16トークン、64トークンは実際にどれくらい?
それでは、C/C++コードの方が日本語文学よりスキップできるタイルが多いのは何故なのか?
その理由を直接調べるのは難しそうなので、Qwen3.8-Flash-Nextのtokenizerを使って、今回使った実入力テキストの日本語文学「吾輩は猫である」は本文先頭から、C/C++コード(llama.cpp)は最初の関数定義の先頭から16トークンと64トークンの範囲を切り出してみた。
| 入力 | 16/64トークンの範囲 |
|---|---|
| 日本語文学 16トークン |
吾輩《わがはい》は猫である。名前はまだ無い。 |
| C/C++コード 16トークン |
ggml_tensor * llama_adapter_cvec::tensor_for(int il) const { |
| 日本語文学 64トークン |
吾輩《わがはい》は猫である。名前はまだ無い。 どこで生れたかとんと見当《けんとう》がつかぬ。何でも薄暗いじめじめした所でニャーニャー泣いていた事だけは記憶している。吾輩はここで始めて人間というものを見た。 |
| C/C++コード 64トークン |
ggml_tensor * llama_adapter_cvec::tensor_for(int il) const { if (il < 0 || il < layer_start || il > layer_end|| (size_t) il >= tensors.size()) { return nullptr; } return tensors[il]; } gg |
イメージとしては日本語だと16トークンは短めの1文、普通の1文だと途中まで、ソースコードでも短い1文ぐらい。64トークンは日本語だと数文、ソースコードだとすごく短い関数1個ぐらい。
※「吾輩は猫である」の16トークン、64トークンが1文の切りが良いところに収まっているのはたぶん偶然。
※llama.cppコードの64トークンの末尾にくっついているggは、1個目の関数定義が63トークンで終わって、64トークン目に2個目の関数定義ggml_tensor * llama_adapter_cvec::apply_to(ggml_context * ctx, ggml_tensor * cur, int il)の冒頭部分が入っている。
16×64maskを実文書で考えてみると?
2つの文書のトークンの境界を表示してみた。以下の表は各行16トークン×12行=合計192トークンを表す。
│ =トークンの境界、·=半角スペース、□=全角スペース、↵=改行
※以下の例は入力文書の本文、最初の関数定義の先頭を指定してtokenizeしたもの。前後の文字列が変わるとトークン境界が変わる可能性があるので、実測時の実際のトークン境界を示したものではない
※「吾輩は猫である」に出てくる │ ⟨E7⟩ │ ⟨8D B0⟩ │は、漢字「獰」が文字コードに分解されて2トークンに分かれたもの。珍しい文字は複数トークンに分割されることがあるらしい
日本語文学「吾輩は猫である」
001–016 │ □ │ 吾 │ 輩 │ 《 │ わ │ が │ はい │ 》 │ は │ 猫 │ である │ 。 │ 名前 │ はまだ │ 無い │ 。
017–032 │ ↵ │ □ │ どこ │ で │ 生 │ れた │ かと │ んと │ 見 │ 当 │ 《 │ けん │ とう │ 》 │ が │ つか
033–048 │ ぬ │ 。 │ 何でも │ 薄 │ 暗 │ い │ じめ │ じめ │ した │ 所で │ ニ │ ャ │ ー │ ニ │ ャ │ ー
049–064 │ 泣 │ いていた │ 事 │ だけは │ 記憶 │ している │ 。 │ 吾 │ 輩 │ は │ ここで │ 始めて │ 人間 │ というもの │ を見た │ 。
065–080 │ しかも │ あと │ で │ 聞 │ くと │ それは │ 書 │ 生 │ という │ 人間 │ 中で │ 一番 │ | │ ⟨E7⟩ │ ⟨8D B0⟩ │ 悪
081–096 │ 《 │ どう │ あく │ 》 │ な │ 種 │ 族 │ であった │ そうだ │ 。 │ この │ 書 │ 生 │ というのは │ 時々 │ 我
097–112 │ 々 │ を │ 捕 │ 《 │ つか │ ま │ 》 │ えて │ 煮 │ 《 │ に │ 》 │ て │ 食 │ う │ という
113–128 │ 話 │ である │ 。 │ しかし │ その │ 当 │ 時は │ 何 │ という │ 考 │ も │ なかった │ から │ 別 │ 段 │ 恐
129–144 │ しい │ とも │ 思 │ わ │ なかった │ 。 │ ただ │ 彼の │ 掌 │ 《 │ て │ の │ ひ │ ら │ 》 │ に
145–160 │ 載 │ せ │ られて │ ス │ ー │ と │ 持ち │ 上げ │ られた │ 時 │ 何 │ だ │ か │ フ │ ワ │ フ
161–176 │ ワ │ した │ 感じ │ があった │ ばかり │ である │ 。 │ 掌 │ の上 │ で │ 少し │ 落ち │ ついて │ 書 │ 生の │ 顔
177–192 │ を見た │ のが │ いわゆる │ 人間 │ という │ ものの │ 見 │ 始 │ 《 │ み │ はじめ │ 》 │ であろう │ 。 │ この │ 時
C/C++コード(llama.cpp)
001–016 │ gg │ ml │ _tensor │ ·* │ ·llama │ _adapter │ _c │ vec │ :: │ tensor │ _for │ (int │ ·il │ ) │ ·const │ ·{
017–032 │ ↵ │ ··· │ ·if │ ·( │ il │ ·< │ · │ 0 │ ·|| │ ·il │ ·< │ ·layer │ _start │ ·|| │ ·il │ ·>
033–048 │ ·layer │ _end │ ·|| │ ·( │ size │ _t │ ) │ ·il │ ·>= │ ·tensors │ .size │ ()) │ ·{ │ ↵ │ ······· │ ·return
049–064 │ ·nullptr │ ; │ ↵ │ ··· │ ·} │ ↵↵ │ ··· │ ·return │ ·tensors │ [ │ il │ ]; │ ↵ │ } │ ↵↵ │ gg
065–080 │ ml │ _tensor │ ·* │ ·llama │ _adapter │ _c │ vec │ :: │ apply │ _to │ (g │ g │ ml │ _context │ ·* │ ·ctx
081–096 │ , │ ·gg │ ml │ _tensor │ ·* │ ·cur │ , │ ·int │ · │ ·il │ ) │ ·const │ ·{ │ ↵ │ ··· │ ·gg
097–112 │ ml │ _tensor │ ·* │ ·layer │ _dir │ ·= │ ·tensor │ _for │ (il │ ); │ ↵ │ ··· │ ·if │ ·( │ layer │ _dir
113–128 │ ·!= │ ·nullptr │ ) │ ·{ │ ↵ │ ······· │ ·cur │ ·= │ ·gg │ ml │ _add │ (ctx │ , │ ·cur │ , │ ·layer
129–144 │ _dir │ ); │ ↵ │ ··· │ ·} │ ↵↵ │ ··· │ ·return │ ·cur │ ; │ ↵ │ } │ ↵↵ │ bool │ ·llama │ _adapter
145–160 │ _c │ vec │ :: │ init │ (const │ ·llama │ _model │ ·& │ ·model │ ) │ ·{ │ ↵ │ ··· │ ·const │ ·auto │ ·&
161–176 │ ·h │ params │ ·= │ ·model │ .h │ params │ ; │ ↵↵ │ ··· │ ·GG │ ML │ _ASSERT │ (t │ ensors │ .empty │ ());
177–192 │ ↵ │ ··· │ ·GG │ ML │ _ASSERT │ (ctx │ s │ .empty │ ()); │ ↵ │ ··· │ ·GG │ ML │ _ASSERT │ (buf │ s
「吾輩は猫である」をもとに16トークンquery × 64トークンKVのmaskを考えてみると、例えば
- 129トークン目の「しい」をquery、1-64トークン目をKVとして、queryの「しい」とKVの各トークン「□」「吾」…のそれぞれに対してQSAの条件で参照可能(有効)/参照不可(無効)を計算する。これが1行目の64個のデータ。
- 次に130トークン目の「とも」をquery、1-64トークン目をKVとして同じ処理をする。これが2行目の64個のデータ。
- 同じことをquery=144トークン目まで繰り返すと、16行×64個の有効/無効データができる。これが1タイル。
- この16×64のデータがすべて無効になるタイルは処理をスキップできる。
- 同じようにして、query=129-144トークン目×KV=65-128トークン目、query=145-160トークン目×KV=1-64トークン目などのタイルも作れる。
だけど、トークンに分解してみても、やっぱりC/C++コードの方が日本語文学よりスキップできるタイルが多いのは何故なのかは分からない。
ざっくり言えば無効なqueryとKVの組がC/C++コードでは64トークンぐらいの単位で固まっていて、日本語文学だと散らばっているということなのだろうけど、なんでそうなるのかが分からない。
FA処理の前に位置エンコーディングなどの処理も入っていて、単純にトークンの意味だけで参照可能(有効)/参照不可(無効)が決まるわけではなさそうなので、ちゃんと調べるには実際のmaskパターンとトークンの関係を調べる必要があって、なかなか大変そう。
とういことで今回は深追いはやめて、実装の調査に戻ろう。
ROCmの方はどうなってる?
VulkanのC/C++コードと日本語文学の違いはだいぶ分かってきたけど、前回の番外編4で測った結果では、ROCmだと差は小さく、64kでは日本語文学の方がC/C++コードより少し速かった。
気になるのでROCmのFlash Attention処理がどうなっているのかをソースコードで調べてみた。
その結果、ROCm/HIPのFAはggml-hipがggml-cuda系の実装を共有していて、Vulkanのflash_attn_cm1.compとは別の実装になっていた。
-
Vulkan:maskを16×64のタイルに分類し、過去KVの途中に散在する
ALL_NEG_INFタイルをFA本体でスキップできる。 -
ROCm/HIP:今回調べたMMA系経路では、CUDA用のcompact sparse gather経路はHIPでは無効。ただしmask最適化が全くないわけではない。
flash_attn_mask_to_KV_maxという処理が、末尾側に連続する不要なKVブロックを調べ、計算するKV範囲の終端を短くできる仕組みがある。
つまり、Vulkanは途中にあるスキップ可能タイルを見ていて、ROCmは末尾側の連続した不要範囲を調べる経路がある。仕組みが違うので、入力による時間差の出方も違うのかもしれない。
ただし、ソースに書いてあるだけでは実際にどのカーネルが動いているか分からないので、こちらもRadeon Developer Panel / RGPで調べてみた。
64k、ROCm b11543で採取した結果は次の通り。両入力で同じflash_attn_ext_f16<256,256,16,4,false,false,false>というAMD WMMA系のFA特殊化と、flash_attn_mask_to_KV_max<16>が確認できた。
| ROCm/HIP RGPのイベント | 日本語文学 | C/C++コード |
|---|---|---|
flash_attn_mask_to_KV_max<16> |
46.914µs | 68.492µs |
flash_attn_ext_f16<256,256,16,4,false,false,false> |
104.911ms | 111.198ms |
| Total wavefronts | 3,072 | 3,072 |
| VGPR / SGPR | 256 / 102 | 256 / 102 |
| LDS | 36,864 bytes | 36,864 bytes |
| Occupancy | 4 / 16 | 4 / 16 |
FA本体の時間は、コードの方が約6.0%長い。Vulkanの同一ノードで見た約4.1倍という大きな差とはずいぶん違う。末尾側のmaskを走査するカーネルが実際に動いていることも確認できた。走査処理自体はFA本体よりかなり短いけど、末尾側で実際にKVをどこまで短縮できたかは測っていない。
ただし、ここはVulkanほど厳密に位置合わせができていない。VulkanのようにKV=49152 / Qcur-19というラベルを付けておらず、ROCmで採取したFAの正確なKV長・層・warmupか本計測かを確定できていない。
なので、この104.911ms対111.198msは、採取できた同種のカーネルでは極端な差が見られなかった、という範囲の話。Vulkanの厳密に位置を合わせたFA 1ノードと同条件で数値比較したわけではない。
時間と体力があればROCmにもラベルを付けて位置合わせまでやりたかったけど、今回は時間切れで終了。
まとめ、今後の予定
今回の調査は、最初にSparse FAを疑ったところから、呼び出し条件を比較し、GPU演算別の時間を測り、RGPで同じFAノードを捕まえ、最後にmaskのタイル分類を数えるところまで進んだ。
- 確認できたこと:VulkanのPP速度差の主な部分はFAのGPU時間差に対応する。同じシェーダー・KV長でもFA本体時間は約4.1倍違い、C/C++コードの方が丸ごとスキップ可能なmaskタイルがかなり多かった。
- まだ分からないこと:非スキップタイル数の比約2.58倍とFA時間約4.1倍の差をどう分解するか。ROCmでもKV位置を厳密に合わせたらどうなるか。ROCmとVulkanの全体PP速度差を、各GPU演算ごとにどう説明するか。そもそも何で日本語文学とC/C++コードでスキップタイル数が変わるのか。
最近、実験の目的が「Qwen3.8-Flash-Nextを高速化して快適に使いたい」から、「llama.cppのソースの中身をちょっとずつ理解しながら改造して遊びたい」に変わっている気がする。
高速化したいだけなら、WindowsからLinuxに変えてHalogenかStrataを試すのが一番良いのだろうけど、それは何かやりたいことと違う。
次回はROCmのFA調査はいったん保留にして、MTP高速化の話を書く予定だけど、その途中でEVO-X2のP-MODEでドツボにハマったので、その話を先に書くかもしれない。
付録
Radeon Developer Panelのキャプチャ位置合わせでハマった話
最初はRGPのAuto CaputureのDispatch timerで「開始後195秒付近を256 dispatchだけ取得」という指定をしたのだけど、出てきた上位の重い演算がFAだとは限らない。後から見るとMoEのものも混ざっていた。
そこで、VulkanのFAの処理にEVOX2_FA_MASK_OPT、EVOX2_FA_MAINというデバッグラベルを追加。RGP側でmask前処理かFA本体かに加えて、KV長やQcur-19のようなテンソル名も見られるようにした。ラベルが表示されることまでは確認できたのだけど、timerを195秒、198秒、205秒と動かしても肝心のFAがうまく捕まらない。
結局、時刻ではなくDispatch rangeの開始番号を指定して取得した。Start dispatch=512ではKV=1,024、50,000ではKV=14,336、180,000では狙っていたKV=49,152が捕まった。大きな範囲を一度に取ろうとしてSQTTのデータ切り詰め警告も出たので、最終的にはStart=180,000 / Count=256 / SQTT Highで比較した。
入力ごとにPP速度が違うのだから、実行から同じ秒数が経った時点を取っても、同じKV位置とは限らない。さらに同じKV長でも層が違うと比較にならない。シェーダー名、KV長、Qテンソル名、dispatch形状までそろえることが大事だった。
テストデータの作り方
日本語・文学
- 出典:青空文庫『吾輩は猫である』
- 加工:ダウンロードしたテキストファイルは文字コードがSJISだったのでUTF-8に変換
C/C++ソースコード
- 出典:ggml-org/llama.cpp
- 加工:今回のベースに使った
de7fa0a3c6a2e1b4cd9f22eb8d6bf5b12dbdb63bのソースから、自作スクリプトで推論関連コードを抽出し、1ファイルに統合した
改造版llama-bench実文書入力のコマンド例
64k相当、PPだけを計測する例。今回と同じくファイルの先頭から入力する。-fが入力ファイル指定、-pが処理するtoken数。次の例は64k入力に必要なだけの長さがあるソースファイルを使用する。
.\llama-bench.exe `
-m "(メインモデルのパス)" `
-f "(十分長い実文書ファイルのパス)" `
--prompt-slice head `
-c 65536 `
-p 61789 `
-n 0 `
-d 0 `
-r 1 `
-b 2048 `
-ub 1024 `
-ctk f16 `
-ctv f16 `
-t 4 `
-ngl 999 `
-ncmoe 0 `
-fa auto `
-o json
上記は標準のPP速度測定の例で、診断計測では必要な環境変数や--no-warmupを別途指定した。実験ごとの設定は本文とGitHubの調査記録を参照。
ランダム入力を測るときは-fと--prompt-sliceを外す。128kなら-c 131072 -p 126253に変更する。
これは私が改造したもので、公式llama-benchに同じオプションがあるという意味ではない。改造ソースはgithubリポジトリに置いてある
詳細な検証環境
Hardware
- Device: NucBox EVO-X2
- CPU / APU: AMD Ryzen AI MAX+ 395 with Radeon 8060S
- CPU clock displayed by Windows: 3.00 GHz
- Installed memory: 128GB
- UMA Frame Buffer: 96GB
- Windows CPU側に見えるRAM: 約32GB
- GPU: AMD Radeon 8060S Graphics
- GPU arch:
gfx1151
OS
- OS: Windows 11 Pro
- Version: 26H2(r5測定時)
- OS Build: 26300.9550
- Update: KB5121794(2026-10-08、r5測定前)
- Windows Feature Experience Pack: 計測資料で未確認
ベースのllama.cpp
- Upstream repository: ggml-org/llama.cpp
- r5 upstream pin: de7fa0a3c6a2e1b4cd9f22eb8d6bf5b12dbdb63b
Vulkan:
- Compiler: LLVM / Clang 20.1.8
- Vulkan SDK: 1.4.357.0
ROCm:
- ROCm: 10.0.0 / TheRock
- Compiler: AMD Clang 23.0.0
- Host toolchain: Visual Studio 2022 Build Tools / MSVC 14.44
- GPU target:
gfx1151
使用したモデル
- Repository:
unsloth/Qwen3.8-Flash-Next-GGUF - URL: https://huggingface.co/unsloth/Qwen3.8-Flash-Next-GGUF
- Quantization:
UD-IQ3_XXS - File size reported by llama.cpp: 76.32 GiB
- BPW: 3.71
- Model params: 176.94B
上記モデルを LaurentZuijdwijkのStrix Halo専用fork 内の gguf-py\gguf\scripts\gguf_split_ple_heads.py で変換したPLE16モデルを使った。






