demoscene.jp Japanese demoscene portal

2912月/120

浮動小数点、その中身とは

こんにちは、Falken/brainstormです。

デモの中では、映像を表示するには色んな数学計算が行われてますね。整数計算は簡単だと思いますが、FPUの小数点はどう計算しているのかと考えたことありませんか?そもそも、floatの中の32ビットはどんな意味を持つのでしょう?

今日の記事はIEEE 754を判りやすく説明いたします。

floatの構造体

では、floatの32ビットの中の割り当てるビットを見ましょう。

floatのビットは3つの部分に別けられてますね。

sign bitは1ビットであり、符号という意味。このビットが0の場合は正の数、1の場合は負の数です。
exponentは8ビットであり、指数部という意味。簡単で言うと、この8ビット分は、基数2で小数点の位置が決められます。
mantissaは残りの23ビットで仮数部という意味。指数部で決められた幅の中で、この23ビット分は実際の数字になります。

この構造体では、C++言語でビットフィールドを使えば、floatの3つの部分は簡単にアクセスできます:

union ieee754
{
    struct
    {
        unsigned int mantissa : 23;
        unsigned int exponent : 8;
        unsigned int sign     : 1;
    };

    float            _f32;
    unsigned int     _u32;
};

そして、この構造を表示する便利な関数:

void print(float val)
{
    ieee754 f;
    f._f32 = val;
    printf("sign=%d, exp=%02Xh (%d), mant=%06Xh (%d), u32=%08Xh, float=%f\n",
        f.sign, f.exponent, f.exponent, f.mantissa, f.mantissa, f._u32, f._f32);
}

まずは指数部が変動すると、どんな数字になるかを見てみましょう。

符号 指数部 仮数部 数字
0 7Ch (124) 0 0.125
0 7Dh (125) 0 0.25
0 7Eh (126) 0 0.5
0 7Fh (127) 0 1.0
0 80h (128) 0 2.0
0 81h (129) 0 4.0
0 82h (130) 0 8.0

これで1つの指数部で数字の幅が決まれることがわかりますね。そして指数部が小さくなるほど精度が良くなり、指数部が大きくなるほど精度が悪くなることがわかります。

次は指数部が7Fhの場合、仮数部を変動してみましょう。

符号 指数部 仮数部 数字
0 7Fh (127) 000000h 1.0
0 7Fh (127) 200000h 1.25
0 7Fh (127) 400000h 1.5
0 7Fh (127) 600000h 1.75
0 7Fh (127) 7FFFF8h 1.999999

もう1つ、指数部が7Chの場合で仮数部が変動するとどうなるかを見てみましょう。

符号 指数部 仮数部 数字
0 7Ch (124) 000000h 0.125
0 7Ch (124) 200000h 0.15625
0 7Ch (124) 400000h 0.1875
0 7Ch (124) 600000h 0.21875
0 7Ch (124) 7FFFBDh 0.249999

少しはわかってきましたでしょうか?実はfloatの数学式が存在します:

数字 = (-1)^符号 * (1 + (仮数部 / 2^23)) * 2^(指数部-127)

仮数部に1が足されていることに注目してください。これはfloatの「隠しビット」と言われており、仮数部が0の場合でも正常に計算ができるために必要です。しかし、このビットが必ず1のため、実際は仮数部には保存されていません。floatの計算を行う時は仮数部の24ビット目に1が通常に入っていることを忘れずに。

では、floatの数学式を一度試してみましょう。例として、別の数字を使ってみます。指数部を124のままで、仮数部を400000hと600000hの間の数字にします。

数字 = (-1)^0 * (1 + (4CCCCDh / 2^23)) * 2^(124-127)
数字 = 1 * (1 + (4CCCCDh / 2^23)) * 2^(-3)
数字 = 1 * (1 + .6) * .125
数字 = 1 * 1.6 * .125
数字 = .2

逆に、ある数字からfloatにするにはまず指数部を探す必要があります。その数字が指数部の幅に入ってるのが見つけたら、仮数部は次の数学式で計算できます:

仮数部 = ((数字 - 指数部での最小値) / (指数部+1での最小値 - 指数部での最小値)) * (2^23)
仮数部 = ((.2 - (指数部=7Ch)) / ((指数部=7Dh) - (指数部=7Ch))) * (2^23)
仮数部 = ((.2 - 0.125) / (0.25 - 0.125)) * (2^23)
仮数部 = (.075 / .125) * (2^23)
仮数部 = .6 * (2^23)
仮数部 = 5033164.8 (丸めで4CCCCDh)

特別なfloat

無限大、NaNなど、という種類のfloatも存在します。IEEE 754では、ゼロも特別です。

種類 指数部 仮数部
ゼロ 00h 000000h
非正規化数 00h 000001h - 7FFFFFh
正規化数 01h - FEh -
無限大 FFh 000000h
SNaN FFh 000000h - 3FFFFFh
QNaN FFh 400000h - 7FFFFFh

計算でfloatに入りきらない場合は指数部が最大値のFFhとなり、無限大になります。符号が1の時にも同様で、floatに入りきらないほどの大きい負の数です。逆にゼロではないが0に近づきすぎる場合は非正規化数となります。

