架构设计与重构

mmap 性能分析与优化

2017-11-18  本文已影响656人  xoyowade

最近项目中需要实现一个进程间共享的动态增长队列(单写多读),采用的是文件 mmap 的方案,有这么几点考虑:

看起来不错,便捷、高效。鱼掌可以兼得,就是这么任性——直到上线后发现系统指标异常,一路怀疑到这里可能有性能问题,才知道事实并不尽然。

初步调优

我们测量了每个数据从入队列到出队列的时间差,数据分布见下表的第一行数据(Disk),其中大于 1ms 的毛刺点竟达到 3.8‰,均值和 90% 分位点也比预期的要高。初步猜测有两个因素可能影响比较大:

+-----------+-------+-----------+---------+----------+---------+
| configure | store | bind_core | >1ms(‰) | mean(us) | 90%(us) |
+-----------+-------+-----------+---------+----------+---------+
| Disk      | disk  | N         |     3.8 |       18 |       9 |
| Bind-d    | disk  | Y         |     1.6 |       19 |      12 |
| Tmpfs     | mem   | N         |     2.2 |       16 |       9 |
| Bind-m    | mem   | Y         |     0.5 |        9 |       7 |
+-----------+-------+-----------+---------+----------+---------+

随后我们分别尝试了用 tmpfs 替代磁盘,绑定进程运行的 CPU 核等不同配置组合。从上表可以看到绑核对毛刺影响较大,再加上用 tmpfs 存储,可以大幅度优化延迟。

其实我们创建 mmap 时,已经用了 MADV_SEQUENTIAL 来提示 kernel 我们是顺序访问,事实上也是如此。但如果这样有效的话,换成 tmpfs 并不能带来多大的加速,这和我们预期不符。所以有必要进一步搞清楚 mmap 的使用方式。

细化分析

我们先来梳理下 mmap 的机制。mmap 分两种,匿名的和有文件映射的,我们只讨论第二种。mmap 的语义是将指定文件区间映射到当前进程的虚拟地址空间,调用返回空间起始指针,后续对这段空间的内存读写就相当于对底层文件内容的读写。默认情况下,mmap 调用时并不会帮你把整个文件都映射进内存,而是按需分配页表:因为你的文件可能很大以至于超过可用内存大小;也可能你只需要随机访问其中一小部分,没必要都映射进来。
那么当你访问到一个尚未分配页表的虚拟地址,CPU 就会触发一次 page fault,当前进程进入相应的内核 page fault handler。在这里,内核需要

  1. 在文件系统中分配该文件区域对应的 block(如果还未分配的话)
  2. 分配一个空闲物理内存页
  3. 读取该段文件内容到对应物理内存页
  4. 更新内存页表,以建立物理内存页到虚拟内存页的映射

当然如果只是 minor page fault(比如访问的共享数据已经被其他进程加载进内存),就只需要第 4 步操作。除此外,大范围的 mmap 还有个问题就是会给 TLB 带来很大负担,影响到整个系统的性能。

从使用者的角度,mmap 的这几项开销的优化思路主要就是预处理了,比如预先分配文件内容(Prealloc),预先触发 page fault(Prefault),让操作系统协助预取(Prefetch)。实现预处理的手段也有好多种:

Prealloc

Prefetch

Prefault

Prealloc 节省了步骤 1 的开销,Prefault 节省了步骤 1-4 的开销,Prefetch 最理想的情况下和 Prefault 效果一致。为此我们设计了一组对照实验,测量每一组配置下用 128 Bytes 的数据块去写入 256MB mmap 区间所需的时间,以模拟我们真实的使用模式。底层的存储都使用 tmpfs,我们还分别测量了 tmpfs 使用的内存与测试进程在不同/相同 NUMA node 的情况。

