我有一个很好的想法,通过分配一大堆内存,然后只对较低类型的数字进行指针算术来节省内存。

/* NORMAL VERSION */
int a = 5;
int b = 3;
int *ptr = &b;

int c = *ptr;
/* LOW MEM VERSION */
int *start = malloc(2 * sizeof(int));
start[0] = 5;
start[1] = 3;

uint8_t ptr = 1 * sizeof(int);
int c = *(start + ptr);

重点在于,指针的第一个版本是普通指针,而第二个版本是已知起始指针的偏移量。问题是访问纯指针和使用指针算法(或索引)访问指针的速度差异会有多大。仅使用指针就能节省 50% 到 87.5% 的内存,这是否值得付出努力。

5

  • 5
    没办法说,现代编译器和 CPU 已经内置了很多优化。相同的 C 代码在不同上下文中可能会变慢/变快。除非你使用的是总是生成完全相同的可预测汇编的旧编译器,并且你的 CPU 对所有汇编都有精确的时间安排,否则我不会费心。优化可读性和传达意图,不要挑剔优化。


    – 


  • 您如何节省内存?ab使用与引用的相同数量的内存start;它只是在堆栈上而不是在堆上。


    – 

  • 3
    除非这样的事情对代码的性能产生明显的影响,否则你真的不应该像这样进行微优化。你测试过“普通版本”和“低内存版本”之间的性能差异吗?此外,这里不需要使用 malloc()。即使你像上面那样使用 malloc(),你仍然必须在完成后释放分配的内存。


    – 

  • 4
    “普通版本”使用内存来存储 3 个 int 和一个指针。“低内存版本”使用内存来存储 3 个 int、一个指针、一个 uint8_t 以及 malloc 用于记账的额外内部内存。“低内存版本”使用更多内存,尽管其中一些内存被转移到动态内存。这没有考虑到可能优化为使用寄存器或完全省略的内容。这里没有节省内存。而且 malloc 增加了执行时间和调用 free 的需要。


    – 


  • 另外,(和)*(start + ptr)完全相同。对于数组,为什么会出现start[ptr]ptr[start]


    – 



最佳答案
2

我有一个很好的想法,通过分配一大堆内存然后只进行指针算术来节省内存……

个好主意。事实上,这个想法非常好,堆管理器(malloc 和 free 背后的实现)已经在这么做了。

当您调用 malloc 分配少量内存时,堆管理器会为您提供从操作系统分配的较大内存块中的一块。向堆管理器请求一个大块并尝试自己将其作为一堆块进行管理实际上是重复工作。

是的,堆管理器会产生一些开销来跟踪大块内存如何被分割成小块。但你也必须这样做。 也许,你可以通过让它不那么通用来以稍微少一点的开销做到这一点。


指针运算并不难,但容易出错。看一下你的“LOW MEM”代码片段:

uint8_t ptr = 1 * sizeof(int);
int c = *(start + ptr);

ptr是您要访问的 int 的字节偏移量,但您的基指针startint *,因此当您尝试将数组中的第二个 int 分配给时c,您实际上是从分配的错误部分读取的,该部分 – 最好 – 是未初始化的内存,但它甚至可能是缓冲区溢出。

我并不是想批评你的技能。我认为这个错误应该提醒你,指针算法很容易出错。堆管理器已经经过实战测试、优化,甚至可能针对某些类型的漏洞进行了强化。

只有当您知道程序具有某些访问模式,而您能够比通用堆管理器更好地处理这些访问模式时,自己开发内存管理器才是值得的。(自己开发内存管理器也可以获得一定的教育意义,所以如果您有这种欲望,那就去尝试吧。但如果您想在生产代码中使用它,请做好付出巨大努力的准备。)

目前有很多关于所谓的内存安全语言(如 Rust)以及寻找提高其他语言(如 C++)安全性的方法的讨论。将所有变量移到仅通过指针算法访问的动态内存中则是朝着另一个方向发展。


现代优化编译器在这个层面上表现得非常好。如果你编写一个循环,使用索引遍历数组进行计算,并编写另一个循环,通过增加数组中的指针进行相同的计算,那么编译器很可能会生成相同的代码。

