原文链接:Faster Charset Decoding - Claes Redestad’s blog

最近,我对 OpenJDK 进行了一些小改进,以解决如何将 byte[] 转换为 String 的问题 —— 包括删除 StringCoding.Result 结构减少一些遗留的 CharsetDecoder 开销

在进行这方面实验时,我偶然发现了一个性能差异:new String(bytes, charset) 通常比使用 InputStreamReader 创建同一字符串快很多倍,差距远大于直觉上的合理范围。

通过分析原因后尽最大努力优化,我可以带来一些相当显著的改进

太长不看:通过重用用于支持 JEP 254: Compact Strings 的一些 intrinsic 方法, 我们能够让与 ASCII 兼容的 CharsetDecoders 在微基准测试中得到高达 10 倍的速度提升。 这些优化应该在 JDK 17 中实现。

紧凑字符串

为了深入理解我所做的工作,我们必须追溯到几年前在 JDK 9 中(重新)引入紧凑字符串的工作。

在 JDK 8 的 String 中,其内容存放于一个 char[] 中,这种实现方式简单明了。 但是 Java 中的 char 是 16 位的原始类型,其值(大致地)映射至 UTF-16。 在很多软件中,很多——甚至大部分——字符串都只是用最低的 7 位(ASCII)或者 8 位(ISO-8859-1), 因此每存储一个字符大概会浪费一个字节。

使得,有些地区的常用码点在 UTF-16 中需要超过 8 位甚至 16 位存储, 但经验中,几乎任何地区的应用程序里都有大量仅限于 ASCII 的字符串。 以更紧凑的形式存储这些数据将非常可观减少内存浪费,特别是对于那些每个字符仍需两个或更多字节存储的字符串来说, 它(几乎)不需要成本。

在以前我们考虑过并实现了更节约空间的表示法。JDK 6 中有 -XX:+UseCompressedStrings, 它会改变 String 的实现,透明地使用 byte[]char[]。 在我开始工作于 OpenJDK 之上前,该实现已经被删除,我被告知它是一个维护噩梦, 而且当运行具有大量非 ASCII 字符串的应用程序时,这会严重降低性能。

在 JDK 9 中,JEP 254:紧凑字符串进行了新的尝试。现在字符串始终由 byte[] 表示, 而不需要在 byte[]char[] 之间来回切换。可以使用一个简单的方案将 char 映射至 byte[] 中:

  • 如果所有字符都可以用 ISO-8859-1 编码表示:“压缩”它们,每个字符使用 1 字节表示
  • 否则将所有字符拆分为两个字节相邻的存储。依然有效地使用 UTF-16 编码

添加一些逻辑进行双向映射,我们就完成了!

嗯,虽然减少内存占用本身很重要,但您也需要恰当的性能。

如果对符合 ISO-8859-1 的字符串进行加速需要在用 UTF-16 编码的字符串上付出巨大代价,这就不好了。 为了缓解这种忧虑,JEP 254 花费了巨大的努力。integration lists 中列出了 9 名合作者与 12 名审查者, 我相信还有更多的工程师参与了 QA 等工作。

Intrinsically fast

优化性能的其中一种方式是实现类似将 char[] 压缩为 byte[] 和将 byte[] 膨胀为 char[] 的 intrinsic 方法, 这在许多情况下都优于 JDK 8 基线。

JDK 术语中的 Intrinsic 方法是一种 Java 方法,JVM(像 OpenJDK HotSpot)可以用高度优化的手工实现的方法替代这些方法。 这样的手工优化需要做大量的工作,但可以确保 JVM 在一些非常具体且对性能高度敏感的情况下做出正确的事情。

对于 JEP 254 中实现的方法,其主要优势是允许专门使用现代的 SIMD 指令。 SIMD 代表单指令、多数据、一次性描述对多位数据进行操作的硬件指令。 例如,Intel 的 AVX2 扩展可以一次性操作 256 位数据。在某些情况下, 使用此类指令能够大大提高速度。

Deep Dive: new String(bytes, US_ASCII)

要查看我们可能正在运行哪些 SIMD 指令,让我们看一个更简单的场景。

