为什么 lambda IntStream.anyMatch() 比简单的实现慢 10?
Why lambda IntStream.anyMatch() is 10 slower than naive implementation?
我最近在分析我的代码,发现其中有一个有趣的瓶颈。这是基准:
@BenchmarkMode(Mode.Throughput)
@Fork(1)
@State(Scope.Thread)
@Warmup(iterations = 10, time = 1, timeUnit = TimeUnit.SECONDS)
@Measurement(iterations = 10, time = 1, timeUnit = TimeUnit.SECONDS)
public class Contains {
private int[] ar = new int[] {1,2,3,4,5,6,7};
private int val = 5;
@Benchmark
public boolean naive() {
return contains(ar, val);
}
@Benchmark
public boolean lambdaArrayStreamContains() {
return Arrays.stream(ar).anyMatch(i -> i == val);
}
@Benchmark
public boolean lambdaIntStreamContains() {
return IntStream.of(ar).anyMatch(i -> i == val);
}
private static boolean contains(int[] ar, int value) {
for (int arVal : ar) {
if (arVal == value) {
return true;
}
}
return false;
}
}
结果:
Benchmark Mode Cnt Score Error Units
Contains.lambdaArrayStreamContains thrpt 10 22867.962 ± 1049.649 ops/s
Contains.lambdaIntStreamContains thrpt 10 22983.800 ± 593.580 ops/s
Contains.naive thrpt 10 228002.406 ± 8591.186 ops/s
If 显示 Array contains operation via lambda 比使用简单循环的简单实现慢 10 倍。我知道 lambda 应该慢一点。但是10次?我做错了 lambda 还是 java 有问题?
您的基准测试实际上并不测量 anyMatch
性能,而是测量流开销。与五元素数组查找等非常简单的操作相比,这种开销可能显得很大。
如果我们从相对数字转向绝对数字,那么放缓看起来不会那么可怕。让我们测量延迟而不是吞吐量以获得更清晰的画面。我省略了 lambdaIntStream
基准测试,因为它的工作方式与 lambdaArrayStream
.
完全相同
Benchmark Mode Cnt Score Error Units
Contains.lambdaArrayStream avgt 5 53,242 ± 2,034 ns/op
Contains.naive avgt 5 5,876 ± 0,404 ns/op
5.8 ns 大约是 2.4 GHz 的 14 个周期 CPU。工作量很小,任何额外的周期都会很明显。那么流操作的开销是多少?
对象分配
现在使用 -prof gc
分析器重新运行基准测试。它将显示堆分配量:
Benchmark Mode Cnt Score Error Units
Contains.lambdaArrayStream:·gc.alloc.rate.norm avgt 5 152,000 ± 0,001 B/op
Contains.naive:·gc.alloc.rate.norm avgt 5 ≈ 10⁻⁵ B/op
lambdaArrayStream
每次迭代分配 152 个字节,而 naive
基准测试不分配任何内容。当然,分配也不是免费的:构造了至少5个对象来支持anyMatch
,每个都需要几纳秒:
- 拉姆达
i -> i == val
- IntPipeline.Head
- Spliterators.IntArraySpliterator
- MatchOps.MatchOp
- MatchOps.MatchSink
调用堆栈
java.util.stream
实现有点复杂,因为它必须支持流源、中间和终端操作的所有组合。如果您在基准测试中查看 anyMatch
的调用堆栈,您会看到类似的内容:
at bench.Contains.lambda$lambdaArrayStream[=12=](Contains.java:24)
at java.util.stream.MatchOpsMatchSink.accept(MatchOps.java:119)
at java.util.Spliterators$IntArraySpliterator.tryAdvance(Spliterators.java:1041)
at java.util.stream.IntPipeline.forEachWithCancel(IntPipeline.java:162)
at java.util.stream.AbstractPipeline.copyIntoWithCancel(AbstractPipeline.java:498)
at java.util.stream.AbstractPipeline.copyInto(AbstractPipeline.java:485)
at java.util.stream.AbstractPipeline.wrapAndCopyInto(AbstractPipeline.java:471)
at java.util.stream.MatchOps$MatchOp.evaluateSequential(MatchOps.java:230)
at java.util.stream.MatchOps$MatchOp.evaluateSequential(MatchOps.java:196)
at java.util.stream.AbstractPipeline.evaluate(AbstractPipeline.java:234)
at java.util.stream.IntPipeline.anyMatch(IntPipeline.java:477)
at bench.Contains.lambdaArrayStream(Contains.java:23)
并非所有这些方法调用都可以内联。此外,JVM 将内联限制为 9 级,但在这里我们看到了更深的调用堆栈。如果我们用 -XX:MaxInlineLevel=20
覆盖限制,分数会变得更好:
Benchmark Mode Cnt Score Error Units
Contains.lambdaArrayStream avgt 5 33,294 ± 0,367 ns/op (was 53,242)
Contains.naive avgt 5 5,822 ± 0,207 ns/op
循环优化
for
对数组的迭代是一个微不足道的计数循环。 JVM 可以在这里应用广泛的循环优化:循环剥离、循环展开等。这不适用于 forEachWithCancel
方法中的 while
类循环,它用于遍历 IntStream。循环优化的效果可以用 -XX:LoopUnrollLimit=0 -XX:-UseLoopPredicate
:
来衡量
Benchmark Mode Cnt Score Error Units
Contains.lambdaArrayStream avgt 5 33,153 ± 0,559 ns/op
Contains.naive avgt 5 9,853 ± 0,150 ns/op (was 5,876)
结论
是 构造和遍历流的一些开销,但这是完全可以理解的,不能将其视为错误。我不会说开销很大(即使是 50 ns/op 也不算多);但是,在这个特定示例中,由于工作量极小,开销占主导地位。
我最近在分析我的代码,发现其中有一个有趣的瓶颈。这是基准:
@BenchmarkMode(Mode.Throughput)
@Fork(1)
@State(Scope.Thread)
@Warmup(iterations = 10, time = 1, timeUnit = TimeUnit.SECONDS)
@Measurement(iterations = 10, time = 1, timeUnit = TimeUnit.SECONDS)
public class Contains {
private int[] ar = new int[] {1,2,3,4,5,6,7};
private int val = 5;
@Benchmark
public boolean naive() {
return contains(ar, val);
}
@Benchmark
public boolean lambdaArrayStreamContains() {
return Arrays.stream(ar).anyMatch(i -> i == val);
}
@Benchmark
public boolean lambdaIntStreamContains() {
return IntStream.of(ar).anyMatch(i -> i == val);
}
private static boolean contains(int[] ar, int value) {
for (int arVal : ar) {
if (arVal == value) {
return true;
}
}
return false;
}
}
结果:
Benchmark Mode Cnt Score Error Units
Contains.lambdaArrayStreamContains thrpt 10 22867.962 ± 1049.649 ops/s
Contains.lambdaIntStreamContains thrpt 10 22983.800 ± 593.580 ops/s
Contains.naive thrpt 10 228002.406 ± 8591.186 ops/s
If 显示 Array contains operation via lambda 比使用简单循环的简单实现慢 10 倍。我知道 lambda 应该慢一点。但是10次?我做错了 lambda 还是 java 有问题?
您的基准测试实际上并不测量 anyMatch
性能,而是测量流开销。与五元素数组查找等非常简单的操作相比,这种开销可能显得很大。
如果我们从相对数字转向绝对数字,那么放缓看起来不会那么可怕。让我们测量延迟而不是吞吐量以获得更清晰的画面。我省略了 lambdaIntStream
基准测试,因为它的工作方式与 lambdaArrayStream
.
Benchmark Mode Cnt Score Error Units
Contains.lambdaArrayStream avgt 5 53,242 ± 2,034 ns/op
Contains.naive avgt 5 5,876 ± 0,404 ns/op
5.8 ns 大约是 2.4 GHz 的 14 个周期 CPU。工作量很小,任何额外的周期都会很明显。那么流操作的开销是多少?
对象分配
现在使用 -prof gc
分析器重新运行基准测试。它将显示堆分配量:
Benchmark Mode Cnt Score Error Units
Contains.lambdaArrayStream:·gc.alloc.rate.norm avgt 5 152,000 ± 0,001 B/op
Contains.naive:·gc.alloc.rate.norm avgt 5 ≈ 10⁻⁵ B/op
lambdaArrayStream
每次迭代分配 152 个字节,而 naive
基准测试不分配任何内容。当然,分配也不是免费的:构造了至少5个对象来支持anyMatch
,每个都需要几纳秒:
- 拉姆达
i -> i == val
- IntPipeline.Head
- Spliterators.IntArraySpliterator
- MatchOps.MatchOp
- MatchOps.MatchSink
调用堆栈
java.util.stream
实现有点复杂,因为它必须支持流源、中间和终端操作的所有组合。如果您在基准测试中查看 anyMatch
的调用堆栈,您会看到类似的内容:
at bench.Contains.lambda$lambdaArrayStream[=12=](Contains.java:24)
at java.util.stream.MatchOpsMatchSink.accept(MatchOps.java:119)
at java.util.Spliterators$IntArraySpliterator.tryAdvance(Spliterators.java:1041)
at java.util.stream.IntPipeline.forEachWithCancel(IntPipeline.java:162)
at java.util.stream.AbstractPipeline.copyIntoWithCancel(AbstractPipeline.java:498)
at java.util.stream.AbstractPipeline.copyInto(AbstractPipeline.java:485)
at java.util.stream.AbstractPipeline.wrapAndCopyInto(AbstractPipeline.java:471)
at java.util.stream.MatchOps$MatchOp.evaluateSequential(MatchOps.java:230)
at java.util.stream.MatchOps$MatchOp.evaluateSequential(MatchOps.java:196)
at java.util.stream.AbstractPipeline.evaluate(AbstractPipeline.java:234)
at java.util.stream.IntPipeline.anyMatch(IntPipeline.java:477)
at bench.Contains.lambdaArrayStream(Contains.java:23)
并非所有这些方法调用都可以内联。此外,JVM 将内联限制为 9 级,但在这里我们看到了更深的调用堆栈。如果我们用 -XX:MaxInlineLevel=20
覆盖限制,分数会变得更好:
Benchmark Mode Cnt Score Error Units
Contains.lambdaArrayStream avgt 5 33,294 ± 0,367 ns/op (was 53,242)
Contains.naive avgt 5 5,822 ± 0,207 ns/op
循环优化
for
对数组的迭代是一个微不足道的计数循环。 JVM 可以在这里应用广泛的循环优化:循环剥离、循环展开等。这不适用于 forEachWithCancel
方法中的 while
类循环,它用于遍历 IntStream。循环优化的效果可以用 -XX:LoopUnrollLimit=0 -XX:-UseLoopPredicate
:
Benchmark Mode Cnt Score Error Units
Contains.lambdaArrayStream avgt 5 33,153 ± 0,559 ns/op
Contains.naive avgt 5 9,853 ± 0,150 ns/op (was 5,876)
结论
是 构造和遍历流的一些开销,但这是完全可以理解的,不能将其视为错误。我不会说开销很大(即使是 50 ns/op 也不算多);但是,在这个特定示例中,由于工作量极小,开销占主导地位。