为什么允许gcc从结构中推测性地加载?

时间:2017-10-02 08:58:50

标签: c gcc assembly x86 compiler-optimization

显示可能出错的gcc优化和用户代码的示例

功能' foo'在下面的代码片段中只会加载一个结构成员A或B;至少这是未经优化的代码的意图。

typedef struct {
  int A;
  int B;
} Pair;

int foo(const Pair *P, int c) {
  int x;
  if (c)
    x = P->A;
  else
    x = P->B;
  return c/102 + x;
}

这是gcc -O3给出的内容:

mov eax, esi
mov edx, -1600085855
test esi, esi
mov ecx, DWORD PTR [rdi+4]   <-- ***load P->B**
cmovne ecx, DWORD PTR [rdi]  <-- ***load P->A***
imul edx
lea eax, [rdx+rsi]
sar esi, 31
sar eax, 6
sub eax, esi
add eax, ecx
ret

因此,似乎允许gcc推测性地加载两个结构成员以消除分支。但是,以下代码是否考虑了未定义的行为或者gcc优化是否非法?

#include <stdlib.h>  

int naughty_caller(int c) {
  Pair *P = (Pair*)malloc(sizeof(Pair)-1); // *** Allocation is enough for A but not for B ***
  if (!P) return -1;

  P->A = 0x42; // *** Initializing allocation only where it is guaranteed to be allocated ***

  int res = foo(P, 1); // *** Passing c=1 to foo should ensure only P->A is accessed? ***

  free(P);
  return res;
}

如果在上述场景中将发生负载推测,则加载P-> B将有可能引起异常,因为P-> B的最后一个字节可能位于未分配的存储器中。如果关闭优化,则不会发生此异常。

问题

上面显示的负载推测的gcc优化是否合法?规范在哪里说或暗示它没问题? 如果优化是合法的,那么naughtly_caller&#39;中的代码如何?结果是未定义的行为?

6 个答案:

答案 0 :(得分:55)

读取变量(未声明为volatile)不被视为&#34;副作用&#34;按C标准规定。因此,就C标准而言,程序可以自由地读取位置然后丢弃结果。

这很常见。假设您从4字节整数请求1字节数据。然后编译器可以读取整个32位,如果它更快(对齐读取),然后丢弃除请求的字节之外的所有内容。您的示例与此类似,但编译器决定读取整个结构。

正式地,这可以在&#34;抽象机&#34;,C11章5.1.2.3的行为中找到。鉴于编译器遵循那里指定的规则,它可以自由地随意执行。列出的唯一规则是关于volatile对象和指令排序。在volatile结构中读取不同的结构成员是不可行的。

对于为整个结构分配太少内存的情况,这是未定义的行为。因为结构的内存布局通常不是由程序员决定的 - 例如,允许编译器在末尾添加填充。如果没有足够的内存分配,即使您的代码只能与结构的第一个成员一起使用,也可能最终访问禁止的内存。

答案 1 :(得分:13)

不,如果正确分配*PP->B永远不会处于未分配的内存中。它可能没有被初始化,就是全部。

编译器完全有权做他们的工作。唯一不允许的是关于P->B的访问,以及未初始化的借口。但他们所做的一切以及如何做是由执行决定而非你的关注。

如果您将指针转换为malloc返回到Pair*的块,该块不能保证足够宽以容纳Pair,那么程序的行为是未定义的。

答案 2 :(得分:8)

这是完全合法的,因为在一般情况下,读取某些内存位置不被视为可观察行为(volatile会改变这种情况)。

您的示例代码确实是未定义的行为,但我无法在标准文档中找到任何明确说明此内容的段落。但我认为从N1570,§6.5p6中查看有效类型的规则就足够了:

  

如果通过一个值存储到没有声明类型的对象中   lvalue的类型不是字符类型,那么左值的类型就变成了   该访问的对象的有效类型以及不修改的后续访问   储值。

因此,您对*P的写访问实际上为该对象提供了类型Pair - 因此它只会扩展到您未分配的内存中,结果是超出访问范围。

答案 3 :(得分:7)

  

后缀表达式后跟->运算符,标识符指定结构或联合对象的成员。该值是第一个表达式指向的对象的指定成员的值

如果调用表达式P->A是明确定义的,那么P实际上必须指向struct Pair类型的对象,因此P->B也是明确定义的

答案 4 :(得分:5)

->上的Pair *运算符意味着已完全分配了整个Pair对象。 (@Hurkyl quotes the standard。)

