Skip to content

FTI-005 FrameTime Continuation 与 FTI-006 GPU Counter UI 修复方案 ​

0. 执行摘要 ​

本批只修两个独立问题:

  1. FTI-005 · Unified CoreProfile 进入 frame-continuation 后 FrameTime 停止更新;
  2. FTI-006 · GPU Counter 动态 Counter 全量套用普通卡片布局,导致 Header/曲线严重挤压、名称逐字符竖排。

两项必须分别验证,禁止把 GPU Counter UI 问题误判为采集失败,也禁止为了修 FrameTime 重新引入 Startup → Frame CoreProfile stop/drain/restart。

本批核心边界:

text
FTI-005:
保持 Build57/58 Unified CoreProfile
单 Tap / 单 kperf owner / start=1 / restart=0 / sourceEpoch=1
只修 continuation 阶段的 Presented Frame 解码与 FrameTime freshness

FTI-006:
保持 GPU Counter 原始采集、wire、Session 持久化、导出不变
只重构 GPU Counter 的 Workbench 展示和选择模型

代码实现、测试、真机结果与任何方案偏差必须同步写回本 devlog。代码完成但开发记录未更新,视为未完成。


1. FTI-005 · FrameTime 在 Startup 完成后冻结 ​

1.1 现场现象 ​

2026-09-16 Build58 真机 Session 约 4 分 40 秒:

text
CoreProfile observed bytes  = 3,753,607,010 bytes ≈ 3.50 GiB
CoreProfile observed chunks = 850
CoreProfile persisted bytes = 0(derivedOnly 正常)
droppedEventHighWater       = 0
CoreProfile startCount      = 1
CoreProfile restartCount    = 0

但 frame-events-live.jsonl 只有:

text
Presented Frame = 57
覆盖时间 ≈ 1.146 秒

并且 57 帧全部来自:

text
coreProfileStreamRole = startup-lifecycle

Startup semantic complete 后:

text
frame-continuation CoreProfile chunks = 815
Presented Frame = 0

最终工作台持续重复最后一次:

text
FrameTime = 32.915208 ms

形成“FrameTime 一直不变化”的假象。

同样问题在 Build57 Unified 真机会话已经存在,所以 不是 Build58 Raw Retention 引入的回归。


1.2 已确认根因 A:decoder 主动跳过 frame-continuation ​

文件:

text
MacApp/Sidecar/perfowl_collector_core/launch_trace.py

当前 LaunchTraceStreamDecoder.consume():

python
def consume(...):
    self._pending_frame_events = []
    if payload.get("coreProfileStreamRole") == "frame-continuation":
        return None

这条逻辑与旧 Startup → Frame rotation 架构相容,但与 Build57 之后的 Unified Continuous Tap 冲突。

Build57 新架构是:

text
Startup + FrameTime
↓
同一 CoreProfile Tap
↓
同一 RAW-v2 stream
↓
Startup semantic complete
↓
CoreProfile 不停止
↓
后续 chunk 仅 role 标记为 frame-continuation

因此 continuation 仍然是同一 RAW-v2 连续字节流的一部分,必须继续经过同一个 RAW parser 状态机才能得到 Presented Frame。

当前提前 return None 导致后续 chunk 根本没有进入:

text
PRESENTED_FRAME_DEBUG_ID
→ timestampNs
→ frameTimeMs
→ _pending_frame_events

这是 FTI-005 的主根因。


1.3 已确认根因 B:FrameTime 被当作无限有效 gauge 重复发布 ​

文件:

text
MacApp/Sidecar/perfowl_collector_core/realtime.py

当前 RealtimeMetricProjector 保存:

python
self._latest_frame_time_ms
self._latest_frame_quality

每个 sysmontap product sample 都直接:

python
metrics["frameTimeMs"] = latest_frame_time_ms

只要目标仍在前台,最后一次 FrameTime 会无限重复。

所以即使上游 Presented Frame 已经中断,UI 仍会不断收到旧值:

text
32.915208
32.915208
32.915208
...

这是第二个独立缺陷。

FrameTime 是事件派生值,不是可以无限保鲜的 gauge。


2. FTI-005 最终修复设计 ​

