mmap()与读取数据块的区别

243

我正在开发一个程序,将要处理可能大小为100GB或更大的文件。这些文件包含了长度可变的多组记录。我已经完成了第一个实现,并且现在致力于提高性能,特别是在更有效地进行I/O操作方面,因为输入文件需要被扫描多次。

有没有一些使用mmap()和C++的fstream库读取块的经验法则?我的想法是从磁盘中读取大块到缓冲区,从缓冲区处理完整的记录,然后再读取更多。

mmap()代码可能会变得非常混乱,因为mmap的块需要位于页面大小的边界上(据我所知),而记录可能潜在地跨越页面边界。通过使用fstreams,我可以直接寻找记录的起点并开始读取,因为我们不受限于只读取位于页面大小的边界上的块。

在实际编写一个完整实现之前,如何在这两个选项之间做出选择?是否有什么经验法则(例如,mmap()速度是2倍)或简单测试?


4
这是一篇有趣的文章:https://medium.com/@sasha_f/why-mmap-is-faster-than-system-calls-24718e75ab37 在实验中,使用mmap()比使用系统调用(例如read())快2-6倍。 - mplattner
13个回答

266
我在寻找关于Linux上mmap / read性能的最终结论时,发现了一篇很好的文章(link)。它是来自Linux内核邮件列表的,虽然自2000年以来内核中的IO和虚拟内存已经有了许多改进,但它很好地解释了为什么mmapread可能更快或更慢。
  • 调用mmapread具有更多的开销(就像epollpoll具有更多的开销,后者比read具有更多的开销)。对于同样的原因,在某些处理器上更改虚拟内存映射是一项相当昂贵的操作,就像在不同进程之间切换一样昂贵。
  • IO系统已经可以使用磁盘缓存,因此如果您读取文件,无论使用什么方法,都会命中或未命中缓存。

然而,

  • 内存映射通常更适合随机访问,特别是当您的访问模式是稀疏和不可预测时。
  • 内存映射允许您在完成操作之前一直使用缓存中的页面。这意味着如果您长时间频繁地使用文件,然后将其关闭并重新打开,页面仍将被缓存。使用read时,您的文件可能已经很久没有被缓存了。如果您使用文件并立即丢弃它,则不适用此规则。(如果您尝试mlock页面只是为了将它们保留在缓存中,那么您正在试图智取磁盘缓存,这种愚蠢的行为很少有助于系统性能)。
  • 直接读取文件非常简单快捷。

关于mmap/read的讨论让我想起了另外两个性能讨论:

  • 一些Java程序员惊讶地发现非阻塞I/O通常比阻塞I/O慢,如果您知道非阻塞I/O需要进行更多的系统调用,这就是完全合理的。

  • 一些其他网络程序员惊讶地发现epoll通常比poll慢,如果您知道管理epoll需要进行更多的系统调用,这就是完全合理的。

结论:如果您随机访问数据,需要长时间保留数据或知道可以与其他进程共享数据,则使用内存映射(如果没有实际共享,则MAP_SHARED不是很有趣)。如果您按顺序访问数据或读取后将其丢弃,则通常读取文件。如果其中任何一种方法使您的程序更简单,请使用该方法。对于许多真实案例,没有确定的方法来展示哪种方法更快,而不是测试实际应用程序,而不是基准测试。(抱歉打扰了这个问题,但我正在寻找答案,而这个问题一直出现在谷歌搜索结果的前面。)

