Skip to content

R8-Fix-9:Pmd3 Subscription Consumer Handoff 与队列等待修复方案 ​

当前状态:implemented · gate-open / E2 completed / E4 pending

本方案针对 Build 70 真机最新 Session 34989ff5-8db1-4b56-99a9-a87307b2e994。

目标不是回退 R8-Fix-8,也不是继续修改 FPS / FrameTime 公式;本批只处理 Pmd3Subscription enqueue → dequeue 的调度交接与 queue wait,并补齐能证明 “consumer 已等待但未及时获得调度”还是“其它事件循环任务造成全局 starvation”的证据。

下一测试包:Build 71。在真机 E4 通过之前不得标记 validated。 实际实现、与方案偏差、E2 数字与 benchmark 见文末 §16–§18。

1. Build 70 E4 事实 ​

1.1 Session 基本事实 ​

  • Session ID:34989ff5-8db1-4b56-99a9-a87307b2e994
  • duration:约 102.463 s
  • FrameLifetime delivered:680
  • FrameLifetime consumed:680
  • droppedEventCount:0
  • undrainedAtClose:0
  • ingressAcceptedAtClose:982
  • ingressPersistedAtClose:982
  • report seal:complete
  • dataCompleteness:complete

因此,本轮不是数据丢失,而是实时交付延迟严重。

1.2 R8-Fix-8 已经生效 ​

Build 69:

text
delivered = 768
pumpYield = 768
pumpMaxBurstCount = 1
pumpQueueWait P50/P95/Max = 157.596 / 354.888 / 563.371 ms

Build 70:

text
delivered = 680
consumed = 680
pumpYield = 21
pumpMaxBurstCount = 32
pumpYieldReasonCounts = {
  event-limit: 21,
  active-time: 0
}
pumpMaxActiveBurstDurationMs = 0.769

结论:

  1. R8-Fix-8 已消除“把 upstream await 墙钟时间重复计入 fairness”的错误;
  2. 不再出现 Build 69 的 yield ~= delivered + maxBurst=1;
  3. Pump 自身同步工作很轻,32-event gate 正常触发;
  4. 不得通过恢复 wall-span time gate 来修 Build 70。

1.3 Build 70 真正失败点:subscription queue wait ​

text
pumpQueueWaitMs:
  count      = 680
  P50        = 552.368 ms
  P95        = 1029.745 ms
  P99        = 1140.568 ms
  Max        = 1346.897 ms
  >100ms     = 639
  >500ms     = 378
  >1000ms    = 49

相比 Build 69:

  • P50:157.596 → 552.368 ms
  • P95:354.888 → 1029.745 ms
  • Max:563.371 → 1346.897 ms

即 R8-Fix-8 修正了错误 yield 原因,但真实 queue wait 进一步暴露并恶化。

1.4 七层诊断仍将瓶颈锁定在 subscription queue ​

Build 70:

text
tapToFilterMs:
  P50 0.051
  P95 1.336

filterToPumpEnqueueMs:
  P50 0.011
  P95 0.028

pumpQueueWaitMs:
  P50 552.368
  P95 1029.745

dequeueToAdapterYieldMs:
  P50 0.005
  P95 0.017

adapterYieldToRealtimeConsumeMs:
  P50 0.115
  P95 0.255

realtimeConsumeToIngressMs:
  P50 0.007
  P95 0.021

除 queue wait 外,其余层均远低于 1~2 ms 量级。

Top gap 中绝大多数也被分类为:

text
classifiedLayer = subscription-queue

因此 R8-Fix-9 的主战场必须是:

text
Pmd3Subscription._pump()
        ↓ enqueue
asyncio.Queue
        ↓
Pmd3Subscription.__anext__()
        ↓ dequeue

2. 必须先纠正的一个诊断误读 ​

Build 70 的 realtimeConsumerFairness.maxObservedActiveBurstMs 中,部分非 Display source 出现 0.5~1.87 s 的值,例如:

text
sysmontap        ~1874 ms
graphics         ~1710 ms
device-network   ~1874 ms
process-network  ~1334 ms
core-profile      ~849 ms
display             0.671 ms

这些大值不能直接证明 event loop 被同步占用 1~2 秒。

当前 RealtimeConsumerFairness.begin_item() 在事件进入 consumer 后执行,而 end_item() 在整个事件处理结束后执行;非 Display 路径中间包含:

  • await append_raw(raw)
  • await append_task
  • await append_decoded_async(...)
  • asyncio.to_thread(...)
  • 其它异步等待

