计算机课程指引人们避开微观的优化而去寻找更优的算法。 硬件价格的下降也使挤压内存占用变得没
有必要。
但该技术仍在重要的情况下有用武之地,而且只要内存有限制,就会有用。 这篇文章的目的是使C程
序员重新发现该技术,使他们能专注于更重要的事情。
1. 对齐的要求
首先要理解的是,在现代处理器上,C编译器在内存里存放基本数据类型时是受限的:以最快存取速度
为目标。
在X86或ARM上,基本数据类型并不是存放在任意内存地址上的。 每种类型除了char都有对齐要求
(alignment requirement); char类型可以开始于任何地址,但2字节的short类型必须存放在偶数地址
上,4字节的整型或浮点型必须放在能被4整除的位置上,而8字节的long或double型必须放在能被8
整除的地址上。有符号或无符号没有差别。
用术语来讲就是,基本C类型在X86和ARM上都是自对齐的(self-aligned)。指针,不管是32位(4字
节)还是64位(8字节)也是自对齐的。
自对齐能存取得更快是因为它能用一条指令来存取该类型数据。 另一方面,如果没有对齐限制,代码
可能会在跨机器字边界存取的时候使用两条以上的指令。 字符是特殊情况: 不管它在们在机器字的哪
个位置,存取代价都是一样的。所以它们没有对齐要求。
在现代处理器上,是因为在有些更老的处理器上,强迫你的C代码违反对齐限制(比如,把一个奇数地
址转换为int指针并试图使用它)不仅会让你的代码变慢,还会造成非法指令异常。 比如在Sun SPARC
芯片上就是这样。 事实上,只要有足够的决心和正确的硬件标志(e18),你也可以在X86上触发该异常
。
自对齐还不是唯一的规则。 历史上,有些处理器(特别是那些没有barrel shifters的)有更严格的规
则。如果你在做嵌入式系统,你可能撞到这些暗礁。要有心理准备。
有时你可以让编译器不遵守处理器的正常对齐规则,一般是使用pragma,比如 #pragma pack。 请不
要随意使用,因为它会生成开销更大、更慢的代码。 通过使用我介绍的技术,你可以节省同样、甚至
更多的内存。
使用#pragma pack的唯一合理理由是,你需要C数据分布完全匹配某些硬件或协议,比如一个经过内存
映射的物理端口,则不违反对齐规则就无法做下去。 如果你处在那种情况,而不理解本文的内容,你
会遇到大麻烦。
2. 填充(padding)
现在我们来看一个简单的例子,变量在内存中的分布。
char *p;
char c;
int x;
如果你不知道数据对齐,你可能会假定这三个变量在内存里占用连续的字节。 即,在32位机器上4字节
的指针后面会紧跟1字节的char,而它后面会紧跟4字节的int。在64位机器上,唯一的差别是指针是8字
节的。
而实际情况是这样的(在x86或ARM或任何自对齐的机器上):p 存储在4字节或8字节对齐的位置上(由机
器的字长决定)。 这是指针对齐-可能的最严格的情况。
c的存储紧跟着p。但x的4字节对齐要求造成一个缺口,就好像有第四个变量插入其中:
char *p; /* 4 or 8 bytes */
char c; /* 1 byte */
char pad[3]; /* 3 bytes */
int x; /* 4 bytes */
pad[3] 数组表示有3个字节浪费了。 老式的说法是“slop(溢出)”。
比较如果x 是2字节的short会怎样:
char *p;
char c;
short x;
在这种情况下,实际的内存分布是这样的:
char *p; /* 4 or 8 bytes */
char c; /* 1 byte */
char pad[1]; /* 1 byte */
short x; /* 2 bytes */
另一方面,如果是在64位机上,x 是一个long:
char *p;
char c;
long x;
我们会得到:
char *p; /* 8 bytes */
char c; /* 1 byte
char pad[7]; /* 7 bytes */
long x; /* 8 bytes */
如果你是仔细看到这儿的,你可能会想如果更短类型的变量放在前面会怎样:
char c;
char *p;
int x;
如果实际的内存分布写成这样:
char c;
char pad1[M];
char *p;
char pad2[N];
int x;
M 和N 应该是多少?
首先,N 是0。x 的地址紧接着p,保证了x 是指针对齐的,而指针对齐肯定比整型对齐更严。
c极有可能被映射到机器字的第一个字节上。 因此M是能让p满足指针对齐的数目-在32位机上是3,在
64位上是7。
中间情况也是可能的。 因为char有可能被安排在一个机器字中的任意位置,M有可能是0到7(在32
位机上是0到3)。
如果你想让这些变量占用较少的空间,你可以交换x和c的位置:
char *p; /* 8 bytes */
long x; /* 8 bytes */
char c; /* 1 byte
通常对于数量较少的C程序中的标量来说,通过调整声明顺序获得的区区几个字节可能没什么大不了。
这种技术如果应用到非标量变量-特别是结构,会变得更加有趣。
在我们继续之前,先说一下标量数组。 在一个自对齐类型的平台上,char/short/int/long/pointer
数组内部没有填充;每个成员都跟在前一个成员后面,自动对齐了。
在下一节我们将看到,在结构体数据里,以上规律并不一定正确。
3. 结构体的对齐和填充
总的来说,结构体实例会和它的最宽成员一样对齐。 编译器这样做因为这是保证所有成员自对齐以获
得快速存取的最容易方法。
而且,在C中,结构的地址等于它的第一个成员的地址-没有前导填充。 注意:在C++中,形似结构
的类可能会破坏这个规则!(跟基类和虚函数如何实现有关,也因编译器而异。)
(当你对此有疑惑时,你可以使用ANSI C 提供的offset()宏来得到结构成员的偏移。)
考虑这个结构:
struct foo1 {
char *p;
char c;
long x;
};
假定是在一台64位机上,那么任何struct foo1的实例都是8字节对齐的。内存分布应是这样的:
struct foo1 {
char *p; /* 8 bytes */
char c; /* 1 byte
char pad[7]; /* 7 bytes */
long x; /* 8 bytes */
};
就好像这些变量是单独声明的。 但如果我们把c放到第一位,就不是这样了:
struct foo2 {
char c; /* 1 byte */
char pad[7]; /* 7 bytes */
char *p; /* 8 bytes */
long x; /* 8 bytes */
};
如果单独声明,c可以在任意字节边界上,而pad的尺寸也会不同。 但因为struct foo2有最宽成员的指
针对齐,以上情况不可能了。 现在c必须处在指针对齐的位置上,后面跟着锁定的7字节的填充。
现在我们讨论一下结构的拖尾填充(trailing padding)。 为了解释,我需要引入一个我称为跨步地址
(stride address)的基本概念。它是跟在结构体后面跟该结构体有相同对齐的数据的第一个地址。拖
尾填充的总规则是: 结构体的拖尾填充一直延伸到它的跨步地址。 这条规则决定了sizeof()的返回
值。
考虑在64位x86或ARM机器上的这个例子:
struct foo3 {
char *p; /* 8 bytes */
char c; /* 1 byte */
};
struct foo3 singleton;
struct foo3 quad[4];
你可能会以为sizeof(struct foo3)会返回9,其实是16。 跨步地址即quad[0].p的地址,这样,在
quad数组里,每个成员都有7字节的拖尾填充,因为下一个结构体的第一个成员需要在8字节边界上对
齐。内存分布就好像这个结构是这样声明的:
struct foo3 {
char *p; /* 8 bytes */
char c; /* 1 byte */
char pad[7];
};
作为对比,考虑这个例子:
struct foo4 {
short s; /* 2 bytes */
char c; /* 1 byte */
};
因为s只需要2字节对齐,跨步地址仅是c后面的一个字节,struct foo4只有一字节的拖尾填充。 就像
这样:
struct foo4 {
short s; /* 2 bytes */
char c; /* 1 byte */
char pad[1];
};
而sizeof(struct foo4) 返回4。
现在让我们考虑位域(bitfields)。 它们使得你能声明比字节宽度更小的成员,低至1位,比如:
struct foo5 {
short s;
char c;
int flip:1;
int nybble:4;
int septet:7;
};
关于位域需要了解的是,它们是由字或字节层面的掩码和移位指令来实现的。 从编译器的角度来看,
struct foo5里的位域就像2字节,16位的字符数组,只用到了12位。 为了使结构体的长度是它的最宽
成员长度(即sizeof(short))的整数倍,还有一个字节的填充:
struct foo5 {
short s; /* 2 bytes */
char c; /* 1 byte */
int flip:1; /* total 1 bit */
int nybble:4; /* total 5 bits */
int septet:7; /* total 12 bits */
int pad1:4; /* total 16 bits = 2 bytes */
char pad2; /* 1 byte */
};
这是最后一个重要细节:如果你的结构体中含有结构体,里面的结构体也要和最长的标量有相同的对齐
。假如你定义了这个:
struct foo6 {
char c;
struct foo5 {
char *p;
short x;
} inner;
};
char *p 成员不但使外层结构体也使内层结构体处在指针对齐的位置上。在64位机上实际的内存分布像
这样:
struct foo6 {
char c; /* 1 byte*/
char pad1[7]; /* 7 bytes */
struct foo6_inner {
char *p; /* 8 bytes */
short x; /* 2 bytes */
char pad2[6]; /* 6 bytes */
} inner;
};
该结构提示我们能从重排结构成员中节省多少空间。24字节中,有13个是填充!超过50%的空间浪费了!
4. 结构成员重排
理解了编译器在结构体中间和尾部插入填充的原因和方式后,我们要检查一下如何挤压这些溢出(slop)
。这就是结构体压缩技术。
首先我们注意到溢出只发生在两个地方。一个是较大的数据类型(从而需要更严格的对齐)跟在较小
的数据后面。 另一个是结构体自然结束的位置到跨步地址之间需要填充,以使下一个相同结构能正确
地对齐。
最简单的消除溢出的方式是按对齐值的递减来排序成员。 即让指针对齐的成员排在最前面,因为在64
位机上它们是8字节;然后是4字节的int;然后是2字节的short,然后是字符。
因此,以简单的链表结构为例:
struct foo7 {
char c;
struct foo7 *p;
short x;
};
把隐含的溢出写明:
struct foo7 {
char c; /* 1 byte */
char pad1[7]; /* 7 bytes */
struct foo7 *p; /* 8 bytes */
short x; /* 2 bytes */
char pad2[6]; /* 6 bytes */
};
一共是24字节。 如果按长度排序,是:
struct foo8 {
struct foo8 *p;
short x;
char c;
};
考虑到自对齐,我们发现没有一个数据域需要填充。因为有较严对齐要求的成员的跨步地址对不太严对
齐要求的数据来说,总是合法的对齐地址。重打包过的结构体只需要拖尾填充:
struct foo8 {
struct foo8 *p; /* 8 bytes */
short x; /* 2 bytes */
char c; /* 1 byte */
char pad[5]; /* 5 bytes */
};
注意重排并不能保证节省空间。 把它应用到先前的例子, struct foo6,我们得到:
struct foo9 {
struct foo9_inner {
char *p; /* 8 bytes */
int x; /* 4 bytes */
} inner;
char c; /* 1 byte*/
};
把填充写明:
struct foo9 {
struct foo9_inner {
char *p; /* 8 bytes */
int x; /* 4 bytes */
char pad[4]; /* 4 bytes */
} inner;
char c; /* 1 byte*/
char pad[7]; /* 7 bytes */
};
还是24字节,因为c不能放进内层结构的拖尾填充。 为了节省这些空间你要重新设计数据结构。
5. 怪异数据类型
如果符号调试器能显示枚举类型的名称而不原始的数字,使用枚举来代替#define是个好办法。然而,虽
然枚举必须与某种整型兼容,C标准却没有指定到底是何种整型。
请当心重打包结构体的时候,枚举型变量通常是int,这跟编译器相关;但它们也可能是short,long,
甚至默认是char。你的编译器可能会有progma或命令行选项指定枚举的尺寸。
long double 是个类似的故障点。 有些C平台以80位实现它,有些是128位,而一些80位平台把它填充
到96或128位。
在以上两种情况下最好用sizeof()来检查存储的尺寸。
6. 可读性和cache局部性
按成员尺寸重排是最简单的消除溢出的方式,但不一定是正确的方式。 还有两个问题:可读性和cache
局部性。
程序不仅与计算机交流,还与人类交流。 特别当交流的观众是将来的你的时候,代码可读性更重要的
。
一个笨拙的、机械的重排可能影响可读性。有可能的话,最好这样重排成员:使得语义相关的数据放在
一起,形成连贯的组。 最理想的是,结构体的设计要与程序的设计相互沟通。
当你的程序频繁地存取某个结构或它的一部分,如果存取总是能放进一条cache 行,对提高性能是很有帮
助的。cache 行是这样的内存块,当处理器要去取该内存块内的任何单个地址时,会把整个内存块都取
出来。 在64位x86上,一条cache 行是64字节,开始于自对齐的地址。在其它平台上通常是32字节。
你为保持可读性而做的事-把相关的和同时要存取的数据放在相邻的位置-也会提高cache行局部性。
它们都是聪明地重排、把数据的存取模式放在心上的原因。
如果你的代码从多个线程上同时存取一个结构体,会有第三个问题:cache line bouncing。 为了减少
昂贵的总线通信,你应该这样安排数据,使得在一个更紧的循环里,从一条cache line 里读数据,而往
另一条写数据。
是的,这种做法与前面说的把相关的数据放入与cache line长度相同的块矛盾。多线程是困难的。
Cache line bouncing 和其它多线程优化问题是很高级的话题,值得单独为它们写个指导。 这里我能
做的只是让你了解有这些问题存在。
7. 其它打包技术
在为你的结构瘦身的时候,重排序与其它技术结合在一起工作得最好。如果你在结构里有几个布尔标志
,可以考虑把它们压缩成1位的位域,然后把它们打包放在本来可能成为slop(溢出)的地方。
你可能会有一点儿存取时间的损失-但如果它把工作空间压缩得足够小,那点损失可以从避免cache
miss 来补偿。
总的原则是,选择能把数据类型缩短的方法。以cvs-fast-export为例,我使用的一个压缩方法是:利
用RCS和CVS在1982年前还不存在这个事实,我弃用了64位的Unix time_t(在1970年开始的时候是零),
而用了一个32位的、从1982-01-01T00:00:00开始的偏移量;这样日期会覆盖到2118年。(注意,如果你
使用这样的技巧,要用边界条件检查以防讨厌的bug!)
每样缩短法不仅减小了结构的可见尺寸,还可以消除溢出或创造额外的机会来进行重新排序。 这种效
果的良性互动是不难被触发的。
最冒险的打包方法是使用union。如果你知道结构体中的某些域永远不会跟另一些域一起使用,考虑用
union使它们共享存储空间。不过请特别小心,要用回归测试验证你的做法。因为如果你的分析有一丁
点儿错误,就会有从程序崩溃到(更糟的)微妙的数据损坏。