Skip to content

R8-Fix-7:Realtime Consumer 有界 Burst 与 Device Conditions 作用域 / Closeout 修复方案 ​

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

本方案是 R8-Fix-6 之后,针对 Build 68 两轮真机 E4 实测数据的定向修复批次。 它不是 FPS / FrameTime 的重新设计:Display 指标公式、Source Owner、 FrameLifetime 白名单与解码器、MetricRegistry、CapturePlan、CoreProfile 策略、 Pmd3 pump 公平性(32 events / 1ms)、ActivityTrace filter 公平性(64 messages / 1ms)、 Swift 10s 终止预算、Python 8s 关闭预算、consumer drain 0.5s 窗口、 以及 Device Condition 20s 采样间隔全部保持不变。 本批次只做三件事——让 realtime Raw consumer 在有 backlog 时按有界 burst 排空、 让 Device Conditions 的启动由 canonical 指标作用域决定、 让 20s 空闲等待在 Stop 时立即退出。

证据等级声明:本批次的代码与自动化测试验证为 E2;方案设计目标是为 下一次真机 E4 提供可验证的行为,因此在真机 E4 执行前,本批次 不得记为 validated,也不得自建发布包。本批次不构建 Build 69。

1. 背景:Build 68 真机 E4 的两个事实 ​

Build 68 跑了两场真机 E4,一场全指标、一场严格收窄到纯 Display。 两场都必须分开读。

1.1 好消息(R8-Fix-6 的分类修复已生效) ​

Build 67 那组自相矛盾的关闭诊断(dataCompleteness=complete 却被判 consumerDrainStatus=timed-out / overallCloseoutStatus=partial, 而 closeoutFailureStage/Reason 均为 null)在 Build 68 中不再以"辅助 sleeper 被算进 consumer drain"的形态出现:超时被明确记在一个真正的 Source Consumer 上, 而不是 sleep 750ms/1000ms 的辅助任务上。 也就是说 R8-Fix-6 的 Part B(source_consumer_tasks / auxiliary_tasks 分离、 auxiliary-stop 登记为 advisory 阶段)在真机上按设计工作, 分类不再是问题。

1.2 问题 A:realtime Raw consumer 每事件 await asyncio.sleep(0) ​

全指标场(Session 544ea082…)应为 6 个 source consumer, 实测 pumpQueueWait P95 = 463.987ms、> 500ms 计数 = 27。

把作用域收窄到纯 Display 后(Session 0e9da98e…,本应为 1 个 source consumer, 实际 2 个 —— 见问题 B),pumpQueueWait 改善为 P50 = 123.471ms / P95 = 282.982ms / Max = 491.312ms、> 500ms 计数 = 0。

多源竞争被削掉之后仍然残留 100–500ms 的 queue wait,这说明剩下的嫌疑在 consumer 自身。读源码可以确认这一点:

python
# 旧行为:每处理完一条 Raw 就无条件让出事件循环
async for raw in source:
    normalized, derived_frames = await append_raw(raw)
    ...
    await asyncio.sleep(0)   # <- 每一条!

关键在于 Pmd3Subscription.__anext__ 的消费契约:它在有界队列仍有 backlog 时 是同步返回的(get_nowait)。因此"每条都 sleep(0)"并不会让 consumer 更公平, 只会把排空速度硬性锁死为每个调度 tick 一条。事件循环被其它任务占用时, 一条 backlog 的排空就会被切成 N × 调度延迟,这正是 100–500ms queue wait 的来源,且它与"纯 Display 只有一个源"无关。

1.3 问题 B:纯 Display 也启动了 Device Conditions ​

Build 68 的纯 Display Session(0e9da98e…)读到:

text
execution metric ids          = 5 项 Display
sourceConsumerTaskCount       = 2               <- 期望 1
adapters                      = [... , pmd3.device-condition.raw]   <- 多出来的那个

Device Conditions 是一个 20s 周期的 diagnostics_relay 任务, 并且在既有实现中它被注册为一个真正的 Raw Source Consumer(不是辅助任务)。 于是用户的指标选择里一个 battery/thermal 指标都没有, 却仍然拉起了一条 20s 的采集 cadence。

更糟的是它与关闭路径的相互作用:正常 Stop 时,这条 consumer 正处在 asyncio.sleep(20) 的空闲等待里,500ms 的 consumer drain 窗口理所当然覆盖不到它:

text
dataCompleteness          = complete
ingressUndrained          = 0
consumerDrainStatus       = timed-out     (500.853ms)
overallCloseoutStatus     = partial

再一次是"没有任何数据丢失,却被判 partial"。 但这一次的根因不是分类缺陷 (R8-Fix-6 已经把分类修对了),而是:

  1. 作用域缺陷:不该启动的源被启动了;
  2. 停止语义缺陷:20s 的空闲等待被当成了"consumer 拒绝完成最后一次入队"。

