benchTrim
Cherry Blossoms

はじめに 

前回の記事で紹介した『ベンチマークツール』は、高速・軽量ツールとしてリリースしました。
そこで今回のテーマは、フレームワーク対決 (関数型プログラミング) です。

前提条件

「敵を知り、己を知れば、百戦して殆(あや)うからず」(孫子)

ということで、う~さんの関数型プログラミングスキルは、怪しいため整理しました。

  • 関数型プログラミングのメリット
  • 簡潔な関数の組み合わせでプログラミングするため、コードがシンプル
  • 同じ入力に対し常に同じ結果を返し、テストが簡単
  • 個々の関数が独立しているため他の処理の影響を受けず、副作用が発生しにくい

Java は手続き型言語のため、関数型言語のようにメソッドをそのまま変数に代入したり、引数として渡すことはできません。 そのかわり、インターフェース (関数型インターフェース) とローカルクラス (匿名クラス) を使って、関数型言語のようにメソッドを変数に代入したり引数に渡すことができます。 このとき、プログラムの記述がけっこう面倒になるのですが、「ラムダ式」や「メソッド参照」を使うと簡単に実現することができます。

関数型インターフェース
抽象メソッドをひとつだけ宣言したインターフェースを「関数型インターフェース」と呼びます。 ジェネリクス(総称型, Generics type) を使って関数型インターフェースを定義することもできます。簡単な例を示します。

// Ref. java.util.function.Function
@FunctionalInterface
interface Function<T, R> {
  R apply(T t);
}

ベンチマーク フレームワーク(用語定義)

※テストケース: JIT オプションと APP オプションの組み合わせ
  ※ベンチマーク: クルーを複数回呼び出してベンチマークを実施
    ※クルー (Crew): ドライバを順番に呼び出してベンチマークを実施
      ※ドライバ (Driver): 担当のターゲットでベンチマークを実施
        ※ラッパー (Wrapper): ターゲットを関数として扱うための参照
          ※ターゲット (Target): ベンチマーク対象のメソド

関数型コード作成

お題は、文字列の左右空白のトリミングです。

今回は、ベンチマークを実施しオリジナルの対抗馬 4~6.について評価します。

ターゲット(Target)

