本站内容由 AI 生成,可能存在错误。如发现问题,欢迎到 GitHub Issues 反馈。

CUDA 编程模型 — 从代码到硬件

CUDA 编程模型 — 从代码到硬件

更新于 2026-04-04

GPU Architecture 文章中,我们了解了 SM 内部结构 — 四个 Processing Block、Warp Scheduler、各类计算单元和内存层次。在 矩阵加速单元 文章中,我们看到了 Tensor Core 和 XMX 的 warp 级协作操作。

现在的问题是:程序员如何控制这些硬件? 本文从 CUDA 编程模型出发,理解 GPU 编程的核心抽象 — 线程层级、内存模型、同步机制,以及这些抽象如何映射到物理硬件。

Section 1: SIMD vs SIMT — 两种并行执行模型

GPU 并行计算有两种主要模型,理解它们的区别是理解 CUDA 编程的起点。

SIMD (Single Instruction Multiple Data):一条指令操作一个向量。程序员必须知道向量宽度(8/16/32),用 intrinsic 或编译器向量化来利用硬件。Intel iGPU 的 EU 内部就是 SIMD 驱动。

SIMT (Single Instruction Multiple Threads):程序员写标量代码(看起来像单线程),硬件自动把 32 个线程打包成 warp 一起执行。程序员不需要管向量宽度。NVIDIA GPU 用的就是 SIMT。

基本操作: a[i] = b[i] + c[i]
同一操作 a[i] = b[i] + c[i] 的两种执行方式SIMD (Intel iGPU)一条指令操作 8-wide 向量vaddb0+c0b1+c1b2+c2b3+c3b4+c4b5+c5b6+c6b7+c7vadd.8 a[0:7], b[0:7], c[0:7] // 一条向量指令SIMT (NVIDIA GPU)32 个线程各执行标量代码 (显示 8 个)T0-T7b0+c0b1+c1b2+c2b3+c3b4+c4b5+c5b6+c6b7+c7a[tid] = b[tid] + c[tid]; // 标量代码 x32 线程结果相同 — 但编程模型不同:SIMD 程序员必须知道向量宽度,SIMT 程序员写标量代码SIMD: 编译器/程序员负责向量化 | SIMT: 硬件自动将 32 个标量线程打包为 warpSIMD 特点显式向量宽度 (8/16/32)程序员用 intrinsic 或编译器向量化Intel EU / CPU SSE/AVXSIMT 特点向量宽度对程序员透明写标量代码,硬件打包执行NVIDIA Warp (32 threads)

关键区别在于 分支处理:SIMT 对分支更友好 — warp divergence 只是效率损失,不需要程序员手动管理 mask。SIMD 的分支需要显式 mask 或 blend 指令。

三种并行执行模型对比SIMD (经典)SIMT (NVIDIA)Intel iGPU (混合)编程视角显式向量指令(intrinsic / 编译器向量化)标量代码(硬件自动并行)SYCL work-item 标量代码(sub-group 暴露 SIMD)硬件执行一条指令操作N-wide 向量寄存器Warp (32 threads)锁步执行同一指令EU Thread 驱动8/16-wide SIMD ALU分支处理需要显式 mask或 blend 指令硬件自动 mask(warp divergence)硬件 mask(channel enable)向量宽度程序员必须知道(8/16/32)对程序员透明(始终 32-wide warp)部分可见(sub-group size)典型硬件CPU (SSE/AVX)Intel EU (底层)NVIDIA SM(FP32 / INT32 Core)Intel Xe-Core(Vector Engine + XMX)

Intel iGPU 是一个有趣的混合体:底层硬件是 SIMD 驱动(EU Thread 执行 8/16-wide 向量操作),但 SYCL/OpenCL 编程层提供了接近 SIMT 的 work-item 抽象。Sub-group 操作暴露了底层 SIMD 宽度,是 Intel GPU 编程的关键工具。


Section 2: Thread → Block → Grid

CUDA 用三级线程层级组织并行计算:

  • Thread(线程):最小执行单元,每个线程执行相同的 kernel 代码,但处理不同的数据
  • Block(线程块):一组线程,共享 Shared Memory,可通过 __syncthreads() 同步
  • Grid(网格):所有 Block 的集合,由一次 kernel launch 产生

