CUDA编程(4):共享内存:减少全局内存访问、合并全局内存访问

目录

[1 共享内存概述](#1 共享内存概述)

[1.1 共享内存](#1.1 共享内存)

[1.2 共享内存分配](#1.2 共享内存分配)

[1.3 为什么要使用共享内存](#1.3 为什么要使用共享内存)

[2 减少全局内存访问----以规约求和为例](#2 减少全局内存访问----以规约求和为例)

[2.1 使用静态共享内存](#2.1 使用静态共享内存)

[2.2 使用动态共享内存](#2.2 使用动态共享内存)

[3 合并全局内存访问----以矩阵转置为例](#3 合并全局内存访问----以矩阵转置为例)

[3.1 合并访问与非合并访问](#3.1 合并访问与非合并访问)

[3.2 使用全局内存,不使用共享内存的矩阵转置](#3.2 使用全局内存,不使用共享内存的矩阵转置)

[3.3 使用共享内存的矩阵转置](#3.3 使用共享内存的矩阵转置)

[3.4 避免共享内存的bank冲突](#3.4 避免共享内存的bank冲突)

参考文献:


1 共享内存概述

GPU内存按照类型(物理上的位置)可以分为

  • 板载内存
  • 片上内存

全局内存是较大的板载内存,延迟高,共享内存是片上的较小的内存,延迟低,带宽高。前面我我们讲过工厂的例子,全局内存就是原料工厂,要用车来运输原料,共享内存是工厂内存临时存放原料的房间,取原料路程短速度快。

共享内存是一种可编程的缓存,共享内存通常的用途有:

  1. 块内线程通信的通道
  2. 用于全局内存数据的可编程管理的缓存
  3. 告诉暂存存储器,用于转换数据来优化全局内存访问模式

1.1 共享内存

共享内存(shared memory,SMEM)是GPU的一个关键部分,物理层面,每个SM都有一个小的内存池,这个内存池被此SM上执行的线程块中的所有线程所共享。共享内存使同一个线程块中可以相互协同,便于片上的内存可以被最大化的利用,降低回到全局内存读取的延迟。

共享内存是被我们用代码控制的,这也是是他称为我们手中最灵活的优化武器。

结合我们前面学习的一级缓存,二级缓存,今天的共享内存,以及后面的只读和常量缓存,他们的关系如下图:

SM上有共享内存,L1一级缓存,ReadOnly 只读缓存,Constant常量缓存。所有从Dram全局内存中过来的数据都要经过二级缓存,相比之下,更接近SM计算核心的SMEM,L1,ReadOnly,Constant拥有更快的读取速度,SMEM和L1相比于L2延迟低大概20~30倍,带宽大约是10倍。

共享内存是在他所属的线程块被执行时建立,线程块执行完毕后共享内存释放,线程块和他的共享内存有相同的生命周期。

对于每个线程对共享内存的访问请求

  1. 最好的情况是当前线程束中的每个线程都访问一个不冲突的共享内存,具体是什么样的我们后面再说,这种情况,大家互不干扰,一个事务完成整个线程束的访问,效率最高
  2. 当有访问冲突的时候,具体怎么冲突也要后面详细说,这时候一个线程束32个线程,需要32个事务。
  3. 如果线程束内32个线程访问同一个地址,那么一个线程访问完后以广播的形式告诉大家

我们刚才说的共享内存的生命周期是和其所属的线程块相同的,这个共享内存是编程模型层面上的。物理层面上,一个SM上的所有的正在执行的线程块共同使用物理的共享内存,所以共享内存也成为了活跃线程块的限制,共享内存越大,或者块使用的共享内存越小,那么线程块级别的并行度就越高。

1.2 共享内存分配

分配和定义共享内存的方法有多种,动态的声明,静态的声明都是可以的。可以在核函数内,也可以在核函数外(也就是本地的和全局的,这里是说变量的作用域,在一个文件中),CUDA支持1,2,3维的共享内存声明,

声明共享内存通过关键字:

cpp 复制代码
__shared__

声明一个二维浮点数共享内存数组的方法是:

cpp 复制代码
__shared__ float a[size_x][size_y];

这里的size_x,size_y和声明c++数组一样,要是一个编译时确定的数字,不能是变量。

如果想动态声明一个共享内存数组,可以使用extern关键字,并在核函数启动时添加第三个参数。

cpp 复制代码
extern __shared__ int tile[];

在执行上面这个声明的核函数时,使用下面这种配置:

cpp 复制代码
kernel<<<grid,block,isize*sizeof(int)>>>(...);

isize就是共享内存要存储的数组的大小。比如一个十个元素的int数组,isize就是10.

注意,动态声明只支持一维数组。

1.3 为什么要使用共享内存

共享内存有两个作用,

  • 减少核函数中对全局内存的访问次数,实现高效的线程块内部的通信,
  • 提高全局内存访问的合并度

下面分别举例看一下共享内存的这两个作用。

2 减少全局内存访问----以规约求和为例

2.1 使用静态共享内存

这个就可以用数组规约的例子,

先看不用共享内存的版本代码

cpp 复制代码
oid __global__ reduce_global(real *d_x, real *d_y)
{
    const int tid = threadIdx.x;
    real *x = d_x + blockDim.x * blockIdx.x;

    for (int offset = blockDim.x >> 1; offset > 0; offset >>= 1)
    {
        if (tid < offset)
        {
            x[tid] += x[tid + offset];
        }
        __syncthreads();
    }

    if (tid == 0)
    {
        d_y[blockIdx.x] = x[0];
    }
}

然后是使用共享内存的版本

cpp 复制代码
void __global__ reduce_shared(real *d_x, real *d_y)
{
    const int tid = threadIdx.x;
    const int bid = blockIdx.x;
    const int n = bid * blockDim.x + tid;
    __shared__ real s_y[128];
    s_y[tid] = (n < N) ? d_x[n] : 0.0;
    __syncthreads();

    for (int offset = blockDim.x >> 1; offset > 0; offset >>= 1)
    {

        if (tid < offset)
        {
            s_y[tid] += s_y[tid + offset];
        }
        __syncthreads();
    }

    if (tid == 0)
    {
        d_y[bid] = s_y[0];
    }
}

然后,我的理解是

2.2 使用动态共享内存

在前面的核函数中,我们在定义共享内存数组时指定了一个固定的长度(128) 。

我们的程序假定了这个长度与核函数的执行配置参数 block_size (也就是核函数中

的 blockDim.x)是一样的。如果在定义共享内存变量时不小心把数组长度写错了,就有

可能引起错误或者降低核函数性能。

有一种方法可以减少这种错误发生的概率,那就是使用动态的共享内存。将前一个版

本的静态共享内存改成动态共享内存,只需要做以下两处修改:

  1. 在调用核函数的执行配置中写下第三个参数:
cpp 复制代码
<<<grid_size, block_size, sizeof(real) * block_size>>>

前两个参数分别是网格大小和线程块大小,第三个参数就是核函数中每个线程块需要

定义的动态共享内存的字节数。在我们以前所有的执行配置中,这个参数都没有出现,

其实是用了默认值零。

  1. 要使用动态共享内存,还需要改变核函数中共享内存变量的声明方式。例如,
cpp 复制代码
extern __shared__ real s_y[];

它与之前静态共享内存的声明方式

cpp 复制代码
__shared__ real s_y[128];

有两点不同:第一,必须加上限定词 extern;第二,不能指定数组大小。

3 合并全局内存访问----以矩阵转置为例

3.1 合并访问与非合并访问

对全局内存的访问将触发内存事务(memory transaction),也就是数据传输(data trans-

fer)。从费米架构开始,有了 SM 层次的 L1 缓存和设备层次的 L2 缓存,可以用于缓存全局内存的访问。在启用了 L1 缓存的情况下,对全局内存的读取将首先尝试经过 L1 缓存;如果未中,则接着尝试经过 L2 缓存;如果再次未中,则直接从 DRAM 读取。一次数据传输处理的数据量在默认情况下是 32 字节。

关于全局内存的访问模式,有合并(coalesced)与非合并(uncoalesced)之分。合并

访问指的是一个线程束对全局内存的一次访问请求(读或者写)导致最少数量的数据传输,否则称访问是非合并的。定量地说,可以定义一个合并度(degree of coalescing),它等于线程束请求的字节数除以由该请求导致的所有数据传输处理的字节数。如果所有数据传输中处理的数据都是线程束所需要的,那么合并度就是 100%,即对应合并访问。所以,也可以将合并度理解为一种资源利用率。利用率越高,核函数中与全局内存访问有关的部分的性能就更好;利用率低则意味着对显存带宽的浪费。

为简单起见,我们主要以全局内存的读取和仅使用 L2 缓存的情况为例进行下述讨论。在此情况下,一次数据传输指的就是将 32 字节的数据从全局内存(DRAM)通过 32 字节的 L2 缓存片段(cache sector)传输到 SM。考虑一个线程束访问单精度浮点数类型的全局内存变量的情形。因为一个单精度浮点数占有 4 个字节,故该线程束将请求 128 字节的数据。在理想情况下(即合并度为 100% 的情况),这将仅触发 128/32 = 4 次用 L2 缓存的数据传输。

数据传输对数据地址的要求:在一次数据传输中,从全局内存转移到 L2 缓存的一片内存的首地址一定是一个最小粒度(这里是 32 字节)的整数倍。例如,一次数据传输只能从全局内存读取地址为 0-31 字节、32-63 字节、64-95 字节、96-127 字节等片段的数据。如果线程束请求的全内存数据的地址刚好为 0-127 字节或 128-255 字节等,就能与 4 次数据传输所处理的数据完全吻合。这种情况下的访问就是合并访问。

3.2 使用全局内存,不使用共享内存的矩阵转置

cpp 复制代码
__global__ void transpose1(const real *A, real *B, const int N)
{
    const int nx = blockIdx.x * blockDim.x + threadIdx.x;
    const int ny = blockIdx.y * blockDim.y + threadIdx.y;
    if (nx < N && ny < N)
    {
        B[nx * N + ny] = A[ny * N + nx];
    }
}

__global__ void transpose2(const real *A, real *B, const int N)
{
    const int nx = blockIdx.x * blockDim.x + threadIdx.x;
    const int ny = blockIdx.y * blockDim.y + threadIdx.y;
    if (nx < N && ny < N)
    {
        B[ny * N + nx] = A[nx * N + ny];
    }
}

__global__ void transpose3(const real *A, real *B, const int N)
{
    const int nx = blockIdx.x * blockDim.x + threadIdx.x;
    const int ny = blockIdx.y * blockDim.y + threadIdx.y;
    if (nx < N && ny < N)
    {
        B[ny * N + nx] = __ldg(&A[nx * N + ny]);
    }
}

这里N表示矩阵的维度,表示矩阵是一个 N x N 的方阵。

上面的代码即用全局内存做矩阵转置的代码,在核函数 transpose1 中,对矩阵 A 中数据的访问(读取)是顺序的,但对矩阵 B 中数据的访问(写入)不是顺序的。在核函数 transpose2 中,对矩阵 A 中数据的访问(读取)不是顺序的,但对矩阵 B 中数据的访问(写入)是顺序的。在不考虑数据是否对齐的情况下,我们可以说核函数 transpose1 对矩阵 A 和 B 的访问分别是合并的和非合并的,而核函数 transpose2 对矩阵 A 和 B 的访问分别是非合并的和合并的。

3.3 使用共享内存的矩阵转置

cpp 复制代码
__global__ void transpose1(const real *A, real *B, const int N)
{
    __shared__ real S[TILE_DIM][TILE_DIM];
    int bx = blockIdx.x * TILE_DIM;
    int by = blockIdx.y * TILE_DIM;

    int nx1 = bx + threadIdx.x;
    int ny1 = by + threadIdx.y;
    if (nx1 < N && ny1 < N)
    {
        S[threadIdx.y][threadIdx.x] = A[ny1 * N + nx1];
    }
    __syncthreads();

    int nx2 = bx + threadIdx.y;
    int ny2 = by + threadIdx.x;
    if (nx2 < N && ny2 < N)
    {
        B[nx2 * N + ny2] = S[threadIdx.x][threadIdx.y];
    }
}

在矩阵转置的核函数中,最中心的思想是用一个线程块处理一片(tile)矩阵。这里,一片矩阵的行数和列数都是 TILE_DIM = 32。为了利用共享内存改善全局内存的访问方式,我们在第 3 行定义了一个两维的静态共享内存数组 S ,其行、列数与一片矩阵的行、列数一致

其实简单理解就是先把全局的数据放到共享内存中,并且往共享内存放的时候是 S[threadIdx.y][threadIdx.x],然后转置的时候用的是S[threadIdx.x][threadIdx.y],相当于调换顺序是在共享内存中调换的。

3.4 避免共享内存的bank冲突

关于共享内存,有一个内存 bank 的概念值得注意。为了获得高的内存带宽,共享内存在物理上被分为 32 个(刚好等于一个线程束中的线程数目,即内建变量 warpSize 的值)同样宽度的、能被同时访问的内存 bank。我们可以将 32 个 bank 从 0 到 31 编号。在每一个 bank 中,又可以对其中的内存地址从 0 开始编号。为方便起见,我们将所有 bank 中编号为 0 的内存称为第一层内存;将所有 bank 中编号为 1 的内存称为第二层内存。在开普勒架构中,每个 bank 的宽度为 8 字节;在所有其他架构中,每个 bank 的宽度为 4 字节。我们这里不关注开普勒架构。

对于 bank 宽度为 4 字节的架构,共享内存数组是按如下方式线性地映射到内存 bank 的:

共享内存数组中连续的 128 字节的内容分摊到 32 个 bank 的某一层中,每个 bank 负责 4 字节的内容。例如,对一个长度为 128 的单精度浮点数变量的共享内存数组来说,第 0-31 个数组元素依次对应到 32 个 bank 的第一层;第 32-63 个数组元素依次对应到 32 个 bank 的第二层;第 64-95 个数组元素依次对应到 32 个 bank 的第三层;第 96-127 个数组元素依次对应到 32 个 bank 的第四层。也就是说,每个 bank 分摊 4 个在地址上相差 128 字节的数据,

只要同一线程束内的多个线程不同时访问同一个 bank 中不同层的数据,该线程束对

共享内存的访问就只需要一次内存事务(memory transaction)。当同一线程束内的多个

线程试图访问同一个 bank 中不同层的数据时,就会发生 bank 冲突。在一个线程束内对

同一个 bank 中的 n 层数据同时访问将导致 n 次内存事务,称为发生了 n 路 bank 冲突。

最坏的情况是线程束内的 32 个线程同时访问同一个 bank 中 32 个不同层的地址,这将导

致 32 路 bank 冲突。这种 n 很大的 bank 冲突是要尽量避免的。

在前一节的核函数 transpose1 中,定义了一个长度为 32 32 = 1024 的单精度浮点型变量的共享内存数组。我们只讨论非开普勒架构的情形,其中每个共享内存 bank 的宽度为 4 字节。于是,每一层的 32 个 bank 将对应 32 个连续的数组元素;每个 bank 有 32 层数据。从前一节核函数 transpose1 的第 19 行可以看出,同一个线程束中的 32 个线程(连续的 32 个 threadIdx.x 值)将对应共享内存数组 S 中跨度为 32 的数据。也就是说,这 32 个线程将刚好访问同一个 bank 中的 32 个数据。这将导致 32 路 bank 冲突,参见上图。相比之下,第 11 行对共享内存的访问不导致 bank 冲突。

通常可以用改变共享内存数组大小的方式来消除或减轻共享内存的 bank 冲突。例如,

将上述核函数中的共享内存定义修改为如下:

cpp 复制代码
__shared__ real S[TILE_DIM][TILE_DIM + 1];

就可以完全消除第 19 行读取共享内存时的 bank 冲突。这是因为,这样改变共享内存数组

的大小之后,同一个线程束中的 32 个线程(连续的 32 个 threadIdx.x 值)将对应共享内存数组 S 中跨度为 33 的数据。如果第一个线程访问第一个 bank 的第一层,第二个线程则会访问第二个 bank 的第二层(而不是第一个 bank 的第二层);如此等等。于是,这 32 个线程将分别访问 32 个不同 bank 中的数据,所以没有 bank 冲突。

参考文献:

《CUD C编程权威指南》 程润伟 机械工业出版社

GitHub - Tony-Tan/CUDA_Freshman

《CUDA编程:基础与实践》 樊哲勇 清华大学出版社

GitHub - brucefan1983/CUDA-Programming: Sample codes for my CUDA programming book

相关推荐
哦豁灬5 天前
CUDA 学习(3)——CUDA 初步实践
学习·cuda
扫地的小何尚5 天前
NVIDIA TensorRT 深度学习推理加速引擎详解
c++·人工智能·深度学习·gpu·nvidia·cuda
哦豁灬7 天前
CUDA 学习(2)——CUDA 介绍
学习·cuda
拿铁加椰果10 天前
docker 内 pytorch cuda 不可用
pytorch·docker·容器·cuda
极客代码19 天前
Linux IPC:System V共享内存汇总整理
linux·c语言·开发语言·并发·共享内存·通信·system v
System_sleep19 天前
win11编译llama_cpp_python cuda128 RTX30/40/50版本
windows·python·llama·cuda
nuczzz20 天前
NVIDIA k8s-device-plugin源码分析与安装部署
kubernetes·k8s·gpu·nvidia·cuda
真昼小天使daisuki22 天前
最简单的方式:如何在wsl2上配置CDUA开发环境
linux·cuda
Cony_1423 天前
Windows系统中在VSCode上配置CUDA环境
windows·vscode·cmake·cuda