36

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

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

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

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

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


基准

MSL指的是我切换到堆分配的数组大小。我对一维和二维数组使用不同的值。

原始 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”实现。我需要一维和二维向量。我切换到 size 以上的堆分配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]; }
};
4

3 回答 3

39

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

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

从中获取您的 VLA 存储。

把它包装好,所以你可以说stack_array<T> x(1024);, 并stack_array处理构造/破坏(注意->~T()where Tisint是合法的noop,构造也可以类似地是noop),或者使stack_array<T>wrap a std::vector<T, TLS_stack_allocator>.

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

SBOstack_array<T>可以使用分配器和与 std 数组联合的 std 向量来实现,或者使用唯一的 ptr 和自定义销毁器或无数其他方式来实现。您可能可以改进您的解决方案,将 new/malloc/free/delete 替换为对上述 TLS 存储的调用。

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

基于堆栈缓冲区的 STL 分配器?是一个 SO Q&A,答案中至少有两个“堆栈”分配器。他们需要一些适应才能自动从 TLS 获取缓冲区。

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

于 2016-04-03T21:01:23.510 回答
17

我认为您已经在问题和评论中列举了大多数选项。

  • 使用std::vector. 这是最明显、最轻松但也可能是最慢的解决方案。
  • 在提供它们的平台上使用特定于平台的扩展。例如,GCC 支持C++ 中的可变长度数组作为扩展。POSIX 指定alloca广泛支持在堆栈上分配内存。_malloca正如快速网络搜索告诉我的那样,即使是 Microsoft Windows 也提供了 .

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

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

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

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

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

于 2016-04-03T20:48:34.637 回答
9

关于对 MSVC 的支持:

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

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

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


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

于 2016-04-03T23:49:39.463 回答