因此该字段当前更接近“单事件处理 wall span”,并非纯 synchronous active CPU time。

R8-Fix-9 禁止仅凭这些值去改 sysmontap / graphics / network 逻辑。 如需利用该诊断判断 starvation,必须先把“真正同步段”与“await wall span”分开。

3. 根因假设 ​

3.1 主假设:Queue waiter 被唤醒,但 producer 没有立即交还执行权 ​

当前 Pmd3Subscription.__anext__() 在队列为空时:

  1. 创建 self._queue.get() waiter;
  2. await asyncio.wait(... FIRST_COMPLETED);
  3. 等 Pump enqueue 唤醒。

Pump 当前使用:

python
self._queue.put_nowait(item)
...
if burst_count >= 32:
    await asyncio.sleep(0)
elif burst_active_ns >= 1ms:
    await asyncio.sleep(0)

put_nowait() 会让 Queue getter 进入 ready 状态,但不会保证 Pump 立刻失去当前执行权。

如果上游 async iterator 内部已经有 buffered message,下一次 async for item in self._source 可能很快再次返回;于是 Pump 可以继续生产, 直到 32-event gate 才显式 sleep(0)。

Build 70 的形态与这个模型高度吻合:

text
680 delivered / 21 event-limit yields ≈ 32 events per explicit yield

但这是高置信度假设,不得跳过证据直接当最终根因。

3.2 次假设:全局 ready-queue / consumer orchestration starvation ​

即使 Pump 正确唤醒 waiter,event loop 上其它 runnable task 也可能长时间排在前面。

R8-Fix-9 必须通过新诊断区分:

  • waiter 在 enqueue 时已经存在,但 wake→dequeue 延迟很大;
  • enqueue 时 consumer 根本不在等待;
  • queue 已形成 backlog;
  • event loop heartbeat 同期是否有明显 stall。

只有证明前一种,才允许落地 consumer-handoff yield。

4. 目标 ​

  1. 将 pumpQueueWait 从 Build 70 的 0.5~1.3 s 量级降到低毫秒级或至少明显低于 Build 69。
  2. 区分:
    • waiter-handoff 延迟;
    • backlog 延迟;
    • upstream source gap;
    • 全局 event-loop stall。
  3. 保留 R8-Fix-8 active-time fairness,不恢复 wall-span gate。
  4. 保证 0 drop、0 undrained。
  5. 不修改 Display 指标公式、FrameLifetime 语义、Source Owner、队列容量。
  6. 不用“增大 queue / 增大 timeout / 降低采样频率”掩盖问题。
  7. Build 71 真机 E4 必须给出可直接与 Build 69/70 对照的数据。

5. Phase A:增加 consumer-handoff 证据 ​

5.1 Pmd3Subscription 显式 consumer wait 状态 ​

不要读取 asyncio.Queue._getters 等私有实现。

在 Pmd3Subscription 内维护自身状态:

python
self._consumer_waiting = False
self._consumer_wait_started_ns = None

在 __anext__():

  • get_nowait() 成功:直接返回,不标记 waiting;
  • 队列为空且即将进入 blocking wait 前:
    • _consumer_waiting = True
    • 记录 wait start;
  • waiter 完成、done、cancel、异常的所有出口都必须在 finally 清除 waiting 状态。

本实现当前设计为单 consumer;新增测试必须锁死该前提。若存在多 consumer 可能,必须改成 waiter count,而不是 bool。

5.2 新增 bounded diagnostics ​

建议至少增加:

text
consumerWaitEnterCount
consumerWakeRequestedCount
consumerWakeToDequeueCount
consumerWakeToDequeueP50Ms
consumerWakeToDequeueP95Ms
consumerWakeToDequeueMaxMs

enqueueWhileConsumerWaitingCount
enqueueWhileConsumerNotWaitingCount

queueDepthAfterEnqueueP50
queueDepthAfterEnqueueP95
queueDepthAfterEnqueueMax
queueDepthOver1Count
queueDepthOver8Count
queueDepthOver32Count

要求:

  • bounded;
  • 不逐事件写盘;
  • snapshot 时计算分位数;
  • 历史 Session 缺失字段正常兼容。

5.3 HostTimingChain 增加 diagnostics-only 分类 ​

为了把 680 条真实 FrameLifetime 的 queue wait 分组,可增加可选诊断字段:

