摘要

上一篇我已经把前四刀做到了一个比较清晰的状态:

  • 第一刀确认默认输入策略继续保留 thumbnail_first
  • 第二刀确认默认辅助图策略继续保留 always_compress
  • 第三刀把 analysisDecodeAvgMs 打到 0.0
  • 第四刀去掉了裁脸临时文件中转,明显压低了 faceTempAvgMs 和 faceEmbedAvgMs

但第四刀之后,问题并没有彻底结束。

新的 profiling 很快暴露出一个更真实的热点:

face 链路的大头,已经不再是裁脸临时文件 I/O,而是源图 decode、旧脸读取和 Isar 写回边界。

所以这篇文章,我把后续第 5 到第 8 刀完整记下来,重点讲三件事:

  1. 为什么第五刀继续打 faceDecodeSrc 是对的
  2. 为什么第七刀虽然思路合理,但应该果断止损
  3. 为什么第八刀虽然验证了 “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 为什么我认为这刀应该撤掉

这一刀的问题不是“完全错”,而是:

复杂度增加了,但收益没有成立。

最直观的点有两个:

  1. faceStoreIsarAvgMs 几乎没动
    说明 Isar 的主成本可能并不在 “删光再插” 这件事上。

  2. 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 增加了这些字段:

  • faceStoreDeleteMs
  • faceStorePutMs
  • faceStaleIds
  • faceEmbedFaces
  • faceEmbedBytes
  • faceEmbedIsar

这样下一轮日志就能回答:

  • 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.0
  • faceStaleIdsAvg=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.1
  • faceStoreIsarAvgMs: 181 -> 167

说明这条局部验证是成立的。

但同时:

  • faceStoreAvgMs: 508 -> 512
  • wallAvgMs: 5473 -> 5564
  • wallP90Ms: 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 路径,但当前端到端慢,不是主要由这条路径决定。


六、什么时候该收手,去做别的工作

做到第八刀之后,我对这个方向的判断已经很明确了:

这条优化线已经进入“继续抠也只能得到局部收益”的阶段。

这时候正确做法不是继续硬抠,而是:

  1. 保留已经建立好的 profiler
  2. 把这条线降级成观察项
  3. 回到更大的热点上

从当时的数据看,更值得重新回去看的其实是:

  • auxAvgMs
  • loadAvgMs
  • 更大的 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
  • 把这条线降级成观察项

然后把时间投入到更值得做的工作上。

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