自定义 C++ 分配器在调试中的 GCC 中太慢了。有解决办法吗?

Custom C++ allocator far too slow in GCC in debug. is there a fix for this?

我正在为自定义分配器的性能而苦苦挣扎。 我的问题是关于调试版本。

通常我不介意只有一点点下降。但目前我正在以 4fps 的速度播放某些东西,而没有自定义分配器则以 60fps 的速度播放(并且可能会更快)。这使得软件开发变得更加困难。

我一直坚持到...基本上继承了标准分配器

请查看来自 'quick-bench.com' 的以下结果 https://quick-bench.com/q/ep3uyYNK6rh_6f8AGAP0zIAflAA

这是一张图片:

蓝条就是:

int main() {
    std::vector<uint8_t, std::vector<uint8_t>::allocator_type> buffer;
    buffer.reserve(numBytes);
    buffer.resize(numBytes);
    return 0;
}

黄色条:

template<typename T>
class CustomAllocatorType : public std::vector<uint8_t>::allocator_type {};

int main() {
    std::vector<uint8_t, CustomAllocatorType<uint8_t>> buffer;
    buffer.reserve(numBytes);
    buffer.resize(numBytes);
    return 0;
}

封装自定义分配器:

#pragma GCC push_options
#pragma GCC optimize ("-O3")
// ....
#pragma GCC pop_options

没有任何效果。我想我需要为矢量实例本身执行此操作,但我不想走那么远...

有人知道这个的解决方案吗?

性能下降的原因

如果分配器是 std::allocator,gcc 的 libstdc++ 会使用某些性能改进。您的 CustomAllocatorType 是与 std::allocator 不同的类型,这意味着优化被禁用。请注意,我 不是 谈论编译器优化,而是 gcc 的 C++ 标准库实现实现了专门针对 std::allocator 的重载或特化。 要命名与您的示例代码相关的示例,std::vector::resize() internally calls __uninitialized_default_n_a() which has a special overload for std::allocator. The special overload bypasses the allocator entirely. If you use CustomAllocatorType, the generic version is used which calls the allocator for every single element. This costs a lot of time. Another function with a special definition and which is relevant to your simple code example is _Destroy().

换句话说,gcc 对 C++ 标准库的实现采取了一些措施来确保在已知安全的情况下生成最佳代码。无论编译器优化如何,这都有效。 如果采用 non-optimized 代码路径并启用编译器优化(例如 -O3),编译器通常能够识别 non-optimal 代码中的模式(例如初始化连续的琐碎元素)并且可以优化所有内容,以便您最终得到相同的指令(或多或少)。

C++20 与 C++17 以及您的 CustomAllocatorType 损坏的原因

如评论中所述,使用 CustomAllocatorType 时性能下降仅出现在 C++20 中,但不会出现在 C++17 中。 要理解原因,请注意 gcc 的 std::vector 实现 而不是 使用声明 std::vector<T,Allocator> 中的 Allocator 作为分配器,即在您的情况下 CustomAllocatorType.相反,它使用 std::allocator_traits<T>::rebind_alloc<T>(有关更多信息,请参阅 here and here). Also see e.g. this post about rebind

由于您没有定义特化 std::allocator_traits<CustomAllocatorType>,它使用通用的。标准says

rebind_alloc<T>: Alloc::rebind<T>::other if present, otherwise Alloc<T, Args> if this Alloc is Alloc<U, Args>

即如果可能,通用的尝试委托给您的分配器。现在,您的分配器 CustomAllocatorType 继承自 std::allocator。这是 C++17 和 C++20 之间的重要区别:std::allocator::rebind 在 C++20 中是 removed。因此:

  • C++17:CustomAllocatorType::rebind 被继承并定义为 std::allocator。因此,std::allocator_traits<CustomAllocatorType>::rebind_alloc,意味着 std::vector 实际上最终使用 std::allocator 而不是 CustomAllocatorType。如果你在 std::vector 构造函数中传入一个 CustomAllocatorType 实例,你最终会拼接
  • C++20: CustomAllocatorType::rebind 定义。因此,std::allocator_traits<CustomAllocatorType>::rebind_allocCustomAllocatorTypestd::vector 最终使用 CustomAllocatorType.

因此 C++17 版本使用 std::allocator,因此享有上述基于库的优化,而 C++20 版本则没有。

您的代码根本不正确,或者至少是 C++17 版本。 std::vector 在 C++17 中根本不使用你的分配器。您还可以注意到,如果您尝试在您的示例中调用 buffer.get_allocator(),它将无法在 C++17 中编译,因为它会尝试将 std::allocator(在内部使用)转换为 CustomAllocatorType.

我认为解决这个问题的正确方法是定义 CustomAllocatorType::rebind 而不是专门化 std::allocator_traits(参见 here and here),像这样:

template<typename T>
class CustomAllocatorType: public std::allocator<T> 
{
  template< class U > struct rebind {
    typedef CustomAllocatorType<U> other;
  };
};

当然,这样做意味着C++17版本在调试时会很慢,但实际上可以工作。

我认为这也再次说明了从 C++ 标准库类型继承通常不是一个好主意的一般规则。如果CustomAllocatorType不是继承自std::allocator,问题一开始就不会出现(另外,因为你需要考虑如何正确设置元素)。

提高性能

假设分配器针对 C++17 是固定的,或者您使用 C++20,您在调试中会得到糟糕的性能,因为库实现使用上述函数的通用版本来填充和销毁数据。不幸的是,所有这些都是库的实现细节,这意味着没有很好的标准方法来强制生成好的代码。

Hacky 解决方案

在您的简单示例中起作用的黑客攻击(并且可能只在那里!)将定义相关函数的自定义重载,例如:

#include <bits/stl_uninitialized.h>
#include <cstdint>
#include <cstdlib>

// Must be defined BEFORE including <vector>!
namespace std{
  template<typename _ForwardIterator, typename _Size, typename _Tp>
  inline _ForwardIterator
  __uninitialized_default_n_a(_ForwardIterator __first, _Size __n, CustomAllocatorType<_Tp>&)
  { return std::__uninitialized_default_n(__first, __n); }


  template<typename _ForwardIterator, typename _Tp>
  _GLIBCXX20_CONSTEXPR inline void
  _Destroy(_ForwardIterator __first, _ForwardIterator __last, CustomAllocatorType<_Tp>&) {
    _Destroy(__first, __last);
  }
}

这些是从 gcc 的 std::allocator 重载中复制和粘贴的(here and here), but overloaded for CustomAllocatorType. More special overloads would be required in real applications (e.g. for is_copy_constructible and is_move_constructible or __relocate_a_1, no idea how many more). Defining the above two functions before the include of <vector> leads to decent performance in debug for your minimal example. At least it does so for me locally using gcc 11.2. It does not work on quick bench because quick bench force-includes benchmark/benchmark.h before any of your code, and which in turn includes <vector>(也比较下一个要点)。

这个黑客在多个层面上都很糟糕:

  • 绝对是non-standard。它仅适用于 stdlibc++,并且可能会在库版本的任何升级或降级时中断。
  • 您还需要确保在包含 <vector> header 之前 定义了重载,否则它们将不会被拾取。原因是calls to std::__uninitialized_default_n_a() are qualified, i.e. are std::__uninitialized_default_n_a(arguments) rather than __uninitialized_default_n_a(arguments), meaning that overloads after the definition of std::vector are not found (cf. e.g. this post or this one)。如上所述,这就是 hack 在 quick bench 上失败的原因。另外,如果你在某些地方搞砸了,你可能会违反 one-definition-rule(这可能会导致更多的怪异)。
  • 示例 hack 假设分配和释放内存不需要使用 CustomAllocatorType,就像 std::allocator 一样。我非常怀疑这是否适用于您真正的 CustomAllocatorType 实施。但也许你实际上可以实现例如__uninitialized_default_n_a() 通过在您的分配器上调用适当的函数,更有效地为您的 CustomAllocatorType

我不推荐这样做。但根据用例,它可能是一个 v可行的解决方案。

启用-Og

在使用 -Og 编译 everything 时,使用 gcc 确实获得了更好的性能。它尝试在不过多干扰调试体验的情况下执行一些优化。在您的简单示例中,与 std::allocator 版本相比, 160x slower to 5x slower 的性能有所提高。因此,如果您不能更改编译器,我认为这可能是最好的方法。

使用 clang

切换到 clang(没有任何优化标志)似乎在一定程度上提高了性能。对于 libstdc++,自定义分配器版本“仅”90x slower。 令人惊讶的是,与 libc++ quick bench 报告的性能大致相同。不幸的是,我无法在本地重现:libc++ 也需要很长时间。不知道为什么结果在本地和快速工作台上会有所不同。

但我可以重现,clang 使用 -Og 进行优化比 gcc 好得多,并且与自定义分配器的性能大致相同。这与 libstdc++ and libc++.

都成立

所以我的建议是使用 clang,可能与 libc++ 一起使用,并使用 -Og.

其他想法

在本地启用优化(#pragma GCC optimize ("-O3") 等)相当不可靠。它对我不起作用。最可能的原因是优化标志没有传播到 std::vector 的实例化,因为它的定义完全在其他地方。您可能需要通过优化编译 C++ 标准库 headers 本身。

另一个想法是使用不同的容器库。例如,boost 有一个 vector class。但我没有检查它的调试性能是否会更好。