String构造函数中缺少边界检查消除?

11

在研究UTF8解码性能时,我注意到protobuf的UnsafeProcessor::decodeUtf8对于以下非ascii字符串的性能优于String(byte[] bytes, int offset, int length, Charset charset): "Quizdeltagerne spiste jordbær med flØde, mens cirkusklovnen"

我尝试找出原因,所以我复制了String中相关的代码,并用不安全的数组访问替换了数组访问,就像UnsafeProcessor::decodeUtf8一样。 这是JMH基准测试结果:

Benchmark                       Mode  Cnt    Score   Error  Units
StringBenchmark.safeDecoding    avgt   10  127.107 ± 3.642  ns/op
StringBenchmark.unsafeDecoding  avgt   10  100.915 ± 4.090  ns/op

我认为差异是由于缺少边界检查消除,而我预计它会启动,特别是因为在 String(byte[] bytes, int offset, int length, Charset charset) 开始处有一个显式的边界检查调用 checkBoundsOffCount(offset, length, bytes.length)

问题真的是缺少边界检查消除吗?

这是我使用 OpenJDK 17 和 JMH 对其进行基准测试的代码。请注意,这仅是 String(byte[] bytes, int offset, int length, Charset charset) 构造函数代码的一部分,并且仅对此特定的德语字符串有效。 静态方法从 String 中复制。 查找指示我用不安全的方式替换了安全访问的 //不安全版本: 注释。

    private static byte[] safeDecode(byte[] bytes, int offset, int length) {
        checkBoundsOffCount(offset, length, bytes.length);
        int sl = offset + length;
        int dp = 0;
        byte[] dst = new byte[length];
        while (offset < sl) {
            int b1 = bytes[offset];
            // the unsafe version:
            // int b1 = UnsafeUtil.getByte(bytes, offset);
            if (b1 >= 0) {
                dst[dp++] = (byte)b1;
                offset++;
                continue;
            }
            if ((b1 == (byte)0xc2 || b1 == (byte)0xc3) &&
                    offset + 1 < sl) {
                // the unsafe version:
                // int b2 = UnsafeUtil.getByte(bytes, offset + 1);
                int b2 = bytes[offset + 1];
                if (!isNotContinuation(b2)) {
                    dst[dp++] = (byte)decode2(b1, b2);
                    offset += 2;
                    continue;
                }
            }
            // anything not a latin1, including the repl
            // we have to go with the utf16
            break;
        }
        if (offset == sl) {
            if (dp != dst.length) {
                dst = Arrays.copyOf(dst, dp);
            }
            return dst;
        }

        return dst;
    }

跟进

显然,如果我将 while 循环的条件从 offset < sl 改为 0 <= offset && offset < sl,那么我会在两个版本中获得类似的性能:

Benchmark                       Mode  Cnt    Score    Error  Units
StringBenchmark.safeDecoding    avgt   10  100.802 ± 13.147  ns/op
StringBenchmark.unsafeDecoding  avgt   10  102.774 ± 3.893  ns/op

结论

这个问题被HotSpot开发者们选中了,详情请见https://bugs.openjdk.java.net/browse/JDK-8278518

优化这段代码最终成功提升了2.5倍以上的解码上述Latin1字符串的速度。

C2优化技术缩小了下面基准测试中commonBranchFirstcommonBranchSecond超过7倍的巨大差距,将会在Java 19中发布。

Benchmark                         Mode  Cnt     Score    Error  Units
LoopBenchmark.commonBranchFirst   avgt   25  1737.111 ± 56.526  ns/op
LoopBenchmark.commonBranchSecond  avgt   25   232.798 ± 12.676  ns/op
@State(Scope.Thread)
@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.NANOSECONDS)
public class LoopBenchmark {

  private final boolean[] mostlyTrue = new boolean[1000];

  @Setup
  public void setup() {
    for (int i = 0; i < mostlyTrue.length; i++) {
      mostlyTrue[i] = i % 100 > 0;
    }
  }

