2694 字
13 分钟
CUDA C编程入门笔记(3)

内存类型#

前面我们已经学过了,用cudaMalloc分配全局显存。

内存还有几个类型:

内存类型在哪里谁能访问速度
寄存器SM片上单个线程私有最快
共享内存SM片上同一Block内所有线程很快
本地内存显存(全局内存区域)单个线程私有
全局内存显存所有线程
常量内存显存(有缓存)所有线程只读较快

本地内存#

全局内存不用说了,前面动态分配的就是。下面是本地内存。

本地内存逻辑上属于某个线程私有,实际上和全局内存一样慢。什么是本地内存?简单讲,就是单个线程的一些变量,在寄存器里放不下了,就会用到本地内存。

(写作本地内存,逻辑上我们当成单个线程私有的,其实就是给物理上扔到显存里,读取很慢)

共享内存#

共享内存是用__shared__修饰的,类似CPU一级缓存,但是不同的是,是可以自己开辟空间的。也就是说,我可以直接指定在共享内存中开一个数组用来存东西,同一个Block中读取速度比较快。

常量内存#

我们知道,全局内存虽然大,但是很慢啊。但是如果我们要给所有线程一块数据,只读不写,有没有办法加速?

常量内存。

常量内存用__constant__修饰,声明在核函数外面,也就是全局作用域。

__constant__ float filter[16]; // 声明常量内存

我们知道,正常的C语言,写常量是直接在声明里写。

const int a = 16;

不过,这里声明的是在显存里的,就不能直接像C语言一样直接写。

所以要用专门的函数写入:

cudaMemcpyToSymbol(filter, h_filter, 16 * sizeof(float));

这写完后,在kernel执行期间,GPU只能读不能写。

这有什么好处呢?

快。不过这里的快是有要求的。GPU有专门的常量缓存。如果同一个Warp里的32个线程,同时读的是同一个地址,那只需要读一次,广播给所有线程,速度接近寄存器。

NOTE

我们可能会想,在用CUDA C的时候,诸如神经网络的层数layer_size之类的,从前我们会这样写:

train_kernel_skipgram<<<grid, block>>>(
d_syn0, d_syn1neg, d_batch,
BATCH_SIZE,
layer1_size, // ← 这些标量参数
alpha
);

有了常量内存后,直接放常量内存,会不会更快?

不会。

因为CUDA的核函数参数,本来就自动存在常量内存里

那什么时候才值得手动用 __constant__

假设我们有一个什么东西要查表,就可以这样扔进常量内存里。

而且,常量内存要求的比较苛刻,如果是随机采样的访问查表,也不优,反而可能比全局内存更慢(常量内存不擅长处理分散读取)。

所以常量内存的使用条件其实挺苛刻的,同时满足三点才划算:

  1. 只读
  2. 数据小于64KB(常量内存只有这么大)
  3. 访问模式是”所有线程读同一地址”(广播)

全局内存#

前面我们学过可以动态分配显存,那可以静态分配吗?

可以。

__device__ 可以用来声明变量,这样声明的变量住在全局内存里,但是是静态分配的(编译时就确定大小)。

__device__ float d_alpha; // 单个标量
__device__ float d_table[1000]; // 固定大小的数组

从CPU读写 __device__ 变量,不能直接赋值,要用专门的函数:

__device__ float d_alpha = 0.025f;
// CPU往里写
float val = 0.01f;
cudaMemcpyToSymbol(d_alpha, &val, sizeof(float));
// CPU从里读
float result;
cudaMemcpyFromSymbol(&result, d_alpha, sizeof(float));

但实际上这个写法,很少用。

因为访问速度和 cudaMalloc 分配的一样,都是全局内存,没有任何性能优势。而且大小编译时就要确定,不灵活。

内存的加速问题#

固定内存#

我们学操作系统的时候就知道,操作系统为了管理内存,普通内存都是可以换页的。

当内存紧张时,OS可以把这块数据临时挪到硬盘上(swap),腾出物理内存给别的程序用。

如果需要大量的GPU向CPU传输,可以改成固定内存。也就是用前面的cudaMallocHost来分配内存。

比如:

int *corpus = (int *)malloc(corpus_size * sizeof(int));
// 改成
int *corpus;
cudaMallocHost(&corpus, corpus_size * sizeof(int));

零拷贝内存#

普通的数据传输流程是这样的:

CPU内存 → cudaMemcpy → GPU显存 → 核函数使用

这里的cudaMemcpy需要显式传输,占用时间。

零拷贝内存的思路是:跳过这个传输步骤,让GPU直接读CPU内存

float *h_data;
cudaHostAlloc(&h_data, size, cudaHostAllocMapped); // 分配零拷贝内存
float *d_data;
cudaHostGetDevicePointer(&d_data, h_data, 0); // 获取GPU视角的指针
// 核函数直接用d_data,不需要cudaMemcpy
kernel<<<grid, block>>>(d_data);

不过,这里有个问题,零拷贝的意思是不需要显式调用cudaMemcpy,但数据还是要通过PCI-E总线传输的,这个物理过程跑不掉。

众所周知,PCI-E总线很慢,比GPU显存带宽慢,所以其实性能上是不优的。

什么时候用呢?

