長さ2の配列に対する次の2つのコードスニペットを考えてみます。
boolean isOK(int i) {
for (int j = 0; j < filters.length; ++j) {
if (!filters[j].isOK(i)) {
return false;
}
}
return true;
}
そして
boolean isOK(int i) {
return filters[0].isOK(i) && filters[1].isOK(i);
}
十分なウォームアップ後、これら2つのピースのパフォーマンスは同様になるはずだと思います。ここやここなどで
説明されているように、JMHマイクロベンチマークフレームワークを使用してこれをチェックし、2番目のスニペットが10%以上高速であることを確認しました。
質問:Javaが基本的なループ展開技術を使用して最初のスニペットを最適化していないのはなぜですか?
特に、以下について理解したいと思います。
- 2つのフィルターの場合に最適なコードを簡単に作成できますが、別の数のフィルターの場合でも機能します(単純なビルダーを想像してください)
return (filters.length) == 2 ? new FilterChain2(filters) : new FilterChain1(filters)
。JITCは同じことを行うことができますか? - JITCは 'filters.length == 2 'が最も頻繁に発生するケースであることを検出し、ウォームアップ後にこのケースに最適なコードを生成できますか?これは、手動で展開したバージョンとほぼ同じくらい最適です。
- JITCは、特定のインスタンスが非常に頻繁に使用されていることを検出し、この特定のインスタンス(フィルターの数が常に2であることがわかっている)のコードを生成できますか?
更新: JITCはクラスレベルでのみ機能するという回答を得ました。はい、わかった。
理想的には、JITCの仕組みを深く理解している方から回答をいただきたいと考えています。
ベンチマーク実行の詳細:
- 最新バージョンのJava 8 OpenJDKとOracle HotSpotで試した結果は同じです
- 使用されているJavaフラグ:-Xmx4g -Xms4g -server -Xbatch -XX:CICompilerCount = 2(ファンシーフラグなしで同様の結果を得た)
- ちなみに、ループで(JMH経由ではなく)数十億回実行するだけでも、同様の実行時間比率が得られます。つまり、2番目のスニペットは常に明らかに高速です。
一般的なベンチマーク出力:
ベンチマーク(filterIndex)モードCntスコアエラー単位
LoopUnrollingBenchmark.runBenchmark 0 avgt 400 44.202±0.224 ns / op
LoopUnrollingBenchmark.runBenchmark 1 avgt 400 38.347±0.063 ns / op
(1行目は最初のスニペットに対応し、2行目は2行目に対応します。
完全なベンチマークコード:
public class LoopUnrollingBenchmark {
@State(Scope.Benchmark)
public static class BenchmarkData {
public Filter[] filters;
@Param({"0", "1"})
public int filterIndex;
public int num;
@Setup(Level.Invocation) //similar ratio with Level.TRIAL
public void setUp() {
filters = new Filter[]{new FilterChain1(), new FilterChain2()};
num = new Random().nextInt();
}
}
@Benchmark
@Fork(warmups = 5, value = 20)
@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.NANOSECONDS)
public int runBenchmark(BenchmarkData data) {
Filter filter = data.filters[data.filterIndex];
int sum = 0;
int num = data.num;
if (filter.isOK(num)) {
++sum;
}
if (filter.isOK(num + 1)) {
++sum;
}
if (filter.isOK(num - 1)) {
++sum;
}
if (filter.isOK(num * 2)) {
++sum;
}
if (filter.isOK(num * 3)) {
++sum;
}
if (filter.isOK(num * 5)) {
++sum;
}
return sum;
}
interface Filter {
boolean isOK(int i);
}
static class Filter1 implements Filter {
@Override
public boolean isOK(int i) {
return i % 3 == 1;
}
}
static class Filter2 implements Filter {
@Override
public boolean isOK(int i) {
return i % 7 == 3;
}
}
static class FilterChain1 implements Filter {
final Filter[] filters = createLeafFilters();
@Override
public boolean isOK(int i) {
for (int j = 0; j < filters.length; ++j) {
if (!filters[j].isOK(i)) {
return false;
}
}
return true;
}
}
static class FilterChain2 implements Filter {
final Filter[] filters = createLeafFilters();
@Override
public boolean isOK(int i) {
return filters[0].isOK(i) && filters[1].isOK(i);
}
}
private static Filter[] createLeafFilters() {
Filter[] filters = new Filter[2];
filters[0] = new Filter1();
filters[1] = new Filter2();
return filters;
}
public static void main(String[] args) throws Exception {
org.openjdk.jmh.Main.main(args);
}
}
@Setup(Level.Invocation)
:わかりません(javadocを参照)。
final
、JITは、クラスのすべてのインスタンスが長さ2の配列を取得することを確認しません。これを確認するには、createLeafFilters()
メソッドは、配列が常に2になることを知るのに十分なほど深くコードを分析します。JITオプティマイザーがそれをコードに深く掘り下げると信じているのはなぜですか?