原文:Data alignment: Straighten up and fly right
**
对齐数据是为了加快访问速度与正确性。

内存访问粒度

程序员将内存视为简单的字节数组。在C及其后续语言中,char*普遍存在的意思是“一块内存”,甚至Java™也具有byte[]类型来表示原始内存。

图1 程序员如何看待内存
howProgrammersSeeMemory.jpg
程序员会任务内存其实就是一个字节数组,处理器会逐字节的访问内存,但是,实际上处理器不会单字节块 对内存进行读写,相反,而是以2,4,8,16甚至是32字节的块访问内存。我们将处理器访问内存的大小成为内存访问粒度

图2 处理器如何看待内存
howProcessorsSeeMemory.jpg
程序员如何看待内存与处理器怎样使用内存两者之间是有差异的。

如果不了解并解决软件中的对齐问题,则以下情况将有可能越来越严重:

  • 你的软件运行速度会变慢;
  • 你的操作系统将崩溃;
  • 您的软件将运行失败,产生错误的结果

对齐基础

事实上,我们所说的数据按2字节对齐,4字节对齐,8字节对齐等,实际上就是数据的内存地址能够被2,4,8整除。

为了说明对齐背后的原理,检查一个不变的任务以及他如何受到处理器的内存访问粒度 的影响。任务很简单:首先从地址0开始读取4个字节到处理器的寄存器中,然后从地址1开始读取4个字节到同一寄存器。

首先检查处理器的内存访问粒度位单字节时将发生什么:

图3 单字节内存访问粒度
singleByteAccess.jpg
这就是初级程序员认为的关于内存工作方式的模型:逐字节访问内存,即首先访问地址0四次读取4个字节,再访问地址1四次读取4个字节。总共需要访问8次

现在看看处理器的内存访问粒度位**2**字节时会发生什么,例如原始的68000 :

图4 双字节内存访问粒度
doubleByteAccess.jpg
从地址0读取时,2字节访问粒度的处理器仅需要两次就可以读取4个字节,所占用的内存访问次数是1字节(四次)访问粒度的处理器的一半。因为每个内存访问都需要固定的开销,所以访问次数少确实可以提高性能

但是,请注意从地址1读取时会发生什么。由于该地址未均匀地落在处理器的内存访问边界上(也就是内存地址不能被2整除,即1不能整除2),因此处理器还有很多工作要做。这样的地址称为不对齐地址。由于地址1是未对齐的,因此具有两字节粒度的处理器必须执行额外的内存访问,从而减慢了操作速度。

根据Lazy processors(懒加载处理器)的工作原理可知,地址1的访问需要经过以下步骤:

  1. 读取地址0-1,去掉地址0的内容
  2. 读取地址2-3
  3. 读取地址4-5,去掉地址5的内容

可以看出,读取非对齐地址会增加读取次数,并且还需要额外的运算与存储空间,相信这也正是很多处理器重视数据对齐的原因,一切都是为了性能。

最后,检查在具有四字节内存访问粒度的处理器上(68030或PowerPC®601)会发生什么:

图5 四字节内存访问粒度
quadByteAccess.jpg

具有四字节粒度的处理器可以一次读取就从对齐的地址中提取四个字节。另请注意,从未对齐的地址读取将使访问计数加倍。

既然您了解了对齐数据访问背后的基础知识,就可以探索一些与对齐有关的问题。

Lazy processors(懒加载处理器)

当访问未对齐的地址时,处理器必须执行一些取巧操作。回到以四字节粒度在处理器上从地址1读取四个字节的示例,您可以确切地算出需要完成的工作:

图6 处理器如何处理未对齐的内存访问
unalignedAccess.jpg
处理器需要读取未对齐地址的第一块,并从第一块中移出“不需要的”字节。然后,它需要读取未对齐地址的第二块,并同样移出不需要的一些信息。最后,将两者合并在一起以放置在寄存器中。这就完成了对不对齐地址的访问工作。

这是可以实现的,只不过一些处理器不愿意为您完成所有这些工作。

最初的68000是具有两字节粒度的处理器,并且缺乏处理未对齐地址的电路。当出现这样的地址时,处理器将引发异常。原始的Mac OS对此异常也并不太友好,通常会要求用户重新启动计算机。

680×0系列的后续处理器(例如68020)解除了此限制,并为用户执行了这些必要的工作。这解释了为什么某些在68020上能够运行的旧软件在68000上崩溃的原因。并且还解释了为什么某些旧Mac编码器使用奇数地址初始化指针的原因。在原始Mac上,如果在未将指针重新分配给有效地址的情况下访问了该指针,则Mac会立即掉入调试器。然后,他们通常可以检查调用链堆栈并找出错误所在。

所有处理器都有一定数量的晶体管来完成这些工作。添加未对齐的地址访问支持将削减此“晶体管预算”。否则,这些晶体管可以用于使处理器的其他部分更快地工作,或者添加新功能。

