C言語:float型の精度

  1. long型からfloat型への変換で精度落ちするはずが
  2. float型変数の初期化
  3. 計算過程を追ってみる
  4. いろいろ試す
  5. アセンブラを見る
  6. おまけ

1. long型からfloat型への変換で精度落ちするはずが

藤原博文著「Cプログラミング専門過程」(技術評論社)のサンプルプログラムを試していたときであった。

  • C言語の式にfloat型とlong型が混在するとき、long型からfloat型へ格上げされて、計算が行われる。
  • float型の有効桁数は、6桁である。
  • long型は、-134217728~134217727が範囲である。
  • long型の範囲でかつ±106を超える範囲では、float型の精度は、long型の精度より低くなる。

したがって次のプログラムでは精度落ちが発生し、計算結果は1234567890にはならない(はず)。

/* float1.c : long型からfloat型へ格上げ時の精度落ち */
#include <stdio.h>

main()
{
    long    lbig = 1234567890L;
    float   ften = 10.0f;
    long    l;

    /* 元の値 */
    printf( "lbig: %ldn", lbig );

    /*
     *      l = (lbig / ften) * ften;
     *
     *  の格上げの様子をキャスト演算子を使って示すと
     *
     *      l = (long)(  ((float)lbig / ften) * ften );
     *
     *  (float)lbig のキャスト時に、精度落ちが発生する
     */
    l = (lbig / ften) * ften;
    printf( "l   : %ldn", l );
}
/*EOF*/

実行結果

lbig: 1234567890
l   : 1234567890

精度が落ちると1234567936になっているはずが、精度落ちしていない!

浮動小数点プロセッサが普及していないころのCコンパイラは、計算途中ではfloat型を常にdouble型で計算を行い、変数に保存するときにfloat型で保存していた。VisualCもfloat型をdouble型として計算しているのだろうか?

2. float型変数の初期化

まず、変数の初期化と表示だけを行った。float型は、1234567890.0を保持できないため、1234567936.0 となる。そのときのメモリ内容は、数値の上位桁から 4e 93 2c 06である。

printf( “%08lx”, *(long *)&f ); は、メモリ内容の16進表示を行っている。

/* float2.c : float型の 1234567890.0 の確認 */
#include <stdio.h>

main()
{
    float f1, f2;

    /* float型の定数で初期化する */
    f1 = 1234567890.0f;
    printf( "f1: %f %08lxn", f1, *(long *)&f1 );

    /* メモリ内容ズバリで初期化する */
    *(long *)&f2 = 0x4e932c06;
    printf( "f2: %f %08lxn", f2, *(long *)&f2 );
}
/*EOF*/

結果は次のとおり。数値表現では1234567936.0, メモリ内容は 0x4e932c06 であった。ここでは、float型の精度に落ちている。

f1: 1234567936.000000 4e932c06
f2: 1234567936.000000 4e932c06

3. 計算過程を追ってみる

float1.cのプログラムの計算式、l = (lbig / ften) * ften の計算過程を各段階で値を表示する。

/* float3.c : float1.cの段階を追う */
#include <stdio.h>

main()
{
    long    lbig = 1234567890L;
    float   ften = 10.0f;
    long    l;
    float   f;

    /* l = (lbig / ften) * ften; */

    /* 予想(1)
     * long型からfloat型への変換で精度落ちする。
     * 1234567936.0
     */
    f = lbig;
    printf( "(1) %fn", f );

    /* 予想(2)
     * 計算式をdouble型で計算する。
     * 123456789.0
     */
    f = lbig / ften;
    printf( "(2) %fn", f );

    /* 予想(3)
     * 計算式をdouble型で計算するが、
     * fに保存するとき度落ち。
     * 1234567936.0
     */
    f = (lbig / ften) * ften;
    printf( "(3) %fn", f );

    /* 予想(4)
     * 精度落ちしたものが復活するはずないので、
     * 1234567936
     */
    l = f;
    printf( "(4) %ldn", l );
}
/*EOF*/

結果

    実際         予想
(1) 1234567890.000000    1234567936.0 ×
(2) 123456789.000000     123456789.0  ○
(3) 1234567890.000000    1234567936.0 ×
(4) 1234567936           12345678936 ○

(3)まで精度落ちしていない。計算途中はdouble型精度で計算していると推測できる。(4)でようやく精度落ちしている。変数f は double型→float型の精度落ちした値が保存されたと推測できる。それならば、(1)(3)でも、変数 f には精度落ちした値が保存されているはずだが、精度落ちしていない。

そういえば、printf のような引数の個数が可変の関数に float型が渡されるときは、double型に変換されてから渡される。計算途中はdouble型精度の一時変数が存在し、この一時変数が printf に渡されているとしたら、精度落ちしない表示がされる。そして一時変数からではなく、float型変数から読み出してきたとき、精度落ちした表示がされるのではないか?

例えばコンパイラが次のような実装だったとすれば。

main()
{
 float  f;
 double tmp; /* 一時変数 */

  tmp = lbig;
  f = tmp;
  printf( "...", tmp );

  tmp = lbig / ften;
  f = tmp;
  printf( "...", tmp );

  l = f; /* l = tmp; なら精度落ちも小さいが、何らかの理由でそうではない */
  printf( "...", l );
}

このようなdouble型の一時変数は、一昔前のコンパイラである。いまどきのコンパイラは、float型のまま計算しているはずである。また「何らかの理由」が思いつかない。

