Mr Sunshine

mmap 性能分析与优化

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

  • 进程间可以共享 mmap 文件映射的内存页,省去额外的内核态到用户态的内存拷贝及可能的 IO 开销,且修改能“实时”被看到(页表已经建好时)
  • mmap 使用方便,就跟访问本地内存一样;而普通文件 read/write 处理动态增长的数据很麻烦
  • mmap 的数据能被 kernel 在后台自动同步回文件,解决了持久化的问题
  • 底层文件可以放在 tmpfs 上,这样性能就与正常的共享内存无异了(事实上 Linux 的 POSIX shared memory 就是这么实现的)

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

初步调优

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

  • 底层存储在磁盘上,发生 major pagefault 时 IO 开销比较大
  • 运行环境系统比较繁忙,CPU 资源紧张
    1
    2
    3
    4
    5
    6
    7
    8
    +-----------+-------+-----------+---------+----------+---------+
    | 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

  • 调用 fallocate 预先分配文件空间

Prefetch

  • 调用 madvise 设置 MADV_SEQUENTIAL 策略(Seq)
  • 调用 madvise 设置 MADV_WILLNEED 策略(Need)

Prefault

  • mmap 调用中设置 MAP_POPULATE 标志位(Prefault)
  • 自行预先访问 mmap 出来的内存区间(ManualPrefault)

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
+-------------------------------+-----------+-----------+
| 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 的策略枚举值是互斥的,不是比特标志位所以不能用 | 连用。这是一部分枚举值的定义:

    1
    2
    3
    4
    5
    # 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. */
  2. 连续调用多次 madvise 以实现多种策略混搭的效果。其实只有最后一条 madvise 语句生效。Linux glibc madvise 实现 中,madvise 直接调用 syscall,并透传参数。 而在 Linux madvise syscall 代码 中也可以看出每次调用都会清空并覆盖之前策略的设置。

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

1
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