IEEE 754は実数をどのように保存するのか
binary32、binary64、丸め、NaN、累積誤差を理解する
IEEE 754は実数をどのように保存するのか
この記事は講義を目的として作成しています。ハードウェア、コンパイラ設定、言語処理系によって細部は変わるため、重要な動作は実際の環境でも確認してください。
0.1 + 0.2が正確な0.3にならないと知り、計算機が計算を間違えたように感じることがある。しかし原因は加算能力ではなく表現方法だ。無限に存在する実数を有限個のビットパターンへ収めるため、多くの値は最も近い表現可能値として保存される。
IEEE 754はビット配置だけでなく、演算、丸め、無限大、NaN、gradual underflow、変換、例外状態を規定する。ここではbinary32とbinary64を分解し、0.1が近似になる理由と実務上の扱い方を説明する。
固定小数点と浮動小数点
固定小数点は小数点位置をあらかじめ決める。整数12345を常に小数二桁として扱えば値は123.45になる。一定の単位には便利だが、非常に小さい値と大きい値を同じ形式で扱う範囲は狭い。
浮動小数点は科学表記に似ている。
6.25 = 1.5625 × 2²
0.15625 = 1.25 × 2⁻³
仮数と指数を分けることで広い範囲を表現する代わりに、隣接する値の間隔は一定ではない。
IEEE 754が扱うもの
IEEE 754-2019は二進および十進の形式、演算、変換、丸め、例外条件と既定処理を定める。実装はハードウェア、ソフトウェア、または両方を利用できる。
| 名前 | よく対応する型 | 全体 | 符号 | 指数 | 保存fraction | 精度 |
|---|---|---|---|---|---|---|
binary32 | 通常float | 32 | 1 | 8 | 23 | 24ビット |
binary64 | 通常double | 64 | 1 | 11 | 52 | 53ビット |
型名だけですべての処理系に同じ形式が強制されるわけではない。C++ではstd::numeric_limits<T>::is_iec559などを確認できる。
binary32の三つの領域
符号1ビット | 指数8ビット | fraction 23ビット
通常の有限値は次の式で表せる。
(-1)^s × (1.fraction)₂ × 2^(E - 127)
指数は127のbiasを使う。正規化された二進仮数の先頭は必ず1なので保存せず、暗黙のビットとして扱う。そのため保存fractionが23ビットでも精度は24ビットになる。binary64のbiasは1023で、52ビットのfractionから53ビット精度を得る。
6.25を符号化する
6.25₁₀ = 110.01₂ = 1.1001₂ × 2²
符号は0、保存指数は2 + 127 = 129、二進では10000001になる。暗黙の1以降は1001000...0だ。
0 10000001 10010000000000000000000
16進数では0x40C80000である。
float value = 6.25f;
std::uint32_t bits = std::bit_cast<std::uint32_t>(value);
特別な指数パターン
| 指数 | fraction | 意味 |
|---|---|---|
| 全て0 | 全て0 | 符号付き0 |
| 全て0 | 0以外 | subnormal |
| 1から254 | 任意 | 正規化有限値 |
| 全て1 | 全て0 | 正または負の無限大 |
| 全て1 | 0以外 | NaN |
この予約により、binary32の正規化指数は-126から127になる。
正の0と負の0
指数とfractionが0でも符号ビットは残るため、+0と-0が存在する。通常の比較では等しいが、逆数などでは符号が現れる。
double p = 0.0;
double n = -0.0;
std::cout << (p == n) << '\n';
std::cout << std::signbit(n) << '\n';
std::cout << 1.0 / p << '\n';
std::cout << 1.0 / n << '\n';
一般的なIEEE動作では最後の二つは正と負の無限大になる。
subnormalとgradual underflow
0の近くで暗黙の1を使い続けると、最小正規化数と0の間に隙間ができる。保存指数が0でfractionが0でないときは次の式を使う。
(-1)^s × (0.fraction)₂ × 2^-126
精度を少しずつ失いながら0へ近づく値がsubnormalである。binary32の最小正規化正数は約1.17549435 × 10^-38、最小subnormal正数は約1.40129846 × 10^-45だ。
性能重視の環境ではsubnormalを0へflushするモードがある。SIMDやGPUの計算では設定を確認する必要がある。
無限大とNaN
指数が全て1でfractionが0なら無限大になる。オーバーフローや0での除算が既定処理のもとで無限大を生成する場合がある。
fractionが0でなければNaN、Not a Numberである。0.0 / 0.0や負数の実平方根などがNaNを作る。
NaNは順序付けできず、自分自身との等価比較も偽になる。
double nan = std::numeric_limits<double>::quiet_NaN();
std::cout << (nan == nan) << '\n';
std::cout << std::isnan(nan) << '\n';
NaNの検出にはstd::isnanを使う。quiet NaN、signaling NaN、payloadの対応は言語とハードウェアで異なる。
0.1が正確にならない理由
0.1は1 / 10である。既約分数が有限二進小数になるには分母の素因数が2だけでなければならない。10には5も含まれるため、二進表現が循環する。
0.1₁₀ = 0.000110011001100110011...₂
binary64は53桁分の有効ビットしか保存できず、残りを丸める。
std::cout << std::setprecision(20) << 0.1 << '\n';
典型的には0.10000000000000000555と表示される。0.1 + 0.2の差は、入力の保存時と加算結果の保存時に起こる丸めから生じる。
値の間隔は大きさで変わる
浮動小数点値は実数直線上に均等に並ばない。binary32は24ビット精度なので16,777,216までの整数を正確に表せる。その先では隣接値の差が2以上になる。
float a = 16'777'216.0f;
float b = a + 1.0f;
std::cout << (a == b) << '\n';
一般的なbinary32では真になる。この大きさではa + 1専用の表現がない。
epsilonは万能ではない
std::numeric_limits<double>::epsilon()は1と次の表現可能なdoubleの差であり、あらゆる大きさに使える許容誤差ではない。0付近の絶対誤差と、それ以外の相対誤差を組み合わせることが多い。
bool almostEqual(double a, double b,
double absoluteTolerance = 1e-12,
double relativeTolerance = 1e-9)
{
double difference = std::abs(a - b);
if (difference <= absoluteTolerance)
{
return true;
}
double scale = std::max(std::abs(a), std::abs(b));
return difference <= scale * relativeTolerance;
}
許容値は座標、時間、物理など対象分野の単位と要件から決める。
丸めモードと例外状態
正確な結果が二つの値の間にあるとき、丸め方向が保存値を決める。一般的な既定値は最近接へ丸め、完全な中間なら最終ビットが偶数になる側を選ぶ方式である。
CとC++は<fenv.h>と<cfenv>で対応する丸め方向と状態フラグを扱う。
IEEEの五つの例外状態はinvalid operation、division by zero、overflow、underflow、inexactである。これは通常C++のthrowではなく、結果を生成して状態フラグを記録する仕組みだ。
std::feclearexcept(FE_ALL_EXCEPT);
volatile double one = 1.0;
volatile double zero = 0.0;
volatile double result = one / zero;
if (std::fetestexcept(FE_DIVBYZERO))
{
std::cout << "division by zero flag\n";
}
演算順序と累積誤差
中間結果が毎回丸められるため、有限精度の加算は結合法則を満たさない場合がある。
double large = 1e16;
double small = 1.0;
double first = (large + small) - large;
double second = small + (large - large);
最初の式ではsmallが失われることがある。多数の値を加算する場合はKahan summationのような補償加算、似た大きさ同士の加算、より広い累算型を検討できる。
double kahanSum(const std::vector<double>& values)
{
double sum = 0.0;
double correction = 0.0;
for (double value : values)
{
double adjusted = value - correction;
double next = sum + adjusted;
correction = (next - sum) - adjusted;
sum = next;
}
return sum;
}
金額と正確な値
二進浮動小数点はグラフィックス、座標、多くの科学計算に適している。一方、正確な十進丸めが必要な金額には通常向かない。
- 円やセントなど最小単位を整数で保存する。
- decimalまたは固定小数点型を使う。
- 丸めの時点と方向を業務規則として定める。
識別子や正確でなければならないカウンタも整数型を使う。
シリアライズ
ファイルやネットワークに保存するときは、単なる型名ではなく形式を定義する。
- binary32かbinary64かを明記する。
- 整数ビットパターンへ移してプロトコルのエンディアンを適用する。
- 符号付き0とNaN payloadを保存する必要があるか決める。
- テキスト往復には十分な桁数を出力する。
同じ型への十進往復にはC++のmax_digits10が利用できる。
実装を確認する
std::numeric_limitsでis_iec559、radix、digits、min()、denorm_min()、max()を確認できる。浮動型のmin()は最小の正の正規化値であり、最も小さい負数ではない。負側の下限にはlowest()を使う。
参考資料
- IEEE 754-2019概要
- Oracle Numerical Computation Guide
- What Every Computer Scientist Should Know About Floating-Point Arithmetic
- C++ 0
- C++ floating-point environment
まとめ
IEEE 754は符号、仮数、指数を使い、限られたビットで広い範囲を表す。その代わり多くの値が近似となり、間隔が大きさによって変わり、中間結果の丸めで演算順序の影響を受ける。この一つのモデルを理解すれば、0.1 + 0.2、NaN比較、大きな座標の揺れ、累積誤差を別々の不思議として扱わずに済む。