Cache优化

一、cache性能特点

      优异的cache性能很大程度上依赖于cache lines的重复使用,优化的最主要目标也在于此,一般通过恰当的数据和代码内存布置,以及调整CPU的内存访问顺序来达到此目的。由此,应该熟悉cache内存架构,特别是cache内存特点,比如linesize, associativity, capacity, replacement scheme,read/write allocation, misspipelining和writebuffer.另外,还需要知道什么条件下CPU STALLS会发生以及产生延时的cycle数。只有清楚了这些,才能清楚如何优化cache。

二、优化cache

      L1 cache的特点(容量、associativity、linesize)相对于L2cache来说更具局限性,优化了L1 cache几乎肯定意味着L2 cache也能得到有效使用。通常,仅优化L2 cache效果并不理想。建议将L2 cache用于一般的类似控制流程等大量内存访问无法预测的部分。L1和L2 SRAM应该用于时间性非常重要的信号处理算法。数据能够用EDMA或IDMA直接导入L1 SRAM,或用EDMA导入L2 SRAM。这样,可使L1cache的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的stallcycles数

    cache优化的最好策略是从上到下的方式,从应用层开始,到程序级,再到算法级别的优化。应用层的优化方法通常易于实现,且对整体效果改善明显,然后再配合一些低层次的优化策略。这也是通常的优化顺序。

应用层级应考虑的几点:
>用DMA搬进/出数据,DMA buffer最好分配在L1或L2 SRAM,出于以下考虑。首先,L1/L2 SRAM更靠近CPU,可以尽量减少延迟;其次,出于cache一致性的考虑。
>L1 SRAM的使用。C64x+提供L1D 和L1PSRAM,用于存放对cache性能影像大的代码和数据,比如:
    @ 至关重要的代码或数据;
    @ 许多算法共享的代码或数据;
    @ 访问频繁的代码或数据;
    @ 代码量大的函数或大的数据结构;
    @ 访问无规律,严重影像cache效率的数据结构;
    @ 流buffer(例如L2比较小,最好配置成cache)
    因为L1 SRAM有限,决定哪些代码和数据放入L1 SRAM需要仔细考虑。L1 SRAM 分配大,相应L1cache就会小,这就会削弱放在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友好性。放到L1SRAM,无需任何cache操作,并且除非bank冲突,无需做memory优化。

procedural级的优化:
   优化目的是减少cache miss,以及miss带来的stall数。前者可通过减少被cache的内存大小并重复使用已经cached lines来获得。尽量避免牺牲行并尽可能写已经分配的行可以提高重用率。利用miss pipelining可以减少stall数。以下根据三种不同类型的读miss来分析优化的方法。
>选用合适的数据类型,以减少内存需要
    16位可以表示的数不要定义成32位,这不但可以省一半内存消耗,而且减少compulsory miss。这种优化容易修改,无需改动算法,而且小数据类型容易实现汇编的SIMD。在不同系统平台端口间的数据流动,容易出现这种低效的数据类型。
>处理链
    前一算法的输出是后一算法的输入。如果输出、输入不是同一级内存地址,后一算法使用前一算法结果时就存在读miss的消耗。这个时候就要考虑两者空间如何布置。如果超过两个数组映射到L1D的同一个set,则会产生conflictmiss(L1D cache是2-wayset-associative),故应该将这些数组连续分配(why???)(详见P55)
>避免L1P conflict miss
   即使cpu需要的指令全在L1Pcache(假定无capacitymiss),仍然可能会产生conflictmiss。以下解释conflict miss是如何产生的,又如何通过code在内存中的连续存放来消除miss。例如:
    for(i=0; i<N; i++)
    {   function_1();   function_2(); }
    如果func2在L2中的位置正好与func1有部分处于同一set中,而L2cache是4-wayset-associativity,处于同一set的指令在被L1P cache循环读取后,可能会出现conflict miss(如刚读入func1,然后读入func2,可能会驱逐掉func1在L1P中的部分cachelines).这种类型的miss是完全可以消除掉的,通过将这两个函数的代码分配到不冲突的set中,最直接具体的方法是将这两个函数在内存中连续存放,存放的方法有二:
1. 使用编译器选项 -mo,将各C和线性汇编函数放到各自独立的section,其中汇编函数必须被放到以.sect标示的sections中。然后检查mapfile,获取各函数的段名,比如上例.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,然后是.textsection中的其它函数。但要注意,使用-mo后会导致整个code尺寸变大,因为包含code的任何段都要按32-byte边界对齐。
2. 为避免-mo只能指定section,而不能单独指定函数的不足,如果仅需要函数连续排放,我们可以在定义函数前,通过#pragmaCODE_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而被驱逐掉,比如中断程序等,可以采用这个函数来避免。

