Data alignment漫谈
Posted 我就没事闲溜溜
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了Data alignment漫谈相关的知识,希望对你有一定的参考价值。
转载请附本文链接:https://blog.csdn.net/maxlovezyy/article/details/70231804
- 对于数据对齐,很多人都是知其一,而不知其二。比如他听说过内存对齐和其大概的作用,但是却不知道cache对齐以及对齐到底有什么作用,更不了解怎么能更好的对结构进行内存布局以提高性能,在本文,你会得到解答。
- 以下讨论的概念性的东西应该都是适用于所有系统的,但是实际操作都是linux系统做的。
- 讨论基于单线程处理,目的是为了简化讨论,简化测试,但并不影响对理论的验证。
- 最后附上验证源码以及其解释。
背景之个人理解
本节内容都是个人的理解,如果有不正确的地方,欢迎讨论。
说内存对齐之前,先说说硬盘对数据的组织方式。我们知道,文件系统管理数据是按照块来管理的,假如说一个4k对齐的文件系统,一个inode节点对应一个4k大小的块,当你写入一个size为1k的文件的时候,这个文件会得到一个inode,对应的写入其4k块中,之后这个块不会存储其他文件的内容了,新的文件会有新的inode。所以对于一个4k大小格式化的文件系统,如果在其上存储的所有文件都是1K这么大,那么硬盘会极大地浪费存储空间,每个4k块都仅仅存储了1K的数据,剩余3k都闲置了。ext4默认是4k一个块,对应8个硬盘扇区。为了简化讨论,我们以文件系统的1块对应硬盘一个扇区的系统来讨论。假如我们写入1byte,那么事实上硬盘的写入了这个1byte也会占用写入的那个扇区,这个扇区不会再写入其他数据了,闲置了511bytes的空间。那么我们不禁会问,我们为什么要这么做?为什么会有扇区这个概念?多浪费空间啊!硬盘创始之初为什么要这么设计呢?
我的猜想是这样:假设硬盘的盘片容量是100万bytes,那么一个机械硬盘一个探头,按照1byte的元数据粒度定位一个位置,是得有多么的慢。。。假如我们按照sector划分,按照512的粒度去定位,是不是就快了很多?尽管对于小于512bytes的文件会有空间浪费,但是这些浪费是很值得的,极大地提高了定位速度。本来cpu就快,内存也很快,IO如果以1byte的粒度定位,那程序没法做IO了。另外,我们一个通用文件系统不可能都存储这么小的文件。对于超过512bytes的文件来说,最多浪费了占用的最后一个扇区的部分空间而已。进而我们可以想象为什么ext4会设计成默认4k一个逻辑块对应了8个扇区了,都是为了定位速度。另一方面,当然也有元数据和存储空间方面的考虑,单位空间越大,总的存储空间也就越大。
通过上面的猜想,我们推想操作系统对内存的组织方式。我们都知道内存是按照页来管理的,为什么要这样?就是为了定位速度(当然此时也是有元数据量的考虑),要知道内存远比cpu慢。
内存对齐
现在可以说说cpu是如何从内存当中取数据了(可以参考Memory access granularity)。
程序员眼中的内存:
cpu眼中的内存:
这里我懒了,盗用上述链接的图,对于64位的机器,一般cpu取存储内存数据的粒度是64bits除以8为8bytes。咱们以64位的讨论。也就是说cpu取一次数据不是1byte,而是8bytes,它不会忙活一次就拿那么点,而是能拿多少拿多少,免得跑那么多次。另一个侧面看的话,也会提升访问效率。
现在又有一个问题了,那就是cpu它从什么位置开始取?随便吗?想想上面讨论的sector的来历,如果cpu一个byte一个byte的计算位置,慢不慢?事实上cpu的寻找方式是8个bytes 8个bytes的找,这下计算到哪找快多了吧?读/写总是从cpu数据访问粒度的倍数的地址开始的:0、8、16等等(通过wikipedia的Data Structure Alignment词条以及多篇中英文文章的阅读推断出来的,想深究的可以深究下告诉我)。
有了上面的基础去讨论内存对齐对性能的影响还不够,还需要了解什么是内存对齐。
内存对齐:基本类型的内存起始地址是其大小的整数倍;复合类型的内存地址是其最大成员的大小的整数倍(对于复合类型如struct的内存padding自动调整到按照最大成员来补齐以方便复合类型内存对齐的知识比较简单,这里就不介绍了。要注意这叫做内存补齐,是为了内存对齐做的)。
那么现在就来看看内存对不对齐对cpu访问的性能有什么影响(基于cpu对内存的访问)。
-
case1 内存访问粒度为1个字节(cpu眼中的内存模型等价于程序员眼中的内存模型):
Result:读取4个字节,两者都需要进行4次内存访问操作。在粒度1的情况下不需要考虑内存对齐。 -
case2 内存访问粒度为2个字节:
Result:读取4个字节,左边的(内存对齐地址)只需要进行2次内存访问操作,右边的需要进行3次内存访问操作+附加操作(见下文)。内存对齐地址取胜! -
case3 内存访问粒度为4个字节:
Result:读取4个字节,左边的只需要进行1次内存访问操作,右边的需要进行2次内存访问操作+附加操作。内存对齐地址再次取胜!简单讨论:
内存对齐地址vs没有内存对齐的地址,在三种不同的内存访问粒度下,取得了2胜一平的完胜战绩。对于32位的机器,实际的内存访问粒度是4个字节,原因如下:
每一次内存访问操作需要一个常量开销;
在数据量一定的情况下,减少内存访问操作,能提高程序运行性能;
增大内存访问粒度(当然不超过数据总线的带宽),能减少内存访问操作(从上面的实例就能够看出来);
一句话,内存对齐确实可以提高程序性能。
cpu如何处理没有内存对齐的数据访问?
1. 读取数据所在的第一块内存空间(0-1),移除多余字节(0)
2. 读取数据所在的第二块内存空间(2-3)
3. 读取数据所在的第三块内存空间(4-5),移除多余字节(5)
4. 把三块数据拼接起来(1-4),放入寄存器中。
如果cpu能这么来处理,也只不过是影响了我们程序的运行性能,至少还是能运行的,但有的cpu并没这么“勤快”,遇到没有内存对齐的数据访问,它会直接抛出一个异常。操作系统可能会响应这个异常,用软件的方式来处理,性能只会更差,或者程序直接崩溃掉。 内存对齐的代码具有更高的可移植性。
cpu cache
看完上述内容,是不是觉得写代码一定要注意内存对齐啊,好处多多。但是很遗憾,需要了解的知识还有很多,先从cache入手。
现代cpu都是有cache(如下图)系统的,cpu访问数据并不是直接访问内存中的数据,而是先访问cache,如先L1 cache,后L2,L3 cache,都不命中才会访问内存。那么问题来了,cache究竟是怎么组织的?
我们抛开L2、L3,讨论他们会使问题复杂化,我们只考虑L1 cache就能理解问题了,下面只说L1 cache。
可以通过dmidecode命令找到Cache Infomation项或者lstopo命令看到cache信息。现在问题又来了,这么大个cache空间,是组织在一起的?当然不是,这“耦合度”多高。这里有一个cache line的概念,是缓存管理的最基本单元,cache line的大小可以通过命令cat /proc/cpuinfo来查看,其中的cache_alignment就是cache line的大小,单位是byte。拿cache line为64来说,如果L1 cache是32k,那么每个core就会拥有32 * 1024 / 64这么多个cache line可以cache数据/指令。cpu读取内存数据的时候有个特性,如果没有,那就是每次即便你只读取1byte,它也会从内存的cache line对齐的位置的第一个包含了数据部的位置开始加载一个cache line这么多的数据到一个cache line之中。下次如果cache失效之前还用数据,就从cache里找包含要访问的数据的cache line,直接拿来用(当然这里包含cache一致性的问题,后面会简单说下)。那么问题来了,访问cache line有没有上面说的对齐的问题?也有,但是以cache的速度来说,你很难感知到,可以理解为没差别。如下图。
现在我们应该对cpu如何从无cache的情况访问数据有一个认识了:
- 以cache line(64的倍数的地址作为起始地址)对齐的方式访问内存,加载一个cache line大小的数据(如果不是cache line对齐,可能加载2个cache line)
- 以cpu粒度对齐访问cache line取得数据(如果不是cache line对齐并且大小可以被cache line整除,可能跨2个cache line)
内存对齐对性能的影响
现在是时候完整的算上cache来讨论内存对齐对性能的影响了。
- 对于第一步从内存加载数据到cache的过程,由于内存加载数据到cache是以cache line对齐的方式,时间性能其实都用在了cache line的加载上。问题是如果你的结构跨了cache line,那么加载它就会加载俩cache line。
- 对于第二步从cache line到cpu寄存器的过程,我这里认为对不对齐对时间性能的影响可以忽略不计。因为L1 cache实在是太快了,你会无感觉的。
- 对于原子操作,不对齐的话非常伤性能。大步长的循环不对齐非常伤性能。
- 可以预见多线程编程的话,一边读一边写什么的,不对齐可能会非常伤性能。因为cache一致性机制就需要保证核间cache包括主存的一致性了。
按照上述,那也就是说数据对不对齐除了某些场景下性能影响较大,其他也无所谓了?也不是,类似于英特尔的SSE指令对内存对齐有着严格的要求,类似于某些指令架构不支持非对齐访问,类似于有的架构的原子操作要求必须对齐等。
你可能注意到上面谈到的都是读,写的话大同小异,我只想了解该不该对齐,有没有用,不想研究那么细致。
cache一致性问题
cpu的每一个core都有自己的L1 cache,cache之间怎么协同的?并发编程时这非常重要。通过学习,我从语义上总结如下(也经过了实测):
- 对于读,每个核心都有自己的cache,如果cache有,编译器会优化(不一定非得循环才优化)读register中的值而不是cache或者内存,这种需要注意是否需要加同步原语让编译器放弃优化,比如原子操作、加锁或者内存屏障等。
- 对于写,先会写到cache,在强保证顺序一致性的内存模型架构中你可以认为语义上它写到了内存,但现实是即便像x86这样的强内存模型的架构也不是语义上百分百保证cache一致性的,所以一个线程写,另一个线程想感知到,必须通过具有内存屏障作用的指令(比如lock、memory barrier、atomic operation等)来保证。
对于缓存一致性的原理讲解,当时了解了上述语义就没去记忆细节。有兴趣的看参考链接或自行google [cache coherence]。这里可能会疑惑为什么要在说内存对齐对性能的影响的时候提cache一致性,因为理解原子操作的语义时需要用到。
原子操作
原子操作是时候登场了。原子操作是什么就不介绍了,自行搜索。我只想说说我理解到的语义上的原子操作和总线锁有什么区别。
- 总线锁: 顾名思义,会锁住总线,其他核心只能等待,自行体味性能。
- **原子操作:**只锁住所在的cache line,写的时候不一定就写到内存,至于一致性,通过cache coherence保证,自行体味性能。另外,如果对跨cache line的数据做原子操作,有的架构可能不支持,有的架构可能会退化成总线锁,其他即便不退化为总线锁,性能也会很差。
这里可能会疑惑为什么要在说内存对齐对性能的影响的时候提原子操作,因为我的例子中会用到,为了方便解释例子,这里必须说下原子操作。
怎么做比较好
综上述讨论,我认为这么做比较好:
- 结构设计的时候一定要考虑cache line的整除性。也就是说一个结构体,大小尽可能被cache line整除,可以通过添加padding很容易做到,这不是编译器能做到的。这样做的好处的显而易见,多个相同结构连续上后不会出现夸cache line的时候,提高了效率;cache line边界的它的某个对象失效时也不会一下子失效俩cache line。这叫做cache友好,这也是内存布局性能优化最最重要的地方,据说内核代码随处可见。另外,也不会出现一个cache line内有两个不相关的数据,一个失效了不会导致另一个也随着cache line的失效而失效。
- 相关联的数据尽量放到一个cache line中。这样的话加载一个数据的时候,另一个就随着cache line的加载被动的加载进来了。
- 内存池上分配时要按照cache line大小的倍数来对齐,nginx就是这么做。
- 内存池上的对象分配时要按照至少cpu粒度大小的倍数对齐,最好是64位机器按16bytes对齐,nginx就是这么做,另外malloc默认就是。
对于第1点,给个例子如下:
struct Test {
char ch1;
int i1;
...
char padding[16]; /*纯粹为了凑凑使得Test大小等于cache line*/
}
另外,其实malloc等等各种系统内存分配api得到的内存空间的首地址都会是cpu粒度对齐的,一般情况下你不需要操心。据我所知,32位系统malloc按8对齐,64位按16对齐。如果你有特殊需求需要自定义对齐,可以通过posix_memalign这个posix标准的api自定义。比如你要按照cache line对齐时,比如你要使用SSE等特殊场景需要特殊对齐时,比如你要按照内存也4k对齐时等等。
测试代码
懒了,用了一点cpp11语法,很容易改成cpp0x的。
代码:
#include <iostream>
#include <sys/time.h>
#include <cstring>
#include <vector>
// 测试组的粒度
#define TEST_GROUPS 128
// 测试个数
long long len = TEST_GROUPS * 1024 * 1024L;
// 是否对齐:打开为不对齐
//#define NON_ALIGN
// 是否测试原子行为
//#define ATOMIC
// 以下3个宏为测试函数开关
#define BIG_PAGE_TEST
//#define SMALL_OBJ_TEST
//#define SMALL_OBJ_4K_TEST
struct A {
char ch[6]; /* 6 bytes */
long long padding[7]; /* 56 bytes */
long long ll; /* 如果不对齐,则从62开始,非cache line友好。 */
#ifndef NON_ALIGN
};
#else
} __attribute__((packed));
#endif
struct LinkNode {
LinkNode() {
void *pa = nullptr;
if (int ret = posix_memalign(&pa, 64, sizeof(A))) {
std::cerr << "posix_memalign err = " << strerror(ret) << std::endl;
exit(ret);
}
a = (A*)pa;
}
A *a;
LinkNode *next;
};
__time_t big_page_test(int step = 64 * 2);
__time_t small_obj_test();
__time_t small_obj_4k_test();
void pretty_print(std::string func_name, __time_t msecs);
int main() {
std::string func_name;
__time_t msecs;
#ifdef BIG_PAGE_TEST
func_name = "big_page_test";
msecs = big_page_test();
#endif
#ifdef SMALL_OBJ_TEST
A a;
A *pa = &a;
std::cout << "pa = " << pa << std::endl;
std::cout << "pa.ll = " << &pa->ll << std::endl;
std::cout << "alignof(A) = " << alignof(A) << std::endl;
std::cout << "sizeof(A) = " << sizeof(A) << std::endl;
func_name = "small_obj_test";
msecs = small_obj_test();
#endif
#ifdef SMALL_OBJ_4K_TEST
A a;
A *pa = &a;
std::cout << "pa = " << pa << std::endl;
std::cout << "pa.ll = " << &pa->ll << std::endl;
std::cout << "alignof(A) = " << alignof(A) << std::endl;
std::cout << "sizeof(A) = " << sizeof(A) << std::endl;
func_name = "small_obj_4k_test";
msecs = small_obj_4k_test();
#endif
pretty_print(func_name, msecs);
return 0;
}
/**
* 几点说明:
* 0. 以下均为单线程下的测试,如果换了多线程,可能差距会进一步拉大。
* 1. 以下测试都使用了尽可能大的内存,尽力弱化L2 L3 cache的影响。
* 2. big_page_test和small_obj_test都没有排除硬件预取的影响。
* small_obj_4k_test会排除硬件预取的影响(预取不会超过一个page cache,
* page cache大小是4k)。
* 3. 每一个case都测试了5次取得均值(更好的方式应该是测试更多次并去掉最优和最劣的结果)。
* 4. 每一个测试的结对值结果都是" [非对齐ms] vs [对齐ms] -> [非对齐ms] : [对齐ms] "。
* 5. 测试机配置:
* - L1 32KB,L2 256KB,L3 8196KB,cache line 64bytes
* - memory 16GB
* - cpu cores 4(超线程8线程),3.50GHz
* - kernel 3.19.0-84-generic
*/
/**
* 测试大的内存页[TEST_GROUPS == 128]。
* 注:
* 1. 默认仅测试跨cache line边界的(步长step为两个cache line大小,我机器是64,读者自行掌握)。
* 2. 读者可以自行把步长调整到8,即每次读取一个机器字(相信应该都是64位的吧)。
*
* 现象及解释:
* [普通读操作]
* case 1: 步长为128
* 现象: 22 vs 13 -> 1.69。对不对齐性能比值差距很大,
* 但是这种测试压力下绝对值差距不是很明显。
* 解释: 因为首次读取cache中没有数据,需要加载cache line,
* 对齐比不对齐少加载一个cache line(假如不考虑L1预取)。
* case 2: 步长为8
* 现象: 60 vs 60 -> 1。对不对齐无感。
* 解释: 对于long long这种8字节的小类型,cache line内的数据居多,跨cache line的很少。
* cpu大多数操作都命中cache line,而这么大数据量下,
* case 1中的cache line边缘访问占比较小了,性能损耗相对很难感知。
* [原子操作]
* case 1: 步长为128
* 现象: 5721 vs 109 -> 52.97。对不对齐性能差别非常非常大。
* 解释: 考虑到读写、cache一致性以及原子操作的“实时”性,另外这种不对齐的原子行为很有可能用了总线锁,差别比[普通读操作]大非常多。
* case 2: 步长为8
* 现象: 10500 vs 700 -> 15。对不对齐性能差据非常大!
* 解释: 同case 1。但由于步长小,有了比较多的cache命中,比值趋向柔和。
* @return
*/
__time_t big_page_test(int step) {
void *p = nullptr;
size_t area_size = sizeof(long long) * (len + 1);
if (int ret = posix_memalign(&p, 64, area_size)) {
std::cerr << "posix_memalign err = " << strerror(ret) << std::endl;
return 0;
}
auto pc = (volatile char*)(p);
// 此case会导致边界访问long long跨cache line
#ifdef NON_ALIGN
pc += 62;
#else
pc += 64;
#endif
volatile unsigned long long tmp;
struct timeval t_val_start;
gettimeofday(&t_val_start, NULL);
for (long long i = 0; i < area_size / step - 1; ++i) {
#ifdef ATOMIC
__sync_fetch_and_add((unsigned long long*)pc, i);
#else
tmp = *((unsigned long long*)pc);
#endif
pc += step;
}
struct timeval t_val_end;
gettimeofday(&t_val_end, NULL);
auto secs = t_val_end.tv_sec - t_val_start.tv_sec;
auto usecs = t_val_end.tv_usec - t_val_start.tv_usec;
auto msecs = secs * 1000 + usecs / 1000;
return msecs;
}
/**
* 测试小的链表连接的内存对象[TEST_GROUPS == 32]。
* 链表及其中的结构都按照64 cache line大小对齐,小步长。
* 现象及解释:
* [普通读操作]
* 现象: 835 vs 1275 -> 0.66。对齐反而比不对齐慢了很多。
* 解释: 按照我的结构体大小来看,对不对齐都会占两个cache line,
* 不对齐也不会省cache,为什么非原子操作不对齐反而会变快?
* [!猜测!]通过内存地址的观察,我发现步长都超过了100bytes,
* 结果就是对齐每200多bytes左右才会miss一次cache line,
* 而不对齐会连续miss cache line,会促进预取(偶尔一次cache miss很正常,比如使用了一个不常用的全局变量,只有连续cache miss几次才会可能触发预取,关于预取参考memory prefetch)。
* 我们不要忽略了硬件预取的特性。硬件在获取数据的时候
* 如果算法内判断可以预取优化,它就会预取之后可能用到的数据。
* -> small_obj_4k_test会验证这个猜测。
* [原子操作]
* 现象: 27500 vs 1600 -> 17.19。差距非常大,并且和big_page_test的小步长结果很像。
* 类似于big_page_test。此时预取的效果会被原子的负载抵消掉。
* @return
*/
__time_t small_obj_test() {
LinkNode *head = new LinkNode();
head->a = nullptr;
auto cur_pre_node = head;
for (long long i = 0; i < len; ++i) {
void *pln;
if (int ret = posix_memalign(&pln, 64, sizeof(LinkNode))) {
std::cerr << "posix_memalign err = " << strerror(ret) << std::endl;
return 0;
}
auto ln = new(pln)LinkNode();
cur_pre_node->next = ln;
cur_pre_node = ln;
}
volatile unsigned long long tmp;
struct timeval t_val_start;
gettimeofday(&t_val_start, NULL);
long long i;
auto cur_node = head->next;
for (i = 0; i < len; ++i) {
#ifdef ATOMIC
__sync_fetch_and_add(&cur_node->a->ll, i);
#else
tmp = *((volatile unsigned long long*)(&cur_node->a->ll));
#endif
cur_node = cur_node->next;
}
struct timeval t_val_end;
gettimeofday(&t_val_end, NULL);
auto secs = t_val_end.tv_sec - t_val_start.tv_sec;
auto usecs = t_val_end.tv_usec - t_val_start.tv_usec;
auto msecs = secs * 1000 + usecs / 1000;
return msecs;
}
/**
* 测试小的链表连接的内存对象[TEST_GROUPS == 32]。
* 基本思想:硬件预取最多会预取一个page cache即4k内的数据,所以加入我的步长超过了4k,
* 那么预取就没有效果了。
* [普通读操作]
* 现象: 72 vs 55 -> 1.31。对不对齐有较大差距。
* 解释: 步长跨4k排除预取干扰,对不对齐回归理论且差距很明显。
* 有的可能会问了,那为什么big_page_test的小步长没出现small_obj_test
* 的那种反常情况?因为big_page_test是long long型用的连续内存,不存在空隙,
* 对不对齐都能很好的预测预取。
* @return
*/
__time_t small_obj_4k_test() {
std::vector<LinkNode*> page_head;
page_head.reserve(512);
LinkNode *head = new LinkNode();
head->a = nullptr;
auto cur_pre_node = head;
LinkNode *last_4k_node;
for (long long i = 0; i < len; ++i) {
void *pln;
if (int ret = posix_memalign(&pln, 64, sizeof(LinkNode))) {
std::cerr << "posix_memalign err = " << strerror(ret) << std::endl;
return 0;
}
auto ln = new(pln)LinkNode();
if (0 == i) {
last_4k_node = ln;
} else if ((reinterpret_cast<long>(&ln->a->ll)
- reinterpret_cast<long>(&last_4k_node->a->ll))
> 1024 * 4) {
page_head.push_back(ln);
last_4k_node = ln;
}
cur_pre_node->next = ln;
cur_pre_node = ln;
}
volatile unsigned long long tmp;
struct timeval t_val_start;
gettimeofday(&t_val_start, NULL);
for (auto p : page_head) {
tmp = *((volatile unsigned long long*)(&p->a->ll));
}
struct timeval t_val_end;
gettimeofday(&t_val_end, NULL);
auto secs = t_val_end.tv_sec - t_val_start.tv_sec;
auto usecs = t_val_end.tv_usec - t_val_start.tv_usec;
auto msecs = secs * 1000 + usecs / 1000;
return msecs;
}
void pretty_print(std::string func_name, __time_t msecs) {
std::cout << func_name << " duration "
#ifdef NON_ALIGN
<< "[without align] attr "
#else
<< "[with align] attr "
#endif
#ifdef ATOMIC
<< "[with atomic] "
#else
<< "[without atomic] "
#endif
<< "is [" << msecs << "] milli seconds." << std::endl;
}
CMakeLists.txt:
cmake_minimum_required(VERSION 3.6)
project(mem_align)
set(CMAKE_CXX_STANDARD 11)
set(CMAKE_CXX_FLAGS "-O2")
set(SOURCE_FILES main.cpp)
add_executable(mem_align ${SOURCE_FILES})
趣事分享
其实我在测试的时候,写过第四个用例,就是对big_page_test的改造,不想贴太多代码就没贴出来,并且也和主题关系不是很大。我定义了一个结构体,成员为char和long long两个。我在测试的方法就是顺序的读(不是原子操作)long long(步长为9,就等于一个结构体一个结构体的访问),我发现用指令packed对齐之后速度反而比不对齐快了,通过上面的讲解,很容易想到为什么。