text
subscriptionConsumerWaitingAtEnqueue
subscriptionQueueDepthAfterEnqueue

要求:

  • diagnostics-only;
  • 不提升正式 raw schema;
  • 不进入任何 FPS/FrameTime 公式;
  • 旧 Session 缺字段时为 None。

然后 DisplayPipelineTiming 增加两组 queue wait 分布:

text
pumpQueueWaitWhenConsumerWaitingMs
pumpQueueWaitWhenBackloggedMs

这一步将直接回答:

1s queue wait 是发生在“consumer 明明已等着但没被调度”,还是“consumer 本来就在忙/队列已有积压”。

6. Phase B:条件式 consumer handoff ​

仅当 Phase A 合成测试证明“waiter 被唤醒但 Pump 连续占有 ready turn”时启用。

6.1 新 yield reason ​

新增:

python
PUMP_YIELD_CONSUMER_HANDOFF = "consumer-handoff"

诊断:

text
pumpYieldReasonCounts = {
  consumer-handoff: ...,
  event-limit: ...,
  active-time: ...
}

不得加入 wall-time。

6.2 触发条件 ​

Pump 在 enqueue 前读取自身维护的 consumer waiting 状态:

python
handoff_needed = self._consumer_waiting
self._queue.put_nowait(item)
...
if handoff_needed:
    record_yield("consumer-handoff")
    await asyncio.sleep(0)
    reset_burst()
elif burst_count >= 32:
    ...
elif burst_active_ns >= 1ms:
    ...

优先级建议:

  1. consumer-handoff
  2. event-limit
  3. active-time

原因:consumer 已经明确阻塞等待当前事件时,应优先完成生产者→消费者交接。

6.3 不得用 queue.empty() 作为唯一真相 ​

禁止仅实现:

python
if queue_was_empty:
    await asyncio.sleep(0)

因为 empty→non-empty 只能间接推断 waiter,不能证明 consumer 正在 await。

使用 subscription 自己维护的 waiter 状态,语义更精确,也不依赖 asyncio 私有字段。

6.4 不再把“yield 数量少”作为成功标准 ​

Build 69 证明“错误原因导致每事件 yield”是坏的; 但如果正常 60Hz 下 consumer 每次都确实阻塞等下一帧,那么 consumer-handoff 可能合法地接近每事件一次。

因此 Build 71 不能再用:

text
pumpYieldCount << deliveredEventCount

作为硬门禁。

真正门禁应是:

  • yield reason 正确;
  • queue wait 大幅下降;
  • event-loop heartbeat 不恶化;
  • throughput 不下降;
  • 0 drop / 0 undrained。

7. Phase C:RealtimeConsumerFairness 诊断精度修正 ​

此部分是诊断修正,不是性能修复。

当前 begin/end 跨越多个 await,maxObservedActiveBurstMs 名称会让人误以为是纯同步 CPU。

两种允许方案,择其一:

方案 C1:重命名并保留兼容字段 ​

保留 legacy:

text
maxObservedActiveBurstMs

但明确标记 legacy wall processing span,并新增:

text
maxObservedSynchronousBurstMs

真正只包住不含 await 的同步段。

方案 C2:拆分 processing wall 与 sync work ​

输出:

text
maxObservedProcessingSpanMs
maxObservedSyncWorkMs

历史字段继续保留,不删除。

禁止直接改变已有字段含义后继续复用同名字段。

8. Closeout:本批只补证据,不与 queue fix 混修 ​

Build 70:

text
closeoutStatus = timed-out
closeoutFailureStage = consumer-drain
consumerDrainDurationMs = 609.382

sourceConsumerTaskCount = 8
completed = 6
cancelled = 2
timedOut = 2

writerDrainStatus = drained
ingressUndrainedAtSeal = 0
dataCompleteness = complete

本轮没有记录具体 timed-out source consumer 名称。

R8-Fix-9 允许增加:

text
sourceConsumerTimedOutTaskNames
sourceConsumerCancelledTaskNames

有界、固定上限。

但禁止在没有 task-name E4 证据前顺手延长 consumer-drain timeout 或修改 closeout 判定。

如果 Build 71 queue wait 修复后仍有 consumer-drain timeout,再独立开 R8-Fix-10。

9. 禁止项 ​

