C++标准是否规定iostreams性能差,还是我只是在处理性能差的实现?

212
每次我提到C++标准库iostream的性能较慢时,都会遭到怀疑的浪潮。然而,我有分析器结果显示iostream库代码花费了大量时间(完整编译器优化),并且从iostreams切换到特定于操作系统的I/O API和自定义缓冲区管理确实可以提高一个数量级。
C++标准库做了哪些额外的工作?它是否符合标准?在实践中是否有用?或者一些编译器是否提供了与手动缓冲区管理相竞争的iostreams实现?
基准测试
为了推进事情的发展,我编写了几个简短的程序来测试iostreams内部缓冲:

请注意,ostringstreamstringbuf 版本运行的迭代次数更少,因为它们速度更慢。

在 ideone 上,与 std::copy + back_inserter + std::vector 相比,ostringstream 大约慢了3倍,比 memcpy 到原始缓冲区慢了约15倍。当我将真实应用程序切换到自定义缓冲区时,这种感觉是一致的,这些都是内存缓冲区,因此 iostreams 的速度慢不能归咎于磁盘 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毫秒
  • vector<char>使用普通迭代器: 10.6毫秒
  • vector<char>迭代器和边界检查: 11.4毫秒
  • char[]: 3.7毫秒

在我的笔记本电脑上 (Visual C++ 2010 x86, cl /Ox /EHsc, Windows 7 Ultimate 64-bit, Intel Core i7, 8 GB RAM):

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

使用Profile-Guided Optimization的Visual C++ 2010 x86编译器:cl /Ox /EHsc /GL /clink /ltcg:pgi编译链接,运行,link /ltcg:pgo测量:

  • ostringstream:61.2毫秒,60.5毫秒
  • 使用普通迭代器的vector<char>:1.04毫秒,1.03毫秒

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

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

同样的笔记本电脑,使用Visual C++ 2008 SP1编译器:cl /Ox /EHsc

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

同一台笔记本电脑,使用Visual C++ 2010 64位编译器:

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

编辑:运行两次以查看结果的一致性。在我看来,结果非常一致。

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

编辑:糟糕,发现了一个vector-with-ordinary-iterator中的错误,迭代器没有被提前,因此缓存命中过多。我想知道为什么vector<char>的表现优于char[]。不过这并没有太大的区别,在VC++ 2010下vector<char>仍然比char[]快。

结论

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

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

我发布的最新代码片段“vector<char> simple iterator plus bounds check”不仅实现了这一点,还在传入块不适合时分配了额外的空间并移动了现有数据。正如Clifford指出的那样,在文件I/O类中进行缓冲不必这样做,它只需刷新当前缓冲区并重用它。因此,这应该是输出缓冲的成本上限。这正是制作工作中内存缓冲所需要的。

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


24
你正在逐个写入一百万个字符,想知道为什么比复制到预分配缓冲区慢? - Anon.
23
@Anon: 我正在一次缓冲四百万个字节,每次四个,但是我想知道为什么这样很慢。如果std::ostringstream没有像std::vector那样智能地指数级增加其缓冲区大小,那就是(A)愚蠢的,并且(B)需要考虑I/O性能的人们应该考虑这个问题。无论如何,缓冲区被重复使用,而不是每次都重新分配。std::vector也使用动态增长的缓冲区。我在这里试图公正。 - Ben Voigt
15
您实际上要对哪项任务进行基准测试?如果您没有使用ostringstream的任何格式设置功能,并且希望尽可能快地执行,则应考虑直接使用stringbufostream类应该通过rdbuf()及其虚函数接口将区域设置感知格式设置功能与灵活的缓冲区选择(文件、字符串等)联系在一起。如果您没有进行任何格式化,则该额外的间接层肯定会相对于其他方法显得代价高昂。 - CB Bailey
5
支持真相。我们在输出涉及double类型的日志信息时,从ofstream转为fprintf,获得了数量级的速度提升。在WinXPsp3上使用MSVC 2008。iostreams非常慢。 - KitsuneYMG
6
这是委员会网站上的一些测试:http://www.open-std.org/jtc1/sc22/wg21/docs/D_5.cpp - Johannes Schaub - litb
显示剩余21条评论
4个回答