  @Benchmark
  public int commonBranchFirst() {
    int i = 0;
    while (i < mostlyTrue.length) {
      if (mostlyTrue[i]) {
        i++;
      } else {
        i += 2;
      }
    }
    return i;
  }

  @Benchmark
  public int commonBranchSecond() {
    int i = 0;
    while (i < mostlyTrue.length) {
      if (!mostlyTrue[i]) {
        i += 2;
      } else {
        i++;
      }
    }
    return i;
  }
}

有趣的是,我一直认为JIT会优化边界检查,尤其是在方法中bytesoffset没有被修改的情况下。我认为你应该使用LinuxPerfAsmProfiler来查看执行的汇编代码。 - Sergey Tsypanov
我打印了生成的汇编代码,但是我无法理解它的大部分内容。 我的主要观点是,我希望HotSpot优化的UTF8解码速度至少与protobuf(或其他第三方替代品)一样快。 目前,仅高度优化包含纯ASCII字符的字符串的解码,但是非ASCII Latin1字符串的情况可以大幅改善。 - Amir Hadadi
我正在尝试在JDK代码库中使用提供的字符串测试0 <= offset && offset < sl,并将结果告诉您。 - Sergey Tsypanov
我已回答了以下问题,请查看带有链接的答案。你是正确的。 - Sergey Tsypanov
1个回答

3
为了测量您感兴趣的分支,特别是当while循环变得繁忙的情况下,我使用了以下基准测试:
@State(Scope.Thread)
@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.NANOSECONDS)
public class StringConstructorBenchmark {
  private byte[] array;

  @Setup
  public void setup() {
    String str = "Quizdeltagerne spiste jordbær med fløde, mens cirkusklovnen. Я";
    array = str.getBytes(StandardCharsets.UTF_8);
  }

  @Benchmark
  public String newString()  {
      return new String(array, 0, array.length, StandardCharsets.UTF_8);
  }
}

实际上,使用修改后的构造函数确实能显著提高性能:

//baseline
Benchmark                             Mode  Cnt    Score   Error  Units
StringConstructorBenchmark.newString  avgt   50  173,092 ± 3,048  ns/op

//patched
Benchmark                             Mode  Cnt    Score   Error  Units
StringConstructorBenchmark.newString  avgt   50  126,908 ± 2,355  ns/op

这很可能是一个HotSpot问题:优化编译器由于某种原因未能消除while循环中的数组边界检查。我猜测原因是offset在循环内被修改了。
while (offset < sl) {
    int b1 = bytes[offset];
    if (b1 >= 0) {
        dst[dp++] = (byte)b1;
        offset++;                     // <---
        continue;
    }
    if ((b1 == (byte)0xc2 || b1 == (byte)0xc3) &&
            offset + 1 < sl) {
        int b2 = bytes[offset + 1];
        if (!isNotContinuation(b2)) {
            dst[dp++] = (byte)decode2(b1, b2);
            offset += 2;
            continue;
        }
    }
    // anything not a latin1, including the repl
    // we have to go with the utf16
    break;
}

