Java で Math.floor() や Math.round() を使うとやっぱり遅い

以前 Javaとかで高速に小数点以下四捨五入をしたい で同じようなことを書きましたが、最近再び Math.round() を使っていて遅いな~と感じたので、復習を兼ねつつ、もう少し真面目に遅い原因について触れながら考察メモ。

ここでは Math.floor() と Math.round() について見ていきます。Math.floor() は、その数を超えない最大の整数を返すメソッド。Math.round() は、四捨五入するメソッドです。

まず、Math.floor() と等価と思われる処理は次のようになります。

// 小数点数型変数 x の floor を整数 n に代入する
if (x > 0) {
    n = (int) x;
} else {
    n = (int) x - 1;
}

// 三項演算子で書いた例
n = x > 0 ? (int) x : (int) x - 1;

基本的に Math.floor() は小数点以下切り捨てのため、キャスト演算子 (int) による切り捨てが有効ですが、値が負の場合は違ってきます。「その数を超えない最大の整数」であるため、例えば「-1.2」のような数値は「-2」が戻り値となります。従って、値が負の場合にはキャストで小数点を切り捨てた後、1 だけ減算する必要があります。

今までこれで等価になると信じ込んで使ってきましたが、残念ながらこのソースには致命的な欠陥がありました。「-2.0」のような負の整数値の場合、「-3」になってしまいます。本来の floor の値は当然「-2」です。何故気づかなかったのか、悲しいです。色々と考えましたが、次のように0.99…9を減算してキャストすることで、Math.floor() と等価と思われる処理を実現できました。

// 倍精度浮動小数点数型変数 x の floor を整数 n に代入する
if (x > 0) {
    n = (int) x;
} else {
    n = (int) (x - 0.99999999999999D);
}

// 三項演算子で書いた例
n = x > 0 ? (int) x : (int) (x - 0.99999999999999D);

同様にして、四捨五入するMath.round()と等価と思われる処理は次のようになります。

// 倍精度浮動小数点数型変数 x の round を整数 n に代入する
if ((x + 0.5) > 0) {
    n = (int) (x + 0.5);
} else {
    n = (int) (x - 0.49999999999999D);
}

// 三項演算子で書いた例
n = (x + 0.5) > 0 ? (int) (x + 0.5) : (int) (x - 0.49999999999999D);

そもそも、本当に遅くなるのか。確認してみます。
100億回繰り返し処理を行い、時間を計測してみました。

その結果、
Math.floor()を100億回呼び出すのにかかった時間 : 56.631秒
Math.floor()と等価な処理を100億回行うのにかかった時間 : 4.242秒

約13倍の時間差がありました。これはひどい。
そもそもMathクラスの中でも同じような処理を行っているはずなのに、何故ここまで時間差が開くのでしょうか。
メソッドを一つ経由しただけでこれだけ時間差が開くものが疑問だったので、Mathクラスの中身を覗いてみました。

public static double floor(double a) {
    return StrictMath.floor(a); // default impl. delegates to StrictMath
}

む。Math.floorメソッドの中で処理をして返すと思いきや、StrictMathクラスを経由しているようです。
StrictMathクラスは、動作環境に依存せず必ず同一の値を返すことを保証したパッケージのようで、StrictMathを使うことで一般的に処理時間は増加するようです。
そして、StrictMathクラスの中身も覗いてみました。

public static native double floor(double a);

おおう。native修飾子がついていて中身が書かれていませんでした。実際のところどうなっているのか、JREの中身をデコンパイルして確認してみます。「rt.jar」を解凍して、Java Decompilerのサイトからデコンパイラを落として中身を見ると次のようになっていました。

public static double floor(double paramDouble) {
    return floorOrCeil(paramDouble, -1.0D, 0.0D, -1.0D);
}

・・・また経由。floorとCeilと同じメソッドを経由させて、第2引数以降で区別をつけているようです。そしてfloorOrCeil()メソッドの中身は次のとおり。

private static double floorOrCeil(double paramDouble1, double paramDouble2, double paramDouble3, double paramDouble4) {
    int i = Math.getExponent(paramDouble1);
    if (i < 0) {
      return paramDouble1 < 0.0D ? paramDouble2 : paramDouble1 == 0.0D ? paramDouble1 : paramDouble3;
    }
    if (i >= 52) {
      return paramDouble1;
    }
    assert ((i >= 0) && (i <= 51));
    long l1 = Double.doubleToRawLongBits(paramDouble1);
    long l2 = 4503599627370495L >> i;
    if ((l2 & l1) == 0L) {
      return paramDouble1;
    }
    double d = Double.longBitsToDouble(l1 & (l2 ^ 0xFFFFFFFF));
    if (paramDouble4 * paramDouble1 > 0.0D) {
      d += paramDouble4;
    }
    return d;
  }

ぬおお。なんか若干面倒臭くなってきました。ちなみにStrictMath.floor()メソッドの中身を違うPCで見たら、内容が全然違う。native修飾子の詳しい働きはよくわかってませんが、きっとこいつのせいだろうと思います。

一応中身を軽く見てみます。
2行目で、Math.getExponent()で引数を2進数表記した場合の指数部を見ています。
3行目で、指数部が負であるか確認しています。指数部が負であれば、値の絶対値は1未満になります。
4行目ではまず、引数が負であれば-1を返しています。つまり、引数が-1よりも大きく0よりも小さい場合です。引数が負でない場合は、0かどうかを確認して、0であればそのまま返します。0でない場合は、やはり0を返します。ややこしい書き方ですが、ここはfloor()とceil()を同じ関数で処理するための書き方のようです。
6行目では、引数の指数部が52以上かを確認し、そうであれば引数をそのまま返しています。

52とはなんでしょうか。42は宇宙の答えとかだったと思いますが、52って何でしたっけ。2の52乗という数字は、およそ4.5035996e+15でした。11行目で出てくる数字と同じようです。調べてもなかなか出てきません。色々検索ワードを変えてみると、DoubleクラスのMAX_VALUE変数が出てきました。ここまできてようやく、52は倍精度浮動小数点数型の仮数部のビット数だと気付きました。遅い。つまり指数部が52以上であると、小数点以下まで保証する有効桁数を超えてしまうため、そのままの値を返すしかないということのようです。

9行目はアサーション。実際に使われているのを初めてみました。変数iは整数型なので、ここまで来るということは0以上51以下になっているはずですが、万が一そうでなかった場合にAssertionErrorが報告されます。どういうケースでしょうか。ちょっと想像がつきません。
10行目では引数をlong型のbit列にしています。
11行目はdouble型の仮数部の最大有効数字を指数部だけ右に論理シフトしています。
12行目で2つのlong型の積集合をとり、0であれば13行目で引数をそのまま返しています。

仮数部の最大有効数字を指数部だけ右に論理シフトしたものと、倍精度浮動小数点数をbit列に直したものの積集合が0になるということは、一体どういうことなのでしょうか。もうなんだかよくわからなくなってきました。また余裕ができたら考えることにして、中途半端ではありますが今回はここまでで諦めます。

17行目で-1.0を足しているところを見ると、ここで値が負かどうかを確認しているようです。こんなに面倒なことが行われているとは思っていませんでした。とりあえず私の利用範囲ではここまで厳密な値の保証は必要無いので、自身で考えた等価と思われる処理を使っていこうと思います。


コメントを残す

メールアドレスが公開されることはありません。