(如何)你考虑到内存碎片?

计算科学 表现 高性能计算 内存管理
2021-11-26 09:20:09

我使用有限元理论中的一个例子,但是任何维护大型数据结构并连续扩展它的人都会发现类似的东西。

假设我有一个点和三角形的非结构化网格,其中点由坐标给出(比如xy) 并且每个三角形都由三个点索引组成(比如i,jk)。

正如 FEM 中常见的那样,网格将被逐次细化。如果我们求助于全局规则细化,三角形的数量将成倍增长4随着细化的每次迭代。根据执行方式的不同,内存布局会有所不同。

假设网格占用了 1 到 300 个存储单元,除此之外的任何内容都是免费的。

示例 1:

我们为新网格分配空间,单元格 301 到 1501,用细化网格的数据填充它,然后忘记旧网格。下一个细化网格将放置在单元格 1501 到 6300 中,下一个在 6301 到 21500 中,依此类推。当前网格的位置将在内存中“向右”移动,而不会使用巨大的补丁。我们可能会过早地耗尽内存。

人们可能会在上面的示例中观察到,这只会阻碍我们进行一次细化,因为即使没有碎片,我们也会在一次细化后耗尽总内存。由于也考虑了顶点数组,问题可能会变得更加严重。

如何避免这种情况?

示例 2:

将三角形数组重新分配到单元格 1..1200。在单元格 1201 到 2400 中创建新网格。将该工作副本的内容复制到单元格 1..1200,然后忘记工作副本。类似地重复。

好的,我们仍然过早地耗尽了内存,因为我们需要一个工作副本。这个怎么样:

示例 3:

将三角形数组重新分配到单元格 1..1500。将旧网格复制到 1201 .. 1500。在单元格 1..1200 中创建新网格。然后忘记旧网格的副本。

这里的情况是人为的,因为人们不会在这些尺度上使用全局网格细化。如果增长要小得多,则可以重新调整内存以避免碎片。然而,

问题:

  1. 内存碎片在实际科学计算/高性能计算中是否变得至关重要?

  2. 如果有的话,你如何避免它?也许我的机器模型甚至是错误的,并且操作系统通过一些重魔法确实会默认重新调整内存,或者管理堆上的碎片块。

  3. 更具体地说,它如何影响网格管理?

4个回答

关于网格的总内存使用和额外的间接性,我不同意 Matt。对于显式方法,通常用很少数量的向量(例如 2 个)来表示所有模拟状态。对于标量问题,仅定义网格的坐标可能不止于此,如果连通性是明确的,则实际上更多。使用显式方法的高分辨率模拟通常需要大量的时间步长,因此通常使用相当小的子域以便能够在合理的墙上时间量内完成模型运行。因此,由于存储网格的成本,内存不足的情况并不常见。

一个更基本的问题是算法每一步的内存带宽要求(例如评估残差或采取时间步长)。对于非结构化方法,这涉及一些基于网格的信息,但通常不需要访问所有网格存储。请注意,即使求解器内存占主导地位(对于许多隐式方法来说很常见),模拟仍然经常依赖于网格遍历的带宽要求。有两个因素:

例行网格遍历过程中需要哪些数据?

在有限元方法中,需要一个元素到全局的映射。对于高于一阶的情况,这通常通过将自由度与中间实体(如面和边)相关联来实现,但是一旦构建了元素到全局映射,就不需要中间实体。特别是,模拟从不直接使用中间实体的连接性。对于隐式求解器或时间积分器的多次迭代,可能不会触及该信息,但在自适应过程中或设置新函数空间时仍需要该信息。在此期间,通常将元素到全局映射设置在一个简单的数组中,而不触及“网格”数据结构,在这种情况下,网格本身的存储格式和数据局部性就不那么重要了。

有限体积方法需要细胞体积、面到细胞的连通性、面面积和面法线。请注意,顶点坐标或连通性不需要可用。在极端示例(例如 FUN3D)中,所有其他信息在离线预处理(网格生成)期间被丢弃,并且只有这种简化的表示可用于模拟。这是非常有效的,但排除了移动网格和自适应细化。

