优化存储访问
优化存储访问
代码和数据缓存
缓存是主存的代理。缓存是为了以最快的可能访问最常用的数据。
缓存组织
大多数chache以行和集合的方式组织。cache机制的更多细节,参考(en.wikipedia.org/wiki/L2_cache)。
如果程序中包含很多变量和对象,它们又刚好分布在映射到相同cache的内存,就会频繁引发cache冲突。
下边的小节,讨论如何尽量减少cache冲突。
一起使用的函数应该存储在一起
函数通常以它们在源代码中出现的顺序存储。因此,收集代码中关键部分使用的函数,并放到同一个源码文件中是一个好主意。把很少使用的函数与常用函数分离;很少运行的分支(例如,错误处理)放到函数结尾或单独封装一个函数。
有时,出于模块化的原因,函数会被保存在不同源码文件。这时,通过控制模块的链接顺序来尽量达到上述目的。链接顺序通常是模块在项目管理文件或Makefile中出现的顺序。通过一个来自链接器的映射文件来检查内存中函数的顺序。映射文件给出了每个函数与程序起始地址的相对地址。映射文件包含从静态库(.lib
或.a
)中链接的函数地址,但不包括动态库(.dll
或.so
)的。没有简易的方法来控制动态链接库函数的地址。
一起使用的变量应该存储在一起
缓存不命中的代价很高。取cache中的变量只需要几个时钟周期;如果cache不命中,则可能需要几百个时钟周期来获取RAM中的变量。
如果一起使用的数据在内存中也存储在彼此附近,那么cache可以高效的工作。变量和对象最好在使用它们的函数中声明。这些变量和对象会被存储在栈上,类似于Level-1 cache。变量存储的细节,可以参考变量存储。如果可能,避免使用全局和static变量,避免动态内存分配。
OOP是保存数据在一起的有效方式。一个类的数据成员总是一起保存在一个类的对象中。父类和子类的数据成员都保存在一个子类的对象中。
如果你有一个大的数据结构,数据存储的顺序是重要的。例如:有两个数组,其元素以a[0],b[0],a[1],b[1],...
被交替访问,那么可以通过用结构体数组方式组织数据来提高性能:
。。。
一些编译器会用不同的内存空间来存不同数组,即使它们从不同时使用。例如:
。。。
把简单变量放到union中不是优化的,因为会妨碍使用寄存器变量。
数据对齐
如果一个变量存储的地址可以被其大小整除,则变量的访问是最高效的。大小应该总是2的次幂。大于16字节的对象应该存在可以被16整除的地址。
结构体和类成员的对齐可能引发cache空间的浪费。参考例7.39。
你可以选择以cache行大小来对齐大的对象和数组,通常是64字节。这确保对象数组与cache行开头相符。一些编译器会自动对齐大的static数组,但你可能也最好显式指定对齐:alignas(64) int BigArray[1024];
。
后续讨论动态分配内存的对齐。
动态内存分配
对象和数组可以使用new
和delete
或malloc
和free
动态分配内存。如果在编译时无法得知所需内存大小时,可以动态分配。
四种动态分配内存的典型应用:
- 编译时不知道大小的大数组;
- 编译时不知道数目的对象;
- 可变大小的字符串或类似的对象;
- 对于栈来说太大的数组;
动态分配内存的好处:
- 一些情形中,使程序结构更清晰;
- 按需分配,不需要按最糟情形分配固定大小的内存。提高了数据cache效率;
- 当无法预先给出所需内存空间量的合理上限时,此功能非常有用;
动态分配内存的缺点:
- 动态分配内存和回收比较耗时。
- 不同大小的对象的随机顺序分配和释放,堆内存会碎片化。降低了数据缓存的效率。
- 堆管理会在堆内存碎片化严重时运行垃圾回收,这可能发生在不合适的时机。
- 在对象被释放后,程序员应该确保这个对象不可再被访问。
- 被分配的内存可能没有优化对齐。
- 对编译器来说,优化使用指针的代码是困难的,因为不可以排除别名。参考指针别名。
- 当矩阵或多维数组的行长度在编译时不可知(每次访问需要计算行地址)时,编译器可能不能使用归纳变量进行优化。
。。。
通常,为多个对象分配大块内存,比每个对象分配小块内存