>避免L1Dconflict miss
    L1P是直接映射型cache,如果cpu访问的地址没有包含在同一cache line内,则会相互evict。然而,L1D是2-wayset-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数组会放在.constsection而非.datasection中)!因此,必须将数组指定到用户定义的段:
    #pragma DATA_SECTION(in1, ".mydata")
    #pragma DATA_SECTION(in2, ".mydata")
    #pragma DATA_SECTION(w1, ".mydata")
    #pragma DATA_SECTION(w2, ".mydata')
    #pragmaDATA_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来为四个数组在L1Dcache中预分配空间,因为数组物理连续,故只需调用一次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);
    ====>touchloop的意义和实现:意义是为了最大限度实现misspiplining。如果连续访问mem,因为一次miss,会搬移一个cacheline,则随后的访问就会hit,miss不能实现overlap。因此,为获得stalls的完全重叠,可以考虑在一个cycle内同时访问两个新的cacheline,即按两个cachelines的间距遍历mem。TI提供的汇编函数“touch”,用于在L1Dcache中预先分配长为length的数组buffer,它对每两个连续cachelines 分别并行load一个byte。为避免bankconflict,这两个并行load之间偏移一个word。(c64x采用基于LSB的mem bank结构,L1D分成8个bank,每个bank宽32-bit,共2K,这些bank均为single port输入,每个cycle允许一个访问,与c621x/c671x的单bank多输入口有区别。这样,对同一bank同时进行读和写访问,总是会造成stall,而同时对同一bank进行读或写,只要满足一定条件,就不会产生stall)。

>避免L1Dthrashing ---具体图示详见two-level-->3-38
   这种Miss情况下,数据集比cache大,连续分配,但数据不需要reused,发生conflict miss,但无capacitymiss发生(因为数据不reused)。 对同一set发生两个以上的读miss,这样在该行全部数据被访问前就将该行驱逐掉了,这种情况就是thrashing.假定所有数据在mem中是连续分配的,这样只有当被访问的所有数据集超过L1D cache容量时才会发生thrashing.这种conflict miss是可以完全避免的,通过在mem中连续分配数据集,并嵌入一些多余数组,强制将数据交叉映射到cache sets。比如:
    int w_dotprod(const short *restrict w, const short *restrictx, const short *restrict h, int N)
    { int i, sum = 0;
       _nassert((int)w % 8 ==0);      //++如果w[],x[],h[]三个数组在内存中都映射到
       _nassert((int)x % 8 ==0);      //++同一L1Dcache set,则L1Dthrashing发生。当前读入
       _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如果设cache32k,放在DDR中的重建或插值帧数据应该考虑到以上问题,一帧的重建数据远大于32k,这时必然需要考虑被处理数据在DDR中怎么排放才能避免cacheLine evict。为了能够明确数据在cacheset映射,应该将DDR划分成16kCE小段,然后数据按小段布置,这样容易做到主观对应也便于操作数据(这估计也是示例中linker划分很多CE external段的原因)                           

      原来的理解是错误的,首先LRU策略理解有误,它只针对行,而非全部cache空间;在编译连接重定位后,不管是数据还是指令,都有了确定的地址和大小,以及在memory中的位置,这样它们到cache的映射set也已经确定下来了,故理论上通过地址排放,我们应该能确定的了解到数据和代码在cache中的具体运行情况,知道了这些,就可以针对性的采取措施以尽量避免missstall了。

>避免capacitymiss---具体图示见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);   }
      可以利用misspipelining进一步减少read missstalls.在每次循环开始用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));   //++每次touchin[]前都要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);             }
    另外,本例中为避免bankconflict,数组w[]和in[]应该对齐到不同的memorybanks:
        #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) //++意味着已#pragmaDATA_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的容量为L1Dcache的两倍,假如为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使用终极目的。

>避免writebuffer相关的stalls
      WB只有4个入口,且深度有限,如果WB满,而又出现写miss,则CPU就会stall直到有WB有空间为止。同时,read miss会使得writebuffer完全停止,因此保证正确的read-after-write顺序非常重要(read miss需要访问的数据很可能仍然在WB中)。通过在L1Dcache中分配输出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);
                                                        }
                                     }

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值