Skip to content

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

因此:

  1. parent consumer 已经直接 park 在 queue.get();
  2. R8-Fix-9 的 per-event consumer-handoff sleep(0) 已完全退出 shipping path;
  3. Pump 重新由既有 32-event / 1ms active-time 公平性门控制;
  4. 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 ms

Build 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 ms

P95/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.256

Realtime 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.190

Filter 自己的同步 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 已经收到:

  1. _get_next_message() 的 await 已结束;
  2. 后续 _parse() 在当前 asyncio Task 内同步执行;
  3. Filter 对 tap.__anext__() 的 await 不保证真的把执行权交回 event loop;
  4. 直到 _parse() 找到下一条 row 并 yield,Perfowl Filter 才重新取得控制;
  5. Filter 的 1ms active-time gate看不到这段同步 parse;
  6. direct waiter 虽然已经 ready,仍必须等这段 upstream 同步工作结束。

这与 Build 72:

text
consumerWakeToDequeue P50 = 105.7ms
eventLoopSchedulerDelay P50 = 348.7ms

方向一致。

6. R8-Fix-11 目标 ​

  1. 把 ActivityTrace row 解码纳入真实 active-time fairness。
  2. 在一个 DTX frame 内同步解析大量 row 时,允许 event loop 定期运行其它 ready task。
  3. 不修改 pymobiledevice3 site-packages / frozen upstream 源码。
  4. 不改变 ActivityTrace config、predicate、whitelist、FrameLifetime 识别规则。
  5. 不改变 Pump / Filter / Realtime Consumer 现有公平性常量。
  6. 保留 direct waiter。
  7. 保证 0 drop、全部 FrameLifetime 可达 recorder。
  8. 用 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
RSS

13.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 门禁 ​

必须:

  1. R8-Fix-11 parser fairness 专项全绿;
  2. R8-Fix-10 direct waiter 全绿;
  3. R8-Fix-9 diagnostics 全绿;
  4. ActivityTrace FrameLifetime 全量回归;
  5. Display pipeline timing 全绿;
  6. Pmd3 fairness 全绿;
  7. Python 全量;
  8. Swift 全量;
  9. VitePress;
  10. 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
drops

17. 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 aligned

Gate B queue wait ​

硬门槛:

text
>500ms = 0
>1000ms = 0

目标:

text
P50 < 20ms
P95 < 50ms
Max < 200ms

Gate 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.020ms

Build 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 < 5ms

Gate G closeout ​

维持 Build 71/72:

text
closeoutStatus = clean
consumerDrainStatus = completed
writerDrainStatus = drained
closeoutStageTimeoutCount = 0

18. 最终判断 ​

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 worktree perfowl-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 级测试:

  1. 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 处截断,补齐无损)。
  2. 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-active

20.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 Max19.506 / 20.470 / 21.037 / 22.418 / 23.059 ms0.126 / 0.147 / 0.203 / 0.806 / 0.815 ms26x – 155x
parseRowCount(诊断)0(未插桩的旧路径)128新路径生效
parseFrameCount01—
parseYieldCount / 原因064–69,全部 active-time让出由 1ms 预算驱动
parseMaxBurstRowCount0≤ 32(本例 2–3)不是 per-row
parseMaxActiveBurstDurationMs01.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 filterYieldCount0–3(墙钟驱动)0–3两者都极小;见下注
pump pumpYieldCount / 原因0 / {consumer-handoff: 0, …}0 / {consumer-handoff: 0, …}128 行未触发 32-event 上界,符合预期
delivered / consumed / pending / dropped1 / 1 / 0 / 01 / 1 / 0 / 0无丢失
consumerDirectWaitCount22消费者确实 park 过
consumer-handoff00R8-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)、残余 consumerWakeToDequeue P50 105.680ms;这里用单帧 128 行把同一个空窗放大到可判定,对照 arm 的 ~21ms 与真机的 105.680ms 同量级、同一成因。

乙 · 真实 DTX 帧的保真度 A/B(§13.6)

同一组合成帧分别经真实的 pinned ActivityTraceTap.__aiter__ 与本批 cooperative wrapper 解码,逐字段比对:

用例覆盖结果
test_07table 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 MiB

yields = 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 诚实边界 ​

  1. 一条既有负载敏感 flake 再次出现,且不是本批回归:test_display_ingress_backpressure.test_b1_b2_synthetic_120hz_benchmark(Source->Ingress max was 12.254ms, expected < 10.0ms)在本批 tree 的一次高负载组合运行(10 个套件同进程连续跑)中出现。此后:
    • 低负载(uptime 4.45)下两棵树各 3/3 单独通过;
    • 两棵树的 Python 全量(负载 4.8–5.0)各 0 失败;
    • 该断言与 test_b1_b2 所在的文件完全不在本批 diff 中。 这与本仓库既有的 R8-Fix-5 时期 flake 记录一致(满负载下观察到 max 22.65ms > 10ms)。本批不放宽该阈值、也不修改该断言,按"一个批次一个范围"交由独立批次处理。
  2. 测试数量差异已归因:本批 tree 778 / 基线 759,差额 19 恰为本批新增专项的用例数。核对时另发现基线 worktree 里不存在共享工作区中另一个并发 AI 新写的 tests/test_display_acquisition_diagnostics.py(未提交文件,不属于 3de8ea7),因此最初一条把该模块列入的收集命令报 ModuleNotFoundError —— 这是文件不在基线上,不是本批回归;从对照命令中移除后两树模块集完全一致。
  3. 本批仅在 E2 层证明:真机 eventLoopSchedulerDelay / eventLoopTickGap(Gate D)与 pumpQueueWait / consumerWakeToDequeue(Gate B / C)的最终改善必须由 Build 73 真机 E4 判定。本批不构建 Build 73、不执行真机 E4、不声明 validated。
  4. 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 拦截),归档 HTML docs/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 = complete

21.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.037ms

Gate 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 → 0

Build 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.787ms

21.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 = 0

22. 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。

Perfowl · Performance Observer