5 ポイント 投稿者 GN⁺ 2024-06-03 | 2件のコメント | WhatsAppで共有
  • Quake 3で有名になった fast inverse square root は、1 / sqrt(x) を float のビット再解釈と Newton-Raphson 補正によって高速に近似する、当時の性能上の解決策である
  • 核心は、IEEE-754 32ビット float の整数ビットパターンを スケールおよびシフトされた log2(x) 近似 のように扱える点にある
  • 0x5f3759df - (i >> 1)log2(x^-0.5) = -0.5 * log2(x) を整数シフトと減算に移した形であり、マジック定数は 3/2 * 2^23 * (127 - σ) から導かれる
  • その後 y = y * (1.5 - 0.5x * y * y) を1回適用して Newton-Raphson 補正 を行い、Quake のコードでは2回目の反復はコメントアウトされている
  • 1999年には照明計算や 3D ベクトル正規化のために逆平方根が毎秒数百〜数千回必要だったが、現代のハードウェアでは専用の浮動小数点処理のおかげで同じトリックの実用性は低下している

Quake のコードがしていること

float Q_rsqrt(float number) {
  long i;
  float x2, y;
  const float threehalfs = 1.5F;

  x2 = number * 0.5F;
  y  = number;
  i  = *(long*)&y;
  i  = 0x5f3759df - ( i >> 1 );
  y  = *(float*)&i;
  y  = y * ( threehalfs - ( x2 * y * y ) );

  return y;
}
  • この関数は number に対する 逆平方根 1 / sqrt(number) の近似値を計算する
  • 最も有名な部分は、float 値を long として解釈した後に 0x5f3759df - (i >> 1) を実行するビット操作である
  • Quake 3 が発売された 1999 年には逆平方根は遅く高価な演算であり、照明方程式や正規化が必要な 3D ベクトル計算で毎秒数百〜数千回必要とされていた
  • 現代のハードウェアでは、この種の計算は CPU で実行されないことも多く、CPU 上で実行される場合でも発達した専用浮動小数点ハードウェアのおかげで高速である

IEEE-754 32ビット float 表現

  • 32ビット float は3つの部分で構成される
    • Sign: 1ビットで、正負を表す
    • Exponent: 8ビットで、値が属する範囲を決める
    • Mantissa: 23ビットで、その範囲内での位置を線形に表す
  • 一般的な値は次の形で解釈される
N = (-1)^S * 2^(E - 127) * (1 + M / 2^23)
  • B = 127biased exponent に使われるバイアス値で、実際の指数は e = E - B である
  • mantissa は単純に m を掛けるのではなく 1 + m の形で使われる
    • m = 0 なら 2^e
    • m = 1 に近づくと、次の指数範囲である 2^(e+1) の直前までを表現する
  • 指数がすべて 0 の場合は sub-normal 数であり、式が異なる
N = (-1)^S * 2^-126 * m
  • sub-normal は 0 および 0 に非常に近い小さな数を表現するために必要である
  • 指数がすべて 1 の場合は特殊値として扱われる
    • E = 255, M = 0 なら Infinity または -Infinity
    • M != 0 なら NaN

float のビットを整数として見ると現れる対数関係

  • float の内部表現を 32ビット整数として見ると、次の式で表せる
I_x = 2^31 S + 2^23 E + M
  • 逆平方根は正の入力を対象とするため、S = 0 とすると式は単純になる
L = 2^23
I_x = L E + M
  • 同じ exponent 範囲内では mantissa が位置を線形に表すが、exponent が大きくなるほど同じ数の mantissa ステップがより広い数直線区間をカバーする
    • E = 127、つまり e = 0 はおおよそ [1, 2) の範囲
    • E = 128、つまり e = 1 はおおよそ [2, 4) の範囲
    • どちらの範囲も mantissa ステップ数は同じだが、後者の範囲は2倍広い
  • この構造のため、float の生ビットパターンを整数として見ると 対数的な関係 が現れる

