Java Math.min/max performance

2022-09-01 19:25:30

编辑:maaartinus给出了我正在寻找的答案,tmyklebu关于这个问题的数据帮助很大,所以谢谢两位!:)

我已经阅读了一些关于HotSpot如何注入代码的“内部函数”的信息,特别是对于Java标准Math库(从这里开始))

所以我决定试一试,看看HotSpot与直接进行比较会产生多大的差异(特别是因为我听说min/max可以编译成无分支asm)。

public class OpsMath {
    public static final int max(final int a, final int b) {
        if (a > b) {
            return a;
        }
        return b;
    }
}

这就是我的实现。从另一个SO问题中,我读到使用三元运算符使用额外的寄存器,我没有发现做if块和使用三元运算符之间的显着差异(即,返回(a > b)? a : b)。

分配一个8Mb的int数组(即200万个值),并对其进行随机化,我做以下测试:

try ( final Benchmark bench = new Benchmark( "millis to max" ) )
    {
        int max = Integer.MIN_VALUE;

        for ( int i = 0; i < array.length; ++i )
        {
            max = OpsMath.max( max, array[i] );
            // max = Math.max( max, array[i] );
        }
    }

我在资源试用块中使用 Benchmark 对象。当它完成时,它在对象上调用 close() 并打印块完成所花费的时间。测试是通过注释上述代码中的最大调用来单独完成的。

“max”被添加到基准测试块外的列表中,稍后再打印,以避免JVM优化整个块。

每次运行测试时,数组都是随机的。

运行测试 6 次,它给出以下结果:

Java 标准数学:

millis to max 9.242167 
millis to max 2.1566199999999998
millis to max 2.046396 
millis to max 2.048616  
millis to max 2.035761
millis to max 2.001044 

因此,在第一次运行后相当稳定,并且再次运行测试给出了类似的结果。

OpsMath:

millis to max 8.65418 
millis to max 1.161559  
millis to max 0.955851 
millis to max 0.946642 
millis to max 0.994543 
millis to max 0.9469069999999999 

同样,第一次运行后的结果非常稳定。

问题是:为什么?这是一个很大的区别。我不知道为什么。即使我完全像Math.max()一样实现我的max()方法(即,返回(a >= b)? a : b)我仍然会得到更好的结果!这是没有道理的。

规格:

CPU: Intel i5 2500, 3,3Ghz.Java 版本:JDK 8(3 月 18 日公开发布),x64。Debian Jessie (測試版) x64.

我还没有尝试过使用32位JVM。

编辑:根据要求进行自包含测试。添加了一行来强制 JVM 预加载 Math 和 OpsMath 类。这消除了OpsMath测试第一次迭代的18ms成本。

// Constant nano to millis.
final double TO_MILLIS = 1.0d / 1000000.0d;
// 8Mb alloc.
final int[] array = new int[(8*1024*1024)/4];
// Result and time array.
final ArrayList<Integer> results = new ArrayList<>();
final ArrayList<Double> times = new ArrayList<>();
// Number of tests.
final int itcount = 6;
// Call both Math and OpsMath method so JVM initializes the classes.
System.out.println("initialize classes " + 
OpsMath.max( Math.max( 20.0f, array.length ), array.length / 2.0f ));
    
final Random r = new Random();
for ( int it = 0; it < itcount; ++it )
{
    int max = Integer.MIN_VALUE;
    
    // Randomize the array.
    for ( int i = 0; i < array.length; ++i )
    {
        array[i] = r.nextInt();
    }
    
    final long start = System.nanoTime();
    for ( int i = 0; i < array.length; ++i )
    {
        max = Math.max( array[i], max );
            // OpsMath.max() method implemented as described.
        // max = OpsMath.max( array[i], max );
    }
    // Calc time.
    final double end = (System.nanoTime() - start);
    // Store results.
    times.add( Double.valueOf( end ) );
    results.add( Integer.valueOf(  max ) );
}
// Print everything.
for ( int i = 0; i < itcount; ++i )
{
    System.out.println( "IT" + i + " result: " + results.get( i ) );
    System.out.println( "IT" + i + " millis: " + times.get( i ) * TO_MILLIS );
}

Java Math.max 结果:

IT0 result: 2147477409
IT0 millis: 9.636998
IT1 result: 2147483098
IT1 millis: 1.901314
IT2 result: 2147482877
IT2 millis: 2.095551
IT3 result: 2147483286
IT3 millis: 1.9232859999999998
IT4 result: 2147482828
IT4 millis: 1.9455179999999999
IT5 result: 2147482475
IT5 millis: 1.882047

OpsMath.max结果:

IT0 result: 2147482689
IT0 millis: 9.003616
IT1 result: 2147483480
IT1 millis: 0.882421
IT2 result: 2147483186
IT2 millis: 1.079143
IT3 result: 2147478560
IT3 millis: 0.8861169999999999
IT4 result: 2147477851
IT4 millis: 0.916383
IT5 result: 2147481983
IT5 millis: 0.873984

总体结果仍然相同。我只尝试过一次随机化数组,并在同一数组上重复测试,我总体上得到了更快的结果,但Java Math.max和OpsMath之间的差异相同.max。


答案 1

很难说为什么比 a 慢,但很容易看出为什么这个基准测试强烈支持分支到条件移动:在第 -th 次迭代中,概率Math.maxOps.maxn

Math.max( array[i], max );

