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

gywdnpxw  于 2022-12-03  发布在  其他
关注(0)|答案(5)|浏览(181)

假设我分配了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文件中实现的情况(与调用代码存在于同一个对象中)?

bq3bfh9z

bq3bfh9z1#

编译器是否对此进行优化
这个问题只有在你询问一个特定的编译器时才能得到答案。而答案可以通过检查生成的代码来找到。
以便在编译器知道第一个内存块不会被再次引用的情况下,重用现有的内存块,而不是分配第二个内存块。
这样的优化不会改变程序的行为,所以是允许的。另一个问题是:是否 * 可能 * 证明内存不会被引用?如果可能,那么在合理的时间内证明是否足够容易?我觉得很有把握地说,一般来说不可能证明,但在某些情况下是可以证明的。
我假设这只在setup和foo在同一个c文件中实现时才起作用(与调用代码存在于同一个对象中)?
这通常需要证明内存的不可触及性,而链接时间优化理论上可能会提高这一要求。
这也适用于动态内存分配吗?
从理论上讲,因为它不会改变程序的行为。然而,动态内存分配通常是由库执行的,因此编译器可能无法证明没有副作用,因此无法证明删除分配不会改变行为。
如果内存在作用域之外持续存在,但使用的方式与示例中给出的相同,是否也可能出现这种情况?
如果编译器能够证明内存泄漏,那么也许。
即使优化是可能的,但它并不是很重要。节省一点堆栈空间可能对运行时间的影响很小。如果数组很大,它可能有助于防止堆栈溢出。

tp5buhyn

tp5buhyn2#

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

您可以看到发送到函数func 1和func 2的指针是不同的,因为在调用func 1时使用的第一个指针是 rsp,而在调用func 2时使用的第一个指针是 [rsp+48]
你可以看到编译器要么完全忽略你的代码,如果它是可预测的;要么,至少对于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

虽然阅读这篇文章并不流利,但很容易看出,在下面的例子中,malloc和free既没有被gcc也没有被clang优化(如果你想尝试使用更多的编译器,请自便,但不要忘记设置优化标志)。
优化堆栈空间不太可能真正影响程序的运行速度,除非你要处理大量的数据。优化动态分配的内存更为重要。AFAIK如果你打算这样做,你将不得不使用第三方库或运行你自己的系统,这不是一个简单的任务。
编辑:忘了提一个显而易见的问题,这是非常依赖编译器的。

mutmk8jj

mutmk8jj3#

当编译器看到a被用作函数的参数时,它不会优化b。它不能,因为它不知道在使用ab的函数中发生了什么。对于a也是如此:编译器并不知道a不再使用。
就编译器而言,a的地址可以例如由setup0存储在全局变量中,并且当用b调用setup1时将由setup1使用。

6uxekuva

6uxekuva4#

简短的回答是:否!编译器无法将此代码优化为您建议的代码,因为它在语义上不等效。详细说明:ab的生存期简化为完整的块。setup_0use_0中的一个在某个全局变量中存储了指向a的指针。现在,setup_1use_1可以通过该全局变量与b(例如,它可以将数组元素ab相加。如果您对代码进行了建议的转换,这将导致未定义的行为。如果您真的想对生命周期进行声明,则必须按以下方式编写代码:

{
    { // Lifetime block for a
        char a[100];

        setup_0(a);
        use_0(a);
    } // Lifetime of a ends here, so no one of the following called
      // function is allowed to access it. If it does access it by
      // accident it is undefined behaviour

    char b[100];

    setup_1(b); // Not allowed to access a
    use_1(b);   // Not allowed to access a
}

还请注意gcc 12.x和clang 15都做了优化。如果你注解掉了花括号,优化就(正确地!)没有完成。

fruv7luv

fruv7luv5#

是的,从理论上讲,编译器可以像您描述的那样优化代码,前提是它可以证明这些函数没有修改作为参数传入的数组。
但是实际上,不,这种情况不会发生。你可以写一个简单的测试用例来验证这一点。我避免了定义helper函数,这样编译器就不能内联它们,而是通过常量引用传递数组,以确保编译器知道函数不会修改它们:

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,没有编译器(愚者,Clang,ICC,或者MSVC)会优化它来使用一个由10个元素组成的堆栈分配数组。当然,每个编译器在堆栈上分配多少空间是不同的。其中一些是由于不同的调用约定,可能需要也可能不需要红色区域。另外,这是由于优化器的对齐偏好。
以愚者的输出为例,您可以立即看出它 * 没有 * 重用数组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

那么,为什么呢?当涉及到一个重要的优化时,编译器只是在这里错过了船吗?不是。在堆栈上分配空间是非常快和便宜的。分配~50个字节与~100个字节相比没有什么好处。最好还是安全起见,分别为两个数组分配足够的空间。
如果两个数组都非常大,那么为第二个数组重用堆栈空间 * 可能 * 会有更多的好处,但根据经验,编译器也不会这样做。
这是否适用于动态内存分配?不适用。我从来没有见过一个编译器可以像这样优化动态内存分配,我也不希望看到这样的编译器。这没有意义。如果你想重用内存块,你应该编写代码来重用它,而不是分配一个单独的内存块。
我想你会想,如果你有像下面这样的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是一个非常廉价的操作,并且由于刚刚释放了适当大小的块,分配也将非常便宜。(大多数运行时库为应用程序维护一个私有堆,甚至不会将内存返回给操作系统,因此根据内存分配策略,您甚至有可能得到完全相同的块。)

相关问题