【问题标题】:GCC: Optimizing away memory loads and storesGCC:优化内存加载和存储
【发布时间】:2019-06-07 10:25:54
【问题描述】:

编辑 1: 添加了另一个示例(表明 GCC 原则上能够完成我想要实现的目标)并在此问题的末尾进行了更多讨论。

编辑2:找到malloc函数属性,应该做什么。请看问题的最后。

这是一个关于如何告诉编译器存储到内存区域的问题在区域之外不可见(因此可以优化掉)。为了说明我的意思,我们来看看下面的代码

int f (int a)
{
    int v[2];
    v[0] = a;
    v[1] = 0;
    while (v[0]-- > 0)
       v[1] += v[0];
    return v[1];
}

gcc -O2 生成以下汇编代码(x86-64 gcc, trunk, on https://godbolt.org):

f:
        leal    -1(%rdi), %edx
        xorl    %eax, %eax
        testl   %edi, %edi
        jle     .L4
.L3:
        addl    %edx, %eax
        subl    $1, %edx
        cmpl    $-1, %edx
        jne     .L3
        ret
.L4:
        ret

可以看到,数组v的加载和存储在优化后都消失了。

现在考虑以下代码:

int g (int a, int *v)
{
    v[0] = a;
    v[1] = 0;
    while (v[0]-- > 0)
       v[1] += v[0];
    return v[1];
}

不同之处在于v 不是在函数中分配(堆栈),而是作为参数提供。在这种情况下gcc -O2 的结果是:

g:
        leal    -1(%rdi), %edx
        movl    $0, 4(%rsi)
        xorl    %eax, %eax
        movl    %edx, (%rsi)
        testl   %edi, %edi
        jle     .L4
.L3:
        addl    %edx, %eax
        subl    $1, %edx
        cmpl    $-1, %edx
        jne     .L3
        movl    %eax, 4(%rsi)
        movl    $-1, (%rsi)
        ret
.L4:
        ret

显然,代码必须将v[0]v[1] 的最终值存储在内存中,因为它们可能是可观察到的。

现在,我正在寻找一种方法来告诉编译器第二个示例中v 指向的内存在函数g 返回后不再可访问,以便编译器可以优化远离内存访问。

举个更简单的例子:

void h (int *v)
{
    v[0] = 0;
}

如果v 指向的内存在h 返回后无法访问,应该可以将函数简化为单个ret

我试图通过使用严格的别名规则来实现我想要的,但没有成功。

在编辑 1 中添加:

GCC 似乎内置了必要的代码,如下例所示:

include <stdlib.h>

int h (int a)
{
    int *v = malloc (2 * sizeof (int));
    v[0] = a;
    v[1] = 0;
    while (v[0]-- > 0)
      v[1] += v[0];
    return v[1];
}

生成的代码不包含任何加载和存储:

h:
        leal    -1(%rdi), %edx
        xorl    %eax, %eax
        testl   %edi, %edi
        jle     .L4
.L3:
        addl    %edx, %eax
        subl    $1, %edx
        cmpl    $-1, %edx
        jne     .L3
        ret
.L4:
        ret

换句话说,GCC 知道通过malloc 的任何副作用无法观察到更改v 指向的内存区域。对于这样的目的,GCC 有__builtin_malloc

所以我也可以问:用户代码(比如malloc 的用户版本)如何使用此功能?

在编辑 2 中添加:

GCC具有以下功能属性:

malloc

这告诉编译器一个函数是类似malloc的,即函数返回的指针P不能为函数返回时任何其他有效的指针起别名,而且在P寻址的任何存储中都不会出现指向有效对象的指针.

使用此属性可以改进优化。编译器预测具有该属性的函数在大多数情况下返回非空值。 malloc 和 calloc 之类的函数具有此属性,因为它们返回指向未初始化或清零存储的指针。然而,像 realloc 这样的函数没有这个属性,因为它们可以返回一个指向包含指针的存储的指针。

似乎做我想做的事,如下例所示:

__attribute__ (( malloc )) int *m (int *h);

int i (int a, int *h) 
{ 
    int *v = m (h);
    v[0] = a;
    v[1] = 0;
    while (v[0]-- > 0)
        v[1] += v[0];
    return v[1];
}

生成的汇编代码没有加载和存储:

i:
        pushq   %rbx
        movl    %edi, %ebx
        movq    %rsi, %rdi
        call    m
        testl   %ebx, %ebx
        jle     .L4
        leal    -1(%rbx), %edx
        xorl    %eax, %eax
.L3:
        addl    %edx, %eax
        subl    $1, %edx
        cmpl    $-1, %edx
        jne     .L3
        popq    %rbx
        ret
.L4:
        xorl    %eax, %eax
        popq    %rbx
        ret

但是,一旦编译器看到m 的定义,它可能会忘记该属性。例如,当给出以下定义时就是这种情况:

__attribute__ (( malloc )) int *m (int *h)
{
    return h;
}

在这种情况下,函数是内联的,编译器会忘记该属性,从而产生与函数 g 相同的代码。

P.S.:最初,我认为restrict 关键字可能会有所帮助,但事实并非如此。

【问题讨论】:

  • 嗯,您能否提供一个可能有用的真实案例?如果“外部”不关心缓冲区的状态,您不能只在本地分配它吗?还是因为它是某种用户提供的暂存缓冲区?
  • 在实际用例中,缓冲区由带有 GC 的内存管理系统提供。在堆栈上分配是不可能的,比如alloca,因为我需要 TCO 才能工作并且因为我需要严格限制堆栈空间。
  • 如果从另一个模块调用g() 会怎样?它必须遵守声明。如果你把它设为inline那么它可能会在调用站点进行优化。
  • 我的真实世界代码不是手工编写的,而是由转译器生成的。转译器不知道适合局部变量等的最大缓冲区是多少。因此我一直在寻找通用解决方案。
  • 我查看了 GCC 扩展(不是详尽无遗,只是可能的类别),没有看到任何可以提供请求功能的东西。理论上,unsigned char a[1]; memset(v, a[1], length); 可以通过将v 设置为“不确定”值来实现这一点,这可以让编译器优化知道之前写入v 的任何内容随后都不会使用,memset 不会使用实际需要。 (虽然它名义上将所有v 设置为一个值,但“不确定”允许其他情况。)但我怀疑编译器在这种情况下实际上会按预期运行。

标签: c gcc compiler-optimization


【解决方案1】:

编辑:讨论最后添加的noinline 属性。

使用下面的函数定义,可以达到我的问题的目的:

__attribute__ (( malloc, noinline )) static void *get_restricted_ptr (void *p)
{
    return p;
}

这个函数get_restricted_ptr只是简单地返回它的指针参数,但是通知编译器返回的指针P在函数返回时不能为任何其他有效的指针起别名,而且在P寻址的任何存储中都不会出现指向有效对象的指针。

这里演示了这个函数的使用:

int i (int a, int *h)
{
    int *v = get_restricted_ptr (h);
    v[0] = a;
    v[1] = 0;
    while (v[0]-- > 0)
        v[1] += v[0];
    return;
}

生成的代码不包含加载和存储:

i:
        leal    -1(%rdi), %edx
        xorl    %eax, %eax
        testl   %edi, %edi
        jle     .L6
.L5:
        addl    %edx, %eax
        subl    $1, %edx
        cmpl    $-1, %edx
        jne     .L5
        ret
.L6:
        ret

在编辑中添加:如果noinline 属性被忽略,GCC 将忽略malloc 属性。显然,在这种情况下,函数首先被内联,这样 GCC 就不会再检查 malloc 属性的函数调用了。 (可以讨论这种行为是否应该被视为 GCC 中的错误。)使用noinline 属性,该函数不会被内联。然后,由于malloc 属性,GCC 知道对该函数的调用是不必要的,并将其完全删除。

不幸的是,这意味着当由于malloc 属性而没有消除它的调用时(平凡的)函数将不会被内联。

【讨论】:

  • 这个答案应该讨论noinline 的需要,因为不清楚为什么需要它。 (这是 GCC 对 malloc 属性的支持中的错误还是由于其他原因?)
  • @EricPostpischil 好点;我加了两段。不幸的是,正如在添加的第二段中所观察到的,使用noinline 会产生一些不利影响。我仍然希望有更好的解决方案。
  • 为什么添加 malloc 属性可以让编译器消除调用?
【解决方案2】:

这两个函数都有副作用,无法优化内存读取和存储

void h (int *v)
{
    v[0] = 0;
}

int g (int a, int *v)
{
    v[0] = a;
    v[1] = 0;
    while (v[0]-- > 0)
       v[1] += v[0];
    return v[1];
}

副作用必须在函数范围之外是可观察的。内联函数可能有另一种行为,因为副作用可能必须在封闭代码之外可见。

inline int g (int a, int *v)
{
    v[0] = a;
    v[1] = 0;
    while (v[0]-- > 0)
       v[1] += v[0];
    return v[1];
}

void h(void)
{
    int x[2],y ;

    g(y,x);
}

这段代码将被优化为一个简单的返回

您可以向编译器保证不会发生任何事情来允许使用关键字限制进行更轻松的优化。当然,你的代码必须遵守这个承诺。

【讨论】:

  • 我的意思是我想让编译器相信副作用不会被观察到,在这种情况下它可以优化它们。
  • 这个问题专门询问了 GCC。你知道 GCC 没有扩展名,它可能会在 g 返回时通知 v 的内容不需要?
  • 确实,说服 GCC 就足够了。
  • 没有。没有办法。
  • @P__J__ 在内部,编译器似乎有一种方法,正如我添加的 malloc 示例所示。
【解决方案3】:

对于 C,唯一的限制是编译器必须确保代码的行为相同。如果编译器可以证明代码行为相同,那么它可以并且将删除存储。

比如我把这个放到https://godbolt.org/:

void h (int *v)
{
    v[0] = 0;
}

void foo() {
    int v[2] = {1, 2};
    h(v);
}

并告诉它使用 GCC 8.2 和“-O3”,并得到以下输出:

h(int*):
        mov     DWORD PTR [rdi], 0
        ret
foo():
        ret

请注意,输出中有两个不同版本的函数 h()。如果其他代码(在其他目标文件中)想要使用该函数(并且可能被链接器丢弃),则存在第一个版本。 h() 的第二个版本直接内联到 foo(),然后优化到完全没有。

如果你把代码改成这样:

static void h (int *v)
{
    v[0] = 0;
}

void foo() {
    int v[2] = {1, 2};
    h(v);
}

然后它告诉编译器不需要只为链接其他目标文件而存在的h()版本,因此编译器只生成h()的第二个版本,输出变为:

foo():
        ret

当然,所有编译器中的所有优化器都不是完美的——对于更复杂的代码(以及不同的编译器,包括不同版本的 GCC),结果可能会有所不同(编译器可能无法进行这种优化)。这纯粹是编译器优化器的限制,而不是 C 本身的限制。

对于编译器优化器不够好的情况,有4种可能的解决方案:

  • 获得更好的编译器

  • 改进编译器的优化器(例如,向编译器的开发人员发送一封电子邮件,其中包含一个最小的示例并让您放心)

  • 修改代码以使编译器的优化器更容易(例如,将输入数组复制到本地数组,如“void h(int *v) { int temp[2]; temp[0] = v[0]; temp[1] = v[1]; ...”)。

  • 耸耸肩说“哦,真可惜”,什么也不做

【讨论】:

  • 这个答案说如果编译器可以看到所有相关代码,它可以进行所需的优化。但问题是寻找调用代码不可见时的解决方案。由于它询问的是 GCC,而不仅仅是标准 C,因此解决方案可能是 GCC 扩展,而不是标准 C。
  • @EricPostpischil:对我来说,OP 的“在区域之外不可见”是指“在函数之外”而不是“在不同的编译单元中”。对于后者,如果要跨编译单元进行优化,则需要链接时间优化。
  • OP 明确要求:“现在,我正在寻找一种方法来告诉编译器第二个示例中 v 指向的内存在函数之后不再可访问g 已返回,以便编译器可以优化内存访问。”如果有编译器扩展可以按照他们的要求进行,则不需要链接时优化。
  • @EricPostpischil:如果 OP 错误地认为优化没有完成,因为他们正在查看函数的错误版本(即将在链接时被丢弃,而不是在同一编译单元中使用的版本)。
  • @Brendan Eric Postpischil 是对的。在我的情况下,即使是整个程序优化也不一定有帮助。无论v 指向什么,最终都会被我的GC 回收,但编译器无法知道堆的这一部分中的值将不再被访问。
猜你喜欢
  • 1970-01-01
  • 2015-08-31
  • 1970-01-01
  • 2021-03-21
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 2022-09-27
  • 1970-01-01
相关资源
最近更新 更多