【Runtime推理引擎①】Runtime架构设计:从Session到Graph Executor的完整骨架

发布时间:2026/11/2 6:26:41
【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 的一生大致经历四个阶段:

  1. 创建(Create):收到推理请求后,从 Session 池中获取一个空闲 Session,绑定模型和输入
  2. 运行(Run):调用 Graph Executor 执行计算图,可能多步迭代(自回归场景)
  3. 完成(Finish):输出结果返回给用户,清理中间状态
  4. 销毁/归还(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 的一生可以分为三个阶段:

  1. 出生:某个节点的输出产生了这个 Tensor
  2. 活跃期:被后续若干个节点作为输入使用
  3. 死亡:最后一个使用它的节点执行完毕,之后再也没人用了

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 性能分析顺序

遇到性能问题,正确的分析顺序应该是:

  1. 先看宏观指标:GPU 利用率、显存占用、QPS、延迟——先搞清楚"慢"是哪种慢
  2. 再定位层级:是业务层慢、调度层慢、还是执行层慢?用二分法缩小范围
  3. 最后钻细节:到了执行层,再去 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 思考问题

  1. 如果让你设计一个支持多模型共享 GPU 的 Runtime,Session 池应该怎么设计?每个模型一个池,还是全局一个池?各有什么优缺点?
  2. 计算图中如果有两个节点没有数据依赖,但它们需要使用同一块 workspace 内存,这时候 Ready Queue 的调度逻辑要怎么改?
  3. 想一想:除了 KV Cache 和 activation,大模型推理中还有哪些类型的 Tensor?它们的生命周期模式是怎样的?

下一篇预告:我们会深入 Runtime 的"内存层",聊聊内存池的设计——从最简单的 Arena Allocator,到工业级的 Size-Class Free List,再到大模型特有的分池策略和显存碎片治理。敬请期待!