生ビットは log2(x) の近似

  • float のビットパターンを整数 I_x として解釈すると、log2(x) に対する区分線形近似のように見ることができる
  • この関係は次の近似式で表される
log2(x) ≈ I_x / L - B
  • 生ビット整数を mantissa サイズ L = 2^23 で割り、exponent bias B = 127 を引くと、log2(x) に近い値になる
  • mantissa 区間内の対数は線形近似として扱う
log2(1 + x) ≈ x + σ
  • σ は近似を調整する チューニングパラメータ であり、x[0, 1] の範囲で exponent 区間内の位置を表す

逆平方根を対数恒等式に変換する

  • 目標は次の値を求めることである
y = 1 / sqrt(x)
  • これは指数形では次のように書き換えられる
y = x^-0.5
  • 対数恒等式を適用すると、逆平方根の計算は次の関係になる
log2(1 / sqrt(x)) = log2(x^-0.5) = -0.5 * log2(x)
  • float のビットが log2(x) 近似のように動作することを利用すると、x の整数ビット表現 I_x から y の整数ビット表現 I_y を直接近似できる
I_y ≈ -0.5 I_x + 1.5 L (B - σ)
  • この式が Quake のコードにある核心の1行につながる
i = 0x5f3759df - ( i >> 1 );
  • i >> 1 は整数ビットを右に1ビットシフトして 1/2 を掛ける役割を果たす
  • 手前の定数 0x5f3759df1.5 * L * (B - σ) に相当する

0x5f3759df 定数の正体

  • σ = 0 とすると、定数は次のように計算される
1.5 * 2^23 * 127 = 1598029824
  • この値の16進表現は 0x5f400000 である
  • Quake の実際の定数 0x5f3759df とは 566817 だけ差がある
  • この差から Quake のコードに対応する σ の値を計算すると次のようになる
σ = 377878 / 2^23
σ = 0.04504656
  • C コードでは次のように同じ定数を計算できる
int32_t compute_magic(void) {
  double sigma = 0.0450465;
  double expression = 1.5 * pow(2.0, 23.0) * (127.0 - sigma);
  int32_t i = expression;
  return i;
}

// -> 0x5f3759df
  • ここでは double を使用しており、整数変換はビット再解釈ではなく通常のキャストである
  • この σ の値は近似を最適化するために選ばれたが、実際の最適値ではなく、誰が作ったのかもはっきりしていない

単なるハックではない理由

  • 0x5f3759df - (i >> 1) は、float の生ビットが対数近似であるという事実を利用して 逆平方根の初期値 を作る式である
  • 複雑な数学的関係に基づいているが、実行段階ではシフトと減算のような高速な演算だけを使う
  • 当時は高価な演算を毎秒数千回処理する必要があったため、この方式はハードウェア制約に適した工学的設計だった
  • ただし、このアルゴリズムは normal float でのみ動作する
    • sub-normal 値では log2(1 + x) ≈ x + σ という近似の前提が成り立たない
    • sub-normal では実際には 0 + x に近い形になるため、近似が破綻する

Newton-Raphson 補正で誤差を減らす

  • ビット操作で得た初期値はかなり良いが、測定可能な誤差は残る
  • 次の行が近似を大きく改善する
y = y * ( threehalfs - ( x2 * y * y ) );
  • この行は Newton-Raphson method を適用した形である
  • 逆平方根の問題を Newton method に合わせるため、次の関数の根を求める問題に変換する
f(y) = 1 / y^2 - x = 0
  • Newton method は現在の近似値 y_n から、より良い近似値 y_(n+1) を次のように作る
y_(n+1) = y_n - f(y_n) / f'(y_n)
  • f(y) = y^-2 - x の導関数は次のとおりである
f'(y) = -2y^-3 = -2 / y^3

除算のない Newton 補正式

  • Newton の式をそのまま使うと、浮動小数点除算が何度も入る
  • このアルゴリズムが高速な理由の1つは 浮動小数点除算の回避 にある
  • 代数的に整理すると、除算なしで乗算だけを使う形になる
