Skip to content

FTI-010 指标缺失与 FrameTime 长尾断流统一修复方案 ​

交付给其他 AI 的唯一执行方案。 本方案以 internal/field-test-issues.md 的 FTI-010 为问题台账,以最新真机 Session 07c7e86c-8886-45f6-8c1d-118a05769156 为主要证据,并合并 FTI-008 的 FrameTime 连续性问题。实施时优先按本文执行,不要继续把“Unified CoreProfile 单 Tap、start=1/restart=0/sourceEpoch=1”视为不可修改硬边界;该边界已被最新真机证据证伪。

0. 本轮目标与禁止项 ​

本轮目标分为 6 条轨道,其中前两条是 P0,必须先完成:

  1. P0-A · FrameTime 长尾恢复:解决 StartupTiming + FrameTime 同选时,FrameTime 只出现数秒后永久 missing;
  2. P0-B · Sample Publisher 解耦:解决只选择 FrameTime / Energy / Battery / Thermal / GPU Counter 等非 sysmontap Source 时,Raw/Decoder 已有值但 sampleCount=0;
  3. P1-C · Thermal Status:停止依赖当前设备不提供的 IOPMPowerSource.ThermalStatus 字段,寻找真实系统 Thermal State Source;
  4. P1-D · GPU Counter:打通 schema 之后的真实 counter data row 协议;
  5. P2-E · Battery:按冻结门禁在拔掉供电条件下验证真实电流/电压/功率;
  6. P2-F · Energy Usage:在明确进入 CapturePlan 且 Sample Publisher 已修复的条件下回归。

全程禁止:

text
- 禁止用 0 代替 missing
- 禁止重复发布最后一个旧 FrameTime
- 禁止用 1000 / FPS 伪造 FrameTime
- 禁止用 Battery Temperature 推导 Thermal Status
- 禁止猜测 GPU Counter 两整数消息的业务含义
- 禁止同时启动两个 CoreProfile/kperf recording
- 禁止为“有 sample 心跳”偷偷强制启动 CPU/Memory Source

1. 最新 E4 证据:FrameTime 是 Source 长尾断流,不是 UI/Decoder 问题 ​

1.1 最新 Session ​

Session:07c7e86c-8886-45f6-8c1d-118a05769156

顶层 Session:

text
startedAt = 2026-09-16T14:39:34.915Z
endedAt   = 2026-09-16T14:40:24.982Z
sampleCount = 43

Collector Core:

text
startedAt = 2026-09-16T14:39:37.957Z
endedAt   = 2026-09-16T14:40:24.427Z
duration  ≈ 46.48 s
status    = completed
identity  = verified

CapturePlan 同时包含:

text
StartupTiming
FrameTime
CPU Usage / CPU Core
Memory
FPS / GPU Usage
Thread / CSwitch / Wakeups / Disk
Network

因此本轮 sysmontap 正常工作,不能用“没有 Sample Publisher heartbeat”解释本轮 FrameTime 中途消失。

1.2 产品 Sample 时间轴 ​

metrics.csv 共 43 行:

text
sample 0  14:39:41.354  FTime 33.252 ms  FPS 0
sample 1  14:39:42.512  FTime 33.676 ms  FPS 40
sample 2  14:39:43.462  FTime 33.198 ms  FPS 40
sample 3  14:39:44.308  FTime 16.544 ms  FPS 59
sample 4  14:39:45.296  FTime 16.385 ms  FPS 59
sample 5  14:39:45.988  FTime 33.973 ms  FPS 59
sample 6  14:39:46.740  FTime 33.298 ms  FPS 60
sample 7  14:39:47.729  FTime missing    FPS 33
sample 8  14:39:48.730  FTime missing    FPS 26
sample 9  14:39:49.810  FTime missing    FPS 50
...
sample 42 14:40:22.818  FTime missing    FPS 37

Manifest:

text
frameTimeMissingSampleCount = 36
frameTimeMissingWhileGraphicsFPSPositiveCount = 22
frameTimeMissingWhileGraphicsFPSAtLeast20Count = 22

结论:App 在 FrameTime missing 期间仍持续渲染,不是静止页面造成的合法无新帧。

1.3 CoreProfile 真正停止的是 binary 数据 ​

text
coreProfileCaptureMode = startup-and-frame
coreProfileStartCount = 1
coreProfileRestartCount = 0
coreProfileStopCount = 1
sourceEpochCount = 1
coreProfileObservedChunkCount = 51
coreProfileObservedByteCount = 187381682
hostDroppedEventHighWater = 0
droppedEventHighWater = 0

以首个 CoreProfile chunk 为 T=0:

text
Startup semantic complete ≈ T+6.070 s
最后一个大块 binary      ≈ T+7.998 s
binary 在 startup complete 后只继续 ≈1.928 s
Session Stop 控制包       ≈ T+45.388 s

最后阶段 Raw 形态:

text
T+6.104   3.91 MB   frame-continuation
T+6.232   0.95 MB   frame-continuation
T+6.897  16.78 MB   frame-continuation
T+7.174   0.69 MB   frame-continuation
T+7.824  16.78 MB   frame-continuation
T+7.998   2.20 MB   frame-continuation
T+7.999   242 B     control
------ 约 37 秒无 binary ------
T+45.388  262 B / 262 B / 242 B control

这说明“51 个 CoreProfile chunk”不能被误解为 CoreProfile 一直健康;后半段只有 Stop/控制消息。

1.4 Decoder 在有数据区间是健康的 ​

frame-events-live.jsonl:

text
Presented Frame = 85
有效 FrameTime interval = 84
真实时间覆盖 ≈ 2.0756 s
最大 Presented Frame gap ≈ 99.97 ms
>500 ms gap = 0
>1000 ms gap = 0

所以链路是:

text
CoreProfile binary 正常
-> Presented Frame 连续
-> Frame decoder 正常
-> FrameTime 正常
-> Unified Tap 后续停止 binary
-> Presented Frame 消失
-> FrameTime freshness gate 正确输出 missing-no-new-frame

不要修改 freshness gate;它是在诚实暴露底层断流。

1.5 与历史 Session 形成重复证据 ​

text
3be2f750  frame-only       binary 基本持续至 Session Stop
203ad5ba  startup+frame    binary ≈7.70 s 后停止
54f82b38  startup+frame    binary ≈10.16 s 后停止,Session 继续约 111 s
07c7e86c  startup+frame    binary ≈7.998 s 后停止,Session 继续约 46 s

因此不再把“Startup+Frame Unified long-tail stall”当成单轮偶发。


2. P0-A 最终修复架构:启动联合 Tap + 启动完成后切到 Frame-only Tap ​

2.1 为什么不继续坚持 Build57 Unified 单 Tap ​

Build57 的目标是消除旧 startup→frame rotation 造成的 5 s 左右 gap,因此冻结:

text
startCount = 1
restartCount = 0
sourceEpoch = 1
Startup semantic complete 后 CoreProfile 不 stop

这个设计在单测层成立,但当前 E4 已重复证明:Startup+Frame 联合 Tap 在真机上可能于 8~10 秒后不再输出 binary,并造成剩余整个 Session FrameTime 永久 missing。

因此旧“严禁 rotation”规则由本方案正式 supersede。

2.2 不能原样回退 Build51 ​

Build51 历史 20 轮 E4:

text
frame-only 源恢复 = 20/20
rotation gap P50 = 5152.740 ms
rotation gap P95 = 5292.041 ms
restart -> first frame chunk P50 = 1239.140 ms
restart -> first frame chunk P95 = 1303.487 ms

它证明串行重启是可靠可行的,但 5 s gap 太大。

本方案不是“回退 Build51”,而是混合两代优点:

text
T0
  联合 Tap = lifecycle filters + Presented Frame
  -> 启动阶段同时得到 Startup + FrameTime

Startup semantic complete
  -> 冻结启动报告
  -> 串行停止旧联合 Tap
  -> 有界排空/关闭 subscription
  -> 同一 DTX owner 上新建 Frame-only Tap
  -> sourceEpoch + 1
  -> 持续 FrameTime 到 Session Stop

任何时刻最多一个 CoreProfile/kperf recording。

2.3 优先复用现有代码,不重写 CoreProfile 生命周期 ​

当前代码已经具备:

text
realtime.py
  ALLOW_CORE_PROFILE_ROTATION_FALLBACK = False
  startup_rotation_requested
  startup_rotation_drain_wait_complete
  startup_rotation_complete
  request_stop_for_drain()
  adapter.stop()
  adapter.restart(... frame_time=true, launch_trace=false ...)
  sourceEpoch advance
  core_profile_rotation telemetry

adapters.py
  CoreProfileRawAdapter.request_stop_for_drain()
  CoreProfileRawAdapter.wait_for_drain()
  CoreProfileRawAdapter.restart()
  _CoreProfileChunkStream stop/drain contract

第一原则:激活并收敛现有路径,不新增第二套 rotation 状态机。

2.4 将“fallback”改成正式 Continuation Policy ​

不要只把:

python
ALLOW_CORE_PROFILE_ROTATION_FALLBACK = False

机械改成 True 后结束。

将语义改成清晰产品策略,例如:

python
STARTUP_FRAME_CONTINUATION_POLICY = "rotate-to-frame-only"

允许未来保留:

text
unified-single-tap        # 仅实验/对照
rotate-to-frame-only      # 当前生产默认

生产默认必须是 rotate-to-frame-only,直到 E4 证明某个新的 Unified 配置可以长窗稳定。

2.5 切换触发时机 ​

只在以下条件全部成立时触发:

text
mode == startup-and-frame
AND startup semantic complete
AND frame_time_enabled == true
AND capture_closing == false
AND stop_event 未触发
AND 本 Session 尚未执行过 continuation cutover

Startup semantic complete 定义继续使用现有:

text
liveComplete == true
OR totalDurationMs != null
   AND requiredMissingPhases empty
   AND status not partial/incomplete/failed

不得为了更早切换伪造启动完成。

2.6 Startup 报告必须先冻结,再切 Source ​

顺序固定:

text
1. mark startup semantic complete
2. 生成/持久化 closure
3. publish startup report
4. startup report 进入 immutable/frozen 状态
5. 才允许 request stop old CoreProfile

新 Frame-only epoch 的任何 bytes 都不得进入 startup phase decoder,也不得修改 Startup report。

2.7 排空策略:先求正确,再用 E4 缩短 gap ​

第一版实施不要猜测 Apple stop 尾包语义,先使用现有安全边界:

text
STARTUP_CORE_PROFILE_DRAIN_IDLE_SECONDS = 0.75 s
STARTUP_CORE_PROFILE_DRAIN_MAX_SECONDS  = 4.0 s
STARTUP_CORE_PROFILE_RESTART_MAX_SECONDS = 5.0 s

目标先从“后 30~100 秒永久无 FrameTime”恢复为“最多一个诚实的 rotation missing 窗口 + 后续长期恢复”。

同时必须新增足够遥测,使下一轮 E4 能决定是否安全收紧 drain:

text
handoffRequestedNs
handoffStopAckNs
handoffDrainCompleteNs
handoffRestartBeginNs
handoffRestartCompleteNs
handoffFirstBinaryNs
handoffFirstPresentedFrameNs
handoffFirstFrameTimeNs
handoffDrainStatus
handoffPostStopChunkCount
handoffGapMs
restartToFirstBinaryMs
restartToFirstFrameTimeMs

只有真机证明 post-stop tail 不需要 4 s 后,才能降低 DRAIN_MAX_SECONDS;不能为了追求连续性直接删 drain。

2.8 Restart 必须有 kperf release 退避重试 ​

项目已有历史证据:旧 CoreProfile owner Stop 后设备释放 kperf 可能异步,立即重开会遇到 core-profile-source-busy。

因此 continuation restart 要有一次统一 helper,例如:

text
attempt 1: immediately
attempt 2: +0.25~0.5 s
attempt 3: +0.5~1.0 s
attempt 4: +1~2 s

每次必须创建 fresh Tap/config,不复用已停止 Tap。

要求:

text
- 总重试窗口受 STARTUP_CORE_PROFILE_RESTART_MAX_SECONDS 约束
- Stop/capture_closing 随时优先取消重试
- 失败后 FrameTime 明确 degraded/missing,不拖垮整个 Session
- 不影响 CPU/Memory/FPS/Network 等其它 Source

不要复制多套 busy retry;若已有可复用的 CoreProfile retry helper,统一抽取。

2.9 Source Epoch 边界必须显式失效旧 FrameTime ​

当前 RealtimeMetricProjector.consume_core_profile() 在 epoch 变化时会重建 RealtimeFrameDecoder,但实施 rotation 时必须确认并补齐:

text
_latest_frame_time_ms = None
_latest_frame_quality = "missing"
_latest_frame_timestamp_ns = None
_last_published_frame_timestamp_ns = None

原因:旧 epoch 最后一帧绝不能在新 epoch 刚启动、尚未收到 Presented Frame 时被重新展示。

同时:

text
新 epoch 第一条 Presented Frame 只能建立基线
至少第二个 Presented Frame 才形成真实 frameTime interval
跨 epoch 禁止计算 timestamp delta

2.10 Rotation gap 的产品语义 ​

切换窗口:

text
frameTimeMs = null
quality = "missing-source-handoff"

恢复后:

text
quality = candidate / 实际 decoder quality

禁止:

text
重复旧值
线性插值
FPS 反推
跨 epoch 间隔

离线 Frame Report 需要保留 source gap / epoch 信息,不把 handoff gap 计成一帧超长卡顿。


3. P0-A 可选安全网:Unified binary stall watchdog ​

即使生产默认改为 post-startup rotation,也建议保留只读 liveness telemetry,避免未来策略改变后再次静默断流。

状态:

text
lastCoreProfileBinaryHostNs
lastPresentedFrameHostNs
lastGraphicsFPS
lastGraphicsFPSHostNs

诊断条件可定义为:

text
startup 已完成
AND frameTime requested
AND Graphics FPS >= 20
AND 最近 graphics 数据新鲜
AND CoreProfile binary 超过阈值未更新

第一版只做 telemetry/diagnostic,不要自动反复 restart。每个 Session 最多允许一次正式 startup→frame cutover,防止 recovery loop 抢占 kperf。

建议事件:

