什么可以解释将引用写入堆位置的巨大性能损失?

9

在研究分代垃圾回收器对应用程序性能的微妙影响时,我发现在写入基本操作 - 写入堆位置 - 时与写入原语或引用有关,性能存在惊人的差异。

微基准测试

@OutputTimeUnit(TimeUnit.NANOSECONDS)
@BenchmarkMode(Mode.AverageTime)
@Warmup(iterations = 1, time = 1)
@Measurement(iterations = 3, time = 1)
@State(Scope.Thread)
@Threads(1)
@Fork(2)
public class Writing
{
  static final int TARGET_SIZE = 1024;

  static final    int[] primitiveArray = new    int[TARGET_SIZE];
  static final Object[] referenceArray = new Object[TARGET_SIZE];

  int val = 1;
  @GenerateMicroBenchmark
  public void fillPrimitiveArray() {
    final int primitiveValue = val++;
    for (int i = 0; i < TARGET_SIZE; i++)
      primitiveArray[i] = primitiveValue;
  }

  @GenerateMicroBenchmark
  public void fillReferenceArray() {
    final Object referenceValue = new Object();
    for (int i = 0; i < TARGET_SIZE; i++)
      referenceArray[i] = referenceValue;
  }
}

结果

Benchmark              Mode Thr    Cnt  Sec         Mean   Mean error    Units
fillPrimitiveArray     avgt   1      6    1       87.891        1.610  nsec/op
fillReferenceArray     avgt   1      6    1      640.287        8.368  nsec/op

由于整个循环减速了近8倍,因此写入本身可能慢了10倍以上。有什么可能解释这样的减速呢?
原始数组的写入速度超过每纳秒10次写入。也许我应该问一个相反的问题:是什么使原始写入如此快呢?(顺便说一句,我已经检查过,时间与数组大小成线性比例。)
请注意,这都是单线程的;指定@Threads(2)将增加两个测量值,但比率将类似。
背景知识:卡表及其关联的写屏障
Young代中的对象可能只能从Old代中的对象访问。为了避免收集活动对象,YG收集器必须知道自上次YG收集以来写入Old代区域的任何引用。这是通过一种称为“脏标记表”的“脏标记表”实现的,每512字节堆块有一个标记。
这个方案的“丑陋”部分在于我们意识到每个引用的每个写入都必须伴随着一段维护“卡表不变式”的代码:保护正在写入的地址的卡表位置必须被标记为“脏”。这段代码被称为“写入屏障”。
具体的机器码如下所示:
lea   edx, [edi+ebp*4+0x10]   ; calculate the heap location to write
mov   [edx], ebx              ; write the value to the heap location
shr   edx, 9                  ; calculate the offset into the card table
mov   [ecx+edx], ah           ; mark the card table entry as dirty

这就是当所写的值是原始类型时,执行相同高级操作所需的全部步骤:
mov   [edx+ebx*4+0x10], ebp

写入障碍似乎只增加了一个写操作,但我的测量结果显示它导致了数量级的减速。我无法解释这一点。

UseCondCardMark 只会让情况更糟

有一个相当晦涩的JVM标志,它应该避免在条目已经被标记为脏时进行卡表写入。这主要在一些退化情况下很重要,其中大量的卡表写入通过CPU缓存在线程之间造成误共享。无论如何,我尝试打开该标志:

with  -XX:+UseCondCardMark:
Benchmark              Mode Thr    Cnt  Sec         Mean   Mean error    Units
fillPrimitiveArray     avgt   1      6    1       89.913        3.586  nsec/op
fillReferenceArray     avgt   1      6    1     1504.123       12.130  nsec/op

有趣。你能验证一下边界检查、循环展开或其他方面是否存在差异吗? - maaartinus
@maaartinus 实际上有一个更大的差异,我错过了它,因为我可能看的是OSR版本而不是常规版本:整个循环实际上被单个“调用”替换了,可能是到Arrays.fill的本地代码。似乎HotSpot能够证明它需要做的一切。 - Marko Topolnik
为什么referenceArrayprimitiveArray是静态的?这将会影响到使用@Threads(2)时的结果。 - Aleksey Shipilev
@MarkoTopolnik,如果你让基准测试运行足够长的时间,数组将不可避免地晋升。或者,您可以使用@Setup(Iteration)生成大量垃圾以增加GC压力。 - Aleksey Shipilev
显示剩余10条评论
1个回答

4

以下是 Vladimir Kozlov 在 hotspot-compiler-dev 邮件列表中提供的权威答案:

你好 Marko,

对于原始类型数组,我们使用手写汇编代码,将 XMM 寄存器用作初始化的向量。对于对象数组,我们没有进行优化,因为这种情况不常见。我们可以像 arracopy 一样进行改进,但我们决定暂时保留它。

问候,
Vladimir

我也想知道为什么优化后的代码没有内联,我也得到了答案:

代码不小,所以我们决定不内联它。请查看 macroAssembler_x86.cpp 文件中的 MacroAssembler :: generate_fill():

http://hg.openjdk.java.net/hsx/hotspot-main/hotspot/file/54f0c207dc35/src/cpu/x86/vm/macroAssembler_x86.cpp


我的原始答案:

在机器码中,我错过了一个重要的部分,因为我看的是编译方法的 On-Stack Replacement 版本,而不是用于后续调用的版本。结果,HotSpot 能够证明我的循环相当于调用了 Arrays.fill,并用一个 call 指令替换了整个循环。我看不到那个函数的代码,但它可能使用了各种可能的技巧,例如 MMX 指令,以用相同的 32 位值填充内存块。

这给了我想要测量实际的 Arrays.fill 调用的想法。结果让我更惊讶:

Benchmark                  Mode Thr    Cnt  Sec         Mean   Mean error    Units
fillPrimitiveArray         avgt   1      5    2      155.343        1.318  nsec/op
fillReferenceArray         avgt   1      5    2      682.975       17.990  nsec/op
loopFillPrimitiveArray     avgt   1      5    2      156.114        0.523  nsec/op
loopFillReferenceArray     avgt   1      5    2      682.209        7.047  nsec/op

使用循环和调用 fill 的结果是相同的。如果说有什么不同,那么这比引发问题的结果更加令人困惑。我本应该期望 fill 无论值类型如何,都能从相同的优化思路中获益。


参考数组的时间似乎存在相当多的错误。也许进行更多的运行会更有价值? - user2357112
@user2357112:基准测试框架(JMH)应该能够处理所有这些。 - maaartinus
@user2357112 我已经重复了5次,每次2秒,并进行了更多的预热。错误减少了,结果已经收敛到几乎相同(循环==填充,但原始和参考之间的差距保持稳定)。请查看更新的答案。 - Marko Topolnik
抱歉,我只是假设你使用了一些相当保守的设置,就像我通常所做的那样。实际上,我什么都不做,因为卡钳的默认设置已经足够保守(每当我试图更加保守时,结果只是确认了这一点)。 - maaartinus
@maaartinus jmh 的默认值过于保守:每个测试方法的测试时间为 5 秒,迭代次数为 20 次。这就是为什么我总是给出自己的值。 - Marko Topolnik
显示剩余2条评论

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