缓存友好矩阵,用于访问相邻单元

Cache-Friendly Matrix, for access to adjacent cells

本文关键字:访问 单元 用于 缓存      更新时间:2023-10-16

当前设计

在我的程序中,我有一个大的二维网格(1000 x 1000, 或更多),每个单元格包含一个小信息。为了表示这个概念,选择非常简单:一个矩阵数据结构。 对应的代码(c++ 中的)类似于:
int w_size_grid = 1000;
int h_size_grid = 1000;
int* matrix = new int[w_size_grid * h_size_grid];

你可以注意到我使用了一个向量,但原理是一样的。

为了访问网格中的一个元素,我们需要一个函数,给定网格中的一个单元格,由(x,y)标识,它返回存储在该单元格中的值。

数学:f(x,y) -> Z很明显:f: Z^2 -> Z,其中Z为整数集。

这可以通过线性函数轻松实现。这里有一个 c++ 代码表示:
int get_value(int x, int y) {
  return matrix[y*w_size_grid + x];
}

附加实现说明

实际上设计概念需要一种"圆形连续网格": 单元的访问索引可以超出网格本身的限制

这意味着,例如,特殊情况:get_value(-1, -1);仍然有效。该函数将返回与get_value(w_size_grid - 1, h_size_grid -1);相同的值。

实际上这在实现中没有问题:

int get_value(int x, int y) {
  adjust_xy(&x, &y);  // modify x and y in accordance with that rule.
  return matrix[y*w_size_grid + x];
}

无论如何,这只是为了使场景更清楚而附加的注释。


问题是什么?

上面的问题很简单,很容易设计和实现。

我的问题来自于矩阵以高频率更新的事实。读取矩阵中的每个单元格,并可能使用新值更新。

显然,矩阵的解析是通过两个循环完成的,根据缓存友设计:

for (int y = 0; y < h_size_grid; ++y) {
  for (int x = 0; x < w_size_grid; ++x) {
    int value = get_value(x, y);
  }
}

内循环是x,因为[x-1] [x] [x+1]是连续存储的。事实上,这个循环利用了局部性原则。

现在问题来了,因为实际上为了更新单元格中的值,它依赖于相邻单元格中的值。

每个单元格恰好有八个相邻单元格,它们是水平、垂直或对角线相邻的单元格。

(-1,-1) | (0,-1) | (1,-1)
-------------------------
(-1,0)  | (0,0)  | (0, 1)
-------------------------
(-1,1)  | (0,1)  | (1,1)

所以代码是直观的:

for (int y = 0; y < h_size_grid; ++y) {
  for (int x = 0; x < w_size_grid; ++x) {
    int value = get_value(x, y);
    auto values = get_value_all_neighbours(x, y);  // values are 8 integer
  }
}

函数get_value_all_neighbours(x,y)将相对于y访问矩阵上一行和下一行。由于矩阵中的一行相当大,它涉及缓存丢失,并且它会弄脏缓存本身。

<标题>

我终于向你展示了这个场景和问题,我的问题是如何"解决"这个问题。

使用一些额外的数据结构,或者重新组织数据是否有办法利用缓存并避免所有这些遗漏?

一些个人考虑

我的感觉引导我走向战略数据结构。

我考虑过重新实现将值存储在向量中的顺序,试图将相邻的那些单元存储在连续的索引中。

这意味着get_value有一个no-more-linear函数。经过思考,我认为不可能找到这个非线性函数。

我还考虑了一些额外的数据结构,如哈希表来存储每个单元的相邻值,但我认为这在空间和CPU周期中也是一个过度杀伤。

让我们假设您确实有一个无法轻易避免的缓存丢失问题(参考这里的其他答案)。

您可以使用空间填充曲线以缓存友好的方式组织数据。从本质上讲,空间填充曲线将一个体积或平面(比如你的矩阵)映射到一个线性表示,这样在空间中靠近的值在线性表示中(大多数)靠近。实际上,如果您将矩阵存储在z顺序数组中,则相邻元素很可能位于同一内存页上。

最好的接近映射是Hilbert曲线,但计算成本很高。更好的选择可能是z曲线(Morton-Order)。它提供了良好的接近性,并且计算速度快。

z曲线:从本质上讲,为了获得排序,你必须将x和y坐标的位交错成一个单独的值,称为"z值"。这个z值决定了在矩阵值列表中的位置(如果使用数组,甚至只是数组中的索引)。对于完全填充的矩阵(使用每个单元格),z值是连续的。相反,您可以在列表中取消交错位置(=数组索引)并获得您的x/y坐标。

两个值的交错位非常快,甚至有专门的CPU指令用很少的周期来做这件事。如果您找不到这些(目前我找不到),您可以简单地使用一些小技巧。

实际上,数据结构不是微不足道的,特别是在涉及优化时。

有两个主要问题需要解决:数据内容和数据使用。数据内容是数据中的值,而用法是数据的存储、检索方式和频率。

数据内容

是否访问了所有的值?经常吗?
不经常访问的数据可以推送到较慢的媒体上,包括文件。将快速内存(如数据缓存)留给频繁访问的数据。

数据是否相似?有规律吗?
对于大量数据相同的矩阵,有一些替代的表示方法(例如稀疏矩阵或下三角形矩阵)。对于大型矩阵,也许执行一些检查并返回常量值可能更快或更有效。

数据使用

数据使用是决定数据有效结构的关键因素。即使是矩阵。

例如,对于频繁访问的数据,映射关联数组可能更快。

有时,使用多个局部变量(即寄存器)可能更有效地处理矩阵数据。例如,首先用值加载寄存器(数据获取),使用寄存器进行操作,然后将寄存器存储回内存。对于大多数处理器来说,寄存器是保存数据最快的介质。

数据可能需要重新排列,以便有效地利用数据缓存和缓存行。数据缓存是一个非常靠近处理器核心的高速内存区域。缓存行是数据缓存中的一行数据。一个有效的矩阵可以在每个缓存行中容纳一行或多行。

更有效的方法是对数据缓存行执行尽可能多的访问。倾向于减少重新加载数据缓存的需要(因为索引超出了范围)。

操作是否可以独立执行?
例如,缩放矩阵,其中每个位置乘以一个值。这些操作不依赖于矩阵的其他单元。这允许以并行方式执行操作。如果它们可以并行执行,那么它们可以委托给具有多核的处理器(例如gpu)。

总结

当一个程序是数据驱动的,数据结构的选择是很重要的。在为数据选择结构以及如何对齐数据时,内容和用法是重要的因素。使用和性能需求也将决定访问数据的最佳算法。互联网上已经有很多关于优化数据驱动应用程序和最佳使用数据缓存的文章。