每个线程通过 threadIdx(Block 内位置)和 blockIdx(Grid 内 Block 位置)确定自己的身份,配合 blockDim(Block 尺寸)计算全局数据位置。

Grid: Block 的集合
Grid: 所有 Block 的集合 (gridDim = 4×3)每个 Block 是独立的线程组,可被分配到任意 SM 执行GridBlock(0,0)blockIdx=(0,0)Block(1,0)blockIdx=(1,0)Block(2,0)blockIdx=(2,0)Block(3,0)blockIdx=(3,0)Block(0,1)blockIdx=(0,1)Block(1,1)blockIdx=(1,1)Block(2,1)blockIdx=(2,1)Block(3,1)blockIdx=(3,1)Block(0,2)blockIdx=(0,2)Block(1,2)blockIdx=(1,2)Block(2,2)blockIdx=(2,2)Block(3,2)blockIdx=(3,2)高亮 Block(1,1) — 下一步展开看内部线程结构blockIdx.x = 0..gridDim.x-1, blockIdx.y = 0..gridDim.y-1gridDim 指定 Grid 中 Block 的数量 (每个维度)

Block 和 Grid 支持 1D/2D/3D 维度 — 二维索引映射到矩阵操作更自然(threadIdx.x 对应列,threadIdx.y 对应行)。

全局索引计算

最基本的 CUDA 编程模式:每个线程计算自己负责的全局数据位置。

总线程数: 32 | 点击选择线程
1D Grid: 4 blocks × 8 threads = 32 个线程Block 0T0g=0T1g=1T2g=2T3g=3T4g=4T5g=5T6g=6T7g=7Block 1T0g=8T1g=9T2g=10T3g=11T4g=12T5g=13T6g=14T7g=15Block 2T0g=16T1g=17T2g=18T3g=19T4g=20T5g=21T6g=22T7g=23Block 3T0g=24T1g=25T2g=26T3g=27T4g=28T5g=29T6g=30T7g=31Global Index 计算公式globalIdx = threadIdx.x + blockIdx.x * blockDim.xglobalIdx = 3 + 1 × 8 = 11threadIdx.x = 3 (线程在 Block 内的位置) | blockIdx.x = 1 (Block 在 Grid 内的位置) | blockDim.x = 8 (每个 Block 的线程数)这个 globalIdx 就是该线程负责处理的数据元素下标: a[11] = b[11] + c[11]
// 向量加法 kernel — 最简单的 CUDA 程序
__global__ void vecadd(float* a, float* b, float* c, int n) {
    int i = threadIdx.x + blockIdx.x * blockDim.x;
    if (i < n) c[i] = a[i] + b[i];
}
// Launch: vecadd<<<(n+255)/256, 256>>>(d_a, d_b, d_c, n);

Section 3: 逻辑到物理映射

Thread → Block → Grid 是逻辑结构。程序员定义它,但不控制它如何映射到物理硬件。

Grid: 6 个 Block 待分配
逻辑视图: Grid 包含 6 个 Block (每个 128 threads = 4 warps)Block 0128 threadsBlock 1128 threadsBlock 2128 threadsBlock 3128 threadsBlock 4128 threadsBlock 5128 threads物理视图: 4 个 SM (每个最多容纳 2 个 Block)SM 0(空闲)SM 1(空闲)SM 2(空闲)SM 3(空闲)Block 到 SM 的分配由 runtime 决定,顺序不确定、不可控 — 程序不应假设分配顺序

关键要点:

  • Block 到 SM 的分配由 runtime 决定 — 顺序不确定,程序不应假设任何执行顺序
  • 一个 SM 可以同时容纳多个 Block — 受限于 register 用量、shared memory 用量、warp 数量
  • Block 内的 Thread 被硬件打包为 Warp — thread 0-31 = warp 0, thread 32-63 = warp 1, …
  • blockDim 应该是 32 的倍数 — 否则最后一个 warp 有空闲线程,浪费计算资源

Section 4: Shared Memory

__shared__ 声明的内存是 Block 级别的快速存储(在 SM 的 L1/SRAM 中),Block 内所有线程共享,Block 结束时释放。

用途:

  • 线程间通信:一个线程写,其他线程读
  • 数据预加载:从 HBM 加载一次到 shared memory,block 内多个线程复用