2.1 保持 Unified CoreProfile,不允许回退 rotation ​

以下 Build57/58 架构是硬边界:

text
Startup + FrameTime
→ 一个 CoreProfile Tap
→ 联合 filters
→ startCount = 1
→ restartCount = 0
→ sourceEpoch = 1
→ Startup semantic complete 不关闭 CoreProfile

严禁为了让 continuation FrameTime 恢复而重新:

text
stop
→ drain
→ restart frame-only tap

否则 FTI-001 会复发。


2.2 continuation 必须继续使用同一 RAW-v2 parser state ​

不要为 continuation 新建第二个 RealtimeFrameDecoder 去从 chunk 中重新找 RAW v2 header。

原因:continuation chunk 是同一流的后续字节,可能没有新的 RAW-v2 header/thread map。

正确设计:

text
LaunchTraceStreamDecoder
    │
    ├─ 一直维护 RAW-v2 stream parser state
    │
    ├─ startup-lifecycle
    │    ├─ thread/PID attribution
    │    ├─ lifecycle semantics
    │    └─ Presented Frame
    │
    └─ frame-continuation
         ├─ 保持 parser state
         ├─ 必要 thread/PID mapping 继续更新
         ├─ Presented Frame 继续提取
         └─ Startup lifecycle report 不再重新打开/污染

推荐实现方式:

python
role = payload.get("coreProfileStreamRole")
frame_continuation = role == "frame-continuation"

删除当前 continuation 的顶层 early return。

但不能只是机械删除一行然后结束;还必须保证 startup semantics 在 complete 后保持冻结。


2.3 continuation 阶段只继续“流解析 + Frame 投影”,不重新修改 Startup 结果 ​

当前 decoder 在 Startup 完成时已有:

python
self._emitted = True
self._emitted_report = published
self._records.clear()
self._reset_semantics()

并且后续 lifecycle retention 已由 not self._emitted 门禁控制。

修复必须锁定以下行为:

text
Startup report 一旦 liveComplete
→ 已发布结果保持冻结
→ continuation 到来的后续 UIKit/scene 事件不得重新改变本次 App Launch 报告

但:

text
Presented Frame
thread map / target attribution continuity
stream byte offset
Mach timebase
loss marker

仍必须继续维护。

如果实现时发现当前 _emitted 已足以冻结 lifecycle semantics,则只增加针对 continuation 的回归测试,不额外造状态机;如果不能证明,则显式增加:

text
startupSemanticFrozen

或等价状态。

禁止为了“安全”直接跳过整个 chunk。


2.4 take_frame_events() 在 continuation 下必须正常返回 ​

修复后行为:

text
consume(frame-continuation chunk)
↓
解析 Presented Frame
↓
_pending_frame_events
↓
take_frame_events()
↓
projector.consume_decoded_core_profile_frames()
↓
frame-events-live.jsonl
↓
FrameTime / Jank / Smooth

必须沿用现有单次 RAW scan,不允许同一个 chunk 被两个全量 decoder 重扫。


2.5 FrameTime freshness:禁止无限重复最后值 ​

推荐不要简单增加“2 秒以后清空”这种时间 magic number。

更可靠的产品语义是:

一个 product sample 只有在自上一个 product sample 后收到过新的有效 Presented Frame 时,才发布新的 FrameTime。

建议 RealtimeMetricProjector 增加:

python
_latest_frame_timestamp_ns
_last_published_frame_timestamp_ns

或等价 sequence/version。

每次 _consume_decoded_frames() 收到新的有效 FrameTime:

text
更新:
_latest_frame_time_ms
_latest_frame_quality
_latest_frame_timestamp_ns

在构造下一个 sysmontap sample 时:

text
如果 target not foreground
→ frameTimeMs = None
→ quality = not-foreground

如果没有任何有效 FrameTime
→ frameTimeMs = None
→ quality = missing

如果 latest_frame_timestamp_ns
   <= last_published_frame_timestamp_ns
→ frameTimeMs = None
→ quality = stale/no-new-frame

如果存在新的 FrameTime
→ 发布最新 frameTimeMs
→ quality = candidate
→ last_published_frame_timestamp_ns = latest_frame_timestamp_ns