在最近 JDK 上的 new String(byte[], Charset),当 CharsetUS_ASCII 时:

    if (COMPACT_STRINGS && !StringCoding.hasNegatives(bytes, offset, length)) {
        this.value = Arrays.copyOfRange(bytes, offset, offset + length);
        this.coder = LATIN1;
    } else {
        byte[] dst = new byte[length << 1];
        int dp = 0;
        while (dp < length) {
            int b = bytes[offset++];
            StringUTF16.putChar(dst, dp++, (b >= 0) ? (char) b : REPL);
        }
        this.value = dst;
        this.coder = UTF16;
    }

if 分支检查 CompactString 是否启用,然后调用 StringCoding.hasNegatives

    @IntrinsicCandidate
    public static boolean hasNegatives(byte[] ba, int off, int len) {
        for (int i = off; i < off + len; i++) {
            if (ba[i] < 0) {
                return true;
            }
        }
        return false;
    }

这是一个简单的检查,如果输入数组中的任何值为负,则返回 true。如果没有负字节,则输入的字符都是 ASCII, 我们可以继续将输入复制到 String 的内部 byte[] 中。

Experimental setup

readStringDirect JMH 微基准测试中,我们可以找到一个简单但有趣的场景:

    @Benchmark
    public String readStringDirect() throws Exception {
        return new String(bytes, cs);
    }

为了放大上面详述过的 US-ASCII 快速路径,我使用 -p charsetName=US-ASCII -p length=4096 运行该基准测试。

我的实验装置是一个有些老旧的基于 Haswell 的 Linux 工作站。对于 Mac 或 Windows, 可能需要对指令进行调整,而且在更新或更旧的硬件上,结果可能有所不同。

我还确保我的 JDK 预备好了 hsdis 共享库,它允许使用 -XX:+PrintAssembly 反编译编译后的方法。 (虽然它是 OpenJDK 的一部分,但是因为各种许可原因,无法分发 hsdis 的构建。这里是 Gunnar Morling 编写的一个很棒的指南, 介绍了如果找不到二进制文件,应该如何自己构建它。)

然后我用 -prof perfasm 运行微基准测试。这个优秀的内置分析器使用 Linux perf 分析器以及通过 -XX:+PrintAssembly 收集的数据, 以非常细粒度的信息描述了微基准测试中执行的热点代码区域。

Experimental Results

扫描分析器输出中热点代码片段,这是其中非常突出的一个部分:

        │   0x00007fef79146223:   mov    $0x80808080,%ebx      
  0.02% │   0x00007fef79146228:   vmovd  %ebx,%xmm0        
        │   0x00007fef7914622c:   vpbroadcastd %xmm0,%ymm0     
  0.21% │↗  0x00007fef79146231:   vmovdqu (%rsi,%rcx,1),%ymm1  
 13.16% ││  0x00007fef79146236:   vptest %ymm0,%ymm1       
 11.34% ││  0x00007fef7914623b:   jne    0x00007fef791462a3    
  1.63% ││  0x00007fef7914623d:   add    $0x20,%rcx        
        │╰  0x00007fef79146241:   jne    0x00007fef79146231    

是的,x86 汇编!让我们尝试分解它……

第一列表示执行每条命令所花费的相对时间。这些值可能有一点偏差或抖动,但单条指令超过 10% 是罕见的。

第二列中的字符画箭头表示控制流变化——例如跳转到循环的开头。第三列列出了地址。其余的是每个地址反汇编出的 x86 汇编代码。

前三条指令准备了 256 位 ymm0 向量寄存器,让其容纳值 0x80——重复 32 次。 这是通过将 0x80808080 加载到 xmm0 寄存器中,然后使用 vpbroadcastd 将其散布到 ymm0 的所有 32 位段中完成的。

为什么是 0x80?因为 0x80 是一个设置了最高位的字节。在 Java 中,设置了最高位的字节将为负数。 因此,ymm0 中的值可以用作掩码,用于检测另一个 ymm 寄存器中的任意字节是否为负。

这正是在下面循环中完成的:

  • vmovdqu (%rsi,%rcx,1),%ymm1) 将 32 个字节从输入数组加载到 ymm 寄存器中。
  • vptest %ymm0,%ymm1ymm0 中的掩码和我们刚刚读取的 32 字节之间执行逻辑与操作。
  • 如果任意字节为负,则下一条指令 jne 跳出循环。
  • 否则,在输入数组中跳过前 32 个字符并重复,直到 rcx 为 0。

