【问题标题】:Why is a ternary operator with two constants faster than one with a variable?为什么具有两个常量的三元运算符比一个具有变量的三元运算符快?
【发布时间】:2020-10-06 06:43:17
【问题描述】:

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

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

【问题讨论】:

  • 嘿!您能否分享有关您如何记录运行时的详细信息?
  • 发布一个完整的工作示例,以便我们验证您的结果。
  • 我想知道是什么棘手的优化让你在这里模糊了结果。我敢打赌,如果你通过 JMH 之类的方法运行它,结果可能看起来会有些不同。
  • 我可以在 Java 中重现该问题,但无法使用 C++ 重现它。
  • 我做了另一个观察:当您将num * (num &lt; 0 ? 0 : 1); 中的一个结果整数更改为float 时,例如0f1f,语句变得和另一个一样慢。

标签: java performance optimization conditional-operator


【解决方案1】:

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

【讨论】:

  • @Dhiraj:谢谢你的建议,但我的意思是不同的:我为带有三元运算符的语言编写了几个编译器,当这种结构与 2 个常量表达式一起使用时,我尝试生成通常可以使用的无分支代码。
  • 很抱歉。我试图按照我认为的意思更新答案。感谢您更新它。现在更清楚了。
【解决方案2】:

首先,让我们用JMH 重写基准以避免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 编译器生成的程序集了。以下是输出中最重要的部分(cmets 是我的):

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 &lt; 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

【讨论】:

  • 在基准测试之前计算随机数不会得到更准确的结果吗?我在这里问是因为当我尝试它时,似乎随机数生成需要更多时间而不是测量的差异,并且您似乎知道主题。
  • @Harpistry 我有意将Random 替换为ThreadLocalRandom,因为后者的开销显着降低。但你是对的 - 预先计算随机数会更好。
【解决方案3】:

我发现了导致第二个语句需要更长的时间的原因,但我无法解释为什么会发生这种情况,如果这有意义的话。话虽如此,我确实相信这应该可以让我们更深入地了解我们在这里遇到的问题。

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

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

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

        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()。最后一行只是将生成的时间值添加到文本文档中,因此我可以轻松添加 cmets。

这是最终的文本文档,它包含了我得出这个结论的整个过程:


    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

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

至于为什么括号会加速操作:我不是专家,但我猜这可能与解释器减慢代码有关:

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

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

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

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

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

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

【讨论】:

    猜你喜欢
    • 2016-07-02
    • 2021-08-01
    • 1970-01-01
    • 1970-01-01
    • 2016-11-02
    • 2013-02-13
    • 1970-01-01
    • 1970-01-01
    相关资源
    最近更新 更多