推荐质量值使用:

text
stale

如果项目现有 quality 枚举/契约不接受 stale,必须先统一定义为:

text
missing-no-new-frame

并同步知识库,不允许私自造一个 UI-only 字符串。

关键目标:

text
没有新帧 ≠ 上一帧继续有效

2.6 不要用 Graphics FPS 推算 FrameTime 作为兜底 ​

禁止:

text
FrameTime = 1000 / CoreAnimationFramesPerSecond

用来补 CoreProfile Presented Frame 缺失。

原因:

text
Graphics FPS = 每秒 gauge
FrameTime = Presented Frame 逐帧间隔

两者语义不同。

FrameTime 缺源时必须诚实 missing/stale。


3. FTI-005 E2 测试 ​

至少新增以下测试。

A. continuation 仍能提取 Presented Frame ​

构造:

text
startup-lifecycle chunk
→ RAW v2 header
→ thread map
→ lifecycle
→ Presented Frame A/B

标记 Startup complete

frame-continuation chunk
→ Presented Frame C/D/E

断言:

text
Startup阶段 frame events > 0
Continuation阶段 frame events > 0
C/D/E timestamp > B
FrameTime连续计算

B. continuation 没有第二个 RAW-v2 header 也能解析 ​

这项必须单独锁定。

第二段 continuation fixture:

text
只包含后续 KD_BUF records
没有 RAW_V2_MAGIC
没有第二份 thread table

同一个 decoder 必须继续成功解析。

防止未来有人错误地换成新 decoder。


C. Startup report continuation 后保持不变 ​

Startup complete 时保存:

text
reportA

随后喂 continuation chunk,包含 Presented Frame,并可混入后续 lifecycle namespace record。

断言:

text
report after continuation == reportA
Total 不变化
phaseResults 不变化
liveRevision 不重新增长

D. 不重复 Presented Frame ​

同一个 Raw chunk 只能产生一次 frame event。

断言:

text
frame timestamp sequence unique
frame count 与 fixture 真实 Presented Frame 数一致

E. freshness / stale ​

流程:

text
新Frame A/B
→ 下一 product sample 发布 FrameTime

没有新Frame
→ 再来 sysmontap sample
→ frameTimeMs = nil
→ quality = stale/no-new-frame

再来新Frame C
→ 下一个 sample 恢复新 FrameTime

禁止第二个 sample 重复上一值。


F. foreground 切换 ​

保持:

text
foreground → background
FrameTime = nil / not-foreground

重新 foreground 后必须等待新 Presented Frame,不能立即复活旧 FrameTime。


G. Unified 主路径 ​

集成测试至少模拟 30 秒以上 continuation:

text
mode=startup-and-frame
startCount=1
restartCount=0
sourceEpoch=1
Startup complete
后续每秒持续输入 Presented Frame

断言:

text
frame-events span > 20s
而不是只覆盖启动约1s

4. FTI-005 E4 真机验收 ​

至少执行:

text
A. FrameTime-only 5 min
B. Startup + FrameTime 5 min
C. Startup + FrameTime 15~30 min(推荐)

每轮记录:

text
CoreProfile startCount / restartCount
sourceEpoch
observed chunk count / bytes
droppedEventHighWater
TRACE_LOST_EVENTS
Presented Frame count
firstPresentedFrame timestamp
lastPresentedFrame timestamp
Presented Frame coverage duration
FrameTime non-null sample count
FrameTime stale/missing sample count
FPS
Startup Total / phase coverage

硬门禁:

text
Startup + FrameTime:
startCount = 1
restartCount = 0
正常 sourceEpoch = 1
Presented Frame coverage 应接近整个有绘制活动的 Session,而非仅约1秒
FrameTime 不得长期复制同一个旧值
Startup Total/阶段不得回归

对于静止页面长期没有新 Presented Frame:

text
FrameTime 显示 missing/stale

是正确结果,不得为追求“曲线一直有线”而重复旧值。


5. FTI-006 · GPU Counter 卡片显示异常 ​

5.1 现场现象 ​

GPU Counter 卡片真机截图出现:

text
GPU Counter 标题逐字符换行
Counter 名称逐字符竖排
大量彩色 legend/readout 横向挤压
图表主体几乎无法阅读

这不是 GPU Usage 的 Renderer/Tiler/Device 三项,也不是 GPU Counter 上游采集断流。

问题是 GPU Counter 动态 schema 直接套用了普通少量 series 卡片布局。


5.2 已确认根因 ​

文件:

text
MacApp/Sources/PerfowlCore/WorkbenchModels.swift

当前:

swift
let uniqueKeys = Array(Set(keys)).sorted().prefix(24)

也就是最多动态创建 24 条 series。

文件:

text
MacApp/Sources/PerfowlApp/PerfowlWorkbenchView.swift

普通卡片 Header:

swift
HStack {
    Text(definition.displayName)
    Spacer()
    ForEach(definition.series) { series in
        MetricSeriesReadout(...)
    }
}

24 个长 Counter name 被强行塞进一个 HStack。

SwiftUI 为满足布局压缩文本,最终形成逐字符竖排。

同时 24 条 source-native Counter 共用一个 Y 轴,本身也没有良好的可读性:不同 Counter 数值规模可能差几个数量级。


6. FTI-006 最终产品设计 ​

6.1 GPU Counter 必须使用专用 Panel ​

仿照当前:

text
MemoryUsagePanel

新增:

text
GPUCounterPanel

在通用 MetricPanel 中 special-case:

swift
if definition.id == .gpuCounter {
    GPUCounterPanel(...)
} else if definition.id == .memoryUsage {
    ...
} else {
    generic panel
}

禁止继续让动态 GPU Counter 走:

text
ForEach(definition.series)
→ 全部塞进 Header

6.2 Header 固定,不显示全部 Counter readout ​

Header 推荐:

text
GPU Counter                  24 counters · 整机

只展示:

text
卡片名
可用 counter 数
scope = device

不在 Header 放 24 个动态 readout。


6.3 第一版采用“单 Counter 选择”,不要同时画 24 条 ​

本批推荐采用单选而不是多选。

原因:

text
GPU Counter = device-specific source-native count
不同 key 的数量级和物理语义可能完全不同
当前只有一个 Y 轴

如果同时画 3~24 条,即使布局修好,也可能因为最大值差异把其它曲线压成直线。

第一版正确产品模型:

text
GPU Counter
↓
设备返回 N 个动态 key
↓
全部采集 / 全部保存
↓
UI 用户从 selector 选择 1 个 key
↓
图表只画该 key

未来如果明确有同量纲可比较 counter,再单独设计多选/多轴/归一化模式。

本轮不做。


6.4 初始状态不要随意猜“最重要 Counter” ​

GPU Counter schema 是 device-specific。

不要基于名字猜测:

text
GRC_*
TA_*
GPU_*

哪个一定最重要。

推荐首次打开:

text
selectedCounterKey = nil

卡片主体显示:

text
已发现 24 个 GPU Counter
选择一个 Counter 查看实时曲线
[选择 Counter]

如果用户已经在本次 App 生命周期选择过一个仍存在的 key,可以保持选择。

可以后续使用 @AppStorage 保存最后选择,但必须以当前 schema 是否包含该 key 为门禁;本轮不是必须项。


6.5 Selector ​

推荐使用:

text
Menu / Popover
+ 搜索框
+ 单选列表

Counter 数量可能大于 24,UI 不应再把 24 当成产品上限。

gpuCounterPanel(keys:) 可以继续为 chart definition 生成需要的 definition,但推荐重构为:

text
全部可用 key 独立作为 availableCounterKeys
当前 selected key 单独生成 1 条 chart series

不要为了 UI 布局而丢掉原始 schema。

Selector 列表:

text
Counter name 单行
lineLimit(1)
truncationMode(.tail)
完整名字通过 help/tooltip 查看

6.6 Chart ​

只画当前 selected counter:

text
series count = 1

Y 轴:

text
使用该 Counter 当前 viewport 的 observed max
niceMaximum()

单位诚实保持:

text
source-native-count

或 UI 简写:

text
count

但 tooltip/help 必须说明:

text
source-native · device scope

