【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_ptrModelmodel;// 模型权重只读std::shared_ptrGraphgraph;// 计算图结构只读std::shared_ptrKernelLibkernel_lib;// kernel 库只读// 可变私有数据每个 Session 独立一份 std::vectorTensorinputs;// 输入 Tensorstd::vectorTensoroutputs;// 输出 Tensorstd::vectorTensoractivations;// 中间激活值std::vectorTensorkv_cache;// KV Cache自回归推理特有std::unordered_mapstd::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_guardstd::mutexlock(mtx_);if(!free_list_.empty()){Session*sfree_list_.front();free_list_.pop_front();s-reset();// 重置状态但不释放内存returns;}// 池子里没有空闲的新建一个returncreate_new_session();}voidrelease(Session*s){std::lock_guardstd::mutexlock(mtx_);free_list_.push_back(s);}private:std::dequeSession*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(Graphgraph,Session*sess){// 1. 初始化统计每个节点的入度依赖的输入个数std::vectorintin_degree(graph.nodes.size());std::queueintready_queue;for(inti0;igraph.nodes.size();i){in_degree[i]graph.nodes[i].inputs.size();if(in_degree[i]0){ready_queue.push(i);// 入度为0的节点可以立即执行}}// 2. 拓扑调度循环intexecuted0;while(!ready_queue.empty()){intnode_idready_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_mapint,intlast_used_node;for(intnode_id0;node_idgraph.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_mapint,std::vectorinttensors_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 生命周期管理决定了显存利用率的上限不同类型的 Tensoractivation vs KV Cache需要不同的管理策略性能瓶颈排查要从宏观到微观不能上来就钻 kernel 细节7.3 思考问题如果让你设计一个支持多模型共享 GPU 的 RuntimeSession 池应该怎么设计每个模型一个池还是全局一个池各有什么优缺点计算图中如果有两个节点没有数据依赖但它们需要使用同一块 workspace 内存这时候 Ready Queue 的调度逻辑要怎么改想一想除了 KV Cache 和 activation大模型推理中还有哪些类型的 Tensor它们的生命周期模式是怎样的下一篇预告我们会深入 Runtime 的内存层聊聊内存池的设计——从最简单的 Arena Allocator到工业级的 Size-Class Free List再到大模型特有的分池策略和显存碎片治理。敬请期待