对于在网格和图像处理、物理引擎和光线跟踪等性能关键领域工作的链表,我发现其中一个最有用的情况是,使用链表实际上可以提高引用的局部性并减少堆分配,有时甚至会减少内存与简单的替代方案相比。
现在这似乎是一个完全矛盾的说法,链表可以做到所有这些,因为它们经常做相反的事情而臭名昭著,但它们有一个独特的属性,即每个列表节点都有固定的大小和对齐要求,我们可以允许它们以可变大小的东西不能的方式连续存储和以恒定时间删除。
因此,让我们举一个例子,我们想要做一个类比等价的存储可变长度序列,其中包含一百万个嵌套的可变长度子序列。一个具体的例子是一个索引网格存储一百万个多边形(一些三角形,一些四边形,一些五边形,一些六边形等),有时多边形会从网格中的任何位置移除,有时会重建多边形以将顶点插入现有多边形或删除一个。在这种情况下,如果我们存储一百万个微小的std::vectors,那么我们最终将面临每个向量的堆分配以及潜在的爆炸性内存使用。一百万个微小的SmallVectors 在常见情况下可能不会遇到这个问题,但是它们未单独堆分配的预分配缓冲区可能仍会导致爆炸性内存使用。
这里的问题是一百万个std::vector 实例将尝试存储一百万个可变长度的东西。可变长度的东西往往需要堆分配,因为如果它们没有将其内容存储在堆的其他位置,它们就不能非常有效地连续存储并以恒定时间删除(至少以一种直接的方式没有非常复杂的分配器)。
如果我们这样做:
struct FaceVertex
{
// Points to next vertex in polygon or -1
// if we're at the end of the polygon.
int next;
...
};
struct Polygon
{
// Points to first vertex in polygon.
int first_vertex;
...
};
struct Mesh
{
// Stores all the face vertices for all polygons.
std::vector<FaceVertex> fvs;
// Stores all the polygons.
std::vector<Polygon> polys;
};
...然后我们大大减少了堆分配和缓存未命中的数量。我们现在只需要在存储在整个网格中的两个向量之一超过其容量(摊销成本)时,才需要为我们访问的每个多边形进行堆分配和潜在的强制缓存未命中,而不是要求堆分配。虽然从一个顶点到下一个顶点的步幅仍然可能导致其缓存未命中的份额,但它仍然通常小于每个单个多边形存储一个单独的动态数组,因为节点是连续存储的,并且相邻顶点可能在驱逐之前访问(特别是考虑到许多多边形会同时添加它们的顶点,这使得大部分多边形顶点完全连续)。
这是另一个例子:
...网格单元用于加速粒子-粒子碰撞,例如,每帧移动 1600 万个粒子。在那个粒子网格示例中,使用链表我们可以通过改变 3 个索引将粒子从一个网格单元移动到另一个网格单元。从一个向量中擦除并推回另一个向量可能会更加昂贵,并且会引入更多的堆分配。链表还将单元的内存减少到 32 位。根据实现,向量可以将其动态数组预分配到一个空向量可以占用 32 个字节的位置。如果我们有大约一百万个网格单元,那就大不相同了。
...这是我发现链表最近最有用的地方,我特别发现“索引链表”种类很有用,因为 32 位索引将 64 位机器上链接的内存需求减半,而且它们暗示节点连续存储在一个数组中。
我通常还将它们与索引的空闲列表结合起来,以允许在任何地方进行恒定时间的删除和插入:
在这种情况下,next 索引如果节点已被删除,则指向下一个空闲索引,如果节点尚未删除,则指向下一个使用的索引。
这是我最近发现的链表的第一个用例。例如,当我们想要存储一百万个可变长度的子序列时,每个子序列平均有 4 个元素(但有时会删除元素并添加到这些子序列中的一个),链表允许我们存储 400 万个链表节点是连续的,而不是 100 万个容器,每个容器都是单独堆分配的:一个巨大的向量,而不是一百万个小的向量。