使用mmap和madvise按行顺序读取大文件为什么比fgets慢?

6

概述

我有一个受IO显著限制的程序,正在尝试加速它的运行。使用mmap似乎是个好主意,但实际上与仅使用一系列fgets调用相比,它会降低性能。

一些演示代码

我已经将演示压缩到了基本要素,并针对大约3.5百万行的800mb文件进行了测试:

使用fgets:

char buf[4096];
FILE * fp = fopen(argv[1], "r");

while(fgets(buf, 4096, fp) != 0) {
    // do stuff
}
fclose(fp);
return 0;

800mb文件的运行时间:

[juhani@xtest tests]$ time ./readfile /r/40/13479/14960 

real    0m25.614s
user    0m0.192s
sys 0m0.124s

mmap版本:

struct stat finfo;
int fh, len;
char * mem;
char * row, *end;
if(stat(argv[1], &finfo) == -1) return 0;
if((fh = open(argv[1], O_RDONLY)) == -1) return 0;

mem = (char*)mmap(NULL, finfo.st_size, PROT_READ, MAP_SHARED, fh, 0);
if(mem == (char*)-1) return 0;
madvise(mem, finfo.st_size, POSIX_MADV_SEQUENTIAL);
row = mem;
while((end = strchr(row, '\n')) != 0) {
    // do stuff
    row = end + 1;
}
munmap(mem, finfo.st_size);
close(fh);

运行时间有很大的差异,但从未比fgets更快:

[juhani@xtest tests]$ time ./readfile_map /r/40/13479/14960

real    0m28.891s
user    0m0.252s
sys 0m0.732s
[juhani@xtest tests]$ time ./readfile_map /r/40/13479/14960

real    0m42.605s
user    0m0.144s
sys 0m0.472s

其他注意事项

  • 观察在top中运行的过程,memmapped版本在此过程中产生了几千个页面错误。
  • fgets版本的CPU和内存使用率都非常低。

问题

  • 为什么会出现这种情况?难道是由于fopen/fgets实现的缓冲文件访问比mmap与madvise POSIX_MADV_SEQUENTIAL实现的积极预取更好吗?
  • 除了即时压缩/解压以将IO负载转移给处理器之外,是否有可能采用其他方法使其更快?看着相同文件上'wc -l'的运行时间,我猜想也许不是这种情况。

2
不知道是哪个平台,无法回答。 - Fred Foo
2
你尝试过第二种解决方案的非内存映射版本吗?即使用一个fread(或read)调用将所有内容读入一个大缓冲区,然后进行解析。 - user2100815
Neil:使用大缓冲区几乎是不切实际的(一些较大的文件可能有数千兆字节大小)。使用100MB缓冲区(因此需要9个fread调用)导致运行时间为27秒,用户/系统几乎为0。 - juhanic
1
我认为这个问题是一个很好的警示,提醒我们不要过早地进行优化。 :-) - R.. GitHub STOP HELPING ICE
我无法重现这个问题。在这里,使用mmap版本的速度是fgets版本的两倍(两者都没有实际操作)。 - AProgrammer
显示剩余7条评论
3个回答

8

POSIX_MADV_SEQUENTIAL仅是对系统的提示,可能会被某些POSIX实现完全忽略。

你两种解决方案的区别在于,mmap要求将文件完全映射到虚拟地址空间中,而fgets在内核空间完全进行IO并只将页面复制到不变的缓冲区。

这也有更多的重叠潜力,因为IO是由某个内核线程完成的。

也许您可以通过让一个(或多个)独立线程读取每个页面的第一个字节来增加mmap实现的感知性能。这个(或这些)线程然后会有所有页面故障和时间你的应用程序线程会到达一个特定的页面它已经被加载了。


4
阅读mmap的手册,可以发现通过在mmap的标志中添加MAP_POPULATE,可以避免页面故障:

MAP_POPULATE(自Linux 2.5.46以来):为映射填充(预期望)页面表。对于文件映射,这会导致对文件进行预读取。然后,对映射的后续访问将不会受到页面故障的阻塞。

