久々のプログラミングの話しです。
ちょっと精度の良いプログラムが必要になったので、gccでプログラミングしていたのですが、浮動小数点型であるlong double型のprintfのフォーマットなんだっけ?
から始まって、
なんでこんな変な値になっちゃうの?
と頭が混乱してきてしまっています。
例えば、
float f;
double d;
long double l;
のように、float、double、long doubleの変数に対して小数点以下100桁の円周率、
のように代入したとする。
それぞれの変数をprintfで出力しようとするとき、どんなフォーマットにするのが正しいのだろうか。
今回は精度を知りたかったので、小数点以下がどんな感じになるのか、
printf("%.100f\n", f);
printf("%.100lf\n", d);
printf("%.100Lf\n", l);
を試した結果、
となった。
赤文字が10進数的に正しい部分です。
floatからdoubleへの赤文字の桁数の伸びにくらべ、
doubleからlong doubleへの赤文字の桁数の伸びが悪い。
long doubleの精度が思いのほか悪いことが解った。
さて、綺麗に表示出来ているじゃないか?
と思われますが、実はここまでくるのが大変でした。
コンパイラオプションをいろいろと変えた結果、
-ansi
を付けると、上記の結果を得られたにすぎません。
因みに -ansi を付けずにコンパイルすると、
こんな感じで、long double型だけがメチャクチャな値を出力しました。
小数点の位置がかなり右に行っているのと、そもそも値が負になってるし、…
-ansi オプションは絶対に必要なんだと、この段階では思ったんです。
で、次の段階に進むことにして、
printf("%.100f\n", sinf(f/4.0));
printf("%.100lf\n", sin(d/4.0));
printf("%.100Lf\n", sinl(l/4.0));
因みに、"%f" は 浮動小数点数型、"%lf"、"%Lf" といったようにビット数の違いによって書き分けたりするのだが、これがそもそも正しいのだろうか?
出力で、そんなフォーマットの書き分ける必要性があるのか?
例えば符号あり整数型では、short、int、long と8ビットから32ビットまであったとして、"%d" だけで事足りていたし、long long の64ビットだけ、"%lld" といった特別なフォーマットを使っていたように思う。
この流れだと、"%llf" といったフォーマットもあるんだろうという推測は出来るが、gccでどうなのかまではよく知らなかった。
-ansi オプションを付けてコンパイルし、実行してみると、
sin(π/4)なので、sin(45˚)ですから、1/√2 なので、
double型しか正しい値を出力していない。
10進数的に正しいのは赤い文字だけです。
なぜ、ここにきて float型、long double型が壊れる?
sinf() の引数、戻り値は float、
sinl() の引数、戻り値は long double
で合ってるよね。
せっかくπの値の精度がでて、sinも精度が出るだろうと思ってたのに、俺何か悪いことしたんかな?
コンパイラオプションが悪いの?
printfのフォーマットが悪いの?
ソースにバグがあるの?
混乱してきてしまったのだが、一旦落ち着いて、いろいろと切り分けを始めた。
まずは、sizeofでそれぞれの型の大きさを調べよう。
printf("float: %d\n", sizeof(float));
printf("double: %d\n", sizeof(double));
printf("long double: %d\n", sizeof(long double));
これはさすがに問題ないよなぁ。
コンパイルオプションをいろいろと変えてみる。
-mlong-double-64 のとき、
float: 4
double: 8
long double: 8
-mlong-double-80 のとき、
float: 4
double: 8
long double: 12
-mlong-double-128 のとき、
float: 4
double: 8
long double: 16
-ansi を付ける、付けないに関わらず、-mlong-double-x の x によって変化する。
x は 64、80、128を選べ、80がデフォルトのようだ。
sizeof の戻り値はバイト数ですから、実データの中身(ビット列)を見てみようと考えた。
というわけで、型宣言から考え直します。
union { float v, long long int x; } f;
union { double v, long long int x; } d;
union { long double v, long long int x; } l;
これで、型宣言は終わり。
union なんて滅多に使わないから、忘れてましたよ。
変数 f.v, d.v, l.v に調べたい浮動小数点の値を入れ、f.x, d.x, l.x で中身を見てしまおうという算段です。
まずは解りやすい値をいれてみる。
f.v = 0.25f;
d.v = 0.25;
l.v = 0.25L;
printf("%016llx\n", f.x);
printf("%016llx\n", d.x);
printf("%016llx\n", l.x);
因みに "%llx" は、long long int や unsigned long long int の64ビット型整数を16進表示するためのものです。
016を付けることで、0パッティングをして、16桁で表示させます。
同様に、小数点以下100桁の円周率も入れてみる。
さて、結果はいかに。
まずは、-ansi オプションなしです。
-mlong-double-64 | -mlong-double-80 | -mlong-double-128 | |
---|---|---|---|
2.5 | 0000000040200000 4004000000000000 4004000000000000 | 0000000040200000 4004000000000000 a000000000000000 | 0000000040200000 4004000000000000 0000000000000000 |
小数点以下 100桁の 円周率 | 0000000040490fdb 400921fb54442d18 400921fb54442d18 | 0000000040490fdb 400921fb54442d18 c90fdaa22168c000 | 0000000040490fdb 400921fb54442d18 8000000000000000 |
この16進数の結果から何が解るのかというと、mlong-double-80の値を16進から2進にしてみます。
00 00 00 00 40 20 00 00 (hex) -->
00000000 00000000 00000000 00000000 01000000 00100000 00000000 00000000 (bin)
00 00 00 00 40 49 0f db (hex) -->
00000000 00000000 00000000 00000000 01000000 01001001 00001111 11011011 (bin)
float型は、
赤文字が仮数部(23ビット)で、2.5と円周率のそれぞれの値なんだろう。
青文字が指数部(8ビット)で、どちらも10000000になっている。
戻り文字が符号(1ビット)で、どちらも0で正ということだろうと、何十年も前にやったであろうコンピュータ概論の授業を思い出しながら考えてみる。
40 04 00 00 00 00 00 00 (hex) -->
01000000 00000100 00000000 00000000 00000000 00000000 00000000 00000000 (bin)
40 09 21 fb 54 44 2d 18 (hex) -->
01000000 00001001 00100001 11111011 01010100 01000100 00101101 00011000 (bin)
double型は、
先のfloat型の円周率の仮数部100100100001111110…が、doubleと合致するところを探すと、
doubleの仮数部が52ビットと解り、指数部は11ビットとなった。
2.5と見比べても問題はないですね。
a0 00 00 00 00 00 00 00 (hex) -->
10100000 00000000 00000000 00000000 00000000 00000000 00000000 00000000 (bin)
c9 0f da a2 21 68 c0 00 (hex) -->
11001001 00001111 11011010 10100010 00100001 01101000 11000000 00000000 (bin)
long double型、問題はここだ。
符号ビットが無くなっているし、指数部は1ビットなのだろうか?
仮数部は63ビットは間違いなさそうなんだけどね。
円周率のdoubleとlong doubleの仮数部を並べてみると、
ビット数が11ビット増えているだけで、そこがオール0なんで、なんら精度が上がっていないことが解る。
では、-ansiオプションを付けてコンパイルしてみると、どうなるだろうか。
-mlong-double-64 | -mlong-double-80 | -mlong-double-128 | |
---|---|---|---|
2.5 | 0000000040200000 4004000000000000 4004000000000000 | 0000000040200000 4004000000000000 a000000000000000 | 0000000040200000 4004000000000000 0000000000000000 |
小数点以下 100桁の 円周率 | 0000000040490fdb 400921fb54442d18 400921fb54442d18 | 0000000040490fdb 400921fb54442d18 c90fdaa22168c235 | 0000000040490fdb 400921fb54442d18 8469898cc51701b8 |
-mlong-double-64 では -ansi の有無で変化はない。
変化したところだけを抜き出してみます。
c9 0f da a2 21 68 c2 35 (hex) -->
11001001 00001111 11011010 10100010 00100001 01101000 11000010 00110101 (bin)
long double型の円周率の精度が11ビット分、何かしら埋まっていることが解ります。
さて、内部構造が見えてきましたね。
mlong-double-128 は、long long intでは桁が足りてないってことだろう。
union の long long int を char の配列とかにできたんだろうか。
まぁ、やってみよう。
ほぼコピペでこんな感じで書いてみた。
for文で書いたところはビット列を表示させるためです。
-mlong-double-128 -ansi
の両方のオプションを付けてコンパイルした結果、
※見にくいので8桁ずつに加工しました、実際の出力には色もスペースもありません。
いずれも符号ビットが0になってるし、指数部、仮数部も問題が無いだろう。
-ansi オプションを付けてコンパイルしているので、円周率の仮数部もしっかりと値が入っている。
円周率の精度の上げ方は、これで解った。
では、sin関数に円周率/4を渡して、sn(45˚)を計算させた結果が、なぜまともじゃないんだろうか。
と先のプログラムにπ/4、sin(π/4)も追加してみる。
π/4の計算部分、
/= 4.0;
としたが、本当ならばシフト演算で、
>>= 2;
とやりたかったのだが、コンパイル時にエラーで弾かれてしまい、やむを得ず、/= 4.0; としました。
-ansi ありだと、
※見にくいので8桁ずつに加工しました、実際の出力には色もスペースもありません。
sin(π/4)のlong doubleが、精度的にみるとfloat並というのはどういう理屈なんだろうか?
シフト演算で>>=2とやろうとしたが、コンパイラに怒られたので、/=4でごましたのだが、πからπ/4への変化は、指数部だけの変化のみで、仮数部に変化が起きていないので、シフト演算をしていないのに、シフト演算と同じ結果となっているのは、コンパイラが優秀なのだろうか。
まぁ、/=4だから、それくらいはやってくれても当たり前でしょうか。
ただ、float、double、long doubleで指数部と仮数部に揺れがある。
この辺が混乱の元だったのだろうか。
-ansi なしだと、
※見にくいので8桁ずつに加工しました、実際の出力には色もスペースもありません。
同様にπからπ/4へ、シフト演算をしていないが、指数部のみの変化で実質シフト演算されている。
こちらは、sin(π/4)の指数部が各変数の揺れがない。
円周率のlong doubleの仮数部はdouble並の精度しかないのだが、
sin(π/4)のlong doubleの仮数部もdouble並の精度になっている。
float = sinf(float);
double = sin(double);
long double = sinl(long double);
だよね。
なのに、関数への引数や戻り値の型は合ってても、戻り値の精度は悪いという結果なのだろうか。
結論としては、
コンパイラオプションに -ansi を付けるか付けないか。
-ansi オプションありでは、
直接値を代入したlong doubleの変数は精度が出る。
四則演算(実際は割り算しか試していないが)は精度に変化はなかった。
しかし、関数を通すと、関数の戻り値の型が double や long double であっても、float並の精度に落ちてしまった。
-ansi オプションなしでは、
直接値を代入したlong double変数の精度は、double並、
long doubleの戻り値も、double並。
ということが解った。
結局精度が出ないならば、
コンパイルオプションは、
-mlong-double-128 だけで、
-ansi は付けず、
プログラム内部でのprintfの出力では、
long doubuleの戻り値の関数は(double)にキャストして、"%lf" で出力すれば問題ないだろう。
どうしても中身を知りたい、精度の高い出力をしたいならば、今回私がやったようなunion型で、独自の出力を作るしかなかろうか。
因みに、
-mlong-double-128 -ansi
での円周率の仮数部を抜き出して、
多倍長演算で正確に計算させてみると、
のような値となり、赤文字部分が10進表記での等しい部分です。
printfの浮動小数のフォーマットで、これくらい出力出来ればいいんでしょうね。
ではでは