这张图片中没有看到的是确保 rcx 中的值为 32 倍数的设置,以及对最多 31 个尾随字节的处理。

好的,我们可以看到我们运行的代码是如何利用 AVX2 指令的。但是这对微基准的性能有多大贡献呢?

Benchmarking the effect of the intrinsic

刚好,我们可以关闭 intrinsic 函数。这让我们能够对比在没有 C2 手工实现的 intrinsic 的情况下的性能。 (一个问题是需要弄清楚 HotSpot 将这些 intrinsic 函数叫做什么;我不得不使用 grep 查询 OpenJDK 源代码,发现它的标识是 _hasNegatives):

Benchmark              Score      Error  Units
readStringDirect    1005.956  ±  36.044  ns/op

-XX:+UnlockDiagnosticVMOptions -XX:DisableIntrinsic=_hasNegatives

readStringDirect    4296.533  ± 870.060  ns/op

在这个简单的基准测试中,hasNegatives 的 intrinsic 向量化让速度提高了三倍以上。太酷了!

Enter the InputStreamReader

None of the above was fresh in my memory until recently. I wasn’t involved in JEP 254, unless “enthusiastic onlooker” counts. But as it happened, I recently started doing some related experiments to assess performance overhead of InputStreamReader. Motivated by a sneaking suspicion after seeing a bit too much of it in an application profile.

I conjured up something along these lines:

    @Benchmark
    public String readStringReader() throws Exception {
        int len = new InputStreamReader(
            new ByteArrayInputStream(bytes), cs).read(chars);
        return new String(chars, 0, len);
    }

这是一个简单的人造微基准,故意避免了 I/O 操作。因为它有些脱离现实,因为 InputStream 的重点通常是在处理 I/O 操作上, 但是测量非 I/O 开销仍然很有趣。

我还配置了我上面实验中使用的 readStringDirect 基准作为对此性能评估的基准。 我预计中 readStringReader 将比 readStringDirect 慢几倍: InputStreamReader 需要先将读取的字节解码为字符,然后在字符串构造器中将它们压缩回字节。 但我仍然对实测出 12 倍差距感到惊讶:

Benchmark          Score      Error  Units
readStringDirect    1005.956  ±  36.044  ns/op
readStringReader   12466.702  ± 747.116  ns/op

Analysis

后来的一些实验表明,对于较小的输入,readStringReader 有明显的恒定开销。 这主要在于需要分配一个 8Kb byte[] 作为内部缓存区。但同样很明显的是,InputStreamReader 的伸缩性也很差:

当输入大小从 4096 变成 25000 时(倍率为 6.1),readStringDirect 基准测试的开销将会上升到 6.5 倍。 这和我的预期时一致的:基本是线性的,但也有一些超出各种缓存阈值带来的小幅度超线性效果。 但是,readStringReader 的开销上升到了 10 倍。

深入分析数据,很明显能发现 readStringReader 的大部分时间都用在 US_ASCII$Decoder.decodeArrayLoop 中将 byte[] 逐个复制到 char[] 上:

    while (sp < sl) {
        byte b = sa[sp];
        if (b >= 0) {
            if (dp >= dl)
                return CoderResult.OVERFLOW;
            da[dp++] = (char)b;
            sp++;
            continue;
        }
        return CoderResult.malformedForLength(1);
    }

在热路径上有多个分支是一个危险信号——这可能是超线性开销增加的原因。

Reuseable intrinsics

事后看来,解决方案是显而易见的:从 byte[] 复制到 char[] 是在 JEP 254 中必须花费大量精力优化以保证良好性能的原因之一。 一旦我意识到它实际上是可行的,重用那些 intrinsic 似乎是一件轻而易举的事情。