不等于是大于所有先前元素的概率。显然,随着增长和给定,这种概率变得越来越低。maxarray[n-1]n

final int[] array = new int[(8*1024*1024)/4];

大多数时候,它是相当微不足道的。条件移动指令对分支概率不敏感,它总是需要相同的时间来执行。条件移动指令比分支预测更快,如果分支很难预测。另一方面,如果能够以高概率很好地预测分支,则分支预测速度更快。目前,我不确定与分支的最佳和最坏情况相比,条件移动的速度。1 个

在你的情况下,除了前几个分支之外,所有分支都是相当可预测的。从大约开始,使用条件移动就没有意义了,因为分支可以保证正确预测,并且可以与其他指令并行执行(我猜你每次迭代只需要一个周期)。n == 10

对于计算最小值/最大值或执行一些低效排序的算法(良好的分支可预测性意味着每步低熵),似乎会发生这种情况。


1 条件移动和预测分支都需要一个周期。前者的问题在于它需要两个操作数,这需要额外的指令。最后,当分支单元空闲时,关键路径可能会变长和/或ALU饱和。通常,但并非总是,分支可以在实际应用中很好地预测;这就是为什么分支预测首先被发明的原因。

至于时间条件移动与分支预测最佳和最坏情况的血腥细节,请参阅下面的评论中的讨论。我自己的基准测试表明,当分支预测遇到最坏的情况时,条件移动明显快于分支预测,但我不能忽视矛盾的结果。我们需要一些解释,究竟是什么造成了这种差异。更多的基准和/或分析可能会有所帮助。


答案 2

当我在旧的(1.6.0_27)JVM上运行(适当修改的)代码时,热循环如下所示:Math.max

0x00007f4b65425c50: mov    %r11d,%edi         ;*getstatic array
                                              ; - foo146::bench@81 (line 40)
0x00007f4b65425c53: mov    0x10(%rax,%rdx,4),%r8d
0x00007f4b65425c58: mov    0x14(%rax,%rdx,4),%r10d
0x00007f4b65425c5d: mov    0x18(%rax,%rdx,4),%ecx
0x00007f4b65425c61: mov    0x2c(%rax,%rdx,4),%r11d
0x00007f4b65425c66: mov    0x28(%rax,%rdx,4),%r9d
0x00007f4b65425c6b: mov    0x24(%rax,%rdx,4),%ebx
0x00007f4b65425c6f: rex mov    0x20(%rax,%rdx,4),%esi
0x00007f4b65425c74: mov    0x1c(%rax,%rdx,4),%r14d  ;*iaload
                                              ; - foo146::bench@86 (line 40)
0x00007f4b65425c79: cmp    %edi,%r8d
0x00007f4b65425c7c: cmovl  %edi,%r8d
0x00007f4b65425c80: cmp    %r8d,%r10d
0x00007f4b65425c83: cmovl  %r8d,%r10d
0x00007f4b65425c87: cmp    %r10d,%ecx
0x00007f4b65425c8a: cmovl  %r10d,%ecx
0x00007f4b65425c8e: cmp    %ecx,%r14d
0x00007f4b65425c91: cmovl  %ecx,%r14d
0x00007f4b65425c95: cmp    %r14d,%esi
0x00007f4b65425c98: cmovl  %r14d,%esi
0x00007f4b65425c9c: cmp    %esi,%ebx
0x00007f4b65425c9e: cmovl  %esi,%ebx
0x00007f4b65425ca1: cmp    %ebx,%r9d
0x00007f4b65425ca4: cmovl  %ebx,%r9d
0x00007f4b65425ca8: cmp    %r9d,%r11d
0x00007f4b65425cab: cmovl  %r9d,%r11d         ;*invokestatic max
                                              ; - foo146::bench@88 (line 40)
0x00007f4b65425caf: add    $0x8,%edx          ;*iinc
                                              ; - foo146::bench@92 (line 39)
0x00007f4b65425cb2: cmp    $0x1ffff9,%edx
0x00007f4b65425cb8: jl     0x00007f4b65425c50

除了奇怪放置的REX前缀(不确定这是什么)之外,这里还有一个循环,它已经展开了8次,它主要执行您期望的操作---加载,比较和条件移动。有趣的是,如果将参数的顺序交换到 ,这里它输出另一种 8 深链。我猜它不知道如何生成一个3深的s树或8个单独的链,以便在循环完成后合并。maxcmovlcmovlcmovl

有了显式的,它变成了一个有条件和无条件分支的鼠巢,展开了8次。我不打算发布循环;它不漂亮。基本上,上述每个都被分解为一个负载,一个比较和一个条件跳转到a和a发生的地方。有趣的是,如果将参数的顺序交换到 ,则此处输出一个 8 深的链。编辑:正如@maaartinus指出的那样,在某些机器上,分支的老鼠实际上更快,因为分支预测器在它们身上发挥了魔力,而这些都是预测良好的分支。OpsMath.maxmov/cmp/cmovlmovjmpmaxcmovle

我不愿从这一基准中得出结论。您有基准施工问题;你必须运行它比你更多的次数,如果你想计算Hotspot最快的代码的时间,你必须以不同的方式分解你的代码。除了包装代码之外,你无法衡量你的速度有多快,或者Hotspot理解你想做什么,或者这里其他任何有价值的东西。的这两种实现都会导致代码太快,任何类型的直接测量在更大的程序上下文中都没有意义。maxmax


推荐