我已经查看了使用LinuxPerfAsmProfiler的代码,这是基准测试的链接https://gist.github.com/stsypanov/d2524f98477d633fb1d4a2510fedeea6,这是修补后的构造函数链接https://gist.github.com/stsypanov/16c787e4f9fa3dd122522f16331b68b7。需要注意什么?让我们找到对应的代码int b1 = bytes[offset];(第538行)。在基准测试中,我们有以下代码:
  3.62%           ││            │  0x00007fed70eb4c1c:   mov    %ebx,%ecx
  2.29%           ││            │  0x00007fed70eb4c1e:   mov    %edx,%r9d
  2.22%           ││            │  0x00007fed70eb4c21:   mov    (%rsp),%r8                   ;*iload_2 {reexecute=0 rethrow=0 return_oop=0}
                  ││            │                                                            ; - java.lang.String::&lt;init&gt;@107 (line 537)
  2.32%           ↘│            │  0x00007fed70eb4c25:   cmp    %r13d,%ecx
                   │            │  0x00007fed70eb4c28:   jge    0x00007fed70eb5388           ;*if_icmpge {reexecute=0 rethrow=0 return_oop=0}
                   │            │                                                            ; - java.lang.String::&lt;init&gt;@110 (line 537)
  3.05%            │            │  0x00007fed70eb4c2e:   cmp    0x8(%rsp),%ecx
                   │            │  0x00007fed70eb4c32:   jae    0x00007fed70eb5319
  2.38%            │            │  0x00007fed70eb4c38:   mov    %r8,(%rsp)
  2.64%            │            │  0x00007fed70eb4c3c:   movslq %ecx,%r8
  2.46%            │            │  0x00007fed70eb4c3f:   mov    %rax,%rbx
  3.44%            │            │  0x00007fed70eb4c42:   sub    %r8,%rbx
  2.62%            │            │  0x00007fed70eb4c45:   add    $0x1,%rbx
  2.64%            │            │  0x00007fed70eb4c49:   and    $0xfffffffffffffffe,%rbx
  2.30%            │            │  0x00007fed70eb4c4d:   mov    %ebx,%r8d
  3.08%            │            │  0x00007fed70eb4c50:   add    %ecx,%r8d
  2.55%            │            │  0x00007fed70eb4c53:   movslq %r8d,%r8
  2.45%            │            │  0x00007fed70eb4c56:   add    $0xfffffffffffffffe,%r8
  2.13%            │            │  0x00007fed70eb4c5a:   cmp    (%rsp),%r8
                   │            │  0x00007fed70eb4c5e:   jae    0x00007fed70eb5319
  3.36%            │            │  0x00007fed70eb4c64:   mov    %ecx,%edi                    ;*aload_1 {reexecute=0 rethrow=0 return_oop=0}
                   │            │                                                            ; - java.lang.String::&lt;init&gt;@113 (line 538)
  2.86%            │           ↗│  0x00007fed70eb4c66:   movsbl 0x10(%r14,%rdi,1),%r8d       ;*baload {reexecute=0 rethrow=0 return_oop=0}
                   │           ││                                                            ; - java.lang.String::&lt;init&gt;@115 (line 538)
  2.48%            │           ││  0x00007fed70eb4c6c:   mov    %r9d,%edx
  2.26%            │           ││  0x00007fed70eb4c6f:   inc    %edx                         ;*iinc {reexecute=0 rethrow=0 return_oop=0}
                   │           ││                                                            ; - java.lang.String::&lt;init&gt;@127 (line 540)
  3.28%            │           ││  0x00007fed70eb4c71:   mov    %edi,%ebx
  2.44%            │           ││  0x00007fed70eb4c73:   inc    %ebx                         ;*iinc {reexecute=0 rethrow=0 return_oop=0}
                   │           ││                                                            ; - java.lang.String::&lt;init&gt;@134 (line 541)
  2.35%            │           ││  0x00007fed70eb4c75:   test   %r8d,%r8d
                   ╰           ││  0x00007fed70eb4c78:   jge    0x00007fed70eb4c04           ;*iflt {reexecute=0 rethrow=0 return_oop=0}
                               ││                                                            ; - java.lang.String::&lt;init&gt;@120 (line 539)

