【边缘计算入门系列】从“在哪里算”到“怎么算”:一文建立边缘计算、算子、计算图与 Tensor 的底层心智模型

🔥 本文专栏:边缘计算

🌸作者主页:努力努力再努力wz

💪 今日博客励志语录:你可以暂时没有结果,但不能长期没有积累。


思维导图

text 复制代码
现实设备产生数据
        ↓
如果全部发送到远端云端计算
        ↓
网络传输会带来额外时延、带宽和稳定性问题
        ↓
把一部分计算能力下沉到更靠近数据源的位置
        ↓
边缘计算
        ↓
云端负责管理、调度算力池
        ↓
算力池集中管理多个算力节点
        ↓
车载节点 / 边缘节点
        ↓
首先解决:在哪里算?
        ↓
进一步追问:到了节点以后,到底怎么算?
        ↓
输入 x → 中间计算过程 f(x) → 输出 y
        ↓
复杂计算并不是一步完成
        ↓
拆成多个可复用的计算步骤
        ↓
Operator / 算子
        ↓
多个算子 + 输入输出依赖关系
        ↓
计算图
        ↓
Executor 根据计算图推动整个流程执行
        ↓
Scheduler 安排已经 ready 的算子如何执行
        ↓
算子最终需要具体硬件完成
        ↓
CPU / GPU
        ↓
同一个算子针对不同硬件存在不同底层实现
        ↓
Kernel
        ↓
算子之间传递的输入、中间结果和输出
        ↓
Tensor
        ↓
Shape:数据结构
DType:元素类型
Device:数据在哪个设备
        ↓
现实世界中的图片 / 文字 / 声音
        ↓
数值化 / 编码
        ↓
输入 Tensor
        ↓
模型计算
        ↓
输出 Tensor
        ↓
解释为最终结果

引入:先不要急着看算子代码

在开始接触所谓的"边缘计算算子模块"之前,如果直接从:

text 复制代码
Tensor
Operator
Kernel
CUDA
GPU
Runtime

这些概念切入,很容易出现一个问题:

每一个词似乎都能单独记住,但是却不知道它为什么会出现,更不知道它在整个系统中处于什么位置。

因此,这里并不准备一开始就进入某个具体算子的实现,也不直接去看 CUDA 或 TensorRT。

更合适的方式,是先回答几个更根本的问题:

text 复制代码
为什么需要边缘计算?
所谓的"算"到底是什么?
一个完整计算为什么要拆成很多算子?
这些算子之间如何组织?
谁来真正执行这些算子?
CPU 和 GPU 在这里又分别承担什么职责?
算子之间传递的 Tensor 到底又是什么?

把这些问题按照逻辑链条串起来以后,后续再进入真正的算子实现时,很多概念就不会再是悬空的。


一、先回答第一个问题:计算到底应该放在哪里?

1. 从云端和算力池开始

在此前接触分布式推理平台时,首先认识到了一个云端。

这里可以先把云端理解为:

text 复制代码
云端
=
整个算力系统的管理者和调度者

云端下面管理着一个所谓的:

text 复制代码
算力池

此前理解算力池时,可以直接从熟悉的线程池切入。

线程池本质上是在集中管理一批线程:

text 复制代码
ThreadPool
├── Thread-1
├── Thread-2
├── Thread-3
└── Thread-4

而算力池则是在集中管理一批能够提供计算能力的节点:

text 复制代码
算力池
├── 算力节点 A
├── 算力节点 B
├── 算力节点 C
└── 算力节点 D

因此可以建立一个最粗的类比:

text 复制代码
线程池
→ 集中管理线程

算力池
→ 集中管理算力节点

在当前项目语境中,这些算力节点又可以继续区分为:

text 复制代码
算力节点
├── 车载节点
└── 边缘节点

这里首先可以从物理位置来理解:

text 复制代码
车载节点
→ 计算能力部署在车这一侧

边缘节点
→ 计算能力部署在车外、靠近业务现场的一侧

需要注意的是,这里是在当前项目的节点分类中区分"车载节点"和"边缘节点"。从更广义的 Edge Computing 概念来看,车载计算本身同样属于将计算能力下沉到靠近数据源一侧的典型方式。


2. 为什么不全部交给远端云端计算?

假设汽车上的摄像头、传感器等设备不断产生数据。

最直接的一种方式就是:

text 复制代码
车端设备
   ↓
产生数据
   ↓
通过网络发送
   ↓
远端云服务器
   ↓
完成计算
   ↓
结果通过网络返回
   ↓
车端得到结果

这种方式并不是不能工作。

问题在于,一次完整请求的耗时并不只有:

text 复制代码
计算时间

还包含:

text 复制代码
数据上传时间
网络排队时间
远端传输时间
结果返回时间

因此在某些实时性要求较高的场景中,即使服务器本身计算得非常快,整个链路依然可能因为网络传输而产生明显时延。

可以把它类比为计算机系统中的 I/O 瓶颈:

text 复制代码
真正执行一次计算可能很快

但是:
把数据送过去
+
把结果拿回来

同样需要时间

于是就产生了一个非常自然的思路:

既然网络传输会产生额外开销,那么能不能让计算发生在距离数据产生位置更近的地方?

这就是理解边缘计算最关键的切入点。


二、边缘计算:解决"在哪里算"的问题

1. 把计算能力向数据源靠近

所谓边缘计算,可以先建立一个非常粗的认知:

将原本需要集中发送到远端云端完成的一部分计算,下沉到更靠近数据产生位置的设备或者计算节点上。

于是原本:

text 复制代码
数据源
  ↓
远端云端
  ↓
计算

就可以变成:

text 复制代码
数据源
  ↓
附近计算节点
  ↓
计算

甚至:

text 复制代码
数据源
  ↓
本地车载计算节点
  ↓
直接计算

因此这里真正改变的是:

text 复制代码
计算发生的位置

而不只是单纯地"换了一台服务器"。


2. 车载节点、边缘节点与云端可以形成不同计算层次

从距离数据源的位置来看,可以先形成下面这个模型:

text 复制代码
数据产生位置
     │
     │ 最靠近
     ▼
车载计算节点
     │
     │ 稍远
     ▼
边缘计算节点
     │
     │ 更远
     ▼
中心云

于是系统面对一个任务时,实际上会出现一个核心问题:

text 复制代码
这个任务到底应该在哪里算?

可以:

text 复制代码
在车上算

也可以:

text 复制代码
在边缘节点算

还可以:

text 复制代码
交给中心云算

因此云端管理算力池,本质上就是掌握一批可以用于完成计算任务的算力资源。

到这里,可以先形成第一句核心认知:

边缘计算首先解决的是"在哪里算"的问题。

但是仅仅确定在哪里算还远远不够。

因为接下来还会有一个更加直接的问题:

计算节点拿到数据以后,到底应该怎么算?

这就开始从"计算位置"进入"计算过程"。


三、从一个黑盒函数开始理解计算

1. 最宏观的计算模型:y = f(x)

先不要考虑 AI,也不要考虑 GPU。

任何一次计算,都可以先抽象成:

text 复制代码
输入 x
  ↓
┌──────────────┐
│     f(x)     │
│   中间计算    │
└──────────────┘
  ↓
输出 y

即:

text 复制代码
y = f(x)

此时,我们把中间的整个计算过程先当成一个黑盒。

但是实际程序中的 f(x) 往往不是一步就直接得到最终结果。

它可能是:

text 复制代码
输入 x
  ↓
计算步骤 A
  ↓
中间结果 1
  ↓
计算步骤 B
  ↓
中间结果 2
  ↓
计算步骤 C
  ↓
最终输出 y

数学上可以粗略表示为:

text 复制代码
y = f3(f2(f1(x)))

于是,原本那个巨大的黑盒开始被拆开。


四、算子:一个完整计算流程中的基本计算单元

1. 算子到底是什么?

当我们把完整计算流程拆开以后:

text 复制代码
输入
 ↓
计算步骤 A
 ↓
计算步骤 B
 ↓
计算步骤 C
 ↓
输出

其中每一个相对独立的计算步骤,就可以先理解成一个:

text 复制代码
Operator
算子

例如:

text 复制代码
输入 A ─┐
        ├─ Add → 输出 C
输入 B ─┘

这里的 Add 就是一个计算单元。

因此可以先建立最简单的认知:

算子就是整个计算过程中的一个具体计算步骤。

也就是说:

text 复制代码
边缘计算
→ 解决在哪里算

算子
→ 解决具体算什么

这两个概念并不是同一个层面。


2. 为什么不直接写成一个巨大的计算函数?

既然最终目标只是:

text 复制代码
输入
↓
得到输出

那么为什么还要把中间过程拆成很多算子?

这里其实和普通 C++ 项目中的模块化设计非常相似。

我们实现一个复杂程序时,一般不会把:

text 复制代码
网络连接
协议解析
业务处理
定时器
数据库访问

全部塞进一个巨大的函数。

而是拆成不同模块:

text 复制代码
Acceptor
TcpConnection
HttpParser
Timer
...

原因之一就是:

text 复制代码
职责更明确
可以复用
可以独立修改
可以独立优化

算子也是一样。

假设计算流程 A:

text 复制代码
输入
 ↓
Add
 ↓
Multiply
 ↓
输出

计算流程 B:

text 复制代码
输入
 ↓
Add
 ↓
Normalize
 ↓
输出

这里的:

text 复制代码
Add

就是一个可以被多个计算流程复用的计算模块。

因此一个完整计算流程可以理解成:

按照特定依赖关系,将多个通用计算单元组合起来。

从这个角度来看,算子就像计算过程中的积木。


五、完整计算并不一定是一条线:从算子过渡到依赖关系

1. 最简单的情况是线性执行

最简单的计算流程可以是:

text 复制代码
A → B → C → D

这里:

text 复制代码
A 的输出
→ B 的输入

B 的输出
→ C 的输入

这种情况下确实可以理解成一条顺序执行的链。

但是实际计算过程并不一定只有一条直线。

例如:

text 复制代码
        ┌→ B ─┐
A ──────┤     ├→ D
        └→ C ─┘

这里 A 计算完成以后,它的结果同时被 B 和 C 使用。

此时:

text 复制代码
A 必须先于 B
A 必须先于 C

但是:

text 复制代码
B 和 C 之间

并不存在严格的先后关系。

只要 A 的结果已经准备好:

text 复制代码
B 可以执行
C 也可以执行

而 D 又同时依赖 B、C 的输出,因此只有:

text 复制代码
B 完成
+
C 完成

以后:

text 复制代码
D 才能执行

所以这里真正描述的已经不只是"执行顺序",而是:

数据依赖关系。


2. 不要把计算过程和线程执行过程混在一起

此前我们很容易从普通程序执行流出发,把计算过程理解成:

text 复制代码
一个线程
从头执行到尾

但这只是最简单的一种执行模型。

更准确地说:

text 复制代码
计算流程
→ 描述应该怎么算、谁依赖谁

线程
→ 描述运行时由谁去执行这些计算

这两者不是一个层面的概念。

例如:

text 复制代码
        ┌→ B ─┐
A ──────┤     ├→ D
        └→ C ─┘

这是计算逻辑本身。

至于运行时:

text 复制代码
B、C 是否由同一个线程执行
是否由不同线程并行执行
是否有某个算子交给 GPU

这是后面的执行和调度问题。


六、计算图:用图结构描述完整计算流程

到这里,其实"计算图"已经自然出现了。

我们已经有两个东西:

text 复制代码
一批算子
+
算子之间的数据依赖关系

这正好可以用此前数据结构中学过的:

text 复制代码
Graph
图

来描述。

计算图中:

text 复制代码
Node / 节点
→ 算子

Edge / 边
→ 数据传递与依赖关系

例如:

text 复制代码
        ┌→ B ─┐
A ──────┤     ├→ D
        └→ C ─┘

其中:

text 复制代码
A、B、C、D

就是节点,也就是算子。

而:

text 复制代码
A → B
A → C
B → D
C → D

则表示输入输出依赖。

这里需要注意:

text 复制代码
A → B

并不仅仅表示:

text 复制代码
A 写在 B 前面

它真正表达的是:

B 的计算需要依赖 A 产生的数据。

因此,可以形成一句比较完整的定义:

计算图就是使用图结构描述一个完整计算过程,其中节点表示算子,边表示算子之间的数据传递和依赖关系。


七、什么时候一个算子可以执行?

有了计算图以后,一个算子什么时候能够执行,其实就很直观了。

核心条件就是:

它所依赖的输入数据是否全部准备完成。

例如:

text 复制代码
A ──┐
    ├──> D
B ──┘

D 同时依赖:

text 复制代码
A 的输出
B 的输出

如果:

text 复制代码
A 已完成
B 未完成

那么 D 不能执行。

只有:

text 复制代码
A 已完成
+
B 已完成

D 所需要的输入全部就绪以后:

text 复制代码
D 才具备执行条件

因此可以形成一个非常重要的认知:

text 复制代码
图中的边
→ 描述数据依赖

依赖全部满足
→ 算子 ready

算子 ready
→ 具备被执行的条件

这里强调的是"具备执行条件"。

至于它到底什么时候真的被执行、由谁执行,则是下一层问题。


八、计算图只是蓝图:真正推动计算的是 Executor

计算图可以告诉系统:

text 复制代码
有哪些算子
谁依赖谁
数据从哪里流向哪里

但是计算图本身并不会执行任何东西。

这里可以把计算图类比成:

text 复制代码
建筑蓝图

建筑蓝图能够描述:

text 复制代码
这里应该建墙
那里应该安装梁
施工之间有哪些前置关系

但蓝图本身不会自动把建筑修出来。

同样:

text 复制代码
计算图
=
计算过程的蓝图

真正需要有一个软件模块拿着这张图,不断推动整个计算向前运行。

这个角色就可以先称为:

text 复制代码
Executor
执行器

其宏观目标非常简单:

text 复制代码
拿到输入
   ↓
按照计算图执行
   ↓
不断完成算子
   ↓
最终得到输出

例如:

text 复制代码
        ┌→ B ─┐
A ──────┤     ├→ D
        └→ C ─┘

执行器会不断做类似的事情:

text 复制代码
发现 A 可以执行
   ↓
执行 A
   ↓
A 的输出 ready
   ↓
B、C 具备执行条件
   ↓
执行 B、C
   ↓
B、C 输出全部 ready
   ↓
D 具备执行条件
   ↓
执行 D

因此:

计算图负责描述计算,Executor 负责把这张图真正跑起来。


九、Scheduler:多个 ready 算子应该怎么安排?

当一个计算图中同一时刻只有一个算子能够执行时,其实没有太复杂的调度问题。

真正需要调度,是当:

text 复制代码
A 执行完成
   ↓
B ready
C ready
E ready

同时出现多个已经具备执行条件的任务。

这时就需要考虑:

text 复制代码
谁先执行?
谁后执行?
哪些可以并行?
交给哪个工作线程?

这就是:

text 复制代码
Scheduler
调度

因此可以先形成一个非常简单的区别:

text 复制代码
Executor
→ 负责推动整个计算图从输入走到输出

Scheduler
→ 负责安排当前已经 ready 的任务怎么执行

这里的 Scheduler 不一定在代码中真的存在一个独立的 Scheduler 类。

真实框架可能直接把调度逻辑写在 Executor 中。

所以更准确地说:

Scheduler 首先是一种职责,而不一定是一个独立对象。


1. 调度器不是操作系统 CPU Scheduler

这里还需要区分两个完全不同的调度层次。

例如一个 CPU 算子已经 ready:

text 复制代码
算子 B
   ↓
框架调度
   ↓
worker thread 2

这个过程属于:

text 复制代码
推理框架 / Executor 内部的任务调度

但是:

text 复制代码
worker thread 2

最终真正运行在哪一个 CPU Core 上,则通常由:

text 复制代码
操作系统 Scheduler

负责。

因此:

text 复制代码
推理框架调度
→ 调度算子任务

操作系统调度
→ 调度线程

不要把两者混为一谈。


