【问题标题】:How do STL linked structures handle allocation?STL 链接结构如何处理分配?
【发布时间】:2018-06-30 20:07:40
【问题描述】:

C++ 标准模板库提供了许多容器类型,它们具有非常明显的链接结构实现,例如listmap

高度链接结构的一个非常基本的优化是使用自定义子分配器和提供固定大小分配的私有内存池。鉴于 STL 对性能的重视,我希望执行这种或类似的优化。同时,所有这些容器都有一个可选的Allocator 模板参数,并且能够为已经使用自定义分配器的东西提供自定义分配器似乎在很大程度上是多余的。

所以,如果我正在寻找具有 STL 的最高性能链接结构,我是否需要指定自定义分配器,还是可以依靠 STL 为我完成这项工作?

【问题讨论】:

  • 真正的问题是正确选择stackoverflow.com/questions/471432/…,然后实现程序正常工作。 5% 的代码库中,用某些东西替换库存容器或分配器可能会带来一些好处很多时候发生,并且与实际问题相比是小菜一碟。
  • 请注意,“STL”是 SGI 的一个库,该公司早已不复存在。 std::list<>标准库 的一部分,源自旧 STL 中的 list<>。但这是 20 世纪的谈话,4 C++ 标准之前。 标准库是一个规范,有多个实际实现。

标签: c++ performance memory-management stl


【解决方案1】:

当然,它可以从一个标准库实现到下一个标准库实现有所不同,但上次我签入 MSVC、GNU C++ 和 EASTL 等库时,链接结构在一次分配中分配节点和元素数据。

但是,每个节点仍然一次分配一个,而不是std::allocator,这是一个相当通用的可变长度分配器(尽管它至少可以假设所有被分配的元素都是特定的数据类型,但很多时候我发现它只是在 VTune 和 CodeXL 会话中默认为 malloc 调用)。有时甚至会进行线程安全的内存分配,当数据结构本身不是为并发修改或同时读/写而设计时,使用线程安全的通用内存分配一次分配一个节点时,这有点浪费.

如果您希望允许客户端将他们自己的自定义分配器作为模板参数传递,那么该设计是有意义的。在这种情况下,您不希望数据结构池化内存,因为这将与分配器可能想要做的事情发生冲突。需要对链接结构做出决定,特别是您是否一次分配一个节点并将更有效的分配技术(如空闲列表)的责任传递给分配器以使每个单独的节点分配高效,或者避免依赖分配器来进行通过以连续方式一次分配多个节点并将它们池化,高效且有效地在数据结构中进行池化。标准库倾向于以前的路线,不幸的是,当与默认的std::allocator 对比使用时,可能会产生std::liststd::map 之类的东西,效率非常低。

对于链接结构,我个人使用自己的手动解决方案,它依赖于数组中的 32 位索引(例如:std::vector),它有效地用作池化内存并像“索引空闲列表”一样工作,如下所示:

...我们实际上可能将链表节点存储在std::vector 中。链接只是成为一种允许我们在恒定时间内删除事物并在恒定时间内回收这些空白空间的方法。实际示例比上面的伪代码稍微复杂一些,因为该代码仅适用于 POD(真实示例使用 aligned_storage、placement new 和像标准容器一样的手动 dtor 调用),但它并没有那么复杂。类似情况:

... 使用std::vector(或类似std::deque,如果您不希望指针失效)的双向链接“索引”列表,例如,用于存储列表节点。在这种情况下,链接允许我们在遍历向量的连续内存时跳过。这样做的全部目的是允许在列表的任何位置进行恒定时间删除和插入,同时保留遍历时的插入顺序(如果我们使用交换到返回和弹出返回技术,这将丢失 std::vector用于从中间移除恒定时间)。

除了使所有内容更连续和缓存友好以遍历以及更快地分配和释放之外,当我们可以使用 32 位索引而不是随机的时,它还将 64 位架构上的链接大小减半存储节点的访问序列。

链接列表实际上在 C++ 中积累了非常糟糕的代表,我相信这主要是因为这个原因。人们对默认分配器使用std::list 进行基准测试,并在遍历时以缓存未命中的形式出现瓶颈,并且通过插入每个单独的节点并通过删除每个单独的节点来释放可能是线程安全的内存分配。与unordered_mapunordered_set 相比mapset 的巨大偏好的类似情况。哈希表可能总是有一些优势,但当mapset 一次只使用一个通用分配器并在树遍历时导致缓存未命中时,该优势是如此倾斜。

所以,如果我正在寻找具有最高性能的链接结构 STL,我是否需要指定自定义分配器,或者我可以依靠 STL 为我完成这项工作?

测量/配置文件的建议总是明智的,但如果您的需求真的很重要(例如您在每一帧重复循环数据,并且它存储了数十万个或更多元素,同时还重复插入和删除元素到/从每帧中间),那么我至少会在使用std::liststd::map 之类的之前获得一个免费列表。链表是如此微不足道的数据结构,如果你真的用标准库中的链接结构来打热点,我实际上建议你自己滚动,而不是必须处理分配器和数据结构的组合来实现高效解决方案(如果数据结构足够简单,可以更轻松地以默认形式满足您的确切需求,那么它可以更容易实现)。