以提升速度为名而牺牲不对齐地址访问支持的处理器的一个示例是MIPS。MIPS是处理器的一个很好的例子,它以更快地完成实际工作为名,消除了几乎所有的麻烦。

PowerPC采用混合方法。迄今为止,每个PowerPC处理器都对未对齐的32位整数访问提供硬件支持。尽管您仍会因未对齐的访问而对性能造成损失,但这种损失往往很小。

另一方面,现代PowerPC处理器缺乏对未对齐的64位浮点访问的硬件支持。当要求从内存中加载未对齐的浮点数时,现代PowerPC处理器将引发异常,并使操作系统在软件中执行对齐操作。在软件中执行对齐比在硬件中执行对齐慢得多。

速度

编写一些测试来证明对未对齐内存访问的性能损失。测试很简单:在10兆字节的缓冲区中读取,取反和写回数字。这些测试有两个变量:

  1. 处理缓冲区的大小(以字节为单位)。首先,每次处理一个字节。然后,一次性移动两个,四个和八个字节
  2. 缓冲区的对齐方式。通过增加指向缓冲区的指针错开对齐方式,并再次运行每个测。

这些测试是在 800 MHz PowerBook G4上进行的。为了帮助规范中断处理带来的性能波动,每个测试运行十次,并保持运行的平均值。首先是一次对单个字节进行操作的测试:

1.每次处理1个字节数据
  1. void Munge8( void data, uint32_t size ) {
  2. uint8_t data8 = (uint8_t∗) data;
  3. uint8_t data8End = data8 + size;
  4. while( data8 != data8End ) {
  5. data8++ = ‑∗data8;
  6. }
  7. }

执行此功能平均需要67,364微秒。现在修改它,使其每次处理两个字节,这将使内存访问次数减半:

2.每次处理2个字节数据
  1. void Munge16( void data, uint32_t size ) {
  2. uint16_t data16 = (uint16_t∗) data;
  3. uint16_t data16End = data16 + (size >> 1); /∗ Divide size by 2. ∗/
  4. uint8_t data8 = (uint8_t∗) data16End;
  5. uint8_t data8End = data8 + (size & 0x00000001); /∗ Strip upper 31 bits. ∗/
  6. while( data16 != data16End ) {
  7. data16++ = ‑∗data16;
  8. }
  9. while( data8 != data8End ) {
  10. data8++ = ‑∗data8;
  11. }
  12. }

此功能需要48,765微秒,比Munge8快38%。但是,该缓冲区已对齐。如果缓冲区未对齐,则所需时间将增加到66,385微秒-大约损失27%的速度。下表说明了对齐内存访问与未对齐访问的性能模式:

图7 单字节访问与双字节访问
doubleChart.jpg

  • 一次访问一个字节的内存总是很慢;
  • 一次访问两个字节的内存时,每当地址不能被2整除时,就会出现大约27%的速度损失。

3. 每次处理4个字节数据
  1. void Munge32( void data, uint32_t size ) {
  2. uint32_t data32 = (uint32_t∗) data;
  3. uint32_t data32End = data32 + (size >> 2); /∗ Divide size by 4. ∗/
  4. uint8_t data8 = (uint8_t∗) data32End;
  5. uint8_t data8End = data8 + (size & 0x00000003); /∗ Strip upper 30 bits. ∗/
  6. while( data32 != data32End ) {
  7. data32++ = ‑∗data32;
  8. }
  9. while( data8 != data8End ) {
  10. data8++ = ‑∗data8;
  11. }
  12. }

处理对齐的缓冲区:43,043微秒 处理未对齐的缓冲区:55,775

因此,在该测试机上,一次访问四个字节的未对齐内存比一次访问两个字节的对齐内存

图8 单字节,双字节和四字节访问
quadChart.jpg

4. 每次处理8个字节数据
  1. void Munge64( void data, uint32_t size ) {
  2. double data64 = (double∗) data;
  3. double data64End = data64 + (size >> 3); /∗ Divide size by 8. ∗/
  4. uint8_t data8 = (uint8_t∗) data64End;
  5. uint8_t data8End = data8 + (size & 0x00000007); /∗ Strip upper 29 bits. ∗/
  6. while( data64 != data64End ) {
  7. data64++ = ‑∗data64;
  8. }
  9. while( data8 != data8End ) {
  10. data8++ = ‑∗data8;
  11. }
  12. }

Munge64在39,085微秒内处理对齐的缓冲区-比一次处理四个字节快大约10%。 处理未对齐的缓冲区花费惊人的1,841,155微秒-比对齐的访问慢两个数量级,从而显着降低了4,610%的性能!

发生了什么?由于现代PowerPC处理器缺乏对未对齐浮点访问的硬件支持,因此处理器会为每个未对齐访问抛出异常操作系统将捕获此异常,并在软件中执行对齐。以下图表说明了性能损耗及其发生的时间:

图9 多字节访问比较
horrorChartWhole.jpg
一字节,两字节和四字节不对齐访问的损失与不对齐的八字节损失相比较小。也许去掉图表的上半部分会使其变得更加清晰:

图9 多字节访问比较#2
quadChart.jpg

比较四字节边界上的八字节访问速度:

图10 多字节访问比较#3

horrorChartHeadlessHilited.jpg

请注意,在四个字节和十二个字节的边界上一次访问八个字节的内存比一次读取四个或两个字节的相同内存要慢。虽然PowerPC具有对四字节对齐的八字节双精度字的硬件支持,但是如果使用该支持,仍然会付出性能损失。显而易见,损耗接近4,610%。

小结:如果未对齐访问,大块访问内存可能比小块访问慢
**

原子性

所有现代处理器都提供原子指令。这些特殊指令对于同步两个或多个并发任务至关重要。顾名思义,原子指令必须是不可分割的 -这就是为什么它们便于同步的原因:不可抢占。

事实证明,为了使原子指令正确执行,传递给它们的地址必须至少四字节对齐。这是由于原子指令和虚拟内存之间的微妙交互。

如果地址未对齐,则至少需要两次内存访问。如果所需数据跨越两页虚拟内存会发生什么?这可能导致停留在第一页而不是最后一页的情况。访问时,在指令中间将产生页面错误,执行虚拟内存管理交换代码,从而破坏了指令的原子性。为了使事情简化,68K和PowerPC都要求原子操作的地址始终至少四字节对齐。

不幸的是,当原子存储到未对齐的地址时,PowerPC并不会引发异常,只是会一直存储失败。这是不好的,因为大多数原子函数已经抢先预留了存储失败重试的机制。一旦这两种情况结合在一起,如果发生原子存储到未对齐的地址的情况,程序将进入无限循环。

Altivec

Altivec与速度有关。未对齐的内存访问会减慢处理器的速度,并消耗宝贵的晶体管。因此,Altivec工程师从MIPS手册中声明了不支持未对齐的内存访问。由于Altivec一次只能处理16个字节的块,因此传递给Altivec的所有地址都必须对齐16个字节。

Altivec不会抛出异常来警告您有关未对齐地址的信息。取而代之的是,Altivec只是忽略了地址的低四位,而是在错误的地址上进行运算。这意味着如果您未明确确保所有数据都对齐,则程序可能会悄无声息地破坏内存或返回不正确的结果。

Altivec的位剥离方法有一个优势。因为您不需要显式截断(向下对齐)地址,所以在将地址交给处理器时,此行为可以为您节省一两个指令。
这并不是说Altivec无法处理未对齐的内存。您可以在《Altivec编程环境手册》中找到有关如何执行操作的详细说明。

结构对齐

  1. void Munge64( void data, uint32_t size ) {
  2. typedef struct {
  3. char a;
  4. long b;
  5. char c;
  6. } Struct;

此结构体的大小(以字节为单位)是多少?许多程序员会回答“ 6个字节”。这是有道理的:a为一个字节b为四个字节c为一个字节。1 + 4 + 1等于6。这是它在内存中的布局方式:

表1 结构体大小(以字节为单位)

Field Type Field Name Field Offset Field Size Field End
char a 0 1 1
long b 1 4 5
char c 5 1 6
Total size in bytes: 6

但是,如果使用编译器提供的sizeof( Struct ),得到的答案可能会大于六个,也许八个,甚至二十四个。这样做有两个原因:向后兼容性和效率。

  • 向后兼容。请记住,68000是具有两字节内存访问粒度的处理器,并且在遇到奇数地址时会引发异常。如果要读取或写入field b,则尝试访问奇数地址。如果未安装调试器,则旧的Mac OS会显示一个带有一个按钮的“系统错误”对话框:重新启动。

因此,编译器没有按照您编写字段的方式对字段进行布局,而是填充结构,使b和c位于偶数地址。

表2 带有编译器填充的结构

Field Type Field Name Field Offset Field Size Field End
char a 0 1 1
padding 1 1 2
long b 2 4 6
char c 6 1 7
padding 7 1 8
Total Size in Bytes: 8

填充是向结构中添加其他未使用的空间以使字段以所需的方式排列的动作。

  • 效率。如今,在PowerPC机器上,两字节对齐是不错的选择,但四字节或八字节更好。您可能不再在意原始的68000在未对齐的结构上被阻塞,但是您可能会担心潜在的4,610%的性能损失,如果某个double字段未在您的设计结构中对齐,则会发生这种情况。

结论

如果不理解并写出数据对齐的代码,可能会导致以下情况:

  • 软件可能会遇到性能中断的未对齐内存访问异常,这会调用非常昂贵的对齐异常处理程序。
  • 应用程序可能尝试原子存储到未对齐的地址,从而导致应用程序锁定。
  • 您的应用程序可能试图将未对齐的地址传递给Altivec,从而导致Altivec从错误的内存部分读取和/或写入错误的内存部分,从而无提示地破坏数据或产生错误的结果。