当您试图将程序中的所有内容强制纳入一个模型时,您实际上可能会妨碍优化器。如果您有一个自动或静态变量,而编译器认为它是不必要的,它可以消除它。但是如果您将一个不必要的变量写入动态内存分配,优化器可能无法摆脱它。

1

  • 阅读以了解有关栈和堆的更多信息。


    – 

问题是访问纯指针和使用指针算法(或索引)访问指针之间的速度差异是什么。

这取决于上下文。例如,如果我们要采用您的第一个例子并将其放入如下程序中:

#include <stdio.h>

int main (void)
{
    int a = 5;
    int b = 3;
    int *ptr = &b;
    int c = *ptr;    
    printf("%d\n", c);
}

然后,所有指针访问和取消引用都将被优化,在汇编器级别 (x86),我们最终会得到类似这样的结果mov esi, 3。也就是说,将数字 3 移动到 CPU 寄存器中,根据该寄存器调用 printf,然后忘记所有这些变量。这显然是非常高效的代码。

因此,如果没有非常具体的场景,我们就无法真正讨论性能,因为显然上述场景的表现将大大优于第二种场景(或者会吗?)。


至于你的第二个例子显然是一个错误。这会导致主流 32/64 位计算机上uint8_t ptr = 1 * sizeof(int);出现,但只有 2 个项目。当涉及到指针算法时,这是一个典型的初学者错误:指针操作数的类型决定了要增加多少字节。由于结果是以字节为单位。在考虑手动性能优化(这是一个高级主题)之前,你需要熟悉指针算法的初学者级别主题可能与 C 编程一样先进。start + 4start+startintstart + 4start + 4*4

关于性能,我们能说的另一件事是堆分配本身非常慢。因此,第二个程序中的瓶颈是调用malloc

但是…编译器也可以优化这部分。因此,如果我们再次编写一个最小程序,但使用第二个版本(修复了错误):

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

int main (void)
{
    int *start = malloc(2 * sizeof(int));
    start[0] = 5;
    start[1] = 3;
    int c = *(start + 1);
    printf("%d\n", c);
    free(start);
}

…然后我们发现自己又一次盯着mov esi, 3。编译器积极地优化了整个 malloc 调用和所有内容。只要程序的行为不受影响,C start 对允许哪些优化就相当宽容。

因此,在这种特定情况下,两个版本实际上都产生了相同的代码。编译器在处理固定数值常量时非常擅长优化代码。为了防止它进行此类优化,我们必须编写无法在编译时预测结果的代码。以下是一些实际导致调用malloc和堆分配的代码:

void func(size_t n, int val)
{
    int *start = malloc(n * sizeof(int));
    start[n-1] = val;
    int c = *(start + 1);
    printf("%d\n", c);
    free(start);
}

至于int c = *(start + i);,我们可能还注意到这是糟糕的风格。该代码 100% 相当于 ,int c = start[i];没有任何性能增益/损失。*(start + i)只是比 更难阅读和理解start[i],所以我们应该始终使用start[i]风格。事实上,[]运算符始终应用于指针,而不是数组 – 请参阅


最后,回到问题的本质:当两者都没有像上面那样优化时,int *ptr = &b; int c = *ptr;和之间的性能差异int c = start[i];一般是什么。前者比后者更快或相当,但绝不会更慢。

因为在前一种情况下,我们处理的是编译器在编译时已知的固定地址。任何明智的编译器都不会分配,ptr而只会获取硬编码的地址。但在后一种情况下,start是从获得的malloc,因此编译器不仅在编译时不知道地址,而且还必须执行实际的算术运算start + i。这仍然是一个非常快的操作,以至于手动优化毫无意义,但它不可能比前面的例子更快。

一般来说,通过索引访问数组是提高性能的好方法。无论目标系统如何,最快的代码通常来自形成查找表,然后通过索引访问它。这是内存优化的执行速度,几乎在任何系统上都是可行的。但在具有数据缓存内存的高端系统上,它特别可行,因为数组可以提前加载到缓存中。并且访问它的程序也可能被预取到指令缓存中。