【Runtime推理引擎①】Runtime架构设计:从Session到Graph Executor的完整骨架
【Runtime推理引擎①】Runtime架构设计:从Session到Graph Executor的完整骨架
本文是「Runtime推理引擎」系列的第一篇,带你从零理解推理Runtime的核心架构。如果你以为Runtime只是"调kernel的for循环",那这篇文章一定会刷新你的认知。
一、开篇引入:Runtime是什么?为什么它不只是"调kernel的for循环"?
很多刚接触推理引擎的同学,对 Runtime 的第一印象大概是这样的:不就是把模型里的算子按顺序一个个丢给 GPU 去算吗?写个 for 循环不就完了?
如果你也这么想,那我们先来看一个真实的场景:
假设你要部署一个 7B 参数的大语言模型,单卡显存刚好够放下权重。现在有 10 个用户同时请求推理,每个用户的输入长度不同、输出长度也不同。你需要:
- 同时管理 10 路推理的状态(KV Cache 各不相同)
- 在显存紧张的情况下,让每一步计算的中间 Tensor 尽可能复用内存
- 保证高吞吐的同时,单请求延迟不能太高
- 遇到某个请求出错,不能把整台机器搞挂
这时候你还觉得一个 for 循环能搞定吗?
Runtime(推理运行时)是介于模型计算图和底层硬件之间的"调度指挥中心"。它负责管理一次推理请求从进来到出去的全过程:Session 的创建与销毁、计算图的拓扑调度、Tensor 内存的分配与复用、硬件资源的协调与管控……
简单说,kernel 决定了"每个算子算得多快",而 Runtime 决定了"整体推理跑得多顺"。一个优秀的 Runtime 可以在同样的硬件和 kernel 下,把整体吞吐提升 2~3 倍甚至更多。
二、Runtime在推理系统中的位置:五层架构
要理解 Runtime 的定位,我们可以把一套完整的推理系统从顶到底分成五层:
| 层级 | 名称 | 职责 | 典型组件 |
|---|---|---|---|
| 第1层 | 业务层 | 接收用户请求、鉴权、前后处理 | HTTP/gRPC 服务、业务逻辑 |
| 第2层 | 调度层 | 请求排队、批量合并、多机多卡调度 | Batch Scheduler、负载均衡 |
| 第3层 | 执行层 | 计算图执行、算子调度、状态管理 | Runtime、Graph Executor |
| 第4层 | 内存层 | 显存分配、内存池、显存碎片治理 | Memory Pool、Allocator |
| 第5层 | 硬件层 | 实际执行计算的硬件 | GPU、NPU、CPU |
可以看到,Runtime 处于第3层(执行层),承上启下:
- 对上:接收调度层分配的推理请求,封装成 Session
- 对下:调用内存层分配显存,调用硬件层执行 kernel
一个常见的误区是把"调度层"和"执行层"混为一谈。调度层(Scheduler)关心的是"哪些请求凑成一批、分配到哪张卡",而 Runtime 关心的是"一批请求拿到之后,怎么高效地把计算图跑完"。两者各司其职,但配合紧密。
三、Session管理:推理请求的"身份证"
3.1 Session 是什么?
Session 是一次推理请求的运行时上下文。你可以把它理解为一次推理的"身份证+工作档案":它记录了这次推理的模型信息、输入输出、中间状态、内存资源等一切运行时数据。
我们来看一个简化的 Session 结构体定义:
structSession{// ===== 只读共享数据(所有 Session 共享同一份) =====std::shared_ptr<Model>model;// 模型权重(只读)std::shared_ptr<Graph>graph;// 计算图结构(只读)std::shared_ptr<KernelLib>kernel_lib;// kernel 库(只读)// ===== 可变私有数据(每个 Session 独立一份) =====std::vector<Tensor>inputs;// 输入 Tensorstd::vector<Tensor>outputs;// 输出 Tensorstd::vector<Tensor>activations;// 中间激活值std::vector<Tensor>kv_cache;// KV Cache(自回归推理特有)std::unordered_map<std::string,void*>workspace;// 工作空间MemoryPool*mem_pool;// 关联的内存池intstep;// 当前推理步数SessionState state;// 状态:READY/RUNNING/DONE/ERROR};3.2 共享只读数据,隔离可变状态
这里有一个非常重要的设计原则:只读数据全局共享,可变状态按 Session 隔离。
- 共享的部分:模型权重、计算图结构、kernel 代码……这些东西在推理过程中不会变,所有 Session 共享同一份即可。如果每个 Session 都存一份 13GB 的模型权重,那 10 个 Session 就要 130GB,谁也扛不住。
- 隔离的部分:输入输出、中间激活、KV Cache……这些是每个请求独有的,必须各自保存,不能互相干扰。
这个原则看似简单,但在实际工程中经常被忽视。比如有人图省事,把一些本该是 Session 级的状态放到了全局变量里,结果并发一上去就各种诡异 bug,排查起来极其痛苦。
3.3 Session 生命周期
一个 Session 的一生大致经历四个阶段:
- 创建(Create):收到推理请求后,从 Session 池中获取一个空闲 Session,绑定模型和输入
- 运行(Run):调用 Graph Executor 执行计算图,可能多步迭代(自回归场景)
- 完成(Finish):输出结果返回给用户,清理中间状态
- 销毁/归还(Destroy/Recycle):销毁或归还 Session 池
对于大模型的自回归推理,Session 的生命周期可能很长(比如一次对话持续几十轮),因此 Session 管理的效率直接影响系统的并发能力。
3.4 Session 池化
如果每次请求都new Session()然后delete,不仅慢(涉及大量内存分配释放),还容易产生显存碎片。工业界的标准做法是Session 池化:
classSessionPool{public:Session*acquire(){std::lock_guard<std::mutex>lock(mtx_);if(!free_list_.empty()){Session*s=free_list_.front();free_list_.pop_front();s->reset();// 重置状态,但不释放内存returns;}// 池子里没有空闲的,新建一个returncreate_new_session();}voidrelease(Session*s){std::lock_guard<std::mutex>lock(mtx_);free_list_.push_back(s);}private:std::deque<Session*>free_list_;std::mutex mtx_;};池化的核心思想是:Session 对象和它关联的大块内存(如 KV Cache)只分配一次,反复复用。归还时只重置状态标记,不真正释放显存。这样可以把创建 Session 的开销从毫秒级降到微秒级。
四、Graph Executor执行引擎:计算图的"心脏"
如果说 Session 是"身份证",那 Graph Executor 就是"发动机"——它负责真正把计算图跑起来。
4.1 输入结构
Graph Executor 的输入是什么?不是模型文件,也不是权重,而是一张已经编译优化好的计算图(Graph)。这张图包含:
- 一组节点(Node):每个节点对应一个算子(如 MatMul、Add、Silu)
- 一组边(Edge):表示数据依赖关系(A 的输出是 B 的输入)
- 每个节点上挂着编译好的 kernel和执行参数
你可以把计算图想象成一张流程图,节点是工序,边是传送带。Graph Executor 的工作就是:按照正确的顺序,把每个节点的工序执行完。
4.2 拓扑执行与 Ready Queue
计算图是一个有向无环图(DAG),执行顺序必须满足拓扑关系:一个节点只有当它的所有输入都就绪时,才能开始执行。
最经典的实现方式是Ready Queue(就绪队列) + 引用计数:
voidGraphExecutor::execute(Graph&graph,Session*sess){// 1. 初始化:统计每个节点的入度(依赖的输入个数)std::vector<int>in_degree(graph.nodes.size());std::queue<int>ready_queue;for(inti=0;i<graph.nodes.size();i++){in_degree[i]=graph.nodes[i].inputs.size();if(in_degree[i]==0){ready_queue.push(i);// 入度为0的节点可以立即执行}}// 2. 拓扑调度循环intexecuted=0;while(!ready_queue.empty()){intnode_id=ready_queue.front();ready_queue.pop();// 执行这个节点的 kernelgraph.nodes[node_id].kernel->launch(sess);executed++;// 通知后继节点:你们的一个输入准备好了for(intnext_id:graph.nodes[node_id].outputs){in_degree[next_id]--;if(in_degree[next_id]==0){ready_queue.push(next_id);}}}// 3. 检查是否有环(正常情况应该所有节点都执行完)if(executed!=graph.nodes.size()){throwstd::runtime_error("Graph has a cycle!");}}这段代码虽然简化了很多,但核心逻辑就是工业级 Runtime 的骨架。原理不复杂,但细节里全是魔鬼:
- 异步执行:GPU kernel 是异步 launch 的,不能 launch 完就认为节点执行完了,需要用 Event 同步
- 依赖管理:有些依赖不是数据依赖,而是内存依赖(两个节点共用一块 workspace,不能并发)
- 异常处理:某个节点 launch 失败了,怎么安全地中止整个图的执行?
4.3 关键路径分析
对于一张计算图,不是所有节点都同等重要。关键路径(Critical Path)是指从输入到输出的最长执行路径,它决定了整个推理的最短耗时。
关键路径上的任何优化(比如把一个 kernel 从 1ms 优化到 0.5ms)都会直接体现在端到端延迟上。而非关键路径上的优化,效果可能不明显。
怎么找关键路径?对计算图做一次拓扑遍历,同时计算每个节点的最早完成时间:
最早完成时间(节点) = max(所有前驱节点的最早完成时间) + 该节点执行时间最终输出节点的最早完成时间,就是整个图的最短执行时间。沿这个时间反推,就能找出哪些节点在关键路径上。
小白提示:关键路径分析是性能优化的第一步。很多新手上来就盯着最慢的那个 kernel 死磕,结果发现它根本不在关键路径上,优化了半天端到端延迟一点没降。先找关键路径,再动手优化,这是专业工程师的基本素养。
五、Tensor生命周期管理:显存的"垃圾分类"
大模型推理中,显存是最宝贵的资源。而 Tensor 作为显存的"消费者",它们的生命周期管理直接决定了显存利用率的上限。
5.1 什么是 Tensor 生命周期?
一个 Tensor 的一生可以分为三个阶段:
- 出生:某个节点的输出产生了这个 Tensor
- 活跃期:被后续若干个节点作为输入使用
- 死亡:最后一个使用它的节点执行完毕,之后再也没人用了
5.2 生命周期计算
怎么计算每个 Tensor 什么时候"死"?方法很简单——拓扑遍历 + 最后使用位置记录:
// 计算每个 Tensor 的最后使用节点std::unordered_map<int,int>last_used_node;for(intnode_id=0;node_id<graph.nodes.size();node_id++){for(intinput_tensor_id:graph.nodes[node_id].inputs){last_used_node[input_tensor_id]=node_id;// 不断覆盖,最后一次就是最终值}}// 有了 last_used_node,就能知道:执行完 node_id 之后,哪些 Tensor 可以释放了std::unordered_map<int,std::vector<int>>tensors_to_free_after;for(auto&[tensor_id,node_id]:last_used_node){tensors_to_free_after[node_id].push_back(tensor_id);}5.3 内存复用收益
如果我们严格按照生命周期来管理显存——Tensor 死了就把它的显存回收,给新生的 Tensor 用——显存占用能降到多少呢?
我们来做个简单的对比:
| 管理方式 | 峰值显存(假设) | 说明 |
|---|---|---|
| 全部都分配 | 100% | 最朴素的方式,所有 Tensor 都有独立显存 |
| 朴素生命周期管理 | 40%60% | 用完就释放,新的可以复用旧的 |
| 最优内存规划 | 20%30% | 结合内存池和 in-place 优化,理论最低值 |
也就是说,好的内存管理可以让你在同样的显存上跑2~3 倍大的模型,或者并发2~3 倍的请求。这就是为什么内存管理是 Runtime 里最有技术含量的模块之一。
5.4 KV Cache 与 Activation 的区别
在大语言模型推理中,有两类非常重要的 Tensor,它们的生命周期模式完全不同:
| 类型 | 生命周期特点 | 内存复用策略 |
|---|---|---|
| Activation(激活值) | 短命,逐层产生、逐层用完就丢 | 可以高度复用,跨层共享显存池 |
| KV Cache | 长命,从第1步一直活到推理结束 | 不能复用,每个 Session 独立持有 |
这个区别非常关键,它直接决定了后面我们要讲的"分池策略"——把不同生命周期模式的 Tensor 放到不同的内存池里管理,才能达到最优的显存利用率。
六、调度流程与性能瓶颈分析
6.1 完整调度循环
我们把前面讲的内容串起来,看一次完整的推理调度流程:
用户请求 → 调度层接收 → 分配/创建 Session → Graph Executor 开始执行 ↓ 节点从 Ready Queue 取出 → 检查输入是否就绪 → Launch Kernel ↓ Kernel 完成 → 释放已死 Tensor 的显存 → 唤醒后继节点 ↓ 所有节点执行完毕 → 取出输出 → 归还 Session → 返回结果对于自回归(auto-regressive)模型(如 GPT、LLaMA),这个循环会重复很多次——每生成一个 token 就完整跑一遍计算图。
6.2 瓶颈对照表
当推理性能上不去的时候,瓶颈可能出在不同的地方。这里给大家一张排查对照表:
| 现象 | 可能的瓶颈 | 排查方向 |
|---|---|---|
| GPU 利用率低(<50%) | 调度开销太大、CPU 跟不上 | 看 CPU 占用率、profile 调度循环耗时 |
| GPU 利用率高但吞吐低 | kernel 本身慢、计算量大 | profile 单个 kernel 耗时,看关键路径 |
| 显存占用高、并发上不去 | 内存管理差、碎片多 | 看峰值显存、检查内存复用率 |
| 首 token 延迟高 | 调度排队、模型加载慢 | 看请求队列长度、检查 prefill 阶段 |
| 每 token 延迟波动大 | 显存分配不稳定、缺页 | 看每步耗时方差、检查是否有热路径分配 |
6.3 性能分析顺序
遇到性能问题,正确的分析顺序应该是:
- 先看宏观指标:GPU 利用率、显存占用、QPS、延迟——先搞清楚"慢"是哪种慢
- 再定位层级:是业务层慢、调度层慢、还是执行层慢?用二分法缩小范围
- 最后钻细节:到了执行层,再去 profile 具体哪个 kernel 慢、哪个节点是瓶颈
很多新手一上来就开 nsys 抓 kernel profile,抓了半天发现根本不是 kernel 的问题,是上面的调度器每步都在 cudaMalloc,那你优化 kernel 有什么用呢?
七、常见误区与小结
7.1 常见误区
误区一:Runtime 就是调 kernel 的 for 循环
错。真正的 Runtime 要管理 Session、调度计算图、分配回收内存、处理异常、对接调度器……kernel 调用只是其中很小的一部分。
误区二:所有 Tensor 都应该内存复用
错。复用是有成本的(内存池开销、碎片化风险)。像 KV Cache 这种长生命周期的 Tensor,复用价值很低,反而应该单独管理。
误区三:GPU 利用率越高越好
不一定。99% 的 GPU 利用率可能是"瞎忙"——kernel 很小、launch 很多,实际有效计算量很低。要看"有效计算利用率",而不是单纯的 SM 占用率。
误区四:内存分配是小事,晚点再优化
大错特错。内存设计是 Runtime 的骨架性设计,后期改起来牵一发动全身。从第一天起就要把内存架构想清楚。
7.2 小结
今天我们从顶层视角梳理了 Runtime 推理引擎的完整骨架:
- 五层架构中,Runtime 处于执行层,承上启下
- Session是推理请求的运行时上下文,遵循"只读共享、可变隔离"原则,通过池化提升并发效率
- Graph Executor基于拓扑排序和 Ready Queue 驱动计算图执行,关键路径分析是性能优化的第一步
- Tensor 生命周期管理决定了显存利用率的上限,不同类型的 Tensor(activation vs KV Cache)需要不同的管理策略
- 性能瓶颈排查要从宏观到微观,不能上来就钻 kernel 细节
7.3 思考问题
- 如果让你设计一个支持多模型共享 GPU 的 Runtime,Session 池应该怎么设计?每个模型一个池,还是全局一个池?各有什么优缺点?
- 计算图中如果有两个节点没有数据依赖,但它们需要使用同一块 workspace 内存,这时候 Ready Queue 的调度逻辑要怎么改?
- 想一想:除了 KV Cache 和 activation,大模型推理中还有哪些类型的 Tensor?它们的生命周期模式是怎样的?
下一篇预告:我们会深入 Runtime 的"内存层",聊聊内存池的设计——从最简单的 Arena Allocator,到工业级的 Size-Class Free List,再到大模型特有的分池策略和显存碎片治理。敬请期待!