Java によるいろいろなカンマ区切り変換(または OpenJDKで提供されるJMH を利用したマイクロベンチマーク測定のやり方)

f:id:Naotsugu:20170728232733p:plain

List<String> list = Arrays.asList("A", "B", "C", "D", "E", "F", "G", "H", "I");

カンマ区切りの文字列に変換

A, B, C, D, E, F, G, H, I

いろいろなやり方がある

文字列結合

String result = "";
for (String s : list) result += result.isEmpty() ? s : ", " + s;

StringBuilder

StringBuilder sb = new StringBuilder();
for (int i = 0; i< list.size(); i++) {
  if (i > 0) {
    sb.append(", ");
  }
  sb.append(list.get(i));
}
result = sb.toString();

StringBuilder 簡略版

StringBuilder sb = new StringBuilder();
for (String s : list) sb.append(sb.length() == 0 ? s : ", " + s);

commons.lang StringUtils

String result = StringUtils.join(list, ", ");

Guava Joiner

String result = Joiner.on(", ").join(list);

Java8 String

String result = String.join(", ", list);

Collectors joining

String result = list.stream().collect(Collectors.joining(", "));

StringJoiner

StringJoiner joiner = new StringJoiner(", ");
list.forEach(e -> joiner.add(e));

collect with StringJoiner

String result = list.stream().collect(
        () -> new StringJoiner(", "),
        StringJoiner::add,
        StringJoiner::merge).toString();


いろいろあるが、パフォーマンスはどうなのか?

JMH ベンチマーク測定ツール

Java でのベンチマークは JITコンパイラによる最適化やGCの影響など、いろいろと考慮すべき点が多くて難しい。

そんな時はOpenJDKで提供されているベンチマーク測定ツール JMH(Java Microbenchmark Harness) を使うと良い。マイクロベンチ界のJUnitといった趣き。

OpenJDK: jmh

このツールを使うと、@Benchmark でアノテートしたメソッドを元に、マイクロベンチ用のコードを生成し、ベンチマーク結果を収集できる。

通常は jar を作成して実行することで、より信頼度の高いベンチマーク結果を得ることができるが、IDE から実行したり、Maven や Gradle から実行できるプラグインも存在する。

ここでは Gradle プラグインを使って、前述の文字列連結のベンチマークを取得してみよう。

Gradle ビルドスクリプトの作成

Gradle 2.1 以降であれば、以下のようにプラグインを指定すれば良い。

plugins {
    id "me.champeau.gradle.jmh" version "0.4.4"
}

昔ながらのプラグイン指定の場合は以下のようにする。

buildscript {
  repositories {
    jcenter()
    maven {
      url "https://plugins.gradle.org/m2/"
    }
  }
  dependencies {
    classpath "me.champeau.gradle:jmh-gradle-plugin:0.4.3"
  }
}

apply plugin: "me.champeau.gradle.jmh"

build.gradle 全体は以下のようになる。

plugins {
    id 'java'
    id "me.champeau.gradle.jmh" version "0.4.3"
}

repositories {
    jcenter()
}

dependencies {
    jmh 'org.apache.commons:commons-lang3:3.6'
    jmh 'com.google.guava:guava:22.0'
}

プラグインの Configuration により jmh が作成されるため、ベンチマークで必要なライブラリの依存は jmh で読み込む。

ベンチマーク用ソースの作成

JMH では、src/jmh 以下にあるソースをベンチマーク対象とみなす。よって、ベンチマーク用のソースファイルは src/jmh/java/ 配下にパッケージを作成する(パッケージ名を指定しないデフォルトパッケージにした場合はエラーになるので注意)。

ベンチマーク対象のメソッドには @Benchmark を付ければよい。以下のようなる。

package etc9;

import com.google.common.base.Joiner;
import org.apache.commons.lang3.StringUtils;
import org.openjdk.jmh.annotations.Benchmark;

import java.util.Arrays;
import java.util.List;
import java.util.StringJoiner;
import java.util.stream.Collectors;

