比较Collectors.summingLong和Collectors.counting的性能表现。

9

Intel Core i5 处理器和 Ubuntu 操作系统下运行基准测试。

java version "1.8.0_144"
Java(TM) SE Runtime Environment (build 1.8.0_144-b01)
Java HotSpot(TM) 64-Bit Server VM (build 25.144-b01, mixed mode)

我正在比较Collectors.countingCollectors.summingLong(x -> 1L)的性能。以下是基准测试:

public List<Integer> ints = new ArrayList<>();

Collector<Integer, ?, Long> counting = Collectors.counting();
Collector<Integer, ?, Long> summingLong = Collectors.summingLong(x -> 1L);

@Setup
public void setup() throws Exception{
    ints.add(new Random().nextInt(1000));
    ints.add(new Random().nextInt(1000));
    ints.add(new Random().nextInt(1000));
    ints.add(new Random().nextInt(1000));
    ints.add(new Random().nextInt(1000));
    ints.add(new Random().nextInt(1000));
    ints.add(new Random().nextInt(1000));
    ints.add(new Random().nextInt(1000));
    ints.add(new Random().nextInt(1000));
    ints.add(new Random().nextInt(1000));
}

@Benchmark
@OutputTimeUnit(TimeUnit.NANOSECONDS)
@BenchmarkMode(Mode.AverageTime)
public Long counting() {
    return ints.stream().collect(counting);
}

@Benchmark
@OutputTimeUnit(TimeUnit.NANOSECONDS)
@BenchmarkMode(Mode.AverageTime)
public Long summingLong() {
    return ints.stream().collect(summingLong);
}

我得到的结果是使用Collectors.countingCollectors.summingLong慢了3倍。

于是我使用-prof perfnorm并进行了25次分支测试。以下是结果:

Benchmark                                        Mode  Cnt    Score     Error  Units
MyBenchmark.counting                             avgt  125   87.115 ±   3.787  ns/op
MyBenchmark.counting:CPI                         avgt   25    0.310 ±   0.011   #/op
MyBenchmark.counting:L1-dcache-load-misses       avgt   25    1.963 ±   0.171   #/op
MyBenchmark.counting:L1-dcache-loads             avgt   25  258.716 ±  41.458   #/op
MyBenchmark.counting:L1-dcache-store-misses      avgt   25    1.890 ±   0.168   #/op
MyBenchmark.counting:L1-dcache-stores            avgt   25  131.344 ±  16.433   #/op
MyBenchmark.counting:L1-icache-load-misses       avgt   25    0.035 ±   0.007   #/op
MyBenchmark.counting:LLC-loads                   avgt   25    0.411 ±   0.143   #/op
MyBenchmark.counting:LLC-stores                  avgt   25    0.007 ±   0.001   #/op
MyBenchmark.counting:branch-misses               avgt   25    0.029 ±   0.017   #/op
MyBenchmark.counting:branches                    avgt   25  139.669 ±  21.901   #/op
MyBenchmark.counting:cycles                      avgt   25  261.967 ±  29.392   #/op
MyBenchmark.counting:dTLB-load-misses            avgt   25    0.036 ±   0.003   #/op
MyBenchmark.counting:dTLB-loads                  avgt   25  258.111 ±  41.008   #/op
MyBenchmark.counting:dTLB-store-misses           avgt   25    0.001 ±   0.001   #/op
MyBenchmark.counting:dTLB-stores                 avgt   25  131.394 ±  16.166   #/op
MyBenchmark.counting:iTLB-load-misses            avgt   25    0.001 ±   0.001   #/op
MyBenchmark.counting:iTLB-loads                  avgt   25    0.001 ±   0.001   #/op
MyBenchmark.counting:instructions                avgt   25  850.262 ± 113.228   #/op
MyBenchmark.counting:stalled-cycles-frontend     avgt   25   48.493 ±   8.968   #/op
MyBenchmark.summingLong                          avgt  125   37.238 ±   0.194  ns/op
MyBenchmark.summingLong:CPI                      avgt   25    0.311 ±   0.002   #/op
MyBenchmark.summingLong:L1-dcache-load-misses    avgt   25    1.793 ±   0.013   #/op
MyBenchmark.summingLong:L1-dcache-loads          avgt   25   93.785 ±   0.640   #/op
MyBenchmark.summingLong:L1-dcache-store-misses   avgt   25    1.727 ±   0.013   #/op
MyBenchmark.summingLong:L1-dcache-stores         avgt   25   56.249 ±   0.408   #/op
MyBenchmark.summingLong:L1-icache-load-misses    avgt   25    0.020 ±   0.003   #/op
MyBenchmark.summingLong:LLC-loads                avgt   25    0.843 ±   0.117   #/op
MyBenchmark.summingLong:LLC-stores               avgt   25    0.004 ±   0.001   #/op
MyBenchmark.summingLong:branch-misses            avgt   25    0.008 ±   0.002   #/op
MyBenchmark.summingLong:branches                 avgt   25   61.472 ±   0.260   #/op
MyBenchmark.summingLong:cycles                   avgt   25  110.949 ±   0.784   #/op
MyBenchmark.summingLong:dTLB-load-misses         avgt   25    0.031 ±   0.001   #/op
MyBenchmark.summingLong:dTLB-loads               avgt   25   93.662 ±   0.616   #/op
MyBenchmark.summingLong:dTLB-store-misses        avgt   2510⁻³             #/op
MyBenchmark.summingLong:dTLB-stores              avgt   25   56.302 ±   0.351   #/op
MyBenchmark.summingLong:iTLB-load-misses         avgt   25    0.001 ±   0.001   #/op
MyBenchmark.summingLong:iTLB-loads               avgt   2510⁻³             #/op
MyBenchmark.summingLong:instructions             avgt   25  357.029 ±   1.712   #/op
MyBenchmark.summingLong:stalled-cycles-frontend  avgt   25   10.074 ±   1.096   #/op