十、调度之前先拆清楚另一个问题:CPU 还是 GPU?

这里很容易产生一个误区:

text 复制代码
Scheduler
→ 看一下算子代码
→ 判断这个算子更适合 CPU 还是 GPU
→ 临时选择设备

为了建立清晰心智模型,这里先把两个问题拆开:

text 复制代码
Where?
这个算子在哪类设备上运行?
→ Device Placement / Backend Selection

When?
这个已经可以运行的算子什么时候被执行?
→ Scheduling

很多推理系统中,一个算子使用哪个设备或者哪个后端实现,在真正执行之前就已经由模型部署方式、Tensor 所在设备、框架配置等条件确定。

例如:

text 复制代码
MatMul
→ 已经确定在 GPU 上执行

那么运行时真正要做的是:

text 复制代码
等 MatMul 的输入全部 ready
   ↓
调度执行
   ↓
调用 GPU 对应实现

当然,真实推理框架的设计非常多样,设备放置和运行时调度可能由不同模块完成,也可能存在动态决策。

这里当前最重要的不是记某个框架的具体实现,而是先把:

text 复制代码
设备选择

和:

text 复制代码
任务调度

在概念上拆开。


十一、CPU 与 GPU:真正完成计算的底层硬件

此前编写的大多数 C++ 程序,本质上都可以视为:

text 复制代码
CPU 程序

代码经过编译以后:

text 复制代码
源代码
 ↓
机器指令
 ↓
CPU 执行

CPU 是一个通用处理器。

其特点可以粗略概括为:

text 复制代码
核心数量相对较少
但是单个核心功能强
擅长复杂控制逻辑
擅长分支
擅长运行通用程序

例如:

text 复制代码
if / else
函数调用
系统调用
网络处理
线程调度
复杂业务逻辑

这些都非常适合 CPU。


1. GPU 为什么适合 AI 计算?

GPU 同样是一种能够执行计算任务的硬件。

只不过它的硬件设计目标与 CPU 不同。

GPU 更擅长的是:

对大量数据同时进行相同或者相似的数值计算。

例如:

text 复制代码
100 万个元素
每一个元素都执行:

x = x * 2

这种任务具有:

text 复制代码
大量数据
+
相似计算
+
高度并行

的特点,非常适合 GPU。

因此可以建立一个非常粗的区别:

text 复制代码
CPU
→ 少量但强大的核心
→ 擅长复杂控制和通用计算

GPU
→ 大量并行计算单元
→ 擅长大规模相似数值计算

图像处理就是典型场景之一。

例如一张图片中存在大量像素,如果每一个像素都需要进行相似的数值操作,那么这些像素计算天然具有很高的并行性。

而 AI 模型中又存在大量:

text 复制代码
矩阵乘法
向量运算
加法
乘法

因此 GPU 同样非常适合这些计算。


十二、CPU + GPU:从单一 CPU 程序进入异构计算

这里会出现一个此前普通 C++ 程序中不太需要关注的问题:

text 复制代码
CPU 和 GPU 都能执行计算
那么到底谁负责整个程序的控制?

在典型 CPU + GPU 异构计算模型中,可以先建立:

text 复制代码
CPU
=
程序的主控端
+
本身也是一个计算设备

GPU
=
CPU 侧程序提交任务以后
负责执行大规模并行计算的设备

例如:

text 复制代码
CPU 正在执行程序
        ↓
运行到某个 GPU 计算任务
        ↓
CPU 侧程序准备数据和参数
        ↓
向 GPU 提交 Kernel
        ↓
GPU 执行计算
        ↓
产生结果

因此并不存在一个所谓的:

text 复制代码
悬浮在 CPU / GPU 之上的软件意图

因为:

"选择走 CPU 还是 GPU"这段控制逻辑本身,通常也是 CPU 正在执行的程序的一部分。

例如可以粗略理解成:

cpp 复制代码
if (use_gpu) {
    launch_gpu_kernel(...);
} else {
    run_cpu_kernel(...);
}

这里:

text 复制代码
if
launch_gpu_kernel()
run_cpu_kernel()

这些控制逻辑本身都是 CPU 在执行。

只有 GPU 任务真正提交以后,GPU 才开始执行对应的计算。


十三、CPU 和 GPU 为什么会涉及不同存储空间?

在普通 CPU 程序中:

cpp 复制代码
int* p = new int[100];

我们通常只需要关心:

text 复制代码
p 指向一块可以访问的数据

至于这份数据当前是在:

text 复制代码
L1 Cache
L2 Cache
L3 Cache
还是 RAM

通常并不需要应用层程序显式决定。

因为这些都属于 CPU 自己的缓存与内存访问体系。

CPU 可以粗略理解为:

text 复制代码
CPU
├── Register
├── L1 / L2 / L3 Cache
└── Main Memory / RAM

但是引入独立 GPU 以后,系统里出现了另一套计算设备和存储空间。

在典型独立显卡模型中,可以先理解成:

text 复制代码
CPU                  GPU
 │                    │
 ↓                    ↓
系统内存 RAM          显存 VRAM

于是软件必须开始关心:

text 复制代码
这份数据到底在哪一侧?

这已经不是:

text 复制代码
L1 还是 L2

这种同一个 CPU 内存体系内部的问题。

而是:

text 复制代码
CPU 设备侧
还是
GPU 设备侧

的问题。


1. CPU 怎么把任务交给 GPU?

一个典型过程可以粗略理解成两件事:

第一件事:准备 GPU 可以访问的数据