x86(与任何普通架构一样)没有副作用来访问正常分配的内存,因此 x86内存语义与C抽象机器对非volatile内存的语义兼容。如果/当他们认为在任何特定情况下他们正在调整的目标微体系结构的性能获胜时,编译器可以推测性地加载。

请注意,在x86上,内存保护以页面粒度运行。只要触摸的所有页面都包含对象的某些字节,编译器就可以以读取对象外部的方式展开循环或向量化SIMD。 Is it safe to read past the end of a buffer within the same page on x86 and x64?。用汇编手工编写的libc strlen()实现这样做,但AFAIK gcc没有,而是在自动向量化循环结束时使用标量循环,即使已经将指针与a对齐(完全对齐)展开的)启动循环。 (也许是因为它会使valgrind的运行时边界检查变得困难。)

要获得您期望的行为,请使用const int * arg

数组是单个对象,但指针与数组不同。 (即使内联到已知两个数组元素都可访问的上下文中,我也无法让gcc像为结构一样发出代码,所以如果它的结构代码是一个胜利,那么它是一个错过的优化而不是在阵列上也可以安全地进行。)。

在C中,只要int非零,就可以将此函数传递给指向单个c的指针。在为x86进行编译时,gcc必须假设它可以指向页面中的最后一个int,并且以下页面未映射。

<强> Source + gcc and clang output for this and other variations on the Godbolt compiler explorer

// exactly equivalent to  const int p[2]
int load_pointer(const int *p, int c) {
  int x;
  if (c)
    x = p[0];
  else
    x = p[1];  // gcc missed optimization: still does an add with c known to be zero
  return c + x;
}

load_pointer:    # gcc7.2 -O3
    test    esi, esi
    jne     .L9
    mov     eax, DWORD PTR [rdi+4]
    add     eax, esi         # missed optimization: esi=0 here so this is a no-op
    ret
.L9:
    mov     eax, DWORD PTR [rdi]
    add     eax, esi
    ret

在C中,可以传递一种将数组对象(通过引用)传递给函数,保证函数允许它甚至触及所有内存如果C抽象机没有。 The syntax is int p[static 2]

int load_array(const int p[static 2], int c) {
  ... // same body
}

但是gcc没有利用,并且向load_pointer发出相同的代码。

关闭主题:clang以相同的方式编译所有版本(struct和array),使用cmov无分支计算加载地址。

    lea     rax, [rdi + 4]
    test    esi, esi
    cmovne  rax, rdi
    add     esi, dword ptr [rax]
    mov     eax, esi            # missed optimization: mov on the critical path
    ret

这不一定好:它的延迟比gcc的结构代码高,因为加载地址依赖于几个额外的ALU uop。如果两个地址都不安全,并且分支预测效果不佳,那就非常好了。

我们可以使用setcc(除了一些非常古老的CPU之外的所有CPU上1个延迟时间为1 uop,而不是cmovcc(2 uop on),从gcc和clang获得相同策略的更好代码英特尔在Skylake之前)。 xor - 归零总是比LEA便宜。

int load_pointer_v3(const int *p, int c) {
  int offset = (c==0);
  int x = p[offset];
  return c + x;
}

    xor     eax, eax
    test    esi, esi
    sete    al
    add     esi, dword ptr [rdi + 4*rax]
    mov     eax, esi
    ret

gcc和clang都将最终的mov置于关键路径上。在Intel Sandybridge系列中,索引寻址模式不会与add保持微融合。所以这会更好,就像在分支版本中做的那样:

    xor     eax, eax
    test    esi, esi
    sete    al
    mov     eax, dword ptr [rdi + 4*rax]
    add     eax, esi
    ret

[rdi][rdi+4]这样的简单寻址模式比英特尔SnB系列CPU上的其他模式具有更低的延迟1c,因此这实际上可能是Skylake上的延迟更差(其中cmov便宜) 。 testlea可以并行运行。

内联后,最终的mov可能不存在,只有add进入esi

答案 5 :(得分:4)

这始终是在&#34; as-if&#34;规则,如果没有合规程序可以区分。例如,一个实现可以保证在使用malloc分配的每个块之后,至少有八个字节可以访问而没有副作用。在这种情况下,如果您在代码中编写代码,编译器可以生成将是未定义行为的代码。因此,只要P [0]被正确分配,编译器就会读取P [1]是合法的,即使这在您自己的代码中是未定义的行为。

但是在你的情况下,如果你没有为结构分配足够的内存,那么读取任何成员是未定义的行为。因此,即使读取P-> B崩溃,也允许编译器执行此操作。