为什么带有两个常量的三元运算符比带有变量的三元运算符更快?

11

在Java中,我有两个使用三目运算符来完成相同结果的不同语句,如下所示:

  1. num < 0 ? 0 : num;
  2. num * (num < 0 ? 0 : 1);

看起来第二个语句不必要地复杂,并且比第一个语句花费更长时间,然而当我用以下代码记录每个语句的执行时间时,结果如下:

final long startTime = System.currentTimeMillis();

Random rand = new Random();
float[] results = new float[100000000];
for (int i = 0; i < 100000000; i++) {
    float num = (rand.nextFloat() * 2) - 1;
    results[i] = num < 0 ? 0 : num;
    //results[i] = num * (num < 0 ? 0 : 1);
}

final long endTime = System.currentTimeMillis();

System.out.println("Total Time: " + (endTime - startTime));
  1. 1.232秒
  2. 1.023秒 (每个都是5次运行的平均时间)

为什么使用第二个语句时速度会显着加快?它似乎包括一个不必要的乘法并具有相同的比较。第一个是否创建了一个分支而第二个没有?


1
嗨!你能分享一下你是如何记录运行时间的细节吗? - akuzminykh
2
我想知道你在这里使用了什么巧妙的优化来掩盖结果。我敢打赌,如果你通过像JMH这样的工具运行它,结果可能会有所不同。 - Rogue
1
我可以在Java中重现这个问题,但是我无法使用C++重现它。 - akuzminykh
我得到了类似的结果。使用“-Djava.compiler = NONE”,乘法仍然更快(255503vs258628,共进行了10次尝试)。这可能只是噪音,但仍然很奇怪,因为乘法的字节码会加载num 2次,实际上在将它们转换为浮点数之前,将1和0作为int推送(另一个则只推送浮点0),并且乘以值,无论路径如何,它都比其他几个指令长。分支看起来是相同的。 - user13149581
1
我又做了一个观察:当你将num * (num < 0 ? 0 : 1);中的一个结果整数更改为float,例如0f1f时,该语句变得和其他语句一样慢。 - akuzminykh
显示剩余2条评论
3个回答

16

首先,让我们使用JMH重新编写基准测试,以避免常见的基准测试陷阱

public class FloatCompare {

    @Benchmark
    public float cmp() {
        float num = ThreadLocalRandom.current().nextFloat() * 2 - 1;
        return num < 0 ? 0 : num;
    }

    @Benchmark
    public float mul() {
        float num = ThreadLocalRandom.current().nextFloat() * 2 - 1;
        return num * (num < 0 ? 0 : 1);
    }
}

JMH 还建议使用乘法代码可以更快:

Benchmark         Mode  Cnt   Score   Error  Units
FloatCompare.cmp  avgt    5  12,940 ± 0,166  ns/op
FloatCompare.mul  avgt    5   6,182 ± 0,101  ns/op

现在是时候使用内置于JMH中的perfasm性能分析器来查看JIT编译器生成的汇编代码。以下是输出的最重要部分(注释是我的):

cmp方法:

  5,65%  │││  0x0000000002e717d0: vxorps  xmm1,xmm1,xmm1  ; xmm1 := 0
  0,28%  │││  0x0000000002e717d4: vucomiss xmm1,xmm0      ; compare num < 0 ?
  4,25%  │╰│  0x0000000002e717d8: jbe     2e71720h        ; jump if num >= 0
  9,77%  │ ╰  0x0000000002e717de: jmp     2e71711h        ; jump if num < 0

mul 方法:

  1,59%  ││  0x000000000321f90c: vxorps  xmm1,xmm1,xmm1    ; xmm1 := 0
  3,80%  ││  0x000000000321f910: mov     r11d,1h           ; r11d := 1
         ││  0x000000000321f916: xor     r8d,r8d           ; r8d := 0
         ││  0x000000000321f919: vucomiss xmm1,xmm0        ; compare num < 0 ?
  2,23%  ││  0x000000000321f91d: cmovnbe r11d,r8d          ; r11d := r8d if num < 0
  5,06%  ││  0x000000000321f921: vcvtsi2ss xmm1,xmm1,r11d  ; xmm1 := (float) r11d
  7,04%  ││  0x000000000321f926: vmulss  xmm0,xmm1,xmm0    ; multiply

关键区别在于mul方法中没有跳转指令。相反,使用了条件移动指令cmovnbe

cmov使用整数寄存器。由于(num < 0 ? 0 : 1)表达式在右侧使用整数常量,JIT足够聪明,会发出条件移动指令而不是条件跳转指令。

在这个基准测试中,条件跳转非常低效,因为由于数字的随机性质,分支预测经常失败。这就是为什么mul方法的无分支代码看起来更快的原因。

