AI技术总监级拆解大模型|第16讲:GPU 与 CUDA------大模型计算到底是怎么跑起来的?
关键词: GPU、CPU、CUDA、Kernel、Thread、Block、Grid、Warp、SIMT、显存、并行计算、PyTorch、性能分析
前15讲,我们从 AI 基础一路讲到大模型推理。现在换个视角:模型里那么多矩阵乘法、注意力计算和梯度运算,究竟由什么硬件执行?为什么 GPU 能把这类任务做得很快?为什么同样把程序放到 GPU 上,有时却快不了多少?
这一讲不追求一上来就写复杂 CUDA,而是把硬件、编程模型和工程性能串起来。读完后,你应该能看懂 CUDA 的基本结构,理解一个最小 Kernel,知道 GPU 性能到底应该怎么测。
一、先讲结论:GPU 的优势是高吞吐并行计算,不是"什么都比 CPU 快"
先看一个简单任务:对一百万个元素逐个执行加法。
C_i=A_i+B_i,\\quad i=0,1,\\ldots,N-1
每个结果只依赖对应的 (A_i) 和 (B_i),各个元素之间几乎没有依赖关系。这类任务可以拆成许多独立的小任务并行执行,称为数据并行(Data Parallelism)。
GPU 的设计目标之一,就是高效处理大量这种相似工作。深度学习恰好有大量矩阵乘法、向量运算、卷积、注意力以及梯度计算,所以 GPU 成为主流 AI 计算设备之一。
但这里有个关键区别:
- 延迟(Latency): 单个任务从开始到完成要多久。
- 吞吐(Throughput): 一段时间总共完成多少工作。
GPU 往往有利于提升大规模任务的吞吐;小任务、复杂分支任务,或频繁在 CPU 和 GPU 之间搬运数据的任务,不一定更快。把 GPU 当作"通用加速按钮",是第一大误区。
二、CPU 和 GPU 的差别:执行目标不同
| 维度 | CPU | GPU |
|---|---|---|
| 核心设计取向 | 强调低延迟、复杂控制和灵活调度 | 强调大规模并行与高吞吐 |
| 典型工作 | 操作系统、程序控制、I/O、复杂分支、任务调度 | 矩阵运算、图像处理、批量张量计算、深度学习算子 |
| 并行能力 | 能多核并行,但资源组织偏向复杂通用任务 | 用大量执行线程处理许多相似的数据任务 |
| 可能的瓶颈 | 指令依赖、分支、单线程延迟、内存等待 | 显存带宽、访问模式、计算单元利用率、同步与启动开销 |
不要把差别简化成"CPU 有几个核,GPU 有几千个核"。二者的核、调度方式和执行组织并不完全可比。GPU 也并非只有一种执行单元:除了通用算术执行资源,现代 GPU 还可能提供专用矩阵计算单元等硬件。
一个生活化例子
假设一家公司要处理 100 万张结构相似的小表格:
- CPU 像一组擅长协调、判断和处理复杂例外的工作人员;
- GPU 像一套可以把大量格式相似的计算任务拆开、批量推进的流水化作业系统。
这个比喻只是帮助建立直觉。真实 GPU 不是"成千上万个彼此完全独立的小 CPU",其执行和调度由具体硬件架构决定。
结论:GPU 的性能优势来自并行组织、执行吞吐、内存系统和软件配合,而不只是核心数量。
三、GPU 内部结构:先认识 SM、CUDA Core 和 Tensor Core
可以先记住这张简化图:
text
GPU
├── 多个 SM(Streaming Multiprocessor,流式多处理器)
│ ├── 线程调度与执行资源
│ ├── CUDA Cores 等通用算术执行资源
│ ├── Registers(寄存器)
│ ├── Shared Memory / Cache 等片上存储资源
│ └── 某些架构包含 Tensor Cores 等专用计算单元
├── 缓存层次
└── 显存接口和其他系统单元
这是一张便于学习的概念图,不是所有 GPU 架构的精确电路图。具体数量和实现因厂商、型号与架构而变化。
1. SM 是什么?
SM 是 NVIDIA GPU 组织和执行线程的重要硬件单元。CUDA 的 Block 会被调度到 SM 上执行。一个 SM 能同时驻留多少 Block,受寄存器、共享内存以及硬件资源限制影响。
程序员通常不需要,也不能在普通 CUDA Kernel 启动中简单指定"Block 0 必须运行在 SM 0"。CUDA 会依据硬件可用资源调度 Block。可扩展程序应尽量让不同 Block 相互独立,不假设它们的执行顺序。
2. CUDA Core 是什么?
CUDA Core 是 NVIDIA GPU 中执行常见算术指令的计算资源之一。但 CUDA Core 数量不等于实际速度,不能只看核心数量判断两张不同架构 GPU 的性能。频率、指令类型、数据精度、缓存、内存带宽、编译器和 Kernel 实现都会影响结果。
3. Tensor Core 又是什么?
Tensor Core 是 NVIDIA 某些 GPU 架构中面向矩阵运算的专用计算单元,对深度学习中的矩阵乘法十分重要。实际收益依赖于硬件、数据类型、矩阵尺寸和软件实现。
本讲只需要知道它的定位。Tensor Core、FP16/BF16、HBM/SRAM、数据复用和 FlashAttention 将在第17讲深入讨论。
四、CUDA 是什么?别把 GPU、CUDA、Kernel 混为一谈
三个概念先划清:
- GPU: 执行计算的硬件。
- CUDA: NVIDIA 的并行计算平台和编程生态,提供编程模型、编译/运行支持、库和开发工具。
- Kernel: 编写给 GPU 并行执行的函数。
还会遇到这些名字:
| 概念 | 作用 |
|---|---|
| CUDA C/C++ | 在 C/C++ 基础上使用 CUDA 扩展编写 GPU 程序 |
| CUDA Runtime / Driver | 支持设备管理、内存操作、Kernel 启动等;二者处于不同软件层次 |
| cuBLAS | 常见的 GPU 线性代数库之一 |
| cuDNN | 深度学习相关的优化计算库之一 |
| PyTorch | 更高层的深度学习框架,组织张量、计算图、自动求导和算子调用 |
CUDA 是 NVIDIA 生态,不是 GPU 的通用同义词。其他厂商也有各自的 GPU 编程工具与软件栈,不能把所有 GPU 编程都叫 CUDA。
从 Python 到 GPU,大致经过哪些层?
text
PyTorch 模型代码
↓
张量算子(矩阵乘法、卷积、Attention 等)
↓
框架调度 / 优化库 / CUDA Kernel
↓
CUDA Runtime 与 Driver 提交工作
↓
GPU 的 SM、执行单元、缓存和显存系统完成运算
当你在 PyTorch 里写 y = x @ w 时,通常是框架调用适合当前张量形状和硬件的矩阵乘法实现。你并没有在 Python 里手工为每个矩阵元素创建一个 Python 线程。
五、CUDA 编程模型:Thread → Block → Grid
CUDA 把并行工作组织成三个层次:
text
Grid:一次 Kernel 启动中的所有线程块
├── Block 0
│ ├── Thread 0
│ ├── Thread 1
│ └── ...
├── Block 1
│ ├── Thread 0
│ └── ...
└── Block 2 ...
- Thread(线程): 一个工作实例,通常负责一个或一部分数据。
- Block(线程块): 一组线程,能共享指定的 Shared Memory,并进行 Block 内同步。
- Grid(网格): 一次 Kernel 启动中的全部 Block。
- Warp: NVIDIA CUDA 执行模型中的常见执行组,通常包含 32 个线程。
可以把这个结构理解为:Grid 负责把整体任务分成很多块;每个 Block 负责其中一部分;每个 Thread 再负责更细的工作。
1. 最常见的全局索引公式
一维任务经常会写:
cpp
int i = blockIdx.x * blockDim.x + threadIdx.x;
符号逐个解释:
threadIdx.x:当前线程在 Block 内的索引;blockIdx.x:当前 Block 在 Grid 中的索引;blockDim.x:一个 Block 在 x 方向有多少线程;i:当前线程对应的全局工作索引。
假设每个 Block 有 256 个线程:
text
Block 0:i = 0 ... 255
Block 1:i = 256 ... 511
Block 2:i = 512 ... 767
...
这只是逻辑上的索引分工。具体 Block 在什么时间、由哪个 SM 执行,是运行时调度问题。
2. 为什么 Block 间不能随便假设执行顺序?
GPU 会根据硬件资源和调度情况执行 Block。不同 Block 可能并行执行,也可能分批先后执行;普通 Kernel 中不应依赖它们按 Block 编号执行。
Block 内有可用的共享内存与同步机制;跨 Block 协调则需要更谨慎的算法设计。__syncthreads() 只能作为 Block 内同步点,它不是让整个 Grid 的所有线程同时停下来等待的全局屏障。
这是很多初学 CUDA 程序出现死锁、数据竞争或结果不稳定的原因之一。
六、动手看懂一个 CUDA Kernel:向量加法
先看数学问题:
C_i=A_i+B_i,\\quad 0\\le i\ 每一个输出元素都能独立计算,因此可以把每个元素分配给一个线程。 解释: Block 数量按向上取整计算:
\\text{blocks}=\\left\\lceil\\frac{N}{\\text{threads}}\\right\\rceil
举例:N=1000,每个 Block 256 个线程:
\\text{blocks}=\\lceil1000/256\\rceil=4
一共启动 4×256=1024 个逻辑线程,最后 24 个线程的索引大于等于 1000。若没有 注意:256 是一个常见的初始配置,不是任何 Kernel 都最优的数字。应该根据资源占用、访存方式和实测结果调整。 真实程序通常还需要: 这里的代码重点展示 Kernel 结构,不是完整可独立编译的工程。生产代码还应对 CUDA API 和 Kernel 启动错误进行检查。 SIMT 是 Single Instruction, Multiple Threads(单指令多线程)。通俗理解:一个 Warp 内的线程通常共同推进相同的指令流,但每个线程处理不同的数据、维护自己的状态。 例如: 所有线程执行同类加法,区别只是索引不同。这种规律性让硬件容易组织并行执行。 考虑下面的代码: 如果同一个 Warp 中一部分线程满足条件、另一部分不满足,Warp 内线程就可能走不同路径。硬件需要处理各个分支路径,一部分执行通道可能暂时没有有效工作,这会影响效率,这就是分支分化(Branch Divergence)。 不是所有分支都很糟糕。影响大小取决于分支是否在 Warp 内分化、不同路径的计算量,以及编译器和硬件如何处理。不要为了消除每一个 因为 Warp 通常包含 32 个线程,128、256 等配置能够整除 32,方便形成完整 Warp。但最优值不是固定的。每线程寄存器数量、每 Block 的共享内存需求、可同时驻留的 Block 数量、Kernel 的指令结构都会影响最终表现。 另外,线程数不是实际并行度的全部:一个 Kernel 即使创建了很多线程,也不表示它们都能在同一时刻运行。硬件会依据资源限制安排实际执行。 传统独立显卡系统中,CPU 主机内存与 GPU 设备内存通常是不同的内存空间: H2D 是 Host-to-Device;D2H 是 Device-to-Host。数据传输通常会经过 PCIe 或系统提供的其他互连。 假设某个计算本身只用 0.1 毫秒,却要花 0.5 毫秒复制数据和启动相关任务,那么只盯着计算单元是没有意义的。你真正关心的往往是端到端总时间。 这也解释了为什么: 现代 GPU 平台还可能提供 Unified Memory 或共享内存架构。它们能简化部分数据管理,却不意味着所有访问成本相同,也不代表数据移动变得免费。 GPU 并不只是一排计算单元。内存层次是性能的另一半。 这里需要澄清:CUDA 术语里的 Local Memory 不一定是物理上很近、访问很快的存储;某些线程局部数据如果寄存器资源不足,可能被放到设备内存层次中。不要只凭名称判断速度。 为什么它与 AI 有关?矩阵乘法和 Attention 不仅要计算,还要不断读取输入、权重、中间结果并写出结果。如果重复加载同一份数据,或访问地址让硬件难以合并请求,就可能浪费大量内存带宽。 本讲建立"内存也会卡性能"的直觉即可。第17讲会进一步讨论 HBM、SRAM、Tensor Core、访存合并、数据复用、Kernel 融合以及 FlashAttention。 一个简化的训练循环是: 这些步骤包含大量张量计算;特别是矩阵乘法和反向传播,通常有较强的数据并行结构。GPU 可以承担核心数值计算,但训练性能还受显存容量、激活保存、梯度与优化器状态、数据加载、分布式通信和同步等影响。 推理时,模型用已训练好的权重生成结果。矩阵乘法、Attention、归一化等算子依然可能在 GPU 执行。对自回归大模型,提示词处理(Prefill)与逐 Token 生成(Decode)的计算形态并不相同,瓶颈也可能不同。这个主题会在第20讲展开。 要记住:GPU 不知道什么是"语言理解"。它只执行数值计算。模型结构与权重由软件定义,框架把模型操作映射成底层算子,再由 GPU 执行。 如果 这段程序把张量放到选定设备上,再执行矩阵乘法。 常见错误是一个张量在 GPU、另一个在 CPU: 排查时检查输入、权重、标签以及中间张量的 GPU 操作常常是异步提交的。CPU 发出命令后可能继续执行后面的 Python 代码,而 GPU 此时还在计算。如果计时没有包含 GPU 真正完成工作的时间,就会把提交开销当成计算耗时。 同步是为了让计时边界包含本次 GPU 计算完成的时间。这个例子只演示基本原则,不能直接当作严谨的性能基准:正式 benchmark 应预热、重复测试、控制后台负载和显存状态,报告中位数或分布;更精细的测量可用 CUDA Events 或 Profiler。 实际优化闭环建议这样做: 不要只盯 GPU 利用率百分比。利用率高不自动意味着实现高效;利用率低也不一定意味着存在问题------一个很短、延迟受限的请求可能就无法填满整张卡。 可以用这几个问题做初筛: 如果是大型矩阵运算,GPU 通常值得优先评估;如果只是少量简单元素操作,CPU 可能足够好。最终答案要靠真实工作负载测量,而不是只靠硬件宣传参数。 本讲必须掌握的结论: 1. 为什么大模型适合 GPU? 因为神经网络包含大量矩阵和张量运算,具有可并行处理的结构;但能否加速仍取决于任务规模、内存访问和软件实现。 2. Thread、Block、Grid 各指什么? Thread 是工作实例,Block 是可协作的一组线程,Grid 是一次 Kernel 启动中的所有 Block。 3. 为什么 因为 Block 数量向上取整后,启动的逻辑线程可能多于实际元素数;边界检查用于避免越界读写。 4. 为什么 GPU Kernel 很快,但整个程序没快多少? 因为总时间还包含数据准备、CPU-GPU 传输、Kernel 启动、同步和后处理;这些开销可能占较大比例。 5. 为什么要先 Profiling 再优化? 因为瓶颈可能在 CPU、内存、数据传输、同步或某个算子,盲目调整线程数或手写 Kernel 未必解决实际问题。 6. 不能,它用于当前 Block 内线程同步,不能作为普通 Kernel 的全 Grid 全局屏障。 下一讲预告:第17讲|Tensor Core、HBM、SRAM、Kernel 优化与 FlashAttention。 第16讲回答"GPU 如何组织并行计算",第17讲将继续回答"为什么某些 GPU 算子能跑满硬件、某些却被内存访问和数据搬运卡住"。 《AI技术总监级拆解大模型》系列第16讲。内容按原理、执行模型、代码、性能分析与工程实践展开;硬件细节以对应 GPU 架构和软件版本的官方文档为准。1. Kernel 代码
#include <cuda_runtime.h>
__global__ void vector_add(const float* A,
const float* B,
float* C,
int N) {
int i = blockIdx.x * blockDim.x + threadIdx.x;
if (i < N) {
C[i] = A[i] + B[i];
}
}
__global__:声明一个可以从 Host 端启动、在 GPU 上执行的 Kernel;A、B、C:指针参数;在典型离散 GPU 场景中,传给 Kernel 的这些指针应指向 GPU 可访问的内存;i:每个线程通过自己的索引找到负责的数据;if (i < N):保护最后一个 Block,避免数组越界。2. 启动方式
int threads = 256;
int blocks = (N + threads - 1) / threads;
vector_add<<<blocks, threads>>>(d_A, d_B, d_C, N);<<<blocks, threads>>> 是 CUDA Kernel 的启动配置,表示启动多少个 Block,以及每个 Block 有多少线程。if (i < N),这些线程可能越界访问内存。3. 完整程序还缺什么?
cudaMalloc;cudaMemcpy;七、SIMT 与 Warp:为什么 GPU 适合做"同一类计算"?
Thread 0:C[0] = A[0] + B[0]
Thread 1:C[1] = A[1] + B[1]
Thread 2:C[2] = A[2] + B[2]
...分支分化是什么?
if (x > 0) {
// 计算路径 A
} else {
// 计算路径 B
}if 盲目改写代码;应该先 Profiling。为什么 Block 线程数常用 128 或 256?
八、Host、Device 和显存搬运:为什么"GPU 很快"仍然可能不快?
CPU / Host Memory
│ 输入数据复制(H2D)
▼
GPU / Device Memory
│ Kernel 读数据、计算、写结果
▼
GPU / Device Memory
│ 结果复制(D2H,如有必要)
▼
CPU / Host Memory
九、GPU 内存先认识四层:寄存器、共享内存、缓存、全局内存
概念
谁使用
用途与直觉
Register(寄存器)
单个线程
存放线程计算中的临时值,访问非常快但资源有限
Shared Memory(共享内存)
同一 Block 内线程
让线程协作共享数据,常用于数据复用和减少重复访问
L1/L2 Cache
由硬件缓存机制管理,具体细节随架构变化
利用数据局部性,降低部分访存成本
Global Memory(全局内存)
Kernel 线程可访问
容量较大,常对应 GPU 显存;访问代价比寄存器等片上资源更高
十、GPU 在大模型训练与推理中的位置
1. 训练:前向、损失、反向和参数更新
输入一个 Batch
↓
前向传播:计算预测
↓
计算 Loss
↓
反向传播:计算梯度
↓
优化器更新参数
↓
进入下一个 Batch2. 推理:同样使用 GPU,但目标与瓶颈不同
十一、用 PyTorch 检查 GPU:把抽象概念落到代码
1. 检查环境
import torch
print("PyTorch version:", torch.__version__)
print("CUDA available:", torch.cuda.is_available())
if torch.cuda.is_available():
print("GPU name:", torch.cuda.get_device_name(0))torch.cuda.is_available() 为 False,常见原因包括安装的 PyTorch 构建不支持当前 CUDA 设备、驱动问题、运行环境没有暴露 GPU,或环境配置不匹配。不要仅凭电脑有 NVIDIA 显卡,就断定 Python 环境一定可使用 GPU。2. 在 GPU 上做矩阵乘法
import torch
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
x = torch.randn(2048, 2048, device=device)
w = torch.randn(2048, 2048, device=device)
y = x @ w
print("input device:", x.device)
print("output device:", y.device)@ 是矩阵乘法运算符;实际底层会由 PyTorch 依据设备、形状和可用实现调度对应算子。
x = torch.randn(100, device="cuda")
w = torch.randn(100) # 默认通常在 CPU
z = x + w # 设备不匹配,通常会报错.device。3. 为什么直接用
time.time() 测 GPU 经常不准?
import time
import torch
if torch.cuda.is_available():
a = torch.randn(4096, 4096, device="cuda")
b = torch.randn(4096, 4096, device="cuda")
torch.cuda.synchronize()
start = time.perf_counter()
c = a @ b
torch.cuda.synchronize()
elapsed = time.perf_counter() - start
print(f"elapsed: {elapsed:.4f} seconds")十二、性能优化应该从哪里下手?先找瓶颈,不要先调参数
现象
可能原因
第一轮排查方向
小任务用 GPU 更慢
启动和数据传输占比太高
测端到端耗时,减少碎片化操作
GPU 利用率不高
工作量不足、CPU 供数慢、同步或依赖阻塞
检查数据加载、Batch、算子调用链和同步点
显存不足 / OOM
权重、激活、梯度、优化器状态或缓存占用过大
调整 Batch、精度、梯度累积和内存生命周期
计算核心没跑满
访存低效、Kernel 太碎、工作分布不均或资源占用受限
用 Profiler 定位,检查访问模式、融合和并行配置
增大线程数反而变慢
寄存器/共享内存消耗增加,驻留 Block 变少,或配置不匹配
对多个配置做可重复测量
第一次运行明显慢
初始化、上下文创建、Kernel 加载或缓存预热
分开统计冷启动与稳态性能
常用工具各管什么?
十三、初学 CUDA 最容易踩的 10 个坑
__syncthreads() 当全局屏障: 它是 Block 内同步机制。十四、如何判断一个任务是否值得放到 GPU?
十五、本讲总结:把整个系统串起来
算法/模型:定义要进行哪些数值计算
↓
框架:把操作组织成张量算子和计算图
↓
CUDA 与优化库:提供并行计算、内存和 Kernel 执行机制
↓
GPU:由 SM 和计算执行资源处理大量线程
↓
显存/缓存/共享内存:提供数据存放与访问路径
↓
Profiling:找出计算、内存、传输或同步瓶颈
↓
优化:调整算子、数据布局、批量方式与并行配置
blockIdx.x * blockDim.x + threadIdx.x,并要做边界检查。自测题
blocks = (N + threads - 1) / threads 后,还需要 if (i < N)?__syncthreads() 能否让整个 Grid 的所有 Block 同时等待?参考资料