【CUDA 入门系列】:Orin、GPU 并行执行模型与 CUDA 核心机制

🔥 本文专栏: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 这些原本看起来彼此分散的知识点,就可以连接成同一套完整的心智模型。



相关推荐
萧瑟余晖3 小时前
Dubbo 综合实战与性能调优详解
架构·dubbo
秃了也弱了。4 小时前
自适应类架构详解:自适应轮询、自适应重试、自适应采样
架构
ESDWAN4 小时前
SD-WAN 企业选型与落地指南:从链路测试到网络架构设计
运维·网络·架构
DianSan_ERP5 小时前
多平台订单自动下载与回传的技术实现:从消息推送到状态闭环引言
java·linux·服务器·前端·网络·架构·自动化
梦帮科技6 小时前
量子张量网络破局大模型:从矩阵乘积态 (MPS) 到张量列 (TT-SVD) 低秩收缩全推导
网络·数据结构·数据库·线性代数·矩阵·架构·模拟退火算法
霸道流氓气质6 小时前
LLM 应用限流与熔断机制完全指南:从多层防护架构到Java生产级弹性实战
java·开发语言·架构
Jmyd01237 小时前
元宇宙虚拟校史馆技术方案解析:一个引擎+两大平台架构拆解
架构·三维数字化
ESDWAN7 小时前
外贸企业网络专线选型与落地实战指南
网络·架构
小朱爱编程1237 小时前
我用 Jev 做了三个实用工具:整理标签页、分诊飞书反馈、找回 GitHub 收藏
java·开发语言·人工智能·后端·python·架构·ai编程