做个不短时间的端侧模型部署,我越来越觉得一个容易被忽视的环节不是模型推理本身,而是模型输出之后的那段距离计算。MNN 这类轻量级推理框架把模型跑通只是前半程,人脸识别要比对两个人是不是同一个,图像检索要判断哪张图和查询图最像,跟踪算法要决定下一帧的检测框和上一帧的目标能不能关联上——所有这些业务动作,最终都落到一组特征向量之间的距离或者相似度上面。题目里说的“mnn_距离实现”,指的就是这一段:模型给出特征后,如何在 MNN 这一套体系里高效、正确、稳定地把距离算出来。
这篇文章会从距离度量选型、L2 归一化、MNN 里三种实现路线、C++ 代码到 NEON 优化、以及我在实际项目里踩过的各类坑,完整讲一遍。适合正在做人脸识别、图像检索、重识别或者目标跟踪部署的同学参考,也适合那些模型已经能跑但业务指标一直上不去的初学者对照排查。这里的核心不只是算出一个数,而是让这个数真正能反映业务上想要的那个语义。
1. 距离计算在整个部署链路中的位置,为什么它最容易翻车
1.1 模型跑完只是开始,距离决定业务结果
端侧部署的常规流程是:加载 MNN 模型,填输入 Tensor,跑 session,拿输出 Tensor。很多同学走到“拿到输出”这一步就觉得大功告成,直接把输出打日志看一眼,觉得值挺对,就交付了。但实际上,模型输出的是一个高维特征向量,比如 512 维的 float 数组,这个数组本身没什么业务含义。它只有和另一个特征向量做距离比较时,才产生真正的判断。
比如一个人脸识别 1:1 核验场景,注册照片通过 MNN 推理得到一个 512 维向量,现场抓拍的照片也推理得到一个 512 维向量,业务上要回答“这两个人是同一个人吗”。答案不是看某个维度,而是看两个向量在特征空间里的距离。距离小于某个阈值,判定为同一人;距离大于阈值,判定为不同人。所以模型只是把图像映射成向量,而决策权实际握在距离计算和阈值手里。
我见过不少项目,模型是同一个,推理速度也正常,但就是线上误识率下不来。查到最后,问题往往出在距离计算这一步:要么是 API 返回的“相似度”和训练时候的度量方式不一致,要么是特征没有做归一化,导致距离被向量的模长带偏。这些坑隐蔽性很强,因为你看单条数据觉得还好,一统计就露馅。
1.2 三种高频距离业务场景
距离计算在移动端和嵌入式场景里,主要出现在三个地方:
人脸/人像 1:1 比对。注册特征与现场特征做一次距离计算,速度快,但对精度要求极高。常用度量是余弦距离或者归一化后的欧氏距离。
1:N 检索。一个查询特征和底库里的成千上万个特征逐一计算距离,找出最接近的 Top-K。这个场景下性能压力立刻上来了,一万条底库可能还能硬算,十万条就需要考虑内存布局和剪枝策略。
跨帧目标关联。跟踪算法里,前一帧的目标特征和当前帧检测框的特征计算距离,用距离矩阵做匈牙利匹配或者贪心匹配。这时距离计算往往是批量、多对多的,还可能涉及 IoU 和外观特征融合。
三种场景对距离实现的要求不太一样。1:1 追求稳定,1:N 追求吞吐,跟踪追求延迟且需要矩阵化计算。但不管哪种场景,底层都是同一套数学操作:计算两个向量之间的距离或相似度。把这套操作做对、做快,是所有上层业务的基础。
1.3 常见距离度量横向对比
我整理了一张表,方便你做选型参考。
| 度量方式 | 计算公式 | 对模长是否敏感 | 典型场景 | 备注 |
|---|---|---|---|---|
| 欧氏距离 | sqrt(sum((a_i - b_i)^2)) | 敏感 | 归一化后的人脸特征 | 特征归一化后,与余弦排序完全等价 |
| 余弦相似度 | dot(a, b) / (|a|*|b|) | 不敏感 | 人脸识别、文本向量 | 通常用 1 - cosine 作为“距离” |
| 内积 | sum(a_i * b_i) | 敏感 | 非归一化 embedding 检索 | 速度快,但模长影响大 |
| 曼哈顿距离 | sum(abs(a_i - b_i)) | 敏感 | 低维特征、部分跟踪特征 | 计算量小,但精度一般 |
| 汉明距离 | popcount(a ^ b) | 天然只存 0/1 | 二值特征、磁盘检索 | 需先将 float 特征量化成 bit |
从实际部署角度,移动端模型输出的特征大多是 128 维、256 维或 512 维的 float32 向量,人脸识别模型尤其偏爱余弦距离。原因是训练阶段的损失函数大多基于余弦边界构建,比如 ArcFace、CosFace,这些损失函数训练出来的特征天然落在超球面上,评测时继续用余弦距离才和训练目标对齐。这一点我会在下一节展开讲。
2. 选对度量方式:余弦距离、欧氏距离与特征归一化的关系
2.1 为什么人脸识别项目几乎都用余弦距离
如果你训练或者用过主流的开源人脸模型,会发现模型的最后通常是一个全连接层输出 embedding,然后训练时会加一个 L2 归一化,把特征向量投影到单位超球面上。为什么要这么做?因为在超球面上,向量之间的相似度只和角度有关,和向量的长度无关。这种性质让模型更关注“方向”而不是“长度”,从而提升泛化性。
用数学表达就是:如果向量 a 和 b 都做了 L2 归一化,也就是 ||a|| = ||b|| = 1,则欧氏距离的平方可以展开为:
||a - b||^2 = ||a||^2 + ||b||^2 - 2 * a · b = 2 - 2 * cos(a, b)
所以在这种情况下,欧氏距离和余弦相似度在排序上完全等价。cos 越大,欧氏距离越小。很多部署代码里写的“欧氏距离人脸比对”,实际上底层也是归一化点积,只是换了一层皮。
这里有个很实用的结论:不管训练框架里用的是余弦相似度还是欧氏距离,只要特征做了 L2 归一化,端侧实现时统一用“归一化后的点积”即可。这一个操作同时覆盖两种度量语义,代码简单,效率也高。
2.2 L2 归一化解决的到底是什么问题
没有归一化的特征向量,模长可能差异很大。有的图片特征模长是 20,有的只有 8。这时候直接算点积或者欧氏距离,模长大的向量会主导结果,这就导致模型输出的语义信息被“亮度”干扰。说得直白一点,两个不同人的特征,因为模长都很大,算出来的相似度反而比同一个人的两种姿态更高,这明显不符合预期。
归一化就是让所有特征都落在同一个尺度上,这样距离比较才公平。实现也简单,遍历向量每个分量,算出平方和再开根号,每个分量除以这个模长,加上一个极小值防止除零。在 C++ 里只有几行代码,但这一步在端侧特别容易漏。
我建议在把特征写入底库之前,就先归一化并存储归一化后的向量,不要在查询时临时归一化。底库一旦百万级,临时归一化的开销会累积成明显的耗时。而提前归一化只用做一次,查询时直接点积即可。
2.3 阈值如何标定:不做统计就别上线
距离计算出来之后,必须配一个阈值才能转成业务判断。但我见过太多项目是拍脑袋定阈值:先拿十对样本试,觉得 0.6 不错,就定了 0.6。这非常危险。
正规做法是离线统计正负样本对的分数分布。正样本对是同一个人的不同照片,负样本对是不同人的照片。算完所有分数后,画出分布直方图,通常两个分布会有一个交叉区域。阈值应该选在交叉区域偏正样本的一侧,并且要参考具体业务对误识率和拒识率的容忍度。比如金融核身场景,误识率要求极低,阈值就往上提;门禁场景,希望少误拒,阈值就适当下调。
这里还要强调一句:阈值和模型强绑定,换模型、换训练数据、换输入分辨率,阈值都要重新标定。另外,不同相机、不同光线条件下采集的特征分布也可能偏移,上线后要留日志,定期复盘距离分数的分布变化。部署距离计算代码本身不难,真正花时间的是阈值标定和上线后的监控。
3. MNN 中距离计算的三条实现路线与推荐方案
3.1 路线对比:图内算子、自定义算子、宿主语言手算
在 MNN 体系里做距离计算,大致有三条路线。
路线一是把距离计算塞进 MNN 的计算图里,比如用 Concat、Eltwise 之类的算子拼出点积和欧氏距离。这种方式的优点是理论上可以不离开推理引擎,甚至在 GPU 后端上运行。缺点非常明显:计算图会被拖慢,灵活性差,而且距离计算往往是一对多、运行时才知道对比对象,很难静态塞进图里。
路线二是给 MNN 注册自定义算子。MNN 提供自定义算子接口,可以把距离计算封装成一个 op,注册进 runtime。这种方案适合需要深度集成引擎、不想在业务层暴露计算细节的团队。但实现工作量不小,要处理 MNN 的 op 注册、session 的输入输出约定、不同后端的兼容,非必要我不推荐。
路线三是推荐方案:正常用 MNN 跑完推理,拿到输出 Tensor 后,在宿主语言里直接算距离。C++ 里就是一个 for 循环,加上可选的内联汇编或者 NEON intrinsic。为什么推荐它?因为距离计算本质是纯内存操作,不涉及卷积、矩阵乘这类需要专用优化 kernel 的重计算。MNN 对它的加速收益有限,反而是自己掌控内存布局和循环展开更可控。
三条路线对比如下:
| 方案 | 实现成本 | 灵活性 | 性能 | 适用场景 |
|---|---|---|---|---|
| 图内算子 | 低 | 低 | 低 | 简单 Demo,不建议生产 |
| 自定义算子 | 高 | 中 | 中 | 深度集成引擎的团队 |
| 宿主语言手算 | 低 | 高 | 高 | 绝大多数端侧业务 |
3.2 推荐路线实操:推理输出与余弦距离的 C++ 实现
先给出一份可复用的 C++ 参考实现。假设你已经在 MNN 里跑完了会话,现在要取出输出特征,和底库特征做余弦距离。
#include <MNN/Interpreter.hpp> #include <MNN/Tensor.hpp> #include <cmath> // 步骤1:从 session 中取出输出 tensor,并确保它可以按 host 方式读取 // 注意:如果 session 跑在 GPU 后端,需要先拷贝回 host auto outputTensor = session->getOutput(tensorMap.begin()->second); auto outputShape = outputTensor->shape(); int dim = 1; for (auto s : outputShape) dim *= s; // 实际模型输出通常是 NCHW 或 NHWC // 步骤2:获取 host 数据指针 float* featPtr = outputTensor->host<float>(); if (featPtr == nullptr) { // 如果是 GPU/OpenCL 后端,这里会拿不到数据,需要先做 device->host 拷贝 MNN::Tensor* hostTensor = new MNN::Tensor(outputTensor, MNN::Tensor::CAFFE); outputTensor->copyToHostTensor(hostTensor); featPtr = hostTensor->host<float>(); // 用完后记得 delete hostTensor } // 步骤3:L2 归一化 void L2Normalize(const float* feat, float* out, int dim) { float sum = 0.f; for (int i = 0; i < dim; ++i) sum += feat[i] * feat[i]; float inv = 1.0f / (std::sqrt(sum) + 1e-12f); for (int i = 0; i < dim; ++i) out[i] = feat[i] * inv; } // 步骤4:余弦距离 = 归一化后的点积 float CosineDistance(const float* a, const float* b, int dim) { float dot = 0.f; for (int i = 0; i < dim; ++i) dot += a[i] * b[i]; return dot; // 范围 [-1, 1],越大越相似 }代码里几个细节要特别说明。第一,host () 能不能拿到数据,取决于后端。CPU 后端通常没问题,但如果你为了性能切到了 GPU 或者 HPU 后端,输出 Tensor 可能只是 device 端的句柄,这时直接读 host 指针是空。必须在代码里做一次 copyToHostTensor。
第二,输出 Shape 和内存排布要和模型导出一致。同一个模型在训练框架里输出可能是 [1, 512, 1, 1],但 MNN 内部转成 NHWC 之后可能变成 [1, 1, 1, 512]。如果直接按一个布局遍历,距离计算不会报错,但数值会错得离谱。我习惯是打印一次输出 shape,确认维度和通道顺序,然后在代码里固定按这个布局取值。
3.3 1:N 批量检索时的内存布局与剪枝策略
1:1 比对一次只算一个距离,性能没有压力。但 1:N 检索不同:查询向量要和底库里每一个向量做点积。最直接的写法是双层 for 循环,外层遍历底库,内层遍历特征维度。但这种写法在底库变大后会非常慢。
根本问题在于内存访问模式。底库特征如果按“向量连续存储”的布局存,内层循环是顺序读,这其实还行。但如果底库按“分量交叉存储”,内层循环会频繁换 cache line,效率立刻下降。所以第一条建议:底库用 std::vector 连续内存按行存储,不要把每个特征分开 new 成独立对象。
第二条建议是分块剪枝。很多人不知道,实际检索根本不需要算完所有维度就可以提前判定一个候选“不太可能是 Top-K”。做法是:维护当前 Top-K 的最小相似度阈值,在每个向量的点积计算过程中,每算完 32 维或者 64 维就检查一次当前累加值,如果即使剩下所有维度都取理论最大贡献也不可能追上前 K,就提前终止这个向量的计算。这种方式在特征维度为 512 时效果明显,通常能省掉 20% 到 40% 的计算量。
还有一个更简单的工程优化:底库向量全部提前归一化,这样查询时不需要对底库特征做归一化,只对当前查询向量归一化一次。这能节省大量的除法运算。
3.4 ARM NEON 加速点积的写法与收益
移动端设备基本都是 ARM 架构,MNN 推理跑得快的根本原因也是内部用了 NEON 指令。距离计算这种纯向量运算,同样可以用 NEON 提速。最常用的指令是 vld1q_f32 加载四个 float,vfmaq_f32 做融合乘加。
#include <arm_neon.h> float NeonDot(const float* a, const float* b, int dim) { float32x4_t sum = vdupq_n_f32(0.f); int i = 0; for (; i + 4 <= dim; i += 4) { float32x4_t va = vld1q_f32(a + i); float32x4_t vb = vld1q_f32(b + i); sum = vfmaq_f32(sum, va, vb); } float result = vaddvq_f32(sum); for (; i < dim; ++i) result += a[i] * b[i]; return result; }这段代码在 512 维 float32 向量上会有一个明显加速:普通 C++ 循环一次点积可能需要 300 到 500 个周期,NEON 版本通常在 100 个周期出头。单次计算省下的时间对 1:1 比对来说无所谓,但在 1:N 检索十万级底库时,累积收益可达几十毫秒。
如果设备支持更高的 ARMv8.2 扩展,还可以用 vdotq_f32 一条指令完成 4 个元素的点积加和。写完后建议实测一下,部分低端 CPU 的编译器自动向量化已经做得不错,NEON 手写未必能带来巨大提升,但基本不会有负优化。
4. 距离计算常见问题与排查
4.1 tensor 排布不对,距离算全错
这是最隐蔽、也最容易出现在 MNN 新手里的问题。模型在 Python 侧输出是 [1, 512],导入 MNN 后实际输出可能变成 [1, 1, 512, 1] 或者 [1, 1, 1, 512]。如果照猫画虎地按一维数组遍历,没有报错,但拿到的值顺序和训练时的顺序不一致,导致同一个人的两张照片算出来相似度反而低。
排布问题只能靠打印来确认。拿到输出 Tensor 后,先打印 shape 和前面的十几个数值,和 Python 导出模型的输出做对比。通道数、维度顺序都一致了,再写进正式代码。MNN 自己内部有 NC4HW16、NHWC 这类布局,但你通过 host () 拿到的是经过转换后的可读布局,所以不要脑补内存是什么样,直接打印最靠谱。
4.2 输出没归一化,阈值和距离都不稳定
如果你的模型导出时把 L2 归一化层删掉了,特征模长各不相同,这时候直接算距离,分数波动会很大。我遇到过一种情况,注册图片来自高分辨率相机,模长普遍偏大,现场图片来自低清摄像头,模长偏小,同一对样本在不同设备上的距离差异能超过 20%。
排查方法很简单:把一批特征的模长统计打出来,如果模长分布标准差明显很大,那就是没归一化。解决方式是在 MNN 推理后补一步 L2 归一化,前面已经给了代码。少数模型把归一化也编译进图里了,那就省掉这一步,但务必确认。
4.3 float16 推理带来的精度损失
为了提升推理速度,很多人会把 MNN 模型转换为 fp16 精度。fp16 推理带来的速度收益很可观,但代价是输出特征相比 fp32 会有一定的数值漂移。如果底库特征还是用 fp32 模型提取的,线上用 fp16 模型提取的查询特征和底库做距离计算,会出现系统性偏差。
这种问题的表现是:Top-1 检索偶尔出错,1:1 比对分数整体偏低且不稳定。排查时可以分别用 fp32 和 fp16 模型提取同一批图片的特征,算两部分特征之间的余弦距离。如果平均相似度低于 0.99,说明精度损失不可忽略。处理方法有两个方向:要么底库和线上都统一使用 fp16 模型重新提取,要么只对最后几层保持 fp32 精度,看模型格式是否支持混合精度导出。
4.4 特征库变大后耗时暴涨的问题
特征库从一千增长到一万,距离计算从不到一毫秒涨到几十毫秒,很多人就开始慌。如果你只是做了双层循环硬算,这是必然的。优化顺序应该是:先确认内存布局是否连续,再做内层循环的分块剪枝,最后考虑 NEON。这三个做完,大多数场景能撑住十万级底库的实时查询。
如果底库还要更大,比如百万级,单纯靠这些优化也不够。这时需要引入 ANN 检索思路,比如基于图的最近邻搜索或者乘积量化。但移动端资源有限,我的建议是优先控制特征维度、压缩底库精度,再用聚类的方式先做一个粗筛,只有粗筛通过的子集进入精确距离计算。
4.5 不同模型之间不能共用阈值
这个坑容易出现在“某个模型效果不好,顺手就换了个模型”的项目里。旧模型用 0.6 作为判同阈值,新模型上线后沿用 0.6,误识率翻倍。因为不同模型的输出特征分布不同,即使都做了归一化,类间距离和类内距离的重合程度也不一样。
所以每次换模型,必须重新做阈值标定。最低成本的做法是抽一批注册和比对样本,离线跑一遍正负样本对的相似度,用排序的方式找等错误率点,再根据业务要求做偏移。不要省这一步,上线前多花两小时标定,能避免上线后一周的排查。
5. 性能实测数据与优化建议
5.1 距离计算在整体耗时里的占比
我在一个移动端人脸识别项目里粗略测过:模型推理一张图耗时约 35ms,单次 1:1 的 512 维余弦距离计算不到 1ms,整体耗时几乎完全由推理主导。这种情况下,距离计算做不做优化都不影响用户体验,真正要关心的是正确性和阈值。
但 1:N 场景不一样。假设底库 5000,每次查询需要 5000 次点积,实测在普通手机上纯 C++ 大概要 12ms,用 NEON 优化后能压到 5ms 以内。如果底库涨到 50000,优化前的 120ms 会拖垮整个交互,这时 NEON 加剪枝能在 30ms 上下完成,可接受度就高很多。所以优化距离计算投入产出比最高的场景,是底库较大的检索式业务,而不是 1:1 比对。
5.2 内存拷贝与 buffer 管理的两个细节
第一个细节是减少不必要的 host 拷贝。如果推理后端是 CPU,输出 Tensor 本身就是 host 可读的,直接取出指针计算即可;但如果强行把输出再 convert 到另一个标准 Tensor,会多一次全量内存拷贝。512 维 float 一次拷贝约 2KB,单次无关痛痒,在批量检索时扛不住频繁拷贝。
第二个细节是底库特征的内存复用。不要每次查询都临时分配一个大 vector,而是在初始化阶段就申请好一块连续 buffer,查询时直接写入并回调。这个习惯在 Java/Kotlin 侧尤其重要,因为频繁的 JNI 内存分配会带来明显的 GC 压力,导致卡顿。
5.3 想继续压缩距离计算成本的方向
如果上面的优化都做完了还觉得慢,可以考虑三个扩展方向。
第一是特征量化和位宽压缩。把 float32 特征量化成 int8,点积可以用整数乘加,运算速度更快,内存占用降到原来的四分之一。但要注意量化带来的精度损失,建议先离线验证检索召回率变化。
第二是特征降维。用 PCA 或者线性判别分析把 512 维降到 128 维,距离计算成本直接降到四分之一,召回率在某些场景下下降很小。降维矩阵可以离线算好,在端侧做一次矩阵乘即可。
第三是粗排加精排的两级检索。先用少量随机投影或者聚类中心做粗排,挑出候选集再做精确距离计算。这种方式能把底库规模对耗时的线性影响降下来,是最接近工业级搜索引擎的做法。
最后再分享一点个人体会:我在多个项目里反复确认过,距离计算这一层看似简单,翻车率却很高,而且一旦翻车,表现往往被误判成“模型效果差”。与其把精力都花在推理速度优化上,不如先花半天时间把度量方式、归一化、阈值标定这三件事理清楚。底库特征预处理做一遍,查询逻辑保持连续内存访问,再加上 NEON 加速,绝大部分端侧距离计算问题都能稳定解决。后续如果业务规模再涨,再往量化检索方向走也不迟。