本节摘要:张量是 Runtime 里的一张「内存身份证」——形状、元素类型、步长、设备上下文四项缺一不可。本节讲清张量的构成、零拷贝成立的条件、编译期内存规划与请求池的关系,并把预处理搬进模型省掉一次内存往返。
3.1 节的骨架代码跑通了,但热路径上最贵的往往不是 infer 本身,而是数据在内存里的几次搬运。这一节沿着「一帧图像从摄像头到输出张量」的路径走一遍,看每一步内存发生在哪、哪些可以省。把内存账算明白,性能优化就完成了一半——另一半是第 7 章的实测。
Runtime 里的张量绑定四项属性:逻辑形状说明数据怎么组织;元素类型精确到比特——无符号八位整型与三十二位浮点占的内存差四倍;步长描述逻辑维度到物理内存的映射,两个形状相同的张量可以因为步长不同而完全不同;设备上下文记录这块内存归谁管——系统内存还是显卡缓冲。
四项里有三项不一致会直接报错,唯独步长不匹配常常不报错,而是触发一次隐式重排。这就是很多「莫名其妙慢」的源头:你以为传的是零拷贝引用,实际每次都发生了整帧拷贝。验证方法是把写好的输入张量与模型声明的布局逐项对一遍,别信「应该没问题」。
零拷贝的含义是:你的数据在物理内存里不动,运行时直接引用。它成立需要同时满足三个条件——设备支持共享内存(核显与 CPU 共享系统内存,天然亲近);元素类型与形状完全一致;内存布局对齐模型声明。三者缺一,运行时退化为拷贝路径。
一个真实案例:视频分析服务把解码输出直接做成输入张量,核显上推理,全程零拷贝,单帧处理时间省下约三成;同一个代码搬到独立显卡上,独显显存与系统内存不共享,每次提交都要跨总线搬运,省下的三成又赔回去一大半。教训是零拷贝不是代码风格,是设备与内存拓扑的属性——换设备就要重新算这笔账。
状态图里的每个箭头都是一次内存写。优化的方向从来只有两个:减少箭头数量(预处理进模型、布局对齐),或者缩短箭头距离(共享内存设备就近推理)。编译期的内存规划已经把网络内部张量复用做到位——同一时刻不需要共存的中间结果共享缓冲——所以应用侧能动的只剩下模型外圈这几步。
把缩放、归一化这些预处理操作编进 IR,是省内存往返的常规手段。转换期或加载期把预处理操作附加到模型输入上,运行时收到原始图像后先在设备侧完成预处理再进主网络——省掉一次「CPU 预处理完再传设备」的往返,也省掉一份中间缓冲。对预处理较重的模型(大分辨率输入、复杂归一化),这一步的收益相当可观。
代价是预处理逻辑从应用代码移进了模型定义,调试时多一层间接。我们的取舍标准:预处理逻辑稳定不变就搬进模型,还在频繁调整的实验期先留在应用侧。还有一个折中技巧值得知道——输入张量直接复用上一帧的缓冲,只要形状与布局不变,覆盖写入即可,连分配器的开销都省了。请求池场景下每个请求各配一份输入输出张量,生命周期与请求一致,这是 3.3 节吞吐调优的前置知识。
把内存账落成代码姿势。姿势一,直接喂 numpy:形状、类型与模型声明完全一致时,最简写法就是同步推理直接传数组——运行时内部做完布局检查后走最优路径,原型期首选。姿势二,显式构造 Tensor:需要复用缓冲、或数据来源是非连续内存时,显式构造并把布局声明写全,多写的三行换来的是「布局明确、无隐式转换」的确定性。姿势三,请求级预分配:服务化场景给每个请求配好输入输出张量,循环里只做数据写入,连分配动作都从热路径消失。三个姿势对应三个阶段:原型、联调、上线——按阶段升级姿势,别在原型期就背上姿势三的工程债。
输出侧还有一个常被忽略的点:输出张量的读取时机。异步推理里,输出张量的内存在请求复用前都有效,但「下一次 start_async」会覆盖它。消费侧的正确姿势是处理完再提交下一帧,或明确拷贝后再提交——「提交了才想起还没读」是异步流水线的经典数据错乱来源,症状是结果偶发错位,极难复现。
流水线服务的内存健康需要一双眼睛。最朴素的观察是周期性打印进程内存与设备内存占用,画成随时间的曲线:平稳有界的曲线是健康态,缓慢爬升的曲线意味着某处对象没释放(常见嫌疑:会话缓存、无限增长的队列、每次请求新建的请求对象)。队列水位同样要监控——帧队列常满说明下游慢,常空说明上游慢,水位波动率本身就是流水线健康的脉搏。这套观察不用引入重型监控系统,一个定时打印加日志平台就够,第 6 章的流水线实战会把它用起来。
💡 一个容易混淆的概念澄清:模型编译期的「内存规划」与应用期的「内存管理」是两层。编译期规划管网络内部张量的复用与峰值压缩,应用层管输入输出与队列的分配复用。性能问题先分清层次——「模型吃内存」查编译期(裁剪、量化),「服务吃内存」查应用层(池化、回收)。层次混着查,时间就浪费了。
内存话题收尾,回答三个高频追问。追问一:模型加载后进程内存比模型文件大不少,正常吗?正常——文件是压缩落盘形态,加载后有权重展开、对齐填充与编译期中间产物,实测常驻内存按文件体积的一到三倍预估;内存预算紧的设备用这个倍数做容量规划,而不是拿文件大小骗自己。追问二:批处理维度怎么影响内存?批大小翻倍,激活内存近似翻倍而权重不变——吞吐模式的内部多流本质是批的变体,这就是吞吐提示下内存占用更高的原因;内存受限设备上吞吐模式要实测内存峰值再启用。追问三:输入张量能直接用设备采集的缓冲吗?共享内存设备(核显)上可以走远程上下文接口把采集缓冲直接挂成输入张量,实现采集到推理的全链路零拷贝;独立显存设备则需要跨设备缓冲机制,复杂度上一个台阶——先用 3.2 节的三条件判断值不值,多数场景「一次显式拷贝换简单架构」是划算的。三个追问的合计结论:内存的账要按「加载倍数、批峰值、拷贝路径」三笔分开算,混在一起算永远算不清。
输入侧讲得多,输出侧也有一笔账。分类模型的输出是几百个浮点数,内存可忽略;检测与分割模型的原始输出常常是「网格形状」的未解码张量——尺寸远大于最终结果,直接把它拷回应用侧再解码,搬运的是九成会用完即弃的中间量。更优的姿势是把解码类后处理搬进模型(与预处理进模型对称),让设备侧直接产出「框加类别」的紧凑结果,跨边界传输的数据量骤减一两个数量级。输出张量的数据类型同样值得检查:概率输出用半精度足够时,别让全精度默认值悄悄翻倍你的搬运量。输入与输出两侧各抠一次,热路径的内存账就基本做到了干净。
内存账算清了,最后一个问题浮出水面:这些请求到底怎么排班——同步还是异步、单流还是多流、单设备还是多设备。3.3 节讲调度策略,这是第 3 章的落点。