0を割り算してしまった場合、または負の数をsqrtするなどはQNaN(「Quiet NaN」)となります。QNaNはどんな計算にされても、QNaNしか結果になりません。

SNaNは「Signaling NaN」と呼ばれて、FPUレジスタに取り込んだ瞬間に例外が起きます。この例外が起きる機能はデバッグ用として使われています。例えばメモリの初期化でSNaNの値に設定しておけば、プログラム上で初期化されてないfloatをアクセスしてしまうと例外になります。

特別なfloat以外は「正規化数」と呼ばれます。これは正の数と負の数を関係なく、正確なfloatまたはnormalized floatという意味です。

上のテーブルを見ると、floatの種類を判断するのは簡単にできそうですね。

enum FloatType
{
    TypeNegativeZero,            // ゼロ
    TypePositiveZero,
    TypeNegativeDenormalized,    // 非正規化数
    TypePositiveDenormalized,
    TypeNegativeInfinity,        // 無限大
    TypePositiveInfinity,
    TypeQuietNaN,                // QNaN
    TypeSignalingNan,            // SNaN
    TypeNegativeValue,           // 正規化数
    TypePositiveValue
};

FloatType getFloatType(float val)
{
    ieee754 f;
    f._f32 = val;

    // ゼロか非正規化数
    if (f.exponent == 0)
    {
        if (f.mantissa == 0)
            return f.sign ? TypeNegativeZero : TypePositiveZero;

        return f.sign ? TypeNegativeDenormalized : TypePositiveDenormalized;
    }

    // 無限大かNaN
    if (f.exponent == 0xFF)
    {
        if (f.mantissa == 0)
            return f.sign ? TypeNegativeInfinity : TypePositiveInfinity;

        return (f.exponent & 0x00400000) ? TypeQuietNaN : TypeSignalingNan;
    }

    // 正規化数
    return f.sign ? TypeNegativeValue : TypePositiveValue;
}

FPUを使わずfloatの加算

ここまではfloatの基本を説明しましたが、次は2つのfloatを足す方法を説明します。符号ビット、無限大、NaNなどを対応すると色々複雑になるので、今回はシンプルでいきます。

まずはゼロが特別な値なので、それを確認します。

float add(float val1, float val2)
{
    ieee754 a, b;
    a._f32 = val1;
    b._f32 = val2;

    // ゼロ確認
    if (a.exponent == 0) return b._f32;
    if (b.exponent == 0) return a._f32;

次は、2つの指数部を大きい方に統一します。統一することで、仮数部も変更しますが、変更された仮数部は同じ数字の意味を持ちます。例えば、6.0と9.0を足す場合は:

符号 指数部 仮数部 数字
0 81h (129) 400000h 6.0
0 82h (130) 100000h 9.0

6.0の指数部を9.0に合わせるためは、指数部を1に上げて、仮数部を1つ下にシフトします:

符号 指数部 仮数部 数字
0 82h (130) 200000h 10.0
    unsigned int diff = 0;

    // どっちの指数部が大きいか
    if (a.exponent > b.exponent)
    {
        // 指数部の差を計算
        diff = a.exponent - b.exponent;

        // 仮数部を下げる
        b.mantissa >>= diff;

        // bは戻り値となるので、指数部を変更
        b.exponent = a.exponent;
    }
    else if (a.exponent < b.exponent)
    {
        // 指数部の差を計算
        diff = b.exponent - a.exponent;

        // 仮数部を下げる
        a.mantissa >>= diff;
    }

指数部が統一したら、仮数部をそのまま足します。但し、仮数部の23ビット分をオーバーフローする可能性はあるので、別の変数に計算します。

    // 指数部が統一するので、仮数部を足す
    unsigned int m = (unsigned int)a.mantissa + (unsigned int)b.mantissa;

統一された6.0と9.0の場合はここで:

m = 200000h + 100000h
m = 300000h

次は隠しビットの分を仮数部に足したら、仮数部の計算は終わりました。

    // 隠しビット分修正
    m += 1 << (23 - diff);
    b.mantissa = m; // オーバーフローしたビットをカット

統一された6.0と9.0の場合はdiffが1なので:

m += 1 << (23 - 1)
m += 1 << 22
m += 400000h
m = 300000h + 400000h
m = 700000h

もし仮数部をオーバーフローした場合(23ビットに入りきれなかった時)は指数部を1つ上げて、仮数部を1つ下げます。

    // 仮数部オーバーフロー対応
    if (m >= (1 << 23))
    {
        b.mantissa >> = 1;
        b.exponent++;
    }

    return b._f32;
}

これで加算はできました。6.0 + 9.0 = 15.0となりました。

符号 指数部 仮数部 数字
0 82h (130) 700000h 15.0

まとめ

この記事ではIEEE 754の32ビットfloatを説明しましたが、「符号」「指数部」「仮数部」という3つの部分さえあれば、計算方法は同じです。64ビットのdoubleや16ビットのhalf floatとの違いは割り当てるビット数だけです。もちろんデータによりますが、例えば8ビットのfloatは符号1ビット、指数部4ビット、仮数部3ビットは可能です。

デモのデータ量を減らしたり、圧縮を良くしたりするためにはカスタムの浮動小数点を作るのがカギとなるかもしれません。

Comments (0) Trackbacks (0)

No comments yet.


Leave a comment

Trackbacks are disabled.