存储缓冲区 - 与 [y][x] 一起使用的 int 数组比使用 [x][y] 快 3 倍?
Storage Buffer - int array used with [y][x] is 3 time faster than using [x][y]?
作为测试项目,我使用计算着色器 (Vulkan) 编写了一个基本的 Consway 生命游戏。基本上:
- "board" 存储在存储缓冲区内的 int 数组中。
#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 执行。
作为测试项目,我使用计算着色器 (Vulkan) 编写了一个基本的 Consway 生命游戏。基本上:
- "board" 存储在存储缓冲区内的 int 数组中。
#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 执行。