2024年07月11日
SoftFloatの未定義動作バグ(3)そもそも単精度浮動小数点数演算をソフトウェアエミュレーションする関数の仮引数はfloatにするべき
前々回の記事では
これを「未定義動作バグ」と呼ぶのが適切なのかは自信がありませんが、おそらくプログラマには 「uint32_t を 31 ビット右シフトした場合、bit 0 以外は全て 0 になり、0x0 か 0x1 のどちらかに必ずなる」という暗黙の仮定があったのではないかと思われ、そうとは限らないという意味で未定義動作バグとしました。前回の記事では
コンパイララインタイム関数は C 言語仕様の範疇ではなく、完全にコンパイラの実装と結びついたもの(コード生成の一部と考えられる)なので、なんでもありなのでしょう。
ではなぜ compiler-rt の関数は大丈夫なのか?という疑問が生まれますが、どうもレジスタ渡しされてきた値を一度スタックに書き込んで符号付きに変換して扱っているから大丈夫なようです。などとごまかして終わらせてしまいましたが、ちゃんと調べました。
そもそも本来は __ltsf2 に渡ってくるのは float 型のようで、そのまま uint32_t で扱ってはいけないのかもしれません。
会社の後輩の河田くんに教えてもらったのですが、以下が規格上の根拠となります。
RISC-V Calling Conventions
https://github.com/riscv-non-isa/riscv-elf-psabi-doc/blob/master/riscv-cc.adoc
実際に確認してみました。
前回の記事で正しく動かなかったコードを再録します。
そして compiler-rt の関数は、スタックにプッシュしているからではなく、引数を正しく float で定義しているからちゃんと動くのだ、ということもわかりました。
compiler-rt のやり方を参考にして、以下のようにコードを修正しました。
RISC-V Calling Conventions
https://github.com/riscv-non-isa/riscv-elf-psabi-doc/blob/master/riscv-cc.adoc
Integer Calling Conventionつまりコンパイラランタイム関数であるかどうかとは無関係で、SoftFloat のコードの問題点は純粋に RISC-V の呼び出し規約に正しく準拠していないことから発生しています。つまり float ではなく uint32_t を使って浮動小数点数エミュレーション関数を定義したことが原因でした。uint32_t ではなく float で定義していれば、上位 32 bit は未定義なので、符号拡張を仮定した最適化はできないはずです。
The base integer calling convention provides eight argument registers, a0-a7, the first two of which are also used to return values.
Scalars that are at most XLEN bits wide are passed in a single argument register, or on the stack by value if none is available. When passed in registers or on the stack, integer scalars narrower than XLEN bits are widened according to the sign of their type up to 32 bits, then sign-extended to XLEN bits. When passed in registers or on the stack, floating-point types narrower than XLEN bits are widened to XLEN bits, with the upper bits undefined.
(以下、RV64I = XLEN は 64 を前提とした意訳)
RISC-V の整数呼び出し規約
基本的に整数呼び出し規約では a0-a7 の 8 つの関数引数レジスタを使用し、最初の 2 つ(a0、a1)は戻り値にも利用される。64 bit までのスカラ値は単一のレジスタで関数に渡される。レジスタが足りない場合はスタック上に値渡しされる。
64 bit 以下の幅の整数スカラ値がレジスタかスタックで渡されたとき、型に応じて 32 bit まで拡張され、その後 64 bit に符号拡張される。64 bit 以下の浮動小数点数型(= float)がレジスタかスタックで渡された場合の上位 32 bit は未定義。
実際に確認してみました。
前回の記事で正しく動かなかったコードを再録します。
int __ltsf2(uint32_t a, uint32_t b) { int aSign = a >> 31; int bSign = b >> 31; if (aSign != bSign) return -1; return 1; }このコードからは以下のような、31 bit 同士の比較のはずなのに、符号拡張を仮定して 63 bit 目を比較しているというコードが出ました。
__ltsf2: xor a0, a1, a0 srai a0, a0, 63 ori a0, a0, 1 retたいていの場合はこれでも問題なく動くのですが、前回の記事で検証したように、cimagf() で生成されたゼロ拡張された値が入ってくると正しく動きません。(たいていの場合 float でも符号拡張されますが、未定義なので cimagf() が生成したゼロ拡張された値も正しい値です。)
そして compiler-rt の関数は、スタックにプッシュしているからではなく、引数を正しく float で定義しているからちゃんと動くのだ、ということもわかりました。
compiler-rt のやり方を参考にして、以下のようにコードを修正しました。
$ cat lt_float.c #include <stdint.h> static __inline uint32_t toUINT32(float x) { const union { float f; uint32_t i; } rep = {.f = x}; return rep.i; } int __ltsf2(float x, float y) { uint32_t a = toUINT32(x); uint32_t b = toUINT32(y); int aSign = a >> 31; int bSign = b >> 31; if (aSign != bSign) return -1; return 1; }最適化をかけてもちゃんと動きました。
$ clang $CLANG_CFLAGS -g -O2 test_complex_main.c test_complex.c lt_float.c $PTHREAD_STUBS $QEMU_LDFLAGS $CLANG_LDFLAGS $ /c/msys64/ucrt64/bin/qemu-system-riscv64.exe -M virt -m 2G -nographic -semihosting -bios none -kernel a.out -2.000000 < 0 -> true生成されたコードは以下のように、正しく bit 31 を比較しています。
__ltsf2: xor a0, a1, a0 sraiw a0, a0, 31 ori a0, a0, 1 retunion 型のスタック変数を作っているから遅くなるのでは…と思いましたが、見事に影も形も無く最適化されたコードが出ています。