浮動小数点
Floating Point ・ ふどうしょうすうてん
小数を仮数と指数で近似表現する方式。表せる値はまばらで、0.1+0.2が0.3にならない理由もここにある。
✦ 30秒で試す — 航海0.1を10回足しても、ぴったり1にはならないこの整数を入れて読み出すと、どうなる?予想してみる概要
浮動小数点は、コンピュータで小数を表すための標準的な方式です。数を「仮数 × 2の指数乗」という形に分解して表現します。10進数でいえば 123000 を 1.23 × 10⁵ と書く科学記法と同じ発想で、小数点の位置が指数によって「浮動」する(動く)ことが名前の由来です。ほぼすべてのCPUと言語が IEEE 754 という共通規格に従っており、float(32ビット)や double(64ビット)という型名でおなじみです。
重要なのは、これが小数の「正確な表現」ではなく「近似表現」だという点です。限られたビット数で表せる値は数直線の上にまばらに散らばった点の集まりにすぎず、点と点の間に落ちた値は最も近い点に丸められます。有名な「0.1 + 0.2 が 0.3 にならない」現象は、バグでも言語の欠陥でもなく、この方式の必然的な性質です。仕組みを知らないと「テストがたまに落ちる」「合計金額が1円ずれる」といった不可解な症状に悩まされることになります。
なぜ生まれたか
小数を扱う素朴な方法として、小数点の位置を固定する「固定小数点」があります。たとえば「必ず小数点以下2桁」と決めれば金額計算には十分ですが、この方式は表せる数の範囲が狭く、原子の大きさ(10⁻¹⁰m)から銀河の距離(10²¹m)までを同じ形式で扱う科学技術計算には全く足りませんでした。浮動小数点は、精度(仮数)と範囲(指数)を分業させることで、限られたビット数で桁違いに広い範囲をカバーする解決策です。
もうひとつの課題は互換性でした。1970年代まで、浮動小数点の形式はメーカーごとにバラバラで、同じプログラムがマシンによって違う答えを出すのが当たり前でした。丸めの規則も機種ごとに違い、数値計算の信頼性は機種依存だったのです。これを統一したのが、William Kahan らが中心となってまとめた1985年の IEEE 754 規格で、ビット配置・丸め規則・無限大や NaN(非数)といった特殊値の扱いまでを標準化しました(規格の草案は1980年の Intel 8087 にいち早く実装されています)。2008年の改訂では16ビットの半精度や10進の浮動小数点も加わりました。今日どの言語・どのCPUでも 0.1 + 0.2 が同じ 0.30000000000000004 になるのは、この規格のおかげです。
詳細
IEEE 754 のビット配置 — 符号・指数・仮数
IEEE 754 の浮動小数点数は、2進数のビット列を3つのフィールドに区切って解釈します。先頭1ビットが符号(0なら正、1なら負)、続くビットが指数(小数点をどれだけ動かすか)、残りが仮数(数の中身にあたる桁)です。32ビットの単精度では指数8ビット・仮数23ビット、64ビットの倍精度では指数11ビット・仮数52ビットが割り当てられます。
指数フィールドには実際の指数に127(倍精度では1023)を足した値を格納し、負の指数も符号なしで表せるようにしています。また、2進数で正規化すると先頭の桁は必ず1になるため、その1は格納せずに「暗黙の1」として扱います。そのため仮数の精度は、格納している23ビットに暗黙の1を足した 24ビット(倍精度では52 + 1 = 53ビット)です。10進数に直すと、単精度で約7桁、倍精度で約16桁の有効数字に当たります。指数が全部0や全部1のときは特別扱いで、ゼロ・非正規化数(0 のすぐ近くを埋める細かい値)・無限大・NaN を表します。
表せる値はまばらな点 — 大きいほど粗い
この仕組みを数直線の上で見ると、浮動小数点の性格がはっきりします。仮数のビット数が決まっているので、指数が同じ範囲(たとえば 1 以上 2 未満)には、同じ個数の点が等間隔に並びます。指数が1つ上がって 2 以上 4 未満になると、同じ個数の点で倍の幅を埋めるので、間隔はちょうど倍になります。つまり絶対的な間隔は値が大きいほど粗く、値に対する相対的な細かさ(相対精度)はどこでもほぼ一定です。
倍精度の点は細かすぎて目に見えないので、符号1・指数4・仮数3ビットの8ビットの小さな浮動小数点で確かめてみます。値をドラッグすると最寄りの点に吸い付き、そのずれが丸め誤差です。「0.1 を置く」を押すと、0.1 が点と点の間に落ちるのが見えます。
数直線のつまみを左右にドラッグしてください。縦の刻みが、この8ビットの浮動小数点で表せる値のすべてです。つまみは最寄りの刻みに吸い付きます。
0.1 + 0.2 はなぜ 0.3 にならないか
鍵は「10進数で切りの良い小数が、2進数では切りが良いとは限らない」ことです。10進数でも 1/3 は 0.3333… と無限に循環し、有限の桁では書き切れません。それと同じことが、2進数では 0.1 に起こります。0.1 = 1/10 の分母10は素因数に5を含むため、分母が2の累乗の分数で表せず、2進数では 0.000110011001100… という無限循環小数になるのです。
つまり 0.1 と書いた瞬間、変数に入っているのはすでに「0.1 に最も近い倍精度の点」であって 0.1 そのものではありません。0.2 も同様です。このわずかにずれた2つの近似値を足し、結果をまた最寄りの点に丸めた結果が 0.30000000000000004 — 「0.3 に最も近い点」とは隣の別の点になります。逆に 0.5 や 0.25 のように分母が2の累乗の小数、そして仮数に収まる範囲の整数は、浮動小数点でも正確に表せます。
丸めの規則も IEEE 754 が決めています。既定は「最近接偶数丸め」で、最も近い点に丸め、ちょうど真ん中なら仮数の末尾が偶数になる方を選びます(切り上げと切り捨てが半々になり、偏りが溜まらない)。ほかに0方向・+∞方向・−∞方向への丸めも規格にあり、区間演算のように誤差の上下限を押さえたい計算で使われます。
実務での落とし穴と対処
第一の鉄則は、浮動小数点数を == で比較しないことです。0.1 + 0.2 == 0.3 は偽になります。比較は「差が十分小さいか」で行い、値の大きさに合わせて許容誤差を相対的に決めます(1e-9 のような固定値は、大きな値では厳しすぎ、小さな値では緩すぎる)。テストフレームワークの多くもそのための近似比較用の関数を用意しています。
誤差は演算のたびに入り込み、計算の順序で結果が変わります。桁の大きく異なる数の加算では小さい方の下位桁が失われる「情報落ち」が起こり、近い値どうしの減算では有効桁が消える「桁落ち」が起こります。たとえば単精度では 1 億(1e8)付近の点の間隔が 8 なので、1e8 に 1 を足しても丸めで消え、何回足しても 1e8 のまま動きません。対策の定番が Kahan の総和法(補正付き加算)で、足すたびに丸めで失われた分を別の変数に取っておき、次の加算で足し戻します。Python の math.fsum のように、誤差を抑えた総和を用意している言語もあります。
金額計算では浮動小数点を使わないのが原則です。1円・1セント単位の整数として扱うか、10進数を正確に表せる Decimal 型(Java の BigDecimal、Python の decimal、RDBMS の DECIMAL 型など)を使います。JavaScript は長らく数値型が倍精度浮動小数点しかなかったため、この問題に特に遭遇しやすい言語です。整数でも、仮数の53ビットを超える 2⁵³(約9千兆)以上は正確に表せなくなるため、巨大なIDを JSON の数値として受け渡して末尾の桁が化ける、という事故も定番です(文字列か BigInt で扱うのが対策です)。
精度を落とす選択 — 機械学習と fp16/bf16
逆に、あえて精度を落とす世界もあります。機械学習、とくにニューラルネットワークの学習と推論では、個々の数値の精度よりも「同じメモリと帯域で何倍の数を運べるか」が性能を決めるため、16ビットの半精度(fp16: 指数5・仮数10ビット)や、指数部を32ビットと同じ8ビットに保ったまま仮数を7ビットに削った bf16(bfloat16)が標準的に使われます。bf16 は「精度は粗くても、表せる範囲が広いのでオーバーフローしにくく学習が安定する」という、指数と仮数の配分を用途に合わせて設計し直した好例です。GPU はこうした低精度演算に特化した回路を持ち、LLM の巨大なモデルを現実的なメモリに収める量子化(重みを8ビットや4ビットの整数・小さな浮動小数点に詰める)へと、この「精度と資源のトレードオフ」はさらに続いていきます。
試問
符号1・指数4・仮数3ビットの、8ビットの小さな浮動小数点で表せる値が、数直線の縦の刻みとして並んでいます。つまみをドラッグすると置く値は整数に吸い付き、最寄りの刻みに丸められた値とずれ(丸め誤差)が表示されます。表示範囲は切り替えられます。
数直線のつまみを左右にドラッグしてください。置く値は整数に吸い付き、縦の刻み(8ビットで表せる値)のうち最寄りのものに丸められます。