本批次明确禁止:

  • 恢复 Pump wall-span fairness gate;
  • 恢复 Build 69 的 burst_started_ns 作为调度条件;
  • 修改 PUMP_FAIRNESS_MAX_BURST_EVENTS = 32 作为首选修法;
  • 修改 PUMP_FAIRNESS_MAX_BURST_NS = 1ms 作为首选修法;
  • 扩大 subscription queue 2048;
  • 丢旧 FrameLifetime 来制造低 latency;
  • 降低 ActivityTrace 采集频率;
  • 修改 FrameLifetime filter;
  • 修改 FPS / FrameTime / P95 / Jitter / Refresh Rate 公式;
  • Graphics FPS fallback;
  • CoreProfile 代替 ActivityTrace FrameLifetime;
  • 延长 Host timeout 掩盖 backlog;
  • 用 queue.empty/private _getters 作为唯一 waiter 判定;
  • 因 Build 70 closeout timeout 顺手重构整个 closeout。

10. 自动化测试 ​

10.1 Pmd3Subscription waiter handoff ​

必须覆盖:

  1. consumer 已阻塞等待,producer enqueue 1 条:

    • waiter 状态为 true;
    • enqueue 后 handoff reason +1;
    • consumer 在下一 scheduler turn 获取事件;
    • queue wait 低且稳定。
  2. consumer 未等待,producer synchronous backlog 200000:

    • 仍由 32-event gate 保证公平;
    • 不发生无意义 handoff yield;
    • 0 drop(在 consumer 能持续 drain 的 fixture);
    • heartbeat gate 通过。
  3. 正常 60/120Hz cadence:

    • handoff 可以按真实 waiter 触发;
    • queue wait P95 应接近 scheduler noise,而不是几百 ms;
    • 不以 yield/event ratio 作为失败条件。
  4. consumer cancel while waiting:

    • waiting flag 必须清除;
    • 不得留下 orphan Queue.get / Event.wait task。
  5. source done 与 queue item 同时完成:

    • 不丢最后事件;
    • done / item race 行为保持原契约。

10.2 Build 70 形态回放 ​

构造:

text
source buffered burst
consumer slower than producer scheduler turns
32-event pump gate

要求旧行为能复现高 queue wait,新行为显著下降。

10.3 非 Display 源回归 ​

至少覆盖:

  • sysmontap
  • graphics
  • device-network
  • process-network
  • core-profile

确认 handoff 机制是 generic subscription 行为,不引入 Display 专用依赖。

10.4 诊断测试 ​

锁死:

  • consumer-handoff / event-limit / active-time reason 互斥计数;
  • 无 wall-time reason;
  • waiter flag 所有异常/取消路径归零;
  • 新分位数有界;
  • 旧 Session 缺新字段可加载;
  • diagnostics 不进入 canonical metric formulas。

11. E2 门禁 ​

开发完成后至少执行:

  1. R8-Fix-9 专项测试全绿;
  2. Pmd3Subscription fairness 全量回归;
  3. Display pipeline timing 回归;
  4. realtime orchestration 回归;
  5. closeout 回归;
  6. Python 全量测试;
  7. Swift 全量测试;
  8. VitePress build;
  9. git diff --check;
  10. 合成 benchmark。

Benchmark 必须报告:

text
delivered / consumed / dropped
pumpYieldReasonCounts
pumpQueueWait P50/P95/P99/Max
>100ms / >500ms / >1000ms
consumerWakeToDequeue P50/P95/Max
heartbeat P95/Max
throughput
CPU
RSS delta

不得只报“测试通过”。

12. Build 71 真机 E4 验收标准 ​

测试方式:

  • 与 Build 69/70 尽量同设备、同目标 App;
  • 纯 Display;
  • 60~90 秒;
  • 正常停止;
  • 不临时开启无关指标。

Gate A:数据完整性 ​

必须:

text
droppedEventCount = 0
undrainedAtClose = 0
deliveredEventCount == consumedEventCount
dataCompleteness = complete

Gate B:queue wait ​

硬目标:

text
pumpQueueWait >500ms count = 0
pumpQueueWait >1000ms count = 0

优先目标:

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

如果尚未达到优先目标,但相比 Build 70 已数量级改善且证据明确显示剩余延迟归属于 upstream/source,可单独评估;不得把 subscription queue 的 500ms+ 延迟判通过。

Gate C:pipeline ownership ​

其它层不得成为新的主要瓶颈:

text
filterToPumpEnqueue P95 < 5ms
dequeueToAdapterYield P95 < 5ms
adapterYieldToRealtimeConsume P95 < 5ms
realtimeConsumeToIngress P95 < 5ms

