【问题标题】:CUDA per-thread arrays with different types具有不同类型的 CUDA 每线程数组
【发布时间】:2019-10-27 02:00:19
【问题描述】:

我的 CUDA 内核的每个实例(即每个线程)都需要三个不同类型的私有数组。

例如

__global__ void mykernel() {
    type1 a[aLen];
    type2 b[bLen];
    type3 c[cLen];

    ...
}

这些类型的大小在编译时之前是未知的,长度aLenbLencLen 是动态的。

当然,我必须为整个块分配一个共享内存实例。

void caller() {
    int threadsPerCUDABlock = ...
    int CUDABlocks = ...

    int threadMemSize = 
        aLen*sizeof(type1) + bLen*sizeof(type2) + cLen*sizeof(type3);

    int blockMemSize = threadsPerCUDABlock * threadMemSize;

    mykernel <<< CUDABlocks, threadsPerCUDABlock, blockMemSize >>>();
}

然后每个线程的任务是确定共享内存的哪个分区是它的私有内存空间,以及如何将其划分为 3 种类型的子数组。在本例中,我将共享内存数组组织为具有结构:

[ thread0_a, thread0_b, thread0_c,  thread1_a, ...]

我不确定如何最好地在内核中解压这个结构。 我已经尝试传递每个线程的私有空间的字节数,最初假设内存空间是 1 字节类型,如 char

mykernel <<< CUDABlocks, threadsPerCUDABlock, blockMemSize >>>(threadMemSize);
__global__ void mykernel(int threadMemSize) {

    extern __shared__ char sharedMem[];

    char* threadMem = &sharedMem[threadMemSize*threadIdx.x]
    type1 *a = (type1*) threadMem;
    type2 *b = (type2*) &a[aLen];
    type3 *c = (type3*) &b[bLen];

    ...
}

这没有奏效(虽然没有任何错误,但很难调试),但我不相信它在原则上应该有效。例如,我不能保证 type1type2type3 类型的大小会严格减小。

那么,一般来说,这样做的正确范例是什么?也就是说,解压多个不同类型和大小的每个线程数组的既定方法?

【问题讨论】:

  • 当然,您的方法的一个问题是 CUDA 中对自然对齐的要求。我怀疑如果您违反了这一点,那么通过使用cuda-memcheck 运行您的代码可以发现这种违规行为。不确定“没有任何错误”是什么意思。也许您没有正确检查错误。我不确定我会建议以这种方式使用共享内存。即使你让它工作,也有可能发生银行冲突。如果您需要可变大小的暂存器,我的建议是使用全局内存,但要为合并访问安排存储模式。
  • 您也可以在共享内存中以大致相同的方式做一些事情,同时尝试使用交错存储来避免银行冲突。而且我不确定是否有一个被广泛认可的“正确范式”。
  • 您能否为此提供快速代码 sn-p(或示例链接)?
  • 共享内存比寄存器慢得多。将您的数组存储在全局内存中,以合并的方式读取并使用寄存器混洗来共享目的。这样会快很多
  • 这听起来像是一个非常糟糕的主意,即使它可以工作

标签: c++ arrays cuda


【解决方案1】:

预赛

通常,人们出于性能原因对 GPU 计算感兴趣 - 以使他们的代码运行得更快。因此,我们会在尝试做出决定时将性能作为指导。

我认为您在问题中提供的草图会遇到的问题之一是natural alignment requirement in CUDA。选择任意指针并将其类型转换为不同的类型可能会与此冲突。如果您的代码中有这样的问题,cuda-memcheck 工具应该能够发现它。

在 C++ 中放置线程私有数组的典型位置是 local 内存,我认为 CUDA 也不例外。但是,至少 CUDA C++ 不支持variable-length arrays。在您的问题中,您草拟了使用共享内存作为代理的草图。您的想法的一个含义(我假设)是,尽管这些数组的大小在编译时是未知的,但必须有一个大小上限,因为共享内存可能会限制每个线程块低至 48KB。因此,如果线程块中有 1024 个线程,则每个线程的最大组合数组大小将限制为 48 个字节。每个块有 512 个线程,您可以想象每个线程有 96 个字节。如果您使用共享内存,这将是由于共享内存限制造成的。

