2024年07月05日
SoftFloatの未定義動作バグ(2)RISC-VのRV64Iではunsignedの32bit即値でも64bitレジスタの上位32bitが0とは限らない
前回の記事「SoftFloatの未定義動作バグ(1)signedのunsignedな絶対値を求める際にINT_MIN」の続きです。
前回の記事では肝心なことを書き忘れたのですが、libc の「fp ソフトウェアエミュレーション」とは、つまり libc でコンパイラランタイムライブラリ(GCC の libgcc や LLVM の compiler-rt)の関数を実装しているということです。FPU が無いターゲットでは、コンパイラは fp 命令の代わりにこの関数を呼び出します。
なぜこんな面倒なことをしているかというと、libgcc も compiler-rt も fenv.h を考慮せず、常に FE_TONEAREST で fp 演算を行うからだと思われます。現実的には FPU が無い CPU で丸め方式の考慮が必要になるようなプログラムを実行するケースは稀だと思いますが、NetBSD にはこだわりがあるのでしょう。(弊社もできる限り全てのサポートターゲットが標準に準拠した動作を行うコンパイラツールチェーン製品を目指しています。)
前回の記事では肝心なことを書き忘れたのですが、libc の「fp ソフトウェアエミュレーション」とは、つまり libc でコンパイラランタイムライブラリ(GCC の libgcc や LLVM の compiler-rt)の関数を実装しているということです。FPU が無いターゲットでは、コンパイラは fp 命令の代わりにこの関数を呼び出します。
なぜこんな面倒なことをしているかというと、libgcc も compiler-rt も fenv.h を考慮せず、常に FE_TONEAREST で fp 演算を行うからだと思われます。現実的には FPU が無い CPU で丸め方式の考慮が必要になるようなプログラムを実行するケースは稀だと思いますが、NetBSD にはこだわりがあるのでしょう。(弊社もできる限り全てのサポートターゲットが標準に準拠した動作を行うコンパイラツールチェーン製品を目指しています。)
さて、前回の記事では SoftFloat のテストスイートは全て通ったのに、exeClang RISC-V RV64 の問題は解決しませんでした。こうなるとやっかいです。振り出しに戻ってしまいました。
今度はしらみつぶし作戦です。libc で compiler-rt の関数を実装しているということは、compiler-rt 側の実装は潰してあるということです。弊社では objcopy の --redefine-syms オプションを使用してシンボルをリネームすることでこれを実現しています。
これを逆転させて、まずは libc 側の実装を全て潰して、compiler-rt の関数を使うようにツールチェーンを修正しました。そしてこの状態で商用テストスイートが pass することを確認しました。
その後は地道に二分探索で関数を絞り込んでいき、最終的に __gtsf2 が真犯人であることがわかりました。そして __gtsf2 の実装の本体は SoftFloat の float32_lt です。かなり絞り込めました。
ここまで来たら __gtsf2 に printf を仕込み、pass する時と fail する時の引数と戻り値のログを取って比較します。そして __gtsf2(0xc0000000, 0x00000000) の時に本来は -1 が返るはずなのに 1 が返っていることがわかりました。
ここまで来たら、後は関連する関数を全部切り出して単体テストを作ってじっくりと見て行けば良いだけです。a と b は異なるので、float32_eq は無視して、float32_lt に絞られます。
この現象を理解するためには、CPU のレジスタを直接観測できるデバッガで追う必要がありました。全く予想外の原因だったので、コードリーディングや printf デバッグだけで解決するのは無理だったと思います。
SoftFloat のもともとのバイナリも、単体テストも、生成されたアセンブラコードは全く同じでした。以下が問題の float32_lt の実装です。
(参考:https://lpha-z.hatenablog.com/entry/2020/01/12/231500 )
つまり、コンパイラのバグかライブラリのバグかは不明ですが、なぜか符号拡張されない状態の 32 bit 即値が渡ってくるパターンが発生していて、符号拡張 64 bit 表現が前提の最適化コードが正しく動かないというのが真相でした。
どうしたものか悩みましたが、最終的に、暗黙的な仮定を陽に記述して「符号 bit を正の 0 か 1 で返す関数」という意味が明確な、以下のような形に修正しました。
今度はしらみつぶし作戦です。libc で compiler-rt の関数を実装しているということは、compiler-rt 側の実装は潰してあるということです。弊社では objcopy の --redefine-syms オプションを使用してシンボルをリネームすることでこれを実現しています。
これを逆転させて、まずは libc 側の実装を全て潰して、compiler-rt の関数を使うようにツールチェーンを修正しました。そしてこの状態で商用テストスイートが pass することを確認しました。
その後は地道に二分探索で関数を絞り込んでいき、最終的に __gtsf2 が真犯人であることがわかりました。そして __gtsf2 の実装の本体は SoftFloat の float32_lt です。かなり絞り込めました。
ここまで来たら __gtsf2 に printf を仕込み、pass する時と fail する時の引数と戻り値のログを取って比較します。そして __gtsf2(0xc0000000, 0x00000000) の時に本来は -1 が返るはずなのに 1 が返っていることがわかりました。
ここまで来たら、後は関連する関数を全部切り出して単体テストを作ってじっくりと見て行けば良いだけです。a と b は異なるので、float32_eq は無視して、float32_lt に絞られます。
#include <stdio.h> /* 省略 */ flag __gesf2(float32 a, float32 b) { if (float32_eq(a, b)) return 0; return float32_lt(b, a) ? 1 : -1; } flag __gtsf2(float32 a, float32 b) { return __gesf2(a, b); } int main() { fprintf(stderr, "__gtsf2(0xc0000000, 0x00000000) = %d\n", __gtsf2(0xc0000000U, 0x00000000U)); return 0; }しかし、なんと再現しませんでした。再現することが確認済みのコンパイル済み softfloat.o をリンクしても同じ。これにはかなり戸惑いました。
この現象を理解するためには、CPU のレジスタを直接観測できるデバッガで追う必要がありました。全く予想外の原因だったので、コードリーディングや printf デバッグだけで解決するのは無理だったと思います。
SoftFloat のもともとのバイナリも、単体テストも、生成されたアセンブラコードは全く同じでした。以下が問題の float32_lt の実装です。
flag float32_lt( float32 a, float32 b ) { flag aSign, bSign; if ( ( ( extractFloat32Exp( a ) == 0xFF ) && extractFloat32Frac( a ) ) || ( ( extractFloat32Exp( b ) == 0xFF ) && extractFloat32Frac( b ) ) ) { float_raise( float_flag_invalid ); return 0; } aSign = extractFloat32Sign( a ); bSign = extractFloat32Sign( b ); if ( aSign != bSign ) return aSign && ( (bits32) ( ( a | b )<<1 ) != 0 ); return ( a != b ) && ( aSign ^ ( a < b ) ); }この前半部分は NaN チェックで、今回の a と b は NaN では無いので無視して良いです。すると、実装全てを載せても、以下だけになります。
INLINE flag extractFloat32Sign( float32 a ) { return a>>31; } flag float32_lt( float32 a, float32 b ) { flag aSign, bSign; aSign = extractFloat32Sign( a ); bSign = extractFloat32Sign( b ); if ( aSign != bSign ) return aSign && ( (bits32) ( ( a | b )<<1 ) != 0 ); return ( a != b ) && ( aSign ^ ( a < b ) ); }そして 0xc0000000 は -2 なので a と b の符号は異なるため、最終的には以下の 1 行が正しく動けば良いことになります。意味も明確。符号が異なり、指数部と仮数部が非ゼロならば、a が負の場合 a < b は真、正の場合は偽となります。さらに今回は a が非ゼロなので、符号の比較さえ正しければ動くはず…ここまでくると動かない方が不思議に思えてきます。
if ( aSign != bSign ) return aSign && ( (bits32) ( ( a | b )<<1 ) != 0 );実際にこの C コードから出るアセンブラコードは以下のようになります。(デバッガの画面のコピペです。)かなり最適化されたコードなのでわかりにくいですが、符号の比較を行う際にわざわざシフトなんてしてません。a(a0 レジスタ)と b(a1 レジスタ)の xor を取って符号が 1 なら(aSign == bSign なので)次に行くというコードになっています。これ、コンパイラが unsigned でも 32 bit 値の 31 bit 目の比較は bltz(if a2 < 0 branch 0x1c)を使った符号判定に置き換えられるという知識を持ってるってことなんですかね?予想外のコードだったので驚きました。
softfloat.c:2161: if ( aSign != bSign ) return aSign && ( (bits32) ( ( a | b )<<1 ) != 0 ); 0000000080005CD8 00A5C633 xor a2, a1, a0 0000000080005CDC 00064E63 bltz a2, 0x1c softfloat.c:2162: return ( a != b ) && ( aSign ^ ( a < b ) ); 0000000080005CE0 00C03633 snez a2, a2 0000000080005CE4 00B535B3 sltu a1, a0, a1 0000000080005CE8 00052513 slti a0, a0, 0 0000000080005CEC 00B54533 xor a0, a0, a1 0000000080005CF0 00A67533 and a0, a2, a0 softfloat.c:2164:} 0000000080005CF4 00008067 retさて、アセンブリコードを見ても正しそうです。しかし実際にデバッガで動く時と動かない時を見てみると…
fail a0(x10) 0000000000000000 a1(x11) 00000000C0000000 pass a0(x10) 0000000000000000 a1(x11) FFFFFFFFC0000000これ最初私も意味がわからなかったのですが、調べてみると RISC-V の 32 bit 即値は、以下のようにして lui 命令でロードされ
00000000800003A8 C0000537 lui a0, 0xc0000符号拡張された表現で 64 bit レジスタで扱われるのが正しい仕様だそうです。最初デバッガかシミュレータがバグってるのかと思いました。そして bltz が見ている符号 bit は bit 31 ではなく bit 63 なので、符号拡張されていない場合は正しく動かないということでした。
(参考:https://lpha-z.hatenablog.com/entry/2020/01/12/231500 )
つまり、コンパイラのバグかライブラリのバグかは不明ですが、なぜか符号拡張されない状態の 32 bit 即値が渡ってくるパターンが発生していて、符号拡張 64 bit 表現が前提の最適化コードが正しく動かないというのが真相でした。
どうしたものか悩みましたが、最終的に、暗黙的な仮定を陽に記述して「符号 bit を正の 0 か 1 で返す関数」という意味が明確な、以下のような形に修正しました。
INLINE flag extractFloat32Sign( float32 a ) { volatile uint32_t u = a & 0xffffffff; return u>>31; }これを「未定義動作バグ」と呼ぶのが適切なのかは自信がありませんが、おそらくプログラマには 「uint32_t を 31 ビット右シフトした場合、bit 0 以外は全て 0 になり、0x0 か 0x1 のどちらかに必ずなる」という暗黙の仮定があったのではないかと思われ、そうとは限らないという意味で未定義動作バグとしました。(C 言語仕様の「未定義動作」とはおそらく異なりますが、前回のタイトルと揃えることを優先しました。)