tap→filter 如受 raw tap burst 影响可按 E4 实际解释,但不得掩盖 host queue wait。

Gate D:handoff 证据 ​

必须能回答:

  • 680/实际 N 条事件中,有多少 enqueue 时 consumer 正在等待;
  • waiting 场景 wake→dequeue P50/P95/Max;
  • backlog 场景 queue wait 分布;
  • consumer-handoff yield 次数;
  • event-limit / active-time 次数。

不接受“延迟变好了但为什么变好不知道”。

Gate E:event loop ​

Heartbeat 不得因新 handoff yield 明显退化。

Gate F:closeout ​

理想:

text
closeoutStatus = clean
consumerDrainStatus = completed/quiescent
writerDrainStatus = drained

若仍为 consumer-drain timeout,但:

  • queue wait 已通过;
  • data complete;
  • writer drain clean;
  • timed-out task 名称已被精确记录;

则 R8-Fix-9 的 queue 方向可以单独判通过,但整体批次保持 gate-open,并立刻拆 R8-Fix-10 Closeout,不得混写成 validated-all.

13. 实施文件范围 ​

预计重点文件:

text
MacApp/Sidecar/perfowl_collector_core/pmd3_backend.py
MacApp/Sidecar/perfowl_collector_core/realtime.py
MacApp/Sidecar/perfowl_collector_core/display_performance.py
MacApp/Sidecar/perfowl_collector_core/contracts.py   # 仅如需 diagnostics-only 分类字段

MacApp/Sidecar/tests/test_pmd3_subscription_fairness.py
MacApp/Sidecar/tests/test_display_pipeline_timing.py
MacApp/Sidecar/tests/test_pump_filter_active_fairness.py
# 新增 R8-Fix-9 专项测试文件,命名按现有规范

禁止把实现扩散到 Swift 指标公式或 UI 展示层。

14. 交付要求 ​

另一 AI 完成后必须:

  1. 更新本 devlog,追加“实际实现 / 与方案偏差 / E2 数字 / benchmark”;
  2. 更新开发时间线;
  3. 提交代码;
  4. 推送当前主远端与 GitHub 镜像;
  5. 不自行宣称 E4;
  6. 不自行构建 Build 71 作为“已验收包”;
  7. 由独立验收重新审计 exact commit;
  8. 独立 E2 通过后再由验收方构建 Build 71;
  9. 用户完成真机测试后再关闭 E4。

15. 最终判断 ​

R8-Fix-8 不应回退。

Build 70 已证明:

text
错误 wall-time yield 被消除
        ↓
真实 subscription queue scheduling 问题暴露

R8-Fix-9 的正确方向不是“重新多 yield”,而是:

text
证明 consumer wait / wake / dequeue 的真实关系
        ↓
只在真正需要 producer→consumer handoff 时显式让出
        ↓
用 Build 71 真机 queue wait 证明结果

成功标准只有一个:真实 FrameLifetime 在 subscription queue 中不再等待数百毫秒到 1 秒以上,同时保持 0 drop、0 undrained、event loop 公平与完整封存。


16. Implementation Update(2026-09-19) ​

状态:implemented · gate-open / E2 completed / E4 pending

实现在本方案被批准后于隔离 worktree(分支 r8-fix9,基线 ccf1397)完成,共享脏工作区未被触碰。 本批不构建 Build 71、不执行真机 E4、不声明 validated。

16.1 相对方案的偏差(一条,且是方案自身授权的条件分支) ​

方案条款实际实现原因
§6.2 触发条件 handoff_needed = self._consumer_waiting("consumer 正在等待"即让出)收窄为"本次 enqueue 恰好解决了那个已 park 的 getter":consumer_was_waiting and depth_before == 0 and _consumer_wake_requested_ns is None方案 §6.2 给出的理由原文是"consumer 已经明确阻塞等待当前事件时,应优先完成生产者→消费者交接",而 §6.3 又要求"不得用 queue.empty() 等间接信号作为唯一真相"。按字面实现"只要 consumer 在等待就让出"是每事件让出(实测把 pump 吞吐从 20000+ 打到 2431 ev/s、冻结的 R8-Fix-8 对照从 10876 → 5435 ev/s、让 1ms active-time 门槛永远攒不满、200000 同步 backlog 从 1066 → 7706ms)。收窄到"解决 park 的那一次 enqueue"后,一次 acquisition window 最多重置一次 burst,backlog 仍由 32 events / 1ms 两个上界管辖。这正是方案 §6.2 的"当前事件"含义,也是 §6.3 对精确性的要求。
§6.1 新增 PUMP_YIELD_CONSUMER_HANDOFF = "consumer-handoff"按方案实现见 §16.2

