planP4E4implemented · gate-open2026-09-20

R8-Fix-11:ActivityTraceTap Cooperative Parse Fairness 方案

> 当前状态:R8-Fix-10 implemented · E2 passed · E4 failed / partially effective > > 真机依据: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:

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:

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:

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

结论:

P50 <5ms / P95 <20ms / Max <100ms 仍明显失败;

2. Build 72 event loop 仍被长时间占用

Build 72 heartbeat:

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:

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:

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:

processedEventCount = 804
yieldCount = 100
maxObservedBurstCount = 8
maxObservedActiveBurstMs = 0.861
maxObservedSynchronousBurstMs = 0.256

Realtime consumer 不是剩余的几百毫秒 owner。

4. Filter active-time 也不能解释 heartbeat

ActivityTrace census:

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 计时位置:

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:

async def __aiter__(self):
    while True:
        await self._get_next_message()
        for message in self._parse():
            yield message

而 _parse() 是同步 Generator:

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:

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:

site-packages/pymobiledevice3/.../activity_trace_tap.py

在 Perfowl 自己的 display_performance.py 或独立小模块中实现:

_CooperativeActivityTraceTapStream

它包装真实 ActivityTraceTap,委托:

但接管迭代:

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 发生在:

当前 row 已交付
        ↓
direct consumer 已被标记 ready
        ↓
tap 准备解析下一 row
        ↓
fairness gate
        ↓
event loop 可先运行 consumer

这正好覆盖 Build 72 的 residual wake→dequeue 空窗。

8. Parser fairness 常量

第一版使用:

ACTIVITYTRACE_PARSE_MAX_BURST_ROWS = 32
ACTIVITYTRACE_PARSE_MAX_BURST_NS = 1_000_000  # 1 ms active parse work

理由:

禁止为了追求漂亮真机数字直接使用每 row sleep(0)。

如果单 row 自身耗时 >1ms,gate 会在该 row 完成后立即让出;本批先接受“row boundary cooperative”,不复制/重写 opcode parser。

9. Parser diagnostics

在 ActivityTraceAcquisitionCensus 增加有界聚合字段:

parseRowCount
parseFrameCount

parseYieldCount
parseYieldReasonCounts = {
  row-limit,
  active-time
}

parseMaxBurstRowCount
parseMaxActiveBurstDurationMs
parseMaxSingleRowDurationMs

parseRowsOver1msCount
parseRowsOver5msCount
parseRowsOver20msCount

可选分位数:

parseRowDurationP50Ms
parseRowDurationP95Ms
parseRowDurationP99Ms

历史 Session 缺字段正常兼容。

这些字段只用于 diagnostics,不进入任何正式 metric 公式。

10. 依赖私有 pmd3 方法的防护

wrapper 会调用 pinned 11.3.1 的:

_get_next_message()
_parse()

两者是上游私有方法,因此必须明确做版本/契约 gate。

启动前至少检查:

hasattr(tap, "_get_next_message")
hasattr(tap, "_parse")

测试锁定 pinned 11.3.1 行为。

如果未来 pmd3 API 改变:

11. 不复制 pymobiledevice3 parser

本批禁止把整份 ActivityTraceTap._parse() opcode 状态机复制进 Perfowl。

原因:

只有 Build 73 证明:

parseMaxSingleRowDurationMs

本身经常达到几十/几百 ms,row-boundary fairness 无法满足 Gate, 才允许开后续 opcode-level cooperative parser 批次。

12. 与现有三层 fairness 的关系

Build 73 shipping 应形成四层独立公平性:

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

四层负责不同同步工作,不互相替代。

特别禁止:

13. 自动化测试

至少覆盖:

13.1 DTX frame 内大量 row

模拟单 frame 含大量 row,_parse() 连续同步产出。

要求:

13.2 upstream receive wait 不计 active

每 frame 前等待 20–50ms:

13.3 expensive row

注入单 row 2–5ms 同步 parse:

13.4 direct waiter integration

组合:

cooperative tap
→ filter
→ Pmd3Subscription direct waiter
→ consumer

要求:

13.5 200k / burst throughput

新增 parser fairness 不能导致类似已拒绝的 per-event yield 退化。

