【问题标题】:why is reading blocks of data faster than reading byte by byte in file I/O为什么在文件 I/O 中读取数据块比逐字节读取更快
【发布时间】:2014-06-12 07:56:55
【问题描述】:

我注意到逐字节读取文件比使用 fread 读取文件需要更多时间。

根据cplusplus:
size_t fread ( void * ptr, size_t size, size_t count, FILE * stream );

从流中读取count 元素的数组,每个元素的大小为size 字节,并将它们存储在ptr 指定的内存块中。

Q1) 那么,fread 再次以 1 个字节读取文件,那么它与以 1 个字节的方法读取不一样吗?

Q2 ) 结果证明fread 仍然需要更少的时间。

来自here

我用一个大约 44 兆字节的文件作为输入来运行它。用VC++2012编译时,得到如下结果:

使用 getc 计数:400000 时间:2.034
使用 fread 计数:400000 时间:0.257

也很少有关于 SO 的帖子谈到它取决于操作系统。
Q3) 操作系统的作用是什么?

为什么会这样?幕后究竟发生了什么?

【问题讨论】:

  • 投反对票的人请发表评论并告诉我原因,如果有任何重复请注明。
  • 这是一个标准的基准测试错误。对于标准驱动器,从磁盘读取数据的速度在 10 到 60 兆字节/秒之间。您无法在 0.257 秒内读取 44 MB 的文件。因此,您实际上并没有在第二次测试中读取驱动器,而是从文件系统缓存中读取。您可以使用 SysInternals 的 RamMap 实用程序让您的机器进入一个可以比较苹果并避开橘子的状态。
  • @Hans:现代磁盘的顺序读取速度从大约 90MB/s 的中档旋转磁盘到超过 500MB/s 的 PCI 连接 SSD。在 1/4 秒内读取 44MB 是完全可能的。
  • 考虑到 Jerry Coffin 是这些数字的来源,我真的怀疑他未能加热缓存。

标签: c++ c file io


【解决方案1】:

fread一次读取一个字节的文件。该接口允许您分别指定sizecount,纯粹是为了您的方便。在幕后,fread 只会读取 size * count 字节。

fread 将尝试在一次读取的字节数高度取决于您的 C 实现和底层文件系统。除非您对两者都非常熟悉,否则通常可以安全地假设 fread 比您自己发明的任何东西都更接近最佳状态。

编辑:与吞吐量相比,物理磁盘的寻道时间往往相对较长。换句话说,他们开始阅读需要相对较长的时间。但是一旦启动,它们可以相对快速地读取连续字节。因此,在没有任何操作系统/文件系统支持的情况下,对fread 的任何调用都会导致开始每次读取的严重开销。因此,为了有效地利用磁盘,您需要一次读取尽可能多的字节。但与 CPU、RAM 和物理缓存相比,磁盘速度较慢。一次读取过多意味着您的程序花费大量时间等待磁盘完成读取,而实际上它本来可以做一些有用的事情(例如处理已读取的字节)。

这就是操作系统/文件系统的用武之地。从事这些工作的聪明人花费了大量时间来确定从磁盘请求的正确字节数。因此,当您调用fread 并请求X 字节时,操作系统/文件系统会将其转换为N 请求每个Y 字节。其中Y 是一些通常的最佳值,它取决于比此处提及的更多变量。

操作系统/文件系统的另一个作用是所谓的“预读”。基本思想是大多数 IO 发生在循环内部。因此,如果一个程序从磁盘请求一些字节,它很有可能会在不久之后请求下一个字节。因此,操作系统/文件系统读取的数据通常会比您最初实际请求的数据略多。同样,确切的数量取决于太多的变量而无法提及。但基本上,这就是一次读取一个字节仍然有点效率的原因(如果没有预读,它会再慢约 10 倍)。

最后,最好将fread 视为向操作系统/文件系统提供一些关于您要读取多少字节的提示。这些提示越准确(越接近您要读取的总字节数),操作系统/文件系统就越能优化磁盘 IO。

