シェーダーで mix()+step() による条件付き移動の最適化を避けるべき理由
(iquilezles.org)- GPUシェーダーで三項演算子や単純な
ifで値を選ぶコードは、通常 条件分岐 ではなく条件付き移動(select)として処理される step()と算術マスキングに置き換えても消せる分岐はないため、いわゆる ブランチ除去最適化 という前提自体が正しくない- AMD と Microsoft のコンパイラ出力には比較と 条件付きマスク/移動 命令が現れ、ジャンプやブランチ命令は見当たらない
step()版は0.0/1.0のマスクを作ったあと、乗算と加算で結果を合成するため、直接条件付き移動するコードより 不要な演算 が増える- 条件によって大きな計算ブロックをスキップする GPU 分岐は依然として有用だが、単純な値選択では生成された マシンコード を確認するほうが安全
単純な値選択は GPU 分岐ではない
- 例の関数
snap45()は、入力ベクトルからx = abs(v.x)を計算したあと、2つの三項演算子で3つのvec2の結果のうち1つを返す - 同じロジックは通常の
if文で書いても維持される - 問題の「最適化」は、三項演算子を
step()と重み付き合成に置き換える方法であるw0,w1,w2をstep()で作るres0,res1,res2をそれぞれ計算するw0*res0 + w1*res1 + w2*res2で最終結果を合成する
- この変換は、元のコードが 条件分岐 を生成するという誤解から出発している
- 単純なレジスタ値の選択は命令ポインタを変えず、予測ミスやパイプラインフラッシュ、命令キャッシュ無効化も引き起こさない
- GPU の実際の分岐は、条件によって大きな計算ブロックをスキップするときには高速で有用になりうる
- ただし例のように単純な値や計算結果を選ぶ場合、生成された マシンコード では分岐が発生しないと見てよい
コンパイラ出力が示す違い
- 元の GLSL 三項演算子コードは AMD コンパイラで比較と条件付きマスク命令に変換される
- 比較:
v_cmp_gt_f32,v_cmp_ngt_f32 - 条件付きマスク:
v_cndmask_b32
- 比較:
- Microsoft コンパイラの出力も同じ構造を示す
- 比較:
lt - 条件付き移動:
movc
- 比較:
- どちらのコンパイラ出力にも jump/branch 命令 は存在しない
step() 方式がより高コストになる理由
step()ベースの方式は、まず条件付き移動で0.0または1.0のマスクを作り、その後に複数の候補結果を乗算と加算でマスキングする- 元のコードは必要な値を直接 条件付き移動 するため、マスク生成と算術合成が追加される
step()方式より無駄が少ない - さまざまなハードウェアで、
step()ベース版は元の版よりはるかに遅く計測される可能性がある - 例のコードにある一部の
abs()GLSL 呼び出しは独立した GPU 命令ではなく 命令 modifier として入るため、この場合のabs()呼び出しはほぼ無料とみなせる float a = mix(b, c, step(y, x));をfloat a = x < y ? b : c;の最適化として推奨するのは誤ったアプローチである
1件のコメント
Hacker Newsのコメント
TFAの結論は正しそうだが、より良い版のコード生成結果だけでなく、両方の版のコード生成結果を見せていれば、議論はもっと強くなったと思う
引用では「最適化したと主張する版は元の版よりはるかに遅い…乗算を2回、加算を1〜2回無駄にする…生成されたマシンコードを見てみよう」と言っているのに、実際には乗算や加算のない良い版しか示していない
それは良い版が問題ないことの証明ではあっても、悪い版のほうがより悪いことの証明にはまだなっていない
もう一方の版の生成コードを見せても、より長いことが分かるだけだった可能性が高く、そちらも分岐が生じるとは期待されないので、大きな価値はなかっただろう
ifがいつ実際に分岐を強制し、いつそうならないのかを知る良い方法があるといい人々が、多少のオーバーヘッドがあってもより高コストかもしれない
mix/lerpを使うのは、分岐が生じることを恐れているからだv = x > y ? a : b;のような最も明快なコードが実際にうまく動くのは良いことだが、同じif構文がある場合には分岐になり、ある場合にはそうならないという点は不安だ本当に分岐してはいけない文脈では、
branch-ifと非分岐 ifが別のキーワードであってほしいし、非分岐キーワードはコンパイラが分岐なしに生成できないならコンパイル失敗にし、分岐キーワードは分岐なしに生成できるなら警告を出してほしい当初はプログラマを怖がらせたくなくて実行モデルを隠し、「スレッド」という抽象化で説明していたし、その後のGPU宣伝でも「CUDAスレッドがものすごく多い」といった言い方を続けたのだと思う
その結果、GPUコーディングには奇妙な迷信が生まれた
実際には、コードに分岐があるほうがよい場合も多く、分岐そのものは速い
問題は、SIMD laneごとに別々の分岐へ抜けられない点にあり、そのためコンパイラは分岐の代わりに両方のコードを出力し、条件に応じて結果をマスクする
したがって、シェーダ入力値、頂点、コンピュートシェーダのインデックスなどに基づく計算は実際には分岐せず、マスキングによって逐次実行される
TFAの例でも
?演算子の両側の値がどちらも計算され、SIMD値に対する条件文も一般には同様だただし、すべてのlaneが同じ値を持つときに計算を高速にスキップする短絡分岐が出ることはあるが、一般には真偽の両方が計算される
実際の分岐を作るのは、スカラレジスタ、つまりシェーダ定数やuniform値に基づく条件文だけで、こうした分岐は非常に速い
たとえばCMOV命令は1995年のP6コアで導入された
分岐はスカラアーキテクチャでも高コストで、コンパイラはいつ代替戦略を使うべきかをできるだけ判断する
ときどき間違えるが、非常に頻繁に間違えるわけではない
条件付き移動が基本であり、実際の分岐はworkgroup全体が同じ方向へ進むuniform分岐の場合にのみ可能な性能最適化だ
a = f(z); b = g(z); v = x > y ? a : b;f()とg()の呼び出しコストが比較的大きいなら、条件付きコードを出すか、それとも両方を計算してから選ぶかはトレードオフになる単純な選択ではなく、判断はコンパイラが下す
コード内のすべての関数を分岐可能/非分岐として色分けするように区別し、非分岐とマークされた関数では
ifが条件付き移動にコンパイルされなければならず、非分岐関数しか呼び出せないようにすることもできる「GPUでは分岐は遅い」という迷信のかなりの部分は、昔のPlayStation 3時代には実際かなり遅かったことに由来する
PS3にはNVIDIA RSX GPUが搭載されており、資料上では分岐は6サイクルだったと記憶しているが、実測では常にそれより遅く出ていた
ワープ内のすべてのスレッドが同じ経路を通る完全にcoherentな分岐でもそうで、incoherentな分岐は
IFEH命令が6サイクルであるうえ、GPUが両方の分岐を実行しなければならないためさらに遅かった今日まで続く「GPU分岐は遅い」という迷信は、そこから始まったのだと思う
今どきのGPU分岐、特にcoherentなものはかなり低コストだ
今日では分岐メカニズム自体のオーバーヘッドは低くなっているかもしれないが、分岐の両側のスループットがアクティブスレッド比率の分だけ下がるという物理的制約はそのままだ
両方の分岐が実行され、命令長も同じなら、両側の平均性能は少なくとも半分に落ちる
だからGPUで分岐が遅いという考えは長く残っており、実際にも正しい
可能であれば、問題を分岐なしで再構成するためにより努力する価値がある
動的分岐を避ける主な理由は、分岐自体が本質的に遅いからというより、こちらに近い
このような分岐回避最適化は、かつては効果があった。
Xbox 360 や古い Intel 内蔵 GPU でプロファイリングしたことがあるが、今ではそうしないほうがよい。
ビット抽出やその他の整数演算も同様。
昔は浮動小数点演算でエミュレートするほうが速かったが、今ではすべての GPU が高速な整数演算を備えている。
たとえば PS5 と Xbox Series S|X のアーキテクチャであるRDNA2 ISAを見ると、整数向けとしては 32 ビットのスカラー命令しか見当たらないように思える。
[0] https://www.amd.com/content/dam/amd/en/documents/radeon-tech...
提示されているコードはすでに分岐のないコードである。
助言する人たちは、条件文のように見える構文がソースにあるかどうかだけで分岐コードかどうかを判断し、それを最適化として避けているつもりらしい。
この記事も関連している: https://medium.com/@jasonbooth_86226/branching-on-a-gpu-18bf...
「GPU で分岐を書く方法をインターネットで尋ねると、地獄の門を開いて悪魔を招き入れるかのように語られることがある。どんな代償を払ってでも避けるべきで、三項演算子や
step()のような奇妙な数学トリックで回避できると言われるだろう。こうした助言の大半は、好意的に見ても古く、多くは単純に間違っている。ここで正しておこう。」プロセッサも変わり、コンパイラも変わる。
こうした細部が重要なら、複数のバリエーションを配布してランタイムで最速のバージョンを選ぶのが最善である。
前にも何度か言ったが、自分で書いたアセンブリを取り除き、普通の C やそれに近いコードに置き換えることで、はるかに高速になったことがある。
そのアセンブリが 10〜20 年前には速かったのかもしれないが、今は状況が違う。
実際にそうしているゲームやエンジンはあまり知らない。
原理的には可能かもしれない。
D3D、GL、Vulkan のようなほとんどの API は性能カウンタを公開しており、ベンダーによって信頼性は異なるものの、代表的なテストシーンを作って何度も再生しながら最適化を測定することはできる。
しかし、多くのゲームは動的に生成されるシーンと動的に生成されるシェーダを使うため、テストすべき組み合わせ数が障害になりうる。
ユーザーにベンチマークが終わるまで待ってもらう必要があるかもしれない。
ハードウェアがあるなら、各ベンダーの複数世代の GPU で事前に測定し、重要な判断だけをハードコードすることはできるだろうが、そのような既存インフラはあまり知らない。
ゲームのシェーダを横取りして、NVIDIA が最適化したカスタムシェーダに置き換える。
そのため NVIDIA ドライバの変更ログには、「ゲーム X を最適化し、40% 高速化」のような文言が見られる。
各シェーダに無限の時間をかけられるわけではない。
重視するハードウェアでプロファイリングし、選んだ方式がどこかの仮想的な未来のプロセッサで遅くなるなら、それはもう仕方がない。
願わくは、そのプロセッサが十分に速くて問題にならないことを期待するしかない。
この記事が正そうとしている誤りや混乱が、ここでも繰り返されているように思う。
記事は条件付き分岐が無料だと主張しているわけではない。
私の見る限り、分岐コードの性能コストについて語る記事でもない。
記事の要点は、提示された形の条件ロジックが条件付き分岐コードにはコンパイルされないということだ。
そして、見た目に条件式らしいものを無理やり隠すという有害な助言を、これ以上広めるべきではないということだ。
実際の分岐コードについては、分岐コードの実行がより複雑なのは自明である。
無料の分岐などなく、合理的な範囲で分岐を避ければ、どんなコードでも高速化する可能性は高い。
幸い、元のコードはすでに分岐のないコードだった。
いつものことだが、最適化に価値があるかを示す普遍的な尺度はない。
[0] ここでは「見た目に」が重要である。生成コードには関心がなく、ソースコードが条件文のように見えないかどうかだけを気にする場合の話だ。
[1] もちろん運が良かったわけではない。IQ に対して、誰かがシェーダコードの一見もっともらしい、しかし誤った改善案を送ってきたのだろうと推測している。
では、コンパイラはなぜ「最適化された」バージョンが同じコードだと分かるほど賢くないのだろうか。
step()を理解して、step() = 0.0、step() == 1.0の場合を別々に最適化できるべきではないのか。少なくとも乗算 1 回は削除できるのだから、通常は条件付きロード/ストアや別の何かに変わるとしても、常に得になるように思える。
コンパイラによっては、場合によってこうした最適化を行う可能性は十分にあるが、コンパイラが理解できないバージョンを書いてしまうことも明らかにありうる。
最適化の大半はドライバ側で行われ、時間がかかりすぎる処理はシェーダコンパイルのスタッターとして現れる。
この最適化が実際に今行われるかどうかは何とも言えないが、常に考慮すべき要素ではある。
問題の「最適化」版がより遅い理由は、
step()関数が実際にはこのように実装されているためである:float step( float x, float y ) { return x < y ? 1.0 : 0.0; }OpenGL の関数が GPU プリミティブを呼び出しているのか、それともエミュレーションされているのかをどうやって知ればよいのか?
HLSL シェーダーではよくそうしていて、仮想命令セットについて多くを学んだ
たとえば GPU には
sincos命令がある一方、逆三角関数はコンパイル時にエミュレートされるのが興味深い性能が重要なら知る必要があるかもしれない
ただし、
stepが専用命令ではなく条件式の上にあるライブラリ関数として実装されているという事実だけでは、専用命令と比べた性能は分からないので、実装そのものにこだわりすぎる必要はないGPU アーキテクチャに興味があるなら、逆アセンブル、オープンソースのドライバーコード、LLVM、ISA 文書を見ればよい
デコンパイルされたシェーダーを見るたびに、だいたい C で考えるものと似ていた
OpenGL のような仕様は多くの組み込み関数の動作を規定しており、実装は標準的なアセンブリ命令でその仕様を満たす形になっている
複数アーキテクチャ向けにデコンパイルしてくれるオンラインサイトを探せばよい
普通は組み込み関数がどう実装されているかを知る必要も、気にする必要もない
気にしているなら、おそらく最適化を考えているのであり、そのときの答えは「測定して、どちらがよいか確認しろ」である
私が学んだ意味では、条件式は分岐である
機械語レベルでは制御フローが実行時に選択されるため、条件付きジャンプは定義上分岐である
step()を使うのは、論理を算術に変えることではなく、ライブラリ関数呼び出しの中に論理を隠しているだけだと考えていたstep()が組み込み関数であったり数学論文に出てくる関数であったりしても、それで変わることはない数学における
step()の定義も文字どおり条件式である条件式なしで適切に最適化したいなら、望む結果に近い連続関数を選び、パラメータを調整して目標にできるだけ近づける必要がある
通常は多項式を選び、標準的な反復近似法を回したうえで、分岐なしに加算・乗算と「妙に具体的な」定数だけからなる
f(x)を作ることになる著者が条件付き移動は「分岐」ではないと強く述べている部分は、あまり理解できない
abs()が GPU 命令ではなく命令修飾子に落ちてタダ同然になるのは、整数の 2 の補数表現と IEEE-754 浮動小数点表現のおかげで、符号ビットを最上位ビットとして扱えるからであるそのため
abs()は最上位ビットを無条件に 0 にするか、読み出す命令でマスクする程度で済むしかし
step()や任意の三項演算、そして私の知る限り条件付き移動命令は、そのような特殊ケースではない基本的な
abs()、sqrt()、三角関数あたりは標準知識に近いが、それ以外はどうせ重要なのか疑問であるstep()はどこかで条件を持たざるをえず、自分で書こうがライブラリに任せようがハードウェアに任せようが、本質的な性質は変わらないこの罠にはまったことがある
Claude や ChatGPT もこれを 最適化 として提案することがある
しかし毎回測定してみると性能は落ちていて、時にはかなり大きく落ちていた
LLM は学習コーパスにある内容を繰り返しているだけである
インターネットの大半が、このような条件付き移動の「最適化」のような誤った内容を勧めていれば、LLM もそう勧める