text
core_profile_liveness
  status = healthy / stalled / recovered
  binaryAgeMs
  graphicsFPS
  sourceEpoch

4. P0-B Sample Publisher 解耦 ​

4.1 已知根因 ​

当前 RealtimeMetricProjector.consume():

python
if event.event_type == "core-profile.chunk.raw":
    self.consume_core_profile(event)
    return None
...
if event.event_type != "sysmontap.raw" or event.target_pid is None:
    return None

所以非 sysmontap Source 虽然已经更新 _latest / Frame/GPU 状态,最终产品 snapshot 仍只能被下一条 sysmontap.raw 发布。

真实证据 3be2f750:

text
CoreProfile chunks = 153
Presented Frame = 487
有效 FrameTime = 486
product sampleCount = 0

4.2 正确架构 ​

拆开“Source ingest”和“Product sample cadence”:

text
Source tasks
  -> consume/update latest state

Independent Product Sample Clock (默认沿现有约 1 Hz 产品节奏)
  -> projector.snapshot(now, target identity)
  -> emit sample

sysmontap.raw 变成普通 Source,而不是全产品隐含 heartbeat。

4.3 实施要求 ​

新增类似:

python
RealtimeMetricProjector.snapshot(...)

把当前 consume() 中 588 行之后“拼装 metrics dict”的逻辑提取到 snapshot;consume() 只负责:

text
- startup observation
- generation/foreground state
- RawMetricEngine.consume
- frame/gpu state update
- target process metadata update

新增单一 sample publisher task:

text
while recording:
    wait product interval
    if target identity 已验证:
        sample = projector.snapshot(...)
        emit sample

注意:

  • 不要每个 Source 各自 emit sample,避免多倍采样频率;
  • 不要因没有 sysmontap 就伪造 process metric 值;未启动 Source 的字段保持 null/not-requested/missing;
  • FrameTime 继续使用 freshness gate:同一个 Presented Frame 不可跨多个 tick 重复;
  • GPU Counter 也需要 freshness/age gate;
  • device-only 指标允许在没有 process source 的 Session 中被产品 sample 发布;
  • CapturePlan 决定“Source 是否启动”,Sample Clock 只负责“何时发布 snapshot”。

4.4 与 FTI-004 的关系 ​

修复后必须满足:

text
只选 FrameTime       -> 只启动 CoreProfile + 必要控制源 -> 有 sample
只选 Energy          -> 只启动 Energy + 必要控制源      -> 有 sample
只选 Thermal/Battery -> 只启动 Device Conditions         -> 有 sample
只选 GPU Counter     -> 只启动 GPU Counter               -> 有 sample

不能再要求用户顺带勾 CPU/Memory。


5. P1-C Thermal Status ​

5.1 已定位断点 ​

最近多个真机 Session 的 device.condition.raw 有:

text
Temperature
VirtualTemperature
Voltage
InstantAmperage
ExternalConnected
IsCharging
...

但没有:

text
ThermalStatus

当前 Metric Engine 固定:

python
thermal_raw = _number(row.get("ThermalStatus"))
thermal_status = thermal_raw if thermal_raw in (0,1,2,3) else None

因此当前 IOPMPowerSource 不是这台设备/iOS 上可用的 Thermal Status 真值源。

5.2 实施原则 ​

先做 Source Discovery,不允许先写映射:

text
1. 枚举 DVT / diagnostics / IORegistry 可读的真实 thermal state 候选
2. 保存原始字段和值域
3. 在至少 normal + 人为升温场景观察是否变化
4. 明确语义/单位/枚举合同
5. 再修改 metric registry + decoder + UI

若当前环境找不到可信真实 Source:

text
MetricAvailability = unavailable/unsupported-for-device
reason = thermal-source-unavailable

优于长期展示一个永远 missing 但标记 available 的卡片。

不得使用 Temperature / VirtualTemperature 阈值自行推导 0~3。


6. P1-D GPU Counter ​

已知两个真机 Session:

text
18833f61: raw 117  = device-info 1 + schema 1 + unsupported-message 115
54f82b38: raw 1053 = device-info 1 + schema 1 + unsupported-message 1051

说明:

text
CapturePlan OK
Source OK
DTX channel OK
device-info OK
schema OK
data message 持续到达
可信 counterValues data row = 0

实施顺序:

text
G1. 对 unsupported-message 做“形状 census”,记录类型、长度、元素类型、相邻 schema 时序,不解释业务含义
G2. 获取可信协议证据:pymobiledevice3 上游实现 / Apple 私有 selector 调用证据 / 真机 wire 对照
G3. 确认 configureCounters:counterProfile:interval:windowLimit:tracingPID: 参数合同
G4. 仅在字段含义有证据后实现 decoder
G5. schema key 数量与 data row value 数量必须严格匹配,否则整行 missing/degraded

禁止把当前两整数 list 直接猜成 timestamp/cycles/counter values。


7. P2-E Battery ​

