这里已经有很多很好的答案,涵盖了许多要点,所以我只添加几个我没有看到上面直接解决的问题。也就是说,这个答案不应该被认为是利弊的综合,而应该是对这里其他答案的补充。
mmap 看起来很神奇
以文件已经完全缓存的情况1作为基线2,mmap 可能看起来很像 magic:
-
mmap 只需要 1 次系统调用来(可能)映射整个文件,之后不再需要系统调用。
-
mmap 不需要将文件数据从内核复制到用户空间。
-
mmap 允许您“作为内存”访问文件,包括使用您可以对内存执行的任何高级技巧对其进行处理,例如编译器自动矢量化、SIMD 内在函数、预取、优化的内存解析例程、OpenMP、等
在文件已经在缓存中的情况下,似乎无法击败:你只是直接访问内核页面缓存作为内存,它不会比这更快。
嗯,可以的。
mmap 实际上并不神奇,因为...
mmap 仍然可以按页面工作
mmap 与 read(2) 的主要隐藏成本(这实际上是 读取块 的可比操作系统级系统调用)是使用 mmap 你需要做“一些为在新映射中访问的每个 4K 页面工作”,即使它可能被页面错误机制隐藏。
举个例子,一个典型的实现只需要mmaps 整个文件将需要故障输入,因此 100 GB / 4K = 2500 万次故障才能读取 100 GB 文件。现在,这些将是minor faults,但是 2500 万个页面错误仍然不会很快。在最好的情况下,一个小故障的成本可能在 100 纳米。
mmap 严重依赖 TLB 性能
现在,您可以将MAP_POPULATE 传递给mmap 告诉它在返回之前设置所有页表,因此在访问它时应该不会出现页面错误。现在,这有一个小问题,它还将整个文件读入 RAM,如果您尝试映射 100GB 文件,这将会爆炸 - 但现在让我们忽略它3。内核需要执行每页工作来设置这些页表(显示为内核时间)。这最终成为mmap 方法中的主要成本,并且与文件大小成正比(即,随着文件大小的增长,它的重要性不会相对降低)4。
最后,即使在用户空间访问这样的映射也不是完全免费的(与不是源自基于文件的mmap 的大内存缓冲区相比)——即使设置了页表,每次访问从概念上讲,新页面将导致 TLB 未命中。由于mmap文件意味着使用页面缓存及其 4K 页面,因此对于一个 100GB 的文件,您再次产生了 2500 万倍的成本。
现在,这些 TLB 未命中的实际成本在很大程度上取决于至少以下硬件方面:(a) 你有多少 4K TLB 实体以及其余的转换缓存工作如何执行 (b) 硬件的性能如何prefetch 处理 TLB - 例如,prefetch 可以触发页面遍历吗? (c) 页面遍历硬件的速度和并行度。在现代高端 x86 Intel 处理器上,page walk 硬件通常非常强大:至少有 2 个并行 page walker,page walk 可以与继续执行同时发生,并且硬件预取可以触发 page walk。因此,TLB 对 流式传输 读取负载的影响相当低 - 无论页面大小如何,这种负载通常都会执行类似的操作。但是,其他硬件通常要差得多!
read() 避免了这些陷阱
read() 系统调用,通常是在 C、C++ 和其他语言中提供的“块读取”类型调用的基础,它有一个每个人都清楚的主要缺点:
- 每个 N 字节的
read() 调用必须将 N 字节从内核复制到用户空间。
另一方面,它避免了上述大部分成本 - 您无需将 2500 万个 4K 页面映射到用户空间。您通常可以malloc 用户空间中的单个缓冲区小缓冲区,并在所有read 调用中重复使用它。在内核方面,4K 页面或 TLB 未命中几乎没有问题,因为所有 RAM 通常使用几个非常大的页面(例如,x86 上的 1 GB 页面)进行线性映射,因此页面缓存中的底层页面被覆盖在内核空间中非常有效。
所以基本上你可以通过以下比较来确定单次读取大文件时哪个更快:
mmap 方法隐含的每页额外工作是否比使用read() 隐含的将文件内容从内核复制到用户空间的每字节工作成本更高? p>
在许多系统上,它们实际上是近似平衡的。请注意,每一个都具有完全不同的硬件和操作系统堆栈属性。
特别是,mmap 方法在以下情况下变得相对更快:
- 操作系统具有快速的次要故障处理,尤其是次要故障批量优化,例如故障回避。
- 操作系统有一个很好的
MAP_POPULATE 实现,可以在底层页面在物理内存中连续的情况下有效地处理大型映射。
- 硬件具有强大的页面翻译性能,例如大型 TLB、快速的二级 TLB、快速并行的 page-walker、良好的预取与翻译交互等。
...而read() 方法在以下情况下变得相对更快:
-
read() 系统调用具有良好的复制性能。例如,在内核端良好的copy_to_user 性能。
- 内核有一种高效(相对于用户空间)映射内存的方式,例如,仅使用几个有硬件支持的大页面。
- 内核具有快速的系统调用和一种跨系统调用保留内核 TLB 条目的方法。
上述硬件因素在不同平台之间大不相同,即使在同一个系列中(例如,在 x86 代中,尤其是在细分市场中),而且肯定会跨架构(例如,ARM 与 x86 与 PPC)。
操作系统因素也在不断变化,双方的各种改进导致一种方法的相对速度大幅提升。最近的列表包括:
- 如上所述,添加了故障处理,这确实有助于在没有
MAP_POPULATE 的情况下使用 mmap。
- 在
arch/x86/lib/copy_user_64.S 中添加快速路径copy_to_user 方法,例如,在快速时使用REP MOVQ,这确实有助于read() 案例。
幽灵和熔毁后更新
Spectre 和 Meltdown 漏洞的缓解措施大大增加了系统调用的成本。在我测量过的系统上,“什么都不做”系统调用的成本(这是对系统调用的纯开销的估计,除了调用所做的任何实际工作)从典型的大约 100 ns现代Linux系统约700纳秒。此外,根据您的系统,专门针对 Meltdown 的 page-table isolation 修复可能会产生额外的下游影响,除了由于需要重新加载 TLB 条目而导致的直接系统调用成本。
与基于mmap 的方法相比,所有这些对于基于read() 的方法来说都是一个相对劣势,因为read() 方法必须对每个“缓冲区大小”的数据进行一次系统调用。您不能随意增加缓冲区大小来分摊此成本,因为使用大缓冲区通常性能更差,因为您超过了 L1 大小,因此经常遭受缓存未命中。
另一方面,使用mmap,您可以使用MAP_POPULATE 映射到一个大的内存区域并高效地访问它,而只需一次系统调用。
1 这或多或少也包括文件未完全缓存开始的情况,但操作系统预读足够好使其看起来如此(即,页面通常在您想要的时候被缓存)。这是一个微妙的问题,因为预读的工作方式在 mmap 和 read 调用之间通常有很大不同,并且可以通过 2 中所述的“建议”调用进一步调整。
2 ...因为如果文件没有缓存,您的行为将完全由 IO 问题主导,包括您的访问模式对底层硬件——你所有的努力都应该是确保这种访问尽可能有同情心,例如通过使用 madvise 或 fadvise 调用(以及您可以进行的任何应用程序级别更改以改进访问模式)。
3您可以绕过这个问题,例如,在较小的窗口中按顺序mmaping,例如 100 MB。
4 事实上,MAP_POPULATE 方法(至少是一些硬件/操作系统组合)只比不使用它快一点,可能是因为内核使用了faultaround - 所以小故障的实际数量减少了 16 倍左右。