C++性能优化笔记-8-优化存储访问

优化存储访问

代码和数据缓存

缓存是主存的代理。缓存是为了以最快的可能访问最常用的数据。

缓存组织

大多数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];
后续讨论动态分配内存的对齐。

动态内存分配

对象和数组可以使用newdeletemallocfree动态分配内存。如果在编译时无法得知所需内存大小时,可以动态分配。
四种动态分配内存的典型应用:

  • 编译时不知道大小的大数组;
  • 编译时不知道数目的对象;
  • 可变大小的字符串或类似的对象;
  • 对于栈来说太大的数组;

动态分配内存的好处:

  • 一些情形中,使程序结构更清晰;
  • 按需分配,不需要按最糟情形分配固定大小的内存。提高了数据cache效率;
  • 当无法预先给出所需内存空间量的合理上限时,此功能非常有用;

动态分配内存的缺点:

  • 动态分配内存和回收比较耗时。
  • 不同大小的对象的随机顺序分配和释放,堆内存会碎片化。降低了数据缓存的效率。
  • 堆管理会在堆内存碎片化严重时运行垃圾回收,这可能发生在不合适的时机。
  • 在对象被释放后,程序员应该确保这个对象不可再被访问。
  • 被分配的内存可能没有优化对齐。
  • 对编译器来说,优化使用指针的代码是困难的,因为不可以排除别名。参考指针别名
  • 当矩阵或多维数组的行长度在编译时不可知(每次访问需要计算行地址)时,编译器可能不能使用归纳变量进行优化。

。。。
通常,为多个对象分配大块内存,比每个对象分配小块内存更高效。

不太为人知的一个替代newdelete的方法是,用alloca来分配可变大小数组。这个函数在栈上分配内存而不是堆上。其分配的内存在离开调用alloca的函数后自动释放。
使用alloca的好处:

  • 分配过程代价很小,因为处理器对栈有硬件支持。
  • 因为栈的FIFO,不会产生碎片化。
  • 释放过程没有开销,因为会自动进行。也不需要垃圾回收。
  • 分配内存是连续的,使得数据缓存更有效率。

下边的例子说明如何用alloca进行可变大小数组的管理:
。。。
C99扩展支持可变大小数组。这个特性是有争议的,而且只在C中可用,C++不行。既然alloca提供相同的效果,可以考虑用alloca代替课表大小数组。

数据结构和容器类

无论何时用到动态内存分配,建议把分配内存操作封装到容器类中。容器类必须有析构函数,来保证所有其分配的都被释放。这是防止与动态内存分配相关的常见错误和内存泄露的最好办法。容器类的一个简单的替代方案是智能指针。

容器类给数组添加边界检查很方便,还有其他高级数据结构的特性也是如此,例如:FIFO、LIFO、排序和查找,二叉树、哈希表等。

通常容器类是模板形式。对象类型作为模板参数。使用模板没有性能代价。

C++11或更新的标准,有现成的容器类模板库。标准容器是通用的、灵活、良好测试的,可用于很多不同的目的。但标准容器为了通用性和灵活性,牺牲了效率。执行时间、节约内存、缓存效率和代码体积的优先级较低。尤其,很多标准容器类中有不必要的内存浪费。很多容器模板类,为每个对象分配一个新的内存块,甚至更多。std::vector在同一个内存块中存储所有的对象,但每次它填满后都要重新分配,重新分配过于频繁因为块大小每次只增加50%或更少每次。虽然std::vectorstd::vector::reserve可以提前分配内存来减少重新分配操作的频率,但许多其他容器没有这个提前保留内存的特性。

通过newdelete频繁分配和释放内存会引起碎片化,降低了缓存的效率。就像前边提到的,这是内存管理和垃圾回收的很大开销。

标准容器模板类的通用性是以代码体积为代价的。事实上,标准库已经因为代码臃肿和复杂性而受到批评Standard_Template_Library。存在标准容器中的对象都允许有构造函数和析构函数。每个对象的移动构造、拷贝构造和析构函数在每次移动对象时都调用,这可能很频繁。如果一个被存储的对象作为自身的容器,那么这是必要的。但用vectorvector的方式实现矩阵,当然是非常低效的方案。

很多容器类是用链表。链表是容器类扩展的方便的方式,但是很低效。在大多数情况,使用连续内存的线性数组比链表高效。

在容器中为了访问元素的所谓的迭代器,对很多程序员来说用起来很麻烦,并且如果可以使用带有索引的线性表时也是不必要的。一个好的编译器在一些情形下可以优化掉迭代器的额外开销,但不是所有情形都可以。

幸运地是,有很多高效的替代方案可用,它们把执行速度、节约内存和代码体积放到较高的优先级,而不是代码通用性。最重要的补救措施是内存池。在大内存块中存储很多对象,比单独给每个对象分配内存高效。一个存储很多对象的大内存块可以通过调用一次memcpy来进行拷贝和移动,而不是每个对象分别进行(如果对象没有移动构造、拷贝构造或析构)。后续会给出一些高效容器类的例子。