当前合同:

text
ExternalConnected == false
AND IsCharging == false

才输出:

text
Battery Current
Battery Voltage
Battery Power

当前观察 Session 均为:

text
ExternalConnected = true
IsCharging = false

所以 null + external-power-connected 是正确结果。

E4:

text
拔掉 USB/供电
使用 Wi-Fi/无线连接
确认 IsCharging=false
连续采集 >= 2 min

验收:current/voltage/power 至少产生稳定真实值;若 Raw 有值而产品仍 null,再定位 decoder/product mapping。


8. P2-F Energy Usage ​

历史 Session 5e04d1d2... 已证明 Energy Source 能返回:

text
energy.cost
energy.cpu.cost
energy.gpu.cost
energy.networking.cost
energy.location.cost
energy.display.cost
energy.overhead

且 energy.cost=-10 等 sentinel 已被正确保持 missing。

最近 5 次没有真正选择 Energy,因此不能判 Source 故障。

Sample Publisher 修复后做两组:

text
E1: Energy only
E2: Energy + CPU Usage

两组都应产生产品 sample;同一 Raw 的 Energy 数值应一致,不允许依赖 CPU 是否选择。


9. 代码修改清单 ​

9.1 MacApp/Sidecar/perfowl_collector_core/realtime.py ​

P0-A:

  • 将 ALLOW_CORE_PROFILE_ROTATION_FALLBACK 重命名/升级为正式 continuation policy;
  • Startup+Frame 生产默认 rotate-to-frame-only;
  • 保留初始联合 filters;
  • startup semantic complete 后冻结报告并触发一次串行 handoff;
  • restart busy 增加受控退避;
  • 增加 handoff/liveness 遥测;
  • 切换期间 FrameTime quality 明确 missing-source-handoff;
  • Stop 与 handoff race 保证 Stop 优先且不再开启新 Tap。

P0-B:

  • 抽取 projector.snapshot();
  • 新增独立 sample publisher task;
  • consume() 不再把 sysmontap.raw 当成产品唯一发布触发器。

9.2 MacApp/Sidecar/perfowl_collector_core/adapters.py ​

  • 复用 CoreProfileRawAdapter.restart();
  • 若需要,将“fresh Tap restart + busy retry”抽成统一 helper;
  • 不允许同时保留旧/new 两个 started recording;
  • 不破坏 Stop/drain 现有 Session finalization。

9.3 MacApp/Sidecar/perfowl_collector_core/launch_trace.py ​

  • 不回退 FTI-006 continuation decoder 修复;
  • startup report freeze 后新 epoch 不再进入 startup semantic mutation;
  • Frame-only 新 epoch 使用新 decoder 基线,不跨 epoch 拼 FTime。

9.4 MacApp/Sidecar/perfowl_collector_core/metric_engine.py ​

  • Thermal 暂不伪造;只有真实新 Source 合同确认后修改;
  • GPU Counter 只有协议证据确认后修改。

9.5 Swift ​

P0-A/P0-B 尽量保持现有 wire shape,因此 UI 不应需要大改。

如新增 quality:

text
missing-source-handoff
thermal-source-unavailable

只增加解释文案,不把 missing 当 0 绘制。


10. 必须新增/调整测试 ​

10.1 FrameTime handoff 单测 ​

扩展:

text
MacApp/Sidecar/tests/test_perfowl_realtime.py
MacApp/Sidecar/tests/test_unified_core_profile.py
MacApp/Sidecar/tests/test_frametime_continuation.py

至少覆盖:

  1. Startup+Frame 初始 Tap filters 同时含 lifecycle + Presented Frame;
  2. semantic complete 前不切换;
  3. semantic complete 后只切换一次;
  4. 旧 Tap stop 完成后才 restart;
  5. restart filters 只含 Frame filter;
  6. sourceEpoch 从 1 -> 2;
  7. 新 epoch 到来时旧 FrameTime 状态被清空;
  8. 新 epoch 第一帧不产生跨 epoch interval;
  9. 第二帧开始恢复真实 FrameTime;
  10. startup report 在切换后不可变;
  11. Stop 发生于 drain/retry 时不得启动新 Tap;
  12. restart busy 可退避恢复,最终失败则 FrameTime degraded,但 Session 继续;
  13. frame-only 模式不走 startup handoff;
  14. startup-only 不启动 frame-only continuation。

旧测试中硬编码:

text
startCount=1
restartCount=0
sourceEpoch=1

用于 Startup+Frame 的断言必须按新生产策略更新;Frame-only 仍应保持单 epoch。

10.2 Sample Publisher 单测 ​

新增至少:

text
FrameTime-only: CoreProfile 有 frame -> 无 sysmontap -> sampleCount > 0
Energy-only: Energy Raw -> 无 sysmontap -> sample 中 Energy 有值
DeviceCondition-only: Battery/Thermal Raw -> sample 正常发布
GPUCounter-only: 若 decoder 有可信值 fixture -> sample 正常发布

