读取大型二进制文件每30个字节的最快方法是什么?

24

如何以最快的方式读取一个大型二进制文件(2-3 GB)中每30个字节?我了解到fseek存在I/O缓冲区的性能问题,但是我也不想在取出每30个字节之前将2-3 GB的数据读入内存。

7个回答

24
我建议您创建一个几千字节的缓冲区,每隔30个字节读取一次,重新加载下一个几千字节的缓冲区,并继续操作直到到达文件结尾。这样可以限制内存中读取的数据量,也不必频繁地从文件中读取。您会发现,创建的缓冲区越大,读取速度就越快。
编辑:实际上,如下所建议,您可能希望将缓冲区设置为几百kb而不是几千字节(就像我说的那样 - 更大的缓冲区 = 更快的文件读取)。

5
+1 -- 我刚写了几乎完全相同的内容 -- 只是我建议每个块使用几百千字节。 - Jerry Coffin
1
是的,那可能更好。我的意思是,如果文件那么大,他显然处于一个可以承受比几千字节更大的缓冲区的环境中 :)(编辑后的答案) - Cam
2
我预测,与标准I/O库中使用的默认缓冲策略相比,对于每30个字节读取的程序而言,这种方案的好处甚至无法衡量。如果有测量结果证明我错了,我会很高兴。 - Norman Ramsey
1
@Norman Ramsey:我预测不同。测试正在运行,我很快会发布一个CW答案。 - Steve Jessop
在许多平台上,使缓冲区大小/读取大小与磁盘的扇区大小匹配会导致最快的读取速度。 - Lisa

17

性能测试。如果你想自己使用它,请注意,完整性检查(打印总数)仅在“步骤”除以BUFSZ,并且MEGS足够小,以免读取文件结束时的内容。这是由于(a)懒惰,(b)不希望掩盖真正的代码。rand1.data是从/dev/urandom使用dd复制的几GB。

#include <stdio.h>
#include <stdlib.h>

const long long size = 1024LL*1024*MEGS;
const int step = 32;

int main() {
    FILE *in = fopen("/cygdrive/c/rand1.data", "rb");
    int total = 0;
    #if SEEK
        long long i = 0;
        char buf[1];
        while (i < size) {
            fread(buf, 1, 1, in);
            total += (unsigned char) buf[0];
            fseek(in, step - 1, SEEK_CUR);
            i += step;
        }
    #endif
    #ifdef BUFSZ
        long long i = 0;
        char buf[BUFSZ];
        while (i < size) {
            fread(buf, BUFSZ, 1, in);
            i += BUFSZ;
            for (int j = 0; j < BUFSZ; j += step) 
                total += (unsigned char) buf[j];
        }
    #endif
    printf("%d\n", total);
}

结果:

$ gcc -std=c99 buff2.c -obuff2 -O3 -DBUFSZ=32*1024 -DMEGS=20 && time ./buff2
83595817

real    0m1.391s
user    0m0.030s
sys     0m0.030s

$ gcc -std=c99 buff2.c -obuff2 -O3 -DBUFSZ=32 -DMEGS=20 && time ./buff2
83595817

real    0m0.172s
user    0m0.108s
sys     0m0.046s

$ gcc -std=c99 buff2.c -obuff2 -O3 -DBUFSZ=32*1024 -DMEGS=20 && time ./buff2
83595817

real    0m0.031s
user    0m0.030s
sys     0m0.015s

$ gcc -std=c99 buff2.c -obuff2 -O3 -DBUFSZ=32 -DMEGS=20 && time ./buff2
83595817

real    0m0.141s
user    0m0.140s
sys     0m0.015s

$ gcc -std=c99 buff2.c -obuff2 -O3 -DSEEK -DMEGS=20 && time ./buff2
83595817

real    0m20.797s
user    0m1.733s
sys     0m9.140s

总结:

我最初使用了20MB的数据,当然适合放在缓存中。第一次读取它(使用32KB缓冲区)需要1.4秒将其带入缓存。第二次(使用32字节缓冲区)只需要0.17秒。第三次(再次使用32KB缓冲区)只需要0.03秒,这太接近我的计时器时间粒度,无法具有实际意义。即使数据已经在磁盘缓存中,fseek也需要超过20秒。

