mirror of
https://github.com/MobileGL-Dev/MobileGL
synced 2026-11-07 01:53:24 +09:00
[Perf] (MG_Remote/Client): the create window lands OFF - the deferral cannot be collected, 8 reply slots against 59,673 replies in one frame - measured on the device at 4,536 blocking creates either way
The window was the handoff's remaining item: let up to N resource_create answers be
outstanding, read a provisional accept, collect the answers in order, and un-latch an
object whose late answer is DECLINED. It is a sound mechanism and the gates below pin
it - and on the device it does nothing at all, which is why the default is 1.
MEASURED, same device, same frame, same 101,421,396 B staged, same binary:
MOBILEGL_IPC_CREATE_WINDOW=4 by_op 2 = 4,536 wall 28.64 s
MOBILEGL_IPC_CREATE_WINDOW=1 by_op 2 = 4,536 wall 28.64 s
(the handoff's pre-window baseline: 31.3 s)
by_op 2 counts the creates that took the BLOCKING path (LinkMetricsBeginReply is
called with ownsReplySlot = rowCarriesReplySlot && wantReply), so it not moving means
no create deferred. A temporary counter in the row said why: pushed=24, blocked=4,476,
took=20, readfail=8,951, pending pinned at 4 - the drain READ the answers back and
failed every time.
WHY, IN THE PROTOCOL'S OWN ARITHMETIC. An answer is readable only until its pool slot
is reused, and the pool is kDefaultReplySlotCount = 8. The server answers EVERY
reply-owning record, not only the rows that wait, because it cannot know which those
are (ClientSession.cpp:1897-1900: "The server posts either way"). This frame posts
59,673 of them: create 4,536 + respecify 20,633 + params 18,056 + sub-data 12,671 +
the rest. 8 slots against 59,673 replies churns 7,459 times per frame, i.e. 13.2
replies between two creates, so an answer is overwritten after 8 replies - 0.61 of a
create. The deferral is not slow, it is UNREADABLE, and no window depth fixes that.
AND IT IS NOT NEUTRAL: with the window on, the provisional accept latches an object
the applier may have refused, and the un-latch that corrects it lives in that same
unreadable drain. A refusal would become an object the client believes is published -
the silent divergence the suspect list exists to prevent. So the machinery is kept
(one experiment away from usable: a deeper pool, or a "do not answer me" bit, both
wire changes) and the default is off.
Worktree note: the window implementation itself was uncommitted in the tree at
82ed1df9 (MOBILEGL-CS-HANDOFF-2.md section 2, "can compile, gates mostly green, never
run on a device"). This commit lands it in the state the measurement supports. The
row also gains the heal the suspect list was missing - a BLOCKING create that lands
takes its object back out of it, or kCreateSuspectMax recoveries would retire the
window for the rest of the process - and the clamp that ties the environment's value
to the array, with a static_assert tying the array to half the reply pool so "someone
raises kCreateWindowMax to 8 and steps on the pool" is a compile error.
Gates (RemoteClientControls.inc), all green, 171/171 RemoteClientTest:
ACreateWindowDefersTheAnswersAndTheWindowOffDoesNot - window on = 12 records 0 waits;
the SAME CHILD turns the knob to 1 and it is 12 records 12 waits (the negative
control); pending <= effective after every create; 99 clamps to the array; the
array is <= half the pool
ARefusedCreateUnlatchesItsObjectAndItsNextCreateBlocks - a real refusal (the
server's consumed mask narrowed) clears the latch, +1 suspect, the object's next
create BLOCKS (2 records 2 waits), and restoring the consumer HEALS it (latch
back, suspect -1)
Docs: CONTRACT-P5E.md section 2.5 rewritten (it still said all three rows keep
kWaitReply), plus
docs/Disaggregated/notes/p12/CREATE-WINDOW-MEASURED.md (the numbers, the arithmetic,
the picture evidence: both arms SSIM 0.977) and
docs/Disaggregated/notes/p12/UNPUBLISH-GUARD-SIGNALS.md (handoff section 3: neither
caps generation nor FreshlyPrimed is a signal for "the applier dropped its records",
and the residual gap is one line - the dedup at TextureEmit.h:838 does not read the
latch, so an UNCHANGED descriptor after a drop produces no record and therefore no
refusal to self-heal from).
This commit is contained in:
@@ -0,0 +1,132 @@
|
||||
# create window 的实测否决,与 StreamLink 的一处 ILink 契约违反(handoff-2 §2 收尾记录)
|
||||
|
||||
> 2026-09-26。分支 `feat/disaggregated` @ `82ed1df9` 加本轮工作树改动。对应
|
||||
> [`MOBILEGL-CS-HANDOFF-2.md`](../../../../MOBILEGL-CS-HANDOFF-2.md) 的 §2(工作树里那版 create window)
|
||||
> 与 §7 的「把 create 那 4,536 批成 ~1,134」。
|
||||
|
||||
## 0. 一句话
|
||||
|
||||
- **窗口开了等于没开**:同一台设备、同一帧、同一 101,421,396 B staged,`MOBILEGL_IPC_CREATE_WINDOW=4`
|
||||
与 `=1` 的 create 行都是 **4,536 次等待**,墙钟都是 **28.64 s**(handoff 的 pre-window 基线是 31.3 s)。
|
||||
临时计数器给出的形态是:`pushed=24 blocked=4,476 took=20 readfail=8,951 pending=4`——**drain 读不回来**。
|
||||
- **不是调参问题**:回复池 8 槽,而这一帧发出 **59,673 条带回复的记录**(服务端对每条 kReplySlot 记录都回,
|
||||
不管客户端要不要),所以一条答案在 **8 条回复**之后就被覆盖,等于 **0.61 个 create**。任何深度的窗口都读不回来。
|
||||
- **顺带修掉一个真 bug**:`StreamLink::ReadReply` 只认「最新一条」(单槽),违背 `ILink::ReadReply(seq)`
|
||||
的按 seq 读取语义。已改成一个 8 槽环 + 用例。
|
||||
- **默认关掉**:`MOBILEGL_IPC_CREATE_WINDOW` 默认 1。窗口开着不只是白费,它会让**被拒绝的 create 留在「已发布」状态**
|
||||
(反闩动作在同一个读不回来的 drain 里),正是 suspect 表要防的那种静默分歧。
|
||||
|
||||
## 1. 实测(同一设备 aikjs4s8cys8ba7l,同一世界,出厂省电)
|
||||
|
||||
| run | 窗口 | `frame-sent 2=` | `frame-op by_op 2=` | 墙钟 | 日志 |
|
||||
|---|---|---|---|---|---|
|
||||
| handoff 基线(无窗口的二进制) | — | 4,536 | 4,536 | 31.26 s | `logs/mcx.client` |
|
||||
| 窗口 4 | 4 | 4,536 | **4,536** | **28.64 s** | `logs/mcx-fix4.client` |
|
||||
| 窗口 1(负控) | 1 | 4,536 | **4,536** | **28.64 s** | `logs/mcx-fix1.client` |
|
||||
|
||||
`by_op 2` 数的是**走了阻塞路径的 create**(`LinkMetricsBeginReply(ownsReplySlot, op)`,
|
||||
`ownsReplySlot = rowCarriesReplySlot && wantReply`,`ClientSession.cpp:1840/1883`)。它没动,就是「一条都没延后」。
|
||||
两臂的 `frame-sent` 全表逐 op 相同(4,536 / 20,633 / 3,770 / 18,056 / 12,671 …),也就是**记录一条没少发**。
|
||||
|
||||
窗口确实进去过——临时行(每 500 条打一次)在窗口 4 下的读数:
|
||||
|
||||
~~~
|
||||
P12diag create=501 cfg=4 eff=4 suspects=0 refusals=0 pending=4 pushed=24 blocked=476 took=20 waitfail=22 readfail=951
|
||||
P12diag create=4501 cfg=4 eff=4 suspects=0 refusals=0 pending=4 pushed=24 blocked=4476 took=20 waitfail=22 readfail=8951
|
||||
~~~
|
||||
|
||||
`pending` 从第 500 条起**一直钉在 4**,`refusals=0`(一条答案都没读到过),`readfail` 约每条 create 涨 2
|
||||
(非阻塞 poll + 阻塞 drain 各失败一次)。
|
||||
|
||||
## 2. 根因:回复池 8 槽 vs 一帧 59,673 条回复
|
||||
|
||||
答案只在**它的槽没被复用**之前可读(`ReplySlotPool`,`kDefaultReplySlotCount = 8`,
|
||||
`Transport/ReplySlot.h:111`)。而这一帧发回复的记录数是:
|
||||
|
||||
| 行 | 条数 | 为什么回 |
|
||||
|---|---|---|
|
||||
| 2 `ResourceCreate` | 4,536 | 等 |
|
||||
| 3 `ResourceRespecify` | 20,633 | **不等也给**(首用确认后 fire-and-forget) |
|
||||
| 47 `SetTextureParams` | 18,056 | **不等也给** |
|
||||
| 48 `ResourceSubData` + 4 | 16,441 | **不等也给** |
|
||||
| 5 / 9 等 | 7 | 等 |
|
||||
| **合计** | **59,673** | |
|
||||
|
||||
服务端**无法知道**客户端要不要这条答案——`ClientSession.cpp:1897-1900` 原文:「The server posts either way,
|
||||
so the wire is unchanged and the row keeps its flag」。于是池子一帧churn **7,459 次**,两条 create 之间平均夹
|
||||
**13.2 条回复**,而一条答案只活得下 **8 条**:
|
||||
|
||||
8 / 13.2 = 0.61 个 create
|
||||
|
||||
**所以窗口的失败不是「慢」,是「读不到」。** 1 深度的窗口就是阻塞路径本身。
|
||||
|
||||
## 3. 顺带发现并修掉:`StreamLink::ReadReply` 违背了自己的接口
|
||||
|
||||
`ILink::ReadReply`(`Transport/ILink.h:285`)的签名是**按 seq 读**;`ShmLink` 用 `ReplySlotPool` 实现它,
|
||||
而 `StreamLink` 到本轮为止只保留**一条**回复(`reply` / `replySeq` / `replyStatus`),并且:
|
||||
|
||||
~~~cpp
|
||||
x.replyReady.wait(lock, [&]{ return closed || x.replySeq >= seq || appliedSeq >= seq; });
|
||||
if (x.replySeq != seq) return ... MOBILEGL_ERR_PROTOCOL_MISMATCH; // 等的是 >=,判的是 ==
|
||||
~~~
|
||||
|
||||
「等到至少 seq」与「只接受恰好 seq」自相矛盾:任何早于最新一条的答案都读不回来。此前没有调用者受影响,
|
||||
是因为每个调用者读的都是自己刚等到的、也就是最新的一条;create window 是第一个读**旧**答案的调用者。
|
||||
已改成 8 槽环(`kReplyRing`,深度取它顶替的那个池:`kDefaultReplySlotCount`),
|
||||
用例 `StreamLinkTest.AnOlderReplyIsStillReadableByItsSeq` 三连发、按 1→2→3 顺序读回。
|
||||
|
||||
**注意这只治好了「单槽」,没治好「池只有 8 槽」**——`ShmLink` 侧本来就是池,也一样读不回来。这也是为什么
|
||||
设备上的读数在修掉单槽之后仍然一模一样。
|
||||
|
||||
## 4. 为什么这是个必须默认关掉的开关,而不是一个慢一点的开关
|
||||
|
||||
窗口的答案是**临时接受**:`PublishCreate` 会因此落闩(「applier holds this handle」),
|
||||
而纠正它的唯一动作——迟到 DECLINE → `MGPipeNoteHandleUnpublished` + suspect 表——**就在那个读不回来的 drain 里**。
|
||||
换句话说窗口开着时,**服务端的拒绝会被客户端当成已发布**,之后每条已转换的行都对一个不存在的记录 fire-and-forget。
|
||||
这正是 suspect 表存在的理由,也是 `TextureEmit.h` 里 `RespecifyOnce` 那句话(「applier 的 REFUSAL 是唯一说
|
||||
『我手里没有这个 handle』的信号」)要防的东西。
|
||||
|
||||
## 5. 门(`MG_Test/Wire/RemoteClientControls.inc`)
|
||||
|
||||
窗口机制本身是自洽的,只是在这条协议上不可用,所以门保留并 pin 住机制:
|
||||
|
||||
| 用例 | 钉住什么 | 读数 |
|
||||
|---|---|---|
|
||||
| `ACreateWindowDefersTheAnswersAndTheWindowOffDoesNot` | 窗口开=12 记录 0 等待;**同一子进程**把窗口关回 1 = 12 记录 **12** 等待(负控);`pending <= effective` 逐条断言;99 被夹到数组大小;数组 <= 池的一半 | ON 0 waits / OFF 12 waits |
|
||||
| `ARefusedCreateUnlatchesItsObjectAndItsNextCreateBlocks` | 真拒绝(收窄服务端 consumed mask)→ **闩清零**、suspect 计数 +1、下一条 create **阻塞**(2 记录 2 等待)、恢复消费者后 **heal**(闩回来、suspect −1) | suspects 6 → 5 |
|
||||
| `StreamLinkTest.AnOlderReplyIsStillReadableByItsSeq` | 传输层按 seq 可读 | 3/3 |
|
||||
|
||||
编译期还有一条:`WireTables.cpp` 的 `static_assert(kCreateWindowMax <= kDefaultReplySlotCount / 2, …)`,
|
||||
把「有人把窗口改成 8 就会踩池子」变成编译错误而不是设备上的错画面。
|
||||
|
||||
## 6. 画面(handoff-2 §6 要的那一格)
|
||||
|
||||
设备端(`MOBILEGL_IPC_SURFACE=server`,画面在手机屏上)在负载帧闭合后 +8 s 截屏:
|
||||
`logs/mcx-fix4.shot2.png` 与 `logs/mcx-fix1.shot2.png` 都是真实世界(地形、物品栏、聊天行),
|
||||
**两臂 SSIM = 0.977**(均值绝对差 2.26/255)。同一 run 内 加载页 vs 世界 SSIM 只有 0.12,说明这个数字不是
|
||||
「两张都黑」的假阳性。**这仍然只是间接证据**:两臂行为本来就相同(窗口是 inert 的),它能说明的是
|
||||
「窗口开着没有把画面弄坏」,不是「延后一条确认后画面仍然对」。
|
||||
|
||||
## 7. 下一步(如果要救这个想法)
|
||||
|
||||
要让延后的答案活下来,只有两条路,**都是协议面而不是调参**:
|
||||
|
||||
1. **加深回复池**:`ReplySlotCount` 在 Hello 里协商(`ClientSession.cpp:957` / `ServerSession.cpp:745`),
|
||||
两端一致即可改。需要的深度 \>= 两条 create 之间的回复数(本帧 13.2)乘以窗口深度,即 4 深度要 ~53 槽;
|
||||
而 `ReplyBytes` 固定 16 MiB 时槽会变小,`ReadPixels` 的 `ReplyCanHold` 会先撞墙,所以要一起抬 `ReplyBytes`。
|
||||
2. **让客户端能说「这条别回」**:四个 Bool 行(create/respecify/params/sub-data)目前服务端一律回;
|
||||
一个 record flag 就能把 59,673 砍到 ~4,543,那时 8 槽够用。这是 wire 改动,两端都要动。
|
||||
|
||||
## 8. 复算
|
||||
|
||||
~~~sh
|
||||
# 两臂的帧读数(handoff-2 §1 的读法)
|
||||
python3 mgl-device/bigframe.py mgl-device/logs/mcx-fix4.client mgl-device/logs/mcx-fix1.client
|
||||
grep -a "kind=frame-op frame=" mgl-device/logs/mcx-fix4.client | tail -1
|
||||
grep -a "kind=frame-sent frame=" mgl-device/logs/mcx-fix4.client | tail -1
|
||||
# 一轮设备 run(自带 force-stop、就绪判据、负载帧检测、截屏、SIGTERM 收尾)
|
||||
cd mgl-device && python3 run-once.py <tag> [MOBILEGL_IPC_CREATE_WINDOW=4]
|
||||
# 门
|
||||
build-split/MobileGL/MG_Test/Wire/RemoteClientTest --gtest_filter='*CreateWindow*:*RefusedCreate*'
|
||||
build-split/MobileGL/MG_Test/Wire/StreamLinkTest --gtest_filter='*OlderReply*'
|
||||
~~~
|
||||
@@ -0,0 +1,100 @@
|
||||
# 「①」该挂哪个信号:三个候选的判定(handoff-2 §3 调查记录)
|
||||
|
||||
> 2026-09-26。分支 `feat/disaggregated`。对应 [`MOBILEGL-CS-HANDOFF-2.md`](../../../../MOBILEGL-CS-HANDOFF-2.md)
|
||||
> 的 §3「把『①』挂到已有信号上」。**结论是 handoff §3.2 的前两个候选都不是信号**,成本仍在协议面;
|
||||
> 本文把三条理由落到符号,并把真正的缺口缩到一句话。
|
||||
|
||||
## 0. 一句话
|
||||
|
||||
- **§3.2 候选 1(caps 代次)不是信号**:caps Adopt 是「快照重发」,而 `MGPipeApplierReset`(make-current 的
|
||||
服务端半边)**故意保留**对象记录——代次动了 ≠ 记录没了。
|
||||
- **§3.2 候选 2(FreshlyPrimed)是同一个理由,而且它就是空的**:`TextureEmit.h:1257` 的
|
||||
`void Reset() {}` 函数体为空,注释写明「Only LATCHES reset here - the applier's object records survive a
|
||||
make-current」。
|
||||
- **真正的缺口只有一种**:描述符**没变**的 respecify 会在发射端被去重吞掉(`TextureEmit.h:838` 的
|
||||
`unchanged` 不读闩),连「拒绝」这个唯一的客户端可见信号都产生不了。描述符变了的那一半今天**已经能自愈**。
|
||||
- **今天不可达**:`MGPipeApplierReleaseObjectRecords` 在生产代码里没有调用者(只有 MG_Test,实测 grep 见 §5)。
|
||||
|
||||
## 1. 失效时要清的三样,逐条落到符号
|
||||
|
||||
| handoff §3.1 | 符号 | 今天的状态 |
|
||||
|---|---|---|
|
||||
| 发布闩 | `MGPipeNoteHandleUnpublished(kind, handle)`(`PipeFill.cpp:1890`),按 `{kind, slot, gen}` 键 | 对象销毁会清;跨 make-current 保留(设计如此) |
|
||||
| 各 emitter 的镜像 | `TextureEmit` 的 `entry.HasLastDesc` / `entry.HasParamsLatch` | **只清闩不够**:去重比闩更早,见 §4 |
|
||||
| create window 的 suspect 表 | `g_createSuspects`(`WireTables.cpp`) | 窗口默认已关(见 `CREATE-WINDOW-MEASURED.md`),这一条随之为空 |
|
||||
|
||||
## 2. 候选 1:`CapsMirror::Generation()` —— 动的是快照,不是记录
|
||||
|
||||
`CapsMirror::Generation()` 每次 `Adopt` 自增,而 Adopt 的触发点是**caps 快照重发**:`ServerMakeEGLCurrent`
|
||||
对「它没持有的 tuple」转发绑定时会重发一次(ID-67,`MakeCurrentRepublishCount`)。也就是说代次动
|
||||
**只说明服务端重新回答了一次能力问题**,不说明它的记录表空了。反证在 `MGPipeApplierReset` 自己身上:
|
||||
它是 make-current 的服务端半边,`PipeApply.cpp:1584-1590` 清的是 working state 与三个 binding-point 窗口,
|
||||
**texture / sampler / view / shader-CSO 记录一个都不清**——注释把理由写死了:「a GL object lives in a
|
||||
share group, and re-publishing one would move its Serial for nothing」。
|
||||
|
||||
照代次清 = 把「记录还活着」的每一次 make-current 也当成失效,每次切上下文重发全部存活纹理。方向安全
|
||||
(`PipeApply.h:822`:「Over-firing is the safe」),但这是**为一个今天不可达的路径付每次 make-current 的钱**。
|
||||
|
||||
## 3. 候选 2:`FreshlyPrimed` —— 同一个理由,且这个函数是空的
|
||||
|
||||
`PipeFill.cpp:3761` 的 FreshlyPrimed 臂确实在 reset 五个 emitter,但纹理那一个的 reset 是:
|
||||
|
||||
~~~cpp
|
||||
// A fresh context: what the server has is no longer what this emitter last sent. Only
|
||||
// LATCHES reset here - the applier's object records survive a make-current and
|
||||
// re-publishing them would move their serials for nothing.
|
||||
void Reset() {} // TextureEmit.h:1257
|
||||
~~~
|
||||
|
||||
空实现就是「这里没有可清的东西」的落地写法。同一个文件 `TextureEmit.h:1267-1271` 还把话说到另一头:
|
||||
「a texture handle and the applier record it names are SHARE-GROUP OBJECT STATE, so nothing here is
|
||||
per-context and **no re-publication path exists or may exist**」。所以候选 2 不是「一行」,是**推翻一条设计**。
|
||||
|
||||
## 4. 真正的缺口:去重在发射端,比闩更早
|
||||
|
||||
`TextureEmit.h:838`:
|
||||
|
||||
~~~cpp
|
||||
const Bool unchanged = entry.HasLastDesc && std::memcmp(&entry.LastDesc, &desc, sizeof(desc)) == 0;
|
||||
~~~
|
||||
|
||||
**它不读闩。** 于是 `MGPipeApplierReleaseObjectRecords` 之后两条路不一样:
|
||||
|
||||
| 情形 | 今天的走向 | 结果 |
|
||||
|---|---|---|
|
||||
| 描述符**变了** | 不去重 → 闩仍为真所以不发 create → `RespecifyOnce` 发 respecify → applier 拒绝 → **补一个 create 再重试** → 接受 | **已自愈**(handoff §3.3 要的门,今天就是绿的) |
|
||||
| 描述符**没变** | `if (unchanged) return;` | **一条记录都不过线**,服务端没有机会拒绝,自愈链断在这里 |
|
||||
|
||||
所以「①」要清的其实是第 2 行,而它要的**不是清闩,是清镜像**(`HasLastDesc`/`HasParamsLatch`)——
|
||||
只清闩的话那条「相同 desc」照样不发(handoff §3.1 已经点到,这里是它的符号)。
|
||||
|
||||
## 5. 可达性(本轮复核)
|
||||
|
||||
~~~
|
||||
$ grep -rn "MGPipeApplierReleaseObjectRecords" MobileGL/ --include=*.cpp --include=*.h | grep -v MG_Test
|
||||
PipeApply.cpp:1593 void MGPipeApplierReleaseObjectRecords() { <- 定义
|
||||
(其余全部是注释)
|
||||
~~~
|
||||
|
||||
与 handoff §3.4 一致:**生产调用者为零**,唯一调用者是 MG_Test(`PipeWireCodecTest`/`StagedTextureStoreTest`
|
||||
各一处、若干 `*EmitTest` 的 SetUp/TearDown)。闩按 `{kind, slot, gen}` 键、对象销毁会清闩,槽位复用安全。
|
||||
|
||||
## 6. 建议(本轮不做,理由在下面)
|
||||
|
||||
1. **不加反向回调**:那是协议面(两端重出 APK),而触发者今天不存在。handoff §7 的判词是「今天是潜伏,
|
||||
别为它付协议面的钱」。
|
||||
2. **不挂代次**:理由见 §2/§3——它会在每次 make-current 上 over-fire,为一个不可达路径付真实成本。
|
||||
3. **该做的是把缺口写成门**:用 `MGPipeApplierReleaseObjectRecords` 造「记录被丢掉」,
|
||||
断言**描述符变了那一半必须自愈**(今天绿,红的方式是把 `RespecifyOnce` 的补 create 去掉),
|
||||
并把**描述符没变那一半**作为已知缺口记账——它是这件事真正欠的债。
|
||||
4. 如果哪天真出现生产调用者,正确形状是给 entry 记一个「本代次内确认过」的戳(caps 代次或会话代次),
|
||||
代次一动就重发一次:它是 over-fire 方向,不用动 wire。
|
||||
|
||||
## 7. 复算
|
||||
|
||||
~~~sh
|
||||
grep -rn "MGPipeApplierReleaseObjectRecords" MobileGL/ --include=*.cpp --include=*.h | grep -v MG_Test
|
||||
sed -n '1249,1258p' MobileGL/MG_Impl/Pipe/TextureEmit.h # void Reset() {}
|
||||
sed -n '834,840p' MobileGL/MG_Impl/Pipe/TextureEmit.h # unchanged,不读闩
|
||||
sed -n '1575,1591p' MobileGL/MG_Pipe/PipeApply.cpp # MGPipeApplierReset 清什么、不清什么
|
||||
~~~
|
||||
Reference in New Issue
Block a user