【问题标题】:Why C compilers optimize switch and if differently为什么 C 编译器会优化 switch 以及是否不同
【发布时间】:2020-02-08 10:35:08
【问题描述】:

我最近在做一个个人项目时偶然发现了一个奇怪的问题。

在一个非常紧凑的循环中,我有一个值介于 0 和 15 之间的整数。对于值 0、1、8 和 9,我需要得到 -1,对于值 4、5、12 和 13,我需要得到 1。

我求助于 Godbolt 检查了几个选项,并惊讶于编译器似乎无法像 if 链一样优化 switch 语句。

链接在这里:https://godbolt.org/z/WYVBFl

代码是:

const int lookup[16] = {-1, -1, 0, 0, 1, 1, 0, 0, -1, -1, 0, 0, 1, 1, 0, 0};

int a(int num) {
    return lookup[num & 0xF];
}

int b(int num) {
    num &= 0xF;

    if (num == 0 || num == 1 || num == 8 || num == 9) 
        return -1;

    if (num == 4 || num == 5 || num == 12 || num == 13)
        return 1;

    return 0;
}

int c(int num) {
    num &= 0xF;
    switch (num) {
        case 0: case 1: case 8: case 9: 
            return -1;
        case 4: case 5: case 12: case 13:
            return 1;
        default:
            return 0;
    }
}

我原以为 b 和 c 会产生相同的结果,并且我希望我可以阅读 bit-hacks 以自己提出一个有效的实现,因为我的解决方案(switch 语句 - 以另一种形式)是相当慢。

奇怪的是,b 被编译为 bit-hack,而 c 要么几乎没有优化,要么根据目标硬件减少为 a 的不同情况。

谁能解释为什么会出现这种差异?优化此查询的“正确”方法是什么?

编辑:

澄清

希望切换解决方案是最快的,或者类似的“干净”解决方案。但是,当在我的机器上进行优化编译时,if 解决方案明显更快。

我编写了一个快速程序来演示,TIO 的结果与我在本地找到的结果相同:Try it online!

使用static inline,查找表会加快一点:Try it online!

【问题讨论】:

  • 我怀疑答案是“编译器并不总是做出明智的选择”。我刚刚用 GCC 8.3.0 用-O3 将你的代码编译成一个对象,它把c 编译成可能比ab 更糟糕的东西(c 有两个条件跳转加上一些位操作,与b 的只有一个条件跳转和更简单的位操作相比),但仍然比天真的逐项测试要好。我不确定您在这里真正要的是什么;一个简单的事实是,优化编译器可以将其中的 any 转换为其他的 any ,如果它愿意的话,并且对于它会做什么没有硬性规定或不会的。
  • 我的问题是我需要它快速,但 if 解决方案不是过度可维护的。有没有办法让编译器充分优化更清洁的解决方案?谁能解释为什么在这种情况下它不能这样做?
  • 我会首先将至少函数定义为静态的,或者——甚至更好地——内联它们。
  • @wildplasser 确实加快了速度,但if 仍然胜过switch(奇怪的是查找变得更快)[TIO 关注]
  • @LambdaBeta 无法告诉编译器以特定方式进行优化。您会注意到 clang 和 msvc 为它们生成完全不同的代码。如果您不在乎,只想在 gcc 上最有效,那么选择它。编译器优化是基于启发式的,并且在所有情况下都不会产生最佳解决方案;他们试图在一般情况下做到最好,而不是在所有情况下都做到最佳。

标签: c gcc optimization bit-manipulation disassembly


【解决方案1】:

您可以只使用算术来创建相同的效果:

// produces : -1 -1 0 0 1 1 0 0 -1 -1 0 0 1 1 0 0 ...
int foo ( int x )
{
    return 1 - ( 3 & ( 0x46 >> ( x & 6 ) ) );
}

尽管从技术上讲,这仍然是(按位)查找。

如果上面看起来太神秘,你也可以这样做:

int foo ( int x )
{
    int const y = x & 6;
    return (y == 4) - !y;
}

