代码改变世界

内存性能工具:Part 5 缺页优化

2010-07-05 16:32  Robbin  阅读(2689)  评论(0编辑  收藏  举报

7.5 缺页优化

像Linux这样按需分页(demand-paged)的操作系统,mmap调用只会修改页表。这就保证了,对于文件映射(file-backed)页面,页面的内部数据是可以找到的,而匿名页面,在发生存取操作时,页面会被初始化为0后才会被允许使用。mmap调用时不会有实际内存被分配出去。{如果你想说“那是错的”,那么先等上一秒钟,后面当然会解释异常情形的。}

分配发生在一个物理页面被第一次请求访问时,不管是读还是写数据,或是运行代码。在处理缺页时,内核进行控制并作出决定,使用页表树,让数据出现在页面上。解决缺页并不低廉,不过进程使用的每个页面都会发生一次缺页。

要想最小化缺页带来的消耗,就必须减少全部已用页面的数量。可以通过优化代码来减少页面的数量。要减少某一程序路径上的消耗(例如,程序的启动阶段),可以通过重新安排代码顺序使得在这个代码路径上被操作的页面数得到减少。但并不是很容易确定正确的顺序。

作者开发了一个工具,基于valgrind工具集之上,可以测量缺页的发生。不是缺页的数量,而是为何发生缺页。pagein工具显示了缺页的信息,包括顺序和时间。输出会写入到一个名为pagein.<PID>中,就像图7.8所示

   0 0x3000000000 C            0 0x3000000B50: (within /lib64/ld-2.5.so)
   1 0x 7FF000000 D         3320 0x3000000B53: (within /lib64/ld-2.5.so)
   2 0x3000001000 C        58270 0x3000001080: _dl_start (in /lib64/ld-2.5.so)
   3 0x3000219000 D       128020 0x30000010AE: _dl_start (in /lib64/ld-2.5.so)
   4 0x300021A000 D       132170 0x30000010B5: _dl_start (in /lib64/ld-2.5.so)
   5 0x3000008000 C     10489930 0x3000008B20: _dl_setup_hash (in /lib64/ld-2.5.so)
   6 0x3000012000 C     13880830 0x3000012CC0: _dl_sysdep_start (in /lib64/ld-2.5.so)
   7 0x3000013000 C     18091130 0x3000013440: brk (in /lib64/ld-2.5.so)
   8 0x3000014000 C     19123850 0x3000014020: strlen (in /lib64/ld-2.5.so)
   9 0x3000002000 C     23772480 0x3000002450: dl_main (in /lib64/ld-2.5.so)

图 7.8: pagein工具的输出

第二列说明了被换入页面的地址。它是代码还页面是数据页面会在第三列指明,以'C'或'D'来区分。第四列指明了自发生第一次缺页后经过了多少时钟周期。剩下的valgrind通过地址找到的对应的造成缺页函数名。地址肯定是正确的,但如果没有包含调试信息则函数名不一定正确。

在图7.8中的例子里,可执行程序在地址0x3000000B50处启动,这就强制在地址0x3000000000处的页面被换入。很快,跟随它之后的页面也被换入了。在之后的页面被调用的程序是_dl_start。初始化代码会访问页面0x7FF000000上的一个变量。这发生在第一次缺页的3,320周期之后,很有可能就是程序的第二条指令(在第一条指令的三个字节之后)。如果有人仔细查看这个程序,会发现这次内存存取有些奇怪。令人奇怪的是这是一条call指令,它是不会显式的读或写数据的。但是它会将返回地址存放到栈上,这也就是所发生的事情。 这里不是正式的进程栈,而仅是valgrind内部的应用程序栈。这意味着当去分析pagein的结果时要注意valgrind也会引入某些额外数据。

pagein的输出可以用来发现程序代码中哪部分应该进行调整顺序。简单的看一下/lib64/ld-2.5.so的代码可以发现第一条指令会立刻调用_dl_start,但这两处是在不同页面上的。重排代码将代码逻辑放到相同的页面上,可以避免或至少延迟缺页发生。到现在为止,决定更优的代码布局的流程显得很笨拙。页面被再次使用时是不会被记录的,因此需要使用试错法来观察改变的效果。使用调用图进行分析,可以有效猜到可能的调用顺序;这可以帮助加速函数和变量的排序过程。

不太严谨的话,调用顺序可以通过检查组成可执行文件或DSO的目标文件来判断。从一或多个入口点(例如,函数名),可以计算出依赖链 。在目标文件层面这项工作非常简单。在每一轮,确定哪个目标文件包含需要的函数和变量。输入集合需要显式指定。然后确定那些目标文件中所有未定义的引用并将它们添加到所需要符号的集合中。重复直到集合稳定下来。

流程的第二步是决定一个顺序。各种目标文件必须进行分组来使得可以使用尽可能少的页面。一个额外的好处是,没有哪个函数需要跨越页边界。这些事情复杂的部分在于,要想使目标文件排列最优,就必须要对链接器后面所要做什么非常了解。一个重要的事实就是可执行文件或DSO都会被链接器按照目标文件出现在命令行的顺序合并(例如,archives程序)。这就给了程序员足够的控制力。

将pagein工具的分析结果和调用顺序信息相结合,可以有效的对程序的某个阶段进行优化是缺页数最小化。对那些愿意多花些时间的人,可以尝试通过使用-finstrument-function选项[oooreorder]自动调用追踪来有效的重排序,gcc会插入__cyg_profile_func_enter和__cyg_profile_func_exit钩子。查看gcc手册来获取关于__cyg_*接口的信息。通过对程序执行的追踪,程序与可以更精确的确认调用链。[oooredorder]中的结果显示通过对函数的重排序在启动阶段会减少5%左右的开销。主要的好处是缺页的减少,不过TLB缓存也扮演重要角色,在虚拟化环境这个日渐重要的领域,TLB未命中变得越来越昂贵。

Linux内核提供了两种额外的机制来避免缺页。第一种是mmap的标志位,它可以告诉内核不要仅修改页表还有预分配好映射区域的所有页面。这是通过给mmap调用的第四个参数添加一个MAP_POPULAE标志位来实现的。这回导致mmap调用明显变得更昂贵,不过如果调用所映射的页面都能够正确被使用,那么收益是巨大的。程序不再会发生一组缺页且同步的系统调用导致每次缺页都非常昂贵,而是变为只有一个很昂贵的mmap调用。当然使用这个标志也有缺点,就是在调用之后不会立刻调用预分配好的大量页面。映射好而不使用明显会浪费大量时间和内存。页面被预分配好但很久之后才被使用也会使系统变得拥塞。内存在其被使用前即被分配同时可能导致内存不足。页面可能被用于新的目的(因为它根本没被修改),虽然不会那么昂贵但同样也会因为分配的动作产生消耗。

MAP_POPULATE的粒度太粗。还有一个可能的问题:这只是一个优化;实际上不能保证所有页面都能映射进来。如果系统过于繁忙则可能放弃执行预分配(pre-faulting)操作。一旦页面被引用则程序还是会发生缺页,不过不会比正常情况更糟。另一个选择是posix_madvise函数使用POSIX_MADV_WILLNEED选项。这会给操作系统一个提示,程序会很快需要在调用中描述的页面。内核可以选择忽略这个提示,不过通常会预分配(pre-faulting)页面。它的优点是粒度更细。单一页面或某个映射地址空间范围内的页面可以预分配。对于有大量数据但在运行时不会使用的内存映射文件,使用MAP_POPULATE有着显著的好处。

将缺页最小化除了这些主动的方法外,也可以采用在硬件设计者中很流行的被动方法。页面中有区域会用于分别存放代码和数据,DSO会占据相邻地址空间中相邻的页面。页面尺寸越小,就需要越多的页面存放DSO。这也就意味着更多的缺页。重要的是相反的情况也为真。更大尺寸的页面,用于映射的必要页面也会减少;也就减少缺页的数量。

大多数价格支持4K大小的页面。IA-64和PPC64上,64k的页面大小也很常见。这意味着它们的内存最小分配单位是64k。页面大小必须在编译内核时确定并且不能动态改变(至少现在不行)。多页面大小ABI被设计为运行一个程序运行时支持多个页面大小。运行时会做一些必要的调整,且一个编写正确的程序是不会注意到这个变化的。大页面意味着更多的部分使用的页面产生更多的浪费,不过在某些情况下这不是问题。

大多数架构支持1MB甚至更多的超大页面。这种页面在某些情况很有用,但并不意味所有内存都以这个单位进行分配。否则浪费的物理内存可能会很多。但超大页面有它们的优点:如果使用大数据集,将它们存储在x86-64上的使用2MB页面会比使用相同数量内存的4k页面减少511次缺页(每个大页面)。这个区别是巨大的。解决方案是选择性的对请求某些地址范围的内存分配请求使用大内存页面,而对进程的其它映射使用普通的页面大小。

大页面当然也有一定代价。由于大页面的物理内存使用时必须是连续的,这就可能导致运行一段时间之后,由于内存碎片导致无法再分配大页面了。人们试图内存无碎片和避免出现碎片,但这是非常复杂的。拿大页面来说,2MB意味着需要512个连续的页面,但通常是非常困难的,只要在系统启动时才比较容易。这也就是为什么现在大页面的解决方案都是使用一个特殊的文件系统,hugetlbfs。这个伪文件系统由系统管理员按需创建,并在下面指定要预留的大页面的数目:

    /proc/sys/vm/nr_hugepages

大页面的数目要预先指定好。如果没有足够多的连续内存剩余这个操作会失败。当使用虚拟化时情况会更有趣。使用VMM模型进行虚拟化的系统不会直接存取物理内存,因此它无法自己创建hugetlbfs。它要依赖VMM但这个特性并不保证被支持。对于KVM模型,Linux内核执行KVM模块,可以执行hugetlbfs分配并可以将从其上申请的一组页面传递给一个客户域。

那么,当一个程序需要大页面,有几种可能的方法:

  • 程序可以通过SHM_HUGETLB标志使用System V共享内存 
  • hugetlbfs文件系统可以被挂载然后程序可以在挂载点创建一个文件,然后使用mmap映射一个或多个页作为匿名页。 

 第一种情况,hugetlbfs不能被挂载。代码中需要一或多个大页面时可以像这样:

key_t k = ftok("/some/key/file", 42);
int id = shmget(k, LENGTH, SHM_HUGETLB|IPC_CREAT|SHM_R|SHM_W);
void *a = shmat(id, NULL, 0);

这段代码的关键在于使用了SHM_HUGETLB标志位并且使用了合适的值LENGTH,它必须是系统大页面的大小的倍数。不同的架构有不同的大小。使用System V共享内存接口一个讨人厌的问题就是要依赖一个key参数来区分(或共享)一个映射。ftok接口很容易产生冲突,也因此最好能够使用其它机制。

如果挂载hugetlbfs文件系统的需求不存在问题,那么最好使用它而不是使用System V共享内存。使用特定文件系统的唯一真正问题是内核必须支持它,但现在还没有已经标准化的挂载点。一旦文件系统被挂载,例如挂载到/dev/hugetlb,程序使用它会很简单:

int fd = open("/dev/hugetlb/file1", O_RDWR|O_CREAT, 0700);
void *a = mmap(NULL, LENGTH, PROT_READ|PROT_WRITE, fd, 0);

在open调用中使用相同的文件名,多个进程可以共享相同的大页面并进行协作。让页面可执行也是可能的,只要在mmap调用中设置PROT_EXEC标志位即可。在System V共享内存的例子中,LENGTH必须是系统大页面大小的倍数。

一个按防御式编程编写的程序(所有的程序都应如此)可以在运行时使用如下函数来决定挂载点:

char *hugetlbfs_mntpoint(void) {
  char *result = NULL;
  FILE *fp = setmntent(_PATH_MOUNTED, "r");
  if (fp != NULL) {
    struct mntent *m;
    while ((m = getmntent(fp)) != NULL)
       if (strcmp(m->mnt_fsname, "hugetlbfs") == 0) {
         result = strdup(m->mnt_dir);
         break;
       }
    endmntent(fp);
  }
  return result;
}

关于这些情况的更多信息可以从随内核源码发布的hugetlbpage.txt文件中找到。这个文档对IA-64的一些特殊处理需求也进行了描述。

图7.9: 使用大页面的Follow测试, NPAD=0

要说明大页面(huge pages)的好处,图7.9显示当NPAD = 0时运行随机Follow测试的结果。使用的数据和图3.15中的数据完全一样,但这次我们测量的数据放在从大页面中分配到的内存上。我们也看到了性能的改进是巨大的。当使用220 字节测试时,使用大页面可以有57%的性能提升。这是由于数据的大小还能够完整的装入一个2MB的页面中去,因此就不会发生DTLB未命中。

这个点之后,收益逐渐变小但随着工作集的增加而再次增长。当工作集达到512MB时,大页面测试下要比之前快38%。大页面测试的曲线在250个周期数左右进入到平台期。在工作集超过227 字节后, 周期数又开始再次增长。平台期的原因是因为64个TLB包含2MB个页面,也就是227 字节。

正如数字所显示的,使用大工作集的大部分开销来自TLB未命中。使用这个小节描述的接口可以得到漂亮的结果。图中所示的改进更多的是一种上限情况,不过即使是普通的程序也能得到明显的加速。数据库由于使用大规模的数据,所以会使用大页面。

现在还没有办法为文件映射数据使用大页面。实现这个功能很有意思,但现在的提议都是如何显式的使用大页面,都依赖于hugetlbfs文件系统。但这是不可接受的:这种情况下大页面应该是透明的。内核可以自动检测出哪个映射足够大并自动使用大页面。但一个大问题是内核并不一定了解使用模式。如果内存可以被映射为大页面,稍后又需要4k大小的页面粒度(例如,因为使用了mprotect修改了被保护的内存的范围)。那么大量宝贵的资源,尤其是物理内存就被浪费了。所以这个想法在被成功实现之前还需要更多时间。

*译注:在Linux 2.6.32中,已经支持mmap使用大页面,需要指定MAP_ANONYMOUS|MAP_HUGETLB,可以参考http://lwn.net/Articles/375096/

second use