text 复制代码
CPU RAM
   ↓
数据传输
   ↓
GPU VRAM

对于独立 GPU,这种数据传输可能通过 PCIe 等硬件通道完成。

实际搬运通常还会涉及驱动、DMA 等机制,因此不应该理解成 CPU 核心亲自逐字节搬运。

更准确地说:

CPU 侧软件发起数据传输。

第二件事:向 GPU 提交计算任务

CPU 侧程序还需要告诉 GPU:

text 复制代码
执行哪个 Kernel?
输入数据在哪里?
输出应该写到哪里?
参数是什么?

于是整体可以理解成:

text 复制代码
CPU
 │
 ├── 数据通道:准备 / 搬运 GPU 所需数据
 │
 └── 控制通道:提交 GPU 计算任务
        ↓
       GPU
        ↓
执行 Kernel

所以:

text 复制代码
数据传输

和:

text 复制代码
计算任务提交

是两件不同的事情。


十四、Operator 与 Kernel:一个负责"算什么",一个负责"怎么在硬件上算"

到这里就可以重新回到算子。

例如:

text 复制代码
Add(A, B)

从算子的角度,它表达的是:

text 复制代码
C = A + B

即:

我要完成加法。

但是同一个 Add 算子如果分别运行在 CPU 和 GPU 上,由于硬件架构、执行模型、指令体系、并行方式以及内存体系都不同,底层实现也可能不同。

于是可以形成:

text 复制代码
             Add Operator
              "做加法"
                  │
          ┌───────┴───────┐
          ↓               ↓
     CPU 实现          GPU 实现
          ↓               ↓
         CPU             GPU

这里的上层语义不变:

text 复制代码
What
→ Add 要干什么
→ 不变

变化的是:

text 复制代码
How
→ 在这种硬件上具体怎么实现
→ 可以不同

1. Kernel 到底是什么?

这里需要先区分:

text 复制代码
Linux Kernel

和现在讨论的:

text 复制代码
Compute Kernel / GPU Kernel

并不是一个概念。

为了建立当前心智模型,可以先把 Kernel 理解成:

一个算子面向某种具体硬件的底层计算实现。

例如:

text 复制代码
MatMul Operator
→ 要完成矩阵乘法

CPU MatMul 实现
→ 在 CPU 上具体怎么完成

GPU MatMul Kernel
→ 在 GPU 上具体怎么完成

因此:

text 复制代码
Operator
→ 算什么

Kernel
→ 在某种具体硬件上怎么把它算出来

需要注意,真实框架对于 CPU 后端具体实现不一定都统一称为 Kernel,但是在当前建立心智模型时,可以先统一这样理解;而在 GPU 编程语境中,Kernel 这个词尤其常见。


十五、算子之间传递的到底是什么?------Tensor

此前一直在说:

text 复制代码
输入
 ↓
算子 A
 ↓
中间结果
 ↓
算子 B
 ↓
输出

那么这里的:

text 复制代码
输入
中间结果
输出

究竟是什么?

在 AI 推理中,一个非常核心的数据结构就是:

text 复制代码
Tensor
张量

工程上可以先把 Tensor 理解成:

统一表示多维数值数据的数据结构。

例如:

0 维 Tensor

text 复制代码
5

就是一个标量。

1 维 Tensor

text 复制代码
[1, 2, 3]

可以理解为一个向量。

2 维 Tensor

text 复制代码
[
  [1, 2, 3],
  [4, 5, 6]
]

可以理解为矩阵。

3 维及更高维 Tensor

text 复制代码
A[i][j][k]
A[i][j][k][l]
...

可以理解为不断增加索引维度的多维数组。

因此:

text 复制代码
0 维
→ 标量

1 维
→ 向量

2 维
→ 矩阵

3 维及以上
→ 更高维数据

这里不建议严格说:

text 复制代码
Tensor 本质就是向量

更准确的是:

Tensor 是对标量、向量、矩阵以及更高维数组的一种统一抽象。


十六、Shape:Tensor 到底"长什么样"?

仅仅知道内存里存在:

text 复制代码
1 2 3 4 5 6

还不能知道它在逻辑上应该被解释成什么结构。

它可能是:

text 复制代码
[1, 2, 3, 4, 5, 6]

也可能是:

text 复制代码
[
  [1, 2, 3],
  [4, 5, 6]
]

还可能是:

text 复制代码
[
  [1, 2],
  [3, 4],
  [5, 6]
]

数据数量都是 6 个,但是逻辑结构不同。

因此 Tensor 需要保存:

text 复制代码
Shape

例如:

text 复制代码
shape = [2, 3]

表示:

text 复制代码
最外层长度 = 2
每一项里面长度 = 3

对应:

text 复制代码
[
  [1, 2, 3],
  [4, 5, 6]
]

因此可以粗暴地理解:

Shape 就是在描述 Tensor 每一维分别有多长。

例如:

text 复制代码
shape = [2, 3, 4]

表示:

text 复制代码
最外层有 2 个
每一个里面有 3 个
每一个里面又有 4 个元素

另外:

text 复制代码
shape 中有几个数
=
Tensor 有几维

例如:

text 复制代码
[3]
→ 1 维

[2, 3]
→ 2 维

[2, 3, 4]
→ 3 维

1. 为什么算子必须知道 Shape?

因为算子不仅需要知道"这里有多少数据",还需要知道:

text 复制代码
这些数据应该按照什么结构解释?

例如矩阵乘法:

text 复制代码
A.shape = [2, 3]
B.shape = [3, 4]

由于中间维度:

text 复制代码
3 == 3

因此可以进行矩阵乘法,结果 Shape 为:

text 复制代码
[2, 4]

但是:

text 复制代码
A.shape = [2, 3]
B.shape = [5, 4]

由于:

text 复制代码
3 != 5

输入就不满足矩阵乘法要求。

因此 Shape 至少能够帮助算子完成:

text 复制代码
解释输入的数据结构
判断输入是否合法
推导输出 Tensor 的结构

所以:

Shape 不只是描述信息,它会直接影响算子能不能计算以及输出应该长什么样。


十七、DType:Tensor 里面的每一个元素是什么类型?

有了 Shape 还不够。

例如:

text 复制代码
shape = [2, 3]

只告诉我们:

text 复制代码
这是一个 2 × 3 的二维结构

但并没有告诉我们:

text 复制代码
每个元素应该按照什么类型解释

因此还需要:

text 复制代码
DType
Data Type

例如:

text 复制代码
float32
float16
int32
uint8
...

所以:

text 复制代码
shape = [2, 3]
dtype = float32

可以理解成:

这是一个 2 × 3 的二维 Tensor,其中每一个元素都是 float32。

这和普通 C++ 数组其实非常相似:

cpp 复制代码
float a[2][3];

这里:

text 复制代码
[2][3]
→ 类似 Shape

float
→ 类似 DType

不同 DType 还会影响:

text 复制代码
单个元素占用多少空间
整体内存占用
计算精度
底层使用哪种计算实现

因此可以先形成:

text 复制代码
Shape
→ 整体数据结构

DType
→ 每一个元素的数据类型

十八、Device:这份 Tensor 的数据到底在哪里?

当系统只有 CPU 时,我们很少需要显式考虑:

text 复制代码
这个数据到底属于 CPU 还是 GPU?

因为整个程序默认就在 CPU 世界中。

但是进入异构计算以后,一份 Tensor 可能位于:

text 复制代码
CPU 侧内存

也可能位于:

text 复制代码
GPU 可访问的显存

因此 Tensor 还需要保存:

text 复制代码
Device

例如:

text 复制代码
Tensor A

shape  = [2, 3]
dtype  = float32
device = CPU

或者:

text 复制代码
Tensor A

shape  = [2, 3]
dtype  = float32
device = GPU

这里 Shape 和 DType 完全相同,但是数据所在的设备不同。


1. 为什么软件必须知道 Device?

这里很容易产生一个疑问:

CPU 和 GPU 自己不是知道怎么访问内存吗?为什么软件层还需要关心?

关键就在于:

硬件知道"怎么访问自己能够访问的存储",但是它不知道应用层的业务意图。

在只有 CPU 的程序中:

text 复制代码
CPU
↓
Cache / RAM

CPU 自己可以处理缓存层次以及访存细节。

但是 CPU + GPU 以后出现:

text 复制代码
                    ┌→ CPU → CPU 内存体系
应用 / Runtime ─────┤
                    └→ GPU → GPU 内存体系

这里出现了一个分叉。

软件必须知道:

text 复制代码
这份 Tensor 当前在哪?
接下来应该走 CPU 路径还是 GPU 路径?
需不需要搬运数据?

例如:

text 复制代码
A.device = CPU
B.device = GPU

而现在决定在 GPU 上执行 Add。

那么可能需要先:

text 复制代码
A:
CPU RAM
   ↓
搬运
   ↓
GPU 可访问的存储

然后才能让 GPU Kernel 使用 A 和 B。

所以 Device 会影响:

text 复制代码
选择哪条执行路径
是否需要数据搬运
输入地址应该如何解释
输出 Tensor 应该在哪里分配

2. 可以用网络编程里的 fd 来帮助理解

以前写网络程序时:

text 复制代码
数据
 ↓
选择一个 fd
 ↓
write(fd, ...)
 ↓
后面的 TCP/IP 细节交给协议栈

应用层必须知道:

text 复制代码
这份数据应该交给哪个连接?

但是并不需要自己实现:

text 复制代码
TCP 分段
重传
拥塞控制
IP 路由

同理,在异构计算中:

text 复制代码
Tensor
 ↓
知道 Device
 ↓
选择 CPU / GPU 对应执行路径
 ↓
更底层的访存与执行交给硬件

所以 Device 的作用不是:

text 复制代码
应用层亲自控制每一级 Cache

而是:

告诉软件运行时,这份数据属于哪个计算设备的内存世界,后续应该走哪条执行路径。


十九、到这里可以重新完整认识一次 Tensor

现在可以把 Tensor 的基础信息先整理成:

text 复制代码
Tensor
├── Data
│   → 真正的数据
│
├── Shape
│   → 数据如何组织
│   → 几维、每一维多长
│
├── DType
│   → 每一个元素是什么类型
│
└── Device
    → 数据当前在哪个计算设备

于是:

text 复制代码
Tensor
  ↓
Operator
  ↓
Tensor
  ↓
Operator
  ↓
Tensor

就不再只是一个抽象箭头。

一个算子拿到 Tensor 以后,会关心:

text 复制代码
Shape 是否满足要求?
DType 是否支持?
Device 在哪里?
应该调用哪个具体硬件实现?
输出 Tensor 应该是什么结构?
输出应该存在哪里?

二十、最源头的问题:为什么 AI 计算的输入会是 Tensor?

