摘要

这次优化针对的是一个本地 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

这组数据几乎已经把方向说透了:

  1. ObjectBox 不是当前吞吐瓶颈。
  2. caption 不是第一优先级。
  3. 真正该打的是 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 一遍。

优化后的逻辑顺序变成了三段式:

  1. 优先使用 PhotoEntity.width/height
  2. 若缺失,则用 image 包的 startDecode 读取头信息
  3. 只有前两步都拿不到时,才 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、文件系统往返、重复读取,完全在热路径上。

所以我做了三件事:

  1. 给 FaceEmbeddingService 增加 embedFaceCropBytes(...)
  2. 给 OnnxFaceEmbeddingService 和 MobileCLIP fallback 都补上 bytes 路径
  3. 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,很容易得出片面的结论;但从四刀整体来看,最大的收益其实是瓶颈不断收敛:

  1. 第一刀确认了默认输入策略不该切成原图优先。
  2. 第二刀确认了辅助分析图不是该删的东西。
  3. 第三刀把无意义的尺寸整图 decode 清零。
  4. 第四刀把人脸裁脸临时文件中转基本拿下。

这四刀做完之后,主瓶颈已经从一团模糊的 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 才是。

结尾

这四刀做完,项目并没有“瞬间起飞”,但它从“哪里都像瓶颈”变成了“我们知道下一个该打哪里”。对性能优化来说,这比一次偶然跑快更重要。

Logo

腾讯云面向开发者汇聚海量精品云计算使用和开发经验,营造开放的云计算技术生态圈。

更多推荐