适合:

  1. 数据量太大,GPU显存根本放不下
  2. 数据只读一遍,没必要先传过去再读
  3. CPU和GPU需要共享同一块数据,频繁同步

内存访问模式#

当一个指令要取内存里的数据的时候,我们有成千上万个指令在并发。难道一个个排队取吗?

不是的。

GPU访问全局内存,不是一个线程一个线程取,是一次取一大块。

简单来说,当一个warp(32个线程)要访问全局内存的时候,硬件会把这些访问合并起来,合并成尽量少点内存事务,每次事务取32字节、64字节或者128字节。

取的次数越少,速度越快。

合并访问、非合并访问#

最理想的情况,是这样的:32个线程,访问连续的32个float(每个4字节,共128字节),硬件一次事务就全取完了。

// 假设 blockDim.x = 32,一个warp
int i = threadIdx.x;
float val = data[i]; // 线程0取data[0],线程1取data[1]...线程31取data[31]

线程: 0 1 2 3 4 5 … 31 地址: [0][1][2][3][4][5]...[31] ,连续!一次搞定

这叫合并访问,效率最高。

非合并访问就很坏了。

比如,跨步访问:

int i = threadIdx.x;
float val = data[i * 2]; // 线程0取[0],线程1取[2],线程2取[4]...

这取的地址是[0] [2] [4] [6] ...,有间隔啊!

每隔一个元素,硬件没法合并(不能说完全无法合并,但是势必要浪费一半),需要多次事务,带宽浪费一半。

更坏的是随机访问。

float val = data[random_index[i]]; // 每个线程访问随机地址

32个线程触发32次独立事务,比合并访问慢32倍。

对齐访问#

先从CPU讲起。假设有一个结构体:

struct Example {
char a; // 1字节
int b; // 4字节
};

这个结构体占多少个字节?

看上去是5个字节是吗?但并不是,sizeof(Example)其实是8。

因为CPU访问内存时,硬件要求(或强烈偏好)一个N字节的数据类型,其起始地址必须是N的倍数。

int是4字节,所以它的地址必须是4的倍数。编译器会在a后面自动插入3字节的padding,让b对齐到偏移量4的位置。

(至于结构体的对齐要求,是其成员中最大的那个对齐要求。)

一般来说,在我们写普通C代码的时候,编译器会自动插入padding,自动把变量对齐。

如果要看见不对齐的效果,可以尝试用指针:

#include <stdio.h>
#include <string.h>
int main() {
char buf[8];
int *p = (int *)(buf + 1); // 故意让int指针指向一个非4对齐的地址
*p = 42;
printf("%d\n", *p);
return 0;
}

在x86上这段代码能跑,输出42,但访问速度会比对齐的慢——因为一次内存读取跨越了两个cache line边界,硬件需要读两次再拼起来。

在某些ARM芯片上(比如早期的嵌入式ARM),这段代码会直接触发SIGBUS崩溃。现代ARM(比如手机上的)一般也改成不崩但变慢了。

然后再看GPU上的对齐。

我们前面知道了,GPU一次内存事务是32字节、64字节或128字节,而且这个事务的起始地址必须是事务大小的整数倍。

比如一个warp的32个线程,每人读一个float(4字节),总共128字节。如果起始地址是128的倍数,硬件一次事务搞定。如果起始地址不对齐,比如偏移了4字节,那这128字节横跨了两个128字节的事务边界,硬件就要发两次事务,带宽浪费接近一半。

线程束洗牌指令#

如果两个线程交换数据,在kepler架构之前,必须要通过共享内存。一个写入,一个读。

就不能直接读吗?

可以。

洗牌指令(Shuffle Instruction):它允许同一个 Warp 内的线程,直接读取对方的寄存器

既然要读别人的寄存器,总得知道别人是谁。这就涉及到了一个东西——束内线程(Lane)

一个 Warp 永远是 32 个线程,这就像一排有 32 个座位的连椅。

  • threadIdx.x 是你在整个大教室(Block)里的绝对编号。
  • laneID(束内线程索引) 就是你在这排 32 个连椅上的“相对座位号”,范围永远是 [0, 31]

在一维的线程块中可以这样计算:

laneID = threadIdx.x % 32;
warpID = threadIdx.x / 32;

不同洗牌指令#

广播机制:__shfl#

__shfl(val, 2),就是所有人都把 laneID=2 的那个人的 val 复制到自己这里。

向上看齐:__shfl_up#

每个人都去读比自己座位号 delta 的人的数据。

比如 __shfl_up(val, 2),也就是去找左边隔一个座位的人拿数据(如图 5-21 所示,箭头向右指,数据向右移)。最左边没有人的那些线程,数据保持不变。

向下看齐:__shfl_down#

每个人去读比自己座位号 delta 的人的数据。比如 __shfl_down(val, 2),去找右边隔一个座位的人拿数据,数据整体向左移(如图 5-22 所示)。这个指令是你 Word2Vec 代码里用的!

蝴蝶交换:__shfl_xor#

通过按位异或(XOR)来配对交换数据。这个模式非常适合 FFT(快速傅里叶变换)这种交叉运算。

CUDA C编程入门笔记(3)
https://fuwari.vercel.app/posts/course/cuda-c/cuda-3/
作者
wegret
发布于
2026-01-03
许可协议
CC BY-NC-SA 4.0