flowchart LR
subgraph Grid["CUDA 网格层次与一维映射"]
direction TB
G["Grid (dim3: blocksPerGrid)"] --> B["Block (dim3: threadsPerBlock = 256/512)"]
B --> W["Warp 0..W-1 (每个 Warp 32 线程)"]
W --> T["Thread (全局索引: idx = blockIdx.x * blockDim.x + threadIdx.x)"]
end
第 1 课:线程映射与 Grid-Stride Loop
参考答案版:包含完整代码实现与问答题深度参考解析。建议先独立完成练习题后再展开核对。
本课目标与完成标准
学完后你应能:从 thread/block/grid 映射一维数据,并写出处理任意 N 的合并访存 kernel。
通过必须同时满足:
- 独立补齐本课唯一的代码填空题,并通过给定检查;
- 三个问答题均说明因果链,而不是只报术语;
- 能指出至少一个正确性边界和一个性能取舍;
- 总分不低于 8/10,且没有一票否决级概念错误。
前置关系
- 课程前置:C/C++、线性代数、基本并行编程
- 本课在路线中的作用:Vector Add 是最小 CUDA 工作流:host launch 决定 grid,device thread 用全局索引定位元素。
核心心智模型
1. 向量加法在 GPU 体系中的定位与第一性原理
向量加法(Vector Add: )是所有高性能 GPU 编程学习者的“Hello World”,更是深入理解 CUDA 线程层次结构与硬件访存机制的最小工作原型:
- 数据并行性(Embarrassingly Parallel):每个元素 的加法运算完全独立,元素之间不存在任何数据依赖或跨线程通信需求。
- 访存受限特征(Memory-Bound Kernel): 向量加法的算术强度极低:每个线程从显存中加载两个
float( 字节),执行 1 次加法(),然后写回一个float( 字节)。 其算术强度仅为: 这决定了其性能完全受限于 GPU 全局显存(HBM / GDDR)的物理带宽。编写高吞吐向量加法内核的核心,在于如何完美对齐硬件的内存访问合并(Memory Coalescing)。
2. 线程层次映射与 Grid-Stride Loop 执行拓扑(如图 1-1 与图 1-2 所示)
A. 层次化坐标映射体系(图 1-1)
图 1-1:GPU 线程层次(Grid/Block/Warp/Thread)与全局内存一维映射拓扑
CUDA 运行时通过网格(Grid)和线程块(Block)管理数十万级并发线程:
threadIdx.x:当前线程在所属 Thread Block 内的一维局部编号()。blockIdx.x:当前 Thread Block 在全局 Grid 内的一维块编号()。- 全局一维绝对线性索引(Global Linear Index):
B. 网格跨步循环(Grid-Stride Loop,图 1-2)
flowchart TD
Start["线程启动: idx = blockIdx.x * blockDim.x + threadIdx.x"] --> Cond{"idx < n ?"}
Cond -->|是| Compute["计算 c[idx] = a[idx] + b[idx]"]
Compute --> Stride["idx += blockDim.x * gridDim.x (步进一个网格)"]
Stride --> Cond
Cond -->|否| End["线程退出"]
图 1-2:Grid-Stride Loop 网格跨步循环执行控制流
在朴素的初学者代码中,通常假设线程总数严格大于等于向量长度 ,直接使用 if (idx < N) c[idx] = a[idx] + b[idx];。这种写法在工业级生产环境中存在严重缺陷(无法应对超大规模 ,且启动过多 Block 会浪费驱动调度开销)。
工业级标准范式是采用 Grid-Stride Loop(网格跨步循环):
int idx = blockIdx.x * blockDim.x + threadIdx.x;
int stride = blockDim.x * gridDim.x;
for (int i = idx; i < n; i += stride) {
c[i] = a[i] + b[i];
}
其执行机制与工程红利包含三重保证:
- 解耦硬件规模与问题规模:
- 步长 严格等于整个 Grid 在单次迭代中能够同时处理的线程总数。
- 无论向量长度 是一千还是十亿,Grid 尺寸可以任意配置并加上限保护(例如限制
gridDim.x <= 4096),线程在循环中自动以固定步长前移,天然处理剩余尾块。
- 保证全局内存合并访存(Coalesced Access):
- 同一个 Warp 内的 32 个连续线程()在第 1 轮循环中访问的地址是连续的:。
- 在进入下一次迭代时,所有线程同步跨进 步,访问的地址依然是 ,依然完全连续!
- 硬件内存控制器可以将这 32 个连续的 4 字节读取合并为单个或两个 128 字节的 Cache Line 突发事务,榨干显存总线带宽。
- 极佳的可移植性与调试性:同一个 Kernel 在只有 1 个 SM 的嵌入式 GPU 或拥有 132 个 SM 的 H100 上均可零修改无缝编译运行。
3. 正确性边界与常见陷阱
- 边界防护
i < n严禁遗漏:- 无论 是否能被 Block 尺寸整除,尾块未对齐部分必须依靠
i < n进行严格的越界拦截。 - 遗漏边界检查会导致非对齐的最后几个线程访问未分配内存,触发非法内存访问(
cudaErrorIllegalAddress)导致 GPU 驱动崩溃。
- 无论 是否能被 Block 尺寸整除,尾块未对齐部分必须依靠
- 指针别名与
__restrict__关键字:- 在函数参数中使用
const float* __restrict__ a告知编译器指针a, b, c之间不存在内存重叠(Aliasing)。 - 这允许 NVCC 编译器放心将全局内存加载优化为使用只读数据缓存(Read-Only Cache / LDG 算子),规避重复加载。
- 在函数参数中使用
4. 性能与工程取舍(Block Size 与 Occupancy)
- Block 大小的选择:
- Block Size 必须是 32(Warp 大小)的整数倍,常见取值为 128、256 或 512。
- 太小(如 32):无法充分掩盖指令流水线停顿,SM 内部调度器无法保持高饱和度;
- 太大(如 1024):单 Block 消耗过多的寄存器堆和共享内存配额,反倒导致每个 SM 上能同时驻留的 Active Blocks 数量锐减,降低硬件占用率(Occupancy)。
- 工业经验法则:对于纯访存密集型向量算子,通常推荐
BLOCK_SIZE = 256或512,在寄存器开销极小的情况下轻松达成 100% 理论 Occupancy。
具体演示与数值推演
设向量长度 ,配置 BLOCK_SIZE = 256,启动网格限制为 grid = 2:
单网格总并发线程数:
跨步循环执行演化:
- 第 1 轮迭代(): 全局索引 的 512 个线程同时启动。所有线程均满足 ,并行计算 。
- 第 2 轮迭代(): 所有线程跨步递增:。
- 线程索引 对应的计算位置为 ,依然满足 ,顺利计算剩余的 488 个元素;
- 线程索引 对应的计算位置为 ,此时条件 判定为假,这 24 个边界线程自然安全退出。
全量覆盖验证:共执行两轮迭代,精确完成 个元素的计算,无任何越界写入,且全流程维持 128 字节合并访存。
实践任务:唯一代码填空题
补齐全局下标与 stride,不改变边界循环。
规则:只能修改 TODO/______ 所在位置;不要删除断言或放宽误差。代码注释说明了每个边界条件。
Code
%%writefile /tmp/01_vector_add.cu
#include <cuda_runtime.h>
// Vector Add: c[i] = a[i] + b[i]
//
// 这是 CUDA 面试里最基础的算子,重点不是算法复杂,
// 而是确认你理解 thread/block/grid 映射和 coalesced memory access。
__global__ void vector_add_kernel(
const float* __restrict__ a,
const float* __restrict__ b,
float* __restrict__ c,
int n
) {
int idx = ______; // TODO 1: 当前线程全局下标
int stride = ______; // TODO 2: 整个 grid 的线程跨度
// grid-stride loop 的好处:
// 1. kernel 可以处理任意 n,不要求 grid 覆盖全部元素后就停止。
// 2. 如果 n 很大,每个线程可以处理多个元素,代码仍然简单。
// 3. 相邻线程访问相邻 idx,读写都是合并访存。
for (int i = idx; i < n; i += stride) {
c[i] = a[i] + b[i];
}
}
void launch_vector_add(
const float* a,
const float* b,
float* c,
int n,
cudaStream_t stream
) {
constexpr int BLOCK_SIZE = 256;
int grid = (n + BLOCK_SIZE - 1) / BLOCK_SIZE;
// 限制 grid 上限是常见写法,避免 n 极大时创建过多 block。
// grid-stride loop 会保证剩余元素继续被处理。
grid = grid > 4096 ? 4096 : grid;
vector_add_kernel<<<grid, BLOCK_SIZE, 0, stream>>>(a, b, c, n);
}检查方法
有 CUDA 环境时执行 nvcc -std=c++17 -c /tmp/01_vector_add.cu -o /tmp/01_vector_add.cu.o;无 CUDA 环境时只做静态审查并登记待验证。
提交时请给出:补齐后的代码、实际运行输出(环境不可用时注明“仅静态审查”)以及对失败用例的解释。
Q1
不要背定义:请从输入、状态变化和输出三个阶段解释“线程映射与 grid-stride loop”的工作机制。
你的答案:
Q2
删掉 i < n 后为什么小尺寸测试也可能偶尔看似正常?
你的答案:
Q3
若 N 远大于最大 grid,为什么 grid-stride loop 仍能覆盖全部元素?
你的答案:
评分与通过规则
- 代码 4 分:正常输入 2 分,边界输入 1 分,解释实现 1 分;
- Q1~Q3 各 2 分;
- 一票否决:结果碰巧正确但核心因果链错误、删除边界检查、把未运行结果说成实测。
需要提示时按四级机制请求:概念区域 → 具体方向 → 关键局部 → 完整答案。