C++ 替代 C99 VLA(目标:保持性能)

Posted

技术标签:

【中文标题】C++ 替代 C99 VLA(目标:保持性能)【英文标题】:C++ replacement for C99 VLAs (goal: preserve performance) 【发布时间】:2016-07-23 06:38:22 【问题描述】:

我正在将一些大量使用可变长度数组 (VLA) 的 C99 代码移植到 C++。

我用在堆上分配内存的数组类替换了 VLA(堆栈分配)。性能损失巨大,下降了 3.2 倍(参见下面的基准)。 我可以在 C++ 中使用哪些快速 VLA 替换?我的目标是在为 C++ 重写代码时尽量减少性能损失。

向我建议的一个想法是编写一个数组类,该类在该类中包含一个固定大小的存储(即可以堆栈分配)并将其用于小型数组,并自动切换到较大数组的堆分配.我的实现在帖子的末尾。它工作得相当好,但我仍然无法达到原始 C99 代码的性能。为了接近它,我必须将这个固定大小的存储(下面的MSL)增加到我不喜欢的大小。我不想在堆栈上分配太大的数组即使对于许多不需要它的小数组,因为我担心它会触发堆栈溢出。 C99 VLA 实际上不太容易出现这种情况,因为它永远不会使用比需要更多的存储空间。

我遇到了std::dynarray,但我的理解是它没有被标准接受(还没有?)。

我知道 clang 和 gcc 支持 C++ 中的 VLA,但我也需要它与 MSVC 一起使用。事实上,更好的可移植性是重写为 C++ 的主要目标之一(另一个目标是将原本是命令行工具的程序变成可重用的库)。


基准测试

MSL 指的是我切换到堆分配的数组大小。我对 1D 和 2D 数组使用不同的值。

原始 C99 代码:115 秒。 MSL = 0(即堆分配):367 秒 (3.2x)。 1D-MSL = 50,2D-MSL = 1000:187 秒 (1.63x)。 1D-MSL = 200,2D-MSL = 4000:143 秒 (1.24x)。 1D-MSL = 1000,2D-MSL = 20000:131 (1.14x)。

增加MSL 进一步提高性能,但最终程序将开始返回错误结果(我假设是由于堆栈溢出)。

这些基准测试是在 OS X 上使用 clang 3.7,但 gcc 5 显示的结果非常相似。


代码

这是我当前使用的“smallvector”实现。我需要一维和二维向量。我切换到大于MSL的堆分配。

template<typename T, size_t MSL=50>
class lad_vector 
    const size_t len;
    T sdata[MSL];
    T *data;
public:
    explicit lad_vector(size_t len_) : len(len_) 
        if (len <= MSL)
            data = &sdata[0];
        else
            data = new T[len];
    

    ~lad_vector() 
        if (len > MSL)
            delete [] data;
    

    const T &operator [] (size_t i) const  return data[i]; 
    T &operator [] (size_t i)  return data[i]; 

    operator T * ()  return data; 
;


template<typename T, size_t MSL=1000>
class lad_matrix 
    const size_t rows, cols;
    T sdata[MSL];
    T *data;

public:
    explicit lad_matrix(size_t rows_, size_t cols_) : rows(rows_), cols(cols_) 
        if (rows*cols <= MSL)
            data = &sdata[0];
        else
            data = new T[rows*cols];
    

    ~lad_matrix() 
        if (rows*cols > MSL)
            delete [] data;
    

    T const * operator[] (size_t i) const  return &data[cols*i]; 
    T * operator[] (size_t i)  return &data[cols*i]; 
;

【问题讨论】:

VLA 在开销方面无可替代。 VLA 的存储完全免费。事实上,在大多数情况下,它是完全免费的,超出了函数调用的现有开销。真的不能比 0% 的成本做得更好,所以如果 MSVC 没有 VLA,你别无选择,只能使用其他替代方案,用于 VLA,并受到性能影响。 如果您乐于使用“特定平台”,那么 GCC 会将 VLA 作为扩展并在所有这些平台上运行。 还有alloca(平台特有功能,但存在于Linux/Windows/OS X):man7.org/linux/man-pages/man3/alloca.3.html在栈上动态分配内存。 alloca 需要在应该使用其堆栈的函数中调用。也就是说,不在向量类(或初始化列表)的构造函数中。类可以将指针作为构造函数参数,如lad_vector vec( (int*)alloca(10 * sizeof(int)), 10 );。也许为此制作一个宏(但不是内联函数),以获得类似lad_vector vec = MAKE_LADVECTOR(10);的语法 增加 MSL 会进一步提高性能,但最终程序将开始返回错误结果(我假设是由于堆栈溢出)。 我不明白堆栈溢出会如何给出你错误的结果。在任何健全的系统上,最坏的情况是你应该得到一个段错误。 (除非发生一些非常不寻常的事情,比如溢出太多以至于你最终会进入其他有效内存区域。)所以也许你应该寻找一个错误。 【参考方案1】:

在线程本地存储中创建一个大缓冲区 (MB+)。 (堆上的实际内存,TLS 中的管理)。

允许客户端以 FILO 方式(类似堆栈)向它请求内存。 (这模仿了它在 C VLA 中的工作方式;而且效率很高,因为每个请求/返回只是一个整数加法/减法)。

从中获取您的 VLA 存储。

把它包装好,所以你可以说stack_array&lt;T&gt; x(1024);,然后让stack_array 处理构造/破坏(注意-&gt;~T() 其中Tint 是合法的noop,构造也可以类似地noop),或者让stack_array&lt;T&gt; 包裹std::vector&lt;T, TLS_stack_allocator&gt;

数据不会像 C VLA 数据那样本地化,因为它将有效地位于单独的堆栈上。您可以使用 SBO(小缓冲区优化),这正是局部性真正重要的时候。

SBO stack_array&lt;T&gt; 可以使用分配器和与 std 数组联合的 std 向量、独特的 ptr 和自定义销毁器或无数其他方式来实现。您可能可以改进您的解决方案,将 new/malloc/free/delete 替换为对上述 TLS 存储的调用。

我说使用 TLS,因为它消除了同步开销的需要,同时允许多线程使用,并反映了堆栈本身隐含 TLS 的事实。

Stack-buffer based STL allocator? 是一个 SO Q&A,答案中至少包含两个“堆栈”分配器。他们需要一些适应才能自动从 TLS 获取缓冲区。

请注意,作为一个大缓冲区的 TLS 在某种意义上是一个实现细节。你可以做大的分配,当你用完空间时再做一个大的分配。您只需要跟踪每个“堆栈页面”的当前容量和堆栈页面列表,因此当您清空一个时,您可以移动到较早的一个。这让您在 TLS 初始分配时更加保守,而不必担心运行 OOM;重要的是你是 FILO 并且很少分配,而不是整个 FILO 缓冲区是一个连续的。

【讨论】:

有趣的想法,我会试试的。什么是 SBO? 我想知道为什么这被否决了。用例是在最初用 C99 编写的代码中替换 C99 VLA。这意味着数组总是以其创建的相反顺序被销毁,所以从“手动管理的堆栈”中获取它们的存储的想法应该可行......如果有预期的问题,我想知道。 @sza 小缓冲区优化(您已经尝试过),“本地”存储小数组。真的,只有在上述性能测试失败时才尝试。 @Szabolcs 作为理论,除了 TLS 细节之外,我的回答与 5gon12eder 的最后一个想法一致;也许有人不喜欢他们有多相似。如果将 TLS 细节折叠到 5gon12 的答案中,我的答案将是多余的;同时,我强烈怀疑这个解决方案是唯一有机会解决您的可移植性和性能问题的解决方案。 您对 FILO 的想法效果很好,缩小了性能差距。一旦我完成实施(也许明天),我将接受答案。【参考方案2】:

我认为您已经列举了您的问题和 cmets 中的大多数选项。

使用std::vector。这是最明显、最轻松但也可能是最慢的解决方案。

在提供它们的平台上使用特定于平台的扩展。例如,GCC 支持 C++ 中的variable-length arrays 作为扩展。 POSIX 指定alloca,广泛支持在堆栈上分配内存。甚至 Microsoft Windows 也提供_malloca,正如快速网络搜索告诉我的那样。