51
不是针对你的具体问题,而是针对标题:2006年的C++性能技术报告(TR18015)在IOStreams(第68页)中有一个有趣的部分。与您的问题最相关的部分在6.1.2节(“执行速度”)中:
“由于IOStreams处理的某些方面分布在多个者面上,因此似乎标准规定了一种低效的实现。但这不是事实——通过使用某种形式的预处理,可以避免大量工作。如果使用比通常使用的稍微聪明一些的链接器,则可以消除其中一些低效性。这在§6.2.3和§6.2.5中讨论。”
因为该报告是在2006年撰写的,因此人们希望许多建议已经被纳入当前的编译器中,但或许并非如此。
正如您提到的,Facet可能不会出现在write()中(但我不会盲目假设)。那么什么特征呢?在使用GCC编译的ostringstream代码上运行GProf时,可以得到以下分解:因此,大部分的时间都花在了 xsputn 上,最终在进行大量检查和更新光标位置和缓冲区后调用 std::copy()(请参看 c++\bits\streambuf.tcc 以了解详细信息)。
我认为你把重点放在了最坏情况上。如果处理的是相当大的数据块,则执行的所有检查都只占总工作量的一小部分。但是你的代码每次以四个字节移动数据,并产生了所有额外的开销。显然,在实际情况下,人们会避免这样做 —— 请考虑如果在一个1m整数数组上调用 write 而不是1m次调用单个整数时所需的惩罚是多么微不足道。而在实际情况下,人们真正欣赏IOStreams的重要特性,即其内存安全和类型安全设计。这些好处是有代价的,你编写了一个测试,使这些成本主导了执行时间。

4
+1 代表支持分析(我想那是一台 Linux 机器吧?)。然而,我实际上是每次添加四个字节(实际上是 sizeof i,但我测试过的所有编译器都使用了 4 个字节的 int)。我觉得这并不是很不现实,你认为在像 stream << "VAR:" << var.x << "," << var.y << endl; 这样的典型代码中,每次调用 xsputn 传入的块大小是多少? - Ben Voigt
@Ben:窗口XP,MinGW GCC 4.4.0。我理解你提到每次移动4个字节的观点,但1m循环仍然占据主导地位,而你典型的代码示例只调用xsputn两次(在var成员上),最多可能调用5次。显然,使用最少的调用次数将数据写入流中是最好的方法,优化版本的测试是将数据分块,然后写出每个块。 - beldaz
42
“典型”的代码示例只调用xsputn五次,但实际上可能位于一个循环内,以写入包含1000万行的文件。将数据以大块传递给iostream在现实生活中不是常见情况,这与我的基准代码相比有很大差别。如果我必须使用最少的调用来写入缓冲流,那么我自己缓冲的意义又在哪里?而对于二进制数据,我可以选择自行缓冲;但当将数百万个数字写入文本文件时,没有批量选项可供选择,我必须为每个数字调用operator <<。” - Ben Voigt
1
@beldaz:可以通过简单的计算来估计I/O何时开始占主导地位。以目前消费级硬盘的典型平均写入速率90 MB/s为例,刷新4MB缓存需要<45ms(吞吐量,由于操作系统写入缓存,延迟不重要)。如果运行内部循环所需时间超过填充缓存的时间,则CPU将成为限制因素。如果内部循环运行得更快,则I/O将成为限制因素,或者至少还有一些CPU时间可以用来做真正的工作。 - Ben Voigt
5
当然,这并不意味着使用iostreams必然会导致程序变慢。如果I/O只是程序的很小一部分,那么使用性能差的I/O库并不会对整体产生太大影响。但是,不被频繁调用并不等同于良好的性能,在I/O密集型应用中,它确实很重要。 - Ben Voigt
显示剩余2条评论

27

我对Visual Studio用户感到相当失望,他们在这个问题上宁愿要求得到:

  • 在实现ostream的Visual Studio中,sentry对象(标准所需)进入了保护streambuf的临界区域(非必需)。 这似乎不是可选的,因此即使对于单线程使用的本地流,也需要支付线程同步的代价,而其不需要同步。

这会严重影响使用ostringstream格式化消息的代码。 直接使用stringbuf可以避免使用sentry,但是格式化插入运算符不能直接在streambuf上工作。 对于Visual C++ 2010,临界区正在将ostringstream::write的速度降低三倍,相比底层stringbuf::sputn调用。

查看beldaz关于newlib的分析器数据,很明显,gcc的sentry不会像这样做任何疯狂的事情。ostringstream::write在gcc下只需要约50%的时间比stringbuf::sputn长,但是stringbuf本身比VC ++下慢得多。 虽然使用vector<char>进行I/O缓冲仍然比较不利,但两者之间的差距不会像在VC ++下那样大。


