ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

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

【边缘计算入门系列】从“在哪里算”到“怎么算”:一文建立边缘计算、算子、计算图与 Tensor 的底层心智模型 本文专栏边缘计算作者主页努力努力再努力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*pnewint[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 维 Tensor5就是一个标量。1 维 Tensor[1, 2, 3]可以理解为一个向量。2 维 Tensor[ [1, 2, 3], [4, 5, 6] ]可以理解为矩阵。3 维及更高维 TensorA[i][j][k] A[i][j][k][l] ...可以理解为不断增加索引维度的多维数组。因此0 维 → 标量 1 维 → 向量 2 维 → 矩阵 3 维及以上 → 更高维数据这里不建议严格说Tensor 本质就是向量更准确的是Tensor 是对标量、向量、矩阵以及更高维数组的一种统一抽象。十六、ShapeTensor 到底“长什么样”仅仅知道内存里存在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 不只是描述信息它会直接影响算子能不能计算以及输出应该长什么样。十七、DTypeTensor 里面的每一个元素是什么类型有了 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 ↓ 推动整个计算图运行当多个算子同时 readyScheduler → 安排执行顺序 → 决定是否并行 → 安排对应执行资源具体某个算子真正执行时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 → 如何把整个计算过程真正运行起来后续再进入某个具体算子时就可以在这套心智模型上继续往下钻而不是重新从一堆陌生名词开始。
返回列表