浮動少数の精度について
今回の記事はグリモアのRe:プロデューサーチーム所属のmasamasaが担当します。
自己紹介
はじめまして、グリモア入社1年目を迎えたmasamasaと申します!
兵庫出身、大阪のゲーム会社でも働いた経験もあるのですが、全く関西弁をしゃべれません… ですが、「なんでやねん!」はめっちゃ好きなワードで、心の中で呟きながらいつもプログラミングに励んでおります。
どうぞよろしくお願い致します!
基本はクライアントプログラマーのお仕事をしていますが、ここ一ヶ月ほどマネージメント業務もやらせて頂いております。
本記事について
6つ目の記事担当になったのですが、何を書くべきかネタが思い浮かばない。そして時間が飛ぶように過ぎていく…
考えた末…使い古されたネタで誠に申し訳ないのですが、 浮動小数の精度問題を復習として書いてみたいと思います! 少しでも皆様の記憶を呼び覚ますきっかけになれば幸いです。
浮動小数の精度として最も取り上げられるのがfloat変数についてだと思うので、これについて見ていこうかと思います。
(※この記事でのfloatは32bit変数です。)
変数の桁数について
コンピューターで表現できる数値には桁数に限りがあります。
32bit変数で言えば、約40億種類の数値を表現できます。16bitなら約65000種類だし、8bitなら256種類。自分が学生の頃、40億も数値表現できたら十分かと思ってましたが全然そんなことないないです…
弊社が運営するタイトルである「ブレイブソード×ブレイズソウル」もダメージ数は億を超えます!
40億をミリ秒に直すと400万秒。分だと約7万分。時間だと約1193時間。日だと49日ぐらいですかね。仮にゲーム開始し、49日以上ゲームやり続けたとします。ゲーム終了時などのタイミングでゲーム起動時間をミリ秒で32bit変数に格納しようとします。
はい、バグが発生しそうですね!over the worldです。
(※正確には32bit変数は42億なので問題ない場合もあります)
というふうに変数で表現できる数には限界があるので、何目的で使う変数なのか知っておかないと痛い目にあうことがあります。
floatの中身について
floatは32bitで表現され、符号部、指数部、仮数部で表現されます。
符号部(s)に1bit、指数部(e)に8bit, 仮数部(m)に23bit で合計32bitの構成ですね。
ここから f = (-1)^s * 2^(e - 127) * (1 + m/2^23 ) でfloat値が決まるのですが、この式自体は個人的にさほど重要ではなく、式が意味することのほうが重要だと思います。
なんとなくこの式で、 指数部(e)で数値の大きさを表現し、仮数部でその数値範囲内における精度を表現してるのかなと理解して頂けると幸いです!
数値範囲内における精度とは、例えば、
1 ~ 2 の範囲において、floatは 約830万分割(≒2^23)の精度表現が可能
2 ~ 4 の範囲においても 830万分割の精度表現が可能
4 ~ 8 の範囲においても 830万分割の精度表現が可能
2の20乗 〜 2の21乗 の範囲においても830万分割の精度表現が可能
ということです。
ここからわかるように、floatのような浮動少数は数値が大きくなればなるほど誤差が大きくなっていき、表現できない値が増えてきます。
このことは割りと重要なのですが、プログラマーでも忘れがちなんじゃないかなと思ってます。
自分は忘れがちでfloat精度による問題が起きた時、アッと思い出します。
floatでの整数表現について
下の計算式実行してみます。
float x = 0.0f;
for (int i = 0; i < 10000 * 10000; i++)
{
x += 1.0f;
}
x が1億になるだろうと予想の上実行してみます。
結果は、x = 1677万ぐらいとなります。原因は上で説明した仮数部(2^23) の精度が関係しています。インクリメントしても整数表現ができず、丸められ加算されなくなります。 floatを使用し整数として綺麗に表せるのは16777216が限界になるので気をつけないといけないです。
60fpsのゲームでfloatをカウンター変数に使っていたとします。
計算してもらえればわかりますが、大体3日ほどでカウンター変数の限界に到達してバグが発生します。毎フレーム実装される関数の中で、float変数をインクリメントしてるようなコードを見かけた際は要注意かもしれません。
自分は過去にこれをやってしまい、先輩にすごく怒られた経験があります。
仮にint値を使って同様のバグが発生した場合は、 オーバーフローしマイナスになるのでバグに気付きやすいのですが、floatの場合は数値が増えなくなるというバグとなるので、非常に気付きにくいバグを仕込むことになります。モバイルゲームでこれをやってしまうと、3日起動し続けるようなエイジングテストはしないんじゃないかと思い、思わぬタイミングでバグが発覚するかもですね。
floatの誤差について
floatを扱う以上、必ず誤差が出てしまいます。floatで大きな数値で表現しようとすればするほど、誤差も大きくなります。うまく付き合っていくしかないと思うのですが、いくつかの方法によっては誤差が発生するにしてもその値を小さくできたりはします。
例えば、計算順序の見直しですね。
float x1 = 0;
for (int i = 0; i < 10000; i++)
{
x1 += 0.01f;
}
上の式において、自分の環境だと100.00295 になるのですが
上の式を下のように変更すると
float x1 = 0;
for (int i = 0; i < 100; i++)
{
float x2 = 0.0f;
for (int j = 0; j < 100; j++)
{
x2 += 0.01f;
}
x1 += x2;
}
結果は、99.999986で、誤差が小さくなりました。
大きさに差がある数を足すことで誤差が大きくなるので、なるべく大きさに差のない足し算になるように変更することで誤差を小さく抑えることができます。
とは書いたものの、実際自分としてやりたくない変更対応ですね。得られる結果がコードの複雑化に見合わない気がするためです。このコードレビューすることになったら、自分であればfloatを使うのやめてdoubleを使ってほしいなぁとか書いちゃうかもです。
環境問題でdouble使えないやdoubleよりfloatの速度のほうがという方もいるかもしれませんが、時代や状況に即した対応にしていきたいですね!環境問題は流石にどうしようもないですが…
あと回避方法としては、計算回数を減らすや大きな数値をfloat で扱わないで済むような工夫を考えてみるとかでしょうかね。
まとめ
floatは誤差が発生する変数なので、うまく付き合い扱っていきましょう!
しかし、そんなfloat誤差で問題になるようなケースって実はそんな多くはないのでは?と思うところもあり、問題がおきたら「そういえば?」と思い出すぐらいでよいかと思ってます。
floatで問題が起きやすいのは、 毎フレームで加算するようなコードを見かけた時、大きな数値を取り扱う時、また衝突検知などの物理演算のコードを書く時だと思います。
衝突検知は、float誤差によって衝突してないのにその判定がされたり、衝突判定抜けが発生したりで誤差対策などが必須で深くこのあたりの知識が要求されたりします。
新卒のころ、コリジョン抜けの原因調査と修正のタスクを出された際に、直し方の方法が全くわかりませんでした。先輩にfloat誤差の説明を受け、それでもどういうことなのかさっぱり理解できず、怒られながら当時ペアプロしていたことをこの記事を書きながら思い出しました。
最後に
ここまでお付き合いいただき、本当にありがとうございます!
ということで、グリモアは一緒に【中二病を救う】側になってくれる仲間を大大大募集中です!
少しでも当社に興味を持って頂けましたら、是非とも下記の採用サイトを御覧ください!
読んでくださりありがとうございま――…… え?さぽーと…?いやいやいや!そんな恐れ多いですよ!でも、サポートいただけると、ゲーム開発が少しだけ楽になるかも…… あ!ごめんなさい、独り言ですっ!えへへへ……