
2026/04/13 4:52
LLVM の RISC-V での 25% の性能劣化の原因追跡
RSS: https://news.ycombinator.com/rss
要約▶
Japanese Translation:
LLVM コマパイラへの最新更新(コミット #190235)により、RISC-V ターゲットにおいて顕著なパフォーマンスの回帰が発生し、特に乱数命令実行を備えた SiFive P550 CPU に多大な影響を与えました。根本原因は、指令結合器の誤ったロジックによる最適化範囲の不適切な縮小であり、具体的には
fpext(float への double 変換)操作が整数から double への直接キャスト(uitofp)に誤って統合され、精度の削減に不可欠な下流の fptrunc(double への float 変換)ヒントが削除されてしまいました。
このエラーにより、コンパイラは遅い 33 サイクルの倍精度除算指令(
fdiv.d)を出力せざるを得なくなり、より速い 19 サイクル単精度のそれ(fdiv.s)を採用することになりました。その結果、この特定のベンチマークにおける実行時間は約 24% 悪化し、影響を受けるコードにおいて LLVM は GCC よりも約 8% スローになりました。この問題は、sitofp/uitofp の連鎖に続いて fpext が存在する場合、それを直接キャストに安全に削減できるかが認識できなかった点にあると判明しており、特にその後に fptrunc が続く場合においてでした。
元のパッチの作成者はこの過ちを認め、PR #190550 によって修復を行いました。解決策は、進んだ範囲解析を拡張して
getMinimumFPType を実装し、複雑な連鎖におけるキャスト認識を正しく処理するために新しいヘルパー関数 canBeCastedExactlyIntToFP を導入することで成されていました。修復後のベンチマークでは、cc-perf における実行時間が約 25% 減少することが確認され、高遅延の fdiv.d 指令を取り除くことに成功し、これらのワークロードについて LLVM のパフォーマンスを GCC と同等に回復させることができました。本文
前回の投稿と同様に、本稿では RISC-V ターゲットにおけるベンチマークに関する私の分析結果について記述します。前回とは異なり、この特定のベンチマークにおいて GCC とのパフォーマンスのギャップを消滅させるパッチに合意することができました!
まとめ (TLDR) 最近の LLVM コミットにより、
isKnownExactCastIntToFP が fpext(sitofp x to float) を倍精度浮動小数点に変換してから単一の直接 uitofp x to double キャストへ折りたたむよう向上しました。しかしながら、この変更は誤って、fpext を介して倍精度から単精度へ狭める(narrowing)という下流の最適化 visitFPTrunc を破損させてしまいました。その結果、RISC-V ターゲットにおいて約 24% のパフォーマンス低下が生じ、fdiv.d(33 サイクルの遅延)が、fdiv.s(19 サイクルの遅延)の代わりに展開されてしまったという問題です。私の修正では、範囲分析を拡張して getMinimumFPType への機能追加を行い、「倍精度浮動小数点への整数キャストから単精度浮動小数点への変換」が「単精度への整数キャスト」へと簡約可能であることを認識させました。これにより、前述の狭める最適化が復元され、パフォーマンス向上を達成しました。
分析
Igalia のサイトで、RISC-V ターゲットにおける LLVM と GCC パフォーマンスの比較を調査していた際、この特定のベンチマークに注意を向けていました。
以下の画像(※実際には挿入不可ですが)に示すように、Sifive P550 CPU 上で特定の本番タスクにおいて、LLVM は GCC よりも約 ~8% 多くのサイクルを必要としていました。
関連する基本ブロックアセンブリの断片を含めています。実質的にすべてのサイクルは以下のアセンブリによって消費されていました。
- LLVM: [アセンブリ断片]
- GCC: [アセンブリ断片]
この 2 つのアセンブリを比較しても、なぜ GCC が優れているのかは直ちに明らかではありませんでした。むしろ非常に似ており、LLVM はここでのブランチ論理さえ最適化できているように見えました。私が確認した大きな違いは、LLVM が倍精度浮動小数点(f64)の除算である
fdiv.d を使用していたことです。
これは有望な兆候だったので、より詳細な状況を把握するために
llvm-mca をソースコードに実行することにしました。なお、私は上流から数日分古くなったものですが、ビルドされたソースコード版の llvm-mca を使用しています。llvm-mca による分析の結果、ループ内では fdiv.d インドレクションは出現していませんでした(上記には表示していませんが)、GCC および LLVM の両方がより後の基本ブロックに fdiv.d インドレクションを含んでいましたが、これはメインループの外側であり、パフォーマンスの差異とは無関係です。
情報
llvm-mca は LLVM スイートに含まれるツールであり、特定の CPU 向けのマシンコードのパフォーマンスを静的に測定する用途で利用できます。
$LLVM_BUILD_DIR/bin/llvm-mca -mtriple=riscv64 -mcpu=sifive-p550 pi.s
本来
fdiv.d が存在する領域では、GCC のように 2 つの fdiv.s が存在していました。このことから、ループ内の fdiv.d インドレクションは間違いなく最近の回帰(regression)であるとの結論に達しました。LLVM は以前より倍精度を単精度へ狭めることが可能でしたが、最新ビルドでは double を float への変換ができなくなっていたのです。
これを事実上の回帰であることを確認するために、同じ CPU およびベンチマークに対する以前の LLVM ビルドと比較を行いました。 https://cc-perf.igalia.com/db_default/v4/nts/profile/260/406/4
以下は、以前ビルドされた LLVM が生成したアセンブリです。 以前のビルドでは
fdiv.d インドレクションはなく、代わりに fdiv.s が使用されました。
乱次実装 (Out-of-Order Execution)
上記のアセンブリと新しい LLVM ビルドのアセンブリの順序が異なる理由を気にされている場合は、ターゲット CPU の Sifive P550 は乱次実行 CPU(out of order CPU)であるためです。前回の投稿で言及された Banana Pi の CPU とは異なり、インオーダー CPU であり、このターゲットはより高いスループットを生み出せる順序で命令を実行することができます。
なぜ
fdiv.d が高いのかについては完全に確信が持てませんが、私の推測では、倍精度除算の遅延が非常に大きいため、CPU は他の命令をスケジュールしてその遅延を「隠そう」としているためと考えられます。fdiv.d の値を使用する fcvt.s.d インドレクション(ft1)は 33 サイクル待機が必要となるため、おそらく CPU はそれらの間に命令をスケジュールすることを決定したのでしょう。
ここでは何が起きているのか?
現時点ではなぜこれが起きたのかは分かりませんが、正しい方向への一歩としては、どこで起こっているかを特定することになるでしょう。もしかすると RISC-V バックエンドの変更かもしれませんか。以下のコマンドが RISC-V に関連するコミットを表示してくれます。
git log --after="2026-04-01 00:11" --before="2026-04-04 00:10" | grep -E "RISC"
- [RISCV] P 拡張に対して add(vec, splat(scalar)) を PADD_*S に選択 (#190303)
- [RISCV] vsetvli のバリアント化を許可する LI に coalesceVSETVLIs を移動させることを許可 (#190287)
- [RISC-V][TTI] vector.extract.last.active のコストを更新し、m8 超過を防ぐ (#188160)
- [RISCV] RISCSIInsertVSETVLI::transferBefore 内で EnsureWholeVectorRegisterMoveValidVTYPE をチェック (#190022)
- [RISCV] vp_ctlz, vp_cttz, vp_ctpop のコード生成を削除 (#189904)
- RISC-V から自明な VP 内在関数を削除する一環として行われました。
- [RISCV] RISCVLoadStoreOptimizer でペアになっていない命令を後ろに移動 (#189912)
は他の命令に隣接する命令を移動させます。RISCVLoadStoreOptimizer
- [RISCV] 圧縮ターゲットに対してスタックマップシャドウの NOP サイズを修正 (#189774)
- [RISCV] convertSameMaskVMergeToVMv の VL 制約を緩和 (#189797)
- [RISCV] RISCVOptWInstrs に SATI_RV64/USATI_RV64 を追加 (#190030)
- RISCVISD::SATI エンコーディングはタイプ幅から 1 を減らします。
- [RISCV][MCA] Sifive-p670 テストをファイル入力を使用するように更新 (#189785)
- [RISCV] vp_minnum, vp_maxnum のコード生成を削除 (#189899)
- RISC-V から自明な VP 内在関数を削除する一環として行われました。
- [RISCV] ComputeKnownBitsForTargetNode/ComputeNumSignBitsForTargetNode に RISCVISD::USATI/SATI を追加 (#189702)
- [RISCV] VSETVLIInfo::hasSEWLMULRatioOnly() へのアサーションを追加。NFC (#189799)
- [RISCV] combine-is_fpclass.ll - ISD::IS_FPCLASS ノードの定数畳み込み失敗を示す初期テストを追加 (#189940)
- [RISCV] VP 浮動小数点丸め内在関数のコード生成を削除 (#189896)
- RISC-V から自明な VP 内在関数を削除する一環として行われました。
- [RISCV] vp_lrint, vp_llrint のコード生成を削除 (#189714)
- RISC-V から自明な VP 内在関数を削除する一環として行われました。
- [RISCV] SATI および USATI のコード生成サポートを追加 (#189532)
これらのコミットのどれもがすぐに目立ちませんでしたので、一旦保留しました。ミドルエンドの調査に戻り、ローカルの LLVM バージョン(数日前のものである)で
opt によって最終的に生成された IR を見てみることにしました。念のため、私のビルドは「動作している」ビルドです。これはパイプラインの最初に生成された LLVM IR(clang の直後)と、最後に生成されたものです。
私が何をしているのか混乱されている場合を想定して、上記の図がこれをより明確に示しているはずです。もしミドルエンドが double を float へ狭めるのに責任があるなら、IR に対してどのような最適化が行われ、それが生じたのかを理解しようとしています。
代わりに、特定のパスで print-before および print-after を使用することもできます。これで IR がどのように変更されているかをパースすることが可能です。
これは最適化パイプラインの最初に生成された IR の関連する断片です。
%conv = sitofp i64 %5 to float ; int -> float %conv2 = fpext float %conv to double ; float -> double %div3 = fdiv double %conv2, 7.438300e+04 ; fdiv.d %conv4 = fptrunc double %div3 to float ; double -> float
これは、パイプラインの終了時点では以下のようになります。
%conv = uitofp nneg i64 %0 to float ; int -> float %conv4 = fdiv float %conv, 7.438300e+04 ; fdiv.s
これらを見てわかるように、ミドルエンドの終わりまでには double が float へと狭められていたことがわかります。これらの断片から、初期の double 値を float へと狭めるのはこのミドルエンドが責任を負っていることは明瞭であると確信します。
なぜ、これらの一見して冗長なキャスト操作が最初に生成されたのか混乱されている場合を想定して、ソースコードに一瞥するだけで理解が深まります。
ソース
int main(int argc, char *argv[]) { float ztot, yran, ymult, ymod, x, y, z, pi, prod; long int low, ixran, itot, j, iprod; ... for(j=1; j<=itot; j++) { iprod = 27611 * ixran; ixran = iprod - 74383*(long int)(iprod/74383); x = (float)ixran / 74383.0; ... } ... }
この特定の行では、ソースコードにおいて
74383.0 は double ですが、float の範囲内に収まります。
x = (float)ixran / 74383.0;
これはなぜ LLVM IR で float から double への変換 (fpext) と、その後から float への倍精度の逆変換 (fptrunc) が発生するのかを説明します。
これは明らかなことかもしれませんが、念のため言っておきます。もし上記の定数自体が元々 float のように以下であれば、fdiv.d は決して生成されなかったでしょう。
x = (float)ixran / 74383.0f;
十分な最適化レベルがある場合でも、コンパイラはこのようなものを検知できるはずです。しかし、コードにおけるわずかな変化がこのような劇的な違いを引き起こす様子は見事です。このケースではサイクル数で +19% 以上の改善となりました!
以下の表は LLVM IR から C ソースへのマッピングを示し、指定された IR が何を行うかについての注記を簡潔に示しています。
| LLVM IR | C ソース | 注釈 |
|---|---|---|
| | 整数乗算 |
| | コンパイラによって最適化された modulo(urem) |
| | 定数 が double なので、まず double へキャストされます |
| | C で が double 定数なので、倍精度除算を行います |
| | 明示的な キャストが結果を再び float へトリンクします |
当時のものであった LLVM ビルドでは、以下の IR が生成されていました。
%mul = mul nuw nsw i64 %ixran.053, 27611 %0 = urem i64 %mul, 74383 %conv2 = uitofp nneg i64 %0 to double %div3 = fdiv double %conv2, 7.438300e+04 %conv4 = fptrunc double %div3 to float
div3 オペランドをみると、%conv2 と小数値が見られます。%conv2 は %0 を double へ変換したキャスト操作の結果ですが、%0 の最大値(74383)は float の範囲内に収まります。
llvm-mca から得られた結果を見ると、以下の fdiv.d が見られます。
1 33 32.00 fdiv.d ft1, ft1, fa3
これは
fdiv.d が 33 サイクルの遅延を持ち、 fdiv.s の 19 サイクルよりも著しく長いことを示しています。パフォーマンス比較では、古い LLVM ビルドは Reciprocal Throughput (RThroughput) で 86.0 を報告したのに対し、新しいビルドでは 100.0 へと増加しました。RThroughput は、プロセッサが次の同種の命令を執行開始する前に待機しなければならないクロックサイクル数を表しています。したがって、値が低いほど良いです。
これは double 除算が float 除算に比べて非常に高価であることを示しています。
上記の過去数日のコミットリストから、以下のものだけが直ちに目立ちました。 [InstCombine] ComputeNumSignBits in isKnownExactCastIntToFP を使用 (#190235)
符号付き整数から FP への変換において、
ComputeNumSignBits は個々の既知ビットがすべて不明な場合であっても、符号伝播を正確に追跡することで ashr(shl x, a), b のようなケースにおいて、計算不能のビット情報を使用して正確さを証明することができます。
InstCombine は LLVM ミドルエンドの最適化パスであり、隣接または関連する命令を単一のより効率的な操作へと結合します。その任務は幅広いので、InstCombine の最適化例は多岐にわたります。例えば、InstCombine は
x = x * 2 を x = x << 1; に簡約することができます。
最新ビルドではもはやその整数を float へ変換できないため、この commit メッセージで言及されている int-to-FP キャスト (
sitofp/uitofp) がすぐに疑いを抱かせてくれました。これが実際に回帰を引き起こしているかどうかは、そのコミット前後に LLVM をビルドして確認できました。この変更前にはすべて動作しましたが、その後 fdiv.d インドレクションがアセンブリに登場しました。
また、このパッチ自体が改善点であることも付け加えておきます。InstCombiner パスにはより多くの情報がありますが、場合によっては改善点が予期せぬ場所での回帰を引き起こす可能性があります。私は、私のような人々が貢献する機会を提供することを提案します 😅
なぜこれが起きたのか?
このコミットの差分を見ると、パッチの著者が関数
isKnownExactCastIntToFP に既知ビット分析を追加したことがわかります。
static bool isKnownExactCastIntToFP(CastInst &I, InstCombinerImpl &IC) { ... // For sitofp, the sign maps to the FP sign bit, so only magnitude bits // (BitWidth - NumSignBits) consume mantissa. if (IsSigned) { SigBits = (int)SrcTy->getScalarSizeInBits() - IC.ComputeNumSignBits(Src, &I); if (SigBits <= DestNumSigBits) return true; } return false; }
この変更の詳細な理解は必要ありませんが、推測すると
isKnownExactCastIntToFP が以前よりも true を返すようになっています。したがって、私は isKnownExactCastIntToFP のすべての呼び出しサイトを調査し、これがどうやって回帰を引き起こすのかをより深く理解することにしました。
以下の関数が
isKnownExactCastIntToFP を呼び出します。これは、先行する命令が整数から FP への変換を行っている場合、fpext インドレクションを削減するために呼ばれます:itofp i64 x to float -> fpext float x to double。これは単に itofp i64 x to double へと簡約することができます。
Instruction *InstCombinerImpl::visitFPExt(CastInst &FPExt) { // If the source operand is a cast from integer to FP and known exact, then // cast the integer operand directly to the destination type. Type *Ty = FPExt.getType(); Value *Src = FPExt.getOperand(0); if (isa<SIToFPInst>(Src) || isa<UIToFPInst>(Src)) { auto *FPCast = cast<CastInst>(Src); if (isKnownExactCastIntToFP(*FPCast)) return CastInst::Create(FPCast->getOpcode(), FPCast->getOperand(0), Ty); } return commonCastTransforms(FPExt); }
そしてこれはそれを組み立てます。 パイプラインの最初に生成された LLVM IR を参照します。
%conv = sitofp i64 %5 to float ; int -> float %conv2 = fpext float %conv to double ; float -> double %div3 = fdiv double %conv2, 7.438300e+04 ; fdiv.d %conv4 = fptrunc double %div3 to float ; double -> float
そして、パイプラインの最後に生成された LLVM IR です。
%conv2 = uitofp nneg i64 %0 to double %div3 = fdiv double %conv2, 7.438300e+04 %conv4 = fptrunc double %div3 to float
そのパッチ後の InstCombiner パスは、
sitofp i64 を float へ変換し、その後 fpext float を double へと変換するものを、単一の uitofp nneg i64 %0 to double へと簡約することができました。しかしながら、visitFPTrunc がコード内のコメントのとおりに後続のパターンを最適化しました。
// 我々は fptrunc(OpI (fpextend x), (fpextend y)) を持つ場合、 // トリンク/拡張操作のうち一つ以上の操作なしに数値的結果を変更しないようにすることができれば、 // この式を簡略化することを望むべきである。 // // オペランドの幅が相互に作用して、何を実行し、何を安全に行えないかを制限する具体的な方法は、 // 操作ごとに異なり、以下の変なケースステートメントで説明されています。
最適化された LLVM IR はもはやその
fpext インドレクションを持っていないため、InstCombiner パス、特に visitFPTrunc は double を float へ狭めることができなくなりました。
ソリューションの実装 (Landing a solution)
したがって、私たちは問題を診断しました - 最近の InstCombine パッチは論理を改善しましたが、それはパスが別の最適化を実行する能力を失う原因となりました。今後必要とされるのは、
fptrunc が後に来ている場合、より早い段階で uitofp/sitofp を狭めるように InstCombiner に教えることです。
最初に私は GitHub でイシューを立ち上げました。これは妥当な問題であると自信を持っていましたが、これが修正に値するのかを確認したかったのです。私以上に先に言及したパッチの著者である @SavchenkoValeriy には感謝しております。彼は私にこの問題を解決するためのガイダンスを提供し、私の PR に対してレビューを提供してくださったからです。私の最初のソリューションはかなり複雑なものになっていたでしょうが、彼は私にはるかにシンプルなアプローチを提供してくれました。
- GitHub Issue: https://github.com/llvm/llvm-project/issues/190503
- PR: https://github.com/llvm/llvm-project/pull/190550
レビュー者によって指摘された通り、現在の
isKnownExactCastIntToFP の問題点は、そのキャスト命令 CastInst のみをチェックしていることです。
%0 = urem i64 %mul, 74383 %conv2 = uitofp nneg i64 %0 to double %div3 = fdiv double %conv2, 7.438300e+04 %conv4 = fptrunc double %conv2 to float
以下は
visitFPTrunc 関数のコードです。異なる操作を含むスウィッチステートメントを見ることができます(FDiv を含む)。これは fdiv.d/fdiv.s に対応します。FPT は fptrunc インドレクションを表し、BO は FPT.getOperand(0) に相当するため、それは %conv2 を指します。私たちは代わりにこの uitofp を float へと変換できるか確認する必要があるため、キャスト操作も合わせてチェックするように getMinimumFPType を修正する必要があります。
Instruction *InstCombinerImpl::visitFPTrunc(FPTruncInst &FPT) { if (Instruction *I = commonCastTransforms(FPT)) return I; ... Type *Ty = FPT.getType(); auto *BO = dyn_cast<BinaryOperator>(FPT.getOperand(0)); if (BO && BO->hasOneUse()) { Type *LHSMinType = getMinimumFPType(BO->getOperand(0), PreferBFloat); Type *RHSMinType = getMinimumFPType(BO->getOperand(1), PreferBFloat); switch (BO->getOpcode()) { default: break; case Instruction::FAdd: case Instruction::FSub: ... ...
私の初期のアイデアの一つは、
isKnownExactCastIntToFP に別のタイプ(私の場合は f32)を持つパラメータを受け取るように修正することでしたが、デフォルト値として nullptr を使用します。これにより、ヘッダー定義と実装のみを変更できることになります。代わりにレビュー者は、isKnownExactCastIntToFP のバリエーションを作成するよう提案しました。canBeCastedExactlyIntToFP です。これは実際のアナリシスをタイプに与え行うものであり、isKnownExactCastIntToFP はそれを呼び出すことができます。私はコミュニティと相互作用し、他の人々からアイデアを求めると良いことを示すためにこの点を言及します。彼らはさまざまな理由によってより優れたアイデアを発見することができます。
以下が最終的な git diff です。私たちは
isKnownExactCastIntToFP を分割し、実際の分析を実行する canBeCastedExactlyIntToFP を作成しました。そして、isKnownExactCastIntToFP がそれをつかいます。最後に、getMinimumFPType は canBeCastedExactlyIntToFP を呼び出します。
--- a/llvm/include/llvm/Transforms/InstCombine/InstCombiner.h +++ b/llvm/include/llvm/Transforms/InstCombine/InstCombiner.h @@ -481,6 +481,8 @@ public: /// Return true if the cast from integer to FP can be proven to be exact /// for all possible inputs (the conversion does not lose any precision). bool isKnownExactCastIntToFP(CastInst &I) const; + bool canBeCastedExactlyIntToFP(Value *V, Type *FPTy, bool IsSigned, + const Instruction *CxtI = nullptr) const; OverflowResult computeOverflowForUnsignedMul(const Value *LHS, const Value *RHS, --- a/llvm/lib/Transforms/InstCombine/InstCombineCasts.cpp +++ b/llvm/lib/Transforms/InstCombine/InstCombineCasts.cpp @@ -2039,10 +2039,17 @@ static Type *shrinkFPConstantVector(Value *V, bool PreferBFloat) { } /// Find the minimum FP type we can safely truncate to. -static Type *getMinimumFPType(Value *V, bool PreferBFloat) { +static Type *getMinimumFPType(Value *V, Type *PreferredTy, InstCombiner &IC) { if (auto *FPExt = dyn_cast<FPExtInst>(V)) return FPExt->getOperand(0)->getType(); + Value *Src; + if (match(V, m_IToFP(m_Value(Src))) && + IC.canBeCastedExactlyIntToFP(Src, PreferredTy, isa<SIToFPInst>(V), + cast<Instruction>(V))) + return PreferredTy; + + bool PreferBFloat = PreferredTy->getScalarType()->isBFloatTy();
見てわかる通り、私たちは今や
fptrunc インドレクションのタイプが渡されるように canBeCastedExactlyIntToFP を呼び出しています。この場合、Src は BO->getOperand(0) の入力であり、BO は fptruc インドレクションの 0 番目のオペランドです。以前見た LLVM IRを思い出す必要があります:
%0 = urem i64 %mul, 74383 %conv2 = uitofp nneg i64 %0 to double %div3 = fdiv double %conv2, 7.438300e+04 %conv4 = fptrunc double %div3 to float
fptrunc は入力を変数へ変換するものであり、したがってタイプ Ty は f32 です。BO は %div3 で、BO->getOperand(0) は %conv2 であり、m_IToFP(m_Value(Src)) は BO->getOperand(0) の入力である %0 を Src へ置きます。
| visitFPTrunc 変数 | LLVM IR 変数 | 値 |
|---|---|---|
| BO | | fdiv double %conv2, 7.438300e+04 |
| BO->getOperand(0) | | uitofp nneg i64 %0 to double |
| BO->getOperand(1) | | double 定数 |
| Src | | urem i64 %mul, 74383 |
結果
これは機能しましたか?以下のコマンドを実行することで、私のパッチ後の LLVM IR を確認できます。
$LLVM_BUILD_DIR/bin/clang -O3 \ --target=riscv64-unknown-linux-gnu \ -march=rv64gc_zba_zbb \ --sysroot=/usr/riscv64-linux-gnu \ -S -emit-llvm pi.c -o pi_fixed.ll
そして、これは関連する LLVM IR です。
%mul = mul nuw nsw i64 %ixran.053, 27611 %0 = urem i64 %mul, 74383 %1 = uitofp nneg i64 %0 to float %conv4 = fdiv float %1, 7.438300e+04
機能しました!🥹
fptrunc はなくなり、キャスト操作 uitofp が今や float へ変換しています。
以下の結果は私のパッチがマージされた後のものです。ターゲットがベンチマークを 1.67 Bn サイクルで実行できていることがわかります。約 25% の改善です。
https://cc-perf.igalia.com/db_default/v4/nts/profile/260/426/422