其余条款逐条照做,无删减:§5.1 显式 wait 状态(自有 bool,不读 asyncio.Queue._getters、不用 queue.empty());§5.2 全部 bounded 诊断;§5.3 HostTimingChain 两个 diagnostics-only 注解 + DisplayPipelineTiming 三段 queue wait 拆分;§7 选 C1(保留 legacy maxObservedActiveBurstMs,新增 maxObservedSynchronousBurstMs);§8 两个有界任务名列表(未延长 0.5s drain 窗口、未改 closeout 判定);§9 全部禁止项一律未做。

16.2 关键文件与实际改动 ​

文件改动
MacApp/Sidecar/perfowl_collector_core/pmd3_backend.py_consumer_waiting / _consumer_wake_in_flight 状态与全部出口清理;_mark_consumer_waiting / _clear_consumer_waiting / _take_wake_mark / _record_wake_to_dequeue / _observe_enqueue_context / _stamp_enqueue_context;__anext__ 快路径只 cancel done_task(去掉多出的一次 gather 挂起);_pump 在解决 park 的那一次 enqueue 以 PUMP_YIELD_CONSUMER_HANDOFF 优先让出;两个定长环形缓冲(4096)+ 分位数仅在 diagnostics() 计算
MacApp/Sidecar/perfowl_collector_core/contracts.pyHostTimingChain 新增 subscription_consumer_waiting_at_enqueue / subscription_queue_depth_after_enqueue(不进 ORDER/RECORD_KEYS)、独立 annotate()/annotated() 入口(带类型校验、first-writer-wins)、ANNOTATION_KEYS、导出 HOST_TIMING_ANNOTATION_KEYS;_host_timing_from_record 把注解当作内容
MacApp/Sidecar/perfowl_collector_core/display_performance.pyDISPLAY_PIPELINE_QUEUE_WAIT_GROUPS 三段拆分(consumer_waiting / backlogged / consumer_not_waiting)+ queueWaitAttributedCount / queueWaitUnattributedCount;R8FIX9_SUBSCRIPTION_DIAGNOSTIC_KEYS 19 个键
MacApp/Sidecar/perfowl_collector_core/realtime.pyPhase C:pause_item() / resume_item() 与 maxObservedSynchronousBurstMs(legacy 字段口径不变);§8:sourceConsumerTimedOutTaskNames / sourceConsumerCancelledTaskNames / sourceConsumerTaskNameCap
MacApp/Sidecar/perfowl_collector_core/closeout.pyCLOSEOUT_TASK_NAME_CAP = 8 + _bounded_task_names();CloseoutTaskDrainResult 两个新字段
MacApp/Sidecar/tests/test_r8fix9_consumer_handoff.py新增,30 项(§10.1 六项 / §10.2 两项 / §10.3 两项 / §10.4 十项 / §7 五项 / §8 五项)
MacApp/Sidecar/tests/{test_pump_filter_active_fairness,test_pmd3_subscription_fairness,test_display_pipeline_timing}.py契约更新:把"让出次数少"的断言换成"原因正确 + 无 wall-time + 对照未被回归"(§6.4 明确要求不再用 yield 比例当门禁),并新增 test_c2 钉死"handoff 必须有 park 的 consumer"

测试契约更新中唯一被"加强"而非放宽的一条:原 test_c_active_work_trips_the_active_time_gate(注入 ≈0.45ms/事件必须撞响 1ms 门槛)改为在 consumer 尚未 park 时生产(先让 pump 把源跑完再消费),因此 handoff 不可能参与,active-time 仍是唯一能触发的门;并新增 test_c2 断言"consumer park 时 handoff 才 +1、且 waiting 状态不残留"。没有削弱任何公平性回归覆盖。

16.3 新增诊断字段(Build 71 的判据入口) ​