禁止未经知识库契约证明把任意 Counter 标成:

text
%
ms
MHz
bytes/s

6.7 Current value readout ​

选中一个 Counter 后,Header 或 selector 下方可以显示:

text
<CounterName>
当前值 123456
整机 · source-native

Counter 名称限制单行并 tail truncate。

完整名称用 tooltip。


6.8 底层采集、Session、Export 不变 ​

本批只改 Workbench presentation。

禁止为了 UI 简化而改成只采一个 Counter。

仍然:

text
设备返回的全部 counterValues
→ PerformanceSample.gpuCounters
→ Session metrics.csv / JSON
→ Export

UI selector 只决定:

text
当前图表画哪一个

不决定:

text
设备采哪个
Session存哪个

这样用户后续回看/导出仍可得到完整数据。


6.9 不要和 GPU Usage 混淆 ​

产品继续保留两个概念:

text
GPU Usage
= Graphics service
= Renderer / Tiler / Device utilization %
= foreground-device

GPU Counter
= 动态硬件 counter schema
= source-native count
= device

GPU Counter 专用卡片标题、scope 与帮助文案必须明确是:

text
整机 · source-native

不要让用户误以为它是目标 App 的 GPU 百分比。


7. FTI-006 E2/UI 测试 ​

至少覆盖:

A. 24 counter schema 不再撑爆 Header ​

构造 24 个长名称:

text
GRC_FAKE_COUNTER_WITH_A_VERY_LONG_NAME_00...

断言:

text
GPU Counter title 仍为单行
Header 不创建 24 个 MetricSeriesReadout

如果项目有 snapshot/pixel test,则增加固定宽度截图测试。


B. 无选择状态 ​

text
24 counters available
selected = nil

应显示:

text
选择一个 Counter 查看实时曲线

图表不绘制错误的 24 条线。


C. 单选 ​

选择 key A:

text
chart series = 1
value = sample.gpuCounters[A]

再选择 key B:

text
chart series切换到B
A不再参与Y轴缩放

D. schema 更新 ​

当前选择 key A,下一批 schema 不再包含 A:

text
selectedCounterKey → nil

显示“请选择 Counter”,禁止继续展示过期 A。


E. 长名字 ​

Counter name 100+ chars:

text
UI单行
尾部省略
help/tooltip完整

不得再逐字符竖排。


F. 底层数据保持全量 ​

即使 UI 只选择 key A:

text
PerformanceSample.gpuCounters

仍应包含 A/B/C/... 全部源返回 key。

Session/Export 不得只剩 A。


8. FTI-006 真机验收 ​

选择 GPU Counter 后至少 5 分钟。

验收:

text
GPU Counter 卡片布局稳定
标题不换成逐字符竖排
Selector能查看设备返回的全部 key
一次只显示一条曲线
切换不同 key 曲线和值同步变化
Session/Export仍包含完整 counter dictionary
Mac UI CPU/RSS无明显异常

需要保留一张修复前截图和一张修复后截图作为 devlog 证据。


9. 推荐实施顺序 ​

Phase 0 · 登记问题 ​

在:

text
internal/field-test-issues.md

新增:

text
FTI-005 · Unified CoreProfile continuation 后 FrameTime 冻结
FTI-006 · GPU Counter 动态卡片布局失控

初始状态:

text
已定位

实施开始后改:

text
修复中

Phase 1 · FTI-005 decoder continuation ​

先只做:

text
移除 frame-continuation 整块 early return
保持同一 RAW parser state
continuation 继续解析 Presented Frame
Startup semantic freeze 不回归

先跑专项 Python tests。


Phase 2 · FTI-005 freshness ​

再做:

text
FrameTime 新鲜度/一次性发布
无新 Presented Frame → missing/stale

不要和 Phase 1 混成一个大改,方便失败时定位。


Phase 3 · FTI-006 专用 GPUCounterPanel ​

实现:

text
固定Header
available key selector
单Counter chart
长名字截断
schema失效自动清选择

不动采集端。


Phase 4 · 全量 E2 ​

必须运行:

text
Sidecar 全量 pytest
Swift 全量 tests
Unified CoreProfile专项
Frame pipeline专项
GPU Counter专项
Session/Export专项