这些数据在内存中是如何布局的?

对于许多具有适度精度和物理复杂性的模拟,性能受到内存带宽的限制。IBM、Intel、AMD 和 NVidia 的现代 CPU 架构支持 4 到 8 次浮点数/字节的算术强度。有了如此高效的浮点单元,我们应该尝试优化我们的内存带宽算法。这可能涉及一些深入的算法更改,例如偏爱未组装的高阶方法(比较第二个图中的“组装”和(未组装)“张量”行),但我们可以从尝试充分利用硬件提供的带宽开始。这通常涉及计算排序(顶点、面、单元格等),以便尽可能地重用缓存。它还涉及利用阻塞和排序未知数,以便拥有最少的元数据并激活正确数量的数据流。(通常 3D 非结构化模拟以“太多”流结束,但是像 POWER7 这样的一些现代硬件需要很多流来饱和带宽,因此有时故意组织数据以激活更多流是有意义的。)PETSc-FUN3D 工作提供了一个经典的,但仍然对非结构化隐式 CFD 的这些性能优化进行了高度相关的讨论。

针对您的问题的建议

  1. 不要害怕malloc()无需将网格的当前细化打包到与网格的较粗不活动部分相同的阵列中。每当您不再需要网格的旧部分时,只需free()它。

  2. 细化后,计算该细化级别的良好排序(Reverse Cuthill-McKee很流行,但也可以使用更多特定于缓存的排序)。如果您的负载不平衡是合理的(例如,小于 10%),您可以在本地计算这个新的排序(无需并行分区和重新分配)。成本可能类似于单个“物理”网格遍历,但可以将这些遍历速度提高 2 倍或更多。这一步通常会在每个网格自适应不发生时得到回报“物理”网格遍历。如果您的问题需要如此频繁的适应性,您可能会进行小的更改,并且仍应偶尔重新排序。我仍然会避免使用细粒度的内存池,因为它会使重新排序更加困难,但是您可以使用相当大的块来平衡峰值内存使用、排序成本和增量更新成本。

  3. 根据离散化,考虑为“物理”遍历提取具有低内存带宽要求的简化表示。不必要的间接是不好的,因为它会增加带宽需求,并且如果间接目标不规则,则会导致缓存重用不佳并抑制预取。

在 deal.II 中,我们通过丢弃旧单元并用新单元替换它们来细化网格。但是新的也被放入删除单元留下的记忆孔中。然后按照在内存中遇到单元的顺序完成所有单元上的所有循环,以保持高速缓存命中率高。

更大的问题是如何存储定义单元格的数据。你当然可以做 struct Simplex { Vertex vertices[4]; 诠释材料ID;int subdomain_id; 使用的布尔值;无效*用户数据;};

类三角测量{单纯形*细胞;}; 但这不是缓存效率,因为所有单元格上的大多数循环只会触及您存储在 Simplex 数据结构中的数据的子集,因此实际上只有一小部分落在缓存中的数据会被使用。一个更好的策略是做这样的事情:class Triangulation { Vertex *vertices; 诠释 *material_ids; int *subdomain_ids; 布尔 *used_flags; 无效* *用户数据;}; 因为在所有单元的循环中,后续迭代可能会访问定义单元的相同数据子集,所以预读缓存只会预加载您实际要使用的数据,因此会导致高缓存命中率。

1)不。求解器内存远远超过网格内存。即使您运行最轻量级的显式求解器,网格也最多占模拟内存的 25%,而且更可能小于 10%。

2)分解您的分配并使用内存池。您不需要为整个网格分配一个连续的块,因为您通常只需要遍历本地块。引入一级间接不会以有意义的方式影响性能。

如果内存不足,只需在更多节点上运行,以便拥有更多内存。您正在浪费非常宝贵的资源(人脑)来解决一个非常容易解决的问题。