前面一直在研究:

text 复制代码
Tensor 怎么计算?

但是还存在一个更加根本的问题:

最开始的输入 Tensor 到底从哪里来?

这里可以从此前学习数学时非常熟悉的思想切入。

如果要进行数学运算,首先就需要把现实问题:

text 复制代码
转换成数学能够处理的表示

例如,计算机不可能直接拿:

text 复制代码
一张图片
一段自然语言
一段声音

作为数学意义上的对象去完成矩阵乘法。

因此首先需要:

text 复制代码
现实数据
   ↓
数值化 / 编码
   ↓
可以参与数学运算的数字
   ↓
按照一定结构组织
   ↓
Tensor

所以 Tensor 并不是凭空出现的。

它本质上是:

现实世界信息被转换成数值表示以后,在模型内部使用的一种统一数据载体。


二十一、图片为什么能够转换成 Tensor?

图片是一个比较容易理解的例子。

一张图片由大量像素组成,而每个像素本身就可以使用数值表示。

例如 RGB 图像中的某个像素:

text 复制代码
R = 120
G = 35
B = 67

于是:

text 复制代码
图片
 ↓
大量像素
 ↓
每个像素转换成数值
 ↓
按照宽、高、通道等结构组织
 ↓
Tensor

因此,模型真正看到的不是:

text 复制代码
"一辆车"

而是一组组织好的数字。

随后模型再通过一系列计算,将这些原始数字不断转换成更加有利于完成最终任务的中间表示。


二十二、文字又是怎么变成 Tensor 的?

文字相比图片更抽象一些。

例如:

text 复制代码
我喜欢汽车

CPU / GPU 并不会直接理解:

text 复制代码
"我"
"喜欢"
"汽车"

这些自然语言语义。

因此第一步需要先把文字转换成模型能够处理的基本单位。

可以先粗略理解成:

text 复制代码
我喜欢汽车
   ↓
Tokenization
   ↓
Token
   ↓
每个 Token 对应一个编号
   ↓
Token ID

例如仅作示意:

text 复制代码
我    → 1024
喜欢  → 5832
汽车  → 9176

于是:

text 复制代码
"我喜欢汽车"

就可以先转换成:

text 复制代码
[1024, 5832, 9176]

这些整数可以组织成:

text 复制代码
Tensor

shape = [3]
dtype = integer

因此文字到最初输入 Tensor 的过程可以先理解成:

text 复制代码
文字
 ↓
Token
 ↓
Token ID
 ↓
Token ID Tensor

需要注意:

text 复制代码
1024
5832
9176

这些编号本身更像"身份证号"。

编号大小本身并不意味着:

text 复制代码
9176 比 1024 更像汽车

它们主要用于标识不同 Token。

在真正进入模型主体计算之前,这些 Token ID 后面通常还会被继续转换成适合数值计算的向量表示,这会自然引出后续的 Embedding。

这部分可以放到下一阶段继续学习。


二十三、那么模型为什么要不断计算这些 Tensor?

现在就可以重新理解整个推理过程。

最开始得到的 Tensor 只是:

text 复制代码
原始输入的数值表示

而不是最终想要的答案。

例如输入一张图片:

text 复制代码
输入 Tensor
=
大量像素数字

但真正想得到的是:

text 复制代码
这是什么物体?

或者:

text 复制代码
汽车概率是多少?
行人概率是多少?

因此模型需要经过:

text 复制代码
输入 Tensor
   ↓
算子 A
   ↓
中间 Tensor
   ↓
算子 B
   ↓
中间 Tensor
   ↓
算子 C
   ↓
......
   ↓
输出 Tensor

这些计算不断改变数据的表示方式。

最终:

text 复制代码
输出 Tensor

再被解释成人真正需要的结果。

因此整个推理流程可以压缩成:

text 复制代码
现实世界数据
        ↓
数值化 / 编码
        ↓
输入 Tensor
        ↓
计算图
        ↓
一系列 Operator
        ↓
不断产生中间 Tensor
        ↓
输出 Tensor
        ↓
解释结果
        ↓
得到最终业务结果

所以:

Tensor 可以看成 AI 模型内部的一种通用数据语言。

图片、文字、声音进入模型之前,先被转换成 Tensor。

模型内部的算子围绕 Tensor 进行计算。

最终再把输出 Tensor 转换成人或者业务系统能够理解的结果。


二十四、把前面的所有概念第一次串起来

现在终于可以把此前所有零散概念串成一条完整链路。

假设现实世界中产生了一份输入:

text 复制代码
图片 / 文字 / 传感器数据

首先:

text 复制代码
现实数据
   ↓
数值化 / 编码
   ↓
Input Tensor

Tensor 中包含:

text 复制代码
Data
Shape
DType
Device

然后 Tensor 进入模型的:

text 复制代码
Computation Graph
计算图

计算图中:

text 复制代码
节点
→ Operator

边
→ Tensor 数据依赖

Executor 拿到计算图以后:

text 复制代码
判断哪些算子输入已经 ready
        ↓
推动整个计算图运行

当多个算子同时 ready:

text 复制代码
Scheduler
→ 安排执行顺序
→ 决定是否并行
→ 安排对应执行资源

具体某个算子真正执行时:

text 复制代码
Operator
→ 描述"要算什么"

Kernel / 后端实现
→ 描述"在具体硬件上怎么计算"

CPU / GPU
→ 真正执行底层计算

执行完成以后:

text 复制代码
产生新的 Tensor
        ↓