至少报告:

throughput
parseYieldCount
filterYieldCount
pumpYieldCount
heartbeat P95/Max
CPU
RSS

13.6 real ActivityTrace fixture

使用现有 ActivityTrace 测试 fixture / captured frame:

行为必须与原 ActivityTraceTap 一致。

14. Build 72 undrainedAtClose=1 说明

Build 72 出现:

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 掉, 于是最终形成:

undrainedAtClose = 1
delivered == consumed
pending = 0
raw count complete

本批不改 closeout 生命周期。

记录为 diagnostics semantic follow-up:

> undrainedAtClose 是 source-close 瞬时 backlog,不应单独解释成 final loss。

Build 73 完整性以:

delivered == consumed
dropped = 0
pending = 0
raw Display count == delivered
ingressUndrainedAtSeal = 0
report seal complete

为硬门禁。

后续单独修正字段命名/最终 undrained 语义,不与 parser fairness 混改。

15. 明确禁止

R8-Fix-11 禁止:

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 必须报告:

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

测试条件:

Gate A 数据完整性

delivered == consumed
dropped = 0
pending = 0
raw Display count == delivered
ingressUndrainedAtSeal = 0
dataCompleteness = complete
report seal sequence fully aligned

Gate B queue wait

硬门槛:

>500ms = 0
>1000ms = 0

目标:

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

Gate C wake→dequeue

核心目标:

P50 < 5ms
P95 < 20ms
Max < 100ms

相比 Build 72:

105.680 / 165.407 / 208.918 ms

必须有数量级改善。

Gate D heartbeat

Build 72:

schedulerDelay P50 = 348.691ms
P95 = 416.257ms
Max = 492.020ms

Build 73 要求:

P50 < 50ms
P95 < 100ms
Max < 250ms

并且:

stalls >200ms

应从 Build 72 的 187 明显下降,优先目标为 0。

Gate E parser diagnostics

必须能回答:

如果:

parseMaxSingleRowDurationMs

仍频繁超过 20–50ms,才进入 opcode-level cooperative parse 研究。

Gate F downstream

保持:

filterToPump P95 < 5ms
dequeueToAdapter P95 < 5ms
adapterToRealtime P95 < 5ms
realtimeToIngress P95 < 5ms

Gate G closeout

维持 Build 71/72:

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

18. 最终判断

Build 72 已证明:

child-task acquisition 已移除
        ↓
direct waiter 生效
        ↓
consumer-handoff 已移除
        ↓
queue wait 明显改善
        ↓
但 event loop 仍 300–500ms stall

现有 Filter active-time 只覆盖:

tap yield message
        ↓
whitelist/filter work

没有覆盖:

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):

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 的接线变成:

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 个:

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):

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 要求的四层独立公平性,且四层的计数互相独立、不互相替代:

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):

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,因此事件循环不可能运行别的任务):

对照(无 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 未变动的语义(回归重点)

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 个套件同进程连续跑)中出现。此后:

这与本仓库既有的 R8-Fix-5 时期 flake 记录一致(满负载下观察到 max 22.65ms > 10ms)。本批不放宽该阈值、也不修改该断言,按"一个批次一个范围"交由独立批次处理。

  1. 测试数量差异已归因:本批 tree 778 / 基线 759,差额 19 恰为本批新增专项的用例数。核对时另发现基线 worktree 里不存在共享工作区中另一个并发 AI 新写的 tests/test_display_acquisition_diagnostics.py(未提交文件,不属于 3de8ea7),因此最初一条把该模块列入的收集命令报 ModuleNotFoundError —— 这是文件不在基线上,不是本批回归;从对照命令中移除后两树模块集完全一致。
  2. 本批仅在 E2 层证明:真机 eventLoopSchedulerDelay / eventLoopTickGap(Gate D)与 pumpQueueWait / consumerWakeToDequeue(Gate B / C)的最终改善必须由 Build 73 真机 E4 判定。本批不构建 Build 73、不执行真机 E4、不声明 validated。
  3. git diff --check 的说明:本批按既有约定在提交后用带 base 的形式(git diff --check 3de8ea7..HEAD)核对,结果见 §20.6。

20.6 门禁 9 / 10 与提交前核对