text
consumerWaitEnterCount                 consumerWakeRequestedCount
consumerWakeToDequeueCount             consumerWakeToDequeueP50Ms/P95Ms/MaxMs
consumerWakeUnclaimedCount             consumerWakeToDequeueSampleCount
enqueueWhileConsumerWaitingCount       enqueueWhileConsumerNotWaitingCount
enqueueWhileConsumerWokenCount         consumerWaitingAtSnapshot
queueDepthAfterEnqueueP50/P95/Max      queueDepthSampleCount
queueDepthOver1Count/Over8Count/Over32Count

HostTimingChain 每条匹配事件额外携带:

text
subscriptionConsumerWaitingAtEnqueue    # bool
subscriptionQueueDepthAfterEnqueue      # int

DisplayPipelineTiming 由此输出三段 queue wait(pumpQueueWaitWhenConsumerWaitingMs / pumpQueueWaitWhenBackloggedMs / pumpQueueWaitWhenConsumerNotWaitingMs),并把 attributed + unattributed == 聚合 queue_wait count 做成可核对的恒等式。

17. Validation Update(2026-09-19) ​

17.1 E2 门禁结果 ​

§11 门禁结果
  1. R8-Fix-9 专项测试全绿 | 30 / 30 OK(test_r8fix9_consumer_handoff.py) |
  2. Pmd3Subscription fairness 全量回归 | 19 / 19 OK |
  3. Display pipeline timing 回归 | 31 / 31 OK |
  4. realtime orchestration 回归 | 19 / 19 OK |
  5. closeout 回归 | 15 / 15 OK |
  6. Python 全量测试 | 747 / 747 OK(同口径基线 ccf1397 为 716,+31 = 新增 30 专项 + 1 test_c2) |
  7. Swift 全量测试 | 见 §17.4 |
  8. VitePress build | 见 §17.4 |
  9. git diff --check | 干净 |
  10. 合成 benchmark | 见 §17.2 / §17.3 |

未改动的公平性上界(逐条核对):pump 32 events / 1ms、ActivityTrace filter 64 messages / 1ms、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 公式改动。

17.2 合成 benchmark 甲:压缩 120Hz 管线(真实桥,5 轮配对,两个 worktree 交替) ​

test_pump_filter_active_fairness.WallVersusActiveTimeBenchmark.test_benchmark_wall_time_versus_active_time_120hz_pipeline(120Hz × 7200 帧 + 6 噪声/帧):

指标R8-Fix-8 基线R8-Fix-9判读
queueWait P502.07 ms0.95 ms-54%
queueWait P954.57 ms1.74 ms-62%
queueWait Max5.19–11.38 ms(含 50.74ms 尖峰)3.06–4.05 ms(无尖峰)尾部同时收紧
吞吐11123 ev/s(中位)11185 ev/s无退化
heartbeat max1.82–3.60ms(含 50.43ms 尖峰)1.73–2.55ms无退化
drops / undrained0 / 00 / 0—
让出原因{event-limit: 225}{consumer-handoff: 54, event-limit: 219, active-time: 0}新原因被真实触发且与两个上界并存

方法学诚实说明:该基准单次运行方差很大(首轮曾观测到 FIX9 吞吐 8597 ev/s 与基线 10113 ev/s 的"退化",5 轮配对后证明是宿主并行负载噪声——本项目工作区长期有多个 AI 并发运行)。本节数字全部来自交替配对的中位数,不以单次结果作结论。

17.3 合成 benchmark 乙:Build 70 形态(真实桥,9 轮配对) ​

形态:6–240 个 32-event 缓冲 burst(上游不 await,正是 Build 70 maxBurst=32 / 21 yields / 680 delivered 的形态),consumer 每事件同步工作并夹一个竞争任务。

指标R8-Fix-8 基线(中位)R8-Fix-9(中位)判读
delivered / consumed / dropped / undrained7680 / 7680 / 0 / 07680 / 7680 / 0 / 0一致
queueWait P501.801 ms1.460 ms-19%
queueWait P952.190 ms1.801 ms-18%
queueWait P992.521 ms2.260 ms-10%
queueWait Max2.76–7.96 ms2.21–48.64 ms尾部仍有宿主尖峰(两侧都有)
吞吐5530.8 ev/s5500.4 ev/s-0.55%(在噪声内)
CPU1246.6 ms1255.4 ms+0.7%
RSS delta0.0 MB0.0 MB无增长
让出原因{event-limit: 240}{consumer-handoff: 240, event-limit: 0}该形态下 handoff 替代了数量上界(仍为每 32 事件一次让出,公平性等价)

新增诊断在该形态下的实测(FIX9,9/9 轮完全一致):

