C++ STL 容器用作简单缓冲区时速度很慢(我做了一个基准测试)

C++ STL containers are slow when used as simple buffers (I did a benchmark)?

本文关键字:基准测试 一个 STL 简单 速度 缓冲区 C++      更新时间:2023-10-16

我一直认为C++代码在效率方面通常与 C 代码相当,如果不是更好的话(例如:std::sort 由于比较器内联而击败 qsort)。人们也普遍认为,如果需要动态内存缓冲区,std::vector 将是一个合理的首选。

最近,我实施了一个基准测试来获取硬数据,并得到了令人惊讶的结果。基本上,在紧密循环中附加到向量(或字符串)相对较

如果可以使其更快,请启发我!

有关基准测试的血腥细节,请参见下文。

当然,一种选择是使用自定义容器。出于显而易见的原因(代码的一致性加上在同一事物的不兼容表示之间进行转换的开销,例如 std::string 和 custom_string),我迫切希望尽可能长时间地避免这条路线。

关于基准。

有一个输入缓冲区和输出缓冲区。如果输入字节通过验证,则按原样传输到输出,对其进行编码并传输生成的 3 个字节(此处进行某种转义)。正在实现的算法是 UTF-8 验证。

基本上这是代码:

// Templated Sink allows us to play with different methods for building
// the output to estimate the relative efficiency of various approaches
// (ex: a large buffer with no bounds checking vs. std::string).
template <typename Sink>
const unsigned char *
fix_utf8_engine(Sink &sink,
                const unsigned char *i, const unsigned char *end)
{
    while (i < end) {
        // for sinks with limited capacity
        if (!sink.check_capacity())
            return i;
        switch (i[0]) {
            case 0x00 ... 0x7f:
                // 1-byte UTF-8 sequence
                sink.template write<1>(i);
                i += 1;
                continue;
...

我已经实现了fix_utf8的不同变体,包括写入预分配的"无限"缓冲区(无边界检查或增长,基线),生成动态增长的malloc-ed缓冲区(malloc),产生std::string(字符串)和生成std::vector(矢量)的

以下是结果(Ivy Bridge Core i7笔记本电脑,clang-600.0.56 -O3)(基于LLVM 3.5svn)):

             ASCII       Unicode     Unicode     Unicode     Unicode     Unicode     Random                         小全恶 混合 恶 短 恶 长基线 0.01307 0.01816 0.01912 0.01909 0.03104 0.03781 0.06127Malloc 0.01798 0.02068 0.02116 0.02095 0.03918 0.04684 0.06909字符串 0.02791 0.03045 0.02575 0.02520 0.07871 0.11513 0.09580矢量 0.06210 0.04925 0.04017 0.04027 0.10103 0.15159 0.12871

不同的列用于不同类型的随机生成的输入(Unicode 小 - 有限范围,最多 2 个字节的结果 UTF-8,邪恶混合 - 各种损坏的 UTF-8 数据穿插正常数据,邪恶的短/长 - 所有 UTF-8 序列都被截断 1 个字节)。

这些结果清楚地表明,字符串向量变体在至少两个非常重要的用例上要慢得多 - ASCIIUnicode(小)。

任何

改善这些结果的帮助都非常感谢!

是的,你是对的。 像这样使用向量或字符串确实很慢。 基本上,每次追加到容器末尾时,都可能要求容器重新分配新缓冲区,将数据复制到新缓冲区,然后删除旧缓冲区。

您可以通过两种方式改善结果。 首先,如果您知道大小,则可以使用reserve()方法。 其次,如果你使用的是向量,你可以切换到deque,当我对我想要的大小感觉不好时,这就是我使用的。 在此用例中,这将更好地工作。 这是因为 deque 不会重新分配内存 - 它会按页数优雅地增长。 对于这种灵活性(一个间接级别),在访问方面有一个权衡,但由于您现在只是插入,因此它应该不适用。

一条评论声称您保留了足够的资金,但事实并非如此。 你可以写的比你读的多。我仍然会使用与 check_capacity() 中的 malloc 策略类似的增长策略。 这将为您提供最好的苹果与苹果的比较。

这是一个模板版本,它应该正确实现 check_capacity() 并适用于所有 STL 容器(包括 deque)。 使用它代替std_vector_sink和std_string_sink。

// Write output to a container
template < typename CONTAINER >
struct std_sink
{
    CONTAINER &v_;
    std_sink(CONTAINER &v): v_(v) {}
    bool check_capacity() { 
        if (v_.capacity() - v_.size() <= 8) {
            v_.reserve(v_.size() + (v_.size() / 2));
        }
        return true;
    }
    template<size_t n> void write(const unsigned char *p) {
        v_.insert(v_.end(), p, p+n);
    }
    void write_bad(const unsigned char *p) {
        unsigned char esc[] = {
            utf8b_1(*p),
            utf8b_2(*p),
            utf8b_3(*p)
        };
        v_.insert(v_.end(), esc, esc + 3);
    }
};

另外,尝试使用deque:

std_sink< std::deque< unsigned char> > sink(result);

当您了解如何使用它们时,它们会很快。 我的猜测是你不断地调用vector.push_back(value),每次用完内存时都会重新分配一个新的缓冲区(通常每次达到限制时,它的分配都会加倍)。 你想先做一个vector.reserve(reasonableCapacity)。

在我的特定环境中,以下内容有所帮助。

使用 std::string 的原始代码速度很慢,因为许多代码路径以不同的 insert() 调用结尾。插入部分内联导致大量代码膨胀(尽管根本不内联会更糟)。

有效的解决方案是放弃 insert() 并使用指针渲染输出。一旦输出指针赶上输出缓冲区结束指针,我们确定字符串内的输出偏移量,将字符串大小增加固定数量并更新指针。

std::string output;
char *opos; // output pos
char *end_obuf;
while (...) {
   if (opos + 6 > end_obuf) { // 6 bytes produced per iteration or less
      output.resize(s.size() + 128);
      // reinit opos, end_obuf pointers
      ...
   }
   // processing
   ...
}

重要的是永远不要写超过字符串末尾,因为没有合法的方法使该数据成为字符串的一部分(一旦字符串增长,超过末尾的数据就会被删除,至少使用默认分配器)。

因此,我们实际上在这里调整字符串的大小(故意使用 resize() 而不是 reserve())。

按固定量增长是可以的;实际上,当内容线性增长时,标准要求缓冲区呈指数级增长。特定的数量(128)是一种权衡:增长太多是不好的,因为我们要零初始化那么多字节。增长太少也是不好的,因为我们不想太频繁地执行调整大小代码。

总而言之,对 insert() 的许多调用被替换为对 resize() 的单个调用,使生成的代码更加精简。调整大小分支不经常执行,因此在多个循环迭代中分摊成本。

几句结束语:

  1. 将 STL 分配器与 realloc 进行比较确实是不公平的。Realloc 通常可以在不复制的情况下调整缓冲区的大小。 由于 OOP 问题,STL 分配器总是制作一个完整的副本。

    功能请求:如果 STL 默认对 POD 类型使用 realloc 会很好。

    "悲观化"的malloc使结果几乎相同(大约20%的性能原始差异是出于这个原因)。

  2. 有趣的是,字符串缓冲区的动态重新分配是否施加了那么多的性能开销。过度预留加上悲观的malloc不足以缩小差距。在这个特定的问题中,数据处理速度更为重要(注意:这里的字符串大小为8MiB,字符串越小,结果可能会改变。

  3. 按配置文件优化(-fprofile-generate/-fprofile-use)允许持续获得另外5-10%,非常感谢此选项。