[Java] An Embedded Java 8 Lambda Expression Microbenchmark

原文はこちら。
https://blogs.oracle.com/jtc/entry/an_embedded_java_8_lambda

長い道のりでしたが、Java 8がついにやってきました。このリリースに含まれる新機能についてたくさんの場所で記述されたり発表されたりしていますが、おそらく最も重要なのはラムダ式(Lambda Expression)の導入でしょう。ラムダは密接にJavaプラットフォームに統合されており、並列プログラミングの伝統的にトリッキーなところで開発者を支援する可能性があります。
JavaOne 2013: Lambda Expressions
https://blogs.oracle.com/thejavatutorials/entry/javaone_2013_lambda_expressions
もう一つ、Compact Profilesは、以前は小さすぎると考えられていた組み込みプラットフォームに対し、Java Standard Editionの互換性という途方もない利点を利用できるようになります。
An Introduction to Java 8 Compact Profiles
https://blogs.oracle.com/jtc/entry/a_first_look_at_compact
どこに向かっているかわかりますよね?同時に2つの技術を使用し、どのように連携するかを調べるのも楽しいかもしれません。このエントリでは、ちょっとしたプログラムの説明とその性能測定を実施します。言うなればマイクロベンチマークですね。その目的は、新しいラムダ式のパラダイムを使用したプログラミングは、一般的なデスクトップおよびサーバ用途だけでなく、どんどん増え続ける組み込みプラットフォーム用途にも成長させるためだけでなく、有益であることをご紹介するためです。

The Hardware/OS Platform(s)

この記事ではBoundary DevicesのBD-SL-i.MX6というシングルボードコンピュータを使います。1GB RAMが載る、クアッドコアのARM® Cortex™-A9ベースのシステムで、armhf Debian Linuxディストリビューションが動きます。この記事を書いた時点で、表示価格は199 USドルです。
Boundary Devices BD-SL-i.MX6
http://boundarydevices.com/products/sabre-lite-imx6-sbc/
imx6q_sabrelite_top1
もっとおもしろくするために、デバイス上でJava8ラムダ式を実行するだけではなく、新しいJava8 Compact1 Profileの範囲内でラムダ式を実行することにします。このJava実行環境の静的フットプリントは10.5MBです。
組込み機器とは能力もキャパシティもまったく異なるもう一つのシステムを比較対象とし、異種ハードウェアおよび異種OS環境間で実行の挙動を比較するために利用します。そのシステムは、Windows7/64-bitを実行している東芝Tecra R840というノートパソコンです。これは、8GBのRAMを搭載したデュアルコア Intel®Core™ i5-2520Mプロセッサを搭載しており、64ビット版Windows用の標準的なJava8ランタイム環境(JRE)を使用しています。
Tecra R840 Laptops & Notebooks (Toshiba Direct)
http://www.toshiba.com/us/computers/laptops/tecra/R840/

The Application

私たちの基本的なアプリケーションの基礎としてサンプルデータセットを探していたところ、理想的(にして架空の)従業員情報のデータベースを以下のリンクから入手できます。
Sample database with test suite
https://launchpad.net/test-db
利用可能なフォーマットの中で、約30万件のエントリーを含むカンマ区切りのCSVファイルが供給されています。今回のサンプルアプリケーションでは、このファイルを読み、LinkedList<EmployeeRec>に従業員レコードを格納します。 EmployeeRecには次のようなフィールドがあります。
public class EmployeeRec {
    private String id;
    private String birthDate;
    private String lastName;
    private String firstName;
    private String gender;
    private String hireDate;
    ...
}
このデータ構造を初期化し、アプリケーションを使って、男性従業員全員の平均年齢を計算する、という簡単なタスクを実行してみましょう。

Old School

まずは、この計算をラムダ式が利用できなかった時代のやり方で実施してみます。この方法をOldSchoolと呼ぶことにします。計算コードは以下のような感じになります。
double sumAge = 0;
long numMales = 0;
for (EmployeeRec emp : employeeList) {
    if (emp.getGender().equals("M")) {
        sumAge += emp.getAge();
        numMales += 1;
    }
}
double avgAge = sumAge / numMales;

Lamba Expression Version 1

2個目のバリエーションでは、ラムダ式を使って実行してみます。このバージョンをLamba stream()と呼ぶことにしましょう。Java 8での主要ステートメントは以下のような感じになります。
double avgAge = employeeList.stream()
                .filter(s -> s.getGender().equals("M"))
                .mapToDouble(s -> s.getAge())
                .average()
                .getAsDouble();

Lambda Expression Version 2