为了保持整洁并尽量减少实现细节的泄露,我最终发布了一个 PR, 它只公开了两个 java.lang 的内部方法以供 sun.nio.cs 中的解码器使用:

  • decodeASCII,它接受一个输入 byte[] 和一个输出 char[],尽可能地进行解码。 为了提高效率和简化,它使用 String 中新的 package private 方法实现:

        static int decodeASCII(byte[] sa, int sp, char[] da, int dp, int len) {
            if (!StringCoding.hasNegatives(sa, sp, len)) {
                StringLatin1.inflate(sa, sp, da, dp, len);
                return len;
            } else {
                int start = sp;
                int end = sp + len;
                while (sp < end && sa[sp] >= 0) {
                    da[dp++] = (char) sa[sp++];
                }
                return sp - start;
            }
        }
    
  • inflateBytesToChars,它公开了 intrinsic 方法 StringLatin1.inflate,特别是用于 ISO_8859_1$Decoder

US_ASCII$Decoder.decodeArrayLoop 中的 while 循环可以这样重写:

    int n = JLA.decodeASCII(sa, sp, da, dp, Math.min(sl - sp, dl - dp));
    sp += n;
    dp += n;
    src.position(sp - soff);
    dst.position(dp - doff);
    if (sp < sl) {
        if (dp >= dl) {
            return CoderResult.OVERFLOW;
        }
        return CoderResult.malformedForLength(1);
    }
    return CoderResult.UNDERFLOW;

语义相同,但将大部分工作委托给 decodeASCII 方法,由于 SIMD intrinsic,这应该能解锁一些速度提升。

Results

使用优化版本绘制同一个图形,将得到完全不同的图像:

考虑到 InputStreamReader 的恒定开销,readStringReader 现在落后于 readStringDirect 大概 2.2 倍, 并显现出类似的倍率。

在长度为 25000 的输入点中,优化让对于 US-ASCII 的速度提高至接近原先的十倍。 在前面提到的 PR 中(现已合并),我试图改进每一个适用的内置 CharsetDecoder。 也许工作比听起来要少,因为他们中的很多继承自一些可以优化的基类。 最终结果是,很多字符集解码器在读取 ASCII 时可以采用这种内在化的快速路径。

之前:

Benchmark          (charsetName)  (length) Cnt       Score       Error  Units
readStringReader        US-ASCII       256  10    2085.399 ±    66.522  ns/op
readStringReader        US-ASCII      4096  10   12466.702 ±   747.116  ns/op
readStringReader        US-ASCII     25000  10  123508.987 ±  3583.345  ns/op
readStringReader      ISO-8859-1       256  10    1894.185 ±    51.772  ns/op
readStringReader      ISO-8859-1      4096  10    8117.404 ±   594.708  ns/op
readStringReader      ISO-8859-1     25000  10   99409.792 ± 28308.936  ns/op
readStringReader           UTF-8       256  10    2090.337 ±    56.500  ns/op
readStringReader           UTF-8      4096  10   11698.221 ±   898.910  ns/op
readStringReader           UTF-8     25000  10   66568.987 ±  4204.361  ns/op
readStringReader      ISO-8859-6       256  10    3061.130 ±   120.132  ns/op
readStringReader      ISO-8859-6      4096  10   24623.494 ±  1916.362  ns/op
readStringReader      ISO-8859-6     25000  10  139138.140 ±  7109.636  ns/op
readStringReader           MS932       256  10    2612.535 ±    98.638  ns/op
readStringReader           MS932      4096  10   18843.438 ±  1767.822  ns/op
readStringReader           MS932     25000  10  119923.997 ± 18560.065  ns/op

之后:

Benchmark          (charsetName)  (length) Cnt       Score       Error  Units
readStringReader        US-ASCII       256  10    1556.588 ±    37.083  ns/op
readStringReader        US-ASCII      4096  10    3290.627 ±   125.327  ns/op
readStringReader        US-ASCII     25000  10   13118.794 ±   597.086  ns/op
readStringReader      ISO-8859-1       256  10    1525.460 ±    36.510  ns/op
readStringReader      ISO-8859-1      4096  10    3051.887 ±   113.036  ns/op
readStringReader      ISO-8859-1     25000  10   11401.228 ±   563.124  ns/op
readStringReader           UTF-8       256  10    1596.878 ±    43.824  ns/op
readStringReader           UTF-8      4096  10    3349.961 ±   119.278  ns/op
readStringReader           UTF-8     25000  10   13273.403 ±   591.600  ns/op
readStringReader      ISO-8859-6       256  10    1602.328 ±    44.092  ns/op
readStringReader      ISO-8859-6      4096  10    3403.312 ±   107.516  ns/op
readStringReader      ISO-8859-6     25000  10   13163.468 ±   709.642  ns/op
readStringReader           MS932       256  10    1602.837 ±    32.021  ns/op
readStringReader           MS932      4096  10    3379.439 ±    87.716  ns/op
readStringReader           MS932     25000  10   13376.980 ±   669.983  ns/op

