数据对齐:整理并向右对齐--为速度和正确性对齐你的数据

文章目录

  • 内存访问粒度
  • 内存对齐基本原理
  • 懒惰处理器
  • 速度
  • 原子性
  • Altivec指令集
  • 结构体对齐
  • 结论

原文:https://developer.ibm.com/articles/pa-dalign/,不恰当和蹩脚之处,请指正。

内存访问粒度

程序员习惯于把内存认为是简单的字节数组。在C和它的衍生语言中,char*普遍被看成是“一块内存”,甚至Java也有byte[]数组来表示物理内存。

图1 程序员是这样看待内存的
在这里插入图片描述
然而,计算机处理器可不是按一个字节大小为单位来读写内存的。相反,它以2字节、4字节、8字节、16字节甚至32字节大小为单位来访问内存。我们把处理器访问内存的单位大小称为它的内存访问粒度。

图2 而计算机是这样看待内存的
在这里插入图片描述
高级语言程序员看待内存的方式和处理器对内存的实际处理方式之间的差异,产生了一些有趣的问题,本文将探索这些问题。
如果你不理解并处理你软件中的内存对齐问题,一下场景都是可能发生的,按严重程度递增的方式列出:

  • 你的软件将运行缓慢。
  • 你的应用将发生死锁。
  • 你的操作系统将会崩溃。
  • 你的软件将悄无声息地发生错误,并产生错误的结果。

内存对齐基本原理

为了阐述内存对齐背后的原理,我们来考察一个常见的任务,并且观察处理器的内存访问粒度是如何影响它的。这个任务很简单:首先从地址0开始读取4个字节到处理器的寄存器,再从地址1开始读取4个字节到相同的寄存器。
首先考察处理器内存访问粒度为1个byte时的情况:

图3 单字节内存访问粒度
[外链图片转存失败(img-WAdybLMQ-1562492737733)(https://developer.ibm.com/developer/articles/pa-dalign/images/singleByteAccess.jpg)]
这和耿直的程序员所认为的内存工作模型相吻合:从地址0开始和从地址1开始读取4个字节都需要4次内存访问。现在来看看内存访问粒度为2个字节的处理器,比如初代的68000处理器。

图4 2字节内存访问粒度
数据对齐:整理并向右对齐--为速度和正确性对齐你的数据_第1张图片
从地址0开始读取时,2字节内存访问粒度的处理器访问内存的次数,是1字节内存访问粒度处理器访问次数的一半。因为每次内存访问都会导致固定的开销,最小化内存访问次数确实对性能有帮助。

然而,请关注当从地址1开始读取时的情况。因为地址没有落在处理器的内存访问边界,处理器需要做些额外的工作。这样的地址被称为未对齐地址。因为地址1未对齐,双字节处理器必须执行一次额外的内存访问,导致整个读取操作变慢。

最后,考察内存访问粒度为4字节的处理器,比如68030或者PowerPC 601。

图5 4字节内存访问粒度
数据对齐:整理并向右对齐--为速度和正确性对齐你的数据_第2张图片
4字节访问粒度处理器可以从对齐的地址一次性地读取4个字节。如果从未对齐的地址读取,所需的次数将加倍。

既然你已经理解了内存对齐背后的原理,你现在可以探索一些和内存对齐相关的问题。

懒惰处理器

当访问未对齐内存时,处理器需要运用一些技巧。回到前面4字节访问粒度处理器从地址1开始读取4字节的例子,你应该能想到需要执行的处理:

图6 处理器是如何处理未对齐内存访问的
[外链图片转存失败(img-lL70x0TE-1562492737737)(https://developer.ibm.com/developer/articles/pa-dalign/images/unalignedAccess.jpg)]
处理器先从未对齐地址读取第一个4字节块,然后剔除掉不需要的字节。再从未对齐地址读取第二个4字节块,再剔除掉不需要的字节。最后,将两块数据剩下的部分合并后放入寄存器。这需要做很多工作。

一些处理器就是不愿意帮你做这些工作。

初始的68000是一款2字节访问粒度处理器,它缺少处理未对齐内存地址的电路系统。当给它一个未对齐的内存地址时,它会抛出异常。初始的Mac OS操作系统对这个异常的处理不是很好,它通常会要有用户重启机器,我去~

随后的680x0系列处理器,比如68020,解除了这个限制并且帮你做一些必要的工作。这就解释了为什么一些古老的软件在68020处理器上工作正常,而在68000处理器上则会崩溃。这也解释了为什么一些老的mac程序员将指针初始化为奇数地址。在初始的Mac操作系统,当指针未经重新赋值成一个有效地址而被访问,Mac会立即打开调试器。然后他们可以检查调用链堆栈并找出哪里发生了错误。

所有处理器用于处理工作的晶体管都是有限的。支持未对齐内存访问会消减他们的“晶体管预算”。这些晶体管本来可以用于帮助处理器更快地处理其他工作,或者给处理器添加新的功能。

一个以牺牲未对齐内存访问支持来换取速度的例子是MIPS处理器。为了让真正有意义的工作能被更快地处理,MIPS处理器几乎抛弃了所有琐碎的功能。

PowerPC处理器则采取了折中的方式。目前每个PowerPC处理器都在硬件层面支持了32-bit非对齐整数的访问。虽然访问非对齐地址还是有性能损耗,但已经越来越小了。

另外,现代的PowerPC处理器在硬件层面缺乏对未对齐的64-bit浮点数的访问支持。当需要从内存加载一个未对齐的64-bit浮点数时,现代的PowerPC处理器将会抛出一个异常,让操作系统在软件层面进行对齐。在软件层面进行对齐比在硬件层面进行对齐可慢多了。

速度

现在编写一些测试用例来显示访问未对齐内存地址的性能损耗。这个测试非常简单:从一个10M的缓冲区中读取数据,取反,然后写回缓冲区。这些测试有两个变量:

  1. 缓冲区数据的处理粒度,单位为byte。首先你每次处理一个字节,然后每次2个字节、4个字节、8个字节。
  2. 缓冲区的对齐:通过增加指向缓冲区的指针来切换缓冲区的对齐方式为对齐、未对齐,并相应地重新运行各个测试。

这些测试运行在 800 MHz PowerBook G4。为了尽量减小因中断处理引起的性能波动,每个测试都运行了10次,并记录了10次的平均值。第一个测试每次处理一个字节。

Listing 1. 每次处理一个字节

void Munge8( void ∗data, uint32_t size ) {
    uint8_t ∗data8 = (uint8_t∗) data;  // data8是数据块的起始指针地址
    uint8_t ∗data8End = data8 + size;  // size是数据块大小,data8End是数据块的结束地址指针
    
    while( data8 != data8End ) {   // 如果起始地址不等于结束地址,不断地执行循环
        ∗data8++ = ‑∗data8;  // 这里首先将data8地址处的数据取反,然后重新复制到data8地址处,最好将data8指针往前移动一个字节
    }
}

执行这个函数平均每次测试花了67,364微秒。现在改成每次处理2个字节,这时内存访问次数将减少一半:

Listing 2. 每次处理2个字节

void Munge16( void ∗data, uint32_t size ) {
    uint16_t ∗data16 = (uint16_t∗) data;
    uint16_t ∗data16End = data16 + (size >> 1); /∗ Divide size by 2. ∗/
    uint8_t ∗data8 = (uint8_t∗) data16End;
    uint8_t ∗data8End = data8 + (size & 0x00000001); /∗ Strip upper 31 bits. ∗/
    
    while( data16 != data16End ) {
        ∗data16++ = ‑∗data16;
    }
    while( data8 != data8End ) {
        ∗data8++ = ‑∗data8;
    }
}

都是处理10M缓存,这个函数花了48,765微秒,比每次处理1个字节快了38%。然而,该10M缓存是对齐了的。如果未对齐,处理时间将增加至66,385微秒,比起对齐的情况大约有27%的内存损失。下图是对齐的内存访问和未对齐内存访问的性能对比图:

图7. 单字节访问和双字节访问
数据对齐:整理并向右对齐--为速度和正确性对齐你的数据_第3张图片
你应该注意到的第一个事实是,每次访问一个字节无一例外地都很慢。第二个有意思的事情是,当每次处理2个字节时,只要内存地址不被2整除,27%的内存损失就会冒头出来。

现在提高要求,一次处理4个字节。

Listing 3. 每次处理4个字节

void Munge32( void ∗data, uint32_t size ) {
    uint32_t ∗data32 = (uint32_t∗) data;
    uint32_t ∗data32End = data32 + (size >> 2); /∗ Divide size by 4. ∗/
    uint8_t ∗data8 = (uint8_t∗) data32End;
    uint8_t ∗data8End = data8 + (size & 0x00000003); /∗ Strip upper 30 bits. ∗/
    
    while( data32 != data32End ) {
        ∗data32++ = ‑∗data32;
    }
    while( data8 != data8End ) {
        ∗data8++ = ‑∗data8;
    }
}

该函数处理已对齐的缓存需要花费43,043微秒,处理未对齐缓冲需要花费55,775微秒。因此,在这台机器上,以4字节的粒度访问未对齐内存的速度要慢于以2字节的粒度访问对齐内存的速度。

图8 单字节、双字节、四字节内存访问粒度对比
[外链图片转存失败(img-IbHQQYxL-1562492737738)(https://developer.ibm.com/developer/articles/pa-dalign/images/quadChart.jpg)]
现在来做一个恐怖的事情:每次处理8字节。

Listing 4. 每次处理8字节

void Munge64( void ∗data, uint32_t size ) {
    double ∗data64 = (double∗) data;
    double ∗data64End = data64 + (size >> 3); /∗ Divide size by 8. ∗/
    uint8_t ∗data8 = (uint8_t∗) data64End;
    uint8_t ∗data8End = data8 + (size & 0x00000007); /∗ Strip upper 29 bits. ∗/
    
    while( data64 != data64End ) {
        ∗data64++ = ‑∗data64;
    }
    while( data8 != data8End ) {
        ∗data8++ = ‑∗data8;
    }
}

按每次8字节处理时,处理对齐内存耗时39,085微秒–大约比每次处理4字节快10%。然而,处理未对齐内存时耗时达到了惊人的1,841,155微秒–比对齐内存慢两个数量级,4,610%的损失损失非常显著。

这发生了什么?因为现代的PowerPC处理器没用处理未对齐浮点数访问的硬件支持,对于每个未对齐的内存访问,该处理器会抛出异常。操作系统捕获这个异常并在软件层面处理内存对齐。以下图表显示了性能损失,以及性能损失发生的时机:

图9. 多个访问粒度的对比图
[外链图片转存失败(img-GUAz67AK-1562492737740)(https://developer.ibm.com/developer/articles/pa-dalign/images/horrorChartWhole.jpg)]
在8字节未对齐地址引起的性能损耗面前,1字节、2字节、4字节未对齐地址的性能损耗变得很矮小。可能去掉图表的顶部,两个数字间的巨大差距会更加明显:

图10. 多个访问粒度的对比图#2
数据对齐:整理并向右对齐--为速度和正确性对齐你的数据_第4张图片
在这些数据中还有一个细微的规律。对比下8字节访问粒度在4字节边界时的速度:

图11. 多个访问粒度的对比图#3
数据对齐:整理并向右对齐--为速度和正确性对齐你的数据_第5张图片
注意,从4字节或12字节边界开始每次处理8字节的速度,比每次处理4字节甚至2字节的速度要慢。虽然PowerPC在硬件层面上支持按4字节对齐的8字节双精度浮点数,但是如果你用上这个特性,你还是要付出性能损失的代价。诚然,这个代价比 4,610%的损失小多了,但是它还是很显著的。这个故事的寓意是:如果内存未对齐,每次处理大粒度的速度比每次处理小粒度的速度要慢。

原子性

所有的现代处理器都提供了原子指令,这些指令对同步两个或更多个并发任务至关重要。就像其名称所蕴含的含义一样,原子指令不可再分割—这就是它们为什么能被这么方便地用于处理同步:它们不能被抢占(译者解读:假设指令A是原子指令,指令B是其他普通指令,不能被抢占是指,指令A执行过程中,处理器不能将指令A暂停下来去处理指令B,然后再处理指令A未完成的部分;处理器必须执行完指令A后,才能执行指令B)。

结果是,为了保证原子指令能正确执行,你传给这些指令的地址必须是按4字节对齐的。这是因为原子指令和虚拟内存之间的交互非常敏感。

如果一个地址没有对齐,那么需要至少两次内存访问。但如果需要读取的数据跨越了两个虚拟内存页,将会发生什么?这会导致一种状况:第一个虚拟内存页的数据位于物理内存中,然而另外一个虚拟内存页的数据没有在物理内存中。访问时,在指令执行过程中,会产生一个缺页异常,导致虚拟内存管理的换页代码被执行,破坏了指令的原子性。为了保持事情的简单性和正确性,68K和PowerPC处理器都要求原子指令的地址是按4字节对齐的。

不幸的是,当对未对齐的地址进行原子存储操作时,PowerPC没有抛出异常。相反,存储操作始终失败。这很糟糕,因为大部分原子操作函数在发生存储失败时都会重试,因为它们认为失败的原因是被别的操作抢占了。这两种情况结合到一起(指PowerPC在遇到未对齐内存时存储操作会失败,和函数在失败后不断重试),会导致你的程序陷入死循环,如果你尝试对一个未对齐的内存地址执行原子存储操作。呃呃~

Altivec指令集

Altivec为速度而生。未对齐的内存方位会拖慢处理器,耗费宝贵的晶体管资源。因此,Altivec工程师借鉴了MIPS,直接不支持未对齐的内存访问。因为Altivec指令每次处理16字节,所有传给Altivec指令的地址必须是16字节对齐的。如果你的内存未对齐,那么将有可怕的事情发生。

Altivec指令不会抛出异常来警告你内存未对齐。相反,Altivec直接忽略地址的低4位,并向前读取,因此读取到了错误的地址。这以为着你的程序可能悄无声息地打乱了内存,或者返回错误的结果,如果你没有明确地保证所有的数据都已对齐。

Altivec直接丢弃比特位的方式也有优点。因为你不需要显示地截取(向下对齐)地址,这么做可以让你少执行一两个指令。

这并不是说Altivec不能处理未对齐的内存。你可以查阅Altivec编程环境手册,找到如何做内存对齐的详细说明。这需要更多的工作,但是因为内存比处理器慢太多,这些技巧的消耗是非常低的。

结构体对齐

考察以下结构体:

Listing 5. 一个简单的结构体

void Munge64( void ∗data, uint32_t size ) {
typedef struct {
    char    a;
    long    b;
    char    c;
}   Struct;

这个结构体占用多少字节?许多程序员将回答6字节。这是有道理的:a占1个字节,b占4个字节,c占1个字节。1+4+1=6。这是它的内存布局:

表1. 结构体占用字节大小

字段类型 字段名称 字段偏移 字段大小 字段结束位置
char a 0 1 1
long b 1 4 5
char c 5 1 6

总共占字节数:6。
然而,如果你让编译器执行sizeof(struct),你得到的答案很可能大于6,答案可能是8或者24。有两个原因导致这个结果:向后兼容和效率。

首先,说说向后兼容。你应该记得68000是一款2字节内存访问粒度的处理器,并且在遇到奇数地址时会抛出异常。如果你对b字段进行读写,你将访问一个奇数地址。如果没有安装调试器,老的Mac OS将弹出一个系统错误对话框,对话框上只有一个“重启”按钮。我勒个去~

因此,编译器不会按你写的代码的顺序来对字段进行内存布局,而是对结构体进行填充,好让b字段和c字段处于偶数地址上。

表2. 编译器填充过的结构体

字段类型 字段名称 字段偏移 字段大小 字段结束位置
char a 0 1 1
  填充 1 1 2
long b 2 4 6
char c 6 1 7
  填充 7 1 8

总字节数:8。
填充是一种向结构体添加没有作用的字符,以使得字段按我们期望的方式对齐的手段。现在,由于68020处理器被生产出来时就在硬件层面支持了未对齐内存的访问,因此填充对它来说是没必要的。然而,填充对它也没什么影响,甚至对性能有些许帮助。

第二个原因是效率。在现代的PowerPC机器上,按2字节对齐是好的,但是按4字节或按8字节对齐更好。你可能并不在意68000处理器不能处理未对齐内存的问题,但是你应该会关心潜在的4,610%的性能损失,如果一个double类型的字段没有对齐,该性能损失将会发生。

结论

如果你不理解内存对齐,并且在编码时没有明确处理好它:

  • 你的软件可能会遇到性能问题–调用非常耗时的对齐异常处理器来处理未对齐内存访问异常。
  • 如果你的应用试图在未对齐内存上执行原子存储操作,可能会导致你的应用死锁。
  • 如果你的应用试图给Altivec指令集传递未对齐地址,会导致Altivec指令集在错误的内存位置上读写数据,从而损坏数据或者产生错误的运算结果。

你可能感兴趣的:(Linux)