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