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 値が関数にレジスタ渡しされてきたならば、それはバグではないか?という疑問が生まれます。そもそもこの値はどこから来たのでしょうか?調べてみました。



この謎の符号拡張されない 32 bit 値は、C99 で導入されたコンパイラ組み込みの型 _Complex から発生しているようです。Clang の RV64I で浮動小数点数がソフトウェアエミュレーションのターゲット(FD 拡張無効)では、float _Complex の crealf() 部を汎用整数レジスタの下位 32 bit に、cimagf() 部を上位 32 bit に持つようです。つまり
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 で扱ってはいけないのかもしれません。

kmckk at 18:59コメント(0)Clang | 若槻 

コメントする

名前
 
  絵文字
 
 
記事検索
最新コメント
アクセスカウンター
  • 今日:
  • 昨日:
  • 累計:

QRコード
QRコード