Files
MobileGL/docs/Disaggregated/notes/recovered/wf2/final/part3.md
T
swung0x48 aba329cc53 [Docs] (Disaggregated): P6's plan and contract draft; the working notes move from a local directory into the repository
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.
2026-09-18 23:31:39 -04:00

43 KiB
Raw Blame History

7. backend → frontend 反向通道

这是历次评审对任何薄 backend 设计的中心反对意见,所以逐条处理,不做概括。实测:grep -rnoE "(->|\.)(SetBackendResource|SetBackendHashMemo|SetBackendStateMemo|SetBackendAuxMemo|WritebackFromBackend|MarkGpuWritten|MarkStorageDirty|AllocateStorage|SetInternalFormat|UpdateMipmapSubData|EnsureGpuResidentStorage|SyncPersistentMappedRange|SyncGpuWrites|RecordError|InvalidateCompileEnv|TruncateMipmapLevels|SetSamples)\(" MG_Backend/ = 95 个调用点 / 17 个方法,外加 6 处 backend 反向进 MG_Impl。

7.1 MGPipeCallbacks:把反向通道具名化(对 gallium 的偏离 D8)

// MG_Pipe/MGPipeCallbacks.h  —— context_create 时安装;monolith 里是直调,split 里是记录
struct MGPipeCallbacks {
    void (*on_gl_error)             (Uint32 code);
    void (*on_gpu_written)          (MGPipeHandle res, Uint rangeCount, const MGPRange*);
    void (*on_buffer_writeback)     (MGPipeHandle res, Uint64 off, MGPBlobRef bytes);
    void (*on_texture_writeback)    (MGPipeHandle res, const MGPBox*, MGPBlobRef bytes);
    void (*on_texture_pull_request) (MGPipeHandle res, Uint16 target, Uint16 firstLevel, Uint16 levelCount,
                                     Uint64 pullSerial);
    void (*on_mip_levels_generated) (MGPipeHandle res, Uint16 base, Uint16 count);   // 只带形状,不带字节
    void (*on_surface_changed)      (const MGPSurfaceInfo*);
    void (*on_caps_invalidated)     ();
    void (*on_log)                  (Uint8 level, const char* text);
    void (*on_xfb_scatter_ready)    (MGPipeHandle scratch, Uint64 packedStride, Uint64 vertices);  // ★v2
};

配套的正向终止符(在 MGPipeContext 里,不在 callbacks 里,因为它是 client→server):

// ★v2:拉取请求的显式应答,可以携带零个 region
void (*resource_subdata_complete)(MGPipeHandle res, Uint16 target, Uint16 firstLevel,
                                  Uint16 levelCount, Uint64 pullSerial);

gallium 没有 shadow writeback、GPU-write 通知、纹理重发请求/终止、default-FB 几何这些词汇——因为在 Mesa 里 state tracker 与 driver 共享地址空间。把它们具名化为 10 个回调 + 1 个终止符,好过藏在 95 个 poke 点里。

7.2 95 个写回点的逐族归属

族 n 变成什么
SyncPersistentMappedRange 20 v2 修正:不是"全部消失",而是逐站点归属。 其中多数紧挨着一次对客户端字节的 CPU 读,而那些读搬到了 client(§5.8),由 tracker 在填 MGHostSpan 之前做同一次 reconcile(逐站点表见 §5.8.1)。但至少一处的消费者搬不走:UniformManager::ResolveUniformBufferPayload(UniformManager.cpp:2022 同步,:2052 读 MappedData()+rangeStart,:2053-2057 零填充)把具名 UBO 打进 Magma 自己的 UBO ring——由 D-B8 的 set_shader_buffers host payload 承载,client 在发射前做 reconcile。P1 的交付物包含这 20 处的逐站点归属表(哪些消失、哪些变 client 发射前 reconcile、哪些需要 host payload),不接受笼统结论
MarkStorageDirty 18 16 处是 server 本地记账——零消息(dirty 归属反转,§7.3)。2 处 true(Managers.cpp:2813、DirectGLES.cpp:6852)变 on_texture_pull_request / on_texture_writeback
AllocateStorage 8 6 处是 backend 凭空造出来的前端对象(Magma 的占位纹理、SwapchainObject 的 default-FBO 占位,SwapchainObject.cpp:284, 305, 329)→ server 原生,永不上线;1 处是生成 mip 的 shadow(DirectGLES.cpp:6261)→ on_mip_levels_generated;1 处是 swapchain 尺寸变更 → on_surface_changed
WritebackFromBackend 8 MGPReplySlot(回读)+ on_buffer_writeback(PBO 回读、XFB 捕获)。必须按操作级批处理:其中两处今天在循环里逐行写回(Utils.cpp:2342、DirectGLES.cpp:7633),绝不能变成"每扫描线一次 IPC"
SetInternalFormat 7 与 AllocateStorage 同批
SyncGpuWrites 6 同 SyncPersistentMappedRange:逐站点,见 §5.8.1
MarkGpuWritten 6 client 在每个 draw/dispatch 发射点保守自建,镜像 DirectGLES.cpp:459-467, 509, 1809 与 UniformManager.cpp:1073, 1229、VulkanRenderer.cpp:11210 的输入。on_gpu_written{res, ranges[]} 是收窄通道
RecordError 6 on_gl_error,必须对命令流有序(§7.4)
SetBackendResource 4 删除。 server 拥有资源表;pooling / 延迟释放原样搬到 server
EnsureGpuResidentStorage 3 server 本地决策
SetBackendHashMemo / SetBackendAuxMemo 3 纯值 → server 侧 per-slot 字段
InvalidateCompileEnv 2 on_caps_invalidated,低频
SetBackendStateMemo 1 直接删除,不翻译(D12)
UpdateMipmapSubData / TruncateMipmapLevels / SetSamples 3 全在 Magma 的占位纹理里 → server 原生