下列因素在选择容器时应该予以考虑:

  • 包含一个元素还是多个?如果容器只保存一个元素,那么用本底分配或智能指针更好。参考智能指针
  • 所需内存大小在编译时是否已知?如果元素的最大数量在编译时已知或可以设置一个不太大的上限,那么优化的方案是一个固定大小的数组。但是,如果数组或容器对栈来说太大,那么需要用动态内存分配。
  • 所需内存大小是否在第一个元素存储前可知?如果元素的总数在第一个元素存储前可知,或者可以合理估算;那么使用诸如std::vector这类可以预先保留所需内存的容器比一块一块逐渐分配更好。
  • 所需内存是大还是小?大而复杂的方案可能并不比小的数据结构更值得,即使需要查找、添加和移除对象的特性。在无序数组中,线性查找当然不是最高效的算法,但如果数组很小,那它可能是最好的方案。
  • 对象是有限连续的吗?如果对象被一个有效范围内连续的索引或关键字标识,那么一个简单数组是最高效的方案。
  • 需要用到多维结构吗?一个矩阵或多维数组应当存储在一个连续内存块。不要给每行或列都用一个容器。如果每行的元素数目在编译时可以确定为常量,那么访问会快很多。
  • 对象集合是以FIFO方式访问吗?如果访问基于先进先出,那么使用队列。以循环缓冲区方式实现队列,比链表更高效。
  • 对象集合是以LIFO方式访问吗?如果访问基于后进先出,那么用带一个栈顶索引的线性数组更好些。
    。。。

标准C++容器

标准C++容器类模板库给组织数据的常见问题提供了方便的、进过良好测试的解决方案。不幸的是,这些容器相当复杂并且不总是有效率的。链表尤其低效,因为它存储数据对象在碎片化的内存,结果就是糟糕的cache性能。代码有大量性能开销。优化强大的编译器可以消除大多数性能开销,但代码的复杂性让其仍然是很难理解和调试的。常见的C++容器类模板如下:

  • std::array。就像一个简单数组,size在编译时就固定。不进行边界检查。
  • std::vector。像一个线性数组。元素存储在连续的内存块。size可增加。如果原有内存块不够用,则重新分配一个内存块(大50%),把原来的数据拷贝至新的内存块。在添加第一个元素前,预估所需内存大小并使用reserve函数预留内存是重要的。这能减少重新分配操作。std::vector是最简单、最高效的利用动态内存分配的标准容器。
  • std:deque。双端队列。可以用作线性数组,也可用作可以在两端高效地插入和移除元素的队列。相比链表,deque可能分配较少的内存块,因为每个内存块可以容纳很多元素如果元素较小。它允许随机访问列表中的任何元素,就像数组一样。deque在线性数组和链表之间提供了可用的妥协方案。
  • std::forward_list。是一个链表。每个元素都分配一个新的内存块。元素智能顺序访问。列表可以排序,但是排序操作是低效的。
  • std::list。双链表。两端都可以添加和移除元素。类似于forward_list,但是list能够向前和向后遍历。
  • std::stack。LIFO列表。是双端队列的一个特例。
  • std::set。是一个有序列表。其中的对象以它们的值标识,以值排序。元素可以随机添加和移除。查找也是高效的。set实现方式是平衡二叉树(红黑树)。如果元素可以随机添加和查找,这是有用的。
  • std::map。类似于set::set。不同之处是,map包含键值对。元素按key标识和排序。
  • std::unorderd_set。在元素不是自然有序时,可以作为std::set的替代方案。实现方式是哈希表。查找、添加和移除元素都是高效的。
  • std::unordered_map。类似于unordered_set,是键值对容器。

创建自己的容器类

高效的容器类应该分配尽可能少的内存块。最好,所有的数据存储在一个连续的内存块。只有在合理的最大内存大小无法在编译时得知的情况,使用动态分配内存才是必要的。容器容纳的对象类型可以很方便的通过模板参数指定。记住,定义一个析构函数,以释放分配的内存。

例子9.4展示了一个线性数组的实现。它在构造函数里指定大小,元素通过[]操作符访问,添加了索引越界。
。。。
例子9.5展示了一个LIFO(即栈)的实现。它通过模板参数指定固定的最大大小。不需要动态内存分配。
。。。
例子9.6展示了FIFO列表(即queue)的一个实现。通过模板参数指定最大容量。不需要动态内存分配。实现底层是循环缓冲区方式。
。。。
C++标准目前没有定义一个高效的方式来创建一个在运行时确定维度的矩阵。例子9.7展示了一个可能的方案:
。。。
如果标准库容器的通用性和灵活性对项目来说不是必要的,可以考虑实现自己的容器类来满足特殊的需求。

