【问题标题】:Using `size_t` for lengths impacts on compiler optimizations?使用“size_t”长度对编译器优化有影响吗?
【发布时间】:2019-01-17 20:13:27
【问题描述】:

在阅读this question 时,我看到第一条评论说:

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

随后是另一条支持该推理的评论。这是真的吗?

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

【问题讨论】:

  • 许多人,包括 branje stroustrup 都认为使用无符号大小是错误的。我碰巧同意这一点,我喜欢使用ptrdiff_t 作为容器大小。
  • 您可以为您的尺码类型命名一个别名(例如using my_size_type = std::size_t;)。这使您可以轻松更改使用的类型。然后,您可以衡量两者的性能。请注意不要在代码中对my_size_type 做出假设。
  • 当相关优化未能发挥作用时(根据我的经验)通常可以忽略不计。我同意@SergeyA 的观点,因为我通常使用与我的class 最可能与之交互的任何类型。尽管其他人可能有不同的经历。
  • 你可以从this question获得很多背景和相关信息。

标签: c++ compiler-optimization


【解决方案1】:

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);

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

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

【讨论】:

  • 可以的。编译器将使用有符号整数展开循环,而不是无符号整数,生成 SIMD。这是一个很大的区别。这并没有被高估。
  • 使用-Wsign-conversion(强烈推荐),sum_arr(some_array, -10);等代码会产生警告。我认为隐式有符号/无符号转换很糟糕,所以我总是启用这个警告。每当我想要有符号/无符号转换时,我都会使用显式转换。
  • @MatthieuBrucher 我不会这么说gcc.godbolt.org/z/jw4qIT 展开和 SIMDed 一样。如果你的编译器不好,那不是unsigned 的错。
  • @MatthieuBrucher 您的评论似乎基于单个编译器(Clang),该编译器以对无符号数字的敌意而闻名。下面您自己的示例显示了与 gcc 和 icc 相同的代码生成。
【解决方案2】:

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

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

最终,您最初应该始终使用在语义上对您有意义的类型,而不是尝试为性能属性选择类型。只有当存在严重的测量性能问题并且看起来可以通过涉及类型选择的权衡来改善时,您才应该考虑更改它们。

【讨论】:

  • unsigned 整数具有额外的保证,并且导致未定义行为的原因更少。这可能会妨碍编译器。使用int,编译器可以假设它永远不会溢出并表现得好像它永远持续下去,因为如果它确实溢出你有UB并且任何发生的事情都是允许的。对于unsigned 整数,编译器必须确保保留环绕行为。
  • 这个答案是完全错误的。你检查过生成的代码是什么样子的吗?机器级别没有区别,C++ 级别有所不同,因此编译器的行为方式也有所不同。
  • @FrançoisAndrieux:这是真的,但在 OP 的案例中没有任何实际意义。
  • 另见 Matteo 的回答,该回答同意性能在这里不是一个重要问题。
【解决方案3】:

我坚持我的意见。

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

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那么令人印象深刻,但它仍然会生成不同的代码。

【讨论】:

  • gcc 和 icc 都生成相同的(至少在观察时)代码。 CLang 选择不同的代码生成这一事实告诉我们关于 CLang,未签名与未签名。
  • 众所周知,ICC 非常激进,有时甚至过于激进,甚至会生成错误代码。我会为 gcc 做更多的挖掘工作。
  • 在我看来,在提供的示例中,ICC 并没有什么侵略性。我听过 LLVM 家伙的一些谈话,在我看来,他们把“从不使用无符号整数”的口头禅放在心上,只是决定让他们成为二等公民。至于糟糕的代码 - 我们用 ICC 构建生产版本很长时间了。我还没有看到错误代码生成的示例(如 - 工作不正确)。
  • 代码不同,未签名的版本更短。至于实际表现我就不知道了。你说的坏代码是什么意思?产生标准未规定的结果的代码?可以分享一下例子吗?
  • 如果有的话,在您的第二个示例中,unsigned 版本看起来稍微好一些(并且稍微短一些,这对 i-cache 的使用没有任何影响),尽管我敢打赌它们会执行得相当不错相同。但同样,关键是性能差异通常低于本底噪声,因此它不应该成为决定因素 - 正确性和易用性更为重要。如果一个特定的循环实际上比你想要的慢,你总是可以花一个下午优化它。
猜你喜欢
  • 1970-01-01
  • 2020-04-14
  • 2012-05-10
  • 1970-01-01
  • 2017-02-05
  • 2021-04-25
  • 2021-11-13
  • 1970-01-01
  • 1970-01-01
相关资源
最近更新 更多