C语言中if-else和三目运算符的速度差异是多少?

14

因为同事的建议,我刚刚测试了三元运算符和相等的If-Else块之间的速度差异...结果显示,三元运算符生成的代码比If-Else快1倍到2倍。我的代码如下:

  gettimeofday(&tv3, 0);
  for(i = 0; i < N; i++)
  {
     a = i & 1;
     if(a) a = b; else a = c;
  }
  gettimeofday(&tv4, 0);


  gettimeofday(&tv1, 0);
  for(i = 0; i < N; i++)
  {
     a = i & 1;
     a = a ? b : c;
  }
  gettimeofday(&tv2, 0);

(很抱歉我使用了gettimeofday而不是clock_gettime……我会努力改进自己。)

我尝试更改计时块的顺序,但结果似乎仍然存在。这是怎么回事?另外,If-Else在执行速度方面显示出更大的变异性。我应该检查gcc生成的汇编代码吗?

顺便说一下,这全部都是在优化级别为零(-O0)下进行的。

这是我的错觉,还是有些东西我没有考虑到,或者这是与机器有关的问题,还是其他什么原因?欢迎任何帮助。


14
关闭优化的基准测试毫无意义。 - Evan Teran
1
顺便提一下,这全部都是在优化级别零(-O0)下完成的。这意味着你已经告诉编译器不要进行优化(基本上是这样),因此生成的代码更冗长(因此更慢)。如果你甚至对其使用 -O1,我怀疑你根本看不出任何差异。在未经优化的代码中看到性能差异没有太大意义。 - T.J. Crowder
1
注意:?是条件运算符,是三元运算符之一,而不是唯一的三元运算符。 - Sani Huttunen
2
@Sani:这是C++中唯一的三元运算符,因此它是“THE ternary operator”。 - Steve Jessop
1
@Sani:是啊,未来美国可能会采用多任总统制度,但在那之前,奥巴马(或其他现任总统)就是“总统”,而布什则是“一位总统”。这真是个有趣的老语言 ;-p - Steve Jessop
显示剩余6条评论
6个回答

25

有很大的可能性,三目运算符会被编译成 cmov,而 if/else 语句则会被编译成 cmp+jmp。建议查看汇编代码(使用 -S),以确保。启用优化后,无论哪种情况下,任何好的编译器都应该生成相同的代码。


14
在今天的CPU上,条件移动具有固定的延迟时间,而一个良好预测的条件分支基本上是免费的。因此,你可能不会看到优化编译器像你想象的那样频繁生成CMOV - Cory Nelson

14

你也可以完全不使用分支语句,然后测量是否有任何差异:

int m = -(i & 1);
a = (b & m) | (c & ~m);

在当前的架构下,这种编程风格已经有些过时了。


3
当你有一个循环条件需要运行大量次数(比如十亿次),它仍然是有用的。 - sumodds

11

4
如果有的话,更换你的编译器吧!对于这种问题,我使用Try Out LLVM页面进行测试。这是LLVM的旧版本(仍然使用gcc前端),但这些都是老套路。以下是我的小样本程序(你的简化版本):
#include <stdio.h>
#include <stdlib.h>
#include <sys/time.h>

int main (int argc, char* argv[]) {
  int N = atoi(argv[0]);

  int a = 0, d = 0, b = atoi(argv[1]), c = atoi(argv[2]);

  int i;
  for(i = 0; i < N; i++)
  {
     a = i & 1;
     if(a) a = b+i; else a = c+i;
  }

  for(i = 0; i < N; i++)
  {
     d = i & 1;
     d = d ? b+i : c+i;
  }

  printf("%d %d", a, d);

  return 0;
}

以下是对应的LLVM IR代码生成:

define i32 @main(i32 %argc, i8** nocapture %argv) nounwind {
entry:
  %0 = load i8** %argv, align 8                   ; <i8*> [#uses=1]
  %N = tail call i32 @atoi(i8* %0) nounwind readonly ; <i32> [#uses=5]

  %2 = getelementptr inbounds i8** %argv, i64 1   ; <i8**> [#uses=1]
  %3 = load i8** %2, align 8                      ; <i8*> [#uses=1]
  %b = tail call i32 @atoi(i8* %3) nounwind readonly ; <i32> [#uses=2]

  %5 = getelementptr inbounds i8** %argv, i64 2   ; <i8**> [#uses=1]
  %6 = load i8** %5, align 8                      ; <i8*> [#uses=1]
  %c = tail call i32 @atoi(i8* %6) nounwind readonly ; <i32> [#uses=2]

  %8 = icmp sgt i32 %N, 0                         ; <i1> [#uses=2]
  br i1 %8, label %bb, label %bb11

bb:                                               ; preds = %bb, %entry
  %9 = phi i32 [ %10, %bb ], [ 0, %entry ]        ; <i32> [#uses=2]
  %10 = add nsw i32 %9, 1                         ; <i32> [#uses=2]
  %exitcond22 = icmp eq i32 %10, %N               ; <i1> [#uses=1]
  br i1 %exitcond22, label %bb10.preheader, label %bb

bb10.preheader:                                   ; preds = %bb
  %11 = and i32 %9, 1                             ; <i32> [#uses=1]
  %12 = icmp eq i32 %11, 0                        ; <i1> [#uses=1]
  %.pn13 = select i1 %12, i32 %c, i32 %b          ; <i32> [#uses=1]
  %tmp21 = add i32 %N, -1                         ; <i32> [#uses=1]
  %a.1 = add i32 %.pn13, %tmp21                   ; <i32> [#uses=2]
  br i1 %8, label %bb6, label %bb11

bb6:                                              ; preds = %bb6, %bb10.preheader
  %13 = phi i32 [ %14, %bb6 ], [ 0, %bb10.preheader ] ; <i32> [#uses=2]
  %14 = add nsw i32 %13, 1                        ; <i32> [#uses=2]
  %exitcond = icmp eq i32 %14, %N                 ; <i1> [#uses=1]
  br i1 %exitcond, label %bb10.bb11_crit_edge, label %bb6

bb10.bb11_crit_edge:                              ; preds = %bb6
  %15 = and i32 %13, 1                            ; <i32> [#uses=1]
  %16 = icmp eq i32 %15, 0                        ; <i1> [#uses=1]
  %.pn = select i1 %16, i32 %c, i32 %b            ; <i32> [#uses=1]
  %tmp = add i32 %N, -1                           ; <i32> [#uses=1]
  %d.1 = add i32 %.pn, %tmp                       ; <i32> [#uses=1]
  br label %bb11

bb11:                                             ; preds = %bb10.bb11_crit_edge, %bb10.preheader, %entry
  %a.0 = phi i32 [ %a.1, %bb10.bb11_crit_edge ], [ %a.1, %bb10.preheader ], [ 0, %entry ] ; <i32> [#uses=1]
  %d.0 = phi i32 [ %d.1, %bb10.bb11_crit_edge ], [ 0, %bb10.preheader ], [ 0, %entry ] ; <i32> [#uses=1]
  %17 = tail call i32 (i8*, ...)* @printf(i8* noalias getelementptr inbounds ([6 x i8]* @.str, i64 0, i64 0), i32 %a.0, i32 %d.0) nounwind ; <i32> [#uses=0]
  ret i32 0
}

好的,所以很可能是中文,尽管我已经重命名了一些变量以使其更易于阅读。

重要的部分是这两个块:

  %.pn13 = select i1 %12, i32 %c, i32 %b          ; <i32> [#uses=1]
  %tmp21 = add i32 %N, -1                         ; <i32> [#uses=1]
  %a.1 = add i32 %.pn13, %tmp21                   ; <i32> [#uses=2]

  %.pn = select i1 %16, i32 %c, i32 %b            ; <i32> [#uses=1]
  %tmp = add i32 %N, -1                           ; <i32> [#uses=1]
  %d.1 = add i32 %.pn, %tmp                       ; <i32> [#uses=1]

分别设置了ad

结论是:没有区别

注意:在一个更简单的例子中,这两个变量实际上被合并了,但似乎优化器没有检测到相似性...


0

要理解的是,编译器如何解释三元表达式完全取决于它自己(除非你使用(内联)汇编强制不这样做)。它可以将三元表达式视为“if..else”语句在其内部表示语言中同样容易理解,并且根据目标后端,它可能选择生成条件移动指令(在x86上,CMOVcc就是这样一个指令。还应该有用于min/max、abs等的指令)。使用条件移动的主要动机是将分支错误预测的风险转移到内存/寄存器移动操作上。这个指令的注意事项是,几乎所有时间,将被条件加载的操作数寄存器都必须评估到寄存器形式,以利用cmov指令。

这意味着无条件的评估过程现在必须是无条件的,这似乎会增加程序的无条件路径长度。但要理解的是,分支错误预测通常被解决为“刷新”流水线,这意味着已经完成执行的指令将被忽略(变成无操作指令)。这意味着实际执行的指令数量更高,因为停顿或NOP的效果与处理器流水线的深度和错误预测率成比例。

这在确定正确的启发式算法时带来了一个有趣的困境。首先,我们可以确定,如果管道太浅或分支预测能够完全从分支历史中学习模式,则 cmov 是不值得做的。如果条件参数的评估成本比平均误判的成本更高,则也不值得执行。

这些可能是编译器难以利用 cmov 指令的核心原因,因为启发式算法的确定在很大程度上取决于运行时分析信息。在 JIT 编译器上使用它更有意义,因为它可以提供运行时仪表反馈并建立更强的启发式算法来使用它(“分支是否真的不可预测?”)。在静态编译器方面,如果没有训练数据或分析器,很难假设何时会有用。然而,一个简单的负启发式是,如前所述,如果编译器知道数据集是完全随机的或者将条件强制转换为无条件评估是昂贵的(可能是由于不可简化的昂贵操作,如 fp 除法),那么最好的启发式算法就是不要这样做。

任何值得一试的编译器都会这样做。问题是,在使用所有可靠的启发式算法之后,它会做什么...


0
任何一个体面的编译器,如果开启了优化,应该会为这些代码生成相同的指令。

1
在我看来,这应该也是一条注释,考虑到前两个真正的答案。 - Armen Tsirunyan
你说得没错,但是... 其中一个回答问题很认真,但没有提到这只是在 -O0 下才会出现的问题。编译器在优化方面做得非常好,人们不应该进行微观优化。完结。 - Karoly Horvath

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