c++标准是否要求iostreams的性能很差,或者我只是在处理一个糟糕的实现?

每当我提到c++标准库iostreams的缓慢性能时,我都会遇到一波难以置信的声音。然而,我的分析结果显示,在iostream库代码上花费了大量时间(完整的编译器优化),从iostream切换到特定于操作系统的I/O api和自定义缓冲区管理确实带来了一个数量级的改进。

c++标准库做了哪些额外的工作,它是标准所要求的吗?它在实践中有用吗?或者一些编译器提供的iostreams实现可以与手动缓冲区管理相竞争?

基准

为了让事情继续下去,我写了几个简短的程序来练习iostreams内部缓冲:

注意,ostringstreamstringbuf版本运行的迭代次数更少,因为它们要慢得多。

在ideone上,ostringstreamstd:copy + back_inserter + std::vector慢大约3倍,比memcpy慢大约15倍。当我将实际应用程序切换到自定义缓冲时,这感觉与前后分析一致。

这些都是内存中的缓冲区,所以iostream的慢不能归咎于磁盘I/O慢,太多的刷新,与stdio的同步,或者人们用来解释观察到的c++标准库iostream慢的任何其他事情。

如果能看到其他系统上的基准测试,以及对常见实现(如gcc的libc++、Visual c++、Intel c++)所做的事情的评论,以及标准要求的开销有多少,那就太好了。

测试的基本原理

许多人已经正确地指出,iostreams更常用于格式化输出。然而,它们也是c++标准为二进制文件访问提供的唯一现代API。但是,在内部缓冲上进行性能测试的真正原因适用于典型的格式化I/O:如果iostreams不能为磁盘控制器提供原始数据,那么当它们还负责格式化时,它们怎么可能跟上呢?

基准时间

所有这些都是外部(k)循环的每次迭代。

在ideone (gcc-4.3.4,未知的操作系统和硬件):

  • ostringstream: 53毫秒
  • stringbuf: 27毫秒
  • vector<char>back_inserter: 17.6 ms
  • vector<char>使用普通迭代器:10.6 ms
  • vector<char>迭代器和边界检查:11.4 ms
  • char[]: 3.7 ms

在我的笔记本电脑上(Visual c++ 2010 x86, cl /Ox /EHsc, Windows 7终极64位,英特尔酷睿i7, 8 GB RAM):

  • ostringstream: 73.4毫秒,71.6毫秒
  • stringbuf: 21.7 ms, 21.3 ms
  • vector<char>back_inserter: 34.6 ms, 34.4 ms
  • vector<char>与普通迭代器:1.10 ms, 1.04 ms
  • vector<char>迭代器和边界检查:1.11 ms, 0.87 ms, 1.12 ms, 0.89 ms, 1.02 ms, 1.14 ms
  • char[]: 1.48 ms, 1.57 ms

Visual c++ 2010 x86,使用Profile-Guided Optimization cl /Ox /EHsc /GL /clink /ltcg:pgi, run, link /ltcg:pgo, measure:

  • ostringstream: 61.2 ms, 60.5 ms
  • vector<char>与普通迭代器:1.04 ms, 1.03 ms

同样的笔记本电脑,同样的操作系统,使用cygwin gcc 4.3.4 g++ -O3:

  • ostringstream: 62.7 ms, 60.5 ms
  • stringbuf: 44.4 ms, 44.5 ms
  • vector<char>back_inserter: 13.5 ms, 13.6 ms
  • vector<char>与普通迭代器:4.1 ms, 3.9 ms
  • vector<char>迭代器和边界检查:4.0 ms, 4.0 ms
  • char[]: 3.57 ms, 3.75 ms

同一台笔记本电脑,Visual c++ 2008 SP1, cl /Ox /EHsc:

  • ostringstream: 88.7 ms, 87.6 ms
  • stringbuf: 23.3 ms, 23.4 ms
  • vector<char>back_inserter: 26.1 ms, 24.5 ms
  • vector<char>与普通迭代器:3.13 ms, 2.48 ms
  • vector<char>迭代器和边界检查:2.97 ms, 2.53 ms
  • char[]: 1.52 ms, 1.25 ms

同样的笔记本电脑,Visual c++ 2010 64位编译器:

  • ostringstream: 48.6 ms, 45.0 ms
  • stringbuf: 16.2 ms, 16.0 ms
  • vector<char>back_inserter: 26.3 ms, 26.5 ms
  • vector<char>使用普通迭代器:0.87 ms, 0.89 ms
  • vector<char>迭代器和边界检查:0.99 ms, 0.99 ms
  • char[]: 1.25 ms, 1.24 ms

编辑:全部运行两次,看看结果是否一致。在我看来相当稳定。

注意:在我的笔记本电脑上,因为我可以腾出比ideone更多的CPU时间,所以我将所有方法的迭代次数设置为1000。这意味着ostringstreamvector重新分配,只发生在第一次传递时,应该对最终结果没有什么影响。

编辑:哎呀,在vector-with-ordinary-iterator中发现了一个错误,迭代器没有被高级化,因此有太多的缓存命中。我想知道vector<char>是如何胜过char[]的。不过这并没有太大的区别,在vc++ 2010下,vector<char>仍然比char[]快。

结论

每次追加数据时,缓冲输出流需要三个步骤:

  • 检查输入块是否适合可用的缓冲空间。
  • 复制传入块。
  • 更新数据结束指针。