我注意到的是:
分支、指令和周期差别几乎达到了3倍。缓存操作也是如此。分支似乎预测得很好,而且缓存未命中也不太多(这只是我的看法)。
因此,问题可能出在编译代码上。使用-prof perfasm运行它(太长了,无法放在这里)。
在编译代码中,我注意到了以下内容: I.Collectors.summingLongassembly 我们有3个循环迭代数组并计数。第一个只计算一个元素。
0x00007f9abd226dfd: mov %edi,%ebp ;contains the iteration index
incl %ebp
;...
0x00007f9abd226e27: incl %edi
0x00007f9abd226e29: cmp %ebp,%edi
0x00007f9abd226e2b: jnl 0x7f9abd226e34 ;exit after the first iteration

每次迭代需要4个元素(这是循环展开吗?),并且在第一个元素后退出。

0x00007f9abd226ea6: add $0x1,%rsi 
;...
0x00007f9abd226ed0: add $0x2,%rsi
;...
0x00007f9abd226efa: add $0x3,%rsi
;...
0x00007f9abd226f1c: add $0x4,%rbx
;...
0x00007f9abd226f20: mov %rbx,0x10(%r14)

第三项计算其余元素。

IICollectors.counting汇编

我们仅有一个循环以逐个计算所有元素(未展开)。此外,我们在计数结果的循环内联了装箱转换。另外,在循环中似乎没有内联装箱转换。

0x00007f80dd22dfb5: mov $0x1,%esi
0x00007f80dd22dfba: nop
0x00007f80dd22dfbb: callq 0x7f80dd046420

看起来这段代码对lambda表达式中提供的1L进行了装箱操作,但是原因并不清楚。实际执行加法时,我们有以下代码:

0x00007f80dd22dfec: mov $0x1,%r10d
0x00007f80dd22dff2: add 0x10(%r11),%r10

