存储缓冲区 - 与 [y][x] 一起使用的 int 数组比使用 [x][y] 快 3 倍?

Storage Buffer - int array used with [y][x] is 3 time faster than using [x][y]?

作为测试项目,我使用计算着色器 (Vulkan) 编写了一个基本的 Consway 生命游戏。基本上:

#define WIDTH 800
#define HEIGHT 600
#define WORKGROUP_SIZE 32

layout (local_size_x = WORKGROUP_SIZE, local_size_y = WORKGROUP_SIZE, local_size_z = 1) in;

layout(binding = 0) readonly buffer buf1 {
   int data[WIDTH][HEIGHT];
} previousBoard;

layout(binding = 1) buffer buf2 {
   int data[WIDTH][HEIGHT];
} nextBoard;

我随机做了一些更改,我注意到如果我使用 data[y][x](来自 gl_GlobalInvocationID.xy)访问数组,我的程序会快 3 倍 而不是使用 data[x][y] 的正常访问(至少在我的计算机(英特尔 UHD 620)上,我有 500 fps 的 [x][y],而 1700 fps 的 [y][x])。

我花了几个小时来隔离此行为,以确保它不是副作用。我什至反汇编了 Spir-v 代码,但没有发现任何有趣的东西可以帮助我理解。这是着色器的差异(使用 [x][y] 和 [y][x]): https://www.diffchecker.com/vFlkEsQp

我完全不明白这里发生了什么。有什么原因可以解释这种性能差距吗?

我不太喜欢使用 [y][x](或者我应该?),那么我是否有其他方法可以使用 [x][y] 实现类似的性能?

这几乎可以肯定是缓存一致性问题。在 GLSL 中,int[WIDTH][HEIGHT]HEIGHT 一维数组 WIDTH int 的数组。即row-major。因此,如果您获取 previousBoard.data[0][0],您将获取一个缓存行(假设 32 字节),其中可能包括第一行的接下来的 7 个元素,以及 none第二行。

您的着色器本身在 2D 模型中执行,在 work-group 中有 32x32 次调用。如果 GPU 同时执行第一行的调用(0,0 到 31,0),它只需要执行 4 次实际内存提取。当然,现在要对所有这些条目执行您的算法,您还需要上一行和下一行,以及右侧内存地址的缓存行。

因此,您总共需要 15 次内存提取。这听起来可能很多。

但是让我们检查一下 GPU 执行第一个 列的 次调用的情况:0,0 到 0,31。那么,你需要多少次提取?您需要 33(底部下方的行 +1),是数字的两倍多。请记住:缓存行是 row-major,而不是 column-oriented。

当然,您需要同样多的缓存行写入。

也就是说,column-first 调用顺序应该能够提高一些性能,因为第二列的调用应该获得与第一列相同的缓存行。但这假设实现将同时执行第二列的调用。如果它决定用更多 work-group 填充它的执行单元(也就是说,它执行第 0 列、第 32 列、第 64 列、第 96 列等),那么你还不如没有缓存。

相比之下,row-first 排序保持合理的缓存一致性,无论其执行顺序如何。

您无法更改 GPU 处理调用的顺序。因此,您应该努力让您的算法尽可能少地关心该排序。

首先,由于您在工作组中的调用之间没有依赖关系,因此您不应该 local_size two-dimensional。您可以使用确切的数字来为硬件找到正确的值,但 16x1 或 32x1 可能会起作用。无法保证调用顺序,但适合波前的工作组中的项目往往会一起执行。所以这将鼓励它以 row-major 的方式工作,执行 0,0; 1,0;等等。

其次,减少您使用的space数量。生命游戏恰好有两种细胞状态。但是您正在使用 thirty-two 位 来存储这两个状态。即使您想避免进行严肃的位操作的痛苦,您至少可以让 uint 的每个字节成为一个单独的单元格。从 uint 中提取第 N 个字节是一个非常简单的过程。

棘手的部分将是写入此类数据,因为您有不同的调用写入单独的数据。但是如果我们假设你在开始之前已经将内存清零,那么你可以使用atomicOr写入值。

第三,对您的数据进行调配。也就是说,不是将其存储为行和列,而是将其存储在块中。您遇到的主要问题是因为您的缓存偏向第一个坐标,但您的 GPU 执行时偏向第二个坐标。

Unswizzled 数据将 (0, 0) 置于字节 0,将 (1, 0) 置于字节 4,将 (0, 1) 置于字节 (4*WIDTH)。使用 swizzling,您要做的是将四个字节 0,0 放入; 1,0; 0,1 和 1,1 都在同一缓存行上。也就是说,(0, 1) 在第 8 个字节,(1, 1) 在第 12 个字节。这样,如果您获取 (1, 1),就可以保证在同一缓存行中获取所有 4 个值.

您可以调整调配模式的大小以获得最佳性能。

除此之外,您甚至可以调配 gl_InvocationID。您可以使调度 one-dimensional 并通过调配矩阵计算调用的 xy 位置,而不是依赖调度的 2D 性质来获取调用的源位置。因此调用 0 将是 (0, 0),调用 1 将是 (1, 0),调用 2 将是 (0, 1),调用 3 将是 (1, 1),依此类推。

如果您投入工作以获得尽可能最佳的数据存储,并使用 swizzling,那么每个缓存行都可以代表一个 8x8 的数据块。这意味着任何连续执行的调用组最多只需要 4 个缓存行的数据(在 4 个块的角落)。此外,这有助于解决写入问题,因为您可以通过对 shared 变量的原子操作构建数据,并在末尾简单地写出值。您安排事情,以便来自不同工作组的两次调用不需要写入相同的值。

这将使一切几乎独立于 GPU 执行。