【Runtime推理引擎⑥】Runtime特性开发实战从IO契约到多租户隔离的正确顺序本文是「Runtime推理引擎」系列的第六篇。做Runtime特性开发最容易犯的错误就是想到啥做啥——听说CUDA Graph快就先接CUDA Graph听说内存池重要就先写内存池。但实际上Runtime特性开发是有严格顺序的先契约正确再资源可控再性能优化再多租户隔离最后稳定性兜底。顺序错了就是在沙滩上盖楼——看起来快一推就倒。一、开篇Runtime特性开发不是堆功能而是按顺序建立信任先讲一个真实的翻车故事。某团队做推理Runtime一上来就奔着性能去先接CUDA Graph说能提速30%再接内存池说能减少malloc开销。上线后发现有时候推理结果不对偶发出现极难复现有时候突然OOM查半天发现是不同模型的显存串了CUDA Graph有时候不命中fallback到eager反而更慢线上出了问题连输入shape对不对都不知道为什么会这样因为他们跳过了最基础的一步——IO契约。没有IO契约你连输入是什么样的都说不清楚shape错了、dtype错了、layout错了都发现不了。在错误的基础上堆CUDA Graph、堆内存池只会让问题越来越隐蔽、越来越难排查。Runtime特性开发的核心原则先建立信任再追求性能。信任的顺序是契约正确 → 资源可控 → 性能优化 → 多租户隔离 → 稳定性兜底。二、Auto IO Info先把输入输出契约定清楚2.1 为什么IO Info是第一优先级Runtime的很多线上问题不是kernel不够快而是输入输出契约错了Shape推导错把[B, S, H]当成[B, H, S]结果全错Layout错kernel要NHWC传进来是NCHW产生隐式transpose或者直接算错Dtype错FP16和BF16混用数值偏差偶发出现查起来要人命Alignment错向量化kernel要求128B对齐输入没满足性能暴跌Device错某个中间tensor在host上下游kernel期望在device上这些问题有一个共同特点——都很低级但排查起来极贵。因为它们藏在层层调用之下等你发现结果不对时已经不知道是哪一步出的问题。Auto IO Info的目标就是把这些契约显式化、可校验、可缓存。2.2 TensorInfo / IOInfo 结构一个完整的Tensor描述应该长这样structTensorInfo{std::string name;// tensor名称报错用std::vectorint64_tshape;// 形状动态维度用符号表示DataType dtype;// 数据类型FP32/FP16/BF16/INT8...DeviceType device;// 设备CPU/CUDA/NPU...Layout layout;// 布局NCHW/NHWC/...size_t alignment;// 对齐要求64B/128B/256B...};structIOInfo{std::vectorTensorInfoinputs;std::vectorTensorInfooutputs;};不要觉得这东西太简单了没必要。等你线上出了问题发现连输入应该是什么shape都要翻半天代码的时候就知道它的价值了。2.3 Auto IO Info应该做什么IO Info不是一个静态的结构体它是一整套机制要做五件事注册RegisterGraph加载时声明所有inputs/outputs的shape/dtype/layout/device/alignment推导Infer对动态shape用symbolic dim表达和推导比如batch、seq_len校验ValidateSession绑定输入时检查是否满足Graph的IO契约不满足直接报错缓存Cache把已推导的shape缓存起来key必须包含所有影响结果的维度值报错Error给出清晰的错误信息——哪个Graph、哪个Tensor、期望什么、实际什么defvalidate_input(tensor_info:TensorInfo,actual_tensor)-bool:# 检查dtypeifactual_tensor.dtype!tensor_info.dtype:raiseValueError(fInput {tensor_info.name} dtype mismatch: fexpected{tensor_info.dtype}, got{actual_tensor.dtype})# 检查shape支持动态维度fori,(expected,actual)inenumerate(zip(tensor_info.shape,actual_tensor.shape)):ifexpected!-1andexpected!actual:# -1表示动态维度raiseValueError(fInput {tensor_info.name} shape mismatch at dim{i}: fexpected{expected}, got{actual})# 检查deviceifactual_tensor.device!tensor_info.device:raiseValueError(fInput {tensor_info.name} on wrong device)returnTrue2.4 常见的坑Shape Cache Key不完整少了dtype或layout导致错误复用——缓存命中率高了但结果错了动态shape依赖host输入值但Graph已经在device上capture了runtime时没法改Alignment只检查首地址没检查stride导致某些kernel silently failLayout转换被默默插入性能掉了几个点没人发现还以为是业务波动三、CUDA Graph在契约和资源稳定后再接3.1 接入顺序不能乱CUDA Graph的接入顺序一定是auto IO info → memory pool → shape bucket → capture/replay → eager fallback为什么因为CUDA Graph的核心要求是——图内的shape和内存地址必须固定。没有IO Info → 你不知道shape对不对可能capture了一个错误的图没有Memory Pool → 你没法保证每次运行用的是同一块地址capture了也白搭没有Shape Bucket → 每个shape都要capture一次显存直接爆3.2 CudaGraphManager接口设计一个生产级的CUDA Graph管理器接口大概长这样structCudaGraphKey{std::string graph_name;// 哪个模型/子图IOInfo io_signature;// IO签名shape/dtype/layoutintbatch_bucket;// batch分桶intseq_bucket;// seq_len分桶DataType dtype;// 数据类型std::string quant_config;// 量化配置intallocator_id;// 内存池ID};classCudaGraphManager{public:// 获取已capture的图没有就capture一个CudaGraphExecGetOrCapture(constCudaGraphKeykey,GraphBuilder builder);// 绑定输入输出地址voidBindInput(CudaGraphExec exec,conststd::stringname,void*ptr);voidBindOutput(CudaGraphExec exec,conststd::stringname,void*ptr);// 重放voidReplay(CudaGraphExec exec,cudaStream_t stream);// 能否重放检查key是否匹配boolCanReplay(constCudaGraphKeykey)const;};3.3 Fallback是一等公民很多人觉得fallback是兜底的、不正常的。错了。在在线服务里fallback才是常态。shape不在bucket里 → fallback内存池不足 → fallbackcapture失败 → fallbackIO校验失败 → fallback你要做的不是消灭fallback而是监控fallbackfallback_rate fallback_requests / total_requestsfallback_rate太高→ 说明bucket设计不合理或者内存池太小fallback_rate为0→ 也要警惕是不是把不该进graph的请求硬塞进去了比如shape差了一点你用padding硬凑进去结果反而更慢经验法则fallback_rate控制在5%~15%比较健康。太低说明你可能over-fit了当前流量一改流量就崩太高说明CUDA Graph没发挥作用。四、内存池优化不是独立Feature而是基础设施很多人把内存池当成一个性能优化feature——先做功能有空了再优化内存池。大错特错。内存池是基础设施不是可选优化。在Relay/Relax/自研Runtime中内存池至少服务四个对象服务对象作用正确性防止use-after-free、错误复用性能热路径无cudaMalloc低抖动CUDA Graph保证地址固定capture才能生效groupContext按group做配额与隔离4.1 MemoryPool接口设计enumclassPoolKind{kWeight,// 权重池kKV,// KV Cache池kActivation,// 激活值池kWorkspace,// 工作空间池kIO,// 输入输出池kPrefixCache// Prefix Cache池};structAllocRequest{size_t bytes;// 申请多少字节size_t alignment;// 对齐要求PoolKind pool;// 哪个池子Lifetime lifetime;// 生命周期op/graph/session/globalSessionId session;// 属于哪个sessionGroupId group;// 属于哪个group};classMemoryPool{public:void*Alloc(constAllocRequestreq);voidFree(void*ptr,constAllocRequestreq);voidReset(Scope scope,SessionId session);// 按作用域重置PoolStatsStats(PoolKind pool,GroupId group)const;};4.2 关键指标内存池好不好看这些指标pool_hit_rate命中率越高越好但也要注意碎片peak_usage峰值占用用来评估容量fragmentation碎片化率太高说明分配策略有问题failed_alloc分配失败次数直接影响可用性OOM_fallbackOOM后fallback的次数leak_detected检测到的泄漏数必须有五、groupContext解决多业务共线问题5.1 为什么需要groupContext当多个业务、多个模型、多个优先级共用一套Runtime时只按Session管理是远远不够的。你需要按group来管理资源Group A在线主链路 → 高优先级 → 保证quota → 不可抢占 Group B内部工具 → 中优先级 → 有限quota → 可降级 Group C离线批量 → 低优先级 → 无guarantee → 可被抢占groupContext要提供的能力包括资源配额group级别的memory quota、max_num_seqs、token budget调度优先级group级别的调度优先级与抢占规则模型隔离group级别的IO registry与模型版本管理可观测性group级别的metrics与回收审计5.2 Group隔离原则高优先级group的deadline优先保证但不能无限抢占导致低优先级永远饥饿低优先级group可以用剩余资源但必须能被降级、暂停或拒绝实验group默认完全隔离不共享prefix cache、不共享KV blocks、不影响主链路指标group删除必须审计还有session/kernel/event/graph exec/显存未释放时不允许销毁5.3 Group Quota的实现难点Quota说起来简单做起来全是坑硬限制还是软限制硬限制安全但利用率低软限制利用率高但容易超被抢占的group怎么处理swap到CPUrecompute还是直接失败quota按什么算显存并发数token/s还是三者都限制指标怎么隔离高优先级group的流量波动不能污染低优先级group的基线这些问题没有标准答案要看业务场景。但有一条原则是共通的先做硬限制再做软限制先保证隔离再谈利用率。六、Runtime稳定性保障不是加个health check就行稳定性不是最后补一个health check endpoint就完事了。它要贯穿整个设计过程。6.1 Watchdog与Cancel每个Session要有deadline。Watchdog发现超时后要触发Cancel。但Cancel不是说停就停那么简单你得回答这些问题Scheduler是否停止给该Session发射新的work当前正在跑的kernel怎么处理等它跑完强行抢占还是标记丢弃Stream、event、workspace、KV blocks怎么回收会不会泄漏客户端能不能收到明确的错误码还是默默挂掉classWatchdog:defcheck_and_cancel(self,session_id,current_time):sessionself.sessions[session_id]ifcurrent_time-session.start_timesession.deadline:# 1. 停止调度新的workself.scheduler.cancel_session(session_id)# 2. 标记当前kernel结果无效如果没法抢的话session.mark_aborted()# 3. 异步回收资源self.resource_manager.recycle_async(session_id)# 4. 返回错误给客户端returnTimeoutError(fSession{session_id}timed out)6.2 异常注入没有验证的fallback都是PPT fallback上线前必须主动注入异常验证你的fallback路径真的能用Kernel hang / kernel errorcudaMalloc failed / OOMShape mismatch / dtype mismatchEvent lost / stream deadlockGroup quota exceededCUDA Graph capture failed为什么要这么做因为没有经过异常注入验证的fallback大概率只是PPT上的fallback——线上真出问题时它不一定能扛住。6.3 回收审计与泄漏检测Session/group结束时必须做回收审计stream 是否空闲/销毁 event 是否销毁 workspace 是否Reset KV blocks 是否归还 graph exec 引用计数是否为0 显存是否回到基线长时间的soak test浸泡测试要看显存是否缓慢上涨泄漏Fragmentation是否持续上升内存池策略有问题Fallback_rate是否漂移流量变了bucket没跟上P99是否随时间变差资源泄漏或碎片累积七、特性开发顺序总结与小结顺序总结把上面的内容串起来Runtime特性开发的正确顺序是第1步Auto IO Info → 建立契约先保证说得清、查得到、错得了 第2步Memory Pool → 资源可控为后续优化打基础 第3步CUDA Graph → 性能优化但要有fallback兜底 第4步多流并发 → 进一步榨干GPU 第5步Dynamic Batch → 提升吞吐和并发 第6步groupContext → 多租户隔离支持多业务共线 第7步稳定性保障 → watchdog、异常注入、回收审计、soak test为什么是这个顺序因为每一步都依赖前一步的基础没有IO Info → CUDA Graph不知道shape对不对没有Memory Pool → CUDA Graph地址不固定capture了白搭没有前面这些 → groupContext的quota都没法算没有全部这些 → 稳定性保障就是空中楼阁小结Runtime特性开发不是堆功能而是按顺序建立信任先契约正确再资源可控再性能优化再多租户隔离最后稳定性兜底Auto IO Info是第一优先级shape/dtype/layout/device/alignment必须显式化、可校验、可缓存——它是所有上层特性的信任基础CUDA Graph要在IO Info和Memory Pool之后接fallback是一等公民fallback_rate必须监控内存池是基础设施不是可选优化它服务于正确性、性能、CUDA Graph和groupContext四个对象groupContext是多业务共线的资源合同quota、优先级、隔离、回收审计一个都不能少稳定性要贯穿始终watchdog/cancel、异常注入、回收审计、soak test不是最后补一个health check就行下一篇预告我们进入LLM推理与Serving系列从最基础的LLM推理原理讲起——Prefill和Decode到底有什么不同为什么KV Cache是LLM推理的核心思考问题Auto IO Info的shape cache key应该包含哪些字段如果少了一个字段比如漏掉了dtype会发生什么为什么说CUDA Graph的fallback_rate为0也可能是危险信号什么情况下fallback_rate0是不正常的groupContext中高优先级group是否应该能抢占低优先级group的KV blocks如何避免抢占导致的p99抖动如果让你设计一个Runtime soak test你会怎么设计请求分布、异常注入、指标断言分别是什么如果有人说我这个feature能让Runtime性能提升40%作为评审人你会要求他提供哪些证据你最担心什么本系列导航第1篇Runtime架构设计从Session到Executor的全链路第2篇内存池设计碎片化、分配策略与多池隔离第3篇CUDA Graphcapture/replay原理与接入实战第4篇多流并发执行计算与通信的流水重叠第5篇动态Batch Scheduler吞吐、延迟与公平的三角平衡第6篇Runtime特性开发实战从IO契约到多租户隔离本文更多内容持续更新中……