一、cache性能特点
优异的cache性能很大程度上依赖于cache lines(缓存行)的重复使用,优化的最主要目标也在于此,一般通过恰当的数据和代码内存布置,以及调整CPU的内存访问顺序来达到此目的。由此,应该熟悉cache内存架构,特别是cache内存特点,比如line size, associativity, capacity, replacement scheme,read/write allocation, miss pipelining和write buffer.另外,还需要知道什么条件下CPU STALLS会发生以及产生延时的cycle数。只有清楚了这些,才能清楚如何优化cache。
二、优化cache
L1 cache的特点(capacity、associativity、linesize)相对于L2 cache来说更具局限性,优化了L1 cache几乎肯定意味着L2 cache也能得到有效使用。通常,仅优化L2 cache效果并不理想。建议将L2 cache用于一般的类似控制流程等大量内存访问无法预测的部分。L1和L2 SRAM应该用于时间性非常重要的信号处理算法。数据能够用EDMA或IDMA直接导入L1 SRAM,或用EDMA导入L2 SRAM。这样,可使L1 cache的mem访问效率获得优化。
有两种重要方法来减少cache ovehead:
1. 通过以下方式减少cache miss数量(L1P,L1D,L2 cache):
a. cache line reuse最大化
>访问cached行中的所有mem位置(应该是对多路组相联才有效,直接映射地址是一对一的)。进入cache行中的数据花费了昂贵的stall cycles,应该被使用;
>cached line中的同一内存位置应该尽可能的重复使用。
b. 只要一行被使用,将要避免牺牲该行
2. 利用miss pipelining,减少每次miss的stall cycles数
cache优化的最好策略是从上到下的方式,从应用层开始,到程序级,再到算法级别的优化。应用层的优化方法通常易于实现,且对整体效果改善明显,然后再配合一些低层次的优化策略。这也是通常的优化顺序。
应用层级应考虑的几点:
>用DMA搬进/出数据,DMA buffer最好分配在L1或L2 SRAM,出于以下考虑。首先,L1/L2 SRAM更靠近CPU,可以尽量减少延迟;其次,出于cache一致性的考虑。
>L1 SRAM的使用。C64x+提供L1D 和L1P SRAM,用于存放对cache性能影像大的代码和数据,比如:
@ 至关重要的代码或数据;
@ 许多算法共享的代码或数据;
@ 访问频繁的代码或数据;
@ 代码量大的函数或大的数据结构;
@ 访问无规律,严重影像cache效率的数据结构;
@ 流buffer(例如L2比较小,最好配置成cache)
因为L1 SRAM有限,决定哪些代码和数据放入L1 SRAM需要仔细考虑。L1 SRAM 分配大,相应L1 cache就会小,这就会削弱放在L2和外部内存中代码和数据的效率。 如果代码和数据能按要求导入L1 SRAM,利用代码和/或数据的重叠,可以将L1 SRAM设小点。IDMA能够非常快的将代码或数据page到L1。如果代码/数据是从外部page进来,则要用EDMA。但是,非常频繁的paging可能会比cache增加更多的overhead。所以,必须在SRAM和cache大小之间寻求一个折中点。
>区别signal processing 和 general-purpose processing 代码
后者通常并行性不好,执行过程依赖于许多条件,结果大多无法预测,比如滤波模块,数据内存访问大多随机,程序内存访问因分支条件而异,使得优化相当困难。鉴于此,当L2不足以放下整个代码和数据时,建议将其代码和数据放到片外,并允许L2 能cache访问到。这样腾出更多的L2 SRAM空间存放易于优化,结构清晰的前者代码。由于后者代码的无法预测性,L2 cache应该是设的越大越好(32k~256k). 前者比较有规律的代码和数据放到L2 SRAM或L1 SRAM更为有利。放到L2,可以允许你根据CPU对数据的访问方式来修改算法,或调整数据结构,以获得更好的cache友好性。放到L1 SRAM,无需任何cache操作,并且除非bank冲突,无需做memory 优化。
procedural(程序)级的优化:
优化目的是减少cache miss,以及miss带来的stall数。前者可通过减少被cache的内存大小并重复使用已经cached lines来获得。尽量避免牺牲行并尽可能写已经分配的行可以提高重用率。利用miss pipelining可以减少stall数。以下根据三种不同类型的读miss来分析优化的方法。
>选用合适的数据类型,以减少内存需要
16位可以表示的数不要定义成32位,这不但可以省一半内存消耗,而且减少compulsory miss。这种优化容易修改,无需改动算法,而且小数据类型容易实现汇编的SIMD。在不同系统平台端口间的数据流动,容易出现这种低效的数据类型。
>处理链
前一算法的输出是后一算法的输入。如果输出、输入不是同一级内存地址,后一算法使用前一算法结果时就存在读miss的消耗。这个时候就要考虑两者空间如何布置。如果超过两个数组映射到L1D的同一个set,则会产生conflict miss(L1D cache是2-way set-associative),故应该将这些数组连续分配(详见P55)
>避免L1P conflict miss
即使cpu需要的指令全在L1P cache(假定无capacity miss),仍然可能会产生conflict miss。以下解释conflict miss是如何产生的,又如何通过code在内存中的连续存放来消除miss。例如:
for(i=0; i<N; i++)
{ function_1(); function_2(); }
如果func2在L2中的位置正好与func1有部分处于同一set中,而L2 cache是4-way set-associativity,处于同一set的指令在被L1P cache循环读取后,可能会出现conflict miss(如刚读入func1,然后读入func2,可能会驱逐掉func1在L1P中的部分cache lines).这种类型的miss是完全可以消除掉的,通过将这两个函数的代码分配到不冲突的set中,最直接具体的方法是将这两个函数在内存中连续存放,存放的方法有
二:
1. 使用编译器选项 -mo,将各C和线性汇编函数放到各自独立的section,其中汇编函数必须被放到以.sect标示的sections中。然后检查map file,获取各函数的段名,比如上例.text:_function1和.text:function_2。则linker命令文件如下:
...
SECTIONS
{ .cinit > L2SRAM
.GROUP > L2SRAM (在CCS3.0及以后,.GROUP标示用于强制指定段的link顺序)
{ .text:_function1 .text:function_2
.text
}
.stack > L2SRAM
.bss > L2SRAM
...
}
linker会严格按照GROUP申明的顺序来link各段。上例中,先func1,然后是func2,然后是.text section中的其它函数。但要注意,使用-mo后会导致整个code尺寸变大,因为包含code的任何段都要按32-byte边界对齐。
2. 为避免-mo只能指定section,而不能单独指定函数的不足,如果仅需要函数连续排放,我们可以在定义函数前,通过#pragma CODE_SECTION来为函数指定sections:
#pragma CODE_SECTION(function_1,".funct1")
#pragma CODE_SECTION(function_2,".funct2")
void function_1(){...}
void function_2(){...}
这样,linker命令文件如下:
...
SECTIONS
{
.cinit > L2SRAM
.GROUP > L2SRAM
{
.funct1.funct2
.text
}
.stack > L2SRAM
...
}
结合上例可见,在同一循环里面或在某些特定时间帧里面反复调用的多个函数,需要考虑重排。如果L1P cache不够大,不足以放下所有循环内函数,则循环必须被拆开来,以保证code无驱逐的重用。但这会增大内存消耗,上函数拆分成如下:
for (i=0; i<N; i++)
{ function_1(in[i], tmp[i]); } //++很显然需要增大tmp[],以保存func1每
for (i=0; i<N; i++) //++次循环的输出结果,作为func2的输入
{ function_2(tmp[i], out[i]); }
>freezing L1P cache
调用CSL函数: CACHE_freezeL1p()与CACHE_unfreezeL1p()可以控制L1P cache,阻止其分配新行,freezing后,cache内容就不会因conflict而牺牲,但其他所有如dirty比特、LRU更新、snooping等等cache行为仍然是一样的。肯定会被重用的code,如果因为其他仅执行一次的code而被驱逐掉,比如中断程序等,可以采用这个函数来避免。
>避免L1D conflict miss
L1P是直接映射型cache,如果cpu访问的地址没有包含在同一cache line内,则会相互evict。然而,L1D是2-way set-associative,对直接映射来说是conflict 的两lines却能够同时保存在cache中,只有当第三个被访问分配的memory地址仍映射到同一set时,早前分配的两个cache lines将根据LRU规则牺牲掉一行。L1D的优化方法与上面L1P类似,区别在于前者是2-way set-associative,而后者是direct-mapped,这意味着对L1D,两个数组能够映射到同一set,并同时保存在L1D。
@定义数组后,通过编译选项-m生成map file可以查看给该数组分配的地址。
与L1P类似,如果不连续定义数组,会导致各种miss(具体各数组是如何映射到L1D cache各way各set的,没看明白,P61),为避免读miss,应在内存中连续分配各数组。注意,因为linker的内存分配规则,在程序中连续定义数组,并不表示他们在内存中的地址也是连续的(比如,const数组会放在.const section而非.data section中)!因此,必须将数组指定到用户定义的段:
#pragma DATA_SECTION(in1, ".mydata")
#pragma DATA_SECTION(in2, ".mydata")
#pragma DATA_SECTION(w1, ".mydata")
#pragma DATA_SECTION(w2, ".mydata')
#pragma DATA_ALIGN(in1, 32) //++ 数组按照cache line边界对齐
short in1 [N];
short in2 [N];
short w1 [N];
short w2 [N];
@另注意:为避免memory bank冲突,非常有必要将数组按不同memory bank对齐,如:
#pragma DATA_MEM_BANK(in1, 0)
#pragma DATA_MEM_BANK(in2, 0)
#pragma DATA_MEM_BANK(w1, 2)
#pragma DATA_MEM_BANK(w2, 2)
@利用miss pipelining可以进一步减少miss stalls。利用touch loop来为四个数组在L1D cache中预分配空间,因为数组物理连续,故只需调用一次touch程序:
touch(in1, 4*N*sizeof(short));
r1 = dotprod(in1, w1, N);
r2 = dotprod(in2, w2, N);
r3 = dotprod(in1, w2, N);
r4 = dotprod(in2, w1, N);
====>touch loop的意义和实现:意义是为了最大限度实现miss piplining。如果连续访问mem,因为一次miss,会搬移一个cacheline,则随后的访问就会hit,miss不能实现overlap。因此,为获得stalls的完全重叠,可以考虑在一个cycle内同时访问两个新的cacheline,即按两个cachelines的间距遍历mem。TI提供的汇编函数“touch”,用于在L1D cache中预先分配长为length的数组buffer,它对每两个连续cache lines 分别并行load一个byte。为避免bank conflict,这两个并行load之间偏移一个word。 (c64x采用基于LSB的mem bank结构,L1D分成8个bank,每个bank宽32-bit,共2K,这些bank均为single port输入,每个cycle允许一个访问,与c621x/c671x的单bank多输入口有区别。这样,对同一bank同时进行读和写访问,总是会造成stall,而同时对同一bank进行读或写,只要满足一定条件,就不会产生stall)。
>避免L1D thrashing ---具体图示详见two-level-->3-38
这种Miss情况下,数据集比cache大,连续分配,但数据不需要reused,发生conflict miss,但无capacity miss发生(因为数据不reused)。 对同一set发生两个以上的读miss,这样在该行全部数据被访问前就将该行驱逐掉了,这种情况就是thrashing.假定所有数据在mem中是连续分配的,这样只有当被访问的所有数据集超过L1D cache容量时才会发生thrashing.这种conflict miss是可以完全避免的,通过在mem中连续分配数据集,并嵌入一些多余数组,强制将数据交叉映射到cache sets。比如:
int w_dotprod(const short *restrict w, const short *restrict x, const short *restrict h, int N)
{ int i, sum = 0;
_nassert((int)w % 8 == 0); //++如果w[],x[],h[]三个数组在内存中都映射到
_nassert((int)x % 8 == 0); //++同一L1D cache set,则L1D thrashing发生。当前读入
_nassert((int)h % 8 == 0); //++的w,x被随后读入的h给替换掉了....
#pragma MUST_ITERATE(16,,4)
for (i=0; i<N; i++)
sum += w[i] * x[i] * h[i];
return sum; }
处理办法是在w,x后填充一个cache行大小的数,使h[0]往下偏移一行,映射到下一set:
#pragma DATA_SECTION(w, ".mydata")
#pragma DATA_SECTION(x, ".mydata")
#pragma DATA_SECTION(pad, ".mydata")
#pragma DATA_SECTION(h, ".mydata")
#pragma DATA_ALIGN (w, CACHE_L1D_LINESIZE)
short w [N];
short x [N];
char pad [CACHE_L1D_LINESIZE];
short h [N];
对应linker命令文件如下指定:
...
SECTIONS
{ GROUP > L2SRAM
{ .mydata:w
.mydata:x
.mydata:pad
.mydata:h }
...
}
对应于我们的应用,L1D如果设cache为32k,放在DDR中的重建或插值帧数据应该考虑到以上问题,一帧的重建数据远大于32k,这时必然需要考虑被处理数据在DDR中怎么排放才能避免cache中Line evict。为了能够明确数据在cache的set映射,应该将DDR划分成16k的CE小段,然后数据按小段布置,这样容易做到主观对应也便于操作数据(这估计也是示例中linker划分很多CE external段的原因)。 原来的理解是错误的,首先LRU策略理解有误,它只针对行,而非全部cache空间;在编译连接重定位后,不管是数据还是指令,都有了确定的地址和大小,以及在memory中的位置,这样它们到cache的映射set也已经确定下来了,故理论上通过地址排放,我们应该能确定的了解到数据和代码在cache中的具体运行情况,知道了这些,就可以针对性的采取措施以尽量避免miss和stall了。
>避免capacity miss---具体图示见two-level-->3-41
这种情况下,数据需要重用reused,但是数据集比cache大,造成capacity和conflict miss。通过分裂数据集,一次处理一个数据子集,可以避免这种miss,这种方法叫做blocking或tiling. 下面以例子说明原因和处理方法。点积函数,调用4次,一个参考矢量,四个不同的输入矢量。
short in1[N];
short in2[N];
short in3[N];
short in4[N];
short w [N];
r1 = dotprod(in1, w, N);
r2 = dotprod(in2, w, N);
r3 = dotprod(in3, w, N);
r4 = dotprod(in4, w, N);
假定每个数组都是L1D cache容量的两倍,对w来说,除了第一次调用需要compulsory miss外,之后应该保存在cache里面重用是最合理的,但分析可知,当处理到N/4的输入数据时,最先进入cache的w就开始被驱逐了,这样w将会被反复多次读入cache,非常浪费。可以考虑加个循环,每次只处理N/4的数据,保证w在读进cache后,直到用完才驱逐,修改如下:
for (i=0; i<4; i++) {
o = i * N/4;
dotprod(in1+o, w+o, N/4);
dotprod(in2+o, w+o, N/4);
dotprod(in3+o, w+o, N/4);
dotprod(in4+o, w+o, N/4); }
可以利用miss pipelining进一步减少read miss stalls.在每次循环开始用touch循环预先在cache分配w[],这样在每次调用点积函数前,需要的输入数组都准备好了:
for (i=0; i<4; i++) {
o = i * N/4;
touch(w+o, N/4 * sizeof(short));
touch(in1+o, N/4 * sizeof(short));
dotprod(in1+o, w+o, N/4);
touch(w+o, N/4 * sizeof(short)); //++每次touch in[]前都要touch w[]是为了保证w[]为MRU,
touch(in2+o, N/4 * sizeof(short)); //++以防访问顺序发生改变导致w[]被驱逐掉。
dotprod(in2+o, w+o, N/4);
touch(w+o, N/4 * sizeof(short));
touch(in3+o, N/4 * sizeof(short));
dotprod(in3+o, w+o, N/4);
touch(w+o, N/4 * sizeof(short));
touch(in4+o, N/4 * sizeof(short));
dotprod(in4+o, w+o, N/4); }
另外,本例中为避免bank conflict,数组w[]和in[]应该对齐到不同的memory banks:
#pragma DATA_SECTION(in1, ".mydata")
#pragma DATA_SECTION(in2, ".mydata")
#pragma DATA_SECTION(in3, ".mydata")
#pragma DATA_SECTION(in4, ".mydata")
#pragma DATA_SECTION(w , ".mydata")
#pragma DATA_ALIGN(w, CACHE_L1D_LINESIZE) //++意味着已#pragma DATA_MEM_BANK(w, 0)
short w [N];
#pragma DATA_MEM_BANK(in1, 2) //++avoid bank conflicts
short in1[N];
short in2[N];
short in3[N];
short in4[N];
这个例子in1~in4的容量为L1D cache的两倍,假如为32k,这样,假定已经从头对齐,按照上面变量的定义顺序,则in1的0~8k将映射到 cache的way0-set0开始,9~16k映射到 cache的way1-set0开始,17~24k映射到 cache的way0-set0开始,25~32k映射到 cache的way1-set0开始,in2的0~8k映射到 cache的way0-set0开始......这样布置后,w刚好从way0-set0开始映射。由此,dotprod(in1,w,N)开始后,in1与w分别进入set0-way0/way1,......到N/4时,超过cache的8k/way容量,9~16k的In1开始进入set0-way0,这是合理可接受的,因为in1的前8k line data已经不再需要了;但同时w的9~16k也开始进入set0-way1,将其前8k的line data替换掉了,这就不合理了,因为后面计算in2的时候还需要用到w的0~8k。这样分析后,可见使用一个简单循环就避免了这个问题。
这个例子的启示是:数据排放不变(有时需要定义的数组太大,必须连续的空间,排放时不方便灵活处理),通过改变程序,从而改变使用数据的顺序,一样可以达到一line data进入cache后,直到用完才释放的cache使用终极目的。
>避免write buffer相关的stalls
WB只有4个入口,且深度有限,如果WB满,而又出现写miss,则CPU就会stall直到有WB有空间为止。同时,read miss会使得write buffer完全停止,因此保证正确的read-after-write顺序非常重要(read miss需要访问的数据很可能仍然在WB中)。通过在L1D cache中分配输出buffer(事先将输出buffer cache进入L1D),可以完全避免WB相关的stalls,这样write操作会在输出buffer中hit,而非由WB写出。事实上,输出buffer是在循环执行过程中逐渐进入L1D的,在此过程中还是会存在read miss的。
void vecaddc(const short *restrict x, short c, short *restrict r, int nx)
{ int i;
for (i = 0 ; i < nx; i++)
r[i] = x[i] + c;
}
/
{
short in[4][N];
short out [N];
short ref [N];
short c, r;
for (i=0; i<4; i++)
{
vecaddc(in[i], c, out, N);
r = dotprod(out, ref, N);
}
}