Bank 结构

Shared memory 被分为 32 个 bank,连续 4 字节映射到连续 bank。一个 warp 的 32 个线程同时访问不同 bank 时可以一拍完成;访问同一 bank 的不同地址时会产生 bank conflict,必须串行化。

Stride=1: 无冲突
Shared Memory: 32 Banks, 连续 4 字节映射到连续 BankThread i 访问地址 i × 4 bytes → Bank = i % 32Stride=1: Thread i → Bank i (每个线程访问不同 Bank)ThreadsT0T1T2T3T4T5T6T7T8T9T10T11T12T13T14T15T16T17T18T19T20T21T22T23T24T25T26T27T28T29T30T31BanksB0B1B2B3B4B5B6B7B8B9B10B11B12B13B14B15B16B17B18B19B20B21B22B23B24B25B26B27B28B29B30B31无 Bank Conflict — 一拍完成所有 32 个访问32 个线程各访问一个不同的 Bank,硬件并行服务所有请求Bank 映射规则: Bank(addr) = (addr / 4) % 32 — 连续 4 字节在连续 Bank 中

避免 bank conflict 的最简单方法:stride=1 的顺序访问天然无冲突。当 stride 是 2 的幂时容易冲突,可用 padding 技巧(tile[32][33] 而非 tile[32][32])打破对齐。


Section 5: Memory Coalescing

全局内存(HBM)访问以 32 字节或 128 字节 transaction 为单位。一个 warp 的 32 个线程访问连续地址时,硬件可以合并为最少的 transaction — 这就是 memory coalescing

Coalesced: 连续访问
Coalesced Access: Thread i 读 A[i] (连续地址)32 个线程读 32 个连续 float (128 bytes) → 合并为 1 个 128B transactionT0A[0]T1A[1]T2A[2]T3A[3]T4A[4]T5A[5]T6A[6]T7A[7]T8A[8]T9A[9]T10A[10]T11A[11]T12A[12]T13A[13]T14A[14]T15A[15]Global Memory (HBM)1 × 128B transaction效率: 128 / 128 = 100% 带宽利用率传输 128 bytes, 有效数据 128 bytes — 零浪费GPU 内存控制器将 warp 内连续地址合并为最少的 transaction行优先访问矩阵的同一行: thread i 读 M[row][i] — 地址连续,天然 coalesced

实际影响:

  • 行优先访问矩阵的同一行M[row][tid] — 地址连续,天然 coalesced
  • 列优先访问矩阵M[tid][col] — stride = 行宽,严重 uncoalesced
  • 这是为什么矩阵乘法需要 tiling 到 shared memory — 先 coalesced 加载到 shared memory,再在 shared memory 中任意 stride 访问(shared memory 的 bank conflict 比 HBM 的 uncoalesced access 便宜得多)

Section 6: 同步与 Barrier

__syncthreads() 是 Block 内的 barrier:所有线程都到达这个点后才能继续执行。

__syncthreads() — Block 内 Barrier 同步正确: 写 Shared Memory → __syncthreads() → 读 Shared MemoryWarp 0写 smem等待读 smemWarp 1写 smem等待读 smemWarp 2写 smem等待读 smemWarp 3写 smem等待读 smem__syncthreads()所有线程到达后才继续如果没有 __syncthreads():Warp 0 写完 smem 后立即读 → 但 Warp 3 还没写完 → 读到的是旧数据或未初始化数据Race Condition: 结果取决于 warp 执行顺序,不确定且不可复现注意: 所有线程必须执行到同一个 __syncthreads() — 不能在 if/else 分支中不对称调用

典型使用场景:写 shared memory → __syncthreads() → 读 shared memory。没有 barrier 的话,快速 warp 可能读到慢速 warp 还没写完的数据 — race condition

注意事项:

  • 所有线程必须执行到同一个 __syncthreads() — 不能在 if/else 分支中不对称调用(否则死锁)
  • Warp 内线程是硬件锁步执行的,隐式同步 — 但显式用 __syncwarp() 更安全(未来架构可能改变锁步保证)
  • __syncthreads() 只同步 Block 内 — Block 之间没有直接同步机制(这是 GPU 编程模型的核心约束)

Section 7: Occupancy