因此,另一种方法(如果您可以遵守这些下限)将简单地设置所需的本地内存上限,并为每个线程静态定义该大小(或 3)的本地内存数组。单个阵列必须在各种阵列之间进行分区,并注意前面提到的对齐。但是考虑到您的方法建议的小尺寸(例如总共约 96 个字节),使用上限固定大小的本地数组(不是共享内存)可能是方便的。

CUDA 中的本地内存最终由与全局内存相同的物理资源(GPU DRAM 内存)支持。然而,这种安排是这样的,如果每个线程正在访问它们自己的本地内存中的特定元素,那么跨线程的效果将等同于合并访问,如果该访问需要由 DRAM 提供服务。这意味着每个线程的本地存储以某种方式交错。如果我们想出自己的可变长度数组实现,出于性能原因,这种交错特性也是我们需要注意的。它同样适用于全局内存代理(以启用合并)或共享内存代理(以避免银行冲突)。

除了出于性能原因需要交错访问之外,更喜欢共享内存实现的一个可能的性能原因是,广泛使用共享内存会对占用率产生负面影响,因此对于表现。这个话题在很多其他地方都有介绍,所以我不会在这里进一步深入。

实现

本地内存

如上所述,我相信关于您使用共享内存的建议的隐含假设之一是,所需数组的实际大小必须有一些(相当小的)上限。如果是这种情况,使用分配有上限大小的 3 个数组可能是方便的:

const int Max_aLen = 9;
const int Max_bLen = 5;
const int Max_cLen = 9;
__global__ void mykernel() {
    type1 a[Max_aLen];
    type2 b[Max_bLen];
    type3 c[Max_cLen];

    ...
}

最多使用例如在我看来,本地内存每个线程 8kbytes 不应该是一个主要问题,但这可能取决于您的 GPU 和内存大小,analysis mentioned/linked below 应该表明任何问题。当然是低水平/限制,例如每个线程约 96 个字节应该不是问题。

全局内存

我相信最简单和最灵活的方法是通过全局内存和传递给内核的指针为此类可变长度数组提供存储。这允许我们通过例如为每个数组分配存储空间。 cudaMalloc,我们可以单独处理单独的数组,我们需要关注的对齐要求相对较少。由于我们假装这些全局数组将被用作线程私有的,因此我们将希望安排我们的索引以创建每个线程的交错存储/访问,这将有助于合并。对于您的 3 数组示例,它可能看起来像这样:

#include <stdio.h>

typedef unsigned type1;
typedef char     type2;
typedef double   type3;

__global__ void mykernel(type1 *a, type2 *b, type3 *c) {

  size_t stride = (size_t)gridDim.x * blockDim.x;
  size_t idx = (size_t)blockIdx.x*blockDim.x+threadIdx.x;
  a[7*stride+idx] = 4;    // "local"  access to a
  b[0*stride+idx] = '0';  // "local"  access to b
  c[3*stride+idx] = 1.0;  // "local"  access to c
}

int main(){
  // 1D example
  type1 *d_a;
  type2 *d_b;
  type3 *d_c;
  // some arbitrary choices to be made at run-time
  size_t alen = 27;
  size_t blen = 55;
  size_t clen = 99;
  int nTPB = 256;
  int nBLK = 768;
  size_t grid = (size_t)nBLK*nTPB;
  // allocate
  cudaMalloc(&d_a, alen*grid*sizeof(type1));
  cudaMalloc(&d_b, blen*grid*sizeof(type2));
  cudaMalloc(&d_c, clen*grid*sizeof(type3));
  // launch
  mykernel<<<nBLK, nTPB>>>(d_a, d_b, d_c);
  cudaDeviceSynchronize();
}

对这种方法的一个可能的批评是,它可能会比本地内存方法消耗更多的设备内存(它也可能消耗更少,具体取决于grid size relative to GPU type)。但是,这可以通过grid-stride looping 等方法限制网格大小来管理。

共享内存

由于动态分配的共享内存只有一个指向共享内存的指针,如果我们对共享内存做一些事情,我们将不得不特别注意对齐。以下是分配和定位正确对齐的指针所需的计算类型示例:

#include <stdio.h>

typedef unsigned type1;
typedef char     type2;
typedef double   type3;

__global__ void mykernel(int b_round_up, int c_round_up) {

  extern __shared__ char sdata[];
  type1 *a = (type1 *)sdata;
  type2 *b = (type2 *)(sdata + b_round_up);
  type3 *c = (type3 *)(sdata + c_round_up);
  size_t stride = blockDim.x;
  size_t idx = threadIdx.x;
  a[7*stride+idx] = 4;    // "local"  access to a
  b[0*stride+idx] = '0';  // "local"  access to b
  c[3*stride+idx] = 1.0;  // "local"  access to c
}

