同一块内存,连续两次memset,耗时居然相差4倍!
文主要讲解一个非常重要的性能调优方法,会涉及CPU和操作系统内部一些实现细节,为讲解清楚,篇幅较长,请耐心看完,一定会有收获!
有这么一道题:
问:
-
三次memset()的性能有差异吗?
-
为什么?
-
如何调优?
该如何回答呢?
先简单验证下这三次memset()的性能到底有没有差异。
测试代码
代码很简单,申请一块内存,连续三次调用memset(),并且测量这三次memset()的耗时。
编译运行一下:
第一次memset()耗时约0.66秒,第二次和第三次耗时0.16秒,这是为什么呢?
内容提要
本文会详细分析这三次memset()的性能差异的原因,并给出优化方案,最终使得三次memset的性能基本一致 ,结果如下:
在解释这三次memset()的性能差异以及优化方案之前,需要先介绍一些关于内存管理的基础知识。为避免枯燥的理论堆积和冗长乏味的源码分析,我会用最通俗的语言来介绍这些概念。
虚拟地址
我们知道,应用程序都要通过虚拟地址来访问内存,比如a = 123;这条语句,语义是把123写入存放变量a的内存地址处。假设程序运行时,变量a的地址是0x10000,我们知道这个是虚拟地址,要把123真正写入内存,CPU必须要先把虚拟地址转换为物理地址,才能通过地址总线访问到物理内存。
那么,虚拟地址是怎么转换为物理地址的呢?
这个过程有点抽象,且比较复杂,为了方便理解,先举一个例子吧。
注:对虚拟地址到物理地址转换不熟悉的童鞋,强烈建议仔细理解一下这个例子,能够很好地帮助理解下文要讲的地址转换过程。如果你已经很熟悉的话,可以跳过,直接看后面的原因分析和优化方法。
实例:设计一个学号查询系统
假设,我们要设计一个学号查询系统,给全国所有的大、中、小学生分配一个全国唯一的学号,给定一个学号能够立即对应到具体的学生。再假设,要求我们必须用一个48bit的数字来表示这个学号。
我们可以这样设计学生学号:用9个bit表示省份,9个bit表示城市,9个bit表示区县,9个bit表示学校,12个bit表示学生在学校内的编号。如下图所示:
接下来,我们来设计这个查询系统。经分析,我们需要至少5种表,分别是:
-
省份表 :包含所有的省份,一个省份对应一个表项,每个表项是一个指针,指向本省的 城市表
-
城市表 :每个省都有一个自己的城市表,包含本省所有的城市,一个城市对应一个表项,每个表项是一个指针,指向本市的 区县表
-
区县表 :每个市都有一个自己的区县表,包含本市所有的县/区,每个区县对应一个表项,每个表项是一个指针,指向本县/区内的 学校表
-
学校表 :每个县/区都有一个学校表,包含本县/区内所有的大、中、小学,每个学校对应一个表项,每个表项是一个指针,指向本学校的 学生表
-
学生表 :包含一个学校内的所有的学生,每个表项是一个 特定的学生
现在,给定一个学号:0x010203040506,我们可以立即根据这个学号找到它对应的学生,如下图所示:
简单解释一下这个过程:
-
我们把省份表存放在固定的一个地方,称之为全局表指针,每次查询时直接从这里获取省份表。
-
根据省份索引在省份表中找到了浙江省的城市表
-
根据城市索引在城市表中找到了杭州市的区县表
-
根据区县索引在区县表中找到了西湖区的学校表
-
根据学校索引在学校表中找到了浙江大学的学生表
-
根据学生校内编号在学生表中找到了对应的学生,张三
这个过程是不是很简单?给定任意一个学号,我们立刻可以对应到:A省B市C区D学校的学生E。
不过,虽然这个查询过程很简单,但是每次都要查询5张表才能找到具体的某个学生,这样效率太低了。有没有什么办法可以提高整体查询效率呢?
性能优化:引入快表
经过统计研究,我们发现一个很重要的规律: 如果我们查询了一个学生,我们通常会很快再次查询这个学生,或者同一个学校的其他学生。
于是,我们又设计了一张新的表,称为 快表 ,把我们最近查询的学生所在的学校对应的学生表直接记录下来,那么下次在查询同一个学校的学生的时候,我们就可以从快表中直接取到学生表,然后从中找到对应的学生。
比如,我们刚刚查询了学号0x010203040506,也就是浙江省杭州市西湖区浙江大学的张三,于是我们在快查表中记录这么一个表项:
0x01020304->浙江大学学生表
然后,我们又查询学号0x010203047777,先用学校信息0x01020304在快表里查询,发现对应浙江大学的表项,于是直接取得浙江大学的学生表,然后用后面的学生编码0x7777找到浙江大学的学生李四。可见,这个过程比从省表、市表、区县表、学校表逐级查询要快很多。
现在,我们查询另外一个学号0x050903016666,同样,先用学校信息0x05090301在快表里查找,没找到对应学校的表项,于是必须逐级从省表、市表、区县表、学校表里逐级查询,最后找到:江苏省南京市南京栖霞区南京大学的小明。于是,我们在快表中再加入一条记录:
0x01020304->浙江大学学生表
0x05090301->南京大学学生表
现在,快表中有浙江大学和南京大学的两条记录了,之后再查询着两个学校的学生时,就可以直接从快表中取得学生表,然后快速定位到具体的学生。
不过, 快表的容量很小,最多只能容纳5个学校的记录,当快表被填满时,就要用新的信息把旧的记录给替换掉 ,这样一来,下次再查询旧信息对应学校的学生时,就必须要逐级查询了。
Finally!终于把这个有点蹩脚的例子讲完了,居然占了这么大的篇幅。。。不知是否看明白了呢?
啰里啰唆了半天,这个学号查询系统,跟虚拟地址到物理地址的转换,到底有什么关系呢?
MMU(Memory Management Unit)
从虚拟地址到物理地址的转换,是由CPU内部一个专门的部件自动完成的,也就是 MMU(Memory Management Unit) 。
MMU以Page为单位进行虚拟地址和物理地址的映射,所谓一个Page,就是一块地址连续的内存 ,不同的CPU,支持的Page大小不同。以x64为例,CPU支持4KB、2MB、1GB大小的Page,但目前大部分系统上默认使用4KB的Page,如Linux。
如果把虚拟地址比作学生学号的话,那么物理地址就是具体的某个学生,而MMU就是我们上面设计的学生ID查询系统。MMU把虚拟地址转换成物理地址的过程,与学生学号对应到具体学生的过程,是非常相似的。
下面以X64 CPU为例进行讲解。 X64虽然理论地址宽度是64bit,但目前大部分CPU只支持48bit共256TB的虚拟地址空间 。对于4KB大小的页面,MMU采用4级页表进行地址转换,分别是:
-
PML4(Page Map Level 4) ,第4级页映射表,每个表项指向一个PDPT
-
PDPT(Page Directory Pointer Table) ,页目录指针表,每个表项指向一个PDT
-
PDT(Page Directory Table) ,页目录表,每个表项指向一个PT
-
PT(Page Table) ,页表,每个表项指向一个物理页面
和学生学号一样,48bit的有效虚拟地址被划分成了5部分:
-
9bit PML4索引
-
9bit PDPT索引
-
9bit PDT索引
-
9bit PT索引
-
12bit 页内偏移
给定一个虚拟地址0x010203040506,转换为物理地址过程如下图所示:
这个过程和上文讲的学号对应到具体学生的例子非常相似。解释下这个过程:
-
从CR3寄存器中取得PML4表的地址。
-
根据虚拟地址中的PML4索引字段,在PML4中找到对应的表项PML4E,指向PDPT的地址。
-
根据PDPT索引,在PDPT中找到对应的表项PDPTE,指向PDT的地址。
-
根据PDT索引,在PDT中找到对应的表项PDTE,指向PT的地址。
-
根据PT索引,在PT中找到对应的表项PTE,指向一个物理页面的地址。
-
获取到物理页的地址后,再加上页内偏移,就取得了最终的物理地址。
一句话概括, 就是根据虚拟地址中各个部分的索引,逐级在PML4、PDPT、PDT、PT中检索,找到物理页面的地址后,再加上页内偏移,就得到最终的物理地址了。
MMU的性能问题
这个转换过程,虽然是MMU硬件自动完成的,但各级页表本身仍然存放在内存中的,一次地址转换过程中,可能要多次访问内存,因此,这个转换过程的开销是非常大的。
如果程序每次访问内存,MMU都要经过这么复杂的转换才能得到物理地址的话,必然会严重影响程序的整体性能。有没有办法解决这个问题呢?
还记得我们是怎么优化学号查询系统的性能的吗?我们把最近查询的学生所在学校的学生表地址,缓存在额外的一张"快表"中,每次查询的时候,先用学号里的学校信息在快表中查找,如果找到,就直接把快表里缓存的学生表取出来,然后用学号里的校内编号就可以直接对应到具体的学生了,如此就避免了逐级查询的开销了。
虚拟地址到物理地址的转换,是不是也可以借鉴这种方法呢?
引入TLB
之前文章讲过,为了解决内存和CPU之间速度严重不匹配的问题,利用程序局部性原理,在CPU和内存之间引入了L1,L2,L3高速缓存,也就是Cache,把最近访问到的数据存放在Cache中,以此减少直接内存访问。
这个思路,在这里同样适用。虚拟地址转换为物理地址时,为了避免每次MMU都必须要逐级查询页表,CPU内部专门设计了一个部件- TLB(Translation Lookaside Buffer),通常称为"快表"。TLB本质上是虚拟页地址和物理页地址对应关系的Cache。
CPU把最近访问到的虚拟页的地址,和它所映射的物理页的地址,组成一个地址对,直接缓存在TLB中。这样一来,每次通过虚拟地址访问内存时,CPU会先用虚拟页的地址在TLB中查找,如果找到,称为 TLB Hit ,就直接得到物理页的地址,然后加上页内偏移就能得到物理地址。如果找不到,称为 TLB Miss ,就必须通过MMU逐级检索页表进行地址转换了。
但是, TLB的容量一般非常小,只能缓存很少的页面地址对,当TLB被占满时,旧的地址对会被新的替换掉。
当然,这只是TLB的基本原理,实际上的实现要复杂的多,而且不同的CPU上TLB也各有差异,限于篇幅,不再展开。
malloc()和缺页异常
应用程序一般通过glibc来申请内存,如malloc()等函数返回的都是 虚拟地址 。
glibc以内存池的方式进行内存管理,应用程序调用malloc()申请内存时,如果内存池中有合适大小的内存块,就直接取出使用,如果没有合适的,就把大的内存块切分成合适的内存块,返回给应用程序。如果连大的内存块都没有的话,就通过系统调用,向内核申请一块新的虚拟内存。
内核为应用程序分配虚拟内存的时候,只是在虚拟地址空间中划分一块指定大小的地址范围出来,并不会立即为其分配物理内存。
应用程序拿到malloc()返回的虚拟地址后,就可以访问这块内存空间了。 第一次访问 这段虚拟地址的一个页面时,由于kernel还没有为其映射物理页面,所以会 触发缺页异常(Page Fault) ,进入内核态缺页异常处理流程。内核会分配一个物理页面,并把物理页面的地址和虚拟地址的映射关系更新到页表中,本次缺页异常处理就结束了,然后切换到用户态继续被中断的内存访问。
这个过程,对用户态程序是透明的,它甚至根本意识不到缺页异常的存在。当应用程序再次访问到同一个虚拟页面时,就能够正常访问了,不会再次触发缺页异常(除非这个页面又被内核swap出去了)。
整个过程如下图所示:
缺页异常的开销是非常大的,主要因为:
-
CPU异常处理机制本身的开销。
-
用户态和内核态上下文切换的开销。
-
分配物理页面的开销。
-
如果物理页面不足,可能触发内存回收动作。
-
如果物理页面被swap出去,就需要通过disk I/O把页面重新swap进来,这个开销非常大。
-
被缺页异常打断的线程,甚至有可能会被调度出去。
看到这里,应该已经明白为什么文章开头test.c中第一次memset()要比第二次和第三次性能差那么多了吧?
为什么第一次memset()那么慢?
test.c用malloc()分配了1GB的虚拟内存,内核并没有立即为其分配物理内存,所以每当第一次访问一个虚拟页面,都会触发缺页异常。也就意味着,第一次memset()的时候,会触发1GB / 4KB = 262144次缺页异常,而当第二次和第三次memset()的时候,内核已经分配好了物理内存,不会再次触发缺页异常,所以性能比第一次memset()要好的多。
这只是理论分析,现在我们来验证一下。先把第二次和第三次memset()注释掉:
编译运行,并用perf统计下一共触发多少次缺页异常:
可以看到,只有一次memset的时候,一共触发了262199次缺页异常,比我们计算的262144多了55次,这是因为程序的数据段、代码段、栈空间等运行时也可能会触发缺页异常。
现在把第二次和第三次memset()重新加回去,重新编译运行,再用perf统计一下:
缺页异常仍然触发了262199次,和只有一次memset()时一模一样,可见,第二次和第三次memset()连一次缺页异常都没触发!
另外,也留意一下,现在执行三次memset()时,一共触发了2574501次dTLB-load-misses,我们后面优化之后,会再对比一下这个数据。
现在知道了第一次memset()性能差的原因,那么,如何进行优化呢?
一种常用的优化方式是,采用Huge Page替代默认的4KB Page。
Huge Page
随着计算机技术的不断发展,物理内存速度越来越快,容量也越来越大。当前计算机的物理内存动辄就是几十GB,或者上百GB,有些高性能服务器上甚至达到TB的级别。在这个背景下,传统的4KB页面大小的内存管理方式,显然对一些对内存需求量大的程序已经不适合了。
传统4KB页面的局限
以4GB虚拟内存为例,如果采用4KB大小的页面进行管理,就要有4GB/4KB = 1048576个Page,也就意味着需要1048576个页表项,再加上中间各级页表目录,就会占用大量的内存来存储各级页表。
更重要的是,程序访问这4GB内存,至少要经过1048576次TLB Miss和缺页异常,才能把这4GB虚拟内存全部映射到物理内存。此外,即便物理内存映射全部完成之后,程序在正常运行时,由于TLB能够缓存的地址映射极其有限,数目如此巨大的页表项必然会引发大量的TLB Miss,严重影响程序性能,尤其当程序局部性不好,内存随机访问比较多的时候,比如大量引用和遍历链表等数据结构时,这种性能影响会尤为明显。
前面介绍MMU地址转换的时候讲过,CPU一般支持多种大小的Page,比如X64就支持4KB、2MB、1GB大小的Page。那么,既然采用传统的4KB页面内存管理,可能存在各种各样的性能问题,是不是可以用更大的页面进行管理呢?
是的,现在的操作系统,虽然一般仍然默认采用4KB大小的页面进行内存管理,但同时也支持更大的页面。以Linux为例,内核引入了Huge Page的特性,可以使用2MB或1GB的大页来代替传统的4KB页面。
这样一来,同样的4GB虚拟内存,如果使用2MB的大页,只需要4GB/2MB = 2048个Page,缺页异常和TLB都会大幅下降,能够显著提升程序性能。如果用1GB的页面的话,只需要4GB / 1GB = 4个Page,此时,缺页异常和TLB Miss的影响被降到了最小。
MMU对大页的支持
对MMU来说,使用更大的页面,和使用4KB的页面,本质上没有区别,只不过使用更大的页面可以减少需要用到的页表层级。此外,使用大页后,页面映射少了,对TLB的压力也就小了,可以降低TLB Miss的概率。
如X64上,使用2MB的页面,只需要3级页表就可以了,相比4KB的页面,减少一级页表,地址转换过程如下图:
如果使用1GB页面的话,就只需要2级页表,相比4KB页面,减少了2级页表,地址转换过程如下图:
简单来说,Huge Page的引入,可以带来这些好处:
-
大大减少Page Fault的次数
-
大大降低TLB Miss的概率
-
减少了MMU地址转换需要的页表层级,不但节省了内存,还能提高MMU地址转换的效率。
-
Huge Page可以永驻内存,避免被swap出去。
当然,大量使用Huge Page的话,也可能会导致部分内存的浪费。
Huge Page的应用非常广泛,很多高性能场景中,都会通过使用Huge Page来提升整体性能,如DPDK等。
Linux对Huge Page的支持
Linux支持两种Huge Page:
-
一种是标准的Huge Page,需要预先分配,管理和使用起来稍显麻烦,但实际场景中用的比较多。
-
另一种叫透明大页(Transparent Huge Pages),可以在使用时动态分配,使用相对简单,但只支持2MB的大页,而且仍然有可能被swap出去。
下面,以我系统上默认支持的2MB的标准Huge Page来验证下,是否能提升第一次memset的性能。
为避免影响,先把透明大页关闭:
echo never > /sys/kernel/mm/transparent_hugepage/enabled
然后,确认下默认支持的Huge Page的大小确实是2MB:
root@ubuntu:test# cat /proc/meminfo | grep Hugepagesize
Hugepagesize: 2048 kB
然后预分配1024个Huge Page,也就是2GB的内存:
root@ubuntu:test# cat /proc/sys/vm/nr_hugepages
0
root@ubuntu:test# echo 1024 > /proc/sys/vm/nr_hugepages
再确认一下:
root@ubuntu:test# cat /proc/meminfo | grep Huge
AnonHugePages: 0 kB
ShmemHugePages: 0 kB
FileHugePages: 0 kB
HugePages_Total: 1024
HugePages_Free: 1024
HugePages_Rsvd: 0
HugePages_Surp: 0
Hugepagesize: 2048 kB
Hugetlb: 2097152 kB
把代码稍微修改一下,以便启用Huge Page:
重新编译运行:
第一次memset()耗时降到了从使用4KB页面时的0.659秒,降到了0.238秒,第二次和第三次memset的耗时也稍有降低!
用perf看下性能数据:
缺页异常次数从使用4KB页面时的262199次,降到了568次,而dTLB-load-misses也从2574501次,降到了27004次!由此可见,与默认的4KB的Page相比,使用2MB的Huge Page确实显著降低了缺页异常和dTLB Miss的数量!
用了2MB的Huge Page后,第一次memset()性能虽然比用4KB页面时提高了不少,但仍然比第二次和第三次慢了很多,还能再继续优化吗?
当然可以!
提前映射页面
使用2MB的Huge Page虽然显著降低了缺页异常,但并没有完全消除,因此第一次memset()仍然比第二次和第三次慢了很多。
不过,mmap提供了一个MAP_POPULATE标记,可以在分配内存的时候提前把页面映射好,从而避免运行时的缺页异常。
咱们来验证一下,简单修改下代码,调用mmap()的时候,加上MAP_POPULATE标记:
重新编译运行:
看到了吧,现在三次memset()性能几乎一样了!
最后,再用perf看下缺页异常次数是否有变化:
果然,现在缺页异常只有55次,可见三次memset()过程中不会再触发任何缺页异常了!
其他可能的优化点
对于内存需求量大的应用场景,除了Huge Page外,还可以考虑从这些方向进行优化:
-
让程序保持较好的局部性,顺序访问优于随机访问,如数组访问优于链表访问。
-
预读或预热,如进入关键路径之前,先确保物理内存已经被分配。比如,有些系统设计为了解决这类问题,甚至会专门创建一个helper线程,专门负责预读,不仅适合文件I/O,也适合内存访问量比较大的场景。
-
禁用swap。swap虽然可以一定程度上缓解物理内存短缺的问题,但是却可能会影响系统整体性能,尤其在热点路径上,如果发生页面swap,会对程序性能产生严重影响。
-
考虑NUMA的影响,程序访问本地node上的内存比访问远程node内存快的多。
良许重磅推出三门精品小课:C语言、51单片机、Qt,全部都是线下班同款,口碑炸裂!!
课程全部提供答疑服务,并且赠送全套资料。目前限时优惠,随时涨回原价,欢迎抢购!
已经购课的小伙伴,请加微信 sgkbc10 ,领取全套资料,拉专属答疑群。