这样,建议使用页面故障预加载线程(如Jens所建议的方式)将变得过时。 编辑:首先,应该刷新页面缓存来进行有意义的基准测试:
    echo 3 | sudo tee /proc/sys/vm/drop_caches

此外:使用 madviseMADV_WILLNEED 建议会预取所需的页面(与 fadvise 中的 POSIX_FADV_WILLNEED 相同)。目前这些调用未能按照文档所述异步执行,直到请求的页面被加载为止一直处于阻塞状态。但是,已经有内核补丁在进行中,将预缓存请求排入内核工作队列中以使这些调用异步化,从而使单独的用户空间预读线程成为过时技术。

mmap使用MAP_POPULATEmadvise使用MADV_WILLNEED是否具有相同的功能? - rosshjb

4
你正在做的事情——读取整个mmap空间——应该会触发一系列页面错误。使用mmap,操作系统仅在需要时(即访问它们时)将mmap的数据页面懒惰地加载到内存中。因此,这种方法是一种优化。尽管你与mmap交互时像整个东西都在RAM中一样,但不是所有东西都在RAM中——它只是在虚拟内存中设置的块。
相比之下,当你将文件从磁盘读入缓冲区时,操作系统会将整个结构都加载到RAM中(加载到缓冲区中)。这可能会产生很多内存压力,挤占其他页面,迫使它们被写回磁盘。如果内存较低,则可能导致抖动。
在使用mmap时的常见优化技术是将数据页式调入内存:通过循环遍历mmap空间并按页大小递增指针,在每个页面上访问一个字节并触发操作系统将所有mmap的页面调入内存; 触发所有这些页面错误。这是一种“准备RAM”进行优化的技术,将mmap拉入并为将来的使用做好准备。使用此方法,操作系统将不需要进行太多的懒加载。你可以在单独的线程上执行此操作以在主线程访问之前引导页面,但要确保不要耗尽RAM或超前于主线程,否则实际上将开始降低性能。
使用mmap和read()到大缓冲区的差异是什么?这有点复杂。
较旧版本的UNIX和某些当前版本并不总是使用按需分页(其中内存被划分为块并根据需要交换出/入)。相反,在某些情况下,操作系统使用传统的交换-将内存中的数据结构视为单体,并根据需要交换整个结构。当处理大文件时,这可能更有效率,因为按需分页需要将页面复制到通用缓冲区高速缓存中,可能会导致频繁交换甚至抖动。交换可以避免使用通用缓冲区高速缓存-减少内存消耗,避免额外的复制操作并避免频繁写入。缺点是你不能从按需分页中受益。使用mmap,你保证按需分页;使用read()则没有。
还要注意,在完整的mmap内存空间中进行页面行走始终比全面读取慢约60%(不包括使用MADV_SEQUENTIAL或其他优化的情况)。
使用mmap w / MADV_SEQUENTIAL时有一个注意事项-使用此选项时,必须确保你的数据确实是按顺序存储的,否则这将使文件的分页速度减慢约10倍。通常,你的数据不会映射到磁盘的连续部分,而是写入分布在磁盘周围的块中。因此,我建议你小心并仔细查看此问题。
记住,太多数据存储在RAM中会污染RAM,导致页面错误在其他地方更加普遍。关于性能,一个常见的误解是CPU优化比内存占用更重要。这不是正确的答案——前往磁盘的时间比CPU操作时间长8个数量级,即使在今天的SSD上也是如此。因此,在程序执行速度成为问题时,内存占用和利用率就变得更加重要。
使用read()的好处之一是数据可以存储在堆栈上(假设堆栈足够大),这将进一步加快处理速度。
如果符合您的用例,使用带流式处理的read()是使用mmap的良好替代方法。这类似于您使用fgets/fputs(fgets/fputs在内部使用read()实现)。在循环中,您需要读取缓冲区中的数据,处理数据,然后读取下一节/覆盖旧数据。这样的流式处理可以使内存消耗非常低,并且可能是最有效的I/O方式。唯一的缺点是您永远没有整个文件在内存中,并且它不会保留在内存中。因此,这是一种一次性的方法。如果可以使用它-很好,就用它。如果不行...那就使用mmap。
因此,无论是read还是mmap更快...它取决于许多因素。测试可能是您需要做的。一般来说,如果您计划长时间使用数据,则mmap很好,因为您将受益于页面请求;或者如果您一次无法处理那么多数据,则也可以使用它。如果使用流式处理,则read()更好-数据不必保留,或者数据可以适合内存,因此内存压力不是一个问题。另外,如果数据不会在内存中存在很长时间,则read()可能更可取。
现在,以当前的实现方式——一种流式处理方式——使用fgets()并在\n处停止。与反复调用read()一百万次(这就是fgets所做的)相比,大块读取更加高效。您不必使用巨大的缓冲区-您不希望产生过多的内存压力(这可能会污染您的缓存和其他内容),系统还有一些内部缓冲区可供使用。但是,您确实需要读取大小为64k的缓冲区。您绝对不应该按行调用read()。
您可以使用多线程解析该缓冲区。只需确保线程访问不同的缓存块-因此找到缓存块的大小,在至少缓存块大小的距离上使您的线程处理不同的缓冲区部分即可。
以下是您特定问题的一些更具体的建议: 您可以尝试将数据重新格式化为一些二进制格式。例如,尝试将文件编码更改为自定义格式而不是UTF-8或其他格式。这可能会减小文件大小。350万行是非常多的字符要循环处理...这可能涉及大约1.5亿个字符比较。
如果您可以在程序运行之前按行长度对文件进行排序...则可以编写算法来更快地解析行 - 只需递增指针并测试到达的字符,确保它是 '\n'。然后执行您需要的任何处理。
使用此方法,您需要找到一种维护已排序文件的方法,通过插入新数据到适当的位置。
您可以更进一步 - 在对文件进行排序后,维护给定长度的多少行在文件中的列表。使用该列表指导分析行 - 跳至每行的末尾,而不必进行字符比较。
如果无法对文件进行排序,只需创建所有偏移量(从每行的开头到其终止换行符)的列表。总共有350万个偏移量。编写算法以在从文件中插入/删除行时更新该列表。
当您涉足此类文件处理算法时...它开始类似于实现noSQL数据库。另一种选择可能仅是将所有这些数据插入noSQL数据库中。这取决于您需要做什么:信不信由你,有时仅使用上述原始自定义文件操作和维护比任何数据库实现(甚至是noSQL数据库)更快。
还有几件事情: 当您使用read()这种流式处理方法时,必须小心处理边缘情况——即当您到达一个缓冲区的结束并开始新的缓冲区时。这称为缝合缓冲区。
最后,在大多数现代系统上,当您使用read()时,数据仍会存储在通用缓冲区中,然后复制到您的进程中。这是一个额外的复制操作。您可以禁用缓冲区以加速处理大型文件的IO,但要注意,这将禁用分页。但是,如果数据仅在内存中存在一段时间,这并不重要。
缓冲区是很重要的 - 找到一种方法在IO完成后重新启用它。也许只为特定进程禁用它,在单独的进程中执行IO,或者类似...我不确定细节,但这是可以做到的。
我认为这不是您真正的问题,说实话,我认为字符比较-一旦解决了这个问题,就应该没问题了。
这是我能想到的最好的建议,也许专家们会有其他想法。继续前进!

欢迎来到Stack Overflow!请注意,您要回答的问题已经接近10年了。向旧问题添加另一个答案并没有什么不妥,因为这些问题仍然可能是相关的。但是当您回答新问题时,您会发现更多人会查看您的答案。例如,您可以按照问题的新旧程度进行排序。 - Andreas Wenzel
这是一条自动消息还是个人建议?无论如何,感谢你的建议。 - ibrust
这不是自动化的。我看到了你的回答,担心你可能没有意识到正在回答一个非常古老的问题,因为这是你的第一个回答。 - Andreas Wenzel
1
啊,我明白了。谢谢你。 不,只是偶然发现了这个页面并受到了启发。也许这很愚蠢,但在这个过程中我整理了自己的思路,这是一个额外的好处。 mmap 不应该很快消失。 - ibrust
1
在旧问题中添加新答案没有任何问题..感谢发布。 - M.M

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