public class MicroBench {

    private static final List<String> list = Arrays.asList(
            "A", "B", "C", "D", "E", "F", "G", "H", "I", "J", "K", "L", "M", "N");

    @Benchmark
    public void simpleString() {
        String result = "";
        for (String s : list) result += result.isEmpty() ? s : ", " + s;
    }

    @Benchmark
    public void stringBuilder() {
        StringBuilder sb = new StringBuilder();
        for (int i = 0; i< list.size(); i++) {
            if (i > 0) {
                sb.append(", ");
            }
            sb.append(list.get(i));
        }
        sb.toString();
    }

    @Benchmark
    public void simpleStringBuilder() {
        StringBuilder sb = new StringBuilder();
        for (String s : list) sb.append(sb.length() == 0 ? s : ", " + s);
        sb.toString();
    }

    @Benchmark
    public void commonsLang() {
        String result = StringUtils.join(list, ", ");
    }

    @Benchmark
    public void guvavJoiner() {
        String result = Joiner.on(", ").join(list);
    }

    @Benchmark
    public void java8String() {
        String result = String.join(", ", list);
    }

    @Benchmark
    public void joining() {
        String result = list.stream().collect(Collectors.joining(", "));
    }

    @Benchmark
    public void stringJoiner() {
        StringJoiner joiner = new StringJoiner(", ");
        list.forEach(e -> joiner.add(e));
    }

    @Benchmark
    public void collectWithStringJoiner() {
        String result = list.stream().collect(
                () -> new StringJoiner(", "),
                StringJoiner::add,
                StringJoiner::merge).toString();
    }

}

メソッドに @Benchmark を付与することでベンチマーク対象となる。

ベンチマークの実行

以下でベンチマークが実行できる。

$ ./gradlew clean jmh

デフォルトだと1秒当たりの実行回数がスループットとしてスコア化される。

Benchmark Mode Cnt Score Error Units
MicroBench.stringBuilder thrpt 200 2681020.541 ± 24225.350 ops/s
MicroBench.commonsLang thrpt 200 2264534.146 ± 25666.285 ops/s
MicroBench.guvavJoiner thrpt 200 2211854.864 ± 22777.299 ops/s
MicroBench.java8String thrpt 200 2195035.344 ± 19511.354 ops/s
MicroBench.stringJoiner thrpt 200 2121539.080 ± 13531.781 ops/s
MicroBench.simpleStringBuilder thrpt 200 2110835.816 ± 32823.739 ops/s
MicroBench.collectWithStringJoiner thrpt 200 1860335.526 ± 24097.640 ops/s
MicroBench.joining thrpt 200 1851112.885 ± 19930.058 ops/s
MicroBench.simpleString thrpt 200 901608.887 ± 15150.450 ops/s

文字列配列の要素数も少ないのでなんとも言えないが、やっぱり文字列連結は遅い。後は大した差はないかな。

ちなみに9個のメソッドのベンチマークをデフォルト設定で取ると、ウォームアップ×20 + 計測×20 × 10回繰り返し × 9メソッドで 1時間くらいかかる。測定条件を変えるにはオプション設定を行う。

JMH オプション

JMH のオプションは、build.gradle で、例えば以下のように指定できる。

jmh {
   iterations = 10
   resultFormat = 'CSV'
}

JMH のオプションは以下の指定が可能。