我过去经常摆弄分配器,触及数据结构并尝试通过尝试分配器来提高它们的效率(取得了适度的成功,足以鼓励我,但结果并不令人惊讶),但我找到了自己的生活制作链接结构来预先汇集他们的记忆变得如此容易(这给了我最惊人的结果)。有趣的是,与我花在摆弄分配器(尝试第三方的分配器以及实现我自己的分配器)上的所有时间相比,与我花在分配器上的所有时间相比,仅仅创建这些更有效地预先分配策略的数据结构所花费的时间更少。这是我快速创建的一个示例,它使用链表检测 400 万个粒子(这是旧的,所以它在 i3 上运行)。

它使用单链表使用我自己的类似双端队列的容器来存储节点,如下所示:

这里有一个空间索引,用于 500k 可变大小的代理之间的碰撞(只花了 2 个小时来实现整个事情,我什至没有费心去多线程):

我主要针对那些说链表效率低下的人指出这一点,因为只要您以高效且相对连续的方式存储节点,它们确实可以成为添加到您的武器库中的有用工具。我认为整个 C++ 社区都过于草率地驳回了它们,因为如果没有链表,我会完全迷失方向。如果使用得当,它们可以减少堆分配而不是相乘,提高空间局部性而不是降低它(例如:考虑上面的网格图,如果它使用单独的 std::vectorSmallVector 实例,每个单元格都有固定的 SBO而不是只存储一个 32 位整数)。写一个非常有效地分配节点的链表并不需要很长时间——如果有人花费超过半小时来编写数据结构和单元测试,我会感到惊讶。类似的情况,比如一棵高效的红黑树,可能需要几个小时,但没什么大不了的。

这些天来,我最终只是将链接节点直接存储在 std::vector 之类的东西中,如果我需要构建并发链接结构等,我自己的大块相当于 std::dequetbb::concurrent_vector 等。生活变得容易多了当有效分配被吸收到数据结构的责任中时,而不是必须将有效分配和数据结构视为两个完全独立的概念,并且必须在所有地方鞭打和传递所有这些不同类型的分配器。这些天我喜欢的设计是这样的:

// Creates a tree storing elements of type T with 'BlockSize' contiguous
// nodes allocated at a time and pooled.
Tree<T, BlockSize> tree;

... 或者我只是省略了 BlockSize 参数,让节点存储在 std::vector 中,并在连续存储 所有 节点的同时进行摊销重新分配。我什至不再为分配器模板参数而烦恼。一旦您将有效的节点分配职责吸收到树结构中,分配器的类模板就不再有太多好处,因为它就像 mallocfree 接口,并且动态调度在那时变得非常便宜如果您出于某种原因仍需要自定义分配器,则只涉及一次,例如,每 128 个节点连续分配/释放一次。

所以,如果我正在寻找具有最高性能的链接结构 STL,我是否需要指定自定义分配器,或者我可以依靠 STL 为我完成这项工作?

所以回到这个问题,如果您真的有非常关键的性能需求(无论是预先预期的大量数据,例如您必须处理每一帧的大量数据,还是事后通过测量),您甚至可以考虑只滚动一些数据您自己的结构,将节点存储在std::vector 之类的东西中。尽管这听起来适得其反,但与整天摆弄和试验内存分配器相比,它所花费的时间要少得多,更不用说使用 32 位索引将节点分配到 std::vector 的“索引链表”了链接将使链接成本减半,并且可能比符合std::allocator 的免费列表花费更少的时间来实现,例如希望如果人们更频繁地这样做,链表可以再次开始变得更受欢迎,因为我认为当以有效分配节点的方式使用它们时,它们很容易被认为效率低下,它们实际上可能是一个很好的某些问题的数据结构。

【讨论】:

    【解决方案2】:

    虽然标准没有明确禁止此类优化,但实施者的设计选择会很糟糕。

    首先,可以想象一个用例,池分配不是一个理想的选择。 在模板参数中引用自定义分配器来引入您想要的池行为并不难,但如果它是容器的一部分,则禁用该行为几乎是不可能的。

    同样从 OOP 的角度来看,您的模板显然有多个职责,有些人认为这是一个不好的迹象。

    总体答案似乎是“是的,您确实需要一个自定义分配器”(Boost::pool_alloc?)。

    最后,您可以写一个simple test 来检查您的具体实现是做什么的。

    【讨论】:

      【解决方案3】:

      这在很大程度上取决于您的工作量。

      如果您不经常迭代您的数据结构,甚至不必费心优化任何东西。最好把时间花在其他地方。

      如果您确实进行了迭代,但您的有效负载很大并且您为每个项目做了很多工作,那么默认实现不太可能成为瓶颈。迭代效率低下将被每个项目的工作所吞噬。

      如果你存储小元素(整数、指针),你做一些微不足道的操作并且你迭代了很多结构,那么你会从 std::vector 或 boost::flat_map 之类的东西中获得更好的性能,因为它们允许更好的预取操作。

      当您发现自己正在分配和释放大量小块内存时,分配器最有用。这会导致内存碎片并可能影响性能。

      与所有性能建议一样,您需要在目标机器上对工作负载进行基准测试。

      附:确保启用优化(即 -O3)。

      【讨论】:

        猜你喜欢
        • 2013-05-23
        • 2014-05-12
        • 1970-01-01
        • 2018-12-07
        • 1970-01-01
        • 1970-01-01
        • 2011-11-24
        • 1970-01-01
        • 1970-01-01
        相关资源
        最近更新 更多