为什么 lambda IntStream.anyMatch() 比原始实现慢10倍?

3
我最近在对我的代码进行性能分析时发现了一个有趣的瓶颈。以下是基准测试结果:
@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

显示通过lambda进行的数组包含操作比使用简单循环的朴素实现要慢10倍。我知道lambda应该会慢一些,但是10倍吗?我是在错误地使用lambda还是这是Java的某些问题?


4
@GhostCat 这是http://openjdk.java.net/projects/code-tools/jmh/。 - Dmitriy Dumanskiy
10
你的数据量不大,因此流式处理框架的固定开销几乎会主导一切。这个结果并不令人惊讶。如果你有更多的数据,它可能会更有竞争力。但就目前而言,流式处理实现会进行几次分配--在你的代码中有两次,另外还有流式处理框架的实现也有很多次--这将会产生成本。 - Louis Wasserman
2
为了补充@Louis Wasserman的评论,您至少通过捕获lambda表达式创建了一个IntStream实例和一个IntPredicate实例,并且十个预热迭代可能不足以让HotSpot省略分配成本。如果我理解正确,测得速度为22867 ops/s与228002 ops/s相比,我们在完成操作时需要0.04毫秒与0.004毫秒... - Holger
1
error, status code: 429, message: Rate limit reached for default-gpt-3.5-turbo in organization org-HHYtgFtDw4VsdlhuRK1UCbv4 on requests per min. Limit: 3 / min. Please try again in 20s. Contact us through our help center at help.openai.com if you continue to have issues. Please add a payment method to your account to increase your rate limit. Visit https://platform.openai.com/account/billing to add a payment method. - Holger
1
这不是文档,只是一个示例中的注释。对于快照而言并非没有根据,但JVM是一个不断变化的目标。这篇文章使用了“虚拟机不太可能尝试常量折叠优化”的短语。这篇文章指出,即使使用BlackHole作为结果,“这也让JVM更难优化它(但并非不可能!)”。您应该始终记住,今天的障碍对明天来说可能是微不足道的。始终验证 - Holger
显示剩余22条评论
1个回答

10

你的基准测试实际上并没有测量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纳秒大约是一台2.4 GHz CPU的14个时钟周期。工作量很小,因此任何额外的周期都会引起注意。那么流操作的开销是多少呢?

对象分配

现在使用-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,每个对象需要几纳秒:

  • Lambda i -> i == val
  • IntPipeline.Head
  • Spliterators.IntArraySpliterator
  • MatchOps.MatchOp
  • MatchOps.MatchSink

调用堆栈

java.util.stream的实现有点复杂,因为它必须支持所有流源、中间和终端操作的所有组合。如果您查看基准测试中anyMatch的调用堆栈,您将看到类似于以下内容:

    at bench.Contains.lambda$lambdaArrayStream$0(Contains.java:24)
    at java.util.stream.MatchOps$2MatchSink.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纳秒也不算太多);但是,在这个特定的例子中,由于工作量极小,这种开销占据了主导地位。


网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接