关键判断:修法不是放宽 drain 窗口。把 0.5s 放大只会掩盖"不该启动的源" 并让真正停摆的 consumer 多等;也不是把 Device Conditions 降级成辅助任务—— 它是一个真实的 Raw 源,降级会破坏"只有真 Source Consumer 才能翻转判决"这条 R8-Fix-6 刚建立的语义。正确修法是缩短它的空闲等待并不启动它。

2. 目标与非目标 ​

2.1 目标 ​

  1. Part A(有界 burst):把 realtime Raw consumer 的"每事件 sleep(0)"换成 有界 burst —— 只在同步工作量为零、且尚未达到任何上限时才让出。 只对同步工作量计费,上游 await 永不入账。
  2. Part A(可观测):为每个源输出有界的 realtimeConsumerFairness 诊断 (maxBurstEvents / maxActiveBurstMs / yieldCount / maxObservedBurstCount / maxObservedActiveBurstMs / perSource)。
  3. Part B(canonical 作用域):Device Conditions 的启动必须由 canonical 的指标 → 源映射(execution_source_ids_for / scoped_feature_flags) 决定,而不是由控件层的布尔量单独决定。纯 Display(5 项)不得启动它; sourceConsumerTaskCount 必须从 2 回到 1。
  4. Part C(可停止的空闲等待):iter_device_conditions 接受 stop_event, 20s 空闲等待在 Stop 时立即退出;在途的 get_battery() 采样必须照常完成。

2.2 非目标(明确禁止) ​

本批次不允许:

  • 改任何指标公式(FPS / FrameTime / P95 / Pacing Jitter / Refresh Rate 一行不改);
  • 改 Source Owner;
  • 改 FrameLifetime 白名单与解码器(含 GAP_REFRESH_MULTIPLE_THRESHOLD = 12);
  • 改 MetricRegistry / CapturePlan 的既有映射语义;
  • 放大 subscription 队列(max_queue_size);
  • 放宽 consumer drain 0.5s 窗口;
  • 把 Device Conditions 重新归类为 auxiliary / 辅助任务;
  • 给 FrameTime 的缺失值补数;
  • 构建 Build 69。

3. Part A:realtime Raw consumer 的有界 burst ​

3.1 只对同步工作量计费 ​

新增 RealtimeConsumerFairness(一个 consumer 一个账本), __slots__ 化、无锁、无分配:

python
fairness = consumer_fairness_registry.for_source(source_id)   # 每条流解析一次,不每事件
while True:
    async for raw in source:
        fairness.begin_item()        # <- 在 await 之后才开始计费
        ...                          # 同步解码 / 投影 / 入队
        if fairness.end_item():      # True = 本 burst 已用完,让出
            await asyncio.sleep(0)

begin_item() 在 __anext__ 返回之后才打时间戳,因此 "等待上游"这段(本来就已经把控制权交回事件循环)永远不会被算成 consumer 的 CPU 占用。源空闲时账本不累积,consumer 完全靠自然的 await 让出—— 不会产生自旋。

3.2 两个上限,先到先让 ​

python
REALTIME_CONSUMER_MAX_BURST_EVENTS = 8
REALTIME_CONSUMER_MAX_ACTIVE_NS    = 1_000_000      # 1.0ms

end_item() 每事件只做一次 time.monotonic_ns() 与几次整数运算,命中任一上限即 _close_burst(reason) 并返回 True;reason ∈ event-limit / active-time, 会计入 yieldReasonCounts。两个上限都不是新引入的"公平性时钟", 而是consumer 自己的 burst 预算——pump 的 32/1ms 与 filter 的 64/1ms 一行未改,两套账本互不干扰。

三处 await asyncio.sleep(0) 全部改为条件让出(Display 快路径、 CoreProfile 关闭期快路径、普通 append 路径),语义从"每事件"变为"每 burst"。

3.3 有界诊断 ​

RealtimeConsumerFairnessRegistry 按源数量设上限(REALTIME_CONSUMER_FAIRNESS_SOURCE_CAP = 64), 超出部分并入单个 __overflow__ 桶,因此内存是 O(源数量) 而非 O(事件数)。 写入 manifest 的 realtimeConsumerFairness 形态:

json
{
  "maxBurstEvents": 8,
  "maxActiveBurstMs": 1.0,
  "yieldCount": 0,
  "processedEventCount": 0,
  "maxObservedBurstCount": 0,
  "maxObservedActiveBurstMs": 0.0,
  "sourceCount": 1,
  "overflowSourceCount": 0,
  "perSource": { "pmd3.activity-trace.frame-lifetime.raw": { "...": "..." } }
}

全部为整数 / 有界小数值,可安全进入每个 manifest。

4. Part B:Device Conditions 由 canonical 作用域驱动 ​

capture_plan.py 新增一个问源契约而不是再写一份指标清单的判据:

python
DEVICE_CONDITION_SOURCE_ID = "pmd3.device-condition.raw"