不能只跑新增测试。


Phase 5 · E4 ​

先验证 FTI-005:

text
Startup + FrameTime 5min

确认连续后,再验证 GPU Counter UI。

如果时间允许补:

text
Startup + FrameTime 15~30min

10. 禁止事项 ​

以下实现直接判定不合格:

  1. 为修 FrameTime 重新恢复 Startup → Frame CoreProfile restart;
  2. continuation 新建一个失去 RAW-v2 header state 的独立 decoder;
  3. 同一个 CoreProfile chunk 被两个完整 RAW scanner 重复解析;
  4. FrameTime 没有新帧时继续重复上一值;
  5. 用 1000/FPS 冒充 FrameTime;
  6. continuation 后重新修改已经冻结的 Startup Total/phase;
  7. GPU Counter 只加 .lineLimit(1) 就宣称完成;
  8. 仍然把 24 个 Counter readout 塞进一个 Header HStack;
  9. 24 条不同量级 Counter 继续共用一张默认曲线图;
  10. 为 UI 单选而减少设备实际采集或 Session 保存的 Counter 数量;
  11. 根据 Counter 名称猜百分比/ms/MHz 等单位;
  12. 把 GPU Counter 和 GPU Usage 合并成一个指标;
  13. 覆盖/回退当前正在开发的 Screenshot Worker 或其它未提交工作;
  14. 未更新 devlog 就标记修复完成。

11. 开发记录硬门禁 ​

实施 AI 必须持续更新本文件:

text
site/devlog/p3-p4-frametime-continuation-and-gpu-counter-ui-repair-plan.md

至少追加:

text
## Implementation Update
- 实际修改文件
- 实际实现
- 与本方案偏差
- 偏差原因
- 当前状态

## Validation Update · E2
- Python测试数
- Swift测试数
- 专项测试
- 失败/修复记录

## Validation Update · E4
- Session时长
- Frame count / coverage
- CoreProfile start/restart/sourceEpoch
- drop/loss
- FrameTime freshness
- GPU Counter UI截图结果
- Session/Export完整性

状态变化必须同步:

text
site/devlog/index.md
internal/field-test-issues.md

如果实施 partial / blocked / paused,也必须即时写入。


12. Definition of Done ​

FTI-005 ​

text
[ ] frame-continuation 不再被 LaunchTraceStreamDecoder 顶层跳过
[ ] continuation Presented Frame 能继续产生
[ ] continuation 无第二 RAW-v2 header 仍可解析
[ ] Startup report complete 后保持冻结
[ ] Presented Frame 无重复
[ ] FrameTime 不再无限复制最后值
[ ] 无新帧时输出 missing/stale
[ ] 新帧恢复后 FrameTime 自动恢复
[ ] foreground/background 语义正确
[ ] startup-and-frame startCount=1
[ ] restartCount=0
[ ] 正常 sourceEpoch=1
[ ] Startup Total/phase 不回归
[ ] 5min 真机 Presented Frame coverage 不再只有约1秒
[ ] 推荐完成 15~30min 长测

FTI-006 ​

text
[ ] GPU Counter 使用专用 Panel
[ ] Header 不再渲染全部动态 Counter readout
[ ] 24+长名称不会挤压标题
[ ] Counter selector 可查看全部动态 key
[ ] 第一版单选一个 Counter
[ ] 图表只绘制当前选择 key
[ ] Y轴只受当前选择 key 影响
[ ] schema失效时旧选择自动清除
[ ] 长名字单行省略并可查看完整 tooltip
[ ] scope 明确显示 device/整机
[ ] unit 保持 source-native
[ ] GPU Counter 与 GPU Usage 语义继续分离
[ ] 底层采集仍保存完整 counter dictionary
[ ] Session/Export 不因 UI 单选丢字段
[ ] 真机 UI 修复前后截图归档

共同门禁 ​

text
[ ] Sidecar全量测试通过
[ ] Swift全量测试通过
[ ] 不覆盖其它未提交工作
[ ] internal/field-test-issues.md 已更新
[ ] 本 devlog 已写 Implementation/E2/E4 记录
[ ] site/devlog/index.md 已同步
[ ] 记录未完成前不得标记已解决

