0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

EVO-X2 x Qwen3.8-Flash-Next番外編5 入力文書と読み込み速度の関係(検証編) FlashAttentionのGPU処理時間が約4.1倍?

0
Last updated at Posted at 2026-10-19

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秒

後半図1.png

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秒

後半図2.png

※この秒数は区間内の複数マイクロバッチを合計した値で、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本体で大きな差が出ている。

RGPのスクリーンショット 文学1回目
スクリーンショット 2026-10-11 011523.png

RGPのスクリーンショット コード1回目
スクリーンショット 2026-10-11 013138.png

※スクリーンショットの右側のタイムグラフの右端は、文学が約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タイル)だった。

後半図4.png

ここで言う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

RGPのスクリーンショット 文学
スクリーンショット 2026-10-11 131534.png

RGPのスクリーンショット コード
スクリーンショット 2026-10-11 132501.png

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形状までそろえることが大事だった。

テストデータの作り方

日本語・文学

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

使用したモデル

上記モデルを LaurentZuijdwijkのStrix Halo専用fork 内の gguf-py\gguf\scripts\gguf_split_ple_heads.py で変換したPLE16モデルを使った。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?