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

Why is a ternary operator with two constants faster than one with a variable?

在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 次)

为什么在使用第二条语句时会有如此显着的加速?它似乎包括一个不必要的乘法并具有相同的比较。第一个创建分支而第二个不创建分支吗?

我没有研究过 java 编译器或 JIT 生成器生成的代码,但是在编写编译器时,我通常会检测和优化执行布尔到整数转换的三元运算符: (num < 0 ? 0 : 1) 转换布尔值到 2 个整数常量之一。在 C 中,此特定代码可以重写为 !(num < 0)。这种转换可以产生无分支代码,这将击败在现代 CPU 上为 (num < 0 ? 0 : num) 生成的分支代码,即使有一个额外的乘法操作码。但是请注意,为 (num < 0 ? 0 : num) 生成无分支代码也相当容易,但 java 编译器/JIT 生成器可能不会。

我已经发现是什么导致第二个陈述需要更长的时间,但我无法解释为什么会这样,如果这有意义的话。也就是说,我确实相信这应该能让我们更深入地了解我们这里的问题。

在解释我的推理之前,我将直接告诉您我的发现:这与从三元运算返回常量或变量无关。它与从三元运算返回整数或浮点数有关。归结为:从三元运算返回浮点数比返回整数“明显”慢。

我无法解释原因,但这至少是根本原因。

这是我的推理:我使用以下代码创建了一个带有结果的小型文本文档,与您的示例代码非常相似。

        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);

由于我现在不会讨论的原因,但您可以阅读有关 here 的内容,我使用 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

这一切仍然引出了一个问题:为什么 returns 浮点数的三元运算比返回 int 的运算慢? int 和 float 都是 32 位.如果没有三元运算,浮点数并不是特别慢,我们可以看到,因为我们可以将返回的 int 与一个浮点变量相乘,而这不会减慢它的速度。我没有答案。

为什么括号会加快运行速度:我不是专家,但我猜这可能与解释器减慢代码有关:

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

解释器在这里看到 results 是一个 float 类型的数组,并简单地将整数替换为 float 作为“优化”,这样它就不必在类型之间进行转换。

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

这里的括号强制解释器在执行任何其他操作之前计算其中的所有内容,这将导致一个 int。只有在那之后,结果才会被转换为浮点数,以便它可以放入数组中,类型转换一点也不慢。

同样,我没有技术知识来支持这一点,这只是我的有根据的猜测。

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

首先,让我们用 JMH to avoid common benchmarking pitfalls.

重写基准
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

现在是使用 perfasm profiler(内置于 JMH 中)查看 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 足够智能,可以发出条件移动而不是条件跳转。

在此基准测试中,条件跳转效率非常低,因为 branch prediction 经常因数字的随机性而失败。这就是mul方法的无分支代码出现得更快的原因。

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

ThreadLocalRandom.current().nextFloat() * 2 - 1

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