1. "内存"拖停
光通信行业的 FAU(Fiber Array Unit,光纤阵列单元)与光纤线序检测,是典型的高分辨率 + 高帧率 + 7×24 连续运行场景。
- 相机侧:通常使用 2000 万像素级面阵相机,或 8K/16K 线阵相机;
- 节拍侧:一秒拍几张到几十张,单帧原始数据 20MB~120MB 不等;
- 算法侧:需要做定位、端面检测、光纤排序(线序)识别,往往还要接一个推理引擎做缺陷分类。
算一笔账:2000 万像素 × 3 通道 × 8bit ≈ 60MB/帧,按 5 fps 计算,每秒就有 300MB 图像在内存里进出;线阵相机连续扫描时,瞬时带宽更高。
如果代码写得不严谨——cv::Mat 随手拷贝、new 出来的缓冲区没人 delete、回调里做深拷贝、推理前再 memcpy 一次——那么内存占用会随着运行时间单调上升。跑几个小时,进程 RSS 涨到几十 GB,最终 OOM 被内核 kill,产线停机。
这类问题的可怕之处在于:它不是"逻辑错误",测试环境跑十分钟完全正常,只有上了产线连续跑 8 小时才暴露。所以正确的做法不是"事后查泄漏",而是在设计阶段就把内存的生命周期管死。
本文讨论三个层层递进的设计点:
2.问题定位
在动手设计之前,先把一条典型流水线上的拷贝点列清楚。很多"内存泄漏"其实不是泄漏,而是拷贝太多 + 释放太晚。
#mermaid-svg-T96IbHiUFm8k2g4Q{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-T96IbHiUFm8k2g4Q .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-T96IbHiUFm8k2g4Q .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-T96IbHiUFm8k2g4Q .error-icon{fill:#552222;}#mermaid-svg-T96IbHiUFm8k2g4Q .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-T96IbHiUFm8k2g4Q .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-T96IbHiUFm8k2g4Q .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-T96IbHiUFm8k2g4Q .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-T96IbHiUFm8k2g4Q .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-T96IbHiUFm8k2g4Q .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-T96IbHiUFm8k2g4Q .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-T96IbHiUFm8k2g4Q .marker{fill:#333333;stroke:#333333;}#mermaid-svg-T96IbHiUFm8k2g4Q .marker.cross{stroke:#333333;}#mermaid-svg-T96IbHiUFm8k2g4Q svg{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-T96IbHiUFm8k2g4Q p{margin:0;}#mermaid-svg-T96IbHiUFm8k2g4Q .label{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;color:#333;}#mermaid-svg-T96IbHiUFm8k2g4Q .cluster-label text{fill:#333;}#mermaid-svg-T96IbHiUFm8k2g4Q .cluster-label span{color:#333;}#mermaid-svg-T96IbHiUFm8k2g4Q .cluster-label span p{background-color:transparent;}#mermaid-svg-T96IbHiUFm8k2g4Q .label text,#mermaid-svg-T96IbHiUFm8k2g4Q span{fill:#333;color:#333;}#mermaid-svg-T96IbHiUFm8k2g4Q .node rect,#mermaid-svg-T96IbHiUFm8k2g4Q .node circle,#mermaid-svg-T96IbHiUFm8k2g4Q .node ellipse,#mermaid-svg-T96IbHiUFm8k2g4Q .node polygon,#mermaid-svg-T96IbHiUFm8k2g4Q .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-T96IbHiUFm8k2g4Q .rough-node .label text,#mermaid-svg-T96IbHiUFm8k2g4Q .node .label text,#mermaid-svg-T96IbHiUFm8k2g4Q .image-shape .label,#mermaid-svg-T96IbHiUFm8k2g4Q .icon-shape .label{text-anchor:middle;}#mermaid-svg-T96IbHiUFm8k2g4Q .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-T96IbHiUFm8k2g4Q .rough-node .label,#mermaid-svg-T96IbHiUFm8k2g4Q .node .label,#mermaid-svg-T96IbHiUFm8k2g4Q .image-shape .label,#mermaid-svg-T96IbHiUFm8k2g4Q .icon-shape .label{text-align:center;}#mermaid-svg-T96IbHiUFm8k2g4Q .node.clickable{cursor:pointer;}#mermaid-svg-T96IbHiUFm8k2g4Q .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-T96IbHiUFm8k2g4Q .arrowheadPath{fill:#333333;}#mermaid-svg-T96IbHiUFm8k2g4Q .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-T96IbHiUFm8k2g4Q .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-T96IbHiUFm8k2g4Q .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-T96IbHiUFm8k2g4Q .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-T96IbHiUFm8k2g4Q .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-T96IbHiUFm8k2g4Q .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-T96IbHiUFm8k2g4Q .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-T96IbHiUFm8k2g4Q .cluster text{fill:#333;}#mermaid-svg-T96IbHiUFm8k2g4Q .cluster span{color:#333;}#mermaid-svg-T96IbHiUFm8k2g4Q div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-T96IbHiUFm8k2g4Q .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-T96IbHiUFm8k2g4Q rect.text{fill:none;stroke-width:0;}#mermaid-svg-T96IbHiUFm8k2g4Q .icon-shape,#mermaid-svg-T96IbHiUFm8k2g4Q .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-T96IbHiUFm8k2g4Q .icon-shape p,#mermaid-svg-T96IbHiUFm8k2g4Q .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-T96IbHiUFm8k2g4Q .icon-shape .label rect,#mermaid-svg-T96IbHiUFm8k2g4Q .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-T96IbHiUFm8k2g4Q .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-T96IbHiUFm8k2g4Q .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-T96IbHiUFm8k2g4Q :root{–mermaid-font-family:\”trebuchet ms\”,verdana,arial,sans-serif;}
① SDK 内部缓冲
② 深拷贝到 cv::Mat
③ 队列出队再拷贝
④ 转 RGB / 归一化
相机 SDK 回调
用户回调 Buffer
业务队列
预处理/缩放
推理引擎输入
结果后处理
上图中 ②③④ 每一处都是一次全帧拷贝。60MB 的帧,拷 3 次就是 180MB 的瞬时内存流量;如果队列里积压 20 帧,就是 1.2GB 常驻。
设计目标:把 ②③④ 全部消除,让同一块物理内存在整条链路上"只被指针引用,不被复制"。
3. 内存池设计
3.1 核心思路
工业视觉的图像尺寸是固定的(相机分辨率不变),这给了我们极大的便利:可以按"帧大小"预分配 N 个等长块,运行期只做借出/归还。
具体做法是定义一个「帧池」对象,它在构造时接收两个参数:单帧字节数与池容量。构造阶段一次性把容量个等长缓冲区全部分配好,同时维护一个空闲槽位索引队列,初始时把所有槽位索引依次压入。
对外只暴露三个动作:
- 借出:加锁后从空闲队列头部取一个索引返回;若队列为空则返回 -1,表示池已耗尽,这是一个明确的背压信号;
- 归还:加锁后把索引重新压回空闲队列尾部;
- 取地址:根据索引返回该槽位缓冲区的首地址,以及单帧的字节长度。
内部成员包括:单帧字节数、池容量、一个二维字节容器(外层是槽位、内层是帧数据,启动时一次性分配完毕)、一个空闲索引双端队列,以及一把互斥锁保护借还操作。
要点:
- storage_ 在构造时一次性 resize,运行期不再有任何堆分配,从根本上杜绝碎片与泄漏;
- 用索引而不是裸指针作为句柄,避免悬垂指针;
- acquire() 返回 -1 是背压信号,而不是抛异常——产线上宁可丢帧,也不能让内存无限增长。
3.2 用 RAII 管住"归还"
裸调 acquire/release 迟早会漏。用 shared_ptr 的自定义删除器把归还动作绑定到生命周期上:
思路是定义一个「帧删除器」结构体,内部持有帧池指针与槽位索引,重载调用运算符,在被调用时执行「把该索引归还给帧池」。再基于它声明一个帧智能指针类型,即带自定义删除器的共享指针。
随后封装一个「申请帧」函数:先向池借出索引,若返回 -1 则直接返回空指针,由调用方决定丢帧还是等待;否则用池中该槽位的地址加上删除器构造一个共享指针返回。
这样,只要最后一个 FramePtr 析构,槽位自动回到池里。无论中间经过多少个队列、多少个线程,都不需要手工 release。
注意:shared_ptr 的引用计数是原子操作,跨线程安全;但高频 acquire/release 时会有缓存行竞争,后续可用 per-thread 缓存或 std::atomic 无锁栈优化。
4. 环形缓冲区:图像的生灭管理
4.1 为什么是环形
流水线是"生产者(相机回调)—消费者(算法线程)"模型。环形缓冲区天然适合:
- 槽位数量固定,内存占用有上界;
- 生产/消费用两个原子游标推进,无需加锁;
- 满了就覆盖最旧帧或丢帧,永远不会 OOM。
实现上是一个模板类,模板参数为元素类型与槽位数量(编译期常量)。内部有一个定长数组作为存储,以及两个原子整型游标:写游标(head)与读游标(tail)。
- 入队:先以宽松内存序读取写游标,算出下一个位置(对槽位数取模);若下一个位置等于读游标(以获取内存序读取),说明队列已满,直接返回失败,由调用方决定丢帧还是覆盖最旧数据;否则把元素移动写入当前写游标位置,再以释放内存序更新写游标,返回成功。
- 出队:先以宽松内存序读取读游标,若它等于写游标(以获取内存序读取),说明队列为空,返回失败;否则把该位置元素移动赋给输出参数,再以释放内存序推进读游标,返回成功。
由于读写游标分属两个线程各自推进,且通过获取/释放语义建立先后关系,整个过程无需加锁。
4.2 与内存池配合:槽位里放的是 FramePtr
关键设计:环形缓冲区里存的不是图像数据,而是 FramePtr(智能指针)。
声明时只需指定元素类型为帧智能指针、槽位数为 32,即得到一个「32 个槽位、只存指针」的帧队列。
这样带来三个好处:
#mermaid-svg-ZmPkT9M4Okd8wUYX{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-ZmPkT9M4Okd8wUYX .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-ZmPkT9M4Okd8wUYX .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-ZmPkT9M4Okd8wUYX .error-icon{fill:#552222;}#mermaid-svg-ZmPkT9M4Okd8wUYX .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-ZmPkT9M4Okd8wUYX .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-ZmPkT9M4Okd8wUYX .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-ZmPkT9M4Okd8wUYX .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-ZmPkT9M4Okd8wUYX .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-ZmPkT9M4Okd8wUYX .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-ZmPkT9M4Okd8wUYX .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-ZmPkT9M4Okd8wUYX .marker{fill:#333333;stroke:#333333;}#mermaid-svg-ZmPkT9M4Okd8wUYX .marker.cross{stroke:#333333;}#mermaid-svg-ZmPkT9M4Okd8wUYX svg{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-ZmPkT9M4Okd8wUYX p{margin:0;}#mermaid-svg-ZmPkT9M4Okd8wUYX .label{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;color:#333;}#mermaid-svg-ZmPkT9M4Okd8wUYX .cluster-label text{fill:#333;}#mermaid-svg-ZmPkT9M4Okd8wUYX .cluster-label span{color:#333;}#mermaid-svg-ZmPkT9M4Okd8wUYX .cluster-label span p{background-color:transparent;}#mermaid-svg-ZmPkT9M4Okd8wUYX .label text,#mermaid-svg-ZmPkT9M4Okd8wUYX span{fill:#333;color:#333;}#mermaid-svg-ZmPkT9M4Okd8wUYX .node rect,#mermaid-svg-ZmPkT9M4Okd8wUYX .node circle,#mermaid-svg-ZmPkT9M4Okd8wUYX .node ellipse,#mermaid-svg-ZmPkT9M4Okd8wUYX .node polygon,#mermaid-svg-ZmPkT9M4Okd8wUYX .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-ZmPkT9M4Okd8wUYX .rough-node .label text,#mermaid-svg-ZmPkT9M4Okd8wUYX .node .label text,#mermaid-svg-ZmPkT9M4Okd8wUYX .image-shape .label,#mermaid-svg-ZmPkT9M4Okd8wUYX .icon-shape .label{text-anchor:middle;}#mermaid-svg-ZmPkT9M4Okd8wUYX .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-ZmPkT9M4Okd8wUYX .rough-node .label,#mermaid-svg-ZmPkT9M4Okd8wUYX .node .label,#mermaid-svg-ZmPkT9M4Okd8wUYX .image-shape .label,#mermaid-svg-ZmPkT9M4Okd8wUYX .icon-shape .label{text-align:center;}#mermaid-svg-ZmPkT9M4Okd8wUYX .node.clickable{cursor:pointer;}#mermaid-svg-ZmPkT9M4Okd8wUYX .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-ZmPkT9M4Okd8wUYX .arrowheadPath{fill:#333333;}#mermaid-svg-ZmPkT9M4Okd8wUYX .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-ZmPkT9M4Okd8wUYX .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-ZmPkT9M4Okd8wUYX .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-ZmPkT9M4Okd8wUYX .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-ZmPkT9M4Okd8wUYX .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-ZmPkT9M4Okd8wUYX .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-ZmPkT9M4Okd8wUYX .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-ZmPkT9M4Okd8wUYX .cluster text{fill:#333;}#mermaid-svg-ZmPkT9M4Okd8wUYX .cluster span{color:#333;}#mermaid-svg-ZmPkT9M4Okd8wUYX div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-ZmPkT9M4Okd8wUYX .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-ZmPkT9M4Okd8wUYX rect.text{fill:none;stroke-width:0;}#mermaid-svg-ZmPkT9M4Okd8wUYX .icon-shape,#mermaid-svg-ZmPkT9M4Okd8wUYX .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-ZmPkT9M4Okd8wUYX .icon-shape p,#mermaid-svg-ZmPkT9M4Okd8wUYX .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-ZmPkT9M4Okd8wUYX .icon-shape .label rect,#mermaid-svg-ZmPkT9M4Okd8wUYX .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-ZmPkT9M4Okd8wUYX .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-ZmPkT9M4Okd8wUYX .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-ZmPkT9M4Okd8wUYX :root{–mermaid-font-family:\”trebuchet ms\”,verdana,arial,sans-serif;}
成功
满
成功
池满
相机回调
pool.acquire()
SDK 数据写入槽位
push 到 RingBuffer
丢帧 + 计数告警
算法线程 pop
推理 / 检测
FramePtr 析构
槽位归还 pool
4.3 生灭管理的三条铁律
- 生产者永不阻塞:回调里只做 acquire + memcpy + push,任何耗时操作都丢给下游;
- 消费者永不持有超过必要时间:处理完立刻让 FramePtr 出作用域;
- 池容量 = 队列容量 + 在途帧数:留 2~3 帧余量给正在处理的帧,避免"队列没满但池空了"的假死。
5. 零拷贝:把相机内存指针直接喂给推理引擎
5.1 相机 SDK 的零拷贝接口
主流工业相机 SDK(GigE Vision / USB3 Vision 厂商 SDK)都支持注册用户缓冲区:你把自己的内存地址交给 SDK,SDK 直接 DMA 写入,回调里拿到的是你自己的指针,而不是 SDK 内部缓冲。
典型用法分两步:
这一步直接消灭了第 2 节图中的 ① 和 ②:SDK 写的就是池里的内存,回调里拿到的也是池里的内存。
5.2 推理引擎的零拷贝输入
以 ONNX Runtime / TensorRT 为例,都支持"外部内存"输入:
以 TensorRT 为例:先准备一个绑定指针数组,把输入设备指针与输出设备指针按顺序放进去,然后调用执行上下文的异步推理接口,把这条 CUDA 流交给它即可。整个过程中,输入数据始终停留在设备内存里,除了必要的 H2D 传输之外,不再产生任何额外的全帧拷贝。
以 ONNX Runtime 为例:先创建一个描述内存属性的对象,指明使用 CPU 侧的内存分配器与默认内存类型;再用它把一块已经存在的内存(帧数据首地址 + 字节长度 + 维度信息)包装成一个张量值对象。这个包装动作只是登记指针与形状,并不会复制底层数据,因此推理引擎读到的就是帧池里的那块内存。
配合 CUDA 的 cudaMemcpyAsync(H2D),整条链路只剩一次必要的设备传输,CPU 侧不再有任何全帧 memcpy。
5.3 必须注意的坑
- 对齐:相机 DMA 通常要求 4KB 对齐,池分配时用 aligned_alloc 或 cudaMallocHost(pinned memory);
- 生命周期:推理是异步的,enqueue 返回不代表用完。必须等 stream 同步或 event 完成后再释放 FramePtr,否则会读到被覆盖的数据;
- 格式转换:Bayer → RGB、归一化这些操作无法完全零拷贝,但可以在 GPU 上做,避免 CPU 侧再开一块缓冲;
- pinned memory 有限:cudaMallocHost 分配过多会拖慢系统,池容量要按实际并发帧数设定。
6. 完整流水线骨架
把三块拼起来,一个可运行的最小骨架如下:
主流程大致是这样组织的:
参数与资源准备:先确定图像宽、高、通道数(例如 5472 × 3648 × 3),据此算出单帧字节数,约 60MB;然后构造帧池,容量取 16 帧,合计约 960MB,实际取值要按机器内存调整;再声明一个容量为 12 的帧队列,元素类型是帧智能指针。
相机线程:在一个「运行中」标志控制的循环里反复执行——先向帧池借出一个槽位索引,若返回 -1 说明池已耗尽,直接把丢帧计数加一并进入下一轮;否则该槽位由相机 SDK 直接写入数据;随后用该槽位地址与删除器构造一个帧智能指针,尝试推入帧队列,若队列已满则同样把丢帧计数加一。
算法线程:同样在循环里反复执行——从帧队列弹出一帧,若队列为空则让出 CPU 时间片后继续;拿到帧后把其裸指针直接交给推理函数,实现零拷贝输入;处理完毕显式重置该智能指针,使引用计数归零、槽位立即归还帧池。
收尾:主线程等待相机线程与算法线程都结束后再退出。
整个骨架里没有任何一处对整帧图像做深拷贝,内存的分配只发生在启动阶段,运行期只有借出与归还两种动作。
7. 线上验证
设计对不对,要用数据说话。建议在产线部署前做三件事:
一个典型的健康曲线:启动后 RSS 稳定在 池容量 × 帧大小 + 固定开销,运行 24 小时波动不超过 5%。
8. 总结
FAU 与线序检测这类高吞吐工业视觉场景,内存管理的正确性直接等于产线的可用性。三个设计点层层递进:
| 内存池 | 频繁 malloc/free 导致的碎片与泄漏 | 启动时一次性分配,运行期借还 |
| 环形缓冲区 | 队列无限增长导致 OOM | 固定槽位 + 原子游标 + 丢帧背压 |
| 零拷贝 | 多次全帧 memcpy 的带宽浪费 | SDK 注册缓冲 + 引擎外部内存输入 |
一句话概括:让每一帧图像从相机到推理,只被"引用",不被"复制";让每一块内存从分配到回收,都有确定的归属。
做到这三点,系统跑 8 小时和跑 8 分钟,内存曲线应该是一模一样的。