这些信息还是最新的吗?据我所知,GCC附带的C++11实现执行了这个“疯狂”的锁。当然,VS2010仍然也这样做。有人能澄清这种行为以及C++11中是否仍然保持“不需要”吗? - mloskot
2
@mloskot:我在sentry上看不到线程安全的要求... "sentry类定义了一个负责执行异常安全前缀和后缀操作的类。"以及一条注释:"sentry构造函数和析构函数还可以执行其他实现相关的操作。"从C++原则“你不为自己不使用的东西付费”可以推断出,C++委员会永远不会批准这样浪费的要求。但是,如果您有关于iostream线程安全性的问题,请随时提问。 - Ben Voigt

7
你看到的问题都在于每次调用write()时所涉及的开销。每增加一层抽象(char[] -> vector -> string -> ostringstream),就会增加几个函数调用/返回和其他杂项操作,如果你调用它一百万次,这些操作就会累加起来。
我修改了ideone上的两个例子,每次写入十个整数。ostringstream的时间从53毫秒降至6毫秒(近10倍改进),而char循环则有所改善(从3.7毫秒降至1.5毫秒)——有用,但只提高了两倍左右。
如果你非常关心性能,那么你需要选择适合该工作的正确工具。ostringstream是有用且灵活的,但以你尝试的方式使用它会有代价。char[]更难处理,但性能提升可能会很大(请记住,gcc可能会为你内联执行memcpys)。
简而言之,ostringstream并没有出错,但越接近底层,代码运行速度就越快。对于某些人来说,汇编仍然有优势。

10
“ostringstream::write()”和“vector::push_back()”有什么区别?如果有的话,应该更快,因为它可以处理一个块而不是四个单独的元素。如果“ostringstream”没有提供任何额外的功能却比“std::vector”慢,那么我会认为它是有问题的。请问这句话怎么翻译? - Ben Voigt
1
相反,正是 vector 需要做的事情,而 ostringstream 不需要做的事情使得在这种情况下 vector 的性能更优。Vector 保证在内存中连续,而 ostringstream 则不一定如此。Vector 是为了提高性能而设计的类之一,而 ostringstream 则不是。 - Dragontamer5788
2
@Ben Voigt:直接使用stringbuf并不能消除所有函数调用,因为stringbuf的公共接口包括基类中的公共非虚函数,然后将其分派到派生类中的受保护虚函数。 - CB Bailey
2
@Charles:在任何一个像样的编译器上,它都应该可以。因为公共函数调用将被内联到编译器已知动态类型的上下文中,它可以消除间接性并甚至内联这些调用。 - Ben Voigt
6
我认为这都是内联模板代码,每个编译单元中都可见。但我猜可能会因实现而异。无论如何,我期望所讨论的调用,公共的sputn函数调用虚拟受保护的xsputn函数,会被内联。即使xsputn没有被内联,编译器也可以在内联sputn时确定所需的精确xsputn覆盖并生成直接调用,而无需通过虚函数表进行调用。 - Ben Voigt
显示剩余2条评论

1
为了获得更好的性能,您必须了解您正在使用的容器的工作原理。在您的char[]数组示例中,所需大小的数组是预先分配的。在您的vector和ostringstream示例中,您强制对象重复分配、重新分配和可能多次复制数据,因为对象增长。
使用std::vector可以轻松解决这个问题,只需像您对char数组所做的那样将向量的大小初始化为最终大小即可;相反,如果将其调整为零,您会不公平地削弱性能!这几乎不是一个公平的比较。
关于ostringstream,预分配空间是不可能的,我建议这是一种不适当的用法。该类具有比简单的char数组更大的实用性,但如果您不需要该实用性,则不要使用它,因为无论如何都会支付开销。相反,它应该用于它擅长的事情——将数据格式化为字符串。C++提供了广泛的容器,而ostringstram是其中最不适合此目的的之一。
在向量和ostringstream的情况下,您可以获得缓冲区溢出的保护,但在char数组中却没有这种保护,而且这种保护并非免费提供的。

1
分配似乎不是ostringstream的问题。他只是在后续迭代中回到零。没有截断。我还尝试了ostringstream.str.reserve(4000000),但没有任何区别。 - Roddy
我认为使用 ostringstream,你可以通过传递一个虚拟字符串来“保留”,即:ostringstream str(string(1000000 * sizeof(int), '\0')); 对于 vectorresize 不会释放任何空间,只有在需要时才会扩展。 - Nim
1
向量..保护免受缓冲区溢出。一个常见的误解 - vector []运算符通常默认情况下不检查边界错误,但是vector.at()会检查。 - Roddy
2
vector<T>::resize(0)通常不会重新分配内存。 - Niki Yoshiuchi
2
@Roddy:不使用operator[],而是使用push_back()(通过back_inserter),这绝对会测试溢出。添加了另一个版本,它不使用push_back - Ben Voigt
显示剩余8条评论

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