严格别名在C中是否仍然值得考虑?

1aaf6o9v  于 2023-05-16  发布在  其他
关注(0)|答案(3)|浏览(129)

我最近读了一个well-known article by Mike Acton about strict aliasing,以及我们应该如何使用它来显著提高C代码的性能。
在某些情况下,如果你告诉编译器不会有两种方式访问你的数据,那么编译器可以更好地优化代码,这似乎很简单。然而,为了了解这个主题并理解其微妙之处,我使用了godbolt...
事实证明,下面的代码完全符合我们从gcc 4. 7开始的直观期望。如果我错了请告诉我,但直到那个版本,它似乎并没有改变任何添加-fstrict-aliasing或不使用-O3。

uint32_t
test(uint32_t arg)
{
  char*     const cp = (char*)&arg;
  uint16_t* const sp = (uint16_t*)cp;

  sp[0] = 0x1;
  sp[1] = 0x1;

  return (arg);
}

这是我提到的文章中的一个例子。在文章中,解释了gcc将cpsp视为两个不同的对象,这是由于严格的别名规则。所以,它只是保持arg不变。如果我指的是godbolt的话,这就是gcc的旧版本中发生的事情。但现在不是了gcc在它的第四个版本中改变了严格的别名规则吗?有没有在某个地方描述过?还是我错了
我还检查了下面的代码,同样,严格别名与否,它不会影响结果。即使使用restrict关键字。我希望能正确理解这意味着什么。

void my_loop(int *n, int x)
{
    while (--x)
        printf("%d", *n);
}

从这段代码中,我希望看到编译器加载n一次,并在每次迭代中使用该值。相反,我注意到n在每次打印时都被取消引用。我错过什么了吗

e3bfsja2

e3bfsja21#

如果我指的是godbolt的话,这就是gcc的旧版本中发生的事情。但现在不是了gcc在它的第四个版本中改变了一些关于严格别名规则的东西吗?有没有在某个地方描述过?还是我错了
不,什么都没变。这是一种未定义的行为(UB),编译器没有义务以特定的方式运行。这正是你所观察到的。
您可以实现相同级别的优化,而无需使用指针双关和调用未定义的行为:

uint32_t test1(uint32_t arg)
{
    union 
    {
        uint32_t arg;
        uint16_t arg2[2];
    }c = {.arg = arg};

    c.arg2[0] = 0x1;
    c.arg2[1] = 0x1;
    return (c.arg);
}

uint32_t test2(uint32_t arg)
{
    unsigned char *ptr = &arg;
    memcpy(ptr, (uint16_t[]){1}, sizeof(uint16_t));
    memcpy(ptr + 2, (uint16_t[]){1}, sizeof(uint16_t));
    return arg;
}

https://godbolt.org/z/nM3rEKocr
第二个示例是一个有效的C代码。

mwkjh3gx

mwkjh3gx2#

gcc在它的第四个版本中改变了一些关于严格别名规则的东西吗?
重要的是要理解严格的别名规则是C语言规范(每个版本)的规定,而不是GCC或其他编译器。取决于编译器的部分是它们对此做了什么--特别是,它们是否进行了优化,这些优化对符合要求的程序是安全的,但对不符合要求的程序不一定是安全的。
有没有在某个地方描述过?
特别行政区或海湾合作委员会对它的行为?
SAR的C23版本是该规范的第6.5/7段:
对象的存储值只能由具有以下类型之一的左值表达式访问:

  • 与对象的有效类型兼容的类型,
  • 与对象的有效类型兼容的类型的限定版本,
  • 是与对象的有效类型相对应的有符号或无符号类型的类型,
  • 作为与对象的有效类型的限定版本相对应的有符号或无符号类型的类型,
  • 在其成员中包括上述类型之一的聚合或联合类型(递归地包括子聚合或包含联合的成员),或者
  • 字符类型。

GCC在这方面的优化细节并没有记录在它的手册中。有一个主开关-f [no-] strict-aliasing控制着它们,但the manual对这些效果含糊其辞:
允许编译器采用适用于正在编译的语言的最严格的别名规则。对于C(和C++),这将根据表达式的类型激活优化。特别地,一种类型的对象被假定永远不会与不同类型的对象驻留在相同的地址,除非类型几乎相同。例如,unsigned int可以别名为int,但不能别名为void*double。字符类型可以别名任何其他类型。
然而,关于
添加-fstrict-aliasing或不添加-O3似乎没有任何变化。
......不,它不会,因为手册还规定:
-fstrict-aliasing选项在-O2-O3-Os级别启用。
如果您在-O3级别进行优化,则添加-fstrict-aliasing没有任何附加意义。但是,通过-fno-strict-aliasing关闭严格混叠分析,您可能会看到不同之处。也可能不会。编译器没有义务在任何特定情况下执行任何特定的优化。
我还检查了下面的代码,同样,严格别名与否,它不会影响结果。即使使用restrict关键字,我也希望正确理解它的含义。

void my_loop(int *n, int x)
{
    while (--x)
        printf("%d", *n);
}

从这段代码中,我希望看到编译器加载n一次,并在每次迭代中使用该值。相反,我注意到每次打印时n都被取消引用。我错过什么了吗?
别名分析与该特定代码无关。假设n是一个有效的指针,它不能在函数入口指向x,因为x是函数的本地指针。我不能解释GCC为什么不执行您所寻找的优化,但也许它试图适应*n被另一个线程修改的可能性。

oogrdqng

oogrdqng3#

严格别名在C中是否仍然值得考虑?
是的。
事实证明,下面的代码完全符合我们从gcc 4. 7开始的直观期望。
我们希望编写我们希望“保证”正确工作的程序。
关于“保证”的事情是,你不能通过给出一个这样的例子来证明所有可能的具有严格别名冲突的无限程序都将正常运行。所呈现的程序“如您所期望的那样工作”,并不能证明所有具有严格别名冲突的可能程序都如您所期望的那样工作。
令人高兴的是(对于我写这个答案),为了反驳相反的说法,我只需要给予一个反例,一个有严格别名的程序不像预期的那样运行。网络上充满了这样的东西。
修改你的代码,所以稍微导致一个程序退出0退出状态与-O0,但与1退出状态与-O2

#include <stdint.h>
uint32_t test(uint32_t *arg) {
  char*     const cp = (char*)arg;
  uint16_t* const sp = (uint16_t*)cp;
  arg[0] = 1;
  sp[0] = 0;
  sp[1] = 0;
  return arg[0];
}
int main() {
    uint32_t arg;
    return test(&arg);
}

gcc在它的第四个版本中改变了严格的别名规则吗?有没有在某个地方描述过?还是我错了
即使它做到了,对具有未定义行为的程序的行为进行推理也是没有实际意义的。行为是未定义的,你观察到的行为变化可能是完全不相关的。GCC不必测试或关心无效的程序。

相关问题