此时我将fseek从环中拆出来,以便其他两个可以继续执行:

$ gcc -std=c99 buff2.c -obuff2 -O3 -DBUFSZ=32*1024 -DMEGS=1000 && time ./buff2
-117681741

real    0m33.437s
user    0m0.749s
sys     0m1.562s

$ gcc -std=c99 buff2.c -obuff2 -O3 -DBUFSZ=32 -DMEGS=1000 && time ./buff2
-117681741

real    0m6.078s
user    0m5.030s
sys     0m0.484s

$ gcc -std=c99 buff2.c -obuff2 -O3 -DBUFSZ=32*1024 -DMEGS=1000 && time ./buff2
-117681741

real    0m1.141s
user    0m0.280s
sys     0m0.500s

$ gcc -std=c99 buff2.c -obuff2 -O3 -DBUFSZ=32 -DMEGS=1000 && time ./buff2
-117681741

real    0m6.094s
user    0m4.968s
sys     0m0.640s

$ gcc -std=c99 buff2.c -obuff2 -O3 -DBUFSZ=32*1024 -DMEGS=1000 && time ./buff2
-117681741

real    0m1.140s
user    0m0.171s
sys     0m0.640s

1000MB的数据似乎被大幅缓存。一个32KB的缓冲区比一个32字节的缓冲区快6倍,但差异在于用户时间,而不是在磁盘I/O上阻塞的时间。现在,8000MB远远超过我的RAM容量,因此我可以避免缓存:

$ gcc -std=c99 buff2.c -obuff2 -O3 -DBUFSZ=32*1024 -DMEGS=8000 && time ./buff2
-938074821

real    3m25.515s
user    0m5.155s
sys     0m12.640s

$ gcc -std=c99 buff2.c -obuff2 -O3 -DBUFSZ=32 -DMEGS=8000 && time ./buff2
-938074821

real    3m59.015s
user    1m11.061s
sys     0m10.999s

$ gcc -std=c99 buff2.c -obuff2 -O3 -DBUFSZ=32*1024 -DMEGS=8000 && time ./buff2
-938074821

real    3m42.423s
user    0m5.577s
sys     0m14.484s

忽略前面的那个,它受益于文件的前1000MB已经在RAM中了。

现在,具有32KB的版本在墙上时间上只稍微快一点(我懒得重新运行,所以暂时忽略它),但是看一下用户+系统时间的差异:20秒对82秒。我认为我的操作系统的猜测读取磁盘缓存在这里挽救了32字节缓冲区的局面:当32字节缓冲区被慢慢地重新填充时,尽管没有人请求它们,操作系统正在加载下几个磁盘扇区。如果没有这样做,我认为比32KB缓冲区慢一分钟(20%),因为后者在请求下一个读取之前在用户空间花费的时间更少。

故事的寓意是:在我的实现中标准I/O缓冲不够用,fseek的性能很差,就像提问者说的那样。当文件被缓存在操作系统中时,缓冲区大小非常重要。当文件不被缓存在操作系统中时,缓冲区大小对墙上时间没有太大影响,但我的CPU更忙了。

incrediman建议使用读取缓冲区是至关重要的,因为fseek很糟糕。在我的机器上争论缓冲区应该是几KB还是几百KB可能是毫无意义的,可能是因为操作系统已经确保了操作是紧密I/O绑定的。但我非常确定这归功于操作系统的磁盘预读取,而不是标准I/O缓冲,因为如果是后者,那么fseek会比它更好。实际上,可能是标准I/O正在进行预读取,但是对fseek的太简单的实现每次都丢弃缓冲区。我没有研究过这个实现(如果我这样做了,我也无法跨越边界进入操作系统和文件系统驱动程序)。