【讨论】:

    【解决方案2】:

    如果你明确列举所有的情况,gcc 是非常有效的:

    int c(int num) {
        num &= 0xF;
        switch (num) {
            case 0: case 1: case 8: case 9: 
                return -1;
            case 4: case 5: case 12: case 13:
                return 1;
                case 2: case 3: case 6: case 7: case 10: case 11: case 14: case 15: 
            //default:
                return 0;
        }
    }
    

    只是在一个简单的索引分支中编译:

    c:
            and     edi, 15
            jmp     [QWORD PTR .L10[0+rdi*8]]
    .L10:
            .quad   .L12
            .quad   .L12
            .quad   .L9
            .quad   .L9
            .quad   .L11
            .quad   .L11
            .quad   .L9
            .quad   .L9
            .quad   .L12
    etc...
    

    请注意,如果 default: 未注释,gcc 将返回其嵌套分支版本。

    【讨论】:

    • @LambdaBeta 您应该考虑不接受我的答案并接受这个答案,因为现代英特尔 CPU 可以执行两次并行索引内存读取/周期,而我的技巧的吞吐量可能是 1 次查找/周期。另一方面,也许我的 hack 更适合使用 SSE2 pslld/psrad 或它们的 8 路 AVX2 等效项的 4 路矢量化。很大程度上取决于您的代码的其他特殊性。
    【解决方案3】:

    以下代码将在约 3 个时钟周期、约 4 条有用指令和约 13 字节高度-inline-able x86 机器代码内计算您的查找无分支、无 LUT。

    这取决于 2 的补码整数表示。

    但是,您必须确保 u32s32 类型定义确实指向 32 位无符号和有符号整数类型。 stdint.h 类型 uint32_tint32_t 本来是合适的,但我不知道标题是否可供您使用。

    const int lookup[16] = {-1, -1, 0, 0, 1, 1, 0, 0, -1, -1, 0, 0, 1, 1, 0, 0};
    
    int a(int num) {
        return lookup[num & 0xF];
    }
    
    
    int d(int num){
        typedef unsigned int u32;
        typedef signed   int s32;
    
        // const int lookup[16]     = {-1, -1, 0, 0, 1, 1, 0, 0, -1, -1, 0, 0, 1, 1, 0, 0};
        // 2-bit signed 2's complement: 11 11 00 00 01 01 00 00 11 11 00 00 01 01 00 00
        // Hexadecimal:                   F     0     5     0     F     0     5     0
        const u32 K = 0xF050F050U;
    
        return (s32)(K<<(num+num)) >> 30;
    }
    
    int main(void){
        for(int i=0;i<16;i++){
            if(a(i) != d(i)){
                return !0;
            }
        }
        return 0;
    }
    

    在这里亲自查看:https://godbolt.org/z/AcJWWf


    关于常数的选择

    您的查找对象是介于 -1 和 +1 之间的 16 个非常小的常量。每个都适合 2 位,其中有 16 个,我们可以如下布局:

    // const int lookup[16]     = {-1, -1, 0, 0, 1, 1, 0, 0, -1, -1, 0, 0, 1, 1, 0, 0};
    // 2-bit signed 2's complement: 11 11 00 00 01 01 00 00 11 11 00 00 01 01 00 00
    // Hexadecimal:                   F     0     5     0     F     0     5     0
    u32 K = 0xF050F050U;
    

    通过将它们与最接近最高有效位的索引 0 一起放置,2*num 的单次移位会将您的 2 位数的符号位放入寄存器的符号位。将 2 位数右移 32-2=30 位,将其符号扩展为完整的 int,完成了这个技巧。

    【讨论】:

    • 这可能是最简洁的方法,使用 magic 注释解释如何重新生成它。你能解释一下你是怎么想出来的吗?
    • 接受,因为这可以“干净”,同时也很快。 (通过一些预处理器魔法:) xkcd.com/541 >)
    • 击败我的无分支尝试:!!(12336 &amp; (1&lt;&lt;x))-!!(771 &amp; (1&lt;&lt;x));
    【解决方案4】:

    C 编译器对switch 有特殊情况,因为他们希望程序员理解switch 的习语并加以利用。

    代码如下:

    if (num == 0 || num == 1 || num == 8 || num == 9) 
        return -1;
    
    if (num == 4 || num == 5 || num == 12 || num == 13)
        return 1;
    

    不会通过合格的 C 编码人员的审查;三四个审稿人会同时惊呼“这应该是switch!”

    C 编译器不值得分析if 语句的结构以转换为跳转表。条件必须恰到好处,并且在一堆if 语句中可能出现的变化量是天文数字。分析既复杂又并且可能得出否定的结果(如:“不,我们不能将这些 ifs 转换为 switch”)。

    【讨论】:

    • 我知道,这就是我开始使用交换机的原因。但是,在我的情况下, if 解决方案要快得多。我基本上是在询问是否有办法说服编译器为开关使用更好的解决方案,因为它能够在 ifs 中找到模式,但在开关中找不到。 (我特别不喜欢 ifs,因为它们不那么清晰或可维护)
    • 赞成但不被接受,因为这种情绪正是我提出这个问题的原因。我使用这个开关,但在我的情况下它太慢了,我想尽可能避免使用if
    • @LambdaBeta:有什么理由避免使用查找表吗?如果你想让它更清楚你分配的内容,让它staticuse C99 designated initializers,它显然非常好。
    • 我会开始至少丢弃低位,这样优化器要做的工作就更少了。
    • @ShadowRanger 不幸的是,这仍然比if 慢(见编辑)。 @R ..我为编译器制定了完整的按位解决方案,这就是我现在正在使用的。不幸的是,在我的情况下,这些是enum 值,而不是裸整数,所以按位黑客不是很容易维护。
    猜你喜欢
    • 2017-09-03
    • 2011-12-13
    • 2020-07-24
    • 1970-01-01
    • 2015-06-16
    • 1970-01-01
    • 2016-04-18
    • 1970-01-01
    • 2015-09-15
    相关资源
    最近更新 更多