Occupancy = SM 中活跃 warp 数 / SM 最大 warp 数。更高的 occupancy 意味着更多 warp 可以在内存延迟时切换执行,更好地隐藏延迟。

Occupancy 受三个因素限制:

  1. Warp 数量:blockDim / 32 个 warp per block × blocks per SM
  2. Register 用量:每线程用越多 register,SM 能容纳的 block 越少
  3. Shared memory 用量:每 block 用越多 shared memory,SM 能容纳的 block 越少
Occupancy Calculator — H100 (Hopper SM)100% OccupancyActive Blocks: 8 | Active Warps: 64 / 64 | Warps/Block: 8每种资源允许的最大 Blocks/SM:Warps:8 blocks ← 瓶颈Registers:8 blocks Shared Mem:14 blocks Max Blocks:32 blocks Occupancy = active warps / max warps = 64 / 64 = 100% — 瓶颈: Warps

高 occupancy 不总是更好 — 有时低 occupancy + 高数据复用(大 tile 占满 shared memory 和 register)反而更快。但通常 occupancy 是一个好的优化起点。

编译时用 --ptxas-options=-v 可以查看 kernel 的 register 和 shared memory 使用量。


Section 8: Intel iGPU 编程要点

CUDA 是 NVIDIA 专有的。Intel GPU 用 SYCL / DPC++,基于标准 C++,概念上与 CUDA 一一对应:

CUDA vs SYCL: 同一个向量加法 Kernel颜色标注对应概念 — 核心逻辑完全相同,只是 API 不同CUDA C++// CUDA Vector Addition__global__void vecadd(float* a, float* b, float* c, int n) { int i = threadIdx.x + blockIdx.x * blockDim.x; if (i < n) c[i] = a[i] + b[i];}// Launchvecadd<<<gridDim, blockDim>>> (d_a, d_b, d_c, n);SYCL (Intel DPC++)// SYCL Vector Additionq.parallel_for( nd_range<1>(N, block_size), [=](nd_item<1> item) { int i = item.get_local_id(0) + item.get_group(0) * item.get_local_range(0); if (i < n) c[i] = a[i] + b[i];});// Launch: 内置于 parallel_for// q 是 sycl::queue// 设备选择在 queue 创建时指定概念映射threadIdx.xget_local_id(0)Block/Work-group 内线程 IDblockIdx.xget_group(0)Block/Work-group IDblockDim.xget_local_range(0)Block/Work-group 大小__global__parallel_forKernel 入口<<<grid, block>>>sycl::queue启动 / 设备选择CUDA: __shared__ → SYCL: local accessor | CUDA: __syncthreads() → SYCL: group_barrier()CUDA: warp (32 threads) → SYCL: sub-group (8/16/32, 宽度由硬件决定)

核心术语映射:

  • work-item ≈ thread — 最小执行单元
  • work-group ≈ block — 线程组,共享 SLM
  • sub-group ≈ warp — 但宽度可能是 8/16/32(不固定为 32)
  • SLM (Shared Local Memory) ≈ shared memory — 用法类似

Sub-group 是 Intel GPU 编程的关键 — 它直接暴露了底层 SIMD 宽度。sub_group::shufflesub_group::reduce 对应 NVIDIA 的 warp shuffle 操作。

XMX 矩阵操作通过 SYCL joint_matrix API 或低层 ESIMD dpas 指令访问,对应 NVIDIA 的 wmma / mma.sync(详见 矩阵加速单元 文章)。


总结

CUDA 编程模型的核心抽象:

  1. SIMT 执行模型 — 写标量代码,硬件并行。Warp divergence 是效率问题而非正确性问题
  2. 三级线程层级 — Thread → Block → Grid,Block 是资源分配和同步的基本单位
  3. 内存层级 — Register(私有)→ Shared Memory(Block 共享,快)→ Global/HBM(全局,慢)
  4. Coalescing + Bank — 全局内存要连续访问,shared memory 要避免 bank conflict
  5. Occupancy — warp 数量 × 数据复用 = 性能,三种资源(warp / register / shared memory)中最紧的决定上限

下一篇文章将把这些概念付诸实践 — GEMM 优化,从 Naive 实现到 Tensor Core GEMM,逐步将矩阵乘法性能推到接近硬件峰值。