13. 最终期望 ​

FTI-005 修复后:

text
Startup + FrameTime
↓
启动阶段立即有 FrameTime
↓
Startup semantic complete
↓
同一 CoreProfile Tap 继续
↓
FrameTime 持续整个 Session
↓
没有新绘制时诚实 missing/stale

FTI-006 修复后:

text
GPU Counter
↓
完整采集设备动态 schema
↓
完整保存所有 counter
↓
Workbench只选择一个 Counter查看
↓
布局稳定、量纲诚实、曲线可读

本批的目标不是增加更多数据,而是:

让已经采到的数据在正确的生命周期持续使用,并让动态 GPU Counter 以可读、诚实、不会破坏布局的方式展示。


Implementation and E2 Verification Update (2026-09-16 · Build 60) ​

1. 实际修改范围与代码落地 ​

  • 关联问题与方案编号:FTI-006(Unified CoreProfile 进入 continuation 阶段后 FrameTime 停止更新且静止时无限重复旧值)、FTI-007(GPU Counter 动态 key 导致通用卡片 Header 逐字符竖排与量级挤压)。方案内部初稿代称 FTI-005/FTI-006 已正式对齐为台账编号 FTI-006/FTI-007。
  • 架构原则兑现:
    • 严格保持 Unified CoreProfile 单 Tap(start=1, restart=0, sourceEpoch=1),未回退为 stop/drain/restart rotation;
    • 启动报告在 liveComplete 后保持完全冻结且不可变,continuation 事件绝不修改或重新触发启动报告;
    • FrameTime 增加严格新鲜度门禁,无新帧时置空(quality = "missing-no-new-frame"),坚决杜绝静止重复旧值伪造匀速卡顿,坚决不使用 1000 / FPS 冒充 FrameTime;
    • GPU Counter 前端移除截断,完整保留全部动态 key;提供专属 GPUCounterPanel,固定 Header、Popover 实时搜索单选、单曲线自适应 Y 轴,底层全量采集、存储与导出完整无损。
  • 核心文件变更:
    1. MacApp/Sidecar/perfowl_collector_core/launch_trace.py:解锁 coreProfileStreamRole == "frame-continuation" 的早退拦截,使同一 decoder 保持 RAW-v2 状态机继续解析后继 presented frame 记录;对 lifecycle/debug_id/report trigger 增加 not is_frame_continuation 保护,确保 startup 报告在 liveComplete 后保持完全冻结,take_frame_events() 持续供给呈现帧。
    2. MacApp/Sidecar/perfowl_collector_core/realtime.py:增加 FrameTime 新鲜度门禁 _latest_frame_timestamp_ns 与 _last_published_frame_timestamp_ns。仅在收到严格晚于上次发布时间戳的有效帧时发布 frameTimeMs;无新帧到达时发布 None,quality="missing-no-new-frame";前后台切换或 generation 变更时重置时间戳。
    3. MacApp/Sidecar/tests/test_frametime_continuation.py(新增):6 项专项单测,覆盖跨阶段流解析、无 raw header 连续流解析、启动报告冻结性、帧去重、新鲜度门禁与前后台切换。
    4. MacApp/Sidecar/tests/test_startup_bootstrap.py:补充断言锁定 continuation 解码流记录但不作为启动端点。
    5. MacApp/Sources/PerfowlCore/WorkbenchModels.swift:移除 GPU Counter 动态 schema 的 .prefix(24) 截断,保留底层返回的全部动态 key;底层全量采集、原始存储与导出完整保留。
    6. MacApp/Sources/PerfowlApp/PerfowlWorkbenchView.swift:实现专属 GPUCounterPanel,固定 Header(展示 GPU Counter 标题与 N counters · 整机);提供带搜索框、清除选择、单行尾部截断(带完整名称 Tooltip)、单位标签与当前值预览的 Popover 列表;未选择时提供引导态,选中时仅绘制该 counter 的独立趋势线,Y 轴自适应缩放;schema 变更时自动清除失效选中。
    7. MacApp/Tests/PerfowlCoreTests/WorkbenchModelsTests.swift:新增单测 testGPUCounterPanelRetainsAllKeysWithoutTruncation,覆盖 30+ 动态 key 的全量保留与属性契约。