为了避免维护噩梦,您真的希望将这些平台依赖项封装到一个抽象接口中,该接口会自动、透明地为当前平台选择合适的机制。为所有平台实现这一点需要一些工作,但如果这个单一功能在您报告时占了 3 倍的速度差异,那么它可能是值得的。作为未知平台的后备方案,我会保留std::vector 作为最后的手段。慢点但正确地运行总比表现不稳定或根本不运行要好。

构建您自己的可变大小数组类型,该类型实现“小数组”优化,该优化嵌入为对象本身的缓冲区,如您在问题中所示。我只是要注意,我宁愿尝试使用 unionstd::arraystd::vector 而不是滚动我自己的容器。

一旦您有了自定义类型,您就可以进行有趣的分析,例如维护该类型所有出现的全局哈希表(按源代码位置),并在程序的压力测试期间记录每个分配大小。然后,您可以在程序退出时转储哈希表并绘制各个数组的分配大小分布。这可能有助于您微调为堆栈上的每个数组单独保留的存储量。

std::vector 与自定义分配器一起使用。在程序启动时,分配几兆字节的内存并将其交给一个简单的堆栈分配器。对于堆栈分配器,分配只是比较和添加两个整数,而释放只是一个减法。我怀疑编译器生成的堆栈分配会快得多。然后,您的“数组堆栈”将与您的“程序堆栈”相关联。这种设计还有一个优势,即意外的缓冲区溢出——同时仍然调用未定义的行为、丢弃随机数据和所有那些坏东西——不会像使用原生 VLA 那样容易损坏程序堆栈(返回地址)。

C++ 中的自定义分配器有点脏,但有些人确实报告说他们成功地使用了它们。 (我自己没有太多使用它们的经验。)您可能想开始查看cppreference。 Alisdair Meredith 是提倡使用自定义分配器的人之一,他在 CppCon'14 上做了一个题为“让分配器工作”(part 1,part 2)的双会话演讲,您可能也会觉得有趣。如果std::allocator 接口对你来说太难用了,用你自己的分配器实现你自己的变量(而不是动态)大小的数组类应该也是可行的.

【讨论】:

类联合听起来很危险,析构函数不会为联合执行。 @Alex 自 C++11 以来这是安全的。当然,您必须注意相应地编写您的析构函数,以调用union 的当前活动成员的适当析构函数。 std::vector 的池分配器必须是世界上最好的。【参考方案3】:

关于对 MSVC 的支持:

MSVC 具有分配堆栈空间的_alloca。它还有_malloca,如果有足够的空闲堆栈空间,则分配堆栈空间,否则回退到动态分配。

您无法利用 VLA 类型系统,因此您必须更改代码以基于指向此类数组的第一个元素的指针工作。

您可能最终需要使用根据平台而具有不同定义的宏。例如。在 MSVC 和 g++ 或其他编译器上调用 _alloca_malloca,或者调用 alloca(如果它们支持),或者创建一个 VLA 和一个指针。


考虑研究重写代码而不需要分配未知数量的堆栈的方法。一种选择是分配一个固定大小的缓冲区,这是您需要的最大值。 (如果这会导致堆栈溢出,则意味着您的代码无论如何都会被窃听)。

【讨论】:

如果没有从声明对象的同一个函数显式调用 alloca,我会担心它使用了错误的堆栈帧。 @Random832 不确定你在说什么,我建议用 alloca 替换 VLA 声明作为可能的选项 我想我很困惑,还以为你在谈论将这种行为隐藏在一个类后面。 @Random832:_alloca()alloca() 如果对使用它们的函数的调用被正确内联,则它们都会做正确的事情。您可以使用__forceinline__attribute__((always_inline)) 确保发生这种情况。我在 C90 代码(也没有 VLA)中广泛使用它。

以上是关于C++ 替代 C99 VLA(目标:保持性能)的主要内容,如果未能解决你的问题,请参考以下文章

C 和 C++ 中的可变长度数组 (VLA)

与 malloc/free 相比,使用 C99 VLA 是个好主意吗?

在 MS Visual C++ 中启用 VLA(可变长度数组)?

mongodb无法启动,由于目标计算机积极拒绝,无法连接

C++ 可变长度数组 (VLA) 警告

C99 在运行时如何计算可变长度数组的大小?