1. String rTrimRegex01Classic(String s) {...} // 右トリム、伝統的な正規表現 /[\s]+$/
2. String rTrimRegex02New(String s) {...} // 右トリム、カスタマイズ正規表現 /[\0- ]+$/
3.(String String#trim() {...}) // トリム、オリジナル
4. String trim04String(String s) {...} // トリム、オリジナルのカスタマイズ Ver.
5. String trim05CharSeq(CharSequence s) {...} // トリム、CharSequence Ver.
6. String rTrim06String(String s) {...} // 右トリム
7. StringBuilder rTrim07Builder(StringBuilder sb) {...} // 右トリムの StringBuilder Ver.

メソッド参照(クラス名::メソド名)を使用してターゲットへのラッパー(Wrapper)を定義します。

1. Function<String, String> rTrimRegex01Classic = Trim::rTrimRegex01Classic;
2. Function<String, String> rTrimRegex02New = Trim::rTrimRegex02New;
3. Function<String, String> trim03Original = String::trim;
4. Function<String, String> trim04String = Trim::trim04String;
5. Function<CharSequence, String> trim05CharSeq = Trim::trim05CharSeq;
6. Function<String, String> rTrim06String = Trim::rTrim06String;
7. Function<StringBuilder, StringBuilder> rTrim07Builder = Trim::rTrim07Builder;

ドライバ(Driver)を定義します。

ドライバは、従来型の場合 7種、関数型では、ラッパー(Wrapper)を引数として受け取れば良くタイプ別に 3種類になります。 しかし、ジェネリクス(総称型, Generics type)を使用すると 2種類になります。
(ドライバの数が少ないということは、コンパイルされやすいということです)

<T> int driver01Generics(Function<T, String> fnc, String ans) { // #1~6
  long nano = loop_time;
  int count = 0;
  String rs;
  do {
    long start = System.nanoTime();
    rs = fnc.apply((T) BENCH_DATA); // ラッパー呼び出し invokeinterface
    nano -= System.nanoTime() - start;
    count++;
  } while (0 < nano);
  assert ans.equals(rs) : rs;
  return count;
}

int driver02Builder(Function<StringBuilder, StringBuilder> fnc, String ans) { ... // #7
}

ベンチマーク

ベンチマーク環境

Core i5 2.6GHz、MEM 8GB、Java 18、Windows 10 (Windows 11 非適合機)

実行オプション

  • JIT オプション
  • -XX:-TieredCompilation
    階層化コンパイル(C1)の使用を無効化(インタプリタ、コンパイラ(C2)かの二択)
    デフォルトでは、このオプションが有効になっています。
  • JIT コンパイラ
    今回のオプションでは、メソドが約1万回近く呼び出されると第1段階のコンパイルは完了します。
    ただしコンパイラは、奥が深くそんなに単純ではありません。(定期的に再コンパイルします。詳細は不明 ^^);

Case.1(FW1、従来型) の測定結果です

ターゲット 1st 2nd 3rd
4. trim04String 0.22 0.21 0.22
5. trim05CharSeq 0.21 0.20 0.21 min 0.15
6. rTrim06String 0.17 0.15 0.17 max 0.22
avg 0.20 0.19 0.20 avg 0.20
  • オリジナルに圧倒的な差をつける性能で安定しています。
  • 数字は、相対誤差で、(測定値ー理論値)÷理論値(オリジナル値)で計算しています。

ウオームアップで、まずターゲットをコンパイルし、 次にクルーを呼び出しドライバ(とターゲット)をコンパイルします。 (最初のターゲットコンパイルは、必須でこれにより動作が安定します。謎^^);

Case.2(FW2、関数型) の測定結果です

ターゲット 1st 2nd 3rd
4. trim04String 0.12 0.11 0.13
5. trim05CharSeq 0.20 0.14 0.20 min 0.11
6. rTrim06String 0.15 0.12 0.15 max 0.20
avg 0.16 0.12 0.16 avg 0.15
  • 上からグッと押さえつけられた、低めの性能です。

ウオームアップで、ラッパーでなくターゲットを単体でコンパイルし、 次にクルーを呼び出してドライバ(とラッパーとターゲット)をコンパイルします。

関数型の負荷の原因は、何でしょうかね?

仕切り直し

こうなると、問題箇所の切り分けが必要です。
バイトコードを眺めていると見慣れないコード (invokeinterface) がありました。
インターフェースを経由した呼び出しに使われるコードです。
そこで思いついたのは、関数型実装部分(ラッパー) を従来コードに変えてしまおうということです。
下記の作業を行い、ドライバの引数「Function」を「Future」に変えれば OK です。

ラッパー(Wrapper)のスーパークラス(Future)を定義します。

// Function と同一インターフェースのスーパークラス
abstract static class Future<T, R> {
  abstract R apply(T t) ;
}

ターゲットへのラッパークラスをターゲットの数だけ実装します。

static class trim04String extends Future<String, String> {
  @Override
  public String apply(String t) {
    return Trim.trim04String(t);
  }
}

ターゲットの数だけラッパーをインスタンス化します。

static trim04String trim04String = new trim04String();

※ あえて、インスタンス名、クラス名、ターゲット名は、同一にしています。

Case.3(FW3、関数型改) の測定結果です

ターゲット 1st 2nd 3rd
4. trim04String 0.14 0.13 0.14
5. trim05CharSeq 0.21 0.19 0.16 min 0.11
6. rTrim06String 0.11 0.13 0.11 max 0.21
avg 0.15 0.15 0.14 avg 0.15
  • なんと、関数型と同じ挙動を示します。 invokeinterface + invokestatic のせいでは、なさそうです。
ちなみにバイトコードは、invokevirtual + invokestatic に変わりました。
(invokevirtual は、インスタンスメソッドの呼び出しに使用されます。これなら普段使っているヤツですね)
この負荷は、ターゲットの呼び出しに(関数型と同じく) 2度のジャンプが必要なせいだと思われます。

まとめ

想定外のベンチマーク(フレームワーク対決)になりました。 性能は、従来型 > 関数型改 = 関数型です。
  • Case1(従来型)、ウオームアップを大幅に書き換え更に安定しました。
  • Case2(関数型)、安定性に欠けますが、思わぬネタを頂きました。
  • Case3(関数型改)、飛び入りですが、バリエーションが増えてフレームワークらしくなりました。
関数型プログラミングは、食わず嫌いでしたが、ネタさえあれば面白いですね。

次回は、ターゲットをもう少し負荷の高いものに変えて様子を見てみたいと思います。
「う~さんどうですか?」『う~、まだ、やるんかい』

オープンソース

インストール

  1. Java をダウンロード(環境を汚さない .zip 版を推奨、複数の Javaもインストールできます)「Java Downloads」
  2. benchTrim をダウンロード「benchTrim - Benchmark framework」 (コマンドを添付しています)
  3. benchTrim フォルダ中の makefile の JAVAHOME 変数に Javaホームパスを設定します。

実行

エクスプローラーで benchTrim フォルダを開き、上部のパンくずに と入力してターミナルを開き と入力します。

「Blog top」 2022.7.23