当前位置: 首页 > 知识库问答 >
问题:

是否有一些有意义的统计数据来证明保持有符号整数算术溢出未定义是合理的?

海鸣
2023-03-14

C标准将有符号整数溢出明确指定为具有未定义的行为。然而,大多数CPU使用定义的溢出语义学实现有符号算术(除法溢出可能除外:x/0INT_MIN/-1)。

编译器编写者一直在利用这种溢出的不确定性来添加更积极的优化,这往往会以非常微妙的方式破坏遗留代码。例如,此代码可能在较旧的编译器上工作,但在当前版本的< code>gcc和< code>clang上不再工作:

/* Tncrement a by a value in 0..255, clamp a to positive integers.
   The code relies on 32-bit wrap-around, but the C Standard makes
   signed integer overflow undefined behavior, so sum_max can now 
   return values less than a. There are Standard compliant ways to
   implement this, but legacy code is what it is... */
int sum_max(int a, unsigned char b) {
    int res = a + b;
    return (res >= a) ? res : INT_MAX;
}

有确凿的证据证明这些优化是值得的吗?是否有比较研究记录了现实生活中的例子或者甚至是经典基准的实际改进?

我在看这个节目时提出了这个问题:C Now 2018:John Regehr“闭幕主旨:未定义的行为和编译器优化”

我正在标记c和c,因为问题在两种语言中相似,但答案可能不同。

共有3个答案

司徒阳曜
2023-03-14

这是一个实际的小基准,冒泡排序。我比较了不带/和-fwrapv的时间(这意味着溢出是UB/不是UB)。以下是结果(秒):

                   -O3     -O3 -fwrapv    -O1     -O1 -fwrapv
Machine1, clang    5.2     6.3            6.8     7.7
Machine2, clang-8  4.2     7.8            6.4     6.7
Machine2, gcc-8    6.6     7.4            6.5     6.5

如您所见,not UB(-fwrapv)版本几乎总是较慢,最大的差异非常大,为1.85x。

这是代码。请注意,我有意选择了一个实现,它应该会为这个测试产生更大的差异。

#include <stdio.h>
#include <stdlib.h>

void bubbleSort(int *a, long n) {
        bool swapped;
        for (int i = 0; i < n-1; i++) {
                swapped = false;
                for (int j = 0; j < n-i-1; j++) {
                        if (a[j] > a[j+1]) {
                                int t = a[j];
                                a[j] = a[j+1];
                                a[j+1] = t;
                                swapped = true;
                        }
                }

                if (!swapped) break;
        }
}

int main() {
        int a[8192];

        for (int j=0; j<100; j++) {
                for (int i=0; i<8192; i++) {
                        a[i] = rand();
                }

                bubbleSort(a, 8192);
        }
}
芮祺
2023-03-14

这不是一个优化的例子,但未定义行为的一个有用结果是 GCC/叮当声的 -ftrapv 命令行开关。它插入在整数溢出时使程序崩溃的代码。

根据无符号溢出是故意的这一思想,它对无符号整数不起作用。

标准关于有符号整数溢出的措辞确保了人们不会故意编写溢出代码,因此ftrapv是发现无意溢出的有用工具。

谈琛
2023-03-14

我不知道有关研究和统计数据,但确实有一些优化考虑到了这一点,编译器实际上就是这样做的。是的,它们非常重要(例如,tldr循环矢量化)。

除了编译器优化之外,还有另一个方面需要考虑。使用UB,您可以获得C/C有符号整数,使其在算术上表现出您期望的数学行为。例如x10

我从 Krister Walfridsson 的博客中找到了一篇优秀的文章《未定义的签名溢出如何在 GCC 中实现优化》,其中列出了一些考虑签名溢出 UB 的优化。以下示例来自它。我正在向它们添加c和汇编示例。

如果优化看起来过于简单、无趣或无阻碍,请记住,这些优化只是更大的优化链中的步骤。蝴蝶效应确实会发生,因为在较早的步骤中看似不重要的优化可以在后面的步骤中触发更具影响力的优化。

