存储器之于CPU好比仓库之于车间。车间加工过程中的原材料、半成品、成品等均需入出仓库,生产效率再快,如果仓库周转不善,也必然造成生产阻塞。如同仓库需要合理地规划管理一般,数据存储也需要恰当的处理技巧来提升CPU的运算性能。
本文基于TI C6000系列DSP,介绍了与运算性能优化有关的存储器知识。针对具体的数据存储问题,给出相应的代码优化策略,并将容易混淆的概念集中讨论。
名词说明
EMIF: External Memory Interface
PMC: Program Memory Controller
DMC: Data Memory Controller
SPC: Section Program Counter
存储体冲突Vs存储别名模糊[1]
1. 存储体(bank)冲突
C6000系列各DSP的片内存储器结构有所不同,其中大多数采用交叉存取式存储体结构,如图1所示,方框中数字表示字节地址。因为每个bank都是一个单口存储器,所以每个周期对每个bank只能有一次访问。例如两个short型数据a和b,a存放在地址1-2中,b存放在地址8-9中,则程序中不能安排a和b并行存/取,否则会导致存储器存取延迟,使流水线暂停一个周期,在暂停周期中进行第二次读/写存储器,这就是存储体冲突现象。
以16bit short型点积计算为例:
int dotp(short a[], short b[]);
一个高效的软件流水核如下。在最后两条指令中,用LDW“字加载”命令,一个周期中同时加载a[0]、a[1]、b[0]、b[1]。
要使软件流水不被阻塞,则需保证数组a和b的并行加载不发生存储体冲突。
冲突例子:a首地址=0;b首地址=8N
不冲突例子:a首地址=0;b首地址=8N+4
但是,完全控制数组和其它对象在存储器空间的起始位置并不总是可以做到,尤其是当一个指针作为参数传递给函数时,调用这个函数的指针参数可能指向不同的存储器位置。
如果不能知道数组a和b在存储体中的排列信息,则只能肯定a[0-1]和a[2-3]不会发生存储体冲突,同理于b[0-1]和b[2-3]。因此,可以通过循环展开的方式,安排a[0-1]和a[2-3]、b[0-1]和b[2-3]进行同时存取,避免了可能的存储体冲突。循环展开后的软件流水核如下:
另外,在线性汇编中,可以通过“.mptr”伪指令,给编译器提供数据的存储相关信息,让编译器自动分析是否会产生存储体冲突并调整指令编排。
2. 存储别名模糊(alias)
当多个不同的变量名都指向相同的存储区域,这时就发生别名模糊,也就是说,对这些变量进行操作的指令可能存在存储相关性。指令间的相关性限制了指令的编排,包括软件流水编排。
汇编优化器假定所有的存储器引用,都是别名化的(aliased),它把控制权交给用户,由用户提供存储是否别名的信息。编程者可通过一个编译选项/“restrict”关键词/两条线性汇编伪指令来提供存储别名的信息。
-mt编译选项:表示代码中没有存储别名现象。要仔细判断是否能使用-mt,如果代码使用了别名技术而又设置了-mt选项,可能会出现意想不到的结果。
restrict关键词:在C编程中,对数组或指针变量用restrict进行声明,提示编译器该变量指向的存储区域不会与其它变量指向的存储区域发生重叠。
.mdep伪指令:用于明确声明存储器相关。
.no_mdep伪指令:告诉汇编优化器函数体中没有存储器相关性出现。
*不要把“存储别名模糊(存储器相关)”和“存储体冲突”两个概念混淆,它们有着不同的含义和影响。别名模糊影响程序的正确性(当然也可能影响性能),存储体冲突影响程序的性能。存储器相关对于指令编排影响大于存储体冲突。
存储器模式Vs数据终结方式[1,2]
1. 存储器模式
C6000编译器支持两种存储器模式:小存储器模式和大存储器模式。
小存储器模式:.bss段限制在32kB内,CPU可用直接寻址方式访问.bss段中的所有对象而无需改变DP(B14)的值。
大存储器模式:不限制.bss段大小,但CPU只能通过寄存器间接寻址访问.bss中的数据,也即需要先将对象地址读入寄存器中,这带来额外的操作。
当全局/静态变量(存放于.bss段)超过32kB,而又希望使用小存储器模式获得快的访问速度,有两种解决办法:
对于大的数组定义,使用far关键字,如此数据不占用.bss段空间,而放入.far段。
使用-ml/-ml0选项,编译器自动对集合数据类型(如结构体和数组)使用far存取。
2. 数据终结方式
指的是多字节数据内部高低有效位的存放顺序。C6000支持两种终结方式:小端终结方式和大端终结方式。
小端终结(Little-Endian):数据高有效位字节存放在地址高位字节(高位高地址)。
大端终结(Big-Endian):数据高有效位字节存放在地址低位字节(高位低地址)。
内存边界对齐[1,3]
C67X DSP支持单次存/取16bit(半字)、32bit(字)、64bit(双字)数据,但前提是数据的存放分别满足半字对齐、字对齐和双字对齐。目前C64X支持在非对齐情况下单次存/取32bit和64bit宽度的数据。
所谓半字对齐指的是数据地址的最低1位为0;字对齐指的是数据地址的最低2位为0;双字对齐指的是数据地址的最低3位为0。
对不支持非对齐单次存/取的器件来说,如果让CPU用多字节存/取指令一次操作非对齐的数据,将会产生额外操作,有些处理器甚至无法处理而产生错误!下图给出了一个处理示例,从图中可见,原本单次可以完成的操作由于数据未能对齐而花费了5次操作。
图2 对非对齐数据进行多字节访问
在C64X中,数组均默认安排8字节(双字)对齐;在C62X和C67X中,数组按4字节/8字节对齐。
结构体的对齐方式由其最大数据类型成员决定,结构体占用的存储空间总是最大成员类型大小的倍数(注意并不是简单地乘以成员个数)。如下两个结构体A和B,它们所占的存储空间分别是8、12。
struct A { short x; short y; int z; }; struct B { short x; int y; short z; };
数据集边界对齐并不意味着它里面的每一个元素的地址都为对齐长度的倍数,而是保证数据集的起始地址和<结束地址+1>为对齐长度的倍数。
对齐的存储器访问
在C/C++代码中,有三个pragma预编译语句可以用来指示编译器将具体的数据按指定的方式进行对齐存储。
DATA_ALIGN:将数据进行2的整数次幂对齐
DATA_MEM_BANK:将数据对齐到指定的bank
STRUCT_ALIGN(C特有):用于指定结构体、联合体进行2的整数次幂对齐
使用_nassert()内联函数能够指示编译器某一数据的内存对齐状态。如
_nassert( ((int)sum & 0x3) == 0);
告诉编译器sum为字边界对齐,有了这个信息,编译器就可以放心地安排SIMD(单指令多数据)指令对数据进行操作,但_nassert本身不产生任何操作。
可以使用_amemXX()和_amemXX_const()内联函数对对齐的字和半字进行访问。一般这类内存访问可以与_hi()、_lo()和_itod()等数据解包和打包内联函数联合使用。
非对齐的存储器访问
C64X支持非对齐的字和双字访问,其对边界对齐和非边界对齐数据访问的比较如下表所示:
从上表可以看出,C64X在每时钟周期只能进行一次非边界对齐的存储器访问,因此,只要可能应尽量使用边界对齐的存储器访问方式。
在C/C++代码中,可以使用_memXX()和_memXX_const()内联函数对非对齐的字和半字进行访问。一般这类内存访问可以与_hi()、_lo()和_itod()等数据解包和打包内联函数联合使用,下面是一个使用示例: