【问题标题】:Are two heap allocations more expensive than a call to std::string fill ctor?两个堆分配是否比调用 std::string 填充 ctor 更昂贵?
【发布时间】:2021-12-20 19:58:09
【问题描述】:

我想要一个容量为 131 个字符(或字节)的字符串。我知道实现这一目标的两种简单方法。那么这两个代码块哪个更快更高效呢?

std::string tempMsg( 131, '\0' ); // constructs the string with a 131 byte buffer from the start
tempMsg.clear( ); // clears those '\0' chars to free space for the actual data

tempMsg += "/* some string literals that are appended */";

或者这个:

std::string tempMsg; // default constructs the string with a 16 byte buffer
tempMsg.reserve( 131 ); // reallocates the string to increase the buffer size to 131 bytes??

tempMsg += "/* some string literals that are appended */";

我猜第一种方法只使用 1 个分配,然后将所有这 131 个字节设置为 0('\0'),然后清除字符串(std::string::clear 是 通常是常量根据:https://www.cplusplus.com/reference/string/string/clear/)。 第二种方法使用 2 个分配,但另一方面,它不必将任何内容设置为 '\0'。但我也听说过编译器会在堆栈上为字符串对象分配 16 个字节以用于优化目的。所以第二种方法也可能只使用 1 个堆分配。

那么第一种方法比另一种更快吗?或者还有其他更好的方法吗?

【问题讨论】:

  • 您的分析器会告诉您,但我分享您对预期结果的分析。
  • // default constructs the string with a 16 byte buffer std::string 的初始容量未指定。在您的平台上可能是 16,在另一个平台上可能是 0,在另一个平台上可能是 8。但总的来说,当字符串提供小字符串优化时,我希望初始容量不为零,其中该容量是 SSO 必须使用的存储量。如果一个实现在默认构造期间分配了动态存储,我会感到惊讶。
  • std::string 的默认构造函数标记为noexcept,因此它不可能执行动态内存分配。
  • 查找 SSO(短字符串优化)。通常非常小的字符串不需要动态存储。您的平台上的限制似乎是 15 char 或更少。
  • @digito_evo 也是。 basic_string() noexcept(noexcept(Allocator()))。如果分配器的默认构造函数抛出,它只能抛出。 std::allocator 的默认构造函数没有。

标签: c++ optimization constructor heap-memory stdstring


【解决方案1】:

最准确的答案是视情况而定。最可能的答案是第二个更快或一样快。调用填充 ctor 不仅需要堆分配,还需要填充(根据我的经验,通常转换为 memset)。

clear 除了将第一个指针或大小整数设置为零之外,通常不会对 POD char 做任何事情,因为 char 是一种容易破坏的类型。 clear 通常不涉及循环,除非您使用非平凡的 UDT 创建 std::basic_string。 在几乎每一个标准库实现中,它都是常数时间并且非常便宜。

编辑:重要说明: 我从来没有遇到过这样的标准 lib 实现,或者它让我记忆犹新(很可能,因为我认为我正在变老),但 Viktor Sehl 向我指出了一些非常重要的事情,我在厘米:

请注意,与 std::vector 不同,某些实现中的 std::string::clear() 会释放分配的内存(如果有的话)。 ——

这实际上会使您的第一个版本涉及两个堆分配。但是第二个应该仍然只有一个(与您的想法相反)。

已恢复:

但我也听说编译器会在堆栈上为字符串对象分配 16 个字节以进行优化。所以第二种方法也可能只使用 1 个堆分配。

小缓冲区优化

第一个分配是针对使用它的实现的小缓冲区堆栈优化(技术上并不总是堆栈,但它会避免额外的堆分配)。它不是单独堆分配的,您无法使用填充 ctor 来避免它(填充 ctor 仍将分配小缓冲区)。你可以避免的是在你用你真正想要的东西填充它之前用'\0'填充整个数组,这就是为什么第二个版本可能更快(稍微或不取决于你从循环中调用它的次数)。这是不必要的开销,除非优化器为您消除它,而且根据我的经验,优化器不太可能在无法使用诸如 SSA 进行优化的循环情况下这样做。

我只是在这里投了,因为你的第二个版本的意图也比用一些东西填充一个字符串作为尝试优化(在这种情况下,如果你问我很可能被误导)只是把它扔掉并替换它你真正想要的。第二个至少在意图上更清晰,并且几乎可以肯定在大多数实现中一样快或更快。

关于分析

如果有疑问,我总是建议进行测量,尤其是在你开始尝试有趣的事情之前,比如在你的第一个例子中。如果您在对性能至关重要的领域工作,我无法推荐该分析器。分析器不仅会为您回答这个问题,而且还会教您避免编写像第一个示例中那样的反直觉代码,除非在它产生真正积极影响的地方(在这种情况下,我认为不同之处在于实际上是消极的或中性的)。从我的角度来看,分析器和调试的使用应该是 CS 101 中理想的教学内容。分析器有助于减轻人们优化错误事物的危险倾向,但会适得其反。它们往往非常容易使用;你只需运行它们并让你的代码执行你想要优化的昂贵操作,你就会得到像这样的好结果:

如果小缓冲区优化让你有点困惑,一个简单的说明是这样的:

struct SomeString
{
    // Pre-allocates (always) some memory in advance to avoid additional
    // heap allocs.
    char small_buffer[some_small_fixed_size] = {};

    // Will point to small buffer until string gets large.
    char* ptr = small_buffer;
};

小缓冲区的分配是不可避免的,但它不需要单独调用malloc/new/new[]。并且它不是在堆上与字符串对象本身分开分配的(如果它是在堆上分配的)。因此,您展示的两个示例最多都涉及单个堆分配(除非您的标准库实现是 FUBAR -- edit: 或 Viktor 正在使用的一个) .除非优化器将其消除,否则第一个示例从概念上讲是填充/循环(可以实现为组装中非常有效的内在函数,但仍然是循环/线性时间的东西)。

字符串优化

那么第一种方法比另一种更快吗?或者还有其他更好的方法吗?

您可以编写自己的字符串类型,该类型使用 SBO,例如 256 字节的小缓冲区,通常比任何std::string 优化都要大得多。然后,您可以完全避免为 131 长度的情况分配堆。

template <class Char, size_t SboSize=256>
class TempString
{
private:
    // Stores the small buffer.
    Char sbo[SboSize] = {};

    // Points to the small buffer until num > SboSize.
    Char* ptr = sbo;

   // Stores the length of the string.
   size_t num = 0;

   // Stores the capacity of the string.
   size_t cap = SboSize;

public:
    // Destroys the string.
    ~TempString()
    {
        if (ptr != sbo)
            delete[] ptr;
    }

    // Remaining implementation left to reader. Note that implementing
    // swap requires swapping the contents of the SBO if the strings
    // point to them rather than swapping pointers (swapping is a
    // little bit tricky with SBOs involved, so be wary of that).
};

这将不适合持久存储,因为如果您将一堆字符串持久存储在容器中,它会占用内存(例如:需要 256+ 字节来存储一个包含一个字符的字符串)。尽管您可以传入和传出函数调用,但它非常适合临时字符串。我主要是一名游戏开发人员,因此考虑到我们对具有高图形保真度的实时反馈的要求,在这里推出我们自己的标准 C++ 库的替代品是很正常的。我不会向胆小的人推荐它,而且绝对不会没有分析器。这在我的领域中是一个非常实用且可行的选择,尽管在您的领域中它可能很荒谬。标准库非常好,但它是为全世界的需求量身定制的。如果您可以非常具体地根据您的需要定制代码并生成更适用的代码,那么您通常可以击败它。

实际上,即使带有 SBO 的 std::string 也非常不适合持久存储,而不仅仅是上面的 TempString,因为如果您像 std::unordered_map&lt;std::string, T&gt;std::string 那样存储,则使用 16 字节 SBO 膨胀 sizeof(std::string) 到32 字节或更多,那么您的密钥将需要 32 字节,即使它们在遍历哈希表时仅在单个缓存行中存储一个仅适合两个或更少字符串的字符。这是使用 SBO 的缺点。它们可能会破坏您对作为应用程序状态一部分的持久存储的内存使用。但是它们非常适合那些内存只是以 LIFO alloc/dealloc 模式推送和弹出堆栈的临时人员,这种模式只需要递增和递减堆栈指针。

如果您想从内存的角度优化许多字符串的存储,那么很大程度上取决于您的访问模式和需求。但是,如果您只想构建字典而不需要动态擦除特定字符串,那么一个相当简单的解决方案是这样的:

// Just using a struct for simplicity of illustration:
struct MyStrings
{
    // Stores all the characters for all the null-terminated strings.
    std::vector<char> buffer;

    // Stores the starting index into the buffer for the nth string.
    std::vector<std::size_t> string_start;

    // Inserts a null-terminated string to the buffer.
    void insert(const std::string_view str)
    {
        string_start.push_back(buffer.size());
        buffer.insert(buffer.end(), str.begin(), str.end());
        buffer.push_back('\0');
    }

    // Returns the nth null-terminated string.
    std::string_view operator[](int32_t n) const
    {
        return {buffer.data() + string_start[n]};
    }
};

如果您将大量重复字符串存储在关联容器中或需要快速搜索可以提前查找的字符串,则另一个非常有用的常见解决方案是使用字符串实习。上述解决方案也可以结合起来实现一种有效的方式来存储所有的实习字符串。然后,您可以存储轻量级索引或指向您的实习字符串的指针,并立即比较它们是否相等,例如,不涉及任何循环,并存储许多对字符串的重复引用,这些引用仅花费整数或指针的大小。

【讨论】:

猜你喜欢
  • 2010-10-12
  • 1970-01-01
  • 2021-01-10
  • 1970-01-01
  • 2012-12-24
  • 1970-01-01
  • 1970-01-01
  • 2011-02-19
  • 1970-01-01
相关资源
最近更新 更多