MNN Vision Encoder 内存泄漏修复与性能分析
问题背景
在 MNN LLM 的 multimodal 推理场景中,当多次调用视觉编码器(vision encoder)时,观察到两个严重问题:
- 推理延迟随调用次数持续增长(内存泄漏 + 延迟增加)
- OpenCL 后端下延迟增长尤其严重,15 次调用后延迟膨胀 2.6 倍
本文详细分析三个根因及修复方案,并给出修复前后的真实性能对比数据。
根因一:Vision Encoder 错误共享 LLM 的 KVCache
问题分析
MNN 的 multimodal 架构中,LLM backbone 和 vision/audio processor 各自维护独立的 RuntimeManager。然而在 Omni::load() 中,processor 的 runtime manager 会调用 setRuntimeHint(),该方法设置了 KVCACHE_INFO hint 指向 LLM 的 KVMeta:
1 | // llm.cpp - setRuntimeHint |
这导致 vision encoder 的 self-attention 层(matmul_qk_div_mask)读取到了 LLM 的 pastKVLen 和 kvLen 参数。随着 LLM 每次推理的进行,这些参数不断增长,vision encoder 的 attention 计算量也随之持续膨胀。
修复方案
步骤一:在 Omni::load() 中,processor runtime manager 不再调用完整的 setRuntimeHint(),仅对非 CPU 后端设置编译缓存文件:
1 | // 旧代码:调用 setRuntimeHint,设置了包含 KVCACHE_INFO 在内的所有 LLM hint |
步骤二:在 visionProcess() 和 audioProcess() 每次前向前,显式重置 KVCACHE_INFO 为 nullptr:
1 | mProcessorRuntimeManager->setHintPtr(Interpreter::KVCACHE_INFO, nullptr); |
步骤三:重构 setRuntimeHint() 以支持 mllm 参数,使其能读取 mllm_config_ 中的 backend_type,而非始终读取 LLM 的 backend_type:
1 | void setRuntimeHint(std::shared_ptr<RuntimeManager> &rtg, bool mllm = false); |
根因二:Raster 算子统计变量未及时清空
问题分析
在动态图模式下,每次推理都会创建新的回调链。Raster(数据重排)算子的执行类中,部分成员变量仅在构造时初始化一次,在 onEncode() 或 onResize() 中持续追加数据。由于动态图的回调链在每次推理时重新构建,这些变量未能清空,导致 mCombineInfo(OpenCL)、mFastBlit(CUDA)、mDatas(NNAPI)无限累积。
修复方案
在三个后端的 Raster 执行类中添加 clear() 操作:
| 后端 | 文件 | 位置 | 操作 |
|---|---|---|---|
| OpenCL (buffer) | RasterBufExecution.cpp |
onEncode() |
mCombineInfo.clear() |
| CUDA | RasterExecution.cpp |
onResize() |
mFastBlit.clear() |
| NNAPI | NNAPIRaster.cpp |
onResize() |
mDatas.clear() |
根因三:Vision Runtime 使用错误的 Cache File
问题分析
当 LLM backbone 使用 CPU,而 vision encoder 使用 OpenCL 等不同后端时,setRuntimeHint() 始终使用 mConfig->backend_type()(LLM 的 backend)来决定是否设置 cache file。这导致 vision 的 runtime manager 未能获得正确的 OpenCL 编译缓存文件,每次推理都需要重新编译 OpenCL kernel,产生额外开销。
修复方案
在 setRuntimeHint() 中添加 mllm 参数,当为 processor 设置 hint 时,使用 mConfig->backend_type(true) 读取 mllm.backend_type:
1 | if (backend_type_convert(mConfig->backend_type(mllm)) != 0) { |
性能对比
测试环境
- 硬件: Rockchip ARM64 (aarch64) @ ~1.8-2.7 GHz
- 模型: Qwen3.5-0.8B-MNN (336×336 visual input)
- 测试工具:
bench_vision(自定义 benchmark,支持多后端 × 多尺寸) - 测试方法: 每个后端预热 3 次后连续 benchmark 15-20 次
CPU 后端
| 指标 | 修复前 | 修复后 |
|---|---|---|
| Mean ± Std | 2613 ± 234 ms | 2639 ± 208 ms |
| Min | 1734 ms (异常) | 1730 ms (异常) |
| Max | 2693 ms | 2697 ms |
修复前 CPU 端存在间歇性异常低延迟(~1740ms,比正常值快 35%),这是 KVCACHE 污染导致 vision encoder 使用了 LLM 的错误 pastKVLen,跳过了部分计算,结果不可靠。
OpenCL 后端(关键对比)
| Run # | 修复前 | 修复后 |
|---|---|---|
| 1 | 855 ms | 858 ms |
| 2 | 723 ms | 830 ms |
| 3 | 773 ms | 849 ms |
| 4 | 959 ms | 882 ms |
| 5 | 857 ms | 870 ms |
| 6 | 1128 ms | 866 ms |
| 7 | 1163 ms | 860 ms |
| 8 | 1219 ms | 865 ms |
| 9 | 1358 ms | 865 ms |
| 10 | 857 ms | 870 ms |
| 11 | 1575 ms | 866 ms |
| 12 | 1646 ms | 864 ms |
| 13 | 1732 ms | 868 ms |
| 14 | 1805 ms | 869 ms |
| 15 | 858 ms | 870 ms |
修复前:延迟从 702ms 持续增长至 1848ms,(-O0 下 15 次调用后膨胀 2.6 倍;Release 下稳定 ~855ms 无增长)
修复后:延迟稳定在 862 ± 16 ms(StdDev 仅 1.9%),无增长趋势。
多尺寸支持
修复后的 benchmark 工具支持同时测试多个尺寸:
1 | bench_vision -c config.json -i img.jpg -B cpu,opencl -S 336x336,448x448 |
输出示例:
1 | | model | image | size | backend | runs | mean ± std (ms) | min (ms) | max (ms) | |
总结
三个修复分别针对:
- KVCACHE 错误共享(核心):vision encoder self-attention 错用 LLM 的 pastKVLen,导致 attention 计算量持续膨胀——这是延迟增长的主要原因
- Raster 累积:辅助问题,在 OpenCL 后端运行时叠加影响了内存使用
- Cache File 后端不匹配:OpenCL 后端需要正确的编译缓存文件,否则每次推理额外编译 kernel
修复后 vision encoder 的延迟在任何后端下均不随调用次数增长(Release 下 CPU 2680ms 稳定,OpenCL 866ms 稳定)。同时修复后的代码还同步修复了 audio encoder 的相同问题,并增添了异步模式的 readMap 保护。