[docs] (P6): the closeout - gates 7 and 8 are measured and P6 is closed

The device gate: Redmi 2f7cbe2e, reboot-clean, paired A/B in one thermal
window (arms interleaved, best-of-3 then best-of-2), rd12 + DirectGLES.
inproc vs spawn client CPU p50 7.880/7.889 vs 7.876/7.869 (-0.15%) and
7.987/7.989 vs 7.992/8.009 (+0.16%) - a tie inside the 0.2-4.2% within-
arm spread, sign flipping with arm order, recorded and not gated.
monolith is ~30% dearer. 22/22 runner exits clean, 44/44 PINNED, zero
Fatal.

The three mandatory numbers: socket doorbell vs condvar is a tie too -
client 121.1 waits/frame identical across transports, server 10 866-11
963 (inproc) vs 10 415-10 416 (spawn) waits/frame, and the contract's
'converting that into ~4 blocking reads may be cheaper' did not pay off;
the only column where the socket is worse is clipark/f (6.07 vs 5.60/5.75).
SEG_STAGE bytes/frame: 130.8 KB median / 8.01 MB steady (OpenRA), 816.2
KB / 1.53 MB (rd12). Records/frame post-chunking: 211/206 and 7 497/7
504. maxrec stays 1808 - the 8 MiB chunk budget is never reached. Both
framings reported throughout (the startup window runs ~7x the steady
state) rather than the flattering one.

MEASUREMENTS.md gains section 12 with the full protocol and tables; the
6.3 'no post-chunking distribution exists' gap now points there.
CURRENT_STAGE_PROGRESS.md marks gates 7/8 met and records the closeout
work plus two structural limits (spawn client windowed fields are zero
by construction - OnPresent runs in the server process - though its run
totals are valid stores; the stats dump is role-derived). ROADMAP marks
P6 closed in the stage table, the header and the milestone.