def device_conditions_required(metric_ids) -> bool:
    return DEVICE_CONDITION_SOURCE_ID in execution_source_ids_for(metric_ids)

execution_source_ids_for 就是运行时激活路径本来就在用的那条 canonical 链路, 因此"要不要启动条件源"与"激活哪些源"从此是同一个答案, 不存在第二份会漂移的硬编码列表。

realtime.py 里原本三处 (include_device_conditions and device_conditions_enabled) 门禁(两处 recorder adapter 列表、一处 consumer 启动)统一收敛到一个闭包:

python
def device_conditions_active() -> bool:
    if not include_device_conditions or not device_conditions_enabled:
        return False
    return device_conditions_required(execution_metric_ids)

include_device_conditions 仍是显式 opt-out(fixtures / 测试用)、 device_conditions_enabled 仍是控制面请求,但两者都不能再"增加"一个 请求的指标并不需要的源。纯 Display 的 5 项指标只映射到 pmd3.activity-trace.frame-lifetime.raw,因此条件源不启动, sourceConsumerTaskCount 回到 1。

5. Part C:可停止的 20s 空闲等待 ​

python
async def wait_for_stop_or_interval(stop_event, timeout_seconds) -> bool:
    """True = 完整间隔已过(可以取下一个样本);False = 先观察到 Stop(应立即退出)。"""
  • stop_event is None → 保持 legacy 的 await asyncio.sleep(timeout)(测试与 fixture 不受影响);
  • 否则 await asyncio.wait_for(stop_event.wait(), timeout=...), 超时即 TimeoutError → True;先被 set → False。 用 wait_for 是因为它在超时时会取消挂起的 Event.wait(), 不会留下孤立的 waiter,也不会产生自旋。

iter_device_conditions 新增 stop_event 参数,并在两处检查: 进入循环前 if stop_event.is_set(): return,以及每次 yield 之后 if not await wait_for_stop_or_interval(...): return。

在途采样不被丢弃:await service.get_battery() 一旦开始就让它跑完并 yield, 因为一条已经解码的样本是真实 Raw,它应当在正常的 drain 窗口内落到 recorder。 stop 被允许提前中止的,只有两次采样之间的空闲等待。

DEVICE_CONDITION_INTERVAL_SECONDS 仍是 20.0,一行未改; 这条 consumer 仍是一个 verdict-bearing 的 Source Consumer,没有被降级为辅助任务。

6. 实施记录(Implementation Update) ​

实际修改范围(R8-Fix-7 自有文件):

文件改动
perfowl_collector_core/capture_plan.py新增 DEVICE_CONDITION_SOURCE_ID 与 device_conditions_required()(走 execution_source_ids_for),加入 __all__
perfowl_collector_core/device_conditions.py新增 wait_for_stop_or_interval();iter_device_conditions() 新增 stop_event 参数与两处提前返回;间隔常量保持 20.0
perfowl_collector_core/realtime.py新增 REALTIME_CONSUMER_MAX_BURST_EVENTS / REALTIME_CONSUMER_MAX_ACTIVE_NS / REALTIME_CONSUMER_FAIRNESS_SOURCE_CAP 与 yield reason 常量;新增 RealtimeConsumerFairness / RealtimeConsumerFairnessRegistry;三处 sleep(0) 改为 if fairness.end_item();新增 device_conditions_active() 闭包并替换三处门禁;consume_conditions() 传入 stop_event;manifest 新增 realtimeConsumerFairness
perfowl_collector_core/__init__.py导出 wait_for_stop_or_interval

相对原方案的偏差与原因:

  1. RealtimeConsumerFairnessRegistry 按源数量设上限,而不是"不设限"。 方案最初只写了"一个源一个账本";实现加了 64 的容量上限与单个 __overflow__ 桶。 原因:源标识虽然事实上有限,但不应该被信任;上限保证 manifest 大小与 内存占用在任何输入下都可证明有界,与本批次其余"有界性"设计一致。
  2. device_conditions_active() 收敛为闭包,而不是在每个门禁点各自判断。 原因:三处门禁必须同时改变,分散判断迟早漂移;闭包让"同一个答案"这件事 在代码结构上显式化,也让 session 级测试可以只验证一个入口。
  3. device_conditions_required() 在源契约不可用时返回 False,由运行时叠加 resource-independent 的 scoped_feature_flags 兜底。 原因:源码树探针(无 resources)不应该让"全 Registry 选择"被静默剥掉 battery/thermal 源。两层判断各自职责清晰:契约负责"这个选择需要哪些源", 控件标志负责"控制面是否允许"。
  4. 未触碰的文件:共享工作区中其它并行批次的未提交改动 (pmd3_backend.py / pmd3_deep_services.py / display_performance.py / frame_derivation.py / metric_engine.py / online_report.py / recording.py / Swift 源与构建脚本 / 若干测试)未被本批次触碰。 其中 pmd3_backend.py(pump 32/1ms)与 pmd3_deep_services.py(filter 64/1ms) 完全不在本批次 diff 中,这就是"公平性时钟未变"的结构性证明。

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