如果我们以某种方式修改基准测试,使一个分支优于另一个分支,例如通过替换

ThreadLocalRandom.current().nextFloat() * 2 - 1
with
ThreadLocalRandom.current().nextFloat() * 2 - 0.1f

那么分支预测就会更好地工作,cmp 方法的速度将变得和 mul 一样快:

Benchmark         Mode  Cnt  Score   Error  Units
FloatCompare.cmp  avgt    5  5,793 ± 0,045  ns/op
FloatCompare.mul  avgt    5  5,764 ± 0,048  ns/op

1
在进行基准测试之前计算随机数会不会导致更准确的结果?我问这个问题是因为当我尝试过时,似乎随机数生成需要比测量的差异更多的时间,而您似乎对此有所了解。 - user13149581
@Harpistry 我故意将 Random 替换为 ThreadLocalRandom,因为后者的开销显著较低。但你是对的 - 预计算随机数会更好。 - apangin

4

我没有调查过Java编译器或JIT生成器生成的代码,但在编写编译器时,我通常会检测和优化执行布尔到整数转换的三元运算符:(num < 0 ? 0 : 1)将布尔值转换为两个整数常量中的一个。在C语言中,这段特定的代码可以重写为!(num < 0)。这种转换可以生成无分支代码,即使使用额外的乘法操作码,也能击败现代CPU为(num < 0 ? 0 : num)生成的分支代码。然而请注意,对于(num < 0 ? 0 : num),也很容易生成无分支代码,但是Java编译器/JIT生成器可能不会这样做。


@Dhiraj:谢谢你的建议,但我想表达的是另一个想法:我为几种带有三元运算符的语言编写了多个编译器,当这样的结构与2个常量表达式一起使用时,我尝试生成无分支代码,这通常是可能的。 - chqrlie
抱歉,我试图根据我理解的意思更新答案。感谢您的更新,现在更清晰了。 - Dhiraj

2
我已经发现了第二个语句为什么需要更长时间,但我不能解释为什么会这样,如果有道理的话。话虽如此,我确信这应该会提供一些更深入的见解来解决我们目前所面临的问题。
在我解释我的推理之前,我要直接告诉你我的发现:这与从三元操作返回常数或变量无关。它与从三元操作返回整数或浮点数有关。归结为这一点:从三元操作返回浮点数比返回整数要“显着”慢。
我无法解释为什么,但至少这是根本原因。
以下是我的推理过程:我使用了以下代码创建了一个带有结果的小文本文档,与你的示例代码非常相似。
        Random rand = new Random();
        final int intOne = 1;
        final int intZero = 0;
        final float floatOne = 1f;
        final float floatZero = 0f;

        final long startTime = System.nanoTime();

        float[] results = new float[100000000];
        for (int i = 0; i < 100000000; i++) {
            float num = (rand.nextFloat() * 2) - 1;
//            results[i] = num < 0 ? 0 : num;
//            results[i] = num * (num < 0 ? 0 : 1);

//            results[i] = num < 0 ? 0 : 1;
//            results[i] = (num < 0 ? 0 : 1);
//            results[i] = (num < 0 ? 0 : num);
//            results[i] = 1 * (num < 0 ? 0 : num);

//            results[i] = num < 0 ? 0 : one;
//            results[i] = num < 0 ? 0 : 1f;
//            results[i] = (num < 0 ? 0 : one);
//            results[i] = (num < 0 ? 0 : 1f);
//            results[i] = (num < 0 ? 0 : 1);

//            results[i] = (num < 0 ? 0f : 1f);
//            results[i] = (num < 0 ? 0 : 1);
//            results[i] = (num < 0 ? floatZero : floatOne);
//            results[i] = (num < 0 ? intZero : intOne);

//            results[i] = num < 0 ? intZero : intOne;

//            results[i] = num * (num < 0 ? 0 : 1);
//            results[i] = num * (num < 0 ? 0f : 1f);
//            results[i] = num < 0 ? 0 : num;
        }

        final long endTime = System.nanoTime();

        String str = (endTime - startTime) + "\n";
        System.out.println(str);
        Files.write(Paths.get("test.txt"), str.getBytes(), StandardOpenOption.APPEND);

出于某些原因(可以在这里阅读),我使用了nanoTime()而不是currentTimeMillis()。最后一行只是将结果时间值添加到文本文档中,以便我可以轻松添加注释。

