【问题标题】:Why is buffered writing to a fmemopen()'ed FILE faster than unbuffered?为什么缓冲写入 fmemopen()'ed 文件比无缓冲更快?
【发布时间】:2016-08-11 13:30:50
【问题描述】:

当然,磁盘上文件的缓冲 I/O 比无缓冲要快。但是为什么即使写入内存缓冲区也有好处呢?

以下基准代码示例是使用 gcc 5.40 使用优化选项 -O3 编译的,与 glibc 2.24 相关联。 (请注意,常见的 glibc 2.23 存在有关 fmemopen() 的错误。)

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

int main() {
  size_t bufsz=65536;
  char buf[bufsz];
  FILE *f;
  int r;

  f=fmemopen(buf,bufsz,"w");
  assert(f!=NULL);

  // setbuf(f,NULL);   // UNCOMMENT TO GET THE UNBUFFERED VERSION

  for(int j=0; j<1024; ++j) {
    for(uint32_t i=0; i<bufsz/sizeof(i); ++i) {
      r=fwrite(&i,sizeof(i),1,f);
      assert(r==1);
    }
    rewind(f);
  }

  r=fclose(f);
  assert(r==0);
}

缓冲版本的结果:

$ gcc -O3 -I glibc-2.24/include/ -L glibc-2.24/lib  test-buffered.c 
$ time LD_LIBRARY_PATH=glibc-2.24/lib ./a.out
real    0m1.137s
user    0m1.132s
sys     0m0.000s

无缓冲版本的结果

$ gcc -O3 -I glibc-2.24/include/ -L glibc-2.24/lib  test-unbuffered.c 
$ time LD_LIBRARY_PATH=glibc-2.24/lib ./a.out
real    0m2.266s
user    0m2.256s
sys     0m0.000s