7. 测试与验证(Validation Update · 2026-09-19 · E2) ​

新增两个测试文件、扩展现有一个:

  • tests/test_realtime_consumer_fairness.py(17 项)
    • Test A(快 backlog):A1 轻量 backlog 按 burst 而非按事件让出; A2 新路径排空快于每事件让出;A3 竞争任务不被 burst 饿死。
    • Test B(active 上限):B1 单条 0.4ms 的 "heavy" 事件先撞 active-time 上限; B2 active burst 永不超事件上限;B3 两个上限就是声明的 8 / 1ms。
    • Test C(空闲上游):C1 上游暂停不被计入 active (源 stall 时 maxObservedActiveBurstMs 保持平坦); C2 空闲源不会在上游等待上产生额外忙等;C3 源停住时不产生自旋 (用 stall_after + 两次快照对比证明空闲稳定)。
    • Test D:D1 心跳延迟未退化。
    • Test E(多源):E1 每个并发源都取得进展;E2 registry 由源数量界定 (7 个不同源 id、cap=3 → sourceCount=4、overflowSourceCount=4)。
    • FairnessWindowRegression:pump 32/1ms 与 filter 64/1ms 未变。
    • DisplayPipelineBenchmark:三种形态(见下)。
  • tests/test_device_condition_scope.py(16 项)
    • 契约层(5 项):纯 Display 不是条件源选择;每个 battery/thermal 指标都选择该通道; 其它 device 指标不选择;全 Registry 选择仍然选择(兜底不被剥掉); 间隔常量仍为 20.0。
    • Test H(6 项):H1 20s 空闲等待被 Stop 在 < 0.5s 内打断; H2 在途样本不被丢弃;H3 无 Stop 时间隔正常走完; H4 Stop 已置位时立即返回;H5 不传 stop_event 时保持 legacy 行为; H6 不留下孤立的 waiter。
    • session 级(5 项):F1 纯 Display 从不打开条件源、sourceConsumerTaskCount == 1、 consumerDrainStatus == completed、dataCompleteness == complete、closeoutStatus == clean; F2 manifest 中 realtimeConsumerFairness(maxBurstEvents=8 / maxActiveBurstMs=1.0 / sourceCount=1)与 displayPipelineGapDiagnostics 共存; G1 选中 battery 指标时仍然打开条件源; I1 条件 consumer 是 Source Consumer 而非 auxiliary(两者同时选中时计数为 2); I2 Stop 被传播进条件迭代器。
  • tests/test_closeout_task_classification.py(+4 项,共 15 项)
    • Test J:J1 空闲 cadence 等待能在 drain 窗口内自行解开 (consumer_status = completed,不再假超时); J2 最后一次入队真的被阻塞时仍是 STATUS_TIMED_OUT 且 verdict-bearing; J3 空闲与阻塞混合时仍正确上报 consumer 失败; J4 consumer 窗口仍是 0.5s,未被本批次改动。

benchmark(新路径 vs legacy 每事件让出):

text
[形态 1] backlog 1500 frames + 0.2ms/turn 占用者(Build 68 纯 Display 形态复现)
  legacy/per-event-yield   552.8ms   2714 ev/s  handoffs=1500  p50=268.054 p95=500.537 p99=520.849 max=526.647 >500ms=77  drops=0 undrained=0
  r8fix7/bounded-burst      91.4ms  16410 ev/s  handoffs=187   p50= 35.206 p95= 60.718 p99= 62.274 max= 63.082 >500ms= 0  drops=0 undrained=0
  -> p95 改善 87.9%;总排空耗时 -83.5%;吞吐 x6.0;>500ms 从 77 归零

[形态 2] backlog 尾部 1500 frames + 0.5ms/turn 占用者
  legacy/per-event-yield  1037.2ms   1446 ev/s  handoffs=1500  p50=513.038 p95=941.562 p99=981.975 max=994.754 >500ms=770 drops=0 undrained=0
  r8fix7/bounded-burst     149.7ms  10020 ev/s  handoffs=187   p50= 61.738 p95= 99.613 p99=102.532 max=103.871 >500ms=  0 drops=0 undrained=0
  -> p95 改善 89.4%;总排空耗时 -85.6%;>500ms 从 770 归零

[形态 3] 稳态 120Hz(时钟压缩 120x),7200 frames
  legacy/per-event-yield   910.3ms   7909 ev/s  handoffs=7200  p50= 59.233 p95=110.497 p99=112.023 max=112.712 >500ms=0 drops=0 undrained=0
  r8fix7/bounded-burst    1125.1ms   6400 ev/s  handoffs=900   p50=  1.066 p95=  1.336 p99=  1.904 max=  3.974 >500ms=0 drops=0 undrained=0
  -> 交接延迟 p95 改善 98.8%;但总排空耗时 +23.6%(见下方诚实说明)

