JVM常见概念之条件移动

问题

当我们有分支频率数据时,有什么有趣的技巧可以做吗?什么是条件移动?

基础知识

如果您需要在来自一个分支的两个结果之间进行选择,那么您可以在 ISA 级别做两件不同的事情。

首先,你可以创建一个分支:

bash 复制代码
    # %r = (%rCond == 1) ? $v1 : $v2
    cmp %rCond, $1
    jne A
    mov %r, $v1
    jmp E
 A: mov %r, $v2
 E:

其次,您可以执行依赖于比较结果的预测指令 。在 x86 中,这采用条件移动 (CMOV) 的形式,当选定条件成立时执行操作:

bash 复制代码
# %r = (%rCond == 1) ? $v1 : $v2
  mov %r, $v1      ; put $v1 to %r
  cmp %rCond, ...
  cmovne %r, $v2   ; put $v2 to %r if condition is false

执行条件移动的优点是它有时会生成更紧凑的代码,就像在这个例子中一样,并且它不会受到可能的分支预测错误惩罚。缺点是它需要在选择返回哪一边之前计算两边,这可能会花费过多的 CPU 周期,增加寄存器压力等。在分支情况下,我们可以选择在检查条件后不计算内容。预测良好的分支将优于条件移动。

因此,是否执行条件移动的选择在很大程度上取决于其成本预测。这就是分支分析可以帮助我们的地方:它可以说出哪些分支可能没有被完美预测,因此适合 CMOV 替换。当然, 实际成本模型还包括我们正在处理的参数类型、两个计算分支的相对深度等。

实验

源码-用例1

java 复制代码
@Warmup(iterations = 5, time = 1, timeUnit = TimeUnit.SECONDS)
@Measurement(iterations = 5, time = 1, timeUnit = TimeUnit.SECONDS)
@Fork(1)
@State(Scope.Benchmark)
@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.NANOSECONDS)
public class BranchFrequency {
    @Benchmark
    public void fair() {
        doCall(true);
        doCall(false);
    }

    @CompilerControl(CompilerControl.Mode.DONT_INLINE)
    public int doCall(boolean condition) {
        if (condition) {
            return 1;
        } else {
            return 2;
        }
    }
}

执行结果

我们在每次调用时都会在分支之间进行切换,这意味着它的运行时配置文件在它们之间大约是 50%-50%。如果我们通过提供 -XX:ConditionalMoveLimit=0 来限制条件移动替换,那么我们就可以清楚地看到替换的发生。

bash 复制代码
# doCall, out of box variant
  4.36%  ...4ac: mov    $0x1,%r11d         ; move $1 -> %r11
  3.24%  ...4b2: mov    $0x2,%eax          ; move $2 -> %res
  8.46%  ...4b7: test   %edx,%edx          ; test boolean
  0.02%  ...4b9: cmovne %r11d,%eax         ; if false, move %r11 -> %res
  7.88%  ...4bd: add    $0x10,%rsp         ; exit the method
  8.12%  ...4c1: pop    %rbp
 18.60%  ...4c2: cmp    0x340(%r15),%rsp
         ...4c9: ja     ...4d0
  0.14%  ...4cf: retq

# doCall, CMOV conversion inhibited
  6.48%    ...cac: test   %edx,%edx         ; test boolean
        ╭  ...cae: je     ...cc8
        │                                   ; if true...
        │  ...cb0: mov    $0x1,%eax         ; move $1 -> %res
  7.41% │↗ ...cb5: add    $0x10,%rsp        ; exit the method
  0.02% ││ ...cb9: pop    %rbp
 27.43% ││ ...cba: cmp    0x340(%r15),%rsp
        ││ ...cc1: ja     ...ccf
  3.28% ││ ...cc7: retq
        ││                                  ; if false...
  7.04% ↘│ ...cc8: mov    $0x2,%eax         ; move $2 -> %res
  0.02%  ╰ ...ccd: jmp    ...cb5            ; jump back

在此示例中,CMOV 版本的表现稍好一些:

bash 复制代码
Benchmark                              Mode  Cnt   Score    Error  Units

# Branches
BranchFrequency.fair                   avgt   25   5.422 ±  0.026  ns/op
BranchFrequency.fair:L1-dcache-loads   avgt    5  12.078 ±  0.226   #/op
BranchFrequency.fair:L1-dcache-stores  avgt    5   5.037 ±  0.120   #/op
BranchFrequency.fair:branch-misses     avgt    5   0.001 ±  0.003   #/op
BranchFrequency.fair:branches          avgt    5  10.037 ±  0.216   #/op
BranchFrequency.fair:cycles            avgt    5  14.659 ±  0.285   #/op
BranchFrequency.fair:instructions      avgt    5  35.184 ±  0.559   #/op

