【算子开发】全局内存访问与合并访存

内存事务与合并访存原理

假设一段 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% 时,每一次未被使用的字节都在隐性地消耗你的实际吞吐量。

理解事务成本的三个层次

要让这个机制真正指导你的优化实践,需要区分三个概念层次:

  1. 段(sector)级别:32 字节是一个事务的最小物理单位。任何访问都会至少拉取一个完整的段。
  2. warp 级别 :一个 warp 的所有线程请求会在内存分区中被合并检查。合并策略是「覆盖所有被请求地址的最小段集合」。
  3. cache line 级别:L2 缓存按 128 字节管理数据。如果多个 warp 访问同一 cache line,L2 可以复用已取回的数据,避免重复访问 DRAM。

实践中的含意很直接:让一个 warp 的线程访问尽可能少的段。最理想的情况是 32 个线程的 4 字节访问正好落在 128 字节连续区间内,此时一个事务搞定一切。如果某个 warp 需要访问 256 字节的连续区域,则会发生两次 128 字节事务------这依然是高效的,因为每个事务的利用率都接近 100%。


这一节的内容为后续优化建立了基石:任何全局内存访问的性能,本质上取决于「它引发了几个事务」而不是「访问了多少字节」 。理解了事务粒度与合并规则,你就掌握了第一个性能判据------接下来要讨论的是,如何在代码层面刻意构造合并的访问模式,以及向量类型 float4double2 在这其中扮演的关键角色。

非合并访问的影响: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 个线程的地址范围覆盖了从 basebase + 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 的性能未必差,但需要重新评估事务成本。核心原则 :先测量,再定论。在优化之前用 nvprofNsight Compute 检查 gld_transactionsgld_requests 之比,确认瓶颈确实是内存事务而非计算。

从布局到向量化

至此,布局选择的核心逻辑已经清晰:SoA 布局将跨步访问转化为连续访问,使每个内存段被 100% 利用,是合并访存的先决条件 。但 SoA 布局只是第一步------将 32 个线程各自读 4 字节合并为 128 字节事务,这利用了线程间 的合并。下一节我们将讨论另一种合并维度:线程内 的向量化。通过 float4double2 等向量类型,单个线程可以一次读取 16 字节,将硬件事务的效率推到极限------而这也要求数据对齐满足 16 字节边界,否则会触发额外事务。

向量化加载与存储

数据布局的讨论揭示了 SoA 结构在合并访存上的巨大优势,但布局只是优化的一半------另一半在于访问方式 。想象一下,即使 SoA 布局让一个 warp 的 32 个线程访问了完全连续的内存区域,每个线程仍然只取回 4 字节。32 字节的最小事务粒度意味着,理论上我们要用 4 次事务才能覆盖 128 字节的连续区域。但如果让每个线程一次取回 16 字节呢?整个 warp 的请求将压缩到一次 128 字节事务中。这正是 CUDA 提供的向量类型(float4int4double2 等)所做的事情。

单线程多元素:向尾部要吞吐

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),则不会崩溃,但性能会出现隐性回退。避免这个问题有两个标准方案:

  1. 使用对齐分配函数cudaMalloc 返回的地址保证 256 字节对齐,足以满足任何向量类型。对于 host 端的 pinned memory,cudaHostAlloc 同样保证对齐。
  2. 使用 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(流多处理器)内部,存在两条不同的通路:

  1. 标准 L1 通路:数据经 L1 缓存(在现代架构中同时承担共享内存与 L1 的双重角色)到达寄存器。这条通路适用于所有类型的访问,包括读写。
  2. 只读缓存(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 错误)。确保对齐的三种方式:

  1. 使用 cudaMalloc:保证至少 256 字节对齐,满足所有向量类型要求;
  2. 静态分配__device__ float A[Total] 由编译器自动对齐;
  3. 动态分配可移植方案posix_memaligncudaMallocPitch(针对 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 不曾面对的新挑战:跨存储体的访问冲突与写入端的非合并模式。

相关推荐
ai产品老杨1 小时前
AI视频平台权限配置参数配置说明与排查指南
人工智能·音视频
AI导出鸭1 小时前
怎么让Grok做表格?AI导出鸭苹果版通过专属解析引擎,将Grok输出的管道表格智能还原为二维结构,一键导出Excel或Word标准表格。
人工智能·chatgpt·word·excel·ai导出鸭
小刘快学习1 小时前
口腔诊所的初诊分诊与方案解读,为什么要把模型调用收进一个平面
人工智能
AI创界者1 小时前
Android 应用安全防御:APK 加密保护、防反编译与加固最佳实践
人工智能·音视频
沙盘客1 小时前
典型应用与趋势:作战实验、AI 决策与数字孪生
c++·人工智能·经验分享
往事如yan1 小时前
VS Code 单击 Markdown 默认打开预览
人工智能
jkyy20141 小时前
移动出行健康空间崛起:人车家一体化,补齐智慧生活健康短板
人工智能·物联网·生活·健康医疗
Lee_Chen861 小时前
5天开发企业级 Agent(设计篇 01)|一条主链与八个模块
人工智能
7177771 小时前
不止工具集成:基于 Gitee 软件工厂构建 DevSecOps 研发治理底座
java·服务器·gitee