2. 验证结果与数字证据 (E2) ​

  • Python 测试:417/417 PASSED(10.10s,0 失败);
  • Swift 测试:154/154 PASSED(15.93s,3 项显式基准跳过,0 失败);
  • VitePress 站点验证:npm run build 通过,63 项 Registry 契约全过,0 报错 0 死链;
  • 安装包生成与签名 (Build 60):
    • App 产物:MacApp/dist/Perfowl.app(Developer ID Application: Jiacheng Li (76J692XB5C) 签名,Hardened Runtime 开启,codesign --verify --deep --strict 通过);
    • 主程序 SHA-256:16cd10b9a0b81bfa400c461f030a5e087e26188d165790179f95c93e2f1a02ea;
    • ZIP 归档:MacApp/dist/Perfowl-internal-test-Build60-x86_64.zip(109,353,018 bytes,SHA-256: ebf9fcef82b1c7c42aca1f41e9fa0c3d319903420279f6461e73d8193c6b0e70,独立解包验签通过);
    • DMG 镜像:MacApp/dist/Perfowl-internal-test-Build60-x86_64.dmg(140,816,085 bytes,SHA-256: f3f25a62f36c71f15da19c378d6c656dc4be406128000d4c1b5b147b36a372fe,hdiutil verify 及 Developer ID 容器验签通过)。
  • 静态审计与报告:
    • 方案 HTML:docs/architecture/perfowl-frametime-continuation-and-gpu-counter-ui-repair-plan-v1-2026-09-16.html
    • 匿名证据:docs/validation/evidence/perfowl-build60-frametime-continuation-and-gpu-counter-ui-e2-anonymous-2026-09-16.json
    • 验收 HTML:docs/validation/perfowl-build60-frametime-continuation-and-gpu-counter-ui-2026-09-16.html

3. 遗留项与未关门门禁 ​

  • 本机缺少 Apple Notary 凭据,公证与 ticket stapling 门禁仍开放;
  • GPL-3.0 法务评估门禁仍开放;
  • 交付状态标记为 partial,等待真机 E4(iPhone 15)在真实 App 上进行长会话连续帧时序与 GPU Counter 选择交互验证。

Validation Update · E4 2026-09-16 · Build60 真机复测 ​

本轮 E4 证明 Build60 的两个实现修复本身生效,但又暴露了新的、范围更窄的后续问题,因此本 devlog 继续保持 implemented · partial:

  1. FrameTime:continuation early-return 已解除,新鲜度门禁也正确,无新帧时输出 missing-no-new-frame,不再无限复制旧值;但长窗仍存在真实逐帧连续性缺口。较长 Session aa26b20d-5d55-49ab-aafd-59655ee56ae5 中 CoreProfile 848 chunks / 约 3.37 GiB / 131.63 s 持续输入,Presented Frame 1744 个、时间戳覆盖约 80.36 s,最大 gap 约 3.53 s;126 个产品 sample 中 34 个 FrameTime missing,其中 19 个时刻 Graphics FPS > 0、14 个 FPS >= 20。该后续问题新记为 FTI-008 · FrameTime 长时连续性。
  2. GPU Counter UI:专用 Panel 布局问题已解决;第一轮 Session 是 Recording 中途才添加 GPU Counter,Start 时未进入 CapturePlan,因此 gpu.counter.raw=0 属正常语义。第二轮 Start 时已包含 ios.device.gpu.counter.source_native,真实收到 1248 个 GPU raw event:device-info 1、schema-ready 1、其余 1246 全为当前 decoder 不支持的两整数 message shape,decoded data row=0。该数值链问题新记为 FTI-009 · GPU Counter data-row protocol。
  3. 后续统一实施方案已并入 site/devlog/p4-build59-screenshot-e4-followup-repair-plan.md,该文件现在同时覆盖 Screenshot E4 后续、FTI-008、FTI-009 与“采集中添加未武装指标下次 Session 生效”的交互语义。

Perfowl · Performance Observer