プロパティ 説明
include [‘.*’] ベンチマークに含めるファイルを正規表現で指定。'クラス名.メソッド名' でマッチング判定される。
exclude ベンチマークから除外するファイルを正規表現で指定。'クラス名.メソッド名' でマッチング判定される。
iterations 10 計測の繰り返し回数(デフォルト20回)
benchmarkMode [‘thrpt’] ベンチマークモードを指定。秒間の実行スループット(thrpt)、1実行の平均時間(avgt)、最大/最小を含む実行時間(sample)、実行時間(1回のみのシングルショット)(ss)、 全て(all)、を指定
batchSize 1 オペレーション毎にベンチマークメソッドを呼ぶ回数(デフォルト1回)。いくつかの計測モードでは設定値は無効になる。
fork 10 1回のベンチマーク測定時に fork する回数。
failOnError false エラー発生時にベンチマークを失敗にするかどうか
forceGC false イテレーション毎に強制的にGCを行うか
jvm fork 時に使用するカスタムjvmを指定
jvmArgs fork 時に使用するカスタムjvmの引数
jvmArgsAppend fork 時のjvm引数 (末尾に追加)を指定
jvmArgsPrepend fork 時のjvm引数 (先頭に追加)を指定
humanOutputFile project.file(“human.txt”) human-readable 出力結果ファイル
resultsFile project.file(“results.txt”) 結果ファイル
operationsPerInvocation 1 ワーカースレッドが実行するベンチマーク対象の実行回数(後述)。
benchmarkParameters [:] ベンチマークパラメータ
profilers 追加で収集するプロファイル [cl, comp, gc, stack, perf, perfnorm, perfasm, xperf, xperfasm, hs_cl, hs_comp, hs_gc, hs_rt, hs_thr]
timeOnIteration ‘1s’ 1回の繰り返しにかける時間(デフォルト1s)
resultFormat ‘CSV’ 結果ファイルのフォーマット。CSV, JSON, NONE, SCSV, TEXTが指定できる。
synchronizeIterations false 繰り返しを同期するかどうか
threads 1 ベンチマーク時のワーカースレッド数(デフォルト1)。2とした場合は、ベンチマークメソッドをtimeOnIterationの時間中2スレッドで実行するため、通常はスループットが高くなる。
threadGroups [2,3,4] 非同期のベンチマークでスレッドグループディストリビューションの上書きを指定
timeout ‘10m’ イテレーションのタイムアウト
timeUnit ‘ms’ 結果の単位(デフォルト ms)
verbosity ‘NORMAL’ 詳細出力。SILENT、NORMAL、EXTRA のいずれかを指定する。
warmup ‘1s’ ウォームアップ毎の時間(デフォルト1s)
warmupBatchSize 1 ウォームアップオペレーション毎にベンチマークメソッドを呼ぶ回数(デフォルト1回)
warmupForks 0 1回のウォームアップにおいてforkする回数。0 とした場合にはウォームアップ時の fork は行わない。
warmupIterations 20 ウォームアップの繰り返し回数(デフォルト20回)
warmupMode ‘INDI’ ウォームアップモード。INDI(individual) 各ベンチマーク毎に独立してウォームアップを実施、BULKとした場合ベンチマーク開始前にバルクでウォームアップを実施、BULK_INDI とした場合は双方実施
warmupBenchmarks [‘.*Warmup’] 追加で実施するウォームアップ操作を指定
zip64 true 大きなアーカイブで ZIP64 フォーマットを使うかどうか
jmhVersion ‘1.19’ Jmh のバージョンを指定する(現時点でのデフォルト1.17)
includeTests false テストソースを生成する jmh の jar に含めるかどうか
duplicateClassesStrategy ‘fail’ jmhJar タスクでクラスの重複が発生した場合のストラテジ

JMH オプションの補足説明

オプションが何を意味するのかはコード見た方が早いので、分かりにくそうな箇所を補足。

fork

