同一块内存,连续两次memset,耗时相差4倍!揪出隐藏的性能杀手!

本专题除讲解常见性能问题以及分析、调优手段外,还会重点讲解一些对系统性能至关重要,却又经常被忽视的高级话题,如Cache、指令流水线、Superscalar、SIMD、分支预测、内存屏障等。此外,还会涉及到编译器、操作系统等话题。

有这么一道题:

图片

问:

  1. 1. 三次memset()的性能有差异吗?

  2. 2. 为什么?

  3. 3. 如何调优?

该如何回答呢?

先简单验证下这三次memset()的性能到底有没有差异。

测试代码

代码很简单,申请一块内存,连续三次调用memset(),并且测量这三次memset()的耗时:

图片

编译运行一下:

图片

第一次memset()耗时约0.66秒,第二次和第三次耗时0.17秒,这是为什么呢?

内容提要

本文会详细分析这三次memset()的性能差异的原因,并给出优化方案,最终使得三次memset的性能基本一致,结果如下:

图片

在解释这三次memset()的性能差异以及优化方案之前,需要先介绍一些关于内存管理的基础知识。

虚拟地址

我们知道,应该程序只能看到虚拟地址,必须要先转换成物理地址,然后才能通过地址总线访问到物理内存。

那么,虚拟地址是怎么转换为物理地址的呢?

这个过程有点抽象,且比较复杂,为了方便理解,先举一个例子吧。

注:对MMU不熟悉的童鞋,建议仔细理解一下这个例子,能够很好地帮助理解下文要讲的地址转换过程。如果你已经很熟悉的话,可以跳过,直接看后面的原因分析和优化方法。

实例:设计一个学号查询系统

假设,我们要设计一个学号查询系统,给全国所有的大、中、小学生分配一个全国唯一的学号,给定一个学号能够立即对应到具体的学生。再假设,要求我们必须用一个48bit的数字来表示这个学号。

我们可以这样设计学生学号:用9个bit表示省份,9个bit表示城市,9个bit表示区县,9个bit表示学校,12个bit表示学生在学校内的编号。如下图所示:

图片

接下来,我们来设计这个查询系统。经分析,我们需要至少5种表,分别是:

  • • 省份表:包含所有的省份,一个省份对应一个表项,每个表项是一个指针,指向本省的城市表

  • • 城市表:每个省都有一个自己的城市表,包含本省所有的城市,一个城市对应一个表项,每个表项是一个指针,指向本市的区县表

  • • 区县表:每个市都有一个自己的区县表,包含本市所有的县/区,每个区县对应一个表项,每个表项是一个指针,指向本县/区内的学校表

  • • 学校表:每个县/区都有一个学校表,包含本县/区内所有的大、中、小学,每个学校对应一个表项,每个表项是一个指针,指向本学校的学生表

  • • 学生表:包含一个学校内的所有的学生,每个表项是一个特定的学生。

现在,给定一个学号:0x010203040506,我们可以立即根据这个学号找到对应的学生,如下图所示:

图片

简单解释一下这个过程:

  1. 0. 我们把省份表存放在固定的一个地方,称之为全局表指针,每次查询时直接从这里获取省份表

  2. 1. 根据省份索引在省份表中找到了浙江省的城市表

  3. 2. 根据城市索引在城市表中找到了杭州市的区县表

  4. 3. 根据区县索引在区县表中找到了西湖区的学校表

  5. 4. 根据学校索引在学校表中找到了浙江大学的学生表

  6. 5. 根据学生校内编号在学生表中找到了对应的学生,张三

这个过程是不是很简单?给定任意一个学号,我们立刻可以对应到: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,转换为物理地址过程如下图所示:

图片

这个过程和上文讲的学号对应到具体学生的例子非常相似。解释下这个过程:

  1. 0. 从CR3寄存器中取得PML4表的地址。

  2. 1. 根据虚拟地址中的PML4索引字段,在PML4中找到对应的表项PML4E,指向PDPT的地址。

  3. 2. 根据PDPT索引,在PDPT中找到对应的表项PDPTE,指向PDT的地址。

  4. 3. 根据PDT索引,在PDT中找到对应的表项PDTE,指向PT的地址。

  5. 4. 根据PT索引,在PT中找到对应的表项PTE,指向一个物理页面的地址。

  6. 5. 获取到物理页的地址后,再加上页内偏移,就取得了最终的物理地址。

一句话概括,就是根据虚拟地址中各个部分的索引,逐级在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出去了)。

整个过程如下图所示:

图片

缺页异常的开销是非常大的,主要因为:

  1. 1. CPU异常处理机制本身的开销。

  2. 2. 用户态和内核态上下文切换的开销。

  3. 3. 分配物理页面的开销。

  4. 4. 如果物理页面不足,可能触发内存回收动作。

  5. 5. 如果物理页面被swap出去,就需要通过disk I/O把页面重新swap进来,这个开销非常大。

  6. 6. 被缺页异常打断的线程,甚至有可能会被调度出去。

看到这里,应该已经明白为什么文章开头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的引入,可以带来这些好处:

  1. 1. 大大减少Page Fault的次数

  2. 2. 大大降低TLB Miss的概率

  3. 3. 减少了MMU地址转换需要的页表层级,不但节省了内存,还能提高MMU地址转换的效率。

  4. 4. 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的数量!

但是第一次memset()性能仍然比第二次和第三次慢了很多,还能再继续优化吗?

当然可以!

提前映射页面

使用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内存快的多。

  • 3
    点赞
  • 5
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
memset函数是C语言中的一个库函数,用于将一块内存区域的值设置为指定的值。它的原型如下: ```c void *memset(void *s, int c, size_t n); ``` 其中,`s`是指向要设置的内存区域的指针,`c`是要设置的值,`n`是要设置的字节数。 使用memset函数时,需要注意以下几点,以避免可能导致内存泄漏的情况: 1. 内存分配:在使用memset函数之前,需要确保已经为要设置的内存区域分配了足够的内存空间。否则,可能会导致访问非法内存区域或者覆盖其他变量的值。 2. 内存释放:如果在使用memset函数之前已经为内存区域分配了内存空间,并且在使用完毕后不再需要该内存空间,应该及时释放内存,以避免内存泄漏。可以使用`free`函数来释放动态分配的内存空间。 3. 注意边界:在使用memset函数时,需要确保不会越界访问内存区域。如果要设置的字节数超过了实际分配的内存大小,可能会导致访问非法内存区域或者覆盖其他变量的值。 4. 结构体和指针:当要设置的内存区域是一个结构体或者指针时,需要特别注意内存的释放和管理。如果结构体或指针中包含了动态分配的内存空间,需要在使用memset函数之前先释放这些内存空间,以避免内存泄漏。 总之,使用memset函数时需要注意内存的分配和释放,以及边界情况的处理,以避免可能导致内存泄漏的问题。

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值