外观
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 已经把分类修对了),而是:
- 作用域缺陷:不该启动的源被启动了;
- 停止语义缺陷:20s 的空闲等待被当成了"consumer 拒绝完成最后一次入队"。
关键判断:修法不是放宽 drain 窗口。把 0.5s 放大只会掩盖"不该启动的源" 并让真正停摆的 consumer 多等;也不是把 Device Conditions 降级成辅助任务—— 它是一个真实的 Raw 源,降级会破坏"只有真 Source Consumer 才能翻转判决"这条 R8-Fix-6 刚建立的语义。正确修法是缩短它的空闲等待并不启动它。
2. 目标与非目标
2.1 目标
- Part A(有界 burst):把 realtime Raw consumer 的"每事件
sleep(0)"换成 有界 burst —— 只在同步工作量为零、且尚未达到任何上限时才让出。 只对同步工作量计费,上游await永不入账。 - Part A(可观测):为每个源输出有界的
realtimeConsumerFairness诊断 (maxBurstEvents/maxActiveBurstMs/yieldCount/maxObservedBurstCount/maxObservedActiveBurstMs/perSource)。 - Part B(canonical 作用域):Device Conditions 的启动必须由 canonical 的指标 → 源映射(
execution_source_ids_for/scoped_feature_flags) 决定,而不是由控件层的布尔量单独决定。纯 Display(5 项)不得启动它;sourceConsumerTaskCount必须从 2 回到 1。 - 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.0msend_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 |
相对原方案的偏差与原因:
RealtimeConsumerFairnessRegistry按源数量设上限,而不是"不设限"。 方案最初只写了"一个源一个账本";实现加了 64 的容量上限与单个__overflow__桶。 原因:源标识虽然事实上有限,但不应该被信任;上限保证 manifest 大小与 内存占用在任何输入下都可证明有界,与本批次其余"有界性"设计一致。device_conditions_active()收敛为闭包,而不是在每个门禁点各自判断。 原因:三处门禁必须同时改变,分散判断迟早漂移;闭包让"同一个答案"这件事 在代码结构上显式化,也让 session 级测试可以只验证一个入口。device_conditions_required()在源契约不可用时返回False,由运行时叠加 resource-independent 的scoped_feature_flags兜底。 原因:源码树探针(无 resources)不应该让"全 Registry 选择"被静默剥掉 battery/thermal 源。两层判断各自职责清晰:契约负责"这个选择需要哪些源", 控件标志负责"控制面是否允许"。- 未触碰的文件:共享工作区中其它并行批次的未提交改动 (
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,未被本批次改动。
- Test J:J1 空闲 cadence 等待能在 drain 窗口内自行解开 (
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 需要验证:
- 纯 Display 会话
sourceConsumerTaskCount == 1, 且pmd3.device-condition.raw不在 adapter 列表中; - 纯 Display 会话
pumpQueueWait的 P95/P99 明显低于 Build 68 的 282.982ms, 且> 500ms计数为 0; - 正常 Stop 时
consumerDrainStatus = completed、overallCloseoutStatus = clean、 不再是 Build 68 那种dataCompleteness = complete+timed-out的自相矛盾组合; - 选中 battery/thermal 指标时条件源仍然启动,20s 间隔保持, Stop → 条件迭代器退出的实测延迟远小于 20s(E2 上为 0.299s);
realtimeConsumerFairness.maxObservedBurstCount <= 8且maxObservedActiveBurstMs约在 1ms 量级(证明只对同步工作量计费);- 真实 120Hz 会话(未压缩)中总排空时间没有可测量的退化 ——形态 3 的取舍只能在真机上判定,不得在此之前写成结论。
- 纯 Display 会话
- 关联文档: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_wait10.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 processed | legacy drops | legacy P95 | burst processed | burst drops | burst P95 | burst Max |
|---|---|---|---|---|---|---|---|
| Run 1 | 5448 / 7200 | 1752 | 508.009ms | 7200 / 7200 | 0 | 5.317ms | 9.770ms |
| Run 2 | 5355 / 7200 | 1845 | 497.764ms | 7200 / 7200 | 0 | 5.509ms | 10.149ms |
| Run 3 | 5280 / 7200 | 1920 | 525.558ms | 7200 / 7200 | 0 | 5.657ms | 9.693ms |
| Python full | 5336 / 7200 | 1864 | 478.606ms | 7200 / 7200 | 0 | 5.517ms | 9.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_frames | R8-Fix-7 发布门禁,只对 bounded burst | processed/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_pressure | legacy 对照,允许丢样 | 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 -> 900active 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 PASSPython 全量从基线 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); Pmd3Subscriptionqueue 容量 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 出队 → adapter | 0.017ms |
| adapter → realtime | 0.247ms |
| realtime → ingress | 0.021ms |
pumpQueueWait | P50 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 方案。