jmh はベンチマーク取得の際に、java コマンドを java.lang.ProcessBuilder 経由で発行してベンチマークを実行している。

  for (int i = 0; i < forkCount; i++) {
   List<String> forkedString  = getForkedMainCommand(...);
   long startTime = System.currentTimeMillis();

   List<IterationResult> result = doFork(...);
   if (!result.isEmpty()) {
     BenchmarkResultMetaData md = server.getMetadata();
     BenchmarkResult br = new BenchmarkResult(params, result, md);
     results.put(params, br);
   }

fork はこの際のループ回数として使われます。

iterations

繰り返し回数は、前述の fork によりForkedMain が実行され、 ForkedRunner の親クラス BaseRunner にある runBenchmark 内でのループ回数になる。かなり省略しますが、以下のようになっています。

  protected void runBenchmark(BenchmarkParams benchParams, 
      BenchmarkHandler handler, IterationResultAcceptor acceptor) {

    long measurementTime = System.currentTimeMillis();

    IterationParams mp = benchParams.getMeasurement();
    for (int i = 1; i <= mp.getCount(); i++) {
      if (runSystemGC()) {
        out.verbosePrintln("System.gc() executed");
      }

      boolean isLastIteration = (i == mp.getCount());
      
      IterationResult ir = handler.runIteration(benchParams, mp, isLastIteration);

      long stopTime = System.currentTimeMillis();
  }

ループ条件の mp.getCount() がiterations で指定した繰り返し回数になります。

そして、BenchmarkHandler#runIteration() 内で実際のイテレーション操作が行われます。

threads

スレッドは繰り返し内における BenchmarkTask の数になっています。

  public IterationResult runIteration(BenchmarkParams benchmarkParams, 
      IterationParams params, boolean last) {
    int numThreads = benchmarkParams.getThreads();
    TimeValue runtime = params.getTime();

    InfraControl control = new InfraControl(benchmarkParams, params,
        preSetupBarrier, preTearDownBarrier, last,
        new Control());

    // preparing the worker runnables
    BenchmarkTask[] runners = new BenchmarkTask[numThreads];
    for (int i = 0; i < runners.length; i++) {
      runners[i] = new BenchmarkTask(control);
    }

    // submit tasks to threadpool
    List<Future<BenchmarkTaskResult>> completed = new ArrayList<>();
    CompletionService<BenchmarkTaskResult> srv = new ExecutorCompletionService<>(executor);
    for (BenchmarkTask runner : runners) {
      srv.submit(runner);
    }
    ...
}

環境によってスレッド実行の方法は変わりますが、threads で指定したスレッド数が maxThreads としてスレッドプールを作成されており、

Executors.newFixedThreadPool(maxThreads, new WorkerThreadFactory(prefix));

ExecutorCompletionService を経由して BenchmarkTask が実行されます。

batchSize

BenchmarkTask から jmh が生成したベンチマーク用コードのメソッドが呼ばれます。そのメソッド内にて、実際のメソッド呼び出しのループ条件として batchSize が使われます。

for (int b = 0; b < batchSize; b++) {
  if (control.volatileSpoiler) return;
  l_microbench0_0.java8String();
}

バッチサイズは、計測モードがシングルショット(ss)やサンプル(sample)の際に、実際のベンチマークメソッドを呼ぶ際のループ回数として使われます。

operationsPerInvocation

ベンチマーク実行を制御するパラメータに見えますが、ベンチマーク結果値を調整するための値です。

以下のようにベンチマークメソッド内でループさせた場合、10 と設定しておくと実行時間などが1/10された値に調整されます。

@Benchmark 
@OperationsPerInvocation(10) 
public void test() {
  for (int i = 0; i < 10; i++) {
    // do something      
  }
} 

アノテーション

ベンチマークメソッドにはアノテーションにて計測方法を上書き設定できる。

@Benchmark
@Warmup(iterations = 5, time = 100, timeUnit = TimeUnit.MILLISECONDS)
@Measurement(iterations = 5, time = 100, timeUnit = TimeUnit.MILLISECONDS)
public double measure() {
    return Math.log(x1);
}

利用可能なアノテーションは以下のようなものがある。

アノテーション 説明
@Benchmark ベンチマーク対象メソッドに付与
@Setup ベンチマーク実行前の設定メソッドに付与
@TearDown ベンチマーク実行後の後処理メソッドに付与
@BenchmarkMode ベンチマークメソッドを指定。Mode.Throughput, Mode.AverageTime, Mode.SampleTime, Mode.SingleShotTime, Mode.All を指定
@Measurement 計測時のパラメータを設定。設定できる項目は、iterations, time, timeUnit, batchSize
@Warmup ウォームアップ時のパラメータを設定。設定できる項目は、iterations, time, timeUnit, batchSize
@OutputTimeUnit 結果レポートの出力単位TimeUnit を指定
@Timeout タイムアウト時間を指定
@Fork fork 回数や、fork 時の jvmオプションを指定
@CompilerControl fork 時のコンパイラオプションを指定(インライン展開を強制など)
@Threads スレッド数を指定
@Group 非同期ベンチマークで複数メソッドをグルーピング指定する
@GroupThreads グループ内のメソッドを実行するスレッド数を指定する
@OperationsPerInvocation 前述の operationsPerInvocation パラメータを参照
@Param 後述
@State 後述

@Param アノテーション

以下のようにベンチマーク時にパラメータを供給する

@Param({"1", "31", "65", "101", "103"})
public int arg;

@Param({"0", "1", "2", "4", "8", "16", "32"})
public int certainty;

@Benchmark
public boolean bench() {
  return BigInteger.valueOf(arg).isProbablePrime(certainty);
}

@State アノテーション

ベンチマーク時に状態を管理するオブジェクトを指定する

@State(Scope.Benchmark)
public static class BenchmarkState {
  volatile double x = Math.PI;
}

@State(Scope.Thread)
public static class ThreadState {
  volatile double x = Math.PI;
}

@Benchmark
public void measureUnshared(ThreadState state) {
  state.x++;
}

@Benchmark
public void measureShared(BenchmarkState state) {
   state.x++;
}

スコープ設定に応じて状態を保持できる。

メソッドチェーン

オプションは OptionsBuilder でメソッドチェーンで指定できる。

public static void main(String[] args) throws RunnerException {
   Options opt = new OptionsBuilder()
     .include(JMHSample_03_States.class.getSimpleName())
     .warmupIterations(5)
     .measurementIterations(5)
     .threads(4)
     .forks(1)
     .build();
  new Runner(opt).run();
}

ベンチマーククラス内に main メソッドを用意してオプション指定して実行することができる。

コマンドラインオプション

以下でJMHランタイムを含むjarが生成できる。

$ ./gradlew jmhJar

作成されたjarは以下のように実行できる。

$ java -jar libs/benchmarks.jar -wi 5 -i 5

ベンチマーク対象クラスBenchmarkに以下のようなmainメソッドを作成した場合、

public static void main(String[] args) throws RunnerException {
  Options options = new OptionsBuilder()
     .include(Benchmark.class.getSimpleName())
     .forks(1)
     .build();
  new Runner(options).run();
}

以下のように実行することでベンチマーク対象を個別に実行できる。

$ java -cp target/benchmark.jar etc9.Benchmark

指定できるコマンドラインオプションは以下。

Usage: java -jar ... [regexp*] [options]
 [opt] means optional argument.
 <opt> means required argument.
 "+" means comma-separated list of values.
 "time" arguments accept time suffixes, like "100ms".

  [arguments]                 Benchmarks to run (regexp+).

  -bm <mode>                  Benchmark mode. Available modes are: [Throughput/thrpt,
                              AverageTime/avgt, SampleTime/sample, SingleShotTime/ss,
                              All/all]

  -bs <int>                   Batch size: number of benchmark method calls per
                              operation. Some benchmark modes may ignore this
                              setting, please check this separately.

  -e <regexp+>                Benchmarks to exclude from the run.

  -f <int>                    How many times to fork a single benchmark. Use 0 to
                              disable forking altogether. Warning: disabling
                              forking may have detrimental impact on benchmark
                              and infrastructure reliability, you might want
                              to use different warmup mode instead.

  -foe <bool>                 Should JMH fail immediately if any benchmark had
                              experienced an unrecoverable error? This helps
                              to make quick sanity tests for benchmark suites,
                              as well as make the automated runs with checking error
                              codes.

  -gc <bool>                  Should JMH force GC between iterations? Forcing
                              the GC may help to lower the noise in GC-heavy benchmarks,
                              at the expense of jeopardizing GC ergonomics decisions.
                              Use with care.

  -h                          Display help.

  -i <int>                    Number of measurement iterations to do. Measurement
                              iterations are counted towards the benchmark score.

  -jvm <string>               Use given JVM for runs. This option only affects forked
                              runs.

  -jvmArgs <string>           Use given JVM arguments. Most options are inherited
                              from the host VM options, but in some cases you want
                              to pass the options only to a forked VM. Either single
                              space-separated option line, or multiple options
                              are accepted. This option only affects forked runs.

  -jvmArgsAppend <string>     Same as jvmArgs, but append these options before
                              the already given JVM args.

  -jvmArgsPrepend <string>    Same as jvmArgs, but prepend these options before
                              the already given JVM arg.

  -l                          List the benchmarks that match a filter, and exit.

  -lp                         List the benchmarks that match a filter, along with
                              parameters, and exit.

  -lprof                      List profilers.

  -lrf                        List machine-readable result formats.

  -o <filename>               Redirect human-readable output to a given file.

  -opi <int>                  Override operations per invocation, see @OperationsPerInvocation
                              Javadoc for details.

  -p <param={v,}*>            Benchmark parameters. This option is expected to
                              be used once per parameter. Parameter name and parameter
                              values should be separated with equals sign. Parameter
                              values should be separated with commas.

  -prof <profiler>            Use profilers to collect additional benchmark data.
                              Some profilers are not available on all JVMs and/or
                              all OSes. Please see the list of available profilers
                              with -lprof.

  -r <time>                   Minimum time to spend at each measurement iteration.
                              Benchmarks may generally run longer than iteration
                              duration.

  -rf <type>                  Format type for machine-readable results. These
                              results are written to a separate file (see -rff).
                              See the list of available result formats with -lrf.

  -rff <filename>             Write machine-readable results to a given file.
                              The file format is controlled by -rf option. Please
                              see the list of result formats for available formats.

  -si <bool>                  Should JMH synchronize iterations? This would significantly
                              lower the noise in multithreaded tests, by making
                              sure the measured part happens only when all workers
                              are running.

  -t <int>                    Number of worker threads to run with. 'max' means
                              the maximum number of hardware threads available
                              on the machine, figured out by JMH itself.

  -tg <int+>                  Override thread group distribution for asymmetric
                              benchmarks. This option expects a comma-separated
                              list of thread counts within the group. See @Group/@GroupThreads
                              Javadoc for more information.

  -to <time>                  Timeout for benchmark iteration. After reaching
                              this timeout, JMH will try to interrupt the running
                              tasks. Non-cooperating benchmarks may ignore this
                              timeout.

  -tu <TU>                    Override time unit in benchmark results. Available
                              time units are: [m, s, ms, us, ns].

  -v <mode>                   Verbosity mode. Available modes are: [SILENT, NORMAL,
                              EXTRA]

  -w <time>                   Minimum time to spend at each warmup iteration. Benchmarks
                              may generally run longer than iteration duration.

  -wbs <int>                  Warmup batch size: number of benchmark method calls
                              per operation. Some benchmark modes may ignore this
                              setting.

  -wf <int>                   How many warmup forks to make for a single benchmark.
                              All iterations within the warmup fork are not counted
                              towards the benchmark score. Use 0 to disable warmup
                              forks.

  -wi <int>                   Number of warmup iterations to do. Warmup iterations
                              are not counted towards the benchmark score.

  -wm <mode>                  Warmup mode for warming up selected benchmarks.
                              Warmup modes are: INDI = Warmup each benchmark individually,
                              then measure it. BULK = Warmup all benchmarks first,
                              then do all the measurements. BULK_INDI = Warmup
                              all benchmarks first, then re-warmup each benchmark
                              individually, then measure it.

  -wmb <regexp+>              Warmup benchmarks to include in the run in addition
                              to already selected by the primary filters. Harness
                              will not measure these benchmarks, but only use them
                              for the warmup.