以下是最终的文本文档,其中包括我得出这个结论的整个过程:


    num < 0 ? 0 : num       // standard "intuitive" operation
    1576953800
    1576153599
    1579074600
    1564152100
    1571285399
    
    num * (num < 0 ? 0 : 1)    // strange operation that is somehow faster
    1358461100
    1347008700
    1356969200
    1343784400
    1336910000
    
    // let's remove the multiplication and focus on the ternary operation
    
    num < 0 ? 0 : 1     // without the multiplication, it is actually slower...?
    1597369200
    1586133701
    1596085700
    1657377000
    1581246399
    
    (num < 0 ? 0 : 1)     // Weird, adding the brackets back speeds it up
    1797034199
    1294372700
    1301998000
    1286479500
    1326545900
    
    (num < 0 ? 0 : num)     // adding brackets to the original operation does NOT speed it up.
    1611220001
    1585651599
    1565149099
    1728256000
    1590789800
    
    1 * (num < 0 ? 0 : num)    // the speedup is not simply from multiplication
    1588769201
    1587232199
    1589958400
    1576397900
    1599809000
    
    // Let's leave the return value out of this now, we'll just return either 0 or 1.
    
    num < 0 ? 0 : one  // returning 1f, but from a variable
    1522992400
    1590028200
    1605736200
    1578443700
    1625144700
    
    num < 0 ? 0 : 1f   // returning 1f as a constant
    1583525400
    1570701000
    1577192000
    1657662601
    1633414701
    
    // from the last 2 tests we can assume that returning a variable or returning a constant has no significant speed difference.
    // let's add the brackets back and see if that still holds up.
    
    (num < 0 ? 0 : floatOne)  // 1f as variable, but with ()
    1573152100
    1521046800
    1534993700
    1630885300
    1581605100
    
    (num < 0 ? 0 : 1f)  // 1f as constant, with ()
    1589591100
    1566956800
    1540122501
    1767168100
    1591344701
    // strangely this is not faster, where before it WAS. The only difference is that I now wrote 1f instead of 1.
    
    (num < 0 ? 0 : 1)  // lets replace 1f with 1 again, then.
    1277688700
    1284385000
    1291326300
    1307219500
    1307150100
    // the speedup is back!
    // It would seem the speedup comes from returning an integer rather than a float. (and also using brackets around the operation.. somehow)
    
    // Let's try to confirm this by replacing BOTH return values with floats, or integers.
    // We're also keeping the brackets around everything, since that appears to be required for the speedup
    
    (num < 0 ? 0f : 1f)
    1572555600
    1583899100
    1595343300
    1607957399
    1593920499
    
    (num < 0 ? 0 : 1)
    1389069400
    1296926500
    1282131801
    1283952900
    1284215401
    
    // looks promising, now lets try the same but with variables
    // final int intOne = 1;
    // final int intZero = 0;
    // final float floatOne = 1f;
    // final float floatZero = 0f;
    
    (num < 0 ? floatZero : floatOne)
    1596659301
    1600570100
    1540921200
    1582599101
    1596192400
    
    (num < 0 ? intZero : intOne)
    1280634300
    1300473900
    1304816100
    1285289801
    1286386900
    
    // from the looks of it, using a variable or constant makes no significant difference, it definitely has to do with the return type.
    
    // That said, this is still only noticeable when using brackets around the operation, without them the int operation is still slow:
    
    num < 0 ? intZero : intOne
    1567954899
    1565483600
    1593726301
    1652833999
    1545883500
    
    // lastly, lets add the multiplication with num back, knowing what we know now.
    
    num * (num < 0 ? 0 : 1)    // the original fast operation, note how it uses integer as return type.
    1379224900
    1333161000
    1350076300
    1337188501
    1397156600
    
    results[i] = num * (num < 0 ? 0f : 1f)  // knowing what we know now, using floats should be slower again.
    1572278499
    1579003401
    1660701999
    1576237400
    1590275300
    // ...and it is.
    
    // Now lets take a look at the intuitive solution
    
    num < 0 ? 0 : num      // the variable num is of type float. returning a float from a ternary operation is slower than returning an int.
    1565419400
    1569075400
    1632352999
    1570062299
    1617906200


这仍然引出一个问题:为什么返回浮点数的三元运算符比返回整数的慢? 整数和浮点数都是32位的。如果没有三元运算符,浮点数并不是特别慢,因为我们可以将返回的整数乘以一个浮点变量,而这不会使其变慢。我没有答案。
至于为什么括号可以加速操作:我不是专家,但我猜可能与编译器通过执行应该是优化的代码而减慢代码有关。
results[i] = num < 0 ? 0 : 1;

在这里,编译器看到results是一个类型为浮点数的数组,并简单地用浮点数替换整数作为“优化”,这样它就不必在类型之间转换。

results[i] = (num < 0 ? 0 : 1);

这里的括号强制解释器在执行其他操作之前计算它们内部的所有内容,这将导致一个int。仅在此之后,结果才会转换为float,以便它适合于数组中,类型转换并不慢。

再次说明,我没有技术知识来支持我的猜测。

希望这是一个足够好的答案,如果不是,至少它应该指向比我更具有技术知识的人的正确方向。


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