作る側の気持ちで理解する浮動小数点数

浮動小数点数を理解している人は比較的少ない[要出典]。だが基本的なアイデアは思うよりも難しくないはずだ。できるだけ短い説明をしたい。そこで、あなたが浮動小数点数を設計する人間になったと思い込んでほしい。すると気持ちが理解できる。ただ、簡潔さのためにちょっと細かい部分(非正規化数など)は触れないことにする。

エスト:64bitでできる限り広い用途で使える実数型を作れ

あなたは64個のビットを持っている。これで、実用上問題のない実数型を作って欲しいと頼まれた。だが実数は無限にある。0から1の範囲ですら無限にあるので、整数に倣って範囲を決めても表現できるようになるわけではない。どうするか?

イデア1:最小単位を決めて、整数と実数を対応させる

一番わかりやすいアイデアがこれだ。しかも、整数演算をかなり流用できるのでその点もありがたい。問題は、表現できる値の範囲だ。

それなりに普通の目的でも、10の±30乗くらいの範囲をカバーしていないといけない(参考:SI単位系プランク定数は10-34 [Js]、太陽の質量は1030 [kg])。まあシミュレーターなんかは系のスケールに応じて単位系を変えるだろうが、単位換算をすることだってあるのだ。それらの数の積を取ったりすることもあるので、安全マージンとして10倍くらい考えると、10300 つまり 21000くらいになってしまう。これを64bitで表現しようとすると最小単位が大きすぎて使い物にならない。

実際には、下の何ビットかを小数点以下の値ということにした実装をすることになるだろう。だが64bitではだいたい1019くらいの数までしか表せないので、だいたい整数部と小数部ともに10桁ないような実数になる。少なくともプランク定数 [Js] は0になってしまうし、太陽の質量はオーバーフローする。宇宙が崩壊してしまう。まあ日常の大抵の用途で事足りるのではという気もするが、もうちょっと考えてみよう。

  • イデア1: 最小単位 ε を決めて、整数 N を実数 Nε だと思い込む
    • pros: 実装が簡単で早い、単純
      • 実質整数、ただし掛け算と割り算は ε のことを考慮する必要がある
    • cons: 表現できる幅に限界がある
      • 最小単位より小さい変化や、ε×263 以上の値が表現できない

ちなみにこれは「固定小数点数」で、ちょいちょい使われている。

イデア2:1.23×105 という表現にならい、指数と小数を分ける

ここで、日常生活で使う実数はたいてい、数学定数などを除けば無限に続くわけではなく、そんなに精度が良くないことに思い至る。せいぜいで小数点以下十数桁を表すことができれば、日常的な範囲では問題なさそうだ。ここから、小数の部分と指数の部分を分離するというアイデアは自然に出てくる。つまり 1.23×105 を表現するときは、1.23を表現する部分と、10の5乗を表現する部分がある方が色々と都合が良い。計算機で扱う都合上、10のN乗ではなく2のN乗のほうが都合が良さそうなのでそういう定義にしよう。指数を表現する部分は、2のN乗のNを直接持てばよいので、普通の符号付き整数ということにしよう。サイズは小数の有効桁数とバランスを取る必要があるので、あとで決める。

小数はどうやって表現すればいいだろうか。これは、ある代表的な範囲(人間が普段 1.23e+10 とか 8.75e-5 とか言ってるときは、 [1, 10)くらいを使っている)を取って、それを等間隔に刻むのがわかりやすい。とするとどこをどう切るべきか。まず、この代表的な領域の幅を決めよう。これはまあ、1 にしておいたほうが色々と都合が良さそうだ。では [0, 1) の範囲にしたら良いだろうか? いやちょっとまってほしい。今考えている型には指数部がある。この範囲を2倍したらどうなる? [0, 2)になる。この範囲は[0, 1) を含んでいる。さらに2倍したら? [0, 4)だ。逆に半分にしたらどうなっただろう。[0, 1/2) だ。そう、[0, 1)で基準の範囲を取ると、指数を考慮したとき全ての領域がオーバーラップしてしまうのだ。これは、明らかに無駄だ。たった64ビットしかないのだから、こんなもったいないことはしたくない。