此外,我们将计数结果存储在栈内mov %r10d,0x10(%rsp),而不是像第一种情况那样存储在堆内。如果我正确理解了正在发生的事情,我的问题是:循环展开和装箱转换导致了3倍的性能下降吗?如果是这样,为什么在counting情况下运行时没有展开循环?请注意,Collectors.summingLong的GC压力比Collectors.counting高2.5倍。这并不是很清楚(我只能猜测我们在Collectors.counting中将中间值存储在栈中)。
MyBenchmark.counting                                      avgt    5    96.956 ±   4.412   ns/op
MyBenchmark.counting:·gc.alloc.rate                       avgt    5   734.538 ±  33.083  MB/sec
MyBenchmark.counting:·gc.alloc.rate.norm                  avgt    5   112.000 ±   0.001    B/op
MyBenchmark.counting:·gc.churn.PS_Eden_Space              avgt    5   731.423 ± 340.767  MB/sec
MyBenchmark.counting:·gc.churn.PS_Eden_Space.norm         avgt    5   111.451 ±  48.411    B/op
MyBenchmark.counting:·gc.churn.PS_Survivor_Space          avgt    5     0.017 ±   0.067  MB/sec
MyBenchmark.counting:·gc.churn.PS_Survivor_Space.norm     avgt    5     0.003 ±   0.010    B/op
MyBenchmark.counting:·gc.count                            avgt    5    16.000            counts
MyBenchmark.counting:·gc.time                             avgt    5    12.000                ms
MyBenchmark.summingLong                                   avgt    5    38.371 ±   1.733   ns/op
MyBenchmark.summingLong:·gc.alloc.rate                    avgt    5  1856.581 ±  81.706  MB/sec
MyBenchmark.summingLong:·gc.alloc.rate.norm               avgt    5   112.000 ±   0.001    B/op
MyBenchmark.summingLong:·gc.churn.PS_Eden_Space           avgt    5  1876.736 ± 192.503  MB/sec
MyBenchmark.summingLong:·gc.churn.PS_Eden_Space.norm      avgt    5   113.213 ±   9.916    B/op
MyBenchmark.summingLong:·gc.churn.PS_Survivor_Space       avgt    5     0.033 ±   0.072  MB/sec
MyBenchmark.summingLong:·gc.churn.PS_Survivor_Space.norm  avgt    5     0.002 ±   0.004    B/op
MyBenchmark.summingLong:·gc.count                         avgt    5    62.000            counts
MyBenchmark.summingLong:·gc.time                          avgt    5    48.000                ms

2
你能否尝试以下代码:final Long one = Long.valueOf(1L); Collector<Integer, ?, Long> counting = Collectors.reducing(0L, e -> one, Long::sum); 并查看是否有所不同?这段代码是 Collectors.counting(...) 的实现,它事先装箱了 1L。请看它是否有所区别。 - Sergey Kalinichenko
1
我认为这是装箱。我从这里获取了Collectors.counting的实现,并用e -> one替换了e -> 1L,其中one是预装箱的值1L - Sergey Kalinichenko
5
请注意,《Effective Java》新版(第46条,第214页)指出:“counting方法返回的收集器仅供作为下游收集器使用。通过Streamcount方法直接获得相同的功能,因此**没有理由使用collect(counting())**。”换句话说,你不应该这样做 :) 可能是因为你在这里所描述的原因。 - Andy Turner
1
@St.Antario 嗯,如果有重大的内存回收,JMH 会失败(但我可能是错的......)。为了避免这种情况,我会使用 jvmArgs 来运行每个基准测试,并尽可能地分配更多堆内存。至于 gdb,我从未试过,所以这超出了我的能力范围 :( - Eugene
1
@apangin 这与性能差异无关...但您是否有任何想法,为什么在Collectors.counting的情况下运行时没有展开循环? - St.Antario
显示剩余8条评论
1个回答

8
我并没有查看汇编代码或对其进行分析,但查看源代码已经提供了一些信息。
使用summingLong()会得到以下结果:
new CollectorImpl<>(
            () -> new long[1],
            (a, t) -> { a[0] += mapper.applyAsLong(t); },
            (a, b) -> { a[0] += b[0]; return a; },
            a -> a[0], CH_NOID);

counting() 的结果如下:

new CollectorImpl<>(
            boxSupplier(identity),
            (a, t) -> { a[0] = op.apply(a[0], mapper.apply(t)); },
            (a, b) -> { a[0] = op.apply(a[0], b[0]); return a; },
            a -> a[0], CH_NOID);

如您所见,counting() 版本正在进行一些额外的操作:

  • 装箱
  • 调用 op.apply(...)

由于op是基于原始类型操作的Long::sum,因此涉及了大量的装箱和拆箱。


1
谢谢您的回答。是的,我看了这段代码并注意到了装箱转换。但是......如果我们使用“-prof gc”运行基准测试,我们可以注意到在“summingLong”情况下的分配速率是2.5倍,即“1800”与“700”MB /秒。 - St.Antario
5
在Java 9中,他们将Collectors.counting更改为summingLong(x -> 1L),这很有趣。 - Eugene
@Eugene 确实有趣,也许他们意识到了其中的区别 ;) - Thomas
@Thomas 实际上,我同意。我刚刚尝试了使用 -XX:LoopUnrollLimit=1 进行基准测试,并验证了在 summingLong 情况下循环不再展开。并没有获得太大的性能差异。似乎装箱操作对性能影响最大。 - St.Antario
3
问题在于 JDK 8 中的 Collectors.counting() 进行了不必要的装箱操作。该问题已在邮件列表上得到讨论,并在 JDK 9 中得到了修复,详见 JDK-8136686 - apangin

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