我发布的最新代码片段“vector<char> simple iterator +边界检查”不仅可以做到这一点,它还可以分配额外的空间,并在传入块不适合时移动现有数据。正如Clifford所指出的,在文件I/O类中缓冲不需要这样做,它只需要刷新当前缓冲区并重用它。所以这应该是缓冲输出成本的上限。这正是创建一个工作的内存缓冲区所需要的。

那么为什么stringbuf在ideone上慢2.5倍,而在我测试时至少慢10倍?在这个简单的微基准测试中没有多态地使用它,所以这不能解释它。

24928 次浏览

您所看到的问题都存在于每次调用write()的开销中。您添加的每个抽象级别(char[] -> vector -> string -> ostringstream)都会添加更多的函数调用/返回和其他一些家务杂事,如果您调用它一百万次的话,这些杂事就会累加起来。

我修改了ideone上的两个例子,一次写10个int。ostringstream时间从53毫秒提高到6毫秒(几乎提高了10倍),而char循环时间提高了(3.7到1.5)——很有用,但只是提高了两倍。

如果您非常关心性能,那么您需要为工作选择合适的工具。Ostringstream很有用,也很灵活,但是按照您试图使用的方式使用它是有代价的。Char[]是比较困难的工作,但是性能收益可能很大(请记住,GCC可能也会为您内联memcpys)。

简而言之,ostringstream没有损坏,但是越接近金属,代码运行就越快。对某些人来说,汇编器仍然有优势。

为了获得更好的性能,您必须了解所使用的容器是如何工作的。在char[]数组示例中,所需大小的数组已提前分配。在vector和ostringstream的例子中,随着对象的增长,你强迫对象重复分配和重新分配数据,可能还会多次复制数据。

对于std::vector,这很容易通过初始化vector的大小到最终大小来解决,就像你做char数组一样;相反,您通过将大小调整为零而相当不公平地削弱了性能!这很难说是一个公平的比较。

对于ostringstream,预分配空间是不可能的,我认为这是一种不适当的使用。这个类比简单的char数组有更大的效用,但是如果您不需要这个效用,那么就不要使用它,因为在任何情况下您都会支付开销。相反,它应该用于它所擅长的地方——将数据格式化为字符串。c++提供了广泛的容器,ostringstream是最不适合用于此目的的容器之一。

在vector和ostringstream的情况下,你得到了防止缓冲区溢出的保护,而在char数组中你得不到这种保护,而且这种保护不是免费的。

没有回答你的问题的细节那么多的标题:2006 c++性能技术报告有一个关于IOStreams的有趣的部分(第68页)。与你的问题最相关的是第6.1.2节(“执行速度”):

因为IOStreams处理的某些方面是 它分布在多个方面 看来标准要求 低效率的实现。但这 难道不是这样吗——用某种形式 对于预处理,大部分工作都可以 被避免的。稍微聪明一点 链接器比通常使用的,它是 有可能移除其中一些 低效率。这在 §6.2.3和§6.2.5.

由于该报告是在2006年撰写的,人们会希望其中的许多建议已经被纳入目前的编纂者中,但情况可能并非如此。

正如你提到的,facet可能不会出现在write()中(但我不会盲目地假设)。那么特点是什么呢?在GCC编译的ostringstream代码上运行GProf会得到以下分解:

  • std::basic_streambuf<char>::xsputn(char const*, int)占44.23%
  • std::ostream::write(char const*, int)为34.62%
  • 12.50%在main
  • std::ostream::sentry::sentry(std::ostream&)中的6.73%
  • std::string::_M_replace_safe(unsigned int, unsigned int, char const*, unsigned int)中的0.96%
  • std::basic_ostringstream<char>::basic_ostringstream(std::_Ios_Openmode)中的0.96%
  • std::fpos<int>::fpos(long long)中的0.00%

所以大部分时间都花在xsputn上,它在大量检查和更新游标位置和缓冲区后最终调用std::copy()(详细信息请参阅c++\bits\streambuf.tcc)。

我认为你只考虑了最坏的情况。如果您正在处理相当大的数据块,那么所执行的所有检查将是所做的全部工作的一小部分。但是您的代码每次只移动四个字节的数据,并且每次都会产生所有额外的成本。显然,在现实生活中,人们会避免这样做——考虑一下,如果write在一个包含1m个int的数组上调用,而不是在一个int上调用1m次,那么惩罚将是多么微不足道。在现实生活中,人们会真正欣赏IOStreams的重要特性,即它的内存安全和类型安全设计。这样的好处是有代价的,您编写的测试使得这些成本占据了执行时间。

我对Visual Studio的用户很失望,他们在这个问题上很有发言权:

  • ostream的Visual Studio实现中,sentry对象(标准要求的对象)进入临界区,保护streambuf(不要求的对象)。这似乎不是可选的,所以即使是单个线程使用的本地流,也需要支付线程同步的成本,因为它不需要同步。

这严重损害了使用ostringstream格式化消息的代码。直接使用stringbuf可以避免使用sentry,但是格式化的插入操作符不能直接作用于__abc3。对于Visual c++ 2010,与底层的stringbuf::sputn调用相比,临界区将使ostringstream::write减慢三倍。

查看Beldaz在newlib上的分析器数据,似乎很清楚gcc的sentry并没有做这样疯狂的事情。gcc下的ostringstream::write只比stringbuf::sputn长50%,但stringbuf本身比vc++下慢得多。并且两者与使用vector<char>进行I/O缓冲相比仍然非常不利,尽管与vc++下不同。