编译器内存优化 - 重用现有块

Posted

技术标签:

【中文标题】编译器内存优化 - 重用现有块【英文标题】:compiler memory optimization - reusing existing blocks 【发布时间】:2017-01-27 15:18:02 【问题描述】:

假设我要分配 2 个内存块。 我使用第一个内存块来存储一些东西并使用这个存储的数据。 然后我使用第二个内存块做类似的事情。


int a[10];
int b[10];

setup_0(a);
use_0(a);

setup_1(b);
use_1(b);    


 || compiler optimizes this to this?
 \/


int a[10];

setup_0(a);
use_0(a);

setup_1(a);
use_1(a);  


// the setup functions overwrites all 10 words

现在的问题是:如果编译器知道不会再次引用第一个块,编译器是否对此进行优化,以便它们重用现有的内存块,而不是分配第二个?

如果这是真的: 这也适用于动态内存分配吗? 如果内存仍然存在于范围之外,这是否也是可能的,但使用方式与示例中给出的方式相同? 我认为这仅在 setup 和 foo 在同一个 c 文件中实现(与调用代码存在于同一个对象中)时才有效?

【问题讨论】:

我认为回答这个问题的最好方法就是去看看。 我可以看到this code 等同于您的“优化”示例。 这真的取决于编译器。用你喜欢的任何编译器试试here。您甚至可以告诉我们您发现了什么。 @NathanOliver 确实这是一个更简洁的例子。 @Voidcrawler 但是有一个主要区别:在您的示例中,两个变量都在同一范围内,而在另一个示例中,当 b 的范围开始时,a 的生命周期已经结束。 【参考方案1】:

编译器是否对此进行优化

只有在询问特定编译器时才能回答此问题。通过检查生成的代码可以找到答案。

如果编译器知道第一个块不会被再次引用,那么它们就可以重用现有的内存块,而不是分配第二个?

这样的优化不会改变程序的行为,所以它是被允许的。另一件事是:是否可能证明内存不会被引用?如果有可能,那么在合理的时间内证明是否足够容易?我很肯定地说,一般情况下不可能证明,但在某些情况下是可以证明的。

我假设这只有在 setup 和 foo 实现在同一个 c 文件中(与调用代码存在于同一个对象中)时才有效?

这通常需要证明内存的不可接触性。理论上,链接时间优化可能会提高这一要求。

这也适用于动态内存分配吗?

理论上,因为它不会改变程序的行为。但是,动态内存分配通常由库执行,因此编译器可能无法证明没有副作用,因此无法证明删除分配不会改变行为。

如果内存持续超出范围,但使用方式与示例中相同,是否也可以这样做?

如果编译器能够证明内存泄漏,那么可能。


即使优化是可能的,它也不是很重要。节省一点堆栈空间可能对运行时间影响很小。如果数组很大,防止堆栈溢出可能很有用。

【讨论】:

【参考方案2】:

https://godbolt.org/g/5nDqoC

#include <cstdlib>

extern int a;
extern int b;

int main()

  
    int tab[1];
    tab[0] = 42;
    a = tab[0];
  

  
    int tab[1];
    tab[0] = 42;
    b = tab[0];
  

  return 0;

使用带有 -O3 编译标志的 gcc 7 编译:

main:
        mov     DWORD PTR a[rip], 42
        mov     DWORD PTR b[rip], 42
        xor     eax, eax
        ret

如果您点击链接,您应该会看到在 gcc 和 clang 上编译的代码具有 -O3 优化级别。生成的 asm 代码非常简单。由于存储在数组中的值在编译时是已知的,因此编译器可以轻松跳过所有内容并直接设置变量 a 和 b。不需要您的缓冲区。 遵循类似于您的示例中提供的代码:

https://godbolt.org/g/bZHSE4

#include <cstdlib>

int func1(const int (&tab)[10]);
int func2(const int (&tab)[10]);

int main()

  int a[10];
  int b[10];

  func1(a);
  func2(b);

  return 0;

使用带有 -O3 编译标志的 gcc 7 编译:

main:
        sub     rsp, 104
        mov     rdi, rsp ; first address is rsp
        call    func1(int const (&) [10])
        lea     rdi, [rsp+48] ; second address is [rsp+48]
        call    func2(int const (&) [10])
        xor     eax, eax
        add     rsp, 104
        ret

您可以看到发送给函数func1和func2的指针是不同的,因为在调用func1时使用的第一个指针是rsp,而[rsp+48]在对 func2 的调用中。

您可以看到,在可预测的情况下,编译器会完全忽略您的代码。在另一种情况下,至少对于 gcc 7 和 clang 3.9.1,它没有优化。


https://godbolt.org/g/TnV62V

#include <cstdlib>

extern int * a;
extern int * b;

inline int do_stuff(int ** to)

  *to = (int *) malloc(sizeof(int));
  (**to) = 42;
  return **to;


int main()

  do_stuff(&a);
  free(a);

  do_stuff(&b);
  free(b);

  return 0;

使用带有 -O3 编译标志的 gcc 7 编译:

main:
        sub     rsp, 8
        mov     edi, 4
        call    malloc
        mov     rdi, rax
        mov     QWORD PTR a[rip], rax
        call    free
        mov     edi, 4
        call    malloc
        mov     rdi, rax
        mov     QWORD PTR b[rip], rax
        call    free
        xor     eax, eax
        add     rsp, 8
        ret

虽然不能流利地阅读本文,但很容易通过以下示例看出,gcc 或 clang 都没有优化 malloc 和 free(如果您想尝试使用更多编译器,适合自己,但不要不要忘记设置优化标志)。 您可以清楚地看到对“malloc”的调用,然后是对“free”的调用,两次


除非您操作大量数据,否则优化堆栈空间不太可能真正影响程序的速度。 优化动态分配的内存更为相关。 AFAIK 如果您打算这样做,您将不得不使用第三方库或运行您自己的系统,这不是一项简单的任务。

编辑:忘了提很明显,这非常依赖于编译器。

【讨论】:

使用您使用的简单代码,是的,编译器能够对其进行优化。你写的类似于int a = 42; int b = 42; return 0;。数组消失了:无聊。如果这些不是extern 变量,它就会消除整个混乱。但这并不能证明这个问题在说什么。 请详细说明。第一个示例显然与 OP 提供的代码相同,使用 extern 变量保持原样无聊,以便编译器在优化期间也不会排除它们(导致输出更加无聊)。我用 malloc 演示了同样的事情,以向 OP 展示这不是由 gcc(以及 clang)优化的。据我了解,OP 询问他的代码是否得到优化,或者两者都得到优化。嗯,它是一个简单的代码,就像提供的那样。 关于动态分配的第二个例子是合理的;我只说第一个。我不确定我还能详细说明多少。这不像 OP 的示例,因为它甚至不是一个数组,就优化器而言。它只是您初始化的一个 extern 变量。而且它仍然没有重用该内存。 OP 的代码正在调用函数并期望它重用同一块堆栈空间。没有编译器会这样做,据我所知,您的代码与该问题无关。 更新了我的答案,使其更具相关性。谢谢你们的cmets。【参考方案3】:

当编译器看到a 被用作函数的参数时,它不会优化b。它不能,因为它不知道在使用ab 的函数中会发生什么。 a 也一样:编译器不知道 a 不再使用。

就编译器而言,a 的地址可以例如已被setup0 存储在一个全局变量中,当b 调用它时将被setup1 使用。

【讨论】:

【参考方案4】:

是的,理论上,编译器可以按照您的描述优化代码,假设它可以证明这些函数没有修改作为参数传入的数组。

但实际上,不,这不会发生。您可以编写一个简单的测试用例来验证这一点。我避免定义辅助函数,因此编译器无法内联它们,而是通过 const-reference 传递数组以确保编译器知道函数不会修改它们:

void setup_0(const int (&p)[10]);
void use_0  (const int (&p)[10]);
void setup_1(const int (&p)[10]);
void use_1  (const int (&p)[10]);