关于形态 3 的诚实说明:形态 3 把时钟压缩 120 倍,7200 帧因此近似同时可用, "稳态"实际上退化成 backlog 形态。此时新路径总的排空墙钟比 legacy 长 23.6%: legacy 每条都让出,pump 因此获得更多调度机会把事件推入队列;新路径每 8 条才让出, consumer 占住循环更久,最后一个事件反而更晚到达。这是一个明确的取舍: 用总排空时间换交接延迟的两个数量级改善(p95 110.5ms → 1.3ms)与 >500ms 的彻底归零。在真实(未压缩)120Hz 会话中帧间隔为 8.33ms, 两条路径都不会饱和,因此该取舍不影响产品形态——但这是推断,必须由真机 E4 确认, 不得写成已验证结论。

验证结果:

text
Python 3.12 全量(隔离 worktree,独立于共享脏工作区)   703 / 703 OK   (57.181s)
  - test_realtime_consumer_fairness                       17 / 17 OK
  - test_device_condition_scope                            16 / 16 OK
  - test_closeout_task_classification                      15 / 15 OK
专注 E2 集合(11 个模块)                                  225 / 225 PASS
Swift 全量                                                见下方门禁
站点(知识门禁 + VitePress build)                          见下方门禁
git diff --check                                          PASS(无空白错误)

静态不变性(逐条核对):本批次未修改

  • FPS / FrameTime / P95 / Pacing Jitter / Refresh Rate 公式;
  • ActivityTrace Source Owner;
  • FrameLifetime 白名单与解码器(含 GAP_REFRESH_MULTIPLE_THRESHOLD = 12);
  • MetricRegistry / CapturePlan 的既有映射语义 / CoreProfile 策略;
  • Swift Core gracefulTimeout = 10s;
  • Python collector 8s 关闭预算;
  • Pmd3 pump fairness 32 events / 1ms(pmd3_backend.py 不在 diff 中);
  • ActivityTrace filter fairness 64 messages / 1ms(pmd3_deep_services.py 不在 diff 中);
  • consumer drain 窗口 SOURCE_CONSUMER_DRAIN_TIMEOUT_SECONDS = 0.5;
  • Device Condition 采样间隔 DEVICE_CONDITION_INTERVAL_SECONDS = 20.0;
  • RawEventEnvelope.SCHEMA(仍为 perfowl.raw-event/1)。

8. 对 R8-Fix-6 的验证结论 ​

见 R8-Fix-6 方案 文末追加的 Validation Update 增量段。

9. 遗留项与下一阶段 ​

  • 本批次不构建 Build 69、不执行真机 E4、不声明 validated。
  • 下一次真机 E4 需要验证:
    1. 纯 Display 会话 sourceConsumerTaskCount == 1, 且 pmd3.device-condition.raw 不在 adapter 列表中;
    2. 纯 Display 会话 pumpQueueWait 的 P95/P99 明显低于 Build 68 的 282.982ms, 且 > 500ms 计数为 0;
    3. 正常 Stop 时 consumerDrainStatus = completed、overallCloseoutStatus = clean、 不再是 Build 68 那种 dataCompleteness = complete + timed-out 的自相矛盾组合;
    4. 选中 battery/thermal 指标时条件源仍然启动,20s 间隔保持, Stop → 条件迭代器退出的实测延迟远小于 20s(E2 上为 0.299s);
    5. realtimeConsumerFairness.maxObservedBurstCount <= 8 且 maxObservedActiveBurstMs 约在 1ms 量级(证明只对同步工作量计费);
    6. 真实 120Hz 会话(未压缩)中总排空时间没有可测量的退化 ——形态 3 的取舍只能在真机上判定,不得在此之前写成结论。
  • 关联文档:R8-Fix-6 及更早批次的结论不回改,只在文末追加带绝对日期的 update 增量段。

10. E2 门禁修复(Validation Update · 2026-09-19 · Test Contract Repair) ​

本节为增量段。 第 7 节已发布的测试数字、样本数与结论不回改(仓库规则 D2); 本节记录独立审计发现的测试契约缺陷、修复方式与修复后的 E2 结果。 本批次只改测试与文档:git diff <R8-Fix-7 提交>..HEAD -- MacApp/Sidecar/perfowl_collector_core 为空,产品代码零改动。

10.1 独立审计发现 ​

独立 clean-worktree 验收确认 R8-Fix-7 的产品实现方向正确——bounded burst 为 8 events / 1ms、bounded-burst 的 synthetic 结果稳定优秀、Device Conditions 作用域与 Stop-aware 空闲等待行为正常、Swift 与站点门禁通过。 唯一稳定阻断来自新 benchmark 对 legacy baseline 的错误断言, 另有一个既有 1ms 窄边界测试的偶发抖动。两者都是测试契约缺陷,产品逻辑无需改动。

