为什么C编译器会优化开关,以及是否有所不同


9

当我偶然发现一个奇怪的问题时,我正在从事一个个人项目。

在一个非常紧密的循环中,我有一个整数,其值在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会产生相同的结果,并且我希望我自己可以阅读比特技巧以提出有效的实现,因为我的解决方案(switch语句-另一种形式)相当慢。

奇怪的是,b编译为位hack的同时c几乎没有进行优化,或者简化为a依赖目标硬件的情况。

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

编辑:

澄清度

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

我编写了一个快速程序来演示,并且TIO的结果与我在本地发现的结果相同:在线尝试!

通过static inline查找表可以加快速度:在线尝试!


4
我怀疑答案是“编译器并不总是做出明智的选择”。我只是使用GCC 8.3.0将您的代码编译为一个对象-O3,并且将其编译c为可能比a或更糟糕的东西bc有两次条件跳转加上一些操作,而对于一次条件跳转只进行了更简单的操作b),但是仍然比幼稚的逐项测试要好。我不确定您在这里真正要的是什么。一个简单的事实是,一个优化的编译器可以将任何这些到任何其他的,如果它选择这样做,有什么会或不会做任何硬性规定。
ShadowRanger

我的问题是我需要快速,但是if解决方案不能过分维护。有什么方法可以使编译器充分优化更清洁的解决方案?有人可以解释为什么在这种情况下不能这样做吗?
LambdaBeta

我将从至少将功能定义为静态功能,或者甚至更好地对其进行内联开始。
wildplasser

@wildplasser确实加快了速度,但if仍然跳动switch(奇怪地,查找变得更快)[TIO跟随]
LambdaBeta

@LambdaBeta无法告诉编译器以特定方式进行优化。您会注意到clang和msvc为这些生成完全不同的代码。如果您不在乎,只是想在gcc上最好用,那就选择它。编译器优化基于启发式算法,并且这些优化方法并非在所有情况下都产生最佳解决方案。他们试图在一般情况下保持良好,而不是在所有情况下都达到最佳。
立方

Answers:


6

如果您明确列举所有情况,那么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将返回其嵌套分支版本。


1
@LambdaBeta您应该考虑不接受我的回答,而是接受这个回答,因为现代的Intel CPU可以执行两个并行索引的内存读取/周期,而我的技巧的吞吐量可能是1个查询/周期。在另一方面,也许我的技巧更适合使用SSE2 pslld/ psrad或其等效8路AVX2 进行4路矢量化。在很大程度上取决于代码的其他特殊性。
Iwillnotexist Idonotexist,

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中找到模式,但不能在开关中找到模式。(我不喜欢
if,

有人提出支持,但没有被接受,因为这正是我提出这个问题的原因。我使用该开关,但是对于我来说,它太慢了,我想避免这种if情况。
LambdaBeta

@LambdaBeta:是否有一些避免查找表的理由?使其static,如果要使其更清楚分配的内容,请使用C99指定的初始值设定项,并且显然非常好。
ShadowRanger

1
我至少会丢弃低位,这样优化器要做的工作就更少了。
R .. GitHub停止帮助ICE,

@ShadowRanger不幸的是,它仍然比慢if(请参阅编辑)。@R ..我为编译器制定了完整的按位解决方案,这就是我现在使用的解决方案。不幸的是,在我的情况下,这些是enum值,而不是裸整数,因此按位编程不太容易维护。
LambdaBeta

4

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

它取决于2的补码整数表示形式。

但是,您必须确保u32s32 typedefs确实指向32位无符号和有符号整数类型。stdint.h类型,uint32_t并且int32_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解释如何重新生成它的注释。你能解释一下你是怎么想到的吗?
LambdaBeta

可以接受,因为它可以变得“干净”,同时速度也很快。(通过一些预处理器魔术:) < xkcd.com/541 >)
LambdaBeta

1
击败了我的无!!(12336 & (1<<x))-!!(771 & (1<<x));
分支

0

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

// 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;
}
By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.