# CMOVs
BranchFrequency.fair                   avgt   25   4.799 ±  0.094  ns/op
BranchFrequency.fair:L1-dcache-loads   avgt    5  12.014 ±  0.329   #/op
BranchFrequency.fair:L1-dcache-stores  avgt    5   5.005 ±  0.167   #/op
BranchFrequency.fair:branch-misses     avgt    5  ≈ 10⁻⁴            #/op
BranchFrequency.fair:branches          avgt    5   7.054 ±  0.118   #/op
BranchFrequency.fair:cycles            avgt    5  12.964 ±  1.451   #/op
BranchFrequency.fair:instructions      avgt    5  36.285 ±  0.713   #/op

您可能认为这是因为 CMOV 没有分支预测失误惩罚,但这种解释与计数器不一致。请注意,在两种情况下,"分支失误"几乎为零。这是因为硬件分支预测器实际上可以记住一个短暂的分支历史,而这种反复出现的分支对它们来说没有任何问题。性能差异的实际原因是分支情况下的跳跃:我们在关键路径上有一条额外的控制流指令。

源码-用例2

java 复制代码
@Warmup(iterations = 5, time = 500, timeUnit = TimeUnit.MILLISECONDS)
@Measurement(iterations = 5, time = 500, timeUnit = TimeUnit.MILLISECONDS)
@Fork(1)
@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.NANOSECONDS)
@State(Scope.Thread)
public class AdjustableBranchFreq {

    @Param("50")
    int percent;

    boolean[] arr;

    @Setup(Level.Iteration)
    public void setup() {
        final int SIZE = 100_000;
        final int Q = 1_000_000;
        final int THRESH = percent * Q / 100;
        arr = new boolean[SIZE];
        ThreadLocalRandom current = ThreadLocalRandom.current();
        for (int c = 0; c < SIZE; c++) {
            arr[c] = current.nextInt(Q) < THRESH;
        }

        // Avoid uncommon traps on both branches.
        doCall(true);
        doCall(false);
    }

    @Benchmark
    public void test() {
        for (boolean cond : arr) {
            doCall(cond);
        }
    }

    @CompilerControl(CompilerControl.Mode.DONT_INLINE)
    public int doCall(boolean condition) {
        if (condition) {
            return 1;
        } else {
            return 2;
        }
    }
}

执行结果

使用不同的 percent 值和 -prof perfnorm JMH 分析器运行它将产生以下结果:
依据上图,你可以清楚地看到几件事:

  • 每个测试的分支数约为 5,而 CMOV 转换将其降至 4。这与之前的反汇编转储相关:我们将测试中的一个分支转换为 CMOV。另外 4 个分支来自测试基础设施本身。
  • 如果没有 CMOV,分支测试性能会受到影响,在 50% 的分支概率下会变得最差。这个峰值反映了硬件分支预测器几乎完全混乱,因为它每次操作都会遇到大约 0.5 次分支失误。这意味着分支预测器并不是一直猜错(这太荒谬了!),而只是一半的时间猜错。我推测基于历史的预测器会放弃,让静态预测器选择最近的分支,而我们只选择了一半的时间。
  • 使用 CMOV 后,我们可以看到操作时间几乎持平 。该图表明 CMOV 成本模型对于此测试来说可能过于保守,并且切换得有点晚。这并不一定意味着它有错误,因为其他情况的表现很可能会有所不同。尽管如此,当进行 CMOV 转换时,对分支情况的改进是巨大的。
  • 您可能会注意到,当分支预测准确率为 >97% 时,分支变体会低于 CMOV 中间平均值。当然,这又是测试、硬件、虚拟机特有的事情。

总结

分支分析允许在执行概率敏感指令选择时做出或多或少明智的选择。条件移动替换通常使用分支频率信息来驱动替换。这再次强调了使用与真实数据类似的数据来预热 JIT 编译代码的必要性,以便编译器能够针对特定情况进行有效优化。

相关推荐
流星52112214 小时前
GC 如何判断对象该回收?从可达性分析到回收时机的关键逻辑
java·jvm·笔记·学习·算法
JanelSirry14 小时前
我的应用 Full GC 频繁,怎么优化?
jvm
JH307315 小时前
jvm,tomcat,spring的bean容器,三者的关系
jvm·spring·tomcat
DKPT18 小时前
JVM直接内存和堆内存比例如何设置?
java·jvm·笔记·学习·spring
siriuuus19 小时前
JVM 垃圾收集器相关知识总结
java·jvm
小满、21 小时前
什么是栈?深入理解 JVM 中的栈结构
java·jvm·1024程序员节
百花~1 天前
JVM(Java虚拟机)~
java·开发语言·jvm
每天进步一点点dlb1 天前
JVM中的垃圾回收算法和垃圾回收器
jvm·算法
漫漫不慢.2 天前
蓝桥杯-16955 岁月流转
java·jvm·蓝桥杯
boy快快长大3 天前
【JVM】线上JVM堆内存报警,占用超90%
jvm