6 处 backend 反向进 MG_Impl: 四处 pDefaultFramebufferInfo 身份比较 → 保留 handle {0,1} + MGPFramebufferState::isDefault;SwapchainObject.cpp:276-330(backend 创建 default FBO 的三张 ITextureObject)→ on_surface_changed,client 自己合成对象——顺带删掉 monolith 里的一处分层倒置;VulkanRenderer.cpp:10700(CopyTextureImageToClientOrPBO_State)→ get_texture_image 返回 "该 level 无 GPU 背书,请从你自己的 shadow 回答"(:10691-10704 今天测的正是这个条件)。

7.2.1 v2 新增:XFB scatter 是对 client shadow 的 read-modify-write,必须搬到 client

v1 把 8 处 WritebackFromBackend 全部归给单向的 server→client 通道。ScatterCapturedRecords(DirectGLES.cpp:893-960)不是单向的:它在 :928 做

Memcpy(staged.data(), target.buffer->MappedData() + target.start, rangeBytes);

——从应用已有的字节起步,然后只把捕获到的 varying 补进去,"这样 gl_SkipComponents 要求的空洞保留应用原本放在那里的东西——这正是这个特性的全部意义"(:889-892 的注释;:880-883 点名 KHR-GL46.transform_feedback.capture_special_interleaved_test 是走到这条路径的用例)。server 没有 MappedData(),而 MGPipeCallbacks 里也没有反向的 buffer 读。照 v1 实施,要么空洞被清零(一致性破坏),要么需要一次 §9.2 没有列出的、发生在 glEndTransformFeedback 上的同步反向读。

修正(不新增停顿类):scatter 搬到 client。

  1. server 把驱动捕获到的紧密打包 scratch 字节通过 on_buffer_writeback(scratchHandle, 0, bytes) 推给 client,并用 on_xfb_scatter_ready(scratchHandle, packedStride, vertices) 告知布局参数;
  2. client 拥有目的 shadow,也从反射归档里拥有 GetTransformFeedbackVaryings() / GetTransformFeedbackStride() / GetTransformFeedbackPackedStride()(ProgramObject.h:1146-1171, 1357-1394),于是原样跑今天 :930-939 的补丁循环;
  3. client 把补好的范围当作普通 resource_subdata 重新发下去(复现今天 :946-948 的 glBufferSubData 回灌),并 bump 自己的 change serial(复现 :942 + BumpBufferMutationEpoch())。

副作用::906-914 的"CPU 模型给出 0 顶点 → 整批捕获丢弃"的诊断落到应用线程上,比落在 server 上更有用。计入 Espryt 子系统 7(§6.4)。

7.3 纹理 dirty 归属反转

client 保留 MipmapStorage 的模型(96-rect 级联合并 + summedArea*4 >= unionArea*3 union-box 回退,MipmapStorage.cpp:300-305),维护一份发射游标,在发射后清自己的标志。server 从不碰 client 的标志。

