我正在开发一个程序,该程序将处理大小可能为 100GB 或更大的文件。这些文件包含可变长度记录集。我已经启动并运行了第一个实现,现在正在寻求提高性能,特别是在更有效地执行 I/O 方面,因为输入文件被扫描了很多次。
使用 mmap()
与通过 C++ 的 fstream
库读取块是否有经验法则?我想做的是将大块从磁盘读取到缓冲区中,处理缓冲区中的完整记录,然后读取更多。
mmap()
代码可能会变得非常混乱,因为 mmap
的块需要位于页面大小的边界上(我的理解),并且记录可能会跨越页面边界。使用 fstream
,我可以只寻找记录的开头并重新开始阅读,因为我们不限于阅读位于页面大小边界上的块。
在没有实际编写完整实现的情况下,如何在这两个选项之间做出决定?任何经验法则(例如,mmap()
快 2 倍)或简单测试?
我试图找到关于 Linux 上 mmap / 读取性能的最终结论,我在 Linux 内核邮件列表上发现了一篇不错的帖子 (link)。它是从 2000 年开始的,因此从那时起内核中的 IO 和虚拟内存有了很多改进,但它很好地解释了 mmap
或 read
可能更快或更慢的原因。
对 mmap 的调用比 read 开销更大(就像 epoll 比 poll 开销更大,poll 比 read 开销更大)。更改虚拟内存映射在某些处理器上是一项相当昂贵的操作,原因与在不同进程之间切换成本高昂的原因相同。
IO系统已经可以使用磁盘缓存,所以如果你读取一个文件,无论你使用什么方法,你都会命中或错过缓存。
然而,
对于随机访问,内存映射通常更快,尤其是在您的访问模式稀疏且不可预测的情况下。
内存映射允许您继续使用缓存中的页面,直到完成。这意味着如果您长时间大量使用文件,然后将其关闭并重新打开,页面仍然会被缓存。通过读取,您的文件可能在很久以前就已从缓存中刷新。如果您使用文件并立即丢弃它,则不适用。 (如果您尝试 mlock 页面只是为了将它们保留在缓存中,那么您就是在试图智取磁盘缓存,而这种愚蠢的做法很少有助于系统性能)。
直接读取文件非常简单快捷。
mmap/read 的讨论让我想起另外两个性能讨论:
一些 Java 程序员惊讶地发现非阻塞 I/O 通常比阻塞 I/O 慢,如果您知道非阻塞 I/O 需要进行更多的系统调用,这完全有道理。
其他一些网络程序员惊讶地发现 epoll 通常比 poll 慢,如果您知道管理 epoll 需要进行更多的系统调用,这完全有道理。
结论:如果您随机访问数据、长时间保留数据,或者如果您知道可以与其他进程共享数据(如果没有,MAP_SHARED
不是很有趣),请使用内存映射实际分享)。如果您按顺序访问数据或在读取后将其丢弃,则可以正常读取文件。如果任何一种方法都可以让你的程序变得不那么复杂,那么那个。对于许多现实世界的案例,如果不测试您的实际应用程序而不是基准测试,就无法确定一种方法会更快。
(对不起,这个问题被删除了,但我一直在寻找答案,这个问题一直出现在谷歌搜索结果的顶部。)
这里已经有很多很好的答案涵盖了许多要点,所以我将添加几个我没有直接在上面看到的问题。也就是说,这个答案不应该被认为是利弊的综合,而是这里其他答案的补充。
mmap 看起来很神奇
以文件已经完全缓存的情况1 作为基线2,mmap
可能看起来很像 magic:
mmap 只需要 1 次系统调用来(可能)映射整个文件,之后不再需要系统调用。 mmap 不需要将文件数据从内核复制到用户空间。 mmap 允许您“作为内存”访问文件,包括使用您可以对内存执行的任何高级技巧对其进行处理,例如编译器自动矢量化、SIMD 内在函数、预取、优化的内存解析例程、OpenMP 等。
在文件已经在缓存中的情况下,似乎无法击败:您只是直接将内核页面缓存作为内存访问,并且无法比这更快。
嗯,它可以。
mmap 实际上并不神奇,因为...
mmap 仍然可以按页面工作
mmap
与 read(2)
的主要隐藏成本(这实际上是 读取块 的可比较的操作系统级系统调用)是使用 mmap
您需要为在新映射中访问的每个 4K 页面,即使它可能被页面错误机制隐藏。
举个例子,一个典型的实现只需要 mmap
s 整个文件将需要故障输入,因此 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) 硬件预取处理的性能如何使用 TLB - 例如,预取可以触发页面遍历吗? (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()
隐含的将文件内容从内核复制到用户空间的每字节工作成本更高?
在许多系统上,它们实际上是近似平衡的。请注意,每一个都具有完全不同的硬件和操作系统堆栈属性。
特别是,在以下情况下,mmap
方法变得相对更快:
该操作系统具有快速的次要故障处理,尤其是次要故障批量优化,例如故障处理。
操作系统有一个很好的 MAP_POPULATE 实现,它可以在底层页面在物理内存中连续的情况下有效地处理大型映射。
硬件具有强大的页面翻译性能,如大型TLB、快速的二级TLB、快速并行的page-walkers、良好的预取与翻译交互等。
...而 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 后更新
Spectre 和 Meltdown 漏洞的缓解措施大大增加了系统调用的成本。在我测量过的系统上,“什么都不做”系统调用的成本(这是对系统调用的纯开销的估计,除了调用完成的任何实际工作)从典型的大约 100 ns现代Linux系统约700纳秒。此外,根据您的系统,除了需要重新加载 TLB 条目的直接系统调用成本之外,专门针对 Meltdown 的 page-table isolation 修复可能会产生额外的下游影响。
与基于 mmap
的方法相比,所有这些都是基于 read()
的方法的相对劣势,因为 read()
方法必须为每个“缓冲区大小”的数据进行一次系统调用。您不能任意增加缓冲区大小来分摊此成本,因为使用大缓冲区通常性能更差,因为您超过了 L1 大小,因此不断遭受缓存未命中。
另一方面,使用 mmap
,您可以使用 MAP_POPULATE
映射到大的内存区域并有效地访问它,而只需一次系统调用。
1 这或多或少也包括文件没有完全缓存开始的情况,但操作系统预读足够好使其看起来如此(即页面通常在您想要的时候缓存)。这是一个微妙的问题,因为预读的工作方式在 mmap
和 read
调用之间通常有很大不同,并且可以通过 2 中所述的“建议”调用进一步调整。
2 ...因为如果文件没有缓存,您的行为将完全由 IO 问题主导,包括您的访问模式对底层硬件的同情程度 -并且您应尽最大努力确保此类访问尽可能具有同情心,例如通过使用 madvise
或 fadvise
调用(以及您可以进行的任何应用程序级别更改以改进访问模式)。
3 例如,您可以通过顺序mmap
在较小尺寸的窗口(例如 100 MB)中解决此问题。
4 事实上,事实证明 MAP_POPULATE
方法(至少是某种硬件/操作系统组合)只比不使用它稍微快一点,可能是因为内核正在使用 faultaround - 所以小故障的实际数量减少了 16 倍左右。
mmap
将具有不可逾越的优势,因为它避免了固定的内核调用开销。另一方面,mmap
也增加了 TLB 压力,实际上使得在当前进程中第一次读取字节的“预热”阶段变慢(尽管它们仍在页面页面中),因为它可能比 read
做更多的工作,例如“故障排除”相邻页面......对于相同的应用程序,“热身”才是最重要的! @CaetanoSauer
主要的性能成本将是磁盘 i/o。 "mmap()" 肯定比 istream 快,但差异可能并不明显,因为磁盘 i/o 将支配您的运行时间。
我尝试了 Ben Collins 的代码片段(见上/下)来测试他关于“mmap() 速度更快”的断言,但没有发现可测量的差异。请参阅我对他的回答的评论。
我当然不建议依次单独对每条记录进行映射,除非您的“记录”很大 - 这将非常慢,每条记录需要 2 次系统调用,并且可能会从磁盘内存缓存中丢失页面...... .
在您的情况下,我认为 mmap()、istream 和低级 open()/read() 调用都差不多。在这些情况下,我会推荐 mmap():
文件中存在随机访问(非顺序),并且整个内容可以舒适地放入内存中,或者文件中存在引用位置,以便可以映射某些页面并映射出其他页面。这样,操作系统就可以使用可用的 RAM 来获得最大收益。或者,如果多个进程正在读取/处理同一个文件,那么 mmap() 非常棒,因为这些进程都共享相同的物理页面。
(顺便说一句 - 我喜欢 mmap()/MapViewOfFile())。
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() 编辑(更简单)。
几个月前,我对 boost_iostreams 的滑动窗口 mmap()-ed 流类进行了半生不熟的实现,但没人关心,我忙于其他事情。最不幸的是,几周前我删除了一个旧的未完成项目的档案,那是受害者之一:-(
更新:我还应该补充一点,这个基准测试在 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 源,但问题是它正在 mmap
ping 整个文件,这将您限制为 2^(wordsize)。在 32 位机器上,4GB 不够大。期望在 Git 中有 .pack
个文件变得比这大得多并不是不合理的,所以我需要分块读取文件而不求助于常规文件 i/o。在 Boost::Iostreams
的掩护下,我实现了一个 Source,它或多或少是 std::streambuf
和 std::istream
之间交互的另一种视图。您也可以尝试类似的方法,只需将 std::filebuf
继承到 mapped_filebuf
中,类似地,将 std::fstream
继承到 a mapped_fstream
中。两者之间的互动很难做到正确。 Boost::Iostreams
为您完成了一些工作,它还为过滤器和链提供了挂钩,所以我认为以这种方式实现它会更有用。
mmap()
一次一页地文件?如果 size_t
的容量足以容纳文件的大小(很可能在 64 位系统上),那么只需 mmap()
一次调用即可处理整个文件。
很抱歉 Ben Collins 丢失了他的滑动窗口 mmap 源代码。在 Boost 中拥有那将是很好的。
是的,映射文件要快得多。您实际上是在使用操作系统虚拟内存子系统将内存与磁盘关联起来,反之亦然。这样想:如果操作系统内核开发人员可以让它更快,他们会的。因为这样做几乎可以让一切变得更快:数据库、启动时间、程序加载时间等等。
滑动窗口方法实际上并不难,因为可以一次映射多个连续页面。因此,只要任何一条记录中最大的一条可以放入内存,记录的大小就无关紧要了。重要的是管理簿记。
如果记录不是从 getpagesize() 边界开始,则您的映射必须从前一页开始。映射区域的长度从记录的第一个字节(必要时向下舍入到 getpagesize() 的最接近的倍数)延伸到记录的最后一个字节(向上舍入到 getpagesize() 的最接近的倍数)。处理完一条记录后,您可以 unmap() 它,然后继续下一条。
这一切在 Windows 下也可以正常工作,使用 CreateFileMapping() 和 MapViewOfFile() (和 GetSystemInfo() 来获取 SYSTEM_INFO.dwAllocationGranularity --- 不是 SYSTEM_INFO.dwPageSize)。
mmap 应该更快,但我不知道多少。这在很大程度上取决于您的代码。如果您使用 mmap,最好一次对整个文件进行 mmap,这将使您的生活更轻松。一个潜在的问题是,如果您的文件大于 4GB(或者实际上限制较低,通常为 2GB),您将需要 64 位架构。因此,如果您使用的是 32 位环境,您可能不想使用它。
话虽如此,可能有更好的途径来提高性能。你说输入文件被扫描了很多次,如果你可以一次读出来然后完成它,那可能会快得多。
也许您应该预处理文件,因此每条记录都在一个单独的文件中(或者至少每个文件都是可映射的大小)。
您还可以为每条记录完成所有处理步骤,然后再进行下一条记录吗?也许这样可以避免一些 IO 开销?
我同意 mmap 的文件 I/O 会更快,但是在您对代码进行基准测试时,反例不应该进行一些优化吗?
本柯林斯写道:
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 仍然赢了,但这应该让事情更接近。
我记得几年前将一个包含树结构的巨大文件映射到内存中。与涉及大量内存工作的正常反序列化相比,我对速度感到惊讶,例如分配树节点和设置指针。所以事实上,我正在比较对 mmap (或其在 Windows 上的对应项)的单个调用与对 operator new 和构造函数调用的许多(许多)调用。对于此类任务,与反序列化相比,mmap 是无与伦比的。当然,应该为此研究提升可重定位指针。
这听起来像是多线程的一个很好的用例......我认为你可以很容易地设置一个线程来读取数据,而其他线程处理它。这可能是一种显着提高感知性能的方法。只是一个想法。
在我看来,使用 mmap() “只是”减轻了开发人员编写自己的缓存代码的负担。在一个简单的“每次读取文件”的情况下,这并不难(尽管 mlbrock 指出您仍然将内存副本保存到进程空间中),但是如果您在文件中来回切换或跳过位等等,我相信内核开发人员在实现缓存方面可能做得比我做得更好......
mmap
用于缓存的真正好处是您只需重新使用已经存在的现有页面缓存,因此您可以免费获得该内存,并且可以跨进程共享也。
我认为 mmap 最大的优点是具有异步读取的潜力:
addr1 = NULL;
while( size_left > 0 ) {
r = min(MMAP_SIZE, size_left);
addr2 = mmap(NULL, r,
PROT_READ, MAP_FLAGS,
0, pos);
if (addr1 != NULL)
{
/* process mmap from prev cycle */
feed_data(ctx, addr1, MMAP_SIZE);
munmap(addr1, MMAP_SIZE);
}
addr1 = addr2;
size_left -= r;
pos += r;
}
feed_data(ctx, addr1, r);
munmap(addr1, r);
问题是我找不到正确的 MAP_FLAGS 来提示应该尽快从文件同步此内存。我希望 MAP_POPULATE 为 mmap 提供正确的提示(即它不会在调用返回之前尝试加载所有内容,但会在异步中使用 feed_data 进行加载)。至少它使用此标志提供了更好的结果,即使手册声明自 2.6.23 以来没有 MAP_PRIVATE 它什么也不做。
posix_madvise
with the WILLNEED
标志用于预填充惰性提示。
posix_madvise
是异步调用。对于那些想要等到整个内存区域可用而没有页面错误的人来说,参考 mlock
也很不错。
不定期副业成功案例分享
mmap
与read()
的许多事实仍然像过去一样真实,但整体性能并不能真正通过将优缺点相加来确定,而只能通过在特定的硬件配置。例如,“对 mmap 的调用比读取的开销更大”是有争议的——是的mmap
必须将映射添加到进程页表,但read
必须将所有读取的字节从内核复制到用户空间。mmap
的开销低于read
。现在确实,如果您想以稀疏和随机的方式访问数据,mmap
确实非常非常好 - 但反过来不一定是正确的:mmap
可能仍然是顺序访问的最佳选择。mmap
更快,我希望至少能看到带有表格结果的整个测试设备(源代码)和处理器型号。mmap
不会刷新 TLB,除非在异常情况下(但munmap
可能)。我的测试包括微基准测试(包括munmap
)和,还包括在实际用例中运行的“应用程序中”。当然我的应用程序和你的应用程序不一样,所以人们应该在本地测试。甚至不清楚mmap
是否受到微基准的青睐:read()
也得到了很大的提升,因为用户端目标缓冲区通常停留在 L1 中,这在更大的应用程序中可能不会发生。所以,是的,“这很复杂”。