浮動小数点数(float, double)演算の丸め誤差と対策(Javaの例)


最近浮動小数点数演算に起因する調査を行う機会があったので、復習しました。

浮動小数点数(Javaで言うとfloatとかdouble)を使って演算するときは丸め誤差によって意図した値にならないことがあります。これが発生するケースや対策について書いてみました。

しっかり書こうと思ったけど、当然ながらこれについて書いている記事は他にも色々あるし、自分自身他のblogで書いたことがあったので、結構省略していきます。

ちなみに以下のサイトとか分かりやすくて良いですよ。

[Java] 小数点の計算をやるからといってすぐにfloatやdoubleを使ってはいけない

現象

package com.tsukaby.calc;

public class App {
  public static void main(String[] args) {
    System.out.println(0.7 + 0.2 + 0.1);
  }
}

さて、このプログラムの出力結果は何でしょうか。

普通に考えば1.0ですが、答えは・・・

0.9999999999999999

上記の計算に使った小数はどれも浮動小数点数であり、循環小数です。そのため、コンピュータの内部では丸め誤差によって意図した値になっていないことがあります。

今回の計算は1.0より僅かに小さい値になりましたが、計算によっては僅かに大きい値になることもあります。

対策

さて、ではどうすれば良いのでしょうか。

セオリー通りにやるならば、BCDを使う方法かと思います。JavaではBCDはBigDecimalとして提供されています。ただし、これは性能が悪いのでシーンによっては四捨五入でも良いかもしれません。

package com.tsukaby.calc;

import java.math.BigDecimal;

public class App {
  public static void main(String[] args) {
    // 丸め誤差あり
    System.out.println(0.7 + 0.2 + 0.1);

    // BCDであるBigDecimalによって正確に計算
    BigDecimal b1 = new BigDecimal("0.7");
    BigDecimal b2 = new BigDecimal("0.2");
    BigDecimal b3 = new BigDecimal("0.1");
    System.out.println(b1.add(b2).add(b3).doubleValue());

    // roundで四捨五入 (戻り値型がintなので、利用シーン次第では再キャスト)
    System.out.println((double)Math.round(0.7 + 0.2 + 0.1));
  }
}

BigDecimalの性能

以下が参考になります。

JavaSE BigDecimalの演算速度 - @//メモ - kagyuu

自分でも実行してみました。

package com.tsukaby.calc;

import java.math.BigDecimal;

public class App {
  public static void main(String[] args) throws Exception {

    final int LOOP = 1000;

    long doubleTime = 0;
    for(int i=0; i<LOOP; i++){
      doubleTime += calcTimeWithDouble();
    }
    double avlDoubleTime = (double)doubleTime / LOOP / 1000 / 1000;

    long decimalTime = 0;
    for(int i=0; i<LOOP; i++){
      decimalTime += calcTimeWithDecimal();
    }
    double avlDecimalTime = (double)decimalTime / LOOP / 1000 / 1000;

    System.out.println("double = " + avlDoubleTime + " ms");
    System.out.println("decimal = " + avlDecimalTime + " ms");
  }

  /**
   * doubleを使った計算をして実行時間を返します。
   */
  private static long calcTimeWithDouble() throws InterruptedException {

    long nanoTimeStart = System.nanoTime();

    double result = 0;

    for (int i = 0; i < 100; i++) {
      result += 0.1;
    }
    long nanoTimeEnd = System.nanoTime();

    return (nanoTimeEnd - nanoTimeStart);

  }

  /**
   * BigDecimalを使った計算をして実行時間を返します。
   */
  private static long calcTimeWithDecimal() throws InterruptedException {

    long nanoTimeStart = System.nanoTime();

    BigDecimal result = new BigDecimal(0);

    for (int i = 0; i < 100; i++) {
      BigDecimal tmp = new BigDecimal("0.1");
      result = result.add(tmp);
    }

    long nanoTimeEnd = System.nanoTime();

    return (nanoTimeEnd - nanoTimeStart);
  }
}

100回doubleまたはBigDecimalを計算する処理を用意して、1000回実行した実行時間の平均を出力します。

結果はこんな感じ。

double = 0.007235735 ms
decimal = 0.09616398299999998 ms

doubleだと0.007msで、BigDecimalは0.096ms、doubleとBigDecimalで13倍の開きがありました。

BigDecimalが遅いという人は結構いるかと思いますが・・・本当に使えないほど遅いかどうかはコンテキスト次第のはずです。仮にWebアプリケーションがリクエストに応じて数値データを返す、みたいな処理があり、これが大体100msだとしましょう。ここに100回double演算を行う処理を足すと100.007msになり、BigDecimalなら100.096msになるはずです。このようなケースであれば自分ならとにかくBigDecimalを使って安全に計算する方が良いと思います。もし100msでは問題があり、高速化が必要になるのであればBigDecimal云々ではなく、パレートの法則に従って他の部分を見直します。

まとめ

  • 言語によっては小数演算で誤差が生じる
  • 実際の例はコードの通り
  • 対策はBigDecimalなどのBCD方式を使うか、許容できるのであれば四捨五入