3
请记住,如果不经过今天的测试就使用基于2000年代硬件和软件的建议,这将是一种非常可疑的方法。此外,虽然有关mmapread()之间的许多事实仍然像以前一样正确,但是整体性能实际上只能通过在特定硬件配置上进行测试才能确定,而不能通过加总优缺点来确定。例如,“调用mmap的开销比读取(read)更大”是有争议的-是的,mmap必须向进程页表中添加映射,但是read必须将所有读取的字节从内核复制到用户空间。 - BeeOnRope
4
你可能对基于2000年代硬件和软件的建议持怀疑态度,但我更加怀疑不提供方法和数据的基准测试。如果你想证明mmap更快,我希望至少看到整个测试设备(源代码)与制表结果,以及处理器型号。 - Dietrich Epp
3
我并不想声称人们应该轻信我所提供的结果,而不是链接帖子中给出的结果。我想说的是,两者都不足够可靠:人们应该在自己的系统上测试,而不仅仅接受那篇帖子中的结果(该帖子也没有提供详细的方法或数据)。我提供我的结果主要是为了指出,与保罗当时的结果相反,我的今天的结果,作为一个请求,希望大家能在本地进行测试。非定量的论证只有在一个解决方案比另一个优胜的情况下才真正具有说服力,而这在这里并不适用。 - BeeOnRope
5
@DietrichEpp - 是的,我很熟悉TLB效应。请注意,mmap通常不会刷新TLB,除非在异常情况下(但是munmap可能会刷新)。我的测试包括微基准测试(包括munmap)和在真实应用场景中运行的“应用内”测试。当然,我的应用程序与您的应用程序不同,因此人们应该进行本地测试。甚至不清楚微基准测试是否更青睐于mmap:由于用户侧目标缓冲区通常留在L1中,因此read()也会大幅提升,而这在较大的应用程序中可能不会发生。所以说,“情况很复杂”。 - BeeOnRope
我真的很好奇它如何与新的字节可寻址持久内存(例如Optane DCPMM)对齐。 - claf
显示剩余2条评论

88

这里已经有很多好的答案,涵盖了许多重要的观点,所以我只会添加一些直接上面没有涉及到的问题。也就是说,这个答案不应该被认为是正反两方面的全面总结,而是其他答案的补充。

mmap 看起来像魔法

在文件已经完全缓存1的情况下,mmap 可能看起来非常像魔法

  1. mmap 仅需要 1 次系统调用(可能)映射整个文件,之后不再需要更多的系统调用。
  2. mmap 不需要将文件数据从内核复制到用户空间。
  3. 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 这基本上也包括文件一开始没有完全缓存的情况,但是操作系统的预读足够好,使它看起来像已经缓存(即,通常在您需要它的时候页面已经被缓存)。这是一个微妙的问题,因为预读的工作方式在 mmapread 调用之间通常有很大的不同,并且可以通过“建议”调用进行进一步调整,如2所述。

2 ... 因为如果文件没有被缓存,您的行为将完全受到IO问题的支配,包括您的访问模式对底层硬件的影响程度 - 所有的努力都应该确保这样的访问尽可能地友好,例如通过使用 madvisefadvise 调用(以及您可以进行的任何应用程序级别的更改以改善访问模式)。

3 例如,您可以通过按顺序将较小大小的窗口(例如100 MB)映射到内存中来解决这个问题。

4 实际上,事实证明使用MAP_POPULATE方法(至少在某些硬件/操作系统组合上)只比不使用它略快一些,可能是因为内核正在使用faultaround - 因此实际的次要故障数量减少了约16倍。