如果例子看起来没有意义(谁会写< code>x * 10

>

  • 消除与0比较的乘法

    (x * c) cmp 0   ->   x cmp 0 
    
    bool foo(int x) { return x * 10 > 0 }
    
    foo(int):
            test    edi, edi
            setg    al
            ret
    

    乘法后除法

    (x * c1) / c2 -

    int foo(int x) { return (x * 20) / 10; }
    
    foo(int):
            lea     eax, [rdi+rdi]
            ret
    

    > 消除否定

    (-x)/(-y)-

    int foo(int x, int y) { return (-x) / (-y); }
    
    foo(int, int):
            mov     eax, edi
            cdq
            idiv    esi
            ret
    

    >

  • 简化始终为真或假的比较

    x + c < x       ->   false
    x + c <= x      ->   false
    x + c > x       ->   true
    x + c >= x      ->   true
    
    bool foo(int x) { return x + 10 >= x; }
    
    foo(int):
            mov     eax, 1
            ret
    

    消除比较中的否定

    (-x) cmp (-y)   ->   y cmp x
    
    bool foo(int x, int y) { return -x < -y; }
    
    foo(int, int):
            cmp     edi, esi
            setg    al
            ret
    

    减少常数的大小

    x + c > y       ->   x + (c - 1) >= y
    x + c <= y      ->   x + (c - 1) < y
    
    bool foo(int x, int y) { return x + 10 <= y; }
    
    foo(int, int):
            add     edi, 9
            cmp     edi, esi
            setl    al
            ret
    

    >

  • 消除比较中的常量

    (x + c1) cmp c2         ->   x cmp (c2 - c1)
    (x + c1) cmp (y + c2)   ->   x cmp (y + (c2 - c1)) if c1 <= c2
    

    第二个变换仅在c1时有效

    bool foo(int x) { return x + 42 <= 11; }
    
    foo(int):
            cmp     edi, -30
            setl    al
            ret
    

    如果一个操作没有溢出,那么如果我们在更广泛的类型中进行操作,我们将得到相同的结果。当在64位体系结构上执行数组索引之类的操作时,这通常很有用-索引计算通常使用32位int完成,但指针是64位的,当有符号溢出未定义时,编译器可以通过将32位整数提升为64位操作而不是生成类型扩展来生成更高效的代码。

    另一方面,未定义的溢出确保了a[i]和a[i 1]是相邻的。这改进了矢量化等的内存访问分析。

    这是一个非常重要的优化,因为循环矢量化是最有效和最有效的优化算法之一。

    这是一个将索引从无符号索引更改为有符号索引以改进生成的程序集的示例:

    #include <cstddef>
    
    auto foo(int* v, std::size_t start)
    {
        int sum = 0;
    
        for (std::size_t i = start; i < start + 4; ++i)
            sum += v[i];
    
        return sum;
    }
    

    对于未签名,必须考虑start 4环绕的情况,并生成一个分支来处理这种情况(分支不利于性能):

    ; gcc on x64 with -march=skylake
    
    foo1(int*, unsigned long):
            cmp     rsi, -5
            ja      .L3
            vmovdqu xmm0, XMMWORD PTR [rdi+rsi*4]
            vpsrldq xmm1, xmm0, 8
            vpaddd  xmm0, xmm0, xmm1
            vpsrldq xmm1, xmm0, 4
            vpaddd  xmm0, xmm0, xmm1
            vmovd   eax, xmm0
            ret
    .L3:
            xor     eax, eax
            ret
    
    ; clang on x64 with -march=skylake
    
    foo1(int*, unsigned long):                             # @foo1(int*, unsigned long)
            xor     eax, eax
            cmp     rsi, -4
            jae     .LBB0_2
            vpbroadcastq    xmm0, qword ptr [rdi + 4*rsi + 8]
            vpaddd  xmm0, xmm0, xmmword ptr [rdi + 4*rsi]
            vpshufd xmm1, xmm0, 85                  # xmm1 = xmm0[1,1,1,1]
            vpaddd  xmm0, xmm0, xmm1
            vmovd   eax, xmm0
    .LBB0_2:
            ret
    

    作为补充说明,使用更窄的类型将导致最差的汇编,从而禁止使用SSE矢量化指令:

    #include <cstddef>
    
    auto foo(int* v, unsigned start)
    {
        int sum = 0;
    
        for (unsigned i = start; i < start + 4; ++i)
            sum += v[i];
    
        return sum;
    }
    
    ; gcc on x64 with -march=skylake
    
    foo(int*, unsigned int):
            cmp     esi, -5
            ja      .L3
            mov     eax, esi
            mov     eax, DWORD PTR [rdi+rax*4]
            lea     edx, [rsi+1]
            add     eax, DWORD PTR [rdi+rdx*4]
            lea     edx, [rsi+2]
            add     eax, DWORD PTR [rdi+rdx*4]
            lea     edx, [rsi+3]
            add     eax, DWORD PTR [rdi+rdx*4]
            ret
    .L3:
            xor     eax, eax
            ret
    
    ; clang on x64 with -march=skylake
    
    foo(int*, unsigned int):                              # @foo(int*, unsigned int)
            xor     eax, eax
            cmp     esi, -5
            ja      .LBB0_3
            mov     ecx, esi
            add     esi, 4
            mov     eax, dword ptr [rdi + 4*rcx]
            lea     rdx, [rcx + 1]
            cmp     rdx, rsi
            jae     .LBB0_3
            add     eax, dword ptr [rdi + 4*rcx + 4]
            add     eax, dword ptr [rdi + 4*rcx + 8]
            add     eax, dword ptr [rdi + 4*rcx + 12]
    .LBB0_3:
            ret
    

    但是,使用有符号索引会产生漂亮的矢量化无分支代码:

    #include <cstddef>
    
    auto foo(int* v, std::ptrdiff_t start)
    {
        int sum = 0;
    
        for (std::ptrdiff_t i = start; i < start + 4; ++i)
            sum += v[i];
    
        return sum;
    }
    
    ; gcc on x64 with -march=skylake
    
    foo(int*, long):
            vmovdqu xmm0, XMMWORD PTR [rdi+rsi*4]
            vpsrldq xmm1, xmm0, 8
            vpaddd  xmm0, xmm0, xmm1
            vpsrldq xmm1, xmm0, 4
            vpaddd  xmm0, xmm0, xmm1
            vmovd   eax, xmm0
            ret
    
    ; clang on x64 with -march=skylake
    
    foo(int*, long):                              # @foo(int*, long)
            vpbroadcastq    xmm0, qword ptr [rdi + 4*rsi + 8]
            vpaddd  xmm0, xmm0, xmmword ptr [rdi + 4*rsi]
            vpshufd xmm1, xmm0, 85                  # xmm1 = xmm0[1,1,1,1]
            vpaddd  xmm0, xmm0, xmm1
            vmovd   eax, xmm0
            ret
    

    使用较窄的符号类型时,仍使用矢量化指令:

    #include <cstddef>
    
    auto foo(int* v, int start)
    {
        int sum = 0;
    
        for (int i = start; i < start + 4; ++i)
            sum += v[i];
    
        return sum;
    }
    
    ; gcc on x64 with -march=skylake
    
    foo(int*, int):
            movsx   rsi, esi
            vmovdqu xmm0, XMMWORD PTR [rdi+rsi*4]
            vpsrldq xmm1, xmm0, 8
            vpaddd  xmm0, xmm0, xmm1
            vpsrldq xmm1, xmm0, 4
            vpaddd  xmm0, xmm0, xmm1
            vmovd   eax, xmm0
            ret
    
    ; clang on x64 with -march=skylake
    
    foo(int*, int):                              # @foo(int*, int)
            movsxd  rax, esi
            vpbroadcastq    xmm0, qword ptr [rdi + 4*rax + 8]
            vpaddd  xmm0, xmm0, xmmword ptr [rdi + 4*rax]
            vpshufd xmm1, xmm0, 85                  # xmm1 = xmm0[1,1,1,1]
            vpaddd  xmm0, xmm0, xmm1
            vmovd   eax, xmm0
            ret
    

    编译器在程序中的每个点跟踪变量的可能值范围,即对于代码,例如

    int x = foo();
    if (x > 0) {
      int y = x + 5;
      int z = y / 4;
    

    它确定x在if语句之后具有范围[1,INT_MAX],因此可以确定y具有范围[6,INT_MAX],因为不允许溢出。下一行可以优化为intz=y

    auto foo(int x)
    {
        if (x <= 0)
            __builtin_unreachable();
        
        return (x + 5) / 4;
    }
    
    foo(int):
            lea     eax, [rdi+5]
            sar     eax, 2
            ret
    

    未定义的溢出有助于需要比较两个值的优化(因为包装大小写会给出形式为[INT_MIN,(INT_MIN4)][6,INT_MAX]的可能值,从而阻止与进行所有有用的比较)

    • 更改比较x

    未定义的有符号溢出有助于循环优化的典型示例是

    for (int i = 0; i <= m; i++)
    

    保证在未定义溢出时终止。这有助于具有特定循环指令的体系结构,因为它们通常不处理无限循环。

    但是未定义的带符号溢出有助于更多的循环优化。所有分析,如确定迭代次数、转换归纳变量和跟踪内存访问,都在使用前面部分中的所有内容来完成其工作。特别是,当允许带符号溢出时,可以矢量化的循环集会大大减少。

  •  类似资料:
    • 最近,有符号整数溢出在C和C中没有正式定义,这引起了很多关注。然而,给定的实现可能会选择定义它;在C语言中,实现可以设置

    • 未定义行为的一个例子是在flow上的整数行为 有没有一个历史的或者(甚至更好!)造成这种差异的技术原因是什么?

    • 众所周知,有符号整数溢出是一种未定义的行为。但是在C 11文档中有一些有趣的东西: 带符号整数类型,宽度分别为8、16、32和64位,不带填充位,并使用2的补码表示负值(仅在实现直接支持该类型时提供) 参见链接 我的问题是:既然标准明确规定,、、和负数是2的补码,那么这些类型的溢出仍然是一种未定义的行为吗? 编辑我检查了C 11和C11标准,以下是我的发现: C 11,§18.4.1: 标题定义了

    • C99标准中哪里说有符号整数溢出是未定义的行为? 我看到关于无符号整数溢出的评论定义得很好(看看为什么定义了无符号整数溢出行为,但没有定义有符号整数溢出?)在第6.2.5节中: 涉及无符号操作数的计算永远不会溢出,因为不能由结果无符号整数类型表示的结果将被减少为比结果类型可以表示的最大值大一的数的模。 但是我在附录J中查看了未定义的行为,我只在列表中看到了这些类似的项目: 具有有符号提升类型的表达

    • 问题内容: 我可以理解对Docker进行无状态服务(例如Web服务器,应用服务器,负载平衡器等)背后的好处。如果您在机器集群上运行这些服务,则很容易以低开销移动这些容器。我不明白容器化数据库的目的是什么?数据库连接到持久存储在特定硬盘中的数据卷。由于状态的原因,实际移动数据库容器并不容易,效率也不高。那么,谁能看到为什么对数据库进行dockerdocker完全有用? 问题答案: “那么,有谁能看到

    • 问题内容: 我了解到,浮点数s在Java中的符号为零。但恐怕还没有: 与 如何存储零值符号? 问题答案: 您不能使用Java整数基元类型存储符号。 负零是IEEE-754表示的伪像,它将符号存储在单独的位中。另一方面,整数以二进制补码表示形式存储,补码表示形式具有唯一的零表示形式。