非常酷。但是 fread 不是针对 1 个字符进行优化的。你可以尝试使用 fgetc 吗? - Norman Ramsey
在每个测试运行中(使用MEGS=20,数据预加载),我无法检测到fgetc与fread之间的任何区别。结果范围为19.4秒至21.2秒,最好和最差的结果都使用了fgetc。我预计其他人的结果会有所不同 - 我不知道cygwin+gcc在多大程度上使用未修改的glibc,也不知道Windows是否存在某些特殊性能问题,导致fseek的性能下降。你可能认为31字节的前向查找“应该”大多数情况下只是增加FILE*中的偏移量,但显然并非如此。 - Steve Jessop
1
我追踪了它,这个笨蛋在每次fseek时都会进行系统调用。真是一群白痴!我修改了你的程序,使用了Phong Vo的sfio库,此时差异仍然存在,但它们相对较小。感谢您发布如此有用的程序。哦,还有+1 :-) - Norman Ramsey
1
谢谢,Norman。性能问题的第一条规则是:编写一个不太完整的基准测试通常非常容易,并且一个不太完整的基准测试通常足以揭示严重的性能灾难 :-) - Steve Jessop
Phong Vo的sfio库可以在https://github.com/ellson/graphviz/tree/master/lib/sfio(以及其他地方)找到,但这里的一些早期链接已经失效。 - TextGeek

10

可以读取一个字节,然后在循环中寻找29个字节。但是IO子系统必须按扇区读取文件,扇区通常为512字节大小,因此它仍将读取整个文件。

从长远来看,更快的方法是以步长的倍数读取整个文件块,然后只需在缓冲区中查找。如果您确保缓冲区大小是30的倍数,并且是512的倍数,则可以使自己的工作更加简单,并使文件IO子系统的工作更容易。

while (still more file to read)
{ 
   char buf[30 * 512];
   int cread = fread (buf, sizeof(buf), 1, fd);
   for (int ii = 0; ii < cread; ii += 30)
   {

   }
}

这种做法看起来效率低下,但实际上比尝试读取30字节块要快。

顺便说一句,如果您在Windows上运行,并且愿意使用特定于操作系统的方法,那么内存映射文件的性能确实无法被超越。 如何扫描大型磁盘文件?


3
重要的一点是,扇区大小意味着操作系统将会读取整个文件,不管文件大小。 - caf
当然,Windows并不是唯一具有内存映射文件的平台。 - Ken
@Ken:我没有关于mmap相对于fread的第一手知识,而且我链接的示例代码仅适用于Windows。 - John Knoeller

9
如果您愿意打破ANSI-C并使用特定于操作系统的调用,我建议使用内存映射文件。这是Posix版本(Windows有自己的特定于操作系统的调用):
#define MAPSIZE 4096
int fd = open(file, O_RDONLY);
struct stat stbuf;
fstat(fd, &stbuf);


char *addr = 0;
off_t last_mapped_offset = -1;
off_t idx = 0;
while (idx < stbuf.st_size)
{
    if (last_mapped_offset != (idx / MAPSIZE))
    {
        if (addr)
            munmap(addr, MAPSIZE);

        last_mapped_offset = idx / MAPSIZE; 

        addr = mmmap(0, MAPSIZE, PROT_READ, MAP_FILE, fd, idx, last_mapped_offset);
    }

    *(addr + (idx % MAPSIZE));

    idx += 30;

}

munmap(addr, MAPSIZE);
close(fd);

1
当您仅一次性地使用mmap()一页,并且从不调用madvise()时,典型的基于POSIX的操作系统是否仍会执行预读取? - bk1e
顺便提一下,mmap() 使用 SIGBUS 来报告文件映射后发生的错误。这比来自 read()fread() 的错误更难正确处理。 - bk1e

