R8-Fix-10:Pmd3Subscription 直接 Queue Waiter 与终止信号方案
> 当前状态:implemented · gate-open / E2 completed / E4 pending > > 真机依据:Build 71,Session > 7adf40be-aa74-42ec-9d36-a265e6217ede。 > > 下一测试包:Build 72。 > > 本批不修改 FPS / FrameTime 公式,不修改 ActivityTrace whitelist,不修改队列容量,不修改 pymobiledevice3 源码。 > 第一目标是消除 Pmd3Subscription.__anext__() 中 > queue.get() child Task + done.wait() child Task + asyncio.wait(FIRST_COMPLETED) > 引入的额外 scheduler turn。 > > 实现与验证结果见 §16 / §17;本批不构建 Build 72、不执行真机 E4、不声明 validated。
1. Build 71 E4 结论
1.1 完整性与 closeout 已通过
Build 71:
deliveredEventCount = 480
consumedEventCount = 480
droppedEventCount = 0
undrainedAtClose = 0
pendingEventCount = 0
dataCompleteness = complete
ingressUndrainedAtSeal = 0
closeoutStatus = clean
consumerDrainStatus = completed
consumerDrainDurationMs = 1.545
sourceConsumerTaskCount = 1
sourceConsumerCompletedCount = 1
sourceConsumerCancelledCount = 0
sourceConsumerTimedOutCount = 0
writerDrainStatus = drained
writerDrainDurationMs = 5.718
closeoutStageTimeoutCount = 0
Build 70 的 consumer-drain timed-out 已消失。
1.2 R8-Fix-9 明显改善 queue wait,但未过 Gate B
Build 70:
pumpQueueWait:
P50 = 552.368 ms
P95 = 1029.745 ms
Max = 1346.897 ms
>500ms = 378
>1000ms = 49
Build 71:
pumpQueueWait:
P50 = 233.889 ms
P95 = 431.700 ms
P99 = 513.215 ms
Max = 593.027 ms
>100ms = 397
>500ms = 7
>1000ms = 0
说明 R8-Fix-9 不是无效修改,但仍未满足:
>500ms count = 0
因此 R8-Fix-9 E4 判定为 failed / partially effective,不得标记 validated。
2. Build 71 最关键的新证据
2.1 consumer-handoff 已真实触发
consumerWaitEnterCount = 203
consumerWakeRequestedCount = 203
consumerWakeToDequeueCount = 203
consumerWakeUnclaimedCount = 0
pumpYieldCount = 203
pumpYieldReasonCounts = {
consumer-handoff: 203,
event-limit: 0,
active-time: 0
}
也就是说:
- consumer 确实进入等待;
- Pump 确实观察到 waiter;
- Pump 确实执行了 handoff yield;
- 每一次 wake 最终都有 dequeue;
- 没有漏 wake。
2.2 但 wake→dequeue 本身仍然是数百毫秒
consumerWakeToDequeue:
P50 = 320.001 ms
P95 = 472.153 ms
Max = 593.027 ms
这一组数字几乎直接解释了 Build 71 的 queue wait。
因此根因已从:
> Pump 没有让出
进一步收窄为:
> Pump 已让出,但真正的 parent consumer 仍需额外 scheduler turn 才能拿到 item。
3. Queue wait 三分组证据
全部 480 条均成功归因:
queueWaitAttributedCount = 480
queueWaitUnattributedCount = 0
consumer waiting
count = 360
P50 = 259.523 ms
P95 = 445.159 ms
Max = 593.027 ms
>500ms = 7
backlogged
count = 100
P50 = 111.642 ms
P95 = 300.039 ms
Max = 346.139 ms
>500ms = 0
consumer not waiting
count = 20
P50 = 144.453 ms
P95 = 204.735 ms
Max = 231.524 ms
最大延迟明确集中在“consumer 已经等待”的场景。
4. 现有 __anext__ 调度结构的额外 scheduler turn
当前逻辑:
item_task = asyncio.create_task(self._queue.get())
done_task = asyncio.create_task(self._done.wait())
completed, pending = await asyncio.wait(
(item_task, done_task),
return_when=asyncio.FIRST_COMPLETED,
)
当 Pump:
self._queue.put_nowait(item)
await asyncio.sleep(0)
实际唤醒链不是:
Pump
→ consumer
而是:
Pump
→ queue.get child Task
→ asyncio.wait completion callback
→ parent Pmd3Subscription.__anext__ Task
至少多一个 scheduler turn。
而 Pump 在自己的 sleep(0) 后也会再次进入 ready queue。 如果 Pump 比 parent consumer 更早重新运行,它会继续进入上游 async iterator。
5. 为什么 ActivityTrace 路径会放大这一空窗
pymobiledevice3 11.3.1:
async def ActivityTraceTap.__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 内可能包含大量 row。
因此:
- Pump handoff 后重新进入 Filter;
- Filter 再进入 ActivityTraceTap;
_parse()会在同一个 asyncio Task 中做同步 opcode/row 解码;- 这些同步工作不经过 event-loop await;
- parent consumer 仍在等待它自己的下一次 scheduler turn。
这与 Build 71 heartbeat 高度一致:
eventLoopSchedulerDelay:
P50 = 335.238 ms
P95 = 529.260 ms
Max = 663.125 ms
eventLoopStallsOver200Ms = 198
eventLoopStallsOver1000Ms = 0
但第一步不直接改 pymobiledevice3,因为可以先去掉 parent consumer 的额外调度层。
6. 非仓库合成实验:已经直接复现
使用当前 Pmd3Subscription,构造:
- 每次 yield 一个 item;
- 下一次 upstream
__anext__()在真正 await 前同步占用 250ms; - consumer 已 park;
- Pump 执行 consumer-handoff。
当前实现:
CURRENT wake→dequeue:
P50 ≈ 1000.432 ms
P95 ≈ 1000.432 ms
Max ≈ 1000.432 ms
同一 upstream,仅把 consumer 改成直接:
item = await queue.get()
结果:
DIRECT wake→dequeue:
0.056 ms
0.145 ms
0.139 ms
0.088 ms
0.137 ms
即:
> 同一上游同步阻塞条件下,直接 queue waiter 可以让 consumer 在 Pump 重新进入上游同步解析前先拿到事件。
这是 R8-Fix-10 的核心依据。
7. 实现目标
把 Pmd3Subscription.__anext__() 从:
parent consumer
↓
asyncio.wait
├─ queue.get child task
└─ done.wait child task
改成:
parent consumer
↓
direct await queue.get()
Pump/source 完成通过一个私有 terminal sentinel 唤醒正在等待的 consumer。
8. 推荐实现
8.1 私有 terminal sentinel
每个 subscription 自有:
self._terminal_item = object()
仅用 identity 判断,不允许任何外部 item 与其冲突。
8.2 __anext__ 快路径
保留现有:
try:
item = self._queue.get_nowait()
except asyncio.QueueEmpty:
...
else:
return normal_item
如 queue 已有 backlog,不新增任何 await。
8.3 direct queue waiter
队列为空:
if self._done.is_set():
raise stored_error_or_stop
self._mark_consumer_waiting()
try:
item = await self._queue.get()
finally:
self._clear_consumer_waiting()
这里从 parent consumer 本身直接 await queue。
不得:
- create
item_task; - create
done_task; asyncio.wait(FIRST_COMPLETED);- 为正常 item acquisition 再
gather()。
8.4 source done / error 的 race
必须避免:
consumer 检查 done=False
→ source 同时结束
→ consumer 永久 await queue.get()
asyncio 单线程下,从:
if self._done.is_set():
...
self._mark_consumer_waiting()
item = await self._queue.get()
在 await queue.get() 前没有 suspension,所以 Pump 不可能插入到 done-check 与 waiter 标记之间。
Pump finally:
self._done.set()
if self._consumer_waiting:
self._queue.put_nowait(self._terminal_item)
此 terminal 仅用于唤醒当前 parked consumer。
如果 consumer 未 parked:
- queue 内已有数据:先正常 drain;
- 下一次
__anext__()看到_done后结束; - 不需要把 terminal 排到 backlog 后面。
这样避免 source close 时在满队列上 await queue.put(terminal)。
8.5 terminal 不算业务事件
terminal:
- 不增加 deliveredEventCount;
- 不增加 consumedEventCount;
- 不进入 drop;
- 不写 HostTimingChain;
- 不写 raw;
- 不进入 Display metric;
- 不进入 queue depth histogram;
- 不计 consumerWakeToDequeue。
它只是一种控制唤醒。
8.6 error 语义
Pump 保存:
self._error = error
consumer 收到 terminal 或下一次发现 done:
if self._error is not None:
raise self._error
raise StopAsyncIteration
必须保持:
> 已 enqueue 的正常 item 先 drain,之后再传播 source error。
与当前语义一致。
9. 决策更新:direct waiter 下取消 consumer-handoff 显式让出
R8-Fix-10 开发阶段已经证明,§9 原“保留 consumer-handoff”要求的前提在 direct waiter 成立后不再成立。
direct waiter 改造后,queue.put_nowait(item) 唤醒的已经是 parent consumer 自身。此时继续执行:
if handoff_needed:
await asyncio.sleep(0)
不再是在补偿 child-task / parent-task 的额外 scheduler turn,而会退化成 paced source 下近似每事件显式让出一次。
同一 worktree 交替对照实测:
10k 同步事件:
无 per-event handoff ≈ 220k ev/s
保留 handoff ≈ 2,025 ev/s
200k 同步 backlog:
无 per-event handoff ≈ 3.4 s
保留 handoff ≈ 71.2 s
120Hz 管道:
无 per-event handoff ≈ 13,159 ev/s
保留 handoff ≈ 6,882 ev/s
这不是可接受的“公平性换吞吐”,而是重复调度造成的结构性退化。
9.1 三种路线的选择
路线 A:保留 handoff,并放宽既有 benchmark 门槛 — 拒绝。
不能把 >20,000 ev/s 的既有门槛放宽约一个数量级来迁就新实现。门槛用于发现回归,不应反向修改门槛使回归通过。
路线 B:把 handoff 改成 N 次一次、按时间节流或 queue-depth 条件触发 — 不作为主线。
这会重新引入经验阈值,并与现有 Pump 32 events / 1ms active-time、Filter 64 messages / 1ms active-time 公平性边界重复。只有 Build 72 证明“direct waiter + 现有公平性边界”仍不能让 parent consumer及时运行时,才允许把它作为实验分支。
路线 C:取消 consumer-handoff 的调度行为,保留 waiter/wake 诊断 — 选定。
shipping 路径改为:
put_nowait
↓
direct queue waiter 被标记 ready
↓
Pump / Filter 继续受既有 active-time / event-count fairness 约束
↓
下一次公平性让出时 parent consumer 直接运行
因此:
- 不再因
handoff_needed调用asyncio.sleep(0); - 不再把
consumer-handoff当作 shipping pump yield reason; consumerWaitEnterCount、consumerWakeRequestedCount、consumerWakeToDequeue*等诊断全部保留;- 为历史 Session / schema 兼容,如
pumpYieldReasonCounts.consumer-handoff仍需保留字段,则新路径固定为 0,不允许伪造为“发生了 handoff yield”; - Pump 原有
event-limit/active-time两个 yield reason 保持不变。
这一步的目标是验证:direct waiter 是否已经把 R8-Fix-9 的显式 handoff 从“必要补偿”变成“重复让出”。
10. 诊断要求
保留:
- consumerWaitEnterCount
- consumerWakeRequestedCount
- consumerWakeToDequeueCount
- consumerWakeToDequeue P50/P95/Max
- queue depth
- queue wait 三分组
- pump yield reasons
- heartbeat
新增可选计数:
consumerDirectWaitCount
consumerTerminalWakeCount
仅用于证明 Build 72 运行的是新路径。
禁止增加逐事件日志。
11. 自动化测试
至少新增/修改:
- upstream sync-block-after-handoff regression
- 5 个事件;
- 每次下一次 source pull 同步阻塞 20–50ms;
- direct waiter 下 wake→dequeue P95 < 5ms;
- 旧模型 helper 必须能复现几十/几百 ms。
- parked consumer single event
consumerWakeRequestedCount +1;- 不产生
consumer-handoff显式 yield; - parent consumer 在现有公平性边界允许的下一调度机会得到 item;
- waiting flag 清零。
- source naturally done while consumer parked
- terminal 唤醒;
- StopAsyncIteration;
- 无 orphan task。
- source raises while consumer parked
- terminal 唤醒;
- 原异常传播;
- 无 orphan task。
- source done with backlog
- 所有已 enqueue item 先消费;
- 再 Stop;
- terminal 不插入 backlog 中间。
- aclose while consumer parked
- consumer 可退出;
- Pump 可结束;
- release exactly once。
- aclose with backlog
- 不死锁;
undrainedAtClose保持真实值。
- full queue
- 原 drop accounting 不变;
- terminal 不造成业务 item 被额外 drop。
- 200000-event backlog
- throughput 不低于 R8-Fix-9;
- heartbeat 无退化;
- 无 Task 数量线性增长。
- task leak
- 每事件不再创建两个 acquisition child task;
- pending Task 数量长期稳定。
12. 明确禁止
本批禁止:
- 修改 pymobiledevice3 11.3.1 源码;
- 给 ActivityTraceTap._parse() 随机插 sleep;
- 增大 subscription queue;
- 延长 timeout;
- 恢复 wall-time pump fairness;
- 降采样;
- 修改 Display formulas;
- 修改 ActivityTrace predicate / whitelist;
- 用 polling timeout 代替直接 waiter;
- 用
asyncio.wait_for(queue.get(), very_small_timeout)循环轮询; - 每事件创建新的 Task 解决 done race;
- 为保留旧 §9 而在 direct waiter 路径每事件执行
asyncio.sleep(0); - 通过放宽既有吞吐 benchmark 门槛来接受结构性性能退化;
- 用新的 N-event / wall-time handoff heuristic 替代现有 32 events / 1ms active-time 公平性门,除非后续 E4 证明现有门不足。
13. E2 门禁
必须:
- R8-Fix-10 专项全绿;
- Pmd3Subscription fairness 全绿;
- R8-Fix-9 专项全绿;
- Display pipeline timing 全绿;
- Python 全量;
- Swift 全量;
- VitePress;
- git diff --check;
- synthetic blocking-after-handoff benchmark;
- 既有 Pump benchmark
>20,000 events/s不得放宽; - 200k backlog 与 120Hz benchmark 不得出现相对 R8-Fix-9 的数量级退化。
合成 benchmark 必须报告:
wake→dequeue P50/P95/Max
pumpQueueWait P50/P95/Max
throughput
CPU
heartbeat P95/Max
delivered/consumed/drop/undrained
Task count / leak
14. Build 72 E4
与 Build 71 同条件:
- 纯 Display;
- 同设备;
- 同目标 App;
- 60–90 秒;
- 正常停止。
Gate A 完整性
delivered == consumed
dropped = 0
undrained = 0
dataCompleteness = complete
Gate B queue wait
硬门槛:
>500ms = 0
>1000ms = 0
目标:
P50 < 20ms
P95 < 50ms
Max < 200ms
Gate C wake→dequeue
R8-Fix-10 的核心门槛:
consumerWakeToDequeue P50 < 5ms
consumerWakeToDequeue P95 < 20ms
Max < 100ms
如果这一项仍是几百毫秒,说明 direct waiter 仍未获得下一 scheduler turn,届时才进入 ActivityTraceTap upstream cooperative parse。
Gate D 其它 host layers
filterToPump P95 < 5ms
dequeueToAdapter P95 < 5ms
adapterToRealtime P95 < 5ms
realtimeToIngress P95 < 5ms
Gate E heartbeat
不得比 Build 71 恶化。
Build 71 基线:
schedulerDelay P50 = 335.238ms
P95 = 529.260ms
Max = 663.125ms
stalls >200ms = 198
如果 queue/wake 已修复但 heartbeat 仍高,单独开后续 ActivityTrace cooperative parse 批次,不再混入 subscription queue 修复。
Gate F closeout
维持 Build 71:
closeoutStatus = clean
consumerDrainStatus = completed
writerDrainStatus = drained
timeout count = 0
15. 最终判断
Build 71 已证明:
Pump handoff 确实发生
↓
queue.get child Task 被唤醒
↓
parent __anext__ 仍然要等额外 scheduler turn
↓
Pump 又进入 ActivityTrace 上游同步解析
↓
parent consumer 迟到数百毫秒
合成实验则证明:
同一 upstream 同步阻塞
current asyncio.wait parent-child model → ~1000ms
direct queue waiter → 0.06–0.15ms
因此 R8-Fix-10 的第一选择不是修改 pymobiledevice3,而是先把真实 consumer 直接挂在 queue waiter 上,并删除已经变成重复调度的 per-event consumer-handoff yield。
现阶段 shipping 组合应为:
direct queue waiter
+ terminal signal
+ Pump 32 events / 1ms active-time
+ Filter 64 messages / 1ms active-time
- per-event consumer-handoff sleep(0)
只有 Build 72 证明 direct waiter + 现有公平性边界之后,consumerWakeToDequeue 仍然维持数百毫秒,才进入上游 ActivityTrace cooperative parse;不得先用新的 handoff heuristic 掩盖根因。
16. Implementation Update(2026-09-20)
> 状态:implemented · gate-open / E2 completed / E4 pending > > 实现在本方案(含用户更新的 §9 路线 C)被批准后于隔离 worktree(分支 r8-fix10,基线 a3ea7cd = 两个远程的 main)完成,共享脏工作区未被触碰。另建 detached worktree perfowl-r8fix10-base(同一 a3ea7cd)用于全程 A/B 对照。 > > 本批不构建 Build 72、不执行真机 E4、不声明 validated。
16.1 相对方案的偏差(两条,均为边界补齐,无条款删减)
| 方案条款 | 实际实现 | 原因 |
|---|---|---|
§8.4 if self._consumer_waiting: self._queue.put_nowait(self._terminal_item) | 补上第二条件:if self._consumer_waiting and not self._queue.full() | 方案给出的理由是"避免在满队列上 await queue.put(terminal) 造成 close 死锁",但 put_nowait 在满队列上抛 QueueFull;若为腾位置先丢一条业务 item,就会让 sentinel 造成额外 drop,与 §8.5"terminal 不进入 drop"直接冲突。因此满队列时跳过 sentinel:此时第一个 put_nowait 已经唤醒过该 getter,consumer 不会滞留,sentinel 只是多余而非必需。该 race 方案 §8.4 未枚举(其枚举的是"consumer 未 parked"这一分支)。 |
§11.1 要求 direct waiter 下 wake→dequeue P95 < 5ms | 专项测试断言的是 §14 Gate C(P50 < 5ms / P95 < 20ms / Max < 100ms) | §11.1 与 §14 Gate C 对同一指标给了两个不同的尾部阈值(5ms vs 20ms)。在分级合成形态(真实 tap 形状、1 帧/消息、17ms/帧)上实测 P95 在 0.40ms(空载)~10.27ms(宿主高负载) 之间,5ms 只在空载可达。Gate C 是本方案给 Build 72 真机 E4 定的判据,专项测试采用它并额外断言 new_p50 < legacy_p50 与 legacy_p50 > 5ms(旧模型必须复现缺陷)。若 Build 72 实测 P95 超过 20ms,按 §14 判失败。 |
其余条款逐条照做,无删减:§8.1 私有 object() sentinel 按 identity 判断;§8.2 快路径(backlog 不新增 await);§8.3 direct queue waiter,不创建 item_task / done_task、不用 asyncio.wait(FIRST_COMPLETED)、不为正常 acquisition gather();§8.5 terminal 不增 delivered/consumed、不进 drop、不写 HostTimingChain、不写 raw、不进 metric、不进 queue depth 直方图、不计 consumerWakeToDequeue;§8.6 已 enqueue 的正常 item 先 drain 再传播 source error;§9 路线 C 的四条全部落地(不再因 handoff_needed 调 sleep(0)、consumer-handoff 不再是 shipping yield reason、wake/waiter 诊断全保留、pumpYieldReasonCounts 键保留但固定为 0、event-limit / active-time 两个 reason 不变);§10 两个可选计数按名实现;§12 全部禁止项一律未做。
16.2 关键文件与实际改动
| 文件 | 改动 |
|---|
MacApp/Sidecar/perfowl_collector_core/pmd3_backend.py | 核心。__init__ 新增 _terminal_item = object() / _terminal_pending = False / consumer_direct_wait_count = 0 / consumer_terminal_wake_count = 0;__anext__ 重写为 direct waiter(快路径 get_nowait → terminal 识别后 continue 重查 _done;_done 检查与 _mark_consumer_waiting() 之间无 suspension;try: await self._queue.get() / finally: _clear_consumer_waiting() 覆盖正常恢复、取消与异常);aclose 的 undrained_at_close 修正为 max(qsize − pending_terminal, 0);_pump 的 finally 按 §8.4+§16.1 条件投递 sentinel;_pump 的 handoff 分支只记录证据(置 _consumer_wake_requested_ns、consumer_wake_requested_count += 1)不再 await asyncio.sleep(0),让出只由 event-limit / active-time 两个上界产生;PUMP_YIELD_CONSUMER_HANDOFF 模块注释补充"R8-Fix-10 撤销该让出"的 Build 71 依据 | MacApp/Sidecar/perfowl_collector_core/display_performance.py | R8FIX9_SUBSCRIPTION_DIAGNOSTIC_KEYS 与 _R8FIX9_COUNTER_ATTRIBUTES 各增 consumerDirectWaitCount / consumerTerminalWakeCount(纯诊断键,不进任何公式) | MacApp/Sidecar/tests/test_r8fix10_direct_queue_waiter.py | 新增,12 项(§11.1–§11.10 全覆盖 + 冻结的 R8Fix9ChildTaskBridge 交替 A/B) | MacApp/Sidecar/tests/test_r8fix9_consumer_handoff.py | 契约更新:consumer-handoff yield 断言由"至少一次/按比例"改为 == 0,test_01 改名并改断言为"证据记录 + 无让出 + directWait == waitEnter" | MacApp/Sidecar/tests/test_pump_filter_active_fairness.py | 同上(test_a_awaited_upstream_does_not_yield_per_event / test_d_active_span_excludes_upstream_wait / test_c2 改名 ..._is_evidenced_not_yielded / 120Hz benchmark),并新增 directWait == waitEnter 与 handoff == 0 断言 | MacApp/Sidecar/tests/test_display_pipeline_timing.py | test_a1 同步改为"无 handoff 让出 + active-time 由实测支撑" |
测试契约更新中唯一被改动的边界(诚实记录,且不涉及本次改动的路径):三处把 pumpYieldCount 钉在绝对 <= 1 的断言(test_r8fix9_consumer_handoff.test_01、test_display_pipeline_timing.test_a1、test_r8fix10...test_02)改为"每事件最多一次让出"并额外断言"若 active-time 触发则必须有 ≥1ms 的实测 pumpMaxActiveBurstDurationMs 支撑"。原因是 active-time 是墙钟阈值,在共享高负载机器上同一次 burst 内合法触发多次;把计数钉成常数等于把机器测量钉死在它自己的阈值上。handoff == 0 这条路线 C 的核心保证未被放宽,且被加强为 directWait == waitEnter(每条 park 都走直接等待)。
16.3 新增诊断字段(Build 72 的判据入口)
R8-Fix-9 的 19 个订阅诊断键全部保留,本批新增两个:
consumerDirectWaitCount # 该 capture 有多少次 acquisition 走了 direct queue waiter
consumerTerminalWakeCount # 私有 sentinel 唤醒过多少次(含 fast-path 与 await-path 两条)
判读方式(§10 的"仅用于证明 Build 72 运行的是新路径"):
consumerDirectWaitCount == consumerWaitEnterCount -> 新路径生效
consumerTerminalWakeCount >= 1 -> 源结束时的哨兵唤醒真实发生过
pumpYieldReasonCounts["consumer-handoff"] == 0 -> 该 capture 未再花费重复让出
pumpYieldReasonCounts 的 consumer-handoff 键保留且固定为 0(历史 Session / schema 兼容),不允许伪造为"发生了 handoff yield"。
17. Validation Update(2026-09-20)
17.1 E2 门禁结果
全部在隔离 worktree(分支 r8-fix10,基线 a3ea7cd)执行;除特别标注外均在同一台机器的同一时段测量。
| §13 门禁 | 结果 |
|---|
- R8-Fix-10 专项测试全绿 | 12 / 12 OK(
test_r8fix10_direct_queue_waiter.py) | - Pmd3Subscription fairness 全量回归 | 19 / 19 OK |
- R8-Fix-9 专项全绿 | 30 / 30 OK |
- Display pipeline timing 全绿 | 31 / 31 OK |
- pump / filter active-time fairness 回归 | 11 / 11 OK(单独运行;见 §17.4 的组合运行说明) |
- Python 全量 | 759 条,1 条失败(
test_g_rejected_flood_keeps_message_bound_fairness,既有负载敏感断言,见 §17.4);同口径基线a3ea7cd747 条,1 条失败(test_b1_b2_synthetic_120hz_benchmark)。759 = 747 + 12 = 本批新增专项 | - Swift 全量 | 182 tests / 3 skipped / 0 failures(
swift test --disable-sandbox,18.930s) | - VitePress build | 0 错误、0 死链(
vitepress v1.6.4,build complete in 26.03s) | git diff --check| 干净 |- 合成 blocking-after-handoff benchmark | 见 §17.2 |
- 既有 Pump benchmark
>20,000 events/s| 未放宽,见 §17.3 | - 200k backlog / 120Hz 无数量级退化 | 见 §17.2(两者均为改善,非退化) |
未改动的公平性上界(逐条核对):Pump 32 events / 1ms active-time、ActivityTrace filter 64 messages / 1ms active-time、realtime consumer 8 events / 1.0ms、Pmd3Subscription 队列 2048、consumer drain 0.5s、Python 关闭 8.0s、Swift watchdog 10s、Device Conditions 20s。零 FPS / FrameTime / P95 / Jitter / Refresh-Rate 公式改动;pmd3_deep_services.py(filter)与上游 tap 解码路径完全不在本批 diff 中。
17.2 合成 benchmark:direct waiter A/B(同一机器、两个 worktree 交替配对)
甲 · 真实 tap 形状(1 帧/消息,17ms/帧,10 个事件) — consumerWakeToDequeue 是 Gate C 的判据入口:
| 指标 | 冻结的 R8-Fix-9 child-task 模型 | R8-Fix-10 direct waiter | 判读 |
|---|
consumerWakeToDequeue P50 | 25.919 / 26.080 / 26.093 ms | 0.160 / 0.192 / 0.176 ms | 约 140x | consumerWakeToDequeue P95 | ≈ P50(样本同值) | 0.226 / 0.236 / 0.227 ms | — | consumerWakeToDequeue Max | 26.258–26.730 ms | 0.246 / 0.246 / 0.302 ms | — | pump 让出 | 10(每事件一次 handoff) | 3(全部 active-time,handoff = 0) | — | consumerDirectWaitCount | 字段不存在 | 20 / 20 / 21 == consumerWaitEnterCount | 新路径生效 |
乙 · 压缩 120Hz 管线(7200 帧 + 6 噪声消息/帧,真实桥) — 3 轮交替配对,结果逐轮可复现:
| 指标 | R8-Fix-9 基线(3 轮) | R8-Fix-10(3 轮) | 判读 |
|---|
pump 让出总数 | 273 | 225 | −48 | 让出原因 | {consumer-handoff: 54, event-limit: 219} | {consumer-handoff: 0, event-limit: 225} | handoff 让出被消除,事件上界独立接管 | filter 让出 | 787(3/3 轮完全相同) | 787(3/3 轮完全相同) | filter 路径零变化 | queueWait P50 | 0.852 / 0.954 / 0.899 ms | 0.496 / 0.495 / 0.512 ms | 中位 0.899 → 0.496 ms(−45%) | queueWait P95 | 1.441 / 2.272 / 1.624 ms | 0.985 / 1.037 / 0.999 ms | 中位 1.624 → 0.999 ms(−38%) | queueWait Max | 2.788 / 3.821 / 2.568 ms | 2.035 / 2.841 / 2.042 ms | 同量级 | 吞吐 | 12795 / 10492 / 11500 ev/s | 12943 / 12098 / 13091 ev/s | 三轮配对均不低,中位 +12% | matched / consumed / drops | 7200 / 7200 / 0 | 7200 / 7200 / 0 | 一致 | CPU | 562 ms | 579 ms | 同量级 | rssDelta | 0.5 MB | 0.4 MB | 无增长 |
丙 · 200000 同步事件 backlog — 3 轮交替配对:
| 指标 | R8-Fix-9 基线 | R8-Fix-10 |
|---|
耗时 | 5817.8 / 6269.8 / 5757.6 ms | 3996.0 / 3927.2 / 3937.2 ms(中位 −32%) | 吞吐 | 34377 / 31899 / 34737 ev/s | 50051 / 50927 / 50798 ev/s(中位 +48%) | consumed / drops / undrainedAtClose | 200000 / 0 / 0 | 200000 / 0 / 0 | maxBurst | 32 | 32(上界未被放宽) | 让出原因 | 含 consumer-handoff | {consumer-handoff: 0, event-limit: 6250, active-time: 0} |
17.3 既有 >20,000 events/s 门槛:未放宽,且本批未比基线差
门槛仍为 test_pmd3_subscription_fairness.FairnessBenchmarks.test_benchmark_pump_throughput_with_fairness 的 assertGreater(rate, 20_000),一个字符都未改。
在共享机器负载均值 约 82 的时段做 5 轮交替配对:
基线 a3ea7cd 17,632 PASS 14,365 12,901 19,981 -> 5 轮中 4 轮未过
本批 r8-fix10 PASS PASS 19,196 PASS PASS -> 5 轮中 1 轮未过
结论:该基准在本项目的共享开发机上对负载高度敏感(负载均值 82 时两者都掉到门槛附近;负载均值 4.5 时两树均稳定通过)。本批没有放宽门槛,且在同一时段每次配对都不低于基线。
17.4 诚实边界:两条既有负载敏感断言(非本批回归,已双向复现)
组合运行(多个套件在同一进程内连续跑、机器被其它并发任务压到负载均值 80+)时,出现过两条失败,均不在本批改动路径上,且均已用基线 a3ea7cd 在同样条件下复现:
| 失败用例 | 机制 | 基线复现 |
|---|
test_pump_filter_active_fairness.test_g_rejected_flood_keeps_message_bound_fairness | 200000 拒绝洪水;message-limit 让出需 > 3123。active-time 是墙钟阈值,宿主停顿落在 64 消息 burst 内时该 burst 改由 active-time 结束,于是 message-limit 计数被扣。实测本批 {message-limit: 3107, active-time: 18}、基线 {message-limit: 3123, active-time: 4} / {3112, 22} | 基线 3 轮中 2 轮同样失败({3123, 2} / {3123, 4}) | test_display_ingress_backpressure.test_b1_b2_synthetic_120hz_benchmark | R8-Fix-5 时期已记录的负载敏感 flake | 基线全量运行唯一失败项(本批全量运行中该项通过) |
两条用例的两侧表现互为对照:每条各自在另一棵树上通过。低负载(负载均值 4.5)下本批五个相关套件全部单独通过(12 / 19 / 30 / 31 / 11 全绿)。本批不修改这两条既有断言——它们是独立于本批 diff 的历史门禁,按本文档 D2 与"一个批次一个范围"的原则交由独立批次处理。
17.5 门禁之外的验证
- diagnostics-only 隔离:两个新键只进入
Pmd3Subscription.diagnostics()与R8FIX9_SUBSCRIPTION_DIAGNOSTIC_KEYS,不属于HostTimingChain.ORDER/RECORD_KEYS,不写 raw、不进 metric、不改任何公式。 - sentinel 语义隔离(§8.5 逐条):专项
test_06/test_09/test_10钉死"不增deliveredEventCount/ 不增consumedEventCount/ 不进 drop / 不进 queue depth / 不计consumerWakeToDequeue",并额外验证undrainedAtClose的修正只扣除 pending sentinel(业务事件排在 sentinel 后面时仍如实计数)。 - 旧 Session 兼容:
consumer-handoff键在pumpYieldReasonCounts中仍存在,新路径恒为 0;基线产物加载后两个新字段为None,不参与任何计算。 - 无 orphan task:
test_04/test_05断言迭代结束后asyncio.all_tasks()无新增任务;test_12断言 60 次 acquisition 期间 pending task 数量平坦(max − min <= 1)。
18. 遗留项与下一阶段
| 项 | 状态 | 归属 |
|---|
Build 71 的 consumerWakeToDequeue P50 320ms 是否由 child-task acquisition 结构造成 | 本批已证成:同一 tap 形状下 direct waiter 把该值压到 0.16–0.19ms(约 140x) | 本批 §17.2 甲 | Build 71 的 pumpQueueWait P50 233.889ms / >500ms = 7 | 代码层应已改善(§17.2 乙/丙),但必须由真机判定 | Build 72 真机 E4(由验收方构建) | 若 Build 72 的 consumerWakeToDequeue 仍为数百毫秒 | 则按 §15 正式进入 ActivityTrace cooperative parse(上游侧解码让出),不得再给 Pump 加新的调度 heuristic | 视 E4 结论开后续批次 | consumer-handoff 让出被撤销后,paced source 下 consumer 是否仍能及时运行 | 合成层面已证(120Hz pipeline queueWait P50 −45%、吞吐不降反升);真机未验 | Build 72 真机 E4 Gate B / Gate C | 两条既有负载敏感断言(§17.4) | 未修改,非本批范围 | 独立批次(建议与 R8-Fix-5.2 的 flake 收口合并处理) | 本批证据等级 | E2(代码 + 自动化测试) | 真机 E4 前不得记 validated |
下一阶段工作:
- 由独立验收审计本批 exact commit,并在隔离环境重跑 §17.1 全部 E2 门禁(建议在低负载时段执行,并知悉 §17.3 的负载敏感性);
- E2 通过后由验收方构建 Build 72(本实现 AI 不构建、不声称为"已验收包");
- 用户执行 Build 72 真机 E4(同设备、同目标 App、纯 Display、60–90 秒、正常停止),按 §14 Gate A–F 判定;
- 用户完成真机测试后方可关闭 E4;在此之前本批次保持
implemented · gate-open。
整体进度:P0–P4 全部阶段仍处 gate-open;本批把 R8-Fix-9 交付的"可区分病因的诊断"向前推进为"病因已按诊断结论修掉,且修法比原补偿更省"——consumer-handoff 这个 R8-Fix-9 引入的补偿让出,被证明在 direct waiter 成立后是重复调度并已撤销;但未执行真机 E4,未关闭任何门禁,也未改变任何指标口径与 FPS/FrameTime 公式。