最後のバリエーションでは、前述のラムダ式にもう少し修正を入れてみます。つまり、 stream() メソッドの代わりに parallelStream() mメソッドを使います。これによりタスクを分割して個別のスレッドで動作する小さな単位に分割することができるようになります。このバージョンをLambda parallelStream()と呼ぶことにしましょう。Java 8のステートメントは以下のようになります。
double avgAge = employeeList.parallelStream()
                .filter(s -> s.getGender().equals("M"))
                .mapToDouble(s -> s.getAge())
                .average()
                .getAsDouble();

Initial Test Results

前述の3個のバリエーションを使って解いたサンプル問題の実行時間をグラフ化し、下にまとめました。左図は、ARM Cortex-A9プロセッサで記録した時間を表し、右図はIntel Core-i5で記録した時間を表しています。より小さな結果であるほどより高速なのですが、両方の結果から、ラムダの直列stream()を活用すると多少のオーバーヘッドがあり、old schoolのラムダ式以前の解法結果より時間がかかっていることがわかります。parallelStream()に関する限り、何とも言えません。Cortex-A9では、parallelStream()オペレーションはOld School方式とはほとんど差がないのに対し、Core-5では、parallelStream()がもたらすオーバーヘッドのために遅くなっています。

ここまでで終えると、並列ストリームがあまり役にたたないと結論付ける人が出てくるかもしれません。しかし、30万人の従業員のリストといった些細な計算を実行するぐらいでは、並列化の利点を示すのには十分ではないと思うんですよね。この次のテストでは、どれほどパフォーマンスが影響を受けるかを確認するために、計算負荷を増加させることにしましょう。

Adding More Work to the Test

今回は、同じ問題を解く(男性の平均年齢を計算する)のですが、それだけでなく、様々な量の中間での計算を追加しています。プログラムに次のidentityメソッドを追加し、必要な計算サイクルの数を変化させながら増やすことができます。
/*
 * Rube Goldberg way of calculating identity of 'val',
 * assuming number is positive
 */
private static double identity(double val) {
    double result = 0;
    for (int i=0; i < loopCount; i++) {
        result += Math.sqrt(Math.abs(Math.pow(val, 2)));    
    }
    return result / loopCount;
}
このメソッドは数値の二乗の平方根をとるため、本質的に高価な恒等関数です。 loopCountの値を変更することにより(これはコマンドラインオプションで指示)、identity()メソッド呼び出しごとのこのループ実行回数を変動させることができます。このメソッドを3個のコードに追加します。例えばラムダのParallelStream()バージョンでは以下のようになります。
double avgAge = employeeList.parallelStream()
                    .filter(s -> s.getGender().equals("M"))
                    .mapToDouble(s -> identity(s.getAge()))
                    .average()
                    .getAsDouble();
identity()メソッドの追加はOld SchoolやLambda Stream()バージョンにも適用しています。Rube Goldbergのような、identity()関数のloopCount内部変数にそれぞれ別の値を与え、その状態でマイクロベンチマークを3回実行した結果をまとめました(下図)。

Cortex-A9では、ループ回数が100に設定されている場合、明らかにparallelStream()のパフォーマンス上の利点を確認できますし、ループ回数が500に増加した場合、その利点はよりいっそう顕著になっています。Core-i5では、parallelStream()の利点を目にするためにはより多くの負荷をかける必要があります。ループカウントを50,000に設定してようやくパフォーマンス上の利点が明らかになることがわかります。Core-i5はたった2コアでとにかく速いため、結果として、parallelStream()の初期オーバーヘッドを上回るためにはもっと多くの負荷が必要というわけです。

Downloads

この記事で試用したサンプルコードはNetBeansプロジェクトとしてお試しいただけます。プロジェクトには30万件以上のエントリを含むCSVファイルが同梱されていますので、想定よりも大きなファイルサイズになってしまいました。このブログ環境(blogs.oracle.com)は2MBを超えるファイルを格納できないので、このプロジェクトソースを圧縮し、3個のファイルに分割しました。以下が3個のファイルのリンクです。
ダウンロードした3個のファイルを結合し、元のLambdaMivrobench.zipファイルを再作成するだけです。Linuxであれば、以下のようなコマンドで結合できます。
$ cat LambdaMicrobench.zip.part? > LambdaMicrobench.zip

Conclusion

多大な労力を費やしてJava8ははるかに普遍的なプラットフォームになりました。10.5MB程度の小さな組み込みJava実行環境であっても最新の進歩を活用できる​​ことがこの簡単なサンプルからわかりますが、これはほんの始まりに過ぎません。並列ストリームラムダ式の性能特性を向上させるためにはまだまだやるべきことがたくさんあります。将来の機能拡張を楽しみにしています。

0 件のコメント:

コメントを投稿