网站Logo Ran's blog

CUDA学习笔记:Block与 Thread协作

ranranranqaq
3
2025-12-10

本章主要学习有关cuda中Block与Thread的关系,Thread间如何通信,以及Thread的同步问题。初步认识cuda如何解决大规模并行问题

CUDA执行层次结构:Grid、Block和Thread

下图表示了Grid,Block,Thread三种结构的递进关系,能俯瞰它们是如何协作处理任务的。

Grid是我们用来表示Block排布方式的结构,比如如下代码:

dim3 grid(10,10);
dim3 block(16,16);
kernel<<<grid,block>>>(data_ptr);

上面的代码中定义了10x10=100个block,每个block都有16x16=256个thread。gird结构如何定义不影响实际的计算过程,但block结构会有性能上的区别

block大小(一个block有多少线程)一般会取128、256、512这几个档。不能超过1024。

blockDim.x比较特殊,x是"变化最快"的维度。因此blockDim.x必须是warp(32个thread)的倍数。

如何处理大规模数据?

首先认识到一个前提:同一个Block内的线程拥有更强的协作能力,比如内存共享,线程同步,资源切换等。

所以最好的情况是用尽可能少的Block,这样更多的线程在同一块内,效率更高。但是一个Block中能包含的Thread数量是有上限的,这就引出了下面这张图:

因此我们需要将图中的两种方案混合起来。具体实现中由于gridDim.x的限制,有下面两种情况:

未触发gridDim上限的基础情况

假设我们要进行长度为3*512+100 = 1636的向量并行加法。假设block大小为512,我们如何算出需要的block数量,又如何将线程与向量元素对应起来呢?

下面的代码是一个简单的示例:

// (N+511)/512实际上就是对N/512向上取整。
// 因为我们可以接受多的一个block中有thread被浪费,但不能接受thread不够用
// 多创建的thread可以通过tid < N来排除掉
kernel<<<(N+511)/512,512>>>(...)

__global__ void kernel(...){
    // 可以理解为: blockIdx.x * blockDim.x将起点从0偏移到了当前block的起点位置,然后再往后数threadIdx.x就是线程的全局索引
    // 此时线程全局索引tid可以对应要处理的向量元素,比如tid = 5就是5号元素
    int tid = blockIdx.x * blockDim.x + threadIdx.x;
    
    // 防止tid越界,这样只会有3*512+100个thread被用到,剩下的412个被丢弃不用
    if(tid < N){...}

}

这个例子比较简单,重点是结合(N+511)/512取整与越界检查来将thread与被操作的元素对应。

数据量太多,没办法完全并行怎么办?

由于gridDim有上限(gridDim.x < 65535),我们不能无限制地创建Block。如果超过上限也无法并行处理全部数据怎么办?

方案之一就是Grid-Stride循环:让每个线程处理完自己的任务空闲后,去做后面可能存在的别的任务。具体如下图所示:

比如上面的基础情况中,假如我们只用了2个Block(一共1024个线程),当tid = 512的线程计算完第512个元素后,它会紧接着去计算第512+1024个元素。跳着将所有元素分配给更少的线程。

Global Memory 与 Shared Memory

Global Memory

Global Memory 是 GPU 上容量最大、所有线程都可以访问的主存。我们通过cudaMalloc分配的内存就是Global Memory。

它的特点是:

  • 容量大,GB级。

  • 所有线程都能访问,包括不同Block的线程。

  • 速度较慢,延迟通常在上百个 cycle。

  • 生命周期跨 kernel/线程,直到释放为止。

使用方式就是简单的cudaMalloc,通过cudaMemcpy赋值。需要程序员手动管理内存(比如cudaFree)。

访问方式会影响性能!主要和warp,Threads访问时与内存的对应方式等有关,后面记得查一下。

Shared Memory

Shared Memory(共享内存)是位于每个 SM 上的一小块高速可编程缓存,供同一个 block 的所有线程共同访问。可以理解成“一个 block 内所有线程共同使用的超高速临时缓存池”。