作为后续算子的输入
        ↓
继续计算

直到:

text 复制代码
Final Output Tensor

产生。

整个过程就是:

text 复制代码
现实输入
   ↓
Tensor
   ↓
计算图
   ↓
Operator
   ↓
Kernel
   ↓
CPU / GPU
   ↓
新的 Tensor
   ↓
继续沿计算图传播
   ↓
最终输出

二十五、做边缘计算算子开发,需要把整套 AI 和数学都学完吗?

到这里还会产生一个比较现实的问题:

如果后续要真正开发算子,是不是还得系统学习机器学习、深度学习以及大量数学?

答案不是完全不需要,而是需要明确学习边界。

如果后续工作确实进入:

text 复制代码
MatMul
Softmax
RMSNorm
LayerNorm
RoPE
Attention

这些具体算子,那么仅仅知道:

text 复制代码
输入 Tensor
→ 调用 Kernel
→ 输出 Tensor

是不够的。

因为还需要理解:

text 复制代码
这个算子为什么存在?
它究竟在算什么?
输入输出代表什么?
为什么采用这样的数学形式?

因此需要补一部分:

text 复制代码
线性代数基础
→ 向量
→ 矩阵
→ 矩阵乘法
→ 转置
→ 点积

基础数学函数
→ exp
→ 平方
→ 平方根
→ 均值

Tensor 运算
→ reshape
→ broadcast
→ reduce

少量深度学习前向计算
→ Linear
→ 激活函数
→ Normalization
→ Softmax
→ Attention

但是如果当前目标是:

text 复制代码
C++ / 边缘推理 / 算子开发

并不意味着必须先完整学习:

text 复制代码
机器学习算法大全
反向传播完整推导
梯度下降
SGD / Adam
各种损失函数
训练调参
CNN / RNN 全体系

因为当前更关心的是:

模型怎么执行,而不是模型怎么训练出来。

更加合适的学习方式是:

text 复制代码
遇到一个算子
   ↓
先搞懂它解决什么问题
   ↓
只补它所需要的数学
   ↓
理解输入 Tensor
   ↓
理解计算过程
   ↓
理解输出 Tensor
   ↓
最后进入 C++ / Kernel 实现

这样不会一直停留在推理框架外层,同时也不会为了进入算子开发,一开始就被整个深度学习理论体系淹没。


二十六、现阶段建立的完整心智模型

到这里,可以把目前所有内容压缩成下面几个最关键的认知。

首先:

text 复制代码
边缘计算
→ 解决"在哪里算"

其次:

text 复制代码
Operator
→ 描述"算什么"

然后:

text 复制代码
计算图
→ 描述多个算子之间如何连接、谁依赖谁

接着:

text 复制代码
Executor
→ 根据计算图推动整个计算流程

Scheduler
→ 安排已经具备执行条件的任务怎么运行

再往下:

text 复制代码
Kernel
→ 某个算子面向具体硬件的底层实现

CPU / GPU
→ 真正完成计算的硬件

而贯穿整个计算流程的数据则是:

text 复制代码
Tensor
├── Shape
├── DType
└── Device

最终,整个 AI 推理过程可以理解成:

text 复制代码
现实世界中的数据
        ↓
转换为数值
        ↓
组织成 Tensor
        ↓
进入计算图
        ↓
经过一个个算子
        ↓
算子调用具体 Kernel
        ↓
CPU / GPU 完成实际计算
        ↓
不断产生新的 Tensor
        ↓
最终得到输出 Tensor
        ↓
将结果解释成真正需要的信息

至此,"边缘计算算子模块"这个词就不再是一个完全悬空的概念。

它可以逐步拆成:

text 复制代码
边缘计算
→ 计算应该部署在哪里

算子
→ 节点上具体要执行什么计算

Kernel
→ 这个计算如何针对具体硬件实现

Tensor
→ 这些计算处理和传递的数据

Runtime / Executor / Scheduler
→ 如何把整个计算过程真正运行起来

后续再进入某个具体算子时,就可以在这套心智模型上继续往下钻,而不是重新从一堆陌生名词开始。

相关推荐
桃西西呀1 小时前
Laya 源码级原理拆解之四:路由检测与服务部署
人工智能·llm·ai编程
黄啊码1 小时前
【黄啊码】一个陪伴类的 Agent 产品,凭什么我觉得它做得好?
人工智能
leoZ2311 小时前
第 34 篇 Copilot 与嵌入式 AI:把能力缝进工作流
人工智能·大模型·copilot·agent
桃西西呀1 小时前
Laya 源码级原理拆解之三:字段编译与业务胶水
人工智能·llm·ai编程
欧特克_Glodon1 小时前
OpenCV计算机视觉开发入门与实践(基于C++):专栏内容介绍及目录
c++·人工智能·opencv·计算机视觉
55873 生态系统1 小时前
55873 全域文明生态系统:技术价值矩阵与底层创新内核
大数据·人工智能·55873全域文明生态体系·55873操作系统
江湖有缘1 小时前
3款开源IT工具箱整理合集,可Docker一键部署!
docker·容器·开源
倔强的石头1061 小时前
【Transformer】Encoder_Decoder_vs_Decoder_Only架构对比
人工智能·深度学习·transformer
技灵AI1 小时前
Seedance 长剧生产实战:用首尾帧接戏解决角色崩脸与场景漂移(含 return_last_frame 用法与提示词模板)
人工智能·prompt·aigc·音视频