CUDA:在矩阵的不同部分启动对 cuBLAS 的许多并行调用,无需序列化
CUDA: Launching many parallel calls to cuBLAS on different subsections of a matrix, without serializing
在我的应用程序中,我有一个双复数 N*3 矩阵(其中 N 是几千)和一个 3*1 向量,我正在使用 zgemv 形成一个 N*1。
N*3 是更大的 M*3 矩阵的一部分(其中 M 略大于 N,但数量级相同)。
每个线程必须对较大矩阵的不同子部分执行 zgemv 调用。也就是说,每个线程的 N*3 都是不同的。但是所有 N*3 都是由较大的 M*3 的某些部分形成的。
没有足够的内存让每个线程存储一个独立的N*3。此外,M*3 太大而无法放入共享内存。因此,每个线程都必须从 M*3 的单个副本中提取数据。如果没有数百万个线程将内存读取序列化到 M*3 中的相同内存位置,我该如何做到这一点?有没有更有效的方法来解决这个问题?
根据我目前收集到的信息,我可能会考虑两种类型的优化:
将使用 相同 N 子集的操作转换为矩阵-矩阵乘法 (zgemm),而不是多个 zgemv 操作。
GPU 二级缓存的缓存块。
我将使用这些数字以相反的顺序进行讨论:
- 男:~10,000
- N: ~3,000
- cublas zgemv 调用:~1e6
- "typical" 开普勒 L2:1.5MB
一个Nx3矩阵需要大约10,000个元素,每个元素16字节,所以我们称它为160K字节。因此,我们可以将这些子集中的约 5-10 个存储在与 L2 缓存大小相当的内存大小中(不考虑子集的重叠——这会增加子集在 L2 中的驻留)。
M 矩阵中有 (M-N) 个可能唯一的连续 N 行子集。有 1e6 次 zgemv 调用,因此平均每个子集被重复使用 1e6/M-N 次,每次大约 100-150 次。我们可以在建议的 L2 中存储大约 10 个这样的子集,因此我们可以 "chunk" 我们的 1e6 调用到 "chunks" 的 ~1,000 个调用中,所有这些调用都来自同一数据集。
因此我要遵循的流程是:
- 将M*3矩阵传输到设备
- 预先确定每个线程需要的N*3个子集。
- 对相似的子集进行排序或分组
- 将排序后的集合分成缓存大小的块
- 对于每个块,启动一个 CDP 内核,它将产生必要的 zgemv 调用
- 重复上述步骤,直到处理完所有块。
人们可能还想知道是否可以将这种策略(具有相当大的复杂性)扩展到 L1/Texture。不幸的是,我认为 CDP 会混淆你实现这一目标的努力。无论如何,人们很少愿意为 L1 的缓存块投入精力。
要将上述策略扩展到 gemm 案例,一旦您按照所需的特定 N 子集对 zgemv 操作进行排序,您就会将类似的操作分组在一起。如果上述算法正确,则每个特定的 N 子集平均需要大约 100-150 个 gemv 操作。您应该将这些gemv操作的相应向量分组到一个矩阵中,并将100-150个gemv操作转换为单个gemm操作。
这会将您的 ~1e6 zgemv 操作减少到 ~1e4 zgemm 操作。然后您仍然可以缓存块,但是许多这些 zgemm 操作将在 M 中 "adjacent" 并且适合单个缓存块,进入单个 CDP 内核调用,以受益于 L2 缓存重用。
考虑到 GEMM 与 GEMV 的操作强度,完全放弃 CDP 的复杂性可能是有意义的,并且简单地 运行 一个主机循环为特定的 N 子集调度 ZGEMM 调用。该主机循环将迭代 M-N 循环。
在我的应用程序中,我有一个双复数 N*3 矩阵(其中 N 是几千)和一个 3*1 向量,我正在使用 zgemv 形成一个 N*1。
N*3 是更大的 M*3 矩阵的一部分(其中 M 略大于 N,但数量级相同)。
每个线程必须对较大矩阵的不同子部分执行 zgemv 调用。也就是说,每个线程的 N*3 都是不同的。但是所有 N*3 都是由较大的 M*3 的某些部分形成的。
没有足够的内存让每个线程存储一个独立的N*3。此外,M*3 太大而无法放入共享内存。因此,每个线程都必须从 M*3 的单个副本中提取数据。如果没有数百万个线程将内存读取序列化到 M*3 中的相同内存位置,我该如何做到这一点?有没有更有效的方法来解决这个问题?
根据我目前收集到的信息,我可能会考虑两种类型的优化:
将使用 相同 N 子集的操作转换为矩阵-矩阵乘法 (zgemm),而不是多个 zgemv 操作。
GPU 二级缓存的缓存块。
我将使用这些数字以相反的顺序进行讨论:
- 男:~10,000
- N: ~3,000
- cublas zgemv 调用:~1e6
- "typical" 开普勒 L2:1.5MB
一个Nx3矩阵需要大约10,000个元素,每个元素16字节,所以我们称它为160K字节。因此,我们可以将这些子集中的约 5-10 个存储在与 L2 缓存大小相当的内存大小中(不考虑子集的重叠——这会增加子集在 L2 中的驻留)。
M 矩阵中有 (M-N) 个可能唯一的连续 N 行子集。有 1e6 次 zgemv 调用,因此平均每个子集被重复使用 1e6/M-N 次,每次大约 100-150 次。我们可以在建议的 L2 中存储大约 10 个这样的子集,因此我们可以 "chunk" 我们的 1e6 调用到 "chunks" 的 ~1,000 个调用中,所有这些调用都来自同一数据集。
因此我要遵循的流程是:
- 将M*3矩阵传输到设备
- 预先确定每个线程需要的N*3个子集。
- 对相似的子集进行排序或分组
- 将排序后的集合分成缓存大小的块
- 对于每个块,启动一个 CDP 内核,它将产生必要的 zgemv 调用
- 重复上述步骤,直到处理完所有块。
人们可能还想知道是否可以将这种策略(具有相当大的复杂性)扩展到 L1/Texture。不幸的是,我认为 CDP 会混淆你实现这一目标的努力。无论如何,人们很少愿意为 L1 的缓存块投入精力。
要将上述策略扩展到 gemm 案例,一旦您按照所需的特定 N 子集对 zgemv 操作进行排序,您就会将类似的操作分组在一起。如果上述算法正确,则每个特定的 N 子集平均需要大约 100-150 个 gemv 操作。您应该将这些gemv操作的相应向量分组到一个矩阵中,并将100-150个gemv操作转换为单个gemm操作。
这会将您的 ~1e6 zgemv 操作减少到 ~1e4 zgemm 操作。然后您仍然可以缓存块,但是许多这些 zgemm 操作将在 M 中 "adjacent" 并且适合单个缓存块,进入单个 CDP 内核调用,以受益于 L2 缓存重用。
考虑到 GEMM 与 GEMV 的操作强度,完全放弃 CDP 的复杂性可能是有意义的,并且简单地 运行 一个主机循环为特定的 N 子集调度 ZGEMM 调用。该主机循环将迭代 M-N 循环。