四刀继续深挖 AI 相册性能:从人脸源图 decode 到 Isar 写回边界
摘要
上一篇我已经把前四刀做到了一个比较清晰的状态:
- 第一刀确认默认输入策略继续保留
thumbnail_first - 第二刀确认默认辅助图策略继续保留
always_compress - 第三刀把
analysisDecodeAvgMs打到0.0 - 第四刀去掉了裁脸临时文件中转,明显压低了
faceTempAvgMs和faceEmbedAvgMs
但第四刀之后,问题并没有彻底结束。
新的 profiling 很快暴露出一个更真实的热点:
face 链路的大头,已经不再是裁脸临时文件 I/O,而是源图 decode、旧脸读取和 Isar 写回边界。
所以这篇文章,我把后续第 5 到第 8 刀完整记下来,重点讲三件事:
- 为什么第五刀继续打
faceDecodeSrc是对的 - 为什么第七刀虽然思路合理,但应该果断止损
- 为什么第八刀虽然验证了 “embedding 回写 Isar 会拖慢 put 路径”,但仍然不该直接改成默认行为
如果你也在做端侧 AI 流水线、图片处理链路或者 Flutter 本地多阶段分析,这几刀的经验会非常有参考价值。
一、第五刀:把人脸源图 decode 从磁盘二次读回,改成优先吃内存 bytes
1.1 刀法
第四刀结束之后,我已经把这条链路削掉了:
crop -> 写 temp jpg -> 再从 File 读回来做 embedding -> delete
但是 profiling 还在告诉我:
faceDecodeSrcAvgMs仍然是 face 子段里的大项analysisDecodeAvgMs已经被第三刀打成0.0
这意味着新的问题不在“为了拿宽高又 decode 一遍”,而在:
face pipeline 仍然会把分析图从磁盘读回来,再做一次源图 decode。
所以第五刀做得很窄:
- 辅助分析图在压缩后,除了落一个临时文件,还顺手保留压缩后的 bytes
FacePipelineService新增imageBytes参数- face pipeline 优先对这份内存 bytes 做 decode,而不是再对分析图文件
readAsBytes()
本质上就是一句话:
让 face pipeline 复用已经生成好的压缩图内存数据,避免“分析图再从磁盘读回来 decode 一次”。
1.2 关键结果
第五刀之后,final.completed-only 的核心指标变成:
| 指标 | cut4 | cut5 |
|---|---|---|
faceDecodeSrcAvgMs |
124 |
35.9 |
faceTempAvgMs |
21.6 |
18.8 |
faceEmbedAvgMs |
76.4 |
118 |
faceStoreAvgMs |
389 |
526 |
wallAvgMs |
5347 |
5473 |
1.3 结论
第五刀是有效的,而且是明确打中目标点的。
最关键的证据不是总 wall,而是:
faceDecodeSrcAvgMs 从三位数高位直接压到了几十毫秒。
这说明这一刀确实把 “分析图二次读盘 + decode” 的成本打掉了。
但这刀同时也暴露了一个更重要的事实:
源图 decode 已经不是 face 链路的一号问题了。
第五刀之后,真正开始往前冒头的热点变成了:
- 旧脸读取
- Isar 写回
- 剩余的人脸检测 / 裁剪 / embedding 组合成本
也就是说,第五刀不是终点,而是把 face pipeline 的主矛盾又往后推了一步。
二、第六刀:把旧脸读取从“拉整条 FaceEntity”改成轻量属性读取
2.1 为什么要打这一刀
第五刀之后,completed-only 里出现了一个非常刺眼的数字:
faceReadAvgMs=149
而 FaceEntity 里本来就有一个很重的字段:
embedding: List<double>?
如果 _loadExistingFaces() 每次都把整条 FaceEntity 读出来,那实际上是在为“删除旧脸 / 清理 debug crop”这件事,顺手把 embedding 大字段也一起拉进来了。
这是非常典型的读取过量问题。
2.2 刀法
第六刀依然很保守,没有去动 face 主体逻辑,只做了两件事:
- 默认只查旧脸的
id - 只有开了 debug crop 时,才额外查
debugCropPath
也就是把旧脸读取改成:
idProperty().findAll()- 必要时
debugCropPathProperty().findAll()
而不是继续 findAll() 整条 FaceEntity
2.3 关键结果
第六刀之后,对比 cut5:
| 指标 | cut5 | cut6 |
|---|---|---|
faceReadAvgMs |
149 |
113 |
faceStoreAvgMs |
526 |
481 |
faceStoreIsarAvgMs |
192 |
185 |
faceDecodeSrcAvgMs |
35.9 |
37.8 |
wallAvgMs |
5473 |
5408 |
2.4 结论
这刀属于局部成功。
它至少证明了一件事:
“过量读取旧脸”这条成本是真实存在的。
但它也没有把整体主链路彻底打穿,因为:
faceReadAvgMs下来了faceDecodeSrcAvgMs已经不是大头- 但
faceStoreIsarAvgMs仍然高 - 总体
wall没有出现决定性改善
所以第六刀的价值不在于“直接让全链路飞起来”,而在于:
它把 face 链路的大头进一步收敛到了写事务边界。
三、第七刀:尝试复用旧 face id,结果不成立,应该止损
3.1 当时的想法
既然第六刀之后,faceStoreIsarAvgMs 还高,一个很自然的想法就是:
- 能不能不要每次都 “删光旧脸 + 全量重插”
- 能不能按
faceIndex -> oldId复用旧记录 - 只删除本次确实已经不存在的
staleIds
这在数据库写入层面看起来很合理。
3.2 实际做法
第七刀做了这些事:
- 旧脸读取额外查
faceIndexProperty() - 构建
faceIndex -> oldId - 新
FaceEntity优先复用旧 id - 写事务里改成 “只删 staleIds,再 putAll(results)”
3.3 结果
但这轮结果并不好:
| 指标 | cut6 | cut7 |
|---|---|---|
faceReadAvgMs |
113 |
183 |
faceStoreIsarAvgMs |
185 |
184 |
faceStoreAvgMs |
481 |
557 |
wallP90Ms |
7943 |
7590 |
3.4 为什么我认为这刀应该撤掉
这一刀的问题不是“完全错”,而是:
复杂度增加了,但收益没有成立。
最直观的点有两个:
-
faceStoreIsarAvgMs几乎没动
说明 Isar 的主成本可能并不在 “删光再插” 这件事上。 -
faceReadAvgMs反而明显变差
很可能是为了支持faceIndex -> oldId,多做了额外读取,结果读的成本比省下来的写入成本更大。
更重要的是,这刀还引入了一个正确性假设:
默认同一张图的人脸检测顺序足够稳定,faceIndex 可以可靠复用。
一旦这个假设不稳,这刀不仅没有收益,还会引入潜在正确性风险。
3.5 结论
第七刀是一个很典型的工程教训:
思路看起来合理,不代表账本上真的成立。
所以这刀我最终选择了撤回,不让它进入默认实验线。
四、第八刀:给 faceStoreIsar 做拆账,并验证 “embedding 不回写 Isar” 是否值得默认启用
4.1 为什么这刀方向是对的
到第七刀结束时,路线已经很清楚了:
- decode 线已经基本被打下去
- temp file 中转已经被打下去
- 旧脸读取有过量读取问题,但也不是最后大头
- Isar 写回边界看起来越来越像真热点
而且从仓库架构看,这个方向是顺着设计走的:
Isar承载业务真相层ObjectBox承载向量索引层face embedding本来就已经同步写入 ObjectBox
所以第八刀不是拍脑袋换存储,而是做一个非常窄的 A/B:
把 FaceEntity.embedding 暂时从 Isar 主热路径里移掉,只写 ObjectBox,看局部写入成本会不会明显下降。
4.2 刀法
第八刀分两部分:
第一部分:补拆账埋点
给 face store 增加了这些字段:
faceStoreDeleteMsfaceStorePutMsfaceStaleIdsfaceEmbedFacesfaceEmbedBytesfaceEmbedIsar
这样下一轮日志就能回答:
- delete 贵不贵
- put 贵不贵
- stale id 多不多
- embedding 真正写了多少
第二部分:增加一个窄开关
新增:
FACE_WRITE_EMBEDDING_TO_ISAR=true|false
默认还是 true,只用于实验。
4.3 基线组结果
FACE_WRITE_EMBEDDING_TO_ISAR=true 的 final.completed-only:
| 指标 | 数值 |
|---|---|
faceStoreIsarAvgMs |
181 |
faceStoreDeleteAvgMs |
0.0 |
faceStorePutAvgMs |
17.8 |
faceStaleIdsAvg |
0.0 |
faceEmbedFacesAvg |
0.5 |
faceEmbedBytesAvg |
2112.0 |
faceStoreAvgMs |
508 |
wallAvgMs |
5473 |
wallP90Ms |
7544 |
这组数据已经说明一个关键信息:
删除旧脸不是热点。
因为:
faceStoreDeleteAvgMs=0.0faceStaleIdsAvg=0.0
也就是说,这一轮里真正变化的核心是 put 路径,而不是 delete 路径。
4.4 no-isar-embedding 组结果
FACE_WRITE_EMBEDDING_TO_ISAR=false 的 final.completed-only:
| 指标 | 数值 |
|---|---|
faceStoreIsarAvgMs |
167 |
faceStorePutAvgMs |
15.1 |
faceStoreAvgMs |
512 |
wallAvgMs |
5564 |
wallP90Ms |
7813 |
4.5 这一刀最重要的结论
这轮实验得出了一个非常干净的工程结论:
不把 FaceEntity.embedding 写回 Isar,确实能降低 Isar put 成本;但它没有带来端到端收益。
可以看到:
faceStorePutAvgMs: 17.8 -> 15.1faceStoreIsarAvgMs: 181 -> 167
说明这条局部验证是成立的。
但同时:
faceStoreAvgMs: 508 -> 512wallAvgMs: 5473 -> 5564wallP90Ms: 7544 -> 7813
这就意味着:
embedding 回写 Isar 会拖慢 put 路径,但它不是当前 wall 尾延迟的主决定项。
4.6 为什么这刀不能直接落成默认
第八刀最容易犯的错误就是:
“既然局部 put 成本下降了,那就直接把 FACE_WRITE_EMBEDDING_TO_ISAR=false 设成默认。”
但这是不对的。
因为当前证据只能支持:
- 局部写入成本下降
不能支持:
- 总体链路收益成立
所以第八刀正确的落地方式应该是:
- 保留拆账埋点
- 保留开关
- 默认值继续保持
true - 不要直接把 no-isar-embedding 升成默认方案
五、这四刀串起来看,真正的意义不是“更快”,而是“更清楚”
如果只盯某一轮 wall 指标,很容易得出片面的结论。
但把第 5 到第 8 刀串起来看,会发现这几轮真正有价值的不是某个局部数字下降了多少,而是:
整个 face 链路的主瓶颈在不断收敛。
第五刀告诉我
分析图二次读盘 decode 是真热点,而且值得打。
第六刀告诉我
过量读取旧脸是有成本的,但它不是最后的大头。
第七刀告诉我
看起来高级的写回策略,不一定真的值钱;复杂度增加了,收益不成立,就该止损。
第八刀告诉我
embedding 大字段确实会拖慢 Isar put 路径,但当前端到端慢,不是主要由这条路径决定。
六、什么时候该收手,去做别的工作
做到第八刀之后,我对这个方向的判断已经很明确了:
这条优化线已经进入“继续抠也只能得到局部收益”的阶段。
这时候正确做法不是继续硬抠,而是:
- 保留已经建立好的 profiler
- 把这条线降级成观察项
- 回到更大的热点上
从当时的数据看,更值得重新回去看的其实是:
auxAvgMsloadAvgMs- 更大的
isarAvgMs - 以及 completed 主链路之外的整体调度与样本分布
所以第八刀之后,我没有继续围着 faceStoreIsar 死磕,而是选择收口。
这不是半途而废,而是正确止损。
七、最终结论
把第 5 到第 8 刀压缩成一句话,就是:
第五刀打中了 face 源图 decode, 第六刀验证了旧脸过量读取确实有成本, 第七刀收益不成立应当撤回, 第八刀证明了 Isar embedding 回写会拖慢 put 路径,但不足以支撑默认切换。
如果你也在做类似的本地 AI 流水线优化,我最想强调的不是某一项指标,而是这个方法论:
1. 一次只改一个变量
不要让多个优化混在一起,否则你永远不知道到底是谁生效了。
2. 局部收益不等于整体收益
putAll 变快,不代表 wallP90 就会一起变快。
3. 敢于止损,和敢于继续优化一样重要
像第七刀这种“思路合理但收益不成立”的尝试,越早撤掉越好。
4. 最终目标不是让某个子段好看,而是找出真正影响全链路的头部瓶颈
这才是性能优化里最值钱的产物。
结尾
做到这里,这条 face 性能优化线我会先冻结在下面这个状态:
- 保留 cut8 的埋点
- 保留
FACE_WRITE_EMBEDDING_TO_ISAR开关 - 默认不切到
no-isar-embedding - 把这条线降级成观察项
然后把时间投入到更值得做的工作上。
AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。
更多推荐


所有评论(0)