在基线提交 a9f10968c71dc238825a8ff4427b2d04627a8ea2 上的独立复现:

text
focused      Ran 197 tests                     FAILED (failures=2)
Python full  Ran 703 tests in 92.341s          FAILED (failures=3)

稳定失败  tests.test_realtime_consumer_fairness.DisplayPipelineBenchmark.test_benchmark_steady_120hz_7200_frames
偶发抖动  tests.test_display_pipeline_timing.PumpActiveBurstDurationTests.test_a1_active_duration_excludes_upstream_wait

10.2 缺陷 1:shipping gate 建在 legacy 模拟器上 ​

原 test_benchmark_steady_120hz_7200_frames 对 legacy 与 burst 两条路径同时要求 processed == 7200、sampleCount == 7200、droppedEventCount == 0、undrainedAtClose == 0, 并额外要求 legacy.handoffs == 7200。

这个契约不成立。legacy 就是旧策略的行为模拟器(每条 Raw 一次 await asyncio.sleep(0));而 Pmd3Subscription.__anext__ 在有界队列仍有 backlog 时是 同步返回的,因此当测试把真实 60 秒的 120Hz 压缩 120 倍时,producer 的压力足以 真实撑爆有界的 pump 队列(2048)。这正是旧策略在高压下丢样的证据, 不是测试环境偶发故障,更不是新实现的缺陷。把它当成 shipping 失败,等于要求 "故意模拟旧坏行为的对照组在超压缩负载下也必须零丢样"。

独立审计连续复跑的 legacy 侧数据:

复跑legacy processedlegacy dropslegacy P95burst processedburst dropsburst P95burst Max
Run 15448 / 72001752508.009ms7200 / 720005.317ms9.770ms
Run 25355 / 72001845497.764ms7200 / 720005.509ms10.149ms
Run 35280 / 72001920525.558ms7200 / 720005.657ms9.693ms
Python full5336 / 72001864478.606ms7200 / 720005.517ms9.510ms

本批次在隔离 worktree 上复现时进一步确认 legacy 的丢样数本身高度不稳定: 一次观测到 processed 6709 / drops 491 / p95 334.611ms,另一次观测到 processed 7200 / drops 0 / p95 160.783ms。一个在 0 与约 1900 之间大幅摆动的量 不能被"零容忍"断言钉住——这从另一面证明该断言不适合作门禁。

10.3 缺陷 2:1ms active-duration 测试把实现阈值当成机器测量上限 ​

PumpActiveBurstDurationTests.test_a1_active_duration_excludes_upstream_wait 原来断言 active_ms < 1.0——而 1ms 正是 pump 的公平性预算本身。断言拿实现阈值当机器测量上限, 没有为两次 monotonic_ns 之间的调度抢占留任何余量;独立全量运行观测到 active_ms = 1.014 即把一个工作正常的产品判为失败(随后单独重跑 5/5 PASS)。

该测试真正要证明的是"上游 await 不被计入 pump 的 active 跨度", 因此修法是把数量级拉开,而不是放宽数字。

10.4 契约修正 ​

benchmark(tests/test_realtime_consumer_fairness.py)——按语义拆开, 并把对照组加回来:

测试语义断言
test_benchmark_shipping_policy_steady_120hz_7200_framesR8-Fix-7 发布门禁,只对 bounded burstprocessed/sampleCount == 7200、drops == 0、undrained == 0、handoffs == ceil(7200/8) == 900、p95 < 10ms、p99 < 10ms、max < 50ms、>500ms == 0
test_benchmark_legacy_vs_bounded_burst_under_pressurelegacy 对照,允许丢样legacy.processed > 0、legacy.sampleCount > 0、legacy.handoffs == legacy.processed、legacy.p50 > 10ms(形态代表性地板);burst 在同样形态下零丢样;burst.p95 < legacy.p95 且 improvement > 30%
test_benchmark_no_overflow_latency_comparator控制组:测试专用 8192 队列让两条路径都不丢样,隔离"调度策略"与"队列溢出"两条路径均 processed/sampleCount == 7200、drops == 0;burst.p95 / p99 / max < legacy 且 burst.handoffs < legacy.handoffs
  • legacy 的丢样数只打印、不 FAIL——它是 historical behavior simulator, 不是 shipping implementation。
  • 8192 只作为测试 fixture 参数传入;生产 Pmd3Subscription 默认队列容量 2048 未被修改,公平性窗口也完全未参与。
  • test_benchmark_backlog_reproduces_build68_and_improves_p95 与 test_benchmark_backlog_tail_removes_the_over_500ms_queue_wait 保持不变、未削弱。

active duration(tests/test_display_pipeline_timing.py): 上游等待 5ms/事件 → 50ms/事件;断言改为 legacy_wall_ms > 40ms、 active_ms < 10ms、active_ms < legacy_wall_ms * 0.20、 legacy_wall_ms - active_ms > 30ms。若错误地把上游 await 计入,active_ms 将是 50ms 量级, 四条断言必然全部失败——判别力只增强、不减弱。