字符串

字符串的长度是编译时无法确定的典型。存储字符串的类,例如stringwstring。这些类在每次创建和修改一个字符串时,使用newdelete分配一个新的内存块。如果程序频繁地创建和修改字很多字符串,那这会非常低效。
在大多数情形,最快的处理字符串的方式是C风格的字符数组。字符串可以用C函数操作,如strcpy,strcat,strlen,sprintf等。但要注意,这些函数都没有数组溢出检查。数组溢出可能在程序无法确定的地方引发无法预测的错误,这种错误很难诊断。确保数组足够大来处理字符串,并在需要的地方做溢出检查,这些是程序员的责任。
如果想要在不妨碍安全性的前提下提高速度,你可以把所有的字符串存储到一个内存池中。就像前边说的。

顺序访问数据

当连续访问数据时,cache可以很高效。倒序访问数据会降低cache效率;随机访问则更低效。这对读和写都适用。
访问多维数组应当在最内层循环改变最后边的索引值。这反映了元素在内存的存储次序。例如:

// Example 9.8
const int NUMROWS = 100, NUMCOLUMNS = 100;
int matrix[NUMROWS][NUMCOLUMNS];
int row, column;

for (row = 0; row < NUMROWS; row++)
    for (column = 0; column < NUMCOLUMNS; column++)
        matrix[row][column] = row +column;

不要交换这两个循环的次序(除了Fortran,它的存储次序是相反的)

大数据结构中的cache竞争

顺序访问多维数组不总是可能的。一些应用(例如,线性代数)要求其他的访问模式。如果多维数组的行之间的距离等于cache分区大小,这可能引起延迟。如果矩阵行的大小是2的次幂,会产生延迟。

下边的例子说明了上述情况。
。。。
这个例子的代码的问题是:如果对角线下的元素matrix[r][c]按行访问,那么对应的元素matrix[c][r]就需要按行访问。

可以根据cache的大小,给matrix添加额外的空列,以提高cache效率。
但也有一些情况,无法给matrix添加空列。例如,一个数学函数库应该对所有的大小的矩阵都高效适用。这种情况下,一个有效的方案是把矩阵分成小的方阵分别处理。具体方法如下所示:
。。。
level-2 cache的不命中的代价如此高昂,以至于做一些处理是很重要的。因此,应该注意矩阵列数是2的高次幂的情形。level-1 cache的冲突代价相对不高。为level-1 缓存使用复杂的冲突缓解技术可能不值得。

square blocking和类似的方法可以参考S。Geodecker和A. Hoisie的“Performance Optimization of Numberically Intensive Codes”。

显式cache控制

支持SSE和SSE2指令集的微处理器允许你操作数据缓冲。这些指令可以通过内建函数访问。

功能程序集名称内建函数名指令集
预取PREFETCH_mm_prefetchSSE
加载4字节,不使用cacheMOVNTI_mm_stream_si32SSE2
加载8字节,不使用cacheMOVNTI_mm_stream_si64SSEs
加载16字节,不使用cacheMOVNTPS_mm_stream_psSSE
加载16字节,不使用cacheMOVNTPD_mm_stream_pdSSE2
加载16字节,不使用cacheMOVNTDQ_mm_stream_si128SSE2
表9.2 Cache控制指令
除了表9.2中列出的,也有其他的指令,例如flush和fence,但这些与优化几乎没有关系。

预取数据

预取指令可以用来取一个cache行,这行稍后会在程序流中用到。不过,得益于乱序执行和先进的预测机制,现代处理器会自动预取数据,所有人工预取指令常常没有进一步提升执行速度的效果。如果数据能够以固定步幅周期性的模式访问,处理器可以自动预取数据,此时不需要显式的进行数据预取。

未缓存的内存的写操作

写未缓存的内存比读未缓存的内存更耗时,因为写操作会引发整个cache行读和回写。
所谓的非临时些指令(MOVNT)被设计来解决这个问题。这个指令直接写内存,而不加载cache行。在将要写的内存或其地址附近的内存不会在对应的cache行失效前被读取的情形中,直接写内存是有益的。但注意,不要把非临时写和对相同内存地址的普通的写或读操作混合在一起。
非临时写指令不适用与例子9.9,因为其实对相同地址的读写,所以无论如何cache行都会被加载。如果修改为只写,那么非临时写指令就有效果了。下边的例子转置一个矩阵,并且存储结果到一个不同的数组。
。。。
如果有掐方式避免cache冲突,那么非临时写指令不是最优的选择。

当然表9.2中的指令的使用也有限制条件。所有的指令要求微处理器支持SSE或SSE2。16-byte指令(MOVNTPS,MOVNTPD,MOVNTDQ)要求操作系统支持XMM寄存器。

欢迎交流
在这里插入图片描述

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值