项目文件夹里躺着一个模块名:mnn_距离实现。第一次看到这个名字,我差点把它当成一个很小的活——不就是加载模型、抽特征、算个余弦相似度嘛。真正做进去才发现,这几个字背后是一整条工程链路:模型怎么转、特征怎么对齐、向量怎么比、量化之后还准不准,每一步都有能让人折腾半天的细节。
这篇把我在这个模块上踩过的坑和验证过的做法整理出来,给正准备在端侧做特征比对的同行一个参考。不管你是做人脸1:1核身、以图搜商品、姿态相似度匹配,还是重复内容检测,距离计算几乎是同一套底层逻辑:mnn框架负责把模型跑起来,距离实现负责把最后一公里走完。
1. 这个需求为什么绕不开“距离”:应用场景与选型逻辑
1.1 距离在业务里的三个典型位置
先说清楚“距离”这个词在工程里到底出现在哪儿。我接触到的场景大致可以分成三类,它们的共同点都是要让模型输出的特征向量之间有一个可比较的数值。
第一类是1:1比对。人脸核身、门禁打卡、证件照验证,本质上都是拿两张图分别过一遍模型,得到两个特征向量,然后算相似度,超过某个阈值就判定为同一个人。这类场景对距离计算的实时性要求最高,用户不可能等两秒钟才看到结果。
第二类是1:N检索。以图搜商品、相册聚类、视频帧去重,都是先抽出一张图的特征,然后去特征库里把所有向量各算一遍距离,排序取TopK。这类场景的瓶颈往往不在单次距离计算,而在特征库的规模——库里有10万条向量,就要算10万个距离。
第三类是模型训练与推理的一致性校验。很多算法同学训练时用cosine loss或者triplet loss,距离度量是训练目标的一部分,如果推理侧换了一种距离算法,线上结果可能和离线评测完全对不上。这个点最容易被工程化时忽略,但我后面会发现它其实是最坑的一个。
这三类场景决定了距离计算不是“随便写个公式”的事。阈值定多少、用哪种度量、要不要归一化,都要跟训练侧的设定保持统一,否则模型本身再好也白搭。
1.2 为什么要把距离计算放到端上
一开始我的方案其实是想把特征抽取放在服务端,端上只负责上传图片。后来被两条硬约束挡回来了:一是隐私合规,用户的照片和视频如果可以直接在本地处理完,就不需要把原始图像传到服务器;二是实时性,一次网络往返在弱网环境下可能要吃几百毫秒,而端上推理加上距离计算通常几十毫秒就能出结果。
这时候mnn框架的优势就很明显了:它是专门为移动端和嵌入式场景设计的轻量级推理引擎,没有复杂的依赖,模型文件体积可控,CPU后端跑起来很稳定,还支持fp16和int8量化。我的想法很简单——模型推理和距离计算全部放到端上,特征只在本地产生、只在本地消费,服务端最多接收一个脱敏后的向量,而且这个向量也可以进一步加密上报。
这次选型还带出一个认知:单次距离计算本身极轻,真正吃资源的是特征抽取。一个512维特征向量的余弦相似度,就算循环算1万次也才几毫秒,但抽取这个特征可能需要跑几十毫秒的卷积网络。所以“距离实现”这个模块的核心工作量,其实有一大半在特征抽取链路的稳定性上,距离公式反而是最简单的部分。
2. 把模型跑起来:特征抽取链路的两块准备工作
2.1 模型转换这一步决定后面顺不顺
算法同学交付的模型通常是训练框架的通用格式,要在mnn上跑,第一步就是用转换工具把它转成mnn的模型格式。命令并不复杂:
./converter --inputModel feature_extractor.onnx --outputModel feature_extractor.mnn --fp16 1这里我用了fp16参数,让权重以半精度存储,模型体积几乎减半,在大多数中高端机型上精度损失也非常小。转换完成之后,很多人直接就拿去部署了,但我会强烈建议多做一步:模型输出一致性验证。
验证方法非常简单。取训练集或测试集里固定10张图,先用原始框架跑一遍,记录输出特征向量;再用转换后的mnn模型跑同一批图,也记录输出特征向量。然后算两组向量之间的余弦相似度。正常情况下,这个相似度应该高于0.999,如果只有0.98甚至更低,说明转换过程中某些算子的实现细节有差异,这时候就不应该往后走,先去排查转换参数或算子支持情况。
这一步我不止一次见过有人跳过,结果就是特征向量里藏着系统性偏移,距离阈值怎么调都不准,还以为是模型训练得不好。mnn的算子实现与训练框架存在细微差异是正常的,尤其是batch norm折叠、卷积算子的加速策略切换,都可能让输出分布产生漂移。提前验证,后面能省下大量排查时间。
2.2 推理核心代码:从输入到特征向量
数据链路跑通之后,推理代码的结构其实很固定。我用C++接口做个示范,因为端上核心模块很少有人愿意用解释型语言来解决:
#include <mnn/Interpreter.hpp> #include <mnn/Tensor.hpp> std::shared_ptr<mnn::Interpreter> interp( mnn::Interpreter::createFromFile("feature_extractor.mnn")); mnn::ScheduleConfig config; config.type = mnn::MNN_FORWARD_CPU; config.numThread = 4; mnn::Session* session = interp->createSession(config); // 输入张量 mnn::Tensor* input = interp->getSessionInput(session); std::shared_ptr<mnn::Tensor> inputHost( new mnn::Tensor(input, mnn::Tensor::CAFFE)); float* inputData = inputHost->host<float>(); // 这里把预处理后的图像数据按 CHW 顺序填入 inputData input->copyFromHostTensor(inputHost.get()); interp->runSession(session); // 输出张量 mnn::Tensor* output = interp->getSessionOutput(session); std::shared_ptr<mnn::Tensor> outputHost( new mnn::Tensor(output, mnn::Tensor::CAFFE)); output->copyToHostTensor(outputHost.get()); const float* feature = outputHost->host<float>(); // 此时 feature[0..D-1] 就是当前输入的向量这段代码里有几个值得注意的地方。输入张量要先开一块host内存,把预处理后的数据填进去,再通过copyFromHostTensor拷给推理引擎的输入张量;输出向量读取时,我刻意做了host拷贝,而不是直接拿output->host<float>(),这个原因后面专门讲。另外,输入图像的前处理必须和训练时完全一致,包括通道顺序是RGB还是BGR、mean和std的取值、缩放尺寸,这些参数差一个,特征向量就会在语义空间中漂移,距离计算跟着失真。
特征向量的维度取决于模型设计,常见的是256、512、1024维。以512维为例,一次输出就是512个float,打包成std::vector<float>就能在模块内部流通了。
3. 特征向量之间的距离:四种度量方式与批量计算思路
3.1 距离度量怎么选
模型输出的特征向量本质上是一组高维空间里的坐标。判断两个坐标“像不像”,业界常用的度量方式大概有四种,各有各的适用边界。
| 度量方式 | 公式 | 适用场景 | 备注 |
|---|---|---|---|
| 余弦相似度 | cos = a·b / (‖a‖·‖b‖) | 人脸、图像检索 | 对向量模长不敏感,最常用 |
| 欧氏距离 | √(Σ(a-b)²) | 聚类、异常检测 | 与余弦在归一化后单调等价 |
| 曼哈顿距离 | Σ|a-b| | 低维或稀疏特征 | 对噪声更稳健 |
| 点积 | Σ a·b | 训练目标本身就是点积 | 模长差异会影响结果 |
我在这类项目中默认使用余弦相似度,理由很简单:很多embedding模型的输出本身没有约束模长,可能同一个人在不同光照下特征向量的模长就差了一截,余弦只看方向,不看长度,天然扛住这种变化。而且业界大量预训练模型已经默认使用者会用余弦,阈值经验值也多是围绕余弦相似度来积累的。
这里还有一个数学上的等价关系值得知道:如果先对向量做L2归一化,让每个向量的模长变成1,那么欧氏距离和余弦相似度就是单调对应的。因为归一化后‖a‖=‖b‖=1,展开‖a-b‖²=2-2cos(a,b),所以按欧氏距离排序和按余弦相似度排序,结果完全一致,只是阈值刻度变了。工程上顺手在抽取特征后统一加一个L2归一化,后面无论想用哪种度量都很方便。
归一化的余弦计算代码很简单,但有几个数值细节:
float cosine_similarity(const float* a, const float* b, int dim) { double dot = 0.0, normA = 0.0, normB = 0.0; for (int i = 0; i < dim; ++i) { dot += static_cast<double>(a[i]) * b[i]; normA += static_cast<double>(a[i]) * a[i]; normB += static_cast<double>(b[i]) * b[i]; } return static_cast<float>(dot / (std::sqrt(normA) * std::sqrt(normB) + 1e-9)); }我用double做累积和,而不是float,是因为高维点积(512维甚至1024维)在float下会累积出不可忽略的舍入误差。阈值卡在0.99这种非常相近的分数时,float误差可能影响最后一位小数,进而翻转判断结果。加1e-9是为了防止零向量时除零,这个在边界输入时非常常见。
3.2 批量距离计算的矩阵化思路
1:1比对场景下,一次只算一个距离,用上面的循环函数没有问题。但1:N检索场景里,特征库可能有几万条甚至几十万条向量,如果每条都调用一次cosine_similarity,虽然总耗时也不会特别夸张,但在移动端上依然不够优雅。更关键的是,这种做法没有利用现代CPU的缓存和SIMD能力。
批量思路本质上是一个矩阵乘。把特征库中N条向量排成一个N行D列的矩阵F,目标查询向量排成一个D维列向量q,两边都预先做好L2归一化,那么所有N个余弦相似度就等于矩阵乘F @ q,一共N×D次乘加。这个计算用BLAS库来做时,会自动展开循环、利用向量化指令,比逐条函数调用快几倍不止。
工程上的落地方案是这样:
void batch_cosine(const float* feature_db, int db_count, int dim, const float* query, float* scores) { // feature_db 按行连续存放,第 i 行是第 i 条特征 // query 假设已经做了 L2 归一化,feature_db 的每一行也预先归一化 std::fill(scores, scores + db_count, 0.0f); // 优化前先用简单循环验证逻辑 for (int i = 0; i < db_count; ++i) { float dot = 0.0f; const float* row = feature_db + i * dim; for (int j = 0; j < dim; ++j) { dot += row[j] * query[j]; } scores[i] = dot; } }这段是逻辑版,性能版本会替换成两个方向的优化:一是把内层循环改成四路展开,减少循环分支跳转;二是放进子线程池并行处理,把db_count切分成多个块分别计算。实测在常见手机上,1万条512维向量的批量比对,也就是几毫秒的事,完全不是瓶颈。
特征库的内存布局值得专门说一句:一定要用一块连续内存,比如std::vector<float>按行存放,而不是用std::vector<std::vector<float>>。连续内存能让cache命中率高很多,如果每条特征是512维,也就是2KB,1万条总共才20MB,现代手机内存带宽完全吃得下。
4. 量化模型下距离值会偏多少:一组实测对比
4.1 量化实验怎么跑
mnn框架对端侧模型的支持很灵活,除了fp32之外,常见做法是把权重压到int8,模型体积能再小一半以上,有些手机上推理速度还能再快一点。但量化是有代价的,代价主要就体现在特征数值的漂移上。
我的实测方案很简单:同一个模型,分别导出fp32和int8两个版本;准备40张图片,其中20对是同类正样本,20对是不同类负样本;用两版模型对同一对图片分别抽取特征,记录各自的余弦相似度。
某次实验的数据长这样:
| 样本组 | FP32余弦 | INT8余弦 | 差值 |
|---|---|---|---|
| 同类01 | 0.8231 | 0.8187 | -0.0044 |
| 同类02 | 0.9125 | 0.9093 | -0.0032 |
| 同类03 | 0.6410 | 0.6275 | -0.0135 |
| 同类04 | 0.7822 | 0.7795 | -0.0027 |
| 异类05 | 0.5123 | 0.5150 | +0.0027 |
| 异类06 | 0.2146 | 0.2210 | +0.0064 |
不同模型架构、不同量化校准数据,偏差方向其实不固定。同类样本通常被压低,但也存在被抬高的;异类样本的偏差相对小一点,可依然有正有负。整体偏差区间大约在±0.015以内,这个量级对大多数业务足够用,但如果业务阈值恰好落在0.6到0.65这种样本密集区间,就有边界样本会被翻转。
4.2 量化误差的修正与验证
了解量化误差的大小之后,我的处理原则是:先判断业务对精度的敏感度,再分情况修正。
修正顺序基本是按成本从低到高排的。第一步,检查量化校准集是否覆盖了真实场景。校准图只有几十张,和实际业务图片分布差异大,量化误差就会明显偏大;换成一个几百张、覆盖各种光照场景角度的校准集,偏差通常能压小一半。第二步,把特征输出的L2归一化从模型里挪到后处理代码中。这一步听着奇怪,但在某些模型里,最后一个归一化算子在量化时被近似得更粗暴,导致每一条特征向量的模长都出现细微波动,挪到外部之后,距离计算的输入就稳定了。第三步,对高安全场景直接保留fp32模型,不强行上int8;很多业务其实不需要极限体积,多出的几MB换来精度稳定是划算的。
上线前我还习惯做一个“距离自检”小工具:用同一批样本分别跑fp32模型和int8模型,输出每个样本的相似度差值,如果任一差值超过0.02就报警,提示需要复核量化配置。这个工具能在一轮版本迭代后立刻暴露问题,不至于等用户反馈才知道量化后距离偏了。
5. 三个最容易被忽略的坑:层折叠、内存拷贝与线程配置
5.1 L2归一化层被折叠,特征向量突然“不归一”
这个坑我印象最深,因为它的表现特别隐匿。模型在训练端是带L2Normalize层的,原始框架跑出来特征向量模长严格等于1,但部署到mnn后,我用同一张图跑,打印输出向量的模长,发现被明显拉离了1。
排查链路是这样的:先是怀疑输入预处理不对,反复检查mean和std,没发现问题;然后怀疑模型转换参数,改了几轮也无果;最后用框架的逐层输出工具对比了最后几个算子的输出,才发现问题出在图优化环节——转换工具把L2Normalize这类归一化算子折叠或者重构了,输出的数值分布被改变了。
解决方式很干脆:导出模型前就把L2归一化层删掉,放到后处理代码里做。归一化本身只是一次开方和除法运算,放在模型外不会带来性能损失,还能彻底绕开算子优化导致的不一致。这个经验后面我不止一次用到,凡是模型最后一层带着纯数学变换的,除非业务方强烈要求,我都会建议挪到后处理里,让推理输出保持“原始logits”,后续怎么变换都在代码里可控。
5.2 输出Tensor读不到数据:内存拷贝姿势不对
另一个常见的坑是读输出特征时直接写了这段代码:
mnn::Tensor* output = interp->getSessionOutput(session); float* feature = output->host<float>(); // 拿到的可能是一堆乱码这里拿不到正确数据,是因为很多情况下输出张量只是设备端内存的wrapper,不是host可读的连续内存。尤其在启用了GPU后端或者某些特定后端时,直接访问host<float>()可能返回空指针,或者拿到未经拷贝的悬空地址。
正确做法是在代码里显式做一次host拷贝,用一个新的Tensor对象按CAFFE布局申请host内存,再把输出拷进去。这多出来的一次拷贝成本极小——512维float才2KB,但换来的是稳定性和可维护性,非常划算。这个坑之所以普遍,是因为有些版本的示例代码省略了host拷贝这一步,让人误以为输出可以直接读。
顺带提一个和Session相关的并发问题:mnn的Session对象不是线程安全的,多个线程同时调用runSession操作同一个Session,轻则结果混乱,重则直接崩溃。我的做法是保持单Session线程独占,外部用锁串行化推理,或者干脆为每个工作线程创建独立的Session。
5.3 线程数不是越大越好
调优阶段还有一个反直觉的现象:把推理线程数从4调到8,单次推理耗时不减反增。
原因在于这个模型输入只有112×112×3,计算量不够大,线程创建和同步的开销占比被放大了;再加上手机CPU大小核的调度策略,线程多了反而容易把一部分任务调度到小核上,整体表现甚至更差。
正确的调参方式是做梯度测试:在目标真机上分别用1、2、3、4、6、8个线程跑同一个任务,各跑20次取中位数,找到耗时拐点。以我的模型为例,4线程通常就是甜点位,2线程更省电但稍微慢一点,6线程以后收益锐减。还要注意一点,不要拿第一次推理的耗时做决策,模型和内存有预热过程,先跑3次再计时,数据才可信。
6. 封装成可复用模块:接口设计要点与落地效果
6.1 接口怎么拆才不容易崩
模块做完以后,我把它整理成了一个独立的FeatureEngine类,业务方只需要关心三个动作:加载模型、抽特征、算距离。接口头文件大致长这样:
class FeatureEngine { public: bool loadModel(const std::string& modelPath, int numThread); bool extractFeature(const ImageData& img, std::vector<float>& feature); float compare(const std::vector<float>& a, const std::vector<float>& b); private: std::shared_ptr<mnn::Interpreter> interpreter_; mnn::Session* session_; std::mutex mutex_; };这样拆有几个好处。模型加载会和特征抽取分离,loadModel可以放到App启动后的空闲时间异步执行,避免占用用户操作路径;extractFeature内部对Session做互斥保护,外部不清洗调用方该不该加锁;compare是纯CPU计算,与模型无关,测试时甚至可以脱离模型单独单测。
特征库的管理建议单独做一个FeatureDB模块,用mmap把特征表直接映射到进程地址空间,查询时零拷贝访问。这样批量比对的2到5毫秒耗时,绝大部分都花在真正的矩阵乘上,而不是数据搬运上。
6.2 实测性能参考与落地效果
模块上线后我在真机上采了一组参考数据,输入是一张112×112的RGB图,模型是一个特征抽取网络,输出512维向量。
| 测试项 | 数值 |
|---|---|
| 单次特征提取(2线程) | 60~80ms |
| 单次特征提取(4线程) | 35~45ms |
| 批量比对1万条(512维) | 2~5ms |
| 模型加载耗时 | 50~100ms |
| 推理常驻内存 | 约200MB以内 |
批量比对1万条的性能证明了一件事:距离计算在移动端根本不是性能瓶颈。真正需要花心思优化的是特征抽取的耗时,它直接影响每一个涉及实时比对的交互。
落地之后还有一个小发现:不要在每次比较前都对特征库做归一化预处理,应该在上库时就归一化并保存好,比对时只归一问询向量,这样又省掉一次全库遍历。这个思路和“训练时怎么用、推理时保持一致”的原则是一脉相承的。
做完这个模块再回头看,“距离实现”真正的难点反而不是距离本身。一个余弦公式哪有难度,难的是让模型在端上稳定跑起来,让输出的特征和训练时保持同一个语义空间,再让量化、线程、内存这些工程细节不去干扰最后那个阈值判断。如果让我给后来者一句经验,那就是先别急着写compare函数,先把模型转换后的特征一致性验证做好,再开始调距离;特征都不准,阈值怎么调都是假的。
另外一个小技巧:开发调试阶段,我把每一张图的特征向量落盘成文本,跑完批量比对后直接人工检查Top5的相似度分布。这个土办法比盯着一堆曲线图更直观,能快速发现距离实现里的逻辑错误,也方便在团队协作时让算法同学直接看到数值是否合理。