void TestFxn()

   int a[10];
   int b[10];

   setup_0(a);
   use_0(a);

   setup_1(b);
   use_1(b);

如您所见here on Godbolt's Compiler Explorer,没有编译器(GCC、Clang、ICC 或 MSVC)会优化它以使用 10 个元素的单个堆栈分配数组。当然,每个编译器在堆栈上分配多少空间是不同的。其中一些是由于不同的调用约定,这可能需要也可能不需要红色区域。否则,这是由于优化器的对齐偏好造成的。

以 GCC 的输出为例,你可以立即看出它没有重用数组a。以下是反汇编,带有我的注释:

; Allocate 104 bytes on the stack
; by subtracting from the stack pointer, RSP.
; (The stack always grows downward on x86.)
sub     rsp, 104


; Place the address of the top of the stack in RDI,
; which is how the array is passed to setup_0().
mov     rdi, rsp
call    setup_0(int const (&) [10])

; Since setup_0() may have clobbered the value in RDI,
; "refresh" it with the address at the top of the stack,
; and call use_0().
mov     rdi, rsp
call    use_0(int const (&) [10])


; We are now finished with array 'a', so add 48 bytes
; to the top of the stack (RSP), and place the result
; in the RDI register.
lea     rdi, [rsp+48]

; Now, RDI contains what is effectively the address of
; array 'b', so call setup_1().
; The parameter is passed in RDI, just like before.
call    setup_1(int const (&) [10])

; Second verse, same as the first: "refresh" the address
; of array 'b' in RDI, since it might have been clobbered,
; and pass it to use_1().
lea     rdi, [rsp+48]
call    use_1(int const (&) [10])


; Clean up the stack by adding 104 bytes to compensate for the
; same 104 bytes that we subtracted at the top of the function.
add     rsp, 104
ret

那么,什么给了?当涉及到重要的优化时,编译器是否只是在这里大量错过了这条船?不。在堆栈上分配空间非常快速且便宜。与 ~100 字节相比,分配 ~50 字节几乎没有什么好处。还不如安全行事,分别为两个数组分配足够的空间。

如果两个数组都非常大,可能重用第二个数组的堆栈空间更有好处,但根据经验,编译器也不会这样做。


这是否适用于动态内存分配?不,坚决不。我从未见过像这样围绕动态内存分配进行优化的编译器,我也不希望看到这样的编译器。这没有任何意义。如果你想重用这块内存,你应该编写代码来重用它,而不是分配一个单独的块。

我想你在想,如果你有类似下面的 C 代码:

void TestFxn()

   int* a = malloc(sizeof(int) * 10);
   setup_0(a);
   use_0(a);
   free(a);

   int* b = malloc(sizeof(int) * 10);
   setup_1(b);
   use_1(b);
   free(b);

优化器可以看到您正在释放a,然后立即重新分配与b 大小相同的块?好吧,优化器不会识别这一点并忽略对freemalloc 的背靠背调用,但运行时库(和/或操作系统)很可能会。 free 是一个非常便宜的操作,并且由于刚刚释放了一个适当大小的块,因此分配也将非常便宜。 (大多数运行时库为应用程序维护一个私有堆,甚至不会将内存返回给操作系统,因此根据内存分配策略,您甚至有可能获得完全相同的块。)

【讨论】:

以上是关于编译器内存优化 - 重用现有块的主要内容,如果未能解决你的问题,请参考以下文章

Linux 内核 内存管理优化内存屏障 ① ( barrier 优化屏障 | 编译器优化 | CPU 执行优化 | 优化屏障源码 barrier 宏 )

Linux 内核 内存管理优化内存屏障 ① ( barrier 优化屏障 | 编译器优化 | CPU 执行优化 | 优化屏障源码 barrier 宏 )

内存的使用和优化的注意事项

Unity优化方向——优化Unity游戏中的垃圾回收

Linux 内核 内存管理优化内存屏障 ② ( 内存屏障 | 编译器屏障 | 处理器内存屏障 | 内存映射 I/O 写屏障 )

Hive入门 优化总结