🔥 本文专栏:CUDA
🌸作者主页:努力努力再努力wz



💪 今日博客励志语录:
你现在觉得不起眼的积累,可能正是未来某一天让你站稳的底气。
思维导图
text
汽车 / 传感器产生数据
↓
如果全部发送到远端云端计算
↓
网络传输距离长
↓
端到端延迟增加
↓
实时场景需要降低延迟
↓
边缘计算
↓
把计算能力放到离数据源更近的位置
↓
边缘节点 / 车载节点
↓
节点内部需要真正执行计算
↓
计算图描述整个计算过程
↓
Operator 表示一个个计算步骤
Tensor 表示算子之间传递的数据
Executor 根据计算图推进执行
Scheduler 负责安排可执行任务
↓
算子可能存在不同 Backend
↓
CPU 实现 / GPU 实现
↓
边缘设备受到:
体积 / 功耗 / 散热 / 实时性约束
↓
需要高集成度计算平台
↓
Orin SoC
↓
CPU + GPU + 内存控制等模块高度集成
↓
CPU / GPU 共享物理 LPDDR
↓
减少传统独立 CPU/GPU 的部分数据搬运
↓
但仍然需要考虑同步和可见性
↓
GPU 适合大量相似并行计算
↓
需要编写 GPU 代码
↓
CUDA
↓
Host Code + Device Code
↓
nvcc 组织 CUDA 编译流程
↓
CPU 启动 Kernel
↓
Grid
↓
Block
↓
Thread
↓
Thread 通过编号映射数据
↓
Block 内线程按 32 个组成 Warp
↓
Block 被调度到 SM
↓
Warp Scheduler 调度 Warp
↓
Register / Shared Memory / Global Memory
↓
Shared Memory 存在线程协作
↓
__syncthreads() 建立阶段屏障
↓
cudaMalloc / cudaMemcpy / Kernel / cudaFree
↓
形成一条完整 CUDA 执行链
↓
最终回到边缘计算 GPU 算子开发
一、为什么会出现边缘计算
在开始学习 CUDA、GPU 以及 Orin 之前,如果直接从:
text
Kernel
Grid
Block
Thread
Warp
SM
Shared Memory
这些概念切入,很容易出现一个问题:
每一个名词似乎都能理解,但是却不知道这些东西为什么会出现在自己的项目里。
因此这里最合适的切入点仍然不是 CUDA,而是我们当前真正面对的场景------边缘计算。
1. 云端到底是什么
在我们当前接触的系统中,可以先把云端理解为一个管理者。
它并不是单纯表示"某一台很远的服务器",而是整个系统中负责管理、调度和协调计算资源的一侧。
可以先建立这样一个最简单的模型:
text
云端
↓
管理
↓
算力池
而所谓算力池,本质上并不神秘,它就是:
一堆可以真正完成计算任务的计算节点。
例如:
text
算力池
├── Node-1
├── Node-2
├── Node-3
└── Node-4
这些节点按照部署位置,又可以进一步区分为:
text
计算节点
├── 车载节点
└── 边缘节点
这里区分车载节点和边缘节点的核心,并不是计算逻辑一定完全不同,而是:
它们部署的位置不同。
车载节点部署在汽车内部,而边缘节点则部署在汽车之外、但是距离数据源相对较近的位置。
2. 为什么要把计算节点放到边缘
假设汽车上的摄像头、雷达或者其他传感器产生了一份数据。
如果所有计算都放在远端云服务器,那么整个链路可能是:
text
汽车传感器
↓
采集数据
↓
通过网络发送
↓
远端服务器
↓
执行计算
↓
得到结果
↓
再次通过网络返回
↓
汽车获得结果
这里真正影响端到端延迟的,不一定只有"计算本身"。
网络传输同样会引入:
text
传输距离
网络波动
排队
协议处理
链路拥塞
等额外开销。
而汽车、机器人、工业控制等场景往往对实时性要求较高。
这里所谓实时性要求高,可以先简单理解为:
结果不只是要算出来,还要在规定时间内足够快地返回。
例如障碍物已经出现在车辆前方,那么几十毫秒或者几百毫秒以后得到结果,与几秒钟以后得到结果,其意义完全不同。
因此,一个自然的优化思路就是:
text
既然远距离网络传输会产生开销
↓
那就不要把所有数据都送到很远的位置计算
↓
把计算能力放到离数据源更近的位置
↓
边缘计算
所以边缘节点可以先理解为:
距离数据产生位置更近、能够直接承担一部分计算任务的计算节点。
二、边缘节点内部到底在计算什么
认识了边缘节点以后,接下来就需要把视角缩小到一个具体节点内部。
一个计算节点最终做的事情,可以抽象为:
text
输入
↓
计算
↓
输出
也就是数学上最简单的:
text
y = f(x)
但是实际的 AI 推理或者数据处理过程通常并不是一步完成的。
例如:
text
输入
↓
预处理
↓
矩阵计算
↓
激活
↓
归一化
↓
其他计算
↓
输出
因此整个计算过程会被拆成很多小步骤。
这些具体的计算步骤,就是后面经常会遇到的:
text
Operator
算子
1. Operator:一个具体的计算步骤
可以把一个算子先理解成:
接收输入数据,完成某一种明确的计算,再产生输出数据。
例如:
text
Input
↓
Resize
↓
Normalize
↓
Conv
↓
ReLU
↓
MatMul
↓
Output
其中:
text
Resize
Normalize
Conv
ReLU
MatMul
都可以看成一个个算子。
所以:
text
算子
≈ 一次可以独立描述的计算步骤
2. Tensor:算子之间传递的数据
算子之间并不是凭空衔接的。
例如:
text
Operator A
↓
产生中间结果
↓
Operator B
这些中间数据在 AI / 推理框架中通常会用 Tensor 来表示。
可以先把 Tensor 理解成:
带有 shape、dtype、device 等信息的多维数据容器。
因此:
text
Operator A
↓
Tensor
↓
Operator B
↓
Tensor
↓
Operator C
这就构成了整个计算过程的数据链。
3. 计算图:描述"怎么算"
当算子越来越多以后,我们就需要描述:
text
有哪些算子
谁依赖谁
谁先执行
谁后执行
哪些节点之间可以并行
于是引出了计算图。
可以先理解为:
text
计算图
=
节点 + 边
其中:
text
节点
→ Operator
边
→ 数据依赖关系
例如:
text
Operator A
/ \
↓ ↓
Operator B Operator C
\ /
↓ ↓
Operator D
计算图本身只是:
描述整个计算过程应该按照什么依赖关系进行。
它并不会自己把计算真正跑起来。
4. Executor 与 Scheduler
真正推进计算过程的,是执行器。
text
计算图
↓
Executor
↓
真正执行一个个算子
而在执行过程中,还需要考虑:
text
哪些算子的前置依赖已经完成
哪些算子现在可以执行
哪些算子可以并行
任务应该放到哪个执行设备
这些就会涉及调度。
因此可以先建立:
text
计算图
= 描述"怎么算"
Executor
= 真正推进计算过程
Scheduler
= 在可执行任务之间进行调度和安排
认识到这里以后,一个新的问题就出现了:
一个算子到底由谁来执行?
这就自然引出了 CPU 与 GPU。
三、CPU 与 GPU:为什么一个算子会存在不同实现
在我们此前编写的大多数 C++ 程序中,其实默认一直在做一件事情:
text
编写 CPU 代码
CPU 是通用计算设备,它拥有少量但能力较强的执行核心,更擅长:
text
复杂控制逻辑
大量条件判断
操作系统运行
网络协议处理
任务调度
普通业务代码
GPU 的设计目标则不同。
GPU 更强调:
text
大量计算单元
+
大量相似计算
+
高吞吐并行执行
例如数组相加:
cpp
for (int i = 0; i < n; ++i) {
c[i] = a[i] + b[i];
}
从计算关系来看:
text
c[0] = a[0] + b[0]
c[1] = a[1] + b[1]
c[2] = a[2] + b[2]
...
这些计算之间高度相似,而且彼此相对独立。
因此非常适合交给大量 GPU 线程并行执行。
于是同一个算子可能存在:
text
Operator
├── CPU Backend
└── GPU Backend
例如:
text
MatMul
├── CPU 实现
└── GPU 实现
这也是后面学习 CUDA 的真正原因:
如果一个算子的某种实现需要运行在 NVIDIA GPU 上,那么我们就需要具备编写 GPU 代码的能力。
四、程序为什么仍然从 CPU 开始执行
这里有一个非常重要的心智模型。
对于我们平时运行的程序来说:
text
程序启动
↓
CPU 开始执行
并不会在程序启动之前凭空出现一个选择:
text
这个程序到底先交给 CPU
还是先交给 GPU?
因为"做选择"这个动作本身也需要一个执行者。
因此,可以先把 CPU 理解成整个程序的执行入口。
GPU 则更像:
CPU 在运行过程中调用的一个专门计算设备。
这和 CPU 与磁盘、网卡等设备协同工作的思想有一定相似性。
区别只在于 GPU 不是普通 I/O 设备,而是拥有非常强计算能力的并行计算设备。
因此整个关系可以先理解成:
text
程序启动
↓
CPU 执行 Host Code
↓
CPU 发现这里需要 GPU 完成计算
↓
CPU 发起 GPU 任务
↓
GPU 执行 Device Code
↓
GPU 得到结果
↓
CPU 继续后续逻辑
这里必须注意:
CPU 是程序的执行入口,并不意味着 GPU 代码最终仍然由 CPU 执行。
真正属于 GPU 的 Device Code,最终确实是在 GPU 上执行的。
五、为什么边缘设备会使用 Orin
认识 CPU 与 GPU 以后,再来看我们会在企业中实际接触到的 Orin。
1. 传统 CPU 与独立 GPU
普通 PC 中,经常存在:
text
CPU
↓
系统主存 RAM
独立 GPU
↓
显存 VRAM
也就是说:
text
CPU 主要访问主存
GPU 主要访问显存
当 CPU 一侧已经拥有一份数据,而接下来需要让独立 GPU 计算时,经常会出现:
text
CPU 主存中的数据
↓
数据搬运
↓
GPU 显存
↓
GPU 计算
因此 CPU 与独立 GPU 之间的数据传输本身也会产生开销。
2. SoC 到底是什么
Orin 经常会和一个术语联系在一起:
text
SoC
System on Chip
片上系统
传统计算机中,很多功能可能分布在不同芯片中。
而 SoC 的设计思想是:
把一整套计算系统中大量核心功能模块高度集成到一颗芯片中。
可以粗略理解成:
text
SoC
┌────────────────────────┐
│ CPU │
│ GPU │
│ 内存控制器 │
│ I/O 控制模块 │
│ 专用计算 / 加速单元 │
└────────────────────────┘
所以 Orin 可以先理解为:
NVIDIA 面向边缘计算和 AI 场景设计的一类高集成度 SoC 计算平台。
它并不是"一个 GPU",而是内部同时包含:
text
CPU
GPU
内存控制相关模块
I/O
其他专用加速单元
等硬件。
因此从直观感觉上来说,可以把它理解成:
一颗高度集成的"微型计算机核心"。
但还要注意:
text
Orin SoC
≠
一台完整计算节点
真正能够 SSH 登录、运行 Linux 和服务程序的完整设备,还需要:
text
Orin
+
LPDDR
+
存储
+
网卡
+
供电
+
主板
+
Linux
+
驱动
+
CUDA
+
上层服务
组合以后,才形成系统里的一个完整计算节点。
3. 为什么边缘场景需要这种平台
边缘设备和机房服务器面对的约束不同。
例如车载设备通常受到:
text
体积
功耗
散热
供电
实时性
等限制。
如果只追求性能,当然可以使用:
text
高功耗 CPU
+
大型独立 GPU
+
复杂散热系统
但是:
text
性能提高
↓
功耗提高
↓
发热增加
↓
散热系统需要变大
↓
设备体积进一步增加
这就很难适应车载和边缘场景。
因此这里真正追求的不是单独某一项做到最大,而是:
在有限体积、功耗和散热预算下,尽可能获得更强的计算能力。
这也是高集成度 SoC 的价值。
整个逻辑可以整理为:
text
边缘设备空间受限
↓
不能像服务器一样无限堆硬件
↓
还要控制功耗和散热
↓
需要高集成度计算平台
↓
Orin
↓
在有限体积和功耗下
提供 CPU + GPU 等计算能力
六、Orin 中 CPU 与 GPU 为什么共享物理内存
Orin 与传统"CPU + 独立显卡"另一个很重要的区别,就是 CPU 和 GPU 可以共享同一套物理 LPDDR 内存。
可以先抽象成:
text
传统 PC:
CPU ── 主存 RAM
GPU ── 独立显存 VRAM
而 Orin:
text
CPU
\
\
LPDDR
/
/
GPU
这意味着在很多场景中,可以减少传统独立显卡架构中:
text
CPU 主存
↓
完整复制
↓
GPU 独立显存
这种数据搬运。
这和边缘计算本身的思想其实形成了一个很有意思的两层优化。
第一层是在系统外部:
text
远端云服务器
↓
网络距离长
↓
引入边缘节点
↓
减少网络传输开销
第二层是在计算节点内部:
text
CPU / GPU 分离存储
↓
频繁数据搬运
↓
共享物理内存体系
↓
减少部分 CPU / GPU 数据复制
最后都指向一个目标:
text
降低端到端延迟
1. 共享物理内存不等于没有数据问题
这里很容易产生一个误区:
text
CPU / GPU 共享物理内存
=
CPU 和 GPU 可以随便同时访问
=
完全不存在数据搬运和同步
这个理解并不准确。
虽然底层物理内存可以共享,但是:
text
CPU 有自己的 Cache
GPU 也有自己的 Cache 和执行状态
因此问题从以前熟悉的:
text
CPU Core
↔
CPU Core
之间的并发访问,扩展成:
text
CPU
↔
GPU
之间的并发访问。
如果 CPU 正在修改一份 Tensor,而 GPU 同时读取这份 Tensor,那么仍然需要解决:
text
CPU 是否已经完成写入
GPU 什么时候可以开始读取
数据是否已经对另一侧可见
等问题。
所以这里理解到这一层就足够:
CPU 和 GPU 共享物理内存,可以减少传统主存与独立显存之间的大块数据复制,但并不意味着完全不需要同步。
对于算子开发来说,没有必要继续深入到底层缓存一致性协议和硬件实现。
七、为什么会自然引出 CUDA
现在回到算子开发。
我们已经知道:
text
Operator
├── CPU 实现
└── GPU 实现
CPU 实现其实就是我们此前最熟悉的普通 C++ 代码。
但是如果希望代码真正运行在 NVIDIA GPU 上,就需要解决一个新的问题:
程序员应该怎么描述 GPU 要执行的代码?这些代码又应该怎么编译、怎么启动、怎么管理?
CUDA 就是在这里出现的。
八、CUDA 到底是什么
CUDA 不是一门和 C++、Java、Python 完全独立的新语言。
更准确地说:
CUDA 是 NVIDIA 提供的一整套 GPU 并行计算平台和编程模型。
可以先拆成:
text
CUDA
├── CUDA C/C++ 编程模型
├── 对 C/C++ 的扩展
├── CUDA Runtime / API
└── CUDA 编译工具链
所以 CUDA 并不是单纯:
text
一些新的语法
也不是单纯:
text
一个编译器
而是一整套让程序能够:
text
编写 GPU 代码
编译 GPU 代码
准备 GPU 数据
启动 GPU 任务
管理 GPU 执行
的软件体系。
九、一份 CUDA 程序里其实有两类代码
普通 C++ 程序里,我们以前主要考虑的是:
text
CPU Code
但是 CUDA 程序中会同时出现:
text
Host Code
+
Device Code
其中:
text
Host
= CPU 一侧
Device
= GPU 一侧
因此:
text
Host Code
→ CPU 执行
Device Code
→ GPU 执行
需要再次强调:
text
整个程序从 CPU 开始执行
但是:
text
GPU 代码最终由 GPU 真正执行
整体过程是:
text
CPU 启动程序
↓
执行 Host Code
↓
发起 GPU 任务
↓
GPU 执行 Device Code
↓
GPU 计算完成
↓
CPU 继续后续逻辑
十、为什么普通 g++ 不够用了
代码最终都需要经过:
text
源代码
↓
编译 / 链接
↓
机器能够执行的代码
即使只考虑 CPU,不同 CPU 架构也可能拥有不同指令集。
例如:
text
x86
ARM
程序员通常不需要手写两份机器码,而是:
text
同一份 C++ 源码
↓
针对不同目标平台编译
↓
生成不同架构的机器指令
CUDA 中则进一步出现:
text
CPU 代码
+
GPU 代码
普通 g++ 主要处理 Host C/C++ 代码,并不知道 CUDA Device Code 应该如何生成 NVIDIA GPU 所需的设备端代码。
因此引出了:
text
nvcc
1. nvcc 是 CUDA 编译流程的入口
这里不要把模型理解成:
text
g++ 和 nvcc
两个平级编译器一起各自处理一半
更准确的是:
text
CUDA 源码
↓
nvcc
↓
识别并组织整个 CUDA 编译流程
其中:
text
Host 部分
↓
调用宿主编译器
例如 g++
Device 部分
↓
交给 NVIDIA 设备端编译链
所以:
nvcc 更像 CUDA 编译流程的统一入口和编译驱动,而不是内部重新实现了一套 g++。
可以抽象为:
text
CUDA 源码
↓
nvcc
/ \
/ \
Host Code Device Code
↓ ↓
Host Compiler NVIDIA 编译链
如 g++ ↓
↓ GPU 设备代码
CPU 代码
\ /
\ /
↓ ↓
最终程序
从使用者视角,我们通常直接调用 nvcc。
但从内部流程来看,CPU 和 GPU 的代码仍然需要不同编译组件处理。
十一、Kernel:GPU 任务的入口函数
CUDA 中并不是所有 GPU 函数都叫 Kernel。
先简单区分两类:
text
GPU 函数
├── Kernel
└── GPU 内部辅助函数
Kernel 通常使用:
cpp
__global__
进行声明,例如:
cpp
__global__ void addKernel(...) {
...
}
可以先理解成:
__global__表示这是一个由 Host 侧发起、真正运行在 GPU 上的 Kernel 函数。
而 GPU 内部还可以存在:
cpp
__device__ float helper(float x) {
return x * x;
}
这种函数同样运行在 GPU 上,但是它通常由 GPU 代码内部继续调用,而不是 CPU 直接发起。
可以类比普通 C++:
text
CPU 程序:
main / 业务入口函数
↓
普通辅助函数
CUDA 中则可以出现:
text
CPU
↓
启动 Kernel
↓
GPU 执行 Kernel
↓
Kernel 内部调用 GPU 辅助函数
所以:
text
Kernel
=
GPU 计算任务的入口函数
十二、CPU 怎么启动一个 Kernel
普通 CPU 函数调用:
cpp
add(x, y);
表示:
text
CPU 调用
↓
CPU 执行 add()
而 CUDA Kernel 的启动形式是:
cpp
add<<<...>>>(...);
例如:
cpp
add<<<4, 256>>>(a, b);
这里出现了两组参数。
第一组:
cpp
<<<4, 256>>>
不是普通函数参数,而是:
text
GPU 执行配置
后面的:
cpp
(a, b)
才是:
text
Kernel 普通参数
所以:
cpp
kernel<<<执行配置>>>(函数参数);
可以翻译成:
CPU 发起一次 GPU Kernel,并指定这次 GPU 任务应该使用怎样的线程组织方式。
十三、Grid、Block、Thread 到底是什么
GPU 的优势之一,就是能够组织大量线程并行完成相似工作。
但是当线程数量达到:
text
几千
几万
几十万
甚至更多
以后,就不能简单平铺管理。
CUDA 因此设计了:
text
Grid
↓
Block
↓
Thread
三级软件执行结构。
1. Thread:最基本的逻辑执行单元
可以先理解成:
text
Thread
=
真正执行 Kernel 代码的一份逻辑执行流
例如:
text
Thread 0 → 计算 c[0]
Thread 1 → 计算 c[1]
Thread 2 → 计算 c[2]
...
需要注意:
text
GPU Thread
≠
一个固定 GPU 物理核心
线程是软件层的执行概念,后面还会被 GPU 硬件进一步组织和调度。
2. Block:一组 Thread
如果存在大量 Thread,那么 CUDA 会把一批线程组织成:
text
Block
例如:
text
Block 0
├── Thread 0
├── Thread 1
├── Thread 2
└── ...
所以:
text
Block
=
一组 Thread
Block 的存在主要有两个作用:
text
1. 方便 GPU 对大量线程进行组织和调度
2. 给一批线程提供协作范围
同一个 Block 内的线程可以:
text
共享 Shared Memory
进行 Block 级同步
协同完成某一部分计算
3. Grid:一组 Block
当 Block 本身也很多时,就进一步组成:
text
Grid
因此:
text
Grid
├── Block 0
│ ├── Thread
│ ├── Thread
│ └── ...
│
├── Block 1
│ ├── Thread
│ ├── Thread
│ └── ...
│
└── ...
一次普通 Kernel 启动会产生一个 Grid。
所以:
text
Kernel
=
要执行什么
Grid
=
这一次 Kernel 启动产生的全部线程组织
4. 看一个最简单的执行配置
例如:
cpp
add<<<4, 256>>>(...);
表示:
text
1 个 Grid
↓
4 个 Block
↓
每个 Block 256 个 Thread
因此逻辑线程总数为:
text
4 × 256
=
1024
十四、每个 Thread 怎么知道自己处理哪份数据
一个很重要的问题是:
所有 Thread 执行的都是同一个 Kernel,那么它们怎么知道自己应该处理哪个数据?
CUDA 为每个线程提供了编号信息。
最基础的一维场景中,先认识:
cpp
threadIdx.x
blockIdx.x
blockDim.x
其中:
text
threadIdx.x
= 当前线程在本 Block 内的局部编号
blockIdx.x
= 当前 Block 在 Grid 中的编号
blockDim.x
= 每个 Block 的线程数量
例如:
text
Block 0
├── Thread 0
├── Thread 1
...
└── Thread 255
Block 1
├── Thread 0
├── Thread 1
...
└── Thread 255
可以看到:
text
threadIdx.x
在不同 Block 中会重复。
因此需要计算全局线程编号:
cpp
int i = blockIdx.x * blockDim.x + threadIdx.x;
假设:
text
blockDim.x = 256
那么:
text
Block 0 的 Thread 3
i = 0 × 256 + 3
= 3
而:
text
Block 1 的 Thread 3
i = 1 × 256 + 3
= 259
因此:
text
threadIdx.x
→ Block 内局部编号
i
→ 整个 Grid 中的一维全局编号
1. 线程编号为什么经常被当作数组下标
假设需要完成:
cpp
c[i] = a[i] + b[i];
那么就可以直接建立:
text
Thread 0 → 处理数组下标 0
Thread 1 → 处理数组下标 1
Thread 2 → 处理数组下标 2
...
所以:
text
线程编号
↓
映射成数据编号
↓
决定当前线程负责哪一份数据
例如:
cpp
__global__ void 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];
}
}
这里所有线程执行的是完全相同的 Kernel。
不同之处只在于:
text
每个线程计算出来的 i 不同
因此最终访问的数据位置也不同。
十五、Block 数量到底怎么计算
假设:
text
n = 数据元素数量
blockSize = 每个 Block 的线程数量
那么需要多少个 Block?
如果:
text
n = 1024
blockSize = 256
刚好:
text
1024 / 256 = 4
所以需要 4 个 Block。
但是如果:
text
n = 1000
blockSize = 256
普通整数除法:
text
1000 / 256 = 3
3 个 Block 只能提供:
text
3 × 256 = 768
个线程,明显不够。
因此需要向上取整:
cpp
int blockCount = (n + blockSize - 1) / blockSize;
其本质就是:
text
blockCount
=
ceil(n / blockSize)
于是对于:
text
n = 1000
blockSize = 256
会启动:
text
4 × 256 = 1024
个线程。
这里会多出 24 个线程,因此 Kernel 中需要:
cpp
if (i < n) {
...
}
避免这些额外线程访问越界。
所以:
text
blockCount 向上取整
↓
保证线程一定够
if (i < n)
↓
保证多出来的线程不越界
这两个逻辑通常是配套出现的。
十六、Warp:为什么线程数量经常选择 32 的倍数
软件层看到的是:
text
Grid
↓
Block
↓
Thread
但是进入 NVIDIA GPU 的硬件执行层以后,线程还会被进一步组织。
其中一个非常重要的概念就是:
text
Warp
一个 Warp 通常包含:
text
32 个 Thread
例如一个 Block 有:
text
256 Thread
那么会形成:
text
Thread 0 ~ 31 → Warp 0
Thread 32 ~ 63 → Warp 1
Thread 64 ~ 95 → Warp 2
...
Thread 224 ~255 → Warp 7
因此:
text
256 Thread
=
8 个完整 Warp
1. Warp 是软件规定的吗
Warp 并不是类似 Linux 线程库那种纯软件层概念。
它更接近:
NVIDIA GPU 硬件执行模型中的线程调度粒度。
CUDA 软件层允许我们以 Thread 为单位描述计算。
但是进入硬件以后,GPU 会以 Warp 为重要执行和调度粒度。
因此可以区分:
text
CUDA 软件编程模型:
Grid
↓
Block
↓
Thread
与:
text
GPU 硬件执行模型:
Block 中的 Thread
↓
按 32 个组成 Warp
↓
Warp 被硬件调度执行
2. 为什么经常选择 128、256、512
假设每个 Block 配置:
text
256 Thread
那么:
text
256 / 32 = 8
正好形成 8 个完整 Warp。
如果配置:
text
270 Thread
则:
text
270
=
8 个完整 Warp
+
1 个不完整 Warp
因此实际 CUDA 开发中,经常会看到:
text
128
256
512
这样的线程数。
它们都是 32 的整数倍,更符合 GPU 的执行粒度。
所以:
cpp
kernel<<<100, 270>>>(...);
并不一定不能执行。
但是:
text
能执行
≠
配置一定高效
这里先理解到这一层即可。
十七、SIMT 与分支发散
为什么 Warp 要把 32 个线程组织到一起?
其中一个核心原因是:
GPU 面对的目标通常是大量相似计算。
例如:
text
Thread 0 → c[0] = a[0] + b[0]
Thread 1 → c[1] = a[1] + b[1]
...
Thread 31 → c[31] = a[31] + b[31]
虽然每个线程处理的数据不同,但是执行逻辑相同。
可以先把这种思想理解成:
text
同一套指令逻辑
↓
很多线程
↓
处理不同数据
CUDA 中通常称为 SIMT:
text
Single Instruction
Multiple Threads
1. 什么是分支发散
假设 Kernel 中存在:
cpp
if (a[i] > 0) {
x[i] = a[i] * 2;
} else {
x[i] = a[i] + 1;
}
同一个 Warp 中可能出现:
text
Thread 0 → if
Thread 1 → if
Thread 2 → else
Thread 3 → if
Thread 4 → else
...
问题就在于:
一个 Warp 中的线程现在想走不同的执行路径。
GPU 往往需要分别处理不同分支。
可以粗略理解成:
text
先执行 if 路径
↓
走 if 的线程有效
走 else 的线程暂时不参与
再执行 else 路径
↓
走 else 的线程有效
走 if 的线程暂时不参与
于是:
text
原本可以一起工作的线程
↓
被不同分支拆开
↓
硬件利用率下降
这就是:
text
Branch Divergence
分支发散
因此真正影响性能的不是简单存在 if。
而是:
同一个 Warp 中不同线程走了不同分支。
如果一个 Warp 的 32 个线程全部进入同一个分支,就不会产生这种典型的 Warp 分支发散问题。
十八、SM:GPU 内部真正承载计算的硬件单元
认识 Warp 以后,还需要知道这些 Block 和 Warp 最终在哪里执行。
这里引出了:
text
SM
Streaming Multiprocessor
可以先把 SM 理解成:
GPU 内部真正承载一批线程计算的硬件执行单元。
可以粗略类比:
text
CPU
├── Core
├── Core
└── Core
GPU 则可以先抽象成:
text
GPU
├── SM
├── SM
├── SM
└── ...
但这里需要注意:
text
SM
≠
CPU Core
两者内部结构和执行模型差异很大,这里只用它们做"GPU 内部存在多个硬件计算单元"的直观类比。
1. Block 与 SM 的关系
Grid 中会存在很多 Block:
text
Grid
├── Block 0
├── Block 1
├── Block 2
├── Block 3
└── ...
GPU 会把这些 Block 调度到不同 SM 上执行。
例如:
text
Block 0 → SM 0
Block 1 → SM 1
Block 2 → SM 0
Block 3 → SM 2
这里并不是:
text
1 Block = 1 SM
一个 SM 在资源允许的情况下,可以同时驻留多个 Block。
因此完整链路可以整理成:
text
Kernel 启动
↓
生成一个 Grid
↓
Grid 中存在很多 Block
↓
Block 被分配到 SM
↓
Block 内 Thread 按 32 个组成 Warp
↓
Warp 驻留在 SM
↓
SM 内部 Warp Scheduler 调度 Warp 执行
这里也需要修正一个常见误区:
并不是 SM 收到 Block 以后,调度器才临时决定哪些 Thread 组成 Warp。
Warp 的划分按照连续线程编号形成。
SM 内部的 Warp Scheduler 负责的是:
text
在已经形成的 Warp 中
选择可以继续执行的 Warp
并发射其指令
十九、GPU 中线程运行时的数据放在哪里
当 Block 和 Warp 真正进入 SM 执行以后,一个很自然的问题就是:
每个线程运行过程中产生的数据到底放在哪里?
这里先建立三个最基础的存储层级:
text
Thread 级
→ Register
Block 级
→ Shared Memory
GPU / Grid 级
→ Global Memory
1. Register:线程自己的高速工作区
例如:
cpp
int i = blockIdx.x * blockDim.x + threadIdx.x;
float x = a[i];
float y = x * 2;
这里:
text
i
x
y
都是当前线程计算过程中使用的临时数据。
理想情况下,这些数据会使用 SM 内部的寄存器资源。
可以先理解成:
text
Thread 0 → 自己的一些 Register
Thread 1 → 自己的一些 Register
Thread 2 → 自己的一些 Register
...
因此:
Register 可以理解成每个线程自己的高速临时工作区。
2. Shared Memory:Block 内线程共享
一个 Block 内的线程有时候不能完全各算各的。
例如:
text
Thread 0 产生的数据
↓
Thread 1 后面也需要使用
这时候需要一块:
text
同一个 Block 内线程
可以共同访问的数据区域
于是引出了:
text
Shared Memory
例如:
cpp
__shared__ float shared[256];
先把 __shared__ 去掉:
cpp
float shared[256];
本身就是:
text
一个包含 256 个 float 的数组
加上:
cpp
__shared__
以后表示:
这个数组位于当前 Block 对应的 Shared Memory 中,由同一个 Block 内的线程共同访问。
需要注意:
text
不是每个 Thread 都拥有一份 shared[256]
而是:
text
Block 0
↓
自己拥有一份 shared[256]
Block 1
↓
自己拥有另一份 shared[256]
所以:
text
Shared Memory
=
Block 级作用范围
从硬件资源上看,它由 SM 提供。
从软件可见范围上看,它属于 Block。
3. Global Memory:大规模数据存储
数组、Tensor 等大规模输入输出数据,通常主要位于:
text
Global Memory
例如:
cpp
c[i] = a[i] + b[i];
这里:
text
a
b
c
这类大数组通常来自 GPU 可访问的全局内存。
因此可以先建立:
text
Register
→ Thread 私有
→ 快
→ 容量小
Shared Memory
→ Block 共享
→ 很快
→ 容量有限
Global Memory
→ 大规模数据
→ 所有相关线程可访问
→ 容量大、访问延迟相对更高
这和之前学习内存池时"越靠近使用者越小越快,越往外容量越大"的思想有一定相似性。
但这里需要注意:
Register、Shared Memory 和 Global Memory 是 GPU 的存储层级和作用范围,并不能简单等同于 ThreadCache、CentralCache、PageCache 这种软件内存池缓存结构。
二十、为什么 Shared Memory 会引出线程同步
假设一个 Block 中有:
text
Thread 0
Thread 1
Thread 2
...
第一阶段,每个线程向 Shared Memory 写入自己的数据:
cpp
shared[threadIdx.x] = input[i];
形成:
text
Thread 0 → shared[0]
Thread 1 → shared[1]
Thread 2 → shared[2]
...
这里每个线程写的是不同位置。
所以当前场景并不存在:
text
Thread 0 与 Thread 1
同时抢写 shared[0]
这种典型互斥问题。
真正的问题出现在下一阶段。
假设:
text
Thread 2
↓
需要读取
↓
Thread 1 写入的 shared[1]
如果 Thread 2 已经完成自己的写入,然后立刻继续向下执行,但是 Thread 1 此时还没来得及写:
text
Thread 2
写完 shared[2]
↓
马上读取 shared[1]
Thread 1
↓
还没有写完 shared[1]
那么 Thread 2 就可能读到尚未准备完成的数据。
因此这里真正需要解决的不是:
text
多个线程能不能同时写
而是:
后一阶段什么时候才能安全地使用前一阶段其他线程产生的数据。
这就是阶段同步问题。
二十一、__syncthreads():Block 内线程的阶段屏障
CUDA 中最常见的 Block 级同步方式之一就是:
cpp
__syncthreads();
可以先把它理解成:
同一个 Block 内所有线程必须在这里集合,只有所有相关线程都完成前一个阶段并到达这个位置以后,才能继续进入后一个阶段。
例如:
text
阶段 1
Thread 0 ───────┐
Thread 1 ────┐ │
Thread 2 ───────────┐
Thread 3 ─────┐ │
↓ ↓
__syncthreads()
↓
所有线程到齐
↓
阶段 2
如果 Thread 1 先到:
text
Thread 1
↓
等待
Thread 3 随后到:
text
Thread 1 → 等待
Thread 3 → 等待
直到整个 Block 中需要参与同步的线程都到达以后,大家才能继续。
因此:
text
__syncthreads()
≠
mutex
mutex 更偏向:
text
同一时间只能有一部分线程进入临界区
而 __syncthreads() 更像:
text
阶段 A
↓
所有线程集合
↓
阶段 B
它解决的是:
阶段之间的依赖关系。
1. 最典型的场景:写 → 同步 → 读
例如:
cpp
shared[threadIdx.x] = input[i];
__syncthreads();
float x = shared[threadIdx.x - 1];
可以翻译成:
text
阶段 1:
每个线程准备自己的共享数据
↓
__syncthreads()
↓
确认整个 Block 都准备完成
↓
阶段 2:
开始读取其他线程产生的数据
但是不要把 __syncthreads() 固定理解成只能:
text
写 → 同步 → 读
也可能出现:
text
读取旧数据
↓
__syncthreads()
↓
开始覆盖数据
或者:
text
计算阶段 A
↓
__syncthreads()
↓
计算阶段 B
只要满足:
阶段 B 的正确执行依赖整个 Block 中的阶段 A 已经完成,
屏障就有意义。
二十二、一段 Shared Memory + 同步的最小代码
下面看一个简单 Kernel:
cpp
__global__ void neighborAdd(const float* input,
float* output,
int n)
{
__shared__ float shared[256];
int tid = threadIdx.x;
int i = blockIdx.x * blockDim.x + threadIdx.x;
if (i >= n)
return;
// 阶段 1:每个线程准备自己的数据
shared[tid] = input[i];
// 等当前 Block 中的线程完成前一阶段
__syncthreads();
// 阶段 2:使用自己和相邻线程准备的数据
if (tid > 0) {
output[i] = shared[tid] + shared[tid - 1];
} else {
output[i] = shared[tid];
}
}
先看:
cpp
__shared__ float shared[256];
表示:
text
当前 Block
↓
拥有一份 shared[256]
↓
Block 内所有 Thread 可以访问
然后:
cpp
int tid = threadIdx.x;
表示:
text
当前 Thread 在 Block 中的局部编号
而:
cpp
int i = blockIdx.x * blockDim.x + threadIdx.x;
表示:
text
当前 Thread 在整个一维 Grid 中的全局编号
因此:
text
tid
→ 更适合访问 Block 内局部数据
→ 例如 shared[tid]
i
→ 更适合访问整个输入输出数组
→ 例如 input[i] / output[i]
接下来:
cpp
shared[tid] = input[i];
形成:
text
Thread 0 → shared[0]
Thread 1 → shared[1]
Thread 2 → shared[2]
...
然后:
cpp
__syncthreads();
保证大家都完成阶段 1。
最后:
cpp
output[i] = shared[tid] + shared[tid - 1];
例如 Thread 2 会读取:
text
shared[2]
+
shared[1]
其中:
text
shared[2]
→ Thread 2 自己之前写入
shared[1]
→ Thread 1 之前写入
因此这段代码真正体现的是:
text
多个线程独立产生数据
↓
写入 Shared Memory
↓
Block 级同步
↓
线程开始互相使用数据
二十三、GPU 计算之前,数据从哪里来
前面 Kernel 中出现:
cpp
const float* input
float* output
这些数据并不是凭空出现的。
在普通独立 GPU 场景中,整个过程一般需要:
text
CPU 准备数据
↓
Host Memory
↓
为 GPU 准备 Device Memory
↓
把输入复制给 GPU
↓
启动 Kernel
↓
GPU 计算
↓
结果写到 Device Memory
↓
把结果复制回 CPU
这就引出了 CUDA Runtime 中几个最基础的 API:
text
cudaMalloc
cudaMemcpy
cudaFree
二十四、cudaMalloc:给 Device 侧准备内存
普通 CPU 编程中,我们很熟悉:
cpp
malloc(...)
例如:
cpp
float* p = (float*)malloc(n * sizeof(float));
本质上就是:
text
申请一段内存
↓
返回这段内存的地址
↓
让指针 p 保存这个地址
CUDA 中:
cpp
cudaMalloc(...)
思想类似。
例如:
cpp
float* d_input;
cudaMalloc((void**)&d_input, n * sizeof(float));
可以先理解成:
申请一段 Device 侧可以使用的内存空间,然后把得到的地址写进
d_input。
两个核心参数:
text
&d_input
→ 把申请到的 Device 地址写到哪里
n * sizeof(float)
→ 申请多少字节
为什么传的是:
cpp
&d_input
而不是:
cpp
d_input
原因和普通二级指针思想一样:
cudaMalloc需要修改指针变量d_input本身,让它最终保存申请到的地址。
二十五、cudaMemcpy:CPU 与 GPU 之间的数据复制
普通 CPU 编程中:
cpp
memcpy(dst, src, size);
CUDA 中:
cpp
cudaMemcpy(dst, src, bytes, direction);
多出来的核心信息就是:
text
数据复制方向
例如 CPU → GPU:
cpp
cudaMemcpy(
d_input,
h_input,
n * sizeof(float),
cudaMemcpyHostToDevice
);
可以翻译成:
text
从 h_input
↓
复制 n * sizeof(float) 字节
↓
到 d_input
方向:
Host → Device
而 GPU → CPU:
cpp
cudaMemcpy(
h_output,
d_output,
n * sizeof(float),
cudaMemcpyDeviceToHost
);
就是:
text
Device
↓
Host
因此可以先类比:
text
CPU CUDA
malloc cudaMalloc
memcpy cudaMemcpy
free cudaFree
其中 cudaMemcpy 额外需要明确:
text
HostToDevice
DeviceToHost
等数据方向。
二十六、一段完整的 CUDA 向量加法代码
现在把前面的知识真正串起来:
cpp
#include <iostream>
#include <cuda_runtime.h>
__global__ void 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];
}
}
int main()
{
const int n = 1024;
const int bytes = n * sizeof(float);
// 1. Host 侧准备数据
float* h_a = new float[n];
float* h_b = new float[n];
float* h_c = new float[n];
for (int i = 0; i < n; ++i) {
h_a[i] = i;
h_b[i] = i * 2;
}
// 2. 准备保存 Device 地址的指针变量
float* d_a;
float* d_b;
float* d_c;
// 3. 分配 Device Memory
cudaMalloc((void**)&d_a, bytes);
cudaMalloc((void**)&d_b, bytes);
cudaMalloc((void**)&d_c, bytes);
// 4. Host → Device
cudaMemcpy(d_a, h_a, bytes, cudaMemcpyHostToDevice);
cudaMemcpy(d_b, h_b, bytes, cudaMemcpyHostToDevice);
// 5. 配置并启动 Kernel
int blockSize = 256;
int blockCount = (n + blockSize - 1) / blockSize;
add<<<blockCount, blockSize>>>(d_a, d_b, d_c, n);
// 6. Device → Host
cudaMemcpy(h_c, d_c, bytes, cudaMemcpyDeviceToHost);
std::cout << h_c[10] << std::endl;
// 7. 释放 Device Memory
cudaFree(d_a);
cudaFree(d_b);
cudaFree(d_c);
// 8. 释放 Host Memory
delete[] h_a;
delete[] h_b;
delete[] h_c;
return 0;
}
把所有语法先拿掉,只看整个数据流:
text
CPU 准备 h_a / h_b
↓
cudaMalloc
↓
得到 d_a / d_b / d_c
↓
cudaMemcpy
Host → Device
↓
add<<<blockCount, blockSize>>>
↓
GPU 大量 Thread 并行执行
↓
结果写入 d_c
↓
cudaMemcpy
Device → Host
↓
结果进入 h_c
↓
cudaFree
这就是一条最基础、最完整的 CUDA 执行链。
1. n 到底是什么
这里:
cpp
const int n = 1024;
本质上表示:
text
数据元素数量
而不是严格意义上的"线程总数"。
线程总数由:
text
blockCount × blockSize
决定。
在这个例子里:
text
n = 1024
blockSize = 256
blockCount = 4
刚好:
text
4 × 256 = 1024
所以线程总数恰好等于数据数量。
但如果:
text
n = 1000
仍然会启动:
text
4 × 256
=
1024 Thread
这时:
text
数据数量 = 1000
线程数量 = 1024
多出来的线程通过:
cpp
if (i < n)
直接退出,不执行数组访问。
因此最准确的关系是:
text
n
=
数据规模
线程数量
=
根据数据规模计算出来的执行规模
只是"一线程处理一个元素"的场景中,两者通常非常接近。
二十七、回到 Orin:并不是所有场景都必须完整 Host → Device 拷贝
上面的:
text
Host Memory
↓
cudaMemcpy
↓
Device Memory
是理解传统独立 GPU CUDA 程序非常重要的基础模型。
但是前面已经认识到,Orin 属于高度集成的 SoC 平台:
text
CPU
\
→ 共享物理 LPDDR
/
GPU
因此在 Orin 这类集成 GPU 平台上,实际内存模型会与传统独立显卡有所不同。
核心不要记成:
text
只要用了 GPU
就一定必须完整复制一份
CPU RAM → GPU VRAM
更通用的理解应该是:
CPU 在启动 GPU 计算之前,需要保证 GPU 所需要的数据已经位于 GPU 可以正确访问的位置和状态,并满足必要的同步要求。
对于传统独立显卡,这往往表现为:
text
Host Memory
↓
Device Memory
显式数据复制。
而对于 Orin 这种共享物理内存的平台,可以在部分场景中减少这样的完整数据搬运。
目前理解到这一层就足够,不需要继续深入缓存一致性协议和底层硬件细节。
二十八、把 CUDA 放回边缘计算算子开发中
现在重新回到最开始的问题:
为什么我们学习边缘计算算子模块,最后会学到 CUDA?
整个链路已经可以完整串起来了。
首先:
text
边缘节点
↓
需要执行计算
↓
计算过程拆成多个 Operator
一个算子可能有:
text
Operator
├── CPU Backend
└── GPU Backend
如果选择:
text
CPU Backend
那么执行的就是此前非常熟悉的普通 C++ 代码。
如果选择:
text
GPU Backend
那么就需要:
text
CUDA Kernel
真正完成 GPU 侧计算。
因此一个算子模块可以粗略抽象成:
text
Operator
↓
确定当前 Backend / Device
↓
┌────────────────┬─────────────────┐
│ CPU Backend │ GPU Backend │
│ │ │
│ 普通 C++ │ CUDA Kernel │
└────────────────┴─────────────────┘
于是 CUDA 在整个边缘计算系统中的位置并不是:
text
CUDA
=
整个边缘计算系统
而更接近:
text
边缘计算系统
↓
计算节点
↓
推理 / 计算执行框架
↓
计算图
↓
Operator
↓
GPU Backend
↓
CUDA
↓
NVIDIA GPU
这样前面的所有概念就真正闭环了。
二十九、目前需要建立的 CUDA 心智模型
学习到这里,还远远不能说已经掌握 CUDA 工程开发。
后面仍然存在很多重要内容,例如:
text
CUDA Stream
异步执行
Event
cudaMemcpyAsync
Pinned Memory
Unified Memory
Memory Coalescing
Occupancy
Register Pressure
Bank Conflict
二维 / 三维 Grid 与 Block
更复杂的 Warp 行为
真正的算子 Kernel 优化
但是目前没有必要一次把这些内容全部塞进来。
对于当前边缘计算算子开发的学习目标来说,最重要的是先建立下面这一条链:
text
CPU 是程序执行入口
↓
Host Code 运行在 CPU
↓
CPU 通过 CUDA 发起 GPU Kernel
↓
Device Code 真正在 GPU 执行
↓
一次 Kernel 启动形成一个 Grid
↓
Grid 包含多个 Block
↓
Block 包含多个 Thread
↓
Thread 通过编号映射自己负责的数据
↓
Block 内 Thread 按 32 个组成 Warp
↓
Block 被调度到某个 SM
↓
Warp Scheduler 调度 Warp
↓
Thread 使用 Register
Block 内线程通过 Shared Memory 协作
大规模数据位于 Global Memory
↓
存在跨线程阶段依赖时
通过 __syncthreads() 建立 Block 级屏障
↓
CPU 通过 cudaMalloc / cudaMemcpy
准备 GPU 所需数据
↓
Kernel 完成计算
↓
结果返回 Host
只要能够真正理解这条链,再去看实际项目中的:
text
__global__
threadIdx
blockIdx
blockDim
__shared__
__syncthreads
<<< >>>
cudaMalloc
cudaMemcpy
cudaFree
这些代码,就不会再只是看到一堆陌生关键字。
而是能够知道:
text
谁在执行
↓
执行多少线程
↓
线程怎么组织
↓
每个线程负责什么数据
↓
数据存在哪里
↓
线程之间怎么协作
↓
CPU 怎么把任务提交给 GPU
这才是当前阶段学习 CUDA 最重要的目标。
三十、总结
最后重新把整篇内容压缩成一条逻辑链。
汽车和各种边缘设备会不断产生数据。
如果所有数据都送到远端云服务器计算,就会引入额外网络传输延迟,因此在实时性要求较高的场景中,需要把一部分计算能力移动到离数据源更近的位置,这就是边缘计算。
边缘节点本质上仍然是一台计算节点。
节点内部需要执行计算图,计算图由一个个算子以及算子之间的数据依赖构成,中间数据通常使用 Tensor 表示。
算子真正执行时,可能存在 CPU Backend 与 GPU Backend。
CPU 擅长复杂控制逻辑,而 GPU 具有大量并行计算资源,更适合执行大量相似数值运算。
车载和边缘设备又受到体积、功耗和散热约束,因此需要像 Orin 这样高度集成的 SoC 计算平台,在有限功耗和空间下同时提供 CPU、GPU 等计算能力。
Orin 中 CPU 与 GPU 可以共享物理 LPDDR,从而减少传统独立 CPU/GPU 架构中的部分数据搬运,但共享物理内存并不意味着完全不需要同步。
当一个算子的实现需要真正运行在 NVIDIA GPU 上时,就会引出 CUDA。
CUDA 提供了 GPU 编程模型、C/C++ 扩展、编译工具链以及 Runtime API。
一份 CUDA 程序同时包含 Host Code 与 Device Code,程序从 CPU 开始执行,CPU 在运行过程中启动 Kernel,而 Kernel 真正由 GPU 执行。
一次 Kernel 启动会组织出:
text
Grid
↓
Block
↓
Thread
大量 Thread 执行同一个 Kernel,但是通过线程编号映射到不同数据。
进入 GPU 硬件执行层以后,Block 中的线程按照 32 个组成 Warp,Block 被调度到 SM,Warp 再由 SM 内部的 Warp Scheduler 进行调度。
线程运行时会涉及:
text
Register
Shared Memory
Global Memory
等不同存储层级。
同一个 Block 内的线程需要交换数据时,可以使用 Shared Memory;当前一阶段和后一阶段存在跨线程依赖时,可以通过 __syncthreads() 建立同步屏障。
最终,在 Host 一侧使用:
text
cudaMalloc
cudaMemcpy
Kernel Launch
cudaMemcpy
cudaFree
就可以形成一条完整的 GPU 计算流程。
至此,边缘计算、Orin、CPU/GPU、算子以及 CUDA 这些原本看起来彼此分散的知识点,就可以连接成同一套完整的心智模型。