10.5 修复后 E2 结果 ​

shipping 发布门禁连续 5 次(§19 要求):

text
run  verdict  processed   drops   p95/ms   max/ms  handoffs  >500ms  undrained
 1   PASS     7200/7200       0    1.629    5.233       900       0          0
 2   PASS     7200/7200       0    1.368   40.074       900       0          0
 3   PASS     7200/7200       0    1.571    3.450       900       0          0
 4   PASS     7200/7200       0    1.366    2.519       900       0          0
 5   PASS     7200/7200       0    1.596   18.318       900       0          0
5/5 PASS

关于 max 的诚实标注:为刻画尾部,在 §19 的 5 次之外另连续跑了 12 次 (连同两轮探索性运行共 19 次独立运行)。其中 12 次刻画运行里 p50 稳定在 0.760–1.085ms、p95 在 1.060–2.078ms、p99 在 1.220–3.156ms, 而单样本 max 在 1.796–47.412ms 之间摆动。 max 是 7200 个样本里的单个样本,由宿主调度与 GC 主导,不是策略属性。 因此本次把百分位(p95 / p99 < 10ms)作为门禁的稳定内核, 同时保留任务书指定的 max < 50ms 上限并记录原始值。 19 次运行中 max 的最坏值为 47.412ms,即上限利用 94.8%—— 这是已知的残余敏感度:真实的分布性退化会被百分位拦住, 但单次宿主级停顿仍可能让 max 断言失败,需在后续 E2 / 真机复跑中继续观察。

legacy 对照(同形态、允许丢样):

text
legacy/per-event-yield    971.9ms  7408 ev/s handoffs=7200  processed=7200/7200 p50=122.396 p95=148.204 p99=150.561 max=152.201 >500ms=0 drops=0 undrained=0
r8fix7/bounded-burst     1013.8ms  7102 ev/s handoffs=900   processed=7200/7200 p50=  0.787 p95=  1.121 p99=  1.555 max=  9.786 >500ms=0 drops=0 undrained=0
-> p95 改善 99.2%

no-overflow 控制组(测试专用 8192 队列,两条路径都不丢样):

text
legacy/per-event-yield    911.8ms  7896 ev/s handoffs=7200  processed=7200/7200 p50= 51.407 p95= 86.704 p99= 91.542 max= 93.551 >500ms=0 drops=0 undrained=0
r8fix7/bounded-burst     1017.0ms  7080 ev/s handoffs=900   processed=7200/7200 p50=  0.839 p95=  1.427 p99=  2.036 max=  3.598 >500ms=0 drops=0 undrained=0
-> p95 改善 98.4%;交接次数 7200 -> 900

active duration 连续 10 次(§20 要求):

text
run  verdict    wall/ms   active/ms  active share
 1   PASS       52.672       0.148       0.2810%
 2   PASS       52.122       0.796       1.5272%
 3   PASS       51.955       0.323       0.6217%
 4   PASS       52.785       0.144       0.2728%
 5   PASS       52.657       0.181       0.3437%
 6   PASS       52.620       0.097       0.1843%
 7   PASS       53.146       0.542       1.0198%
 8   PASS       52.553       0.134       0.2550%
 9   PASS       53.490       0.868       1.6227%
10   PASS       52.612       0.101       0.1920%
10/10 PASS;upstream wall wait 51.955-53.490ms,active 0.097-0.868ms(占比 < 1.7%)

保留的 backlog 形态(未改动,复跑确认稳定):

text
backlog 1500 + 0.2ms/turn  legacy p95 474.8-729.4ms / >500ms 4-543   -> burst p95  55.2- 79.9ms / >500ms 0  (改善 86-89%)
tail    1500 + 0.5ms/turn  legacy p95   0.9-  1.3s / >500ms 734-974 -> burst p95 101.9-152.6ms / >500ms 0

全量门禁:

text
专项 E2 集合(7 模块)     Ran 199 tests              OK   (38.462s)
  test_realtime_consumer_fairness   19
  test_device_condition_scope       16
  test_closeout_task_classification 15
  test_pmd3_subscription_fairness   18
  test_activitytrace_frame_lifetime 62
  test_display_pipeline_timing      31
  test_perfowl_realtime             38
Python 全量               Ran 705 tests in 60.442s   OK   (0 failures / 0 errors)
Swift 全量                182 tests / 3 skipped / 0 failures
站点(知识门禁 + VitePress build) PASS
git diff --check          PASS

Python 全量从基线 703 变为 705:fairness 模块 17 → 19(-1 旧 steady,+3 新 benchmark), 即净 +2。

10.6 本批次边界与静态不变性 ​