并验证:

text
1 Hz publisher 不因 5 个 Source 同时到达变成 5 Hz
同一 FrameTime 不跨多个 tick 重复
未启动 Source 字段保持 null/missing

11. 真机 E4 验证矩阵 ​

E4-A · 最新 Bug 直接回归 ​

使用与 07c7e86c... 相同组合,持续 >= 60 s,并持续操作 App 保证 FPS 活跃。

必须验证:

text
Startup 有值
前启动窗口 FrameTime 有值
handoff 后 Frame-only Tap 成功启动
handoff 后 FrameTime 恢复并持续到 Stop
不再出现“前 7 个有值、后 30+ 个永久 missing”

硬证据:

text
coreProfileStartCount >= 1
coreProfileRestartCount = 1
sourceEpochCount = 2
rotation/handoff 事件完整
第二 epoch 有持续 binary
第二 epoch 有 Presented Frame
第二 epoch 有 FrameTime

E4-B · 30 秒 / 5 分钟长窗 ​

分别运行 30 s、5 min。

若 Graphics FPS >= 20 且持续交互:

text
不允许第二 epoch binary 在 Session 中途永久停止
不允许 FrameTime 在 handoff 恢复后永久 missing

记录:

text
handoffGapMs
restartToFirstBinaryMs
restartToFirstFrameTimeMs
post-handoff longestPresentedFrameGapMs
missingWhileFPS>=20 count
lastBinaryToStopMs

E4-C · Frame-only ​

只选 FrameTime(在 Sample Publisher 修复后不额外勾 CPU):

text
sampleCount > 0
CoreProfile 不发生 startup handoff
FrameTime 持续

E4-D · Startup-only ​

只选 StartupTiming:

text
Startup 正常
不创建 Frame-only continuation

E4-E · Energy-only / DeviceCondition-only ​

验证 Sample Publisher 解耦。


12. 关闭门禁 ​

FrameTime P0-A 可关闭条件 ​

必须全部满足:

text
[ ] 最新复现组合 60 s 不再永久断值
[ ] 5 min 持续交互不再发生第二 epoch 永久 binary stall
[ ] handoff 期间只出现诚实 missing,不出现旧值重复
[ ] startup report 与切换前一致且切换后冻结
[ ] 单 kperf owner,无 busy 泄漏
[ ] Stop 无挂死/二次启动
[ ] Frame-only / Startup-only 无回归

rotation gap 允许暂时存在,但必须被量化并标记 missing-source-handoff。若 P95 仍接近历史 5 s,则后续单独立优化项;不能因为 gap 尚未最优而继续接受整场 FrameTime 永久丢失。

Sample Publisher P0-B 可关闭条件 ​

text
[ ] FrameTime-only 无 sysmontap 仍有 sample
[ ] Energy-only 无 sysmontap 仍有 sample
[ ] DeviceCondition-only 无 sysmontap 仍有 sample
[ ] 采样频率保持产品约定,不因 Source 数量放大
[ ] 未请求 Source 不被偷偷启动

13. 实施顺序(给执行 AI) ​

严格按以下顺序,不要并行大改:

text
Step 1  给最新 07c7e86c Session 写回归 fixture/断言
Step 2  激活 startup-complete -> frame-only 串行 handoff
Step 3  补 epoch reset + busy retry + handoff telemetry
Step 4  跑 Python 全量测试,修正旧 Unified 单 Tap 断言
Step 5  构建真机包,先做 60 s E4-A
Step 6  若 FrameTime 长尾恢复,再做 5 min E4-B
Step 7  独立实施 Sample Publisher 解耦
Step 8  做 FrameTime-only / Energy-only / DeviceCondition-only E4
Step 9  GPU Counter 与 Thermal 分别按证据轨道推进,禁止猜协议
Step 10 Battery 拔线复测,Energy 做 only vs +CPU A/B

不要在 Step 2 尚未真机证明 FrameTime 恢复前,同时重构 GPU Counter/Thermal 协议。 P0-A 的目标是先恢复当前用户最直接遇到的 FrameTime 长尾可用性。


14. 最终期望架构 ​

text
CapturePlan
  |
  +-- Source Activation(严格 scoped,不启动未选择 Source)
  |
  +-- CoreProfile
  |     Startup+Frame:
  |       combined tap during launch
  |       -> freeze startup
  |       -> serial handoff
  |       -> frame-only tap for long tail
  |
  |     Frame-only:
  |       frame-only tap directly
  |
  +-- Other Sources
        sysmontap / graphics / network / energy /
        device conditions / gpu counter

所有 Source -> Projector latest state
                     |
                     v
          Independent Product Sample Clock
                     |
                     v
               product sample