【问题讨论】:

  • 作为一个有根据的猜测:写入无缓冲可能会导致更多的系统调用。
  • @DanielJour 另一方面,写入内存缓冲区(这是fmemopen 创建的)根本不会导致任何系统调用,这两种情况下的零系统时间都证实了这一点.
  • 你能编译和链接到 muscl 吗?我能够通读它的实现,它没有对 fmemopen 文件的单一系统调用。
  • @ralfg c 太多了。我的意思是musl-libc.org
  • @Daniel:我在 Ubuntu 16.04 上安装了 musl、musl-dev 和 musl-tools,并使用 musl-gcc 进行了编译。结果:0m0.848s(缓冲)反之亦然 0m0.664s(无缓冲)。这是我一开始所期望的比率。因此:一个很大的提示,glibc 有一些(对于 memfiles 不必要的)系统调用:(而且,关于 memfiles,musl 胜过 glibc 相当重要。感谢这个提示!

标签: c stdio


【解决方案1】:

缓冲版本性能记录:

Samples: 19K of event 'cycles', Event count (approx.): 14986217099
Overhead  Command  Shared Object      Symbol
  48.56%  fwrite   libc-2.17.so       [.] _IO_fwrite
  27.79%  fwrite   libc-2.17.so       [.] _IO_file_xsputn@@GLIBC_2.2.5
  11.80%  fwrite   fwrite             [.] main
   9.10%  fwrite   libc-2.17.so       [.] __GI___mempcpy
   1.56%  fwrite   libc-2.17.so       [.] __memcpy_sse2
   0.19%  fwrite   fwrite             [.] fwrite@plt
   0.19%  fwrite   [kernel.kallsyms]  [k] native_write_msr_safe
   0.10%  fwrite   [kernel.kallsyms]  [k] apic_timer_interrupt
   0.06%  fwrite   libc-2.17.so       [.] fmemopen_write
   0.04%  fwrite   libc-2.17.so       [.] _IO_cookie_write
   0.04%  fwrite   libc-2.17.so       [.] _IO_file_overflow@@GLIBC_2.2.5
   0.03%  fwrite   libc-2.17.so       [.] _IO_do_write@@GLIBC_2.2.5
   0.03%  fwrite   [kernel.kallsyms]  [k] rb_next
   0.03%  fwrite   libc-2.17.so       [.] _IO_default_xsputn
   0.03%  fwrite   [kernel.kallsyms]  [k] rcu_check_callbacks

无缓冲版本性能记录:

Samples: 35K of event 'cycles', Event count (approx.): 26769401637
Overhead  Command  Shared Object      Symbol
  33.36%  fwrite   libc-2.17.so       [.] _IO_file_xsputn@@GLIBC_2.2.5
  25.58%  fwrite   libc-2.17.so       [.] _IO_fwrite
  12.23%  fwrite   libc-2.17.so       [.] fmemopen_write
   6.09%  fwrite   libc-2.17.so       [.] __memcpy_sse2
   5.94%  fwrite   libc-2.17.so       [.] _IO_file_overflow@@GLIBC_2.2.5
   5.39%  fwrite   libc-2.17.so       [.] _IO_cookie_write
   5.08%  fwrite   fwrite             [.] main
   4.69%  fwrite   libc-2.17.so       [.] _IO_do_write@@GLIBC_2.2.5
   0.59%  fwrite   fwrite             [.] fwrite@plt
   0.33%  fwrite   [kernel.kallsyms]  [k] native_write_msr_safe
   0.18%  fwrite   [kernel.kallsyms]  [k] apic_timer_interrupt
   0.04%  fwrite   [kernel.kallsyms]  [k] timerqueue_add
   0.03%  fwrite   [kernel.kallsyms]  [k] rcu_check_callbacks
   0.03%  fwrite   [kernel.kallsyms]  [k] ktime_get_update_offsets_now
   0.03%  fwrite   [kernel.kallsyms]  [k] trigger_load_balance

差异:

# Baseline    Delta  Shared Object      Symbol                            
# ........  .......  .................  ..................................
#
    48.56%  -22.98%  libc-2.17.so       [.] _IO_fwrite                    
    27.79%   +5.57%  libc-2.17.so       [.] _IO_file_xsputn@@GLIBC_2.2.5  
    11.80%   -6.72%  fwrite             [.] main                          
     9.10%           libc-2.17.so       [.] __GI___mempcpy                
     1.56%   +4.54%  libc-2.17.so       [.] __memcpy_sse2                 
     0.19%   +0.40%  fwrite             [.] fwrite@plt                    
     0.19%   +0.14%  [kernel.kallsyms]  [k] native_write_msr_safe         
     0.10%   +0.08%  [kernel.kallsyms]  [k] apic_timer_interrupt          
     0.06%  +12.16%  libc-2.17.so       [.] fmemopen_write                
     0.04%   +5.35%  libc-2.17.so       [.] _IO_cookie_write              
     0.04%   +5.91%  libc-2.17.so       [.] _IO_file_overflow@@GLIBC_2.2.5
     0.03%   +4.65%  libc-2.17.so       [.] _IO_do_write@@GLIBC_2.2.5     
     0.03%   -0.01%  [kernel.kallsyms]  [k] rb_next                       
     0.03%           libc-2.17.so       [.] _IO_default_xsputn            
     0.03%   +0.00%  [kernel.kallsyms]  [k] rcu_check_callbacks           
     0.02%   -0.01%  [kernel.kallsyms]  [k] run_timer_softirq             
     0.02%   -0.01%  [kernel.kallsyms]  [k] cpuacct_account_field         
     0.02%   -0.00%  [kernel.kallsyms]  [k] __hrtimer_run_queues          
     0.02%   +0.01%  [kernel.kallsyms]  [k] ktime_get_update_offsets_now  

深入源码后发现,fwrite,也就是iofwrite.c中的_IO_fwrite,只是实际写函数_IO_sputn的一个封装函数。 并且还发现:

libioP.h:#define _IO_XSPUTN(FP, DATA, N) JUMP2 (__xsputn, FP, DATA, N)
libioP.h:#define _IO_sputn(__fp, __s, __n) _IO_XSPUTN (__fp, __s, __n)

由于__xsputn函数实际上是_IO_file_xsputn,可以找到如下:

fileops.c:  JUMP_INIT(xsputn, _IO_file_xsputn),
fileops.c:# define _IO_new_file_xsputn _IO_file_xsputn
fileops.c:versioned_symbol (libc, _IO_new_file_xsputn, _IO_file_xsputn, GLIBC_2_1);

最后进入fileops.c中的_IO_new_file_xsputn函数,相关部分代码如下:

/* Try to maintain alignment: write a whole number of blocks.  */
      block_size = f->_IO_buf_end - f->_IO_buf_base;
      do_write = to_do - (block_size >= 128 ? to_do % block_size : 0);

      if (do_write)
    {
      count = new_do_write (f, s, do_write);
      to_do -= count;
      if (count < do_write)
        return n - to_do;
    }

      /* Now write out the remainder.  Normally, this will fit in the
     buffer, but it's somewhat messier for line-buffered files,
     so we let _IO_default_xsputn handle the general case. */
      if (to_do)
    to_do -= _IO_default_xsputn (f, s+do_write, to_do);

在 RHEL 7.2 上,如果启用了缓冲区,block_size 等于 8192,否则等于 1。

所以有以下几种情况:

  • 案例 1:启用缓冲区

    do_write = to_do - (to_do % block_size) = to_do - (to_do % 8192)

在我们的例子中, to_do = sizeof(uint32) 所以do_write = 0,并会调用_IO_default_xsputn函数。

  • 案例 2:没有缓冲区

new_do_write 函数,之后,to_do 为零。 而new_do_write 只是对_IO_SYSWRITE 的包装调用

libioP.h:#define _IO_SYSWRITE(FP, DATA, LEN) JUMP2 (__write, FP, DATA, LEN)

正如我们所见,_IO_SYSWRITE 实际上是 fmemopen_write 调用。 因此,性能差异是由fmemopen_write 调用引起的。 之前的表现记录证明了这一点。

最后,这个问题很好,我对它很感兴趣,它帮助我学习了一些表面下的IO功能。有关其他平台的更多信息,请参阅https://oxnz.github.io/2016/08/11/fwrite-perf-issue/

【讨论】:

  • 分析得很好,到目前为止。谢谢。您如何获得绩效记录?
  • 很高兴它有帮助。对于性能记录,您需要perf 包。然后假设您的代码编译为名为@9​​87654347@ 的东西,因此您只需调用perf record ./fwrite,这将指示性能将fwrite 的运行统计信息采样到名为perf.data 的文件中,然后调用perf report查看它。
  • @ralfg:您还可以使用 valgrind 套件,它提供了 callgrind,一个性能分析器。然后,您可以通过 kcachegrind 等程序获得可视化(全面)报告。
  • 确实如此。使用哪个分析器取决于您的目的。 perf 命令使用 sample 来收集数据,所以它不是那么准确,但更轻量级。所以我建议先使用perf,以便对性能有一个全面的了解。然后使用一些更准确的分析器,如callgrind 来解决瓶颈。
【解决方案2】:

感谢大家迄今为止的帮助。

我检查了 glibc 2.24 的库源代码,似乎在每次刷新时添加 0 字节的附加逻辑是造成时间开销的原因。另请参阅手册页:

当一个已打开写入的流被刷新时 (fflush(3)) 或关闭(fclose(3)),一个空字节被写入末尾 有空间就缓冲。

在无缓冲模式下,这个 Null-Byte 被添加到每个 fwrite() 之后,只是为了被下一个 fwrite() 覆盖。

我复制了 fmemopen_write() 的库源代码,供那些也想知道这种奇怪行为的人使用...

static ssize_t
fmemopen_write (void *cookie, const char *b, size_t s)
{
  fmemopen_cookie_t *c = (fmemopen_cookie_t *) cookie;;
  _IO_off64_t pos = c->append ? c->maxpos : c->pos;
  int addnullc = (s == 0 || b[s - 1] != '\0');

  if (pos + s > c->size)
    {
      if ((size_t) (c->pos + addnullc) >= c->size)
    {
      __set_errno (ENOSPC);
      return 0;
    }
      s = c->size - pos;
    }

  memcpy (&(c->buffer[pos]), b, s);

  c->pos = pos + s;
  if ((size_t) c->pos > c->maxpos)
    {
      c->maxpos = c->pos;
      if (c->maxpos < c->size && addnullc)
    c->buffer[c->maxpos] = '\0';
      /* A null byte is written in a stream open for update iff it fits. */
      else if (c->append == 0 && addnullc != 0)
    c->buffer[c->size-1] = '\0';
    }

  return s;
}

【讨论】:

  • 嗯,还在想。我所描述的,只影响我的基准程序中 j 的第一个循环。在此之后,我倒带,但文件保持其大小(即 maxpos 标记 EOF 在源代码中保留一个大数字)。因此,在循环 j=1 到 1024 中不会发生空字节的添加。还有其他想法吗?
  • 我对此表示怀疑。您显示的函数肯定不是直接调用,而是通过系统调用,然后将控制权返回给所述函数。
【解决方案3】:

当调用一个库时,代码的优化级别,不受代码影响,是恒定的。

这就是为什么更改写入大小不会影响测试限制内的比率的原因。 (如果写入大小倾向于您的数据大小,那么您的代码将占主导地位。

调用 fwrite 的开销将决定是否刷新数据。

虽然我不确定内存流的 fwrite 实现,但如果调用接近内核,那么操作系统功能上的syscall 或安全门可能会导致成本占主导地位。这就是为什么写入数据最适合底层存储的原因。

根据经验,我发现文件系统可以很好地处理 8kb 块。我会考虑 4kb 用于内存系统 - 因为这是处理器页面边界的大小。

【讨论】:

    猜你喜欢
    • 2015-07-05
    • 1970-01-01
    • 1970-01-01
    • 2019-03-09
    • 2018-10-03
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    相关资源
    最近更新 更多