シェーダでは浮動小数点のアンダーフローを意識しないと端末依存バグが起こるよって話

シェーダでは浮動小数点のアンダーフローをちゃんと意識しないとダメだよって話です。

アンダーフローとは?なぜ意識しないとダメなのか?

シェーダではfloatやhalfといった型の浮動小数点数をよく使います。

浮動小数点数は表現できる正の最小値が決まっていて、
それ未満の値を代入するとゼロとして扱われてしまいます。
この現象をアンダーフローと呼びます。

そしてこのようにしてゼロになった値で除算を行うといわゆるゼロ除算になります。

シェーダでは、ゼロ除算の結果は「未定義」であり、つまり何が起こるかわかりません。

stackoverflow.com

上記のフォーラムでは、iPadのシミュレータとiPad端末で結果が大きく異なったことが報告されています。

f:id:halya_11:20200225145013p:plain
左: iPadシミュレータ / 右: iPad端末

このようにシェーダでは、ゼロ除算をしてもエラー出力などされないため気付きづらく、
さらに悪いことに結果がハードウェアに依存するため、
多様な端末を扱うモバイル開発では非常に面倒な機種依存問題を引き起こします。

そこで本記事では、シェーダを書く上で必要なアンダーフロー周りの知識についてまとめます。

浮動小数点数の仕組みと正規化・非正規化

さてまず浮動小数点数の仕組みと正規化・非正規化についてまとめます。

例えば今0.101という2進数の値(10進数だと0.625)があったとします。
浮動小数点数をバイナリデータとして扱う際には、まずこれを 1.A×2^{B}の形式に変換します。

0.101 (2進数) ->  {1.01 \times 2^{-1}} (正規化)

これを正規化と呼びます。
ここで、小数点以下を仮数部、指数部分を指数部と呼びます。

f:id:halya_11:20190504194131p:plain:w200
仮数部・指数部

次にこの仮数部と指数部を次のようなビット列に格納していきます。

f:id:halya_11:20190504193158p:plain:w300
floatのビット列

一番左の1bit(赤い部分)には符号、オレンジ色の部分には指数部、緑色の部分には仮数部を入れます。

このあたりの浮動小数点の基礎知識については以下の記事にまとめていますので、
より詳細な説明が必要な場合はこちらを参照してください。

light11.hatenadiary.com

さて上記の通り、仮数部と指数部のビット数は固定であるため、
ここから浮動小数点数の表現できる最小値が決まります。

ただしここで、浮動小数点数 1.A \times 2^{B}として正規化する代わりに {0.A \times 2^{B}}とすれば、
指数部のビット数を節約してより小さな値を表現できることがわかります。

0.101 (2進数) ->  {0.101 \times 2^{0}} (非正規化)

ゼロに近い値をこのようにして表す手法を非正規化と呼びます。
したがって、浮動小数点数には正規化した場合の最小値と非正規化した場合の最小値という考え方が存在します。

floatの正の最小値

さてそれでは次に実際の最小値を見ていきます。
まずfloat(単精度浮動小数点)については以下の通りとなります。

  • 正規化:  約1.18 \times 10^{-38}
  • 非正規化:  約1.40  \times 10^{-45}

普段CPUでfloatを使って計算する際には特に気にせず非正規化の値を使っているかと思います。

実際にfloat.epsilonSystem.Single.Epsilon、またUnityのMathf.epsilon
いずれも 1.401298 \times 10^{-45}という値を示します。

docs.microsoft.com

halfの正の最小値

それでは次にhalf(半精度浮動小数点)の最小値について見ていきます。
halfはパフォーマンス向上のために、特にモバイルのシェーダではよくつかわれる浮動小数点数です。
これが表現できる最小値は以下の通りとなります。

  • 正規化:   約6.10 \times 10^{-5}
  • 非正規化:  約5.96 \times 10^{-8}

floatと比べて扱える最小値がかなり大きい値になってしまっていることがわかります。

例えばこの正規化された値である  約6.10 \times 10^{-5}がどの程度の値であるかを、
シェーダで0~1を再マッピングするときによく使うpow()関数を使って考えてみます。

いま、pow(x, 10)としてxに入れた0~1の値を10乗することを考えます。
xの値を1から小さくしていくと、0.37の時点ですでに結果が 4.809 \times 10^{-5}となり、
halfの表現できる最小値を下回ることでアンダーフローが発生します。

こうして考えるとhalfによるアンダーフローはシェーダにおける演算で普通に起こりうることが理解できます。

非正規化は対応GPUが限られてるしパフォーマンスも悪い

前節では正規化された最小値を下回るとアンダーフローすると書きましたが、
実際には非正規化された値が最小値になるんじゃないの?という疑問が生まれます。

実は、GPUによっては非正規化には対応していないものが存在します。

また、非正規化を使った値の計算はパフォーマンス悪いことが多いです。

When denormal values are entirely computed in hardware, implementation techniques exist to allow their processing at speeds comparable to normal numbers;[3] however, the speed of computation is significantly reduced on many modern processors; in extreme cases, instructions involving denormal operands may run as much as 100 times slower
Denormal number - Wikipedia より

したがってシェーダのような処理負荷をシビアに考える必要のあるプログラムにおいては、
そもそも非正規化しないと表現できない値を取り扱わないように配慮することが重要です。

結論

説明が長くなりましたが、結論としてはhalf型の値には  6.10 \times 10^{-5}未満の値を代入するとゼロになる、ということを覚えておくべきです。

例としてPostProcessingのソースコードでも#define EPSILON 1.0e-4とされています。
(数行下にあるFLT_EPSILONについては計算機イプシロンなのでまた別の定義です)

github.com

アンダーフローは地味ですが重要な点なので、しっかり意識してシェーダを書いていきたいところです。

参考

stackoverflow.com

qiita.com

docs.microsoft.com

en.wikipedia.org

github.com