它的特点是:

  • 速度非常快(延迟接近寄存器,比 global memory 快几十倍到上百倍)。

  • 同一个block内的所有线程都能读写同一片空间,但只在同一个block内共享。

  • 生命周期在 block 级别(block 结束它就被释放)

由于Shared Memory高带宽低延迟,支持并行访问,更利于线程协作计算等特点,一般在矩阵乘法、卷积、稠密计算等操作中,会把小块数据(tile)拷贝进 shared memory,让所有线程重复使用这份缓存,从而加速。

Shared memory 被分成多个 bank(通常 32 个,为什么是32个?)。如果多个线程访问落在同一个 bank,则会发生冲突,速度会变慢。设计访问模式时要尽量做到让 warp 内每个线程访问不同 bank,或者使用 “广播”(多个线程读同一地址不会冲突)(广播是什么?

每个SM的 shared memory 只有几十 KB,一般是48KB,64kb,96KB等。使用过多会限制同时驻留的 block 数量

记得查一下什么是tile-based。

下面的代码很直观地展示了如何使用Shared Memory:

静态分配:

// 静态分配,编译期就确定大小
__global__ void kernel() {
    __shared__ float buf[256];  // 大小在编译期已知
    int tid = threadIdx.x;
    
    buf[tid] = tid * 1.0f;  // 所有线程读写同一片 shared memory

    __syncthreads();        // 同步,确保所有线程读写完毕

    // 使用 buf...
}

动态分配:

// 动态分配
__global__ void kernel() {
    extern __shared__ float buf[];  // 大小运行时由 kernel 调用方指定
    int tid = threadIdx.x;

    buf[tid] = tid;
    __syncthreads();
}

调用kernel时设定大小:

int sharedSize = 256 * sizeof(float);
kernel<<<grid, block, sharedSize>>>();

Shared Memory同步问题

由于Shared Memory可以被并行访问,因此需要考虑线程同步的问题。cuda中可以使用__syncthreads()来进行同步,下图展示了__syncthreads()的作用:

也就是说,它会让每个线程运行到这里时都停下来,直到所有线程都运行到此处,它才释放。同步每个线程的"进度"。

应用举例:向量点积并行计算

第一部分,首先将两个向量所有元素都对应相乘,每个线程负责一个索引,并将结果存入shared memory(也就是图中的cache)

这样一来,后面将每个乘法结果加起来的操作就能在shared memory(或者说在一个Block内)完成,不用总是访问global memory。

注意此处cache的大小被指定为threadsPerBlock,这是因为shared memory只能被一个block内的线程访问。所以下面的流程实际上只计算了threadsPerBlock个元素,最后需要把这些结果写回global memory,再在cpu上求和。

第二部分,将cache中的乘积结果加起来,得到最终的点积结果。也可以称之为并行归约

首先需要使用__syncthreads()保证每一个元素的乘积结果都已经写入到cache中了;然后用下图所示的方法在原地进行求和计算,这里实际上只用到前面一半的线程,会抛弃掉后面一半。最终还需要再用一次__syncthreads() 保证全部求和操作都结束(因为后面还要写回global memory)。

__syncthreads() 的使用要注意:不管算法中是否需要用到所有线程,所有线程都必须执行到__syncthreads() ,不然会死锁。比如上面的流程中,绝不能将__syncthreads() 写到if里面。

上面的例子还体现了一个思想:可以利用CPU做一些负担比较小,灵活性比较高的任务,比如最终的小规模求和。对于千万级别的向量点积,CPU在这里做的只是对block数量级别的数据求和。

对比

特性

Shared Memory

Global Memory

作用范围

同一个 block 内共享

所有线程都可访问

生命周期

block 级别

程序员控制(malloc/free)

速度

极快(SM 内)

较慢(DRAM)

容量

几十 KB

几 GB

使用方式

__shared__ 静态 / extern __shared__ 动态

cudaMalloc 分配,kernel 中正常访问

合适场景

tile 缓存、重复读写、协作计算

存大数组、模型参数、全局数据

是否需同步

通常需要 __syncthreads()

不需要(但可能需要 atomic 保证一致性)

访问模式影响

要避免 bank conflict

要保证 coalesced 访问