我在Java中编写了一些代码,在某种程度上,程序的流程是由两个int变量"a"和"b"是否为非零来确定的(注:A和B都不为负,并且永远不在整数溢出范围内)。
我可以用
1
| if (a != 0 && b != 0) { /* Some code */ } |
或者
1
| if (a*b != 0) { /* Some code */ } |
因为我期望这段代码在每次运行时运行数百万次,所以我想知道哪段代码更快。我在一个巨大的随机生成的数组上对它们进行了比较,并且我也很好奇数组的稀疏度(数据的分数=0)将如何影响结果:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22
| long time ;
final int len = 50000000;
int arbitrary = 0;
int[][] nums = new int[2][len ];
for (double fraction = 0 ; fraction <= 0.9 ; fraction += 0.0078125) {
for(int i = 0 ; i < 2 ; i ++) {
for(int j = 0 ; j < len ; j ++) {
double random = Math. random();
if(random < fraction ) nums [i ][j ] = 0;
else nums [i ][j ] = (int) (random *15 + 1);
}
}
time = System. currentTimeMillis();
for(int i = 0 ; i < len ; i ++) {
if( /*insert nums[0][i]*nums[1][i]!=0 or nums[0][i]!=0 && nums[1][i]!=0*/ ) arbitrary ++;
}
System. out. println(System. currentTimeMillis() - time );
} |
结果表明,如果你期望"a"或"b"大于约3%的时间,那么a*b != 0比a!=0 && b!=0快:
我很好奇为什么。有人能发光吗?它是编译器还是硬件级的?
编辑:出于好奇…既然我了解了分支预测,我想知道A或B的模拟比较结果是非零的:
我们确实看到了与预期相同的分支预测效果,有趣的是,图表有点沿x轴翻转。
更新
我在分析中加入了!(a==0 || b==0),看看会发生什么。
2-在学习了分支预测之后,出于好奇,我还包括了a != 0 || b != 0、(a+b) != 0和(a|b) != 0。但它们在逻辑上并不等同于其他表达式,因为只有a或b需要非零才能返回true,因此不必对它们进行处理效率比较。
3-我还添加了用于分析的实际基准,它只是迭代一个任意的int变量。
4-有些人建议将a != 0 & b != 0与a != 0 && b != 0进行比较,因为我们会消除分支预测效应,因此预测它将更接近a*b != 0。我不知道&可以用于布尔变量,我认为它只用于整数的二进制运算。
注意:在我考虑所有这些的上下文中,int溢出不是一个问题,但在一般上下文中这绝对是一个重要的考虑。
CPU:英特尔酷睿[email protected]
Java版本:1.80y45Java(TM)SE运行时环境(构建1.80y45-B14)Java热点(TM)64位服务器VM(构建25.45-B02,混合模式)
- 那么if (!(a == 0 || b == 0))呢?微基准是出了名的不可靠,这不太可能真的是可测量的(对我来说,大约3%听起来像是一个误差范围)。
- 尝试比较a*b != 0和!( a == 0 || b == 0)。如果这两个结果更接近,这是一个优化的怪癖。
- 或a != 0 & b != 0。
- 如果预测的分支错误,则分支速度很慢。a*b!=0少了一个分支
- 我补充说!(a==0 b==0)按照你们的建议进行分析,和a完全一样!=0&B!= 0。它似乎真的取决于比较器的数量,算术运算符和更快
- 我有点惊讶,一个乘法比一个比较更快,甚至考虑到流水线的效果@stevenc指出。看到JIT编译器通过优化这两种形式所做的工作是很有趣的。优化a*b==0到(a|b) == 0的窥视孔是非常简单的,优化短路比较是比较容易的工作。无论如何,您应该尝试使用a|b选项,因为这将不超过一个周期(如果与另一个操作一起发送,它甚至可能是"免费的"),而在现代x86中,乘法通常是2到4个周期。
- 但两者都不同于零。
- 十二分之一的人有某种形式的色盲。仅使用颜色来区分点的图形对于那些人来说可能是困难的或不可能的。通常的解决方法是使点具有不同的形状。也就是说,这是一个很好的图表和一个很好的问题。
- @吉恩:你提出的优化方案无效。即使忽略溢出,如果a和b中的一个为零,则a*b为零;只有当两者都为零时,a|b为零。
- 因为这个问题可能是分支预测,所以一定要用程序中的实际值按照它们在程序中出现的顺序进行测试。给定条件分支的Taken/NotTaken序列控制分支预测的工作情况。正确预测分支是一种快速操作。
- 你看到这个答案了吗?您可以使用JMH编写微基准,并获得包括CPU分支预测统计信息在内的低级详细信息。
- @准确地编码混沌。当然有很多这样的例子。例如,983040 * 6422528 == 0(作为32位整数),但两个因子都不是零(作为32位整数)。如果你把它看作十六进制,我的例子是0xF0000 * 0x620000 == 0x5BE00000000,但由于32位整数只保留8个最不重要的十六进制数字,所以乘积是0x00000000,或者只是零。
- 我很抱歉。脑痉挛我是说(a&b)!=0。
- 过早的优化是万恶之源。不要再做毫无意义的微优化,而是改进算法。
- @吉恩:那也不管用,比如说,1&2。
- @用户882813为什么?如果你能用这个技巧将程序的执行时间减少25%以上,你会不这样做吗?
- @因为互联网和同龄人一遍又一遍地告诉他搜索和发现这样的优化被称为"过早优化",而"过早优化是不好的",这就是为什么。有人在某个地方说过,所以这是普遍正确的。天哪,你没有收到备忘录吗?
- 我倾向于打开一个帖子,"为什么使用itneger 1是一个常数计算,导致1 faste?"…
- 要检查这是编译器还是硬件,可以关闭编译器优化…
- @TechnikEmpire实际上有两个人。一个根据定义大喊在开发过程中过早进行优化是不好的人——应该禁止这个人讨论。但是还有另一个是优秀的科学家:在没有花费足够的时间来验证优化不会影响实际逻辑的情况下,提前执行优化。别怠慢他的智慧!
- 在某种程度上,使用&&而不是&的习惯是当结果相同(没有副作用需要考虑)时,这就是过早的优化。在C语言中,我们倾向于使用&&,因为它的速度更快(工作更少),但有时当发现增加的分支比省略的分支成本更高时,这还为时过早。(公平地说,&是错误的情况比&&是错误的情况更为常见,但&&的偏好可以追溯到C语言中的分支预测没有那么大的影响,速度影响影响了后来语言的习惯)。
- 如果分支预测失误确实是问题所在(正如我怀疑的那样),那么您应该从非分支、按位的&操作符:if ((a != 0) & (b != 0))中看到更好的性能。这不仅可以避免溢出问题,而且考虑到乘法的速度有多慢,它应该快得多。IT和Division是唯一需要多个周期的操作;相比之下,位旋转非常快。
- 我对BoCales的位和工作同样感到惊讶,但是Java不是我常用的语言。也就是说,我怀疑我的一个或两个建议也会有类似的效果。
- 你写A和B是变量,但它们不是,它们是更复杂的表达式。正如@pagefault的答案所指出的,这很可能会产生影响。
- 听起来像是我大学生涯的大部分…编写这样的代码…………噢,该死的图表。见鬼,我在开谁的玩笑,我还是这么做。
- @阿勒克西托哈莫,谢谢。对不起的。这是漫长的一天。思维不清晰。
- @codygray-nitpicking:当&的操作数是布尔值时,它不是位而是逻辑运算符,请参见jls&167;15.22。
- 西西布雷格!果然。这就是我作为一个C++的家伙来评论一个在"热点问题"部分出现的问题。:-)事实上,这可以解释为什么更新后的基准测试显示它的性能不如我预期的那样好。Java的编译器或运行时环境可能无法像按位操作那样高效地优化这一点。它可能仍在将其编译为一个分支。
- @HenningMakholm:我读到的时候,他建议(a|b) != 0作为a != 0 || b != 0和(a*b) != 0对a != 0 && b != 0的优化——前者很好,后者的风险是大于等于2&185;?.
- @Siegi:当然,JLS 15.22没有详细说明对a&b中b的延迟评估,这将决定是否使用分支(以及是否发生副作用)。但是如果你只是批评"按位"你是对的-除非我们接受一个布尔值有一个位!
- @不相容仅仅是因为用"fast"(顺便说一句,不正确,如在这个问题中)替换"slow"(慢)原始操作不会得到25%的提升。过去的好日子已经过去了,只要用位移位替换除法2,就可以显著提高计算速度。永远。为了提高现在的性能,你应该,不,不应该,你必须打开你的大脑,改进算法。或者,至少,选择正确的一个。我很遗憾地说,但我们的世界上再也没有魔法独角兽和仙女了。相反,我们有万亿字节的"棘手的优化"低质量代码。
- @用户882813"你不会得到25%的提升,仅仅用"快速"一个替换"缓慢"的原始操作-那么问者怎么会显示出确切的结果呢?
- 如果a和b是更大的数字呢?
我忽略了这样一个问题,即你的基准测试可能存在缺陷,并以表面价值衡量结果。
Is it the compiler or is it at the hardware level?
我认为后者:
将编译为2个内存加载和两个条件分支
将编译为2个内存加载,一个乘法和一个条件分支。
如果硬件级别的分支预测无效,则乘法可能比第二个条件分支更快。当你提高比率…分支预测越来越不有效。
条件分支变慢的原因是它们导致指令执行管道暂停。分支预测是通过预测分支将朝哪个方向发展,并根据预测来推测选择下一条指令,从而避免出现停顿。如果预测失败,则在加载另一个方向的指令时会有延迟。
(注:以上解释过于简单。要获得更准确的解释,您需要查看CPU制造商为汇编语言编码器和编译器编写器提供的文献。关于分支预测器的维基百科页面是很好的背景。)
但是,有一件事需要您在这个优化中小心。是否有任何值a * b != 0会给出错误的答案?考虑计算产品导致整数溢出的情况。
更新
你的图表倾向于证实我所说的。
更新2
我不明白为什么a + b != 0和a | b != 0的曲线不同。在分支预测器逻辑中可能有一些聪明的东西。或者它可以指示其他的东西。
(请注意,这种情况可能是特定于特定芯片型号或甚至版本的。您的基准测试的结果可能在其他系统上有所不同。)
但是,它们都具有为a和b的所有非负值工作的优势。
- 谢谢你的回答,这很有趣。为了解决您对基准测试的关注,我只迭代了一个任意的int变量("arb++")。我忽略了它,因为我认为只要我用同一个就没关系了。
- @如果我错了,请纠正我。对于(a != 0 && b != 0),您需要加载a,使用0执行beqz,存储该值;对于b,也是这样。所以,我们有2个LW,2个SW和2个分支。对于(a * b != 0),有两个lw用于a,b;一个sw用于存储a*b,然后一个beqz用于与0比较。所以,我们有2个LW,1个SW和1个分支。你认为这个推理是正确的吗?此外,在第二种情况下,只有一个可用的分支,因此分支预测器的概率总是趋向于1。
- @Debosmitray-1)不应存在软件。中间结果将保存在寄存器中。2)在第二种情况下,有两个可用的分支:一个是执行"某些代码",另一个是跳到if后面的下一个语句。
- @Stephenc感谢您指出错误。
- @Stephenc你对A+B和A_B感到困惑是对的,因为曲线是一样的,我认为是颜色非常接近。向色盲者道歉!
- @马尔贾姆——我的意思是——我很困惑,他们不同于埃多克斯一〔9〕案。我可没想到。也许在JIT编译器生成的本机代码中有一条线索。或者我们可能看到了一个基准产品。
- @由于a*b和a+b不同,因此结果可能不同。分支预测在这些方面不能工作相同。
- @NJZK2从概率的角度看,这些情况应按50%的轴对称(a&b和a|b的零概率)。他们是,但并不完美,这就是难题所在。
- @Stephenc a*b != 0和a+b != 0基准不同的原因在于a+b != 0根本不等同,不应该作为基准。例如,对于a = 1, b = 0,第一个表达式的计算结果为假,而第二个表达式的计算结果为真。乘法的作用类似于AND运算符,而加法的作用类似于OR运算符。
- @安东&237;恩勒谢克,我认为概率会有所不同。如果你有n零,那么a和b为零的可能性随着n的增加而增加。在AND操作中,当n越高,其中一个非零的概率就越大,满足条件。这与OR操作相反(其中一个为零的概率随n的增加而增加)。这是基于数学的观点。我不确定硬件是否就是这样工作的。
- 第一个图中的情况1和情况3有什么区别?
- 一件事是编译器在字节代码级别发出的指令,另一件事是Hotspot生成的机器代码实际执行的指令。
- 是的。看我答案的前几行。
我认为你的基准测试有一些缺陷,可能对推断真正的程序没有帮助。以下是我的想法:
对于溢出的值,(a*b)!=0会做错误的事情,而(a+b)!=0会另外对总和为零的正值和负值做错误的事情,因此在一般情况下,即使它们在这里工作,也不能使用这两个表达式中的任何一个。
(a|b)!=0和(a+b)!=0两个值中的任何一个值为非零时进行测试,而(a*b)!=0和a != 0 && b != 0两个值均为非零时进行测试。对于相同百分比的数据,这两种类型的条件将不是真的。
当fraction为0时,当分支几乎从未取下时,vm将在外部(fraction循环的前几次运行期间优化表达式。如果以0.5启动fraction,优化器可能会做不同的事情。
除非vm能够在这里消除一些数组边界检查,否则表达式中还有其他四个分支只是由于边界检查,这是一个复杂的因素,当试图弄清楚在低级别上发生了什么。如果将二维数组拆分为两个平面数组,将nums[0][i]和nums[1][i]更改为nums0[i]和nums1[i],可能会得到不同的结果。
CPU分支预测器试图检测数据中的短模式,或者检测正在执行或未执行的所有分支的运行情况。随机生成的基准数据对于分支预测器来说是最糟糕的事情。如果您的真实数据有一个可预测的模式,或者它有所有零值和所有非零值的长时间运行,那么分支的成本可能会低很多。
在满足条件后执行的特定代码会影响评估条件本身的性能,因为它会影响诸如循环是否可以展开、哪些CPU寄存器可用以及在评估条件后是否需要重用任何已获取的nums值之类的事情。仅仅增加基准中的计数器并不是真正代码所能做的完美的占位符。
System.currentTimeMillis()在大多数系统上的精度不超过+/-10 ms。System.nanoTime()通常更准确。
正如你所看到的,有很多不确定性,而且总是很难用这些微优化来确定任何事情,因为在一个虚拟机或CPU上速度更快的技巧在另一个虚拟机或CPU上速度更快。如果您的虚拟机是热点,请注意还有另外两种类型,与"服务器"虚拟机相比,"客户机"虚拟机具有不同(较弱)的优化。
如果您可以反汇编由VM生成的机器代码,那么就这样做,而不是试图猜测它做了什么!
- @灰色不,a*b可以溢出到0。随机例子:196608*327680是0,因为实际结果恰好可以被2*32整除,这意味着它的32位低位是0,如果是int操作,这些位就是您得到的全部。我关于a+b的观点是,它不只是测试算术的时间:由于条件更为真实,它会导致更多的if体的执行,这也需要更多的时间。
- 我不知道,波安。很好的例子。
这里的答案很好,尽管我有一个想法可以改善事情。
由于两个分支和关联的分支预测可能是罪魁祸首,因此我们可以在不改变逻辑的情况下将分支减少到单个分支。
1 2 3
| bool aNotZero = (nums[0][i] != 0);
bool bNotZero = (nums[1][i] != 0);
if (aNotZero && bNotZero) { /* Some code */ } |
这也可能有效
1 2 3
| int a = nums[0][i];
int b = nums[1][i];
if (a != 0 && b != 0) { /* Some code */ } |
原因是,根据短路规则,如果第一个布尔值为假,则不应计算第二个布尔值。如果nums[0][i]是假的,它必须执行额外的分支以避免评估nums[1][i]。现在,您可能不关心nums[1][i]的计算结果,但是编译器不能确定它不会在执行此操作时抛出超出范围或空引用。通过将if块减少为简单的bools,编译器可能足够聪明,从而认识到不必要地计算第二个布尔值不会产生负面的副作用。
- 虽然我觉得这并不能很好地回答这个问题。
- 这是一种引入分支而不改变非分支逻辑的方法(如果您获得a和b的方法有副作用,您就可以保留它们)。你还有一个&&,所以你还有一个分支。
当我们进行乘法时,即使一个数是0,那么积也是0。写作时
它评估产品的结果,从而消除从0开始的前几次迭代。结果,当条件为
其中每个元素都与0进行比较并进行计算。因此所需时间较少。但我相信第二个条件可能会给你更准确的答案。
- 在第二个表达式中,如果a为零,则不需要计算b,因为整个表达式已经是假的。所以每一个元素的比较都不是真的。
- 我的想法完全正确
您使用的是随机输入数据,这使得分支不可预测。实际上,分支通常是可预测的(约90%),因此在实际代码中,分支代码可能更快。
就是这么说的。我不知道a*b != 0比(a|b) != 0快多少。一般来说,整数乘法比按位或运算更昂贵。但像这样的事情有时会变得很奇怪。请参阅处理器缓存效果库中的"示例7:硬件复杂性"示例。
- &不是"位或",但(在本例中)是"逻辑与",因为两个操作数都是布尔值,而不是|;-)
- "SIGEI TIL Java"和"AMP"实际上是一个逻辑的,没有短路。