iOS 26 帧耗时来源:FrameLifetime 并入 FrameUpdate

问题

Owner 在设备 A(iOS 26,120 Hz 高刷屏)上测目标 App A,帧耗时(FTime)整场为空,卡顿/平滑度/1% Low 只来自启动后约 1–1.6 秒的 CoreProfile 片段。

诊断证据(E3)

• 190 秒会话的 ActivityTrace 采集普查:收到 70,147 条 signpost,FrameLifetime 命中 0 条;同期 com.apple.coreanimation / IOMFBServer / FrameUpdate 2,053 条。逐帧来源因此始终 primary-insufficient-evidence,帧耗时列 190 行全空。

• 直接用 pymobiledevice3 11.3.1 ActivityTraceTap(与产品相同配置:onlySignposts=1、predicate="")在设备 A 上抓 150 秒、Owner 期间滑动页面:FrameUpdate 1,046 条(IOMFBServer 1,035 + IOMFBServer.Stalls 11),全部来自 backboardd,event_type=Event,格式串固定为:

base_mct=%{signpost.generator:base_mct}llu  args1=…#llx args2=…#llx contributors=…P
FrameLifetimeInterval=%{name=FrameLifetimeInterval,signpost.generator:begin_end_32b}llu
FrameRender=… FrameReady=… FramePrevious=%{name=FramePrevious,signpost.generator:event}llu FrameGPUExecute=…

• 原始消息元组:base_mct 与 FramePrevious 为 8 字节 uint64(Mach 绝对时间 ticks);FrameLifetimeInterval 为 4 字节无符号整数(相对 base_mct 的结束偏移);contributors 为 data 类型。

• 链式验证:每一条事件的 FramePrevious 都恰好等于上一条事件的 base_mct + FrameLifetimeInterval,1,045 对相邻事件 1,045 对精确相接、0 断链(包括 11 条 Stalls 事件,它们是链中的普通帧)。base_mct 本身可以比上一条小(流水线并行),但呈现时刻严格递增。

• 帧间隔分布:P5 / P50 / P95 = 8.34 / 8.34 / 16.68 ms,与 120 Hz 高刷屏一致;静止时出现长达约 10 秒的间隔。

• 事件中没有刷新间隔字段(旧 refresh_interval 不再提供)。

公式(先文档后代码)

对 iOS 26 FrameUpdate:

presentationMach(i) = base_mct(i) + FrameLifetimeInterval(i)        // ticks
prevMach(i)         = FramePrevious(i)
FrameTimeMs(i)      = (presentationMach(i) − prevMach(i)) × numer ÷ denom ÷ 1e6

• 与 iOS 17 FrameLifetime 的 end_time − prev_frame 是同一含义:本帧呈现时刻减上一帧呈现时刻;整数 ticks 先换算 ns(向下取整),再保留 6 位小数 ms。

• 连续性:prevMach(i) 必须精确等于上一条已解码事件的 presentationMach,否则 discontinuity;会话/重连/目标代际变化或首帧为 warmup(与原规则相同)。

• 长间隔门:旧规则为“> 12 个刷新间隔即 source-gap 置空”。FrameUpdate 没有刷新间隔,改用固定 200 ms(即 60 Hz 的 12 个刷新间隔),超过即 source-gap、FrameTime 为空;不补零、不复用旧值。算法版本 frameupdate-presentation-gap.v1。

• 刷新率(displayRefreshRateHz):FrameUpdate 不提供刷新间隔,该来源下刷新率保持缺失,不从帧间隔反推。

• P95 帧窗:沿用同一呈现间隔与 200 ms 长间隔门,其余窗口规则不变。

• 范围:foreground-device(backboardd 合成器帧,不是目标进程私有帧),与原 FrameLifetime 相同。

实施范围

• display_performance.py:白名单增加 com.apple.coreanimation / IOMFBServer(.Stalls) / FrameUpdate;新增 FrameUpdate 字段解析(按格式串说明符与消息非文字元组逐一对齐,含 data 占位);解码器在刷新间隔未知时用 200 ms 门;输出增加 sourceSignpost,标准化事件族名仍为 FrameLifetime 以复用下游契约。

• p95_frame_window.py:对 sourceSignpost=FrameUpdate 的事件,用 200 ms 门代替 12×刷新间隔。

• 不改 CoreProfile 回退、FPS(sysmontap)、卡顿/平滑度公式。

验收标准

• 单元:真实格式串与原始元组样例解析正确;链式相接为 valid、断链 discontinuity、超 200 ms source-gap;原 FrameLifetime 用例全部不变。

• 真机(E4):设备 A 上 60 秒以上、含滑动的会话,帧耗时在滑动段逐秒有值,逐帧来源被选中(atHealthy=true),静止段允许为空;卡顿/1% Low 统计覆盖整场而非启动 1 秒。

Implementation Update · 2026-11-02

文件修改
MacApp/Sidecar/perfowl_collector_core/display_performance.py白名单接受 IOMFBServer / IOMFBServer.Stalls 下的 FrameUpdate,保持原短路读取顺序;新增 _extract_frame_update_fields,按格式串说明符与消息非文字元组逐一对齐(data 占位),映射为 begin_time=base_mct、end_time=base_mct+FrameLifetimeInterval、prev_frame=FramePrevious、refresh_interval=0;解析失败为 unsupported-schema。解码器在刷新间隔为 0 时以 200 ms 为长间隔门,输出 sourceSignpost。
MacApp/Sidecar/perfowl_collector_core/p95_frame_window.pysourceSignpost=FrameUpdate 且刷新间隔为 0 的事件,用 200 ms 代替 12×刷新间隔。
MacApp/Sidecar/tests/test_ios26_frame_update.py新增 7 项:白名单、真机记录的相接样本、链式 valid / 断链 discontinuity / 200 ms 边界与 source-gap、残缺消息、逐帧来源合格判定、P95 帧窗。
MacApp/Sidecar/tests/test_display_dispatch_equivalence.py冻结的白名单参考实现同步加入 FrameUpdate(读取顺序断言不变)。
知识页frame-time 增加 iOS 26 说明与版本行;display-refresh-rate 注明 iOS 26 下刷新率缺失。

• 偏差:标准化事件族名仍写 FrameLifetime,以复用下游已有契约;真实来源以新增字段 sourceSignpost 区分。

• 复杂度:每条 FrameUpdate 解析为一次线性遍历(19 个元组),不增加缓存或额外进程;被拒的其他 signpost 走原有短路路径,读取次数不变。

验证(E2)

• 新增 7 项与帧/显示相关 17 个模块共 290 项:全部通过。

• Python 全量 3137 项:10 失败 / 6 错误 / 81 跳过;失败与错误全部在 test_native_direct_archive、test_native_raw_graph_normalization、test_sw1_p1_continuation_admission 三个模块。撤下本批改动在同一环境复跑这三个模块,结果完全相同(9F2E、1F3E、1E),属本工作目录未构建原生扩展的既有环境差异,与本批无关。

遗留

• 真机 E4:新包上 60 秒以上含滑动会话,确认逐帧来源被选中、帧耗时滑动段逐秒有值。

• 启动阶段若画面完全静止、主来源在启动追踪结束前未合格,仍会转入 CoreProfile 回退并可能因资源上限失败;当前分段账本不允许之后再切回主来源。另立项。

• 实时元数据中 frameTimeMs 的来源标签仍写 activitytracetap.FrameLifetime,待一并整理。

开发记录