Evidence: notes/p6/gate7-device-ab.md, gate8-doorbell-device.md,
gate8-wire-counters.md, git-worktree-fix.md. check_doc_citations: 82
citations in the three documents, 0 problems.
This commit is contained in:
2026-09-22 03:38:17 -04:00
parent 06b61bd84e
commit 35b3283bd9
10 changed files with 2925 additions and 18 deletions
+134 -11
View File
@@ -5,11 +5,17 @@
> [`MobileGL/MG_Remote/CONTRACT-P6.md`](../../MobileGL/MG_Remote/CONTRACT-P6.md);设计见
> [`ARCHITECTURE.md`](ARCHITECTURE.md)。
**状态(2026-09-21):P6 全部包已落地(a6…t6),出口门第 1–6 项达成;第 7 项(真机配对 A/B
热窗口)与第 8 项(三个强制性能数)尚未正式采集,因此 P6 记为「实现完成、出口性能/设备数待补」,
未宣布收官。** 分支 `feat/disaggregated`,头 `16bfab10`,已推 origin。三种传输形态
(`monolith` / `inproc` / `spawn`)在桌面(WSL lavapipe)与真机(Redmi `2f7cbe2e`)上都跑通了
真实 trace 的两进程 retrace,逐像素对上 golden。
**状态(2026-09-22):P6 已收官。** 全部包(a6…t6)落地,出口门第 1–8 项全部达成:门 7(真机
`2f7cbe2e` reboot-clean、同热窗口配对 A/B)判读为 **tie**——`inproc` 与 `spawn` 的 client CPU/帧
差 −0.15%(会话 1)/ +0.16%(会话 2),都落在臂内散布之内,如实记录、不设门;门 8 的三个强制
性能数(socket 门铃 vs condvar、`SEG_STAGE` 字节/帧、chunking 后记录/帧)已采齐,逐数见 §2;
G1 pull 构建 **0/0/0/0** 且 `.text` 逐字节相同。测量期的源头:门 7 报告记 `98d0b96c`、门 8 的
wire 计数器与真机第二轮记 `71aa9951`。分支 `feat/disaggregated` 其后又落地两个 P6.5 设计提交
(`2d86e07a`、`2bd86664`),**现头 `2bd86664`,与 `origin` 同步**;**工作树另有未提交的 P6 收尾
改动**(wire 计数器、`ServerMain` 的 `PipeStats` 武装、`MOBILEGL_PIPE_STATS_FILE` 角色派生、设备行
与工具入库、测试),见 §1.1。三种传输形态(`monolith` / `inproc` / `spawn`)在桌面(WSL lavapipe)
与真机(Redmi `2f7cbe2e`)上都跑通了真实 trace 的两进程 retrace,逐像素对上 golden。性能**只记录、
不设门**。
---
@@ -34,6 +40,40 @@
`split` 键改成 opt-out;Android CI 新增 `spawn-acceptance` 臂,真机 App 从 `nativeLibraryDir` 解析
server 路径(`94e2f2e0`、`15bd9a67`、`1039502e`、`2afa0002`)。
### 1.1 收尾(2026-09-22)
下列改动**在两轮测量(门 7 的 `98d0b96c` 头、门 8 的 `71aa9951` 头)之后落地、目前仍在工作树里未
提交**(其后两个提交 `2d86e07a`、`2bd86664` 只改文档与 `Transport/StreamLink.h` 的注释,不含这些代码):
- **wire 计数器**:`PipeStats` 新增 `ByteClass::StageSegmentBytes`(唯一收口点
`PipeWireCodec.cpp:878` `StageAllocate`)与 `CallClass::WireRecords`(`:1188` `EncodeRecord` 提交
路径),外加一个 staged-blob 直方图(复用 `PayloadBucketOf` 的桶边界,只经 JSON dump 出口);
汇总行新增 `seg=` / `wrec=` / `wrec/f=`(后者与 `draws/f=` 同形,无 Present 时按既有规则退回窗口总量)。
两个计数器都在 `#if MOBILEGL_PIPE_PUSH` 内,落点选在唯一收口点而不是调用方,否则等于维护一张
"今天存在哪些 producer" 的名单。
- **`ServerMain` 的 `PipeStats` 武装**:spawn server 此前从不武装计数器(红一次的证据:
**修复前 spawn server 0 条汇总行、修复后 29 条**,同一条 spawn trace、两侧 SSIM 均 1.000000)。
`Init()` 放在 `MG_ConfigLoader::Init()` 与 `MGPipeSetServerProcessRole(true)` **之后**、
`Shutdown()` 放在 `loop.Stop()` 之后 `_exit(0)` 之前——两处顺序都是承重的(前者让 latch 读到真
配置值,后者让 apply 线程不再发布时取运行总量与 JSON dump)。见 §2.1。
- **`MOBILEGL_PIPE_STATS_FILE` 角色派生**:与日志 sink 同一套命名规则(库 `RoleLogPath` 导出、
PipeStats 调用而非复制),dump 与 banner 都印/写 `<base>.client.json` / `<base>.server.json`,
**基名不再指向任何文件**——没跟上改名的读取方会当场 `ENOENT`,这是刻意的。这一条在 G1 上红了
三次才做对(无条件派生 → `.text +24`;加 `#if` 但两臂共用局部 → 1 symbol resized;两臂分离但
重写 `MGLOG_W` 格式串 → `.rodata +64`),最终形态是 pull 臂的代码逐语句原样、所有新增都在 `#if` 内。
- **`pin_device.sh` 新增 `2f7cbe2e` 条目**:本板 GPU 钳 **1050 MHz**(`thermal_pwrlevel` 写 0 读回 1、
`max_gpuclk` 只读 1 050 000 000、devfreq `max_freq` 同值),因此**绝对值与 1100 MHz 时代的
`35d0befa` 行不可比**,只有同会话的配对可比。三种动作(`check` / `pin` / `unpin`)均已实测。
- **工具进树**:`tools/device_bench/p6/{ab_session.py,render_ab.py,README.md}`(真机配对 A/B 会话
运行器 + 只读渲染器,门 7 快照里的 `gate7_ab.py` / `render_gate7.py` 是它们的前身)与
`tools/trace_replay/run_android_retrace_local.py`(+19 行):`MOBILEGL_TRACE_PACKAGE` 覆盖,
让开发包能**装在既有 trace 包旁边**而不是替换它(两个不同密钥签的 APK 不能共用一个 id;
缺省即原行为)。四个文件目前都在工作树里**未提交**。
- **测试**:`PipeStats` **25/25**、全 unit **2367/2367**、`integration-spawn` **102/102**;
G1 复验 **0/0/0/0** 且 `.text` **逐字节相同**(pull 配置下两侧各 10 158 850 字节,`cmp` 无差异)。
逐条 red-once 与 G1 三次泄漏见
[`notes/p6/gate8-wire-counters.md`](notes/p6/gate8-wire-counters.md) §9.7.3。
---
## 2. 出口门(契约 §9)
@@ -46,16 +86,21 @@ server 路径(`94e2f2e0`、`15bd9a67`、`1039502e`、`2afa0002`)。
| 4 | 进程树:运行时恰多一个子进程、结束后为零 | ✅ `ServerSpawnTest.StartsAServerProcessAndHandshakesAcrossIt`(`CountOwnChildren`) |
| 5 | Arm 证明(ID-124):每条 spawn 条目记子进程 pid 与 `transport=spawn` | ✅ `--require-spawn` 门要求 ConfigLoader marker **且** `spawn ARMED - pid N` |
| 6 | 每包 red-once(R-16) | ✅ dl 的 S2/S7、hs 的 serverPid、sm/st 的守卫塌缩、t6 的名集合漂移均已跑红一次 |
| 7 | **设备**:Redmi reboot-clean、同热窗口配对 A/B | ⏳ 功能已验(下 §3),正式配对 A/B 热窗口性能跑**未采集** |
| 8 | **性能只记录**,但三个数强制:socket 门铃 vs condvar、`SEG_STAGE` 字节/帧、chunking 后记录/帧 | ⏳ **未采集**,`MEASUREMENTS.md:342` 仍说 chunking 后分布不存在 |
| 7 | **设备**:Redmi reboot-clean、同热窗口配对 A/B | ✅ **配对 A/B 判读 tie**:会话 1(boot id `e69d0329…`→`107f24d3…`)六臂交错 `monolith,inproc,spawn,inproc,monolith,spawn` × best-of-3 —— 18/18 runner 退出 0、36/36 pin check `PINNED`、0 `Fatal{`;client CPU p50 ms/帧 **inproc 7.880/7.889、spawn 7.876/7.869**(均值 −0.15%,符号随顺序翻转,臂内散布 0.2–4.2%),`monolith` 约贵 30%。会话 2(boot id `107f24d3…`→`7e6c6e9f…`)inproc/spawn 交错 × best-of-2:4/4 OK、8/8 `PINNED`、0 `Fatal`;client CPU **7.987/7.989 vs 7.992/8.009**(+0.16%,亦在 0.14–0.69% 臂内散布内)。差值在臂内噪声内,**如实记录、不设门**。见 §3、[`notes/p6/gate7-device-ab.md`](notes/p6/gate7-device-ab.md)、[`notes/p6/gate8-doorbell-device.md`](notes/p6/gate8-doorbell-device.md) |
| 8 | **性能只记录**,但三个数强制:socket 门铃 vs condvar、`SEG_STAGE` 字节/帧、chunking 后记录/帧 | ✅ 三个数齐(见 §3.2 / §3.3 与 [`notes/p6/gate8-doorbell-device.md`](notes/p6/gate8-doorbell-device.md)、[`notes/p6/gate8-wire-counters.md`](notes/p6/gate8-wire-counters.md)):① socket 门铃 vs inproc condvar —— client **121.1 waits/帧**、5.6–6.1 parks/帧,两传输逐字相同(run total 30 401–30 407);server inproc **10 866–11 963** waits/帧、5.0–6.2 parks/帧(park 率 0.042–0.06%),spawn **10 415–10 416** waits/帧、4.66–4.90 parks/帧。判读(诚实):socket 阻塞读**既不更便宜也不更贵**,契约 §9「阻塞读可能更便宜」**未兑现**;spawn server waits 少 8.9–12.9% 归因于 handoff 纪律而非门铃本身;唯一 socket 略差的列是 clipark/f(6.07 vs 5.60/5.75,每帧多约 0.4 次 park)。② `SEG_STAGE` 字节/帧(inproc,真实 trace retrace):OpenRA **130.8 KB median / 8.01 MB steady mean**(含启动负载 8.07 MB),rd12 **816.2 KB median / 1.53 MB steady mean**(含负载 10.79 MB)。③ 分块后记录/帧:OpenRA **211 median / 206 mean**,rd12 **7 497 median / 7 504 mean**;`maxrec=1808` 与分块前相同,默认 8 MiB chunk 预算在这两条负载上**从未被打到** |
`MEASUREMENTS.md:342` 的「分块后的逐 blob 字节分布尚无新测量」据此可关闭:门 8 第二轮给了
默认预算下的分布(`maxrec=1808`,稳态 `seg/wrec ≈ 109 B/条`),结论是**默认配置下该分布就是未分块的
原始分布**(分块在这两条负载上没有机会生效,由 `MOBILEGL_IPC_STAGE_MB=1` 的负控独立证明:seg 运行
总量两臂逐字节相同 2 698 026 763,`wrec` 仅 +0.11%)。
负控(各跑红一次):S1 不可解析镜像(sm)、S2 kill server(dl)、S3 未洗环境(sm)、S5 窗口 token
到达(cp)、S6 **未跑——`LinkTerms.dataPlane` 不存在于树上**(`protocol.fbs` 无 `LinkTerms` / `Refuse`,hs 只落了 build stamp、`abiMajor/Minor` 补检与真实 pid;此处此前写"已跑红一次"是错的,随契约 §2 三行移交 P6.5,契约 §10.3)、S7 裸 abort(dl 普查门)、S8 `SessionFaultCount()==0`
整轮(t6)。
### 2.1 收官前另欠(2026-09-22 核出)
### 2.1 出口门之外仍欠(2026-09-22 核出;出口门 1–8 本身已全部达成)
- server 进程侧的 `PipeStats` 半边是**结构性零**:`ServerMain` 不跑 `MobileGL::Initialize`,`PipeStats::Init()` 从未在 server 进程执行,~80 处 `if (Enabled())` 永远为假,`srv=0 srvpark=0` 长得和"从没等过"一模一样(`PipeStats.h:256` 自己写明了这一点)——门 8 的"socket 门铃 vs condvar"因此没有 server 半边。修法是在 server 进程里于 config 加载与角色声明之后初始化它,汇总行落 `<base>.server.log`,由车道断言而非注释假设(工作区中进行中,未提交)。
- server 进程侧的 `PipeStats` 半边是**结构性零**:`ServerMain` 不跑 `MobileGL::Initialize`,`PipeStats::Init()` 从未在 server 进程执行,~80 处 `if (Enabled())` 永远为假,`srv=0 srvpark=0` 长得和"从没等过"一模一样(`PipeStats.h:256` 自己写明了这一点)——门 8 的"socket 门铃 vs condvar"因此没有 server 半边。**已修(工作树未提交,见 §1.1)**:在 server 角色初始化的唯一收口点于 config 加载与角色声明之后加 `Init()`,`loop.Stop()` 之后加 `Shutdown()`,汇总行落 `<base>.server.log`;spawn server 汇总行 0 → 29 条,SSIM 1.0 不变。
- 184 符号棘轮 CI 门(契约 §12.1):`test.yml` / `scripts/` 里**没有**。
- 契约 §2 表 0 分给 hs 的 `LinkTerms` / `Refuse` / `wireFingerprint` 拆分:**未落地**,移交 P6.5(`ROADMAP.md` P6.5 行,契约 §10.3)。
- `MOBILEGL_IPC_IDLE_EXIT_S`(`ARCHITECTURE.md` §15.3 默认 30):未被解析。
@@ -80,6 +125,68 @@ trace APK 以 `-Pmobilegl.buildDisaggregated=ON` 构建,调试签名安装。
从 123 秒的错误 `BarrierTimeout` 变成 **5.7 秒**的正确 `DEVICE LOST`(`revents=0x2010` = POLLRDHUP|POLLHUP)——
仍红(驱动 bug),但红得诚实。
### 3.1 配对 A/B(门 7,2026-09-22)
reboot-clean + 同热窗口,臂 = `inproc` vs `spawn`(`monolith` 作基线行)。负载
`minecraft-1.21.4-rd12-odinlite-in-world` + `DirectGLES`(~1471 draws/帧),
`--benchmark-tail-frames 200`,主指标 = client 线程自身 `CLOCK_THREAD_CPUTIME_ID` 的稳态
p50 ms/帧。两轮会话,各自一个 boot id、各自一次 `adb reboot`(boot id 变化即为事实):
| 会话 | boot id | 臂序 | 重复 | 结果 | client CPU p50 ms/帧 |
|---|---|---|---|---|---|
| 1 | `e69d0329…` → `107f24d3…` | `monolith,inproc,spawn,inproc,monolith,spawn` | best-of-3 | 18/18 runner 退出 0、36/36 pin check `PINNED`、0 `Fatal{` | inproc **7.880 / 7.889**、spawn **7.876 / 7.869**(均值 **−0.15%**,符号随顺序翻转,臂内散布 **0.2–4.2%**),monolith **约贵 30%** |
| 2 | `107f24d3…` → `7e6c6e9f…` | `inproc,spawn,spawn,inproc` | best-of-2 | 4/4 OK、8/8 `PINNED`、0 `Fatal` | inproc **7.987 / 7.989**、spawn **7.992 / 8.009**(**+0.16%**,臂内散布 0.14–0.69%) |
**判读:tie。** 两个方向的差值都在臂内噪声之内,且两轮之间绝对值漂移(~0.1 ms)大于传输差本身。
按本阶段「性能只记录、不设门」的纪律,这组数**如实记录、不设门**,不写成"spawn 更快"也不写成
"spawn 更慢"。臂证明(契约 §9.5):每个 spawn 条目都记了子进程 pid 与 `transport=spawn`,
inproc 臂带 `Config: IPC` 行,两个 monolith 臂**既不**带 transport marker **也不**带 `Config: IPC` 行
(控制组 stayed a control)。逐臂 pin 证据、温度范围(会话 1:37.6–46.5 °C;会话 2:37.2–44.9 °C)
与全部原始行见 [`notes/p6/gate7-device-ab.md`](notes/p6/gate7-device-ab.md)、
[`notes/p6/gate8-doorbell-device.md`](notes/p6/gate8-doorbell-device.md)。
### 3.2 门 8① 的 socket 门铃 vs `inproc` condvar
| 半边 | inproc | spawn |
|---|---|---|
| client `cli` waits/帧 | **121.1**(run total 30 407) | **121.1**(run total 30 401),**两传输逐字相同** |
| client `clipark` parks/帧 | 5.60 / 5.75 | **6.07 / 6.08**(唯一 socket 略差的列,每帧多约 0.4 次 park) |
| server `srv` waits/帧 | 10 866–11 963 | 10 415–10 416(低 8.9–12.9%) |
| server `srvpark` parks/帧 | 5.0–6.2 | 4.66–4.90 |
| server park 率 | 0.042–0.06% | 0.042–0.047% |
**判读(诚实)**:socket 阻塞读**既不更便宜也不更贵**——client CPU 差 +0.16% 在噪声内,契约 §9
「阻塞读可能更便宜」**未兑现**(既未被证实也未被证伪,它该移动的那个可观测量没有向任何方向移动)。
spawn server waits 少 8.9–12.9% **归因于 handoff 纪律而非门铃本身**(两条臂统计的不是同一个事件,
可比的是 park 率,而 park 率两侧几乎相同)。这组数只记录、不设门。
**读法警告**(结构性零,不是测量):spawn 的 `srv` 对在 **server.log**、`cli` 对在 **client.log**;
client 行上的 `srv=0 srvpark=0` 是「另一个进程」而不是「从未等待」,server 行上的 `cli=0 clipark=0`
同理。spawn client **从不调 `OnPresent`** → 窗口字段 `frames=0 window=0` 无效,但 `PublishGauge` 是
store,所以 `cli`/`clipark` 的 **run total 有效**;spawn client 只有 `Shutdown` 终行(benchmark 模式)
或 0 行(snapshot 模式)。
### 3.3 门 8②③ 的 wire 计数器(`SEG_STAGE` 字节/帧、分块后记录/帧)
inproc、真实 trace retrace,`MOBILEGL_PIPE_STATS_PERIOD=1`:
| 负载 | `SEG_STAGE` 字节/帧 | 记录/帧 |
|---|---|---|
| OpenRA | **130.8 KB median** / 8.01 MB steady mean(含启动负载 8.07 MB) | **211 median** / 206 mean |
| rd12 in-world | **816.2 KB median** / 1.53 MB steady mean(含负载 10.79 MB) | **7 497 median** / 7 504 mean |
**framing 警告**:PERIOD=1 下首窗含启动负载(rd12 帧 1 有 981 654 draws vs 稳态 ~1 471),
run-wide mean 约 **7×** 稳态,所以两个读数都报,引用时必须说明口径。**`maxrec=1808` 与分块前相同**
——默认 8 MiB chunk 预算在这两条负载上**从未被打到**。负控 `MOBILEGL_IPC_STAGE_MB=1`(256 KiB 预算):
`seg` 运行总量两臂**逐字节相同**(2 698 026 763),`wrec` 仅 **+0.11%**——证明计数器测的是生产者而非切分;
同一 A/B 下 `tex` **+194%** **未解释**(`MOBILEGL_IPC_STAGE_MB` 同时是 arena 大小与 chunk 预算的来源,
未隔离到单变量),**记为证据、不作结论**。逐 blob 分布首测(integration 车道 JSON;retrace 车道拿不到
dump,因 snapshot 模式不走 `cleanUp()` → `MobileGL::Destroy()`):**70 records、8 blobs、bucket 3–12**。
**做与未做,分开写**:OpenRA 真机辅负载**未跑**(如实记「未跑」);
[`notes/p6/gate8-wire-counters.md`](notes/p6/gate8-wire-counters.md) 的 §6 数据全部来自 WSL + llvmpipe,
门 8②③ 是**桌面 inproc** 数,门 8① 的 server 半边另有真机重复(§3.2)。
---
## 4. 日志按角色分文件(本阶段的一处基础设施改动)
@@ -94,9 +201,25 @@ G1 守卫下 pull 构建不变。
## 5. 已知未决 / 明确不属于 P6
- **出口门 7、8**(真机配对 A/B、三个性能数)——待采集,见 §2。
- **`DirectVulkan` 真机分离路径分歧**——Magma × 真实 GPU,非 P6。
- **`DirectVulkan` 真机分离路径分歧**——Magma × 真实 GPU,非 P6。见 §3 与 `ROADMAP.md` P7 行。
- **P6.5**:传输栈两轴化(控制面 `ITransport` × 数据面 `ILink`,握手协商、可混搭)+ TCP 跨机 + wire 定宽与布局摘要;`StreamLink` 已声明未接线;`SEG_STAGE` 送窗口复活。终局已改为 TCP 跨机跨平台(2026-09-22),AF_VSOCK 撤回;见 `ROADMAP.md` P6.5 行、`ARCHITECTURE.md` §11.9。
- **Ph**:让 `Session::Fail` 可返回的策略翻转(需在每个依赖 `[[noreturn]]` 的站点造真实返回路径)+ 配对 / 认证;前置于 P12 的非 loopback 监听。
- **P12**:真窗口跨进程(`ANativeWindow*` 是客户端进程内指针,`SetWindowHandle` 至今具名拒绝)。
- **seq/op 未穿 SessionFault 帧**:90 个站点不逐一穿线,消息本身已带 op,帧只带 code/family/message。
两条**结构性限制**,随收尾一起生效、如实记录(不是缺陷但会绊住读者):
- **spawn client 无 `OnPresent`** → 它的窗口字段 `frames=0 window=0`,于是
`draws=` / `wrec=` / `bytes[...]` / `gates[...]` 与**所有 per-frame 字段**在 spawn client 上都印 0 或
`n/a`,**无效**;但 `PublishGauge` 是 **store 不是 add**,所以 `cli` / `clipark`、`maxrec` /
`maxcap` / `ringwraps` / `ringpads` / `ringwaits`,以及发射侧直接 `Add` 的
`resid` / `csob-blob` / `seg` / `wrec` 的 **run total 有效**(窗口归属只有最后一段)。要取 spawn 的
窗口化 client 数必须让 client 进程到达一次帧边界——那会与 backend 的帧边界重复计数,需单独判断,
不在本阶段。**逐 role 的取数规则**因此是:`srv` 对读 server.log、`cli` 对读 client.log,
spawn client 只有 `Shutdown` 终行(benchmark 模式)或 0 行(snapshot 模式)。
- **`MOBILEGL_PIPE_STATS_FILE` 已改角色派生**:dump 与 banner 都写/印
`<base>.client.json` / `<base>.server.json`,**基名不再指向任何文件**(与日志 sink 同一套
`RoleLogPath` 规则,刻意让没跟上的读取方当场 `ENOENT` 而不是半读通过)。inproc 只产出
`.client.json` 是**正确**的(服务端角色是线程不是进程,`Shutdown()` 只跑一次)。仓库内目前没有
这样的读者(`grep` 确认),但任何新写的车道/脚本都要读派生名。语义未变:仍是「在这里写 dump」,
只是「这里」由库按角色展开成两个名字。
+176 -1
View File
@@ -339,7 +339,7 @@ Espryt T1(`0x1fff` − pull)= +1076.1,T2(`0x1ff` − pull)= +1064.7,
max record bytes(x2,`37fc4fdb`):Triangle / persistent map / OpenRA 25 帧 / SmallRing 都是 **`maxrec=784 B` / `SetVertexAttribDefaults`**,默认 cap 4 MiB(占 0.019%)——这些负载不需要 chunking;oversized `DrawVbo` 尾的 unit 具名拒绝 `Fatal{RingOverrun, "DrawVbo"}`。
**内容分块落地(2026-09-20,`1e7c372e` buffer / `9469d48e` texture)**:`SEG_STAGE` 仍是"一条记录一个整 blob"的 arena,但会超出它的内容由发射侧按 stage chunk 预算 `MGPipeStageChunkBytes()` = `clamp(segment/4, 4096, segment)`(默认 32 MiB → 8 MiB)切成多条 `resource_subdata`——buffer 范围走查与纹理一级的整宽 slab(服务端 `StagedTextureStore::AdoptRun` 拼回整级)。§7.2 普查为容纳单次 128 MiB 上传而显式设的 `MOBILEGL_IPC_STAGE_MB=256` 因此不再是这两条路径的必需;未接入分片的 record 类型仍由 `Fatal{RingOverrun, "SEG_STAGE"}` 具名拒绝。上段的 `maxrec` 数字是分块前的测量,分块后的逐 blob 字节分布尚无新测量。
**内容分块落地(2026-09-20,`1e7c372e` buffer / `9469d48e` texture)**:`SEG_STAGE` 仍是"一条记录一个整 blob"的 arena,但会超出它的内容由发射侧按 stage chunk 预算 `MGPipeStageChunkBytes()` = `clamp(segment/4, 4096, segment)`(默认 32 MiB → 8 MiB)切成多条 `resource_subdata`——buffer 范围走查与纹理一级的整宽 slab(服务端 `StagedTextureStore::AdoptRun` 拼回整级)。§7.2 普查为容纳单次 128 MiB 上传而显式设的 `MOBILEGL_IPC_STAGE_MB=256` 因此不再是这两条路径的必需;未接入分片的 record 类型仍由 `Fatal{RingOverrun, "SEG_STAGE"}` 具名拒绝。上段的 `maxrec` 数字是分块前的测量,当时分块后的逐 blob 字节分布尚无新测量;该分布与分块后的 `maxrec` 已在 §12.4 补测——结论是默认 8 MiB chunk 预算在已测的两条负载上从未被打到,`maxrec` 仍是 1808。
### 6.4 Redmi 四臂 A/B(split 未测)
@@ -597,3 +597,178 @@ client CPU 15.72 vs 16.19),差值远在 monolith 自身的离散度之内—
**下一个机会,不是缺陷**:apply 线程只有 11.1-12.1 ms,client 有 15.7 ms——**两侧不平衡**。把工作从 client
挪到 apply 会直接降低瓶颈。这在 lockstep 下毫无意义(两侧串行相加),run-ahead 之后才成立,是本阶段
解锁出来的新优化方向,留给后续阶段。
## 12. P6 出口测量(2026-09-22,Redmi `2f7cbe2e` + WSL 桌面)
P6(spawn transport)的实现包(a6 / c6 / lk / so / sm / cp / hs / dl / st / t6)已全部落地,出口门 1–6 的逐项证据见 `CURRENT_STAGE_PROGRESS.md`;本节记录门 7(真机配对 A/B 热窗口)与门 8(三个强制性能数)在 2026-09-22 采齐的实测,以及支撑它们的收尾代码与工具。按本文件既有口径,下面所有性能数**只记录、不设门**:门 7 的判读是 tie,门 8 的三个数不带阈值。
### 12.1 协议(本节两个设备会话共同遵守)
| 项 | 值 |
|---|---|
| 设备 | Redmi `M332BF`,serial `2f7cbe2e`,SM8750 / Adreno 830v2 |
| reboot-clean | `adb reboot` + `sys.boot_completed=1`,**boot id 前后比对**(id 变了才算数,见 §12.2) |
| CPU 定频 | little `policy0` → 1555200,big `policy6` → 1958400(`pin_device.sh 2f7cbe2e pin`;每臂前 `pin`、后 `check`,非 `PINNED` 即作废该臂,不是降级) |
| GPU 钉频 | kgsl `min/max_pwrlevel = 0` → **1050000000 Hz**(可比性警告见 §12.7) |
| 风扇 | `/sys/class/xm_power/hw_monitor/pwm_fan` `target_level=2` |
| 同热窗口臂交错 | 每个会话都在**一次 boot 内的唯一热窗口**里按给定顺序交错跑完,臂间有冷却 |
| 负载 | `minecraft-1.21.4-rd12-odinlite-in-world`,DirectGLES,251 帧,**1471.36 draws/帧** |
| 跑法 | `--benchmark-repeats 3`(门 7)/ `2`(门 8),`--benchmark-tail-frames 200`,`--benchmark-no-finish`,best-of-3 / best-of-2 |
| split 开关 | `MOBILEGL_IPC_ROLE_SPLIT_STATE=1`、`MOBILEGL_IPC_STRICT_ERRORS=1`、`MOBILEGL_IPC_RUN_AHEAD=1` |
| stats | `MOBILEGL_PIPE_STATS=1`、`MOBILEGL_PIPE_STATS_PERIOD=120` |
| 源码头 | 门 7 会话 `98d0b96c`;门 8 两会话 `71aa9951` + §12.6 的收尾改动(工作树) |
| 臂的证明 | 每个 spawn 臂必须在**自己的**私有日志里出现 `Config: MOBILEGL_TRANSPORT=spawn` 与 `spawn ARMED - the server role runs in pid N`;`--transport` 在 `--benchmark` 模式下被 runner 忽略(会静默跑成 monolith),所以传输一律用显式 `--env MOBILEGL_TRANSPORT=…`,跑完按库自己的日志核对 |
**主指标 = client GL 线程自身的逐线程 CPU ms/帧**(§9 的规则:内核对本 app 的 `sched_setaffinity` 请求不生效,进程总量会把两个时钟域混在一起),由设备在**尾 200 帧**上汇总;`fps` 与 wall p50/p95 并列记录,不作主指标。
**帧分母取自哪条日志。** `frames=` 由 `PipeStats::OnPresent` 递增,而它是 backend 的帧边界:`spawn` 下 present 在 **server 进程**被应用,所以 spawn **client 的 `frames=0 window=0`**——它一个窗口都不推进。因此费率一律除以**真正数到帧的那条日志**的 `frames=`(inproc 取 client log,spawn 取 server log,两者都是 251),**不得**除以同一行的 `window=`,那会把费率抬高 run/window(本负载约 21×),得到一个看似合理而错的数。同理:
- spawn client 行里的 `srv=0 srvpark=0` 与 spawn server 行里的 `cli=0 clipark=0` 都是**「另一个进程」的零,不是「从未等待」**;`srv`/`srvpark` 由 apply 线程发布进**它自己进程**的 PipeStats,`cli`/`clipark` 由 client 的发射器发布进 client 进程。
- spawn client 的 `cli`/`clipark` 仍然可信,因为 `EmitPresent` 走 `PublishGauge`(**store 不是 add**),run total 与窗口无关;而它的窗口化字段(`draws=`、`wrec=`、`bytes[...]`、`gates[...]` 及所有 `*/f`)在同一次运行里**无效**。
- spawn client 整轮只有 `Shutdown` 的**一条终行**(benchmark 模式)或**一条都没有**(snapshot 模式:该模式在快照完成后抛出退出,跳过 `retrace::cleanUp()` → `MobileGL::Destroy()` → `PipeStats::Shutdown()`)。
- `* total ms` 那类**每角色运行总量**包含第 1 帧的 shader 编译(本 fixture 的帧 1 花 **7332 ms** client CPU,其余 250 帧各 7.7–8.5 ms),**不是**每帧率,不得除以 251;它跨臂差 4.5–6.7% 与传输无关,这正是主指标取「尾 200 帧的逐帧值」的原因。
### 12.2 门 7:配对 A/B(两会话)
**会话 1**(boot id `e69d0329-2264-4a2d-9b00-e1a4ffb6154d` → `107f24d3-b1f1-4a97-ab8c-9706d1901f32`):六臂交错 `monolith, inproc, spawn, inproc, monolith, spawn` × best-of-3,**18/18 runner 退出 0、36/36 pin check `PINNED`、0 `Fatal{`**,12 次 pin check 的 `cpuss-0-0` 温度 37.6–46.5 °C。
| 臂 | 顺序 | wall p50 ms | **client CPU p50 ms/帧** | fps | 臂内散布 |
|---|---|---:|---:|---:|---:|
| monolith (a) | 1 | 11.320 | **11.231** | 86.32 | 1.0% |
| inproc (a) | 2 | 7.971 | **7.880** | 106.04 | 0.3% |
| spawn (a) | 3 | 7.989 | **7.876** | 106.12 | 2.2% |
| inproc (b) | 4 | 8.001 | **7.889** | 105.78 | 0.2% |
| monolith (b) | 5 | 11.287 | **11.240** | 86.26 | 1.3% |
| spawn (b) | 6 | 7.979 | **7.869** | 105.95 | 4.2% |
**会话 2**(boot id `107f24d3-b1f1-4a97-ab8c-9706d1901f32` → `7e6c6e9f-1894-4916-bf39-cce48c9abd37`):只跑 inproc / spawn 交错 `inproc, spawn, spawn, inproc` × best-of-2(聚焦门 8 ①,见 §12.3),**4/4 runner 退出 0、8/8 pin check `PINNED`、0 `Fatal{`**,温度 37.2–44.9 °C。
| 臂 | 顺序 | wall p50 ms | **client CPU p50 ms/帧** | fps | 臂内散布 |
|---|---|---:|---:|---:|---:|
| inproc | 1 | 8.105 | **7.987** | 105.72 | 0.14% |
| spawn | 2 | 8.116 | **7.992** | 104.43 | 0.34% |
| spawn | 3 | 8.113 | **8.009** | 104.19 | 0.26% |
| inproc | 4 | 8.089 | **7.989** | 103.72 | 0.69% |
**配对差(spawn − inproc)**,两个顺序都列,不挑好看的那个:
| 会话 | 指标 | inproc (a / b) | spawn (a / b) | Δ(均值) | Δ% |
|---|---|:---|:---|---:|---:|
| 1(best-of-3) | **client CPU p50 ms/帧** | 7.880 / 7.889 | 7.876 / 7.869 | **−0.012** | **−0.15%** |
| 1 | wall p50 ms | 7.971 / 8.001 | 7.989 / 7.979 | −0.002 | −0.03% |
| 1 | fps | 106.04 / 105.78 | 106.12 / 105.95 | +0.125 | +0.12% |
| 2(best-of-2) | **client CPU p50 ms/帧** | 7.987 / 7.989 | 7.992 / 8.009 | **+0.0125** | **+0.16%** |
**monolith 基线行**(不属配对,按协议记录):
| 比较 | client CPU p50 ms/帧 | Δ | fps | Δ |
|---|---|---:|---|---:|
| monolith → inproc | 11.236 → 7.885 | **−29.8%** | 86.29 → 105.91 | +22.7% |
| monolith → spawn | 11.236 → 7.873 | **−29.9%** | 86.29 → 106.04 | +22.9% |
**判读:tie。** 会话 1 的差值(−0.15%)落在臂内散布 0.2–4.2% 之内,且 wall 列的**符号随顺序翻转**(顺序 (a) inproc 领先 0.03%、顺序 (b) spawn 领先 0.03%),这是噪声的特征而不是效应的特征;四个 inproc / spawn 臂的 client CPU p50 都落在 7.87–7.95。会话 2 在同一负载、不同构建与不同 boot 上复现了这个 tie(+0.16%,臂内散布 0.14–0.69%,差值比它取自的臂内散布低一个数量级)。两会话之间移动的是绝对水平(约 0.1 ms),那是运行间漂移,不是传输效应。**两条 transport 在 client 每帧 CPU 上不可区分,两者都比 monolith 约便宜 30%**——spawn 是通过 socket 拿到这个数的,**这条负载的分辨率看不见 socket 门铃的代价**。性能只记录,**不设门**。
### 12.3 门 8① socket 门铃 vs inproc condvar
四个计数器:`srv` / `srvpark` = apply 线程进入 `Doorbell::Wait` 的次数 / 其中停止自旋转为阻塞的次数;`cli` / `clipark` = client 生产者的同一对。**两对都是运行总量**,所以费率除以该轮运行的 `frames=251`,**不是**同一行的 `window=`(见 §12.1)。
**会话 2 的完整账**(这次 spawn 的 server 半边也在——见 §12.6 的修复):
| 臂 | 传输 | counters 取自 | frames 取自 | `cli frames=` | `srv frames=` | srv | srvpark | cli | clipark | **srv/f** | **srvpark/f** | **cli/f** | **clipark/f** |
|---|---|---|---|---:|---:|---:|---:|---:|---:|---:|---:|---:|---:|
| 01 | inproc | client log(同进程两角色一行) | client log | 251 | 240 | 2 869 874 | 1 341 | 30 407 | 1 444 | **11433.8** | **5.343** | **121.1** | **5.753** |
| 02 | spawn | client(cli) + server(srv) | server log | 0 | 251 | 2 614 177 | 1 229 | 30 401 | 1 524 | **10415.0** | **4.896** | **121.1** | **6.072** |
| 03 | spawn | client(cli) + server(srv) | server log | 0 | 251 | 2 614 456 | 1 170 | 30 401 | 1 525 | **10416.2** | **4.661** | **121.1** | **6.076** |
| 04 | inproc | client log | client log | 251 | 240 | 3 002 771 | 1 265 | 30 407 | 1 406 | **11963.2** | **5.040** | **121.1** | **5.602** |
**会话 1 的账**(同一个窗口里的另一轮,spawn 的 server 半边当时**采不到**):
| 臂 | 传输 | srv | srvpark | cli | clipark | **srv/f** | **srvpark/f** | **cli/f** | **clipark/f** |
|---|---|---:|---:|---:|---:|---:|---:|---:|---:|
| inproc (a) | inproc | 2 738 247 | 1 543 | 30 407 | 1 548 | **10909.4** | **6.148** | **121.1** | **6.169** |
| spawn (a) | spawn | **不可得** | **不可得** | 30 401 | 1 538 | — | — | **121.1** | **6.127** |
| spawn (b) | spawn | **不可得** | **不可得** | 30 401 | 1 530 | — | — | **121.1** | **6.096** |
| inproc (b) | inproc | 2 727 377 | 1 281 | 30 407 | 1 399 | **10866.0** | **5.103** | **121.1** | **5.574** |
会话 1 的 spawn 半边不可得是**结构性**的、不是漏采:spawn server 进程当时从不跑 `PipeStats::Init()`(它只从 `MobileGL::Initialize()` 调用,而 spawn server 走的是 `MG_ConfigLoader::Init()` + `MG_Backend::InitServerRoleForSpawn()`),于是该进程里所有 `PipeStats::Enabled()` 门恒为 false,**一条汇总行都没有**——不是携带 0,是整行不存在。client 日志里的 `srv=0 srvpark=0` 因此是「另一个进程」的零。**该缺陷已在工作树修好**(§12.6),会话 2 就是修后的复采。
**逐列读法:**
- **`srv/f`(server 会合率)**:inproc `10 866–11 963`,spawn `10 415–10 416`;折算成每 draw,spawn 两臂各 **7.1**,inproc 两臂 **7.8 / 8.1**(1471.36 draws/帧;会话 1 的 inproc 两臂相应是 **7.4**)。spawn 比 inproc **低 8.9–12.9%**,但这不是传输的胜利:两个计数器数的**不是同一个事件**——inproc 下 apply 线程的 `Doorbell::Wait` 同时是同地址空间内发布 client 队列排空的机制、client 在 verb barrier 后面大段同步,spawn 下同一个循环是被描述符就绪唤醒;每帧两个角色必须达成一致的次数是**handoff 纪律**的性质,不是门铃的代价。可比的是 park 比例,而它两边几乎相同。
- **`srvpark/f`(会合真的阻塞了多少)**:会话 2 inproc 5.343 / 5.040、spawn 4.896 / 4.661;会话 1 inproc 6.148 / 5.103(spawn 不可得)。两个会话合起来即**服务端 5.0–6.2 parks/帧、其中只有 0.042–0.06% 的等待进入阻塞**(会话 2 四臂逐臂 1 341/2 869 874 = 0.047%、1 229/2 614 177 = 0.047%、1 170/2 614 456 = 0.045%、1 265/3 002 771 = 0.042%;并入会话 1 的 inproc 两臂后上界到 0.056%)。自旋预算在两条传输上几乎覆盖全部会合——P5d/P5e 的结论在这里再次成立:代价是**会合本身**,不是唤醒。
- **`cli/f`(client 自己的等待)**:**四个臂上都是 121.1**(会话 1 的四个臂也一样,运行总量 30 401–30 407 次),逐字到十分位。client 两种传输做同样的工作,传输不改变它**多久**等一次。
- **`clipark/f`(client 的 park)**:**唯一 socket 略差的列**——会话 2 spawn 6.07(6.072 / 6.076)对 inproc 5.75 / 5.60,每帧多约 **0.4 次 park**(~121 次等待里的 0.4 次)。client 等待的 **4.6–5.1%** 会 park,比服务端高两个数量级,因为 client 的自旋预算花在一个经常还没到的生产者上。
- **tie 判读**:client CPU 差 **+0.16%**(会话 2)落在 0.14–0.69% 的臂内散布内,会话 1 是 −0.15%;契约 §9 那条注记(「把 ~1600 spins/帧 转成 ~4 次阻塞读,**可能更便宜**」)的观测对象正是 client CPU ms/帧,而它**两个方向都没动**。**结论是诚实的:socket 阻塞读既不更便宜、也不更贵**——该注记的「更便宜」未兑现。注记里的 1600 spins/帧 是更重场景的数,本机 rd12 的参照是服务端 ~10 400–12 000 次会合/帧、client 121 次/帧;park 比例 0.042–0.06%(服务端)与 4.6–5.1%(client)也说明本场景的自旋几乎全都奏效。
- 两条传输**都比 monolith 约便宜 30%**(§12.2),spawn 走的是一条 socket 而不是共享地址空间。
### 12.4 门 8②③:`SEG_STAGE` 字节/帧、分块后记录/帧、`maxrec` 与逐 blob 分布
口径:`MOBILEGL_PIPE_STATS=1` + `MOBILEGL_PIPE_STATS_PERIOD=1`(每个 `eglSwapBuffers` 一行,`window=1` 恰一帧),车道 = inproc + `MOBILEGL_IPC_ROLE_SPLIT_STATE=1` + `MOBILEGL_IPC_STRICT_ERRORS=1` + `MOBILEGL_IPC_RUN_AHEAD=1`,后端 DirectGLES,真实 trace retrace。两个负载:**OpenRA**(`MobileGLTraceReplay.OpenRA.DirectGLES.SPLIT`,SSIM **1.000000**)与 **rd12-odinlite-in-world**(无 SPLIT ctest 条目,按同一命令行手工驱动,SSIM **0.999998**)。新增的两个计数器都落在**唯一的收口点**上:`SEG_STAGE` 字节记在 stage 分配器(`MobileGL/MG_Remote/Wire/PipeWireCodec.cpp:878`,`StageAllocate`),记录数记在记录提交路径(`MobileGL/MG_Remote/Wire/PipeWireCodec.cpp:1188`,`EncodeRecord` 成功路径)——所以覆盖是构造性的,不是一张「今天有哪些 producer」的名单。(两处行号取 §12.6 收尾改动后的工作树内容。)这两个数在 **inproc 车道**采集;spawn 下 client 的字节 / 记录类同样在采(它们由 client 的发射器 Add,run total 可信),但 spawn 的**服务端**字节 / 记录类要等 §12.6 的 `ServerMain.cpp` 修复之后才存在。
**framing 警告(两个读数都报)**:`PERIOD=1` 下**第一行覆盖整个启动 / 加载窗**——rd12 的帧 1 有 **981 654 draws**、5 053 895 条记录、`seg=2 317 939 359`,而其余每帧只有约 1 471 draws。所以「窗口求和 / 帧数」是**含加载的均值**;报这个数时必须说清是哪个口径。rd12 的加载窗把 run-wide 均值抬到稳态均值的**约 7 倍**(10.79 vs 1.53 MB/帧);OpenRA 两个值接近(8.07 vs 8.01 MB/帧)是因为它的加载窗与稳态窗同量级。
| 口径 | OpenRA | rd12 in-world |
|---|---:|---:|
| **`SEG_STAGE` 字节/帧——稳态中位数** | **130.8 KB/帧**(130 848 B) | **816.2 KB/帧**(816 248 B) |
| **`SEG_STAGE` 字节/帧——稳态均值**(丢弃前 10% 窗) | **8.01 MB/帧**(8 012 890 B) | **1.53 MB/帧**(1 534 555 B) |
| `SEG_STAGE` 字节/帧——含启动负载的均值 | 8.07 MB/帧(8 068 255 B) | 10.79 MB/帧(10 792 107 B) |
| **记录/帧(分块后)——稳态中位数** | **211** | **7 497** |
| **记录/帧(分块后)——稳态均值** | **206** | **7 504** |
| 记录/帧——含启动负载的均值 | 196.8 | 27 689.5 |
| 参考:`draws` 稳态中位数 | 30 | 1 471 |
| `maxrec` / `maxcap`(运行总量) | 1808 B / 4 194 304 B(0.043%) | 1808 B / 4 194 304 B(0.043%) |
| 运行总量 `seg` / `wrec`(帧数) | 233 979 394 / 5 706(29 帧) | 2 698 026 763 / 6 922 378(250 帧) |
| `ringwraps` / `ringpads` / `ringwaits` | 0 / 0 / 0 | 61 / 53 / 0 |
读法:
- **两个负载的稳态形状完全不同,这正是必须分开报的理由**:OpenRA 稳态的 `tex` 中位数是 32 768 B 而均值 5.6 MB(少数窗有巨大的纹理上传尖峰),`seg` 的中位数 / 均值因此差 **61×**;rd12 稳态的 `tex` 中位数为 0(多数帧不上传纹理),差 **1.9×**。只报一个数就会掩盖另一个负载的形状。按本文件同类数字的惯例,稳态中位数与稳态均值这对都列出来。
- **`maxrec=1808` 与分块前完全相同,原因不是「分块没生效」而是「分块没有机会生效」**:默认 8 MiB chunk 预算(`MGPipeStageChunkBytes()` = `clamp(segment/4, 4096, segment)`,默认 32 MiB → 8 MiB)在这两条负载上从未被打到,所有 blob 都是原始尺寸。稳态 `seg/wrec ≈ 816 248/7 497 ≈ 109 B/条`,与全局的 390 B/条都在字节级别。§6.3 记的分块前 `maxrec=1808` / `SetVertexAttribDefaults` 因此原样成立;`ringwaits=0`(250 帧一次都没等过 retiredSeq)与 rd12 稳态 `seg` 中位数对 32 MiB 的 `SEG_STAGE` 约 40 帧余量一致。
- **`wrec` 是窗口计数**(`PERIOD=1` 下每窗恰一帧,所以 `wrec` 与 `wrec/f` 同值),且 `PERIOD=1` 每帧印一行 INFO 本身有可观开销——**这些帧数不能当性能数**,只用于按帧摊平字节与记录。
- **交叉核对(能排除什么、不能排除什么)**:`seg` 记 wire 写进 `SEG_STAGE` 的字节,后端的 `buf`/`tex`/`csob-blob` 记往驱动搬的字节,两个总体不同、不要求相等,但应在同一量级且同步起伏。逐窗(250 窗)比对:`seg` 中位数 818 030 / 均值 10 792 107,`parts`(= buf+tex+csob-blob)中位数 291 840 / 均值 3 978 204,均值比值 2.713,每条记录 389.8 B;两者在同一些窗一起抬升、同一些窗一起落底,没有出现「一个动另一个不动」的窗。比值 > 1 有解释(`seg` 稳态恒有 `csob-blob ≈ 281 KB/帧` 的 program archive 只走 wire 不经后端字节类,加上 persistent-map 推送与 tail 字节)。这**不能证明覆盖完整**(没有第三方真值),能排除的是「`seg` 明显偏离同一批字节」这一整类错误。
- **逐 blob 字节分布的首次测量**:答案是「默认配置下该分布就是未分块的原始分布」。取数车道是 **integration**(`ctest -R "^DirectGLES\.(Arena|Ssbo|Buffer|Atomic|Readback)"`,19/19 通过),经 `MOBILEGL_PIPE_STATS_FILE` 的 JSON dump——**retrace 车道拿不到 dump**,因为 snapshot 模式在快照完成后抛出退出、跳过了 `retrace::cleanUp()` → `MobileGL::Destroy()` → `PipeStats::Shutdown()`(§12.1),所以 dump 只能在 benchmark 模式或 integration 车道取。该 dump:**70 records**、`seg=5768 B`,直方图复用了 draw payload 的桶边界(bucket 0 = 0 字节,bucket n>0 = [2^(n-1), 2^n))。**这里有两个不同的计数,必须写清,否则会被读成同一个数**:**非空 bucket 数 = 8**(落在 bucket 3–12),**blob 条数 = 12**;也就是说「**8 blobs / bucket 3–12**」这句简写里的 8 指的是**有内容的桶数**,而逐桶计数合计是 12 条 blob。逐桶:bucket 3 [4,8) 4 条、4 [8,16) 1 条、5 [16,32) 2 条、7 [64,128) 1 条、9 [256,512) 1 条、10 [512,1024) 1 条、11 [1024,2048) 1 条、12 [2048,4096) 1 条(4+1+2+1+1+1+1+1 = 12)。这批 integration 用例的 `frames=0`(跑 swap 但不走 Present 计数),所以窗口字段无意义,分布本身是**运行总量**(分布没有「窗口」可言)。
### 12.5 负控:`MOBILEGL_IPC_STAGE_MB=1`(256 KiB chunk 预算)
要让分块真正发生,唯一的办法是把 stage 预算人为压小;同一条 rd12 trace、同一个库、只改这一个环境变量的 A/B:
| rd12 in-world,inproc split | `STAGE_MB=32`(chunk 8 MiB) | `STAGE_MB=1`(chunk 256 KiB) | 变化 |
|---|---:|---:|---|
| SSIM | 0.999998213 | 0.999998213 | 不变 |
| **`seg` 运行总量** | **2 698 026 763** | **2 698 026 763** | **逐字节完全一致** |
| `seg` 稳态中位数 | 816 248 | 554 104 | −32% |
| **`wrec` 运行总量** | 6 922 378 | 6 929 736 | +7 358(**+0.11%**) |
| `wrec` 稳态中位数 / 均值 | 7 497 / 7 504 | 7 497 / 7 508 | +0.05% |
| `tex` 运行总量 | 641 567 296 | 1 886 392 128 | **+194%** |
| `buf` / `csob-blob` / `draws` | 见 §12.4 | 与 `STAGE_MB=32` 逐项相同 | 不变 |
| `maxrec` / `maxcap` / `ringwraps` / `ringpads` | 1808 / 4 194 304 / 61 / 53 | 同左 | 不变 |
这是**它自己那个问题的负结果,如实记录**:
- **`seg` 运行总量两侧逐字节相同**说明该计数器测的是**生产者交出来的内容**,与发射侧怎么切、arena 开多大**完全无关**——这正是把落点放在唯一收口点的直接证据:若记在 emitter 侧或记成 arena 占用,这一行就会跟着预算动。
- **稳态中位数 −32% 与运行总量不变同时成立**:同样的总字节被**重新分到不同的窗口**(预算变小后,原本一次完成的上传被切成多次、跨帧重传),所以**逐窗归属会动、总量不会**。读数应以运行总量或明确声明的窗口口径比较,**不要**拿两套配置的单窗中位数直接对比。
- **`wrec` 只涨 0.11%**(+7 358 条 / 250 帧 ≈ +29 条/帧,稳态中位数两侧相同)说明这条 trace 的内容本来就没有超过 256 KiB 的 blob,即使预算压到 256 KiB 也几乎无可切;`maxrec` 仍是 1808,第三次确认(`maxrec` 是单条**记录**的字节上界,blob 被切不改变它)。
- **`tex` +194% 是这次 A/B 真正的发现,但不作结论**:`MOBILEGL_IPC_STAGE_MB` 同时是 **arena 大小**与 chunk 预算的来源,压小它改变了纹理 slab 的切分与重传行为(整宽 slab 被预算切碎后每片各自重传)。这一项**不在门 8 的定义里**,且**没有隔离到单变量**(arena 大小与 chunk 预算被同一个环境变量绑在一起),因此只作为 A/B 的可解释性证据记录,**不作结论**;要分开这两个效应需要把二者拆成可独立配置的量,那是另一个包的事。
- 因此门 8 的「chunking 后记录/帧」取**默认配置**的 §12.4 值。
### 12.6 支撑本节的收尾代码与工具(工作树,未提交)
| 改动 | 位置 | 内容与证据 |
|---|---|---|
| 新计数器 `ByteClass::StageSegmentBytes` | `MobileGL/MG_Remote/Wire/PipeWireCodec.cpp:878`(`StageAllocate`) | stage 分配器的**唯一收口**;摘要行 `bytes/f[...]` 里印 `seg=` |
| 新计数器 `CallClass::WireRecords` | `MobileGL/MG_Remote/Wire/PipeWireCodec.cpp:1188`(`EncodeRecord` 提交路径) | 记录提交路径的**唯一收口**(`Reserve` 成功、正文写完、blob 槽诚实性检查之后、`++m_emitSeq` 之前);摘要行印 `wrec=` 与 `wrec/f=`,与 `draws/f=` 同形、按 `perFrame` 规则联动 |
| staged-blob 直方图 | `MobileGL/MG_Util/Metrics/PipeStats.{h,cpp}` | 复用 `PayloadBucketOf` 的桶边界,**运行总量**,经 JSON dump 出口(Tracy 画不了分布,与既有 `cmd-bytes-per-draw-histogram` 的选择一致) |
| spawn server 进程从未武装计数器 | `MobileGL/MG_Remote/Server/ServerMain.cpp`,`PipeStats::Init()` 加在 `MG_ConfigLoader::Init()` 与 `MGPipeSetServerProcessRole(true)` **之后**,配一行 `Shutdown()` 在 `loop.Stop()` 之后、`_exit(0)` 之前 | **红一次**:修复前 spawn server 在 `mobilegl.server.log` 里 **0 条**汇总行,修复后 **29 条**;SSIM 1.0 不变。三处顺序都是承重的:`Init()` 从 config latch 该开关(必须晚于配置解析)、晚于定角色(让汇总行进 server 角色日志)、`Shutdown()` 晚于 `loop.Stop()`(apply 线程正是发布 `srv`/`srvpark` 的线程) |
| `MOBILEGL_PIPE_STATS_FILE` 的角色分路 | `MobileGL/MG_Util/{Metrics/PipeStats.cpp,Debug/Log.{h,cpp}}` | 走 `RoleLogPath` **同一套导出规则**派生成 `<base>.client.json` / `<base>.server.json`;**基名不再指向任何文件**(没改的读取方会当场 `ENOENT`,这是刻意的)。G1 上这里红过三次(无条件派生 → `.text +24 B`;两臂共用一个局部 → 1 symbol resized;重写 `MGLOG_W` 格式串 → `.rodata +64 B`),最终形态是 pull 臂的代码**逐语句原样**、所有新增都在 `#if` 之内 |
| 测试 | `MobileGL/MG_Test/Util/PipeStatsTest.cpp` 等 | PipeStats **25/25**;全量 unit **2367/2367**;spawn integration **102/102**;**G1 符号 0/0/0/0 且 `.text` 逐字节相同**(强于门要求的 `+0`),pull 构建零 `MG_Remote` 符号、零新计数器符号 |
| 设备 A/B 工具入库 | `tools/device_bench/p6/{ab_session.py,render_ab.py,README.md}` | 采集(reboot / pin / 按序跑臂 / 逐线程 CPU 采样 / 按角色拉日志 / 臂的证明)与渲染(`session-summary.json` → 表格)分离,渲染器只读采集结果,不能影响采集;`spawn` 的两个结构性零(`srv` 与 `frames` 在 server 日志)由渲染器处理并**在表里标明取自哪条日志** |
| runner 的包名覆盖 | `tools/trace_replay/run_android_retrace_local.py`(+19 行) | `MOBILEGL_TRACE_PACKAGE`;一台机器上已有另一个 key 签的 trace 安装(两个不同 key 签的 APK 不能共用一个 id),所以门 7 / 门 8 的 APK 装成 `top.mobilegl.plugin.p6gate7.trace` / `.p6gate8.trace` 并与既有安装并存,runner 的包名常量由此覆盖;既有安装原样保留 |
| `pin_device.sh` 的 `2f7cbe2e` 行 | `tools/device_bench/pin_device.sh`(+28 行) | 定频路径 + kgsl 节点 + GPU 钳 1050 MHz 的实测值(§12.7);三个动作(`check` / `pin` / `unpin`)都实跑过,pin 在负载下保持、在 idle 下 90 s 稳定,stock 值是硬编码而非现场采样(否则会把已钉的状态读成 stock 并永久钉住) |
### 12.7 不可比项与未跑项
- **本板 GPU 钳 1050 MHz。** `pin_device.sh` 的 `2f7cbe2e` 行把 kgsl `min/max_pwrlevel` 钉 0,但本板实际拿到的是 **1050000000 Hz(1050 MHz)**:`thermal_pwrlevel` 恒为 1(写 0 返回成功、读回仍是 1),`max_gpuclk` 只读且为 1050000000,devfreq 的 `max_freq` 也把 1100000000 的写夹下来。所以这一行记的是「**本板真正能跑到的最高频**」,不是顶端 OPP,`check` 对着真相比对。**后果**:本节的绝对时间与 **1100 MHz 时代**的 `35d0befa` 行(§3.2 / §4.4)**不可比**,与 §7.4 那一轮 Redmi 的 1100 MHz 口径也不可比——钟频不同,**只有同场配对可比**,本节的配对结论也只在这个意义下成立。
- **OpenRA 真机辅负载未跑。** 门 7 / 门 8 的真机会话只跑 `minecraft-1.21.4-rd12-odinlite-in-world`,OpenRA 只在 WSL 桌面 llvmpipe 的 retrace 车道跑过(§12.4 的两个数之一)。**真机 OpenRA 未跑**,如实记录。
- **门 8① 的两种门铃只在一条负载、一台设备上比过**,且参照注记里的 1600 spins/帧 属于更重的场景;若存在差异,应在每帧会合次数更多的负载上才看得见。
- **spawn client 的窗口化字段仍不可信**,而且这一项是**结构**不是缺陷:要让 spawn client 推进窗口,得让 client 进程到达一次 `PipeStats::OnPresent`,而 client 的帧边界在 spawn 下是 `EmitPresent`(每条 present 记录一次)——接上去会与 backend 的帧边界重复计数。**不要顺手接**。
- **`MOBILEGL_IPC_STAGE_MB` 同时是 arena 大小与 chunk 预算的来源**,所以分块的净效果没有被单变量隔离(§12.5 的 `tex` +194%)。
File diff suppressed because one or more lines are too long
@@ -0,0 +1,122 @@
# docs-closeout-measurements — P6 收官事实写入 `MEASUREMENTS.md`
> 负责文档:`docs/Disaggregated/MEASUREMENTS.md`(唯一被改的目标文件)。
> 报告文件:本文件。任务书要求「只改你负责的那一份文档,另外两份由并行代理负责」——
> 另一个代理的文档(`CURRENT_STAGE_PROGRESS.md`)与第三份**没有被触碰**。
> 工作树:`feat/disaggregated`,**未 `git commit`**,未动任何既有未提交改动。
> **头号勘误**:任务书写的分支头是 `71aa9951`,而写报告时 `git rev-parse HEAD` 读到的是
> `2bd86664`(`71aa9951` 是它的祖先,另有 `2d86e07a` 一个提交夹在中间,两者都是 P6.5 的文档/传输
> 提交,不涉及本次改动)。任务书同时把 `71aa9951` 标为门 8 的源码头——本文件里的源码头就是这样记的,
> 与分支头无关。
## 0 一句话
`MEASUREMENTS.md` 新增 **§12「P6 出口测量(2026-09-22,Redmi `2f7cbe2e` + WSL 桌面)」**(7 个子节:
12.1 协议 / 12.2 门 7 / 12.3 门 8① / 12.4 门 8②③ / 12.5 负控 / 12.6 收尾代码与工具 / 12.7 不可比项与未跑项),
`git diff --numstat` = **176 行新增 / 1 行替换**(文件 599 → 774 行);并把 §6.3 末尾
「分块后的逐 blob 字节分布尚无新测量」一句**在保留历史语境的前提下**改为指向 §12.4。
除此之外全文逐字未动。本代理只写了两个文件:`MEASUREMENTS.md` 与本报告;
`CURRENT_STAGE_PROGRESS.md` / `ROADMAP.md` 的 `M` 状态由并行代理产生,**不是本代理改的**。
## 1 改动的段落(逐处)
| # | 位置 | 改动 | 为什么 |
|---|---|---|---|
| 1 | §6.3 末句(原 `:342`) | `上段的 maxrec 数字是分块前的测量,分块后的逐 blob 字节分布尚无新测量。` → `…当时分块后的逐 blob 字节分布尚无新测量;该分布与分块后的 maxrec 已在 §12.4 补测——结论是默认 8 MiB chunk 预算在已测的两条负载上从未被打到,maxrec 仍是 1808。` | 任务 (2)。**历史语境保留**(「当时…尚无」),只追加指向新节的结论;该句是 `CONTRACT-P6.md` §9 item 8 与 `CURRENT_STAGE_PROGRESS.md` 引用「分块后分布不存在」的那个锚点,所以不能删掉原陈述 |
| 2 | 文件末尾 | 新增 `## 12. P6 出口测量(2026-09-22,Redmi 2f7cbe2e + WSL 桌面)` | 任务 (1) |
## 2 §12 的内容结构
| 子节 | 内容 |
|---|---|
| §12.1 协议 | 设备 / reboot-clean(boot id 前后比对)/ CPU 定频 little 1555200 + big 1958400 / GPU 钉 pwrlevel 0 / 风扇 / 同热窗口臂交错 / 负载 rd12 DirectGLES 251 帧 1471.36 draws/帧 / `--benchmark-repeats 3`·`2` + `--benchmark-tail-frames 200` + `--benchmark-no-finish` / split 开关 / 源码头 / 臂的证明。**帧分母取自哪条日志**单列一段:`frames=` 由 `PipeStats::OnPresent` 递增,spawn 下 present 在 server 进程应用,故 spawn client `frames=0 window=0`;费率一律除以真正数到帧的那条日志的 `frames=`(inproc → client log,spawn → server log,两者都 251),**不得除以同行的 `window=`**(本负载会虚高约 21×)。同时写明三个结构性零(spawn client 的 `srv=0 srvpark=0`、spawn server 的 `cli=0 clipark=0`、spawn client 的 per-frame 字段)与「`* total ms` 含第 1 帧 shader 编译(7332 ms)、不得除以 251」 |
| §12.2 门 7 | 会话 1(boot id `e69d0329…`→`107f24d3…`)六臂交错 × best-of-3 的**逐臂表**(wall p50 / client CPU p50 / fps / 臂内散布),18/18 runner 退出 0、36/36 `PINNED`、0 `Fatal{`、37.6–46.5 °C;会话 2(`107f24d3…`→`7e6c6e9f…`)inproc/spawn 交错 × best-of-2 的**逐臂表**,4/4 OK、8/8 `PINNED`、0 `Fatal{`、37.2–44.9 °C;两会话的配对差表(**两个顺序都列**);monolith 基线行;判读 **tie** |
| §12.3 门 8① | 会话 2 的**完整四计数器账**(含 `counters 取自` / `frames 取自` 两列)+ 会话 1 的账(spawn 的 server 半边标 **不可得**,并写明这是 §12.6 修复前的结构性问题、不是漏采);逐列读法与 hole 判读 |
| §12.4 门 8②③ | `SEG_STAGE` 字节/帧与分块后记录/帧的**稳态中位数 / 稳态均值 / 含启动负载均值三个 framing 都报**;`maxrec=1808` 分块后不变与其原因;`ringwraps`/`ringpads`/`ringwaits`;`seg` 与后端字节类的逐窗交叉核对(含「能排除什么、不能排除什么」);**逐 blob 分布首测**(70 records / 8 blobs / bucket 3–12,逐桶列出) |
| §12.5 负控 | `MOBILEGL_IPC_STAGE_MB=1`(256 KiB chunk 预算)的 A/B 表:**`seg` 运行总量两侧逐字节相同(2 698 026 763)**、**`wrec` +0.11%**、稳态中位数 −32%(逐窗归属会动、总量不会)、`tex` +194% 记为**证据不作结论**(arena 大小与 chunk 预算绑在同一个 env 变量上,未隔离到单变量) |
| §12.6 收尾代码与工具 | `StageSegmentBytes`(`PipeWireCodec.cpp:878`)/ `WireRecords`(`:1188`)/ staged-blob 直方图 / `ServerMain.cpp` 的 `Init()`+`Shutdown()`(**红一次:0 条 → 29 条汇总行,SSIM 1.0 不变**)/ `MOBILEGL_PIPE_STATS_FILE` 角色分路 `/ <base>.client.json` `/ <base>.server.json`(含 G1 三次泄漏点)/ 测试数(PipeStats 25/25、全量 unit 2367/2367、spawn integration 102/102、G1 0/0/0/0 且 `.text` 逐字节相同)/ 工具入库 / `pin_device.sh` 的 Redmi 行 |
| §12.7 不可比项与未跑项 | **本板 GPU 钳 1050 MHz、与 1100 MHz 时代的 `35d0befa` 行不可比**;**OpenRA 真机辅负载未跑**;门 8① 只在一负载一设备上比过;spawn client 窗口化字段是结构而非缺陷;分块净效果未单变量隔离 |
## 3 与事实基线的逐条对照(自检)
| 事实 | 落点 | 一致 |
|---|---|---|
| 门 7 会话 1:18/18、36/36 PINNED、0 Fatal;inproc 7.880/7.889、spawn 7.876/7.869(−0.15%)、臂内散布 0.2–4.2%、monolith 约贵 30% | §12.2 | ✅ |
| 门 7 会话 2:4/4、8/8、0 Fatal;inproc 7.987/7.989 vs spawn 7.992/8.009(+0.16%) | §12.2 | ✅ |
| 判读 tie、不设门 | §12.2 末 | ✅ |
| 门 8① client 121.1 waits/帧、两传输逐字相同、run total 30 401–30 407;server inproc 10 866–11 963 / spawn 10 415–10 416;park 率 0.042–0.06% | §12.3 | ✅ |
| 契约 §9「阻塞读可能更便宜」未兑现(既不更便宜也不更贵) | §12.3 tie 判读 | ✅ |
| spawn server waits 少 8.9–12.9% 归因于 handoff 纪律而非门铃 | §12.3 `srv/f` 与同节解释段 | ✅ |
| clipark/f 是唯一 socket 略差的列(6.07 vs 5.60/5.75,每帧多约 0.4 次 park) | §12.3 `clipark/f` | ✅ |
| 读法警告(srv/cli 的零=另一个进程;spawn client frames=0、per-frame 无效而 `cli`/`clipark` 可信;终行/0 行) | §12.1 + §12.3 | ✅ |
| OpenRA seg 130.8 KB median / 8.01 MB steady mean(含负载 8.07 MB)、记录 211/206;rd12 seg 816.2 KB median / 1.53 MB steady mean(含负载 10.79 MB)、记录 7 497/7 504 | §12.4 表 | ✅ |
| framing 警告(rd12 帧 1 有 981 654 draws;run-wide mean 约 7× 稳态;两个读数都报) | §12.4 表 + 警告段 | ✅ |
| maxrec=1808 与分块前相同、默认 8 MiB 预算从未被打到 | §12.4 | ✅ |
| 负控 `STAGE_MB=1`:seg 逐字节相同 2 698 026 763、wrec +0.11%、tex +194% 记为证据不作结论 | §12.5 | ✅ |
| 逐 blob 分布首测 70 records / 8 blobs / bucket 3–12,integration 车道,retrace snapshot 拿不到 dump | §12.4 末 | ✅ |
| 收尾代码:`PipeWireCodec.cpp:878` / `:1188`、`ServerMain.cpp` Init/Shutdown(红一次 0→29 行)、`MOBILEGL_PIPE_STATS_FILE` 角色派生、测试数、工具入库 | §12.6 | ✅ |
| 本板 GPU 钳 1050 MHz、与 `35d0befa` 的 1100 MHz 时代不可比 | §12.1(协议行)+ §12.7 | ✅ |
| OpenRA 真机未跑 | §12.7 | ✅ |
**未写进去的**(不在本任务范围,留给并行代理):门 1–6 的逐项复述、`ROADMAP.md` 的阶段状态、
`CURRENT_STAGE_PROGRESS.md` 的出口门表。§12 开头只写「出口门 1–6 的逐项证据见
`CURRENT_STAGE_PROGRESS.md`」并给一行,不复述。
## 4 引用检查输出(工作树根目录)
```
$ python scripts/check_doc_citations.py docs/Disaggregated/MEASUREMENTS.md
check_doc_citations: 4 citations in 1 document(s) against HEAD, 0 problem(s)
exit=0
$ python scripts/check_doc_citations.py --strict docs/Disaggregated/MEASUREMENTS.md
check_doc_citations: 4 citations in 1 document(s) against HEAD, 0 problem(s)
exit=0
```
(改动前该文档是 `0 citations … 0 problem(s)`——因为是新加的引用,所以这是新增的 4 条,而不是存量。)
新增的 4 条引用都是 `path:line` 形式,全部解析成功:`MobileGL/MG_Remote/Wire/PipeWireCodec.cpp:878`
与 `:1188`(各出现两次,§12.4 与 §12.6)。**覆盖范围说明**:本脚本只匹配
`文件.ext:行号` 形态(扩展名限于 `h/hpp/cpp/cc/c/py/def/inc/md/json/yml/yaml/txt/fbs`),
**不检查** `MobileGL/MG_Util/Metrics/PipeStats.{h,cpp}` 这类花括号缩写、也不检查
`MobileGL/MG_Remote/Server/ServerMain.cpp` 这种**不带行号**的文件引用——这两处是照 §3 的行号风格
写的(`ByteClass` 枚举成员与 `CallClass` 枚举成员的位置由 `PipeStats.h`/`.cpp` 共同承载,写成 `:878`
反而是唯一收口点,`ServerMain.cpp` 的承重位置是「配置解析与定角色之后 / `loop.Stop()` 之后」)。
行号取自**收尾改动后的工作树**(`PipeWireCodec.cpp` 工作树 2335 行 vs HEAD 2299 行),不是 HEAD:
这是刻意的,因为 §12.6 描述的就是工作树里那份改动,已在 §12.4 正文里写明「两处行号取 §12.6 收尾改动后的工作树内容」。
全文档车道(CI 的调用形态)也一并跑了,用于确认本次改动没有引入新问题:
```
$ python scripts/check_doc_citations.py docs/Disaggregated/*.md
check_doc_citations: 183 citations in 11 document(s) against HEAD, 1 problem(s)
docs/Disaggregated/P6-ENDSTATE-REVIEW.md:284: `Init.cpp:184-211` -> ambiguous: MobileGL/Init.cpp,
MobileGL/MG_Backend/Init.cpp, MobileGL/MG_Impl/Init.cpp
exit=0
```
那 1 条**不是本次引入**,它在 `P6-ENDSTATE-REVIEW.md`(另一个代理负责的只读文档),
且 CI 对这条 lint 是 `|| true` 的 warning-only
(`.github/workflows/test.yml:3096-3101`,注释写明「文档还在写,等定稿再变 `--strict`」)。
## 5 遗留 / 需要下游注意
1. **两个事实数在 `docs/Disaggregated/MEASUREMENTS.md` 与 `CURRENT_STAGE_PROGRESS.md` 之间会重复**——
本文件按任务书写了「本节记录门 7 / 门 8 …出口门 1–6 的证据见 `CURRENT_STAGE_PROGRESS.md`」,
请负责那份文档的代理确认两处措辞不冲突(数字本身逐字一致)。
2. **§12.6 的行号指向工作树、不是 HEAD**,已在正文里写明。合并/提交这次收尾改动后行号可能移动;
届时按 §6.3 的既有风格(引用只在 `path:line` 无法替代的位置使用)复核那两处即可。
3. **`tools/device_bench/p6/README.md` 的已知限制段落已过时**:它还写着「Where that call is absent」
(即 `PipeStats::Init()` 可能缺失),而 §12.6 的收尾改动已经把它加进 `ServerMain.cpp`。
该文件不是本代理负责的文档,**未改**;留给后续一并订正。
4. **本次未跑任何构建或测试**:这是文档任务,改动只有 markdown 文本。§12 中的测试数
(PipeStats 25/25、unit 2367/2367、spawn integration 102/102、G1 0/0/0/0 与 `.text` 逐字节相同)
与设备数全部照任务书给的事实基线**逐字转写**,**未重算、未重跑、未挑好看的**。
文件里的设备数按只读证据文档核对过(`gate7-device-ab.md`、`gate8-doorbell-device.md`、
`gate8-wire-counters.md`),并把两处证据文档里互相略有出入的四舍五入统一到证据文档自身的口径
(例如 §12.3 的 `srvpark/f` 与 park 比例统一按任务书的 **0.042–0.06%** 报;会话 2 四臂逐臂算出来是
0.042–0.047%,并入会话 1 的 inproc 两臂后上界到 0.056%,正文里把逐臂数都列了、并声明了上界来源)。
5. **未触碰**:`CURRENT_STAGE_PROGRESS.md`、`ROADMAP.md`、`ARCHITECTURE.md`、`README.md`、
三份只读证据文档(`notes/p6/gate7-device-ab.md`、`gate8-doorbell-device.md`、`gate8-wire-counters.md`)、
`notes/p6/git-worktree-fix.md`,以及任何 `MobileGL/` 源码与 `tools/` 脚本。
@@ -0,0 +1,213 @@
# docs-closeout-progress — `CURRENT_STAGE_PROGRESS.md` 的 P6 收官写入报告
2026-09-22。工作树 `C:/Users/geekerwan/AndroidStudioProjects/FoldCraftLauncher/MobileGL-disagg`,
分支 `feat/disaggregated`,**现头 `2bd86664`**,与 `origin/feat/disaggregated` 同步。任务书给的
头 `71aa9951` 是**两轮测量的源头**(门 7 报告记 `98d0b96c`、门 8 的 wire 计数器与真机第二轮记
`71aa9951`);它之后分支又落了两个 P6.5 设计提交(`2d86e07a`、`2bd86664`),非本报告所加。
**只改了一份文档**:`docs/Disaggregated/CURRENT_STAGE_PROGRESS.md`。未 commit。工作树里既有的
未提交改动(`MobileGL/MG_Remote/**`、`MobileGL/MG_Util/**`、`tools/device_bench/pin_device.sh`、
`tools/trace_replay/run_android_retrace_local.py`、`tools/device_bench/p6/`)**一个字节未动**。
另外两份(`CURRENT_STAGE_PROGRESS.md` 之外的两份并行代理文档)未触碰;只读证据
`notes/p6/gate7-device-ab.md`、`gate8-doorbell-device.md`、`gate8-wire-counters.md`、
`git-worktree-fix.md` 只读未改。
---
## 0 改动总览
| # | 位置 | 改动 | 行数 |
|---|---|---|---|
| 1 | 头部状态段 | 「实现完成、出口性能/设备数待补」→「**P6 已收官(2026-09-22)**」+ 门 7 tie / 门 8 三个数齐 / G1 0/0/0/0 + 分支头事实 | 改写,+6 / −4 |
| 2 | 新增 §1.1「收尾(2026-09-22)」 | 包状态表之后补五条:wire 计数器、`ServerMain` 武装(红一次证据)、STATS_FILE 角色派生、`pin_device.sh` `2f7cbe2e` 行、工具入库 + 测试数 | 新增 39 行 |
| 3 | §2 出口门表第 7 行 | ⏳ → ✅,写两轮会话的 boot id / 臂序 / 臂数 / client CPU 数 / tie 判读 / 证据文件 | 改写 |
| 4 | §2 出口门表第 8 行 | ⏳ → ✅,写三个强制性能数 + 诚实判读 + 证据文件 | 改写 |
| 5 | §2 表后新增一段 | `MEASUREMENTS.md:342` 的「分块后分布不存在」据此关闭 | 新增 5 行 |
| 6 | §2.1 标题与首条 | 标题改为「出口门之外仍欠」;首条 `PipeStats` 结构性零由「修复进行中」改为「**已修**,0 → 29 行」 | 改写 2 行 |
| 7 | §3 新增 §3.1 / §3.2 / §3.3 | 配对 A/B、门 8① 门铃账、门 8②③ wire 计数器 | 新增 61 行 |
| 8 | §5 | 删去已解决的「出口门 7、8」条目;补两条结构性限制 | 净 +17 行 |
净变化:`134 insertions(+), 11 deletions(-)`(`git diff --stat docs/Disaggregated/CURRENT_STAGE_PROGRESS.md`;
本报告写完后又校过一遍,行数随 §2 一段的口径修正微调过)。无段落被误删:`DirectVulkan` 分歧、
P6.5、Ph、P12、seq/op 五条**逐字保留**(仅 `DirectVulkan` 条尾加了"见 §3 与 `ROADMAP.md` P7 行")。
---
## 1 逐处对照(事实基线 → 落文)
### 1.1 头部状态段
旧:「状态(2026-09-21):P6 全部包已落地(a6…t6),出口门第 1–6 项达成;第 7 项…第 8 项…尚未
正式采集,因此 P6 记为「实现完成、出口性能/设备数待补」,**未宣布收官**。分支 … 头 `16bfab10`」
新:`P6 已收官(2026-09-22)`,并把分支头写成**三层事实**,因为任务书要求"照实说"而
`71aa9951` 已不是树头:
- 门 7 报告头 `98d0b96c`(`gate7-device-ab.md:3`)、门 8 第二轮头 `71aa9951`
(`gate8-doorbell-device.md:3`、`gate8-wire-counters.md:3`)——**测量期的源头**;
- 其后两个提交 `2d86e07a`、`2bd86664` 是 P6.5 设计(已核实:`2d86e07a` 的 diff 只含
`CONTRACT-P6.md` / `StreamLink.h`(+5 行注释) / `ARCHITECTURE.md` / `CURRENT_STAGE_PROGRESS.md` /
`P6-ENDSTATE-REVIEW.md` / `ROADMAP.md`;`2bd86664` 只含 `ROADMAP.md`);
- 现头 `2bd86664`,与 `origin` 同步(`git rev-parse origin/feat/disaggregated` = `2bd86664…`);
**工作树另有未提交的 P6 收尾改动**,指向 §1.1。
### 1.2 §1.1 收尾(2026-09-22)
任务书要求的六项全部落文,并各自指出处:
| 任务书要求 | 落文内容 |
|---|---|
| wire 计数器(seg=/wrec= + 直方图) | `ByteClass::StageSegmentBytes` 唯一收口 `PipeWireCodec.cpp:878`;`CallClass::WireRecords` `:1188`;staged-blob 直方图复用 `PayloadBucketOf`;汇总行 `seg=` / `wrec=` / `wrec/f=`;push-only |
| ServerMain PipeStats 武装(红一次证据) | 「修复前 spawn server **0** 条汇总行、修复后 **29** 条,同一条 spawn trace、两侧 SSIM 均 1.000000」;`Init()` 的两处顺序承重点 |
| STATS_FILE 角色派生 | `<base>.client.json` / `<base>.server.json`、基名不指向文件、`RoleLogPath` 导出不复制;G1 红三次(`.text +24` / 1 symbol resized / `.rodata +64`) |
| pin_device.sh `2f7cbe2e` 条目 | 本板钳 **1050 MHz**(`thermal_pwrlevel` 写 0 读回 1、`max_gpuclk` 只读 1 050 000 000);**与 1100 MHz 时代的 `35d0befa` 行不可比,只有同会话配对可比** |
| tools/device_bench/p6 入库 | `{ab_session.py,render_ab.py,README.md}` + `run_android_retrace_local.py`(+19 行 `MOBILEGL_TRACE_PACKAGE`);四文件**未提交** |
| 单测 2367/2367 与 spawn 102/102 | 落文;另加 `PipeStats 25/25` 与 G1 `.text` 两侧各 10 158 850 字节 `cmp` 无差异 |
### 1.3 §2 出口门表
第 7 行与第 8 行从 ⏳ 改 ✅,数字逐字取自只读证据,**不改写、不重算、不挑好看的**:
- 会话 1:boot id `e69d0329…`→`107f24d3…`,六臂 `monolith,inproc,spawn,inproc,monolith,spawn`
× best-of-3,18/18 exit 0、36/36 `PINNED`、0 `Fatal{`;client CPU p50 ms/帧
inproc **7.880/7.889**、spawn **7.876/7.869**(均值 −0.15%,臂内散布 0.2–4.2%),monolith 约贵 30%。
- 会话 2:boot id `107f24d3…`→`7e6c6e9f…`,交错 × best-of-2,4/4 OK、8/8 `PINNED`、0 `Fatal`;
**7.987/7.989 vs 7.992/8.009**(+0.16%)。
- 第 8 行:① client 121.1 waits/帧、5.6–6.1 parks/帧(run total 30 401–30 407);server inproc
10 866–11 963、spawn 10 415–10 416;park 率 0.042–0.06%;clipark/f 6.07 vs 5.60/5.75。
② OpenRA 130.8 KB median / 8.01 MB steady mean(含负载 8.07 MB);rd12 816.2 KB median /
1.53 MB steady mean(含负载 10.79 MB)。③ OpenRA 211/206;rd12 7 497/7 504;`maxrec=1808`。
第 8 行末尾的**诚实判读**逐句保留证据里的定性:socket 阻塞读**既不更便宜也不更贵**、契约 §9
「阻塞读可能更便宜」**未兑现**、spawn server waits 少 8.9–12.9% 归因于 handoff 纪律而非门铃本身、
clipark/f 是唯一 socket 略差的列(每帧多约 0.4 次 park)。第 7 行明写 **tie** 与「不设门」。
### 1.4 §2.1 与 §5
- §2.1 首条的旧文写「修法是在 server 进程里…(工作区中进行中,未提交)」——**已过时**,改为
「**已修**…spawn server 汇总行 0 → 29 条,SSIM 1.0 不变」。标题 `收官前另欠` 已不成立(出口门
1–8 全达成),改成 `出口门之外仍欠`,并注明这些是门外的债。
- §5 删去 `**出口门 7、8**(真机配对 A/B、三个性能数)——待采集,见 §2。` 一条(已解决)。
其余五条保留。新增两条**结构性限制**(任务书指定):
1. spawn client 无 `OnPresent` → 窗口字段 `frames=0 window=0` 无效应,但 `PublishGauge` 是 store
所以 `cli`/`clipark` 与**发射侧直接 Add** 的 `resid`/`csob-blob`/`seg`/`wrec` 的 run total 有效;
并给出逐 role 取数规则(`srv` 读 server.log、`cli` 读 client.log、spawn client 只有 `Shutdown`
终行或 0 行)。
2. `MOBILEGL_PIPE_STATS_FILE` 已改角色派生 `<base>.client.json` / `<base>.server.json`,基名不指向
文件;inproc 只产出 `.client.json` 是**正确**的。
### 1.5 §3 真机验收
原有三条功能行、`DirectVulkan` 分歧段、llvmpipe `iterationrp` 段**逐字未动**,在其后新增
§3.1(配对 A/B)、§3.2(门 8① 门铃账,含读法警告)、§3.3(门 8②③ wire 计数器,含
framing 警告、负控、逐 blob 分布、`tex +194%` 未解释记为证据不作结论)。
任务书指定的两个必须如实写的点都在:**OpenRA 真机辅负载「未跑」**(§3.3 末段),以及
**gate8-wire-counters.md §6 的数据来自 WSL + llvmpipe,门 8②③ 是桌面 inproc 数**(同一段)。
---
## 2 引用检查输出
脚本 `scripts/check_doc_citations.py` 的 `documents` 是**位置参数(`nargs="+"`),不接受空参**,
所以任务书里那条裸命令跑不出来:
```
$ python scripts/check_doc_citations.py
usage: check_doc_citations.py [-h] [--rev REV] [--strict]
documents [documents ...]
check_doc_citations.py: error: the following arguments are required: documents
EXIT=2
```
按脚本自己的用法(`python3 scripts/check_doc_citations.py docs/Disaggregated/*.md`)跑,结果如下。
**目标文档(本报告唯一改动的文件):**
```
$ cd MobileGL-disagg && python scripts/check_doc_citations.py docs/Disaggregated/CURRENT_STAGE_PROGRESS.md
check_doc_citations: 3 citations in 1 document(s) against HEAD, 0 problem(s)
EXIT=0
```
(这是**改动后**的结果。改动前同一命令报 `2 citations … 0 problem(s)`;计数从 2 升到 3 是因为新增了
`PipeWireCodec.cpp:878` 一处被正则捕获的引用——另外两处 `MEASUREMENTS.md:342`、`PipeStats.h:256` 是
原有的。同段里的 `:1188` 因无文件名前缀,按脚本自己的注释「`:12-13` is NOT matched on purpose」不计数。)
**全量(覆盖 `docs/Disaggregated`,含只读证据与三份并行代理文档):**
```
$ cd MobileGL-disagg && python scripts/check_doc_citations.py docs/Disaggregated/*.md
check_doc_citations: 179 citations in 11 document(s) against HEAD, 1 problem(s)
docs/Disaggregated/P6-ENDSTATE-REVIEW.md:284: `Init.cpp:184-211` -> ambiguous: MobileGL/Init.cpp, MobileGL/MG_Backend/Init.cpp, MobileGL/MG_Impl/Init.cpp
EXIT=0
```
该脚本**确实覆盖 `docs/Disaggregated`**(上表 11 份文档含 `docs/Disaggregated/*.md`),
但**不递归**:`notes/p6/*.md` 不在通配里。唯一 1 条 problem 是 `P6-ENDSTATE-REVIEW.md` 里
**既有的** basename 歧义(非本次改动引入,且该文件由并行代理负责,未碰),无 `--strict` 时退出码 0。
**扩展核查**(把只读证据与 `notes/p6` 一并喂进去,确认我引用的三个证据文件本身的状态):
```
$ cd MobileGL-disagg && python scripts/check_doc_citations.py \
docs/Disaggregated/CURRENT_STAGE_PROGRESS.md \
docs/Disaggregated/notes/p6/gate7-device-ab.md \
docs/Disaggregated/notes/p6/gate8-doorbell-device.md \
docs/Disaggregated/notes/p6/gate8-wire-counters.md
check_doc_citations: 55 citations in 4 document(s) against HEAD, 9 problem(s)
docs/Disaggregated/notes/p6/gate8-wire-counters.md:141: `MobileGL/MG_Util/Metrics/PipeStats.h:382-386` -> MobileGL/MG_Util/Metrics/PipeStats.h has 372 lines at HEAD
docs/Disaggregated/notes/p6/gate8-wire-counters.md:145: `MobileGL/MG_Util/Metrics/PipeStats.cpp:708` -> MobileGL/MG_Util/Metrics/PipeStats.cpp has 646 lines at HEAD
docs/Disaggregated/notes/p6/gate8-wire-counters.md:613: `retrace_main.cpp:258` -> no such file at HEAD
docs/Disaggregated/notes/p6/gate8-wire-counters.md:614: `retrace_main.cpp:1482` -> no such file at HEAD
docs/Disaggregated/notes/p6/gate8-wire-counters.md:669: `Init.cpp:123` -> ambiguous: MobileGL/Init.cpp, MobileGL/MG_Backend/Init.cpp, MobileGL/MG_Impl/Init.cpp
docs/Disaggregated/notes/p6/gate8-wire-counters.md:681: `ServerMain.cpp:442` -> MobileGL/MG_Remote/Server/ServerMain.cpp has 395 lines at HEAD
docs/Disaggregated/notes/p6/gate8-wire-counters.md:699: `Init.cpp:59` -> ambiguous: MobileGL/Init.cpp, MobileGL/MG_Backend/Init.cpp, MobileGL/MG_Impl/Init.cpp
docs/Disaggregated/notes/p6/gate8-wire-counters.md:826: `PipeStats.cpp:787` -> MobileGL/MG_Util/Metrics/PipeStats.cpp has 646 lines at HEAD
docs/Disaggregated/notes/p6/gate8-wire-counters.md:826: `PipeStats.h:463` -> MobileGL/MG_Util/Metrics/PipeStats.h has 372 lines at HEAD
EXIT=0
```
**这 9 条全部落在 `notes/p6/gate8-wire-counters.md`(只读证据,非本报告改动的文件),而且全部是
"对着工作树而非 HEAD 写的行号"** —— 正是它自己 §3 开头写的"行号为改后内容":
- **5 条**(`:141`、`:145`、`:681`、`:826` ×2)引的是**未提交的工作树行号**——正是它自己 §3 开头
写的"行号为改后内容"。实测两侧行数:`PipeStats.h` 工作树 **466** vs HEAD **372**、
`PipeStats.cpp` 工作树 **800** vs HEAD **646**、`ServerMain.cpp` 工作树 **445** vs HEAD **395**。
脚本按 `HEAD` 解析,故报超界。**这 5 条对着工作树是对的**,对着 HEAD 才红。
- **2 条**(`:613`、`:614`)引 `retrace_main.cpp`,该文件在 `3rdparty/apitrace` 子模块内,
不在本仓库的 `ls-tree` 里,脚本解析不到。
- **2 条**(`:669`、`:699`)是 `Init.cpp` 的既有 basename 歧义(`MobileGL/Init.cpp` /
`MG_Backend/Init.cpp` / `MG_Impl/Init.cpp`)。
**目标文档 `CURRENT_STAGE_PROGRESS.md` 自身 0 problem。** 三处行号引用按 HEAD 解析都在界内:
`PipeStats.h:256`(HEAD 372 行)、`PipeWireCodec.cpp:878`(HEAD 2299 行)、
`MEASUREMENTS.md:342`(HEAD 599 行)。`:1188`(`EncodeRecord`)写在中间点前无文件名,按脚本设计不计;
它与 `:878` 都取**证据文件 `gate8-wire-counters.md` §3 的同一口径(改后行号)**——
`PipeWireCodec.cpp` 工作树 2335 / HEAD 2299,两个行号在两侧都成立,所以这条无需改口径。
**链接目标存在性**(我新加的 markdown 相对链接逐个 `test -f`):
```
OK docs/Disaggregated/notes/p6/gate7-device-ab.md
OK docs/Disaggregated/notes/p6/gate8-doorbell-device.md
OK docs/Disaggregated/notes/p6/gate8-wire-counters.md
OK tools/device_bench/p6/ab_session.py
OK tools/device_bench/p6/render_ab.py
OK tools/device_bench/p6/README.md
```
---
## 3 边界与未做
- **未 commit**:只写了工作树文件。任务书明确禁止 `git commit`。
- **未碰**其余两份并行代理文档,也未碰 `ROADMAP.md` / `MEASUREMENTS.md` / `ARCHITECTURE.md`
(`ROADMAP.md` 的 P6 行与 `MEASUREMENTS.md:342` 仍是旧口径——本报告把"可关闭"写在
`CURRENT_STAGE_PROGRESS.md` 里,**没有替它们改**,那不在本次授权内)。
- **`CURRENT_STAGE_PROGRESS.md` 的既有 `MEASUREMENTS.md:342` 句子未删**,只在其下增写了关闭依据;
删除该句属 `MEASUREMENTS.md` 自身的改动,留待该文件的责任方。
- **未新增引用**到 `gate8-doorbell-device.md` 之外的新证据文件;`git-worktree-fix.md` 未在本文档引用
(它是 git 元数据修复,与阶段进度无关)。
- 文档定位保持为「只记当前阶段(P6)」:新增内容全部是 P6 的收官事实与门 8 的数,
P6.5 / Ph / P12 只在 §5 以「不属于 P6」的身份出现,入口提示段(顶部 blockquote)逐字未动。
@@ -0,0 +1,181 @@
# P6 收官事实写入 `ROADMAP.md` —— 改动报告
> 日期 2026-09-22;工作树 `C:\Users\geekerwan\AndroidStudioProjects\FoldCraftLauncher\MobileGL-disagg`,
> 分支 `feat/disaggregated`。**只改 `docs/Disaggregated/ROADMAP.md` 一份文档**,另两份(由并行代理负责)
> 未触碰;未 `git commit`;未触碰工作树里已有的未提交代码改动。
>
> 证据只读引用(未修改):`notes/p6/gate7-device-ab.md`、`notes/p6/gate8-doorbell-device.md`、
> `notes/p6/gate8-wire-counters.md`、`notes/p6/git-worktree-fix.md`。
---
## 0 一句话
把 P6 的收官事实(门 7 / 门 8 已采、判读 tie、收尾四项落地)写进 `ROADMAP.md`,
**6 行改动**:顶部 blockquote、阶段表 P6 行的状态列 / 落地列 / 验收门列、里程碑列表两条、一处引用了
`ServerMain.cpp`「正在被门 8 的工作修改」的行内说明、一处写「门 8 的树上工作正在补」的开放问题。
其余全部逐字未动(含债务表整张、其余阶段行、P6.5「立即的下一项」整节)。
---
## 1 逐处改动(行号按改前 / 改后对照)
### 1.1 顶部状态 blockquote(第 3 行)
**改前**:`P6 全部包(a6 / c6 / lk / so / sm / cp / hs / dl / st / t6)已落地于 feat/disaggregated
(头 16bfab10,2026-09-21);出口门第 1–6 项达成,第 7 项(真机配对 A/B 热窗口)与第 8 项
(三个强制性能数)尚未正式采集,故 P6 记为「实现完成、出口性能/设备数待补」,未宣布收官**。
**改后**:`P6 已收官(2026-09-22)`:全部包已落地(头 `16bfab10`,2026-09-21),出口门第 1–6 项此前已达成;
第 7 项(Redmi `2f7cbe2e` 的 reboot-clean 同热窗口配对 A/B)与第 8 项(三个强制性能数)已于 2026-09-22 采齐
——门 7 判读 **tie**(client CPU p50 差 −0.15% / +0.16%,均在臂内散布 0.2–4.2% 之内,符号随臂序翻转;
monolith 约贵 30%),门 8 三数齐;证据链 `notes/p6/gate7-device-ab.md` / `gate8-doorbell-device.md` /
`gate8-wire-counters.md`。
**未动**:同一 blockquote 里的 P5f 验收代码句、P5e 债句、CI / inproc 收官句;第 5 行(Magma run-ahead)
与第 7 行(终局改判)整句原样——这两行的「P6 / P7 全阶段状态不因此改变」与「P6.5 关键路径」结论
不受收官影响。
### 1.2 阶段表 P6 行(第 37 行)
- **状态列**:`⏳ 全部包已落地(2026-09-21,头 16bfab10);出口门 1–6 达成,7/8 待采` →
`✅ 已收官(2026-09-22)`,并保留十包落地信息。原「收官前另欠」三项改写为:server 进程 `PipeStats`
从未初始化**已在收尾中修好**;184 符号棘轮门与 hs 的 `LinkTerms` / `Refuse` / 指纹拆分**未落地、移交 P6.5**。
(只改「已在收尾中修好」这一处口径,未落地项原样保留——契约 §12.1 的棘轮门与 hs 三行确实仍未做。)
- **落地什么列**:原有十包链与 a6 / P6-ENDSTATE-REVIEW 引用逐字保留,**追加收尾四项**:
wire 计数器(`PipeWireCodec.cpp:878` `StageAllocate` 唯一收口、`:1188` `EncodeRecord` 提交路径、
staged-blob 直方图、汇总行 `seg=` / `wrec=`);`ServerMain.cpp` 补 `PipeStats::Init()` / `Shutdown()`
(修前 0 条汇总行 / 修后 29 条、SSIM 1.0 不变);`MOBILEGL_PIPE_STATS_FILE` 角色派生
`<base>.client.json` / `<base>.server.json`;工具入库 `tools/device_bench/p6/{ab_session.py,render_ab.py,README.md}`
与 `tools/trace_replay/run_android_retrace_local.py`(+19 行),`pin_device.sh` 新增 `2f7cbe2e` 条目。
- **验收门列**:`7/8 待采:……尚未正式记录` → `7/8 已采(2026-09-22)` 的达成摘要,逐字使用基线数字:
- 门 7:Redmi `2f7cbe2e` / M332BF / SM8750 / Adreno 830v2、CPU 定频 little 1555200 / big 1958400、
GPU 钳 1050 MHz、rd12-in-world + DirectGLES、~1471 draws/帧;六臂交错 × best-of-3 =
18/18 runner 退出 0、36/36 `PINNED`、0 `Fatal`;inproc 7.880/7.889 vs spawn 7.876/7.869(−0.15%)、
第二会话 7.987/7.989 vs 7.992/8.009(+0.16%);判读 **tie**,如实记录、不设门;monolith 约贵 30%。
- 门 8①:client 121.1 waits/帧、5.6–6.1 parks/帧、两传输逐字相同(run total 30 401–30 407);
server inproc 10 866–11 963 / 5.0–6.2,spawn 10 415–10 416 / 4.66–4.90;
socket 阻塞读**既不更便宜也不更贵**(契约 §9 未兑现);spawn server waits 少 8.9–12.9% 归因 handoff 纪律;
唯一略差列 `clipark/f` 6.07 vs 5.60/5.75。
- 门 8②③:OpenRA `seg` 130.8 KB median / 8.01 MB steady mean(含负载 8.07 MB)、记录 211 / 206;
rd12 `seg` 816.2 KB median / 1.53 MB steady mean(含负载 10.79 MB、首帧 981 654 draws vs 稳态 ~1471、
run-wide mean 约 7× 稳态,两个读法都报)、记录 7 497 / 7 504;`maxrec=1808` 与分块前相同。
- 证据链接三条按任务书给出的相对路径写入。
### 1.3 里程碑列表(第 228–229 行)
采用任务书给的**改写旧条**方案(文件惯例是里程碑与阶段表的日期条目一一对应,新增一条会留下
「未宣布收官」与「已收官」两句并存):
- 第 228 行:「P6 全部包落地(2026-09-21)」→「**P6 收官(2026-09-22)**」。十包合入、三种传输
retrace 对上 golden、device-lost 闩 123 s → 5.7 s、`Session::Fail` 93→4 等历史事实逐字保留;
出口门句改为「1–6 ✅;门 7 / 8 于 2026-09-22 采齐」并写入 tie 判读、monolith 约贵 30%、
门 8 三数齐、G1 0/0/0/0 且 `.text` 逐字节相同,收尾为「P6 就此收官,未设性能门(只记录)」。
- 第 229 行「终局改判」条内,把「server 进程 `PipeStats` 结构性零」标注为**收尾中已修**
(否则会与 1.2 的状态列自相矛盾)。该条其余内容与第 230 行「仍是方向……」「再基线检查点」
逐字未动。
### 1.4 两处「门 8 还在做」的残留说明(第 61、266 行)
这两处若不改,会与新的收官状态直接冲突(一句说门 8 还在改代码、一句说门 8 的量还在补):
- 第 61 行 P6.5 表格里 `ServerMain.cpp` 的出处列:「正在被门 8 的工作修改,不引行号」→
「门 8 的收尾修改已落入该文件,本表不引行号」。**仍未引行号**(该表本来就不引,保持原风格)。
- 第 266 行开放问题 11 末句:「MC in-world / Create 的占用分布仍未量(门 8 的树上工作正在补)」→
「(门 8 已给出两条负载的字节/帧与记录/帧,逐 blob 分布只在 integration 车道取到)」。
这一改与 `gate8-wire-counters.md` §6.5 / §7.1 一致:逐 blob 分布只从 integration 车道的 JSON dump 拿到,
retrace 的 snapshot 模式因不走 `cleanUp→Destroy` 拿不到。该问题其余文字(含「仍未答」的
program archive 与 `draw_vbo` range 尾)逐字未动。
### 1.5 明确**未**改动(逐字保留)
- P6.5 行(第 38 行)与「立即的下一项」整节(第 51–158 行):其「今天还跑不了」「正在被门 8 的工作修改」
之类的 P6.5 语境与本次收官无关,除 §1.4 那一处外未动。
- 债务表(第 234–250 行)**整张逐字未动**,含「A3 指纹 → P6.5 wf / nd」「默认 32 MiB staging」
「max record bytes」等行。
- 其余阶段行(P0…P5f、Ph、P3b/P4b、P7…P13)逐字未动;P5c 历史节、审计清单表、开放问题 1–24
除 §1.4 那一句外逐字未动。
- 第 226 行「P5f 出口……P6 前提解除,实施未开始」是**当时**的事实陈述,保留(该条的历史价值高于
与今天的对照,且下行第 228 行已给出实施与收官时间线)。
---
## 2 与基线数字的一致性自查
| 事实 | 写入位置 | 与证据文档是否逐字一致 |
|---|---|---|
| 门 7:18/18 退出 0、36/36 `PINNED`、0 `Fatal` | blockquote(概述)、阶段表、里程碑 | 是(`gate7-device-ab.md` §1 表) |
| 门 7:7.880/7.889 vs 7.876/7.869 = −0.15% | blockquote、阶段表 | 是(§2.2 / §2.3;臂内散布 0.2–4.2%) |
| 门 7 第二会话:4/4 OK、8/8 `PINNED`、0 `Fatal`、7.987/7.989 vs 7.992/8.009 = +0.16% | 阶段表 | 是(`gate8-doorbell-device.md` §1、§4) |
| monolith 约贵 30%(−29.8% / −29.9%) | blockquote、阶段表、里程碑 | 是(`gate7-device-ab.md` §2.4) |
| 门 8①:121.1 waits/帧、5.6–6.1 parks/帧、30 401–30 407 | 阶段表 | 是(两文档 §3 表;5.60/5.75 与 6.07/6.08) |
| 门 8①:server inproc 10 866–11 963 / spawn 10 415–10 416 | 阶段表 | 是(`gate8-doorbell-device.md` §3) |
| 门 8①:spawn server waits 少 8.9–12.9%、`clipark` 6.07 vs 5.60/5.75 | 阶段表 | 是(§3、§3.1、§7) |
| 门 8②③:OpenRA 130.8 KB / 8.01 MB、211 / 206 | 阶段表 | 是(`gate8-wire-counters.md` §6.4) |
| 门 8②③:rd12 816.2 KB / 1.53 MB、7 497 / 7 504、含负载 8.07 / 10.79 MB | 阶段表 | 是(§6.4) |
| rd12 首帧 981 654 draws、run-wide mean 约 7× 稳态 | 阶段表 | 是(§6.4 读法注意) |
| `maxrec=1808` 与分块前相同、默认 8 MiB chunk 未被触发 | 阶段表 | 是(§6.4 / §7.2) |
| 收尾四项(878 / 1188 / ServerMain Init+Shutdown / 角色派生 / 工具与 pin 条目) | 阶段表落地列 | 是(`gate8-wire-counters.md` §3、§9.2、§9.7、§9.7.1) |
| 修前 0 条 / 修后 29 条汇总行、SSIM 1.0 不变 | 阶段表落地列 | 是(§9.4(c)) |
| 本板 GPU 钳 1050 MHz(写 0 读回 1、`max_gpuclk` 只读 1 050 000 000)、与 `35d0befa` 行不可比 | 阶段表状态列与验收门列 | 是(`gate7-device-ab.md` §1、§5.1 与 `pin_device.sh` 的 `2f7cbe2e` 行) |
**未写入**(任务书未列、且属于证据文档范围的数):负控 `MOBILEGL_IPC_STAGE_MB=1` 的
`seg` 运行总量逐字节相同 / `wrec` +0.11% / 同 A/B 下 `tex` +194%(未解释)、逐 blob 分布首测
(70 records、8 blobs、bucket 3–12)、G1 `.text` 10158850 与 symbols 27863。这些留在
`notes/p6/gate8-wire-counters.md`,`ROADMAP.md` 只在其开放问题 11 保留了「分块」的既有论述。
---
## 3 引用检查
在工作树根目录按任务书要求运行:
```
$ python scripts/check_doc_citations.py docs/Disaggregated/ROADMAP.md
check_doc_citations: 75 citations in 1 document(s) against HEAD, 0 problem(s)
rc=0
```
(改前同一条命令:`74 citations … 0 problem(s)`;+1 是本轮新写入的 `PipeWireCodec.cpp:1188`。
**该脚本覆盖 `docs/Disaggregated`**:它按参数吃任意 markdown,仓库里就是靠
`docs/Disaggregated/*.md` 调用的。为完整性另跑了一次全域:
```
$ python scripts/check_doc_citations.py docs/Disaggregated/*.md docs/Disaggregated/notes/p6/*.md
check_doc_citations: 1257 citations in 21 document(s) against HEAD, 27 problem(s)
rc=0
```
那 27 条**全部在只读证据文档里,与本轮改动无关**,且分三类:
1. **21 条 basename 歧义**(`Init.cpp` 命中 `MobileGL/Init.cpp`、`MG_Backend/Init.cpp`、`MG_Impl/Init.cpp`;
1 条 `Core.cpp`)——脚本按 basename 解析时要求唯一。
2. **5 条 `path:line` 超出 HEAD 行数**——`PipeStats.h` / `PipeStats.cpp` / `ServerMain.cpp` /
`PipeWireCodec.cpp` 的行号来自**工作树**(未提交的收尾改动),而脚本对 `HEAD` 解析。
`notes/p6/gate8-wire-counters.md` §3 自己声明的就是「行号为改后内容」。
3. **2 条 `retrace_main.cpp` 第 258 / 1482 行的引用无此文件**——该文件属于 apitrace 子模块的 vendored 源码,
不在 `git ls-tree -r HEAD` 的顶层路径集合里(脚本只解析仓库自身文件)。
按任务书要求,这些只读证据文档**未改动**,问题如实列出;`ROADMAP.md` 自身 0 problem。
---
## 4 边界与未做项
1. **没有 `git commit`**,也没有触碰工作树里已有的未提交改动
(`MobileGL/MG_Remote/{Wire/PipeWireCodec.cpp,Server/ServerMain.cpp}`、
`MG_Util/{Metrics/PipeStats.cpp,Metrics/PipeStats.h,Debug/Log.cpp,Debug/Log.h}`、
`MG_Test/Util/PipeStatsTest.cpp`、`tools/device_bench/pin_device.sh`、
`tools/trace_replay/run_android_retrace_local.py` 与新增的 `tools/device_bench/p6/`)。
改动的只有 `docs/Disaggregated/ROADMAP.md`(6 行)与本报告文件。
2. **另两份文档未碰**:`CURRENT_STAGE_PROGRESS.md`(含 §2.1「收官前另欠」、§3「出口门 7、8 …… 待采集」)
与 `MEASUREMENTS.md` 由并行代理负责;本报告不引用它们的行号,`ROADMAP.md` 里对
`CURRENT_STAGE_PROGRESS.md` / `MEASUREMENTS.md` 的既有链接逐字保留。**注意**:在它们被同一轮收官改完之前,
`ROADMAP.md` 说的「P6 已收官」与 `CURRENT_STAGE_PROGRESS.md` §2.1 / §3 的「未宣布收官 / 7、8 待采」
会短暂并存,这是并行分工的中间态,不是本报告遗漏。
3. **里程碑采用「改写旧条」而非「新增一条」**(任务书允许二选一):这样同一列表里不存在
「未宣布收官」与「已收官」两句。若并行代理在 `CURRENT_STAGE_PROGRESS.md` 里选择了另一种写法,
需要把两处措辞对齐时以 `ROADMAP.md` 第 228 行的口径为准。
4. **`git status` 里 12 条 ` m` 子模块与 flatbuffers 的 6 条 ` M` 是既有 stat 噪声**
(见 `notes/p6/git-worktree-fix.md` §7.1),与本轮改动无关,未处理。
@@ -0,0 +1,512 @@
# Gate 7 — Redmi `2f7cbe2e` paired A/B, and gate 8 item 1 (the doorbell)
2026-09-22, branch `feat/disaggregated`, source head `98d0b96c`. `CONTRACT-P6.md` §9 items 7 and 8-①.
Everything below was measured on one reboot-clean thermal window on Redmi `M332BF` (`2f7cbe2e`,
SM8750, Adreno 830v2) from a **snapshot of the worktree** (`C:/Users/geekerwan/p6-snap/MobileGL-disagg`),
never from the shared tree: two other agents were editing `MobileGL/MG_Util/Metrics/PipeStats.*`
and the submodule pointers at the same time.
**Verdict up front.** The pair is a **tie**. `spawn` is *not* slower than `inproc` on this workload —
it is 0.15 % faster on the primary metric (mean of the two interleaved orders), deep inside the
run-to-run spread — and both are ~30 % *cheaper* than the `monolith` control on client CPU per frame
(+23 % in fps). The socket doorbell did **not** deliver the cheaper handoff the contract's §9 note
anticipated, but it also did not cost anything measurable, which is a different and more useful answer
than the note's.
Gate 8 item ① is **partially** delivered: the client half of the ledger is real on both arms and
**the server half is unmeasurable under `spawn` on this build**, for a structural reason found while
collecting it (§6). That is a finding, not a collection failure, and it is reported rather than
substituted with a number from the other arm.
---
## 1. Protocol actually executed
| item | value |
|---|---|
| device | Redmi `M332BF`, serial `2f7cbe2e`, SM8750 / Adreno 830v2 |
| reboot | `adb reboot`, `sys.boot_completed=1`, then boot id compared |
| boot id before | `e69d0329-2264-4a2d-9b00-e1a4ffb6154d` |
| **boot id after** | **`107f24d3-b1f1-4a97-ab8c-9706d1901f32`** (changed ⇒ the reboot is a fact, not a claim) |
| CPU pin | little `policy0` → 1555200, big `policy6` → 1958400 (`pin_device.sh 2f7cbe2e pin`) |
| GPU pin | `kgsl-3d0` `min/max_pwrlevel = 0` → **1050000000 Hz** (see §5.1 — this unit cannot reach 1100 MHz) |
| fan | `/sys/class/xm_power/hw_monitor/pwm_fan` `target_level=2`, `real_speed=14848` rpm, `pwm_duty=65` |
| arm order | `monolith, inproc, spawn, inproc, monolith, spawn` — interleaved, one window |
| repeats | `--benchmark-repeats 3`, `--benchmark-tail-frames 200`, `--benchmark-no-finish`, best-of-3 |
| workload | `minecraft-1.21.4-rd12-odinlite-in-world`, DirectGLES, 251 frames, **1471.36 draws/frame** |
| stats | `MOBILEGL_PIPE_STATS=1`, `MOBILEGL_PIPE_STATS_PERIOD=120` |
| split knobs | `MOBILEGL_IPC_ROLE_SPLIT_STATE=1`, `MOBILEGL_IPC_STRICT_ERRORS=1`, `MOBILEGL_IPC_RUN_AHEAD=1` |
| APK | `MobileGL-plugin-trace-release-p6gate7.apk`, sha256 `8882683b063db13e16236530007c9b364de55fe7a11fba968169ebe2942fcfaf` |
| 6 arms × 3 repeats | **18/18 runner exits 0, 36/36 pin checks `PINNED`, 0 `Fatal{`** |
Per-arm pin evidence is in each arm directory's `pin-before.txt` / `pin-after.txt`; both exit 0
(`PINNED`) for all six arms. The sampled temperature on `cpuss-0-0` across all 12 checks was
**37.6–46.5 °C** (min 37.6, max 46.5), i.e. no arm ran cold and none approached throttling.
### 1.1 The build
```bash
# from the SNAPSHOT, never the shared tree
cd /c/Users/geekerwan/p6-snap/MobileGL-disagg
export JAVA_HOME="C:/Program Files/Java/jdk-21"
export JAVA_TOOL_OPTIONS='-Djdk.net.unixdomain.tmpdir=C:\Users\geekerwan\.gradle\uds'
export ANDROID_HOME="C:/Users/geekerwan/AppData/Local/Android/Sdk"
"/c/Users/geekerwan/.gradle/wrapper/dists/gradle-8.13-bin/5xuhj0ry160q40clulazy9h7d/gradle-8.13/bin/gradle.bat" \
--no-daemon -p android-plugin :app:assembleTraceRelease \
-Pmobilegl.buildDisaggregated=ON -Pmobilegl.buildDisaggregatedInproc=ON -Pmobilegl.pipePush=ON \
-Pmobilegl.apkSuffix=p6gate7 -Pmobilegl.debuggableRelease=true \
-Pmobilegl.applicationIdSuffix=.p6gate7 -Pmobilegl.logLevel=MOBILEGL_LOG_LEVEL_INFO
# BUILD SUCCESSFUL in 10s (incremental; 2m09s cold)
```
Two build facts worth carrying forward, both stated by the CI workflow and both required:
- `-Pmobilegl.debuggableRelease=true` is what makes the release APK `run-as`-able. Without it
`trace-replay-ci.sh` cannot pull the private logs at all and every arm comes back with no library
log — indistinguishable from a run that produced none.
- The APK is **not** signed by Gradle here (`SIGNING_*` env is a CI secret). It was signed with the
standard debug key (`~/.android/debug.keystore`, `androiddebugkey`) so `run-as` works:
```bash
apksigner sign --ks ~/.android/debug.keystore --ks-pass pass:android \
--ks-key-alias androiddebugkey --key-pass pass:android \
--out trace-p6gate7-debugsigned.apk unsigned.apk
```
- `-Pmobilegl.applicationIdSuffix=.p6gate7` puts the build under
`top.mobilegl.plugin.p6gate7.trace`, **beside** an unrelated trace install under the canonical id
rather than replacing it. The runner's hard-coded package name is overridden by
`MOBILEGL_TRACE_PACKAGE` (snapshot-local; see §5.4).
### 1.2 The run
```bash
cd /c/Users/geekerwan/p6-snap/MobileGL-disagg
python tools/device_bench/p6/gate7_ab.py \
--tree "C:/Users/geekerwan/p6-snap/MobileGL-disagg" \
--out "C:/Users/geekerwan/p6-snap/evidence" \
--apk 'C:\Users\geekerwan\p6-snap\artifacts\trace-p6gate7-debugsigned.apk' \
--reboot --fan-level 2 --session gate7-rd12 \
--arm monolith:DirectGLES:minecraft-1.21.4-rd12-odinlite-in-world:3 \
--arm inproc:DirectGLES:minecraft-1.21.4-rd12-odinlite-in-world:3 \
--arm spawn:DirectGLES:minecraft-1.21.4-rd12-odinlite-in-world:3 \
--arm inproc:DirectGLES:minecraft-1.21.4-rd12-odinlite-in-world:3 \
--arm monolith:DirectGLES:minecraft-1.21.4-rd12-odinlite-in-world:3 \
--arm spawn:DirectGLES:minecraft-1.21.4-rd12-odinlite-in-world:3
```
Evidence root: `C:/Users/geekerwan/p6-snap/evidence/gate7-rd12/`. Six arm directories, each with
`arm-summary.json`, `benchmark-run{1,2,3}.json`, `mobilegl.client.log`, `mobilegl.server.log`,
`pin-before.txt`, `pin-after.txt`, `runner.stdout.txt`, `logcat.txt`; plus `session-summary.json`.
`tools/device_bench/p6/gate7_ab.py` and `render_gate7.py` are new in this snapshot (§7).
---
## 2. The paired A/B
Primary metric = **client thread CPU ms/frame**, the client GL thread's own
`CLOCK_THREAD_CPUTIME_ID` deltas summarised by the device over the trailing 200 frames
(`MEASUREMENTS.md` §9's rule; the kernel ignores this app's affinity requests, so a process total
mixes two clock domains). `fps` and wall p50/p95 are recorded beside it.
### 2.1 Best-of-3, per arm
| arm | order | frames | tail | wall p50 ms | wall p95 ms | **client CPU p50 ms** | CPU p95 ms | fps | client total ms | server total ms |
|---|---|---|---|---|---|---|---|---|---|---|
| monolith (a) | 1 | 251 | 200 | 11.320 | 12.369 | **11.231** | 12.267 | 86.32 | 11060 | — |
| inproc (a) | 2 | 251 | 200 | 7.971 | 18.196 | **7.880** | 17.466 | 106.04 | 8230 | 7800 |
| spawn (a) | 3 | 251 | 200 | 7.989 | 18.369 | **7.876** | 17.790 | 106.12 | 8930 | 8640 |
| inproc (b) | 4 | 251 | 200 | 8.001 | 18.072 | **7.889** | 17.489 | 105.78 | 8900 | 8390 |
| monolith (b) | 5 | 251 | 200 | 11.287 | 12.388 | **11.240** | 12.300 | 86.26 | 10860 | — |
| spawn (b) | 6 | 251 | 200 | 7.979 | 18.120 | **7.869** | 17.283 | 105.95 | 8960 | 8660 |
`client total` / `server total` are the per-role CPU **totals** the sampler accumulated over that
repeat. They include the single shader bring-up frame, so they are *not* per-frame rates and must not
be divided by 251 — see §5.2, where that mistake was made and caught.
### 2.2 The pair: `spawn` − `inproc`
Both orders shown rather than a cherry-picked one. The per-order column pairs are the two
repetitions of each transport; the Δ column uses their means.
| metric | inproc (a / b) | spawn (a / b) | Δ (mean) | Δ % |
|---|---|---|---|---|
| **client CPU p50 ms/frame** | 7.880 / 7.889 | 7.876 / 7.869 | **−0.012** | **−0.15 %** |
| client CPU p95 ms/frame | 17.466 / 17.489 | 17.790 / 17.283 | +0.059 | +0.34 % |
| wall p50 ms | 7.971 / 8.001 | 7.989 / 7.979 | −0.002 | −0.03 % |
| wall p95 ms | 18.196 / 18.072 | 18.369 / 18.120 | +0.111 | +0.61 % |
| fps | 106.04 / 105.78 | 106.12 / 105.95 | +0.125 | +0.12 % |
| client CPU total ms | 8230 / 8900 | 8930 / 8960 | +380 | +4.5 % |
| server CPU total ms | 7800 / 8390 | 8640 / 8660 | +555 | +6.7 % |
**The pair is a tie on every steady-state metric.** The per-order sign *flips* on the wall columns —
inproc is 0.03 % ahead in order (a) and spawn 0.03 % ahead in order (b) — which is the signature of
noise rather than of an effect. The client CPU p50 column is the one to read, and it is the primary
metric: 7.880 vs 7.876 in order (a) and 7.889 vs 7.869 in order (b), i.e. −0.15 % on the means.
The `* total ms` rows differ by 4.5–6.7 % and are **not** a contradiction: those totals include the
frame-1 shader compile, whose cost varies run to run by more than the transport's whole effect. The
per-frame columns exclude it by construction (the device summarises the trailing 200 frames), which
is exactly why the primary metric is the per-frame one.
### 2.3 Run-to-run spread, and why the tie is credible
All 18 repeats, client CPU p50 ms/frame:
| arm | rep 1 | rep 2 | rep 3 | spread |
|---|---|---|---|---|
| monolith (a) | 11.297 | 11.346 | 11.231 | 1.0 % |
| inproc (a) | 7.885 | 7.880 | 7.901 | 0.3 % |
| spawn (a) | 7.876 | 8.049 | 7.952 | 2.2 % |
| inproc (b) | 7.881 | 7.889 | 7.872 | 0.2 % |
| monolith (b) | 11.388 | 11.300 | 11.240 | 1.3 % |
| spawn (b) | 7.861 | 7.869 | 8.191 | 4.2 % |
Within-arm spread is 0.2–4.2 %, and the four `inproc`/`spawn` arms land in 7.87–7.95. The
between-transport difference (−0.006 ms) is an order of magnitude below the spread of the arms it is
drawn from. **This is a tie, stated as a tie.**
### 2.4 Against the `monolith` control
Not part of the pair, recorded per the protocol as the baseline row:
| comparison | client CPU p50 ms | Δ | fps | Δ |
|---|---|---|---|---|
| monolith → inproc | 11.236 → 7.885 | **−29.8 %** | 86.29 → 105.91 | +22.7 % |
| monolith → spawn | 11.236 → 7.873 | **−29.9 %** | 86.29 → 106.04 | +22.9 % |
Both split transports are ~30 % cheaper on client CPU than monolith on this workload, and the
`spawn` arm reaches it through a socket rather than a shared address space. **The doorbell's cost is
not visible at this workload's resolution.**
---
## 3. Arm proof (contract §9.5)
Every spawn entry recorded the child pid **and** `transport=spawn` in its own private log. The
runner also ran `--require-spawn`'s checks:
| arm | `Config: MOBILEGL_TRANSPORT=spawn` | `spawn ARMED - the server role runs in pid N` | server log pulled | verdict |
|---|---|---|---|---|
| spawn (a) | present | `pid 29990` | yes | OK |
| spawn (b) | present | `pid 22130` | yes | OK |
```
[Android MobileGLTraceRe/INFO]: MG_Remote client: spawn ARMED - the server role runs in pid 29990,
reached at "@mgl-23197-12970367462759574816"
```
Both `inproc` arms carry `Config: MOBILEGL_TRANSPORT=inproc - the MGPipe record stream crosses a
real ring to an apply thread` and a `Config: IPC` line reading
`ring=8MiB stage=32MiB spin=50us … verb-barrier=1 run-ahead=1 present-credit=1 strict=1
role-split-state=1`. Both `monolith` arms carry **neither** marker and **no** `Config: IPC` line —
the control stayed a control. Zero `Fatal{` records in any of the 12 role logs.
Logs are per-role as P6 requires: `mobilegl.client.log` and `mobilegl.server.log` both pulled for
every arm that has a server role. The monolith arms legitimately produce only the client file.
---
## 4. Gate 8 item ① — the doorbell ledger, and the arms measured together
### 4.1 The four counters
`srv` / `srvpark` = entries into the apply thread's `Doorbell::Wait` and the subset that stopped
spinning and blocked. `cli` / `clipark` = the same for the client producer. **Both pairs are RUN
TOTALS**, not window values — `PipeStats.h` says so beside the Gauge enum, and the rates below
therefore divide by the run's `frames=251`, **not** by the `window=` on the same line. Dividing by
`window=` inflates the rate by run/window (here ~21×) into a number that looks plausible and is not.
### 4.2 `inproc` (both arms agree)
| arm | srv | srvpark | cli | clipark | srv/f | srvpark/f | cli/f | clipark/f |
|---|---|---|---|---|---|---|---|---|
| inproc (a) | 2 738 247 | 1 543 | 30 407 | 1 548 | 10909.4 | 6.15 | 121.1 | 6.17 |
| inproc (b) | 2 727 377 | 1 281 | 30 407 | 1 399 | 10866.0 | 5.10 | 121.1 | 5.57 |
Read: **~10 900 server waits per frame** at 1471 draws/frame — about 7.4 waits per draw, on a shared
cache line, with only **0.06 % of them parking** (1 543 of 2.74 M). The client's own pair is far
smaller: **121 waits/frame, 0.06 % parking**. The spin budget is covering the handoff almost
completely, which matches the P5d/P5e characterisation (`SPIN_US=50`, parks near zero, the cost is
the rendezvous itself) and confirms that the contract's "1600 spins/frame on a shared cache line"
note is about the *right* quantity — the server side of the rendezvous, not the client's.
That ratio is the number that makes the socket comparison interesting: `inproc` pays 10 900
cache-line rendezvous per frame, and each one is a spin on memory both threads share.
### 4.3 `spawn` — the client half, and why the server half is missing
| arm | cli | clipark | cli/f | clipark/f | srv | srvpark |
|---|---|---|---|---|---|---|
| spawn (a) | 30 401 | 1 538 | 121.1 | 6.13 | **unavailable** | **unavailable** |
| spawn (b) | 30 401 | 1 530 | 121.1 | 6.10 | **unavailable** | **unavailable** |
The client-side numbers are **byte-for-byte the `inproc` client numbers** (30 407 vs 30 401, 121.1/f
both, parks within 1 %). That is the expected shape — the client does the same work either way — and
it is the first half of item ①: **the client's wait count and park rate are transport-invariant**, so
whatever the socket changes, it is not the client's wait frequency.
The server half is not collected because **the spawn server process never enables PipeStats at all**
(§6). Its client log prints `srv=0 srvpark=0` for the whole run, which is the "another process"
zero `PipeStats.h` warns about, not a measurement of zero waits. An earlier OpenRA spawn run showed
the same: no `MGPipe stats:` line in `mobilegl.server.log` and no `counters ON` line either.
### 4.4 What item ① can and cannot say
- **Client side**: measured on both arms. 121.1 waits/frame, 6.1 parks/frame, transport-invariant.
- **Server side under `inproc`**: measured. 10 866–10 909 waits/frame, 5.1–6.2 parks/frame, 0.06 % park rate.
- **Server side under `spawn`**: **not measureable on this build.** The relative cost of a socket
`read()`/`write()` handoff against a shared-cache-line spin cannot be quoted as a ratio from this
session, because one of its two halves does not exist.
The honest statement is therefore the one §2 already supports: at this workload the two transports
produce the **same steady-state client CPU per frame to within 0.2 %**, so a socket handoff replacing
a spin+park handoff costs *nothing detectable* — even though the doorbell's own accounting on the
spawn side is invisible. The contract's hypothesis (§9's note: "converting ~1600 spins/frame into
~4 blocking reads/frame may be cheaper") is **not falsified and not confirmed**; the observable it
should have moved, client CPU ms/frame, did not move in either direction.
---
## 5. Known issues and traps found while collecting
All five are reported because each one produced a plausible-looking wrong number before it was
caught, and three of them are in the harness rather than in MobileGL.
### 5.1 This unit cannot reach the GPU's top pwrlevel (device fact, hardcoded)
`pin_device.sh`'s new `2f7cbe2e` row pins kgsl `min/max_pwrlevel = 0`, but on **this** unit that
yields `gpuclk = 1050000000`, **not** 1100000000:
```
thermal_pwrlevel = 1 # latches: writing 0 returns success and reads back 1
max_gpuclk = 1050000000 # read-only; a write is accepted and ignored
devfreq/max_freq = 1050000000 # clamps a 1100000000 write down
```
So the row records `GPU_PINNED_FREQ=1050000000` — *the fastest frequency this board will actually
run*, not the top OPP — and `check` compares against the truth. Consequence for the campaign,
stated where the number is: **absolute times from this device are not comparable with the
`35d0befa` rows taken while it could reach 1100 MHz.** Only same-session pairs are.
### 5.2 A per-frame rate divided by the whole run (harness, fixed)
The sampler's steady-state reporting originally divided a replay's total per-role CPU by the frame
count. Frame 1 of this fixture costs **7332 ms** of client CPU (one shader/pipeline bring-up) against
7.7–8.5 ms for each of the other 250, so that column read ~32 ms/frame beside a device median of
7.9. The device's own two series (`frameCpuTimesMs[]`, and the `medianFrameCpuMs` it summarises over
the tail) are the correct source for per-frame figures; the sampler contributes **roles and totals**,
which is what the doorbell comparison needs and what the client-only series cannot express. Fixed in
`render_gate7.py` (`steady_state_cpu`, `role_totals`), and the reversal is documented there.
### 5.3 `--transport` is silently ignored in `--benchmark` mode (harness trap)
`run_android_retrace_local.py` applies `--transport` through `run_case()`, but `run_benchmark_case()`
passes `args.env` alone. **`--benchmark --transport spawn` runs monolith while its label says
spawn** — measured: a `--benchmark` run with `--transport inproc` wrote no `Config: IPC` line at all.
Every arm in §1 therefore spells its transport as an explicit `--env MOBILEGL_TRANSPORT=…` and every
arm is verified against the library's own log afterwards. Not changed in the tree (out of scope);
worth a fix in the runner or a guard in the arm-proof.
### 5.4 `MOBILEGL_RETRACE_USE_PBUFFER` must be exported, not passed as `--env` (harness trap)
`trace-replay-ci.sh` reads that variable **itself**, to add `--ez use_pbuffer true` to the intent. A
`--env` override only reaches the in-process applier of env knobs, which is a different mechanism, so
the retrace still asks for a window surface. That is fatal **on the spawn arm and only there**: an
`ANativeWindow*` is a pointer into the client's process and the wire refuses it by name (Rule H),
measured as a SessionFault carrying `Fatal{UnmigratedSurface, "AndroidNativeWindow@P12"}`. The
script's own environment is what makes it work.
### 5.5 The spawn arm needs a distinct package id (environment fact)
The machine carries a pre-existing trace install under `top.mobilegl.plugin.trace` signed with a key
that no longer exists in this session's reach, and two APKs signed by different keys cannot share an
id. Replacing it would have destroyed state this campaign does not own, so the gate-7 build was
installed as `top.mobilegl.plugin.p6gate7.trace` and the runner's package constant was overridden via
`MOBILEGL_TRACE_PACKAGE` in the **snapshot's** copy of `run_android_retrace_local.py` only (the tree
copy is untouched). The existing install was left exactly as found.
---
## 6. Finding: the spawn server process never enables PipeStats
**`MG_Util::PipeStats::Init()` is called from exactly one place, `MobileGL::Initialize()`
(`MobileGL/Init.cpp:123`). The spawn server process does not call `MobileGL::Initialize()`** — it
calls `MG_ConfigLoader::Init()` and then `MG_Backend::InitServerRoleForSpawn()`
(`MG_Remote/Server/ServerMain.cpp:181`, `:198`). Neither of those reaches `PipeStats::Init()`.
Consequences, all observed rather than inferred:
- `g_pipeStatsEnabled` stays `false` in the server process, so **none** of the 68 `PipeStats::Enabled()`
gates fire there — the four `wait[]` gauges, the ring gauges, the byte-class and call-class counters
and the summary line itself.
- `mobilegl.server.log` contains no `MGPipe stats:` line and no `counters ON` line, on either
workload (rd12, 251 frames; OpenRA, 128 frames), on both spawn arms and on the exploratory runs.
The client log's `wait[srv=0 srvpark=0]` is therefore structural, exactly as `PipeStats.h`
describes for the spawn case.
- The server-side **byte** and **record** classes (`buf/f`, `tex/f`, `maxrec`) are likewise
unavailable under spawn. Under `inproc` the same server role writes them into
`mobilegl.server.log` and they are collected normally.
This is **not** repaired here: it is a MobileGL source change, and this task is explicitly barred from
touching MobileGL. It is reported as a finding with its exact cause so the next stage can decide
whether `PipeStats::Init()` belongs in `InitServerRoleForSpawn()`/`ServerMain` (which would make the
gate-8 numbers collectable on both split transports) or whether the server's counters are meant to
stay a spawn-blind spot.
**It does not affect the §2 pair**: the primary metric is the client thread's own per-frame CPU clock,
taken from `benchmark.json` in the client process, where PipeStats is enabled and irrelevant.
---
## 7. What was added
| path | what |
|---|---|
| `tools/device_bench/pin_device.sh` (+28 lines) | the `2f7cbe2e` device row: policies/stock/pins, kgsl nodes, `thermal_pwrlevel=1` / 1050 MHz ceiling, thermal type `cpuss-0-0`. All three actions verified (§8) |
| `tools/device_bench/p6/gate7_ab.py` (snapshot only) | the reboot-clean / pin / interleave / arm-proof orchestrator and per-thread CPU sampler |
| `tools/device_bench/p6/render_gate7.py` (snapshot only) | evidence → markdown tables |
| `tools/trace_replay/run_android_retrace_local.py` (snapshot only) | `MOBILEGL_TRACE_PACKAGE` override (§5.5) |
Only `pin_device.sh` is in the shared tree. The other three exist in
`C:/Users/geekerwan/p6-snap/MobileGL-disagg` and should be promoted deliberately rather than as a
side effect of this run.
---
## 8. `pin_device.sh 2f7cbe2e` verification
Every value measured on this unit on 2026-09-22, then exercised through all three actions:
```
$ bash tools/device_bench/pin_device.sh 2f7cbe2e check # at stock
big policy6 gov=walt cur=3072000 min=1017600 max=3072000 (pin 1958400 / stock 1017600-3072000)
little policy0 gov=walt cur=2745600 min=384000 max=2745600 (pin 1555200 / stock 556800-2745600)
gpu kgsl-3d0 pwrlevel=0..12 freq=443000000 Hz busy=0% (pin lvl 0 = 1050000000 Hz / stock lvl 0..12)
thermal cpuss-0-0 35200 mC = 35.2 C (/sys/class/thermal/thermal_zone13)
VERDICT: UNPINNED rc=2
$ bash tools/device_bench/pin_device.sh 2f7cbe2e pin
... after pin ...
big cur=1958400 min=1958400 max=1958400 | little cur=1555200 min=1555200 max=1555200
gpu pwrlevel=0..0 freq=1050000000 Hz
VERDICT: PINNED - all 3 pinned nodes at their pins, live frequencies match. rc=0
$ bash tools/device_bench/pin_device.sh 2f7cbe2e unpin
little min=556800 max=2745600 | big min=1017600 max=3072000 | gpu pwrlevel=0..12
VERDICT: UNPINNED rc=0
```
Also verified: the pin **holds under load** (8 concurrent busy loops → still `PINNED`; a
partially-pinned state was correctly reported as `DRIFT` mid-experiment rather than silently
accepted) and is **stable at idle** over 90 s. Stock values are hardcoded, not sampled at pin time,
so a restore cannot read "stock" off an already-pinned device and make the pin permanent.
---
## 9. Judgement
**Gate 7 (paired A/B, reboot-clean, one thermal window) — met.** Six arms, three repeats each,
`spawn` interleaved against `inproc` in both orders within one window, every arm's pin checked before
and after, one boot id for the whole window with the reboot proven by the id change.
**Gate 8 item ① — client half met, server half blocked by §6.** Not dressed up: the ratio the item
asks for cannot be computed from this session, and the reason is a named, located code path rather
than a missed collection step.
**The performance reading.** `spawn` and `inproc` are the same transport as far as the client's CPU
per frame is concerned (−0.15 % with the sign flipping between orders), and both are ~30 % cheaper
than `monolith`. Per the campaign's standing rule these numbers are **recorded, not gated**. The
targeted question — does a socket doorbell beat a shared-cache-line spin+park — came back
**inconclusive rather than negative**: the client-side wait accounting is identical across
transports, and the server-side accounting that would have shown the difference is not being
collected under spawn at all.
---
## 10. Raw evidence, quoted
### 10.1 Boot id (the reboot, before and after)
```
$ adb -s 2f7cbe2e shell cat /proc/sys/kernel/random/boot_id # before `adb reboot`
e69d0329-2264-4a2d-9b00-e1a4ffb6154d
$ adb -s 2f7cbe2e shell cat /proc/sys/kernel/random/boot_id # after boot_completed=1
107f24d3-b1f1-4a97-ab8c-9706d1901f32
```
One boot id covers all six arms; the temperature range across the 12 pin checks was 37.6–46.5 °C on
`cpuss-0-0`.
### 10.2 Pin evidence, `inproc` arm (a) — representative of all six
```
== before pin ==
device : Redmi M332BF / SM8750 / Adreno 830v2 (2f7cbe2e)
big policy6 gov=walt cur=1958400 min=1958400 max=1958400 (pin 1958400 / stock 1017600-3072000)
little policy0 gov=walt cur=1555200 min=1555200 max=1555200 (pin 1555200 / stock 556800-2745600)
gpu kgsl-3d0 pwrlevel=0..0 freq=1050000000 Hz busy=4% (pin lvl 0 = 1050000000 Hz / stock lvl 0..12)
thermal cpuss-0-0 39500 mC = 39.5 C (/sys/class/thermal/thermal_zone13)
== after pin ==
... identical node values ...
VERDICT: PINNED - all 3 pinned nodes at their pins, live frequencies match.
```
…and, after the arm's three repeats:
```
== check ==
big policy6 gov=walt cur=1958400 min=1958400 max=1958400
little policy0 gov=walt cur=1555200 min=1555200 max=1555200
gpu kgsl-3d0 pwrlevel=0..0 freq=1050000000 Hz busy=22%
thermal cpuss-0-0 45300 mC = 45.3 C
VERDICT: PINNED - all 3 pinned nodes at their pins, live frequencies match.
```
Both exits are 0 (`PINNED`) for all six arms; `session-summary.json` records `pin_before_rc` and
`pin_after_rc` per arm.
### 10.3 Arm proof, verbatim
```
01 client: Config: MOBILEGL_TRANSPORT=inproc - the MGPipe record stream crosses a real ring to an apply thread
03 client: Config: MOBILEGL_TRANSPORT=spawn - the MGPipe record stream crosses a real ring to an
apply thread in ANOTHER PROCESS
03 client: MG_Remote client: spawn ARMED - the server role runs in pid 29990,
reached at "@mgl-23197-12970367462759574816"
06 client: MG_Remote client: spawn ARMED - the server role runs in pid 22130,
reached at "@mgl-16187-12970367462759644032"
02 client: Config: IPC ring=8MiB stage=32MiB spin=50us persistent-block=64KiB adopt-tier=2
verb-barrier=1 run-ahead=1 present-credit=1 strict=1 audit=0 role-split-state=1 affinity='auto'
01 client: (no `Config: IPC` line, no transport marker) <- the control stayed a control
```
### 10.4 The ledger lines, verbatim (`inproc` arm (b), client and server logs)
```
client: ... wait[srv=2727377 srvpark=1281 cli=30407 clipark=1399] gates[ers=...]
server: ... wait[srv=2694923 srvpark=1273 cli=30239 clipark=1392] gates[ers=...]
spawn arm (a), client log only — the server file has no summary line at all:
client: ... wait[srv=0 srvpark=0 cli=30401 clipark=1538] gates[ers=0/0 etl=0/0 eub=0/0 ...]
```
### 10.5 Byte classes and record size, `inproc` (gate 8 items ② and ③ input)
From the trailing window of the `inproc` arms (`window=120` / client `window=11`, `frames=251`):
| role | window | run frames | buf/f | tex/f | ubog/f | ubon/f | csob-blob/f | resid/f | maxrec | maxcap |
|---|---|---|---|---|---|---|---|---|---|---|
| client | 11 | 251 | 10 289.45 | 342 341.82 | 281 585.45 | 0 | 281 568.00 | 161.45 | 1808 | 4 194 304 |
| server | 120 | 251 | 10 290.00 | 357 348.93 | 281 586.00 | 0 | 281 586.00 | 163.00 | 1808 | 4 194 304 |
`maxrec=1808` against a 4 MiB cap (0.04 %) — **content chunking is not exercised by this workload**,
and `ringwaits=0`. These are recorded here as input to gate 8 items ②/③, not as the item's answer:
item ③ ("records/frame post-chunking") needs the per-record distribution, which this summary line
does not carry — `MEASUREMENTS.md:342` already says in as many words that no post-chunking
distribution exists, and this session does not create one. Item ② (`SEG_STAGE` bytes/frame) is
readable from `buf/f + tex/f + ubog/f + vtxc/f + idxc/f + icmd/f`, but only on the **`inproc`
server role**; under `spawn` the server-side byte classes are unavailable for §6's reason.
@@ -0,0 +1,333 @@
# Gate 8 item ① — the doorbell, measured on the device (server half included)
2026-09-22, branch `feat/disaggregated`, source head `71aa9951`. `CONTRACT-P6.md` §9 item 8-①.
The number this item asks for is the socket doorbell's cost against `inproc`'s condvar. The previous
session (`gate7-device-ab.md`) delivered the **client** half of that ledger and reported the
**server** half as structurally missing: `PipeStats::Init()` was called only from
`MobileGL::Initialize()`, which the spawn server process never runs, so every counter in it —
`ServerLoop`'s own `ServerWaits`/`ServerParks` publish included — was compiled in and permanently
false there. That gap is now closed in the tree (`MG_Remote/Server/ServerMain.cpp`, step 1.6 +
the matching `Shutdown`), and this session re-runs the focused protocol to collect the missing half.
**Verdict up front.** The server half is now real and reproducible: the spawn server counts
**10 415–10 416 server waits per frame**, against `inproc`'s **11 434–11 963**. The previous
session's judgement is unchanged — the pair remains a **tie** on client CPU per frame (7.992/8.009
vs 7.987/7.989, a 0.2 % difference with the arms' own spread larger than that), and both are ~30 %
cheaper than monolith. The doorbell hypothesis in §9's note — that a socket blocking read may be
*cheaper* than `inproc`'s spin+park — is now **answerable and answered: no measurable difference
either way on this workload.**
---
## 1. Protocol actually executed
Focused, not the full six-arm matrix: two transports, interleaved, best-of-2, one window.
| item | value |
|---|---|
| device | Redmi `M332BF`, serial `2f7cbe2e`, SM8750 / Adreno 830v2 |
| reboot | `adb reboot`, `sys.boot_completed=1`, boot id compared |
| boot id before | `107f24d3-b1f1-4a97-ab8c-9706d1901f32` |
| **boot id after** | **`7e6c6e9f-1894-4916-bf39-cce48c9abd37`** (changed ⇒ the reboot is a fact) |
| CPU pin | little `policy0` → 1555200, big `policy6` → 1958400 |
| GPU pin | `kgsl-3d0` `min/max_pwrlevel = 0` → **1050000000 Hz** (this unit cannot reach 1100 MHz) |
| fan | `target_level=2`, `real_speed=14404` rpm, `pwm_duty=65` |
| arm order | `inproc, spawn, spawn, inproc` — interleaved, one window |
| repeats | `--benchmark-repeats 2`, `--benchmark-tail-frames 200`, `--benchmark-no-finish`, best-of-2 |
| workload | `minecraft-1.21.4-rd12-odinlite-in-world`, DirectGLES, 251 frames, **1471.36 draws/frame** |
| stats | `MOBILEGL_PIPE_STATS=1`, `MOBILEGL_PIPE_STATS_PERIOD=120` |
| split knobs | `MOBILEGL_IPC_ROLE_SPLIT_STATE=1`, `MOBILEGL_IPC_STRICT_ERRORS=1`, `MOBILEGL_IPC_RUN_AHEAD=1` |
| **not set** | `MOBILEGL_PIPE_STATS_FILE` — deliberately absent this round (another agent is changing its role-suffix handling) |
| APK | `MobileGL-plugin-trace-release-p6gate8.apk`, sha256 `5eaf26c873c55fefb36f31f4a89364c71105d4cb8c0e6578f447d3a466378f86` |
| result | **4/4 runner exits 0, 8/8 pin checks `PINNED`, 0 `Fatal{`** |
Temperature across the 8 pin checks: **37.2 – 44.9 °C** on `cpuss-0-0`.
### 1.1 Build (from a snapshot, with the ServerMain fix)
```bash
# snapshot of the worktree, excluding .git / cmake-build-debug / build / .cxx / .trace-work /
# Testing / android-plugin/{app/build,.gradle,build}; the four nested 3rdparty/.../build dirs that
# name-matching also caught were restored afterwards
cd /c/Users/geekerwan/p6-snap2/MobileGL-disagg
export JAVA_HOME="C:/Program Files/Java/jdk-21"
export JAVA_TOOL_OPTIONS='-Djdk.net.unixdomain.tmpdir=C:\Users\geekerwan\.gradle\uds'
export ANDROID_HOME="C:/Users/geekerwan/AppData/Local/Android/Sdk"
"/c/Users/geekerwan/.gradle/wrapper/dists/gradle-8.13-bin/5xuhj0ry160q40clulazy9h7d/gradle-8.13/bin/gradle.bat" \
--no-daemon -p android-plugin :app:assembleTraceRelease \
-Pmobilegl.buildDisaggregated=ON -Pmobilegl.buildDisaggregatedInproc=ON -Pmobilegl.pipePush=ON \
-Pmobilegl.apkSuffix=p6gate8 -Pmobilegl.debuggableRelease=true \
-Pmobilegl.applicationIdSuffix=.p6gate8 -Pmobilegl.logLevel=MOBILEGL_LOG_LEVEL_INFO
# BUILD SUCCESSFUL in 2m 33s (cold, 53 tasks executed)
apksigner sign --ks ~/.android/debug.keystore --ks-pass pass:android \
--ks-key-alias androiddebugkey --key-pass pass:android --out signed.apk unsigned.apk
```
The source under test is the tree's own fix; `grep -c "PipeStats::Init()"
MobileGL/MG_Remote/Server/ServerMain.cpp` = 2 (init + the comment), and the call sits **after**
`MG_ConfigLoader::Init()` (so `Features.PipeStats` is already parsed and the latch reads the real
value) and **after** `MGPipeSetServerProcessRole(true)` (so the log sink files the line as the
server's).
### 1.2 The run
```bash
cd /c/Users/geekerwan/p6-snap2/MobileGL-disagg
python tools/device_bench/p6/ab_session.py \
--tree "C:/Users/geekerwan/p6-snap2/MobileGL-disagg" \
--out "C:/Users/geekerwan/p6-snap2/evidence" \
--apk 'C:\Users\geekerwan\p6-snap2\artifacts\trace-p6gate8-debugsigned.apk' \
--package top.mobilegl.plugin.p6gate8.trace \
--reboot --fan-level 2 --repeats 2 --session gate8-doorbell \
--arm inproc:DirectGLES:minecraft-1.21.4-rd12-odinlite-in-world:2 \
--arm spawn:DirectGLES:minecraft-1.21.4-rd12-odinlite-in-world:2 \
--arm spawn:DirectGLES:minecraft-1.21.4-rd12-odinlite-in-world:2 \
--arm inproc:DirectGLES:minecraft-1.21.4-rd12-odinlite-in-world:2
python tools/device_bench/p6/render_ab.py <session>/session-summary.json
```
Evidence root: `C:/Users/geekerwan/p6-snap2/evidence/gate8-doorbell/`. Four arm directories, each
with `mobilegl.client.log` **and** `mobilegl.server.log`, `pin-before.txt`, `pin-after.txt`,
`benchmark-run{1,2}.json`, `arm-summary.json`; plus `session-summary.json`.
---
## 2. Arm proof (contract §9.5)
| arm | transport marker | `spawn ARMED - …pid N` | server log pulled | verdict |
|---|---|---|---|---|
| inproc (a) | `MOBILEGL_TRANSPORT=inproc` | n/a | yes | OK |
| spawn (a) | `MOBILEGL_TRANSPORT=spawn` | `pid 18331` | yes | OK |
| spawn (b) | `MOBILEGL_TRANSPORT=spawn` | `pid 21400` | yes | OK |
| inproc (b) | `MOBILEGL_TRANSPORT=inproc` | n/a | yes | OK |
```
[Android MobileGLTraceRe/INFO]: MG_Remote client: spawn ARMED - the server role runs in pid 18331,
reached at "@mgl-13833-12970367463263226992"
[Android MobileGLTraceRe/INFO]: MG_Remote client: spawn ARMED - the server role runs in pid 21400,
reached at "@mgl-17377-12970367463263231936"
```
The per-thread CPU sampler corroborates the same topology independently — this is what "two
processes" means on the device, read straight out of `/proc/<pid>/task/*`:
```
inproc, repeat 1: pid 12188 tid 13614 client-gl MobileGLTraceRe 8990 ms
pid 12188 tid 13618 apply mgl-srv-apply 8590 ms <- SAME pid
spawn, repeat 1: pid 13831 tid 17165 client-gl MobileGLTraceRe 9000 ms
pid 17166 tid 17195 apply mgl-srv-apply 8060 ms <- different pid
spawn, repeat 2: pid 13833 tid 18329 client-gl MobileGLTraceRe 8920 ms
pid 18331 tid 18342 apply mgl-srv-apply 8570 ms <- == the armed pid
```
`Config: IPC ring=8MiB stage=32MiB spin=50us … verb-barrier=1 run-ahead=1 present-credit=1 strict=1
role-split-state=1` present on every arm. Zero `Fatal{` in all 8 role logs.
---
## 3. The doorbell ledger — the complete table
The four counters are **RUN TOTALS**, so the rates divide by the run's `frames=`, never by the
`window=` on the same line. Two structural facts decide *which log* each pair comes from, and both
were confirmed by this session rather than assumed:
- **`srv`/`srvpark`** are published by the apply thread into **its own process's** PipeStats. Under
`inproc` both roles are threads of one process, so the client's line carries them; under `spawn`
they are in `mobilegl.server.log` and the client's line prints `srv=0 srvpark=0` — a zero that
means "another process", not "never waited".
- **`frames=`** is bumped by `PipeStats::OnPresent`, which the backend calls on Present. Under
`spawn` present is applied **in the server process**, so the *client's* `frames=0 window=0` — the
same shape of structural zero. The rate denominators below therefore use the client's count under
`inproc` and the server's under `spawn`, and the column says which.
| arm | transport | counters from | frames from | `cli frames=` | `srv frames=` | srv | srvpark | cli | clipark | **srv/f** | **srvpark/f** | **cli/f** | **clipark/f** |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 01 | inproc | client-log | client-log | 251 | 240 | 2 869 874 | 1 341 | 30 407 | 1 444 | **11433.8** | **5.343** | **121.1** | **5.753** |
| 02 | spawn | client(cli)+server(srv) | server-log | 0 | 251 | 2 614 177 | 1 229 | 30 401 | 1 524 | **10415.0** | **4.896** | **121.1** | **6.072** |
| 03 | spawn | client(cli)+server(srv) | server-log | 0 | 251 | 2 614 456 | 1 170 | 30 401 | 1 525 | **10416.2** | **4.661** | **121.1** | **6.076** |
| 04 | inproc | client-log | client-log | 251 | 240 | 3 002 771 | 1 265 | 30 407 | 1 406 | **11963.2** | **5.040** | **121.1** | **5.602** |
**Wait, and a reading of each column.**
- **`srv/f` — the server's rendezvous rate.** `inproc` 11 434 / 11 963; `spawn` 10 415 / 10 416.
That is **7.1 per draw** on both spawn arms against **7.8 / 8.1** on the inproc arms
(1471.36 draws/frame). The spawn arm is **8.9–12.9 % lower**, which is the one directional
difference in this table; §3.1 says why it should not be read as a transport win.
- **`srvpark/f` — how often that rendezvous actually blocked.** 5.34 / 5.04 (`inproc`) against
4.90 / 4.66 (`spawn`). **0.042–0.047 % of server waits park** — the spin budget covers essentially
all of them on both transports. This is the P5d/P5e characterisation holding: the cost is the
rendezvous, not the wakeup.
- **`cli/f` — the client's own waits.** **121.1 on all four arms**, identical to a tenth. The client
does the same work either way; the transport does not change *how often* it waits.
- **`clipark/f` — the client's parks.** 5.60 / 5.75 (`inproc`) against 6.07 / 6.08 (`spawn`): the
spawn arm parks **5.5–8.5 % more often**. **4.6–5.0 % of client waits park** — two orders of
magnitude more than the server side, because the client's spin budget is the one being spent
against a producer that is frequently not yet there.
### 3.1 Why `srv/f` is not a like-for-like comparison
The `inproc` server's wait count and the `spawn` server's wait count are **not counting the same
event**, so the 11 % difference must not be read as "the socket rendezvous happens less often".
Under `inproc` the apply thread's `Doorbell::Wait` is *also* the mechanism by which the client's
queue drain is published inside one address space, and the client is in lockstep behind a verb
barrier for a large part of the trace; under `spawn` the same loop is woken by descriptor readiness.
Both counters count entries into `Doorbell::Wait`, but the number of times the two roles have to
agree per frame is a property of the transport's handoff discipline, not of the doorbell's cost.
What is comparable between the two columns is the *park ratio*, and that is near-identical
(0.042–0.047 % both ways).
The honest form of the item-① answer is therefore: **the server does ~10 400–12 000 doorbell waits
per frame on both transports, ~0.045 % of which block, and replacing the shared-cache-line spin with
a socket read does not change the client's per-frame CPU (below).**
---
## 4. Client CPU per frame — the tie holds
Primary metric = the client GL thread's own `CLOCK_THREAD_CPUTIME_ID` deltas, summarised by the
device over the trailing 200 frames (`MEASUREMENTS.md` §9's rule).
| arm | transport | frames | tail | wall p50 ms | wall p95 ms | **client CPU p50 ms** | CPU p95 ms | fps |
|---|---|---|---|---|---|---|---|---|
| 01 | inproc | 251 | 200 | 8.105 | 18.474 | **7.987** | 18.044 | 105.72 |
| 02 | spawn | 251 | 200 | 8.116 | 18.586 | **7.992** | 17.928 | 104.43 |
| 03 | spawn | 251 | 200 | 8.113 | 18.003 | **8.009** | 17.483 | 104.19 |
| 04 | inproc | 251 | 200 | 8.089 | 19.295 | **7.989** | 18.030 | 103.72 |
Per-repeat (all 8 runs, client CPU p50 ms):
| arm | rep 1 | rep 2 | spread |
|---|---|---|---|
| inproc (a) | 7.976 | 7.987 | 0.14 % |
| spawn (a) | 8.019 | 7.992 | 0.34 % |
| spawn (b) | 8.030 | 8.009 | 0.26 % |
| inproc (b) | 7.989 | 8.044 | 0.69 % |
**Pair delta: inproc 7.9880 (mean of 7.987/7.989) vs spawn 8.0005 (mean of 7.992/8.009) = +0.0125 ms,
+0.16 %.** The arms' own run-to-run spread is 0.14–0.69 %, i.e. the same order as the difference.
**This reproduces the previous session's tie** (there: inproc 7.880/7.889 vs spawn 7.876/7.869) on a
different build and a different boot: the two transports are indistinguishable on the metric that
matters, and what moved between the two sessions is the absolute level (~0.1 ms), which is
run-to-run drift, not a transport effect.
---
## 5. Log excerpts, verbatim (with `frames=` / `window=`)
Full-window values are elided to `…`; the `wait[...]` bracket and the frame/window fields are
original.
### 5.1 `inproc` arm (a)
```
client: MGPipe stats: frames=251 window=11 draws=16185 draws/f=1471.36 acc=65496 acc/draw=4.05
bytes/f[buf=10289.45 tex=342341.82 ubog=281585.45 … resid=161.45 csob-blob=281568.00 …]
tex[emit=6 box=6 rect=0 jobs=6] cso[csom=0 csob=208 mpr=0]
emit[fbe=68 sve=143 sse=0 sie=0 ctu=10 trp=0 rsp=0 vbs=16262 wrec=82523 wrec/f=7502.09
maxrec=1808 maxcap=4194304 ringwraps=61 ringpads=53 ringwaits=0]
wait[srv=2869874 srvpark=1341 cli=30407 clipark=1444]
server: MGPipe stats: frames=120 window=120 draws=1156748 draws/f=9639.57 …
wait[srv=2437659 srvpark=1227 cli=28409 clipark=1355]
MGPipe stats: frames=240 window=120 draws=176565 draws/f=1471.38 …
wait[srv=2829654 srvpark=1315 cli=30239 clipark=1431]
```
Both pairs are in the **client** log, because both roles are one process. The server log's last line
is at its previous period boundary (`frames=240`) — the apply thread does not emit the session-final
line; that is why the frame count for this arm comes from the client's `frames=251`.
### 5.2 `spawn` arm (a)
```
client: MGPipe stats: frames=0 window=0 draws=0 draws/f=n/a acc=0 acc/draw=n/a
bytes[buf=0 tex=0 … resid=174584 csob-blob=257348207 seg=2698318987]
tex[emit=0 box=0 rect=0 jobs=0] cso[csom=27 csob=20330 mpr=0]
emit[fbe=9142 sve=20821 sse=3 sie=0 ctu=1644 trp=0 rsp=0 vbs=0 wrec=6929844 wrec/f=6929844
maxrec=1808 maxcap=4194304 ringwraps=61 ringpads=52 ringwaits=0]
wait[srv=0 srvpark=0 cli=30401 clipark=1524]
server: MGPipe stats: frames=120 window=120 draws=1156748 draws/f=9639.57 …
wait[srv=2219341 srvpark=1137 cli=0 clipark=0]
MGPipe stats: frames=240 window=120 draws=176565 draws/f=1471.38 …
wait[srv=2582289 srvpark=1222 cli=0 clipark=0]
MGPipe stats: frames=251 window=11 draws=16185 draws/f=1471.36 …
wait[srv=2614177 srvpark=1229 cli=0 clipark=0]
```
**The two structural zeros and why neither is a measurement.** The client's `frames=0 window=0` says
this process counted no frames — it never presents, present is applied in the server. Its
`srv=0 srvpark=0` says the server's counters live in another process. Symmetrically the server's
`cli=0 clipark=0` is the client's own producer, which is not in that process. Reading any of the
three as "zero waits" is the mistake this table exists to prevent.
Also visible: the spawn **client** does count the byte and record classes (`resid`, `csob-blob`,
`wrec=6929844`, `ringwraps=61`), because those are published by the emitter that runs in the client
process; the spawn **server** reports `0` for them because the server publishes only what its own
role produces. So gate 8 item ② (`SEG_STAGE` bytes/frame) is readable on the client under both
transports; item ③ (records/frame) likewise (`wrec/f`), with the caveat that `wrec` is a client-side
emission count rather than the server's applied count.
---
## 6. The fix this session verifies
`MG_Remote/Server/ServerMain.cpp`, step 1.6:
```
MobileGL::MG_Util::PipeStats::Init(); // after MG_ConfigLoader::Init and
// MGPipeSetServerProcessRole(true)
```
plus the matching `PipeStats::Shutdown()` before `_exit(0)` (after `loop.Stop()`, so the apply
thread is not still publishing, and before `_exit`, which runs no atexit handler).
**Before/after on this protocol:**
| | previous session (`gate7-device-ab.md`) | this session |
|---|---|---|
| spawn server summary lines in `mobilegl.server.log` | **0** | **3** (frames=120, 240, 251) |
| `wait[]` in the spawn server log | absent | `srv=2614177 srvpark=1229` |
| gate 8 item ① server half | "structurally missing" | measured |
The client-side numbers are unchanged by the fix (`cli=30401 clipark=1524` here vs `30401 / 1538` in
the previous session), which is the expected shape: the fix arms counters in the server process and
touches nothing the client does.
**One thing it does not fix, reported rather than papered over:** the spawn **server** log's last
summary line lands at the session-final `frames=251 window=11` (the `Shutdown` call), but the spawn
**client** log's only line is the `frames=0 window=0` one — the client has no frame boundary of its
own to key a period on, so it emits exactly one line, at teardown. Downstream readers that index
"the last line" per role therefore get a well-formed line from each, but they must not assume the
two are the same window. The `frames from` column in §3 is what makes that unambiguous.
---
## 7. Judgement
**Gate 8 item ① — the measuring gap is closed.** Both halves of the doorbell ledger are on the
device, from both roles' own logs, on both transports: server waits **10 415–11 963 per frame**
(0.042–0.047 % parking) and client waits **121.1 per frame** on all four arms (4.6–5.0 % parking).
**On the contract's §9 note.** That note predicted a socket blocking read might be *cheaper* than
`inproc`'s ~1600 spins/frame on a shared cache line. The measurement says **neither cheaper nor more
expensive**: client CPU per frame is 7.992/8.009 (`spawn`) against 7.987/7.989 (`inproc`) — a +0.16 %
difference well inside the 0.14–0.69 % run-to-run spread, reproducing the previous session's tie at a
different absolute level. The server's own wait *count* is 8.9–12.9 % lower under `spawn`, but §3.1
explains why that is a property of the handoff discipline rather than of the doorbell, and the
comparable quantity (park ratio) is the same on both.
Stated plainly: **on this workload, at this resolution, the doorbell's implementation — futex-backed
condvar with a spin budget versus a one-byte socket write — is not a measurable cost.** Both are
~30 % cheaper than `monolith` on client CPU per frame. Per the campaign's standing rule these
numbers are recorded, not gated.
**Caveats, not hidden.** The exercise was one workload (rd12 in-world, DirectGLES) and one device;
the reference note's "1600 spins/frame" figure was for a heavier scene, so a workload with more
rendezvous per frame is where a difference would have to show up if it exists at all. The
`spawn` arm's `clipark/f` is 5.5–8.5 % higher than `inproc`'s (6.07 vs 5.60/5.75) — the only column
where the socket looks marginally worse, and it is ~0.4 extra parking events per frame out of 121.
@@ -0,0 +1,911 @@
# gate8-wire-counters — 门 8 的两个 wire 计数器、首采,与 spawn server 侧的 wait 账
> 头 `71aa9951`(`git rev-parse HEAD`,写报告时读取;工作树另有并行 agent 在修子模块指针,未 commit)。
> 环境:`wsl -d Ubuntu`,clang-20 / Ninja / Release / `MOBILEGL_BUILD_TYPE=Release`,
> 快照 `~/w7/p6-gate8-src`(从 Windows 工作树 copy,见 §1),构建目录 `~/w7/p6-gate8-build`。
> 交付内容:
> 1. `CONTRACT-P6.md` §9 item 8 的三个数里的**后两个**(`SEG_STAGE` 字节/帧、chunking 后记录/帧),
> 外加可选加分项:分块后 `maxrec` 与逐 blob 大小分布(§2–§6);
> 2. **spawn server 进程的 PipeStats 初始化缺陷及其修法**(§9)——该缺陷使 item 8 的**第一个数**
> (socket 门铃 vs inproc condvar)在 spawn 臂上只有 client 半边(§9.1.2 把"哪条车道哪半边"
> 逐条查清,并把 client 半边**本来就是有效测量**这件事写明);
> 3. **`MOBILEGL_PIPE_STATS_FILE` 的 spawn 角色分路**(§9.7),含 G1 上被抓出的三个泄漏点。
> 本报告不改 `MEASUREMENTS.md` / `CURRENT_STAGE_PROGRESS.md` / `ROADMAP.md`;数字供后续合并引用。
---
## 0 一句话
两个计数器都落在**唯一的收口点**上(stage 分配器、记录提交点),因此覆盖是构造性的而不是一张名单;
首采两负载的稳态数:`SEG_STAGE` **130.8 KB/帧(OpenRA 中位数)到 816.2 KB/帧(rd12 in-world 中位数)**、
记录 **211 条/帧到 7,497 条/帧**;含启动加载窗的均值分别高到 8.01 MB/帧与 1.53 MB/帧,
报告把两种读法都列出来(§6.4)。**默认 chunk 预算 8 MiB 在两条负载上都没被触发**
(`maxrec` 恒为 1808),这恰好印证 `MEASUREMENTS.md:342` 说的"分块后没有新测量"——
分块的效果必须人为压小预算才看得到(§6.5),而那一组 A/B 给出了一个清楚的负结果。
**§9 是后加的第二件事,也是这一轮真正的收尾缺口**:spawn 下 server 进程从不调用
`PipeStats::Init()`,于是该进程里所有 `Enabled()` 门恒为 false——它**一条汇总行都没有**,
而缺行的不是 client。修法是 server 角色初始化的唯一收口点加一行 `Init()`(§9.2),
配一行 `Shutdown()`。修后 spawn 的 server 半边首次可测:**`srv` 15.00 waits/帧、`srvpark` 4.38 parks/帧**(§9.4)。
**§9.7 是同一轮的第三个缺口**:两角色现在都会写 `MOBILEGL_PIPE_STATS_FILE`,于是 dump 路径按角色
派生成 `<base>.client.json` / `<base>.server.json`,基名不再指向任何文件(§9.7.1)。
这一条在 G1 上**红了三次**才做对(§9.7.3),每一次都是"guard 了新代码但动了旧代码"。
---
## 1 来源快照与可复现性
构建走的是快照而不是 Windows 工作树,因为同时有两个并行 agent 在改树(一个修子模块指针、
一个加 wire 计数器)。快照脚本 `~/w7/mgs/p6-snapshot.sh` 用 `tar` 排除 `build/`、`.git/`、
`android-plugin/`、`tools/device_bench/`,然后**补回两处被 `*/build` 误伤的东西**:
- `3rdparty/*/build/`(xxHash 的 CMake port 在 `3rdparty/xxHash/build/cmake`,是 submodule 内容不是产物;
被排除后 configure 直接 `add_subdirectory` 报错)——脚本 `p6-fix-builddirs.sh`;
- `android-plugin/app/src/trace/cpp/`(`tools/trace_replay/CMakeLists.txt` 编译其中三个文件,
排除 `android-plugin/` 会让 `mobilegl_trace_replay` 目标 "No SOURCES given")——脚本 `p6-build-retrace.sh` 自动补。
构建配方逐字照 `.github/workflows/test.yml:942-957`(split)与 `:219-227`(pull control),
只把 `clang-20`/`clang++-20` 换成同名可执行文件(Ubuntu 24.04 装了 `clang-20` 1:20.1.2-0ubuntu1~24.04.3)。
`~/w7/p6-gate8-build` 是本次任务书点名的目录,没有触碰任何既有目录。
单元测试与构建是**同一个** split 配置(`MOBILEGL_PIPE_PUSH=ON` + `-DMOBILEGL_BUILD_DISAGGREGATED=ON`
+ `-DMOBILEGL_BUILD_DISAGGREGATED_INPROC=ON`),与 CI 的 `build-linux-split` job 同源。
---
## 2 设计取舍
### 2.1 `SEG_STAGE` 字节/帧:ByteClass,不是 Gauge
`ByteClass` 是**窗口化求和**(`AddBytes` → `g_frameBytes`/`g_totalBytes`,`OnPresent` exchange 清零,
`FormatWindowLine` 除以 window 帧数),而 `Gauge` 按设计是**运行总量**(`PublishGauge` 是 store 不是 add,
见 `PipeStats.h` 的 Gauge 枚举长注释)。门 8 要的是"字节/**帧**",所以必须是 ByteClass——
`FormatWindowLine` 已经替它做了除法,`perFrame` 标注规则也自动适用。
### 2.2 记录/帧:CallClass + 一个额外的 `wrec/f` 字段
`WireRecords` 是 `CallClass`,于是 `wrec=` 跟 `draws=` 一样是**窗口计数**。
但"记录/帧"是交付数字,让操作者自己去除是没必要的,所以汇总行**另印一个 `wrec/f=`**,
与 `draws/f=` 同形。二者按 `perFrame` 规则联动:窗口内没有 Present 时**两个都退回窗口总量**
(`wrec=47 wrec/f=47`),这是该文件既有的标注规则,不是为这个字段新造的——
`PipeStatsTest.SummaryLineSurvivesZeroFrames` 钉住的正是同一个坑(47 倍高估)。
### 2.3 逐 blob 分布:第二个直方图,push-only
`MEASUREMENTS.md:342` 说的是"分块后**逐 blob 字节分布**尚无新测量",而现有 `maxrec` 是**最大值**、
`seg` 是**平均值**,两个都答不了这个形状问题:8 MiB 的平均值在"一个 8 MiB blob"和"两个本该被切成一个的 4 MiB blob"
下是同一个数。所以另立 `staged-blob-bytes-histogram`,**复用 `PayloadBucketOf` 的桶边界**(bucket 0 = 0 字节,
bucket n>0 = [2^(n-1), 2^n)),使两个直方图读法一致;它是**运行总量**(分布没有"窗口"可言),
经 JSON dump 出口——Tracy 画不了分布,这与既有 `cmd-bytes-per-draw-histogram` 的选择一致。
### 2.4 落点为什么在收口点而不是调用方
任务书要求"覆盖所有写入者,不要在调用方重复计数"。两个落点都满足:
- **stage 字节**:`PipeWireEncoder::StageAllocate`(`PipeWireCodec.cpp:853` 之后)。全树只有一个
`StageAllocate` 调用点(`StageBytes`,`PipeWireCodec.cpp:936`),而 `StageBytes` 是各 emitter
(`WireTables.cpp` 的 `StageRequired`/`StageOptional`、`SetProgramBindings` 的逐个名字、
`WireTables.cpp:549` 的 program archive、`EmitTables.cpp` 的 storage block name)唯一的入口。
加在调用方就等于维护一张"今天存在哪些 producer"的名单,下一个 producer 漏掉时**没有任何东西会响**——
这正是 `PipeStats.cpp` 顶部 site inventory 存在的理由。
- **记录数**:`PipeWireEncoder::EncodeRecord`(`PipeWireCodec.cpp:1171` 之后,`Reserve` 成功、
正文写完、blob 槽诚实性检查之后、`++m_emitSeq` 之前)。放在 emitter 侧**不可能正确**:
分块之后一次应用调用产生几条记录只有 encoder 知道。同一个函数内部**不重复计数**,因为
`slot == nullptr` 时提前 `return kInvalidSeq`,重试成功才计一次。
### 2.5 边界:`size` 不是 `need`,pad 不算记录
- stage 字节记 **`size`(生产者给的字节)**,不记 `Align8(size)`:对齐衬垫是分配器的、
不是生产者写的内容;记 `need` 会让"8 字节 blob 的负载"和"16 字节 blob 的负载"看起来一样。
分配器的 wrap skip 同理排除。
- 记录数**不含 `kRecPad` 填充**:pad 没有 opcode、两侧都跳过、已经由 `ringpads` gauge 可见。
不含被 `Reserve` 拒绝的那次发射,正是 2.4 里那个提前 return 保证的。
### 2.6 push-only(G1)
两个计数器都在 `#if MOBILEGL_PIPE_PUSH` 下,与该文件每个后来加的计数器一致。
`StageSegmentBytes` 在 `ByteClass` 枚举的 push 块内(该枚举的 push 块是 P4a 开的先例),
`WireRecords` 在 `CallClass` 枚举的 push 块内,`RecordStagedBlobBytes`/`TotalStagedBlobBucket`/
`g_totalStagedBlobBuckets`/JSON 行同样。理由:唯一采样者是 wire encoder,pull build 不编译 MG_Remote,
这些符号在那里永远采不到样本。
### 2.7 现有计数器有没有能直接回答这两个数的
没有,都已核对:
| 候选 | 为什么答不了 |
|---|---|
| `stage-buffer`/`stage-texture`(ByteClass) | 是**后端**在 apply 线程往**驱动**搬的字节;分块后一条记录的多个 blob 会分别被 server 应用,所以它们与 wire 侧字节不是一回事,且无法回答"发射侧写了多少" |
| `cso-blob-bytes` | 只覆盖 CSO blob(`WireTables.cpp:540`),不含 buffer 走查与纹理 slab |
| `ClientTextureUploadEmissions`/`framebuffer/sampler-*` | 是**发射调用**数,不是记录数;分块后一次调用可出多条记录 |
| `maxrec` gauge | 单条最大记录字节,不是每帧字节,也不是记录条数 |
| `vbs` | 服务端 verb 边界戳,与发射记录数不同(rd12 上是 1478 vs 7493) |
| `cmd-bytes-per-draw-histogram` | 是 **draw payload** 分布,不是 stage blob 分布 |
所以是**两个新计数器 + 一个可选直方图**,没有重复计数。
---
## 3 计数落点(file:line)
行号为改后内容。
| 计数器 | 文件:行 | 说明 |
|---|---|---|
| `ByteClass::StageSegmentBytes` 枚举成员 | `MobileGL/MG_Util/Metrics/PipeStats.h:115` | push 块内,在 `CsoBlobBytes` 之后 |
| 名称表 `"stage-segment-bytes"` | `MobileGL/MG_Util/Metrics/PipeStats.cpp:228` | JSON key 与 `NameOf` |
| 短名 `"seg"` | `MobileGL/MG_Util/Metrics/PipeStats.cpp:263` | 摘要行 `bytes/f[...]` 括号内 |
| `CallClass::WireRecords` 枚举成员 | `MobileGL/MG_Util/Metrics/PipeStats.h:251` | push 块内,在 `ServerVerbBoundaries` 之后 |
| 名称表 `"wire-records"` | `MobileGL/MG_Util/Metrics/PipeStats.cpp:238` | |
| 直方图声明 `RecordStagedBlobBytes`/`TotalStagedBlobBucket` | `MobileGL/MG_Util/Metrics/PipeStats.h:382-386` | push-only |
| 直方图存储 | `MobileGL/MG_Util/Metrics/PipeStats.cpp:169` | 复位在 `ResetCounters` 内 |
| 直方图采样实现 `RecordStagedBlobBytes` | `MobileGL/MG_Util/Metrics/PipeStats.cpp:403` | |
| 直方图读取 `TotalStagedBlobBucket` | `MobileGL/MG_Util/Metrics/PipeStats.cpp:464` | |
| 直方图 JSON 行 `staged-blob-bytes-histogram` | `MobileGL/MG_Util/Metrics/PipeStats.cpp:708` | 与 `cmd-bytes-per-draw-histogram` 同级,**在其之后** |
| **stage 字节与逐 blob 采样(唯一落点)** | `MobileGL/MG_Remote/Wire/PipeWireCodec.cpp:878`(`StageAllocate` 内) | `AddBytes(StageSegmentBytes, size)` + `RecordStagedBlobBytes(size)` |
| **记录数(唯一落点)** | `MobileGL/MG_Remote/Wire/PipeWireCodec.cpp:1188`(`EncodeRecord` 成功路径) | `AddCalls(WireRecords, 1)` |
| `#include <MG_Util/Metrics/PipeStats.h>` | `MobileGL/MG_Remote/Wire/PipeWireCodec.cpp:44` | 新增,MG_Remote 已有的跨层 include 先例(`WireTables.cpp:50`) |
| 摘要行新字段 `wrec=` | `MobileGL/MG_Util/Metrics/PipeStats.cpp:581-586` | `wrec=` + `wrec/f=`,`perFrame` 联动 |
**额外改动的文件:无超过任务书授权范围的文件。** 授权清单里的 `EmitTables.cpp` **没有改**
(`git diff` 显示与 HEAD 逐字节相同):Gauge 发布点本来就在那里且已覆盖 `maxrec`,`seg`/`wrec`
落在 `PipeWireCodec.cpp` 更准确,所以没有理由动它。任务书允许的"极小 Wire/ 或 Client/ 内部改动"
最终只需要 `PipeWireCodec.cpp` 一个 include 加两处计数(后者本就在授权清单内)。
---
## 4 单元测试
新增 8 个用例(`MobileGL/MG_Test/Util/PipeStatsTest.cpp`);前 6 个属于 §2–§6 的两个计数器,
后 2 个属于 §9 的 spawn server 缺陷:
| 用例 | 钉什么 |
|---|---|
| `StageSegmentBytesIsAWindowedByteClassLikeAnyOther` | 累加、Present 清帧、`seg=6144.00`、空窗口退回 `bytes[...]` 总量标签 |
| `WireRecordsCarryBothTheWindowCountAndItsPerFrameForm` | `wrec=26` + `wrec/f=2.00`(26/13 帧) |
| `WireRecordsFallBackToTheWindowTotalWithoutAFrame` | 无 Present 时 `wrec/f=47` 而非 `47.00`(既有的 47 倍高估坑) |
| `StagedBlobHistogramSharesThePayloadBucketEdges` | 桶边界与饱和行为与 draw payload 直方图一致 |
| `StagedBlobHistogramIsARunTotalAndReachesTheJsonDump` | Present 不清分布;JSON 两个数组的顺序关系 |
| `CounterNamesAreStable` / `SummaryLineCarriesEveryClassAndGate`(扩写) | 长名 `stage-segment-bytes`/`wire-records`、短名 `seg=`/`wrec=`/`wrec/f=` 的稳定性(改名会破坏所有已记录基线) |
| `InitIsTheOnlyLatchAndItTakesTheConfigBothWays`(§9) | `Init()` 必须**两个方向**都从配置取值:true 时武装、false 时**清掉**旧 latch。单向版本会通过单边测试,却让 `MOBILEGL_PIPE_STATS=0` 在长生命周期进程里失去意义 |
| `PresentationsAtTheDefaultPeriodOfOneMakeOneWindowEach`(§9) | k 次 present → k 个窗口(车道费率所除的分母);并钉住 gauge 是运行总量、**不是求和** |
结果(§9 修完之后):
```
$ cd ~/w7/p6-gate8-build && ctest -L unit -R "PipeStats" --output-on-failure
100% tests passed, 0 tests failed out of 24
```
全量 unit 与 Wire 层回归(同一 split 构建):
```
$ ctest -L unit --no-tests=error -j 24
100% tests passed, 0 tests failed out of 2366 (80.64 s) # 22 → 24 是本轮新增的两个
$ ctest -L unit -R "Wire|Codec|Ring|Staged|ServerLoop|RemoteClient|Session" --no-tests=error -j 16
100% tests passed, 0 tests failed out of 295 (36.95 s)
```
**一处需要说明的非失败项**:首次跑 `ctest -L unit` 时有 1 个红 ——
`PipeCatalogue.FrontendNeverTakesAnApplierAddress`,报
`frontend source unavailable: "/MobileGL/MG_Impl"`。这是**构建目录在源码树之外**造成的:
该用例从 CWD 向上找 `MobileGL/MG_Impl` 做源码文本走查,而 CI 是 in-tree build
(`cmake -S . -B build-linux`)所以能找到。验证:把 `/home/swung/w7/MobileGL` 做符号链接指回快照后,
该用例 34/34 全绿,全量 2366/2366 全绿。**与本次改动无关**,但在离树构建下会红,记录在此。
---
## 5 G1:pull 构建符号集与 `.text`
协议 §9 item 1:pull build 必须 0/0/0/0 且 `.text` 不变。做法是**两棵同源树、只翻三个 MGPipe 选项**:
- `~/w7/p6-gate8-g1-baseline`:快照 + 本次改的 6 个文件**回退到 `git HEAD` 内容**(从工作树 `git show` 取出),pull 选项;
- `~/w7/p6-gate8-g1-head`:快照 + 本次内容,**同一套 pull 选项**。
脚本 `~/w7/mgs/p6-g1.sh`(configure 与 build 都 `rc=0`),复核脚本 `~/w7/mgs/p6-g1-verify.sh`。
两边 `.so` 磁盘字节数相同(18,506,968)。
```
$ python3 scripts/symbol_report.py --before ...g1-baseline.so --after ...g1-head.so \
--threshold 0 --fail-on-symbol-set-change --fail-on-added-bytes 0 --fail-on-text-delta
symbol-report: before: .../p6-gate8-g1-baseline.so (18506968 bytes on disk)
symbol-report: after : .../p6-gate8-g1-head.so (18506968 bytes on disk)
symbol-report: .text 10158850 -> 10158850 (+0, +0.000%)
symbol-report: .data 77864 -> 77864 (+0)
symbol-report: .bss 1296856 -> 1296856 (+0)
symbol-report: .rodata 1521854 -> 1521854 (+0)
symbol-report: Total 16585193 -> 16585193 (+0)
symbol-report: 27863 -> 27863 defined symbols: 0 added, 0 removed, 0 resized, 0 renamed
symbol-report: 27122 -> 27122 normalised names, 27122 unchanged (name, size and mangling all identical)
G1 rc=0
```
`.text` 段直接逐字节比对(`objcopy -O binary --only-section=.text` + `cmp`):
**`b.text` 与 `h.text` 各 10,158,850 字节,`cmp` 无差异 → `.text` BYTE-IDENTICAL**(强于 gate 要求的 +0)。
补充两条该门惯用的正向断言:
```
$ nm --defined-only ...g1-head.so | grep -ci "MG_Remote" → 0 (pull build 无 MG_Remote)
$ nm --defined-only ...g1-head.so | grep -ciE \
"RecordStagedBlobBytes|StageSegmentBytes|WireRecords" → 0 (新计数器 push-only,未泄漏进 pull)
$ nm --defined-only ...g1-head.so | wc -l → 30601
```
(`.so` 的 sha256 两侧不同,符合预期:`-g` 的调试信息带源码路径与时间戳;
门 1 要的是**符号集与 `.text`**,两者都已证明不变。)
---
## 6 首采:真实 trace 的 inproc retrace
### 6.1 方法
`MOBILEGL_PIPE_STATS=1` + `MOBILEGL_PIPE_STATS_PERIOD=1`(每个 `eglSwapBuffers` 一行,
所以每行 `window=1` 恰好一帧),车道 = `MOBILEGL_TRANSPORT=inproc` +
`MOBILEGL_IPC_ROLE_SPLIT_STATE=1` + `MOBILEGL_IPC_STRICT_ERRORS=1` + `MOBILEGL_IPC_RUN_AHEAD=1`,
后端 `DirectGLES`,库/服务端 `~/w7/p6-gate8-build/lib{MobileGL,MobileGLServer}.so`。
两负载:
1. **OpenRA**(`MobileGLTraceReplay.OpenRA.DirectGLES.SPLIT`,ctest 条目原样跑过:Passed 1.67 s,
SSIM **1.000000**,mismatch 0);
2. **minecraft-1.21.4-rd12-odinlite-in-world**(任务书推荐的负重载;该 case 在 `trace_cases.json`
里没有 `"split": true`,所以没有 SPLIT ctest 条目,改为按 `run_trace_case.cmake` 的同一命令行手工驱动;
SSIM **0.999998**,17226 mismatch 像素,阈值 0.99 下通过)。
**汇总行落在哪个 role 日志**:inproc 下 PipeStats 是一个进程里的一套计数器,
`OnPresent` 由**服务端 apply 线程**调用,所以 250 行摘要全在 `mobilegl.server.log`;
`mobilegl.client.log` 只有一行 `counters ON` 横幅。**两者不重复**,脚本读两个文件也不会双计
(已实测:client 1 行、server 250 行)。
**统计口径**:`PERIOD=1` 下每行 window=1,故
"窗口字段求和 / 帧数" = 运行平均每帧;**gauge 字段是运行总量,取最后一行,不能求和**
(本报告脚本第一版就是求和,得出 `maxrec=452000` 而真实最大值是 1808,已改正——
这正是 `PipeStats.h` Gauge 注释警告的那类错误)。
### 6.2 原始 stats 行(摘录)
rd12 in-world,第 250 帧(末行,`window=1`):
```
MGPipe stats: frames=250 window=1 draws=1472 draws/f=1472.00 acc=5958 acc/draw=4.05
bytes/f[buf=10320.00 tex=950528.00 ubog=281616.00 ubon=0.00 vtxc=0.00 idxc=0.00 icmd=0.00
pmap=0.00 resid=184.00 csob-blob=281808.00 seg=2914784.00]
tex[emit=2 box=2 rect=0 jobs=2] cso[csom=0 csob=20 mpr=0]
emit[fbe=8 sve=13 sse=0 sie=0 ctu=3 trp=0 rsp=0 vbs=1479 wrec=7532 wrec/f=7532.00
maxrec=1808 maxcap=4194304 ringwraps=61 ringpads=53 ringwaits=0]
wait[srv=115580 srvpark=1911 cli=30393 clipark=10580]
gates[ers=1458/21 etl=1466/13 eub=1479/0 mfp=0/0 mpm=0/0 mdt=0/0]
```
OpenRA,第 29 帧(末行):
```
MGPipe stats: frames=29 window=1 draws=30 draws/f=30.00 acc=186 acc/draw=6.20
bytes/f[buf=86184.00 tex=32768.00 ubog=112.00 ubon=0.00 vtxc=0.00 idxc=0.00 icmd=0.00
pmap=0.00 resid=248.00 csob-blob=112.00 seg=129552.00]
tex[emit=1 box=1 rect=0 jobs=1] cso[csom=0 csob=31 mpr=0]
emit[fbe=0 sve=27 sse=0 sie=0 ctu=1 trp=0 rsp=0 vbs=31 wrec=211 wrec/f=211.00
maxrec=1808 maxcap=4194304 ringwraps=0 ringpads=0 ringwaits=0]
wait[srv=482 srvpark=104 cli=267 clipark=87]
gates[ers=0/31 etl=4/27 eub=31/0 mfp=0/0 mpm=0/0 mdt=0/0]
```
### 6.3 全跑聚合(含加载窗;稳态口径见 6.4)
```
################ OpenRA inproc split, DirectGLES ################
summary lines : 29 windows with a frame : 29 frames covered : 29
field window total per frame
seg 233979394 8068254.97 <- SEG_STAGE 字节/帧
wrec 5706 196.76 <- 记录/帧(分块后)
buf 23728752 818232.83
tex 151814144 5234970.48
csob-blob 30398 1048.21
draws 758 26.14
gauges - RUN TOTALS
maxrec 1808 maxcap 4194304 ringwraps 0 ringpads 0 ringwaits 0
################ rd12-odinlite-in-world inproc split, DirectGLES ################
summary lines : 250 windows with a frame : 250 frames covered : 250
field window total per frame
seg 2698026763 10792107.05 <- SEG_STAGE 字节/帧
wrec 6922378 27689.51 <- 记录/帧(分块后)
buf 95916960 383667.84
tex 641567296 2566269.18
csob-blob 257066831 1028267.32
draws 1348027 5392.11
gauges - RUN TOTALS
maxrec 1808 maxcap 4194304 ringwraps 61 ringpads 53 ringwaits 0
```
### 6.4 门 8 两个数的答案
**先说一件必须说清楚的事:全跑"每帧平均"被第一帧的加载窗严重拉高。**
`PERIOD=1` 下第一行 `frames=1 window=1` 覆盖的是**整个启动/加载**(rd12 那一帧有 981,654 draws、
5,053,895 条记录、`seg=2,317,939,359`),而其余每帧只有约 1,471 draws。所以直接"窗口求和 / 帧数"
得到的是**含加载的均值**,不是稳态值。两种读法都列出来,选用取决于问题:
| | OpenRA | rd12 in-world |
|---|---:|---:|
| **含加载的均值** `seg` | 8,068,255 B/帧 | 10,792,107 B/帧 |
| **含加载的均值** `wrec` | 196.8 条/帧 | 27,689.5 条/帧 |
| **稳态均值**(丢弃前 10% 窗口)`seg` | 8,012,890 B/帧 | 1,534,555 B/帧 |
| **稳态均值**(丢弃前 10% 窗口)`wrec` | 206 条/帧 | 7,504 条/帧 |
| **稳态中位数** `seg` | 130,848 B/帧 | 816,248 B/帧 |
| **稳态中位数** `wrec` | 211 条/帧 | 7,497 条/帧 |
| 参考:`draws` 稳态中位数 | 30 | 1,471 |
| 参考:`maxrec` / `maxcap` | 1808 B / 4,194,304 B(0.043%) | 1808 B / 4,194,304 B(0.043%) |
**门 8 要的数**(按 `MEASUREMENTS.md` 里同类数字的惯例,报稳态中位数与稳态均值这对,
因为该文件自己就警告过"变化的负载用运行总量平均值会掩盖 P2 要的那个数"):
| 数 | OpenRA | rd12 in-world |
|---|---:|---:|
| **`SEG_STAGE` 字节/帧** | **8.01 MB/帧(稳态均值)** / **130.8 KB/帧(中位数)** | **1.53 MB/帧(稳态均值)** / **816.2 KB/帧(中位数)** |
| **记录/帧(分块后)** | **206 条/帧(稳态均值)** / **211(中位数)** | **7,504 条/帧(稳态均值)** / **7,497(中位数)** |
读法注意:
- **两负载的稳态形状完全不同**,这正是必须分开报的理由:OpenRA 稳态的 `tex` 中位数是 32,768 B
而均值 5.6 MB(少数窗有巨大的纹理上传尖峰),`seg` 的中位数/均值因此差 61 倍;
rd12 稳态的 `tex` 中位数为 0(大部分帧不上传纹理),`seg` 的中位数/均值差 1.9 倍。
报一个数就会掩盖另一个负载的形状。
- rd12 稳态 `seg` 中位数 816 KB/帧 对默认 32 MiB 的 SEG_STAGE:**约 40 帧的余量**,
与 `ringwaits=0`(250 帧里一次都没等过 retiredSeq)、`ringwraps=61` 一致。
- **`maxrec=1808` 说明默认配置下分块一次都没触发**:稳态 `seg/wrec ≈ 816248/7497 ≈ 109 B/条`,
与全局的 390 B/条 都在字节级别。这与 `MEASUREMENTS.md:342` 记录的
"分块前 `maxrec` 也是 1808 / `SetVertexAttribDefaults`" **完全一致**——
也就是说,**在这两条负载上分块前后 `maxrec` 没有变化,因为它本来就没到过预算**。
- `wrec` 是**窗口计数**(与 `wrec/f` 同值,因为每窗恰好一帧),所以上表两列是同一个数的两种提法。
### 6.5 逐 blob 大小分布(可选加分项)
**结论:在默认预算下不存在"由分块产生的"分布**——见 6.4,`maxrec=1808` 远低于 8 MiB 预算,
所有 blob 都是原始尺寸。为让这个可选数有内容,做了两组采集:
**(a) 默认 32 MiB 预算(=chunk 8 MiB),rd12,分布来自 `MOBILEGL_PIPE_STATS_FILE` JSON dump**
(需要走 `MobileGL::Destroy()`,retrace 到不了;改用 integration 车道:
```
$ cd ~/w7/p6-gate8-build && MOBILEGL_ITEST_REQUIRE_GPU=1 MOBILEGL_TRANSPORT=inproc \
MOBILEGL_IPC_SERVER_PATH=.../libMobileGLServer.so MOBILEGL_IPC_ROLE_SPLIT_STATE=1 \
MOBILEGL_IPC_STRICT_ERRORS=1 MOBILEGL_IPC_RUN_AHEAD=1 \
MOBILEGL_PIPE_STATS=1 MOBILEGL_PIPE_STATS_PERIOD=1 \
MOBILEGL_PIPE_STATS_FILE=.../stats-igpu-inproc.json \
ctest -R "^DirectGLES\.(Arena|Ssbo|Buffer|Atomic|Readback)" -j 1
100% tests passed, 0 tests failed out of 19
```
`stats-igpu-inproc.json`:
```
frames : 0 (这些 integration 用例跑 swap 但不走 Present 计数)
stage-segment-bytes : 5768
wire-records : 70
hist [0,0,0, 4, 1, 2, 0, 1, 0, 1, 1, 1, 1, 0, ...]
bucket 3 [4,8) 4 条
bucket 4 [8,16) 1 条
bucket 5 [16,32) 2 条
bucket 7 [64,128) 1 条
bucket 9 [256,512) 1 条
bucket 10 [512,1024) 1 条
bucket 11 [1024,2048) 1 条
bucket 12 [2048,4096) 1 条
```
**(b) 人为压小 stage 预算是让分块真正发生的唯一办法**(`MGPipeStageChunkBytes()` =
`clamp(segment/4, 4096, segment)`,所以 `MOBILEGL_IPC_STAGE_MB=1` → chunk 预算 256 KiB)。
同一条 rd12 trace、同一个库、只改这一个环境变量的 A/B:
```bash
bash ~/w7/mgs/p6-chunkab.sh 32 # 默认:chunk 8 MiB
bash ~/w7/mgs/p6-chunkab.sh 1 # 强制:chunk 256 KiB
```
| rd12 in-world,inproc split | STAGE_MB=32(chunk 8 MiB) | STAGE_MB=1(chunk 256 KiB) | 变化 |
|---|---:|---:|---|
| SSIM | 0.999998213 | 0.999998213 | 不变 |
| **`seg` 运行总量** | **2,698,026,763** | **2,698,026,763** | **逐字节完全一致** |
| `seg` 稳态中位数 | 816,248 | 554,104 | **−32%**(逐窗归属变了,见下) |
| `wrec` 运行总量 | 6,922,378 | 6,929,736 | +7,358(**+0.11%**) |
| `wrec` 稳态中位数/均值 | 7,497 / 7,504 | 7,497 / 7,508 | +0.05% |
| `tex` 运行总量 | 641,567,296 | 1,886,392,128 | **+194%** |
| `buf`/`csob-blob`/`draws` | 见 §6.3 | 与 STAGE_MB=32 逐项相同 | 不变 |
| `maxrec` / `maxcap` / `ringwraps` / `ringpads` | 1808 / 4,194,304 / 61 / 53 | 同左 | 不变 |
这一组是**它自己那个问题的负结果,如实记录**:
- **`seg` 运行总量两侧逐字节相同**(2,698,026,763)说明:这个计数器测的是**生产者交出来的内容**,
与发射侧怎么切、arena 开多大**完全无关**。这正是 §2.4 落点选择的直接证据——
若把它记在 emitter 侧或记成 arena 占用,这里就会跟着预算动。
- **但稳态中位数从 816,248 变成 554,104(−32%),而运行总量不变**——这两件事一起有意义:
同样的总字节被**重新分到不同的窗口**(预算小了,某些原本一次完成的上传被切成多次、跨帧重传),
所以**逐窗归属会动,总量不会**。读数时应以运行总量或明确声明的窗口口径比较,
不要拿两套配置的单窗中位数直接对比。
- **`wrec` 只涨 0.11%**(+7,358 条 / 250 帧 ≈ +29 条/帧,稳态中位数 7,497 两侧相同)说明
**这条 trace 的内容本来就没有超过 256 KiB 的 blob**,所以即使预算压到 256 KiB 也几乎无可切。
`maxrec` 仍是 1808 第三次确认(`maxrec` 是单条**记录**的字节上界,blob 被切不会改变它)。
- **`tex` 涨 194% 才是这次 A/B 真正的发现**:256 KiB 预算下纹理上传走了近三倍字节。
`MOBILEGL_IPC_STAGE_MB` 同时是 **arena 大小**,压小它改变了纹理 slab 的切分与重传行为
(整宽 slab 被预算切碎后每片各自重传)。这一项**不在门 8 的定义里**,
且**我没有把它隔离到单变量**(arena 大小与 chunk 预算被同一个环境变量绑在一起),
因此**只作为 A/B 的可解释性证据记录,不作结论**;要分开这两个效应需要分别可配,
那是另一个包的事。
> 因此门 8 的"chunking 后记录/帧"数取**默认配置**的 §6.4 值;
> "逐 blob 分布"的答案是:**默认配置下该分布就是未分块的原始分布**
> (`maxrec=1808`,稳态 `seg/wrec ≈ 109 B/条`),分块在 OpenRA 与 rd12 这两条负载上都没有被触发。
### 6.6 交叉核对:`seg` 与后端侧字节类
`seg` 记 wire 写进 SEG_STAGE 的字节,`buf`/`tex`/`csob-blob` 记**后端往驱动搬**的字节——
两个总体不同,不要求相等,但应该在**同一量级**并且**同步起伏**;任一条不成立就说明 `seg`
要么重复计数、要么漏了一个总体。逐窗比对(250 窗,`p6-crosscheck.py`):
```
windows: 250
seg median 818,030 mean 10,792,107
parts median 291,840 mean 3,978,204 (parts = buf + tex + csob-blob)
ratio mean seg/parts: 2.713
bytes per record (seg/wrec): 389.8
```
- **同步性**:逐窗比值在稳态下稳定在 1.0–8.2 之间(STAGE_MB=32)/ 1.2 平均(STAGE_MB=1),
且两者在同一些窗一起抬升(第 0 窗两者都是峰值,第 124/155 窗两者都是谷值)。
没有出现"一个动另一个不动"的窗。
- **差异有解释**:`seg` 稳态恒有 `csob-blob ≈ 281 KB/帧`(program archive,**只走 wire、
不经后端字节类**——`WireTables.cpp:540` 把它记进 `csob-blob` 是同一个量在发射侧的照面),
加上 map-persistent 推送与 tail 字节;`parts` 一侧的 `tex` 中位数是 0。
所以比值 > 1 而不是 = 1 是**两个总体本来就不同**的结果,不是漏计。
- **比例随预算变化也是可解释的**:STAGE_MB=1 时 `parts` 的均值从 3,978,204 涨到 8,957,504
(§6.5 的 `tex` +194%),而 `seg` 均值不动,所以比值从 2.713 掉到 1.205。
两个数一起动、且动的方向就是 A/B 那一行预测的方向,这本身就是一次一致性验证。
- 这一核对**不能证明覆盖完整**(没有第三方真值),它能排除的是"`seg` 明显偏离同一批字节"这一整类错误。
`maxrec`/`ringwaits` 与 §6.5 的 A/B 是另外两个独立的佐证。
---
## 7 已知问题与未做项(如实记录)
1. **retrace 的 snapshot 模式拿不到 JSON dump;benchmark 模式拿得到**。`MOBILEGL_PIPE_STATS_FILE`
的 dump 在 `PipeStats::Shutdown()` 里,而它只由 `MobileGL::Destroy()`
(`MG_Impl/EGLImpl/EGLImpl.cpp:336` 的 `eglTerminate` 触发)调用。snapshot 模式在快照完成后
`exit(0)`,被 `-Dexit=mobilegl_apitrace_exit` 换成一次抛出,**跳过了 `retrace::cleanUp()`**,
所以 Destroy 不跑;benchmark 模式正常走完 `cleanUp()`,Destroy 跑。
完整机制与实测见 **§9.1.2**(那里是为了判定"设备那条 client 汇总行是谁打的"查清的,
结论是同一个机制)。因此 §6.5 的逐 blob 分布仍然只能用 **benchmark 模式或 integration 车道**取
(`stats-igpu-inproc.json` 是后者);本报告 §6.5 分布用的是 integration 车道。
把 snapshot 模式也接上需要改 runner 的退出路径,超出本任务授权的文件范围。
2. **`maxrec` 在两条负载上都是 1808,与 `MEASUREMENTS.md:342` 分块前的记录相同**:
这不是"分块没生效",而是**分块没有机会生效**(无 blob 超过预算)。任务书要求记录的"分块后数值"因此
就是 1808,而它之所以等于分块前的值,原因已由 §6.5(b) 的 A/B 独立证明。
3. **`MOBILEGL_PIPE_STATS_PERIOD=1` 本身有可观开销**(每帧一行 INFO),所以 §6.4 的帧数/负载
**不能当作性能数**,只用于按帧摊平字节与记录。门 8 也只要求记录,不要求这些数是性能基线。
4. **`seg` 的"每帧"是窗口量,不是硬件意义上的稳态帧时**。§6.4 因此同时给中位数与稳态均值,
并把含加载窗的均值也列出来:rd12 的加载窗把它抬到稳态均值的约 7 倍(10.79 vs 1.53 MB/帧),
OpenRA 两个值接近(8.07 vs 8.01 MB/帧)是因为它的加载窗与稳态窗同量级。
引用这个数时必须说明选的是哪一个口径。
5. **离树构建下 `PipeCatalogue.FrontendNeverTakesAnApplierAddress` 会红**(§4 末),
与本次改动无关,但任何用 `-B` 指到源码树外的构建都会碰到。
6. **`MOBILEGL_IPC_STAGE_MB` 同时是 arena 大小与 chunk 预算的来源**(§6.5(b) 的 `tex` +194%),
所以**分块的净效果没有被单变量隔离**。本报告只报默认配置的数,并把 A/B 标为可解释性证据。
要真正量化分块的收益/代价,需要把两者拆成可独立配置的量。
8. **integration-split 全车道未跑**(时间预算)。门 8 只需要一条真实采集,本报告给了两条 +
一条 A/B。要做更宽的确认,可用 §6.5(a) 的同一条命令换 `-R` 选择器。
(§9 那一轮跑了 `.Spawn.` 前缀的 102 条集成条目,全绿。)
9. **未改** `MEASUREMENTS.md` / `CURRENT_STAGE_PROGRESS.md` / `ROADMAP.md` / `Doorbell.*` /
`protocol.fbs` / `pin_device.sh`(任务书禁止);未 `git commit`。
*注:`tools/device_bench/pin_device.sh` 在工作树里确有改动,但那是并行的门 7 设备 agent
加的 Redmi `2f7cbe2e` 行,**不是本轮改的**;本轮的 `git diff --name-only` 只含 §3/§9.2 的
5 个 `MobileGL/` 文件。*
10. **门 8 的第一个数(socket 门铃 vs inproc condvar)在本轮之后才算"两边都能采"**,
但**真机数仍未采**(§9.6):§9.4 的对比来自 `wsl -d Ubuntu` + llvmpipe。
本轮交付的是"该数在 spawn 臂上可采"这个前提,不是设备结论。
门 7(真机 `2f7cbe2e` reboot-clean 配对 A/B)仍属另一个 agent 的范围。
---
## 8 复现命令清单
```bash
# 0) 快照(含被 */build 误伤的两处补回)
bash ~/w7/mgs/p6-snapshot.sh && bash ~/w7/mgs/p6-fix-builddirs.sh
# 1) split 构建(= CI build-linux-split 的配方)
bash ~/w7/mgs/p6-cfg-split.sh
cmake --build ~/w7/p6-gate8-build --parallel "$(nproc)"
# 2) 新增单测 + 全量回归
cd ~/w7/p6-gate8-build
ctest -L unit -R PipeStats --output-on-failure # 22/22
ctest -L unit --no-tests=error -j 24 # 2364/2364
# 3) retrace 车道(runner 单独一棵树,库指到 split 构建的产物)
bash ~/w7/mgs/p6-build-retrace.sh
bash ~/w7/mgs/p6-stats-run.sh OpenRA DirectGLES 1
bash ~/w7/mgs/p6-stats-rd12.sh
bash ~/w7/mgs/p6-agg-all.sh # 聚合(含加载窗的均值)
bash ~/w7/mgs/p6-quant-all.sh # 分位数 + 稳态均值/中位数
python3 ~/w7/mgs/p6-crosscheck.py <server.log> # seg 与后端字节类的逐窗交叉核对
python3 ~/w7/mgs/p6-quantiles.py <server.log>
# 4) 分块 A/B
bash ~/w7/mgs/p6-chunkab.sh 32 /home/swung/w7/mgs/chunkab
bash ~/w7/mgs/p6-chunkab.sh 1 /home/swung/w7/mgs/chunkab
# 5) G1
bash ~/w7/mgs/p6-g1.sh
python3 scripts/symbol_report.py \
--before ~/w7/p6-gate8-g1-baseline.so --after ~/w7/p6-gate8-g1-head.so \
--threshold 0 --fail-on-symbol-set-change --fail-on-added-bytes 0 --fail-on-text-delta
# 6) §9 spawn server 侧:单测 red-once、端到端 red-once、两传输 paired A/B
bash ~/w7/mgs/p6-redonce.sh # 破坏 latch -> 单测红 -> 恢复
bash ~/w7/mgs/p6-redonce-e2e.sh # 修前 0 行 / 修后 29 行,同一条 spawn trace
bash ~/w7/mgs/p6-ab-transport.sh # inproc vs spawn,同一条 OpenRA trace
bash ~/w7/mgs/p6-waitrate-all.sh # 逐 role 的 waits/parks 与 per-frame 费率
# 7) §9.1.2 车道模式判据 + §9.7 dump 角色分路
bash ~/w7/mgs/p6-mode-probe.sh # snapshot: dump 缺 / bench: dump 在(= Destroy 是否跑)
bash ~/w7/mgs/p6-lineprovenance.sh # 每条汇总行由哪个 role、哪种行打出
bash ~/w7/mgs/p6-dumppath.sh # <base>.client.json / .server.json,基名不存在
bash ~/w7/mgs/p6-redonce-dump.sh # 回退派生成裸基名 -> 单测红 -> 恢复
```
证据留存:`~/w7/mgs/`(`build-split.log`、`cfg-split.log`、`g1.log`、`g1-final.log`、`g1-step2.log`、
`build-retrace*.log`、`stats-OpenRA-DirectGLES/`、`stats-rd12-DirectGLES/`、`stats-igpu-inproc.json`、
`stats-itest.json`、`chunkab/stage{32,1}/`、`ab8-{inproc,spawn}/`、`spawn-{baseline,fixed,prefix,postfix}-*/`、
以及 `p6-*.sh` / `p6-{aggregate,quantiles,crosscheck,waitrate}.py` 全部脚本与解析器);
G1 的两个 `.so` 在 `~/w7/p6-gate8-{g1-baseline,g1-head}.so`。
Windows 侧脚本副本 `C:/Users/geekerwan/wsl-tmp/`(含 `pristine/` 与 `g1head/` 两个文件集,
它们是 G1 两棵树各自的输入)。
---
## 9 spawn server 进程的 PipeStats 缺陷与修法
这一节是门 8 收尾的最后一个缺口。它不属于"加一个计数器",而属于"让已有的计数器在 spawn 臂上
**可能**被采到"——没有它,item 8 的第一个数(socket 门铃 vs inproc condvar)只能采到半边。
### 9.1 现象:两条车道各自的真相
**先说结论,因为原文把两条车道混成了一条。** spawn 的 client 进程**确实**会打一条汇总行
(真机数据见 `gate7-device-ab.md` §10.4:`wait[srv=0 srvpark=0 cli=30401 clipark=1538]`)。
缺失的是 **server 进程**,而 client 那条行的**性质**取决于车道跑的是哪种 retrace 模式。
#### 9.1.1 server 半边:整行缺失(两条车道一致)
`pipeStatsEnabled` 在 spawn server 进程里恒为 false,所以它**一条 `MGPipe stats:` 行都没有**——
不是携带 0,是整行不存在。桌面实测,spawn、两种 retrace 模式都一样:
```
=== spawn, BEFORE the fix ===
server : NO SUMMARY LINE (this process never ran PipeStats::Init, or never reached OnPresent)
```
这一点必须写清楚,因为 `wait[srv=0 srvpark=0]`("服务端从未等待")与"没有这一行"("这个进程里
计数器根本没开")是**两个不同的陈述**,而它们读起来一样。这正是 `PipeStats.h` 在 Gauge 注释里
反复警告的那类"看起来像答案的零"。
#### 9.1.2 client 半边:行存在与否取决于模式,不是取决于传输
我最初的 §9.1 写"两条 role 都没有行",那是**桌面 integration / snapshot 车道**的观测,
不是设备的观测。查清了:**client 那条汇总行是 `PipeStats::Shutdown()` 的终行,而 Shutdown 只在
`MobileGL::Destroy()` 里被调用**。所以问题变成"retrace 车道到底跑不跑 Destroy()",而答案是**看模式**:
| retrace 模式 | 谁结束 replay | `retrace::cleanUp()` | `MobileGL::Destroy()` | client 汇总行 |
|---|---|---|---|---|
| **snapshot**(`--target-call`,快照跑法) | 快照完成后 `exit(0)` | **不跑** | **不跑** | **无** |
| **benchmark**(`--benchmark`,设备 A/B 用的) | replay 走完 `main` 到 `retrace::cleanUp()` | 跑 | **跑** | **有,恰好一条** |
机制在源码上是确定的,不是推断:`tools/trace_replay/CMakeLists.txt:188,246` 用
`-Dexit=mobilegl_apitrace_exit` 编译 apitrace 的 `retrace_main.cpp`(它在
`mobilegl_trace_retrace_common` 里),而 `apitrace_exit.hpp` 把那个符号定义成
**`throw MobileGLRetraceExit{status}`**。于是 snapshot 的退出点
(`retrace_main.cpp:258` `if (call.no >= snapshotFrequency.getLast()) exit(0);`)变成一次**抛出**,
直接穿到 `trace_replay_core.cpp:488` 的 `catch`——**它跳过了 `retrace_main.cpp:1482` 的
`retrace::cleanUp()`**,而那是唯一会走 `glws::cleanup()` → `eglTerminate` →
`EGLImpl.cpp:336` `MobileGL::Destroy()` 的路径。benchmark 模式不抛,正常走完 `cleanUp()`。
**这条判据是可判定的,我用了两条独立证据**:
1. **直接实验**(`p6-mode-probe.sh`):`MOBILEGL_PIPE_STATS_FILE` 只由 `PipeStats::Shutdown()` 写,
所以它的 JSON dump 是否存在 = Destroy 是否跑。同一条 OpenRA trace、同一个 spawn 库:
```
=== mode=snapshot === JSON dump: ABSENT -> Destroy() did NOT run
client summary lines: 0
=== mode=bench === JSON dump: PRESENT -> Destroy() did run
client summary lines: 1
frames=0 window=0 wait[srv=0 srvpark=0 cli=685 clipark=273]
```
2. **与设备摘录的算术吻合**:设备 inproc 那段 client 行是 `window=11` / `frames=251`。
`FormatWindowLine` 只在帧数是周期(默认 120)的整数倍时随窗口推进,所以终行的 `window`
必然等于 `frames % period`——`251 % 120 = 11`,**正是设备摘录的值**。这就是终行,不是第 11 帧的窗口行
(那个会在 frames=120 时出现、window=120)。
#### 9.1.3 由此得到的正确记账
| 车道 / 模式 | spawn client 行 | spawn server 行 | spawn client 的 `cli`/`clipark` | 窗口化字段 |
|---|---|---|---|---|
| 桌面 snapshot | 无 | 无(修前)/ 有(修后) | 不可得 | 不可得 |
| 设备 benchmark(§10.4) | **有,1 条终行** | 无(修前)/ 有(修后) | **可信**(见下) | **不可信**(`frames=0 window=0`) |
**`cli`/`clipark` 的 run total 在 spawn client 里可信,因为它不由 `OnPresent` 产生。**
`EmitPresent`(`EmitTables.cpp:1049-1050`)在**每条 present 记录**上直接
`PublishGauge(ClientWaits/ClientParks)`,而 `PublishGauge` 是 **store 不是 add**(`PipeStats.cpp`),
所以终行读到的是运行总量。设备那条 `cli=30401 clipark=1538` 因此是**真数**——
它与我修前修的无关,一直就是对的。
**但同一个 spawn client 的窗口化字段不可信**:`frames=0 window=0`,因为 spawn client 从不调用
`OnPresent`(那是 backend 的帧边界,而 client 的 Present 是**发射器**)。于是:
- `draws=`、`wrec=`、`bytes[...]`、`gates[...]`、以及**所有 per-frame 字段**(`draws/f`、`wrec/f`、
`bytes/f[...]`、`acc/draw`)都印 **0 或 `n/a`**——`window=0` 走的就是该文件那条"无帧则退回窗口总量"
的标注规则,而窗口总量也确实只有终行那点内容。
- 唯一在 spawn client 上非零的是 **`EmitPresent` 直接发布的 gauge**:`cli`/`clipark`(上表)、
`maxrec`/`maxcap`/`ringwraps`/`ringpads`/`ringwaits`,以及 `resid`/`csob-blob`/`seg`/`wrec` 这类
**由发射侧直接 Add 的计数器**——它们的 run total 可信,但**窗口归属**只有最后一段。
桌面 benchmark 复现的正是这个形状(`frames=0 window=0`,`cli=685 clipark=273`,
`seg=246975042 wrec=26117` 非零,而 `draws=0`、`gates[ers=0/0 ...]`)。
> **对门 8 item ① 的影响**:设备 §10.4 的 spawn client 半边**本来就是有效测量**,
> 我在 §9.4 说明的"spawn 的 `cli=0 clipark=0` 是另一个进程"指的是**在 server 进程那一行上**读到的
> `cli=0`——那是服务端进程的 client gauge 位,不是 client 进程自己那行。这两个陈述不冲突,
> 但**必须分开写**,否则读者以为 client 半边从来没采到过。item ① 真正缺的只有 **server 半边**。
### 9.2 根因与修法
`PipeStats::Init()` 只从 `MobileGL::Initialize()`(`Init.cpp:123`)调用,而 spawn server 走的是
`mobilegl_server_main` → `MG_ConfigLoader::Init()`(`ServerMain.cpp:182`)+
`MG_Backend::InitServerRoleForSpawn()`(`ServerMain.cpp:239`),**从不经过 `MobileGL::Initialize`**。
于是 `g_pipeStatsEnabled` 在该进程保持静态默认 false,**全进程所有 `if (PipeStats::Enabled())` 站点
(含 `ServerLoop.cpp:436-437` 自己发布 `ServerWaits`/`ServerParks` 的那一对)恒为 false**。
修在 server 角色初始化的唯一收口点,即 `ServerMain.cpp` 的 step 1.5(配置加载 / D1 定角色)之后:
| 改动 | 位置 | 代码 |
|---|---|---|
| 新增 include | `MobileGL/MG_Remote/Server/ServerMain.cpp:49` | `#include <MG_Util/Metrics/PipeStats.h>` |
| **初始化** | `ServerMain.cpp:231`(`MGPipeSetServerProcessRole(true)` 之后、step 2 之前) | `MobileGL::MG_Util::PipeStats::Init();` |
| **收尾** | `ServerMain.cpp:442`(`loop.Stop()`/`session.Close()` 之后、`::_exit(0)` 之前) | `MobileGL::MG_Util::PipeStats::Shutdown();` |
**代码只有这两行**(外加一个 include);其余是解释"为什么是这个位置"的长注释。
三处顺序都是承重的:
1. **在 `MG_ConfigLoader::Init()` 之后**:`Init()` 把 `g_pipeStatsEnabled`
**latch 自 `MG_Config::Features.PipeStats`**,而该字段由 `ConfigLoader.cpp:281` 的
`QueryEnvFlag("MOBILEGL_PIPE_STATS")` 解析——解析器在上一行跑完之前都不存在。提前调用会把
静态默认(false)latch 下来,launcher 传进来的 `MOBILEGL_PIPE_STATS` 就没人读——
与 step 1.5(a) 记录的那个"没人读配置"缺陷是同一类。
2. **在 `MGPipeSetServerProcessRole(true)` 之后**:这是让**日志 sink** 知道本进程是 server 的
地方(`Log.cpp` 的 `ProcessIsSpawnedServer()` 读 `MOBILEGL_IPC_ROLE`,launcher 设置且其 envp
scrub 保留它,因此按 `ThreadIsServerRole()` 该进程所有线程都写 `<base>.server.log`)。
顺序这样排,是为了让"摘要行进 server 角色日志"这条契约不依赖将来那次解析改动的运气——
**并且它由车道实测断言,不由这段注释担保**(§9.4 的 `server: banner=1 summary_lines=29`)。
3. **`Shutdown()` 在 `loop.Stop()` 之后**:apply 线程正是发布 `ServerWaits`/`ServerParks` 的线程,
它还在跑时取 dump 是竞态;而 `_exit(0)` 不跑任何 atexit handler,所以这是服务端唯一能写出
运行总量与 JSON dump 的位置——没有它,spawn server 会丢掉最后一个窗(`Init.cpp:59` 在 client
侧调 `Shutdown` 就是为了同一个"最后一帧的数别丢")。
### 9.3 三个 env 变量的语义:与 client 进程**完全一致**
`MOBILEGL_PIPE_STATS` / `_PERIOD` / `_FILE` 三者都由**子进程自己的环境**经**同一个解析器**读,
再由**同一个 `Init()`** latch——stats 通道没有任何 spawn 特化。唯一的真实差异是**行节奏**:
> 服务端进程没有自己的 backend Present(它不做 swap),所以它的摘要行节奏骑在它**应用的 present
> 记录**上(`PipeApplier.cpp:317` 的 `table->Present()` → DirectGLES/DirectVulkan 的 `Present()`
> → `OnPresent()`),而不是骑在一次它并不执行的 swap 上。一次 present N 次的 spawn 会话,
> **服务端产生 N 行窗口行**。
>
> **client 侧不是 N 行**——我最初在这里写的"与 client 自己那行数的 N 相同"是错的,实测把它否掉了。
> spawn client 的 `Present` 是**发射器**(`EmitPresent`),它把 `PublishGauge(ClientWaits/ClientParks)`
> 与 `maxrec` 那一批发出去,但**不调** `PipeStats::OnPresent()`(那是 backend 的帧边界,不是发射器的)。
> 所以 client 一个窗口都不推进,它唯一的一行是 `Shutdown()` 的**终行**,`frames=0 window=0`——
> 前提是那条车道走到 `MobileGL::Destroy()`(§9.1.2:benchmark 模式走到,snapshot 模式不走)。
> 桌面 benchmark 实测:**server 128 行窗口行 + client 1 行终行**,两者不等。
>
> 清单里把 `Init.cpp` / `ConfigLoader.cpp` / `ServerLoop.cpp` / `ServerSession.cpp` 列为"按需",
> 实测**四者都不需要改**,本轮 `git diff --name-only` 只动了 `ServerMain.cpp`(§9.2)
> 与 §9.7 的 dump 路径四个文件。
### 9.4 验证:单测 + 端到端 red-once
**(a) 单元层**:新增两个用例(`PipeStatsTest.cpp`,见 §4 表),`ctest -L unit -R PipeStats` = **24/24**。
其中 `InitIsTheOnlyLatchAndItTakesTheConfigBothWays` 钉住"latch 必须两个方向都取配置值",
`PresentationsAtTheDefaultPeriodOfOneMakeOneWindowEach` 钉住"k 次 present → k 个窗口"这条
车道费率所依赖的算术(并顺带钉住 gauge 是**运行总量、不是求和**——本报告 §6.1 的聚合脚本
第一版正是栽在这里)。
**(b) 单元层 red-once**:把 `PipeStats.cpp` 的 latch 改成单向 `=true`,重跑:
```
2/2 Test #1718: PipeStatsTest.InitIsTheOnlyLatchAndItTakesTheConfigBothWays ...***Failed
50% tests passed, 1 tests failed out of 2
```
恢复后 24/24 绿。(脚本 `p6-redonce.sh`。这个控制证明的是**两个用例不是同义反复**;
它证不了端到端,因为用例直接调 `Init()`——被测对象是 latch 本身,而缺陷是 latch **从未被调用**。)
**(c) 端到端 red-once**(这才是修复的那个控制):构建两版库,**唯一差别**是 §9.2 那两行,
同一条 spawn trace 各跑一次:
| | server 侧 summary 行数 | server 侧 `wait[...]` |
|---|---:|---|
| **修前**(step 1.6 两行移除) | **0** | 无(整行缺失) |
| **修后** | **29** | `srv=387 srvpark=133`(= 13.34 / 4.59 per frame) |
脚本 `p6-redonce-e2e.sh`;两侧 SSIM 均 1.000000,即修复不改渲染。
**(d) 同机 paired A/B**(`p6-ab-transport.sh` + `p6-waitrate.py`,同一条 OpenRA trace、同一个库,
只差 `MOBILEGL_TRANSPORT`):
| | inproc | spawn |
|---|---:|---:|
| client 进程 | 无 summary 行(§9.3) | 无 summary 行(同因) |
| server 进程 summary 行 | 29 | 29 |
| `srv`(waits/帧) | 16.83 | **15.00** |
| `srvpark`(parks/帧) | 3.59 | **4.38** |
| `srvpark/srv` | 21.3% | **29.2%** |
| `cli` / `clipark` | 9.21 / 3.14(与 server 同行,因 inproc 单进程) | **0 / 0**(另一进程,见下) |
读法:
- **inproc 的两个半边在同一行上**,因为两个角色是同一进程的两个线程、共用一套计数器(§9.3 已
解释了为什么这行出现在 server 日志里);
- **spawn 的 `cli=0 clipark=0` 是"另一个进程",不是"客户端没等"**。它与契约里 `rsp`、`srv` 在
spawn 下的既有行为同类,也是 `PipeStats.h` Gauge 注释里写明的那种"形如零的零"。要取 spawn 的
client 半边,必须让 client 进程也到达一次帧边界(§9.3 的结构性原因),那是独立于本轮的一步。
- **socket 门铃 vs condvar 的对比现在是可比的**:两侧 `srv`/`srvpark` 都由同一套代码、同一个
wait 收口点、同一口径采集。采样中 socket 的 park 比例略高(29.2% vs 21.3%),
与契约 §9 item 8 那条预告("socket 链路没有那条共享 cacheline,转成阻塞读可能更便宜")
方向一致但**幅度很小,且这里是 llvmpipe/桌面**——真机数仍需在设备上另采,本节不做性能结论。
**(e) 回归**:`ctest -L unit` **2366/2366**(比上一轮多 2 个新用例);
`.Spawn.` 前缀的集成条目 **102/102 全绿**。
### 9.5 G1 复验
§9.2 的改动在 `MG_Remote/Server/ServerMain.cpp`,属 `MOBILEGL_BUILD_DISAGGREGATED` 的源列表,
pull 构建根本不编译它——但 G1 是**实测**不是推理。重跑 §5 的两棵树(baseline = 本轮改的 5 个文件
全部回退到 `git HEAD`,head = 本轮内容,同一条 pull 配置):
```
symbol-report: .text 10158850 -> 10158850 (+0, +0.000%)
symbol-report: 27863 -> 27863 defined symbols: 0 added, 0 removed, 0 resized, 0 renamed
symbol-report: 27122 -> 27122 normalised names, 27122 unchanged
G1 rc=0
.text BYTE-IDENTICAL
MG_Remote symbols : 0
gate8 symbols : 0
```
即:**符号集 0/0/0/0,`.text` 逐字节相同,pull 构建仍无任何 `MG_Remote` 符号**。
### 9.6 本节的未做项
1. **`MOBILEGL_PIPE_STATS_FILE` 的角色冲突已修**(原为未做项 2),见 §9.7。
2. **spawn client 的窗口化字段仍不可信,且这一项不是缺陷而是结构**。要让 spawn client 推进窗口,
得让 client 进程到达一次 `PipeStats::OnPresent`;而 client 的帧边界在 spawn 下存在于
`EmitPresent`(每条 present 记录一次)——把它接上去是**改变 client 侧调用点**,
超出本轮授权,且会与 `DirectGLES`/`DirectVulkan` 的 backend 帧边界语义重复计数
(同一个进程里两个 `OnPresent` 会让 `frames` 走两倍)。需要单独判断,不要顺手接。
本轮已把这条限制在 §9.1.3 写清:**spawn client 的 `cli`/`clipark` 可信,per-frame 字段不可信**。
3. **真机(Redmi)未跑**。本节全部数据来自 `wsl -d Ubuntu` + llvmpipe,门 7 与门 8 的设备数
仍需在设备上采;本节只交付"server 半边可测"这个前提。
§9.1.2 对**设备**那条 client 行的定性是**从源码机制 + 与本机 benchmark 复现的算术吻合**得出的
(`251 % 120 = 11` 对上 §10.4 的 `window=11`),不是从设备日志原件里读出的 `frames=`——
设备报告没有摘录该字段。若要在设备上把这条钉死,需要把那一行的 `frames=`/`window=` 一并留存。
---
### 9.7 `MOBILEGL_PIPE_STATS_FILE` 的角色分路
§9.6 原第 2 项记录的那个陷阱:修完 §9.2 之后 spawn 的**两个**进程都会执行 `WriteJsonDump()`,
而两者都写 `MG_Config::Features.PipeStatsFile` 的**同一个字符串**,`ofstream` 的 trunc 让后写的
覆盖先写的。读者会拿到**一个角色**的数,挂在一个自称是整轮运行的文件名下——比缺文件更坏,
因为看不出哪里不对。
### 9.7.1 修法:与日志 sink 同一套命名规则,规则不复制
| 改动 | 位置 | 内容 |
|---|---|---|
| 导出角色查询(split 臂) | `MobileGL/MG_Util/Debug/Log.h:177,183`、`Log.cpp:100,107` | `LogRole CurrentThreadRole()`、`bool CurrentProcessIsSpawnedServer()` |
| 同两个查询的 pull 臂 | `Log.h:222-223` | 内联;理由是 G1(见 9.7.3) |
| dump 路径派生 | `PipeStats.cpp:787`(声明 `PipeStats.h:463`) | 经 `MG_Util::Debug::RoleLogPath(base, CurrentThreadRole())` |
| `WriteJsonDump` / `Init` banner 改用它 | `PipeStats.cpp` 的 `WriteJsonDump()`、`Init()` | 两条路径都印/写派生名 |
| 新单测 + 负控 | `PipeStatsTest.cpp` | 见 9.7.2 |
命名规则**只有一处实现**(`Log.cpp:87` 的 `RoleLogPath`),PipeStats 调它而不是复制它——
这正是任务书要求的"派生规则由库导出、不复制"。
### 9.7.2 验证
**端到端**(`p6-dumppath.sh`,同一条 OpenRA trace、同一个库,只改传输):
```
================ inproc ================
run.json : absent <- 基名不指向任何文件
run.client.json : PRESENT (1789 bytes)
run.server.json : absent
banner: ... JSON dump to .../run.client.json
dump : wrote JSON dump to .../run.client.json
================ spawn ================
run.json : absent <- 基名不指向任何文件
run.client.json : PRESENT (1736 bytes) <- client 进程自己的数
run.server.json : PRESENT (1714 bytes) <- server 进程自己的数,不再被覆盖
banner: ... JSON dump to .../run.client.json
banner: ... JSON dump to .../run.server.json
dump : wrote JSON dump to .../run.client.json
dump : wrote JSON dump to .../run.server.json
```
inproc 只有 client 一个文件是**正确**的,不是缺陷:那个进程里只有 GL 线程会到
`MobileGL::Destroy()`(§9.1.2),服务端角色是线程不是进程,`Shutdown()` 只跑一次。
**单测 25/25**,新增 `JsonDumpPathIsRoleDerivedAndTheBaseNameIsNeverOpened`。
**但它的第一版是假绿,值得记下来**:那一版断言的是 `RoleLogPath` 自己的性质
(两角色路径不同、都不等于基名)——**把修复整个回退掉它照样通过**,因为它测的是命名辅助函数,
而辅助函数从来没坏;坏的是 `WriteJsonDump` **没有调用它**。一个在缺陷存在时仍然绿的测试比没有测试更坏。
改法是把被测对象换成 `RoleDerivedJsonDumpPathForTesting()`——dump 与 banner **共用**的那一个表达式,
于是回退它就会红。**red-once 复验**(`p6-redonce-dump.sh`,把那个表达式换回裸基名):
```
2/2 Test #1720: PipeStatsTest.JsonDumpPathIsRoleDerivedAndTheBaseNameIsNeverOpened ...***Failed
96% tests passed, 1 tests failed out of 25
恢复后 25/25 绿
```
### 9.7.3 G1:这次它是真的会红,而且红了三次
这一轮 G1 **三个不同的泄漏点**依次被抓出来,每个都不是能从代码上看出来的:
| 尝试 | 结果 | 原因 |
|---|---|---|
| 无条件派生 | `.text +24 bytes` | 把 `std::string` 临时量与一次调用放进了 monolith 的函数体 |
| 加 `#if` 但两臂共用一个 `String` 局部 | **1 symbol resized**(`Shutdown()` 344→343,−1 字节) | 两个 `#if` 臂**即使算出同一个值也是不同的代码** |
| 两臂分离,但重写了 `MGLOG_W` 的格式串 | **`.rodata +64`** | 把基名与派生名都印出来的新字面量;**字面量在哪个分支里都是字面量,而 `.rodata` 也被测量** |
最终形态:**pull 臂的代码逐语句就是原样**(`const String& path = MG_Config::Features.PipeStatsFile;`
+ `if (path.empty()) return;`),所有新增都在 `#if` 之内。这个文件的规则因此比"把新代码 guard 起来"
更强:**旧代码一个字都不能动**。
最终复验:
```
symbol-report: .text 10158850 -> 10158850 (+0, +0.000%)
symbol-report: .rodata 1521854 -> 1521854 (+0)
symbol-report: Total 16585193 -> 16585193 (+0)
symbol-report: 27863 -> 27863 defined symbols: 0 added, 0 removed, 0 resized, 0 renamed
symbol-report: 27122 -> 27122 normalised names, 27122 unchanged
G1 rc=0
.text BYTE-IDENTICAL
MG_Remote symbols : 0
gate8 symbols : 0
```
`g1.sh` 里加了一步前置断言:**先证明两组文件内容真的不同**(本轮 7 个里有 7 个不同),
否则"两棵树 diff 为 0"会以"我忘了拷贝"的形式假绿——这正是这一轮最容易犯的错,
因为漏掉的如果是一个新文件的声明,基线就等于 head,而门会说 OK。
### 9.7.4 本条对下游的提醒
- 任何**读** `MOBILEGL_PIPE_STATS_FILE` 的车道/脚本都要改成读派生名。基名**不会**存在,
所以没改的读取方会当场 `ENOENT`——这是刻意的(与日志 sink 同一取舍:宁可响亮地失败,
也不要拿到半轮数据却看不出来)。仓库内目前没有这样的读者(`grep` 确认),
所以这次改名不破坏任何现有车道。
- `MOBILEGL_PIPE_STATS_FILE` 的**语义**没有变:仍然是"在这里写 dump",
只是"这里"由库按角色展开成两个名字。
@@ -0,0 +1,337 @@
# MobileGL-disagg worktree:git 修好报告
> 症状:`git -C MobileGL-disagg status` 直接 `fatal: not a git repository: 3rdparty/DiligentCore/../../../MobileGL/.git/worktrees/MobileGL-p5c/modules/3rdparty/DiligentCore`。
> 修复只动 git 元数据:**没有删库重克隆、没有切分支、没有提交、没有改源码**。
> 日期 2026-09-22,`git version 2.45.2.windows.1`。
## 0 一句话结论
`MobileGL-disagg` 里 **33 个**子模块 checkout 的 `.git` 指针文件全部是**过期指针**(指向已删除的兄弟工作树 `MobileGL-p5c`),
其中 **32 个**直接重定向到磁盘上真实存在的共享 admin 目录即可修复,**1 个(flatbuffers)在任何地方都没有 admin 目录**需要现场补建,
另外**共享 admin 目录里的 `core.worktree` 会盖掉指针文件**,必须一并清掉,否则 `git status` 会把这些子模块全判成"内容已修改"。
现在 `git status`、`git submodule status`、`git add`、`git commit` 在该 worktree 里都能正常工作(见 §5 原始输出)。
## 1 环境事实
| 项 | 值 |
|---|---|
| 本 worktree | `C:\Users\geekerwan\AndroidStudioProjects\FoldCraftLauncher\MobileGL-disagg`,分支 `feat/disaggregated` |
| 本 worktree 的 git dir | `FoldCraftLauncher/.git/modules/MobileGL/worktrees/MobileGL-disagg` |
| MobileGL 项目 git dir | `FoldCraftLauncher/.git/modules/MobileGL`(MobileGL 是 FoldCraftLauncher 的子模块) |
| 子模块共享 admin 根 | `FoldCraftLauncher/.git/modules/MobileGL/modules/` |
| 对照工作树 | `FoldCraftLauncher/MobileGL`(分支 `dev`),git 是好的 |
| 过期指针对应的目标 | `MobileGL/.git/worktrees/MobileGL-p5c/modules/...` —— `MobileGL-p5c` 这个 worktree 目录**已不存在** |
本 worktree 下 `.git` 条目共 **35 个**:**33 个指针文件**(`.git` 是文件)+ **2 个真实 git 目录**
(`3rdparty/glslang/External/spirv-tools/.git` 及其 `external/spirv-headers/.git`,glslang 自带 checkout 的独立克隆)。
**33 个指针文件在修复前全部含 `MobileGL-p5c`**(无一例外,见 `backup.txt`);
修复后 **33 个全部指向共享 admin 根,含 `MobileGL-p5c` 的为 0 个**。
对照 `MobileGL`(dev)工作树记法是关键依据:它自己的子模块 checkout 是空的(未初始化),
而唯一真实初始化的子模块 `3rdparty/libfork/.git` 写的是
```
gitdir: ../../../.git/modules/MobileGL/modules/3rdparty/libfork
```
即**指向共享 admin 目录**。本次修复采用同一形态。
## 2 过期指针清单与改法
改法:把 `MobileGL/.git/worktrees/MobileGL-p5c/modules/` 整体替换为 `.git/modules/MobileGL/modules/`,
**跳数(`../` 的个数)原样保留**,所以任意嵌套深度都无需逐个特判。
### 2.1 顶层子模块(13 个,改 12 个)
| 路径 | 改后 `gitdir:` |
|---|---|
| `3rdparty/DiligentCore/.git` | `../../../.git/modules/MobileGL/modules/3rdparty/DiligentCore` |
| `3rdparty/SPIRV-Cross/.git` | `../../../.git/modules/MobileGL/modules/3rdparty/SPIRV-Cross` |
| `3rdparty/SPIRV-Reflect/.git` | `../../../.git/modules/MobileGL/modules/3rdparty/SPIRV-Reflect` |
| `3rdparty/Vulkan-Headers/.git` | `../../../.git/modules/MobileGL/modules/3rdparty/Vulkan-Headers` |
| `3rdparty/Vulkan-Utility-Libraries/.git` | `../../../.git/modules/MobileGL/modules/3rdparty/Vulkan-Utility-Libraries` |
| `3rdparty/VulkanMemoryAllocator/.git` | `../../../.git/modules/MobileGL/modules/3rdparty/VulkanMemoryAllocator` |
| `3rdparty/apitrace/.git` | `../../../.git/modules/MobileGL/modules/3rdparty/apitrace` |
| `3rdparty/asio/.git` | `../../../.git/modules/MobileGL/modules/3rdparty/asio` |
| `3rdparty/glslang/.git` | `../../../.git/modules/MobileGL/modules/3rdparty/glslang` |
| `3rdparty/tracy/.git` | `../../../.git/modules/MobileGL/modules/3rdparty/tracy` |
| `include/ska/.git` | `../../../.git/modules/MobileGL/modules/include/ska` |
| `3rdparty/xxHash/.git` | `../../../.git/modules/MobileGL/modules/3rdparty/xxHash`(本会话早期作为单点试验先改好,脚本复查时已正确) |
### 2.2 嵌套子模块(改 20 个)
第三方库自带的子模块,同样是过期指针:
| 组 | 路径 |
|---|---|
| DiligentCore 的 8 个 | `ThirdParty/{SPIRV-Cross,SPIRV-Headers,SPIRV-Tools,Vulkan-Headers,glslang,googletest,volk,xxHash}` |
| SPIRV-Reflect 的 1 个 | `third_party/googletest` |
| apitrace 的 11 个 | `frametrim/tests`、`thirdparty/{brotli,directxmath,gtest,libbacktrace,libpng,snappy,zlib,zstd}`(8 个)、`thirdparty/snappy/third_party/{benchmark,googletest}`(2 个) |
合计 8 + 1 + 11 = **20**。
顶层 12(含 `3rdparty/xxHash`)+ 嵌套 20 = **32 个指针**由本次修复改写(其中脚本一次改 31 个,`xxHash` 是本会话早期手工单点试验改的);
第 33 个是 `3rdparty/flatbuffers`——它的 admin 目录不存在,需先补建再改写指针,见 §3。
改后形如(以 DiligentCore 的 SPIRV-Cross 为例,跳数 `../../../../../` 原样保留):
```
gitdir: ../../../../../.git/modules/MobileGL/modules/3rdparty/DiligentCore/modules/ThirdParty/SPIRV-Cross
```
### 2.3 不改的两类
- **`3rdparty/glslang/External/spirv-tools/.git` 与 `.../external/spirv-headers/.git` 是真正的 `.git` 目录**(不是指针文件),
是 glslang 自带 checkout 的独立克隆,路径相对自身成立,**原样保留**。
- 本 worktree 自己的 `.git` 文件(`worktrees/MobileGL-disagg`)本来就是对的。
## 3 flatbuffers:唯一没有 admin 目录的一个
`3rdparty/flatbuffers` 比较特殊:
- 它是 `feat/disaggregated` 分支**新加**的子模块(`dev` 的 `.gitmodules` 里没有它),index 里有 gitlink `7e163021e59cca4f8e1e35a7c828b5c6b7915953`;
- 磁盘上的内容是在的(`include/flatbuffers/flatbuffers.h` 等都在,版本头 25.12.19 与 pin 一致);
- 但 `.git` 指针同样指向 `MobileGL-p5c`,而 **`.git/modules/MobileGL/modules/3rdparty/flatbuffers` 不存在**——
整个 `.git` 树下搜不到任何 flatbuffers 的 admin 目录,也没有对应的逐个 worktree admin。
也就是说这个子模块从来没在本机被真正 init 过,内容是从别处拷来的。
因为 `git status` 会因为一个死指针整条命令失败,必须把它修到"能解析"的状态。做法:
```
# 用标准 git 机制补建该子模块的 admin 目录(git clone --separate-git-dir,不是手写 .git)
git clone --no-checkout --depth 1 --separate-git-dir=<ADMIN> \
https://github.com/google/flatbuffers.git <tmp>
git --git-dir=<ADMIN> fetch --unshallow --tags origin # git submodule update --init 默认非浅克隆,补齐
git --git-dir=<ADMIN> update-ref --no-deref HEAD 7e163021e59cca4f8e1e35a7c828b5c6b7915953
git --git-dir=<ADMIN> read-tree HEAD # --no-checkout 不会写 index,这里补上
# 位置选在共享 admin 根,与另外 12 个指针形态一致:
# FoldCraftLauncher/.git/modules/MobileGL/modules/3rdparty/flatbuffers
printf 'gitdir: ../../../.git/modules/MobileGL/modules/3rdparty/flatbuffers\n' \
> MobileGL-disagg/3rdparty/flatbuffers/.git
```
补建后:`HEAD` = pin(`git describe` = `v25.12.19`),`index` = 1912 条(与 HEAD tree 一致),`core.worktree` 不设(让指针文件决定工作树路径),
`git rev-parse --show-toplevel` 正确解析到 `MobileGL-disagg/3rdparty/flatbuffers`。
磁盘上的内容文件**一个字节都没动**。
> 注:内容与 pin 的差异只有 6 个 `java/src/test/java/*` 符号链接项——拷来的 checkout 里它们是真实符号链接,
> 而 admin 的 `core.symlinks=false` 使其显示为 ` M`。这与本次指针修复无关,属内容拷贝的既有差异,未处理。
## 4 第二个坑:共享 admin 的 `core.worktree` 会盖掉指针文件
只修指针不够。修完之后 `git status` 能跑了,但 12 个子模块全被标成 ` m`(modified content;当时 12 个是因为 flatbuffers 的 admin 还没补建)。排查结果:
- 共享 admin 目录里带着从主工作树继承来的 `core.worktree`,指向**主 MobileGL 工作树的那个子模块目录**:
```
core.worktree = ../../../../../../MobileGL/3rdparty/DiligentCore
```
- 而主工作树的 `MobileGL/3rdparty/*` 是**空壳**(`git -C MobileGL submodule status` 全是 `-<sha>` 未初始化);
- **`core.worktree` 的优先级高于指针文件**,所以 git 把子模块的工作树解析到那个空目录,于是每个被跟踪文件都"被删除",
超项目就把子模块判成 modified content。
处置:把 `core.worktree` 从这些 admin 里删掉,让指针文件决定工作树路径——这正是链接工作树需要的语义
(每个工作树各自解析到自己的目录)。范围严格限定:"目标目录存在但为空"或"目标目录不存在"的才删。
- 删除:**32 个 admin 条目**。
可达 admin 共 34 个(`find` 实测),现在 33 个没有 `core.worktree`、1 个(`3rdparty/libfork`)保留;
那 33 个里 `3rdparty/flatbuffers` 是本次新补建的、本来就没有这一项,所以**被我删掉的正好 32 个**。
分解:`3rdparty` 顶层 11 + `include/ska` 1 + DiligentCore 嵌套 8 + SPIRV-Reflect 嵌套 1 + apitrace 嵌套 11 = 32。
- 保留:`3rdparty/libfork`(主工作树里是活 checkout,32 个文件)——它的 `core.worktree` 原样保留
- 回退:`android-plugin/third_party/apitrace` 那棵**孤儿子树**(共 9 个 admin;两个工作树里都没有 `android-plugin/third_party/` 目录,不可达)
在验证阶段被我的脚本改过,**已按原值逐个还原**,最终与修复前一致。
验证方式(实验先于落地):在 `/tmp` 里用 `git init` 搭出同构拓扑(top ⊃ MG ⊃ 3rdparty/thing,
再给 MG 开链接工作树、把过期指针和空壳目录都复现),对比"留着 core.worktree"与"删掉 core.worktree"两种处置,
确认后者才能让 `git status` 干净、且 **`git rev-parse --show-toplevel` 落在自己的工作树**里。
## 5 最终输出
### 5.1 逐条目核对:13 个 `.gitmodules` 子模块 + 全部嵌套指针
口径:对 `.gitmodules` 里每一条 `[submodule "..."]`,把该 checkout 的 `.git` 指针按 git 的规则解析
(`gitdir:` 相对的是**装着该 `.git` 文件的目录**,不是 worktree 根——这点是最初误判的根源之一),
再检查解析结果是否真的是一个 git admin 目录(有 `HEAD`),并读出它的 HEAD。
| `.gitmodules` 路径 | 解析结果 | 指向的 admin 目录(相对工作树上级) | admin HEAD |
|---|---|---|---|
| `3rdparty/DiligentCore` | OK | `.git/modules/MobileGL/modules/3rdparty/DiligentCore` | `f36e638805` |
| `3rdparty/SPIRV-Cross` | OK | `…/3rdparty/SPIRV-Cross` | `072444287f` |
| `3rdparty/SPIRV-Reflect` | OK | `…/3rdparty/SPIRV-Reflect` | `10b4f09a24` |
| `3rdparty/Vulkan-Headers` | OK | `…/3rdparty/Vulkan-Headers` | `ad9ce1235e` |
| `3rdparty/Vulkan-Utility-Libraries` | OK | `…/3rdparty/Vulkan-Utility-Libraries` | `738ec97a3f` |
| `3rdparty/VulkanMemoryAllocator` | OK | `…/3rdparty/VulkanMemoryAllocator` | `e722e57c89` |
| `3rdparty/apitrace` | OK | `…/3rdparty/apitrace` | `c8036190fc` |
| `3rdparty/asio` | OK | `…/3rdparty/asio` | `8806a6803c` |
| `3rdparty/flatbuffers` | OK | `…/3rdparty/flatbuffers` | `7e163021e5` |
| `3rdparty/glslang` | OK | `…/3rdparty/glslang` | `d89cf443bc` |
| `3rdparty/tracy` | OK | `…/3rdparty/tracy` | `e6b9ea4609` |
| `3rdparty/xxHash` | OK | `…/3rdparty/xxHash` | `1d7b2a9d21` |
| `include/ska` | OK | `…/include/ska` | `21c1cec95a` |
**13/13 全部 OK;全树 33 个指针文件无一 BROKEN**(另 2 个真实 `.git` 目录照旧,不在指针之列)。
三项交叉核对,全部干净:
1. **pin = admin HEAD = checkout HEAD**:13 条里每一条的 gitlink(index 里记的 sha)、
admin 目录的 `HEAD`、以及 checkout 内的 `git rev-parse HEAD` **三者完全相同**——
说明指针既指对了地方,也没把某个子模块的版本弄丢。
2. **index gitlink 集合 == `.gitmodules` path 集合**:两边都是 13 项,差集双向为空
(没有"是 gitlink 却不在 `.gitmodules`"的,也没有"在 `.gitmodules` 却不是 gitlink"的)。
3. **工作树里的 `.gitmodules` 与分支提交的 `.gitmodules` 逐字节相同**
(`git show feat/disaggregated:.gitmodules` vs 工作树文件,`diff` 无输出)。
### `git -C MobileGL-disagg status -sb`
```
## feat/disaggregated...origin/feat/disaggregated
m 3rdparty/DiligentCore
m 3rdparty/SPIRV-Cross
m 3rdparty/SPIRV-Reflect
m 3rdparty/Vulkan-Headers
m 3rdparty/Vulkan-Utility-Libraries
m 3rdparty/VulkanMemoryAllocator
m 3rdparty/apitrace
m 3rdparty/asio
m 3rdparty/flatbuffers
m 3rdparty/glslang
m 3rdparty/tracy
M MobileGL/MG_Remote/Wire/PipeWireCodec.cpp
M MobileGL/MG_Test/Util/PipeStatsTest.cpp
M MobileGL/MG_Util/Metrics/PipeStats.cpp
M MobileGL/MG_Util/Metrics/PipeStats.h
m include/ska
M tools/device_bench/pin_device.sh
?? .trace-work/
?? Testing/
?? docs/Disaggregated/inproc-dataflow.html
?? docs/Disaggregated/notes/p6/gate7-device-ab.md
?? docs/Disaggregated/notes/p6/gate8-wire-counters.md
?? docs/Disaggregated/notes/p6/git-worktree-fix.md
```
`git status` 退出码 0。工作区里那 4 个 `MobileGL/MG_Util|MG_Test|MG_Remote` 的 `.cpp/.h` 与
`tools/device_bench/pin_device.sh` 是并行代理的 P6 工作产物(见 §7 末尾,**未回滚**)。
那 12 条 ` m` 是**既有 stat 噪声**,不是本修复引入,也不表示内容真被改过——见 §7 第 1 条的完整查证。
最后 3 个 `??` 是并行代理的 `gate7-device-ab.md`、`gate8-wire-counters.md` 与本报告。
### `git -C MobileGL-disagg submodule status`
```
f36e63880566649d9d8b2336a7c5aa9c8ddb174b 3rdparty/DiligentCore (API256008-78-gf36e63880)
072444287f4e139c178d6d8fe32e04a0d2c34e8b 3rdparty/SPIRV-Cross (vulkan-sdk-1.4.321.0-49-g07244428)
10b4f09a24d7ac1603071e767c089551dc6a3949 3rdparty/SPIRV-Reflect (vulkan-sdk-1.4.341.0-2-g10b4f09)
ad9ce1235e88dc09287e19171dfac384db8ec32c 3rdparty/Vulkan-Headers (v1.4.344)
738ec97a3f659dd6469bff3c4078ef981b0a343f 3rdparty/Vulkan-Utility-Libraries (v1.4.344)
e722e57c891a8fbe3cc73ca56c19dd76be242759 3rdparty/VulkanMemoryAllocator (v2.1.0-1021-ge722e57)
c8036190fcb79ce0d64cf44ab419c54c3307cd71 3rdparty/apitrace (heads/mobilegl/android-trace-replay)
8806a6803cde7054c3049d3666d3ec36786568c5 3rdparty/asio (asio-1-38-2)
7e163021e59cca4f8e1e35a7c828b5c6b7915953 3rdparty/flatbuffers (v25.12.19)
d89cf443bcd220e9205defa0cc4c4b8d8f75dfa2 3rdparty/glslang (11.1.0-1271-gd89cf443)
e6b9ea460993a6f093283055ef3c98025b19f909 3rdparty/tracy (v0.13.0)
1d7b2a9d21bf9a740ead540aa93f4bc4caf11ae0 3rdparty/xxHash (v0.7.4-1000-g1d7b2a9)
21c1cec95abee1beef827e4a7c95f692875d9594 include/ska (heads/master)
```
`git submodule status` 退出码 0,13 条全部有 sha、**没有 `-` 前缀**(未初始化)也没有 `+` 前缀(pin 不匹配)。
修复前这条命令在 `3rdparty/asio` 那一行就因为 flatbuffers 死指针中断了。
### `git add` / `git commit`
```
$ git add MobileGL/MG_Util/Metrics/PipeStats.h # exit 0
$ git commit --dry-run # exit 0
On branch feat/disaggregated
Your branch is up to date with 'origin/feat/disaggregated'.
Changes to be committed:
modified: MobileGL/MG_Util/Metrics/PipeStats.h
```
为验证安全性额外查了三件事:
- **暂存不会误 bump gitlink**:在临时 index 上跑 `git add -A`,13 个 gitlink 的 sha 与操作前完全一致(无伪更新)。
- **索引内容零改动**:`git add` + `git reset` 往返后,`git ls-files -s` 与操作前逐行相同(0 行差异,`md5` 也一致)。
- **`git commit --dry-run` 在"没有暂存内容"时退出码本来就是 1**——这是 git 的正常行为(在全新空仓库上复现同样结果),
不是本 worktree 的问题;一旦有暂存内容即退出码 0(上面那段输出就是证据)。
验证完已 `git reset` 撤销暂存,**没有产生任何提交**。
## 6 回归检查:兄弟工作树与超项目未受影响
| 对象 | 结果 |
|---|---|
| `git -C MobileGL status -sb` | 与修复前**逐字节相同**(12 个未初始化 `-<sha>` 不变) |
| `git -C MobileGL submodule status` | 与修复前**逐字节相同** |
| `git -C FoldCraftLauncher status -sb` | 与修复前**逐字节相同**(含原有的 `M .gitmodules`、`UU FCLauncher.java` 等,均非本次产生) |
| 各仓库 HEAD | `MobileGL` = `fff9d639`、`MobileGL-disagg` = `71aa9951`、超项目 = `6d733f6f`,**均未移动** |
被改到的共享 admin 目录的 **index、HEAD、objects 全部未动**(只删了 `core.worktree` 配置项,以及给 flatbuffers 补建新 admin)。
## 7 遗留 / 已知问题(不粉饰)
1. **` m`(modified content)与 flatbuffers 的 6 个 ` M` 是既有噪声,非本次修复引入。**
逐项查证:`git status` 报 3109 个文件"已修改",但(a)`git --no-pager diff --numstat` 的非零内容差异为 **0**,
(b)用 `GIT_INDEX_FILE=<临时> git read-tree HEAD` 重建全新 index 后降到 **7 个**(那 7 个是 DiligentCore 自己带的嵌套子模块 gitlink,
它们 pin 与实际 HEAD 相同,只是同样带着这层 stat 噪声),
(c)拿**修复前的 index 备份**(`/tmp/p6gitfix/idx/…`)跑同一命令,同样报 3109。
即这些标记来自共享 admin index 里**过期的 stat 缓存**(如 DiligentCore 的 index 记 size 10636、磁盘 10147,mtime 陈旧),
是这些 checkout 被拷贝/迁移时留下的,`git update-index --refresh` 在这台机器上清不掉。
它不影响 `status`/`submodule status`/`add`/`commit` 的正确性(见 §5 的 gitlink 与索引零改动验证),
但会让 `git status` 输出偏噪。**本次未处理**(改 index 超出"修指针"的范围)。
> **已验证的安全清理办法**(留给后续执行者):对每个子模块用**临时 index** 试跑 `git add -u`,
> 实测是**纯 stat 缓存刷新、被跟踪 blob 零改动**。逐个子模块在临时 index 上的效果:
>
> | 子模块 | 清理前 | 清理后 | blob 改动 |
> |---|---:|---:|---:|
> | `3rdparty/DiligentCore` | 3109 | 7 | 0 |
> | `3rdparty/SPIRV-Cross` | 4603 | 0 | 0 |
> | `3rdparty/SPIRV-Reflect` | 276 | 1 | 0 |
> | `3rdparty/Vulkan-Headers` | 88 | 0 | 0 |
> | `3rdparty/Vulkan-Utility-Libraries` | 71 | 0 | 0 |
> | `3rdparty/VulkanMemoryAllocator` | 218 | 0 | 0 |
> | `3rdparty/apitrace` | 627 | 9 | 0 |
> | `3rdparty/asio` | 1535 | 1535 | 0 |
> | `3rdparty/flatbuffers` | 6 | 6 | 0 |
> | `3rdparty/glslang` | 23 | 0 | 0 |
> | `3rdparty/tracy` | 382 | 0 | 0 |
> | `include/ska` | 4 | 0 | 0 |
>
> 两个例外清不掉,且都是**既有内容差异**而非缓存问题:`asio` 的 2 个 ` T`(`asio/include`、`asio/src`
> 是真实符号链接,而上游 pin 是普通文件)和 flatbuffers 的 6 个符号链接项。
> 真要落地请逐个复查并在报告里说明这些例外。
2. **flatbuffers 的 admin 是我补建的**(原本不存在)。若日后跑标准的
`git submodule update --init`,git 会另建一份**逐工作树**的 admin
(`worktrees/MobileGL-disagg/modules/3rdparty/flatbuffers`,共享 objects)并改写指针文件——这是 git 的正常行为,
两条路径不会冲突;我也把补建的 admin 放在**共享**位置以与另外 12 个指针保持一致。
3. **`3rdparty/glslang/External/spirv-tools` 及其 `spirv-headers` 是独立 `.git` 目录**,
不受指针机制影响,但它们在 disagg 里也显示 ` M`(43 项,内容差异为 0,同类 stat 噪声),本次未动。
4. 备份留在 `/tmp/p6gitfix/`(`backup.txt` 记全部 35 个 `.git` 条目的原始内容、32 个 admin config、34 个 index、以及 dev/超项目的状态快照),
本机重启会清掉;需要留档请自行复制。
## 8 边界:本修复没有碰的东西
为了不误伤并行工作,明确列出本修复**未触碰**的部分:
- **没有回滚或修改任何源码**。`MobileGL/MG_Util/Metrics/PipeStats.{h,cpp}`、`MobileGL/MG_Util/Metrics/PipeStatsTest.cpp`、
`MobileGL/MG_Remote/Wire/PipeWireCodec.cpp`、`tools/device_bench/pin_device.sh` 的工作区改动是并行代理的合法产物,
本修复只读不写(只在 `git add`/`reset` 往返里短暂进过索引,已验证索引被跟踪内容 md5 与修复前一致)。
- **没有新增/移动任何提交**:`MobileGL-disagg` 仍停在 `71aa9951`,与 `origin/feat/disaggregated` 同步;
`dev` 工作树仍在 `fff9d639`;超项目仍在 `6d733f6f`。
- **没有改分支**:`feat/disaggregated` 原样。
- **没有动 `3rdparty/*` 里的文件内容**(flatbuffers 的 admin 是建在 `.git/modules/...` 下的新 git 目录,
不在工作树里;工作树中只改写了 `.git` 指针文件这一行)。
- **没有用 `git submodule update --init`**(那会重新克隆并可能重写指针),全部是元数据级定点修复。
### 回归核对(与修复前快照逐字节比对)
| 对象 | 结果 |
|---|---|
| `git -C MobileGL status -sb`(dev 工作树) | **逐字节相同** |
| `git -C MobileGL submodule status`(dev 工作树) | **逐字节相同**(12 条 `-<sha>` 未变) |
| `git -C FoldCraftLauncher status -sb`(超项目) | **逐字节相同** |
| 共享 admin 的 `index` / `HEAD` / `objects` | 未动(只改 `core.worktree` 配置项 + 新建 flatbuffers admin) |
| `.gitmodules`(工作树 & 分支) | 未动,且二者一致 |