而在修补后的代码中,相应的部分是

 17.28%           ││  0x00007f6b88eb6061:   mov    %edx,%r10d                   ;*iload_2 {reexecute=0 rethrow=0 return_oop=0}
                  ││                                                            ; - java.lang.String::&lt;init&gt;@107 (line 537)
  0.11%           ↘│  0x00007f6b88eb6064:   test   %r10d,%r10d
                   │  0x00007f6b88eb6067:   jl     0x00007f6b88eb669c           ;*iflt {reexecute=0 rethrow=0 return_oop=0}; - java.lang.String::&lt;init&gt;@108 (line 537)
  0.39%            │  0x00007f6b88eb606d:   cmp    %r13d,%r10d
                   │  0x00007f6b88eb6070:   jge    0x00007f6b88eb66d0           ;*if_icmpge {reexecute=0 rethrow=0 return_oop=0}; - java.lang.String::&lt;init&gt;@114 (line 537)
  0.66%            │  0x00007f6b88eb6076:   mov    %ebx,%r9d
 13.70%            │  0x00007f6b88eb6079:   cmp    0x8(%rsp),%r10d
  0.01%            │  0x00007f6b88eb607e:   jae    0x00007f6b88eb6671
  0.14%            │  0x00007f6b88eb6084:   movsbl 0x10(%r14,%r10,1),%edi       ;*baload {reexecute=0 rethrow=0 return_oop=0}; - java.lang.String::&lt;init&gt;@119 (line 538)
  0.37%            │  0x00007f6b88eb608a:   mov    %r9d,%ebx
  0.99%            │  0x00007f6b88eb608d:   inc    %ebx                         ;*iinc {reexecute=0 rethrow=0 return_oop=0}; - java.lang.String::&lt;init&gt;@131 (line 540)
 12.88%            │  0x00007f6b88eb608f:   movslq %r9d,%rsi                    ;*bastore {reexecute=0 rethrow=0 return_oop=0}; - java.lang.String::&lt;init&gt;@196 (line 548)
  0.17%            │  0x00007f6b88eb6092:   mov    %r10d,%edx
  0.39%            │  0x00007f6b88eb6095:   inc    %edx                         ;*iinc {reexecute=0 rethrow=0 return_oop=0}; - java.lang.String::&lt;init&gt;@138 (line 541)
  0.96%            │  0x00007f6b88eb6097:   test   %edi,%edi
  0.02%            │  0x00007f6b88eb6099:   jl     0x00007f6b88eb60dc           ;*iflt {reexecute=0 rethrow=0 return_oop=0}; - java.lang.String::&lt;init&gt;@124 (line 539)

if_icmpgeaload_1字节码指令之间的基线中,我们进行了边界检查,但在修补后的代码中没有进行边界检查。
因此,您最初的假设是正确的:这是关于缺少边界检查消除的问题。
更新:我必须更正我的答案:事实证明边界检查仍然存在。
13.70%            │  0x00007f6b88eb6079:   cmp    0x8(%rsp),%r10d
 0.01%            │  0x00007f6b88eb607e:   jae    0x00007f6b88eb6671

我指出的代码是编译器引入的,但它什么也不做。问题本身仍然与边界检查有关,因为其明确声明可以临时解决该问题。


1
感谢您的调查,Sergey。请注意,我使用的字符串是Latin1而不是ASCII,因此它不会进入您所声称的分支。无需通过添加俄语字符来修改它。您提到的分支仅适用于ASCII字符串(所有字节均为非负数)。另外,请您能否突出显示相关的汇编部分? - Amir Hadadi
1
相关部分开始于 3.05% │ │ 0x00007fed70eb4c2e:cmp 0x8(%rsp),%ecx此外,我已经为此提交了问题 https://bugs.openjdk.java.net/browse/JDK-8278518。看起来其他代码片段也观察到了相同的行为。如果您不介意,我也想为此创建一个PR。 - Sergey Tsypanov
你能修复一下关于我提供的字符串触发ASCII代码路径的部分吗?然后我会接受你的答案。此外,与你在错误报告中写的相反,我不建议使用0 <= offset条件作为解决此问题的正确方法。我认为正确的解决方法要么是让c2更加智能地处理这种情况,要么是更加彻底地思考,在被认为在jdk源代码中安全的代码中完全消除边界检查(这可能只是所有jdk源代码)。但我想这是非常有争议的。 - Amir Hadadi
1
我已经从答案中删除了那部分内容。我也重新修订了工单。我完全同意您的观点,即这是一个编译器问题,必须在那里进行适当的修复。我们可以建议Java修复并提供有关该问题的完整说明,让Oracle团队决定是否采纳。顺便说一下,在String.translateEscapes()中观察到了类似的行为。 - Sergey Tsypanov

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