问题与范围
图像处理、视频帧处理和实时预览通常不会在启动时把所有 Metal pipeline 一次性建完。某个 kernel、函数常量或输出组合第一次被请求时,系统才按需创建对应的 MTLComputePipelineState。
最直观的实现是一个字典:命中就返回,未命中就创建后写回。单线程下它足够正确;但冷启动恰好容易有多个请求同时发现同一个缓存键不存在。它们会一起进入创建路径,令同一份工作重复发生,也让 reset 与旧创建的交错难以解释。
本文讨论计算 pipeline 的并发缓存合同,不讨论滤镜效果、相机采集、视频编码或跨设备性能数字。
先给结论
- 一个 pipeline identity 至少要有“不存在、正在创建、已完成”三种状态;普通缓存字典只表达其中两种。
- 同一个 identity 的后来者应等待首个创建任务,并接收同一个成功结果或失败结果;不同 identity 不应因此串行。
- 创建要在缓存锁外进行;等待者也不能带着缓存锁等待,否则完成阶段可能形成互相等待。
- reset 应隔离旧结果对新缓存的写入,而不是让已在等待的调用者失去明确结果。
- identity 的定义必须覆盖会改变 pipeline 的编译输入;键过粗会错误共享,键过细会失去合并效果。
为什么“有缓存”仍会重复创建
下面的写法只处理了已完成结果:
if let pipeline = cache[identity] {
return pipeline
}
let pipeline = try createPipeline(identity)
cache[identity] = pipeline
return pipeline
当线程 A 与线程 B 同时执行到第一行,二者都可能看到空缓存,然后都开始创建。问题并不是字典线程安全就能解决:即使读取与写入都加锁,只要“检查为空”和“登记创建者”不是同一个受保护的状态转换,重复创建仍可能发生。
正确的模型是为每个 identity 记录一项在途任务:
线程 A:X 不存在 → 登记正在创建 X → 创建
线程 B:X 正在创建 → 等待 A 的结果
线程 C:X 正在创建 → 等待 A 的结果
这里的目标不是把全部请求排成一列,而是只合并相同 identity。不同 identity 仍可独立创建,避免一个慢 kernel 阻塞整个冷启动路径。
锁只保护状态转换,不包住 Metal 创建
一个可靠的最小流程可以写成:
- 短暂持有缓存锁,检查已完成缓存。
- 若同一 identity 已有在途任务,取出它;若没有,登记新的在途任务。
- 释放缓存锁。
- 创建者在锁外调用 Metal 创建;等待者只等待该任务自己的结果。
- 创建结束后唤醒全部等待者,并在当前 generation 仍有效时写入缓存。
这避免了两类问题:长时间持有全局缓存锁会让无关 identity 彼此阻塞;等待者持锁等待则可能阻止创建者回写结果并通知等待者。
在途任务必须传播 Result,而不仅是成功值。这样一批等待者会一起拿到同一个 pipeline,或一起收到同一个错误;失败不会把等待者留在未知状态。失败后的下一次请求可以重新尝试创建,但当前这批请求必须先被完整收口。
递归和 reset 是两个常被漏掉的边界
如果创建 X 的过程又在同一线程请求 X,等待自己完成会形成确定性的自死锁。因此在途任务应识别创建线程,并对这种递归返回明确的配置错误。
reset 也不只是清空字典。考虑以下交错:
旧创建开始
reset 清空缓存
新请求创建同一 identity
旧创建结束并写回缓存
如果没有额外边界,旧结果会污染 reset 后的新状态。一个直接的做法是引入 generation:创建开始时记录 generation;结束时只有 generation 未变化才能写入当前缓存。旧任务仍应向已经等待它的调用者交付结果,但不再拥有修改新缓存的资格。
这区分了两件事:对已经接受的调用负责,以及对当前资源世代负责。二者不必互相牺牲。
源码案例与验证范围
Harbeth 的 Device.makePipelineState(for:create:) 先检查已完成的 identity 缓存,再查询 pipelineCreations 中同 identity 的在途创建。相同 identity 的后来者等待同一 ComputePipelineCreation;不同 identity 不持有共同缓存锁完成 Metal 创建。创建完成后,只有 generation 未变化,结果才会写入缓存。
ComputePipelineCreation 用 NSCondition 保存一次性的 Result 并唤醒等待者;同线程递归等待返回配置错误。相应的 PipelineConcurrencyTests 本轮在 macOS arm64e 运行 6 项、0 失败,覆盖:16 个并发冷请求只得到 1 个 unique pipeline、reset 不污染新 generation、失败可重试、递归不死锁、不同 identity 不等待慢创建,以及失败唤醒所有等待者。
测试输出曾记录一次约 270ms 的冷启动探针,但它只属于该测试环境和该次运行,不能外推为通用性能结论。这里被验证的是并发结果与状态边界,而不是设备级性能承诺。
Kakapos 在这个问题中只提供媒体层的边界案例:CameraEngine 创建 preview 与 recording controller 时将 controlsSourceLifecycle 设为 false,由 CameraEngine 管理共享 source 的启动。采集、录制和停止生命周期仍属于媒体编排层;逐帧 GPU 后端不应反过来接管它们。
小结
Pipeline cache 一旦面对并发首次请求,就不再只是一个容器,而是一份小型并发协议。把“正在创建”建模出来,并明确以下规则,才能让冷启动可解释、可测试:
- 谁登记创建者;
- 同键后来者如何加入;
- 失败如何传递和重试;
- reset 如何隔离旧结果;
- 递归等待如何终止;
- 不同 identity 如何保持并行。
你在 Metal pipeline 或其他 GPU 资源缓存中,遇到过重复创建、reset 竞态或递归死锁吗?欢迎讨论 identity 的边界、等待机制与失败处理策略。