【讨论】:

    【解决方案2】:

    这取决于您如何逐字节阅读。但是每次调用fread 都会产生很大的开销(它可能需要进行操作系统/内核调用)。

    如果您调用fread 1000 次以逐个读取 1000 个字节,那么您需要支付 1000 次的费用;如果您调用fread 一次以读取 1000 个字节,那么您只需支付一次费用。

    【讨论】:

    • 对不起,但这仍然不是答案。
    • 请详细说明OS callcost 相关联。
    【解决方案3】:

    考虑磁盘在物理上发生了什么。每次你要求它执行读取时,它的头部必须寻找到正确的位置,然后等待盘片的右侧部分在它下面旋转。如果您执行 100 次单独的 1 字节读取,则必须执行 100 次(作为第一个近似值;实际上,操作系统可能有一个足够聪明的缓存策略来弄清楚您要做什么并提前读取)。但是,如果您一次操作读取 100 个字节,并且这些字节在磁盘上大致连续,那么您只需执行一次。

    Hans Passant 关于缓存的评论在金钱上也是正确的,但即使没有这种效果,我也希望 1 批量读取操作比许多小型读取操作更快。

    【讨论】:

      【解决方案4】:

      导致速度降低的其他因素是指令流水线重新加载和数据总线争用。数据缓存未命中与指令流水线重载类似,此处不再赘述。

      函数调用和指令管道

      在内部,处理器在高速缓存(物理上靠近处理器的快速存储器)中有一条指令流水线。处理器将用指令填充管道,然后执行指令并再次填充管道。 (注意,一些处理器可能会在管道中打开插槽时获取指令)。

      执行函数调用时,处理器会遇到分支语句。在分支解决之前,处理器无法将任何新指令提取到管道中。如果分支被执行,管道可能会重新加载,浪费时间。 (注意:有些处理器可以读入足够多的指令到缓存中,因此不需要读取指令。一个例子是一个小循环。)

      最坏的情况,当你调用 read 函数 1000 次时,你会导致 1000 次重新加载指令流水线。如果您调用一次读取函数,则管道仅重新加载一次。

      数据总线冲突
      数据通过数据总线从硬盘驱动器流向处理器,然后从处理器流向内存。某些平台允许从硬盘驱动器到内存的直接内存访问 (DMA)。在任何一种情况下,都会有多个用户争用数据总线。

      数据总线最有效的用途是发送大块数据。当用户(组件,例如处理器或 DMA)想要使用数据总线时,用户必须等待它变得可用。最坏的情况是,另一个用户正在发送大块,因此延迟很长。当发送 1000 个字节时,一次发送一个,用户必须等待 1000 次,让其他用户放弃使用数据总线的时间。

      图片在市场或餐厅排队(排队)。您需要购买许多物品,但您购买了一件,然后必须返回并再次排队等候。或者您可以像其他购物者一样购买许多商品。哪个更耗时?

      总结
      使用大块进行 I/O 传输的原因有很多。一些原因与物理驱动器有关,其他原因涉及指令流水线、数据缓存和数据总线争用。通过减少数据请求的数量和增加数据大小,也减少了累积时间。一个请求的开销比 1000 个请求少得多。如果开销为 1 毫秒,则一个请求需要 1 毫秒,而 1000 个请求需要 1 秒。

      【讨论】:

        【解决方案5】:

        Protip:使用您的分析器确定实际问题中最重要的瓶颈...

        Q1) 那么,fread 再次以 1 个字节读取文件,那么它与以 1 个字节的方法读取不一样吗?

        手册中是否有任何内容表明一次只能读取一个字节?闪存变得越来越普遍,通常需要您的操作系统一次读取大至 512KB 的块。也许您的操作系统会为您的利益执行缓冲,因此您不必检查全部数量...

        Q2 ) 结果证明fread 仍然需要更少的时间。

        从逻辑上讲,这是一个谬误。没有要求fgetc 在检索字节块时比fread 慢。事实上,优化的编译器很可能会在优化解析后生成相同的机器代码。

        实际上,它也被证明是无效的。大多数证明(例如,您引用的证明)都忽略了 setvbuf(或 C++ 中的 stream.rdbuf()->pubsetbuf)的影响。

        然而,下面的经验证据集成了setvbuf,并且至少在我测试过的每个实现中,都表明fgetc 在读取大量数据时大致与fread 一样快,在一些毫无意义的误差范围内摆动任何一种方式......请多次运行这些测试,如果您找到一个系统,其中一个明显比另一个快,请告诉我。我怀疑你不会。有两个程序可以从这段代码构建:

        gcc -o fread_version -std=c99 file.c
        gcc -o fgetc_version -std=c99 -DUSE_FGETC file.c
        

        两个程序编译完成后,生成一个包含大量字节的test_file,您可以像这样进行测试:

        time cat test_file | fread_version
        time cat test_file | fgetc_version
        

        废话不多说,代码如下:

        #include <assert.h>
        #include <stdio.h>
        
        int main(void) {
            unsigned int criteria[2] = { 0 };
        
        #   ifdef USE_FGETC
            int n = setvbuf(stdin, NULL, _IOFBF, 65536);
            assert(n == 0);
        
            for (;;) {
                int c = fgetc(stdin);
                if (c < 0) {
                    break;
                }
                criteria[c == 'a']++;
            }
        #   else
            char buffer[65536];
            for (;;) {
                size_t size = fread(buffer, 1, sizeof buffer, stdin);
                if (size == 0) {
                    break;
                }
                for (size_t x = 0; x < size; x++) {
                    criteria[buffer[x] == 'a']++;
                }
            }
        #   endif
        
            printf("%u %u\n", criteria[0], criteria[1]);
        
            return 0;
        }
        

        附:您甚至可能已经注意到fgetc 版本比fread 版本更简单;它不需要嵌套循环来遍历字符。这应该是要吸取的教训,在这里:编写代码时要考虑维护,而不是性能。如有必要,您通常可以提供提示(例如 setvbuf)来优化您已使用分析器识别的瓶颈。

        附言您确实使用分析器将其识别为实际问题中的瓶颈,对吧?

        【讨论】:

          猜你喜欢
          • 2013-09-07
          • 2017-04-25
          • 1970-01-01
          • 1970-01-01
          • 1970-01-01
          • 2019-04-26
          • 1970-01-01
          • 2011-05-25
          相关资源
          最近更新 更多