2024年07月10日
RV64Iでunsignedの32bit値が符号拡張されないで関数にレジスタ渡しされるのはどういう時か?
前回の記事「SoftFloatの未定義動作バグ(2)RISC-VのRV64Iではunsignedの32bit即値でも64bitレジスタの上位32bitが0とは限らない」の補足です。
RISCV RV64I では unsigned な 32 bit 値(uint32_t)であっても、常に汎用レジスタ上では符号拡張された形で扱われます。例えば f(uint32_t) 関数に f(0xc0000000) を渡した場合、第一引数が渡る a0(x10)レジスタには 0x00000000c0000000 ではなく 0xffffffffc0000000 が渡ります。Clang コンパイラもこの仕様を前提とした最適化を行っています。
ならば、符号拡張されない形で 32 bit 値が関数にレジスタ渡しされてきたならば、それはバグではないか?という疑問が生まれます。そもそもこの値はどこから来たのでしょうか?調べてみました。
RISCV RV64I では unsigned な 32 bit 値(uint32_t)であっても、常に汎用レジスタ上では符号拡張された形で扱われます。例えば f(uint32_t) 関数に f(0xc0000000) を渡した場合、第一引数が渡る a0(x10)レジスタには 0x00000000c0000000 ではなく 0xffffffffc0000000 が渡ります。Clang コンパイラもこの仕様を前提とした最適化を行っています。
ならば、符号拡張されない形で 32 bit 値が関数にレジスタ渡しされてきたならば、それはバグではないか?という疑問が生まれます。そもそもこの値はどこから来たのでしょうか?調べてみました。
この謎の符号拡張されない 32 bit 値は、C99 で導入されたコンパイラ組み込みの型 _Complex から発生しているようです。Clang の RV64I で浮動小数点数がソフトウェアエミュレーションのターゲット(FD 拡張無効)では、float _Complex の crealf() 部を汎用整数レジスタの下位 32 bit に、cimagf() 部を上位 32 bit に持つようです。つまり
結論から言いますと、今回の現象は
コンパイララインタイム関数は C 言語仕様の範疇ではなく、完全にコンパイラの実装と結びついたもの(コード生成の一部と考えられる)なので、なんでもありなのでしょう。コンパイラのバグでは無さそうなので、ここで終わっても良いのですが、もう少し詳しく見てみます。
確認のため、以下のようなテストコードを用意しました。ファイルを分けないとコンパイル時に計算されてしまい、デバッガで確認することができなくなるため分けています。また、簡単のため、コンパイラランタイム関数 __ltsf2 は、__ltsf2(-2, 0) のケースしか考慮していません。つまり符号さえ異なれば真を意味する -1 を返しています。
まず、main() 先頭から test_complex() 呼び出しまでです。
あとは前回の記事と同じです。
そして同様にこのコード、本来は 31 bit 目を比較して、0xc0000000 と 0x00000000 は符号が異なる、という結果になるはずが、63 bit 目を比較しているので符号は同じという誤った結果になっています。
ではなぜ compiler-rt の関数は大丈夫なのか?という疑問が生まれますが、どうもレジスタ渡しされてきた値を一度スタックに書き込んで符号付きに変換して扱っているから大丈夫なようです。
float _Complex x = -0.0f + -2.0f * I;の内部表現は 0xc000000080000000 の 64 bit 値の形で汎用整数レジスタに保持されます。
結論から言いますと、今回の現象は
- 浮動小数点数がソフトウェアエミュレーション(FPU 無し)
- 単精度の複素数型 float _Complex を使用
- 実部、または虚数部が負
- float _Complex の crealf() または cimagf() を float として取り出して演算を行う
- 最適化有効
float y = ...; ... if (y < 0.0f)の if 文から __ltsf2(y, 0.0f) 関数呼び出しがコンパイラにより生成されます。
コンパイララインタイム関数は C 言語仕様の範疇ではなく、完全にコンパイラの実装と結びついたもの(コード生成の一部と考えられる)なので、なんでもありなのでしょう。コンパイラのバグでは無さそうなので、ここで終わっても良いのですが、もう少し詳しく見てみます。
確認のため、以下のようなテストコードを用意しました。ファイルを分けないとコンパイル時に計算されてしまい、デバッガで確認することができなくなるため分けています。また、簡単のため、コンパイラランタイム関数 __ltsf2 は、__ltsf2(-2, 0) のケースしか考慮していません。つまり符号さえ異なれば真を意味する -1 を返しています。
$ cat lt.c #include <stdint.h> int __ltsf2(uint32_t a, uint32_t b) { int aSign = a >> 31; int bSign = b >> 31; if (aSign != bSign) return -1; return 1; }
$ cat test_complex.c #include <complex.h> #include <stdio.h> void test_complex(float _Complex z) { float y = cimagf(z); fprintf(stderr, "%f < 0 -> ", y); if (y < 0.0f) { fprintf(stderr, "true\n"); } else { fprintf(stderr, "false\n"); } }複素数の表現をわかりやすくするために -0.0f と負の数にしていますが、この実部は今回は使いません。
$ cat test_complex_main.c #include <complex.h> void test_complex(float _Complex z); int main() { float _Complex x = -0.0f + -2.0f * I; test_complex(x); return 0; }これを弊社の exeClang RISCV(開発版)でコンパイルして QEMU で実行すると、最適化無しの場合は以下のように正しい(符号は異なる)結果になります。
$ clang -v clang version 18.1.7 (省略) $ clang $CLANG_CFLAGS -g test_complex_main.c test_complex.c lt.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しかし最適化をかけると誤った結果になります。
$ clang $CLANG_CFLAGS -g -O2 test_complex_main.c test_complex.c lt.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 -> false実際に何が起きているのかデバッガで見てみましょう。
まず、main() 先頭から test_complex() 呼び出しまでです。
main test_complex_main.c:5:int main() { (省略) 0000000080000398 80000537 lui a0, 0x80000ここで a0 には 0xffffffff80000000 が入ります。
000000008000039C 0015051B addiw a0, a0, 1 00000000800003A0 01F51513 slli a0, a0, 0x1fそして 1 を足して 31 bit 左論理シフト(Shift Left Logical Immediate; slli)することで a0 に複素数 -0.0f(0x80000000) + -2.0f(0xc0000000)i に相当する 0xc000000080000000 を構築していますが、だいぶトリッキーなコードだと思います。よくこんなコード出せるもんだと感心しました。
test_complex_main.c:8: test_complex(x); 00000000800003A4 014000EF jal 0x14そして test_complex(a0 = 0xc000000080000000) を呼び出します。
test_complex test_complex.c:4:void test_complex(float _Complex z) { (省略) 00000000800003C8 02055413 srli s0, a0, 0x20ここで 32 bit 右論理シフト(Shift Right Logical Immediate; srli)することにより float y = cimagf(z); を行って虚数部を取り出しています。この時点での s0 の値は 0x00000000c0000000 で、これが問題の値です!
(省略) test_complex.c:8: if (y < 0.0f) { 00000000800003F4 00040513 mv a0, s0 00000000800003F8 00000593 mv a1, zero 00000000800003FC 04C000EF jal 0x4cそして a0 と a1 に -2(0xc0000000)と 0 を渡して __ltsf2(a0 = 0x00000000c0000000, a1 = 0x0000000000000000) 呼び出し。
あとは前回の記事と同じです。
__ltsf2 lt.c:7: if (aSign != bSign) return -1; 0000000080000448 00A5C533 xor a0, a1, a0 lt.c:9:} 000000008000044C 43F55513 srai a0, a0, 0x3f 0000000080000450 00156513 ori a0, a0, 1 0000000080000454 00008067 retコードを簡単にしたので bltz による判定ではなくなっていますが、シフトして比較…などは最適化で消えて、XOR 一発で符号を比較している点は同じです。XOR(符号が異なっていたら 1)して 63 bit 算術右シフト(Shift Right Arithmetic Immediate; srai)して 1 と OR を取っています。つまり符号が異なっていたら 0xffffffffffffffff と 1 の OR なのでそのまま 0xffffffffffffffff = -1 が返り、一致していたら 0x0000000000000000 と 1 の OR の 0x0000000000000001 が返ります。(ここらへんも本当にすごいコードが出ています。)
そして同様にこのコード、本来は 31 bit 目を比較して、0xc0000000 と 0x00000000 は符号が異なる、という結果になるはずが、63 bit 目を比較しているので符号は同じという誤った結果になっています。
ではなぜ compiler-rt の関数は大丈夫なのか?という疑問が生まれますが、どうもレジスタ渡しされてきた値を一度スタックに書き込んで符号付きに変換して扱っているから大丈夫なようです。
// llvm-18.1.8/compiler-rt/lib/builtins/fp_lib.h #if defined SINGLE_PRECISION typedef uint16_t half_rep_t; typedef uint32_t rep_t; typedef uint64_t twice_rep_t; typedef int32_t srep_t; typedef float fp_t; (省略) static __inline rep_t toRep(fp_t x) { const union { fp_t f; rep_t i; } rep = {.f = x}; return rep.i; } // llvm-18.1.8/compiler-rt/lib/builtins/fp_compare_impl.inc static inline CMP_RESULT __leXf2__(fp_t a, fp_t b) { const srep_t aInt = toRep(a); (省略) // llvm-18.1.8/compiler-rt/lib/builtins/comparesf2.c COMPILER_RT_ABI CMP_RESULT __lesf2(fp_t a, fp_t b) { return __leXf2__(a, b); } (省略) COMPILER_RT_ALIAS(__lesf2, __ltsf2)そもそも本来は __ltsf2 に渡ってくるのは float 型のようで、そのまま uint32_t で扱ってはいけないのかもしれません。