外观
R8-Fix-11:ActivityTraceTap Cooperative Parse Fairness 方案
当前状态:R8-Fix-11 validated · E2 passed · E4 passed
真机依据:Build 72,Session
ef830089-0e72-4511-9628-5c00157328b5。下一测试包:Build 73。
本批主目标是解决 ActivityTraceTap 同步
_parse()在 asyncio event loop 内长时间连续占用执行权的问题。 不回退 direct waiter,不恢复 consumer-handoff,不修改 FPS / FrameTime 公式,不扩大 subscription queue, 不修改 pymobiledevice3 安装包源码。实现与验证结果见 §19 / §20;本批不构建 Build 73、不执行真机 E4、不声明
validated。
1. Build 72 E4 结论
1.1 R8-Fix-10 路径已确认生效
Build 72:
text
consumerWaitEnterCount = 486
consumerDirectWaitCount = 486
consumerTerminalWakeCount = 1
consumerWakeRequestedCount = 485
consumerWakeToDequeueCount = 485
consumerWakeUnclaimedCount = 0
pumpYieldReasonCounts = {
consumer-handoff: 0,
event-limit: 25,
active-time: 0
}
pumpMaxBurstCount = 32
pumpMaxActiveBurstDurationMs = 0.963因此:
- parent consumer 已经直接 park 在
queue.get(); - R8-Fix-9 的 per-event
consumer-handoff sleep(0)已完全退出 shipping path; - Pump 重新由既有 32-event / 1ms active-time 公平性门控制;
- Build 72 不是旧 child-task 路径。
1.2 queue wait 显著改善,但仍未达到 R8-Fix-10 Gate C
Build 71:
text
pumpQueueWait:
P50 = 233.889 ms
P95 = 431.700 ms
Max = 593.027 ms
>500ms = 7
consumerWakeToDequeue:
P50 = 320.001 ms
P95 = 472.153 ms
Max = 593.027 msBuild 72:
text
pumpQueueWait:
P50 = 87.864 ms
P95 = 167.971 ms
P99 = 205.814 ms
Max = 260.963 ms
>500ms = 0
>1000ms = 0
consumerWakeToDequeue:
P50 = 105.680 ms
P95 = 165.407 ms
Max = 208.918 ms结论:
- R8-Fix-10 对 queue latency 是实质改善;
- Build 71 的 >500ms 病态尾延迟已经消失;
- 但 R8-Fix-10 Gate C 原目标
P50 <5ms / P95 <20ms / Max <100ms仍明显失败; - direct waiter 已不是最后的结构性瓶颈。
2. Build 72 event loop 仍被长时间占用
Build 72 heartbeat:
text
eventLoopSchedulerDelay:
P50 = 348.691 ms
P95 = 416.257 ms
Max = 492.020 ms
eventLoopTickGap:
P50 = 449.280 ms
P95 = 516.299 ms
Max = 592.026 ms
stalls >200ms = 187
stalls >1000ms = 0相比 Build 71:
text
schedulerDelay:
P50 = 335.238 ms
P95 = 529.260 ms
Max = 663.125 msP95/Max 有改善,但 P50 仍约 350ms。
因此:
subscription handoff 已缩短,但 event loop 本身仍被其它同步路径长时间占有。
3. 七层 pipeline 已排除下游
Build 72:
text
tapToFilter:
P50 = 0.052 ms
P95 = 0.586 ms
Max = 51.095 ms
filterToPumpEnqueue:
P50 = 0.017 ms
P95 = 0.033 ms
pumpQueueWait:
P50 = 87.864 ms
P95 = 167.971 ms
dequeueToAdapterYield:
P50 = 0.011 ms
P95 = 0.037 ms
adapterYieldToRealtimeConsume:
P50 = 0.160 ms
P95 = 0.304 ms
realtimeConsumeToIngress:
P50 = 0.013 ms
P95 = 0.038 ms除 queue wait 外,Perfowl 自己可观测的后续层仍在亚毫秒级。
Realtime consumer:
text
processedEventCount = 804
yieldCount = 100
maxObservedBurstCount = 8
maxObservedActiveBurstMs = 0.861
maxObservedSynchronousBurstMs = 0.256Realtime consumer 不是剩余的几百毫秒 owner。
4. Filter active-time 也不能解释 heartbeat
ActivityTrace census:
text
totalMessageCount = 9890
frameLifetimeMatchedCount = 804
frameLifetimeRejectedCount = 9086
rawCallbackRateHz = 112.845
filterYieldCount = 622
filterYieldReasonCounts = {
message-limit: 0,
active-time: 622
}
filterMaxBurstMessageCount = 24
filterMaxActiveBurstDurationMs = 2.135
filterMaxBurstDurationMs = 640.190Filter 自己的同步 work 最多约 2.1ms,却出现约 350ms scheduler delay。
关键原因是现有 Filter 计时位置:
python
async for message in self._tap:
active_start_ns = time.monotonic_ns()
matched = is_frame_lifetime_signpost(message)
...即 active_start_ns 只在 tap 已经 yield message 之后 才开始。
因此:
await self._tap.__anext__()内部在真正返回下一条 row 之前做的同步解析,并没有进入 Filter active-time budget。
5. pymobiledevice3 11.3.1 的同步 parse 空窗
当前 pinned ActivityTraceTap:
python
async def __aiter__(self):
while True:
await self._get_next_message()
for message in self._parse():
yield message而 _parse() 是同步 Generator:
python
while True:
...
result = operations[opcode](word)
...
if opcode == CMD_END_ROW and result is not None:
yield result一旦一个 DTX payload 已经收到:
_get_next_message()的 await 已结束;- 后续
_parse()在当前 asyncio Task 内同步执行; - Filter 对
tap.__anext__()的 await 不保证真的把执行权交回 event loop; - 直到
_parse()找到下一条 row 并 yield,Perfowl Filter 才重新取得控制; - Filter 的 1ms active-time gate看不到这段同步 parse;
- direct waiter 虽然已经 ready,仍必须等这段 upstream 同步工作结束。
这与 Build 72:
text
consumerWakeToDequeue P50 = 105.7ms
eventLoopSchedulerDelay P50 = 348.7ms方向一致。
6. R8-Fix-11 目标
- 把 ActivityTrace row 解码纳入真实 active-time fairness。
- 在一个 DTX frame 内同步解析大量 row 时,允许 event loop 定期运行其它 ready task。
- 不修改 pymobiledevice3 site-packages / frozen upstream 源码。
- 不改变 ActivityTrace config、predicate、whitelist、FrameLifetime 识别规则。
- 不改变 Pump / Filter / Realtime Consumer 现有公平性常量。
- 保留 direct waiter。
- 保证 0 drop、全部 FrameLifetime 可达 recorder。
- 用 Build 73 证明 heartbeat 与 wake→dequeue 同时改善。
7. 推荐实现:Perfowl-owned cooperative tap wrapper
不要直接 patch:
text
site-packages/pymobiledevice3/.../activity_trace_tap.py在 Perfowl 自己的 display_performance.py 或独立小模块中实现:
text
_CooperativeActivityTraceTapStream它包装真实 ActivityTraceTap,委托:
__aenter____aexit__- config
- channel lifecycle
但接管迭代:
python
while True:
await tap._get_next_message()
parser = tap._parse()
while True:
active_start_ns = time.monotonic_ns()
try:
message = next(parser)
except StopIteration:
break
active_ns += time.monotonic_ns() - active_start_ns
row_count += 1
yield message
if row_count >= PARSE_MAX_BURST_ROWS:
await asyncio.sleep(0)
reset()
elif active_ns >= PARSE_MAX_BURST_NS:
await asyncio.sleep(0)
reset()7.1 为什么 yield 之后检查 gate
必须让当前解析出的 row 先交给现有 Filter。
当该 row 最终匹配 FrameLifetime 并 wake direct consumer 后, 下一次 Pump 再向 tap 请求 row 时,wrapper 会先执行上一个 row 之后的 fairness 判断。
这样 cooperative yield 发生在:
text
当前 row 已交付
↓
direct consumer 已被标记 ready
↓
tap 准备解析下一 row
↓
fairness gate
↓
event loop 可先运行 consumer这正好覆盖 Build 72 的 residual wake→dequeue 空窗。
8. Parser fairness 常量
第一版使用:
python
ACTIVITYTRACE_PARSE_MAX_BURST_ROWS = 32
ACTIVITYTRACE_PARSE_MAX_BURST_NS = 1_000_000 # 1 ms active parse work理由:
- 与 Pump 的 32 events / 1ms active-time 对齐;
- row-count 是安全上限;
- active-time 才是主 gate;
- 不使用 wall-span;
- DTX receive await 不计 active work。
禁止为了追求漂亮真机数字直接使用每 row sleep(0)。
如果单 row 自身耗时 >1ms,gate 会在该 row 完成后立即让出;本批先接受“row boundary cooperative”,不复制/重写 opcode parser。
9. Parser diagnostics
在 ActivityTraceAcquisitionCensus 增加有界聚合字段:
text
parseRowCount
parseFrameCount
parseYieldCount
parseYieldReasonCounts = {
row-limit,
active-time
}
parseMaxBurstRowCount
parseMaxActiveBurstDurationMs
parseMaxSingleRowDurationMs
parseRowsOver1msCount
parseRowsOver5msCount
parseRowsOver20msCount可选分位数:
text
parseRowDurationP50Ms
parseRowDurationP95Ms
parseRowDurationP99Ms历史 Session 缺字段正常兼容。
这些字段只用于 diagnostics,不进入任何正式 metric 公式。
10. 依赖私有 pmd3 方法的防护
wrapper 会调用 pinned 11.3.1 的:
text
_get_next_message()
_parse()两者是上游私有方法,因此必须明确做版本/契约 gate。
启动前至少检查:
python
hasattr(tap, "_get_next_message")
hasattr(tap, "_parse")测试锁定 pinned 11.3.1 行为。
如果未来 pmd3 API 改变:
- probe/start 明确失败;
- 不得静默退回不可控的旧 parse path;
- 错误信息必须包含 cooperative parser contract changed。
11. 不复制 pymobiledevice3 parser
本批禁止把整份 ActivityTraceTap._parse() opcode 状态机复制进 Perfowl。
原因:
- 维护风险高;
- 容易与 pinned upstream 行为漂移;
- 当前证据只要求在 row boundary 增加 cooperative scheduling;
- 先用
next(parser)包装即可测量单 row 成本。
只有 Build 73 证明:
text
parseMaxSingleRowDurationMs本身经常达到几十/几百 ms,row-boundary fairness 无法满足 Gate, 才允许开后续 opcode-level cooperative parser 批次。
12. 与现有三层 fairness 的关系
Build 73 shipping 应形成四层独立公平性:
text
ActivityTrace parser:
32 rows / 1ms parse-active
ActivityTrace filter:
64 messages / 1ms filter-active
Pmd3Subscription pump:
32 events / 1ms pump-active
Realtime consumer:
8 events / 1ms consumer-active四层负责不同同步工作,不互相替代。
特别禁止:
- 恢复 consumer-handoff;
- 恢复 wall-time fairness;
- 因新增 parser gate 删除 Filter gate;
- 把 parser receive await 算成 active parse;
- 用一个“大一统 wall clock”替代四层 active work。
13. 自动化测试
至少覆盖:
13.1 DTX frame 内大量 row
模拟单 frame 含大量 row,_parse() 连续同步产出。
要求:
- parser 按 32-row 或 1ms active gate 让出;
- concurrent heartbeat/consumer 获得执行;
- 所有 row 顺序不变;
- 无丢失/重复。
13.2 upstream receive wait 不计 active
每 frame 前等待 20–50ms:
parseMaxActiveBurstDurationMs不包含等待;- 不因 receive wall time产生 yield。
13.3 expensive row
注入单 row 2–5ms 同步 parse:
- 当前 row 完成后 active-time gate 立即触发;
parseMaxSingleRowDurationMs正确记录;- 不伪称能在 row 内抢占。
13.4 direct waiter integration
组合:
text
cooperative tap
→ filter
→ Pmd3Subscription direct waiter
→ consumer要求:
- wake→dequeue P95 显著低于无 cooperative wrapper 对照;
consumer-handoff=0;- directWait > 0;
- delivered == consumed。
13.5 200k / burst throughput
新增 parser fairness 不能导致类似已拒绝的 per-event yield 退化。
至少报告:
text
throughput
parseYieldCount
filterYieldCount
pumpYieldCount
heartbeat P95/Max
CPU
RSS13.6 real ActivityTrace fixture
使用现有 ActivityTrace 测试 fixture / captured frame:
- table reset;
- split opcode carry;
- end row;
- message ordering;
- FrameLifetime whitelist;
行为必须与原 ActivityTraceTap 一致。
14. Build 72 undrainedAtClose=1 说明
Build 72 出现:
text
deliveredEventCount = 804
consumedEventCount = 804
pendingEventCount = 0
undrainedAtClose = 1
raw Display event count = 804
ingressAcceptedAtClose = 805
ingressPersistedAtClose = 805
finalRawSequence = 805
lastMetricConsumedRawSequence = 805
reportConsumedRawSequence = 805
dataCompleteness = complete因此这 不是实际 FrameLifetime 丢失。
原因是 undrainedAtClose 在 Pmd3Subscription.aclose() 开始阶段快照 queue depth; 在后续 await pump stop 的窗口,consumer 仍可能把最后一个正常 item drain 掉, 于是最终形成:
text
undrainedAtClose = 1
delivered == consumed
pending = 0
raw count complete本批不改 closeout 生命周期。
记录为 diagnostics semantic follow-up:
undrainedAtClose是 source-close 瞬时 backlog,不应单独解释成 final loss。
Build 73 完整性以:
text
delivered == consumed
dropped = 0
pending = 0
raw Display count == delivered
ingressUndrainedAtSeal = 0
report seal complete为硬门禁。
后续单独修正字段命名/最终 undrained 语义,不与 parser fairness 混改。
15. 明确禁止
R8-Fix-11 禁止:
- 修改 pymobiledevice3 site-packages / frozen upstream 文件;
- 复制完整 ActivityTrace opcode parser;
- 每 row 无条件
sleep(0); - 恢复 consumer-handoff;
- 恢复 wall-time fairness;
- 修改 Pump 32 / 1ms;
- 修改 Filter 64 / 1ms;
- 修改 Realtime 8 / 1ms;
- 扩大 subscription queue;
- 降低 ActivityTrace 采集频率;
- 修改 ActivityTrace predicate;
- 修改 FrameLifetime whitelist;
- 修改 FPS / FrameTime / Jitter / P95 / Refresh Rate 公式;
- 通过放宽既有 throughput gate 接受性能回归。
16. E2 门禁
必须:
- R8-Fix-11 parser fairness 专项全绿;
- R8-Fix-10 direct waiter 全绿;
- R8-Fix-9 diagnostics 全绿;
- ActivityTrace FrameLifetime 全量回归;
- Display pipeline timing 全绿;
- Pmd3 fairness 全绿;
- Python 全量;
- Swift 全量;
- VitePress;
- git diff --check。
Benchmark 必须报告:
text
parse row count
parse yield reasons
parse single-row P50/P95/Max
parse active burst Max
filter/pump yield counts
wake→dequeue P50/P95/Max
queueWait P50/P95/Max
heartbeat P50/P95/Max
throughput
CPU
RSS
drops17. Build 73 E4
测试条件:
- 纯 Display;
- 与 Build 71/72 同设备、同目标 App;
- 60–90 秒;
- 正常停止。
Gate A 数据完整性
text
delivered == consumed
dropped = 0
pending = 0
raw Display count == delivered
ingressUndrainedAtSeal = 0
dataCompleteness = complete
report seal sequence fully alignedGate B queue wait
硬门槛:
text
>500ms = 0
>1000ms = 0目标:
text
P50 < 20ms
P95 < 50ms
Max < 200msGate C wake→dequeue
核心目标:
text
P50 < 5ms
P95 < 20ms
Max < 100ms相比 Build 72:
text
105.680 / 165.407 / 208.918 ms必须有数量级改善。
Gate D heartbeat
Build 72:
text
schedulerDelay P50 = 348.691ms
P95 = 416.257ms
Max = 492.020msBuild 73 要求:
text
P50 < 50ms
P95 < 100ms
Max < 250ms并且:
text
stalls >200ms应从 Build 72 的 187 明显下降,优先目标为 0。
Gate E parser diagnostics
必须能回答:
- 一共解析多少 row;
- fairness yield 多少次;
- row-limit / active-time 各多少次;
- 单 row 最大耗时;
- 最大 parse active burst;
- 是否存在单 row 本身 >20ms 的情况。
如果:
text
parseMaxSingleRowDurationMs仍频繁超过 20–50ms,才进入 opcode-level cooperative parse 研究。
Gate F downstream
保持:
text
filterToPump P95 < 5ms
dequeueToAdapter P95 < 5ms
adapterToRealtime P95 < 5ms
realtimeToIngress P95 < 5msGate G closeout
维持 Build 71/72:
text
closeoutStatus = clean
consumerDrainStatus = completed
writerDrainStatus = drained
closeoutStageTimeoutCount = 018. 最终判断
Build 72 已证明:
text
child-task acquisition 已移除
↓
direct waiter 生效
↓
consumer-handoff 已移除
↓
queue wait 明显改善
↓
但 event loop 仍 300–500ms stall现有 Filter active-time 只覆盖:
text
tap yield message
↓
whitelist/filter work没有覆盖:
text
DTX frame already received
↓
ActivityTraceTap._parse()
↓
opcode / row synchronous decode
↓
tap yield message因此 R8-Fix-11 的正确修复边界是:
在 Perfowl-owned wrapper 中给 ActivityTraceTap 的 row parsing 增加 active-time cooperative fairness,让 ready 的 direct consumer 能在同一 DTX frame 的 row burst 中及时运行。
19. Implementation Update(2026-09-20)
状态:implemented · gate-open / E2 completed / E4 pending
实现在隔离 worktree(分支
r8-fix11,基线3de8ea7= 两个远程的main)完成;共享脏工作区未被触碰。另建 detached worktreeperfowl-r8fix11-base(同一3de8ea7)用于全程 A/B 对照。本批不构建 Build 73、不执行真机 E4、不声称为已验收包。
19.1 落点:为什么是 Perfowl-owned wrapper
方案 §7 要求"不要直接 patch site-packages/pymobiledevice3/.../activity_trace_tap.py"。实现完全遵守:
| 方案条款 | 实际实现 |
|---|---|
§7 Perfowl-owned _CooperativeActivityTraceTapStream,委托 __aenter__ / __aexit__ / config / channel lifecycle,只接管迭代 | 完全照做。类位于 display_performance.py,与 _ActivityTraceFilteredStream 相邻;__aenter__ / __aexit__ 直接转发给被包装的 tap;tap 的 _config 仍在 adapter 的 _make_stream 中写入(与改动前同一位置、同一顺序) |
§7.1 fairness gate 必须在 yield 之后判断 | 照做。row 先交给 filter → filter 命中后由 pump put_nowait 唤醒已 park 的 direct consumer → consumer 下一次向 tap 要 row 时,wrapper 才执行 gate。这恰好覆盖 Build 72 的 consumerWakeToDequeue 空窗 |
§8 ACTIVITYTRACE_PARSE_MAX_BURST_ROWS = 32 / ACTIVITYTRACE_PARSE_MAX_BURST_NS = 1_000_000 | 原值照做,未调整。row-count 是安全上限,active-time 是主 gate;不使用 wall-span;DTX receive await 不计入 active work |
§11 不复制 ActivityTraceTap._parse() opcode 状态机 | 照做。_parse() 仍是唯一解码器,wrapper 只用 next(parser) 推进一步 |
| §15 全部禁止项 | 逐条未做(见 §19.4) |
新增/修改的模块级符号(全部在 MacApp/Sidecar/perfowl_collector_core/display_performance.py):
text
ACTIVITYTRACE_PARSE_MAX_BURST_ROWS = 32
ACTIVITYTRACE_PARSE_MAX_BURST_NS = 1_000_000 # 1 ms
ACTIVITYTRACE_PARSE_YIELD_ROW_LIMIT = "row-limit"
ACTIVITYTRACE_PARSE_YIELD_ACTIVE_TIME = "active-time"
ACTIVITYTRACE_PARSE_ROW_HISTORY_CAPACITY = 2048
ACTIVITYTRACE_PARSE_REQUIRED_TAP_METHODS = ("_get_next_message", "_parse")
_assert_cooperative_tap_contract(tap) # 私有 API 契约 gate(§10)
_CooperativeActivityTraceTapStream(tap, census)# 新增,row-boundary 合作式调度ActivityTraceFrameLifetimeRawAdapter._make_stream 的接线变成:
python
tap = ActivityTraceTap(provider, enable_http_archive_logging=False)
...
cooperative = _CooperativeActivityTraceTapStream(tap, census=self._census)
return _ActivityTraceFilteredStream(cooperative, census=self._census)即 cooperative 层插在白名单 wrapper 之下、pinned tap 之上——row decode 必须在 filter 看到任何 row 之前就被纳入公平性,否则 filter 的 active-time 仍然只能从"tap 已经 yield"那一刻开始计时。
接线带来的可见变化(诚实记录):_ActivityTraceFilteredStream._tap 现在指向 cooperative wrapper,而不是 ActivityTraceTap 本身。cooperative wrapper 因此暴露一个 tap 属性指向真实 tap。已核对全仓库:唯一会解包 wrapper 的代码是 adapters.py::_underlying_prepared_stream(),它只按显式 isinstance 白名单解包 _ThrottledAsyncStream / _CoreProfile* / _SysmontapEnvelopeStream / _LifecyclePollStream,_ActivityTraceFilteredStream 从不在其中;ActivityTrace adapter 也不走 prepared-stream 路径(它的 prepare_transport() 只做状态断言,tap 在 start() 时惰性打开)。故本批不改变任何既有解包行为。
19.2 相对方案的偏差(四条,均为边界补齐,无条款删减)
| 方案条款 | 实际实现 | 原因 |
|---|---|---|
§9 parseMaxBurstRowCount / parseMaxActiveBurstDurationMs("有界聚合字段") | 这两个 max 在每一行都更新,而不是只在 gate 触发时更新 | 若只在让出时记录,则一次"frame 内 row 数从未达到 32、active 也从未达到 1ms"的真实采集会把两者都留成 0——而 Build 72 的 9890 条消息正是这个形态。0 无法回答 Gate E 的任何问题。逐行更新后二者恒等于"两次合作式让出之间最多解析了多少行 / 累积了多少同步解析时间",含义更强且仍然有界。 |
§9 可选分位数 parseRowDurationP50/P95/P99Ms | 由**有界 deque(容量 2048)**上的最近窗口计算,并额外输出 parseRowDurationHistoryCapacity / parseRowDurationRetainedCount | §9 同一段同时要求"有界聚合字段"与分位数;精确分位数需要无界内存。采用本文件既有 DisplayPipelineTiming 的同一约定(bounded deque + _percentile_ms),并在快照里显式暴露容量与保留数,使读者能判断窗口是否代表整段采集。parseMaxSingleRowDurationMs 与 parseRowsOver{1,5,20}msCount 仍对整段采集精确。 |
| §7 伪代码 | burst 计数不在 frame 边界重置 | _get_next_message() 内部的 channel.receive_message() 不保证是调度点(DTX 队列里可能已经排好下一帧)。跨帧累计只会低估实际提供的公平性(报出的 burst 更大),对门禁而言是安全方向。_CooperativeActivityTraceTapStream 的类注释写明了这一点。 |
| §10 契约 gate 的位置 | 在 _CooperativeActivityTraceTapStream.__init__ 中检查,因此异常从 _make_stream → open_channel → start() 抛出 | probe() 阶段还没有 ActivityTraceTap 实例(tap 需要 live provider),因此 start() 是能够检查的最早点。错误信息包含方案要求的字面量 cooperative parser contract changed,并列出缺失的方法名。 |
19.3 新增诊断字段(Build 73 的 Gate E 判据入口)
ActivityTraceAcquisitionCensus 的既有 19 个字段全部保留,本批新增 16 个:
text
parseFrameCount # 收到并交给 _parse() 的 DTX 帧数
parseRowCount # 解码出的 row 总数
parseYieldCount # 合作式让出总次数
parseYieldReasonCounts # {row-limit, active-time}
parseMaxBurstRowCount # 两次让出之间最多解析多少行
parseMaxActiveBurstDurationMs # 两次让出之间最多的同步解析时间
parseMaxSingleRowDurationMs # 单 row 最大同步解析耗时(整段采集精确)
parseRowsOver1msCount # 整段采集精确
parseRowsOver5msCount
parseRowsOver20msCount
parseRowDurationHistoryCapacity / parseRowDurationRetainedCount
parseRowDurationP50Ms / P95Ms / P99Ms # 最近窗口判读方式(Gate E):
text
parseYieldReasonCounts["row-limit"] 与 ["active-time"] 给出两个 gate 各自的实际作用
parseMaxActiveBurstDurationMs <= 1ms + parseMaxSingleRowDurationMs
-> 证明"没有任何一段解析在不让出的情况下超过预算 + 一个不可抢占的 row"
parseMaxBurstRowCount <= 32
-> 证明让出不是 per-row(Build 73 若看到 ~row 数量级即判定失败)
parseRowsOver20msCount 频繁非零
-> 才按 §11 进入 opcode-level cooperative parse 研究这些字段只用于 diagnostics:不属于 HostTimingChain.ORDER / RECORD_KEYS,不写 raw、不进 metric、不改任何公式。历史 Session 缺字段按既有约定兼容(新字段为 None)。
19.4 方案 §15 禁止项逐条核对
| 禁止项 | 状态 |
|---|---|
| 修改 pymobiledevice3 site-packages / frozen upstream 文件 | 未做。git status 中 site-packages 不在任何仓库内;本批 diff 只有 1 个产品文件 + 1 个新测试文件 |
| 复制完整 ActivityTrace opcode parser | 未做。_parse() 仍是唯一解码器 |
每 row 无条件 sleep(0) | 未做。只有 32-row / 1ms 两个上界触发 |
| 恢复 consumer-handoff | 未做。pumpYieldReasonCounts["consumer-handoff"] 仍恒为 0,专项测试 test_17 每轮断言 |
| 恢复 wall-time fairness | 未做。新增的 parser gate 用 active-time,不是 wall-span |
| 修改 Pump 32 / 1ms、Filter 64 / 1ms、Realtime 8 / 1ms | 未做。三处常量与三处 gate 代码零改动 |
| 扩大 subscription queue | 未做。pmd3_backend.py 本批零改动 |
| 降低 ActivityTrace 采集频率 | 未做。tap 的 config 构造顺序与内容未变 |
| 修改 ActivityTrace predicate / FrameLifetime whitelist | 未做。is_frame_lifetime_signpost() 与 ActivityTraceFrameLifetimeConfig 零改动 |
| 修改 FPS / FrameTime / Jitter / P95 / Refresh Rate 公式 | 未做。FrameLifetimeDecoder 零改动 |
| 通过放宽既有 throughput gate 接受性能回归 | 未做。>20,000 ev/s 门槛一个字符未改;新增 parser benchmark 的 floor(5,000 rows/s)是新增的、且比真机 row 速率高 40 倍以上 |
19.5 测试侧发现:pinned 解码器的 tables 是 append-only
为满足 §13.6"使用真实 ActivityTrace fixture"的要求,本批在测试里实现了DTX opcode 帧编码器(test_r8fix11_cooperative_parse_fairness.py 顶部的 _encode_* / build_activity_frame)。它只产出 wire 字节,不复制解码器——被测的仍是真实的 pinned ActivityTraceTap._parse()。
编码过程中确认了两条上游语义,两条都不是本次改动的产物,但都会影响后续任何 DTX 级测试:
- pushed item 的位打包:16-bit word,除最后一个外高 2 位为
0b10、最后一个为0b11,低 14 位为数据;14-bit 载荷拼接后左移补齐到字节边界再按大端输出。可达的字节长度只有 2/4/6/8/9/11/13/15/16/18/20/22/23/25/27/29…——1/3/5/7 字节不可达,所以字符串必须以额外 NUL 补齐(decode_str在第一个 NUL 处截断,补齐无损)。 self.tables只增不清:_handle_table_reset()只做generation += 1; stack = [],不清空tables;_handle_end_row()用 opcode 低字节直接索引self.tables[generation]。因此同一次采集里的第二张表是tables[1],其 end-row opcode 低字节必须是1。本批的test_09把这条语义钉死为契约(table_index=1),也正因如此,任何"每帧都重新定义表"的合成帧都会在第二帧开始解错。
另外:_handle_end_row() 取的永远是 stack[-len(columns):],所以 push 的值的个数与顺序必须严格等于该表的列数——多 push 一个值会让整行的字段整体错位(本批编码器第一版就踩到这一点)。
20. Validation Update(2026-09-20)
全部在隔离 worktree(分支 r8-fix11,基线 3de8ea7)执行。共享开发机 uptime 负载均值在测量期间为 4.5 – 8.3(另有高负载窗口,见 §20.5)。
20.1 E2 门禁结果(§16 逐条)
| §16 门禁 | 结果 |
|---|---|
| 1. R8-Fix-11 parser fairness 专项全绿 | 19 / 19 OK(test_r8fix11_cooperative_parse_fairness.py,1.32s;含 3 次重复运行均 OK) |
| 2. R8-Fix-10 direct waiter 全绿 | 12 / 12 OK |
| 3. R8-Fix-9 diagnostics 全绿 | 30 / 30 OK |
| 4. ActivityTrace FrameLifetime 全量回归 | OK(test_activitytrace_frame_lifetime.py) |
| 5. Display pipeline timing 全绿 | 31 / 31 OK |
| 6. Pmd3 fairness 全绿 | 19 / 19 OK |
| 7. Python 全量 | 778 条,0 失败(115.422s);同口径基线 3de8ea7 759 条,0 失败(110.042s)。778 = 759 + 19 = 本批新增专项,无其他增减 |
| 8. Swift 全量 | 182 tests / 3 skipped / 0 failures(swift test --disable-sandbox,15.465s) |
| 9. VitePress | 见 §20.6(0 错误、0 死链) |
10. git diff --check | 见 §20.6(干净) |
未改动的公平性上界(逐条核对):Pump 32 events / 1ms、ActivityTrace filter 64 messages / 1ms、ActivityTrace parser 32 rows / 1ms(本批新增的第四层)、realtime consumer 8 events / 1.0ms、订阅队列 2048、consumer drain 0.5s、Python 关闭 8.0s、Swift watchdog 10s、Device Conditions 20s。零 FPS / FrameTime / P95 / Jitter / Refresh-Rate 公式改动;pmd3_backend.py、pmd3_deep_services.py、realtime.py、contracts.py 本批完全不在 diff 中(git diff --name-only 可核)。
Build 73 shipping 因此形成方案 §12 要求的四层独立公平性,且四层的计数互相独立、不互相替代:
text
ActivityTrace parser : 32 rows / 1 ms parse-active (本批新增)
ActivityTrace filter : 64 messages / 1 ms filter-active
Pmd3Subscription pump: 32 events / 1 ms pump-active
Realtime consumer : 8 events / 1 ms consumer-active20.2 基准报告(§16 要求的全部读数)
甲 · 端到端 integration(真实 DTX 帧 → cooperative tap → 白名单 filter → Pmd3Subscription direct waiter → consumer)
形态:1 个 DTX 帧,128 行,第 1 行是 FrameLifetime、后 127 行是被白名单拒绝的消息。消费端在 channel 仍为空时先 park(first_delay_s=0.03),再放帧进来,因此 consumerWakeToDequeue 是真实样本。两个 arm 各跑 2 次、取 min、交替:
| 指标 | 对照(未包装的 pinned tap) | 本批 cooperative | 判读 |
|---|---|---|---|
consumerWakeToDequeue Max | 19.506 / 20.470 / 21.037 / 22.418 / 23.059 ms | 0.126 / 0.147 / 0.203 / 0.806 / 0.815 ms | 26x – 155x |
parseRowCount(诊断) | 0(未插桩的旧路径) | 128 | 新路径生效 |
parseFrameCount | 0 | 1 | — |
parseYieldCount / 原因 | 0 | 64–69,全部 active-time | 让出由 1ms 预算驱动 |
parseMaxBurstRowCount | 0 | ≤ 32(本例 2–3) | 不是 per-row |
parseMaxActiveBurstDurationMs | 0 | 1.602 – 2.318 ms | ≤ 1ms 预算 + 单行不可抢占部分 |
parseRowDuration P50 / P95 / Max | — | 0.617–0.663 / 0.828–1.032 / 1.001–1.376 ms | 单行成本(含上游 make_dataclass) |
parseRowsOver1ms / 5ms / 20ms | — | 1–8 / 0 / 0 | 无单行达到 5ms 量级 |
| filter(内容计数) | 128 messages / 1 matched / 127 rejected | 同上,逐项相等 | filter 语义未变 |
filter filterYieldCount | 0–3(墙钟驱动) | 0–3 | 两者都极小;见下注 |
pump pumpYieldCount / 原因 | 0 / {consumer-handoff: 0, …} | 0 / {consumer-handoff: 0, …} | 128 行未触发 32-event 上界,符合预期 |
delivered / consumed / pending / dropped | 1 / 1 / 0 / 0 | 1 / 1 / 0 / 0 | 无丢失 |
consumerDirectWaitCount | 2 | 2 | 消费者确实 park 过 |
consumer-handoff | 0 | 0 | R8-Fix-10 路线 C 未被回退 |
filterYieldCount在两个 arm 都是 0–3:filter 自己的 gate 是墙钟驱动的(burst_duration_ns),而本轮 128 条消息全部落在同一个 1ms 窗口之外时才让出。因此不能断言两侧相等——它随宿主计时变化。本批对此的断言刻意只覆盖 filter 的内容计数(消息数 / 命中数 / 拒绝数 / 被拒身份 TopN),这些在两个 arm 上逐项相等(test_18)。
该合成的意义是复现机制,不是复现 Build 72 的绝对量级:Build 72 真机是 9890 条消息 / 60s(
rawCallbackRateHz 112.845)、残余consumerWakeToDequeueP50 105.680ms;这里用单帧 128 行把同一个空窗放大到可判定,对照 arm 的 ~21ms 与真机的 105.680ms 同量级、同一成因。
乙 · 真实 DTX 帧的保真度 A/B(§13.6)
同一组合成帧分别经真实的 pinned ActivityTraceTap.__aiter__ 与本批 cooperative wrapper 解码,逐字段比对:
| 用例 | 覆盖 | 结果 |
|---|---|---|
test_07 | table reset + 3 帧 + 混合命中/拒绝 | 6 行逐字段完全相同 |
test_08 | 一个逻辑帧被切成 3 段 DTX 消息(跨帧 opcode carry) | 6 行完全相同,thread 序列 [1,2,3,4,1,2] |
test_09 | 同一次采集里两张表(tables[0] 3 列 / tables[1] 6 列) | 3 行完全相同,字段集合与表对应 |
test_10 | 经白名单 filter 后的输出 | 3 条 TimedRawEvent 的 payload.thread 完全相同 |
丙 · parser 吞吐与开销(§13.5)
2048 行、单帧、无延迟(row ceiling 是唯一会触发的 gate):
text
rows=2048 wall=8.7–12.2ms cpu=7.9–9.3ms rows/s=202,323–235,036
yields=63 {'row-limit': 63, 'active-time': 0} maxBurstRows=32
singleRowP50/P95/Max = 0.000/0.000/0.011–0.047 ms peakRSS≈55–56 MiByields = 2048 / 32 − 1 = 63 是预测值,测试断言的就是这个等式——它同时是"让出不是 per-row"的判别式(per-row 会给出 2048)。吞吐下限断言的 floor 是 5,000 rows/s,比真机 row 速率(Build 72 消息率 112.845 Hz)高 40 倍以上;这是新增的下限,既有 >20,000 ev/s 门槛未动。
丁 · 事件循环是否真的被释放(§13.1 的"concurrent heartbeat 获得执行")
test_14 用同一形状做确定性对照(两次读计数之间没有任何 await,因此事件循环不可能运行别的任务):
text
对照(无 gate) :100 行 × 0.2ms 同步解析 -> ticker 增量 == 0 (缺陷被确定性复现)
本批 cooperative :同样的 100 行 -> parseYieldCount > 10,
ticker 增量 >= 10 (每次让出给 ready 任务一次机会)本批没有也不可能在 E2 层测到真机的
eventLoopSchedulerDelay(那是 Gate D,只有真机能测)。E2 层给出的等价证据是两条:(1)parseMaxActiveBurstDurationMs恒满足<= 1ms + parseMaxSingleRowDurationMs,即"没有任何一段同步解析在不让出的情况下超过预算 + 一个不可抢占的行";(2)test_14的确定性对照,证明 gate 确实把执行权交回事件循环。
20.3 未变动的语义(回归重点)
- 新增诊断字段的隔离性:16 个
parse*字段只进入ActivityTraceAcquisitionCensus.snapshot(),不属于HostTimingChain.ORDER/RECORD_KEYS,不写 raw、不进 metric、不改任何公式。 - 旧 Session 兼容:这些字段在旧产物里不存在(读取方按既有约定视为缺失);
consumer-handoff键仍存在且恒为 0。 - 无 orphan task:
test_14显式取消并 await ticker 任务;integration 测试取消 consumer 后aclose(),无 pending 任务。 ActivityTraceFrameLifetimeRawAdapter的接线:test_06断言_make_stream返回_ActivityTraceFilteredStream→ 其_tap是_CooperativeActivityTraceTapStream→ 其tap真的暴露_parse;即白名单 wrapper 绝不会绕过 cooperative 层直接拿到 raw tap。- 契约 gate:
test_04断言缺任一私有方法时抛RuntimeError且消息含cooperative parser contract changed;test_05断言 pinned tap 仍然满足该契约。
20.4 与方案 §14 的关系(undrainedAtClose)
方案 §14 记录的 Build 72 undrainedAtClose = 1 / delivered == consumed / pending = 0 / raw count complete 语义问题,本批不改 closeout 生命周期,也不改该字段。R8-Fix-11 的完整性验收仍以 §14 的硬门禁为准(delivered == consumed、dropped = 0、pending = 0、raw Display count == delivered、ingressUndrainedAtSeal = 0、report seal complete)。本批的 integration 测试对前四项逐轮断言。
20.5 诚实边界
- 一条既有负载敏感 flake 再次出现,且不是本批回归:
test_display_ingress_backpressure.test_b1_b2_synthetic_120hz_benchmark(Source->Ingress max was 12.254ms, expected < 10.0ms)在本批 tree 的一次高负载组合运行(10 个套件同进程连续跑)中出现。此后:- 低负载(
uptime4.45)下两棵树各 3/3 单独通过; - 两棵树的 Python 全量(负载 4.8–5.0)各 0 失败;
- 该断言与
test_b1_b2所在的文件完全不在本批 diff 中。 这与本仓库既有的 R8-Fix-5 时期 flake 记录一致(满负载下观察到max 22.65ms > 10ms)。本批不放宽该阈值、也不修改该断言,按"一个批次一个范围"交由独立批次处理。
- 低负载(
- 测试数量差异已归因:本批 tree 778 / 基线 759,差额 19 恰为本批新增专项的用例数。核对时另发现基线 worktree 里不存在共享工作区中另一个并发 AI 新写的
tests/test_display_acquisition_diagnostics.py(未提交文件,不属于3de8ea7),因此最初一条把该模块列入的收集命令报ModuleNotFoundError—— 这是文件不在基线上,不是本批回归;从对照命令中移除后两树模块集完全一致。 - 本批仅在 E2 层证明:真机
eventLoopSchedulerDelay/eventLoopTickGap(Gate D)与pumpQueueWait/consumerWakeToDequeue(Gate B / C)的最终改善必须由 Build 73 真机 E4 判定。本批不构建 Build 73、不执行真机 E4、不声明validated。 git diff --check的说明:本批按既有约定在提交后用带 base 的形式(git diff --check 3de8ea7..HEAD)核对,结果见 §20.6。
20.6 门禁 9 / 10 与提交前核对
- 门禁 9 · VitePress:先跑知识门禁
python3 site/scripts/metric-knowledge-gate.py→PASS: 66 Registry contracts, Canonical ownership, versionless governance, ten-element markers, sidebar, overview, knowledgeRole governance, and rendered visual coverage;再按既有约定用rsync -a --delete --exclude='.DS_Store' ../docs/ public/archive/做归档增量同步(不用sync-archive.sh里的rm -rf public/archive,它会被本机批量删除保护 Hook 拦截),归档 HTMLdocs/architecture/perfowl-p4-r8-fix11-activitytrace-cooperative-parse-fairness-plan-2026-09-20.html已就位;最后npx vitepress build→build complete in 33.21s、退出码 0、0 错误、0 死链(只有一条既有的 "chunks larger than 500 kB" 提示,与本批无关)。 - 门禁 10 ·
git diff --check:按既有约定在提交后用带 base 的形式核对 ——git diff --check 3de8ea7..HEAD空输出(干净,无 trailing whitespace / space-before-tab / blank line at EOF)。 - 提交范围:隔离 worktree
/private/tmp/perfowl-r8fix11,分支r8-fix11,基线3de8ea7;显式路径提交(MacApp/Sidecar/perfowl_collector_core/display_performance.py、MacApp/Sidecar/tests/test_r8fix11_cooperative_parse_fairness.py、site/devlog/p4-r8-fix11-activitytrace-cooperative-parse-fairness-plan.md、site/devlog/index.md、docs/architecture/perfowl-p4-r8-fix11-activitytrace-cooperative-parse-fairness-plan-2026-09-20.html),绝不git add -A;提交前git status --short断言只列本批次文件。构建用的site/node_modules软链在提交前rm -f。
21. Build 73 E4 验收结果
验收状态:R8-Fix-11 validated · E4 passed
Build 73 Session:
61a85ff8-e91e-4229-96c6-434903323b59
21.1 Gate A:数据完整性 PASS
text
deliveredEventCount = 623
consumedEventCount = 623
pendingEventCount = 0
droppedEventCount = 0
undrainedAtClose = 0
raw Display count = 623
ingressAcceptedAtClose = 624
ingressPersistedAtClose = 624
ingressUndrainedAtSeal = 0
finalRawSequence = 624
lastMetricConsumedRawSequence = 624
reportConsumedRawSequence = 624
dataCompleteness = complete21.2 Gate B / C:queue wait 与 wake→dequeue PASS
Build 72 → Build 73:
text
pumpQueueWait P50: 87.864ms → 0.124ms
pumpQueueWait P95: 167.971ms → 0.388ms
pumpQueueWait Max: 260.963ms → 1.037ms
>100ms: 334 → 0
>500ms: 0 → 0
consumerWakeToDequeue P50: 105.680ms → 0.124ms
consumerWakeToDequeue P95: 165.407ms → 0.388ms
consumerWakeToDequeue Max: 208.918ms → 1.037msGate B/C 全部通过。
21.3 Gate D:event-loop heartbeat PASS
Build 72 → Build 73:
text
schedulerDelay P50: 348.691ms → 19.114ms
schedulerDelay P95: 416.257ms → 33.952ms
schedulerDelay Max: 492.020ms → 55.872ms
stalls >200ms: 187 → 0
stalls >1000ms: 0 → 0Build 73 已满足 P50 <50ms / P95 <100ms / Max <250ms。
21.4 Gate E:parser diagnostics PASS,无需 opcode-level
text
parseFrameCount = 52
parseRowCount = 8047
parseYieldCount = 7941
parseYieldReasonCounts = {
row-limit: 0,
active-time: 7941
}
parseMaxBurstRowCount = 2
parseMaxActiveBurstDurationMs = 37.818
parseMaxSingleRowDurationMs = 37.818
parseRowDuration P50/P95/P99 =
8.256 / 13.565 / 16.212 ms
rows >=1ms = 7905
rows >=5ms = 5198
rows >=20ms = 24真实设备 row 明显比 synthetic benchmark 昂贵,所以 1ms active gate 在实际设备上几乎每 row 都触发:
text
7941 / 8047 = 98.68%但这没有形成采集吞吐退化:
text
Build 72 raw callback = 112.845 Hz
Build 73 raw callback = 134.582 Hz
Build 72 matched = 9.174 Hz
Build 73 matched = 10.419 Hz超过 20ms 的 row:
text
24 / 8047 ≈ 0.30%不属于“频繁超过 20–50ms”。同时 queue wait、wake→dequeue、heartbeat 已全部通过,因此当前没有证据要求进入 opcode-level cooperative parser。
21.5 Gate F:downstream PASS
text
tapToFilter P95 = 0.118ms
filterToPump P95 = 0.026ms
dequeueToAdapter P95 = 0.035ms
adapterToRealtime P95 = 0.270ms
realtimeToIngress P95 = 0.031ms
tapToIngress P95 = 0.787ms21.6 Gate G:closeout PASS
text
closeoutStatus = clean
consumerDrainStatus = completed
consumerDrainDurationMs = 0.174
sourceConsumerTaskCount = 1
sourceConsumerCompletedCount = 1
sourceConsumerCancelledCount = 0
sourceConsumerTimedOutCount = 0
writerDrainStatus = drained
writerDrainDurationMs = 0.028
closeoutStageTimeoutCount = 022. R8-Fix-11 最终状态
Build 73 最终证明:
text
queueWait P95:
167.971ms → 0.388ms
wake→dequeue P95:
165.407ms → 0.388ms
heartbeat scheduler-delay P95:
416.257ms → 33.952ms
>200ms event-loop stalls:
187 → 0因此:
R8-Fix-11 E4 通过,Display / FrameTime subscription queue 与 ActivityTrace event-loop starvation 方向关闭。
后续不得在没有新 E4 证据的情况下继续调整 parser 32 rows / 1ms、Filter 64 messages / 1ms、Pump 32 events / 1ms、Realtime 8 events / 1ms、direct waiter 或 consumer-handoff=0。