Sunday, May 22, 2011

Exploring Groovy 1.8 Part 1 - そのコード、本当に最適化されてますか?

Groovy目下の課題であるパフォーマンス向上の為、1.8では整数演算とメソッド直接呼び出しへの最適化が行われるようになりました。どちらの最適化も考え方は同じで、簡単にいってしまえば"出来る限り書かれた通りにコンパイルする"というものです。これはこれまでGroovyコンパイラが構文を正しく解析してくれなかった、という意味ではありません。Groovyでは柔軟なメタプログラミングを可能にする為にコンパイル時に劇的な変換が施されるのです(このあたりは以前のポスト、インサイドGDKでも触れています)。問題は例外がなかったことです。この後の話の都合上、フィボナッチ数の計算メソッドを例としてあげます:
----
int fib(int n) {
    if (n < 2) return n
    return fib(n - 1) + fib(n - 2)
}
----


このコードは全くメタプログラミングの恩恵を受ける必要のないコードですが、v1.7以前では次のように変換されます(実際はGroovyのAPIが登場しますし行っていることももう少し複雑なのですが、今回の話では重要ではない為省略します)。全ての演算/メソッド呼び出しはリフレクションへ置き換えられ、整数に対してせわしなくボクシングとアンボクシングが繰り返されます:
----
int fib(int n) {
    // Get the Method objects
    if (lessThanMethod.invoke(Integer.valueOf(n), Integer.valueOf(2))) 
        return Integer.valueOf(n).intValue();
    return 
        ((Integer) plusMethod.invoke(
            fibMethod.invoke(
                minusMethod.invoke(Integer.valueOf(n), Integer.valueOf(1)
            ),
            fibMethod.invoke(
                minusMethod.invoke(Integer.valueOf(n), Integer.valueOf(2)
            )
        )).intValue();
    );
----


これがv1.8では次のように最適化されます。これが冒頭での言った"出来る限り書かれた通りにコンパイルする"の意味です:
----
int fib(int n) {
    if (n < 2) return n;
    return fib(n - 1) + fib(n - 2);
}
----


さて、話はこれで終わりません。というのも、どうやらこの最適化の恩恵を受ける条件は我々が想像しているよりずっと厳しいものだということです。リリースノートに書かれている次の注意事項を忠実に守るだけでは充分ではありません:
1. 整数の最適化を求めるのであれば1つの式の中でint型とInteger型を混ぜないこと
2. メソッド呼び出しの最適化を求めるのであれば"this"上で実行でき、引数の型がprimitiveかfinalであること


次の表を見てください。これはフィボナッチ数を求めるメソッドをいくつかの方法で実装し、それらの実行速度をv1.8.0とv1.7.10で比較したものです:
フィボナッチ(30)の実行時間(単位はナノ秒)
ランク
アルゴリズム / スタイル
v1.8
v1.7
改善
1
反復 + Groovyスタイル for loop
49720
46276
-6.93%
Down :-(
2
反復 + Java/Cスタイル for loop
129360
77824
-39.84%
Down :-(
3
再帰 + if + return
45080820
548522821
+1116.75%
Up :-)
4
再帰+ 三項演算子
120489259
538476119
+346.91%
Up :-)
5
再帰 + if-else
259945537
546381516
+110.19%
Up :-)

この結果は次の3つのことを示しています:
1. 反復版はパフォーマンスが少し劣化した
2. 再帰版はパフォーマンスが大幅に向上した
3. コーディングスタイルによるパフォーマンスの差が広がった

理由は簡単です。結果が芳しくないものは両方、あるいはいずれかの最適化に失敗しているのです。この表で言えば、先に例にあげた再帰 + if + return版以外は全て何らかの形で最適化に失敗しています。例えば再帰を使った中で最も悪い結果となった再帰 + if  + else版はこうです:

----
int fib(int n) {
    if (n < 2) n
    else fib(n - 1) + fib(n - 2)
}
----

これは次のように変換されます(繰り返しになりますが正確には実際のものとは違います):
----
int fib(int n) {
    if (n < 2) return n;
    else return 
        ((Integer) plusMethod.invoke(
            fibMethod.invoke(Integer.valueOf(n - 1)),
            fibMethod.invoke(Integer.valueOf(n - 2))
        )).intValue();
    );
}
----

たったこれだけのスタイルの違いが最適化が成功するかを左右し、パフォーマンスに5倍の差を作るのです。こういった問題はこれから改善されていくでしょうが、少なくとも現時点では諸手を上げて喜べる段階ではないようです。


さてさて、そのコード、本当に最適化されてますか?




(今回計測に使ったコード一覧)


反復 + Groovyスタイル for loop:
----
int fib(int n) {

    int a = 0
    int b = 1
    int c
    for (int i in 2..n) {
        c = a + b
        a = b
        b = c
    }
    b
}
----


反復 + Java/Cスタイル for loop:
----
int fib(int n) {
    int a = 0
    int b = 1
    int c
    for (int i = 2; i <= n; i++) {
        c = a + b
        a = b
        b = c        
    }
    b
}
----


再帰 + if + return:
----

int fib(int n) {
    if (n < 2) return n
    return fib(n - 1) + fib(n - 2)
}
----


再帰 + 三項演算子:
----
int fib(int n) {
    n < 2 ? n : fib(n - 1) + fib(n - 2)  
}
----


再帰 + if-else:
----
int fib(int n) {
    if (n < 2) n
    else fib(n - 1) + fib(n - 2)
}
----

No comments:

Post a Comment