コンテンツにスキップ

数の表現

この章でわかること:

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

基礎編ではCPUが「数を計算する」仕組みを見ましたが、 その「数」自体がどう表現されているかは素通りしていました。 ここから始まる応用編の最初の章として、土台の土台から埋めます。

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}");
}
stable / debugPlaygroundで開く ↗

パニックします。次に同じコードを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}");
}
stable / releasePlaygroundで開く ↗

-2147483648へ黙って回り込みます。 「検査には分岐のコストがかかるため、releaseでは既定で外す。 ただし挙動は未定義ではなく回り込みと定義する」というのがRustの設計です。

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

小数を扱う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));
}
stable / releasePlaygroundで開く ↗

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

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");
}
stable / releasePlaygroundで開く ↗

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

対策は3つ提示しました。

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

4章で見た「部分和に分けると精度も上がる」現象も、 同じ原理(個々の部分和が小さく保たれ、桁落ちが減る)です。 そして、順序で結果が変わるからこそコンパイラは勝手に並べ替えられない—— 4章の結合則の話は、この章の表現の帰結だったわけです。

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

Section titled “特殊値 — 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);
}
stable / releasePlaygroundで開く ↗
  • 無限大(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になる代わりに なだらかに沈む(gradual underflow)ための仕組みです

近年、機械学習を中心に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_系で型に書きます
  • 浮動小数点は「±1.仮数×2^指数」の2進表現。2の負べきの和で 書けない数(0.1など)は最初から近似値です
  • 丸め誤差は蓄積します。Kahanの総和、広い型での累積、整数化が対策です
  • NaNは自分と等しくなく、ソートにはtotal_cmpを使います。 非正規化数の性能はハードウェア依存です
  • bf16はf32と同じ指数幅を持つ切り詰め形式で、現代のML計算の主役です

次章は、メモリの「住所」の仕組み——仮想メモリとTLB——を開きます。 2章で学んだキャッシュの話は、実はアドレス変換という もう1つの階層の上に載っていました。