使用`size_t`长度会影响编译器优化吗?

时间:2019-01-17 20:13:27

标签: c++ compiler-optimization

在阅读this question时,我看到了第一个评论:

  

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

之后是另一条支持推理的评论。是真的吗

这个问题很重要,因为如果我要写例如在矩阵库中,图像尺寸可以为size_t,只是为了避免检查它们是否为负数。但是,所有循环自然会使用size_t。这会对优化产生影响吗?

3 个答案:

答案 0 :(得分:6)

size_t被取消签名通常是一个历史性的意外-如果您的世界是16位,则将最大对象大小从32767增大到65535是一个大胜利;在当今的主流计算(标准为64位和32位)中,size_t是无符号的这一事实在很大程度上是很麻烦的。

尽管无符号类型具有 less 不确定的行为(因为保证了环绕),但实际上它们大多具有“位域”语义,这通常是导致错误和其他不良后果的原因。特别是:

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

    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 < b\n"; // 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--) {
        // ...
    }
    

此外,无符号值不能假定负值的事实主要是稻草人。 可以避免检查负值,但是由于隐式的有符号-无符号转换,它不会停止任何错误-您只是在怪罪。如果用户使用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);

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

长话短说:我会选择签名而不是出于性能方面的考虑,而是因为在大多数常见情况下语义通常都不那么令人惊讶/敌意。

答案 1 :(得分:2)

该评论完全是错误的。在任何合理的体系结构上使用本机指针大小的操作数时,有符号偏移量和无符号偏移量在计算机级别上没有区别,因此它们没有空间具有不同的性能属性。

您已经注意到,size_t的使用具有一些不错的特性,例如不必考虑某个值可能为负的可能性(尽管考虑它可能很简单,就像在接口合同中禁止该值一样简单) )。它还确保您可以使用标准类型的大小/计数来处理呼叫者请求的任何大小,而无需截断或边界检查。另一方面,当偏移量可能需要为负数时,它会排除使用相同类型的索引偏移量,并且在某些方面使其难以执行某些类型的比较(您必须将它们进行代数编写,以使任何一方都不是否定的),但是在使用带符号类型时也会出现相同的问题,因为必须进行代数重排以确保没有子表达式可以溢出。

最终,您一开始应该始终使用对您而言在语义上有意义的类型,而不是尝试为性能属性选择一种类型。只有在出现严重的,可衡量的性能问题,并且看起来可以通过涉及类型选择的折衷方案来改善此问题时,才考虑更改它们。

答案 2 :(得分:1)

我坚持我的评论。

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

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 ...这么简单:

Let's check godbolt.

好吧,已签名的版本具有展开的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;
    }
}

As you want gcc

好的,虽然不像clang那样令人印象深刻,但是它仍然生成不同的代码。