优化C++软件(10)

9. 优化内存访问

9.1. 代码与数据缓存

在计算机中,缓存是主内存的代理。这个代理比主内存小且更接近CPU,因此访问它快得多。为了实现对大多数使用数据最快可能的访问,可能有2到3级缓存。

CPU速度与RAM内存速度的差异持续增加。因此,高效缓存越来越重要。

9.2. 缓存组织

如果你正在写有非连续访问的大数据结构的程序,且你希望防止缓存竞争,知道缓存如何组织是有帮助的。如果你满足于更有启发性的指引,可以跳过这一节。

大多数缓存组织为行与组。让我以一个例子来解释。我的例子是一行64字节、8kb的缓存。每行覆盖内存中连续64字节。1k字节是1024字节,因此可以算出行数是8*1024/64 = 128。这些行被组织为32组 x 4路。这表示特定的内存地址不能载入任意的缓存行。32组中仅其中一个可用,但可以使用该组中(4行)任意一行。通过公式:(set) = (memory address) / (line size) % (number of sets),我们可以算出,对特定的内存地址,使用哪一组缓存行。这里,/表示带截断的整形除法,%表示取模。例如,如果我们希望读内存地址a = 10000,那么我们有(set) = (10000 / 64) % 32 = 28。这表示a必须读入组号28的4个缓存行中的一个。如果使用16进制数,计算会更简单,因为所有的数都是2的指数。使用16进制数,我们有a = 0x2710 and (set) = (0x2710 / 0x40) % 0x20 = 0x1C。从地址0x2710读或写一个变量,会使缓存将地址0x2700到0x273F的64或0x40字节载入0x1C组4条缓存行中的一条。如果程序后面读写这个范围里的任意其他地址,这个值已经在缓存中,因此我们无需等待另一个内存访问。

假设一个程序从地址0x2710读,随后从地址0x2F00、0x3700、0x3F00及0x4700读。这些地址都属于组0x1C。每组中仅有4个缓存行。如果缓存总是选择最近最少使用的缓存行,那么在读0x4700时,覆盖地址访问0x2700到0x273F的行将被逐出。再读地址0x2710将导致一次缓存不命中。但如果程序之前读了具有其他组值的其他地址,那么包含地址范围0x2700到0x273F的行将仍然留在缓存中。这个问题仅出现一次,因为地址相距0x800的倍数。我将称这个距离为关键步长(critical stride)。在内存距离是关键步长倍数的变量将竞争相同的缓存行。关键步长可以计算作:(critical stride) = (number of sets) x (line size) = (total cache size) / (number of ways)。

如果程序包含许多分散在内存里的变量与对象,那么存在几个变量恰好相距关键步长倍数,导致数据缓存中竞争的风险。如果有许多函数分散在程序内存里,相同的事情也会发生。如果在程序同一个部分里使用的几个函数恰好相距关键步长倍数,这会在代码缓存里导致竞争。下面的章节描述避免这些问题的各种方法。

更多关于缓存如何工作的细节,可以在Wikipedia的CPU cache部分找到(en.wikipedia.org/wiki/L2_cache

不同处理器的缓存组织细节在手册3《Intel,AMD与VIA CPU微架构》里描述。

9.3. 一起使用的函数应该一起保存

如果彼此在附近使用的函数在代码内存中也保存在彼此附近,代码缓存工作效率最高。函数通常以出现在源代码中的次序保存。因此,将在代码最关键部分中使用的函数彼此邻近放在同一个源文件里,是一个好主意。将常用函数与很少使用的函数分开,将很少使用的分支,比如错误处理,放在函数末尾或一个独立的函数里。

有时,出于模块化的原因,函数被保存在不同的源文件里。例如,父类的成员函数在一个源文件,派生类在另一个源文件,可能是方便的。如果从程序的同一个关键部分调用父类与派生类的成员函数,将这两个模块在程序内存里连续存放是有好处的。这可以通过控制模块链接的顺序的来实现。链接顺序通常是模块出现在项目窗口或makefile中的顺序。通过向链接器要求一个映射文件,你可以检查函数在内存中的顺序。映射文件显示每个函数相对于程序开头的地址。映射文件包括从静态库(.lib或.a)链接的库函数地址,但没有动态库(.dll或.so)。控制动态链接库函数的地址,没有容易的方法。

9.4. 一起使用的变量应该一起保存

缓存不命中的代价非常高。从缓存获取一个变量仅需几个时钟周期,但如果这个变量不在缓存里,从RAM内存获取它,需要超过100个时钟周期。

如果一起使用的数据片段在内存中保存在彼此邻近,缓存的效率最高。变量与对象最好声明在使用它们的函数中。这样的变量与对象将保存在栈上,它很可能在1级缓存里。不同类型变量的储存在第17页解释。尽可能避免全局与静态变量,避免动态内存分配(new与delete)。

面向对象编程是将数据保持在一起的有效方式。一个类的数据成员(也称为属性)总是保存在该类的一个对象中。一个父类与一个派生类的数据成员保存在该派生类的一个对象中(参考第39页)。

如果你有大的数据结构,数据保存的次序会是重要的。例如,如果程序有两个数组,a与b,以a[0],b[0],a[1],b[1],……的顺序访问元素,那么通过经数据组织为一个结构体数组,可以改进性能:

// Example 9.1a

int Func(int);

const int size = 1024;

int a[size], b[size], i;

...

for (i = 0; i < size; i++) {

     b[i] = Func(a[i]);

}

在这个例子中,如果数据组织为下面的方式,可以被顺序地访问:

// Example 9.1b

int Func(int);

const int size = 1024;

struct Sab {int a; int b;};

Sab ab[size];

int i;

...

for (i = 0; i < size; i++) {

     ab[i].b = Func(ab[i].a);

}

在程序代码中,制作例子9.1b的结构体,没有额外的开销。相反,代码变得更简单,因为它仅需要对一个数组,而不是两个,计算元素地址。

某些编译器对不同的数组使用不同的内存空间,即使它们从不会在同一时间里使用。例子:

// Example 9.2a

void F1(int x[]);

void F2(float x[]);

void F3(bool y) {

      if (y) {

           int a[1000];

           F1(a);

       }

       else {

            float b[1000];

            F2(b);

       }

}

这里,对a与b使用相同的内存区域是可能的,因为它们的生命期不重叠。通过在一个联合中合并a与b,可以节省大量的缓存空间:

// Example 9.2b

void F3(bool y) {

      union {

            int a[1000];

            float b[1000];

      };

      if (y) {

            F1(a);

      }

      else {

            F2(b);

      }

}

当然,使用联合不是一个安全的程序实践,因为如果a与b的使用重叠,你将不会得到编译器的警告。你仅应该对占用大量缓存空间的大对象使用这个方法。将简单变量放在一个联合里不是最优的,因为它阻碍了寄存器变量的使用。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值