为什么具有两个常量的三元运算符比具有变量的三元运算符快?
Why is a ternary operator with two constants faster than one with a variable?
在Java中,我有两个不同的语句通过使用三元运算符实现相同的结果,如下:
num < 0 ? 0 : num;
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.232 秒
- 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
在Java中,我有两个不同的语句通过使用三元运算符实现相同的结果,如下:
num < 0 ? 0 : num;
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.232 秒
- 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