text
consumerWakeRequestedCount = 240   consumerWakeToDequeueCount = 240   unclaimed = 0
consumerWakeToDequeue P50 / P95 / Max = 1.74 / 2.12 / 2.48–35.01 ms
enqueueWhileConsumerWaitingCount = 7680   enqueueWhileConsumerNotWaitingCount = 0
queueDepthAfterEnqueue P50 / P95 / Max = 16 / 30 / 31
queueDepthOver8Count = 5520   queueDepthOver32Count = 0

基线(ccf1397)上述字段全部为 None → 旧 Session 兼容路径被真实走过。

17.4 诚实边界:handoff 不能修"上游同步占用事件循环"的形态 ​

方案 §10.2 要求"旧行为能复现高 queue wait,新行为显著下降"。按真机数字反推形态(32-event burst、上游每事件 17ms 同步工作、burst 跨度 ≈544ms)重构后实测(3 轮配对):

指标R8-Fix-8 基线R8-Fix-9判读
queueWait P50273.526 ms273.366 ms无变化
queueWait P95511.820 ms511.739 ms无变化
queueWait Max529.0 ms529.6 ms无变化
queueWait >500ms1212无变化
queueWait >1000ms00—
吞吐53.7 ev/s53.7 ev/s无变化
consumerWakeToDequeue P50(字段不存在)528.920 ms关键读数

这是本批最重要的诚实结论:handoff 只能把 ready turn 还给 consumer;当上游自己用同步代码占住事件循环几十毫秒时,pump 没有任何让出手段能解决它。 而 consumerWakeToDequeue 恰好把这件事量化证明了——wake 已发出,consumer 却在 528.9ms 后才取到 item,与该形态的 queue wait 完全相等。

也就是说,R8-Fix-9 交付的是一套能区分病因的诊断外加一个只对"pump 持有 ready turn"这一病因有效的让出; Build 70 的 552ms P50 究竟属于哪一类,仍必须由 Build 71 真机 E4 判定(这正是方案 §5 设立 Phase A 的目的)。 若 Build 71 显示剩余延迟由 upstream 同步占用主导,则应开 R8-Fix-10 走上游侧(tap/filter 解码让出)方向,而不是继续在 pump 上加让出。

17.5 门禁之外的验证 ​

  • Swift 全量:见下方交付汇报(swift test --disable-sandbox)。
  • VitePress build:0 错误、0 死链。
  • 旧 Session 兼容:基线产物加载后新字段为 None,不参与任何计算(§17.3 实测)。
  • diagnostics-only 隔离:新增字段不属于 HostTimingChain.ORDER / RECORD_KEYS,只能经 annotate() 写入,stamp() 对其抛 ValueError(专项测试 test_05 / test_10 钉死)。

18. 遗留项与下一阶段 ​

项状态归属
Build 70 的 552ms P50 queue wait 是否由 pump 持有 ready turn 造成未判定,需真机 E4Build 71 真机 E4(由验收方构建)
若剩余延迟由 upstream 同步占用主导诊断已就位(consumerWakeToDequeue vs pumpQueueWaitWhen*)视 E4 结论开 R8-Fix-10(上游侧)
Build 70 仍有的 consumerDrainStatus=timed-out本批只补名称,未改判定/未延长窗口若 Build 71 修复后仍出现,独立开 R8-Fix-10 Closeout
real-device maxObservedSynchronousBurstMs 对照诊断已就位,等待 E4 读数Build 71 真机 E4
本批证据等级E2(代码 + 自动化测试)真机 E4 前不得记 validated

下一阶段工作:

  1. 由独立验收审计本批 exact commit,并在隔离环境重跑 §17.1 全部 E2 门禁;
  2. E2 通过后由验收方构建 Build 71(本实现 AI 不构建、不声称为"已验收包");
  3. 用户执行 Build 71 真机 E4(同设备、同目标 App、纯 Display、60–90s、正常停止),按 §12 Gate A–F 判定;
  4. 用户完成真机测试后方可关闭 E4;在此之前本批次保持 implemented · gate-open。

整体进度:P0–P4 全部阶段仍处 gate-open;本批把"subscription queue 等待"从一个无法归因的 0.5–1.3s 数字推进为可判定病因的三段拆分 + 一个已验证有效的 partial 修法,但未关闭任何门禁,也未改变任何指标口径与 FPS/FrameTime 公式。

Perfowl · Performance Observer