4. いろいろ試す

もう一つ考えられるのが、浮動小数点プロセッサ内のレジスタが常にdouble型であること。一種の、グローバルな一時変数とでも言えるだろう。

double tmp; /* 一時変数 */

main()
{
 float  f;

  tmp = lbig;
  f = tmp;
  printf( "...", tmp );

  tmp = lbig / ften;
  f = tmp;
  printf( "...", tmp );

  l = f; /* 何らかの理由で、 l = tmp; ではない */
  printf( "...", l );
}

「何らかの理由」は、直前で関数(この例ではprintf)を呼んでいることだろう。関数内で浮動小数点の計算をしていたら、double tmp; の値は変化しているだろうから。つまり計算の流れが続いているときは double tmp、すなわち浮動小数点プロセッサのレジスタの値を使う。計算の流れが断ち切られたら、本来のfloat変数を読みにいっているのではないか?

/* float4.c : いろいろ試す */
#include <stdio.h>

void func(void);
void print_float( float f );
void print_double( double d );

main()
{
    long    l = 1234567890L;
    float   f, g;

    f = l;
    printf( "%fn", f );
    printf( "%fn", f );  /* 連続して printf */
    printf( "n" );

    f = l;
    g = f * 2.0;        /* 別の計算を挿入 */
    printf( "%fn", f );
    printf( "n" );

    f = l;
    func();             /* 関数を挿入 */
    printf( "%fn", f );
    printf( "n" );

    f = l;
    print_double( f );
    print_float( f );
    printf( "n" );

    f = l;
    print_float( f );
    print_double( f );
    printf( "n" );

    f = l;
    print_double( f );
    print_double( f );
    printf( "n" );
}

void func(void)
{
}

void print_float( float f )
{
    printf( "%fn", f );
}

void print_double( double d )
{
    printf( "%fn", d );
}
/*EOF*/

どうやら予想はあっていそうだ。

float4.c
1234567890.000000
1234567936.000000 精度落ち

1234567936.000000 精度落ち

1234567936.000000 精度落ち

1234567890.000000
1234567936.000000 精度落ち

1234567936.000000 精度落ち
1234567936.000000 精度落ち

1234567890.000000
1234567936.000000 精度落ち

5. アセンブラを見てみる

念のため、アセンブラコードを見る。(実はアセンブラコードを見てから予想したのです)

  1. ViuaslCの[プロジェクト]-[設定…]を開く。
  2. [プロジェクトの設定]ダイアログの右側で、[C/C++]タブを開く。
  3. 中央の[カテゴリ]一覧から、”ファイルリスティング”を選択する。
  4. [リスティングファイルタイプ]一覧から、”ソースコードを含む”を選択する。
  5. [OK]で閉じて、コンパイルする。
  6. Debugフォルダ内の、拡張子asm が、アセンブラファイルである。
; 37   :
; 38   :     f = l;

    fild  DWORD PTR _l$[ebp]  ....(1)
    fst   DWORD PTR _f$[ebp]  ....(2)

; 39   :     print_double( f );

    sub   esp, 8
    fstp  QWORD PTR [esp]     ....(3)
    call  _print_double
    add   esp, 8

; 40   :     print_double( f );

    fld   DWORD PTR _f$[ebp]  ....(4)
    sub   esp, 8
    fstp  QWORD PTR [esp]     ....(5)
    call  _print_double
    add   esp, 8

擬似コードで書き直すため、内部レジスタを reg, スタック(関数の引数を渡すための領域)を stk とすると

(1)、reg = (double)l;
(2)、f = (float)reg; 精度落ち発生!
(3)、stk = reg;
(4)、reg = (float)f;
(5)、stk = reg;

(3)でレジスタの値をスタックに保存している、値の由来は(1)。
(5)でレジスタの値をスタックに保存している、値の由来は(4)、すなわち精度落ちした変数f。

6. おまけ

long型からfloat型への格上げ(?)の問題が、VisualCでの float型の扱いの確認になってしまった。

VisualC+Pentiumでは、float型の変数は、float型のまま扱われていることがわかった。コンパイラの最適化と浮動小数点プロセッサのおかげで、float型でも精度落ちが発生しにくいことがわかった。(コンパイルオプションでプロセッサを386/486に指定しても結果は同じだった)精度落ち「しにくい」であって「しない」わけではない。

変数のサイズは、float 4バイト、double 8バイトである。floatのメリットはサイズが小さいので、巨大配列が必要な場合はメモリを節約でき、転送時間が速い。

しかしC標準の算術関数は、引数も戻り値も double型ばかりである。float型<–>double型の型変換の警告はかなりうるさい。また引数を渡すときの float型<–>double型の型変換は、処理が遅くなってしまう可能性がある。

精度としては、通常はfloat型の精度で十分である。計算精度が問題になるような処理では当然double型を使うだろう。

初心者に浮動小数点を教えるときに、どちらから教えるかは悩みどころだ。筆者自身は特に理由がなければ、double型を使っている。

float型
printf/scanf で、両方 “%f” を使うことができる。
(printfにfloat型を渡すときは、自動的にdouble型に変換されている)
sin/cosなどの算術関数の引数はdouble型が多いので「型変換をしました」警告がうっとおしい。
double型
printfでは “%f”、scanfでは “%lf” と使い分ける必要がある。

(2000年頃)