显卡内存结构和 CUDA 基本理解
这页只讲一件事:GPU 为什么快,以及你写 CUDA 时到底在跟谁打交道。核心不是“会写 kernel”这么简单,核心是你得同时理解 计算核心怎么并行 和 数据在内存层级里怎么流动。大多数 CUDA 优化,最后都能还原成一句话:让更多线程持续吃到数据,并且别让慢内存把快核心饿死。
先把结论打穿:GPU 快,不是因为“单核猛”,而是因为“同时开很多工”
CPU 的思路
少量复杂核心,擅长分支、缓存、乱序执行、低延迟响应。它追求的是:一个线程尽量快。
所以 CPU 很适合操作系统、数据库调度、复杂逻辑、强分支代码。
GPU 的思路
大量相对简单的计算单元,追求高吞吐。它不想把一个任务做得特别快,而是想:一次把成千上万个相似任务一起做完。
所以 GPU 特别适合矩阵乘、卷积、向量加法、attention、图像处理这类规则并行工作。
GPU 计算核心:不是一坨“核”,而是一套分层调度系统
一个简化版 GPU 视图
SM 组成,还连着大显存 HBM / GDDR32 个线程一起走threadIdx最容易搞错的点
1. CUDA core 不等于 CPU core。
GPU 的所谓“核心”更像一组算术执行单元,不是一个带复杂控制逻辑的独立���脑。
2. 线程不是独立飞行。
硬件按 warp 执行。一个 warp 里 32 个线程通常同一拍执行同一条指令。
3. SM 才是资源竞争中心。
寄存器、shared memory、可驻留的 block 数量,都会限制并发度。
| 层级 | 它负责什么 | 你写 CUDA 时该关心什么 |
|---|---|---|
| GPU | 全局吞吐、显存带宽、SM 数量、L2 容量 | 问题规模够不够大,是否能把卡喂满 |
| SM | 执行 block,调度 warp,管理片上资源 | occupancy、寄存器压力、shared memory 使用量 |
| Warp | 实际指令发射单位 | 避免 warp divergence,按 32 线程思考访存与分支 |
| Thread | 处理单个元素或小块任务 | 索引映射是否连续,是否越界,是否负载均衡 |
内存架构:CUDA 性能很多时候不是算出来的,是“搬出来的”
GPU 的麻烦在于:算得飞快,但最慢的是取数据。于是整个架构被设计成多层内存金字塔,越靠近执行单元越快、越小、越贵。
从快到慢的内存层级
每层内存到底怎么理解
寄存器:最快,但你一旦用太多寄存器,每个 SM 能同时挂的线程就会下降,occupancy 掉下去。
Shared memory:CUDA 优化最常用武器。把会重复用的数据先搬进来,让一个 block 里多个线程反复消费。
Global memory:容量最大,也是你最容易被坑的地方。访问模式乱,带宽就被浪费;访问模式整齐,硬件才能合并事务。
Host ↔ Device:很多“GPU 没提速”的真凶不是 kernel 慢,而是数据来回拷贝把收益吃光了。
| 内存 | 作用 | 优点 | 典型坑 |
|---|---|---|---|
| Register | 线程局部临时值 | 最快 | 用太多会压低并发 |
| Shared Memory | block 内共享缓存 | 低延迟,可显式控制 | bank conflict、容量有限、同步开销 |
| L2 | GPU 级缓存 | 减少显存访问压力 | 不可完全依赖,命中模式受 workload 影响大 |
| Global Memory | 主显存 | 大 | 高延迟,乱访问浪费带宽 |
| Host Memory | CPU 内存 | CPU 侧处理方便 | 跨总线传输慢 |
为什么“连续访存”这么重要
合并访存 coalescing
一个 warp 里 32 个线程如果访问的是连续地址,硬件就能把这些小访问合并成少量大事务。这样带宽利用率高,延迟也更好隐藏。
如果 32 个线程东一榔头西一棒子地取数据,显存控制器就得处理一堆碎片事务,性能直接塌。
分支发散 divergence
同一个 warp 里如果线程走不同分支,硬件常常只能先执行一部分线程,再执行另一部分线程,等于串行化。
所以 GPU 喜欢规则、整齐、批量化的工作,不喜欢“一堆线程各有主意”。
CUDA 执行模型:你写的是 kernel,硬件看到的是 grid → block → warp → thread
CPU 分配内存、把数据拷到 GPU、配置 kernel 启动参数。
你写
kernel<<<grid, block>>>(...),把任务切成很多 block。每个 block 只会待在一个 SM 上运行,但一个 SM 可以同时驻留多个 block。
硬件按 warp 发射指令,warp 之间交替执行,用来隐藏访存延迟。
每个线程根据
blockIdx、threadIdx 算出全局索引,处理一个元素或一个 tile 的一部分。最小 kernel 例子
这个例子里真正发生了什么
threads = 256:一个 block 有 256 个线程,也就是 8 个 warp。
blocks:总任务量除以每个 block 能处理的数量。
i:把二维层级信息压成一个全局一维索引。
if (i < n):防越界。最后一个 block 往往不会正好填满。
CUDA 为什么这样设计
原因 1:让程序员描述并行
你只要说“有多少线程、每个线程干什么”,硬件负责把 block 调到各个 SM 跑。这样同一段代码可以映射到不同代 GPU。
原因 2:让硬件隐藏延迟
一个 warp 等显存时,SM 可以切去执行另一个就绪 warp。GPU 不是靠单线程低延迟赢,而是靠大量并发把等待时间盖掉。
原因 3:鼓励局部协作
block 内线程能共享 shared memory,还能同步;block 之间默认独立,方便大规模扩展和调度。
CUDA 怎么用好:把优化问题压缩成 6 个硬原则
如果算得少、搬得多,重点是访存;如果矩阵乘这类算得很重,重点是 Tensor Core、tile、数据布局。
索引设计优先保证 coalescing。线程编号和数据排布要对齐,不然显存带宽白白漏掉。
凡是一个 block 内多个线程都要反复读的值,都值得考虑先搬到 shared memory。
资源用太猛,SM 同时能驻留的 warp 变少,延迟反而藏不住。occupancy 不是越高越好,但太低通常危险。
把大量分支判断移到 kernel 外,或者让一个 warp 内线程尽量走同一路。
一次性把更多工作留在 GPU 上,用 stream、异步拷贝、pipeline 重叠传输和计算,别跑一个小 kernel 就回 CPU 一趟。
把这些原则映射到真实工作负载
| 场景 | 真正卡在哪 | 常见 CUDA 思路 |
|---|---|---|
| 向量加法、简单逐元素算子 | 通常是显存带宽 | 连续访存、足够大 batch、kernel fusion,别让 launch 开销和拷贝开销盖过计算 |
| 矩阵乘 / GEMM | 算力 + 数据搬运同时重要 | tile、shared memory、寄存器 blocking、Tensor Core、布局对齐 |
| 卷积 | 数据复用和访存模式 | im2col / direct conv / Winograd,核心还是提高局部性 |
| LLM attention / KV cache | 显存容量、带宽、cache 命中 | paged KV、连续内存布局、prefill/decode 分治、减少无效搬运 |
| 稀疏或强分支任务 | warp 发散、负载不均 | 重排数据、分桶、压缩表示,尽量把相似工作分到一起 |
一张图记住 CUDA 的本质
所以,CUDA 编程不是“写一堆线程”这么简单,而是:把算法改写成适合 GPU 吞吐机器的形状,再把数据放到对的位置。