这同时修复 FTI-010 暴露的两个架构错位:

  1. CoreProfile startup+frame 不应为了“单 Tap 理想连续性”牺牲整场 FrameTime 长尾;
  2. 产品 Sample 不应为了发布节奏强绑定 sysmontap Source。

15. 实施进展记录 · 2026-09-17 ​

对应 AGENTS.md C6/C7 方案实施记录硬门禁。

15.1 进展概览与状态台账 ​

步骤任务内容状态证据等级备注
Step 1给最新 07c7e86c Session 写回归 fixture/断言completedE2test_frametime_continuation.py 中的 StartupToFrameOnlyHandoffRegressionTests 已修正 KD_BUF 4 条记录定位门禁并通过
Step 2激活 startup-complete -> frame-only 串行 handoffcompletedE2生产默认改为 rotate-to-frame-only,保证单一 kperf recording 串行切换
Step 3补 epoch reset + busy retry + handoff telemetrycompletedE2sourceEpoch 边界重置 Projector 状态,增加退避重试与切换期完整遥测
Step 4跑 Python 全量测试,修正旧 Unified 单 Tap 断言completedE2Sidecar 437 项测试全绿,Swift 167 项测试全绿
Step 5构建真机包,先做 60 s E4-A 回归completedE4真机 Session 727c4179 验证通过:presentedFrameCount=884,Epoch 2 产出 755 帧持续至 Stop,长尾断流彻底修复
Step 6若 FrameTime 长尾恢复,再做 5 min E4-B 长窗验证completedE4真机 Session 38249077 验证通过:294.72s 持续长窗,切换仅 3.36s,第二 epoch 持续存活并产出 182MB binary 与 16.4ms 帧时间
Step 7独立实施 Sample Publisher 解耦completedE2P0-B 架构拆分:snapshot() 与 consume() 解耦,独立 1Hz publisher 定时器,7 项单测全绿
Step 8~10Energy/Battery/Thermal/GPU 验证与协议推进pending-按证据轨道推进

15.2 §10.1 14 项单测覆盖闭环验收 ​

方案 §10.1 明确要求的 14 项覆盖指标已全部达成闭环:

  1. Startup+Frame 初始 Tap filters 同时含 lifecycle + Presented Frame:test_startup_flush_publishes_total_then_restarts_frame_source (assertGreater(len(options[0]['filters']), 1));
  2. semantic complete 前不切换:test_startup_flush_publishes_total_then_restarts_frame_source (assertLess(total_index, frame_index));
  3. semantic complete 后只切换一次:test_startup_flush_publishes_total_then_restarts_frame_source (FakeCore.restarts == 1);
  4. 旧 Tap stop 完成后才 restart:drain_index < frame_index;
  5. restart filters 只含 Frame filter:assert(options[-1]['filters'] == [0x31800000]);
  6. sourceEpoch 从 1 -> 2:test_frametime_recovers_after_source_epoch_handoff_not_permanently_missing;
  7. 新 epoch 到来时旧 FrameTime 状态被清空:projector._latest_frame_time_ms = None 重置;
  8. 新 epoch 第一帧不产生跨 epoch interval:新 epoch 首帧 frameTimeMs is None (warmup);
  9. 第二帧开始恢复真实 FrameTime:frames2b[0]["frameTimeMs"] 恢复真实 candidate;
  10. startup report 在切换后不可变:test_startup_report_frozen_across_continuation_chunks;
  11. Stop 发生于 drain/retry 时不得启动新 Tap:test_11_stop_during_drain_does_not_start_new_tap;
  12. restart busy 可退避恢复,最终失败则 FrameTime degraded,但 Session 继续:test_12a_restart_busy_recovers_via_backoff & test_12b_restart_busy_exhausted_degrades_and_continues_session;
  13. frame-only 模式不走 startup handoff:test_13_frame_only_mode_does_not_perform_startup_handoff;
  14. startup-only 不启动 frame-only continuation:test_14_startup_only_mode_does_not_start_frame_continuation。

15.3 真机 E4 回归与长窗验证实测数据 ​

严格遵循隐私红线(设备与应用统一匿名化为“设备 A / 目标 App A”):