3
整个缓冲I/O库的目的是让您摆脱这些顾虑。如果你必须读取每30个字节,操作系统将会读取整个文件,因为操作系统是以更大的块进行读取。以下是您的选项,从最高性能到最低性能:
  • 如果您拥有大的地址空间(即,在64位硬件上运行64位操作系统),那么使用内存映射I/O(在POSIX系统上使用mmap)将节省操作系统从内核空间复制数据到用户空间的成本。这种节省可能非常显著。

  • 如下详细说明所示(感谢Steve Jessop提供基准测试),如果您关心I/O性能,则应从AT&T高级软件技术组下载Phong Vo的sfio库。它比C标准I/O库更安全、更好地设计和更快。在使用fseek的程序中,它速度显著提升:在简单的微基准测试中最多快七倍。

  • 只需轻松使用fseekfgetc,它们被精确地设计和实现以解决您的问题。

如果您认真对待这个问题,应该测量所有三种替代方案。Steve Jessop和我表明使用fseek会更慢,而且如果您使用GNU C库,fseek会慢很多。您应该测量mmap;它可能是最快的。
补充:您需要查看您的文件系统,并确保它可以快速地从磁盘中读取2-3 GB的数据。例如,XFS可能会比ext2更好。当然,如果你被困在NTFS或HFS+上,它就会变得很慢。
令人震惊的结果刚刚出现
我在Linux上重复了Steve Jessop的测量结果。GNU C库在每次fseek时都会进行一次系统调用。除非POSIX由于某种原因要求这样做,否则这是荒谬的。我可以消耗大量的二进制代码并呕吐出一个更好的缓冲I/O库。无论如何,成本增加了约20倍,其中大部分花费在内核中。如果您使用fgetc而不是fread来读取单个字节,则可以在小型基准测试中节省约20%。
使用良好的I/O库的结果不那么令人震惊
我再次进行了实验,这次使用Phong Vo的sfio库。阅读200MB需要
  • 不使用fseek,时间为0.15秒(BUFSZ为30k)
  • 使用fseek,时间为0.57秒

重复测量表明,不使用fseek,使用sfio仍然可以缩短约10%的运行时间,但运行时间非常嘈杂(几乎所有时间都花费在操作系统上)。

在这台机器(笔记本电脑)上,我没有足够的空闲磁盘空间来运行一个不适合于磁盘缓存的文件,但我愿意得出以下结论:

  • 使用明智的I/O库,fseek更昂贵,但不足以产生很大的差异(如果您只做I/O,则需要4秒钟)。

  • GNU项目没有提供合理的I/O库。 与经常发生的情况一样,GNU软件很糟糕。

结论:如果您想要快速的I/O,您的第一步应该是用AT&T sfio库替换GNU I/O库。 与此相比,其他影响可能会很小。


准备好震惊了,fseek 导致我机器(NTFS,Windows XP,cygwin)变得非常缓慢。 - Steve Jessop
@Steve:我对Cygwin持怀疑态度。我很想知道与Microsoft C编译器和库(相同代码)相比性能如何。 - Norman Ramsey
2
“我可以咀嚼一堆二进制代码,然后吐出一个比那个更好的缓冲I/O库。” 这是开源的。 重新编写并提交它; 如果因某些重大原因(例如POSIX要求)而被拒绝,则您将知道为什么GNU库的性能如此糟糕。 如果被接受,那么您将单手为Linux的默认I / O库做出巨大改进。 - fouric

1

您几乎可以不用担心它。运行时很可能为每个文件句柄缓冲最后读取的块。即使没有,操作系统也会为您缓存文件访问。

话虽如此,如果您一次读取一个块,确实可以节省对fseek和fread函数的调用开销。一次读取的块越大,您保存的调用开销就越多,尽管其他成本在一定程度上开始显现。


0

如果您正在从带有旋转盘的硬盘中读取数据,那么答案是使用大缓冲区顺序读取整个文件,并且丢弃您不需要的内存部分。

标准硬盘驱动器可能访问的最小访问单位是扇区。所有常见旋转磁盘驱动器的扇区大小都多于30字节。这意味着无论主机请求看起来像什么,硬盘控制器都必须访问每个扇区。没有低级别的魔法可以改变这一点。

即使不是这种情况,您也可以读取单个字节,但随机读取操作与顺序读取操作相比具有巨大的优势。最好的情况仍然与顺序读取相同。在现实世界中,即使使用大量命令缓冲区,信号开销也可能阻止这些方案运行。


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