这是安全的,且已核实:MG_Impl 里没有任何 IsStorageDirty( / GetStorageDirtyRects( / GetStorageDirtyRegion( 调用点(前端从不读自己的 dirty 状态),而它自己在五处主动清(GL_Texture.cpp:528, 701, 5547, 5621, 5691)。这一条删掉 PLAN.md §5.6a 的整个 ack 协议与风险 R6。

v2 修正 1:发射游标必须按存储属主键控,不能按 (texture, uploadTarget, level)。 TextureObjectView 把 IsStorageDirty / MapMipmapData / MarkStorageDirty / MarkStorageDirtyRegion / GetStorageDirtyRegion 全部转发给存储属主的 mipmap 并做索引重映射(TextureObjectView.cpp:290-322;:281 直接写属主的数据)。一个 view 与它的属主共用同一份 dirty 状态却会各带一个游标:谁先发射谁就清掉了另一个还需要的标志,或者两边都发同一批纹素。 正确键:(storageOwnerHandle, ownerUploadTarget, ownerLevel)——查询与清除前先经 GetViewStorageOwner() 与 view 的 ToOwnerUploadTarget() / ToOwnerLevel() 映射。 门:新增场景,通过 view 上传、经属主采样(以及反向),跨 draw 边界各一次。

v2 修正 2:MOBILEGL_PIPE_VERIFY 需要一个"保留模式",否则它在最危险的子系统上是瞎的。 影子比对(§10.3-②)的参照物是"从头重算一次快照"。但发射后 client 已经把 dirty 标志清了,从头重算无法重建当时的 rect 集合——于是子系统 5(resource_subdata 的 payload)恰恰是 verify 看不见的那一块,而它同时是 §6.4 标注"全表最危险"、押着 +6ms/frame 悬崖与 7 条 repack 路径的那一块。 修正:MOBILEGL_PIPE_VERIFY=1 时 tracker 保留清除前的 dirty 集合到本次 draw 结束,G4 比对发射出去的 (unionBox, regionCount, regions[]) 与快照重算的结果。并且新增 TextureUploadShapeScenario:把逐纹理逐帧的上传形状(box vs N 个 region、作业数)录成金标,与 SSIM 并列比对——+6ms 悬崖由形状相等把关,不是由 SSIM 把关(SSIM 对它完全不敏感)。

上传形状决策留在 server:resource_subdata 同时带 union box 与 region 列表(§4.5.6),Mali 按作业数计价的悬崖在哪一侧付 GPU 代价,决策就留在哪一侧。

7.4 反向通道的有序性是正确性要求,不是优化

on_buffer_writeback 必须与 epoch bump 有序。 今天每一次 WritebackFromBackend 后面都紧跟一次 BumpBufferMutationEpoch()(DirectGLES.cpp:834-837, 942, 7625-7629),否则 server 自己的 draw-clean memo 会在 epoch 背后变陈旧。split 里这变成反向通道上的一条排序规则:一次写回的 epoch bump 必须在任何后续读该 handle 的命令之前被 server 侧应用。反向通道需要与正向通道相同的有序保证。

on_gl_error 必须对命令流有序,否则 glGetError 答错。glGetError 本身永远本地(GL_Getter.cpp:2811-2817;不变式 Core.cpp:48-49)。

v2 修正:kNeedsAck 只标真正同步的分配点,不是"看起来像分配"的 GL 入口。 v1 把 "glRenderbufferStorage*、可能失败的 glTexImage*/glTexStorage*/glCopyTexImage* 形式、glBufferStorage" 全标成 kNeedsAck,让 OOM 探测惯用法(allocate; if (glGetError()==GL_OUT_OF_MEMORY) 用更小的重试;)成立。实测这批里纹理族根本不调 backend 表:MG_Impl/GLImpl/Texture/GL_Texture.cpp 在 :2515, 2671, 2755 只做 MarkStorageDirty(..., true),Espryt 在 sync 时刻才惰性分配;纹理侧的错误上报 RecordGLError(DirectGLES.cpp:6309-6324)只有一个调用者——glGenerateMipmap(:6916)。连唯一一处真正的同步分配 glRenderbufferStorage* 也是在 BackendRenderbufferObject::SyncToBackend(Managers.cpp:8674-8684)里惰性做的。

修正后的规则:

  • 纹理分配的 OOM 在 monolith 里就已经推迟到 sync 时刻,拆分不改变任何可观察行为 —— 这批不标 kNeedsAck,并把这条事实写进文档(避免后人以为是遗漏)。
  • kNeedsAck 只标两项:glBufferStorage(真同步)与 glRenderbufferStorage*(若决定把它的分配提前到 GL 调用时刻以支持 OOM 探测;否则它也不标,同样写明)。这个"若"由 P0 回答:查 MC / Iris 语料里有没有真的 glRenderbufferStorage OOM 探测惯用法;没有就不标,省掉整条 ack 路径。
  • 其余错误一律晚到,走有序的 on_gl_error。

对 PLAN.md §7.4 的强制修正:on_log 必须按严重级分级。 PLAN.md 把全部 EvLogLine 设为有损(覆盖最旧 + eventDropped)。但 §5.7 已确认:backend program link/compile 失败只以一行日志加一次 bind-program-0 的空 draw 呈现。统一有损策略下,系统里诊断价值最高的那一行会在日志压力下静默消失。

规则:on_log(level ≤ WARN) 有损;on_log(level ≥ ERROR) 无损,加入触发 eventRingFull + 停止 apply 的语义事件集;再加一个每秒 ERROR 速率限制器,超限时发一条显式的 "N errors suppressed"。MGLOG_E_ONCE 的 latch 变成 per-server。P9 的故障注入门:日志洪泛下注入一次 link 失败,那行 ERROR 必须出现且两侧都恢复。

7.5 唯一的新停顿类:server 发起的纹理重铸拉取(D-B6)

server 不保留纹素字节,三个原因会要求 client 重发已发过的 level:RequireImageBindableStorage 的 re-dirty(Managers.cpp:2813)、整格式再生(:3950-4195)、view 源重铸(:3616-3707)。四条缓解同时上(v1 是三条,v2 补第 (e) 条终止符),加一个专门的门和一个必须发布的计数器:

(a) 预防主因。 client 给纹理打 everImageBound 标记,resource_create/respecify 一直携带 imageBindableHint,于是 image-bindable 存储在前期就分配好。这把 RequireImageBindableStorage 从稳态里彻底移除。

(b) 拉取是异步的。 server 发 on_texture_pull_request{res, target, levels[], pullSerial} 并把那个 twin 标为 not-ready;client 在下一次 publish 时重发。因为 client 跑在前面,常见情况下字节在 server 到达采样该纹理的 draw 之前就到了;即使没到,阻塞的是 mgl-srv-apply 线程,不是应用线程。

(c) 有上限的保留(默认关闭)。 可选的逐纹理保留位,受一个显式的 LRU 字节预算约束(MOBILEGL_PIPE_TEXEL_RETAIN_MB,v2 把默认从 32 改为 0)。理由:MipmapStorage 保有每个 level 的完整 CPU 影子(MipmapStorage.h:117 的 Vector<Vector<Uint8>> m_data),所以一次拉取总是能从 client 已有的字节服务——保留缓存买的是延迟,不是正确性,而它花的是内存,恰好是 §0.4 用来对比 replica 的那个指标。只有 (d) 的实测拉取率非平凡才开,并拿真预算。

(d) 门与计数器。 TextureRemintPullScenario:同时强制 RequireImageBindableStorage 与一次帧中格式再生。拉取次数逐 trace 用例发布,与 SSIM 并列。本设计从不声称"零 round trip",它测量并公布。

(e) v2 新增:显式终止符——因为存在"答不出来"的拉取。 RequireImageBindableStorage 的重放会 re-dirty 每个上传目标的每个 level(Managers.cpp:2789-2822),而它自己已经跳过 GetMipmapByteSize(...) == 0 的 level(:2810-2812)。但还有一类 level:内容只来自渲染、来自一次 CanMirrorCopyImageShadow 拒绝的 glCopyTexSubImage(DirectGLES.cpp:7068-7073)、或来自 GPU 侧 mip 生成——client 那里根本没有字节。没有终止符,apply 线程会 park 在一个永远不会 ready 的 twin 上。B-R4 与 TextureRemintPullScenario 只针对拉取的频率,从来没针对无解的拉取。 修正:

  • 拉取是 request/response 对,由 resource_subdata_complete(res, target, firstLevel, levelCount, pullSerial) 终止,它可以携带零个 region;
  • 收到零 region 的应答时,server 带着"已分配但为空"的存储继续(这正是 monolith 的行为:EnsureGenerateMipmapStorageAllocated(DirectGLES.cpp:6270-6271)也是 AllocateStorage + MarkStorageDirty(false),不填内容),并记一条 MGLOG_W;
  • TextureRemintPullScenario 必须包含这个无解用例(一张只被渲染过、随后被 image-bind 的纹理),且它必须在终止符落地之前是红的(表现为 apply 线程挂死或超时)。

若在真实语料(MC 与 Iris fixture)上实测拉取率非平凡,(c) 从可选升级为强制并拿到真预算。


8. 传输、数据面、同步、present、线程、平台、构建

8.1 原样继承方案 A 的部分

以下全部逐条继承 PLAN.md,本文不复述:

PLAN.md 章节 内容
§6.1 段布局(SEG_CMD 8MiB / SEG_STAGE 32MiB↑ / SEG_REPLY 8MiB / SEG_EVENT 256KiB / SEG_SHADOW[n] / SEG_ADOPT[n]);shm 创建矩阵;SCM_RIGHTS 必须在第一个 transport commit 里实现(Feat/CS-Delta-IPC 把 out->fd = -1 硬编码在 LocalSocketTransport.cpp:296,它的数据面在唯一重要的平台上一个字节都没过去);SEG_SHADOW 块的 pending free-list 退休规则
§6.2 / §6.2a RingControl:两组独立游标三元组、三个 seq 水位、serverEpoch、ringGeneration、consumerParked/producerParked、eventRingFull/eventDropped;双向 doorbell,MOBILEGL_IPC_SPIN_US 默认 50µs,inproc 用 condvar
§6.3 记录格式:8B RecHeader、24B BlobRef、无 per-record 序号、X-macro 每种一条 static_assert 加生成的运行期边界检查 → Fatal{ProtocolCorruption}、kVarTail 自描述长度自洽校验。方案 B 把这套机制扩展到全部 MGPipe 调用(G3)
§6.4 WAR 纪律:调用时刻拷进 ring slot(P1-4);P4.5 的 SEG_SHADOW 零拷贝 + 逐 shadow 64KiB 块发送水位
§6.5 ring 分配与背压:逐字移植 PersistentRing(Managers.cpp:641-727、RingAllocateSlow :1891-1970、RingOnPresent :1975-2016)
§6.6 前三条 unpack PBO 完全在 client 解析;压缩 internalformat 永不到达 backend;glCopyTexSubImage* 与 glClearTexImage 整体留在 client
§6.7 第 2、5 行 PBO 回读改 fire-and-forget(严格优于 monolith,DirectGLES.cpp:9189-9205 无条件停等);glEndTransformFeedback 的无限 ClientWaitSync(GL_Drawing.cpp:1326-1337)推迟到首次读
§6.8 persistent map 与 ≥16MiB 采纳的三档,由运行时 POST 探针选择,绝不硬编码驱动名
§7.1 FlatBuffers 纪律;protocol_generated.h 提交;gen_protocol.py + CI flatc-check;默认构建图里没有 flatc
§7.2 帧封装;publish 触发器(每记录 release-store cmdHead、显式门铃点、SEG_STAGE 余量 < 1/4、轮询入口也是门铃点、GL_SYNC_FLUSH_COMMANDS_BIT 无条件 publish、MOBILEGL_IPC_POLL_ESCALATE 饥饿升级);glFinish/glFlush 保持免费(Definitions.cpp:111-112)
§7.3 两个互相独立的窗口(字节 credit、present credit);server 不发 credit 消息
§7.4 事件 ring + 排空点 + 溢出策略。加上 §7.4 的分级修正
§8 末尾 fence 完成度必须来自真的逐 fence 退休,不是 present 水位(DirectVulkan.cpp:1120-1128;magma-mc1215-fence-oom);三个应先独立落 dev 的 monolith 修复
§9 / §9.1-§9.3 Present 与 eglSwapBuffers 严格 1:1、绝不批量;MOBILEGL_IPC_PRESENT_CREDIT 默认 1 与延迟叠加公式;Magma 从不注册 SetSwapInterval(BackendObject_DirectVulkan.cpp:698);DirectGLES 的非 present fence tick
§10 线程模型;server 的 mgl-srv-io + mgl-srv-apply;核心放置与 MOBILEGL_IPC_SERVER_AFFINITY、报逐线程 CPU 时间;拆机顺序
§11.1-§11.6 启动与握手;extern "C" visibility("default") 与 nm -D 门;Android 的 android:process=":mgl" Service 路径;X11 XID;EGL_PLATFORM=surfaceless;Windows overlapped named pipe;崩溃时的 device-lost latch
§12 第 1-3 层 / §12.4 编译期折叠;唯一 hook 点 MG_Backend/Init.cpp:48-70;P4.5 的 allocator 改动整段包裹;MOBILEGL_TRANSPORT 复用全部既有开关通道
§13 目录形状;一份库两个角色;FlatBuffers submodule 的双重 guard;ctest/trace-replay 的三个陷阱;SPLIT 后缀与 -DTRACE_TRANSPORT=;CI 的 flatc-check 与 fprintf grep 门
§14 对 Feat/CS-Delta-IPC 的 REUSE / CHANGE / DROP 判定
§15 P0 卫生清单与两个 spike

8.2 与方案 A 的差异

删除: Server/ReplicaContext.{h,cpp}(换成 Server/PipeObjectTables.{h,cpp} + Server/IndexHostMirror.{h,cpp});§5.0 的"replica vs 重写"决策;§5.1 的三步发射协议;§5.2;§5.4 的 replica 对象表规则与 Fatal{IdentityDivergence};§5.6a 的纹理 ack 协议;§5.7 的 Phase 1-4 composite 分支;§5.9b 的 mutation replay 机制(MutationCoverage.def、ImplMutationSurface.inc、MG_Remote::Shared:: helper 族);§6.9 的 relink 档与 MOBILEGL_IPC_PROGRAM;§6.4 的拷贝第 (3) 行;§12.2 的 pGLContext shim;阶段 P5(6 天回收);风险 R1 与 R6;开放问题 §17-5。 不删:gen_impl_mutation_surface.py 本体——它改造成 gen_pipe_dirty_surface.py(§0.3 推论 4)。

改变:

PLAN.md § 差异
§5.9a READ 面的编目生成器变成三道禁止门(§4.7.2)。原 477 行 inventory 保留为 tracker 侧覆盖检查表(G6)
§6.4 拷贝账 第 (3) 行不存在:P1-4 = 3 次,P4.5 = 2 次。PLAN.md 自己的"方案 B"目标按结构达成,开放问题 §17-5 自动关闭
§6.6 第 4 条 逐 level serverAuthoritative 位被 dirty 归属反转(§7.3)+ on_texture_writeback + on_texture_pull_request/resource_subdata_complete 取代
§6.9 RecProgramLinkOp 不可能(§5.7)。ProgramPublish 第一天;reflectionDigest 换成"schema 完整性绊线";P5 消失。新增前置 P0.5 的头文件抽取(§4.5.5),否则 `nm -D
§5.10 第 2、3 条逐字继承(R2 仍是最高优先级正确性项)。第 1 条缩成一个推送的 hasLiveHostWrites 位
§6.10 四类应用指针按 §5.8 归属;ClientArrayBounds 变成 flag 门控的 MGPDrawInfo::minIndex/maxIndex。陈旧索引纪律改为逐站点表(§5.8.1),不是笼统规则
§7.4 on_log 按严重级分级,加每秒 ERROR 速率限制器
§12.2 需要角色隔离的进程全局从 4 个降到 2 个
§13 MG_Pipe/ 是默认构建里的非可选目录;只有 MG_Remote/ 在 MOBILEGL_BUILD_DISAGGREGATED 之后
§15 在 P1a 之前新增两整段:P0.5(头文件抽取)与 backend 推送改造。PLAN.md 把后者定价为"~0 逻辑改动";在方案 B 里它是工作量主体

新增:

  • SEG_STAGE 尺寸必须额外容纳这些它以前不承载的字节(v2 修订清单):
    1. client 顶点数组;
    2. client 索引数组;
    3. multi-draw 参数块(first[]/count[]/indices[][]/basevertex[],drawcount*4 级);
    4. client 解析后的 *IndirectCount 命令块(几十字节);
    5. 具名 UBO 的 host payload(D-B8,kCapNeedsHostUboBytes 下逐 draw 逐块);
    6. 纹理 subdata 的紧密重打包区域(§4.5.6;今天走 unpack ring 时也已经紧密重打包,所以字节量同阶,但现在过 ring slot)。 不在此列(v1 曾担心,D-B7 解决):restart 重写的整 EBO(kMaxRestartRewriteBytes = 1<<26 = 64 MiB,是默认 SEG_STAGE 的两倍)与 multi-draw 展平的索引流(kMaxFlattenedIndices = 1<<24)——它们由 server 侧的索引宿主镜像喂养,不过 SEG_STAGE。 上限由 P0 落地的计数器实测定,不用默认值猜。并且 G3 必须为"单条记录大于段容量"定义明确的分块/降级路径(大 subdata 分块成多条,而不是一条巨记录)。
  • Server/IndexHostMirror(D-B7):由 resource_create/respecify/subdata 流增量维护,覆盖 bindMask & ELEMENT_ARRAY 的资源;预算 MOBILEGL_PIPE_INDEX_MIRROR_MB(默认 64);逐帧发布 index-mirror-bytes 与 index-bytes-shipped(超预算退化路径的计数)。
  • 新事件种类:on_texture_writeback(CopyImage 镜像搬走后只剩一个生产者:CPU 生成 mip 路径 DirectGLES.cpp:6811-6861)、on_texture_pull_request、on_mip_levels_generated、on_xfb_scatter_ready;正向终止符 resource_subdata_complete。on_buffer_writeback 从"优化"升级为承载语义。
  • 新环境变量:MOBILEGL_PIPE_PUSH、_VERIFY、_STATS、_LEGACY_MEMOS、_TEXEL_RETAIN_MB(默认 0)、_INDEX_MIRROR_MB(默认 64)。
  • RenderbufferObject::GetLifetimeId()(今天没有)。但不需要 GetVersion()——推送模型里 glRenderbufferStorage* 本身就是一次 pipe 调用。

8.3 persistent map:唯一被显式隔离的传输相关决策

AcquirePersistentMap(BufferObject.h:112)是永久的地址空间捐赠(D4/D-B4)。它原样穿过 monolith 改造(P0..P13 一动不动),只有 IPC 那一步才打破它。 决策路径:

  • P0 的 spike B 在第一周给方向:导出 HOST_VISIBLE|HOST_COHERENT VkBuffer 的 fd,client mmap 后回读,在两台设备上跑。
  • T2(拒绝,永久正确的回退):返回 nullptr,前端已在三处容忍(BufferObject.cpp:174, 439-442, 470-472)。此档下 PLAN.md §5.10 的 client 侧块粒度推送是强制的,由 PersistentCoherentMapScenario 把门。
  • T1(server 导出自己的映射):每次存储定义一次 round trip(v2 修正 v1 的"每 store 生命周期一次"——TryAdoptLargeStorage 在存储定义时触发,反复扩容的 arena 付 N 次)。StorageBufferRegrowScenario 必须发布 map-persistent-roundtrips。
  • T0(server 导入 client 分配):理想但可用性未知。

若两台设备都否,IPC 期的该阶段从 8 天缩为 2 天的文档与负面对照。绝不允许一个平台未知数挡住 260 天的接口工作。


9. Roundtrip 清单与稳态零 roundtrip 论证

9.1 稳态零 roundtrip 的项

类 roundtrip 依据
全部 draw、clear、blit、copy、dispatch、barrier、XFB 跨度标记、全部 bind、全部 CSO create/bind、全部 set_*、全部 buffer/texture 上传、present 0 单向记录;present 只查 credit
全部 89 个 caps 站点 0 首次 MakeEGLCurrent 的一次 MGPCaps 快照(BackendObject.cpp:341-347,每次 surface 变更重新武装 :301);callMask 精确复现 DirectVulkan 少注册的槽位
glGetError / glFinish / glFlush 0 前者永远本地(GL_Getter.cpp:2811-2817;不变式 Core.cpp:48-49),后两者是彻底的 no-op(Definitions.cpp:111-112)且必须继续免费
fence 与 query 的创建,以及每一次非阻塞轮询 0 handle 由 client 铸造;未命中合法地答 GL_UNSIGNALED/"未就绪"(BackendObject.h:210-214、:236-241;前端已遵守,GL_Query.cpp:302-311)
glGetTexImage / glGetTextureImage(DirectGLES),包括 GPU 生成的 mip level 0 client shadow 回答(CopyTextureImageToClientOrPBO_State,GL_Texture.cpp:5368-5420,取用点 :6460)。v2 显式决定:on_mip_levels_generated 只带形状不带字节,因为 monolith 也是如此——EnsureGenerateMipmapStorageAllocated(DirectGLES.cpp:6243-6274)对每个新 level 做 AllocateStorage(...) + MarkStorageDirty(..., false),内容留空。split 因此与 monolith 行为一致:GPU 生成的 level 在两种模式下都返回已分配但未填充的影子。只有 CPU 回退生成路径(RGB16F/RGB32F,:6811-6861)产生真纹素,由 on_texture_writeback 回来
glReadPixels → pack PBO 0 fire-and-forget + client 侧 MarkGpuWritten。严格优于 monolith(DirectGLES.cpp:9189-9205 无条件停等)
glEndTransformFeedback 0 取消无限 fence 等待(GL_Drawing.cpp:1326-1337),改为对 capture target 置 MarkGpuWritten;scatter 由 §7.2.1 的 client 侧路径完成
eglSwapBuffers 0 次阻塞 round trip,一次非阻塞 credit 检查 只有 presentsSent - presentAckSerial >= MOBILEGL_IPC_PRESENT_CREDIT(默认 1)时才阻塞
glMultiDrawElementsIndirectCount / glMultiDrawArraysIndirectCount 0 client 从自己的 shadow 解析计数,只做 SyncPersistentMappedRange()——与 monolith 完全相同的 reconcile 集合(§5.8.1)。P8 验收要求 create-indirect fixture 上该计数器读零
primitive-restart 重写 / multi-draw 展平 0 server 从索引宿主镜像读(D-B7)

9.2 不可避免的阻塞点(全部罕见,逐条给理由与缓解)

# 站点 为什么不可避免 缓解
1 握手 Hello/Welcome + 段 fd 传递 — 一次
2 InitializeEGLDisplay、Create/Resize EGL*Surface、首次 MakeEGLCurrent + InitCapabilities 出参 / 返回 Bool;caps 只在那一刻存在 每 surface 至多一次;surface 回复顺带 MGPSurfaceInfo。SwapEGLBuffers 不需要回复(BackendObject.cpp:365-393 对 client 镜像的 EGL 状态求值)
3 glReadPixels → 客户内存 GL 要求返回时字节已就位 像素进 SEG_REPLY slot;逐行写回循环留在 server 内,按操作级批成一段
4 glGetTexImage/glGetTextureImage(DirectVulkan) Magma 对只存在于 GPU 的 level 没有 client 可答的 shadow get_texture_image 对"无 GPU 背书"的 level 返回"请从你的 shadow 回答"(VulkanRenderer.cpp:10691-10704)
5 GPU-write pending 的 buffer 首次 CPU 读 shader 在前端背后写了 store monolith 里本来就阻塞(Managers.cpp:1246 的 glFinish();VkBufferManager.cpp:80-85 → VulkanRenderer.cpp:9807-9817)。client 保守 pending 集触发,由 writableMask 与 on_gpu_written{ranges} 两侧收窄
6 glClientWaitSync(timeout>0)、glGetQueryObject*(GL_QUERY_RESULT) 未完成、glBeginConditionalRender GL 定义即阻塞;glBeginConditionalRender 连 _NO_WAIT 模式也阻塞(GL_Query.cpp:705-706) 非阻塞兄弟是 0 round trip。条件渲染谓词只解析一次(Core.h:387-391),之后每个条件 draw 在 client 侧丢弃,server 永远不需要那个 query 对象
7 分配类入口的 ack OOM 探测惯用法 v2 收窄:只有 glBufferStorage(真同步)与——若 P0 证实语料里确有 glRenderbufferStorage OOM 探测——glRenderbufferStorage*。纹理族在 monolith 里就已经推迟到 sync 时刻,不标 kNeedsAck(§7.4)
8 map_persistent(仅 T1 档) 应用必须拿到一个不再经过任何 API 调用就能写的地址 每次存储定义一次(v2 修正),不是每 store 生命周期一次;StorageBufferRegrowScenario 发布计数
9 server 发起的纹理重铸拉取 server 不保留纹素 四条缓解 + 终止符 + 专门的门 + 逐用例发布的计数器(§7.5)。异步形态下阻塞的是 mgl-srv-apply 而非应用线程;零 region 的应答让 server 带着空存储继续,永不永久 park
10 client 侧索引扫描,当源 EBO 在 pending 集里 monolith 在同一位置调 SyncGpuWrites()(VulkanRenderer.cpp:3431) §5.8.1 的逐站点表;*IndirectCount 不在此列(它今天不调 SyncGpuWrites())
11 ring/stage 耗尽、present credit 节奏,非语义 PersistentRing 的升级路径 + producerParked doorbell

9.3 论证的形式:测量,不是声称

验收门措辞:在全部 40 个 trace 用例上发布逐用例的 roundtrip 计数器、纹理拉取计数器、索引镜像字节数与 index-bytes-shipped。不做笼统的"零 round trip"声明。 条件渲染与阻塞 query 的次数按用例列出。

轮询挂死的防护(继承 PLAN.md §7.2/R4)必须有它自己的门:glFenceSync(); while (glClientWaitSync(s, GL_SYNC_FLUSH_COMMANDS_BIT, 0) == GL_TIMEOUT_EXPIRED) {} 必须在有界时间内退出。


10. Monolith 保留

10.1 接口在进程内就是直调

monolith 模式下 MGPipeContext 用 backend 自己的函数填充,MGPipeCallbacks 用对 MG_State 的直调填充,MGHostSpan.ptr 指向 client 自己的 shadow(零新增拷贝),MGPipeHandle 按值走一对寄存器。split 模式下同一张表换成发射器,applier 反序列化后调同一批 backend 函数。全世界只有一份 backend 实现。

10.2 热路径的间接成本,动态口径的诚实版(v2 重写)

v1 这张表把今天的每 draw 状态获取写成 "Espryt 124 / Magma 169 次 accessor 调用"。那是静态调用点数(§2.1(d) 的定义),不是动态每 draw 调用数——树里每一处都已被 memo 门控(§2.3.1 逐条列了早退位置)。按动态口径重写:

今天(动态稳态) 之后(动态稳态)
每 verb 的分发 1 次间接调用 + 3 个寄存器实参(DrawArrays) 1 次间接调用 + ~48 B 固定头(MGPDrawInfo)+ 按 flag 的变长尾。这是一项新增成本,不是持平
每 draw 的状态获取(值类) Espryt:1 次 Uint16 比较(DirectGLES.cpp:2016-2018)早退;未命中时 1.2KB×3 段 memcmp。Magma:1 次版本比较(:4982)+ 1 次版本比较(:5888);pipeline memo 未命中时 ~40 次 accessor 走查(:5155-5200) 1 次 Uint16 比较;pipeline 版本动了才算 ~25-30 字的子集哈希 + 1 次 map 探测(D-B1);动态子集动了才发 ~200 B
每 draw 的状态获取(对象类) Espryt:SyncNeccessaryTextures 6 值键 + PairingsIntact + 每条目 IsDrawSyncClean;CurrentUnitBindingsEpoch 三值快门。Magma:TrySetupDrawFastPath ~10 次 accessor + ~20 次字比较 + 两次有损版本求和(:6249-6250) 5 个聚合世代各 1 次 Uint64 比较(推论 4);命中才走 touched 前缀 + 集合 hash;hash 未变不发(§5.4-4)
memo 查表 对指针位做斐波那契散列的直接映射探测 + owner 相等性(3 次/draw) 按 slot 的数组下标
真删除的机制 — ~372 行 per-draw 失效发现(§2.5)
搬到 client 的机制 — ~175 行(去抖 + 完备性解析,§2.5)

结论(诚实版):推送在稳态应当是净减少——省掉三次散列探测、一次 1.2KB 三段 memcmp(换成 ~30 字哈希)、两次有损求和、CurrentUnitBindingsEpoch 的 owner 走查;付出 MGPDrawInfo 的 payload 构造与集合 hash。但差距远小于 v1 声称的量级,而且 §2.7 表明 monolith 的净行数是增加的。所以本设计的 monolith 论据是 §10.3-④ 的逐线程 CPU 数字,不是删除行数。

两个诚实的告诫:

  1. 可达性遍历是搬走了,不是消失了,头号指标必须是逐线程 CPU 时间。
  2. Magma 的 SetupDrawSnapshot 快路径命中率在两种模式下会合法地不同,A/B 比的是渲染输出与计数器,永远不是 memo 轨迹。

两个 backend 编进同一个共享库(CMakeLists.txt:356-383、:485),backend 在 init 时锁存一次(ConfigLoader.cpp:212-225),所以去虚化在两种形态下都不可得,也都不需要。函数指针 struct 而非虚基类的理由见 §4.1。

10.3 替代字节一致门的五部分验证门

先把成本写在明面上:PLAN.md §12 第 4 层在方案 B 里按构造死亡。这是方案 B 的代价,必须写进设计文档而不是藏起来。

①(v2 扩为三道)接口纯度门。

  • 门 A(include 图):disaggregated 配置编译 MG_Backend 时把 MG_State/GLState 从 include 搜索路径移除(或断言 -H 输出)。这是唯一能因它存在的理由变红的检查——nm --undefined-only 对"只 include 不调用"是瞎的,而 RenderState.h:12 → FramebufferObject.h:12-13 → TextureObject.h / RenderbufferObject.h 正是这种耦合,RenderStateParameters 用 FramebufferObject::MAX_DRAW_BUFFERS 定长(:263, 273)。依赖 P0.5 的 MGPipeValueTypes.h。
  • 门 B(符号):nm --undefined-only libMobileGLServer.so | grep -E 'MG_State::GLState::|glslang' 为空。
  • 门 C(未声明):grep -c 'pGLContext' MG_Backend/ == 0(grep pGLContext 不是 pGLContext->)。三道门都只跑非 verify 构建(D-B5)。
  • 外加一条 debug 断言"每个 backend memo 键都是 {slot, gen} 对,永不是裸前端指针",由 HandleRecycleScenario 支撑——这个场景在 0e 重键之前必须在至少一个 backend 上是红的。

② 语义影子比对(MOBILEGL_PIPE_VERIFY=1)——决定性的那一条。 阶段 B 期间两套状态模型活在同一个地址空间:tracker 再用 SnapshotFromGLContext() 填一份 PipeInputs,G4 生成的比对器逐字段、每 draw 与推送版本比对,打印第一个分歧字段名与 draw 序号。抓三种事:(a) tracker 忘了推的字段;(b) dirty 位触发得太少——危险的那个方向;(c) 两条路径上被变换得不一样的值。第三种 CI 模式,跑全部 40 个 trace 与 367 个集成测试;~5-10× 慢,永不出货。 必须逐字段比而不是 memcmp:DirectGLES.cpp:2029-2033 明确记录 RenderStateParameters 的 memcmp 会因 padding false-DIFFER(无害)但永不 false-match——比对器要零误报。 v2 修正 A:verify 需要"保留模式"。 消费即清的组(纹理 dirty rect)在发射后无法从头重算,所以 verify 在纹理 subdata 上是瞎的——而那正是最危险的子系统。MOBILEGL_PIPE_VERIFY=1 时 tracker 保留清除前的集合,G4 比对发射出去的 (unionBox, regionCount, regions[])(§7.3)。 v2 修正 B:verify 活过 P13。 SnapshotFromGLContext() 与它的 MG_State include 整体包在 #if MOBILEGL_PIPE_VERIFY 里保留;纯度门只跑非 verify 构建(D-B5)。P13 另交付录制-金标模式(MGPipe recorder,§10.4-9)作为不依赖 MG_State 的长期语义门。

③ 行为 A/B。 全部 ~40 个 trace 用例(tools/trace_replay/trace_cases.json,默认 SSIM 阈值 0.99)在 {monolith-pull, monolith-push, split} 三种下同一判定、SSIM ≥ 0.99;ctest -L integration-gpu 在 DirectGLES. 与 DirectGLES.Pipe./DirectGLES.Split.(以及 DirectVulkan 对)之间产生逐名相同的通过/失败集;428 个单元测试全绿;CTS 逐后端 conformance 在 0.5 个百分点内,按本项目的逐后端表格式上报(行 = GL 版本/扩展,列 = 状态计数,rate = Pass/(Pass+Fail),NS 不进分母)。 两个 Create fixture 带 coherent_as_flush: true,必须在两种模式下都开着该开关跑。 v2 补充:TextureUploadShapeScenario——上传形状(box vs N region、作业数)录金标比对,因为 SSIM 对 +6ms 悬崖完全不敏感(§7.3)。 v2 补充:参考构建的定义。 P2 之后 monolith 本身已经变了,所以逐名基线必须明确为**"P1 出口的重构后 monolith",而 P1 出口本身要先用 verify 证明重构等价于 81b17c0b。81b17c0b 的 monolith 只作为 §10.3-④ 性能对照的锚点,不作为逐名功能基线。**

④ monolith 性能不回归。 两台设备(35d0befa Adreno 830、3B159D009VZ00000 Mali),reboot-clean、同热窗口、配对 A/B,用 tools/bench.sh + trace replay 的 --benchmark --benchmark-tail-frames --benchmark-result 逐帧 JSON。指标是逐线程 CPU 时间,monolith-push 在 p50 与 p99 上都要落在 monolith-pull 的噪声内。CPU 定频按本项目协议。 v2 补充三条:(a) 绝对阈值——tracker 每 draw 的 ns 必须公布并设上限,因为真实拉取基线只有 10-25 次 accessor(§2.3.1),相对噪声阈值会平凡通过;(b) Blaze3D blend-toggle 微基准(enable/draw/disable/draw,MC batch 速率)单列,它是 D-B1 的判据;(c) 负面对照——关掉 CSO 内容寻址(MOBILEGL_PIPE_PUSH 的一位)重跑,把"推送更慢"与"CSO 设计更慢"分开。

⑤ 覆盖 + poison + handle 纪律。 gen_pipe.py 重生成 477 行 inventory 的 MGPipe 映射列,0 UNMAPPED,git diff --exit-code;gen_pipe_dirty_surface.py 重生成 mutator→聚合世代 映射,0 未映射(推论 4);PipeInputs::m_filledGen 的逐 verb世代 poison(§6.2.2);G7 的 render-state setter 一致性测试;P13 的 static_assert(sizeof(ResidualValueBlock) == 0);ResidualValueBlock 的逐成员 offsetof 断言。

两条字节级等式仍然幸存:MOBILEGL_BUILD_DISAGGREGATED=OFF 时 nm --defined-only libMobileGL.so | grep MG_Remote 为空且链接行不增加任何库;nm -D libMobileGL.so | grep mobilegl_server_main 在 RelWithDebInfo 里命中。 符号与 .text 漂移每阶段作为信息性指标发布——一次无法解释的跳变仍然是一个 smell,只是不再是一条断言。

10.4 monolith 侧净收益清单(即使 IPC 永不上线也成立)

  1. ~372 行 per-draw 失效发现机制真删除(§2.5),另有 ~175 行搬到 client。注意 §2.7:monolith 的净代码量是增加的(约 +6,650 手写 + 4,000 生成),所以这一条是佐证,不是主论据。
  2. 复用地址 ABA 一整类不可表达:D1/D2/D3/D10/D11/D13/D14/D16/D17/D20 全部由 {slot, gen} 关闭。
  3. FBO → program 排序 hazard 消失:DirectGLES.cpp:2712-2732 的 fragColor 重推导 workaround 与 g_broadcastMemo* 删除(机制是惰性特化,D-B3 v2)。
  4. 一处分层倒置消失:SwapchainObject.cpp:276-330 不再往 MG_Impl 的 pDefaultFramebufferInfo 里写。
  5. 两个潜伏 bug 顺带修掉:D21(m_xfbCounterSlotByObject 用裸 GL name 做键,VulkanRenderer.cpp:11136-11146)与 RenderbufferObject 缺 GetLifetimeId()。两条都先独立落 dev。
  6. 一个死能力被暴露:CapabilityInput::FramebufferSrgb 与 DepthClamp(RenderState.h:165, 168)没有任何存储——SetCapability 落到 default: // not supported currently(RenderState.cpp:380),IsCapabilityEnabled 返回 false(:428-429)。六个 backend 读点今天恒为 false。 必须在渲染状态 chunk 表冻结之前回答(它决定 pipeline/dynamic 划分里要不要这个字段)。
  7. 一次 glslang 编译离开 monolith 启动路径(Magma 的内部 shader 烘焙)。
  8. inproc = monolith 的渲染线程,且只需隔离两个进程全局——本项目手上最大的单一 CPU 杠杆。
  9. MG_Test 的 mock backend 顺理成章变成 MGPipe recorder:tools/trace_replay 获得一种比 apitrace 精确得多的 MGPipe 级录制格式(记录的是已解析的状态),而且它是 P13 之后不依赖 MG_State 的长期语义门(D-B5、开放问题 11 的答案)。