+-------------------------------+-----------+-----------+
|             name              | same_node | diff_node |
+-------------------------------+-----------+-----------+
| BM_MMap11ManualPrefault       |    48.127 |    63.045 |
| BM_MMap10ManualPrefaultNeed   |    48.294 |    62.709 |
| BM_MMap06PreallocPrefault     |    48.316 |    62.539 |
| BM_MMap03Prefault             |    48.421 |    62.744 |
| BM_MMap07PreallocPrefaultNeed |    48.527 |    62.970 |
| BM_MMap04PrefaultNeed         |    48.543 |    63.133 |
| BM_MMap05PrefaultSeq          |    48.570 |    62.954 |
| BM_MMap02Prealloc             |   106.558 |   152.471 |
| BM_MMap08PrefetchNeed         |   127.054 |   174.662 |
| BM_MMap09PrefetchSeq          |   128.844 |   173.948 |
| BM_MMap01                     |   129.806 |   174.084 |
+-------------------------------+-----------+-----------+

我们可以看到,Prefech 的行为没有明显的效果,主要是因为操作系统需要一定时间去做预取,并且这个行为我们是不可控的,所以也没有特别安排不同等待时间的实验。Prealloc 能带来 20% 不到的提升,这也是步骤 1 的开销。其他各种 Prefault 的组合效果差不多,大约 60% 出头,这基本就是 page fault 的所有开销了。ManualPrefault 效果更好应该是因为它同时也做了 cache prefetch。

感兴趣的同学可以跑下我的实验代码,比较不同环境的测试结果。

用户态动态预处理

从实验结果我们可以看到,Prefault 能极大地减少 mmap 的开销,但它的代价也是很大的——需要把整个文件都事先加载进来。对于我们这个动态增长队列的用法就更糟糕了,相当于我们必须加载进可能的最大队列长度,而实际上大多数队列只使用了一小部分空间,这就造成极大的浪费。

比较折衷的办法是把不可控的 kernel pretch 搬到用户态来:预先分配一小部分空间,在运行的过程中根据使用情况预先处理。每次预处理长度的算法可以根据实际应用具体调整,丰俭由人。由于 page fault 是在 kernel 态完成的,天然就是线程安全,所以多线程的预取实现起来很方便,也不会影响主线程的正常访问。

常见错误

在设计实验前,参考了些其他相关的测试代码,发现不少 madvise 的错误用法,一般有这么两种错误类型:

  1. 用 | 连接两个不同的策略。事实上,madvise 的策略枚举值是互斥的,不是比特标志位所以不能用 | 连用。这是一部分枚举值的定义:
# define MADV_NORMAL 0 /* No further special treatment. */
# define MADV_RANDOM 1 /* Expect random page references. */
# define MADV_SEQUENTIAL 2 /* Expect sequential page references. */
# define MADV_WILLNEED 3 /* Will need these pages. */
# define MADV_DONTNEED 4 /* Don't need these pages. */
  1. 连续调用多次 madvise 以实现多种策略混搭的效果。其实只有最后一条 madvise 语句生效。Linux glibc madvise 实现中,madvise 直接调用 syscall,并透传参数。 而在 Linux madvise syscall 代码中也可以看出每次调用都会清空并覆盖之前策略的设置。

另外一个是 man mmap 里对 MAP_POPULATE 的解释有歧义:

MAP_POPULATE is supported for private mappings only since Linux 2.6.23.

它其实想说的是,从 Linux 2.6.23 版本以后才开始支持私有映射,共享映射一直都是支持的。但乍一看很容易理解成从 Linux 2.6.23 版本以后只支持私有映射。吐槽的人不只我一个哦。

大页支持

传统页表大小只有 4KB,其实现代处理器架构可以处理更大的页表,从而减少访问同样内存大小所需的 page fault 数量和 TLB 压力。Linux 有两种大页支持,hugetlbpagetransparent hugepage。hugetlbpage 需要创建一个 hugetlb 文件系统,但是只能读不能写,不符合我们的需求;transparent hugepage 灵活一些,可以在 mount tmpfs 时指定 huge 参数,但我们现在生产环境版本还没有这项功能。

参考

  1. mmap-vs-reading-blocks
  2. kernel mail list
上一篇下一篇

猜你喜欢

热点阅读