g++のOfastオプションとNaNの話

C++のプログラムがNaNの周りで不可解な挙動をしたので追ってみたという記事です

結論から言うと、-Ofast-ffast-math)は有限の浮動小数点数しか扱わないので気を付けようということです*1

何もわかってない部分も多いのでツッコミ歓迎です :bow:

背景と概要

最近レイトレの高速化を少しやっていて、その過程でsimdを使ってみたがその結果をうまく取得することができなかった

取得したい結果は_mm256_cmp_pdの大小比較(_CMP_LE_OS)の結果で、仕様によるとtrueのときは0xFFFFFFFFFFFFFFFFが入り、falseの時は0x0が入る

gccではresults[0]のようにして結果の__mm256d型の各要素をdouble型として取得できるので、results[0] != 0std::signbit(results[0])のようにして結果の取得ができると思った*2が、_mm256_cmp_pdの結果と先ほどの判定が一致せず困った

NOTE: 僕の環境はIntelなので今後の内容は主にIntel® 64 and IA-32 Architectures Software Developer Manuals を参照してます

浮動小数点数

今回の話では0xFFFFFFFFFFFFFFFF(全bitが1)と0x0(全bitが0)を内部に持つdouble型が問題になる

一般に、浮動小数点数の指数部のbitが全て1だと無限大やNaNなど特殊な扱いになることが多く、この辺りはアーキテクチャにもよるらしい(wiki

マニュアルを読むと、僕の環境では0x0+Zeroで、0xFFFFFFFFFFFFFFFFNaN*3に属するとわかる

通常、これらを区別するには0との比較(bitがどこかに立ってればfalse)やstd::signbit(最上位bitが立ってればtrue*4)を使えば十分なはずである

// compile option: -std=c++20 -O3

#include <iostream>
#include <bit>
#include <cstdint>
#include <cmath>

int main() {
    // 最適化を防ぐために入力を噛ませている
    // 実行時に 0 を与えて mask64 == UINT64_MAX にしている
    int shift; std::cin >> shift;
    uint64_t mask32 = (1LL<<32) - (1<<shift);
    uint64_t mask64 = (mask32 << 32) + mask32;

    double d = std::bit_cast<double>(mask64);
    std::cout << d << std::endl;               // output: nan
    std::cout << (d==0) << std::endl;          // output: 0
    std::cout << std::signbit(d) << std::endl; // output: 1
}

コンパイラオプション

今回のプログラムは高速化を目的としていたこともあり、高速化用のコンパイラオプションとして-Ofast -march=nativeをつけていた

この-Ofast-O3コンパイラオプションを更に追加したもので、その中でも今回は-ffast-mathが関係している

-ffast-math自体も複数のコンパイラオプションから構成されている*5が、具体的には--ffast-mathの中でも(d==0)のほうは-ffinite-math-onlyオプションで結果が変わり、std::signbit(d)のほうは-fno-signed-zerosオプションで結果が変わるようになる

よって-Ofast環境下では、NaNInfinityを含むdouble型を操作をする場合にはstd::bit_castなど用いて*6bit表現を介することで処理するのが(おそらく)正攻法になる

つまりは今回のようにNaNに対して何か操作をしようとした時点で未定義動作なので何も保証はないのだが、ただの0との比較やstd::signbit判定において何を最適化する部分があるのか気になったのでもう少し深追いしてみた

// compile option: -std=c++20 -O3 -ffast-math

#include <iostream>
#include <bit>
#include <cstdint>
#include <cmath>

int main() {
    // 最適化を防ぐために入力を噛ませている
    // 実行時に 0 を与えて mask64 == UINT64_MAX にしている
    int shift; std::cin >> shift;
    uint64_t mask32 = (1LL<<32) - (1<<shift);
    uint64_t mask64 = (mask32 << 32) + mask32;

    double d = std::bit_cast<double>(mask64);
    std::cout << d << std::endl;               // output: nan
    std::cout << (d==0) << std::endl;          // output: 1
    std::cout << std::signbit(d) << std::endl; // output: 0
}

アセンブラの比較(d==0のケース)

#include <iostream>
#include <bit>
#include <cstdint>
#include <cmath>

int main() {
    // 最適化を防ぐために入力を噛ませている
    // 実行時に 0 を与えて mask64 == UINT64_MAX にしている
    int shift; std::cin >> shift;
    uint64_t mask32 = (1LL<<32) - (1LL<<shift);
    uint64_t mask64 = (mask32 << 32) + mask32;

    double d = std::bit_cast<double>(mask64);
    std::cout << d << std::endl;               // output: nan

    // output: 1 if compiled with -ffinite-math-only else 0
    std::cout << (d==0) << std::endl;
}

上記のコードを-O3オプションでコンパイルしたものと-O3 -ffinite-math-onlyコンパイルしたものを比較してdiffをとった

intel記法で、xmm0edxはゼロクリアされ、xmm1dが入っており、edxの値が直後に出力される状況である

# -O3
<       mov     eax, 0
<       ucomisd xmm1, xmm0
<       setnp   dl
<       cmovne  edx, eax
# -O3 -ffinite-math-only
>       comisd  xmm1, xmm0
>       sete    dl
-O3 -ffinite-math-only(後者)について
comisd  xmm1, xmm0 : xmm1とxmm0の比較を行う
sete    dl : ZFが立っている場合edxの最下位byteを1にする(つまり今回だとedxを1にする)

かなり直感的に処理をしていることがわかる

ただし、comisdの仕様を見ると以下のように記述されており、UNORDEREDの場合でもゼロフラグが立つため、xmm1nanが与えられた場合には常に1が返ってきて、非直感的な結果となるのが確認できる

Operation
COMISD (All Versions)
RESULT := OrderedCompare(DEST[63:0] <> SRC[63:0]) {
(* Set EFLAGS *) CASE (RESULT) OF
  UNORDERED: ZF,PF,CF := 111;
  GREATER_THAN: ZF,PF,CF := 000;
  LESS_THAN: ZF,PF,CF := 001;
  EQUAL: ZF,PF,CF := 100;
ESAC;
OF, AF, SF := 0; }
-O3(前者)について
mov     eax, 0 : eaxに0を代入
ucomisd xmm1, xmm0 : xmm0とxmm1を比較
setnp   dl : PFが0のときedxの最下位byteを1にする
cmovne  edx, eax: ZFが0のときedxにeaxを代入する(つまりedxを0にする)

先ほどと比べてもう少し何かをしてそうなことがわかる

COMISDGREATER_THANまたはLESS_THANのときに限ってZF=0となるので、最後のcmovne命令より、xmm1 > xmm0 or xmm1 < xmm0のときはedx=0である

残りのケースであるCOMISDUNORDEREDのときとEQUALの区別としてsetnpが使われており、EQUALの場合のみPF=0となるのでxmm0=xmm1の時に限ってedx=1となるコードになっていることが確認できる

比較まとめ

xmm1nanのケースを丁寧に弾くか弾かないかで結果と命令数が変わるというだけのことだった

ちなみに、comisducomisdの違いはsource operandQNaNの時に例外シグナルを飛ばすかどうかで、comisdのみシグナルを飛ばす(SNaNの時は両方シグナルを飛ばす)

-ffinite-math-only側でcomisdが使われてるのはNaNなんか知るかという意志を感じる(NaNをサポートしてないので姿勢としては正しそう?)

アセンブラの比較(std::signbit(d)のケース)

#include <iostream>
#include <bit>
#include <cstdint>
#include <cmath>

int main() {
    // 最適化を防ぐために入力を噛ませている
    // 実行時に 0 を与えて mask64 == UINT64_MAX にしている
    int shift; std::cin >> shift;
    uint64_t mask32 = (1LL<<32) - (1LL<<shift);
    uint64_t mask64 = (mask32 << 32) + mask32;

    double d = std::bit_cast<double>(mask64);
    std::cout << d << std::endl;               // output: nan

    // output: 0 if compiled with -fno-signed-zeros else 1
    std::cout << std::signbit(d) << std::endl;
}

上記のコードを-O3オプションでコンパイルしたものと-O3 -fno-signed-zerosコンパイルしたものを比較してdiffをとった

intel記法で、xmm1dが入っており、edxの値が直後に出力される状況である

# -O3
>       mov  rcx, rbx
<       movmskpd        edx, xmm1
<       and     edx, 1
# -O3 -fno-signed-zeros
>       xor     edx, edx
>       mov  rcx, rbx
>       pxor    xmm0, xmm0
>       comisd  xmm0, xmm1
>       seta    dl
-O3(前者)について
mov  rcx, rbx: diffの対応の意味で入れたが、関係ないので無視する
movmskpd        edx, xmm1: xmm1の最上位bitが立っていればedxの最下位bitを1とする
and     edx, 1: edx&1を計算してedxに格納する

すごく単純で何も問題なさそうだが、このmovmskpdがどうやらsimd演算命令で、(多分)命令として遅いのが問題っぽい

仕様読んだところ、この演算はxmm1128bit目も見に行くとのことで、128bit目の情報が下から2番目のbitに格納されるので最後にand`をしている

-O3 -fno-signed-zeros(後者)について
xor     edx, edx: edxをゼロクリアしている
mov rcx, rbx: 関係ない処理。データハザード回避のために迷い込んでいると勝手に予想している
pxor    xmm0, xmm0: xmm0をゼロクリアしている
comisd  xmm0, xmm1: xmm0とxmm1の比較
seta    dl: CF=0かつZF=0のときedxの最下位byteを1にする

comisdCF=0かつZF=0というのはxmm0 > xmm1の時のみ成立するので、つまりはxmm1=nanだとedx=0となる

それ自体はそれでいいのだが、なぜ-fno-signed-zerosという+Zero-Zeroを区別しないというオプションが関係あるのかと考えると、確かにxmm1=-0.0のとき、本来は最上位bitが立っているのでedx=1となってほしいが、xmm0=0.0=-0.0=xmm1よりedx=0となることがわかる

普通のケースでは0未満かどうかを見れば最上位bitが立っているかどうかはわかるので確かに良さそうで、命令数は増えたものの重い命令はないから速い(ということなんだろう)

比較まとめ

-fno-signed-zerosオプションの有無によってnanの扱いも変わるなど、思わぬところにも挙動に違いが出ることがわかった(未定義なので何されても仕方ない)

また、当たり前だが単純に命令数だけではなく命令の重さもちゃんと考慮して最適化してそうなことも伺えた

あと、素人目にはsimdでしか最上位bit取得する命令がないの不思議な感じがする

まとめ

-ffast-mathは結構怖いので使うときは気を付けたほうがよさそう(今回触れたもの以外でも怖そうな最適化をしている)

以下の記事でも、パフォーマンスのチェックに-ffast-mathを使うが、実際にはそのままオプションを足すのではなく、差分と安全性を考慮したうえでソースコードに手を加えるようにしていると書かれている*7 kristerw.github.io

*1:X上ではO3オプションといいましたが、Ofastでした

*2:なるべくcast系の命令を挟みたくなかった

*3:細かくは QNaN だがこの記事ではあまり関係ない

*4:https://en.cppreference.com/w/cpp/numeric/math/signbit にあるように、NaNに対しても使える

*5:https://kristerw.github.io/2021/10/19/fast-math/ がわかりやすい

*6:union型使ってましたが、https://x.com/t5nat/status/1713359396372377625?s=20 で bit_cast の存在を教えてもらいました、ありがとうございます

*7:僕は面倒なので気を付けて Ofast 使うかという気持ちになっているが...