🔥 本文专栏:边缘计算
🌸作者主页:努力努力再努力wz
💪 今日博客励志语录:
你可以暂时没有结果,但不能长期没有积累。
思维导图
现实设备产生数据 ↓ 如果全部发送到远端云端计算 ↓ 网络传输会带来额外时延、带宽和稳定性问题 ↓ 把一部分计算能力下沉到更靠近数据源的位置 ↓ 边缘计算 ↓ 云端负责管理、调度算力池 ↓ 算力池集中管理多个算力节点 ↓ 车载节点 / 边缘节点 ↓ 首先解决:在哪里算? ↓ 进一步追问:到了节点以后,到底怎么算? ↓ 输入 x → 中间计算过程 f(x) → 输出 y ↓ 复杂计算并不是一步完成 ↓ 拆成多个可复用的计算步骤 ↓ Operator / 算子 ↓ 多个算子 + 输入输出依赖关系 ↓ 计算图 ↓ Executor 根据计算图推动整个流程执行 ↓ Scheduler 安排已经 ready 的算子如何执行 ↓ 算子最终需要具体硬件完成 ↓ CPU / GPU ↓ 同一个算子针对不同硬件存在不同底层实现 ↓ Kernel ↓ 算子之间传递的输入、中间结果和输出 ↓ Tensor ↓ Shape:数据结构 DType:元素类型 Device:数据在哪个设备 ↓ 现实世界中的图片 / 文字 / 声音 ↓ 数值化 / 编码 ↓ 输入 Tensor ↓ 模型计算 ↓ 输出 Tensor ↓ 解释为最终结果引入:先不要急着看算子代码
在开始接触所谓的“边缘计算算子模块”之前,如果直接从:
Tensor Operator Kernel CUDA GPU Runtime这些概念切入,很容易出现一个问题:
每一个词似乎都能单独记住,但是却不知道它为什么会出现,更不知道它在整个系统中处于什么位置。
因此,这里并不准备一开始就进入某个具体算子的实现,也不直接去看 CUDA 或 TensorRT。
更合适的方式,是先回答几个更根本的问题:
为什么需要边缘计算? 所谓的“算”到底是什么? 一个完整计算为什么要拆成很多算子? 这些算子之间如何组织? 谁来真正执行这些算子? CPU 和 GPU 在这里又分别承担什么职责? 算子之间传递的 Tensor 到底又是什么?把这些问题按照逻辑链条串起来以后,后续再进入真正的算子实现时,很多概念就不会再是悬空的。
一、先回答第一个问题:计算到底应该放在哪里?
1. 从云端和算力池开始
在此前接触分布式推理平台时,首先认识到了一个云端。
这里可以先把云端理解为:
云端 = 整个算力系统的管理者和调度者云端下面管理着一个所谓的:
算力池此前理解算力池时,可以直接从熟悉的线程池切入。
线程池本质上是在集中管理一批线程:
ThreadPool ├── Thread-1 ├── Thread-2 ├── Thread-3 └── Thread-4而算力池则是在集中管理一批能够提供计算能力的节点:
算力池 ├── 算力节点 A ├── 算力节点 B ├── 算力节点 C └── 算力节点 D因此可以建立一个最粗的类比:
线程池 → 集中管理线程 算力池 → 集中管理算力节点在当前项目语境中,这些算力节点又可以继续区分为:
算力节点 ├── 车载节点 └── 边缘节点这里首先可以从物理位置来理解:
车载节点 → 计算能力部署在车这一侧 边缘节点 → 计算能力部署在车外、靠近业务现场的一侧需要注意的是,这里是在当前项目的节点分类中区分“车载节点”和“边缘节点”。从更广义的 Edge Computing 概念来看,车载计算本身同样属于将计算能力下沉到靠近数据源一侧的典型方式。
2. 为什么不全部交给远端云端计算?
假设汽车上的摄像头、传感器等设备不断产生数据。
最直接的一种方式就是:
车端设备 ↓ 产生数据 ↓ 通过网络发送 ↓ 远端云服务器 ↓ 完成计算 ↓ 结果通过网络返回 ↓ 车端得到结果这种方式并不是不能工作。
问题在于,一次完整请求的耗时并不只有:
计算时间还包含:
数据上传时间 网络排队时间 远端传输时间 结果返回时间因此在某些实时性要求较高的场景中,即使服务器本身计算得非常快,整个链路依然可能因为网络传输而产生明显时延。
可以把它类比为计算机系统中的 I/O 瓶颈:
真正执行一次计算可能很快 但是: 把数据送过去 + 把结果拿回来 同样需要时间于是就产生了一个非常自然的思路:
既然网络传输会产生额外开销,那么能不能让计算发生在距离数据产生位置更近的地方?
这就是理解边缘计算最关键的切入点。
二、边缘计算:解决“在哪里算”的问题
1. 把计算能力向数据源靠近
所谓边缘计算,可以先建立一个非常粗的认知:
将原本需要集中发送到远端云端完成的一部分计算,下沉到更靠近数据产生位置的设备或者计算节点上。
于是原本:
数据源 ↓ 远端云端 ↓ 计算就可以变成:
数据源 ↓ 附近计算节点 ↓ 计算甚至:
数据源 ↓ 本地车载计算节点 ↓ 直接计算因此这里真正改变的是:
计算发生的位置而不只是单纯地“换了一台服务器”。
2. 车载节点、边缘节点与云端可以形成不同计算层次
从距离数据源的位置来看,可以先形成下面这个模型:
数据产生位置 │ │ 最靠近 ▼ 车载计算节点 │ │ 稍远 ▼ 边缘计算节点 │ │ 更远 ▼ 中心云于是系统面对一个任务时,实际上会出现一个核心问题:
这个任务到底应该在哪里算?可以:
在车上算也可以:
在边缘节点算还可以:
交给中心云算因此云端管理算力池,本质上就是掌握一批可以用于完成计算任务的算力资源。
到这里,可以先形成第一句核心认知:
边缘计算首先解决的是“在哪里算”的问题。
但是仅仅确定在哪里算还远远不够。
因为接下来还会有一个更加直接的问题:
计算节点拿到数据以后,到底应该怎么算?
这就开始从“计算位置”进入“计算过程”。
三、从一个黑盒函数开始理解计算
1. 最宏观的计算模型:y = f(x)
先不要考虑 AI,也不要考虑 GPU。
任何一次计算,都可以先抽象成:
输入 x ↓ ┌──────────────┐ │ f(x) │ │ 中间计算 │ └──────────────┘ ↓ 输出 y即:
y = f(x)此时,我们把中间的整个计算过程先当成一个黑盒。
但是实际程序中的f(x)往往不是一步就直接得到最终结果。
它可能是:
输入 x ↓ 计算步骤 A ↓ 中间结果 1 ↓ 计算步骤 B ↓ 中间结果 2 ↓ 计算步骤 C ↓ 最终输出 y数学上可以粗略表示为:
y = f3(f2(f1(x)))于是,原本那个巨大的黑盒开始被拆开。
四、算子:一个完整计算流程中的基本计算单元
1. 算子到底是什么?
当我们把完整计算流程拆开以后:
输入 ↓ 计算步骤 A ↓ 计算步骤 B ↓ 计算步骤 C ↓ 输出其中每一个相对独立的计算步骤,就可以先理解成一个:
Operator 算子例如:
输入 A ─┐ ├─ Add → 输出 C 输入 B ─┘这里的 Add 就是一个计算单元。
因此可以先建立最简单的认知:
算子就是整个计算过程中的一个具体计算步骤。
也就是说:
边缘计算 → 解决在哪里算 算子 → 解决具体算什么这两个概念并不是同一个层面。
2. 为什么不直接写成一个巨大的计算函数?
既然最终目标只是:
输入 ↓ 得到输出那么为什么还要把中间过程拆成很多算子?
这里其实和普通 C++ 项目中的模块化设计非常相似。
我们实现一个复杂程序时,一般不会把:
网络连接 协议解析 业务处理 定时器 数据库访问全部塞进一个巨大的函数。
而是拆成不同模块:
Acceptor TcpConnection HttpParser Timer ...原因之一就是:
职责更明确 可以复用 可以独立修改 可以独立优化算子也是一样。
假设计算流程 A:
输入 ↓ Add ↓ Multiply ↓ 输出计算流程 B:
输入 ↓ Add ↓ Normalize ↓ 输出这里的:
Add就是一个可以被多个计算流程复用的计算模块。
因此一个完整计算流程可以理解成:
按照特定依赖关系,将多个通用计算单元组合起来。
从这个角度来看,算子就像计算过程中的积木。
五、完整计算并不一定是一条线:从算子过渡到依赖关系
1. 最简单的情况是线性执行
最简单的计算流程可以是:
A → B → C → D这里:
A 的输出 → B 的输入 B 的输出 → C 的输入这种情况下确实可以理解成一条顺序执行的链。
但是实际计算过程并不一定只有一条直线。
例如:
┌→ B ─┐ A ──────┤ ├→ D └→ C ─┘这里 A 计算完成以后,它的结果同时被 B 和 C 使用。
此时:
A 必须先于 B A 必须先于 C但是:
B 和 C 之间并不存在严格的先后关系。
只要 A 的结果已经准备好:
B 可以执行 C 也可以执行而 D 又同时依赖 B、C 的输出,因此只有:
B 完成 + C 完成以后:
D 才能执行所以这里真正描述的已经不只是“执行顺序”,而是:
数据依赖关系。
2. 不要把计算过程和线程执行过程混在一起
此前我们很容易从普通程序执行流出发,把计算过程理解成:
一个线程 从头执行到尾但这只是最简单的一种执行模型。
更准确地说:
计算流程 → 描述应该怎么算、谁依赖谁 线程 → 描述运行时由谁去执行这些计算这两者不是一个层面的概念。
例如:
┌→ B ─┐ A ──────┤ ├→ D └→ C ─┘这是计算逻辑本身。
至于运行时:
B、C 是否由同一个线程执行 是否由不同线程并行执行 是否有某个算子交给 GPU这是后面的执行和调度问题。
六、计算图:用图结构描述完整计算流程
到这里,其实“计算图”已经自然出现了。
我们已经有两个东西:
一批算子 + 算子之间的数据依赖关系这正好可以用此前数据结构中学过的:
Graph 图来描述。
计算图中:
Node / 节点 → 算子 Edge / 边 → 数据传递与依赖关系例如:
┌→ B ─┐ A ──────┤ ├→ D └→ C ─┘其中:
A、B、C、D就是节点,也就是算子。
而:
A → B A → C B → D C → D则表示输入输出依赖。
这里需要注意:
A → B并不仅仅表示:
A 写在 B 前面它真正表达的是:
B 的计算需要依赖 A 产生的数据。
因此,可以形成一句比较完整的定义:
计算图就是使用图结构描述一个完整计算过程,其中节点表示算子,边表示算子之间的数据传递和依赖关系。
七、什么时候一个算子可以执行?
有了计算图以后,一个算子什么时候能够执行,其实就很直观了。
核心条件就是:
它所依赖的输入数据是否全部准备完成。
例如:
A ──┐ ├──> D B ──┘D 同时依赖:
A 的输出 B 的输出如果:
A 已完成 B 未完成那么 D 不能执行。
只有:
A 已完成 + B 已完成D 所需要的输入全部就绪以后:
D 才具备执行条件因此可以形成一个非常重要的认知:
图中的边 → 描述数据依赖 依赖全部满足 → 算子 ready 算子 ready → 具备被执行的条件这里强调的是“具备执行条件”。
至于它到底什么时候真的被执行、由谁执行,则是下一层问题。
八、计算图只是蓝图:真正推动计算的是 Executor
计算图可以告诉系统:
有哪些算子 谁依赖谁 数据从哪里流向哪里但是计算图本身并不会执行任何东西。
这里可以把计算图类比成:
建筑蓝图建筑蓝图能够描述:
这里应该建墙 那里应该安装梁 施工之间有哪些前置关系但蓝图本身不会自动把建筑修出来。
同样:
计算图 = 计算过程的蓝图真正需要有一个软件模块拿着这张图,不断推动整个计算向前运行。
这个角色就可以先称为:
Executor 执行器其宏观目标非常简单:
拿到输入 ↓ 按照计算图执行 ↓ 不断完成算子 ↓ 最终得到输出例如:
┌→ B ─┐ A ──────┤ ├→ D └→ C ─┘执行器会不断做类似的事情:
发现 A 可以执行 ↓ 执行 A ↓ A 的输出 ready ↓ B、C 具备执行条件 ↓ 执行 B、C ↓ B、C 输出全部 ready ↓ D 具备执行条件 ↓ 执行 D因此:
计算图负责描述计算,Executor 负责把这张图真正跑起来。
九、Scheduler:多个 ready 算子应该怎么安排?
当一个计算图中同一时刻只有一个算子能够执行时,其实没有太复杂的调度问题。
真正需要调度,是当:
A 执行完成 ↓ B ready C ready E ready同时出现多个已经具备执行条件的任务。
这时就需要考虑:
谁先执行? 谁后执行? 哪些可以并行? 交给哪个工作线程?这就是:
Scheduler 调度因此可以先形成一个非常简单的区别:
Executor → 负责推动整个计算图从输入走到输出 Scheduler → 负责安排当前已经 ready 的任务怎么执行这里的 Scheduler 不一定在代码中真的存在一个独立的Scheduler类。
真实框架可能直接把调度逻辑写在 Executor 中。
所以更准确地说:
Scheduler 首先是一种职责,而不一定是一个独立对象。
1. 调度器不是操作系统 CPU Scheduler
这里还需要区分两个完全不同的调度层次。
例如一个 CPU 算子已经 ready:
算子 B ↓ 框架调度 ↓ worker thread 2这个过程属于:
推理框架 / Executor 内部的任务调度但是:
worker thread 2最终真正运行在哪一个 CPU Core 上,则通常由:
操作系统 Scheduler负责。
因此:
推理框架调度 → 调度算子任务 操作系统调度 → 调度线程不要把两者混为一谈。
十、调度之前先拆清楚另一个问题:CPU 还是 GPU?
这里很容易产生一个误区:
Scheduler → 看一下算子代码 → 判断这个算子更适合 CPU 还是 GPU → 临时选择设备为了建立清晰心智模型,这里先把两个问题拆开:
Where? 这个算子在哪类设备上运行? → Device Placement / Backend Selection When? 这个已经可以运行的算子什么时候被执行? → Scheduling很多推理系统中,一个算子使用哪个设备或者哪个后端实现,在真正执行之前就已经由模型部署方式、Tensor 所在设备、框架配置等条件确定。
例如:
MatMul → 已经确定在 GPU 上执行那么运行时真正要做的是:
等 MatMul 的输入全部 ready ↓ 调度执行 ↓ 调用 GPU 对应实现当然,真实推理框架的设计非常多样,设备放置和运行时调度可能由不同模块完成,也可能存在动态决策。
这里当前最重要的不是记某个框架的具体实现,而是先把:
设备选择和:
任务调度在概念上拆开。
十一、CPU 与 GPU:真正完成计算的底层硬件
此前编写的大多数 C++ 程序,本质上都可以视为:
CPU 程序代码经过编译以后:
源代码 ↓ 机器指令 ↓ CPU 执行CPU 是一个通用处理器。
其特点可以粗略概括为:
核心数量相对较少 但是单个核心功能强 擅长复杂控制逻辑 擅长分支 擅长运行通用程序例如:
if / else 函数调用 系统调用 网络处理 线程调度 复杂业务逻辑这些都非常适合 CPU。
1. GPU 为什么适合 AI 计算?
GPU 同样是一种能够执行计算任务的硬件。
只不过它的硬件设计目标与 CPU 不同。
GPU 更擅长的是:
对大量数据同时进行相同或者相似的数值计算。
例如:
100 万个元素 每一个元素都执行: x = x * 2这种任务具有:
大量数据 + 相似计算 + 高度并行的特点,非常适合 GPU。
因此可以建立一个非常粗的区别:
CPU → 少量但强大的核心 → 擅长复杂控制和通用计算 GPU → 大量并行计算单元 → 擅长大规模相似数值计算图像处理就是典型场景之一。
例如一张图片中存在大量像素,如果每一个像素都需要进行相似的数值操作,那么这些像素计算天然具有很高的并行性。
而 AI 模型中又存在大量:
矩阵乘法 向量运算 加法 乘法因此 GPU 同样非常适合这些计算。
十二、CPU + GPU:从单一 CPU 程序进入异构计算
这里会出现一个此前普通 C++ 程序中不太需要关注的问题:
CPU 和 GPU 都能执行计算 那么到底谁负责整个程序的控制?在典型 CPU + GPU 异构计算模型中,可以先建立:
CPU = 程序的主控端 + 本身也是一个计算设备 GPU = CPU 侧程序提交任务以后 负责执行大规模并行计算的设备例如:
CPU 正在执行程序 ↓ 运行到某个 GPU 计算任务 ↓ CPU 侧程序准备数据和参数 ↓ 向 GPU 提交 Kernel ↓ GPU 执行计算 ↓ 产生结果因此并不存在一个所谓的:
悬浮在 CPU / GPU 之上的软件意图因为:
“选择走 CPU 还是 GPU”这段控制逻辑本身,通常也是 CPU 正在执行的程序的一部分。
例如可以粗略理解成:
if(use_gpu){launch_gpu_kernel(...);}else{run_cpu_kernel(...);}这里:
if launch_gpu_kernel() run_cpu_kernel()这些控制逻辑本身都是 CPU 在执行。
只有 GPU 任务真正提交以后,GPU 才开始执行对应的计算。
十三、CPU 和 GPU 为什么会涉及不同存储空间?
在普通 CPU 程序中:
int*p=newint[100];我们通常只需要关心:
p 指向一块可以访问的数据至于这份数据当前是在:
L1 Cache L2 Cache L3 Cache 还是 RAM通常并不需要应用层程序显式决定。
因为这些都属于 CPU 自己的缓存与内存访问体系。
CPU 可以粗略理解为:
CPU ├── Register ├── L1 / L2 / L3 Cache └── Main Memory / RAM但是引入独立 GPU 以后,系统里出现了另一套计算设备和存储空间。
在典型独立显卡模型中,可以先理解成:
CPU GPU │ │ ↓ ↓ 系统内存 RAM 显存 VRAM于是软件必须开始关心:
这份数据到底在哪一侧?这已经不是:
L1 还是 L2这种同一个 CPU 内存体系内部的问题。
而是:
CPU 设备侧 还是 GPU 设备侧的问题。
1. CPU 怎么把任务交给 GPU?
一个典型过程可以粗略理解成两件事:
第一件事:准备 GPU 可以访问的数据
CPU RAM ↓ 数据传输 ↓ GPU VRAM对于独立 GPU,这种数据传输可能通过 PCIe 等硬件通道完成。
实际搬运通常还会涉及驱动、DMA 等机制,因此不应该理解成 CPU 核心亲自逐字节搬运。
更准确地说:
CPU 侧软件发起数据传输。
第二件事:向 GPU 提交计算任务
CPU 侧程序还需要告诉 GPU:
执行哪个 Kernel? 输入数据在哪里? 输出应该写到哪里? 参数是什么?于是整体可以理解成:
CPU │ ├── 数据通道:准备 / 搬运 GPU 所需数据 │ └── 控制通道:提交 GPU 计算任务 ↓ GPU ↓ 执行 Kernel所以:
数据传输和:
计算任务提交是两件不同的事情。
十四、Operator 与 Kernel:一个负责“算什么”,一个负责“怎么在硬件上算”
到这里就可以重新回到算子。
例如:
Add(A, B)从算子的角度,它表达的是:
C = A + B即:
我要完成加法。
但是同一个 Add 算子如果分别运行在 CPU 和 GPU 上,由于硬件架构、执行模型、指令体系、并行方式以及内存体系都不同,底层实现也可能不同。
于是可以形成:
Add Operator “做加法” │ ┌───────┴───────┐ ↓ ↓ CPU 实现 GPU 实现 ↓ ↓ CPU GPU这里的上层语义不变:
What → Add 要干什么 → 不变变化的是:
How → 在这种硬件上具体怎么实现 → 可以不同1. Kernel 到底是什么?
这里需要先区分:
Linux Kernel和现在讨论的:
Compute Kernel / GPU Kernel并不是一个概念。
为了建立当前心智模型,可以先把 Kernel 理解成:
一个算子面向某种具体硬件的底层计算实现。
例如:
MatMul Operator → 要完成矩阵乘法 CPU MatMul 实现 → 在 CPU 上具体怎么完成 GPU MatMul Kernel → 在 GPU 上具体怎么完成因此:
Operator → 算什么 Kernel → 在某种具体硬件上怎么把它算出来需要注意,真实框架对于 CPU 后端具体实现不一定都统一称为 Kernel,但是在当前建立心智模型时,可以先统一这样理解;而在 GPU 编程语境中,Kernel 这个词尤其常见。
十五、算子之间传递的到底是什么?——Tensor
此前一直在说:
输入 ↓ 算子 A ↓ 中间结果 ↓ 算子 B ↓ 输出那么这里的:
输入 中间结果 输出究竟是什么?
在 AI 推理中,一个非常核心的数据结构就是:
Tensor 张量工程上可以先把 Tensor 理解成:
统一表示多维数值数据的数据结构。
例如:
0 维 Tensor
5就是一个标量。
1 维 Tensor
[1, 2, 3]可以理解为一个向量。
2 维 Tensor
[ [1, 2, 3], [4, 5, 6] ]可以理解为矩阵。
3 维及更高维 Tensor
A[i][j][k] A[i][j][k][l] ...可以理解为不断增加索引维度的多维数组。
因此:
0 维 → 标量 1 维 → 向量 2 维 → 矩阵 3 维及以上 → 更高维数据这里不建议严格说:
Tensor 本质就是向量更准确的是:
Tensor 是对标量、向量、矩阵以及更高维数组的一种统一抽象。
十六、Shape:Tensor 到底“长什么样”?
仅仅知道内存里存在:
1 2 3 4 5 6还不能知道它在逻辑上应该被解释成什么结构。
它可能是:
[1, 2, 3, 4, 5, 6]也可能是:
[ [1, 2, 3], [4, 5, 6] ]还可能是:
[ [1, 2], [3, 4], [5, 6] ]数据数量都是 6 个,但是逻辑结构不同。
因此 Tensor 需要保存:
Shape例如:
shape = [2, 3]表示:
最外层长度 = 2 每一项里面长度 = 3对应:
[ [1, 2, 3], [4, 5, 6] ]因此可以粗暴地理解:
Shape 就是在描述 Tensor 每一维分别有多长。
例如:
shape = [2, 3, 4]表示:
最外层有 2 个 每一个里面有 3 个 每一个里面又有 4 个元素另外:
shape 中有几个数 = Tensor 有几维例如:
[3] → 1 维 [2, 3] → 2 维 [2, 3, 4] → 3 维1. 为什么算子必须知道 Shape?
因为算子不仅需要知道“这里有多少数据”,还需要知道:
这些数据应该按照什么结构解释?例如矩阵乘法:
A.shape = [2, 3] B.shape = [3, 4]由于中间维度:
3 == 3因此可以进行矩阵乘法,结果 Shape 为:
[2, 4]但是:
A.shape = [2, 3] B.shape = [5, 4]由于:
3 != 5输入就不满足矩阵乘法要求。
因此 Shape 至少能够帮助算子完成:
解释输入的数据结构 判断输入是否合法 推导输出 Tensor 的结构所以:
Shape 不只是描述信息,它会直接影响算子能不能计算以及输出应该长什么样。
十七、DType:Tensor 里面的每一个元素是什么类型?
有了 Shape 还不够。
例如:
shape = [2, 3]只告诉我们:
这是一个 2 × 3 的二维结构但并没有告诉我们:
每个元素应该按照什么类型解释因此还需要:
DType Data Type例如:
float32 float16 int32 uint8 ...所以:
shape = [2, 3] dtype = float32可以理解成:
这是一个 2 × 3 的二维 Tensor,其中每一个元素都是 float32。
这和普通 C++ 数组其实非常相似:
floata[2][3];这里:
[2][3] → 类似 Shape float → 类似 DType不同 DType 还会影响:
单个元素占用多少空间 整体内存占用 计算精度 底层使用哪种计算实现因此可以先形成:
Shape → 整体数据结构 DType → 每一个元素的数据类型十八、Device:这份 Tensor 的数据到底在哪里?
当系统只有 CPU 时,我们很少需要显式考虑:
这个数据到底属于 CPU 还是 GPU?因为整个程序默认就在 CPU 世界中。
但是进入异构计算以后,一份 Tensor 可能位于:
CPU 侧内存也可能位于:
GPU 可访问的显存因此 Tensor 还需要保存:
Device例如:
Tensor A shape = [2, 3] dtype = float32 device = CPU或者:
Tensor A shape = [2, 3] dtype = float32 device = GPU这里 Shape 和 DType 完全相同,但是数据所在的设备不同。
1. 为什么软件必须知道 Device?
这里很容易产生一个疑问:
CPU 和 GPU 自己不是知道怎么访问内存吗?为什么软件层还需要关心?
关键就在于:
硬件知道“怎么访问自己能够访问的存储”,但是它不知道应用层的业务意图。
在只有 CPU 的程序中:
CPU ↓ Cache / RAMCPU 自己可以处理缓存层次以及访存细节。
但是 CPU + GPU 以后出现:
┌→ CPU → CPU 内存体系 应用 / Runtime ─────┤ └→ GPU → GPU 内存体系这里出现了一个分叉。
软件必须知道:
这份 Tensor 当前在哪? 接下来应该走 CPU 路径还是 GPU 路径? 需不需要搬运数据?例如:
A.device = CPU B.device = GPU而现在决定在 GPU 上执行 Add。
那么可能需要先:
A: CPU RAM ↓ 搬运 ↓ GPU 可访问的存储然后才能让 GPU Kernel 使用 A 和 B。
所以 Device 会影响:
选择哪条执行路径 是否需要数据搬运 输入地址应该如何解释 输出 Tensor 应该在哪里分配2. 可以用网络编程里的 fd 来帮助理解
以前写网络程序时:
数据 ↓ 选择一个 fd ↓ write(fd, ...) ↓ 后面的 TCP/IP 细节交给协议栈应用层必须知道:
这份数据应该交给哪个连接?但是并不需要自己实现:
TCP 分段 重传 拥塞控制 IP 路由同理,在异构计算中:
Tensor ↓ 知道 Device ↓ 选择 CPU / GPU 对应执行路径 ↓ 更底层的访存与执行交给硬件所以 Device 的作用不是:
应用层亲自控制每一级 Cache而是:
告诉软件运行时,这份数据属于哪个计算设备的内存世界,后续应该走哪条执行路径。
十九、到这里可以重新完整认识一次 Tensor
现在可以把 Tensor 的基础信息先整理成:
Tensor ├── Data │ → 真正的数据 │ ├── Shape │ → 数据如何组织 │ → 几维、每一维多长 │ ├── DType │ → 每一个元素是什么类型 │ └── Device → 数据当前在哪个计算设备于是:
Tensor ↓ Operator ↓ Tensor ↓ Operator ↓ Tensor就不再只是一个抽象箭头。
一个算子拿到 Tensor 以后,会关心:
Shape 是否满足要求? DType 是否支持? Device 在哪里? 应该调用哪个具体硬件实现? 输出 Tensor 应该是什么结构? 输出应该存在哪里?二十、最源头的问题:为什么 AI 计算的输入会是 Tensor?
前面一直在研究:
Tensor 怎么计算?但是还存在一个更加根本的问题:
最开始的输入 Tensor 到底从哪里来?
这里可以从此前学习数学时非常熟悉的思想切入。
如果要进行数学运算,首先就需要把现实问题:
转换成数学能够处理的表示例如,计算机不可能直接拿:
一张图片 一段自然语言 一段声音作为数学意义上的对象去完成矩阵乘法。
因此首先需要:
现实数据 ↓ 数值化 / 编码 ↓ 可以参与数学运算的数字 ↓ 按照一定结构组织 ↓ Tensor所以 Tensor 并不是凭空出现的。
它本质上是:
现实世界信息被转换成数值表示以后,在模型内部使用的一种统一数据载体。
二十一、图片为什么能够转换成 Tensor?
图片是一个比较容易理解的例子。
一张图片由大量像素组成,而每个像素本身就可以使用数值表示。
例如 RGB 图像中的某个像素:
R = 120 G = 35 B = 67于是:
图片 ↓ 大量像素 ↓ 每个像素转换成数值 ↓ 按照宽、高、通道等结构组织 ↓ Tensor因此,模型真正看到的不是:
“一辆车”而是一组组织好的数字。
随后模型再通过一系列计算,将这些原始数字不断转换成更加有利于完成最终任务的中间表示。
二十二、文字又是怎么变成 Tensor 的?
文字相比图片更抽象一些。
例如:
我喜欢汽车CPU / GPU 并不会直接理解:
“我” “喜欢” “汽车”这些自然语言语义。
因此第一步需要先把文字转换成模型能够处理的基本单位。
可以先粗略理解成:
我喜欢汽车 ↓ Tokenization ↓ Token ↓ 每个 Token 对应一个编号 ↓ Token ID例如仅作示意:
我 → 1024 喜欢 → 5832 汽车 → 9176于是:
“我喜欢汽车”就可以先转换成:
[1024, 5832, 9176]这些整数可以组织成:
Tensor shape = [3] dtype = integer因此文字到最初输入 Tensor 的过程可以先理解成:
文字 ↓ Token ↓ Token ID ↓ Token ID Tensor需要注意:
1024 5832 9176这些编号本身更像“身份证号”。
编号大小本身并不意味着:
9176 比 1024 更像汽车它们主要用于标识不同 Token。
在真正进入模型主体计算之前,这些 Token ID 后面通常还会被继续转换成适合数值计算的向量表示,这会自然引出后续的 Embedding。
这部分可以放到下一阶段继续学习。
二十三、那么模型为什么要不断计算这些 Tensor?
现在就可以重新理解整个推理过程。
最开始得到的 Tensor 只是:
原始输入的数值表示而不是最终想要的答案。
例如输入一张图片:
输入 Tensor = 大量像素数字但真正想得到的是:
这是什么物体?或者:
汽车概率是多少? 行人概率是多少?因此模型需要经过:
输入 Tensor ↓ 算子 A ↓ 中间 Tensor ↓ 算子 B ↓ 中间 Tensor ↓ 算子 C ↓ …… ↓ 输出 Tensor这些计算不断改变数据的表示方式。
最终:
输出 Tensor再被解释成人真正需要的结果。
因此整个推理流程可以压缩成:
现实世界数据 ↓ 数值化 / 编码 ↓ 输入 Tensor ↓ 计算图 ↓ 一系列 Operator ↓ 不断产生中间 Tensor ↓ 输出 Tensor ↓ 解释结果 ↓ 得到最终业务结果所以:
Tensor 可以看成 AI 模型内部的一种通用数据语言。
图片、文字、声音进入模型之前,先被转换成 Tensor。
模型内部的算子围绕 Tensor 进行计算。
最终再把输出 Tensor 转换成人或者业务系统能够理解的结果。
二十四、把前面的所有概念第一次串起来
现在终于可以把此前所有零散概念串成一条完整链路。
假设现实世界中产生了一份输入:
图片 / 文字 / 传感器数据首先:
现实数据 ↓ 数值化 / 编码 ↓ Input TensorTensor 中包含:
Data Shape DType Device然后 Tensor 进入模型的:
Computation Graph 计算图计算图中:
节点 → Operator 边 → Tensor 数据依赖Executor 拿到计算图以后:
判断哪些算子输入已经 ready ↓ 推动整个计算图运行当多个算子同时 ready:
Scheduler → 安排执行顺序 → 决定是否并行 → 安排对应执行资源具体某个算子真正执行时:
Operator → 描述“要算什么” Kernel / 后端实现 → 描述“在具体硬件上怎么计算” CPU / GPU → 真正执行底层计算执行完成以后:
产生新的 Tensor ↓ 作为后续算子的输入 ↓ 继续计算直到:
Final Output Tensor产生。
整个过程就是:
现实输入 ↓ Tensor ↓ 计算图 ↓ Operator ↓ Kernel ↓ CPU / GPU ↓ 新的 Tensor ↓ 继续沿计算图传播 ↓ 最终输出二十五、做边缘计算算子开发,需要把整套 AI 和数学都学完吗?
到这里还会产生一个比较现实的问题:
如果后续要真正开发算子,是不是还得系统学习机器学习、深度学习以及大量数学?
答案不是完全不需要,而是需要明确学习边界。
如果后续工作确实进入:
MatMul Softmax RMSNorm LayerNorm RoPE Attention这些具体算子,那么仅仅知道:
输入 Tensor → 调用 Kernel → 输出 Tensor是不够的。
因为还需要理解:
这个算子为什么存在? 它究竟在算什么? 输入输出代表什么? 为什么采用这样的数学形式?因此需要补一部分:
线性代数基础 → 向量 → 矩阵 → 矩阵乘法 → 转置 → 点积 基础数学函数 → exp → 平方 → 平方根 → 均值 Tensor 运算 → reshape → broadcast → reduce 少量深度学习前向计算 → Linear → 激活函数 → Normalization → Softmax → Attention但是如果当前目标是:
C++ / 边缘推理 / 算子开发并不意味着必须先完整学习:
机器学习算法大全 反向传播完整推导 梯度下降 SGD / Adam 各种损失函数 训练调参 CNN / RNN 全体系因为当前更关心的是:
模型怎么执行,而不是模型怎么训练出来。
更加合适的学习方式是:
遇到一个算子 ↓ 先搞懂它解决什么问题 ↓ 只补它所需要的数学 ↓ 理解输入 Tensor ↓ 理解计算过程 ↓ 理解输出 Tensor ↓ 最后进入 C++ / Kernel 实现这样不会一直停留在推理框架外层,同时也不会为了进入算子开发,一开始就被整个深度学习理论体系淹没。
二十六、现阶段建立的完整心智模型
到这里,可以把目前所有内容压缩成下面几个最关键的认知。
首先:
边缘计算 → 解决“在哪里算”其次:
Operator → 描述“算什么”然后:
计算图 → 描述多个算子之间如何连接、谁依赖谁接着:
Executor → 根据计算图推动整个计算流程 Scheduler → 安排已经具备执行条件的任务怎么运行再往下:
Kernel → 某个算子面向具体硬件的底层实现 CPU / GPU → 真正完成计算的硬件而贯穿整个计算流程的数据则是:
Tensor ├── Shape ├── DType └── Device最终,整个 AI 推理过程可以理解成:
现实世界中的数据 ↓ 转换为数值 ↓ 组织成 Tensor ↓ 进入计算图 ↓ 经过一个个算子 ↓ 算子调用具体 Kernel ↓ CPU / GPU 完成实际计算 ↓ 不断产生新的 Tensor ↓ 最终得到输出 Tensor ↓ 将结果解释成真正需要的信息至此,“边缘计算算子模块”这个词就不再是一个完全悬空的概念。
它可以逐步拆成:
边缘计算 → 计算应该部署在哪里 算子 → 节点上具体要执行什么计算 Kernel → 这个计算如何针对具体硬件实现 Tensor → 这些计算处理和传递的数据 Runtime / Executor / Scheduler → 如何把整个计算过程真正运行起来后续再进入某个具体算子时,就可以在这套心智模型上继续往下钻,而不是重新从一堆陌生名词开始。