5
感谢您对这个复杂问题提供了更细致入微的回答。大多数人认为 mmap 更快,但实际上情况往往并非如此。在我的实验中,使用 in-memory index 随机访问一个大小为 100GB 的数据库,结果使用 pread() 更快,尽管我需要为每次访问 malloc() 分配一个缓冲区。而且看起来很多业内人士也观察到了相同的现象。(参考链接:https://github.com/facebook/rocksdb/issues/507) - Caetano Sauer
6
是的,这取决于具体情况。如果你要读取的数据量足够小,并且长时间内你倾向于重复读取相同的字节,则使用mmap将具有无法逾越的优势,因为它避免了固定的内核调用开销。另一方面,mmap也会增加TLB压力,并且在“热身”阶段(其中字节是在当前进程中第一次被读取,尽管它们仍然在页缓存中)可能比read更慢,因为它可能需要处理更多的工作,例如“故障绕过”相邻页面......对于某些应用程序,“热身”是最重要的!@CaetanoSauer - BeeOnRope
1
我认为你所说的“...但是25亿个页面错误仍然不会非常快...”应该改为“...但是2500万个页面错误仍然不会非常快...”。我不是100%确定,所以我没有直接编辑。 - Ton van den Heuvel

51

主要的性能成本将会是磁盘IO。 "mmap()" 比 istream 更快,但由于磁盘IO会支配你的运行时间,所以差异可能不明显。

我尝试了Ben Collins的代码片段(见上/下文)来测试他的说法,“mmap() 要快得多”,但没有发现可测量的差别。请参阅我的评论。

除非您的“记录”非常大,否则我肯定不会建议单独 mmap 每个记录 - 这将非常慢,每个记录需要 2 个系统调用,并且可能会丢失磁盘内存缓存中的页面。

在您的情况下,我认为 mmap()、istream 和低级的 open()/read() 调用都差不多。对于这些情况,我建议使用 mmap():

  1. 文件内存在随机访问(而不是顺序访问),并且
  2. 整个文件可以轻松地放入内存中,或者其中某些页面可以映射进去而其他页面映射出去,因此操作系统能够最大程度地利用可用的RAM。
  3. 或者如果多个进程正在读取/处理同一个文件,则 mmap() 是绝佳选择,因为这些进程都共享相同的物理页面。

(顺便说一句 - 我喜欢mmap()/MapViewOfFile()。)


关于随机访问的观点非常好:这可能是导致我的感知的事情之一。 - Ben Collins
2
我不会说文件必须舒适地适应内存,只需要适应地址空间即可。因此,在64位系统上,没有理由不映射大型文件。操作系统知道如何处理它;这是用于交换的相同逻辑,但在这种情况下不需要额外的磁盘交换空间。 - MvG
@MvG:你明白磁盘I/O的关键吗?如果文件适合于地址空间但不适合内存,并且您需要进行随机访问,则每个记录的访问都可能会要求磁头移动和寻道,或者SSD页面操作,这将对性能造成灾难。 - Tim Cooper
4
磁盘I/O方面应该与访问方法无关。如果您对大于RAM文件具有真正的随机访问权限,则mmap和seek+read都会受到严重的磁盘限制。否则,两者都将从缓存中受益。我不认为文件大小与内存大小相比是任何一方的有力论据。另一方面,文件大小与地址空间的比较是一个非常有力的论据,特别是对于真正的随机访问。 - MvG
我的原始答案有并且仍然有这个观点:“整个东西可以舒适地放在内存中,或者文件内有良好的引用局部性”。因此第二点解决了你所说的问题。 - Tim Cooper

42

mmap更快。你可以编写一个简单的基准测试来证明它:

char data[0x1000];
std::ifstream in("file.bin");

while (in)
{
  in.read(data, 0x1000);
  // do something with data
}

对比:

const int file_size=something;
const int page_size=0x1000;
int off=0;
void *data;

int fd = open("filename.bin", O_RDONLY);

while (off < file_size)
{
  data = mmap(NULL, page_size, PROT_READ, 0, fd, off);
  // do stuff with data
  munmap(data, page_size);
  off += page_size;
}

显然,我省略了一些细节(例如如何确定文件结尾,如果您的文件不是page_size的倍数,但实际上它不应该比这更复杂。

如果可能的话,您可以尝试将数据分成多个文件,可以整个文件进行mmap()而不是部分进行mmap()(更简单)。

几个月前,我有一个未完成的boost_iostreams滑动窗口mmap流类的半成品实现,但没有人关心,我还忙于其他事情。最不幸的是,几周前我删除了旧的未完成项目存档之一,这是其中之一:-(

更新: 我还应该添加一个警告,那就是在Windows中,此基准测试看起来会非常不同,因为Microsoft实现了一个漂亮的文件缓存,它做了您在第一次使用mmap时要做的大部分工作。也就是说,对于频繁访问的文件,您可以只执行std :: ifstream.read(),它将像mmap一样快,因为文件缓存已经为您进行了内存映射,并且是透明的。

最终更新: 看吧,人们:在许多不同的操作系统和标准库,磁盘和内存层次结构的平台组合中,我无法确定系统调用mmap,作为一个黑盒子,将始终比read 快多少。尽管我的话可能被理解成这样,这并不是我的意图。最终,我的观点是,基于内存映射的i/o通常比基于字节的i/o更快;这仍然是正确的。如果您实验发现两者之间没有区别,那么对我来说唯一合理的解释是,您的平台在底层实现了有利于调用read性能的内存映射方式。要绝对确定您正在以可移植的方式使用基于内存映射的i/o,唯一的方法是使用mmap。如果您不关心可移植性,并且可以依赖于目标平台的特定特性,则使用read可能适合而不牺牲可测量的任何性能。

编辑整理回答列表: @jbl:

滑动窗口mmap听起来很有趣。你能再详细说一些吗?

当时我正在编写一个Git的C++库(即libgit ++),遇到了类似的问题:我需要能够打开大文件(非常大)而不会降低性能(就像使用std :: fstream一样)。

Boost :: Iostreams已经有一个mapped_file Source,但问题是它在mmapping整个文件,这将限制您到2^(wordsize)。在32位机器上,4GB还不够大。在Git中,您可能会遇到比这更大的.pack文件,因此我需要分块读取文件,而不必采用常规文件i / o。在Boost :: Iostreams的底层,我实现了一个Source,它更多地是另一种std :: streambufstd :: istream 之间交互的视图。您也可以通过将std :: filebuf


4
关于在Windows上使用mmaped文件缓存。确切地说,在启用文件缓冲时,内核会将您正在读取的文件进行内部映射到内存中,并将数据读取到该缓冲区中,然后将其复制回您的进程。这就像您自己进行内存映射一样,只是多了一步复制过程。 - Chris Smith
6
我不太愿意反对一个被接受的答案,但我认为这个答案是错误的。我按照你的建议,在一个64位的Linux机器上尝试了你的代码,结果发现mmap()并没有比STL实现更快。此外,从理论上讲,我不认为'mmap()'会更快(或更慢)。 - Tim Cooper
10
亲爱的Ben:我已经阅读了那个链接。如果在Linux上'mmap()'不比'MapViewOfFile()'快,而在Windows上'MapViewOfFile()'也不比'mmap()'快,那么你怎么能声称"mmap更快"呢?另外,出于理论上的原因,我认为对于顺序读取来说,mmap()并不更快——你有任何相反的解释吗? - Tim Cooper
12
为什么要逐页地使用 mmap() 映射文件呢?如果一个 size_t 类型的变量能够容纳下文件的大小(在64位系统中很可能如此),那么可以直接一次性调用 mmap() 映射整个文件。 - Steve Emmerson
3
你是否正在对整个文件进行内存映射?部分性能提升源于避免进行重复的系统调用。 - Joseph Garvin
显示剩余9条评论

7

很遗憾,Ben Collins失去了他的滑动窗口mmap源代码。将其放入Boost中会很好。

是的,映射文件速度更快。您实际上正在使用操作系统虚拟内存子系统将内存与磁盘关联起来,反之亦然。这样考虑:如果操作系统内核开发人员可以使其更快,他们会这样做。因为这样做可以使几乎所有事情都变得更快:数据库、启动时间、程序加载时间等等。

滑动窗口方法并不难,因为多个连续页面可以同时映射。因此,只要任何单个记录中的最大记录可以适合内存,记录的大小就无关紧要。重要的是管理记账工作。

如果记录不从getpagesize()边界开始,则映射必须从上一页开始。映射区域的长度从记录的第一个字节开始(如果需要,则向下舍入到最近的getpagesize()的倍数),到记录的最后一个字节(向上舍入到最近的getpagesize()的倍数)。处理完记录后,您可以取消映射(unmap())它并继续进行下一步。

在Windows下,使用CreateFileMapping()和MapViewOfFile()(以及GetSystemInfo()获取SYSTEM_INFO.dwAllocationGranularity --- 而不是SYSTEM_INFO.dwPageSize)也可以正常工作。


我刚刚谷歌搜索并找到了关于dwAllocationGranularity的小片段——我之前使用的是dwPageSize,结果一切都出问题了。谢谢! - wickedchicken

5

mmap应该更快,但我不知道有多快。这在很大程度上取决于你的代码。如果你使用mmap,最好一次性映射整个文件,这会让你的生活变得更轻松。一个潜在的问题是,如果你的文件大于4GB(或者实际上限制更低,通常为2GB),你需要一个64位架构。因此,如果你正在使用32位环境,你可能不想使用它。

话虽如此,提高性能可能有更好的方法。你说“输入文件被扫描多次”,如果你可以一次读出来然后完成,那可能会更快。


3

我认为使用mmap的文件I/O会更快,但是在你对代码进行基准测试时,计数器示例不应该过于优化吗?

Ben Collins写道:

char data[0x1000];
std::ifstream in("file.bin");

while (in)
{
    in.read(data, 0x1000);
    // do something with data 
}

我建议您也尝试一下以下方法:
char data[0x1000];
std::ifstream iifle( "file.bin");
std::istream  in( ifile.rdbuf() );

while( in )
{
    in.read( data, 0x1000);
    // do something with data
}

此外,您还可以尝试将缓冲区大小设置为虚拟内存一页的大小,以防0x1000不是您机器上虚拟内存一页的大小... 在我看来,mmap文件I / O仍然胜出,但这样应该使事情更接近。


3

我记得几年前将一个包含树结构的大文件映射到内存中。与常规反序列化相比,我对速度感到惊讶,后者需要在内存中进行大量工作,例如分配树节点和设置指针。

因此,实际上我是在比较单个调用mmap(或其在Windows上的对应项)与许多(很多)调用operator new和构造函数调用。

对于这种类型的任务,与反序列化相比,mmap是无与伦比的。当然,对于此类任务,应该考虑使用boost的可重定位指针。


1
这听起来更像是一场灾难的配方。如果对象布局发生了变化,你该怎么办?如果你有虚函数,所有的vftbl指针可能都会出错。你如何控制文件映射到哪里?你可以给它一个地址,但这只是一个提示,内核可能会选择另一个基地址。 - Jens
3
当您拥有稳定且明确定义的树形布局时,此方法可以完美运行。然后,您可以将所有内容转换为相关的结构体,并通过每次添加“mmap起始地址”的偏移量来跟随内部文件指针。这与使用inode和目录树的文件系统非常相似。 - Mike76

3
也许你应该预处理这些文件,使每个记录都在单独的文件中(或者至少每个文件都是可以进行mmap的大小)。
此外,在移动到下一个记录之前,您是否可以为每个记录执行所有处理步骤?也许这可以避免一些IO开销?

2

在我看来,使用mmap()函数“只是”减轻了开发人员编写自己的缓存代码的负担。在简单的“仅读取文件一次”的情况下,这并不难(尽管正如mlbrock指出的那样,您仍然可以节省将数据复制到进程空间中的内存),但如果您需要在文件中来回跳转或跳过某些部分,我相信内核开发人员可能比我更擅长实现缓存...


3
你很可能可以比内核更好地缓存应用程序特定的数据,因为内核是以页面大小的块进行操作,并且使用一种非常简单的伪LRU方案来决定哪些页面要被淘汰,而你可能非常了解正确的缓存粒度,并且对未来的访问模式有一个很好的想法。 mmap 作为缓存的真正好处在于你可以简单地重新使用已经存在的页面缓存,这些缓存已经存在,因此你可以免费获得该内存,它还可以跨进程共享。 - BeeOnRope

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接