Files
MobileGL/docs/Disaggregated/PLAN.md
T

1291 lines
164 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# MobileGL 前后端进程拆分实施计划(branch `feat/disaggregated`
> **2026-09-05 追加:本文件是方案 Areplica `GLContext`)。经用户方向修正——backend 应拥有贴近后端 API 的状态机并通过 gallium 式显式接口解耦——推荐路线改为方案 B,见同目录 `PLAN-B-MGPipe.md`(含逐项对比与 GO/NO-GO 对冲路径)。本文件保留:§6-§13(传输、数据面、同步、present、线程、平台、构建)被方案 B 原样继承并以本文为准;§5、§12 的 replica 特化部分已被取代。**
> 状态:设计定稿 v12026-09-05)。基线 `dev@81b17c0b`;实施分支 `feat/disaggregated`worktree `../MobileGL-disagg`)。
> 产出方式:7 个只读代码调研 → 4 个独立架构方案 → 3 个评审打分 → 综合 → 3 个对抗性审查(38 条发现)→ 修订;评审记录见同目录 `REVIEW.md`。
> 上一次尝试 `Feat/CS-Delta-IPC`2026-08-29/30worktree `../MobileGL-CS`)的复用/丢弃结论见 §14。
---
## 0. TL;DR 与核心决策
**Server 就是 `libMobileGL` 自己**,在自己的进程里跑一个**真实的 `MG_State::GLState::GLContext`replica**,由一个 delta applier 通过普通 MG_State mutator API 驱动。**两个 backendDirectGLES 27k / DirectVulkan 40k 行)一行不改。** Client 也是同一个 `libMobileGL`,在 init 时换掉几个对象:`MG_Backend::gBackendFunctionsTable` 换成发射表,`MG_Backend::pActiveBackendObject` 换成 `BackendObject_Remote``SetBufferBackendOps` 换成发射 ops。一份产物,两个角色,由一个 env var 选择。
这样做的唯一理由是:**backend 的 draw-path 失效模型无法表达成 wire 字段。** DirectGLES 有 memo 直接借用 binding slot 的 `shared_ptr` 地址(`DirectGLES.cpp:1463-1477` `UnitTextureSyncEntry` + `PairingsIntact`);`VertexInputStateFactory.cpp:78` 把**后端堆上的裸指针**写进前端 VAO;`IsBufferDrawClean` 开头就是裸指针身份比较(`Managers.cpp:1435-1436`,注释:"Identity first: a respecify path can hand the frontend a NEW resource");三个门控计数器是回绕的 `Uint16`,只有配合指针身份比较才正确(`Managers.h:772-777`postmortem 在 `DirectGLES.cpp:2823-2831`);`UniformManager.cpp:1418-1497` 构造并驱动真实 `TextureObject2D``VulkanRenderer.cpp:4211-4356` 通过真实 `ShaderObject`/`ProgramObject` 编译链接 GLSL。replica 逐字满足 DirectGLES 107/107、DirectVulkan 165/169 次 `pGLContext` 读取;重写则是把一套被文档记录为"具体设备 bug 疤痕组织"的失效模型重新推导一遍——那正是 `Feat/CS-Delta-IPC` 走的路,它一帧都没渲出来。
**第二个核心决策:每个困难语义先上"慢但可证明正确"的版本,后续阶段用 flag 换成快版本,并把慢版本保留成 oracle。**
- Phase 1-4**server 从源码重新 link shader**(只需 5 个 schema 字段,而不是 ~40 字段的 reflection schema),并带 `reflectionDigest` 交叉校验 → Phase 5 换成 `ProgramPublish``relink` 保留为 A/B 对照与常驻 oracle。
- Phase 1-6**关闭 ≥16MiB persistent-map 采纳**(前端在 `BufferObject.cpp:174,439-442,470-472` 已容忍 `nullptr` 返回,`MOBILEGL_DISABLE_LARGE_BUFFER_ADOPTION` 已存在)→ Phase 7 攻 external memory 导出,**允许结论是"设备 X 上拒绝,已记录,回退成本 N ms"**。
- Phase 1-4**调用时刻拷贝进 ring**WAR 由构造消除)→ Phase 4.5 shadow-in-shm 零拷贝。
**第三个核心决策(本轮对抗性评审后新增,是本计划与上一版最大的语义差异):客户端是所有"隐式发布"语义的唯一发起者。**
上一版把三件事交给"server 在 replica 上照常做,事件回传给 client",全部被证伪:
1. **persistent-map 的写发布**`BufferObject::SyncPersistentMappedRange()``BufferObject.cpp:238-250`)是 shadow-backed persistent 非-FLUSH_EXPLICIT map 的**唯一**推送点,而 `grep -rn SyncPersistentMappedRange MobileGL/` 的全部生产调用点都在 `MG_Backend/` 里(DirectGLES.cpp:262/4412/4666/4667/4768/4769、Managers.cpp:1547、MultiDraw.cpp:498、DirectVulkan.cpp:290/481/895、UniformManager.cpp:2022、VkBufferManager.cpp:573/620、VulkanRenderer.cpp:3432/3511/3826/7070/12015/12016)。MG_Impl 与 MG_State 里**一个都没有**。拆分后这段代码跑在 server 对 replica 上,而 replica 的 `m_isMapped` 是 false(没有 map delta),第一行就 returnclient 侧则根本没人调。**应用通过 coherent persistent map 写下的字节会被静默丢弃。**
2. **`MarkGpuWritten`**:同样只有 `MG_Backend/` 里的 6 个调用点(`DirectGLES.cpp:465,509,1809``UniformManager.cpp:1073,1229``VulkanRenderer.cpp:11210`),而它在 monolith 里是**在 draw 调用内同步置位的**。拆分后 draw 是 fire-and-forget`glDrawElements(); glMapBufferRange(SSBO, READ);` 会在 server 还没 apply 之前就读到陈旧 shadow,零 round trip、零报错。
3. **纹理 dirty flag**:上一版声称"client 从不清 dirty flag",但 `MipmapStorage::MarkDirtyRegion``MipmapStorage.cpp:196-233`)只要 `m_isDirty[level]` 为真就把 incoming **并进** union box 并追加 rect,只有 `MarkDirty(level,false)``:171-189`)会重置。永不清 = union box 只增不减、rect 列表饱和、`summedArea*4 >= unionArea*3` 一触发就退化成整 level 上传,正好与计划要保留的调优相反。
所以本版的规则是:**任何 monolith 里由 backend 代码触发的"前端状态发布/消费",在拆分模式下必须由 client 在发射点自己做一遍**,server 侧那份照常跑(它对 replica 操作,幂等或无害)。事件回传只允许作为**收窄优化**,永远不允许作为语义的**建立者**。
**Phase 1 的目标改为:在 Linux 上以 `inproc` 与 `spawn` 两种传输跑通垂直切片;真机 OpenRA traceSSIM ≥ 0.99)移到 P2 出口判据。** 理由见 §15Android 交付链(server `.so` 打包、`untrusted_app` 域 exec、trace app 的 env 透传)本身是独立工作量,把它压进 P1 的 10 天里是上一版最薄弱的排期假设。
### 核心决策速查
| # | 决策 | 理由 |
|---|---|---|
| D1 | Server = replica `GLContext` + 未改动 backend | 293 次 `pGLContext` 读、13 个身份键 memo、25 个 backend→frontend 写全部原样工作 |
| D2 | 发射点 = 三个**已经是间接的**边界(`gBackendFunctionsTable` / `pActiveBackendObject` / `SetBufferBackendOps`),不进 MG_State mutator | monolith 侵入面 = `MG_Backend/Init.cpp:48-70` 里一个 switch 分支;~250 个边界调用点零 `#ifdef` |
| D3 | 版本计数器**不上线**replica 靠 mutator replay 自然 bump | 不需要 `Install*` setter,不需要在 wire 上维护回绕 `Uint16` 的单调性 |
| D4 | 控制面走 **SPSC shm ring**watermark 放在一条**共享 cache line****双向 doorbell** | `GetSyncStatus`/`IsQueryResultAvailable`/ring 回收/present credit 变成一次 acquire load;但**所有等待都必须能挂起**,不能自旋 |
| D5 | FlatBuffers:热路径用 **`struct`**(定长、无 vtable、无 verifier walk),罕见/变长用 `table` 走 socket | 满足"用 FlatBuffers 序列化"的要求,同时 `DrawArrays` 记录 32B 而不是 ~60B |
| D6 | **composite pipeline program 由 client 解析**并下发 handle | `Core.cpp:644` 在 pipeline cache miss 时 `MakeShared<ProgramObject>(0u)` 并 **link**server 在 Phase 5 之后没有源码,必须由 client 定 |
| D7 | 覆盖度由**两侧生成的编译期断言**保证:backend 的 READ 面 **和** MG_Impl 的 MUTATOR 面 | backend 新增一个 read、或 MG_Impl 在 table 调用旁新增一个 mutation 而 applier 没 replay → 编译失败,而不是设备回归 |
| D8 | monolith 保留由 **`nm --defined-only` + `.text` size diff** 机械证明,且**每个阶段都跑**,不只 P0 | 不靠"测试没变" |
| D9 | **client 是隐式发布语义的唯一发起者**persistent map 推送、`MarkGpuWritten`、纹理 dirty 清除、XFB CPU 计数、生成 mip 的存储分配) | 见上文三条被证伪的假设 |
| D10 | `inproc``spawn` 拆成**两个 CMake option**:出货构建只开 `spawn``pGLContext` 保持普通全局,GL 热路径上没有 TLS | Android dlopen 的 shared library 无法用 initial-exec TLS1494 个 `pGLContext->` 上每次 `__tls_get_addr` 调用不可接受 |
---
## 1. 目标与非目标
### 目标
1. 前端(MG_Impl + MG_State + glslang 链接)与后端(MG_Backend + SPIRV-Cross + 驱动)跑在两个进程,通过 IPC 通信。
2. Client 把前端状态 reconcile 成 delta,序列化(FlatBuffers)后发送;server 更新自身状态并调用 backend API。
3. **稳态帧零 round trip**readback / 阻塞式 query / sync wait / present credit / 分配类错误 ack 之外)。
4. 两半尽可能互相异步:client 至多领先 server 1 个 present(默认值,见 §9 的延迟叠加分析)。
5. 平台特定代码最小化并集中在 `MG_Remote/Transport/``MG_Remote/Client/Surface*`
6. **单进程 Monolith 保持字节级不变**,且可机械验证。
7. 所有验收门用**现有测试**`ctest -L unit` / `-L integration-gpu` / `tools/trace_replay` / `tools/cts` / `tools/device_bench`
### 非目标(本分支明确不做)
- **share-group sessioning 重构。** monolith 今天所有 EGL context 共用一个 `GLContext``GLState/Core.cpp:20,1487``eglCreateContext` 只存 `SharedContext``EGLState/Core.cpp:640`,全代码库无人读取)。单 context client 与今天等价。`c7c9e346`/`29d721ef` 那套(共享 VAO-0 破坏、四个头文件 `public:` 泄漏、无锁进程全局 current session、`MOBILEGL_SESSION_SWAP` kill switch)整体丢弃。
- **BFA strict-C-ABI backend 插件 / UtilRuntime C-ABI 化。** server 与 backend 同一 CMake 工程、同一产物发布,ABI 边界永不移动。
- **macOS 拆分。** `CAMetalLayer` 无公开跨进程表示,MobileGL 在 macOS 是 `DYLD_INSERT_LIBRARIES` interposer(导出表锁定于 `CMakeLists.txt:600-612`),无 CI 无设备 → **monolith only,写进文档**
- **Windows 窗口拆分。** WGL / ANGLE-DXGI 对外进程 HWND 不是受支持配置 → **headless(pbuffer) only**
- Phase 9 之前不做任何窗口路径(全部离屏)。
---
## 2. 现状:今天的前后端边界(七个面)
### (a) `GLFunctionsTable` — 73 项,`MG_Backend/BackendObject.h:117-285`
MG_Impl 侧 91 个调用点(`GL_Drawing.cpp` 37、`GL_Query.cpp` 22、`GL_Framebuffer.cpp` 11、`GL_Texture.cpp` 10、`GL_Sync.cpp` 6、`GL_Getter.cpp` 3、`GL_Program.cpp` 1+ `MG_Util/ShaderTranspiler/CompileEnv.cpp:134,138` 两处。
- 20 个 draw、9 个 clear4 个 `ClearNamedFramebuffer*` 携带 `SharedPtr<FramebufferObject>`)、5 个 blit/copy`CopyImageSubData` 携带两个 `CopyImageEndpoint``BackendObject.h:32-39`)、`GenerateMipmap`、3 个 readback、4 个 compute/barrier、`BindImageTexture`(已经收 GL name)。
- **两项是死代码**`GetInteger64i_v``BackendObject.h:196`MG_Impl 零调用点;`GL_Getter.cpp:1307` 把 64 位形式委派给 32 位)和 `GetProgramiv``:197``GL_Program.cpp:851` 全部从 `ProgramObject` 回答)。两个 backend 都注册并实现了它们。
- `GetIntegeri_v` 只有 `GL_MAX_COMPUTE_WORK_GROUP_COUNT/SIZE` 真正转发(`GL_Getter.cpp:1161-1179`)。
- `BeginOcclusionQuery != nullptr` 被当作能力探测用(`GL_Query.cpp:471,545,768`);DirectVulkan 只注册 64/72 项(`BackendObject_DirectVulkan.cpp:690-770`,不注册 7 个 XFB + `PatchParameteri` + `SetSwapInterval`)。
### (b) `BackendObject` 虚函数 — `BackendObject.h:541-568`
MG_Impl 侧 89 个 `pActiveBackendObject->`**其中 45 个是 `GetDynamicParameters()`**,若干落在 per-API-call 校验路径上(`Buffer/Validators.cpp:63``VertexArray/Validators.cpp:22``GL_VertexArray.cpp:536``GL_Texture.cpp:406`)。
**关键时序:`InitCapabilities()` 懒执行在第一次成功的 `eglMakeCurrent` 内部**`BackendObject.cpp:341-347`),DirectGLES 在那里才改写 advertised extension string`BackendObject_DirectGLES.cpp:786-796`)。
### (c) `BufferBackendOps` — 7 个 hook`BufferState/BufferObject.h:76-121`,注册入口 `:124`
DirectGLES 注册 7/7`Managers.cpp:1336-1345`),DirectVulkan 注册 6/7(无 `ResidentSubData``VkBufferManager.cpp:104-111`)。
`AcquirePersistentMap``:112`)**把 GPU 内存裸指针交给应用**;`TryAdoptLargeStorage``BufferObject.cpp:167-176``kLargeBufferAdoptBytes = 16MiB`)在 store **定义时**单方面采纳。理由块 `BufferObject.cpp:153-166`MC 26.3 的 128MB chunk arena,实测 p99 163→21ms、40→115fps、省 ~400MB。
### (d) 状态拉取 — 293 个 `pGLContext->`DirectGLES 124 / DirectVulkan 169+ ~90 个前端对象 getter
`PrepareForDraw``DirectGLES.cpp:2916-2976`)与 `SetupDraw``VulkanRenderer.cpp:6371`)在这里把整个 `GLContext` 拉出来。**这一面在本设计中不过线。**
### (e) backend → frontend 写回(25 个语义点 / 14 个类)
`MarkGpuWritten` ×3、`WritebackFromBackend` ×7、`MarkStorageDirty` ×11、`SetBackendResource` ×2、`AllocateStorage` ×1、`RecordError` ×2,加两处 shadow `Memcpy``DirectGLES.cpp:6861` 生成 mip、`:7144` CopyImage 镜像);DirectVulkan 另有 `SetBackendHashMemo`/`SetBackendStateMemo`**存后端堆裸指针**/`SetBackendAuxMemo`/`EnsureGpuResidentStorage`/`InvalidateCompileEnv`/`SwapchainObject.cpp:276-331` 改写 default-FBO 占位纹理。
**replica 模型下这 25 处大部分落在 server 自己的 replica 上**,但其中三类是"语义建立者",client 必须自己做一遍(§5.6、§5.6a)。
### (f) backend 反向进 MG_Impl — 恰好 6 处
`DirectGLES.cpp:1917,2838,2867,9675``pDefaultFramebufferInfo`)、`SwapchainObject.cpp:276``VulkanRenderer.cpp:10700``CopyTextureImageToClientOrPBO_State`)。replica 模型下全部正常解析(server 也链接完整 MG_Impl)。
但注意:`MG_Impl::GLImpl::FramebufferImpl::pDefaultFramebufferInfo` 全库 22 处引用,client 侧 MG_Impl 也在读(`GL_Framebuffer.cpp:495,1827,1837,1897,1905,1913,1927,1936,2549,2590,2598,2608,2611`)。它是**第二个进程全局**`inproc` 模式下必须与 `pGLContext` 一起做角色隔离(§12)。
### (g) MG_Impl 在 table 调用旁做的 MG_State mutation**上一版遗漏的第七个面**)
`GLFunctionsTable` 是一个**命令**边界,不是一个**状态**边界的两侧对称点:MG_Impl 在调 table 之前/之后还会自己改 MG_State,而这些改动 applier 只 replay table 是拿不到的。已确认的两族:
1. **`glGenerateMipmap` / `glGenerateTextureMipmap` / 自动 mipmap**`GLImpl::GenerateMipmap``GL_Texture.cpp:6681-6699`)在 `GenerateMipmap_Backend` **之前**`EnsureGeneratedMipmapStorageAllocated(*mipmapTexture)``GL_Texture.cpp:501-541`),后者对 level 1..N 做 `AllocateStorage``MarkStorageDirty(...,false)``:528`)、`TruncateMipmapLevels``:533`)、`BumpContentVersion()``:538`)。`:534-537` 的注释写明了这个 version bump 存在的理由:没有它,"a cached sampled VkImageView built for the pre-generate level range would otherwise stay stale and clamp LOD>0 sampling to mip 0"。只 replay table 的 applier 会在 replica 上**精确复现这个已知 bug**。`GenerateTextureMipmap``:6702-6711`)和 `MaybeAutoGenerateMipmap``:1625-1635`)同形。
2. **Transform feedback CPU 计数**`AccountTransformFeedbackPrimitives``GL_Drawing.cpp:172-236`)在每个被捕获的 draw 上改 6 个 GLContext 计数器:`AddTransformFeedbackPausedPrimitives`:177)、`AddTransformFeedbackInputPrimitives`:184)、`AddTransformFeedbackGeometryCaptureDraw`:214)、`AddTransformFeedbackPrimitives`:231)、`AddTransformFeedbackCapturedVertices`:232)、`AddTransformFeedbackAccountedCaptureDraw`:237)。DirectGLES 在 `DirectGLES.cpp:900``GetTransformFeedbackCapturedVertices()` 来给 scattered capture 定容量;DirectVulkan 在 `DirectVulkan.cpp:1384``GetTransformFeedbackPausedPrimitiveCounter()` 并在 `:1337` 把前端 delta 折进 query 结果。这些计数器**没有版本号**,也不在任何 accessor 的门控里;replica 上它们恒为 0 → scattered XFB 什么都不捕、`PRIMITIVES_WRITTEN`/`PRIMITIVES_GENERATED` 错。它们还在 XFB 对象绑定时按对象存取(`Core.cpp:1273,1296``Core.h:313-357`),所以简单"发个标量"的补丁必须跟着对象切换走。
§5.9 的覆盖生成器**抓不到这一类**:它扫 `MG_Backend/**` 的 READ 面,所以 backend 读 `GetTransformFeedbackCapturedVertices` 会被正常分类并通过,而 MG_Impl 那半个生产者从来没被审计过。**所以 §5.9 必须有第二个生成器**(见 §5.9b)。
### (h) 工作树污染(Phase 0 必须先清)
`DirectGLES.cpp:640-663``Managers.cpp:875-877` 有**未提交的 per-draw `fprintf(stderr)`**(格式串里还有字面量 `' + NL + '`,且位于 `pendingMutex` 临界区内的 buffer flush 路径上)。`Feat/CS-Delta-IPC``d96be9f3` 提交过同类东西(`DirectGLES.cpp:+2583-2590`),导致该分支上**每一次测量**144-failure Windows run、OpenRA `ssim=0.000036` 设备 run)都跑在每 draw 一次 stderr 写的构建上。
---
## 3. 目标架构总览
```
┌───────────────────────── CLIENT 进程 (libMobileGL.so) ─────────────────────────┐
│ App / LWJGL │
│ │ gl* │
│ ▼ │
│ MG_Impl (validate → RecordError → 调 MG_State mutator) ← glGetError 本地 │
│ │ │
│ ▼ │
│ MG_State::pGLContext (权威状态 + ShaderCompilePool + glslang) │
│ │ │
│ ├─ gBackendFunctionsTable = EmitTable ─┐ │
│ ├─ pActiveBackendObject = BackendObject_Remote (+ CapsMirror) │
│ └─ SetBufferBackendOps(&g_emitBufferOps) ─┤ │
│ ▼ │
│ MG_Remote::WireMirror │
│ (① PublishImplicitStatepersistent-map 推送、 │
│ MarkGpuWritten 保守置位、XFB 计数、mip 分配 │
│ ② 读版本计数器 → 决定发什么 │
│ ③ 清 dirty flag / 记录 shipped 水位) │
│ │ │
└──────────────────────────────────────────────────┼─────────────────────────────┘
SEG_CMD (SPSC ring, POD 记录) ────────────┤ 写
SEG_STAGE (bulk 字节 ring, 独立游标) ─────┤ 写
SEG_SHADOW[n] (P4.5+, client 拥有)────────┤ RW
RingControl (一条 cache line 的 atomics) ◄─┤ 读 watermarkacquire load
├─ producerParked ──► server 敲门铃
SEG_REPLY / SEG_EVENT (server 拥有) ◄─┘ 读(在每个等待循环里排空)
CTRL socket (socketpair / 继承 overlapped pipe): FlatBuffers table
+ SCM_RIGHTS + 双向 doorbell
┌──────────────────────────────────────────────────┼─────────────────────────────┐
│ MobileGLServer (dlopen libMobileGL.so → mobilegl_server_main) │
│ thread mgl-srv-io : asioframingfd 传递,doorbell,控制面 RPC │
│ thread mgl-srv-apply: 终身持有 EGL/Vulkan context(可绑大核) │
│ │ │
│ ▼ Applier::Apply(RecHeader) → MG_State mutator / 共享 helper / │
│ GLFunctionsTable │
│ MG_State::pGLContext (replica) │
│ ▲ │
│ │ 293 次 pGLContext-> + ~90 getter**零改动** │
│ MG_Backend (DirectGLES / DirectVulkan) + MG_Util(SPIRV-Cross, 转译缓存) │
│ │ │
│ ▼ 真实 GLES / Vulkan 驱动 │
└────────────────────────────────────────────────────────────────────────────────┘
```
三种运行模式(`MOBILEGL_TRANSPORT`):`monolith`(默认,编译期折叠)、`inproc`(同进程第二个 `GLContext` + apply 线程,**需要 `MOBILEGL_BUILD_DISAGGREGATED_INPROC`**)、`spawn` / `unix:<path>` / `pipe:<name>`(真跨进程,出货形态)。
---
## 4. 边界定义(每个面变成什么)
| 面 | 变成 |
|---|---|
| **(a) `GLFunctionsTable`** | `MG_Remote::Client::MakeEmitTable()` 返回的发射表。61 项 = 先跑 `PublishImplicitState`、再追加一条定长记录、返回;5 项 request/reply4 项分配类 `kNeedsAck`(§5.6c);`GetIntegeri_v` 由 CapsMirror 本地回答;`GetInteger64i_v`/`GetProgramiv` **从 wire 与 table 中删除**(并提议在 `dev` 上删掉两个 backend 的实现)。7 个携带 `SharedPtr` 的项转成 `WireHandle`。**DirectVulkan 未注册的 8 个槽由 `CapsSnapshot.tableSlotMask` 精确复现**`GL_Query.cpp:471,545,768` 拿槽位空否当能力探测)。 |
| **(b) `BackendObject` 虚函数** | `BackendObject_Remote`。8 个 EGL 生命周期虚函数 → `SurfaceOp` RPC(前 4 个阻塞,因为返回 `Bool`)。`GetDynamicParameters`(45)/`GetRendererInfo`(8)/`GetFormatCapabilities`(4)/`GetBackendType`(3)/`GetBackendAPIVersionString`**CapsMirror 本地,零 round trip**`DynamicBackendParameters``BackendObject.h:299-521`)是 flat POD,逐字节传。先例:`CompileEnv``CompileEnv.h:28-45`)就是为同一个原因做的同一件事。`GetBackendType()` 返回**远端**类型,所以 `GL_Texture.cpp:6453-6457``CompileEnv.cpp:122``GL_Framebuffer.cpp:38` 全部照旧。 |
| **(c) `BufferBackendOps`** | `g_emitBufferOps``Respecify`/`SubData`/`FlushMappedRange`/`OnDestroy` → 记录;`ResidentSubData` P7 之前不注册(只有 adopted store 才可达);`AcquirePersistentMap` → P1-6 返回 `nullptr`P7 返回 `AdoptSeg` 映射基址;`ReadbackFromGpu` → 阻塞请求(monolith 里本来就是 `glFinish()``Managers.cpp:1246`)。 |
| **(d) 状态拉取** | **不过线。** backend 读 server 自己的 replica。 |
| **(e) backend→frontend 写** | 大部分落在 replica 上;三类"语义建立者"由 client 自己做(`MarkGpuWritten`、纹理 dirty 清除、persistent-map 推送);六类需要事件回传(§5.6)。**per-row `WritebackFromBackend` 循环(`Utils.cpp:2342``DirectGLES.cpp:7633`)在 server 内部执行,永远不会变成"每扫描线一次 IPC"。** |
| **(f) MG_Impl 反向调用** | server 链接完整 MG_Impl6 处照常解析。default-FBO 描述通过 `EvDefaultFramebufferInfo` 事件回传给 client`SwapchainObject.cpp:276-331` 的写在 server 侧发生)。 |
| **(g) MG_Impl 在 table 旁的 mutation** | 抽成 client/server 共享 helper`MG_Remote::Shared::`),applier 在 replay 对应记录时调同一个 helper;或作为显式记录下发。由 §5.9b 的生成器强制全覆盖。 |
---
## 5. 状态 delta 模型
### 5.0 决定:replica `GLContext` vs 重写 backend
**选 replica。** 三条不可协商的证据:
1. **身份键 memo 无 delta 对应物。** `UnitTextureSyncEntry` 借用 binding slot 的 `shared_ptr` **地址**`DirectGLES.cpp:1463-1467`),`PairingsIntact` 再校验 `entry.slot->get() != entry.texture``:1472-1477`)——注释明说没有它"replay 会拿纹理 B 的前端状态驱动纹理 A 的后端 twin"。`IsBufferDrawClean``Managers.cpp:1435-1436`)第一句就是资源裸指针身份比较。DirectVulkan 13 个缓存同类。replica 里这些**逐字工作**,因为对象仍由 `SharedPtr` 持有、unit 数组仍按值存放。
2. **回绕计数器。** `FramebufferBindingSlot::GetVersion()``FramebufferObject::GetObjectVersion()``VAO::GetIndexBufferBindingSlot().GetVersion()` 都是 `Uint16` 回绕,只有配合指针身份才正确。replay 让两侧跑同一段回绕逻辑。
3. **backend 自有 generation 表达的是"驱动对象被重新铸造"**`g_bufferBackendIdGeneration``g_attachmentBackendIdGeneration`),**任何 client delta 都无法承载**——它们本来就该纯 server 侧,replica 天然满足。
代价:server 进程要链接 MG_State + MG_Impl + MG_UtilSPIRV-Cross、转译缓存、格式处理器、POST 探针)。这本来就无法避免——`BackendProgramObjectImpl::TranspileSpirvToEssl``Managers.cpp:6575-7110`)在 draw 线程跑 SPIRV-Cross`UniformManager.cpp:1418-1497` 构造真实 `TextureObject``VulkanRenderer.cpp:4211-4356``ShaderObject::Compile()`/`ProgramObject::Link(false)`。"thin server"在这个代码库里是伪命题。
**replica 模型的边界必须明确写出来(R1 的真正内容)**replica 只保证"对 backend 可见的状态"与 client 一致。凡是 client 的**入口点**MG_Impl)在调 table 之外还做过的 MG_State 改动,applier 必须显式复刻——这不是理论风险,是 §2(g) 已经确认的两族实例。§5.9b 把它变成编译期门。
### 5.1 reconcile 在哪里发生
`MobileGL/MG_Remote/Client/WireMirror.{h,cpp}`,在**发射点**运行:每个 `GLFunctionsTable` 命令、`Present`、任何阻塞请求。
每个发射点分三步,顺序不可换:
**步骤 ①:`PublishImplicitState(scope)`** —— 复刻 backend 在 monolith 里会做的隐式发布,**必须在读任何版本计数器之前跑**,因为它自己会 bump 版本:
- 对 scope 内每个 **live persistent-mapped buffer** 调 client 侧的推送(§5.10)。
- 对 draw/dispatch scope,保守置 `MarkGpuWritten()`:镜像 `MarkShaderStorageBuffersGpuWritten``DirectGLES.cpp:459-467`,走 `GetTouchedBufferBindingPointCount(ShaderStorage)` + `GetBufferBindingPoint`)、`SyncAtomicCounterBuffers``:509`、以及可写 image-buffer 纹理的 `:1809`。XFB active 时对每个 capture target 同样置位(镜像 `VulkanRenderer.cpp:11210`)。
- 对 draw scope,若 XFB active,跑共享的 `AccountTransformFeedbackPrimitives` helper(§2(g)-2monolith 里这一步本来就在 MG_Impl 里,拆分后它继续在 client 跑,同时把结果作为 `RecXfbAccounting` 下发给 replica)。
-`GenerateMipmap` scope`EnsureGeneratedMipmapStorageAllocated` 本来就在 client 的 MG_Impl 里跑过了;WireMirror 只需把它产生的 level 分配 + `TruncateMipmapLevels` + `BumpContentVersion` 作为 `RecGenerateMipmapLevels` 下发(§5.6a)。
**步骤 ②:可达性遍历。** 这就是 `DirectGLES::PrepareForDraw``DirectGLES.cpp:2916-2976`)的遍历,把 sync 换成 emit——不是比喻,是同一集合、同一顺序、同一门控:
1. `GetBoundVertexArray()``GetConfigVersion()`;其 enabled attribute 的 `BufferObject`index buffer slot**版本 + 裸指针身份**)。
2. `GetProgramForDraw()`(在 client 侧 join compile pool,与今天一致)→ link/UBO-content/block-binding/SSBO-override 版本。**composite pipeline 见 §5.7。**
3. texture unit `[0, GetMaxTouchedTextureUnit()]`,门控 `GetTextureBindGeneration()`;每纹理 `GetContentVersion()`/`GetTextureParamsVersion()`;每 unit `GetSamplerObject()->GetVersion()`
4. image unit `[0, imageHighWater]``GetImageTextureBinding(unit)`
5. 每 target 的 buffer binding point,上界 `GetTouchedBufferBindingPointCount(target)`
6. draw/read FBO,门控 slot version + `GetObjectVersion()` + `GetAllFramebufferAttachmentVersions()`,再逐 attachment**attachment 若是 renderbuffer,另查 `RenderbufferObject::GetVersion()`**P0 新增,见 §5.4)。
7. `GetRenderStateParameters()`,门控 render-state 版本。
8. **pack** pixel-storebackend 从不读 unpack;六个读点全部传 `false``DirectGLES.cpp:6129,7614,9101,9480``Utils.cpp:2301``VulkanRenderer.cpp:10622``ScopedDefaultUnpackState` 强制默认值,`Managers.cpp:2888-2910`)。
**步骤 ③:清消费型状态。** 对本次发射的每个纹理 level 调 `MarkStorageDirty(uploadTarget, level, false)`(§5.6a)。
因为 backend 自身这套门控已被证明有界且便宜,reconciler 的每 draw 成本形状是**已知的**,不是估计。
存储:
```cpp
// MobileGL/MG_Remote/Client/WireMirror.h
struct ShipRecord { // 40 B
Uint64 shippedA, shippedB, shippedC; // 打包版本元组,按 kind 解释
Uint32 flags; // Created | Published | Deleted | ServerAuthoritative
Uint32 pad;
};
class WireMirror {
ska::flat_hash_map<Uint64 /*lifetimeId*/, ShipRecord> m_ship;
struct DrawKeys { Uint64 contextId, samplingGen, bindGen; Int maxUnit; } m_lastDrawKeys;
// ① 的输入:只遍历真正 mapped / 真正可能被 GPU 写的对象,不是全表
ska::flat_hash_set<MG_State::GLState::BufferObject*> m_livePersistentMaps;
ska::flat_hash_map<Uint64 /*lifetimeId*/, Uint64 /*emitSeq*/> m_gpuWritePendingSeq;
public:
void PublishImplicitState(EmitScope, RingProducer&);
void ReconcileForDraw(RingProducer&); // 上面 1-8
void ReconcileForDispatch(RingProducer&);
void ReconcileForClear(RingProducer&);
void OnObjectCreated(ObjKind, Uint32 name, Uint64 lifetimeId);
void OnObjectDestroyed(ObjKind, Uint64 lifetimeId);
void OnBufferMapped(BufferObject&, Range1D, BufferMappingAccess);
void OnBufferUnmapped(BufferObject&);
};
```
外加与 backend `DrawTextureSyncKeys``DirectGLES.cpp:1496-1518`)同键的 per-draw memo:状态未变的重复 draw 只花 ~10 次整数比较就追加一条 32 字节记录。
### 5.2 版本计数器**不上线**
applier 不设置版本,它 **replay mutation**,所以 replica 的计数器恰在 applier 改动了东西时 bump——恰是 backend 必须重新 sync 的时刻。
- 不需要给 `RenderState`/`ProgramObject``Install*` setter`Feat/CS-Delta-IPC``b50f3348` 加了,代价是把 `RenderState.h` 的私有成员漏成 public)。
- 不需要在 wire 上维护回绕 `Uint16` 的单调性。
- 唯一残留风险是**过度失效**replica bump 了而 client 没 bump。只要 applier 只做 client 明确下发的 mutation,就不会发生;`RenderStateBlob` 整块下发是唯一例外(它整块 bump `m_version`,与 client 自己的 bump 等价)。
### 5.3 触发器 → delta 对照表
| Client 触发器(accessor / 事件) | Delta 记录 |
|---|---|
| `BufferObject::GetChangeSerial()` + emit-ops 里排队的 range | `RecBufferRespecify` / `RecBufferSubData` / `RecBufferFlushRange` |
| `glMapBuffer*` / `glUnmapBuffer`**新增** | `RecBufferMap{handle, range, accessFlags}` / `RecBufferUnmap{handle}` |
| persistent-map 脏块(**新增**,§5.10 | `RecBufferSubData`(块粒度) |
| `MipmapStorage::IsStorageDirty(target,level)`, `GetContentVersion()` | `RecTexAllocLevel` / `RecTexSubImage`union box 或 ≤96 rects,变长) |
| `glGenerateMipmap` 前的 level 分配(**新增** | `RecGenerateMipmapLevels{handle, target, requiredLevelCount, bytesPerTexel}` |
| `ITextureObject::GetTextureParamsVersion()` | `RecTexParam` |
| `ITextureObject::GetViewStorageOwner()` + view 字段 | `RecTexView`**必须先于 owner 的任何 re-mint 顺序到达** |
| `SamplerObject::GetVersion()` | `RecSamplerParam` |
| `RenderbufferObject::GetVersion()`**P0 新增** | `RecRenderbufferStorage{handle, internalFormat, w, h, samples}` |
| `VertexArrayObject::GetConfigVersion()` + 每 attrib Switch/Format/Buffer 版本 | `RecVaoConfig`(变长,整份配置;P6 再做逐属性 diff) |
| index buffer slot version **+ 指针身份** | `RecVaoIndexBuffer` |
| `FramebufferObject::GetObjectVersion()` + attachment 版本 | `RecFboAttach` / `RecFboDrawBuffers` / `RecFboReadBuffer` |
| `RenderState::m_version` / `m_pipelineStateVersion` | `RecRenderStateBlob`(整个 trivially-copyable `RenderStateParameters``RenderState.h:517-535` |
| `ProgramObject::GetLinkVersion()` | `RecProgramLinkOp`(P1-4) → `RecProgramPublish`(P5+) |
| `ProgramObject::GetUBOContentVersion()` | `RecProgramUboContent` |
| block-binding / SSBO-override 版本 | `RecProgramBlockBinding` / `RecProgramSsboBinding` |
| `GetProgramForDraw()` 解析出 composite | `RecSetResolvedDrawProgram`(§5.7 |
| `GetTextureBindGeneration()` + unit slot 遍历 | `RecBindTexture` / `RecBindSampler` / `RecActiveTexture` |
| `GetTouchedBufferBindingPointCount()` 遍历 | `RecBindBuffer` / `RecBindBufferRange` |
| `GetImageTextureBinding(unit)` | `RecBindImageTexture` |
| pack `PixelStoreParameters` | `RecPixelStorePack` |
| XFB active 时的 draw**新增** | `RecXfbAccounting{pausedPrims, inputPrims, prims, capturedVerts, geomDraws, accountedDraws}` 增量 |
| `GLFunctionsTable` 命令 | `RecDraw*` / `RecClear*` / `RecBlit*` / `RecCopy*` / `RecDispatch*` / `RecXfb*` / `RecPresent` … |
### 5.4 对象身份、创建/删除顺序
wire handle = `WireHandle { kind:u8, glName:u32, lifetimeId:u64 }``GetLifetimeId()` 永不复用(`BufferObject.h:208``FramebufferObject.h:158``ProgramObject.h:1620``VertexArrayObject.h:120``SamplerObject.h:141``TextureObject.h:83,161`)。
**`RenderbufferObject` 既没有 `GetLifetimeId()` 也没有 `GetVersion()`(已在 `MG_State/GLState/RenderbufferState/RenderbufferObject.h` 上确认为零命中)—— Phase 0 两个都补上**,`GetVersion()``SamplerObject::GetVersion` 的同款形状(`Uint16`,每次 `RenderbufferStorage*` bump),并在 §5.1 步骤②-6 的 per-attachment 遍历里读它。理由:`BackendRenderbufferObject::SyncToBackend``Managers.cpp:~8620-8700`)缓存 `{internalFormat,width,height,samples}`,而对一个**已 attach 的** renderbuffer 重新 `glRenderbufferStorageMultisample` 不必然 bump `GetAllFramebufferAttachmentVersions()`,没有 `GetVersion()` 就没有触发器。
replica 使用**与 client 相同的 GL name**applier 直接 `ctx.CreateBufferObject(name)`,绕过 server 自己的 `IndexGenerator`。server 侧维护 `ska::flat_hash_map<(kind,name), {SharedPtr<T>, clientLifetimeId}>`
**若某次 create 的 `lifetimeId` 与记录不符 → `Fatal{IdentityDivergence}`,不做"先销毁再创建"的修复。** 上一版的"先销毁"是错的:replica 上那个对象可能仍被 FBO attachment、binding slot、texture view`GetViewStorageOwner`)或 XFB capture target 通过 `SharedPtr` 合法持有,GL 保证它活到最后一个引用消失;强行销毁要么留下悬挂引用要么静默 detach,把一个协议 bug 变成一个会被归咎于 backend 的渲染 bug。协议正确时这个分支不可达,所以响亮地停下来严格优于静默的破坏性修复(`MOBILEGL_IPC_RESPAWN=1` 时改为强制 `ResyncSnapshot`)。
这就是 packed_pixels 的教训(身份 + 计数器,绝不单靠计数器)在协议层的应用,也是本设计对 name 空间漂移的**结构性预防**(而非事后 checksum 检测)。
创建/删除在 `glGen*`/`glDelete*` 时刻**立即**发射,顺序即 ring 顺序。client 的 `~BufferObject` 触发 emit-ops 的 `OnDestroy` 追加 `RecObjDelete`server 的 replica `~BufferObject` 触发**真实**的 `Ops_OnDestroy`,完成 pooling / 延迟 `glDeleteBuffers``Managers.cpp:1271-1300`)——一行不改。
### 5.5 合并规则
1. **版本门控本身就是合并器。** 两个发射点之间的 N 次 mutation 折叠成一条 delta;改了又改回去的状态永不上线。
2. **Buffer range** 在 per-buffer `VecRange1D` 里累积(复用 `MG_Util/Math/VectorTypes.h:264` 已调优的 7% span gap 合并),在该 buffer 的下一个发射点 flush。**绝不 union 成整个 buffer**——`Managers.cpp:860-864` 的 postmortem 记录了那样会每帧重拷近乎整个 chunk-mesh arena。
3. **纹理区域**逐字沿用 `MipmapStorage::GetDirtyRects`,含 `summedArea*4 >= unionArea*3` 回退(`MipmapStorage.cpp:305`)。**注意代价轴是反的**buffer 按字节计价,texture sub-image 按 **job 数**计价(`Managers.cpp:4311-4319`~100 rects vs 一个 box 实测 +6ms/frame)。client 下发**区域形状**union box 或 rect 列表,按 client 自己的 `GetDirtyRects` 判定),server 的 backend 从自己 replica 的 dirty 状态重新推导**上传形状**,让已调优的启发式留在付 GPU 代价的那一侧。
4. `RecRenderStateBlob`、各类 bindlast-writer-winsreconciler 只发**当前值**。
5. **命令永不合并、永不重排。**
### 5.6 backend→frontend 写:三种归属
| 写 | 归属 |
|---|---|
| `SetBackendResource``SetBackendHashMemo`/`StateMemo`/`AuxMemo` | **纯 server 本地**,零 wire 流量 |
| `MarkGpuWritten` ×3 + `EnsureGpuResidentStorage` | **client 保守自建**(§5.6b)。server 侧照常在 replica 上置位;`EvGpuWritten{handle, ranges[]}` 仅作为**收窄提示** |
| `MarkStorageDirty(…,false)` ×11 | **client 在发射后自己清**(§5.6a)。server 侧照常在 replica 上清 |
| `MarkStorageDirty(…,true)``Managers.cpp:2813` RequireImageBindableStorage 的 re-dirty | 纯 server 本地:它是 server 的 re-mint 导致的,client 无从预测,也无需知道——重传由 server 自己在 replica 上完成 |
| `WritebackFromBackend`PBO/XFB | server 侧写 replica shadow;合并后的 range 变成 `EvBufferWriteback` 回传 client |
| `RecordError` ×2`DirectGLES.cpp:6319``Managers.cpp:8679`+ DirectVulkan 4 处 | **分两类**(§5.6c):分配类同步 ack,其余走 `EvGlError` 晚一批可见 |
| `AllocateStorage` 生成 mip`DirectGLES.cpp:6270-6271,6861`)、`MirrorCopyImageIntoDestinationShadow``:7144` | **per-level `serverAuthoritative` 位**(§6.6 |
| `InvalidateCompileEnv``SwapchainObject` 改写 default-FBO 占位纹理 | 事件 `EvCompileEnvInvalidate` / `EvDefaultFramebufferInfo` |
#### 5.6a 纹理 dirty flagclient 必须清(推翻上一版)
上一版写"client 的 dirty flag 从不被清,已发送状态存在 WireMirror 里"。这是错的:
- `MipmapStorage::MarkDirtyRegion``MipmapStorage.cpp:196-233`)只要 `m_isDirty[level]` 为真,就把 incoming **union 进** `m_dirtyRegions[level]``InsertDirtyRect`;只有 `MarkDirty(level,false)``:171-189`)重置两者。永不清 ⇒ box 单调增长、rect 列表撑满 `kMaxDirtyRects``GetDirtyRects` 一旦跨过 3/4 阈值就返回 0"用 box"),于是每次动画图集 tick 都传整个 level。
- `ShipRecord` 只有三个 `Uint64` 版本字,**无法**从中重建区域。
- `MarkDirtyRegion` 的 rect 播种分支(`:214-221``if (!m_isDirty[level]) rects.clear(); else if (rects.empty() && !region.Empty()) rects.push_back(region);`)本身就是为"有人会清"写的。
好消息是清是安全的:**MG_Impl 里没有任何 `IsStorageDirty(` / `GetStorageDirtyRects(` / `GetStorageDirtyRegion(` 调用点**(已 grep 确认为零),前端从不读自己的 dirty 状态;它自己也在五处主动清(`GL_Texture.cpp:528,701,5547,5621,5691`)。
**规则**WireMirror 在追加纹理记录之后,立刻对该 (target, level) 调 `MarkStorageDirty(..., false)`。ack 问题按两条收口:
1. `ResyncSnapshot` 永远从**完好的 shadow** 传整 levelshadow 从不被丢弃,除非 buffer 被 adopt——纹理没有 adopt 路径),所以"清早了导致重传丢数据"在 resync 场景不成立。
2. 硬 drain(§6.5)会 bump `ringGeneration`drain 后 client 对**所有已发射但未 `appliedSeq` 覆盖的纹理记录**做一次重发(WireMirror 保留最近一批记录的 (handle, target, level) 列表 + emitSeqdrain 时把 seq > appliedSeq 的重新标脏并重发)。这是有界的,因为 ring 里最多只有 ring 容量那么多未 apply 的记录。
#### 5.6b `MarkGpuWritten`client 保守自建(推翻上一版)
monolith 里这个 flag 是在 draw 调用**内部同步**置位的:`MarkShaderStorageBuffersGpuWritten``DirectGLES.cpp:459-467`)走 `GetTouchedBufferBindingPointCount(ShaderStorage)` 并对每个绑定对象 `MarkGpuWritten()`,从 draw 路径的 `SyncNeccessaryBuffers` 调用(`DirectGLES.cpp:687,697`);atomic counter 在 `:509`;可写 image-buffer 纹理在 `:1809`DirectVulkan 在 `UniformManager.cpp:1073,1229``VulkanRenderer.cpp:11210`
拆分后 draw 是 fire-and-forget,所以 `glDispatchCompute(); glMapBufferRange(SSBO,...,GL_MAP_READ_BIT);` 会在 server 还没 apply 前就走完 `AcquireMemoryRange``SyncGpuWrites()``BufferObject.cpp:454`)→ `m_gpuWritePending` 为 false → 立即 return`BufferObject.cpp:266`)→ 应用拿到陈旧 shadow,零 round trip、零报错。这会以"看起来像 flaky"的形式打掉 P4 计划里的 `SsboArrayLengthScenario``AtomicCounterScenario``StorageBufferRegrowScenario` 一整族。
**规则**`PublishImplicitState`(§5.1 步骤①)在每个 draw/dispatch 发射点保守置位,输入与 `DirectGLES.cpp:459-467/509/1809` 完全一致(client 全都有)。同时把 `emitSeq` 记进 `m_gpuWritePendingSeq`。在任一读入口(`glMapBuffer*``glMapBufferRange``glGetBufferSubData``glGetNamedBufferSubData``glCopyBufferSubData` 的源、`FillSubData`):若该 buffer 在 pending 集合里 → `Publish()` → 等 `appliedSeq >= recordedSeq` → 排空 `SEG_EVENT` → 再读。`EvGpuWritten{handle, ranges[]}` 只用于**取消**该 pending 项或**收窄** readback 范围,晚到无害。
同时,§7.4 的事件排空点必须补上 `glMapBuffer` / `glMapBufferRange` / `glGetBufferSubData` / `glGetNamedBufferSubData`——上一版的排空点列表(`glGetError``glGetQueryObject*``glClientWaitSync``eglSwapBuffers`)不含它们。
#### 5.6c GL 错误:分配类同步 ack,其余晚到
上一版把所有 backend `RecordError` 一律走"晚一批"事件,只给 CTS lane 留 `MOBILEGL_IPC_STRICT_ERRORS`。这在**分配探测**这个通用惯用法上是错的:那两个站点(`Managers.cpp:8679` renderbuffer 存储、`DirectGLES.cpp:6319` 纹理操作)报的是 `GL_OUT_OF_MEMORY`,而应用的标准写法是 `glRenderbufferStorage(...); if (glGetError() == GL_OUT_OF_MEMORY) { 用更小的目标重试; }`。晚到 ⇒ 应用走成功分支 ⇒ 往一块 server 从未分配的存储上渲染。
**规则**:只把**分配类**入口点标 `kNeedsAck`——`glRenderbufferStorage` / `glRenderbufferStorageMultisample` / `glNamedRenderbufferStorage*``glTexImage*` / `glTexStorage*` / `glCopyTexImage*` 中 backend 可能失败的形式、`glBufferStorage`。它们本来就罕见且昂贵,ack 几乎免费,换来 OOM 探测精确。其余全部保持晚到。有了这个划分,`MOBILEGL_IPC_STRICT_ERRORS` 从"CTS 专用"降级为纯诊断开关(默认 0,出问题时用来判断某个失败是不是错误时序引起的)。
`glGetError` 本身永远本地(`GL_Getter.cpp:2811-2817``Core.cpp:48-49` 的 "GL error state is GL-thread-owned" 不变式)。
### 5.7 composite pipeline program
`GLContext::GetProgramForDraw()``Core.cpp:612-660`)在 program-pipeline 路径下:join 每个 stage → `ComputeDrawProgramSignature()` → cache miss 时 **`MakeShared<ProgramObject>(0u)` 并 link 一个匿名 composite**`Core.cpp:644`;注释明说"故意不是命名 program……不得占用应用可能拿到的 name"),随后 `RefreshCompositeUniforms`/`MirrorUniformValues` 每 draw 改它。
- **Phase 1-4server relink**:下发 pipeline 状态(`UseProgramStages` 等)+ 各 stage program 的 `RecProgramLinkOp`server 的 replica 自己走同一路径构建自己的 composite。加一条 `RecResolvedProgramDigest{signature, reflectionDigest}` 让分歧当场暴露。
- **Phase 5+ProgramPublish**server 没有源码,**不得 link**。client 解析 composite,把它作为**保留高位 handle 的合成 program** 发布(`RecProgramPublish` + `RecSetResolvedDrawProgram{handle}`)。MG_State 加:
```cpp
// MobileGL/MG_State/GLState/Core.h (整段 #if MOBILEGL_BUILD_DISAGGREGATED 包裹,保证 monolith 字节不变)
void SetReplicaResolvedDrawProgram(SharedPtr<ProgramObject>);
void SetReplicaResolvedDispatchProgram(SharedPtr<ProgramObject>);
// GetProgramForDraw()/GetProgramForDispatch() 首行先查该槽位
```
server 因此**永不 link、永不 join compile pool**`PrepareForDraw` 首条语句照常工作。
### 5.8 全量快照 / resync
稳态**没有初始状态**transport 在 `MG_Backend::Init()` 内建立,早于任何 GL 对象存在。
`ResyncSnapshot` 只服务三件事:**server 重启**、**backend context 丢失**EGL surface 变更销毁整个原生 context 并 bump `g_backendContextGeneration`/`g_syncContextGeneration``DirectGLES.cpp:10664-10676`)、**硬 drain 后的纹理重发**(§5.6a)。实现 = 同一个 reconcile 遍历,关闭"已发送版本"门控。
**关键纪律:一个 applier、两个 producer**——快照发同样的记录种类,因而被同一套测试覆盖。`Feat/CS-Delta-IPC` 的结构性错误正是有一个与生产路径零共享代码的平行 applier(`StateEmitter.h:312-501` vs `ServerCore.cpp:389-401`)。
**P7 之后的限制**adopted store 的字节住在 serverclient 无法重建它们。因此 `MOBILEGL_IPC_RESPAWN=1``MOBILEGL_IPC_ADOPT_TIER != 2` 互斥:要么关采纳换可 resync,要么开采纳并接受 server 死亡 = context lost(不重启)。这条互斥必须在 `ConfigLoader` 里显式检查并 `MGLOG_W`
### 5.9 覆盖度的**编译期**保证
#### 5.9a READ 面(backend 读了什么)
1. `scripts/gen_backend_state_surface.py` 扫描 `MG_Backend/**`,抽出 `pGLContext->X` 与前端对象 getter,生成 `MG_Remote/Protocol/generated/BackendStateSurface.inc`**已提交**)。相对 `Feat/CS-Delta-IPC``extract_backend_read_inventory.py`**删掉 `GetBuffer*`/`GetTexture*`/`GetProgram*`/`GetVertex*` 前缀兜底规则**`:234-241`,它把"0 UNMAPPED"制造出来),未知 accessor 一律 `UNMAPPED`。同时把"真 pull point"与"signature handle 化"分开统计(那 167 个 "handle-ify" 里含 `BackendObject.h:158-186` 的**声明**和 `DirectGLES.cpp:55` 的静态全局)。
2. 手维护 `MG_Remote/Protocol/Coverage.def``accessor → 记录种类 | MGL_COVER_LOCAL | MGL_COVER_NA(理由字符串)`
3. `MG_Remote/Client/CoverageAssert.cpp` 同时 include 两者,未映射 accessor → `#error`
#### 5.9b MUTATOR 面(MG_Impl 在 table 调用旁改了什么)—— **本轮新增,是 §2(g) 的门**
1. `scripts/gen_impl_mutation_surface.py` 扫描 `MG_Impl/**`:找出**同时**包含 `gBackendFunctionsTable.GL.*``pActiveBackendObject->` 调用**和** `pGLContext->` mutator 调用(写方法:`Add*`/`Set*`/`Mark*`/`Bump*`/`Allocate*`/`Truncate*`/`Record*`/`Notify*`/`Begin*`/`End*`)的函数,把每个 mutator 站点写进 `MG_Remote/Protocol/generated/ImplMutationSurface.inc`(**已提交**)。为避免误报,脚本对每个函数做一次简单的调用图一层展开(`EnsureGeneratedMipmapStorageAllocated` 这种 helper 会被计入调用它的 `GenerateMipmap`)。
2. 手维护 `MG_Remote/Protocol/MutationCoverage.def``函数::mutator → MGL_MUT_REPLAYED_BY(记录种类) | MGL_MUT_SHARED_HELPER(helper 名) | MGL_MUT_CLIENT_ONLY(理由) | MGL_MUT_NA(理由)`
3. 同一个 `CoverageAssert.cpp` 展开两张表,未映射站点 → `#error`
已知必须在第一轮映射的条目(不是穷举,是脚本首次运行时保证不为空的锚点):
- `GenerateMipmap` / `GenerateTextureMipmap` / `MaybeAutoGenerateMipmap``EnsureGeneratedMipmapStorageAllocated``AllocateStorage` / `MarkStorageDirty(false)` / `TruncateMipmapLevels` / `BumpContentVersion``MGL_MUT_REPLAYED_BY(RecGenerateMipmapLevels)`。applier 收到该记录后调**同一个共享 helper**(把 `EnsureGeneratedMipmapStorageAllocated` 抽到 `MG_Remote::Shared::` 或让 applier 直接调 `MG_Impl::GLImpl::TextureImpl::` 里那个已存在的函数——server 链接完整 MG_Impl,这是可行且最省的做法)。
- `DrawArrays`/`DrawElements`/… 的 `AccountTransformFeedbackPrimitives` 六个计数器 ⇒ `MGL_MUT_REPLAYED_BY(RecXfbAccounting)`applier 把六个增量加到 replica 的对应计数器上;必须跟着 `RecBindTransformFeedback` 的对象切换走,因为它们按 XFB 对象存取,`Core.cpp:1273,1296`)。
- `glCopyTexSubImage*``CopyReadFramebufferIntoMipmapRegion``MarkStorageDirty(...,true)``GL_Texture.cpp:1095`)⇒ `MGL_MUT_CLIENT_ONLY`(该函数整体留在 client,见 §6.6)。
- `glClearTexImage``MarkStorageDirty(...,true)``GL_Texture.cpp:1005`)⇒ `MGL_MUT_CLIENT_ONLY`(同上)。
- `GL_Query.cpp` 的 conditional-render 布尔与查询结果缓存 ⇒ `MGL_MUT_CLIENT_ONLY`
CI:两个生成器都重新生成 + `git diff --exit-code`
**backend 长出一个 reconciler 走不到的 read,或 MG_Impl 长出一个 applier 没 replay 的 mutation → 编译失败,而不是设备回归。**
### 5.10 persistent mapclient 侧的推送(本轮新增的独立小节)
**问题**(已在仓库确认):`BufferObject::SyncPersistentMappedRange()``BufferObject.cpp:238-250`)依次早退于 GPU-resident、非 Persistent、非 Write、FlushExplicit、空 range,剩下的情况(**persistent + write + coherent + shadow-backed**)走 `NotifySubData(整个 mapped range)`。它的全部生产调用点都在 `MG_Backend/` 里(19 处,见 §0)。P1-P6 默认关采纳(§6.8 T2),`AcquireMemoryRange``BufferObject.cpp:459-475`)于是回退到 shadow 并把 `m_resource.Bytes() + range.start` 交给应用——应用之后**不再调任何 GL 函数**就直接写。拆分后:client 没人推,server 的 replica `m_isMapped==false` 第一行就 return。字节丢失。
另外,`IsBufferDrawClean``if (frontend->IsMapped()) return false;``Managers.cpp:1447`,注释:"A live non-zero-copy map may owe a per-draw SyncPersistentMappedRange push")也依赖 map 位,replica 上恒 false 会把这个 buffer 判成 clean 而跳过整个同步。
**解法三件套**
1. **map/unmap 上线**`RecBufferMap{handle, rangeStart, rangeEnd, accessFlags}``RecBufferUnmap{handle}`,从 `glMapBuffer`/`glMapBufferRange`/`glUnmapBuffer`/`glFlushMappedBufferRange` 的 MG_Impl 入口发射(emit-ops 的 `FlushMappedRange` 已覆盖最后一个)。replica 的 `m_isMapped`/`m_mappedRange`/`m_mappingAccess` 于是与 client 一致,`IsMapped()` 门和 server 侧的 `SyncPersistentMappedRange` 都恢复 monolith 行为。
2. **client 侧脏块推送**WireMirror 维护 `m_livePersistentMaps`(只装 persistent+write+非-FlushExplicit+非-GpuResident 的 buffer,进出由 `OnBufferMapped`/`OnBufferUnmapped` 维护)。`PublishImplicitState` 对**本次操作可达的**每个这类 bufferVAO attribute buffer、index buffer、indirect/parameter buffer、UBO/SSBO/atomic binding point、XFB capture target——即 backend 那 19 个调用点的并集)做**块粒度**发送:把 mapped span 切成 64KiB 块,只发自上次发送以来被改过的块。
"被改过"的判定:P1-4 用**保守版**(每个发射点把该 buffer 的整个 mapped span 当脏,但按块拆成多条 `RecBufferSubData`,让 §6.5 的 range 合并与 ring 复用机制生效);P4.5 shadow-in-shm 落地后升级为**精确版**shadow 住在 client 拥有的 `SEG_SHADOW` 里,用与 WAR 水位同一套 64KiB 块脏位跟踪;块脏位由 `SyncPersistentMappedRange` 的调用点触发一次 `memcmp` 或由 mprotect 写屏障提供——先做 `memcmp`,它对 1MB 块是 ~50µs 量级,且只在真正 mapped 的 buffer 上跑)。
**这是 §6.4 拷贝表里上一版完全没有的一行**,且在 P1-4 的保守版下代价可观(一个持久映射的 chunk arena 会在每个可达发射点重传整个 mapped span)。所以:`MOBILEGL_IPC_PERSISTENT_BLOCK_KB`(默认 64)可调,且**P1 验收必须记录这条路径的字节量**(Tracy 计数器分类为 `persistent-map-push`)。若 P1-4 的保守版在 Create/Flywheel fixture 上不可接受,把 P4.5 的精确版提前到 P2(这是计划里唯一一个允许因测量结果而改变阶段顺序的地方)。
3. **P1 就要有门**:新增 `PersistentCoherentMapScenario`map PERSISTENT|WRITE|COHERENT、写、不做任何其它 GL 调用、draw、readback 校验),列为 P1 验收项。**今天计划里没有任何门能抓到这个 bug。**
**与 `MOBILEGL_COHERENT_AS_FLUSH` 的关系**:该开关(`GL_Buffer.cpp:297-305`,默认 false`Config.h:174` / `ConfigLoader.cpp:185`)把应用请求的 persistent+FLUSH_EXPLICIT 改写成 coherent,从而**制造**上面这个情形。上一版禁止它在拆分模式下生效——但那只处理了"我们自己改写出来的 coherent map",没处理"应用自己就请求 coherent"。有了上面的三件套,两种来源都被覆盖,所以**禁令改为可选**:`MOBILEGL_COHERENT_AS_FLUSH` 在拆分模式下**照常生效**,这样 `tools/trace_replay/trace_cases.json` 里那两个带 `coherent_as_flush: true` 的用例(`minecraft-1.21.1-neoforge-create-indirect-in-world``minecraft-1.21.1-neoforge-create-instancing-in-world`)在 split 与 monolith 下走同一条 buffer 路径,P2 的逐名对比才有意义。若 P2 测出保守推送在这两个 fixture 上代价过高,改为"这两个用例在 split 模式下同时关掉该开关,并在报告里标注",而不是让两侧走不同路径还宣称对比通过。
---
## 6. 数据面
### 6.1 段(segment)布局
| 段 | 拥有者 | 默认大小 | 内容 |
|---|---|---|---|
| `SEG_CMD` | clientserver 只读) | 8 MiB2 的幂,64B 对齐 | `RingControl`(4KiB) + POD 记录 + ≤4KiB 内联负载 |
| `SEG_STAGE` | clientserver 只读) | 32 MiB → 上限由实测定,**不是默认 256 MiB** | bulk 字节:buffer sub-data、纹理区域、UBO scratch、client 顶点/索引/indirect 数组、persistent-map 脏块 |
| `SEG_REPLY` | **server**client 只读) | 8 MiB4KiB slot | readback 像素、buffer writeback |
| `SEG_EVENT` | **server**client 只读) | 256 KiB SPSC ring | `EvQueryResult`/`EvGpuWritten`/`EvGlError`/`EvLogLine`/`EvDefaultFramebufferInfo`… |
| `SEG_SHADOW[n]` | clientserver 只读) | 每对象,P4.5+,≥256KiB shadow | 零拷贝 buffer/texture shadow |
| `SEG_ADOPT[n]` | **server**client RW | 每 bufferP7,≥16MiB adopted store | 应用直写 GPU 内存 |
创建:Android `ASharedMemory_create`API 26`android/sharedmem.h:78`libc 的 `memfd_create` wrapper 是 API 30`sys/mman.h:196`);桌面 Linux `syscall(SYS_memfd_create, …)`macOS `shm_open`+`shm_unlink`Windows `CreateFileMappingW`(`Local\`)。
**传递:POSIX `SCM_RIGHTS`,在第一个 transport commit 里实现**asio 无 cmsg API → 在 `socket.native_handle()` 上裸 `sendmsg`/`recvmsg`,约 80 行)。`Feat/CS-Delta-IPC` 把它推迟到"P6"`LocalSocketTransport.h:16-20``PollOffer``out->fd = -1` 硬编码于 `:296`),结果它的数据面在唯一重要的平台上**一个字节都过不去**。
**SEG_SHADOW 块的退休规则(本轮新增)**:§6.4 的 64KiB 块发送水位只解决"覆盖一个**活着的** shadow";它没说怎么**释放**一个 shadow。`glDeleteBuffers``glBufferData` 重定义会释放/重分配 `SEG_SHADOW` 的 arena 块,而携带 `{segId, offset, size}` 指向该块的记录可能还没被 apply——server 于是读到另一个对象的字节。规则:释放的块进入 pending 链表,只有当 `appliedSeq`(对被借入 GPU 时间线的 slot 是 `retiredSeq`)越过最后一条引用它的记录之后才归还 arena,而不是在对象析构时立即归还。
### 6.2 RingControlwatermark 是一条共享 cache line**且带双向 doorbell**
```cpp
// MobileGL/MG_Remote/Transport/Ring.h
struct alignas(4096) RingControl {
// ---- SEG_CMD 游标 ----
alignas(64) std::atomic<Uint64> cmdHead; // producer:累计写入字节
alignas(64) std::atomic<Uint64> cmdAppliedTail; // consumer:已解码并拷出的字节
std::atomic<Uint64> cmdRetiredTail; // consumer:被借入 GPU 时间线的 slot 已释放
// ---- SEG_STAGE 游标(独立三元组;上一版遗漏)----
alignas(64) std::atomic<Uint64> stageHead;
alignas(64) std::atomic<Uint64> stageAppliedTail;
std::atomic<Uint64> stageRetiredTail;
// ---- 序号 / 帧水位 ----
alignas(64) std::atomic<Uint64> appliedSeq; // 已 apply 的记录序号
std::atomic<Uint64> submittedSeq; // 已提交给驱动
std::atomic<Uint64> retiredSeq; // GPU 已完成
std::atomic<Uint64> completedFrameSerial;
std::atomic<Uint64> presentAckSerial;
// ---- doorbell / 代 ----
alignas(64) std::atomic<Uint32> serverEpoch; // context 丢失 / server 重启时 ++
std::atomic<Uint32> ringGeneration; // 硬 drain 后 ++,作废缓存 offset
std::atomic<Uint32> consumerParked; // server 睡了,producer 要敲门
std::atomic<Uint32> producerParked; // client 睡了,server 要敲门(本轮新增)
std::atomic<Uint32> eventRingFull; // SEG_EVENT 满,server 已停止 apply
std::atomic<Uint32> eventDropped; // 被丢弃的 EvLogLine 计数
};
```
**三个 seq 水位严格区分**(混为一谈是经典错误):`appliedSeq` 释放 `cmdAppliedTail`/`stageAppliedTail``submittedSeq` 释放 staging`retiredSeq`/`completedFrameSerial` 释放 `*RetiredTail``SEG_ADOPT` 复用。
**两个 tail 是必须的**`Ops_ResidentSubData` 把字节拷进 `pendingResidentWrites``Managers.cpp:1158-1166`),P7 之后 server 会**借用** ring slot 而不是再拷一次——那种 slot 只能在 `completedFrameSerial` 之后回收。单 tail 会在 P7 落地当天变成保守回收。
**SEG_STAGE 必须有自己的游标三元组**:§7.2 把"`SEG_STAGE` 余量 < 1/4"列为 Publish 触发器,而第二个 ring 的占用率无法从第一个 ring 的游标算出;且 stage slot 的退休条件(`retiredSeq`)与 cmd 记录(`appliedSeq`)不同。
#### 6.2a 双向 doorbell(本轮新增,修 "client 只能自旋" 的缺陷)
- **client → server**consumer 自旋 ~200µs → 置 `consumerParked=1` → 在控制 socket 上阻塞读 1 字节;producer 在 release-store `cmdHead` 之后,仅当 `consumerParked` 时写 1 字节(字节码 `0x01 = 'ring advanced'`)。
- **server → client**(上一版缺失):client 在**任何**等待里(present credit、`kNeedsAck` 阻塞请求、ring/stage 满的升级等待)先自旋 `MOBILEGL_IPC_SPIN_US`(默认 50µs),再置 `producerParked=1`,然后在同一个 socket 的反向流上阻塞读;server 在 release-store 任何 watermark 之后,仅当 `producerParked` 时写 1 字节(字节码 `0x02 = 'watermark advanced'`)。
没有这一条,上一版的每一处 client 等待都退化成跨进程自旋一条共享 cache linepresent-credit 等待最长一整帧(60Hz 下 16.6ms),在手机上就是一颗大核满频空转,与 GPU 和游戏 JVM 抢核;§6.5 的"有界 50ms 等待"就是 50ms 自旋。而 MobileGL 全库没有任何亲和性控制(`grep -rn 'sched_setaffinity\|cpu_set_t' MobileGL/` 零命中),无法把它赶到小核上。
`spawn` 模式用 socketpair 的两个方向做 doorbell`inproc` 模式用一对 `std::condition_variable`(同一套 `producerParked`/`consumerParked` 语义)。**零 futex/eventfd/named-event 平台代码**asio 已 vendored`3rdparty/asio/include` 已在主 target 的 include path 上,`CMakeLists.txt:483`)。
### 6.3 记录格式
```cpp
// MobileGL/MG_Remote/Protocol/RecordKinds.h
struct RecHeader { Uint16 kind; Uint16 flags; Uint32 size; }; // 8 Bsize 含 header8 字节倍数
enum RecFlags : Uint16 { kNone=0, kNeedsAck=1<<0, kHasBlob=1<<1, kPad=1<<2, kBorrowSlot=1<<3, kVarTail=1<<4 };
struct BlobRef { Uint32 seg; Uint32 pad; Uint64 offset; Uint64 size; }; // 24 B
```
**没有 per-record 序号字段**seq 就是记录序数(producer `m_emitSeq++`consumer `m_applySeq++`),省 8B/记录并消除一整类失步。
X-macro 单一真相源:
```cpp
// MobileGL/MG_Remote/Protocol/Records.def
#define MGL_REC_LIST(X) \
X(BindBuffer, RecBindBuffer, 24) \
X(DrawArrays, RecDrawArrays, 32) \
X(DrawElements, RecDrawElements, 56) \
X(BufferSubData, RecBufferSubData, 64) \
X(BufferMap, RecBufferMap, 40) \
X(BufferUnmap, RecBufferUnmap, 24) \
X(RenderStateBlob, RecRenderStateBlob, 40) \
X(XfbAccounting, RecXfbAccounting, 56) \
X(GenerateMipmapLevels, RecGenerateMipmapLevels, 32) \
X(RenderbufferStorage, RecRenderbufferStorage, 40) \
/* … ~95 项 … */
#define MGL_REC_SIZE_CHECK(name, T, sz) \
static_assert(sizeof(MobileGL::Wire::T) == (sz), #name " record size drift");
MGL_REC_LIST(MGL_REC_SIZE_CHECK)
```
**每种一条 `static_assert`** ——修掉正是 `Feat/CS-Delta-IPC` 中过一次的 bug 类(`b50f3348`"旧的 off-by-one 让 applier 误读 TexImage 之后的每一条 state delta"),而它那条只断言 union 首成员的 assert`ServerCore.cpp:31-33`)永远抓不到中间插入。
**运行期边界纪律(本轮新增)**`SEG_CMD` 是对端并发写入的区域,编译期 `static_assert` 管不到运行期损坏。同一个 X-macro 额外生成 applier 分发前的前置条件:
```cpp
#define MGL_REC_BOUNDS_CHECK(name, T, sz) \
case RecKind::name: \
if (h.size < (sz) || h.size > remainingRingBytes || (h.size & 7u)) \
return Fatal(FatalCode::ProtocolCorruption, #name); \
break;
```
`kVarTail` 记录额外校验 `定长前缀 + 尾巴自描述长度 == h.size`。违反一律 `Fatal{ProtocolCorruption}`,绝不进入未定义行为。
变长记录(`RecVaoConfig``RecTexSubImage` 的 rect 列表、`RecProgramLinkOp``RecMultiDrawArgs`):`kVarTail` + 定长前缀 + 自描述长度的内联尾巴。
### 6.4 WAR 危害与字节稳定性
**Phase 1-4 规则:GL 调用时刻把字节拷进 ring slot。** slot 从写入到 `stageAppliedTail` 越过它为止不可变,client 拿不回它 → **危害按构造消除**。代价是一次 memcpy,而 `Ops_ResidentSubData``Managers.cpp:1165`)和 `StageBlocksIntoUnpackRing` 在 monolith 里已经在付同样的钱。
**Phase 4.5 规则(shadow-in-shm,零拷贝):** ≥256KiB 的 shadow 分配在 client 拥有的 `SEG_SHADOW` 里——`PipeResource``MapAlignedAllocator``PipeResource.h:33-60`,无状态、25 行、64B 对齐)增加一个 shm arena(保留 `MIN_MAP_BUFFER_ALIGNMENT=64` 契约,`PipeResource.h:28`),`MipmapStorage` 的 level vector 同理。`RecBufferSubData` 于是只带 `{segId, offset, size}`**client 侧零拷贝**。
WAR 用 **per-shadow 64KiB 块发送水位**:若应用写入某块而该块最后一次发送尚未 `appliedSeq` 覆盖,这次写走 `SEG_STAGE`。有界、局部、压力下自动退化成 Phase-1 行为。这套块水位同时是 §5.10 精确版 persistent-map 推送的脏位来源。
**该改动必须整段 `#if MOBILEGL_BUILD_DISAGGREGATED` 包裹**`PipeResource``MipmapStorage` 住在 `MG_State`,不在 `MG_Remote`,而改一个容器的 allocator 就改了类型;不包裹的话 §12/D8 的 `nm`/`.text` 门会在 P4.5 变红。写法是"分配器特化:option OFF 时逐字折叠成今天的 `MapAlignedAllocator`"。
#### 拷贝账(更正版,MC pan 一帧约 9MB section mesh + ~1MB UBO scratch
上一版这张表把 monolith 和 split 两侧都数少了。逐条核对:
- monolith 的 `glBufferSubData` → shadow store 是 **2 次**(1) app→shadow`BufferObject::UploadSubData``Memcpy`),(2) shadow→目的地(`FlushPendingRangesNow``Memcpy(dst, bufferObject.MappedData()+start, size)` 进 invalidating map`Managers.cpp:914`;或 `Memcpy(g_uploadRing.store.mappedPtr+ringOffset, ..., size)` 进 upload ring`Managers.cpp:922`)。
- split P1-4 是 **4 次**app→client shadow (1)、client shadow→`SEG_STAGE` (2)、applier replay mutator ⇒ `SEG_STAGE`→**replica** shadow (3)、server 的 `FlushPendingRangesNow` ⇒ replica shadow→upload ring (4)。
- P4.5 只去掉 (2),剩 **3 次**。它去不掉 (3),因为 `SEG_SHADOW` 是 client 拥有 / server 只读,而 replica 的 `BufferObject` 拥有自己的 `PipeResource` 分配。
| 路径 | monolith | P1-4 | P4.5 | P4.5+replica-adopt(可选,见下) |
|---|---|---|---|---|
| `glBufferSubData` → shadow store | 2 | 4 | 3 | **2** |
| `glBufferSubData` → adopted storeP7 | 2 | — | — | 2 |
| `glMapBufferRange(WRITE)`+unmap | 3 | 5 | 4 | 3 |
| persistent coherent map 推送(§5.10 保守版) | 0 | 2/发射点 | 1/发射点(精确块) | 1/发射点 |
| `glTexSubImage` | 2 | 3 | 2 | 2 |
| 全局 UBO / draw | 1 | 2 | 2 | 1 |
| adopted ≥16MiBP7 T1/T0 | 0 | — | — | 0 |
**目标选择(必须在 P4.5 之前拍板)**
- **方案 A(默认,保守)**:接受 3 次,写进文档。P4.5 的价值是消掉 client 侧那次拷贝与那份重复内存。
- **方案 B(激进,需额外设计)**:给 replica 的 `PipeResource` 增加**第三种模式** `AdoptedClientShadow`——`Bytes()` 返回 server 映射的 client `SEG_SHADOW`(只读),applier 的 `UploadSubData` 退化成一次 range 记账 + change-serial bump,只剩 server 的 ring 拷贝。这保持了 mutator replay 的全部副作用(包括 `IsBufferDrawClean` 比较的 change serial),只是不搬字节。风险:replica 的 shadow 变成只读会让任何 server 侧写(`WritebackFromBackend`、生成 mip、CopyImage 镜像)需要就地 copy-on-write 升级回普通 shadow。**先按方案 A 实现并测量,方案 B 作为 P6 的候选优化项,由 Tracy 计数器决定是否值得。**
无论选哪个,`TracyPlot` 字节计数器必须**装在 wire 两侧**client 的 emit 字节 + server 的 apply 字节 + server 的 ring/staging 字节),P4.5 的验收看**总量**,不是只看 client 一侧的数字。
### 6.5 Ring 分配与背压
逐字移植 `PersistentRing``Managers.cpp:657-727``RingAllocateSlow` `:1891-1970``RingOnPresent` `:1975-2016`):单调 head/tail、2 的幂掩码、frame mark。分配失败升级:**扩容(翻倍) → 对最老未 retire 批次有界等待(默认 50ms,走 §6.2a 的 producerParked doorbell,不是自旋) → 硬 `Drain` 请求 + `ringGeneration` bump**。generation bump 上线,防止后续记录引用被回收的 offset;硬 drain 之后按 §5.6a 重发未 apply 的纹理记录。
`SEG_CMD``SEG_STAGE` 各自独立跑这套升级(各有自己的游标三元组)。
### 6.6 纹理
- **Unpack PBO 完全在 client 解析**`GL_Texture.cpp:1719,1765,1887,1976,2457,2604,2722,4458,6176``pixelUnpackBufferObject->MappedData() + (SizeT)pixels`,再由 `ProcessTexturePixelsDataUnpack` 紧密重排)。**没有任何纹理像素以 PBO 引用形式过线,server 永远不需要 `GL_PIXEL_UNPACK_BUFFER` 状态。`PixelStoreBlob` 只用于 PACK 方向。**
- **压缩纹理永不到达任何 backend**(前端在 `glTexImage` 时把压缩 internalformat 解析成非压缩后备,`GL_Texture.cpp:298-306``grep -i compress MG_Backend/DirectGLES/*.cpp` 只命中一条注释)。逐字节 `m_compressedData` blob 仅供 `glGetCompressedTexImage`,纯 client 侧,不过线。
- **`glCopyTexSubImage*``glClearTexImage` 整体留在 client(推翻上一版的 P4 项)。** 已确认这两个入口今天就是**纯前端操作**:`CopyTexSubImage{1,2,3}D_State``GL_Texture.cpp:3955,3979`)调 `CopyReadFramebufferIntoMipmapRegion``:1044-1097`),它借一次 backend `ReadPixels` 进 CPU scratch`:1079`)、逐行 memcpy 进 mipmap shadow`:1089-1094`)、`MarkStorageDirty(...,true)``:1095`)。拆分后它恰好是**一次阻塞 ReadPixels round trip**,产生的脏区按普通纹理 delta 下发——正确,且不需要任何新命令。上一版提议"整体移到 server + `EvTexWriteback`"是错的:那个事件在 §7.4 的列表里根本不存在(只有 `EvBufferWriteback`),它仍然要付一次 round tripclient shadow 必须为 `glGetTexImage` 保持最新),还多出一个 `GLFunctionsTable` 里没有对应项的命令。`glClearTexImage``GL_Texture.cpp:985-1006`)同形。
- **per-level `serverAuthoritative` 位**只保留给两处**字节确实在 backend 里写进 shadow** 的场景:生成 mip 的 CPU 路径(`DirectGLES.cpp:6270-6271,6861``AllocateStorage` + 直写 `MapMipmapData`)与 `MirrorCopyImageIntoDestinationShadow``:7144``glCopyImageSubData` 的目的地镜像)。client 在发射对应命令时对受影响 level 置位。`CopyTextureImageToClientOrPBO_State` 查它:**清 → 本地 shadow 回答,零 round trip**(应用自己上传的 level 全走这条);**置 → 一次 round trip**。
### 6.7 回读
| 路径 | monolith | 拆分后 |
|---|---|---|
| `glReadPixels` → 客户内存 | 阻塞 | 一次 round trip,像素放 `SEG_REPLY` slotper-row 循环留在 server 内 |
| `glReadPixels` → pack PBO | **也阻塞**`DirectGLES.cpp:9189-9205` 把整个 PBO map 回来写 shadow | **fire-and-forget** + client 侧对该 PBO 置 `MarkGpuWritten`(§5.6b),代价推迟到之后的 map/read。**严格优于 monolith** |
| `glGetTexImage`/`glGetTextureImage` | DirectGLES 从 client shadow 回答 | DirectGLES **零 round trip**(除 `serverAuthoritative` level);DirectVulkan 一次 |
| `glGetBufferSubData` / `glMapBuffer(READ)` on gpuWritePending | 阻塞(`glFinish()``Managers.cpp:1246` | 一次,由 client 侧 pending 集合触发(§5.6b),被 `EvGpuWritten{ranges}` 收窄 |
| XFB capture writeback | `glEndTransformFeedback` 里无条件无限 `ClientWaitSync``GL_Drawing.cpp:1326-1337` | **不等**client 对 capture target 置 `MarkGpuWritten`,首次读时付;`FixupGsStripCaptureOrder` 移到 server |
| `glCopyTexSubImage*` | 内含一次同步 ReadPixels | 一次 round trip(保持前端实现不变) |
### 6.8 persistent map 与 ≥16MiB 采纳
三档,由**运行时 POST 探针**选择(遵循本项目"后端限制一律探针判定、绝不硬编码驱动名"的既定规则):
- **T2 — 拒绝(P1-6 默认,永久正确回退)**:`AcquirePersistentMap` 返回 `nullptr`。**此档下 §5.10 的 client 侧推送是强制的**,否则应用的 coherent persistent 写会丢。
- **T1 — server 导出自己的映射(P7 主攻)**server 照常铸造 coherent map`Managers.cpp:988-1058` / `VkBufferManager.cpp:515-563`),经 `VK_KHR_external_memory_fd` / `AHardwareBuffer_sendHandleToUnixSocket`API 26`hardware_buffer.h:521`/ `VK_KHR_external_memory_win32` / `GL_EXT_memory_object_fd` 导出,client `mmap` 后调 `PipeResource::AdoptPersistentMap(base)`。**每 store 生命周期一次 round trip。** 采纳成功后 §5.10 的推送对该 buffer 自动停止(`SyncPersistentMappedRange``IsGpuResident()` 早退),与 monolith 一致。
- **T0 — server 导入 client 分配**client 分配 `AHardwareBuffer`/dma-bufserver 以 `GL_EXT_external_buffer`+`glBufferStorageExternalEXT``VK_EXT_external_memory_host` 导入。理想但可用性未知。
**`MOBILEGL_COHERENT_AS_FLUSH` 在拆分模式下照常生效**(推翻上一版的禁令,理由见 §5.10 结尾):有了 client 侧推送,被改写出来的 coherent map 与应用原生请求的 coherent map 走同一条正确路径,两个 Create/Flywheel fixture 才能在 split 与 monolith 下做同路径对比。
### 6.9 program artifacts
- **P1-4**`RecProgramLinkOp{handle, shaderSources[], bindAttribLocations[], bindFragDataLocations[], xfbVaryings[], xfbMode, separable, reflectionDigest}` — server 重新 link。只需 5 个 schema 字段,**且分歧不可能静默**(两半跑同一二进制里的同一段代码)。源码可得:`ProgramObject::GetLinkedShaderSnapshot()``ProgramObject.h:157`)刻意持有 linked shader 的 `SharedPtr`(注释在 `:1716`),所以 `glDeleteShader` 之后源码仍在。
- **`reflectionDigest` 必须覆盖 backend 实际读的全集**xxHash over
`(uniformName, location, type, typeFacts, samplerOrImageUnitIndex)` 全表 + `maxUniformLocation` + `(blockName, blockBinding, blockSize)` 全表 + `shaderStorageBlockBindingOverrides` + `PointSizeDemoted` + `GetLinkedShaderStages` + `xfbVaryings/xfbStrides/xfbPackedStride/xfbBufferMode` + **`GetGeneratedSpirv()` 各 module 的 xxHash**。不匹配 → `Fatal{ReflectionDivergence}`
(理由:本项目自己的二分历史记录过"glslang 反射/生成顺序是真载重,桌面字节一致是语料受限的假绿"。)
- **P5**`RecProgramPublish{handle, stages[], spirvBlobs[], reflectionBlobRef}`reflection 用 **`Visit()` 式归档**
```cpp
// MobileGL/MG_State/GLState/ProgramState/ProgramArtifactsArchive.h
template <class Ar> void Visit(Ar& ar, LinkArtifacts& a) { ar(a.writtenUniformLocationBits, /*…全字段…*/); }
static_assert(sizeof(LinkArtifacts) == MGL_LINKARTIFACTS_SIZE,
"新字段请加进 Visit() 并 bump MGL_LINKARTIFACTS_SIZE");
```
一份字段表服务两个方向 + `sizeof` 绊线。**序列化整个结构体**(而非 backend 当前读的 ~40 字段),这样 backend 新增一次 read 永不需要改协议。
安装入口:`ProgramObject::InstallPublishedLink(LinkArtifacts&&, SpirvArtifacts&&, linkVersion, imageUnitVersion, backendStateVersion)`,绕过 `m_pendingLink`/`m_pendingSpirv`**server 因此不需要 compile pool**。
- `relink` 路径保留为常驻 oracle 与 A/B 对照(`MOBILEGL_IPC_PROGRAM=publish|relink`)。
- 全局 UBO scratch 相反:小、每次 `glUniform*` 变、有版本 → 走 `SEG_STAGE`,键 `(programHandle, uboContentVersion)`,复现 monolith 的"每 program 每帧至多一次"`DirectGLES.cpp:3369-3392`)。
### 6.10 应用指针(四类,范围全部可算)
| 类 | 范围 | 站点 |
|---|---|---|
| client 顶点数组(仅 DrawArrays 族) | `(first+count-1)*stride + elementSize` | `Managers.cpp:2560``VulkanRenderer.cpp:3737` |
| client 索引数组 | `count * indexSize` | `DirectGLES.cpp:4436``VulkanRenderer.cpp:4081` |
| client indirect / parameter 块 | `stride*(drawcount-1)+cmdSize` | `DirectGLES.cpp:276``DirectVulkan.cpp:303` |
| `MultiDraw*` 参数数组、`ClearBuffer*` value | `drawcount*4`、16B | `DirectVulkan.cpp:963-1057` |
唯一无界的是**索引 draw 下的 client 顶点数组**:索引扫描(`TryComputeMaxIndexFromHostBytes``VulkanRenderer.cpp:3406-3470`)必须在 **client** 侧跑,只有 client 同时持有两个数组。实现于 `MG_Remote/Client/ClientArrayBounds.cpp`,两个 backend 共用。
**陈旧索引危害(本轮新增)**:monolith 在每次这类扫描之前都调 `indexBuffer->SyncGpuWrites()``DirectGLES.cpp:4413``MultiDraw.cpp:499``VulkanRenderer.cpp:3431,4159`),因为 EBO 可能刚被 compute shader 或 XFB 写过。client 侧扫的是 client shadow,若不做同样的强制回读,算出的 `maxIndex` 来自陈旧字节,顶点数组会被少拷 → 几何缺失/花屏,或越界读应用数组。同样的暴露面还有 primitive-restart 重写(`DirectGLES.cpp:4412-4414`)与 `*IndirectCount` 的 parameter buffer 读(`DirectGLES.cpp:4666-4693,4768-4793`)。
**规则**`ClientArrayBounds`、restart 重写、indirect-count 读者在触碰 shadow 之前,必须走 §5.6b 的 pending 检查(Publish + 等 `appliedSeq` + 排空事件),即 monolith 里 `SyncGpuWrites()` 所在的**同一个位置**。P2 增加 `ClientArrayAfterComputeWriteScenario` 作为门。
draw 记录里 `indicesAreClient` 由"是否绑定了 element array buffer"决定(`DirectGLES.cpp:4423` vs `:4425-4442`),在 binding 所在的一侧判定。
---
## 7. 控制面
### 7.1 FlatBuffers 用法
**一份 schema `MobileGL/MG_Remote/Protocol/protocol.fbs`,两种用法:**
- **热路径 → FlatBuffers `struct`**flatc 保证定长布局、无 vtable、无偏移间接、无需 verifier walk,只需边界检查),直接放进 ring:`[RecHeader | struct | 可选变长尾]``DrawArrays` = 8+24 = 32B(对比 table-per-command 的 ~60B 与一次 vtable 遍历)。这正是 `Feat/CS-Delta-IPC` 自己的 plan 第 55 行要求而实现没做的事。
- **罕见/变长/需演进 → FlatBuffers `table`**,走 CTRL socket。
```fbs
namespace MobileGL.Wire;
// ---------- 热路径 struct(进 ring----------
struct WireHandle { kind:ubyte; p0:ubyte; p1:ubyte; p2:ubyte; glName:uint; lifetimeId:ulong; }
struct BlobRef { seg:uint; pad:uint; offset:ulong; size:ulong; }
struct RecBindBuffer { target:uint; index:uint; h:WireHandle; }
struct RecDrawArrays { mode:uint; first:int; count:int; instances:int; baseInstance:uint; pad:uint; }
struct RecDrawElements { mode:uint; count:int; type:uint; flags:uint; indices:ulong; blob:BlobRef; }
struct RecBufferSubData { h:WireHandle; offset:ulong; size:ulong; blob:BlobRef; }
struct RecBufferMap { h:WireHandle; rangeStart:ulong; rangeEnd:ulong; access:uint; pad:uint; }
struct RecBufferUnmap { h:WireHandle; }
struct RecTexSubImage { h:WireHandle; target:uint; level:uint; box:[uint:6]; rectCount:uint;
pad:uint; blob:BlobRef; } // rects 在变长尾
struct RecGenerateMipmapLevels { h:WireHandle; target:uint; requiredLevelCount:uint;
bytesPerTexel:uint; shrinkingAxes:uint; }
struct RecRenderbufferStorage { h:WireHandle; internalFormat:uint; width:int; height:int;
samples:int; pad:uint; }
struct RecXfbAccounting { pausedPrims:ulong; inputPrims:ulong; prims:ulong;
capturedVerts:ulong; geomDraws:uint; accountedDraws:uint; }
struct RecRenderStateBlob{ version:ushort; pipelineVersion:ushort; pad:uint; blob:BlobRef; }
struct RecPresent { frameSerial:ulong; swapInterval:int; pad:uint; }
struct RecSetResolvedDrawProgram { h:WireHandle; }
// … 共约 95 个
// ---------- 控制面 table(走 socket----------
table SegmentRef { id:uint; kind:ubyte; sizeBytes:ulong; name:string; }
table Hello { abiMajor:uint; abiMinor:uint; buildFingerprint:string; backendType:uint;
pid:uint; configBlob:[ubyte]; }
table Welcome { abiMajor:uint; abiMinor:uint; serverPid:uint;
cmdRing:SegmentRef; stageRing:SegmentRef; replyPool:SegmentRef; eventRing:SegmentRef; }
table CapsSnapshot { dynamicParameters:[ubyte]; // DynamicBackendParameters 逐字节
rendererInfo:[ubyte]; formatCaps:[ubyte]; extensions:[string];
apiVersion:string;
maxComputeWorkGroupCount:[int:3]; maxComputeWorkGroupSize:[int:3];
tableSlotMask:ulong; // 远端实际注册了哪些 GLFunctionsTable 槽
prefersCpuXfbPrimitiveAccounting:bool; }
table DefaultFramebufferInfo { width:int; height:int; colorFormat:uint; depthFormat:uint; stencilFormat:uint; }
table SurfaceOp { seq:ulong; kind:ubyte; display:ulong; surface:ulong; windowKind:ubyte;
nativeToken:ulong; width:int; height:int; swapInterval:int; }
table SurfaceReply { seq:ulong; ok:bool; eglMajor:int; eglMinor:int; defaultFb:DefaultFramebufferInfo; }
table ProgramReflection { /* Visit() 归档的结构化镜像,P5 */ }
table ResyncRequest { serverEpoch:uint; } table ResyncDone {}
table AuxRequest { seq:ulong; kind:ubyte; payload:[ubyte]; } // 外来线程 sync/query
table Fatal { code:uint; message:string; }
table LogLine { level:ubyte; text:string; }
union CtrlMsg { Hello, Welcome, CapsSnapshot, SurfaceOp, SurfaceReply,
ProgramReflection, ResyncRequest, ResyncDone, AuxRequest, Fatal, LogLine }
table CtrlEnvelope { msg:CtrlMsg; }
root_type CtrlEnvelope;
```
`protocol_generated.h` **提交进仓库**,由 `scripts/gen_protocol.py` 重新生成(镜像 `tools/trace_replay/CMakeLists.txt:52-69` 驱动 `glproc.py` 的做法);CI 加 `flatc-check` 步骤重新生成并 `git diff --exit-code`
**codegen 绝不进默认构建图(本轮加强)**`Feat/CS-Delta-IPC:MobileGL/Protocol/CMakeLists.txt:22-38``MOBILEGL_FLATC_EXECUTABLE` 未设时 `add_subdirectory(3rdparty/flatbuffers)` 并开 `FLATBUFFERS_BUILD_FLATC ON`——这正是它自称要修的 NDK 陷阱(交叉编译造出 arm64 `flatc` 然后在 host 上执行)。**本计划不复用这一段**:`gen_protocol.py` 是纯开发者/CI 目标,默认构建图里没有 `flatc``MOBILEGL_FLATC_EXECUTABLE` 只服务 CI 的 `flatc-check`。FlatBuffers 运行时是 header-only,只需要 `3rdparty/flatbuffers/include` 在 include path 上(P4 用 `nm` 复核 `libMobileGL.so` 链接行没有新增库,不靠断言)。
### 7.2 帧封装与 flush 策略
CTRL socket 封帧:`[u32 'MGLF'][u32 len][payload]`64MiB 上限,**读时校验**`Feat/CS-Delta-IPC``Feed()` 永远返回 OK,坏 magic 变成静默永久挂起,`Framing.h:41-45``StartRead` 直接按 wire 长度分配无上限检查,`LocalSocketTransport.cpp:232-236`)。接收缓冲不足时**返回所需大小并保留消息**(上一版的 transport 会失败且不弹出消息,把流永久卡死)。
#### Publish 触发器(重写,删掉 64KiB 阈值)
上一版设 "records ≥ 64KiB" 为主触发器。按 §6.3 的记录尺寸,64KiB ≈ 1200-2700 条记录,即**一整帧**(计划自己把 MC 帧估为 1000-4000 draw)。那意味着 server 在 client 发完整帧之前无法开始工作——这不是异步,是一个整帧的流水线气泡,且在 present credit 之上再加一整帧延迟。它还在 P2.5 跑之前就先把 P2.5 的假设否掉了(inproc 的全部意义就是让 `PrepareForDraw` 与 GL 线程重叠,帧粒度 publish 保证零重叠)。而 `SEG_CMD` 是 SPSC ring"publish" 只是一次 `cmdHead` 的 release store,唯一值得摊销的是门铃写。
**新规则**
- **每条记录(或每 8-16 条,用来摊销 storerelease-store `cmdHead`**;仅当 `consumerParked` 时敲门铃。
- 显式门铃点:`Present`、任何 `kNeedsAck` 阻塞请求、`eglMakeCurrent``glFlush`**刷出 outbox,不等待**)。
- **`SEG_STAGE` 余量 < 1/4** 时敲门铃(用 `stageHead - stageAppliedTail`)。
- **轮询类入口点也是门铃点(本轮新增,修 livelock)**:`glClientWaitSync`(任意 timeout)、`glGetSynciv(GL_SYNC_STATUS)``glGetQueryObject*(GL_QUERY_RESULT_AVAILABLE | GL_QUERY_RESULT_NO_WAIT)`
理由:GL 的标准惯用法是 `glFenceSync(); while (glClientWaitSync(s, GL_SYNC_FLUSH_COMMANDS_BIT, 0) == GL_TIMEOUT_EXPIRED) {}``while (!avail) glGetQueryObjectuiv(id, GL_QUERY_RESULT_AVAILABLE, &avail);`。循环里没有别的 GL 调用,若这些入口不 publish,`RecFenceSync` 就永远躺在 ring 里,server 看不到,watermark 不动,循环永久自旋——这是挂死,不是变慢。仓库自己在意这件事:`DirectVulkan.cpp:1158-1160` 写明 "GL_SYNC_FLUSH_COMMANDS_BIT: flush regardless of timeout, so a zero-timeout poll loop makes progress across calls",而 MG_Impl 无条件把 flags 透传给 backend`GL_Sync.cpp:96`)。
**携带 `GL_SYNC_FLUSH_COMMANDS_BIT` 的调用无条件 publish**spec 要求 flush)。
- **饥饿升级**:同一个 handle 连续 N 次(默认 64`MOBILEGL_IPC_POLL_ESCALATE`)本地回答 `TIMEOUT_EXPIRED` / "未就绪" 而 watermark 毫无移动时,升级成一次阻塞 round trip,这样一个已经卡住的 server 不会把 client 自旋成死循环。
**`glFinish` 保持纯 no-op**`Definitions.cpp:111-112`)——应用唯一的强制停顿手段在 monolith 里免费,拆分后也必须免费。
### 7.3 序号与 credit
seq = 记录序数。**两个互相独立的窗口,绝不是 per-batch 锁步**`Feat/CS-Delta-IPC` 在 apply 循环里同步发 ack,`ServerCore.cpp:421-429`,是最差的节奏;而且它的 credit 算成 `baseSeq + items.size()`,只有 `baseSeq==0` 时才对):
- **字节 credit**`SEG_CMD``SEG_STAGE` 各自的占用,升级路径见 §6.5。
- **Present credit**`eglSwapBuffers``presentsSent - presentAckSerial >= MOBILEGL_IPC_PRESENT_CREDIT`(**默认 1**,见 §9)时阻塞。
server 端**不发 credit 消息**:它对 `RingControl` 做 release storeconsumer 每 64 条记录更新一次 `appliedSeq`,并在 `producerParked` 时敲反向门铃。
### 7.4 事件回传通道
`SEG_EVENT` 是 server→client 的 SPSC POD ring`EvQueryResult{handle, available, value}``EvFenceSignaled{handle}``EvGpuWritten{handle, rangeCount, ranges[]}``EvBufferWriteback{handle, offset, BlobRef}``EvReadbackDone{seq, BlobRef}``EvGlError{code}``EvDefaultFramebufferInfo``EvCompileEnvInvalidate``EvLogLine{level,len,text}`
#### 排空点(补齐)
client 在下列位置排空:`glGetError``glGetQueryObject*``glClientWaitSync``glGetSynciv``eglSwapBuffers`、**`glMapBuffer` / `glMapBufferRange` / `glGetBufferSubData` / `glGetNamedBufferSubData` / `glCopyBufferSubData`**(§5.6b 要求),以及**每一次等待循环的每一轮**(present credit、`kNeedsAck`、ring/stage 满)。最后一条是必须的,见下。
#### 溢出策略(本轮新增,修一个双向死锁)
上一版没说 `SEG_EVENT` 满了怎么办,也没要求 client 在**等待中**排空。具体死锁:client 卡在 `eglSwapBuffers` 等 present creditserver 的 apply 线程一边 apply 一边产 `EvLogLine``EvGpuWritten``SEG_EVENT` 满;apply 线程阻塞在生产上;`presentAckSerial` 永不前进;client 永不离开 `eglSwapBuffers`,因而永不排空。两边都死。
**策略**
1. client **必须**在每个等待循环内排空 `SEG_EVENT`,不只是在入口点边界。
2. `EvLogLine` 是**有损**的:覆盖最旧,并累加 `RingControl.eventDropped`(client 在排空时把丢失条数打进日志)。丢一条日志绝不允许卡住渲染。
3. 语义承载事件(`EvGpuWritten``EvReadbackDone``EvFenceSignaled``EvBufferWriteback``EvGlError``EvDefaultFramebufferInfo``EvCompileEnvInvalidate`**无损**ring 装不下时 server 置 `RingControl.eventRingFull=1` 并**停止 apply**(停在一条记录的边界上,不是记录中间),敲反向门铃;client 排空后清标志并敲正向门铃。状态因此永远可恢复。
4. 故障注入测试:在 client 被 credit 阻塞时灌满 `SEG_EVENT`,与 P8 的 SIGKILL 测试并列。
server 侧的 `MGLOG` 与延迟诊断按流顺序 replay 进 client 日志流——复用已存在的 `DeferredLogLine`/`ApplyDeferredDiagnostics` 机制(`JobNode.h:26-58,149-158`)。
---
## 8. Roundtrip 清单
### 不可避免的阻塞点
| # | 站点 | 频率 | 为什么 |
|---|---|---|---|
| 1 | 握手 `Hello`/`Welcome` + 段 fd 传递 | 一次 | — |
| 2 | `InitializeEGLDisplay`(写 `major`/`minor` | 一次 | 出参 |
| 3 | `CreateEGL{Window,Pbuffer}Surface` / `Resize` | 罕见 | 返回 `Bool`;回复顺带 `DefaultFramebufferInfo` |
| 4 | 首次 `MakeEGLCurrent` + `InitCapabilities``CapsSnapshot` | 每 surface 一次 | caps 只在那一刻才存在(`BackendObject.cpp:341-347` |
| 5 | `glReadPixels` → 客户内存 | 罕见(CTS 热) | GL 要求返回时字节已就位 |
| 6 | `glCopyTexSubImage*` / `glClearTexImage`(内含 ReadPixels) | 罕见 | 前端实现本来就借一次 ReadPixels |
| 7 | `glGetTexImage`/`glGetTextureImage`DirectVulkanDirectGLES 仅 `serverAuthoritative` level | 罕见 | — |
| 8 | `glGetBufferSubData` / `glMapBuffer(READ)` on client-pending | 罕见 | monolith 里本来就阻塞;client pending 集合触发 |
| 9 | client 顶点数组的索引扫描 / restart 重写 / indirect-count 读(当 EBO 在 pending 集合里) | 罕见 | monolith 在同一位置调 `SyncGpuWrites()` |
| 10 | `glClientWaitSync(timeout>0)` 超出 watermark | 每帧级 | 应用请求的等待 |
| 11 | `glGetQueryObject*(GL_QUERY_RESULT)` 未完成;`glBeginConditionalRender` | 罕见 | `GL_Query.cpp:300``:705-706`(后者注释明说"by WAITING even for the _NO_WAIT modes" |
| 12 | 轮询饥饿升级(连续 N 次无进展) | 极罕见 | 防死锁保险 |
| 13 | **分配类入口点的错误 ack**`glRenderbufferStorage*`、部分 `glTexImage*`/`glTexStorage*`/`glCopyTexImage*``glBufferStorage`) | 罕见 | OOM 探测惯用法(§5.6c) |
| 14 | `AcquirePersistentMap`(仅 P7 T1 | 每 store 一次 | 返回映射 |
| 15 | ring/stage 耗尽、present credit | 节奏 | 非语义 |
### 变成异步或本地的
- 全部 20 个 draw、9 个 clear、blit/copy、`GenerateMipmap`、dispatch、barrier、image bind、7 个 XFB 跨度标记、`PatchParameteri``ShaderStorageBlockBinding`(权威状态已在 client`GL_Program.cpp:3391`)、所有 buffer/texture/program/VAO/FBO delta、`Present`
- **`glGetError` 永远本地**`GL_Getter.cpp:2811-2817``Core.cpp:48-49` 的不变式)。
- **`glFinish`/`glFlush` 保持免费**。
- **89 个 caps 站点全部本地**45 `GetDynamicParameters` + 8 `GetRendererInfo` + 4 `GetFormatCapabilities` + 3 `GetBackendType` + `IsTimerQuerySupported` + `PrefersCpuXfbPrimitiveAccounting` + `BeginOcclusionQuery!=nullptr`)。
- **`glGetIntegeri_v` 全部本地**`glDispatchCompute` 的三次 per-dispatch 校验查询(`GL_Drawing.cpp:719`)改读 `CompileEnv::maxComputeWorkGroupCount``CompileEnv.h:52-54`)。
- **`GetInteger64i_v``GetProgramiv` 删除**。
- **`FenceSync``Begin{TimeElapsed,Occlusion,XfbPrimitives}Query``QueryCounterTimestamp` → client 铸造 handle**fire-and-forget(前端本来就铸造应用可见的名字:`GL_Sync.cpp:61``GL_Query.cpp:54`)。
- **`GetSyncStatus``ClientWaitSync(0)``IsQueryResultAvailable``GetQueryResult64(wait=false)` → 先 publish(§7.2),再从水位一次 acquire load 回答**。miss 返回 `GL_UNSIGNALED` / "未就绪",两处契约明确允许(`BackendObject.h:210-214``:236-241``GL_Query.cpp:302-311` 已遵守:读 0、**不缓存**、保留 backend handle)。
- **`glReadPixels` 进 PBO → fire-and-forget**(配 client 侧 `MarkGpuWritten`),比 monolith 更好。
- **`glEndTransformFeedback` 的无限 fence 等待取消**(配 client 侧对 capture target 置 `MarkGpuWritten`)。
**稳态帧:零 round trip**(对不使用 conditional render / 阻塞式 query / 分配类调用的帧而言;见 §15 P3 的门措辞修正)。
### fence 完成度必须来自真 fence,不是 present 水位(本轮新增)
上一版让 `retiredSeq`/`completedFrameSerial` 兜底 fence 语义。但在 DirectGLES 上这两个水位**只在 `Present()` 里前进**`DirectGLES.cpp:10626-10643``eglSwapBuffers` 之后轮询 4 深 fence ring),或在 `WaitForFrameSerialCompleted``:10583-10607`)里。帧中创建的 fence 于是要等到**下一次 present 退休**才报 signalled,即 fence 完成度退化成帧计数推断。`DirectVulkan.cpp:1120-1128` 恰恰写明这是被修掉的 bug:完成度必须"track the GPU itself rather than the frame-count inference; MC 1.21.5's fence-paced ring buffers depend on this to recycle their space instead of growing without bound",而项目记忆 `magma-mc1215-fence-oom` 记录了它曾导致 native-heap OOM kill。
**规则**`RecFenceSync` 在 server 侧转成一次**真实的 backend `FenceSync()`**server 用自己已有的逐 fence 轮询(DirectGLES 有 `WaitForFrameSerialCompleted` 的 fence 选择逻辑 `:10586-10600` 可复用;DirectVulkan 有 `IsSubmitIndexComplete`)在**非 present 时刻**也推进,并发 `EvFenceSignaled{handle}`。client 的本地快路径读的是"由真实逐 fence 退休导出的 handle 水位",不是 present 水位。
### 三个应先独立落到 `dev` 的 monolith 修复(可二分、monolith 自身受益)
1. `glEndTransformFeedback` 的无条件无限 `ClientWaitSync``GL_Drawing.cpp:1326-1337`)→ 用既有 `MarkGpuWritten`/`SyncGpuWrites` 推迟到首次读。
2. `glDispatchCompute` 三次 `GetIntegeri_v``CompileEnv`
3. 删除 `GetInteger64i_v`/`GetProgramiv` 两个死表项及两个 backend 的实现。
---
## 9. Present 与帧节奏
`eglSwapBuffers``EGLImpl::SwapBuffers``EGLImpl.cpp:162-183`)→ `BackendObject::SwapEGLBuffers``BackendObject.cpp:369-398`,其线程归属校验全部对 client 镜像的 EGL 状态求值,**不需要回复**)→ 发 `RecPresent{frameSerial, swapInterval}` → publish + 敲门铃 → 返回,除非 `presentsSent - presentAckSerial >= MOBILEGL_IPC_PRESENT_CREDIT`
**`Present` 与应用的 `eglSwapBuffers` 严格 1:1,绝不批量。** Magma 侧四次 `OnFrameBoundary()` 缓存老化、`TryDrainFrameTransients` 和全部四次 `BeginFrame` 只在 `Present` 内发生(`VulkanRenderer.cpp:12765-12904`);Espryt 侧三个 ring 与 `TrimBufferPool` 在那里 retire`DirectGLES.cpp:10646-10649`)。批量会饿死这些排空。
### 9.1 延迟是叠加的:credit 默认改为 1
上一版设 credit=2 并论证它"镜像系统已有预算",因此"不引入新的停顿类别"。停顿**类别**确实不新,但**延迟会叠加**,而上一版没有把它加起来:
- server 自己的 `Present` 在返回之前就已经等了 2-3 帧:`VulkanRenderer::Present` 末尾调 `FrameContext::WaitAndAcquireNextImage`,其第一条语句是 `vkWaitForFences(device, 1, &frame.imageInFlightFence, VK_TRUE, timeout)``FrameContext.cpp:288-290`)。`presentAckSerial` 因此只能在那次等待完成后才前进。
- 一个被允许领先 2 个 present 的 client,叠在一个自身已领先 GPU 2-3 帧的 server 上 = **端到端 4-5 帧**60Hz 下 66-83ms,对第一人称游戏不可接受。
- 现有的验收门都看不见它:SSIM 是帧内容比较,`bench.sh` 量的是 FPS,都不是 input-to-photon。
**规则**`MOBILEGL_IPC_PRESENT_CREDIT` **默认 1**(可配 1-4)。文档里写明叠加公式:`端到端 ≈ client credit + server FIF + 驱动深度`。P3 与 P9 的验收增加**输入延迟测量**:用已有的 `GetGpuTimestampNs` 与 trace-replay `--benchmark` 的逐帧 JSON 构建 "记录发射时刻 → present 完成时刻" 直方图;只有当实测吞吐收益能抵掉实测延迟代价时才调高 credit。
参考基线:`MagmaFramesInFlight = 3` 钳到 `[2, maxImageCount]``VulkanRendererConfig.h:14-19``VulkanRenderer.cpp:3051-3058`),Espryt 深度 4 的 fence ring 刻意高于驱动的 2-3`DirectGLES.cpp:10071-10074`)。
### 9.2 swap interval 与 Magma
Swap interval 搭 `RecPresent` 过去。注意 Magma 从不注册 `SetSwapInterval``BackendObject_DirectVulkan.cpp:698` 只注册 `Present`)且偏好 `MAILBOX`/`IMMEDIATE``SwapchainObject.h:74-79`),因此 **IPC credit 成为 Magma 唯一的显式限帧器** —— 记录在案,P6/P9 在设备上测量输入延迟与帧节奏;若 Magma 需要,把"注册 `SetSwapInterval` 并映射到 FIFO"作为**独立的 `dev` 变更**,不让两套机制同时管节奏。
### 9.3 无 present 循环下的水位饥饿
`retiredTail` 的回收依赖 server 发布准确的 `completedFrameSerial`。DirectVulkan 有 `TryDrainFrameTransients`/`RefreshCompletedSubmits` 可以在非 present 时刻推进,**DirectGLES 没有对应物**`g_completedFrameSerial` 只在 `Present()` 里(`DirectGLES.cpp:10626-10643`)和 `WaitForFrameSerialCompleted``:10583-10607`,且要求存在覆盖目标 serial 的活 fenceslot 被回收时返回 false)前进。在无 present 的负载里——`tools/cts``run_cts_local.py`、回读循环、从不 swap 的 `MG_IntegrationTest` 场景——一个 fence 都不会被插入,`retiredTail` 永不前进,`SEG_STAGE` 填满,§6.5 的升级路径在每个用例上都跑到硬 drain。那会把一次 CTS run 变成一连串 50ms 等待加整体 drain,并可能被误读成一致性回归。
**规则**:给 DirectGLES 的 server 加**非 present fence tick**——距上次 `Present` 超过阈值(默认 8ms)或每 N 条已 apply 记录(默认 4096)时,插入一个 `glFenceSync` 并轮询 fence ring,复用 `g_frameFenceRing` 机制。同时把 ring 占用率与升级次数打进 Tracy 计数器(P0 交付),让"水位饿死"表现为一个指标而不是一次无法解释的停顿。P2 增加一个无 present 的 split 用例。
---
## 10. 线程模型
### Client
- **v1 不加线程。** 编码在调用方 GL 线程上直接写进 ring。前端本来就是 per-context 单线程契约(`GLContext` 无 mutex`EGLState::MakeCurrent` 强制一个 owner 线程,`EGLState/Core.cpp:1215-1220`,测试在 `MG_Test/EGLState/EGLStateTest.cpp:39-92`)。
- **flow = per context,不是 per thread。** 今天恰好一个 flow。`eglMakeCurrent` 是 flow 所有权转移,在既有 `EGLOperationMutex``EGLImpl.cpp:241`)下发射。**顺手修既有漏洞**`EGLImpl::ReleaseThread``:341-350`)与 `SwapInterval``:435-450`)今天不取该锁而另外三个(`MakeCurrent`/`SwapBuffers`/`DestroySurface`)取。
- **外来线程的 sync/query**:读全部从 `RingControl` 无锁 acquire load 回答(比取 registry mutex 更好);少数必须发射的(`FenceSync``Begin*Query`,以及 §7.2 要求的轮询 publish)取 `ctrlMutex` 并走 CTRL socket 的 out-of-band `AuxRequest` 帧(SPSC ring 不允许第二个 producer)。
- **等待必须能挂起**:所有 client 侧等待(present credit、`kNeedsAck`、ring/stage 满、轮询升级)走 §6.2a 的 `producerParked` + 反向门铃,自旋窗口 `MOBILEGL_IPC_SPIN_US`(默认 50µs)。
- ShaderCompilePool 原样保留在 client`ShaderCompilePool.h:77-82`,≤4 worker,为 RSS 上限)。
- 可选 `mgl-client-tx` 双缓冲发送线程:**P6 项,凭测量决定**。在 P6 的 Tracy 数据出来之前不要预先加线程(会引入拷贝或锁)。
### Server
| 线程 | 职责 |
|---|---|
| `mgl-srv-io` | asio `io_context::run`:封帧读写、`SCM_RIGHTS`、双向 doorbell、CTRL RPC |
| `mgl-srv-apply` | **终身持有原生 EGL/Vulkan context**:消费 ring → 解码 → apply 进 replica → 调 backend 表 |
| `mgl-srv-dec`(可选,P6 | FlatBuffers/边界校验前置,凭测量决定 |
因为 context 永不迁移:`g_backendContextOwnerThread``DirectGLES.cpp:10052`)只写一次;`DirectGLES::MakeCurrent` 的 8 缓存失效风暴(`:10123-10140`)变成启动期一次性成本;`IsBackendContextCurrentOnThisThread` 的每帧 EGL 复核(`:10195-10228`,动机是 `eglGetCurrentContext` 实测占渲染线程 16%)恒真。DirectGLES 的 off-thread 降级(`FenceSync` 返回 null 等)消失——**保真度提升**。延迟 replay 机制(`Managers.h:458-473``pendingRespecify`/`pendingRanges`/`pendingResidentWrites`)保留但永不触发。
### 核心放置(本轮新增,是性能主张的前提)
§5.1 明说 reconciler "就是 `PrepareForDraw` 的可达性遍历"。这意味着这套遍历**每 draw 跑两次**client 的 `WireMirror` 一次,server 未改动的 `PrepareForDraw``DirectGLES.cpp:2916-2975`)一次,外加编码与解码。其中有些并不便宜:`CurrentUnitBindingsEpoch``DirectGLES.cpp:1421-1438`)在 `GetTextureBindGeneration()` 变动时会退化成对每个 touched texture unit 做 owner-equality 全走查,而代码自己注明这在冗余重绑时就会发生("26.2 re-binds the unit's own sampler around every texture-unit switch")。
所以拆分的全部性能主张都押在"两半落在两个都快的核上"。而 MobileGL 全库从不设置亲和性(`grep -rn 'sched_setaffinity\|cpu_set_t\|affinity' MobileGL/` 零命中),server 是 fork/exec 出来的独立进程、不继承 launcher 的亲和性,项目记忆 `pojav-bigcore-affinity-trap` 又记录过 `pojavBigCore=true` 把整个游戏 JVM 加 MobileGL worker 钉死单核、让一整批历史测量作废。若 `mgl-srv-apply` 落到 1.55GHz 小核,它做的工作严格多于 monolith 在 1.96GHz 大核上做的,拆分按构造就是回归,而 §15 P3 的"帧时在 monolith 10% 内"会以一个没人会正确归因的理由失败。
**规则**
1. 计划里必须写出**总 CPU 工作量差**client reconcile + encode + decode + server `PrepareForDraw` vs monolith 的 `PrepareForDraw`),不只是单侧成本。
2. 复用 `ShaderCompilePool` 已有的大核探测(`ShaderCompilePool.cpp:73-96``ReadCpuMaxFrequencyKHz` / `DetectBigCoreCount`)把 `mgl-srv-apply` 绑到大核,开关 `MOBILEGL_IPC_SERVER_AFFINITY`(默认 auto),并把解析出的 mask 打进日志。
3. P2.5 与 P3 必须报**逐线程 CPU 时间**,不只是墙钟帧时,这样"没有收益"的结论能被归因到放置 vs 编码成本。
### 拆机顺序(三条约束)
`Publish()` + server 排空并 ack → 停 apply 线程 → 关 transport →(client)排空 compile pool(必须先于 `glslang::FinalizeProcess()``pGLContext` 析构,`ShaderCompilePool.h:106-110``Init.cpp:56-62`)→ `MobileGL::Destroy()``EGLImpl.cpp:335-338`)→ 释放 sync/query handle`GL_Sync.cpp:223-226`)。
---
## 11. EGL/窗口与进程生命周期
### 11.1 启动与握手
client 定位 server 的顺序(**本轮修正**):
1. `MOBILEGL_IPC_SERVER_PATH`**主要机制**)。
2. `dladdr(&MobileGL::Initialize)` → dirname → `libMobileGLServer.so`**兜底**)。
上一版把 `dladdr` 当主要机制,但两个桌面验收门都因此找不到 server:`MG_IntegrationTest/CMakeLists.txt:28-35` 在非 Android 上把 `MGL_ITEST_MOBILEGL_TARGET` 设成 `MobileGL_s`**静态链接**),`dladdr` 解析到测试可执行文件自身的路径而不是库目录;trace replay 则由 `tools/trace_replay/CMakeLists.txt:285-290` 显式传 `-DMOBILEGL_LIBRARY=$<TARGET_FILE:MobileGL>`,其目录是 MobileGL 的构建输出目录,而 CMake 默认把 `add_executable` 放在定义它的目录的 binary dir。
**配套**:把 `MobileGLServer``RUNTIME_OUTPUT_DIRECTORY` 设成 `$<TARGET_FILE_DIR:MobileGL>`,并把 `"MOBILEGL_IPC_SERVER_PATH=$<TARGET_FILE:MobileGLServer>"` 加进每一条新的 ctest `ENVIRONMENT`(经 `mgl_itest_join_environment``${MGL_ITEST_COMMON_ENV}` 合并)以及 `add_trace_replay_test``SPLIT` 分支。**并复核绝对路径能否活过 CI 的 artifact 搬运**`.github/workflows/test.yml:174-185` 只重写 `CTestTestfile.cmake` 里的 `cmake` 路径,不重写 `ENVIRONMENT` 值——若不行,改为在测试启动时由 harness 相对 `argv[0]` 解析。
启动方式:`socketpair(AF_UNIX, SOCK_STREAM)` + `fork`/`execve`fd 3 = socketWindows 见 §11.5)。**无文件系统 socket 路径、无 abstract namespace、Android 上无 SELinux 争议。**
**子进程必须被强制成 monolith(本轮新增,修无界 fork 链)**`MG_Config::Transport``ConfigLoader` 从环境变量读(与 `features.CoherentAsFlush = QueryEnvFlag(...)``ConfigLoader.cpp:185`)同形),而 `fork`/`execve` 的子进程会继承 `MOBILEGL_TRANSPORT=spawn`。server stub 里 `dlopen(libMobileGL.so)` + `dlsym("mobilegl_server_main")` 之后必然要起一个真 backend,即走 `MG_Backend::Init()``Init.cpp:48-70`)——变量还在,于是它再构造一个 `BackendObject_Remote` 并再 spawn 一次,首次 GL 调用时形成无界 fork 链。
**规则**(a) spawn 时构造**显式 envp**,剔除 `MOBILEGL_TRANSPORT` 与所有 `MOBILEGL_IPC_*`(只保留 server 真正需要的少数几个,如 `MOBILEGL_BACKEND_TYPE`、日志路径);(b) `mobilegl_server_main` 在能到达 `MG_Backend::Init()` 之前把 `MG_Config::Transport` 硬置为 `Monolith`。两条都做,任一条单独失效时另一条兜住。P0 增加一个 `MG_Test/Wire` 测试:spawn 一个 server 并断言进程树只多出**恰好一个**子进程。
`Hello{abiVersion, backendType, buildFingerprint, configBlob}``Welcome``configBlob` 转发 client 解析好的 `MG_Config::Features`,两半不可能对某个 quirk 开关有分歧。`buildFingerprint`git hash + `Records.def` 的 hash)不匹配 → 握手期 `Fatal`
### 11.2 `mobilegl_server_main` 的可见性(本轮新增)
`CMakeLists.txt:497-510` 在**非 Debug** 构建上给共享目标设 `C_VISIBILITY_PRESET hidden` / `CXX_VISIBILITY_PRESET hidden` / `VISIBILITY_INLINES_HIDDEN ON`——而 plugin 与 FCL 出货的正是 RelWithDebInfo`MobileGL/build.gradle``fordebug` 类型强制 `-DCMAKE_BUILD_TYPE=RelWithDebInfo`)。所以 `dlsym("mobilegl_server_main")` 在 Debug 下能用、在设备上静默失败。
**规则**:入口点声明为
```cpp
extern "C" __attribute__((visibility("default"))) int mobilegl_server_main(int argc, char** argv);
```
并在 P0 验收里加 `nm -D libMobileGL.so | grep mobilegl_server_main` 断言(与既有的 `nm --defined-only` 门并列)。若哪天 macOS/Windows 也要托管 server,还需同步 `MG_Impl/DyldInterpose/ExportedSymbols.txt``wgl.def`
### 11.3 Android
**minSdk 26 没有任何公开 NDK API 能扁平化 `ANativeWindow`**NDK r27.3 的 `android/native_window.h` 无 parcel 符号;`libbinder_ndk` 是 API 29`binder_ibinder.h:191``ASurfaceControl` 是 API 29`surface_control.h:67`)。`Feat/CS-Delta-IPC``nativeBlob`"binder-flattened ANativeWindow"`protocol.fbs:377-379`)不可实现。
- **P1-P8 验证路径:无窗口。** 两个 PIE ELF。**实测**:从解压出的 nativeLibraryDir exec 在 API 36 上可行(`run-as … libtrace_replay_runner.so` → exit 132 = SIGILL,即 ELF 已被加载进入,而非 `EACCES`;文件 0755 / `u:object_r:apk_data_file:s0` 且无 MLS category**跨 package 也可**)。`useLegacyPackaging = true` 在 FCL`../FCL/build.gradle.kts:76-82`)与 plugin`android-plugin/app/build.gradle.kts:198-203`)都已开。surface 用 pbuffer 或 `AImageReader` 支持的 `ANativeWindow``HeadlessGL.cpp:86-131,268-274`),trace replay 默认 pbuffer`apitrace_glws_egl.cpp:614-618`)。
**注意实测的域**:上述 SIGILL 证据是经 `run-as` 取得的,即 `runas_app` 域,而不是 trace Activity 所在的 `untrusted_app` 域。**P0 的 Android spike 必须从应用自身进程 `posix_spawn` 一次**(见 §15 P0)。
- **P9 生产路径**Java `Surface`Parcelable)→ Messenger/AIDL → `MobileGLServerService``android:process=":mgl"`)→ JNI `ANativeWindow_fromSurface(env, surface)`,就是 FCLauncher 今天在 `egl_bridge.c:81` 做的那一次调用。**仓内先例**`android-plugin``BenchService` 已在 `android:process=":bench"` 里跑 MobileGL`BenchService.java:19-77`)。代价:server 进程多一个 ART~15-25MB)。
- **纠正一条过期笔记**:FCL 把游戏 JVM 跑在**主进程**,不是 `:jvm``../FCL/src/main/AndroidManifest.xml:112-121``JVMActivity` 没有 `android:process``:jvm` 是下载 Service)。第二个进程必须新建。
- **HeadlessGL 的 fork 预检与孤儿 server(本轮新增)**:`MG_IntegrationTest/Harness/HeadlessGL.cpp:344-368` 会 fork 一个子进程跑完整 EGL bring-up 然后 `_exit(step)`,注释(`:364-366`)明说这是刻意的——"every atexit handler and static destructor in this address space belongs to the parent's copy of the world"。拆分模式下那个子进程的 bring-up 会走到 `MG_Backend::Init()` 并 spawn 一个 server`_exit` 不跑任何拆机,那个 server 成为孤儿,活到它发现 EOF 或撞上 `MOBILEGL_IPC_IDLE_EXIT_S`(默认 30s)。父进程随即对同一设备起自己的 server。`HeadlessGL.cpp:585-589` 已经把这种失败模式命名为"a leaked exclusive device, an environment the child did not have"。
**规则**server 的 EOF 检测必须**即时且无条件退出**(亚秒级,不靠 30s 看门狗);client spawn 时把 socket fd 设成 `_exit` 会确定性关闭的形态(不设 `FD_CLOEXEC` 以外的保活);再加一次**有界重试的就绪握手**,这样残留的预检 server 不会把父进程弄 flaky。这个交互本身列为 P1 验收步骤 1 的一部分,先于任何广度工作。
### 11.4 Linux / X11
`Window` 是 XID`nativeToken:u64` 直接送。backend 自己 `XOpenDisplay(getenv("DISPLAY"))` 并构造 `VkXlibSurfaceCreateInfoKHR``VulkanRenderer.cpp:14486-14521`),只要同 `DISPLAY`/`XAUTHORITY` 就免费。Wayland 今天不支持(`BackendObject.h:529` TODO),维持。
WSL/CI**永不开窗** —— `EGL_PLATFORM=surfaceless` + `EnsureHeadlessPlatform()``HeadlessGL.cpp:160-196`,它存在正是因为一台带 WSLg `DISPLAY` 的工作站曾把这条 lane 弄挂)。
### 11.5 Windows
`HWND``nativeToken`。Vulkan 可行(`hinstance` 是历史遗留,`VulkanRenderer.cpp:14456-14463`);**WGL/ANGLE-DXGI 对外进程 HWND 不受支持 → headless only**。
transport:默认 named pipeasio `windows::stream_handle`)。**"继承句柄就免掉 accept/connect"这句在 asio 上不能直接照搬(本轮修正)**:`windows::stream_handle` 的 IOCP 服务要求句柄是 **overlapped** 的,而 `CreatePipe` 造的匿名管道不是。所以句柄对必须这样造:用一个 GUID 唯一命名的 `CreateNamedPipeW(..., FILE_FLAG_OVERLAPPED)` 做 server 端,配一次 `CreateFileW(..., FILE_FLAG_OVERLAPPED)` 做 client 端,然后把 server 端句柄设为可继承并 `CreateProcess` 传下去。§11 必须把这套构造写清楚。
asio 1.38.2 在 Win32 上确实定义了 `ASIO_HAS_LOCAL_SOCKETS``3rdparty/asio/asio/include/asio/detail/config.hpp:1085-1092`,只排除 `ASIO_WINDOWS_RUNTIME`,且自带 `sockaddr_un_type``socket_types.hpp:220`),但其 IOCP `async_accept``AcceptEx`AF_UNIX 从不支持它——AF_UNIX-everywhere 是 P6 的**可选简化**,需真编真跑验证,named pipe 是已知可用的默认。
### 11.6 崩溃
- **server 死**client 读到 EOF/EPIPE → device-lost 闩锁:后续 GL 调用变 no-op、`eglSwapBuffers` 返回 `EGL_FALSE`+`EGL_CONTEXT_LOST``glGetGraphicsResetStatus`(若 robustness 分支落地)返回 `GL_UNKNOWN_CONTEXT_RESET``MOBILEGL_IPC_RESPAWN=1` 时重启 + `ResyncSnapshot`(默认关,静默重启会掩盖 bug;且与 `MOBILEGL_IPC_ADOPT_TIER != 2` 互斥,见 §5.8)。
- **client 死**server 读到 EOF → **立即**销毁原生 context 并退出(不等看门狗);`MOBILEGL_IPC_IDLE_EXIT_S`(默认 30)只作为 EOF 都收不到时的最后保险。
---
## 12. Monolith 保留与模式选择
**四层保证,从强到弱:**
1. **编译期折叠。** `MOBILEGL_BUILD_DISAGGREGATED`(默认 **OFF**)关闭时 `MobileGL/MG_Remote/**` 不进 `SOURCE_FILES``MG_Config::Transport``constexpr Monolith``MG_Backend/Init.cpp` 里的分支在编译期消失。**默认构建与今天字节一致。**
2. **唯一 hook 点。** 整个拆分入口是 `MG_Backend/Init.cpp:48-70` 里的一个分支:
```cpp
void Init() {
MGLOG_D("Initializing MobileGL Backend...");
#if MOBILEGL_BUILD_DISAGGREGATED
if (MG_Config::Transport != TransportKind::Monolith) {
pActiveBackendObject = MakeUnique<MG_Remote::BackendObject_Remote>();
} else
#endif
switch (MG_Config::ActiveBackendType) { /* 原样不动 */ }
if (!InitSpecificBackendLibs()) { /* 原样 */ }
LogBackendInfo();
}
```
`BackendObject_Remote::GetBackendFunctions()` 返回发射表,`Initialize()` 负责 spawn/connect。下游 ~250 个边界调用点**零 `#ifdef`**。
3. **P4.5 的 allocator 改动必须同样包裹。** `PipeResource::MapAlignedAllocator``MipmapStorage` 的 level vector 住在 `MG_State`,改它们的 allocator 就改了类型;写成"分配器特化,option OFF 时逐字折叠回今天的 `MapAlignedAllocator`",否则第 4 层会在 P4.5 变红。
4. **机械证明**:对 `libMobileGL.so``nm --defined-only` 与去调试信息后的 `.text` size diff,改前改后必须一致。**这是每个阶段的出口判据(P0…P9),不只是 P0**(上一版只在 P0 跑)。
### 12.1 两个 option,不是一个(本轮重大修正)
上一版说"OFF 时字节一致",但**每一条部署路径都要求出货构建是 ON**:FCL 用户可编辑 env、plugin APK 的 V2 开关表、ctest `ENVIRONMENT` 变体、`/data/local/tmp` CTS 路径。而上一版又说 ON 构建里 `inproc` 会把 `pGLContext` 变成 thread-local 加 `operator->` shim。那个 shim 坐在全库最热的路径上:`grep -rho 'pGLContext->' MobileGL/MG_Impl | wc -l` = **1494**,加 DirectGLES 124、DirectVulkan 169。Android 上 dlopen 的共享库无法可靠使用 initial-exec TLS,每次访问会退化成一次 `__tls_get_addr` 调用,而今天那里只是一次对全局引用的加载(`Core.h:564` `extern UniquePtr<GLState::GLContext>& pGLContext`)。
**规则**:拆成两个 option。
- **`MOBILEGL_BUILD_DISAGGREGATED`**(出货形态):只含 `spawn`/`unix:`/`pipe:`。每进程只有一个 `GLContext`、一份 `gBackendFunctionsTable`、一个 `pActiveBackendObject`、一份 `pDefaultFramebufferInfo` → 这四个**全部保持普通全局**,GL 热路径上没有任何 TLS 与间接。侵入面就是 `MG_Backend/Init.cpp` 里那一个可预测的分支。
- **`MOBILEGL_BUILD_DISAGGREGATED_INPROC`**(CI/调试形态,隐含开启前者):额外加角色隔离 shim。
### 12.2 `inproc` 需要隔离的是**四个**进程全局,不是一个(本轮修正)
上一版只谈了 `pGLContext`。实际上 `inproc` 下同一进程要同时扮演两个角色,以下四个全局都必须按角色分身:
| 全局 | 定义处 | 谁读 |
|---|---|---|
| `MG_State::pGLContext` | `GLState/Core.h:564` 声明,`Core.cpp:1487` 定义,`Core.cpp:20` 构造,`Init.cpp:63` reset | 全部 |
| `MG_Backend::gBackendFunctionsTable` | `MG_Backend/Init.cpp:44` 赋值 | client 侧 MG_Impl91 处)**与 server 侧 MG_Impl**`GL_Texture.cpp:1621` `GenerateMipmap_Backend``:6713-6725` `GetTexImage` 回退链、`FixupGsStripCaptureOrder``CopyReadFramebufferIntoMipmapRegion``ReadPixels` |
| `MG_Backend::pActiveBackendObject` | `MG_Backend/Init.cpp:53-61` 赋值 | MG_Impl 89 处 + backend 内部 |
| `MG_Impl::GLImpl::FramebufferImpl::pDefaultFramebufferInfo` | `GL_Framebuffer.cpp:3344` 定义,全库 22 处引用 | client 侧 MG_Impl 13 处(`GL_Framebuffer.cpp:495,1827,1837,1897,1905,1913,1927,1936,2549,2590,2598,2608,2611`+ server 侧 backend 5 处(`DirectGLES.cpp:1917,2838,2867,9675``SwapchainObject.cpp:276`,其中 `SwapchainObject` 是**写** |
一旦 client 装上发射表,`inproc` 里 applier 与 server 侧 MG_Impl 就没有任何路径能拿到真正的 DirectGLES/DirectVulkan 表;而一个进程也不可能同时持有 client 的 default-FBO 描述与 server 的(`SwapchainObject` 直接往里写 server 的视角)。
**shim 的完整需求**(上一版只提了 `operator->`):`operator->``operator bool``get()``== nullptr` 相等比较、从 `MakeUnique` 赋值、`reset()`。非箭头用法的实际数量是 **133**`grep -rn pGLContext MobileGL/ --include=*.cpp --include=*.h | grep -v 'pGLContext->' | wc -l` = 133,上一版写的"约 65 处"少了一倍),其中 MG_Impl 只有 2 处(`GL_Debug.cpp:99``.get()``GL_Program.cpp:1630``== nullptr`),绝大多数在 MG_Backend——尤其 DirectVulkan 里约 90 处 `MOBILEGL_ASSERT(MG_State::pGLContext, ...)` 的真值判断,另有 `DirectGLES.cpp:146``.get()``Managers.cpp` 里十来处 `if (MG_State::pGLContext)` 守卫。**因为 backend 侧那一簇恰恰是必须看到 replica 的,shim 的原型应当先拿 `MG_Backend/DirectVulkan/DirectVulkan.cpp` 的 assert 密集区开刀。**
**如果这层隔离的成本被判定过高**,退路是把 `inproc` 降级为**纯测试模式**:applier 通过显式传入的表指针工作,server 侧不跑 MG_Impl(于是 `GenerateMipmap_Backend` 那类回退不可用,需要在 `inproc` 下走另一条路径)。但那样 P2.5 就不再测量它本该测量的"monolith 渲染线程"交付物——**这个取舍必须在 P0 结束前拍板并写进文档,不能悬着**。
### 12.3 `inproc` 作为产品交付物
在隔离成本可接受的前提下,`inproc` 不只是测试脚手架:同进程第二个 `GLContext` + `mgl-srv-apply` 线程 = monolith 的渲染线程。今天 `PrepareForDraw`(状态调和、VAO/FBO/纹理/program/render-state sync、UBO ring memcpy)加驱动调用全部同步跑在 `glDrawElements` 里;把它们搬到 apply 线程,对 GL 线程 CPU-bound 的应用(本项目的 profiling 史说 Minecraft 就是)是**手上最大的单一杠杆**,且不需要任何 IPC/shm/平台工作。§15 的 P2.5 就是证伪它的门。
### 12.4 运行时选择与开关
`MOBILEGL_TRANSPORT = monolith(默认) | inproc | spawn | unix:<path> | pipe:<name>`,在 `ConfigLoader.cpp` 与既有开关并列解析。这一个选择免费换来:ctest `ENVIRONMENT` 变体、trace-replay 的 `setenv` 块(`trace_replay_core.cpp:134-207`)、FCL 的用户可编辑 env 偏好(`FCLauncher.java:417-430`)、plugin APK 的 V2 开关表(`android-plugin/app/build.gradle.kts:77-103`,由 `.github/scripts/validate-plugin-apks.sh` 校验)、`/data/local/tmp` CTS 路径。**零新增管线。**
保留全部既有负面对照开关(`MOBILEGL_ESPRYT_DISABLE_{UBO,UNPACK,UPLOAD}_RING``_INVALIDATE_FLUSH``MOBILEGL_DISABLE_LARGE_BUFFER_ADOPTION`),新增:`MOBILEGL_IPC_SHADOW_SHM``MOBILEGL_IPC_ADOPT_TIER``MOBILEGL_IPC_PROGRAM``MOBILEGL_IPC_INLINE_PAYLOADS``MOBILEGL_IPC_PRESENT_CREDIT``MOBILEGL_IPC_SPIN_US``MOBILEGL_IPC_POLL_ESCALATE``MOBILEGL_IPC_PERSISTENT_BLOCK_KB``MOBILEGL_IPC_SERVER_AFFINITY`
---
## 13. 构建布局
```
MobileGL/MG_Remote/
Protocol/ protocol.fbs protocol_generated.h(提交) Records.def RecordKinds.h
Handles.h Coverage.def MutationCoverage.def
generated/BackendStateSurface.inc(提交) generated/ImplMutationSurface.inc(提交)
Transport/ ITransport.h InProcessTransport.{h,cpp} SocketTransport.{h,cpp}
Framing.h Ring.{h,cpp} ShmSegment.{h,cpp} ShmSegmentPosix.cpp ShmSegmentWin32.cpp
FdPassing.{h,cpp} Doorbell.{h,cpp}
Shared/ XfbAccounting.{h,cpp} # client 与 applier 共用的 MG_Impl-side mutation helper
MipmapLevelPlan.{h,cpp}
Client/ WireMirror.{h,cpp} EmitTable.cpp EmitBufferOps.cpp
BackendObject_Remote.{h,cpp} CapsMirror.{h,cpp}
ClientArrayBounds.cpp CompositeResolver.cpp ShadowArena.{h,cpp}
PersistentMapTracker.{h,cpp} GpuWritePending.{h,cpp}
CoverageAssert.cpp Surface/{X11,Win32,Android,Headless}.cpp
Server/ ReplicaContext.{h,cpp} Applier.cpp ServerLoop.{h,cpp}
ReplyPool.{h,cpp} EventRing.{h,cpp} ServerMain.cpp
ServerJni.cpp # Android,与 DriverPostJni.cpp 并列
scripts/ gen_protocol.py gen_backend_state_surface.py gen_impl_mutation_surface.py
MobileGL/MG_Test/Wire/CMakeLists.txt # 复制自 MG_Test/Buffer/27 行)+ MobileGL_Protocol
```
CMake
- `MG_Remote/**` 仅在 `MOBILEGL_BUILD_DISAGGREGATED` 下追加进 `SOURCE_FILES``CMakeLists.txt:226-419`),因此 `MobileGL``:485`)与 `MobileGL_s``:552`)都拿到。
- `MobileGLServer`:桌面 `add_executable` 链接 `MobileGL_s``RUNTIME_OUTPUT_DIRECTORY` 设为 `$<TARGET_FILE_DIR:MobileGL>`(§11.1);**Android** `add_executable` + `set_target_properties(MobileGLServer PROPERTIES PREFIX "lib" SUFFIX ".so" OUTPUT_NAME "MobileGLServer")` 并链接**共享**的 `MobileGL`(一份 ~43MB 的 glslang/SPIRV-Cross/SPIRV-Tools),由 AGP 打进 `jniLibs`。server 主体是 ~30 行 stub`dlopen(libMobileGL.so)``dlsym("mobilegl_server_main")`(可见性见 §11.2)。**一份共享库、两个角色,版本必然匹配**(对比 `Feat/CS-Delta-IPC` 的四件必须互相匹配的产物)。
**AGP 能否打包一个被改名成 `lib*.so` 的 `add_executable`,是 P0 spike 的验证项之一**`MobileGL/build.gradle` 没有设 `targets` 列表,上一版把这条当成已知事实)。
- **FlatBuffers**submodule `3rdparty/flatbuffers` 置于既有的 `if (EXISTS .../flatbuffers/CMakeLists.txt)` 保护下,**去掉 `if (NOT ANDROID)` 一刀切**。因为 `protocol_generated.h` 已提交,**默认构建图里没有 `flatc`,也不 `add_subdirectory(3rdparty/flatbuffers)`**(§7.1)。运行时是 header-only,只需要 `3rdparty/flatbuffers/include` 在 include path 上。
**guard(本轮新增)**:若 `MOBILEGL_BUILD_DISAGGREGATED=ON``3rdparty/flatbuffers/include` 不存在,强制把该 option 设回 OFF 并 `message(WARNING ...)`——否则 `MG_Remote/**` 已经进了 `SOURCE_FILES` 而头文件找不到,构建以一个莫名其妙的错误失败(现有的 `EXISTS` 保护只包住 Protocol 子目录)。
`MOBILEGL_FLATC_EXECUTABLE` 只服务 CI 的 `flatc-check`,经 `MobileGL/build.gradle:17-21` 已在用的 `externalNativeBuild { cmake { arguments } }` 槽传入。
- 测试接线:
- `MG_Test/Wire/`label `unit`)→ 现有 CI `test` job 自动收,**无需改 workflow**。
- `MG_IntegrationTest/CMakeLists.txt` 每 backend 增加一条 `gtest_discover_tests``TEST_PREFIX "DirectGLES.Split."` / `"DirectVulkan.Split."`),**必须用 `mgl_itest_join_environment(... ${MGL_ITEST_COMMON_ENV})` 构造**,并带上 `MOBILEGL_IPC_SERVER_PATH`。三个已被文档记录的陷阱要遵守:ctest `ENVIRONMENT` 是**替换而非追加**`:339-343`)、`;` 必须转义(`:322-332`)、property 覆盖 job env`test.yml:253-262`)。
- **trace replay 的 `SPLIT` 接线(本轮补细节)**`add_trace_replay_test` 今天把测试命名为 `MobileGLTraceReplay.${CASE_NAME}.${BACKEND}``tools/trace_replay/CMakeLists.txt:330-332`),加一个 `SPLIT` 参数会与同 case+backend 的现有测试**重名**。改成 `MobileGLTraceReplay.${CASE_NAME}.${BACKEND}${SPLIT_SUFFIX}`。另外该测试的命令是 `cmake -P run_trace_case.cmake` 加约 18 个 `-DTRACE_*` 变量,所以还要加 `-DTRACE_TRANSPORT=` 并在 `run_trace_case.cmake` 里消费它——**这两个文件都要列进 P2 的交付物**。
- CI 新增三个 step`flatc-check`(重生成 `protocol_generated.h` + `git diff --exit-code`)、`coverage-check`(重生成两个 `.inc` + `git diff --exit-code`)、`monolith-abi-check`OFF 构建与 ON+monolith 构建的 `nm --defined-only` / `.text` size 对基线)。
- **CI 新增一条 grep 门**:禁止 `MG_Backend/``MG_State/` 下出现 `fprintf(stderr` / `printf(`
---
## 14. 对 `Feat/CS-Delta-IPC` 的复用清单
### REUSE(原样取)
| 路径 | commit | 备注 |
|---|---|---|
| `docs/CS_Refactor/HandleSessionGeneration.md` | `546895aa` | 分支上最好的产物。三处修改:handle 清单补 `RenderbufferObject::GetLifetimeId()` **与 `GetVersion()`**;把第 2 节的 server 侧 share-group 要求降为 v2;把"lifetimeId 不符 → 销毁重建"改成 `Fatal`(§5.4 |
| `MobileGL/Protocol/mg_protocol_base.h` | `546895aa` | 干净无依赖的词汇(`MobileGLResult`、span、`ShmRegion`、id typedef、structSize-first 版本纪律) |
| `MobileGL/Protocol/tests/ProtocolSmoke.cpp` | `546895aa` | schema 往返门(默认改 ON |
| 根 `CMakeLists.txt``EXISTS` 保护 + `.gitmodules` 条目 | `546895aa` | 去掉 `NOT ANDROID`,另加 §13 的 include-dir guard |
| `docs/CS_Refactor/HANDOFF.md` 第 6 节"已知坑清单" | `d5c00b9d`/`5964628d` | 逐字留作事后复盘:路径转换、versionCode 降级、双设备 `ANDROID_SERIAL`、flatbuffers camelCase accessor、union vector 产生指针、Release 下 `MGLOG_D` 被编译掉、嵌套 submodule 配方、`assembleTraceDebug` 改名 |
### CHANGE(取走并改造)
| 路径 | commit | 改造 |
|---|---|---|
| `MobileGL/Protocol/protocol.fbs` | `546895aa` | 保留 delta 目录、`RenderStateBlob` 整块思想、`BufferShmAdopt`、命令清单、事件分类学。改:热路径转 `struct` + ring;删掉冗余的 `inlineBytes`/`data` 双胞胎(`:111-112``:125-126`,两半代码对哪个字段是真的意见不一:`ServerCore.cpp:184-208` 只读 `data``StateEmitter.h:60,111` 只写 `inlineBytes`);加 `ResyncSnapshot``AuxRequest`;给 `ProgramPublish.reflection``ObjectCreate.params` 真 schemakind 枚举生成 + 每 kind `static_assert` + 运行期边界检查 |
| `MobileGL/Protocol/CMakeLists.txt` 的 flatc 解析 | `546895aa`/`65717b4c` | **不再照搬**`add_subdirectory(3rdparty/flatbuffers)` 从默认路径整段删除(它就是那个 NDK 陷阱本体);只保留 `MOBILEGL_FLATC_EXECUTABLE` 供 CI`enable_testing()` 移到根 |
| `MobileGL/ServerCore/ServerCore.{h,cpp}` | `65717b4c`+`c2260dd8` | 保留握手→解码→apply→credit 形状与 plugin manifest loader 思路。修:单次校验 + 零拷贝解码(今天校验两次外加一次整体拷贝,`:492-498``:218-221`);io/apply 分线程(`:404-406` 自承 worker 从未落地);完整事件集(`SendEvent` 只实现 `BATCH_APPLIED``:373-382`);credit 用最后一条实际 seq(`:427``baseSeq + items.size()`);接收缓冲不能是对着 64MiB 帧上限的固定 4MiB(`:478`);真正的段生命周期(`m_segments` 只增不减,`blobOwners` 只 push 不释放) |
| `ServerCore/tests/LoopbackSmoke.cpp` + `Backends/Dummy/` | `65717b4c` | 分支上最便宜的端到端门,**第一个重建**,重定向到真 applier |
| `MobileGL/Remote/InProcessTransport.h` | `65717b4c` | 重表述在 C++ `ITransport` 上;单侧 shutdown(今天 `:89-92` 连对端 inbox 一起关);真段生命周期(`Unmap`/`Close` 今天是 no-op);补 §6.2a 的双向 doorbellcondvar 版) |
| `MobileGL/Remote/Framing.h` | `65717b4c` | 保留帧格式;`m_pendingSize`/`m_haveHeader``mutable`(今天 `const_cast``:81,85`);`Feed()` 真校验 magic 与长度(今天永远返回 OK,坏 magic = 静默永久挂起);缓冲不足返回所需大小且**保留消息**;真正在 socket transport 里使用它(今天是死代码) |
| `MobileGL/RemoteClient/StateEmitter.h:39-307`**仅 emit 半边** | `b50f3348`+`d96be9f3` | 各域字段遍历是真知识,抬进 `WireMirror`/`ResyncSnapshot`。GL name 换 `lifetimeId`(今天 `:48-49,85,166-168,203,230` 全把 GL name 塞进 `handle`);`:175-181,:244-249,:253-258,:293-298` 的 O(n²) 线性扫描换 handle map;固定 6 attachment`:232-236`)换 `MaxColorAttachments`;补上被跳过的 texture view`:70-74`)。**不取 applier 半边(`:312-501`** |
| `scripts/extract_backend_read_inventory.py` | `546895aa` | 改造成 `gen_backend_state_surface.py`:删掉前缀兜底(`:234-241`),未知 accessor 一律 UNMAPPED 并**编译失败**;把真 pull point 与 signature handle 化分开统计。**另写一个全新的 `gen_impl_mutation_surface.py`**(§5.9b),它在原分支没有对应物 |
### DROP
| 路径 | 理由 |
|---|---|
| `MobileGL/Protocol/bfa.h`480 行) | "strict C ABI"不是 C ABI`ServerCore.cpp:177-179` 把 FlatBuffers 生成表的指针交给插件,插件必须是 C++ 且链接 FlatBuffers`StateEmitter.h:330,351,362,372` 就是这么用的)。手抄的 60 字段 `MobileGLDynamicParameters``:63-129`)自承尾部不全、同步脚本从未写过——正是已在本项目造成 481 例 CTS 失败簇的那类数据的**长期静默漂移炸弹**。而本设计根本不需要 delta-apply vtable |
| `MobileGL/Protocol/mgruntime_api.h` + `MobileGL/UtilRuntime/*` | 360 行契约对 ~50 行实现(8 域实现 2 域);唯一消费者传 `nullptr``ServerCore.cpp:61`);缓存每次命中整份拷贝(`:79`)、按 `clear()` 淘汰(`:91-93`);smoke 断言 `api->metrics == nullptr``RuntimeApiSmoke.cpp:66`)。它的唯一理由随 BFA 消失;且本设计里翻译全在 server(它无论如何要链 SPIRV-Cross),glslang 全在 client |
| `MobileGL/Remote/LocalSocketTransport.{h,cpp}``ShmFactory.{h,cpp}` 实现 | 从未被任何测试执行(`LoopbackSmoke` 用的是 `InProcessTransport`,唯一另一个消费者 `ServerHost` 编译不过);每次 send 都 use-after-free`:199``asio::buffer(next)` 指向局部 vector 而 lambda 捕获的是另一份拷贝);按 wire 长度无上限分配(`:232-236`);`Start` 里阻塞 accept/connect`:116``:139-144`);无 strand 且 `framesSent++` 非原子(`:177-178`);**且完全没有 POSIX fd 传递**`:296` 硬编码 `fd=-1`),Linux/Android 数据面一字节过不去。只保留 `ShmFactory.h:4-12` 作平台矩阵规格 |
| `MobileGL/ServerHost/main.cpp` | 编译不过(`:31,39,44,53-54` 对指针用 `.``c2260dd8` 改返回类型后成为死码)。`MobileGLServer` 在默认 ALL target 里,**分支 tip 无法完成一次完整构建** |
| `MobileGL/RemoteClient/tests/StateEquivalenceTest.cpp` | 把 delta apply 进第二个 `MG_State::GLContext`——验证的是它自己的 thin-server 前提说不该存在的数据路径;与生产 apply 路径零共享代码;只测全量 resync;`d96be9f3` 声称五域逐字段而文件只比了纹理、buffer、render-state blob、buffer binding slot(没有 VAO 属性/FBO attachment/RBO 格式比较) |
| `c7c9e346` + `29d721ef` 全部(share-group sessioning | 非 v1 前提(monolith 只有一个 `GLContext``GLState/Core.cpp:20,1487`);且非可合并质量:`VertexArrayState.cpp:+20-26` 往已共享的表里再压一个 default VAO 并重复 `Insert(0)`;四个头文件 `public:` 未复位泄漏私有成员;current session 是无锁进程全局,连它自己的 per-thread current 都没兑现;在状态权威里塞 `MOBILEGL_SESSION_SWAP` env kill switch 与 `s_defaultAdopted` 偷 context 的 hack。日后作为独立 PR 带多 context 测试落 `dev` |
| `b50f3348``RenderState::InstallParameters` + `public:` | 本设计不需要 Install setter(D3);若日后需要整块安装,用正确作用域的方法或单条 friend,绝不靠裸 `public:` |
| `d96be9f3` 的 TRIAGE 指令(`DirectGLES.cpp:+2583-2590` | per-draw `fprintf(stderr)`。**分支上每一次测量都跑在它上面。** 同规则适用于当前工作树的 `[IBOTX]`/`[BUFTX]`P0 清除) |
---
## 15. 分阶段实施计划
> 通用纪律(每个 commit 都适用):默认 ALL target 必须能完整构建;禁止提交热路径插桩;每个门必须**能因它存在的理由变红**;**Windows 机器不是正确性门**(其 Vulkan 缺 `vkCreateHeadlessSurfaceEXT`,占该机 567 个基线集成失败中的 423 个);设备对比走 reboot-clean + 同窗口配对 A/B;**每个阶段的出口都跑一次 §12 第 4 层的 `nm`/`.text` monolith 门**(不只是 P0)。
### P0 — 卫生、骨架与两个 spike(5 天)
**交付物**
- 清除工作树 `[IBOTX]`/`[BUFTX]` fprintf`DirectGLES.cpp:640-663``Managers.cpp:875-877`,后者在 `pendingMutex` 临界区内)。
- `RenderbufferObject::GetLifetimeId()` **与 `GetVersion()`**(§5.4)。
- 两个 CMake option`MOBILEGL_BUILD_DISAGGREGATED`(OFF) 与 `MOBILEGL_BUILD_DISAGGREGATED_INPROC`(OFF)`MOBILEGL_TRANSPORT` 解析;§13 的 flatbuffers include-dir guard。
- `MG_Remote/{Protocol,Transport}` 骨架:`ITransport``InProcessTransport`、校验型 `Framing``Ring` + `RingControl`(**双 tail、双游标三元组、双向 doorbell**)、`Doorbell``ShmSegment`memfd/ASharedMemory/shm_open/CreateFileMappingW)、**`SCM_RIGHTS` fd 传递(第一优先)**。
- `protocol.fbs` + 提交的 `protocol_generated.h` + `gen_protocol.py` + CI `flatc-check``Records.def``static_assert` 与**运行期边界检查**生成。
- `gen_backend_state_surface.py` + `Coverage.def` **和** `gen_impl_mutation_surface.py` + `MutationCoverage.def` + `CoverageAssert.cpp` + CI `coverage-check`
- `MG_Test/Wire/` 目录(复制 `MG_Test/Buffer/CMakeLists.txt`)。
- **`TracyPlot` 字节计数器**,装在 wire **两侧**,按类别分:`cmd-records``stage-buffer``stage-texture``stage-ubo``persistent-map-push``server-ring``server-staging`(树里今天完全没有 per-frame 字节度量:`MG_Util/Metrics` 只是格式算术,Tracy 只有 zone 无 plotMC 26.3 战役的 PANDIAG 已不在树里)。
- `mobilegl_server_main``extern "C" __attribute__((visibility("default")))` 声明(§11.2)。
- **spike AAndroid 交付链,半天)**:从根 CMakeLists 造一个平凡的 `libMobileGLServer.so``add_executable` + `PREFIX "lib"/SUFFIX ".so"`),确认 AGP 把它打进 `lib/arm64-v8a/`;让 `TraceReplayActivity``getApplicationInfo().nativeLibraryDir` **`posix_spawn`** 它并打一行日志——在**应用自身进程(`untrusted_app` 域)**验证 exec,而不是靠 `run-as`。同时把一个通用 env 透传(`--es mobilegl_env "K=V;K=V"`)接进 trace 路径的五个文件(`trace-replay-ci.sh``TraceReplayActivity.java`、JNI Request marshalling、`trace_replay_core.cpp``run_android_retrace_local.py`),取代逐 knob 加 `--es/--ez`
- **spike Bexternal memory 可行性,半天)**:最小程序,导出一个 `HOST_VISIBLE|HOST_COHERENT` VkBuffer 的 fd`mmap` 后回读校验,在 `35d0befa`Adreno 830)与 `3B159D009VZ00000`Mali)各跑一次。与 `SCM_RIGHTS` 测试同批。**目的是让 P7 的结论在第一周就有方向**:若两台都不行,P7 缩为"记录并回退",省 6 天。
**验收**
- Linux 与 Android/NDK 上 `cmake --build .` 默认 target 成功。
- `ctest -L unit``-L integration-gpu``81b17c0b` 同一通过集。
- `MG_Test/Wire` 的 fd 传递测试把一个 memfd 从 fork 出的子进程传回父进程并读到相同字节。
- **`nm --defined-only` 与去符号 `.text` size 与改动前的 `libMobileGL.so` 一致**OFF 构建);`nm -D | grep mobilegl_server_main` 在 RelWithDebInfo 下命中。
- spike A:设备上打出那行日志。
- spike B:结论写进 §17 的开放问题并驱动 P7 的排期。
- **§12.2 的取舍拍板**`inproc` 走"四全局角色隔离"还是"降级为纯测试模式",写进文档。
### P1a — 垂直切片(client + inproc applier),Linux 门(6 天)
**范围刻意收窄到 OpenRA 需要的东西**:仅 DirectGLESbuffer(仅 shadow,采纳强制关,**含 §5.10 的 persistent-map 推送**);2D 纹理的整 level 与 union-box 上传(**含 §5.6a 的 clear-on-emit**);VAOFBOrender statebinds;索引与非索引 drawclearpresent**server 从源码 relink**(带全字段 `reflectionDigest`);一条阻塞 `ReadPixels`**client 侧 `MarkGpuWritten` 保守置位(§5.6b**。不含 sync/query/XFB/compute/dirty-rects/MultiDraw。
**交付物**`WireMirror`(含 `PublishImplicitState``PersistentMapTracker``GpuWritePending`)、`EmitTable``EmitBufferOps``BackendObject_Remote``CapsMirror``ClientArrayBounds``CompositeResolver`P1-4 走"server 自建 composite + digest 校验",见 §5.7);`ReplicaContext``Applier`(含 `MG_Remote::Shared::` 的 XFB/mipmap helper 接线,即使这一阶段还用不到 XFB)、`ServerLoop`(io+apply)`InProcessTransport` 上跑通。
**验收**
1. `ctest -R "DirectGLES\.Split\..*(ClearThenReadPixels|Triangle)"` 在 Linux + `MOBILEGL_TRANSPORT=inproc` 绿。
2. **新增 `PersistentCoherentMapScenario`**map `PERSISTENT|WRITE|COHERENT`、写、不做任何其它 GL 调用、draw、readback 校验)在 split 下绿。**这是本计划里唯一一个专为一个 fatal 缺陷设的门**,必须在 P1a 就绿。
3. 记录**两个进程/两个角色的峰值 RSS**(不只是 server 的)作为 P5 与 §16-R14 的基线。
4. Tracy 计数器给出 `persistent-map-push` 的字节量(§5.10 保守版的代价)。
**明确非目标**:性能。P1-4 双份 glslang**MC 级负载不在此测**。
### P1b — spawn transportLinux 门(4 天)
**交付物**`SocketTransport`socketpair + `fork`/`execve` + 显式 envp 剔除 `MOBILEGL_TRANSPORT`/`MOBILEGL_IPC_*`)、`ServerMain``MOBILEGL_IPC_SERVER_PATH` 发现链、就绪握手与有界重试、EOF 即时退出。
**验收**
1. P1a 的全部测试在 `MOBILEGL_TRANSPORT=spawn` 下绿(两个真进程、真 socket、真 `SCM_RIGHTS` 段)。
2. **fork 链测试**spawn 一个 server 并断言进程树只多出恰好一个子进程(§11.1)。
3. **HeadlessGL 预检交互测试**:在开着 fork 预检的 Linux 上跑整套 split 集成用例,断言没有孤儿 server(用 `pgrep` 计数 + 预检结束后 100ms 内归零)。
### P2 — 广度:集成套件、trace 语料对齐、设备首跑(9 天)
**交付物**
- 其余记录种类(MultiDraw/indirect 族含 client 数组范围计算与索引扫描、纹理 dirty rects、texture view、buffer texture、image unit、sampler、**renderbuffer storage**、program pipeline、`CopyImageSubData``BlitNamedFramebuffer``PixelStorePack``CurrentAttrib`)。
- 完整 caps mirror 与 `tableSlotMask`
- DirectVulkan applier 支持(`SwapchainObject` 的 default-FBO 占位写变成 `EvDefaultFramebufferInfo`)。
- `add_trace_replay_test``SPLIT` 参数:测试名加后缀、`-DTRACE_TRANSPORT=``run_trace_case.cmake` 的消费、`MOBILEGL_IPC_SERVER_PATH` 注入(§13)。
- 一个**无 present** 的 split 集成用例(§9.3)。
- `ClientArrayAfterComputeWriteScenario`(§6.10)。
**验收**
1. `ctest -L integration-gpu -R '^DirectGLES\.Split\.'``'^DirectGLES\.'` **逐名同一通过/失败集**DirectVulkan 同。
2. CI 全部 trace caseOpenRA、`minecraft-1.21.4-startup``-main-menu``1.21.11``1.17`、两个 Create)在 Linux split 模式 SSIM ≥ 0.99。**两个带 `coherent_as_flush: true` 的 Create 用例在 split 与 monolith 下都开着该开关跑**(§5.10 已让两侧走同一路径),若 Tracy 显示保守推送在这两个 fixture 上代价不可接受,则把 §5.10 的精确版(P4.5 的块脏位)提前到本阶段——这是全计划唯一允许因测量改变阶段顺序的地方。
3. **`python tools/trace_replay/run_android_retrace_local.py --case OpenRA --backend DirectGLES``35d0befa` 上 SSIM ≥ 0.99split 模式)** —— 本阶段的出口判据(从 P1 移来),每轮约 1 分钟。
### P2.5 — inproc 渲染线程:单机收益证伪门(3 天)
**交付物**`add_trace_replay_test``INPROC` 变体;应用线程与 apply 线程的**逐线程 CPU 时间**插桩(不只是墙钟);`MOBILEGL_IPC_SERVER_AFFINITY` 的大核绑定(复用 `ShaderCompilePool.cpp:73-96`);用现有 `--benchmark --benchmark-tail-frames --benchmark-result` 在全部 fixture 上跑。
**验收**`inproc``monolith` 的应用线程帧时差 + 两侧 CPU 时间在 Create/Flywheel 与 MC fixture 上被**测量并记录**,且带亲和性开/关两组。若不利,整个计划的价值主张在第 6 周(而不是第 15 周)被重新审视。**这是本计划最早的证伪点,也是 §16-R15 排期风险的退火器。**
### P3 — sync / query / present 节奏(5 天)
**交付物**
- client 铸造的 sync/query handle;轮询入口的 publish + 饥饿升级(§7.2)。
- **fence 完成度来自真实逐 fence 退休**(§8 末尾):server 侧真 `FenceSync` + 非 present 轮询 + `EvFenceSignaled`
- **DirectGLES 的非 present fence tick**(§9.3)。
- `EvQueryResult`present credit **默认 1** + 三个 seq 水位;swap interval 搭 `RecPresent`
- §8 的三个 `dev` 独立修复。
- per-frame round-trip 计数器;**输入延迟直方图**(记录发射 → present 完成,§9.1)。
**验收**
1. `XfbPrimitiveQueryScenario``PrimitivesGeneratedNoXfbScenario``AsyncCompileScenario` 在 split 下绿。
2. round-trip 计数器:在**全部 trace case** 的稳态帧上,draw/state/upload 路径的 round trip 读 **0**conditional render 与阻塞式 query 的次数按用例列表公布(不是笼统宣称"零 round trip")。
3. **零 timeout 轮询循环测试**:一个只有 `glFenceSync` + `while(glClientWaitSync(...,0)==GL_TIMEOUT_EXPIRED){}` 的用例必须在有界时间内退出(若无 §7.2 的 publish 规则它会永久挂起)。
4. `bench.sh``35d0befa` 配对 A/B(两侧均关采纳)显示 split 帧时在 monolith 的 10% 内,且**输入延迟直方图**的 p50/p99 被记录。
### P4 — 回读与 GPU-written5 天)
**交付物**`SEG_REPLY`;阻塞 `ReadPixels` → 客户内存;PBO readback 变 fire-and-forget + client 侧 `MarkGpuWritten``EvGpuWritten` 作为收窄提示;`EvBufferWriteback``glGetTexImage`/`GetTextureImage` 路由 + **per-level `serverAuthoritative` 位**(只覆盖生成 mip 与 CopyImage 镜像两处,§6.6);`EvGlError` + **分配类入口的 `kNeedsAck`**(§5.6c);`SEG_EVENT` 溢出策略与等待中排空(§7.4)。
**`glCopyTexSubImage*` / `glClearTexImage` 保持前端实现不变**(推翻上一版的"移到 server + `EvTexWriteback`")。
**验收**
1. split 下 `DepthStencilReadbackScenario``DepthStencilReadbackMatrixScenario``DepthStencilReadbackAttachmentShapeScenario``PackedWordReadbackScenario``LayeredTextureReadbackScenario``ClearThenReadPixelsScenario``PixelStoreSweepScenario``CopyImage*`(4)、`SsboArrayLengthScenario``AtomicCounterScenario``StorageBufferRegrowScenario` 双 backend 全绿。
2. **OOM 探测用例**:请求一个必然失败的巨大 renderbuffer,断言紧接着的 `glGetError()` 返回 `GL_OUT_OF_MEMORY`
3. **事件 ring 溢出故障注入**client 被 present credit 阻塞时灌满 `SEG_EVENT`,双方都不死锁,`eventDropped` 只统计到 `EvLogLine`
### P4.5 — 零拷贝 shadow-in-shm 前移(4 天)
(原计划推到 P6;MC pan 每帧 ~9MB 的额外拷贝不该背六个阶段)
**交付物**`ShadowArena``MapAlignedAllocator``MipmapStorage` level vector 的 shm arena(≥256KiB 才走,**整段 `#if MOBILEGL_BUILD_DISAGGREGATED` 包裹**,§12 第 3 层);per-shadow 64KiB 块发送水位 WAR 规则;**shadow 块退休规则**(§6.1);§5.10 精确版 persistent-map 推送复用同一套块脏位;`MOBILEGL_IPC_SHADOW_SHM` 开关。
**验收**
1. P2/P4 门在开关两态下均不回归。
2. **两侧** `TracyPlot` 显示 buffer 上传路径的总拷贝次数从 4 降到 3(或选方案 B 则到 2,§6.4);staged-copy 回退率被记录成数字。
3. `nm`/`.text` monolith 门仍绿(这一条是本阶段最容易破的)。
4. 对象删除/重定义与未 apply 记录并发的压力测试不读到别的对象的字节。
### P5 — `ProgramPublish`,退役 server relink6 天)
**交付物**`ProgramArtifactsArchive.h``Visit()` + `sizeof` 绊线);`ProgramObject::InstallPublishedLink``GLContext::SetReplicaResolvedDrawProgram``#if MOBILEGL_BUILD_DISAGGREGATED` 包裹)+ client 侧 composite 解析;`MOBILEGL_IPC_PROGRAM=publish|relink``publish` 下移除 server compile pool**顺带把 DirectVulkan 的 blit / depth-mipmap 四段固定 shader 在构建期烘成 SPIR-V**`VulkanRenderer.cpp:4211-4356`,同时也从 **monolith 启动**里去掉一次 glslang 编译链接;逃生口 `MOBILEGL_BAKED_INTERNAL_SHADERS=0`)。
**验收**
1. P2 全门在 `publish` 下重跑不变。
2. `relink``reflectionDigest` 在每个 trace case 绿(即它是活门不是死门)。
3. `35d0befa` 上用 `minecraft-1.21.4-startup` trace 做首帧 link 延迟 A/B`publish ≤ relink`
4. **`nm` 复核 `libMobileGLServer.so``publish` 下不再引用 glslang 库符号**(注意 `ProgramObject.h` 传递包含 `ShaderObject.h``ShaderCompileTask.h`,所以这条必须**用 `nm` 验证而不是断言**)。
5. server 峰值 RSS 相对 P1a 基线下降;两个进程的 RSS 合计与 §16-R14 的预算对表。
### P6 — 数据面性能(6 天)
**交付物**`PendingResidentWrite` 借用 ring slot(用 `*RetiredTail` 门控);全局 UBO ring 进 shm;解码移到 `mgl-srv-io`;可选 `mgl-client-tx`bind 合并(凭数据决定);`mirror-map` 两次映射消除 ring wrap`MOBILEGL_IPC_INLINE_PAYLOADS` 负面对照;§6.4 方案 Breplica adopt client shadow)的可行性评估与实现(若 Tracy 数据支持);Windows AF_UNIX 评估(§11.5);`MOBILEGL_IPC_SPIN_US``MOBILEGL_IPC_PRESENT_CREDIT` 的设备调优。
**验收**:两台设备上 `minecraft-1.21.4-fabric-sodium-in-world` 的配对 A/B,每项优化用自己的开关单独可 A/B;P2/P4 门在任意开关组合下不回归;输入延迟直方图不因任何优化恶化。
### P7 — persistent map 与 ≥16MiB 采纳(8 天,若 P0 spike B 全否则缩为 2 天)
**交付物**`SEG_ADOPT`(server 分配)+ 三档探针(T2/T1/T0)+ 自动回退到 P4.5 路径;阻塞 `AcquirePersistentMap`client 侧注册 `ResidentSubData``MOBILEGL_IPC_RESPAWN``MOBILEGL_IPC_ADOPT_TIER` 的互斥检查(§5.8)。
**验收**`LargeArenaAdoptionScenario``ResidentIndexScenario` 在采纳开启下绿;`bench.sh` 在 Mali 设备 `3B159D009VZ00000` 上用 `minecraft-1.21.4-in-world` 报出 {monolith, split+采纳, split+回退} 的 p99 帧时,以 MC 26.3 的 163→21ms 为标尺。
**"设备 X 上拒绝,已记录,回退成本 N ms" 是本阶段的可接受结论**——因为回退路径在 P1a/P4.5 已交付并测量。
### P8 — XFB / compute / 健壮性 / 多线程(6 天)
**交付物**XFB capture writeback 与 scatter(全部在 server 对 replica 执行,只有合并后的 range 过线);**`RecXfbAccounting` 与共享 helper 的完整接线**(§2(g)-2;注意它必须跟着 `RecBindTransformFeedback` 的对象切换走,`Core.cpp:1273,1296`);GS strip 顺序修正移到 servercompute dispatch/indirect/barrier/image load-store`EvGlError``glGetError` 的顺序 + `MOBILEGL_IPC_STRICT_ERRORS` 诊断开关;server 死亡的 device-lost 闩锁与 client 死亡的 server 拆机;外来线程 sync/query 的 `AuxRequest`;修 `EGLOperationMutex` 既有漏洞(`ReleaseThread``SwapInterval`)。
**验收**
1. split 下 `Xfb*`(5)、`Tessellation*`(2)、`SsboArrayDynamicIndexScenario``ImageLoadStoreSsoScenario` 双 backend 绿。
2. `tools/cts/scripts/run_cts_local.py --backend {DirectGLES,DirectVulkan} --env MOBILEGL_TRANSPORT=spawn` 在 GL33 caselist 上 conformance rate 与 monolith 相差 ≤ 0.5 个百分点(按项目既定的逐 backend 表格式报告:行=GL 版本/扩展,列=状态计数,conformance rate = Pass/(Pass+Fail),分母不含 NS)。
3. 故障注入测试在帧中 SIGKILL serverclient 干净地以 `EGL_CONTEXT_LOST` 退出而不崩溃。
**注**XFB 场景的 `RecXfbAccounting` 骨架其实在 P1a 就要落地(helper + 记录 + applier 分支),只是这里才被真正测到。§5.9b 的生成器会在 P0 就把它标成未映射并让编译失败,从而强制这个顺序。
### P9 — Android 生产窗口路径(10 天)
**交付物**`MobileGLServerService``android:process=":mgl"`);Messenger/AIDL 的 `Surface` 交接;surface 生命周期(`surfaceDestroyed`、1×1 pbuffer 交换舞、resize)作为协议消息;`ResyncSnapshot`APK 打包与 `validate-plugin-apks.sh` 更新;`MOBILEGL_TRANSPORT` 进 plugin V2 metadata 与 FCL 用户 env 偏好。
**验收**FCL 在 `35d0befa` 上以 split 模式把 Minecraft 1.21.4 拉到主菜单并进入世界;`bench.sh` 在同一个热窗口内报出 split vs monolith 的游戏内 FPS **与输入延迟**plugin APK 通过 `.github/scripts/validate-plugin-apks.sh`;旋屏/后台切换的 surface 销毁重建无泄漏无挂起;两个进程的合计 RSS 落在预算内。
**合计 ≈ 77 人日 ≈ 16 周**5+6+4+9+3+5+5+4+6+6+8+6+10)。里程碑:**第 3 周末 Linux 上跨进程渲染出第一帧**(P1b),**第 5 周末真机 OpenRA 绿**P2),**第 6 周有 monolith 侧的独立收益数字**P2.5)。
---
## 16. 风险与对策
| # | 风险 | 对策 |
|---|---|---|
| R1 | **replica applier 在某个长尾副作用上与 client 的 MG_State 语义分歧**——具体形态是 MG_Impl 在 table 调用旁做的 mutation(§2(g) 已确认两族:`EnsureGeneratedMipmapStorageAllocated``AccountTransformFeedbackPrimitives`)。症状是错误像素或错误查询结果,不是崩溃 | **§5.9b 的第二个生成器**把这一面变成编译期门:MG_Impl 里任何与 table 调用同函数的 mutator 未映射即 `#error`。两族已知实例在 P1a 就用共享 helper 接线。P2 的门是**全部集成场景 + trace 语料的逐名通过集对齐**,远比 `Feat/CS-Delta-IPC` 的两 `GLContext` 逐字段比较(且只查了 5 域中的 2 域)严苛。外加 `MOBILEGL_IPC_VALIDATE_SERVER`server 侧保留 MG_Impl 校验器,分歧变成 server 侧 GL error 而非错误像素;CI 常开,出货构建用 `kPrevalidated` 短路) |
| R2 | **应用通过 coherent persistent map 写下的字节丢失**`SyncPersistentMappedRange` 无 client 侧调用者) | §5.10 三件套:map/unmap 上线、client 侧块粒度推送、`PersistentCoherentMapScenario` 作为 **P1a 门**。这是本轮新增的最高优先级修复 |
| R3 | **read-after-GPU-write 静默读到陈旧 shadow**`MarkGpuWritten` 无 client 侧建立者) | §5.6bclient 在每个 draw/dispatch 发射点保守置位并记 `emitSeq`;读入口强制 publish+等待+排空;`EvGpuWritten` 降级为收窄提示。§7.4 的排空点补上四个 buffer 读入口 |
| R4 | **零 timeout 轮询循环挂死**(轮询入口不是 publish 触发器) | §7.2`glClientWaitSync`/`glGetSynciv`/`glGetQueryObject*(AVAILABLE\|NO_WAIT)` 全部成为门铃点,`GL_SYNC_FLUSH_COMMANDS_BIT` 无条件 publish;连续 N 次无进展升级为阻塞 round trip。P3 有专门的门 |
| R5 | **fence 完成度退化成帧计数推断**DirectGLES 的 `completedFrameSerial` 只在 Present 前进),重蹈 MC 1.21.5 的 native-heap OOM | §8 末尾:server 侧真 fence + 非 present 轮询 + `EvFenceSignaled`;§9.3 的非 present fence tick 同时解决无 present 循环下的 ring 饥饿 |
| R6 | **纹理每次更新都传整 level**(永不清 dirty flag ⇒ union box 单调增长) | §5.6aclient 在发射后立刻 `MarkStorageDirty(...,false)`ack 问题由"resync 从完好 shadow 传整 level"+"硬 drain 后重发未 apply 记录"两条收口。已确认 MG_Impl 从不读自己的 dirty 状态,所以清是安全的 |
| R7 | **每 draw 编解码成本超过它替换掉的东西**MC 级帧(1000-4000 draw)反而更慢;且总 CPU 工作量本来就变大(遍历跑两次) | 记录是 FlatBuffers `struct`8B header + 定长),无 verifier walkpublish 是每记录一次 release store 而不是 64KiB 攒批(§7.2)。**`TracyPlot` 两侧计数器在 P0 就落地**;P2.5 在第 6 周给出 inproc 的证伪数字**并带逐线程 CPU 时间**;`mgl-srv-apply` 绑大核(§10),mask 打日志;P3 门要求 split 帧时在 monolith 10% 内**才**授权后续优化 |
| R8 | **client 侧等待全是跨进程自旋**(无 producer 侧门铃),手机上一颗大核满频空转 | §6.2a 的双向 doorbell`producerParked` + 反向 1 字节;自旋窗口 `MOBILEGL_IPC_SPIN_US` 可调可测。`inproc` 用 condvar |
| R9 | **`SEG_EVENT` 满 + client 被 credit 阻塞 = 双向死锁** | §7.4:等待循环内必须排空;`EvLogLine` 有损(覆盖最旧 + `eventDropped` 计数);语义事件无损,满时 server 置 `eventRingFull` 并停在记录边界上停止 apply。P4 有故障注入门 |
| R10 | **端到端延迟叠加**client credit + server FIF + 驱动深度 = 4-5 帧) | §9.1:credit 默认 1;文档写出叠加公式;P3/P9 增加**输入延迟直方图**门,只有实测吞吐收益抵得过实测延迟才调高 |
| R11 | **`inproc` 因为四个进程全局而不可行**,从而 P2.5 这个最早的证伪门消失 | §12.1/§12.2:拆成两个 CMake option(出货只开 `spawn`,热路径无 TLS);四个全局都要角色隔离,shim 需求列全,非箭头用法实测 133 处;**P0 结束前必须拍板**是做隔离还是把 `inproc` 降级为纯测试模式,并写清后者对 P2.5 的含义 |
| R12 | **分配类 GL 错误晚到,OOM 探测惯用法失效** | §5.6c:只把分配类入口标 `kNeedsAck`(罕见且本来就贵),其余保持晚到;`glGetError` 永远本地。P4 有 OOM 探测门 |
| R13 | **server 分配的 host-visible coherent 内存无法导出重映射**,丢掉 ≥16MiB 采纳(值 p99 163→21ms、~400MB RSS | 排在**最后**P7),且 **P0 的 spike B 在第一周就给出方向**。此时 P1a/P4.5 的 shadow 路径已交付并测量。阶段明确允许"拒绝,已记录"的结论。前端已容忍 `nullptr`(三处),kill switch 已存在,无需回滚任何代码 |
| R14 | **内存翻倍无预算**client 段(`SEG_CMD` 8MiB + `SEG_STAGE` 32MiB↑)+ 完整 replica context(每 buffer 一份 `PipeResource`、每 texture level 一份 `MipmapStorage`+ server 自己的三个 ringUBO/unpack/upload 各 4→64MiB`Managers.cpp:82-96`+ 64MiB buffer pool`Managers.cpp:566`)。合计可达 ~450MiB 新增,而本项目把"省 400MB"当作采纳修复的头条成果,且有 blanket-immutable 导致 LMK 屠杀的记忆 | 计划里与 round-trip 预算并列写出**稳态内存预算**;P1a 验收记录**两个进程**的 RSS(不只是 server);`SEG_STAGE` 上限由实测定而不是默认 256MiB;优先推进 §6.4 方案 Breplica 采纳 client shadow),因为它同时消掉重复 shadow 而不只是一次拷贝 |
| R15 | **排期乐观**P0 5 天含两个 spike + 四平台 shm + SCM_RIGHTS + 两个代码生成器;P1a+P1b 10 天做完整 client 与 server)。校准点:`Feat/CS-Delta-IPC` 10 个 commit / 6668 行、从未渲出一帧,并自承四天耗在一个不可复现的回归上 | P1 已拆成 P1a/P1b,设备 retrace 移到 P2 出口;**P2.5 是排期风险的退火器**——第 6 周就能拿到"这条路值不值得走"的数字,且它本身不依赖任何跨进程工作。若 P0/P1 超期 50%,先跑 P2.5 的 inproc 部分再决定是否继续 |
| R16 | **socket transport 是新实现**,而上一版有每次 send 的 UAF、无上限分配、无 fd 传递 | 从设计草图重写而非修补:读时按 64MiB 上限校验 magic/长度;接收缓冲不足时返回所需大小**且保留消息**;`async_write``shared_ptr` payload 自持缓冲;socketpair + 继承 fd 完全去掉 accept/connectWindows 用 overlapped named pipe 对,§11.5)。**`SCM_RIGHTS` 是 P0 交付物并带独立测试** |
| R17 | **Android 交付链**server `.so` 打包、`untrusted_app` 域 exec、trace app env 透传)比想象的重,或被 AGP/SELinux 挡住 | **P0 的 spike A** 在第一周就验证;P1-P8 全部离屏且不依赖它(Linux 门优先);两条回退:裸 exec PIE server 配 `AHardwareBuffer_sendHandleToUnixSocket` blit-back;或把 split 作为 headless/工装专用配置发布 |
| R18 | **spawn 出来的 server 继承 `MOBILEGL_TRANSPORT` 而无限 fork** | §11.1 双保险:显式 envp 剔除 + `mobilegl_server_main` 强制 MonolithP1b 有进程树计数门 |
| R19 | **HeadlessGL 的 fork 预检留下持有 GPU 的孤儿 server** | §11.3:EOF 即时退出(亚秒);就绪握手有界重试;P1b 有 `pgrep` 计数门 |
| R20 | **`MobileGLServer` 在两个桌面门里都找不到**`dladdr` 对静态链接的 itest 与显式 `-DMOBILEGL_LIBRARY` 的 retrace 都失效) | §11.1`MOBILEGL_IPC_SERVER_PATH` 为主、`dladdr` 兜底;`RUNTIME_OUTPUT_DIRECTORY` 对齐;每条新 ctest `ENVIRONMENT` 都注入;并复核 CI artifact 搬运后绝对路径是否还成立 |
| R21 | **`mobilegl_server_main` 在出货构建里 dlsym 不到**(非 Debug 的 hidden visibility preset | §11.2:显式 `visibility("default")`P0 加 `nm -D` 断言 |
| R22 | **Magma 的 present 节奏被 IPC credit 改变**(它从不注册 `SetSwapInterval` 且偏好 MAILBOX/IMMEDIATE | `MOBILEGL_IPC_PRESENT_CREDIT` 可配;P6/P9 在设备上测量输入延迟与帧节奏;若 Magma 需要,把"注册 `SetSwapInterval` 并映射到 FIFO"作为**独立的 `dev` 变更**,不让两套机制同时管节奏 |
| R23 | **两件 Android 产物版本漂移** | 一份共享库两个角色:server 是 ~30 行 stub`dlopen(libMobileGL.so)` + `dlsym(mobilegl_server_main)``Hello`/`Welcome` 里的 build fingerprintgit hash + `Records.def` hash)不匹配 → 明确报错而非静默协议故障 |
| R24 | **`SEG_CMD` 的记录被并发写坏导致 applier 游标走飞** | §6.3 的运行期边界检查(`size >= sizeof(T) && size <= remainingRingBytes && (size%8)==0``kVarTail` 另查尾长自洽),违反即 `Fatal{ProtocolCorruption}`,绝不进入 UB |
---
## 17. 开放问题
1. **T1/T0 采纳在 Adreno 830 与 Mali-G925 上到底能不能用?****P0 的 spike B** 在第一周回答(导出 `HOST_VISIBLE|HOST_COHERENT` VkBuffer 的 fdclient `mmap` 后回读),与 `SCM_RIGHTS` 测试同批。若两台设备都不行,P7 缩为"记录并回退",节省 6 天;若可行,还要回答 GLES 侧能否用 `GL_EXT_memory_object_fd` + `glBufferStorageMemEXT` 走同一条路(DirectGLES 的采纳今天走的是 `glBufferStorageEXT` + `glMapBufferRange(PERSISTENT|COHERENT)`,不是外部内存)。
2. **`glGetError` 的严格性 CTS 到底要求到什么程度?** §5.6c 已把分配类改成同步 ack,剩下的晚到错误里,哪些 CTS case 可能观察到?P8 需要列出清单。若清单为空,`MOBILEGL_IPC_STRICT_ERRORS` 可以永久保持默认关。
3. **P2.5 的 inproc 数字若为负怎么办?** 需要事先约定:若 inproc 相对 monolith 无收益甚至更慢(含亲和性绑定之后),是继续(因为拆分本身还有内存隔离、崩溃隔离、工装价值)还是收缩到 headless 工装用途?**建议在 P2.5 前由协调者拍板判据**,并同时约定"绑大核后仍无收益"与"未绑核无收益"是两个不同的结论。
4. **§12.2 的隔离取舍**`inproc` 做四全局角色隔离(含 133 处非箭头用法的 shim)值不值?若判定不值而把 `inproc` 降级为纯测试模式,P2.5 测的就不再是 monolith 渲染线程交付物——那时 monolith 侧的收益要靠什么证明?**P0 结束前必须有答案。**
5. **§6.4 的拷贝目标选方案 A 还是 B?** 方案 B(replica 的 `PipeResource` 采纳 client 的 `SEG_SHADOW` 只读映射)能把 buffer 上传路径从 3 次降到 2 次并消掉重复 shadow(对 R14 的内存预算意义更大),但要处理 server 侧写(`WritebackFromBackend`、生成 mip、CopyImage 镜像)的 copy-on-write 升级。P4.5 先做 A 并测量,P6 由数据决定是否做 B。
6. **`SEG_SHADOW` 在 Android 上应该用 `ASharedMemory` 还是 memfd?** 前者是平台正道且有 `setProt` 只读降权(正好匹配"client 拥有、server 只读"),后者有 sealing。大 buffer 频繁重映射的场景需要一次实测。
7. **client 侧是否需要 `mgl-client-tx` 发送线程?** 只有 P6 的 `TracyPlot` 数据能回答;在此之前不要预先加线程(会引入拷贝或锁)。
8. **`ResyncSnapshot` 与采纳的互斥能否放松?** §5.8 目前规定 `MOBILEGL_IPC_RESPAWN=1``MOBILEGL_IPC_ADOPT_TIER != 2` 互斥,因为 adopted store 的字节在 server。是否值得为 adopted buffer 单独做一条"server 死亡时其内容视为丢失、按 `hasDefinedContent=false` 重建"的降级路径?取决于 MC 的 chunk arena 在 respawn 后能否被应用自己重填。
9. **Windows AF_UNIX-everywhere 是否值得?** asio 的 IOCP `async_accept``AcceptEx`(AF_UNIX 从不支持);我们用继承 overlapped 句柄绕开 accept,理论上可行但需真编真跑。P6 评估,named pipe 是已知可用的默认。
10. **P9 的 ART 启动成本具体是多少?** 若不可接受,是否接受"游戏内走 monolith,工装/CTS 走 split"的长期二元形态?
11. **`tools/trace_replay` 的 Android 应用内路径是否从非主线程驱动 GL、是否每重放帧调 `Present`** 桌面重放器传 `--singlethread``trace_replay_core.cpp:430`),Android 应用内路径本次未完整追踪,它决定该工装能否验证节奏模型(尤其是 §9.1 的输入延迟直方图)。
12. **`MOBILEGL_IPC_PERSISTENT_BLOCK_KB` 的默认值与脏块判定方式**:P1-4 的保守版(整 mapped span 按块重传)在 Create/Flywheel fixture 上的实测代价是多少?精确版用 `memcmp` 还是 mprotect 写屏障?前者对 1MB 块是 ~50µs 量级且只在真正 mapped 的 buffer 上跑,看起来够用,但需要 P2 的数据确认。
13. **`SEG_STAGE` 的上限该定多少?** R14 要求由实测定而不是默认 256MiB。需要 P2 之后用 MC in-world 与 Create 两类 fixture 的 `stage-*` Tracy 计数器给出 p99 占用。
---
## 附:环境变量与 CMake 选项汇总
**CMake**
| 选项 | 默认 | 说明 |
|---|---|---|
| `MOBILEGL_BUILD_DISAGGREGATED` | OFF | 出货形态。开启后 `MG_Remote/**``SOURCE_FILES`,支持 `spawn`/`unix:`/`pipe:`。四个进程全局保持普通全局,GL 热路径无 TLS |
| `MOBILEGL_BUILD_DISAGGREGATED_INPROC` | OFF | CI/调试形态,隐含开启上者,额外加四全局角色隔离 shim |
| `MOBILEGL_FLATC_EXECUTABLE` | 空 | 只服务 CI 的 `flatc-check`;默认构建图里没有 `flatc` |
| `MOBILEGL_BAKED_INTERNAL_SHADERS` | ON (P5+) | DirectVulkan 的 blit/depth-mipmap shader 构建期烘 SPIR-Vmonolith 也受益 |
**运行时**
| 变量 | 默认 | 说明 |
|---|---|---|
| `MOBILEGL_TRANSPORT` | `monolith` | `monolith` / `inproc` / `spawn` / `unix:<path>` / `pipe:<name>` |
| `MOBILEGL_IPC_SERVER_PATH` | 空 | server 可执行文件路径(**主要发现机制**,`dladdr` 兜底) |
| `MOBILEGL_IPC_RING_MB` | 8 | `SEG_CMD` 大小 |
| `MOBILEGL_IPC_STAGE_MB` | 32 | `SEG_STAGE` 初始大小;上限由实测定(§17-13) |
| `MOBILEGL_IPC_PRESENT_CREDIT` | **1** | client 允许领先的 present 数(1-4);延迟叠加见 §9.1 |
| `MOBILEGL_IPC_SPIN_US` | 50 | 挂起前的自旋窗口(两侧 doorbell 共用) |
| `MOBILEGL_IPC_POLL_ESCALATE` | 64 | 同一 handle 连续无进展轮询多少次后升级为阻塞 round trip |
| `MOBILEGL_IPC_PERSISTENT_BLOCK_KB` | 64 | persistent-map 推送的块粒度 |
| `MOBILEGL_IPC_PROGRAM` | `relink` (P1-4) → `publish` (P5+) | program artifact 传输方式;`relink` 保留为常驻 oracle |
| `MOBILEGL_IPC_ADOPT_TIER` | `auto` | `auto`/`0`(T0)/`1`(T1)/`2`(T2 拒绝);与 `MOBILEGL_IPC_RESPAWN` 互斥(§5.8 |
| `MOBILEGL_IPC_SHADOW_SHM` | 1 (P4.5+) | shadow-in-shm 零拷贝 |
| `MOBILEGL_IPC_INLINE_PAYLOADS` | 0 | 负面对照:一律内联,不用 `SEG_STAGE` |
| `MOBILEGL_IPC_SERVER_AFFINITY` | `auto` | `mgl-srv-apply` 的核绑定;`auto``ShaderCompilePool` 的大核探测 |
| `MOBILEGL_IPC_VALIDATE_SERVER` | CI=1,出货=0 | server 侧保留 MG_Impl 校验器,分歧变成 server GL error |
| `MOBILEGL_IPC_STRICT_ERRORS` | 0 | 诊断开关:让所有 backend 错误同步 ack(分配类默认已是同步) |
| `MOBILEGL_IPC_AUDIT` | 0 | 记录级审计日志 |
| `MOBILEGL_IPC_TRACE` | 0 | 逐记录 trace(仅调试构建) |
| `MOBILEGL_IPC_ATTACH` | 空 | 附着到已运行的 server(调试) |
| `MOBILEGL_IPC_RESPAWN` | 0 | server 死亡后重启 + `ResyncSnapshot` |
| `MOBILEGL_IPC_IDLE_EXIT_S` | 30 | server 的最后保险看门狗(EOF 应当即时退出) |
**保留的既有负面对照开关**`MOBILEGL_ESPRYT_DISABLE_UBO_RING``_UNPACK_RING``_UPLOAD_RING``_INVALIDATE_FLUSH``MOBILEGL_DISABLE_LARGE_BUFFER_ADOPTION``MOBILEGL_COHERENT_AS_FLUSH`(**在拆分模式下照常生效**,§5.10/§6.8)。