y_(n+1) = y_n * (1.5 - 0.5x * y_n^2)
  • Quake のコードでは x2 = number * 0.5F0.5x をあらかじめ計算し、これを次の行で使っている
y = y * ( threehalfs - ( x2 * y * y ) );
  • この1回の反復の後、最大絶対誤差は 0.175% であり、多くの場合はこれより小さい
  • 元のコードには2回目の Newton 反復もあるが、コメントアウトされている
// y  = y * ( threehalfs - ( x2 * y * y ) );   // 2nd iteration, this can be removed

起源と関連アルゴリズム

  • このアルゴリズムは John Carmack が発明したものではなく、正確な起源は 100% 確実ではない
  • 関連情報として Beyond3D の記事がリンクされている: The truth is the exact origin is not 100% certain
  • Chris Lomont は、対数近似段階で 最適な sigma 値 を探す論文を書いている: InvSqrt.pdf
  • CORDIC は浮動小数点なしで加算とビットシフトだけを使って sine と cosine を計算するアルゴリズムであり、fast inverse square root とは詳細な方式が大きく異なる
  • 両アルゴリズムに共通するのは、数学的観察を当時のハードウェア制約に合わせて効率的に適用している点である

2件のコメント

 
joyfui 2024-06-03

忘れた頃にまた上がってくる不思議なコード…w

 
GN⁺ 2024-06-03
Hacker News のコメント
  • 1999年以降に作られたコンピュータなら、たいてい SSE 命令セットをサポートしており、その中には逆平方根を一度に4つ、より高速に計算する _mm_rsqrt_ps がある: https://www.intel.com/content/www/us/en/docs/intrinsics-guid...
    それでも、ここで扱われている手法がまだ完全に無意味になったわけではない。float/int 変換は速いが、rsqrtsqrtpowlog 命令を持たないハードウェアも今なお存在し、こうした演算はこのトリックで近似できる

    • SSE の浮動小数点逆数命令は Intel と AMD で結果が少し異なることがあり、PC 間で決定的な結果を期待すると厄介になり得る: https://robert.ocallahan.org/2021/09/rr-trace-portability-di...
    • 面白いことに、SSE には通常の平方根命令もあるが、逆平方根よりはるかに遅いため、精度低下を許容できるなら sqrt(x)x * 1/sqrt(x) として計算するほうが速い
    • 実際には、コンピュータの圧倒的多数は SSE どころか i386/amd64 命令セット自体をサポートしておらず、エミュレーションではない形でサポートしている割合は減り続けている
      GPU 命令セット、ARM、RISC-V、AVR、PIC、8051、FPGA などでは近似逆平方根演算が組み込まれていることも多いが、おそらくこうしたアルゴリズムで実装されている可能性が高い
  • 記事に少し難癖をつけるなら、こうした計算が最近の CPU では行われないかのような説明は正しくない。ゲームや浮動小数点演算の多いアプリが、すべての浮動小数点演算を GPU に渡したがっているというのはよくある誤解だ
    実際に GPU に渡すのが妥当なのは、大きく均一な処理だけだ。あるオブジェクトを別のオブジェクトの方に向けるための回転行列を構成するような 一回限りのベクトル正規化なら、CPU 側に残したほうが速い。GPU への転送時間を除いて見ても、単一の浮動小数点演算は CPU のほうが速い。GPU は通常クロックが低く、高い FLOP 数を並列性で達成しているからだ

    • ここで言っているのは GPU ではなく FPU を指しているように思う。昔は FPU が非同期に計算しており、現在は CPU に統合された一部と見なされている
  • MMIX 実装を書いてみた。元の入力値が 2^-1021 より大きいという仮定を置いている

  • 興味があるなら、Wikipedia にもこの関数と歴史についての良い説明がある: https://en.wikipedia.org/wiki/Fast_inverse_square_root

  • こういうものをいくつか集めている: https://github.com/ncruces/fastmath/blob/main/fast.go
    関連する StackOverflow の投稿もある: https://stackoverflow.com/questions/32042673/optimized-low-a...

    • 昔作った80年代後半風の 3D エンジンを書き直そうとしていて、こうした手法のコレクションを始めようかと思っていたところなので助かる
    • fastmath パッケージの ベンチマークも見てみたい
  • 難癖をつける時間だ。float の式にタイプミスがあり、-1^S ではなく (-1)^S であるべきだ。前者は常に -1 になる
    生のビットパターンを解釈することが対数の区分的線形近似だという説明も正確ではない。青いグラフのデータ点同士を結ぶ線は実際には存在せず、ビットを半分だけ 1 に設定することはできない。むしろ対数の 離散版に近く、実際に存在するデータ点、つまり赤い線と青い線が交わる点は、文字どおりスケール・移動された対数と同じだ。それ以外は良い記事だ

    • よく理解できない。とても小さな 6 ビット float を考えると、符号 1 ビット、指数 2 ビット、仮数 3 ビットの場合、[010000, 010111] の区間は 2、2.25、2.5、2.75、3、3.25、3.5、3.75 を含む
      しかし、これらの数の底 2 の対数が示唆する仮数はそれぞれ .0000000.0010101.0101001.0111010.1001010.1011001.1100111.1110100 であり、最初を除けば float の 001010 などとは一致しない。[2,4) 区間の float は線形間隔だが、対応する対数はそうではないため、記事の言うように float は対数の 区分的線形近似と見なせる
    • 連続的な区分的線形近似ではなく、離散的な区分的線形近似だ。青い線が連続ではないという点は正しいが、解釈は間違っている。青いグラフは交点がいくつかあるだけではなく、x 軸に沿って等間隔に配置された 256 個の個別の点で構成されている
      グラフ全体であれば、区分的線形パターンの中に 2^32 個の選択肢があるはずだが、元記事が描いているのはそうした全体グラフではない。記事が 32 ビット整数と IEEE-754 32 ビット float 演算を扱っている以上、説明で「離散」を省略しても問題ないと思う
  • 興味深い概念をたくさん説明している良い記事だが、あるセクションの 代数展開が驚くほどひどい
    「最初の形からこの形へ至る正確なステップは多いが、完全性のためにすべて含めた」の後の展開には不要なステップが多く、互いに打ち消し合う符号の誤りもいくつもある。特に2行目から3行目に行くところで、負号が正しく分配されていない。2行目以降なら y_n+1 = y_n + (1 - x * y_n^2) / y_n^2 * (y_n^3 / 2) から始めて、y_n+1 = y_n (1.5 * y_n - 0.5 * x * y_n * y_n) までずっと短く進められるし、中間ステップも正しい。代数を理解している人には自明なステップばかりだと思う

  • 有名なコード片の マジックナンバーは最適な定数ではない。別の定数を使えば、相対誤差をたぶん 0.5% ほどさらに減らせる
    当時は絶対的な最適値を見つけるのが難しかったかもしれないが、今では比較的簡単だ。私もかつてこの沼にはまり、(1/x^2)(1/x) の最適なマジックナンバーを探す Jupyter Notebook を持っている

    • その問いを探究した論文へのリンクが記事の下のほうにある
  • この記事で一番興味深かったのは “How Java's Floating-Point Hurts Everyone Everywhere” へのリンクだった: https://people.eecs.berkeley.edu/~wkahan/JAVAhurt.pdf
    著者は “Old Man of Floating-Point” としても知られる William Kahan だ: https://news.ycombinator.com/item?id=29042853 - An Interview with the Old Man of Floating-Point (1998)

    • 本題とは別だが、JAVAhurt の PDF を読み始めたところ、組版がひどい。単語間隔を過度に、しかも不揃いに広げる TeX パッケージを使ったような感じで、別の文書を OCR したあと余分な空白が入ったようにも見える
      等幅フォント部分にも妙な追加スペースがある。読むのに集中するのが本当に難しく、実際には違うと分かっているが、ほとんど科学オタクのマニフェストのように感じられた
  • 以前見たこの動画が本当に良かった: https://www.youtube.com/watch?v=p8u_k2LIZyo