JIT 预热后,如何使 ByteBuffer 与直接 byte[] 访问一样高效?

byt*_*101 5 java performance jvm bytebuffer jmh

我正在尝试优化一个简单的解压缩例程,并遇到了这个奇怪的性能怪癖,我似乎找不到太多信息:手动实现的简单字节缓冲区比内置字节缓冲区(堆)快 10%-20% & 映射)用于简单操作(读取一个字节,读取 n 个字节,是否是流末尾)

\n

我测试了3个API:

\n
    \n
  • 方法ByteBuffer.wrap(byte[])
  • \n
  • 原始字节[]访问
  • \n
  • 字节访问的简单包装器上的方法(大部分)镜像 ByteBuffer API
  • \n
\n

简单的包装器:

\n
class TestBuf {\n    private final byte[] ary;\n    private int pos = 0;\n\n    public TestBuf(ByteBuffer buffer) {  // ctor #1\n        ary = new byte[buffer.remaining()];\n        buffer.get(ary);\n    }\n    \n    public TestBuf(byte[] inAry) { // ctor #2\n        ary = inAry;\n    }\n\n    public int readUByte() { return ary[pos++] & 0xFF; }\n\n    public boolean hasRemaining() { return pos < ary.length; }\n\n    public void get(byte[] out, int offset, int length) {\n        System.arraycopy(ary, pos, out, offset, length);\n        pos += length;\n    }\n}\n
Run Code Online (Sandbox Code Playgroud)\n

我的主循环的精简核心大致是以下模式:

\n
while (buffer.hasRemaining()) {\n    int op = buffer.readUByte();\n    if (op == 1) {\n        int size = buffer.readUByte();\n        buffer.get(outputArray, outputPos, size);\n        outputPos += size;\n    } // ...\n}\n
Run Code Online (Sandbox Code Playgroud)\n

我测试了以下组合:

\n
    \n
  • native-array:传递byte[]byte[]-accepting 方法(无副本)
  • \n
  • native-testbuf:传递byte[]给将其包装在 a 中的方法TestBuf(无副本,ctor #2)
  • \n
  • native-buffer:传递ByteBuffer.wrap(byte[])给 ByteBuffer 接受方法(无副本)
  • \n
  • buffer-array:传递ByteBuffer.wrap(byte[])给将 ByteBuffer 提取到数组的方法
  • \n
  • buffer-testbuf:传递ByteBuffer.wrap(byte[])给将 ByteBuffer 提取到TestBuf(ctor #1)中的数组的方法
  • \n
\n

我使用 JMH(黑洞化每个输出数组),并在 OpenJDK 和 GraalVM 上测试了 Java 17,并使用预加载到 RAM 中的约 5GiB 的解压语料库,其中包含约 150,000 个项目,平均大小从 2KiB 到 15MiB。每个语料库需要约 10 秒的时间来解压,并且 JMH 运行进行了适当的预热和迭代。我确实将测试剥离了最少必要的非数组代码,但即使对原始代码进行基准测试,差异也几乎相同的百分比(即,我认为除了缓冲区/数组之外没有太多其他内容)访问控制我的原始代码的性能)

\n

在多台计算机上,结果有点不稳定,但相对一致:

\n
    \n
  • GraalVM 通常比 OpenJDK 慢 10-15% 左右(这让我很惊讶),尽管顺序和相对性能通常与 OpenJDK 相同
  • \n
  • native-arraynative-testbuf最快的选项,由于优化器的帮助,误差范围内(低于 0.5%)(9.3 秒/语料库)
  • \n
  • native-buffer始终是最慢的选择。这总是比最快的慢 17-22% native-array/native-testbuf (11.4s/语料库)
  • \n
  • buffer-arraybuffer-testbuf处于中间位置,彼此之间的差距约为 1%,但比 慢约 4-7% native-array。然而,尽管它们产生了额外的数组副本,但它们总是比native-buffer约 15-17%。(9.7 秒/语料库)
  • \n
\n

其中两个结果最令我惊讶:

\n
    \n
  • 与自定义简单的类似 ByteBuffer 的包装器相比,通过 ByteBuffer API () 使用包装的字节数组native-buffer非常慢(native-testbuf通过
  • \n
  • 制作数组的整个副本 ( buffer-*) 仍然比使用ByteBuffer.wrap对象 (native-buffer )
  • \n
\n

我尝试四处查找有关我可能做错的信息,但据我所知,大多数性能问题都与本机内存和有关,而我MappedByteBuffers正在使用。与我重新实现的简单读取访问相比,HeapByteBuffers为什么这么慢?HeapByteBuffers有什么方法可以HeapByteBuffers更有效地使用吗?这是否也适用于MappedByteBuffer

\n

更新:我已经在https://gist.github.com/byteit101/84a3ab8f292de404e122562c7008c133上发布了完整的基准测试、语料库生成器和算法请注意,在尝试让语料库生成器工作时,我发现我的 24 位数字导致性能损失,因此添加了一个buffer-bufer目标,其中将缓冲区复制到新缓冲区并使用新缓冲区比在 24 位数字之后使用原始缓冲区更快。

\n

使用生成的语料库在我的一台机器上运行:

\n
Benchmark                  Mode  Cnt  Score   Error  Units\nSOBench.t1_native_array      ss   60  0.891 \xc2\xb1 0.018   s/op\nSOBench.t2_buffer_testbuf    ss   60  0.899 \xc2\xb1 0.024   s/op\nSOBench.t3_buffer_buffer     ss   60  0.935 \xc2\xb1 0.024   s/op\nSOBench.t4_native_buffer     ss   60  1.099 \xc2\xb1 0.024   s/op\n
Run Code Online (Sandbox Code Playgroud)\n

最近的一些观察结果:删除未使用的代码(请参阅要点中的注释)使 ByteBuffer 与本机数组一样快,轻微的调整(更改逻辑比较的位掩码条件)也是如此,所以我当前的理论是,它是一些内联缓存未命中也与偏移量相关

\n

小智 2

我认为 Java 17 存在回归。我正在使用一个处理字符串的库。通过#String.Split 或#String.getBytes 多次创建新副本。所以我尝试了使用 ByteBuffer 的替代实现。

在 Java 11 中,该解决方案比原始基于字符串的版本快了约 30%。

time: 129 vs 180 ns/op
gc.alloc.rate: 2931 vs 3861 MB/sec
gc.count: 300 vs 323
gc.time: 172 vs 178 ms
Run Code Online (Sandbox Code Playgroud)

在 Java 17 中,情况发生了变化。ByteBuffer 版本恶化,String 版本改进。

time: 143 vs 146 ns/op
gc.alloc.rate: 2889 vs 4781 MB/sec
gc.count: 426 vs 586
gc.time: 240 vs 305 ms
Run Code Online (Sandbox Code Playgroud)

甚至 gc.count 和 gc.time 也增加了。