なので、0のそばの領域は置いておいて、[1, 2)の範囲を刻むことにしよう。指数部分があるので、これを倍にすると[2, 4)の範囲が、さらに倍にすると[4, 8)の範囲が、以下同様にして[2N-1, 2N)の範囲が、それぞれ等間隔に刻まれて表現できるようになった。小さい方も、[0.5, 1)の範囲が、[0.25, 0.5)の範囲が、同様にして[2-N-1, 2-N)の範囲がそれぞれ等間隔に刻まれて表現できる。これはかなりよさそうだ。

こういう風に指数を増やしたり減らしたりしてうまい具合に全体が覆えるのは、2が1の2倍になっているからだ。[1.5, 3)でも別に構わない。2倍すると[3, 6)になり、[6, 12)になり、と続くので覆い尽くすことができる。指数が3なら、[1, 3)の範囲を使うのが便利だっただろう。

おっと、負の数もほしいので、この表現はそのままに符号ビットを追加しよう。

  • イデア2: 2NのNと、[1, 2)の範囲を等間隔に分割したものを組み合わせる
    • pros:
    • 表現できる範囲が広い
      • 指数部分のおかげ
    • 常にそれなりの相対精度がある
      • 大きい値は大きい間隔で表現され、小さい値は小さい間隔で表現される
    • cons:
    • 演算がだるい
      • ハードウェア技術者がつらい
    • たまに凄いエッジケースがある
      • ソフトウェア技術者がつらい

最後にビット数を決めよう。符号ビットは1bitで十分だ。指数部を最初に考えたような範囲、つまり21000くらい使えるように、正負両方1000~210なので、11ビットあればよい。残りの64 - 11 - 1 = 52bitを全部小数部分に割り当てると、だいたい16桁くらい表現できるようだ。十分な気がするな。これで行こう。

というわけで、倍精度浮動小数点数(仮)ができた。

実際にはここに追加で非正規化数やNaNやInfがあるので、もう少し複雑になる。指数部分も普通の整数の表現とちょっと違う。なので、これが実際の倍精度浮動小数点数だと思わないでほしい。が、基本的なアイデアを理解するにはこれで十分だ。あとはWikipediaを見れば理解できるようになっている。 これらの追加要素のせいで、実際の倍精度浮動小数点数はもう少しややこしい。そこにも落とし穴があるが、今回はこれ以上書かない。

ここでアイデア2を出すために使われた発想は全て、直観的に不自然な部分はなかったように思う。そのせいで倍精度浮動小数点数の演算がめちゃくちゃ不自然になるのは面白いところだ。例えば、

不自然な点の例: 0.1 倍は誤差が出る

上のアイデア2を知っていると、これはわかる。

まず、0.1は[1, 2)の範囲にない。なので指数を使う必要がある。1.6×2^-4か。というわけで、指数部は-4を表せばいい。そこまではいい。[1, 2)を等間隔に分割したことと、1.6 = 1.0 + 0.6 を考えると、0.6を二進表記で書いたものが小数部分の値になるはずだ。

0.6は2進数では循環小数(0.100110011001... = 1/2 + 1/16 + 1/32 + 1/256 ...)になる。我々は有限個のビットしかしか持っていないので、打ち切らざるを得ない。なので、浮動小数点数で 0.1 を表現すると誤差が出る。

不自然な点の例:x + y - y != x になることがある

これは今回話していない「丸め」が関わってくることなのでちょっとずるい。

浮動小数点数では、[1, 2)と[2, 4)の間で刻み方が違う。なので、ちょうど 2 の位置では、一つ小さい値と一つ大きい値との距離が違う。2倍の差があるのだ。 題の式で x がこの位置に位置しており、y がちょうど、一つ大きくするには不十分だが一つ小さくするには十分な値だった場合、足しても値は(丸められて)変わらないのに、引くとちゃんと引ける、ということになってしまう。

なのでこういうことが起きる。


なるほどね。は???(威圧)