git diff <R8-Fix-7 提交>..HEAD -- MacApp/Sidecar/perfowl_collector_core 为空—— 本批次未修改任何产品代码。逐条核对,以下全部保持原值:

  • realtime consumer bounded burst = 8 events / 1ms (REALTIME_CONSUMER_MAX_BURST_EVENTS / REALTIME_CONSUMER_MAX_ACTIVE_NS);
  • Pmd3 pump fairness = 32 events / 1ms(PUMP_FAIRNESS_MAX_BURST_EVENTS / _NS);
  • ActivityTrace filter fairness = 64 messages / 1ms (ACTIVITYTRACE_FILTER_MAX_BURST_MESSAGES / _NS);
  • Pmd3Subscription queue 容量 2048(8192 只以测试 fixture 参数出现);
  • consumer drain 窗口 SOURCE_CONSUMER_DRAIN_TIMEOUT_SECONDS = 0.5;
  • Python collector 关闭预算 COLLECTOR_CLOSEOUT_SOFT_BUDGET_SECONDS = 8.0;
  • Device Condition 采样间隔 DEVICE_CONDITION_INTERVAL_SECONDS = 20.0;
  • Swift Core watchdog gracefulTimeout = runtimeKind == .core ? 10 : 2;
  • FPS / FrameTime / P95 / Jitter / Refresh Rate 算法与 RawEventEnvelope.SCHEMA (仍为 perfowl.raw-event/1)。

本批次不构建 Build 69、不执行真机 E4、不声明 validated; E4 门禁仍按第 9 节的六条执行。修复过程与证据另存 E2 门禁修复归档。

Validation Update · 2026-09-19 · Build 69 真机 E4 ​

Build 69 真机 E4 已执行(纯 Display,Session 1bc4d6c4-9cf0-43e2-9351-f2ff0be3b630, 时长 81.878s,交付 768 条 FrameLifetime)。结论一半成立、一半证伪,必须分开写。

成立的一半:作用域与停止语义(Part B) ​

Build 69 的会话是纯 Display,即 R8-Fix-7 的 Part B(启动由 canonical execution_source_ids_for 决定、纯 Display 只映射到 ActivityTrace 一条源)在真机上 达到了设计目标:不再把 20s 周期的 pmd3.device-condition.raw 注册成 Source Consumer, 第 8 节问题 B 的"2 个 source consumer"形态未再出现。 Part C(20s 空闲等待 stop-aware)与本条的观测一致,未引入新的停止异常。

被证伪的一半:有界 burst 未能消除残余队列延迟(Part A) ​

Build 68 纯 Display 会话残留 pumpQueueWait P95 282.982ms; Build 69 纯 Display 一条源时仍为 P50 157.596ms / P95 354.888ms / Max 563.371ms, >500ms 计数虽为 0,但量级没有下落。也就是说 Part A(三处每事件 sleep(0) 改为 8 events / 1.0ms 有界 burst)没有解决最终一段队列延迟—— 它解决的是"排空速度被调度延迟钉死"的那一档(R8-Fix-7 第 3 节的比对基准仍然成立), 而 Build 69 暴露的是更前面一档。

定位:七层诊断把残余钉在订阅队列,指纹指向 wall 门槛 ​

Build 69 第一次让 R8-Fix-6 的七层 host 时间戳跑在完整数据上,逐层读数是:

层实测(P95)
filter → pump 入队0.035ms
pump 出队 → adapter0.017ms
adapter → realtime0.247ms
realtime → ingress0.021ms
pumpQueueWaitP50 157.596ms / P95 354.888ms / Max 563.371ms

除订阅队列外每一层都在亚毫秒级,而 pump 的公平性诊断给出决定性指纹:

text
deliveredEventCount            768
pumpYieldCount                 768      <- 让出次数 == 交付条数
pumpMaxBurstCount              1        <- 每次 burst 只处理 1 条
pumpMaxActiveBurstDurationMs   0.058    <- pump 自身同步工作仅 58 微秒

filterYieldCount               883
filterMaxBurstMessageCount     16       <- 上界是 64
filterMaxActiveBurstDurationMs 2.131

"让出次数 == 交付条数"且 maxBurstCount == 1 且 maxActive 只有 58 微秒, 说明让出不是被 pump 的工作撞响的:R8-Fix-5 留下的时间门槛比较的是 含上游 await 的 wall-span,而 60Hz 源的 await(≈16.6ms)本身已经挂起过本任务一次—— 门禁于是对同一次等待又触发一次显式让出,把每条事件的交付硬性拉长一个调度往返。 Filter 侧同构:last_ns 每条匹配消息刷新,使 883 次让出里每次 burst 只有 16 条消息。

本条不修改 R8-Fix-7 的任何常量或代码:8 events / 1.0ms 的 consumer burst 保持不变, R8-Fix-7 的三条修复全部保留。上述残余由后续批次 R8-Fix-8 定向处理 (把 Pump 的 32 events / 1ms 与 Filter 的 64 messages / 1ms 的时间门槛 改为只比较自身同步工作),见 R8-Fix-8 方案。

Perfowl · Performance Observer