Two things land together: the plan and contract draft P6 needs before it starts, and the
working notes that until now existed on exactly one machine.
1. P6's plan and contract draft (neither started; not one line of code)
docs/Disaggregated/P6-SPAWN-PLAN.md the package plan
docs/Disaggregated/P6-CONTRACT-DRAFT.md the contract draft (moves to
MG_Remote/CONTRACT-P6.md when c6 lands)
The three findings that mattered, all written into the documents:
a. P6 is SMALLER than its roadmap cell suggests. The transport primitives are already in
the tree and already tested: SocketDoorbell including hangup detection and the two
lost-wakeup fences (Doorbell.cpp:211-360, four cases in FdPassingTest), SCM_RIGHTS fd
passing, ShmSegment::Adopt, and the SurfaceOp / WindowKind schema already defined in
protocol.fbs. inproc uses a real second shm mapping rather than an alias, and
SessionRings.h:22-28 says that was done so today is not the first time it runs. P6 is
assembly plus a control plane plus a process, not "write a socket transport".
b. The evidence for "P6 is only a transport swap" has EXPIRED. It was bought by P5c, whose
audit found 59 direct memory accesses in a tree that already looked clean. P5e changed
the tree again. So the first package, a6, is a read-only audit that writes no code, and
every contract row that depends on it is marked PENDING a6 rather than decided in
advance.
c. P6 does not get a game running on the phone. ANativeWindow* is meaningless in the child,
and protocol.fbs:186-196's own comment says Android transfers the window out of band -
that band is P12. P6 lands pbuffer / surfaceless / offscreen, which is what HeadlessGL,
the split-equivalent lane and trace replay need, and which is exactly what P6's own exit
gate measures. A real window reaching the server is a named refusal. This gets its own
section because "P6 is done" would otherwise read as "the game runs now".
The contract adds rule G: a role may not name a process-local handle. It extends P5c's
rule E (a role may not name the other role's memory) from memory to identity - EGLDisplay /
EGLSurface / EGLContext / ANativeWindow* / fd number / pid / mapped address either cross as
a token this contract defines or they do not cross, and a descriptor crosses only through
SCM_RIGHTS, where the kernel renumbers it.
What P5e left for P6 gets its own section. The latch semantics are already designed
(ARCHITECTURE.md:504) and are copied unchanged. What is new is that after run-ahead the
client no longer waits while the peer process can DIE: the client holds records that were
published and will never be applied, and Android's freezer suspends the child without
closing anything. So slow and dead must be distinguishable WITHOUT a timeout threshold.
That is P6's only new design; everything else is carriage. It shares a predicate with E1's
outstanding re-specification (ID-122), and the two should be designed together.
Two gates carried over from P5e's lessons: G2 requires integration-spawn's test-name set to
match integration-split's name for name, because a differing set means the lane is quietly
running less; and the arm-proof gate requires every entry to record the child pid and
transport=spawn in its OWN private log - ID-124 established that this kind of false green
really happens, and a spawn lane that actually ran monolith would pass every other gate.
S2's shape must be settled before it is written, and the document says why: E1 is the
cautionary case.
2. The working notes move into the repository (docs/Disaggregated/notes/, 415 .md files)
ROADMAP / CURRENT_STAGE_PROGRESS / MEASUREMENTS cite ruling numbers, audit line numbers and
the provenance of gate figures from ~/w7/notes/, a directory that exists on one machine and
is under no version control. A record cited hundreds of times that lives only locally is not
a record, so it moves in whole, with its directory structure intact.
Only .md moves. Scripts, run logs, census and gate result JSON, patches, APKs and nm dumps
stay where they are: they are evidence DATA rather than documents - large, regenerated per
run, and mostly carrying absolute paths and device serials that mean something only on the
machine that produced them. The mapping and this split are written in
docs/Disaggregated/notes/README.md.
Two exceptions are recorded there too: P6's BRIEF has been promoted to a first-class
document and keeps no copy under notes/, so the two cannot drift apart; and recovered/ holds
material salvaged from failed workflow runs, which may duplicate the like-named phase
directories and is kept because a few of those were the only complete version at the time.
CI's citation lint takes docs/Disaggregated/*.md only (non-recursive), and notes/ is
deliberately outside it: those notes' file:line citations were written against their own
code heads, and resolving them against today's HEAD would fail wholesale - which is not an
error, it is their correct state as a historical record. To check one, use the revision it
declares (--rev).
3. Inconsistencies fixed in passing
- README.md's status line still said P5e was in progress while the stage table already said
closed.
- CURRENT_STAGE_PROGRESS's next-step item 1 still listed the exit gate as pending, though
(a) and (b) were run in the previous commit; rewritten to closed plus the one item that
actually remains (E1).
- ROADMAP's P6 row still had the pre-phase plan in three of its cells; it now carries the
package order, the scope boundary and the two new gates.
Checked with check_doc_citations.py: 125 citations across docs/Disaggregated/*.md, 0 problems.
53 KiB
MobileGL 前后端进程拆分实施计划(branch feat/disaggregated)
0. TL;DR 与核心决策
Server 就是 libMobileGL 自己,在自己的进程里跑一个真实的 MG_State::GLState::GLContext(replica),由一个 delta applier 通过普通 MG_State mutator API 驱动。两个 backend(DirectGLES 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",全部被证伪:
- 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),第一行就 return;client 侧则根本没人调。应用通过 coherent persistent map 写下的字节会被静默丢弃。 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、零报错。- 纹理 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 trace(SSIM ≥ 0.99)移到 P2 出口判据。 理由见 §15:Android 交付链(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 TLS,1494 个 pGLContext-> 上每次 __tls_get_addr 调用不可接受 |
1. 目标与非目标
目标
- 前端(MG_Impl + MG_State + glslang 链接)与后端(MG_Backend + SPIRV-Cross + 驱动)跑在两个进程,通过 IPC 通信。
- Client 把前端状态 reconcile 成 delta,序列化(FlatBuffers)后发送;server 更新自身状态并调用 backend API。
- 稳态帧零 round trip(readback / 阻塞式 query / sync wait / present credit / 分配类错误 ack 之外)。
- 两半尽可能互相异步:client 至多领先 server 1 个 present(默认值,见 §9 的延迟叠加分析)。
- 平台特定代码最小化并集中在
MG_Remote/Transport/与MG_Remote/Client/Surface*。 - 单进程 Monolith 保持字节级不变,且可机械验证。
- 所有验收门用现有测试:
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_SWAPkill switch)整体丢弃。 - BFA strict-C-ABI backend 插件 / UtilRuntime C-ABI 化。 server 与 backend 同一 CMake 工程、同一产物发布,ABI 边界永不移动。
- macOS 拆分。
CAMetalLayer无公开跨进程表示,MobileGL 在 macOS 是DYLD_INSERT_LIBRARIESinterposer(导出表锁定于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 个 clear(4 个
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 是拿不到的。已确认的两族:
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)同形。- 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:290-303 与 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 │
│ (① PublishImplicitState:persistent-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) ◄─┤ 读 watermark(acquire 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 : asio,framing,fd 传递,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/reply;4 项分配类 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_Impl,6 处照常解析。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。 三条不可协商的证据:
- 身份键 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 数组仍按值存放。 - 回绕计数器。
FramebufferBindingSlot::GetVersion()、FramebufferObject::GetObjectVersion()、VAO::GetIndexBufferBindingSlot().GetVersion()都是Uint16回绕,只有配合指针身份才正确。replay 让两侧跑同一段回绕逻辑。 - backend 自有 generation 表达的是"驱动对象被重新铸造"(
g_bufferBackendIdGeneration、g_attachmentBackendIdGeneration),任何 client delta 都无法承载——它们本来就该纯 server 侧,replica 天然满足。
代价:server 进程要链接 MG_State + MG_Impl + MG_Util(SPIRV-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,跑共享的
AccountTransformFeedbackPrimitiveshelper(§2(g)-2;monolith 里这一步本来就在 MG_Impl 里,拆分后它继续在 client 跑,同时把结果作为RecXfbAccounting下发给 replica)。 - 对
GenerateMipmapscope,EnsureGeneratedMipmapStorageAllocated本来就在 client 的 MG_Impl 里跑过了;WireMirror 只需把它产生的 level 分配 +TruncateMipmapLevels+BumpContentVersion作为RecGenerateMipmapLevels下发(§5.6a)。
步骤 ②:可达性遍历。 这就是 DirectGLES::PrepareForDraw(DirectGLES.cpp:2916-2976)的遍历,把 sync 换成 emit——不是比喻,是同一集合、同一顺序、同一门控:
GetBoundVertexArray()→GetConfigVersion();其 enabled attribute 的BufferObject;index buffer slot(版本 + 裸指针身份)。GetProgramForDraw()(在 client 侧 join compile pool,与今天一致)→ link/UBO-content/block-binding/SSBO-override 版本。composite pipeline 见 §5.7。- texture unit
[0, GetMaxTouchedTextureUnit()],门控GetTextureBindGeneration();每纹理GetContentVersion()/GetTextureParamsVersion();每 unitGetSamplerObject()->GetVersion()。 - image unit
[0, imageHighWater]经GetImageTextureBinding(unit)。 - 每 target 的 buffer binding point,上界
GetTouchedBufferBindingPointCount(target)。 - draw/read FBO,门控 slot version +
GetObjectVersion()+GetAllFramebufferAttachmentVersions(),再逐 attachment;attachment 若是 renderbuffer,另查RenderbufferObject::GetVersion()(P0 新增,见 §5.4)。 GetRenderStateParameters(),门控 render-state 版本。- pack pixel-store(backend 从不读 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 成本形状是已知的,不是估计。
存储:
// 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整块下发是唯一例外(它整块 bumpm_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 合并规则
- 版本门控本身就是合并器。 两个发射点之间的 N 次 mutation 折叠成一条 delta;改了又改回去的状态永不上线。
- 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。 - 纹理区域逐字沿用
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 代价的那一侧。 RecRenderStateBlob、各类 bind:last-writer-wins,reconciler 只发当前值。- 命令永不合并、永不重排。
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 flag:client 必须清(推翻上一版)
上一版写"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 问题按两条收口:
ResyncSnapshot永远从完好的 shadow 传整 level(shadow 从不被丢弃,除非 buffer 被 adopt——纹理没有 adopt 路径),所以"清早了导致重传丢数据"在 resync 场景不成立。- 硬 drain(§6.5)会 bump
ringGeneration;drain 后 client 对所有已发射但未appliedSeq覆盖的纹理记录做一次重发(WireMirror 保留最近一批记录的 (handle, target, level) 列表 + emitSeq,drain 时把 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-4(server 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 加:
// 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 的字节住在 server,client 无法重建它们。因此 MOBILEGL_IPC_RESPAWN=1 与 MOBILEGL_IPC_ADOPT_TIER != 2 互斥:要么关采纳换可 resync,要么开采纳并接受 server 死亡 = context lost(不重启)。这条互斥必须在 ConfigLoader 里显式检查并 MGLOG_W。
5.9 覆盖度的编译期保证
5.9a READ 面(backend 读了什么)
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的静态全局)。- 手维护
MG_Remote/Protocol/Coverage.def:accessor → 记录种类 | MGL_COVER_LOCAL | MGL_COVER_NA(理由字符串)。 MG_Remote/Client/CoverageAssert.cpp同时 include 两者,未映射 accessor →#error。
5.9b MUTATOR 面(MG_Impl 在 table 调用旁改了什么)—— 本轮新增,是 §2(g) 的门
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)。- 手维护
MG_Remote/Protocol/MutationCoverage.def:函数::mutator → MGL_MUT_REPLAYED_BY(记录种类) | MGL_MUT_SHARED_HELPER(helper 名) | MGL_MUT_CLIENT_ONLY(理由) | MGL_MUT_NA(理由)。 - 同一个
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 map:client 侧的推送(本轮新增的独立小节)
问题(已在仓库确认):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 而跳过整个同步。
解法三件套:
-
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 行为。 -
client 侧脏块推送:WireMirror 维护
m_livePersistentMaps(只装 persistent+write+非-FlushExplicit+非-GpuResident 的 buffer,进出由OnBufferMapped/OnBufferUnmapped维护)。PublishImplicitState对本次操作可达的每个这类 buffer(VAO 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(这是计划里唯一一个允许因测量结果而改变阶段顺序的地方)。 -
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 模式下同时关掉该开关,并在报告里标注",而不是让两侧走不同路径还宣称对比通过。