四刀把 AI 相册主链路从“误判瓶颈”打到真实热点
这次优化针对的是一个本地 AI 相册项目,技术栈是 Flutter + Isar + ObjectBox + ML Kit + MobileCLIP + ONNX。一开始最容易被怀疑的是 ObjectBox、ANN、caption,甚至是模型推理本身;但真实 profiling 结果表明,主链路的大头其实集中在图片读取、辅助分析图、尺寸判断和人脸链路。
摘要
这次优化针对的是一个本地 AI 相册项目,技术栈是 Flutter + Isar + ObjectBox + ML Kit + MobileCLIP + ONNX。一开始最容易被怀疑的是 ObjectBox、ANN、caption,甚至是模型推理本身;但真实 profiling 结果表明,主链路的大头其实集中在图片读取、辅助分析图、尺寸判断和人脸链路。
本文记录我用“四刀”逐步收敛瓶颈的过程:先验证输入策略,再验证辅助图策略,再打掉无意义的整图 decode,最后移除人脸裁脸嵌入中的临时文件中转。结论很简单:不要一上来换库,也不要把 ANN 当性能万能药,先把热路径真正测清楚。
一、初始画像:真正的瓶颈不在 ObjectBox
最初 final.completed-only 的关键指标如下:
| 指标 | 数值 |
|---|---|
| loadAvgMs | 1161 |
| auxAvgMs | 1012 |
| faceAvgMs | 938 |
| faceStoreAvgMs | 266 |
| analysisDecodeAvgMs | 234 |
| objectBoxAvgMs | 5.8 |
这组数据几乎已经把方向说透了:
- ObjectBox 不是当前吞吐瓶颈。
- caption 不是第一优先级。
- 真正该打的是 load -> aux -> analysisDecode -> face 这条链。
所以后续四刀,我都遵循同一个原则:一次只动一个变量,用统一埋点看均值和长尾,不靠感觉做优化。
二、第一刀:给 _prepareAnalysisInput 做策略开关,而不是直接改默认行为
第一刀针对的是图片输入策略。我没有一上来改默认行为,而是先把它做成三档可切换:
- thumbnail_first
- original_first
- thumbnail_timeout
同时把这些字段打进了单张日志和汇总日志:
- inputStrategy
- thumbnailAttempted
- thumbnailTimedOut
- fallbackToOriginal
- fallbackReason
1.1 A/B 结果
thumbnail_first 的 final.completed-only:
| 指标 | 数值 |
|---|---|
| loadAvgMs | 1277 |
| decodeAvgMs | 54.7 |
| auxAvgMs | 1068 |
| faceAvgMs | 976 |
| wallAvgMs | 5803 |
| wallP90Ms | 7412 |
thumbnail_timeout(120ms) 的 final.completed-only:
| 指标 | 数值 |
|---|---|
| loadAvgMs | 734 |
| decodeAvgMs | 1582 |
| auxAvgMs | 1425 |
| analysisDecodeAvgMs | 451 |
| wallAvgMs | 6646 |
| wallP90Ms | 9125 |
| thumbTimedOut | 59 |
| fallbackToOriginal | 59 |
original_first 的阶段性滚动汇总也呈现同样趋势:前置 load 虽然更低,但后续 decode 和长尾明显更差,wallP90Ms 一直偏高。
1.2 结论
默认策略继续保留 thumbnail_first。
原因并不复杂:thumbnail_first 并不是让前置读取最小,而是让后面的 decode 成本足够轻。而 thumbnail_timeout(120ms) 在这套环境里几乎退化成“多试一次缩略图然后还是回原图”,最终整体更慢。
三、第二刀:验证辅助分析图到底是负担,还是护城河
第二刀针对 _createAuxiliaryAnalysisFile。这一步最容易产生一个直觉误区:既然压缩辅助图要花时间,那是不是直接拿原图给 OCR、ML Kit 和 face 用就更快?
所以我把辅助图策略做成了两档:
- always_compress
- use_original
同时把这些字段写进 profiler:
- auxiliaryStrategy
- auxiliarySource
- auxiliaryCreated
另外,我还顺手把 caption 从辅助临时图解耦回了原图,避免异步 caption 反向绑住辅助图生命周期。
2.1 A/B 结果
always_compress 的 completed-only:
| 指标 | 数值 |
|---|---|
| auxAvgMs | 1396 |
| analysisDecodeAvgMs | 182 |
| faceAvgMs | 1275 |
| faceStoreAvgMs | 720 |
| wallAvgMs | 6258 |
| wallP90Ms | 7833 |
use_original 的 completed-only:
| 指标 | 数值 |
|---|---|
| auxAvgMs | 0.0 |
| analysisDecodeAvgMs | 1471 |
| faceAvgMs | 2544 |
| faceStoreAvgMs | 1115 |
| wallAvgMs | 8337 |
| wallP90Ms | 14594 |
2.2 结论
默认不要切到 use_original,继续保留 always_compress。
这轮实验说明了一件非常重要的事:辅助分析图压缩不是纯负担,它实际上在替后面的 ML Kit 和 face 链路“挡灾”。你省掉的 auxAvgMs,会被更重的原图 decode 和人脸链路成倍还回来。
四、第三刀:把 _readImageDimensions 从“整图解码”改成“轻量拿尺寸”
第三刀打的是一个非常典型、但非常容易被忽略的热路径:为了拿宽高,默认把整张图 decode 一遍。
优化后的逻辑顺序变成了三段式:
- 优先使用 PhotoEntity.width/height
- 若缺失,则用 image 包的 startDecode 读取头信息
- 只有前两步都拿不到时,才 full decode
3.1 结果
第三刀之后,两组日志中:
- final.completed-only 的 analysisDecodeAvgMs=0.0
- final 的 analysisDecodeAvgMs=0.0
3.2 结论
第三刀是完全打中的。
这说明“为了拿尺寸去整图 decode”这条成本已经基本从主账本里移除了。这个点可以正式从主战场撤下,不需要再反复怀疑它。
五、第四刀:移除人脸裁脸嵌入中的临时文件中转
第四刀进入 face_pipeline_service.dart。优化前的人脸热路径是这样的:
crop -> 写 temp jpg -> embedding service 再从 File 读回 -> delete
这条链的问题很明显:磁盘 I/O、文件系统往返、重复读取,完全在热路径上。
所以我做了三件事:
- 给 FaceEmbeddingService 增加 embedFaceCropBytes(...)
- 给 OnnxFaceEmbeddingService 和 MobileCLIP fallback 都补上 bytes 路径
- FacePipelineService 直接把裁脸后的 jpeg bytes 传给 embedding service,不再落临时文件
5.1 对比结果
拿第三刀后的 always_compress completed-only 当参考:
| 指标 | 第三刀 | 第四刀 |
|---|---|---|
| faceTempAvgMs | 124 | 21.6 |
| faceEmbedAvgMs | 196 | 76.4 |
| faceStoreAvgMs | 616 | 389 |
| faceDecodeSrcAvgMs | 120 | 124 |
5.2 结论
第四刀非常明确地砍中了裁脸临时文件 I/O。
但它也暴露了一个更真实的新瓶颈:faceDecodeSrcAvgMs 基本没动,甚至略高。这意味着第四刀之后,face 链路的大头已经不再是“临时文件往返”,而是:
- 源图 decode
- 旧脸读取
- 一部分 Isar 持久化成本
换句话说,第四刀没有把整条 completed 主链路一起打下来,但它把错误的大头拿掉了,让真正的大头浮出来了。
六、四刀之后,我得到的不是“更快”,而是“更清楚”
如果只看某一轮的 wallAvgMs,很容易得出片面的结论;但从四刀整体来看,最大的收益其实是瓶颈不断收敛:
- 第一刀确认了默认输入策略不该切成原图优先。
- 第二刀确认了辅助分析图不是该删的东西。
- 第三刀把无意义的尺寸整图 decode 清零。
- 第四刀把人脸裁脸临时文件中转基本拿下。
这四刀做完之后,主瓶颈已经从一团模糊的 face / aux / load,收敛成了更明确的方向:
- FaceCropUtil.decodeSourceImage
- faceReadAvgMs
- faceStoreIsarAvgMs
- 以及样本噪声里的 load / aux
七、这次优化最重要的经验
这次性能优化最值得总结的,不是某一项指标降了多少,而是下面这三点:
第一,不要在没有 profiling 的情况下先怀疑数据库和 ANN。
这轮里 objectBoxAvgMs 一直都很低,真正的成本并不在那里。
第二,不要把“前面省掉的时间”当成真实收益。
thumbnail_timeout 和 use_original 都证明了一件事:你在前面省下来的,很可能会在后面成倍还回去。
第三,热路径优化最重要的是把变量拆开。
每一刀只改一个点,才能知道到底是哪一段真的打中了,哪一段只是看起来像优化。
八、下一步计划
如果继续做第五刀,我会直接进入 FaceCropUtil.decodeSourceImage 和 face 源图 decode 路径,优先级高于继续纠结辅助图策略。因为第四刀之后,数据已经说明:face temp 和 embed I/O 已经不是主矛盾了,源图 decode 才是。
结尾
这四刀做完,项目并没有“瞬间起飞”,但它从“哪里都像瓶颈”变成了“我们知道下一个该打哪里”。对性能优化来说,这比一次偶然跑快更重要。
更多推荐
所有评论(0)