对长度使用"size_t"会影响编译器优化

Using `size_t` for lengths impacts on compiler optimizations?

本文关键字:quot 影响 编译器 优化 size      更新时间:2023-10-16

在阅读这个问题时,我看到了第一条评论:

size_t表示长度不是一个好主意,出于优化/UB的原因,正确的类型是带符号的。

后面跟着另一条支持推理的注释。这是真的吗?

这个问题很重要,因为如果我要写一个矩阵库,图像维度可以是size_t,只是为了避免检查它们是否为负数。但是,所有循环都将自然地使用size_t。这会影响优化吗?

size_t未签名大多是历史上的意外——如果你的世界是16位的,那么从32767到65535的最大对象大小是一个巨大的胜利;在当今的主流计算中(64位和32位是标准),size_t是无符号的这一事实在很大程度上是一个麻烦。

尽管无符号类型的未定义行为较少(因为包装是有保证的),但它们大多具有"位字段"语义这一事实往往会导致错误和其他不好的意外;特别是:

  • 无符号值之间的差异也是无符号的,具有通常的环绕语义,因此如果您可能期望负值,则必须事先强制转换;

    unsigned a = 10, b = 20;
    // prints UINT_MAX-10, i.e. 4294967286 if unsigned is 32 bit
    std::cout << a-b << "n"; 
    
  • 更一般地说,在有符号/无符号比较和数学运算中,无符号获胜(因此有符号值被隐式转换为无符号),这再次导致意外;

    unsigned a = 10;
    int b = -2;
    if(a < b) std::cout<<"a < bn"; // prints "a < b"
    
  • 在常见的情况下(例如向后迭代),无符号语义通常是有问题的,因为您希望边界条件的索引为负数

    // This works fine if T is signed, loops forever if T is unsigned
    for(T idx = c.size() - 1; idx >= 0; idx--) {
    // ...
    }
    

此外,无符号值不能假定负值的事实主要是strawman可能会避免检查负值,但由于隐式有符号无符号转换,它不会停止任何错误-您只是在推卸责任。如果用户使用size_t将负值传递给库函数,那么它将变成一个非常大的数字,即使不是更糟,也是错误的。

int sum_arr(int *arr, unsigned len) {
int ret = 0;
for(unsigned i = 0; i < len; ++i) {
ret += arr[i];
}
return ret;
}
// compiles successfully and overflows the array; it len was signed,
// it would just return 0
sum_arr(some_array, -10);

对于优化部分:有符号类型在这方面的优势被高估了;是的,编译器可以假设溢出永远不会发生,所以在某些情况下它可能会非常聪明,但通常情况下这不会改变游戏规则(因为在当前的架构中,一般来说,包装语义是"免费的");最重要的是,像往常一样,如果您的探查器发现某个特定区域是一个瓶颈,您可以只对其进行修改,使其运行得更快(如果您觉得有利,可以在本地切换类型,使编译器生成更好的代码)。

长话短说:我选择签名,不是出于性能原因,而是因为在大多数常见场景中,语义通常不会那么令人惊讶/充满敌意。

该注释完全错误。当在任何合理的体系结构上使用本机指针大小的操作数时,在机器级别上有符号和无符号偏移量之间没有差异,因此没有空间让它们具有不同的性能属性。

正如您所指出的,size_t的使用有一些不错的特性,比如不必考虑值可能为负数的可能性(尽管考虑它可能很简单,就像在接口契约中禁止它一样)。它还确保您可以使用大小/计数的标准类型处理调用方请求的任何大小,而无需截断或边界检查。另一方面,当偏移量可能需要为负时,它排除了对索引偏移量使用相同类型,并且在某些方面使执行某些类型的比较变得困难(您必须将它们以代数方式排列,以便双方都不是负的),但当使用有符号类型时也会出现相同的问题,因为您必须进行代数重排,以确保没有子表达式可以溢出。

最终,最初您应该始终使用语义上有意义的类型,而不是尝试为性能属性选择类型。只有当有一个严重的衡量性能问题,看起来可能会通过选择类型的权衡来改善时,你才应该考虑改变它们。

我支持我的评论。

有一种简单的方法可以检查:检查编译器生成的内容。

void test1(double* data, size_t size)
{
for(size_t i = 0; i < size; i += 4)
{
data[i] = 0;
data[i+1] = 1;
data[i+2] = 2;
data[i+3] = 3;
}
}
void test2(double* data, int size)
{
for(int i = 0; i < size; i += 4)
{
data[i] = 0;
data[i+1] = 1;
data[i+2] = 2;
data[i+3] = 3;
}
}

那么编译器会生成什么呢?我希望循环展开,SIMD。。。就这么简单:

让我们检查一下螺栓。

有符号的版本有展开SIMD,而不是无符号的版本。

我不打算展示任何基准测试,因为在这个例子中,瓶颈将是内存访问,而不是CPU计算。但你明白了。

第二个例子,只保留第一个赋值:

void test1(double* data, size_t size)
{
for(size_t i = 0; i < size; i += 4)
{
data[i] = 0;
}
}
void test2(double* data, int size)
{
for(int i = 0; i < size; i += 4)
{
data[i] = 0;
}
}

正如你想要的gcc

好吧,没有clang那么令人印象深刻,但它仍然会生成不同的代码。