int main(){
  // 1D example
  // some arbitrary choices to be made at run-time
  int alen = 9;
  int blen = 5;
  int clen = 9;
  int nTPB = 256;
  int nBLK = 1;
  // calculate aligned shared mem offsets
  int b_round_up = (((nTPB*alen*sizeof(type1) + sizeof(type2)-1)/sizeof(type2))*sizeof(type2)); // round up
  int c_round_up = (((b_round_up + nTPB*blen*sizeof(type2) + sizeof(type3)-1)/sizeof(type3))*sizeof(type3)); // round up
  // allocate + launch
  mykernel<<<nBLK, nTPB, c_round_up + nTPB*clen*sizeof(type3)>>>(b_round_up,c_round_up);
  cudaDeviceSynchronize();
}

我并不是说我的任何代码都没有缺陷,但是从相对代码复杂性的角度来看,您可以看到本地或全局选项是首选。我无法轻易想象共享内存实现更受欢迎的原因或情况。

【讨论】:

  • 这是一个非常棒的解释,非常感谢!事实上,我无法为每个线程设置内存上限,因此必须使用全局内存。
【解决方案2】:

我的 CUDA 内核的每个实例(即每个线程)

线程不是内核的实例。线程是块的一部分,块形成网格,网格运行内核函数。

我的 CUDA 内核的每个 [线程] 都需要三个私有数组

是吗?我对此表示怀疑。我猜您的计算问题可以重新表述,以便许多线程协作并处理单个这样的数组三元组(或者可能是几个这样的三元组)。

然后每个线程的任务就是确定共享内存的哪个分区是它的私有内存空间

不一定。即使您坚持使用 3 个私有数组,您也可以将它们放在“本地内存”中(这实际上只是线程私有的全局内存)。如果每个线程只使用少量的本地内存,它可能都适合 L2 缓存,这 - 虽然最佳地比共享内存慢 - 有时由于各种原因(例如共享内存库冲突)是有意义的。

或者,如果您的小数组的整体大小真的很小,您可以考虑将它们粘贴到寄存器中。这意味着您不能对它们使用索引访问(这是一个非常严格的条件),但是寄存器速度非常快,而且数量很多——例如,超过了共享内存的大小。

无论您选择什么内存空间 - 始终测量,并使用分析器确定这是否是您的瓶颈;是否影响占用率,或GPU核心功能单元的有效使用等。如果您对得到的结果不满意,请尝试其他选项之一。

我将共享内存数组组织成结构:

[ thread0_a, thread0_b, thread0_c,  thread1_a, ...]

是的……这可能不是一个好的选择。你看,共享内存是按银行排列的;如果您的经线通道(经线中的线程)尝试从 same 银行访问数据,这些访问将被序列化。例如,假设每个数组的大小是 128 字节的倍数。如果一个 warp 中的所有线程都通过访问 a[0] 开始它们的工作(这种情况经常发生) - 它们都将尝试访问 same 银行,您将获得 32 倍的减速。

如果扭曲中的通道倾向于访问数组中的相同索引,最好交错数组,即使用以下排列(使用您的说明方式):

[ thread_0_a[0], thread_1_a[0], thread_2_a[0], ... thread_n_a[0], thread_0_a[1], ... ]

这还有一个额外的好处,您只需要知道线程私有数组的最大长度和线程数,就可以准确地确定每个线程的数组从哪里开始。另一方面,这意味着您可以“打包”更少的数组。但这还不错!每个块使用更少的扭曲,你应该仍然可以。

我应该提到@RobertCrovella 的回答提出了类似的观点。

警告:请注意,我在这部分答案的开头确实说过 if。线程的访问模式可能不同。如果是这样,隔行扫描可能对您没有帮助。再次分析和测量检查可能是个好主意。


由于我的回答提出了更深远的改变(而且我没有时间),所以我不会详细说明。如果我在某个地方不清楚,请随时发表评论。

【讨论】:

    猜你喜欢
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 2018-07-31
    • 2015-12-19
    • 1970-01-01
    • 2023-03-20
    • 2019-07-24
    • 2015-07-10
    相关资源
    最近更新 更多