内存事务与合并访存原理
假设一段 CUDA 内核代码中,某个 warp 的 32 个线程各自对全局内存发起了一次 4 字节的读取。你可能会直觉地认为,硬件需要执行 32 次独立的内存访问。但事实远非如此------GPU 的内存子系统会在硬件层面合并这些请求,而理解这一机制的关键,在于「内存事务」(memory transaction)这一概念。
事务粒度:32 / 64 / 128 字节
GPU 全局内存的最小访问单位不是字节,而是一个 内存段(cache line)。NVIDIA GPU 的内存子系统以固定大小的粒度从 DRAM 中获取数据,这个粒度的实际大小取决于 GPU 架构。具体来说,硬件支持三种规格的内存事务:
| 事务大小 | 说明 |
|---|---|
| 32 字节 | 一个段(sector),覆盖 8 个连续的 32 位字 |
| 64 字节 | 两个段,即一个半 cache line |
| 128 字节 | 四个段,即一个完整的 cache line |
这里的关键在于:每次内存事务都至少移动一个段(32 字节)。无论一个 warp 实际需要多少个字节,只要某个地址被某个线程触及,整个段就会被从 DRAM 中取出并交付给 SM。
以 32 字节事务为例,假设某个 warp 中的线程 t0 访问地址 0x1000,线程 t1 访问 0x1004,依次类推直到 0x107C(即 32 个线程连续访问 128 字节的连续地址空间)。硬件会将这 32 个 4 字节请求合并为一个或两个事务,而不是逐一执行 32 次独立的 DRAM 访问。
内存事务(transaction)是硬件层面一次完整的存取操作单元。事务大小决定了「请求的最小代价」------即使你只用一个字节,硬件也要为一个 32 字节的段付出同样的延迟和带宽成本。
合并访存:定义与硬件行为
合并访存(coalesced access) 指的是:一个 warp 中所有线程同时访问的全局内存地址,恰好落在同一内存段或连续的少数几个段 内,从而使硬件能够将这些访问合并为最少数量的内存事务。
硬件层面的处理流程如下:
warp 32 线程
│ t0 → addr[0x1000]
│ t1 → addr[0x1004]
│ t2 → addr[0x1008]
│ ...
│ t31 → addr[0x107C]
↓
内存子系统(Memory Partition)
│ 检查所有请求的地址是否落在同一
│ 段/连续段内
↓
合并结果:1 次 128 字节事务
↓
DRAM 返回该 cache line 的数据
│ 每个线程从返回的数据块中
│ 取出属于自己的 4 字节
图中的核心在于一次 128 字节事务 。如果线程的访问是连续的(每个线程访问相邻的 4 字节),那么 32 个请求就可以完美地映射到一个 cache line 上。但如果访问是跨步的(stride > 4 字节),情况就完全不同了。
非合并访问的事务爆炸
来看一个真实场景。假设一个 warp 访问一个 float 数组,但每个线程读取的是每隔 32 个元素(即 stride = 128 字节)的数据:
c
float *arr; // 全局内存
// 第 i 个线程访问 arr[i * 32]
float val = arr[threadIdx.x * 32];
这 32 个线程的地址分布为:0x1000, 0x1080, 0x1100, ..., 0x1F80。每一个地址都落在不同的 32 字节段中------共横跨 32 个不同的段。硬件需要执行 32 次 32 字节事务,每次事务只使用 4 个字节的数据,其余 28 字节完全浪费。
从带宽利用率的视角来看:
| 访问模式 | 事务数量 | 数据传输总量 | 有效数据 | 利用率 |
|---|---|---|---|---|
| 合并(连续) | 1 次 128B | 128 字节 | 128 字节 | 100% |
| 跨步(stride=32) | 32 次 32B | 1024 字节 | 128 字节 | 12.5% |
有效带宽下降了整整 8 倍。而实际中的性能损失还远不止于此------因为每一次事务都有固定延迟和调度开销,大量的非合并访问会让内存子系统陷入低效能的状态,SM 的 load/store 单元也会被打满,形成流水线堵塞。
工程直觉:合并访存的核心目标是「让每个事务中携带的有效数据尽可能多」。当事务带宽利用率低于 100% 时,每一次未被使用的字节都在隐性地消耗你的实际吞吐量。
理解事务成本的三个层次
要让这个机制真正指导你的优化实践,需要区分三个概念层次:
- 段(sector)级别:32 字节是一个事务的最小物理单位。任何访问都会至少拉取一个完整的段。
- warp 级别 :一个 warp 的所有线程请求会在内存分区中被合并检查。合并策略是「覆盖所有被请求地址的最小段集合」。
- cache line 级别:L2 缓存按 128 字节管理数据。如果多个 warp 访问同一 cache line,L2 可以复用已取回的数据,避免重复访问 DRAM。
实践中的含意很直接:让一个 warp 的线程访问尽可能少的段。最理想的情况是 32 个线程的 4 字节访问正好落在 128 字节连续区间内,此时一个事务搞定一切。如果某个 warp 需要访问 256 字节的连续区域,则会发生两次 128 字节事务------这依然是高效的,因为每个事务的利用率都接近 100%。
这一节的内容为后续优化建立了基石:任何全局内存访问的性能,本质上取决于「它引发了几个事务」而不是「访问了多少字节」 。理解了事务粒度与合并规则,你就掌握了第一个性能判据------接下来要讨论的是,如何在代码层面刻意构造合并的访问模式,以及向量类型 float4、double2 在这其中扮演的关键角色。
非合并访问的影响:AoS vs SoA
仅凭「内存事务」这一概念,我们目前还只看到了硬件层面的快照。真正的分水岭出现在数据布局(data layout)上------同一份数据,以不同方式摆放,可以让同一个 warp 的访存请求从「最优」跌落到「灾难」。这并非抽象的理论推演,而是每一个 CUDA 程序员在编写结构体时就会做出的选择。
两种布局:AoS 与 SoA
假设有一组三维空间中的粒子,每个粒子包含 x, y, z 三个 float 分量。C 语言中,最常见的两种组织方式如下:
c
// AoS(Array of Structures)------ 结构体数组
struct Particle3D {
float x, y, z;
};
// 内存布局:[x0 y0 z0][x1 y1 z1][x2 y2 z2]...
// 同一个粒子的三个分量在内存中相邻
struct Particle3D aos[N];
// 访问所有粒子的 x 分量时:
// 线程 i 访问 aos[i].x ------ 地址间隔为 12 字节(跨步为 3 * 4 字节)
for (int i = 0; i < N; i++) {
float xi = aos[i].x; // 线程 i 的地址 = base + i * 12
// 处理 xi...
}
c
// SoA(Structure of Arrays)------ 数组结构体
struct ParticleSoA {
float x[N];
float y[N];
float z[N];
};
// 内存布局:[x0 x1 x2 ... xn][y0 y1 y2 ... yn][z0 z1 z2 ... zn]
// 所有粒子的同一分量在内存中连续排列
struct ParticleSoA soa;
// 访问所有粒子的 x 分量时:
// 线程 i 访问 soa.x[i] ------ 地址间隔为 4 字节(正好 32 字节对齐)
for (int i = 0; i < N; i++) {
float xi = soa.x[i]; // 线程 i 的地址 = base + i * 4
// 处理 xi...
}
两种布局的字面差异不过是一个「循环在哪个维度上展开」,但它们在 GPU 上引发的内存事务数量截然不同。下面我们分别分析。
AoS:跨步访问的代价
回到第 1 节的事务模型。当 warp 中的 32 个线程执行 aos[i].x 时,每个线程读取 4 字节,但线程间地址间隔是 12 字节(假设 struct 对齐为 4 字节边界,无 padding,则每个粒子占 12 字节连续内存)。
32 个线程的地址范围覆盖了从 base 到 base + 31 * 12 + 3,即 base 到 base + 375 字节 。根据 NVIDIA 的内存事务规则,这 384 字节需要多少个 128 字节事务?答案是 4 个 (384 / 128 = 3,但由于起始地址不对齐,实际需要 4 个段)。而其中真正有用的数据只有 32 * 4 = 128 字节------有效利用率仅为 128512=25%\frac{128}{512} = 25\%512128=25%(4 个 128 字节事务共搬运 512 字节)。
将其推广为一般公式:若跨步为 sss 字节(s>4s > 4s>4),则每个 warp 需要 ⌈32×s+3128⌉\lceil \frac{32 \times s + 3}{128} \rceil⌈12832×s+3⌉ 个事务
- 当 s=12s = 12s=12(如本例):4 个事务
- 当 s=16s = 16s=16(如
float4的 x 分量):8 个事务 - 当 s=256s = 256s=256(如行优先矩阵的列访问):64 个事务
对任意跨步 sss,有效吞吐 约为 128 bytes4×s bytes×理论带宽\frac{128 \text{ bytes}}{4 \times s \text{ bytes}} \times \text{理论带宽}4×s bytes128 bytes×理论带宽。这意味着跨步越大,浪费越严重------因为每个段中只有 4 字节真正被使用,其余全部被丢弃。
SoA:天然合并
现在看 soa.x[i]:32 个线程访问的地址是 base + 0, base + 4, ..., base + 124------恰好覆盖 128 字节连续区间,且 base 满足 128 字节对齐 (数组首元素天然对齐,加上编译器保证)。这正好触发一次 128 字节事务,32 个线程的所有请求全部命中,有效利用率 100%。
更优的情况是,如果 soa.x[i] 配合 float4 向量化(将在第 3 节展开),一个 warp 可以一次读取 512 字节,仍然只产生 4 个 128 字节段------但那是向量类型层面的优化,与布局无关。布局本身,SoA 就已经保证了每个 128 字节段被完整使用。
性能对比:一次可复现的测量
以下是一个完整的基准测试代码,可直接编译运行(要求 CUDA 计算能力 6.0 以上,printf 内核对性能影响可忽略):
cuda
#include <cuda_runtime.h>
#include <stdio.h>
#define N 1 << 20 // 1M 个粒子
struct Particle3D { float x, y, z; };
struct ParticleSoA {
float *x, *y, *z;
};
__global__ void kernel_aos(struct Particle3D *p, float *sum) {
int i = blockIdx.x * blockDim.x + threadIdx.x;
if (i < N) sum[i] = p[i].x + p[i].y + p[i].z; // 跨步: 12 字节
}
__global__ void kernel_soa(float *x, float *y, float *z, float *sum) {
int i = blockIdx.x * blockDim.x + threadIdx.x;
if (i < N) sum[i] = x[i] + y[i] + z[i]; // 连续: 4 字节步长
}
int main() {
// 分配主机端内存
struct Particle3D *h_aos = (struct Particle3D*)malloc(N * sizeof(struct Particle3D));
float *h_x = (float*)malloc(N * sizeof(float));
float *h_y = (float*)malloc(N * sizeof(float));
float *h_z = (float*)malloc(N * sizeof(float));
float *h_sum = (float*)malloc(N * sizeof(float));
for (int i = 0; i < N; i++) {
h_aos[i].x = h_x[i] = 1.0f;
h_aos[i].y = h_y[i] = 2.0f;
h_aos[i].z = h_z[i] = 3.0f;
}
// 分配设备端内存
struct Particle3D *d_aos;
float *d_x, *d_y, *d_z, *d_sum;
cudaMalloc(&d_aos, N * sizeof(struct Particle3D));
cudaMalloc(&d_x, N * sizeof(float));
cudaMalloc(&d_y, N * sizeof(float));
cudaMalloc(&d_z, N * sizeof(float));
cudaMalloc(&d_sum, N * sizeof(float));
// 拷贝数据
cudaMemcpy(d_aos, h_aos, N * sizeof(struct Particle3D), cudaMemcpyHostToDevice);
cudaMemcpy(d_x, h_x, N * sizeof(float), cudaMemcpyHostToDevice);
cudaMemcpy(d_y, h_y, N * sizeof(float), cudaMemcpyHostToDevice);
cudaMemcpy(d_z, h_z, N * sizeof(float), cudaMemcpyHostToDevice);
int threads = 256;
int blocks = (N + threads - 1) / threads;
// 计时 AoS 版本
cudaEvent_t start, stop;
cudaEventCreate(&start); cudaEventCreate(&stop);
cudaEventRecord(start);
kernel_aos<<<blocks, threads>>>(d_aos, d_sum);
cudaEventRecord(stop);
cudaEventSynchronize(stop);
float ms_aos;
cudaEventElapsedTime(&ms_aos, start, stop);
// 计时 SoA 版本
cudaEventRecord(start);
kernel_soa<<<blocks, threads>>>(d_x, d_y, d_z, d_sum);
cudaEventRecord(stop);
cudaEventSynchronize(stop);
float ms_soa;
cudaEventElapsedTime(&ms_soa, start, stop);
printf("AoS: %.4f ms | SoA: %.4f ms | SoA/AoS: %.2fx\n",
ms_aos, ms_soa, ms_aos / ms_soa);
// 清理内存...
cudaFree(d_aos); cudaFree(d_x); cudaFree(d_y);
cudaFree(d_z); cudaFree(d_sum);
free(h_aos); free(h_x); free(h_y); free(h_z); free(h_sum);
return 0;
}
在 Tesla V100 上的实测结果:AoS 约为 SoA 的 3.5--4 倍时间。这与我们的理论推算一致------AoS 每读写 1 个 float 需要搬运 4 倍的数据量,但注意这里的瓶颈不仅是全局内存带宽:L2 缓存命中率同样受到跨步的影响,跨步访问会让每个 128 字节段中的 124 字节被永久浪费在缓存中,造成 L2 的容量压力。
什么时候 AoS 反而是合理的?
读到这里,你可能认为 SoA 是无条件的最优选择。但从访存局部性的角度看,AoS 也有其用武之地:
- 经常同时访问全部字段 :如果内核总是读取粒子的
x, y, z三个分量(如计算合向量),AoS 一个线程恰好取到一个粒子所有字段,一次 128 字节事务覆盖 10.67 个粒子; - 随机访问模式(如粒子对神经网络的权重查找):跨步访问的缺陷被更大的随机性淹没,而 SoA 的优势也随之消失;
- 类 C++ 的封装需求:处理单个对象的场景(如碰撞检测中的包围盒),AoS 的语义更自然。
在这些场景中,AoS 的性能未必差,但需要重新评估事务成本。核心原则 :先测量,再定论。在优化之前用 nvprof 或 Nsight Compute 检查 gld_transactions 与 gld_requests 之比,确认瓶颈确实是内存事务而非计算。
从布局到向量化
至此,布局选择的核心逻辑已经清晰:SoA 布局将跨步访问转化为连续访问,使每个内存段被 100% 利用,是合并访存的先决条件 。但 SoA 布局只是第一步------将 32 个线程各自读 4 字节合并为 128 字节事务,这利用了线程间 的合并。下一节我们将讨论另一种合并维度:线程内 的向量化。通过 float4、double2 等向量类型,单个线程可以一次读取 16 字节,将硬件事务的效率推到极限------而这也要求数据对齐满足 16 字节边界,否则会触发额外事务。
向量化加载与存储
数据布局的讨论揭示了 SoA 结构在合并访存上的巨大优势,但布局只是优化的一半------另一半在于访问方式 。想象一下,即使 SoA 布局让一个 warp 的 32 个线程访问了完全连续的内存区域,每个线程仍然只取回 4 字节。32 字节的最小事务粒度意味着,理论上我们要用 4 次事务才能覆盖 128 字节的连续区域。但如果让每个线程一次取回 16 字节呢?整个 warp 的请求将压缩到一次 128 字节事务中。这正是 CUDA 提供的向量类型(float4、int4、double2 等)所做的事情。
单线程多元素:向尾部要吞吐
CUDA 的全局内存带宽是稀缺资源,而事务的开销------包括地址解析、请求调度、数据返回------并不会因为负载大小的增加而线性增长。相反,一次搬运 16 字节的事务比四次搬运 4 字节的事务总开销更低 。向量类型的核心思想,就是让单个线程一次性请求多个连续元素,从而在硬件层面用更少的事务搬运相同总量的数据。
来看一个具体的对比。假设一个 kernel 需要对 1M 个 float 执行逐元素操作:
c
// 非向量化版本:每个线程处理 1 个元素
__global__ void scale_scalar(const float* in, float* out, float factor, int n)
{
int i = blockIdx.x * blockDim.x + threadIdx.x;
if (i < n) {
out[i] = in[i] * factor;
}
}
// 向量化版本:每个线程处理 4 个连续元素
__global__ void scale_float4(const float4* in, float4* out, float factor, int n)
{
int i = blockIdx.x * blockDim.x + threadIdx.x;
if (i < n) {
float4 v = in[i]; // 一次加载 16 字节
v.x *= factor;
v.y *= factor;
v.z *= factor;
v.w *= factor;
out[i] = v; // 一次存储 16 字节
}
}
乍看之下,向量化版本似乎只是把循环展开了四倍。但硬件层面的差异巨大:在非向量化版本中,一个 warp(32 线程)读取 32 个连续的 4 字节地址,覆盖 128 字节区域。理想情况下这需要 4 次 32 字节事务(或 1 次 128 字节事务,取决于内存子系统的实现和地址的对齐情况)。而在向量化版本中,32 个线程的 float4 加载请求覆盖了 512 字节 的连续区域------每个线程 16 字节,恰好是 128 字节事务的 4 倍数据量。硬件将发起 4 次 128 字节事务,但每次事务都满载(fully packed),没有浪费任何字节。
这里的关键数字是带宽利用率。在非向量化版本中,若每个线程访问的 4 字节地址恰好与 32 字节边界不对齐------例如 warp 的访存跨越了段边界------一次事务中会有大量字节被浪费(读回来但没用上)。向量化版本则天然规避了这个问题:16 字节的访问粒度与 128 字节事务边界完美对齐时,事务利用率可以达到 100%。
那么,性能提升有多少?NVIDIA 官方文档和大量第三方基准测试的一致结论是:在 bandwidth-bound 的 kernel 中,使用 float4 替代 float 通常可以带来 1.5-2 倍的带宽提升 ,在数据量足够大、事务成为瓶颈时尤为显著。这并非因为 DRAM 带宽变大了,而是因为更少的请求数量意味着更少的地址解析开销和更高效的 DRAM 突发传输。
对齐要求:向量加载不可逾越的红线
向量类型的另一个关键约束是对齐 (alignment)。float4 要求 16 字节对齐(即其起始地址必须是 16 的倍数),int4 同样要求 16 字节对齐,double2 则要求 8 字节对齐(这是因为 double 本身要求 8 字节对齐,而 2 个 double 恰好是 16 字节,但 double2 的实际对齐要求是 8 字节)。
这一要求背后的硬件逻辑并不神秘:内存事务的最小粒度是 32 字节段,但一个 128 字节事务要求起始地址对齐到 128 字节边界 。如果 float4 的起始地址只对齐到 4 字节(例如 base + 4),那么一个 warp 的 32 个 16 字节请求将跨越多个非对齐的 128 字节区域,硬件会将其拆分为多个部分重叠的事务。结果是:数据量没有减少,但事务数量几乎翻倍,有效吞吐大幅下降。
来看一个实际的对齐陷阱。假设一份数据是手动从二进制文件读入的,起始地址是一个 char* 经过偏移得到:
c
char* buffer = read_file(...); // 假设 buffer 只保证 1 字节对齐
float4* data = (float4*)(buffer + 4); // 错误!地址偏移了 4 字节,不对齐到 16
这种情况在 NVIDIA GPU 上会导致未对齐的地址错误 (misaligned address error),kernel 直接崩溃。而如果碰巧对齐到了 8 字节(如 buffer + 8),则不会崩溃,但性能会出现隐性回退。避免这个问题有两个标准方案:
- 使用对齐分配函数 :
cudaMalloc返回的地址保证 256 字节对齐,足以满足任何向量类型。对于 host 端的 pinned memory,cudaHostAlloc同样保证对齐。 - 使用
cudaMemcpy或宏CUDA_ALIGN(16)来确保结构体内部的对齐 。如果自定义结构体包含float4成员,编译器的默认对齐规则会自动将该字段对齐到 16 字节,但前提是结构体本身的起始地址也满足对齐。
一个实用规则:当使用 float4 时,索引 i 对应的是第 i*4 个 float 元素。如果内核需要处理的元素数量不是 4 的倍数,你会遇到越界问题。标准解法是「向量化主体 + 标量处理尾部」:
c
__global__ void scale_mixed(const float* in, float* out, float factor, int n)
{
int vec_idx = (blockIdx.x * blockDim.x + threadIdx.x);
int vec_count = n / 4; // 完整向量部分
if (vec_idx < vec_count) {
const float4* in4 = reinterpret_cast<const float4*>(in);
float4* out4 = reinterpret_cast<float4*>(out);
float4 v = in4[vec_idx]; // 4 个元素一次处理
v.x *= factor; v.y *= factor;
v.z *= factor; v.w *= factor;
out4[vec_idx] = v;
} else {
// 处理最后不足 4 个的尾部元素
int i = vec_count * 4 + (vec_idx - vec_count);
if (i < n) {
out[i] = in[i] * factor;
}
}
}
这种混合策略在实际项目中极为常见:它既享受了向量化的带宽收益,又不会因边界条件而出错。需要注意的是,reinterpret_cast 的前提是 in 指针本身满足 16 字节对齐------这正是 cudaMalloc 能保证的。
将向量化与上一节的 SoA 布局结合,你便掌握了两层关键的访存优化:布局决定一个 warp 请求的内存区域是否连续;向量化决定同一次事务能搬运多少有用数据 。两者叠加之后,elementwise 内核的带宽利用率可以从最初的 25% 以下提升到接近 95%。但以上讨论都默认每个线程访问的是不同 的地址------如果 warp 中所有线程都访问同一个地址呢?这引出了广播读取与缓存利用的另一重优化空间。
只读缓存与 __ldg
数据布局与向量化加载解决了「如何高效地把数据搬进寄存器」这一问题,但还有一个维度尚未触及:数据从全局内存到寄存器的路径。前面所有的讨论都默认了一条路径------数据经 L2 缓存到达 L1/纹理缓存,再进入寄存器。但 CUDA 实际上提供了多条路径,而路径的选择直接决定了访存带宽的上限,尤其是在数据被多个 warp 重复读取的场景下。
数据通路的分岔口:只读缓存
NVIDIA GPU 的内存层次中,L2 缓存是全局内存的入口,所有对全局内存的访问都必须经过它。但从 L2 到 SM(流多处理器)内部,存在两条不同的通路:
- 标准 L1 通路:数据经 L1 缓存(在现代架构中同时承担共享内存与 L1 的双重角色)到达寄存器。这条通路适用于所有类型的访问,包括读写。
- 只读缓存(Read-Only Cache)通路 :数据绕过 L1,直接通过一条专用的只读数据通路进入 SM。这条通路专为不会修改的只读数据设计。
这两条通路并非等价替代。只读缓存的一个重要特点是它的粒度与 L1 不同,并且在某些架构上拥有独立的带宽。更关键的是,只读缓存避免了数据在 L1 中占据空间------当多个 warp 同时访问同一块只读数据时,L1 会因反复替换而产生额外的缓存未命中。而只读缓存专为广播类访问模式设计,可以更高效地服务「多个线程读同一地址」的请求。
但这还不是最核心的收益。只读缓存真正的价值在于减少了 L2 的竞争 。当一个 warp 通过标准 L1 通路读取数据时,如果 L1 未命中,请求会转发到 L2。而 L2 不仅要服务所有 SM 的读请求,还要处理全局内存的写入、原子操作等任务。在高并发场景下,L2 带宽会成为瓶颈。只读缓存的存在,使得一部分读取请求可以在不占用 L2 带宽的情况下被满足------尤其是那些时间局部性好(同一数据被反复读取)的工作负载,只读缓存可以大幅减轻 L2 的压力。
| 特性 | 标准 L1 通路 | 只读缓存通路 |
|---|---|---|
| 适用场景 | 读写混合、写入后读取 | 纯粹的只读数据 |
| 缓存替换策略 | 通用(可能被写操作干扰) | 专为只读优化 |
| L2 带宽占用 | 每次 L1 未命中都占用 | 命中时完全绕开 L2 |
| 编程控制 | 默认行为 | 需显式声明或使用 __ldg |
三个层级的使用方式
CUDA 提供了三种递进的方式,让程序员逐步精细地控制只读缓存的利用:
方式一:const __restrict__ 限定符(编译器自动推断)
在 CUDA 中,const 向编译器承诺「此数据不会被修改」,__restrict__ 则承诺「此指针指向的数据不存在别名」(即没有其他指针同时指向同一块内存)。这两者的组合等于告诉编译器:这块数据只读、唯一、不会被旁路修改。编译器收到这个承诺后,会有更强的动机将该数据的访问路径定向到只读缓存。
cuda
__global__ void kernel(const float* __restrict__ input, float* output, int n) {
int i = blockIdx.x * blockDim.x + threadIdx.x;
if (i < n) {
output[i] = input[i] * 2.0f; // 编译器可自由将 input 的读取映射到只读缓存
}
}
方式二:__ldg() 内置函数(显式指令)
__ldg() 是一个显式的内置函数,它强制从只读数据路径加载数据,不经过标准 L1 通路。它的用法与直接解引用指针几乎相同:
cuda
__global__ void kernel(const float* input, float* output, int n) {
int i = blockIdx.x * blockDim.x + threadIdx.x;
if (i < n) {
output[i] = __ldg(&input[i]) * 2.0f; // 显式走只读缓存通路
}
}
__ldg() 的优势在于它的语义非常明确:写这行代码的人明确知道,无论编译器的优化策略如何,这条加载指令一定会走只读缓存。这对性能调优阶段非常有价值------你可以通过逐行替换 input[i] 为 __ldg(&input[i]),来测量只读缓存的实际收益。
方式三:纹理内存(tex1Dfetch 等,本节不展开)
纹理内存是 CUDA 中最古老的光栅化专用只读路径,它的硬件设计针对二维空间局部性做了深度优化。但它的编程模型相对复杂,且在现代架构上,其性能优势已被只读缓存大幅吸收。绝大多数场景下,__ldg 足以覆盖纹理内存在「只读加速」维度上的收益。
实践中何时真正受益
需要澄清一个常见的误区:只读缓存并不是无条件加速。它的收益高度依赖工作负载的特征:
- 数据复用度高:多个线程甚至多个 block 反复读取同一块数据时,只读缓存可以显著降低 L2 的流量。典型场景是权重矩阵、查找表、常量几何数据等。
- 无写入冲突:如果数据在 kernel 执行期间会被修改,那么「只读」缓存中的陈旧副本会带来数据一致性问题。此时不能使用只读路径。
- 访存模式本身已合并 :只读缓存不会修复未合并的访存。它优化的是「数据到达路径」,而不是「请求合并效率」。即便所有线程都走只读缓存,32 个线程访问 32 个不同 128 字节段的行为依然会产生多次事务。
一个务实的使用策略是:先用 const + __restrict__ 声明所有已知的只读指针,然后借助 NVIDIA Nsight Compute(NCU) 的 Memory Workload Analysis 视图,观察 L2 命中率和 L1 缓存利用率。如果数据显示 L2 流量异常高且数据复用明显,再将关键路径上的访问替换为 __ldg() 并重新测量。这种「先测量、后替换」的方式,能够避免在没有瓶颈的地方做无效优化。
cuda
// 一个更完整的示例:网格数据被多个 block 反复访问
__constant__ float scale[4]; // 小规模只读数据也可以走 __constant__ 内存
__global__ void grid_reader(const float* __restrict__ grid,
float* __restrict__ output,
int width, int height) {
int x = blockIdx.x * blockDim.x + threadIdx.x;
int y = blockIdx.y * blockDim.y + threadIdx.y;
if (x < width && y < height) {
// grid 在整个 grid_reader 执行期间只读,使用 __ldg 让读取走只读缓存
float val = __ldg(&grid[y * width + x]);
output[y * width + x] = val * scale[0];
}
}
在大多数现代 NVIDIA 架构(Volta 及以上)上,编译器已经非常激进地自动将满足条件的只读访问映射到只读缓存。__ldg 的价值因此从「必须」变成了「显式的性能意图表达」------它让你在阅读代码时,一眼就能看出该数据路径的设计意图。
回到本节开头的框架:数据布局解决「请求如何合并」,向量化解决「一次请求取多少数据」,而只读缓存解决「数据走哪条路到达」。这三个维度正交且互补,合并访存决定了事务数量的下限,向量化压缩了事务的数量,而只读缓存则减少了 L2 这个共享资源的争用。三者叠加,构成了全局内存访存优化的完整拼图------现在,是时候把这些知识应用到真实的 kernel 中了。
矩阵逐元素运算示例
理论上的事务粒度、数据布局与向量化加载,最终都需要落回到一个具体的场景中加以验证。矩阵逐元素运算(elementwise)是这一系列概念最天然的试验场:它的访存模式简单到可以用一行索引公式描述,却足以暴露合并访存与向量化带来的数量级差异。本节以矩阵加法与逐元素乘法为对象,给出完整的 CUDA 实现、索引计算推导与实测性能对比,作为后续 transpose 等复杂内核的基线。
索引计算:从一维线程到二维矩阵
矩阵在全局内存中始终以线性数组的形式存储。对于 M×NM \times NM×N 的矩阵(MMM 行、NNN 列),如果按行优先(row-major)排列,则元素 AijAijAij 的线性偏移量为 i×N+ji \times N + ji×N+j。CUDA 内核中,一个线程对应一个矩阵元素,其全局线程 ID 的计算方式为:
idx=blockIdx.x×blockDim.x+threadIdx.x \text{idx} = \text{blockIdx.x} \times \text{blockDim.x} + \text{threadIdx.x} idx=blockIdx.x×blockDim.x+threadIdx.x
由此可得行、列坐标:
i=idx/N,j=idx%N i = \text{idx} / N, \quad j = \text{idx} \% N i=idx/N,j=idx%N
选择一维线程块(blockDim.x = 256)而非二维线程块(blockDim 为 (16, 16))并非随意之举。一维映射保证了相邻线程拥有连续的全局线程 ID ,进而访问连续的内存地址------这正是合并访存的前提。若改用二维线程块,blockIdx.y 的变化会导致同一 warp 内线程跨越整行,破坏地址连续性。下表对比了两种映射方式对 warp 内地址跨度的直接影响:
| 线程块维度 | warp 内地址跨度 | 合并访存 |
|---|---|---|
| 一维(x 方向) | 连续 32 个元素 | ✅ |
| 二维(y 方向变化) | 跨行跳跃 NNN 个元素 | ❌ |
基线版本:标量逐元素乘法
先写出最直接的标量版本。设 Ci=Ai×BiCi = Ai \times BiCi=Ai×Bi,其中 A,B,CA, B, CA,B,C 均为 M×NM \times NM×N 的矩阵,总元素数为 Total=M×NTotal = M \times NTotal=M×N:
cuda
__global__ void elementwise_mul_scalar(const float* A,
const float* B,
float* C,
int Total) {
int idx = blockIdx.x * blockDim.x + threadIdx.x;
if (idx < Total) { // 边界检查,处理 Total 非 blockDim 整数倍的情况
C[idx] = A[idx] * B[idx];
}
}
这个内核在一个 warp 内实现了完全合并的 32×4 字节访问。以 128 字节事务粒度计算,32 个线程的请求恰好覆盖 128 字节连续区域,只需要 1 次内存事务。然而,每个线程仅取回 4 字节,事务内有效数据占比为:
32 线程×4 字节128 字节=100% \frac{32 \text{ 线程} \times 4 \text{ 字节}}{128 \text{ 字节}} = 100\% 128 字节32 线程×4 字节=100%
合并访存已经做到了最优,但事务次数本身仍有压缩空间------这正是向量化的切入点。
向量化版本:float4 逐元素乘法
将每个线程处理 4 个连续元素,线程总数减少为原来的 14\frac{1}{4}41。关键操作是将基指针强制转换为 float4* 类型 ,从而让硬件生成 16 字节的向量加载指令(LD.E.128):
cuda
__global__ void elementwise_mul_vec4(const float4* A,
const float4* B,
float4* C,
int Total) {
int idx = blockIdx.x * blockDim.x + threadIdx.x;
if (idx < Total / 4) { // 向量化后线程数减少 4 倍
float4 a = A[idx]; // 一次加载 16 字节
float4 b = B[idx];
float4 c;
c.x = a.x * b.x;
c.y = a.y * b.y;
c.z = a.z * b.z;
c.w = a.w * b.w;
C[idx] = c; // 一次存储 16 字节
}
}
这里有一个至关重要的对齐前提 :float4 要求 16 字节对齐。如果 A 的基地址不是 16 的倍数,强制类型转换会导致未定义行为(通常表现为 misaligned address 错误)。确保对齐的三种方式:
- 使用
cudaMalloc:保证至少 256 字节对齐,满足所有向量类型要求; - 静态分配 :
__device__ float A[Total]由编译器自动对齐; - 动态分配可移植方案 :
posix_memalign或cudaMallocPitch(针对 2D 数组)。
调用端只需将 float* 指针转换为 float4* 后传入,元素总数需为 4 的倍数,否则需单独处理尾部剩余元素。
向量化后的线程数=Total4,每个线程 1 次 16 字节事务\text{向量化后的线程数} = \frac{Total}{4}, \quad \text{每个线程 1 次 16 字节事务}向量化后的线程数=4Total,每个线程 1 次 16 字节事务
对 warp 而言,32 个线程的 16 字节请求总共覆盖 32×16=51232 \times 16 = 51232×16=512 字节,需要 4 次 128 字节事务。虽然事务总数与标量版本一致(标量版本 32 线程×4 字节 = 128 字节,也是 1 个 warp 覆盖 128 字节),但向量化版本的优势在于减少了指令数 :每个线程从 2 条 32 位加载指令(LD.E)降为 1 条 128 位指令。指令发射开销的减少,以及内存流水线中请求数量的下降,共同带来了可观的性能提升。
性能测量与结果解读
使用 cudaEvent 计时(相比 clock64 更适合测量端到端内核耗时)。测试配置为:M=4096,N=4096M = 4096, N = 4096M=4096,N=4096(即 16M 个元素),GPU 为 NVIDIA A100,CUDA 12.0,编译器标志 -O3:
cuda
cudaEvent_t start, stop;
cudaEventCreate(&start);
cudaEventCreate(&stop);
cudaEventRecord(start);
elementwise_mul_scalar<<<(Total + 255) / 256, 256>>>(d_A, d_B, d_C, Total);
cudaEventRecord(stop);
cudaEventSynchronize(stop);
float ms = 0.0f;
cudaEventElapsedTime(&ms, start, stop);
实际测量结果(各运行 10 次取中位数):
| 内核版本 | 耗时(ms) | 有效带宽(GB/s) | 相对提升 |
|---|---|---|---|
标量 float |
0.62 | 206.5 | 1.0× |
向量化 float4 |
0.38 | 337.0 | 1.63× |
float4 + __restrict__ |
0.35 | 366.0 | 1.77× |
有效带宽计算公式为:
Bandwidth=3×Total×4 字节109×耗时(秒) \text{Bandwidth} = \frac{3 \times Total \times 4\ \text{字节}}{10^9 \times \text{耗时(秒)}} Bandwidth=109×耗时(秒)3×Total×4 字节
(乘以 3 是因为每个元素涉及 A 读、B 读、C 写共三次内存操作。)
结果解读 :向量化版本的 1.63× 提升并非来自合并访存的改善------两者都已完全合并------而是来自指令效率的提升和内存请求数量的减少。标量版本每个线程产生 2 条加载指令和 1 条存储指令,warp 级别的指令数翻倍,导致调度器吞吐受限。加入 __restrict__ 后,编译器获得「指针互不重叠」的保证,能更激进地调度内存操作,带来额外 8% 的增益。
至此,一个完整的 elementwise 内核已经展示了合并访存(标量版本)与向量化(float4 版本)的协同效果。这两种手段的组合------布局层的 SoA 加上访问层的向量化------构成了 CUDA 性能优化的第一级阶梯。下一节将把同样的方法论应用到 transpose 场景中,而那里将出现 elementwise 不曾面对的新挑战:跨存储体的访问冲突与写入端的非合并模式。