RustではじめるCPUとGPU

数の表現

この章でわかること:

  • 整数の2の補数表現と、オーバーフロー時の挙動(debugビルドとreleaseビルドで挙動が異なる理由)
  • 浮動小数点数(IEEE 754)のビット構造。なぜ0.1が正確に表せないのか
  • 丸め誤差の蓄積の実測と、その対策(Kahanの総和、f64、整数化)
  • NaN・無限大・符号つきゼロ・非正規化数という特殊値の意味と注意点
  • なぜ体系に必須か: 4章の「結合則が成り立たない」も 25章の混合精度も、この章の表現の理解を前提とします

基礎編ではCPUが数を計算する仕組みを説明しましたが、 その数自体がメモリ上でどう表現されているかは説明を省いていました。 応用編の最初の章として、この最も基本的な前提から説明します。

整数の表現: 2の補数

Rustのi32u64は、メモリ上ではただのビット列です。 符号なし整数(u32など)はそのままの2進数ですが、符号つき整数は 2の補数(two's complement)という表現を使います。

規則は1つだけです。最上位ビットの重みを負にします。 8ビットのi8なら、各ビットの重みは −128, 64, 32, 16, 8, 4, 2, 1 です。

 0111_1111 =  127   (i8::MAX)
 1000_0000 = -128   (i8::MIN)
 1111_1111 =   -1

この表現の利点は、加算器が1種類で済むことです。 符号つきでも符号なしでも、ビット列としての足し算はまったく同じ回路で 実行できます(1章add命令に符号つき版と 符号なし版の区別がなかったのはこのためです)。

オーバーフローの意味論

i32::MAX + 1のように表現範囲を超えると (オーバーフロー、overflow)、ビット列の足し算としては 最上位からあふれた桁が消え、結果は最小値へ回り込みます。 Rustはこの状況の扱いをビルド設定で変えます(6章 で述べた挙動の実演です)。まずdebugビルドで実行してください。

オーバーフロー(debugビルド)
use std::hint::black_box;

fn main() {
    // black_box で「実行時にしかわからない値」にする
    // (定数のままだとコンパイル時に検出されてビルドが止まる)
    let a: i32 = black_box(i32::MAX);
    let b = a + 1;
    println!("i32::MAX + 1 = {b}");
}

パニックします。次に、同じコードをreleaseビルドで実行します。

同じコード(releaseビルド)
use std::hint::black_box;

fn main() {
    // black_box で「実行時にしかわからない値」にする
    // (定数のままだとコンパイル時に検出されてビルドが止まる)
    let a: i32 = black_box(i32::MAX);
    let b = a + 1;
    println!("i32::MAX + 1 = {b}");
}

今度はパニックせず、検査なしで-2147483648へ回り込みます。 Rustの設計は、検査には分岐のコストがかかるためreleaseビルドでは 既定で検査を外し、ただし挙動は未定義ではなく回り込みと定義する、 というものです。

意図がある場合は明示のAPIを使います。回り込みが仕様なら wrapping_add(本書の実験で多用してきた理由です)、 あふれを検出したいならchecked_add(Optionを返す)、 上限で止めたいならsaturating_addです。 挙動を型で明示すれば、ビルド設定に依存しないコードになります。

浮動小数点: IEEE 754の構造

小数を扱うf32/f64は、IEEE 754という標準の 2進浮動小数点表現です。構造は「2進数の科学記法」で、 値を ±(1.仮数) × 2^指数 の形で持ちます。 ビットの配置は次の図のとおりです。

S指数 (11bit)仮数 (52bit)符号2^何乗か1.xxxx… の xxxx 部分(先頭の1は暗黙)
f64のビットレイアウト。f32は指数8bit+仮数23bitの同じ構造

仮数は2進の有限桁なので、2の負べきの有限和で書けない数は 正確に表現できません。10進の0.1は2進では無限循環小数になり、 52ビットに丸められます(通常は「最近接偶数への丸め」という規則です)。 実際の値を実験で確認します。

0.1の実際の値を見る
fn main() {
    println!("0.1 + 0.2 == 0.3 : {}", 0.1 + 0.2 == 0.3);
    println!("0.1 + 0.2        = {:.20}", 0.1 + 0.2);
    println!("0.3              = {:.20}", 0.3);
    println!();
    // 0.1 として格納されている64ビットの中身
    println!("0.1 のビット列:");
    let bits = 0.1f64.to_bits();
    println!("  符号: {:b}", bits >> 63);
    println!("  指数: {:011b}", (bits >> 52) & 0x7FF);
    println!("  仮数: {:052b}", bits & ((1 << 52) - 1));
}

0.1 + 0.20.30000000000000004…となり、0.3(こちらも別の 近似値)とは一致しません。JavaScriptでもPythonでも同じ現象が 起きるのは、どれも同じIEEE 754(f64相当)を使っているからであり、 言語の問題ではありません。

整数はどこまで正確に持てるか

f64の仮数は52ビット(暗黙の1を足して53ビット)なので、 連続する整数を一意に表せるのは2^53−1 (約9千兆) までです。 JavaScriptのNumber.MAX_SAFE_INTEGERがこの値です。 それを超えると整数の間隔が2以上に開き、表せない整数が現れます (2^53自体や2の倍数は表せるため、表せる整数が不連続になります)。

丸め誤差: 蓄積の実測と対策

1回の演算の丸めは最終桁の半分以下(相対誤差で約10^-16、f32なら 約10^-7)にすぎず、問題になるのはその蓄積です。 0.1を1000万回足す実験をします。正解は100万です。

0.1を1000万回足す3つの方法
fn main() {
    // 0.1 を1000万回足す。正解は 1,000,000
    let n = 10_000_000;

    // (1) 素朴に足す
    let mut plain = 0.0f32;
    for _ in 0..n {
        plain += 0.1;
    }

    // (2) Kahanの総和: こぼれた誤差 c を覚えておき、次の加算で戻す
    let mut sum = 0.0f32;
    let mut c = 0.0f32;
    for _ in 0..n {
        let y = 0.1 - c;
        let t = sum + y;
        c = (t - sum) - y; // この1行が「こぼれた分」を回収する
        sum = t;
    }

    // (3) 累積だけ f64 で行う
    let mut wide = 0.0f64;
    for _ in 0..n {
        wide += 0.1f32 as f64;
    }

    println!("f32 素朴   : {plain}");
    println!("f32 Kahan  : {sum}");
    println!("f64 で累積 : {wide:.3}");
    println!("正解       : 1000000");
}

筆者の実測(Playground)では、単純なf32の合計は1,087,937で、 正解から約9%ずれます。0.1の表現誤差に加えて、合計が大きくなるほど 「大きい数+小さい数」の加算で小さい側の下位桁が失われていくためです。

対策は次の3つです。

  • Kahanの総和(Kahan summation): 各加算で失われた分を 変数cに保存し、次の加算で加え戻します。誤差はほぼ消えます (コード中の(t - sum) - yは数学的にはゼロですが、 浮動小数点では失われた分がそのまま残ります)
  • 累積だけ幅の広い型で行う: f32のデータでも合計はf64で計算します。 多くの場面で最も簡単な対策です
  • そもそも整数で持つ(コードにはありません): 金額は f64の円ではなくi64の銭やセントで持つのが定石です

4章で見た「部分和に分けると精度も上がる」現象も、 同じ原理(個々の部分和が小さく保たれ、桁落ちが減る)です。 また、加算の順序で結果が変わるため、コンパイラは浮動小数点の 加算を並べ替えられません。4章で述べた結合則の問題は、 この章で説明した表現から生じます。

特殊値: NaN、無限大、符号つきゼロ、非正規化数

IEEE 754は、指数部が全0または全1のビットパターンを 特殊な値のために予約しています。

特殊値の挙動を確かめる
fn main() {
    // NaN は ==, <, >, <=, >= の比較にすべて「偽」で答える(!= だけが真)
    let nan = f64::NAN;
    println!("NaN == NaN : {}", nan == nan);
    println!("NaN <  1.0 : {}", nan < 1.0);
    println!("NaN >  1.0 : {}", nan > 1.0);

    // だから f64 は Ord を実装せず、sort() が直接使えない。
    // 全順序が必要な場面には total_cmp を使う
    let mut v = vec![3.0, f64::NAN, 1.0, 2.0];
    v.sort_by(f64::total_cmp);
    println!("total_cmp でソート: {v:?}");

    println!();
    // 正規化数の下限を割っても、精度を落としながら少しだけ粘れる
    println!("f32 の最小の正規化数      : {:e}", f32::MIN_POSITIVE);
    println!("それを8で割る(非正規化数) : {:e}", f32::MIN_POSITIVE / 8.0);
    println!("表現できる最小の正の値    : {:e}", f32::from_bits(1));

    println!();
    // 符号つきゼロ: 等しいのに割ると符号が現れる
    println!("0.0 == -0.0 : {}", 0.0f64 == -0.0f64);
    println!("1.0 /  0.0  = {}", 1.0f64 / 0.0);
    println!("1.0 / -0.0  = {}", 1.0f64 / -0.0);
}
  • 無限大(inf): オーバーフローや1.0/0.0の結果です。 整数の1/0がパニックするのと違い、浮動小数点は値として計算を続行します
  • NaN(not a number): 0.0/0.0(-1.0).sqrt()の結果です。 最も注意が必要な性質は、自分自身とも等しくないことです。このためf64は 全順序(Ord)を実装せず、Vec<f64>sort()は直接呼べません。 ソートには実験のとおりtotal_cmp(IEEE 754が定める全順序)を使います
  • 符号つきゼロ: 0.0-0.0は等しく比較されますが、 1.0/xの結果はinf-infに分かれます。どちらの側から0に 近づいたかの情報を保存する設計です
  • 非正規化数(subnormal): 最小の正規化数(f32で約1.2×10^-38)を 下回ると、暗黙の1を使わず精度を下げながら、さらに小さい値を 表現します(実験では10^-45まで)。急に0になる代わりに 段階的に0へ近づく(gradual underflow)ための仕組みです

非正規化数と性能

非正規化数の演算は、CPUによっては通常の10〜100倍遅くなることが 知られています(専用の低速な経路で処理されるため)。音声処理などで 減衰する信号が非正規化数の範囲に入り、処理時間が突然増える事例が 典型です。ただしこの性質はハードウェアに強く依存し、 筆者がPlayground(AMD Zen系)で計測した範囲では有意な差が 出ませんでした。近年のCPUには速度低下をほぼ解消したものもあります。 疑わしい場合は自分の環境で計測してください(8章)。

縮小フォーマット: f16、bf16

近年、機械学習を中心に32ビットより狭い浮動小数点形式が 広く使われています。ここでは構造の違いだけを説明します。

形式 構成(符号+指数+仮数) おおよその範囲 10進での有効桁
f64 1+11+52 ±10^308 約16桁
f32 1+8+23 ±10^38 約7桁
f16 (half) 1+5+10 ±65504 約3桁
bf16 (bfloat16) 1+8+7 ±10^38 約2桁

特に重要な形式はbf16です。f32の上位16ビットに相当する形式で (f32からの変換時には通常丸めが入ります)、 指数部がf32と同じため表現できる値の範囲がほぼ変わりません。 精度は大きく低下しますが、機械学習の学習では、桁が合っていること (勾配がオーバーフローやアンダーフローしないこと)が精度より 重要な場面が多く、メモリ帯域と演算器を半分にできる利点が 精度の低下を上回ります。 この形式が実際に使われる場面は、25章の 行列エンジンで紹介します。

まとめ

  • 符号つき整数は2の補数で表現します。オーバーフローはdebugビルドで 検査され、releaseビルドでは回り込みます。意図は wrapping_/checked_/saturating_系のAPIで型に書きます
  • 浮動小数点は「±1.仮数×2^指数」の2進表現です。2の負べきの有限和で 書けない数(0.1など)は最初から近似値です
  • 丸め誤差は蓄積します。Kahanの総和、広い型での累積、整数化が対策です
  • NaNは自分自身と等しくなく、ソートにはtotal_cmpを使います。 非正規化数の性能はハードウェアに依存します
  • bf16はf32と同じ指数幅を持つ短縮形式で、現代の機械学習計算で 広く使われています

次章は、仮想メモリとTLBというアドレス変換の仕組みを扱います。 2章で学んだキャッシュは、アドレス変換というもう1つの階層を 前提にしています。