15.3.1 E4-A · 60 秒真机直接回归 (727c4179-e91c-4362-b2b5-bdceafd7661b) ​

  • 测试环境:设备 A (iOS 17.0,ProductType: iPhone15,4),目标 App A (冷启动 controlled-cold-launch),Build 61 签名打包工具;
  • 采样时长:61.47 s,生成 55 个产品 samples;
  • 核心遥测指标:
    • coreProfileStartCount = 1
    • coreProfileRestartCount = 1
    • sourceEpochCount = 2 (sourceEpochs = [1, 2])
    • coreProfileObservedByteCount = 1,865,796,872 bytes (1.86 GB,441 chunks)
    • coreProfilePostStartupObservedBytes = 1,677,568,376 bytes (388 chunks)
    • presentedFrameCount = 884 (此前断流 Session 仅 85)
    • presentedFrameCoverageSeconds = 44.23 s (此前断流 Session 仅 2.08 s)
    • handoffGapMs = 5,733.48 ms
    • restartToFirstBinaryMs = 1,175.69 ms
    • restartToFirstFrameTimeMs = 1,669.14 ms
  • 两阶段流转事实:
    • Epoch 1(启动窗口):产出 129 帧,启动报告完整捕获 totalDurationMs = 1990.88 ms (candidate);
    • 切换过程:flush-requested -> drain -> frame-source-restarted -> frame-source-active,遥测完整闭环;
    • Epoch 2(Frame-only 长尾):产出 755 帧与 754 个有效 frameTimeMs(均值约 16ms 左右,真实捕获 451ms/182ms 瞬时卡顿),持续输出直到 Session Stop!
  • 最终聚合:Avg(FTime) = 40.67 ms, Avg(FPS) = 24.59, Stutter[%] = 41.05%, Jank(/10min) = 301.08 均为 candidate。
  • 结论:彻底攻克并关闭 FTI-010 / FTI-008 的“前 7 秒有值、后续永久断流”缺陷!

15.3.2 E4-B · 5 分钟真机长窗稳定性 (38249077-882e-4a12-9225-00e0fb18ec88) ​

  • 测试环境:设备 A (iOS 17.0),目标 App A (冷启动),采集持续 300 秒;
  • 采样时长:294.72 s,生成 291 个产品 samples;
  • 核心遥测指标:
    • coreProfileStartCount = 1, coreProfileRestartCount = 1, coreProfileStopCount = 1
    • sourceEpochCount = 2 (sourceEpochs = [1, 2])
    • handoffGapMs = 3,364.64 ms(平滑完成串行切换,仅 3.36 秒)
    • restartToFirstBinaryMs = 1,183.80 ms
    • restartToFirstFrameTimeMs = 1,659.10 ms
    • coreProfilePostStartupObservedBytes = 182,635,921 bytes (182 MB,49 chunks)
    • Epoch 2 在 5 分钟全程持续活跃,Session 结束时的最后三帧 frameTime 分别为 16.60 ms、16.51 ms、16.45 ms;
  • 结论:确认切换至 Frame-only Tap 后的第二 epoch binary 不会在长窗中途提前 stall,长尾连续性得到验证。

15.4 验证结果 ​

  • Sidecar Python 专项与全量测试:439 passed in 14.53s (E2)
  • Swift Core/App 全量单元测试:167 passed in 22.81s (E2)
  • 真机 60 秒 E4-A 回归测试:Session 727c4179 验证通过 (E4)
  • 真机 5 分钟 E4-B 长窗测试:Session 38249077 验证通过 (E4)
  • 文档构建:npm run build 耗时 13.19s,0 错误、0 死链。

15.5 Step 7 · P0-B Sample Publisher 解耦实施记录 ​

  • 实施日期:2026-09-17
  • 关联根因:原架构中 RealtimeMetricProjector.consume() 只有在收到 sysmontap.raw 且包含目标进程行时才拼装并返回 sample 字典,导致用户单选 FrameTime、Energy、Battery、Thermal、GPU Counter 等非 sysmontap Source 时虽然底层 decoder 正常吐出数据但产品 sampleCount=0。
  • 修改范围与关键文件:
    • MacApp/Sidecar/perfowl_collector_core/realtime.py:
      • RealtimeMetricProjector:将 sample 拼装逻辑抽取为独立方法 snapshot(monotonic_ns=None, ...),consume() 专注于 Source 状态更新(startup observation、代际状态、engine.consume、frame/gpu 状态、进程元数据),返回 None;
      • 新增 bind_target_identity(pid, name, generation, verified),解耦对 sysmontap 进程发现的强依赖;
      • stream_candidate_session:移除 ingest 循环中对 sample 的 emit 调用;新增以恒定 interval_ms(默认 1Hz)运行的独立 stream-sample-publisher 协程任务,由单一定时器调用 projector.snapshot() 并 emit("sample", ...)。
    • MacApp/Sidecar/tests/test_sample_publisher_decoupling.py(新增):
      • 落实 §10.2 要求的全部 7 项单测(FrameTime-only、Energy-only、DeviceCondition-only、GPUCounter-only、恒定频率不倍增、FrameTime freshness 不跨 tick 重复、未请求指标保持 null/missing),100% 通过。
    • MacApp/Sidecar/tests/test_perfowl_realtime.py & test_frametime_continuation.py:
      • 适配既有单测的 snapshot() 显式采集调用;修复 test_startup_ring_experiment 对 pytest 的非预期依赖。
  • 闭环验证证据:
    • Sidecar 439 项单元测试全绿 (E2);
    • Swift 167 项单元测试全绿 (E2);
    • 达成全部 Section 10.2 闭环要求。

Perfowl · Performance Observer