请注意,UTF-8——也许是现在使用的最广泛的编码之一——在其解码器中已经有了 ASCII 快速路径。 这种快速路径避免了一些分支,并且似乎比其他字符集解码器伸缩性更好: 在扣除恒定开销后,从 4096 到 25000 个输入的开销倍率为 6.5 倍。 但即使是 UTF-8 通过重用 intrinsic 函数在我的系统上也能看到显著改进。 25000 个字节输入的情况下几乎提高了四倍。

最后,在这个特定的微型计算机上,对于小输入的性能提高至大概原先的 1.3 倍, 对于大输入提高至 10 倍以上。

我添加了许多其他微基准,以探索在输入中添加非 ASCII 字符时微基准的行为,包括添加在开头、结尾以及混合至输入中: 之前/之后*Reader 微基准的行为一般近似于 *Direct,但也有一些例外, 一些 Reader 变体由于以 8Kb 块处理输入,其实际性能变得更好。

可能有一些方法可以进一步改进代码,尤其是在处理混合输入时: 当解码为 char[] 时,将 String.decodeASCII 转换为结合 hasNegatives + inflate 的 intrinsic 可能是有意义的, 因为当我们发现一个负字节时,我们实际上不必退出并重新开始。但这一改进已经取得了很大的进步, 因此我拒绝了追求额外收益的诱惑。至少在尘埃落定之前。

Real world implications?

一位用户找到我,希望在他们的一个应用程序中对此进行测试,因为他们在分析中看到了 decodeArrayLoop 的大量使用。 在从源代码构建 OpenJDK PR 后,他们甚至可以在集成之前测试补丁——并报告总 CPU 使用量减少了大概 15-25%!

但是……事实证明,I/O 通常是他们的瓶颈。因此对 CPU 的节约非常显著,但在他们的很多测试中,这并没有转化为吞吐量的提升。 YMMV: Not all I/O is shaped the same way, and the optimization could have positive effects on latency.

最后,我认为这个问题中的用户似乎对 CPU 使用的减少很满意,即使这没有大大提高他们的吞吐量。 不出意外的话,这将转化为对耗电量和成本的节约。

Acknowledgements

I’d like to especially thank Tobias Hartmann for helping out with many of the questions I had when writing this post. I also owe a debt of gratitude to him alongside Vivek Deshpande, Vladimir Kozlov, and Sandhya Viswanathan for their excellent work on these HotSpot intrinsics that I here was merely able to leverage in a few new places. Also thanks to Alan Bateman and Naoto Sato for reviewing, discussing and helping get the PR integrated, and to David Delabassee for a lot of editorial suggestions.

Appendix: Internals of a C2 intrinsic

I was curious to find out if my reading of the disassembly made sense, but couldn’t find my way around. The C2 code is tricky to find your way around, mainly due heavy reliance on code generation, but Tobias Hartmann - who I believe wrote much of this particular intrinsic - was kind enough to point me to the right place: C2_MacroAssembler::has_negatives.

This is the routine that emits x86 assembler custom-built to execute this particular piece of Java code as quickly as possible on the hardware at hand. If you studied that code you’d find the macro assembler used to emit the hot piece code I found when profiling above at line 3408:

      movl(tmp1, 0x80808080); // create mask to test for Unicode chars in vector
      movdl(vec2, tmp1);
      vpbroadcastd(vec2, vec2, Assembler::AVX_256bit);

      bind(COMPARE_WIDE_VECTORS);
      vmovdqu(vec1, Address(ary1, len, Address::times_1));
      vptest(vec1, vec2);
      jccb(Assembler::notZero, TRUE_LABEL);
      addptr(len, 32);
      jcc(Assembler::notZero, COMPARE_WIDE_VECTORS); 

Not without its’ quirks, but a bit more higher level and somewhat readable.