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.
35 KiB
5. 前端 state tracker
5.1 推送发生在哪里——本设计里最容易做错的一个决定
不在 GL setter 里。 glEnable(GL_BLEND) 绝不调 bind_render_state。Blaze3D 每个 batch 都用 glEnable/glDisable(GL_BLEND) 包住,代码自己标注它是最热的路径(DirectGLES.cpp:2044-2046)。天真的 per-setter 推送把每一次冗余开关变成一次接口调用加一次 server 侧 CSO 查表——严格慢于今天,而 trace 语料会在一天之内说出来。
在 validate 时刻。 就是 gallium 的 st_validate_state 形状:
// MG_Impl/Pipe/Tracker.h
class MGPipeTracker {
public:
void ValidateForDraw(const MGPValidateHint&); // 20 个 GL draw 入口调用
void ValidateForDispatch(); // glDispatchCompute*
void ValidateForClear(GLbitfield); // 只 framebuffer + blend/colormask
void ValidateForBlitOrCopy(); // 只 framebuffer + pack state
private:
Uint64 m_dirty; // MGPIPE_NEW_* 位
Uint64 m_lastPushed[kGroupCount]; // 每组上次推送的(加宽后的)版本
};
MG_Impl 里 89 个表调用站点中大约 40 个是 verb,validate 就插在这 40 个之前。只有资源 mutation 在 GL 调用时刻推送——这恰恰是 BufferBackendOps 今天的做法(BufferObject.h:70-71:"在 GL 调用时刻分发,就在 shadow 拷贝刚更新之后")。
稳态成本 = 一次 64 位 dirty word 测试 + N 次 set_*/bind_* 间接调用,N = 变脏的组数(稳态 1-3;因为 CSO 内容寻址,重复状态常常 N=0)。 这严格少于它替换掉的东西:124 / 169 次 accessor 调用 + 版本比较 + 一次 ~1.2KB 的三段 memcmp + CurrentUnitBindingsEpoch 的逐 unit owner 走查 + Magma 的两次有损版本求和。
5.2 dirty bits:全部来自已有计数器,MG_State 零新增记账
MobileGL 已经有 dirty 底盘,只是拼写成"由 backend 轮询的版本计数器"。tracker 只是把极性反过来。
| dirty 位 | 现有计数器 | 位置 |
|---|---|---|
NEW_RENDER_STATE / NEW_PIPELINE_STATE |
m_version / m_pipelineStateVersion(Uint16) |
RenderState.h:522, 529;bump 点 RenderState.cpp:311 |
NEW_VERTEX_ELEMENTS |
VertexArrayObject::GetConfigVersion()(Uint32) |
VertexArrayObject.h:155 |
NEW_VERTEX_BUFFERS |
逐属性 VertexAttributeVersion{Format, Buffer, Switch} |
:66-70, 150-151 |
NEW_INDEX_BUFFER |
索引 slot GetVersion()(回绕 Uint16)+ 绑定对象身份 |
MG_Util/Types.h:197 |
NEW_FRAMEBUFFER |
GetObjectVersion()(Uint16)+ GetAllFramebufferAttachmentVersions()(Array<Uint16,40>)+ slot 版本 |
FramebufferObject.h:145, 149, 171, 183 |
NEW_SAMPLER_VIEWS |
GetContentVersion(Uint64)+ GetShapeVersion + GetTextureParamsVersion(Uint16)+ GetTextureBindGeneration() + GetSamplingResolutionGeneration() |
TextureObject.h:61-71, 203-214;Core.h:130, 136 |
NEW_SAMPLERS |
SamplerObject::GetVersion()(Uint16) |
SamplerObject.h:532, 551 |
NEW_SHADER_IMAGES |
ImageTextureBinding::Version |
TextureState.h:24, 34 |
NEW_SHADER |
GetLinkVersion() + GetImageUnitVersion() |
ProgramObject.h:844, 906 |
NEW_SHADER_BINDINGS |
GetBackendStateVersion()、GetBlockBindingVersion()、GetUniformWriteSetVersion() |
:841, 1064, 652 |
NEW_GLOBAL_CONSTANTS |
GetUBOContentVersion()(~0u 跳过回绕) |
:791-794 |
NEW_CONST_BUFFERS / NEW_SHADER_BUFFERS / NEW_SO_TARGETS |
slot 版本 + BufferObject::GetChangeSerial()(Uint64) |
BufferObject.h:211 |
NEW_PATCH_STATE |
patch 三字段,用 BitwiseEqual 比较(NaN 合法,DirectGLES.cpp:2807-2814) |
RenderState.h:241, 247, 248 |
NEW_PIXEL_PACK |
PixelStoreParameters |
RenderState.h:190-199 |
三个回绕的 Uint16 在 tracker 边界加宽。 BindingSlot::m_version、FramebufferObject::m_objectVersion、SamplerObject::m_version 今天只有配合裸指针身份比较才正确(packed_pixels postmortem,DirectGLES.cpp:2815-2827)。m_lastPushed[] 是 tracker 自己的字段而不是 MG_State 的字段,所以加宽到 Uint32/Uint64 不需要改 MG_State 一行;同时 handle 与它同行过线。回绕在 tracker 本地是无害的(一次回绕造成一次多余的重推,永远不会造成一次漏推),因为 tracker 还额外保存推送内容的 hash 用于 CSO 查表。
5.3 每命令 validate 与固定顺序
1 set_framebuffer_state ← 必须先于 2 和 4
2 create_shader_state(link 时刻发,不是 draw 时刻) / set_draw_program
3 set_sampler_views / bind_sampler_states / set_shader_images / set_shader_buffers / set_global_constants
4 create_render_state(未命中时)/ bind_render_state
5 bind_vertex_elements_state / set_vertex_buffers / set_index_buffer / set_vertex_attrib_defaults
6 set_patch_state / set_stream_output_targets
7 draw_vbo
framebuffer 严格第一是承重的(D-B3):四个跨对象 mask 从 attachment 格式推出(Managers.cpp:5616-5619),被渲染状态推送(DirectGLES.cpp:2014)与 program 陈旧性判定(:2769-2770)消费。今天 Espryt 靠在 SyncCurrentProgram 内部自己重新推导广播数来绕开(:2712-2732,注释:否则用陈旧计数编译出的 program 要迟一个 draw 才重链)。固定推送顺序把这个 hazard 变成免费属性,那段 workaround 与 g_broadcastMemo* 一起删掉。同理 ImageUnitFormatsStillMatch(Managers.cpp:6545-6573,注释明说"不可表达为单调版本")由 set_shader_images 直接告知。
create_shader_state 从编译池的终止 continuation 发出(JobNode.h:109-123),不是从 draw 发出——这样 SPIR-V 在用到它的第一个 draw 之前就到达 server。这是 monolith 拿不到的异步收益,也是把阻塞的 JoinLinkAndSpirv() 移出 server draw path 的那一步。
5.4 合并:保留代码库已经发现的三条
- 整块结构优于逐字段。 Magma 的
ComputePipelineStateHash(VulkanRenderer.cpp:4818-4826)已经把 ~17 次 accessor 调用换成一次 bulk fetch;Espryt 的三段 memcmp 同理。tracker 发变化的段(spanMask),段划分与现有 memcmp 完全一致。 - 高水位标记。
BufferState::TouchBindPoint/GetTouchedBindPointCount(BufferState.h:49-62,每 target 84 个绑定点,理由:23-28)与TextureState::NoteUnitTouched/GetMaxTouchedUnit(Core.h:123-126,192 个单元)必须留在 tracker 的走查里——没有它们,每次 validate 都要走 84×4 个绑定点和 192 个纹理单元。它们直接就是set_shader_buffers/set_sampler_views的count实参。 - 只发 program 解析过的集合。
set_sampler_views只推 shader 真正读的那些单元,用LinkArtifacts::uniformSamplerOrImageUnitIndex(ProgramObject.h:1298)——这是 client 侧数据,所以是纯 client 收益。两个 backend 今天已经在算这个(ResolveAndBindUnitTextures,DirectGLES.cpp:2973;UniformManager::CollectSampledTextures)。
索引绑定的范围必须在 validate 时刻实时解析,不是在 bind 时刻快照。 BindingSlotRange1D::GetRange() 对整 buffer 绑定返回 Range1D(0, object->GetSize()) 而不是绑定时的快照,因为 glBindBufferBase 之后再 glBufferData 是普通应用代码,只有 glBindBufferRange 才钉死一个窗口。
5.5 sampler view 在 client 侧解析
GL 是每个 unit 每个 target 各一个绑定(TextureUnit.h:393-394;TextureState::m_textureUnits 是 Array<TextureUnit, 192> 按值存放,TextureState.h:128,而每 stage 广告上限是 32,:46),shader 看见哪一个取决于 sampler uniform 的声明类型、mipmap 完备性(IsMipmapCompleteForFilter,TextureObject.h:309;SamplesAsIncompleteTexture,:315)和 IsUndefinedDefaultTexture(:329-332)。gallium 的"每槽一个 view"就是解析后的形态。
解析留在 client。 把这本 GL 完备性规则书推给 server,就是在 server 里重建前端——正是用户否掉的那个冗余。
两处 backend 特定的后处理留在 server,并作用在已解析的集合上:Espryt 的 raw-depth-fetch sampler 替换(DirectGLES.cpp:3540-3546)与 Magma 的 feedback-loop 检测(对着 draw FBO,UniformManager.cpp:554)。两者都可以从已推送的 set_framebuffer_state + view 集合判定,所以是普通的 server 侧 lowering。
5.6 对象生命周期、共享组与 composite pipeline program
5.6.1 生命周期
resource_create 在前端对象构造时发(lifetimeId 铸造的地方),存储由 resource_respecify 惰性定义。resource_destroy 在前端对象析构时发。三条今天靠 SharedPtr 边表达、接口必须显式声明的顺序约束:
- view 先于其存储属主销毁:
ITextureObject::GetViewStorageOwner()(TextureObject.h:96-100)是SharedPtr,正是为了免费拿到 GL 的名字删除语义(glDeleteTextures(orig)之后对象因 view 引用而存活)。→MGPResourceDesc::viewOf+ server 侧 keep-alive。 - FBO attachment 钉住纹理:
FramebufferAttachmentObject::m_texture(FramebufferObject.h:95)。→set_framebuffer_state的 surface handle 隐含 server 侧 keep-alive。 - buffer texture 钉住 buffer,且范围实时解析:
TextureObjectBuffer::m_bufferBindingSlot(TextureObjectBuffer.h:46),kWholeBuffer = ~0(:28)使后续 respecify 继续被跟随,GetBufferRangeSizeInBytes(:35-41)对实时 size 钳制。→MGPResourceDesc::{bufferForTexBuffer, bufOffset, bufSize}。
5.6.2 共享组
v1:一个 screen、一个 context、一个扁平 handle 空间、一条 flow,没有 session id(理由见 §1.2 与 §4.3)。eglMakeCurrent 是 flow 所有权转移,在既有 EGLOperationMutex(EGLImpl.cpp:241)下发射——顺手修今天不取该锁的两个入口:ReleaseThread(:341-350)与 SwapInterval(:435-450)。v2 的映射是干净的(每个 share group 一个 client GLContext,每个 GL context 一个 MGPipeContext),但走独立 PR 带多 context 测试落 dev,不在本分支。
5.6.3 composite pipeline program:判过死刑的那个反对意见,答案是"什么都不用做"
GLContext::GetProgramForDraw()(Core.cpp:592)今天就已经完全在前端完成 program pipeline 的合成:join 每个 stage 的 JoinLinkAndSpirv()(:617 附近的 J1 join 点)、按 ComputeDrawProgramSignature()(:630,键是每个图形 stage 的 (lifetimeId, linkVersion))查 cache、miss 时构造故意不命名的 MakeShared<ProgramObject>(0u)(:644)、挂上每个 stage 被钉住的 linked snapshot、重装捕获 stage 的 XFB varyings、Link(true)、缓存、RefreshCompositeUniforms。
tracker 调它,拿到一个 SharedPtr<ProgramObject>,推一个 handle。合成体没有 GL name,但它有 lifetimeId,而它的 slot 从 ShaderCso 的保留高位段分配(§4.2.1)。生命周期:pipeline cache 淘汰该条目时(signature 变化或 pipeline 删除)释放 slot、gen++、发 delete_shader_state——CompositeResolver.cpp 里三行。
合成体从不过线、从不被重新实现,PLAN.md §5.7 提议往 MG_State/GLState/Core.h 加的 SetReplicaResolvedDrawProgram 钩子完全不需要——pipe 调用就是那次解析。副带收益:阻塞的 JoinLinkAndSpirv() 彻底离开 server 的 draw path。
5.7 program artifacts 与全局 UBO scratch
create_shader_state 的 payload 是 SPIR-V + 全结构体反射归档(§4.5.5),不是源码。理由是硬约束而非偏好:ProgramObject.h:11 → ShaderObject.h:12 → ShaderCompileTask.h,ShaderObject.h:146 返回 SharedPtr<glslang::TShader>,ProgramObject.h:14 → SpvcSession.h——链接真 ProgramObject 就链接 glslang。所以 RecProgramLinkOp 不可能存在,ProgramPublish 第一天就上,nm -D libMobileGLServer.so | grep glslang 为空是接口纯度门的一部分。
SPIRV-Cross 留在 server(TranspileSpirvToEssl,Managers.cpp:6575):它消费 SPIR-V 加设备事实,两者都在 server 侧。glslang 留在 client。 这是一次文件级切割,不是一次审计。
全局 UBO scratch 走独立入口(D6):set_global_constants(shaderCso, MGPBlobRef bytes, Uint32 version),键 (shaderCso.slot, uboContentVersion),复现 DirectGLES.cpp:3369-3392 的"每 program 每帧至多一次"。~0u 跳过回绕的规则保留(ProgramObject.h:792,那是 backend 的"从未上传"哨兵)。
backend 侧 program link/compile 失败不需要任何同步返回,也不需要新事件种类。 实测:SyncToBackend 在 Managers.cpp:8091 link、:8094 读 GL_LINK_STATUS、:8095 折进 m_backendProgramUsable、:8097-8101 取驱动日志、:8106 发 MGLOG_E;Use() 随后绑 program 0(:8357)并 MGLOG_E_ONCE(:8364-8372)。没有 GL error、没有 ProgramObject 变更、GL_LINK_STATUS 永不撤回——代码在四处说明理由(:7098、:7247-7249、:6478、:7827:"GL 无法撤回它已经报告为 true 的 LINK_STATUS,所以'链接了但画不出来'就是答案")。同步查询 glGetProgramiv(GL_LINK_STATUS) 与 glGetProgramInfoLog 完全由 client 从 ProgramObject 回答(GL_Program.cpp:851 → ProgramObject.h:913)。所以 on_log 逐字复现它——但由此推出一条对 PLAN.md §7.4 的强制修正,见 §7.4。
5.8 emulation 所需前端数据的显式传递
归属规则用 gallium 自己的:驱动表达不了的变换在 state tracker 里 lowering,硬件/驱动强加的变换在 driver 里 lowering,且每一条由一个具名 cap 位门控(这是 Design 1 的贡献,它让归属可审计、可推广到第三个 backend)。
| emulation | 归属 | cap 门 | 过线的是什么 |
|---|---|---|---|
client 顶点数组(Managers.cpp:2500-2592 把 attrib.Offset 当应用裸指针,每 draw 每属性上传 (first+count-1)*stride+elementSize;VulkanRenderer.cpp:3737 是唯一无界的应用指针读) |
client(它拥有地址空间) | — | 字节,永不是指针。范围 client 侧算。gallium 根本没有 client array 概念,Mesa 的 _mesa_upload_vertices 就是这么做的 |
索引扫描(TryComputeMaxIndexFromHostBytes,VulkanRenderer.cpp:3407-3470,用于给上一条定界) |
client(只有它同时持有两个数组) | — | MGPDrawInfo::minIndex/maxIndex(gallium 本来就有这两个字段),~0 = 未知 |
| client 索引数组 | client | — | MGPDrawInfo::userIndices(MGHostSpan) |
primitive-restart 重写(DirectGLES.cpp:4368-4470 整 EBO 重写,kMaxRestartRewriteBytes 上限;VulkanRenderer.cpp:4159-4161) |
client,!kCapPrimitiveRestart 时 |
kCapPrimitiveRestart / kCapPrimitiveRestartFixedIndex |
重写后的索引 + kRestartRewritten 标志。"draw 被丢弃"的诊断随之落到应用线程上,比落在 server 上更有用 |
multi-draw 展平(MultiDraw.cpp:498-540) |
CPU 档在 client(!kCapMultiDraw);compute 档留 server(kMaxComputeFlattenedIndices,:81-83,那是驱动 lowering) |
kCapMultiDraw* |
只在 cap 说需要时才发索引字节 |
*IndirectCount CPU 回退(DirectGLES.cpp:4684-4793 从 parameterBuffer->MappedData() 读实际 draw 数) |
client,!kCapMultiDrawIndirectCount 时 |
kCapMultiDrawIndirectCount |
client 从自己的 shadow 解析出计数,发解析后的 MGPDrawRange[] |
viewport-array N 遍回放(DirectGLES.cpp:3742-3846,今天包住 14 个 draw 入口) |
server(它取决于转译出来的 ESSL 有没有被注入路由 gate) | kCapViewportArray |
无新增:16 组 viewport/scissor/depth-range 已在 render-state blob 里,decline 输入(XFB active、rasterizer discard)也在 |
fp64 顶点窄化(Managers.cpp:2518-2557) |
server(这是后端格式决策) | kCapFloat64VertexAttrib(BackendObject.h:487-500 明说它与 SupportsShaderFloat64 独立) |
原始字节;IsLong 与 Type 分开过线 |
image-bindable 存储加宽/拆分(Managers.cpp:2789-2822、:4620-4630) |
server(纯驱动 workaround) | — | 正向:imageBindableHint 预防;反向:on_texture_pull_request(§7.5) |
| 生成 mipmap 的前端存储 | 拆开:client 分配 level 存储(它必须回答 glGetTexImage),server 生成 |
— | MGPMipPlan;CPU 回退路径的纹素由 on_texture_writeback 回来 |
CopyImage shadow 镜像(DirectGLES.cpp:7065-7140) |
client(给定区域,它可以用自己的两张纹理做同样的拷贝) | — | 只回"拷贝成功"。删掉一整条 server→client 字节通道 |
XFB CPU 图元计数(GL_Drawing.cpp:172,调用点 :1133, 1141, 1195, 1668) |
纯 client | kCapCpuXfbPrimitiveAccounting |
MGPDrawInfo::xfbCpuCapturedVertices + end_stream_output 的 MGPXfbAccounting。PLAN.md 的 RecXfbAccounting、MG_Remote::Shared:: helper 族、整个 gen_impl_mutation_surface.py 生成器随之删除 |
| 压缩纹理 / pixel unpack 规整 | 纯 client(GL_Texture.cpp:298-306;PixelStoreProcessor) |
— | 无 |
5.8.1 陈旧索引纪律——这条不做就是几何消失
上表里每一次 client 侧扫描/重写,在 monolith 里都紧跟在 SyncPersistentMappedRange() + SyncGpuWrites() 之后(DirectGLES.cpp:4412-4413、MultiDraw.cpp:498-499、VulkanRenderer.cpp:3431, 4159),因为 EBO 可能刚被 compute shader 或 XFB 写过。tracker 必须在完全相同的程序点做同一次 reconcile:publish → 等 appliedSeq → 排空事件 → 再碰 shadow。跳过它,maxIndex 就来自陈旧字节,顶点数组被少拷 → 几何缺失/花屏,或越界读应用数组。
门:ClientArrayAfterComputeWriteScenario(新增),必须能因它存在的理由变红(去掉那次等待就应当看到几何缺失)。
6. 后端状态机改造
6.1 什么原样不动(先说这个,因为它是"最短可信改造"的依据)
每一个 ring、pool、arena、quirk、lowering pass 原地不动:
Espryt:三条 persistent-mapped ring(UBO Managers.h:591-637、unpack :639-671、upload :673-…)、PersistentRing 的分配/背压算法、buffer pool(64MiB 预算 / 单 buffer 8MiB)、全部 7 条 fallback-repack 路径(Managers.cpp:3209-3527)、m_backendColorSlots draw-buffer 置换表(Managers.h:1183-1195)、三个 scratch FBO 及其驱动侧 attachment 影子、PackState、全部驱动绑定影子、Adreno 的"禁用属性无指针 SIGSEGV" workaround(Managers.cpp:2371-2380, 2427-2433)、Mali 的 XFB 捕获丢失 workaround(DirectGLES.cpp:400-410)、ScopedDefaultUnpackState、SPIRV-Cross 会话与 6 次 post-emission ESSL 重写、驱动 POST 自检族。
Magma:VulkanRenderer 全部 memo 与 scratch、PipelineFactory、ProgramFactory、UniformManager 的 ring 与描述符集、五个 Vk*Manager、FrameContext、SwapchainObject、DynamicStateShadow、VertexInputStateFactory 的 cache 本体、以及 D18 的节点式容器纪律。
接口改变的是 backend 怎么知道这些事实,不是它拿这些事实做什么。 这就是为什么这是最短的可信改造路径:对多数子系统,函数体不变,只有 accessor 表达式换个地方取值。
唯一两处必须真改的 MG_State 类型内部用法(PLAN.md 把它们计为零成本,两轮评审都点了名):
- Magma 的占位纹理(
UniformManager.cpp:161-181, 1416-1500, 1624-1634):构造真的TextureObject2D/TextureObject2DMultisample/TextureObject2DMultisampleArray,走SetInternalFormat(RGBA8)/AllocateStorage({1,1,1},4)/UpdateMipmapSubData/MarkStorageDirty/SetSamples(2)(VUID-RuntimeSpirv-samples-08726 禁止 MS=1 读 1-sample image)/TruncateMipmapLevels(1),唯一理由是让"未绑定单元"复用SyncTextureAndGetDescriptor(ITextureObject&)这个签名。改成 backend 自己分配VkImage+ view + descriptor:~120 行前端对象木偶戏变成 ~60 行直白的 VMA/Vulkan,43 个MOBILEGL_ASSERT(pGLContext)里的 9 个随之消失。 这是推送模型减少工作量最清楚的一处;上一轮"薄 server 不可行"的判决,是针对一个拉取接口下的判决。 - Magma 的两个内部 shader(
InitializeBlitResourcesVulkanRenderer.cpp:4210-4283、InitializeDepthMipmapResources:4287-4356):从字符串字面量构造ShaderObject、Compile()、建ProgramObject、Link(false)、读GetUniformLocation×7、断言GetUBOSize() > 0、后面MapUBO()(:8450-8452)。烘焙成 SPIR-V。 方式:把生成的 SPIR-V、uniform location、UBO 布局作为生成头文件签进树里,用一个MG_Test用例重跑树内 glslang 对同一批源码字符串并逐字节比对来守新鲜度。 不用构建期 host glslang target——那需要一个 host 工具目标,在 Android NDK 交叉构建、WSL、Windows、macOS 四条 lane 上以四种不同方式坏掉。签进树 + 测试守卫是本仓库对 golden 已经在用的模式。uSource的描述符绑定本来就是由ProgramFactory自己的 SPIRV-Reflect 走查找到的(:4340-4350),原样存活。顺带把一次 glslang 编译从 monolith 启动路径上删掉。
Espryt 有一个小号同类:g_rawDepthFetchSamplerState,一个独立的 SamplerObject(0) 被 SyncToBackend 驱动(DirectGLES.cpp:166-179)→ 改写成 backend 原生的 sampler 记录,~40 行。
6.2 strangler 脚手架:PipeInputs + 两个填充器 + poison mask
// MG_Backend/MGPipe/PipeInputs.h (backend 私有;迁移结束后不 include 任何 MG_State 头)
namespace MobileGL::MG_Pipe {
struct PipeInputs {
// 阶段 A:字段类型与 backend 今天读到的**完全一致**
const RenderStateParameters& GetRenderStateParameters() const;
Uint16 GetRenderStateParametersVersion() const;
const MGPVaoRec& GetBoundVertexArray() const;
// … 每个 backend 真正用到的 GLContext 方法一个访问器(Espryt 32 个 / Magma 55 个)
#if MOBILEGL_DEBUG || MOBILEGL_BUILD_DISAGGREGATED
Uint64 m_filledMask[2]; // G5 生成;读未填字段 = Fatal{UnmigratedPipeInput, "<field>"}
#endif
};
extern PipeInputs gPipeInputs;
}
#if MOBILEGL_PIPE_PUSH
# define MGB_CTX (&::MobileGL::MG_Pipe::gPipeInputs)
#else
# define MGB_CTX (::MG_State::pGLContext)
#endif
PipeInputs 按 memo 键组织,不是按读点组织。 这是它只有 ~20KB、且字段集在整个迁移期稳定的原因(Design 2 的贡献)。每 draw 实际推送的增量由 §5.4 的两个高水位标记定界。
6.2.1 三个阶段,其中阶段 A 可证明是 no-op
| 阶段 | 改什么 | 怎么证明 |
|---|---|---|
| A — 别名 | 机械 sed:MG_State::pGLContext-> → MGB_CTX->(293 处);外加手工转换 58 行非箭头用法(§2.4,其中 DirectGLES.cpp:146 的 .get() 裸指针捕获 sed 完全抓不到)。SnapshotFromGLContext() 在 PrepareForDraw(DirectGLES.cpp:2916)/ SetupDraw(VulkanRenderer.cpp:6371)顶端填 gPipeInputs。backend 函数体其余部分不变 |
nm --defined-only + 剥调试信息后的 .text size diff 在 pull 构建里一致 |
| B — 推送 | tracker 填 gPipeInputs;SnapshotFromGLContext() 仍在,按 MOBILEGL_PIPE_PUSH 位图逐字段让位 |
MOBILEGL_PIPE_VERIFY=1(§10.3-2):tracker 再填一份快照版,G4 生成的比对器逐字段每 draw 比一次,打印第一个分歧字段名与 draw 序号 |
| C — handle 化 | SharedPtr<FrontendObject> 字段 → MGPipeHandle + POD 描述符;memo 重键;写回变回调 |
全套门(§10.3) |
迁移粒度是一个 accessor,不是一个子系统。 §6.4/§6.5 的阶段划分只是方便的批次,不是硬边界。
6.2.2 poison mask 是完整性的运行期绊线
m_filledMask 由 G5 从 PipeCalls.def 生成。在 debug 与 disaggregated 构建里,读一个 tracker 从未推送的字段是 Fatal{UnmigratedPipeInput, "GetStencilState"},发生在第一个 draw 上——响亮、精确、不可能渲染过去。迁移结束(P13)删掉 SnapshotFromGLContext() 之后,完整性变成构建期事实:一个从未被写入的字段就是一个编译器能标出来的字段。
这就是历次评审所说"reconciler 完整性只有测试绊线、没有构建绊线"的答案,而它只有在"接口先落在 monolith 里"时才存在。
6.3 Track V / Track H 与残余值块
32 个 Espryt accessor 与 55 个 Magma accessor 分两条轨:
- Track V(值类型):
GetRenderStateParameters、GetPixelStoreParameters、IsCapabilityEnabled(+Indexed)、GetStencilState、GetColorMaskIndexed、GetDepthMask、GetScissorBox、GetPatchVertices、GetCurrentVertexAttribute、Magma 的 ~22 个标量 getter…… 约占 B 类读点的 55%。它们完全不需要重塑形状:client memcpy 值,server 把自己那份的引用交给 backend。机械,每组 ~1 天。 - Track H(对象类型):167 个
SharedPtr<MG_State…>点。真活:参数类型变MGPipeHandle+const MGPXRec*,twin registry 变 slot 数组,memo 重键。
Track V 的 55% 不需要逐字段接口条目就能跑起来,所以 P2 发一个显式临时调用:
void (*set_residual_value_state)(MGPBlobRef); // 尚未迁移的值类 accessor 的并集,~1.5KiB
struct ResidualValueBlock {
RenderStateParameters renderState; // 直到 create/bind_render_state 落地
PixelStoreParameters pack; // 直到 set_pixel_pack_state 落地
Uint64 capabilityBits; // 直到能力位折进 render-state CSO
Uint32 patchVertices; Float patchOuter[4], patchInner[2];
// … 每个阶段变小 …
};
三条硬性纪律,缺一不可:
- 它是显式临时的,退役是一个编译错误。
static_assert(sizeof(ResidualValueBlock) == MGL_RESIDUAL_BLOCK_SIZE),常量每阶段下调;P13 到 0 之后static_assert(sizeof(ResidualValueBlock) == 0, "残余块必须清空才能删除 pull 路径")一直红到最后一个字段消失。本设计里没有任何"临时重复"是没有具名退役绊线的。 - 布局必须逐成员断言,不能只断言 sizeof。 这是一个异质 POD 的并集,跨编译器/ABI 边界最容易出的正是 padding 差异,而 monolith 的 verify harness 看不见它(monolith 里两侧是同一个 TU)。所以:G3 为块内每个成员生成
static_assert(offsetof(ResidualValueBlock, X) == N),并且在 split 模式下该块逐字段序列化(走 G3 生成的编解码器)而不是整块 memcpy。 - 它只在 P2..P13 之间存在,且
MOBILEGL_PIPE_STATS必须把它的字节量单独计一类,让"它还有多大"是一个可见指标。
6.4 DirectGLES(Espryt)逐子系统
PrepareForDraw 的阶段顺序(DirectGLES.cpp:2916-2975,工作树行号):GetBoundVertexArray → ResolveVaoTwin → GetProgramForDraw(join 编译池)→ CaptureDrawTextureSyncKeys → SyncNeccessaryBuffers → SyncCurrentVAO → SyncNeccessaryTextures → SyncImageTextureBindingsForDraw → MarkWritableImageBufferTexturesGpuWritten(改前端)→ SyncCurrentFBO → SyncCurrentProgram → SyncRenderState → BindCurrentFBO → VAO bind → SyncCurrentVertexAttributeValues → BindCurrentTextures → BindCurrentProgramWithResources → StartPendingTransformFeedback。
| # | 子系统 | 消除读点 | memo | 写回 | 轨 | 天 | 风险 |
|---|---|---|---|---|---|---|---|
| 0a | GetIntegeri_v/GetInteger64i_v/GetProgramiv 移回 MG_Impl |
14 | 0 | 0 | — | 1-2 | 极低(严格 no-op) |
| 0b | handle 基建;6 个 registry → slot 数组;删 TwinLookupMemo×3 / OwnerEquals / g_fbSlotCache / 2 个 GC 扫描 |
— | 9 删 | — | — | 5-7 | 低 |
| 1 | 渲染状态(DirectGLES.cpp:1962-2654,693 行) |
4(:2007, 2021, 2050, 2133) |
0(本来就是值键) | 0 | V | 3-5 | 低:693 行函数体、三段 memcmp、g_syncedColorMaskAlphaWidenMask、dual-source decline 全部不动,只有取值表达式换地方 |
| 2 | buffer + 7 个 BufferBackendOps |
19 | 3 | 6(+23 处 re-entry 删除) | H | 10-13 | 高(但不碰 AcquirePersistentMap) |
| 3 | VAO / vertex elements | 2(+~10 getter) | 4 | 0(Espryt 不往任何前端对象写 memo,已核实) | H | 7-9 | 中 |
| 4 | framebuffer / renderbuffer | 8 + 4 处 pDefaultFramebufferInfo |
4 | 1 | H | 7-9 | 中高 |
| 5 | 纹理 / sampler / image unit | 18(+~35 getter) | 8(5 删) | 21 | H | 20-26 | 高 |
| 6 | program + constant buffer | 16(+~30 getter) | 5 | 0 | H | 14-18 | 高 |
| 7 | XFB | 3 | 1 | 2 | H | 5-7 | 中 |
| 8 | emulation + MGHostSpan |
~12 | 0 | 3 | — | 8-11 | 中 |
| 9 | 回读 / pack state | ~10 | 1 | 7 | V+H | 5-7 | 中 |
| 10 | 删 pull 路径 + MGB_CTX |
— | — | — | — | 4-6 | 低 |
| 合计 | 124 | ~32 | 28 | 89-120 |
子系统 5 是全表最危险的一处,因为它同时压着实测 +6ms/frame 的 box-vs-rects 悬崖(Managers.cpp:4311-4319)与 7 条 fallback-repack 路径(其可行性判定要求 uploadData == mipData,即驱动用 UNPACK_ROW_LENGTH 跨步直接读前端字节)。缓解:resource_subdata 同时携带 box 与 rect 列表、server 选形状(§4.5.6);repack 族原地不动,只把输入从"拉取的 shadow 指针"换成 MGPBlobRef(monolith 里是同一个指针);子系统 5 拆成两个可独立落地的半(先 sampler view + sampler,再 image unit + dirty 归属反转),让回归能二分到其中一半。
6.5 DirectVulkan(Magma)逐子系统
| # | 子系统 | 读点 | memo | 写回 | 天 | 风险 |
|---|---|---|---|---|---|---|
| 0a/0b | 同 Espryt;13 个身份缓存重键 | ~10 | 13 | 0 | 5-8 | 低 |
| 1 | pipeline + 动态状态(ComputePipelineStateHash :4818、ApplyDynamicDrawStateTail :5871、GetOrCreatePipeline :4948) |
~55 | 1 | 0 | 3-4 | 低——两个 backend 里最便宜的一次转换 |
| 2 | SetupDraw + TrySetupDrawFastPath(:5994,377 行)+ SetupDrawSnapshot[4] |
~48 | 4 | 0 | 10-13 | 高 |
| 3 | VkBufferManager(7 个 op 里的 6 个;ResidentSubData 保持 null) |
~19 | 2 | 4 | 7-9 | 高 |
| 4 | VertexInputStateFactory + VaoDrawMemo(删掉写进前端 VAO 的后端堆裸指针) |
~6 | 2 | 3 | 2-3 | 低(纯结构性收益) |
| 5 | VkTextureManager(3504 行)+ VkSamplerManager |
~30 | 3 | 7 | 13-16 | 高 |
| 6 | UniformManager 描述符 + 占位纹理原生化(§6.1-1) |
~35 | 4 | 6,且删 ~120 行 | 12-15 | 高 |
| 7 | VkRenderPassManager / VkClearManager / framebuffer(保留 D18 的节点式容器) |
~20 | 2 | 0 | 7-9 | 中高 |
| 8 | ProgramFactory + 内部 shader 烘焙(§6.1-2,含 4 天烘焙与回归测试) |
~15 | 1 | 2 | 7-9 | 中(构建 lane) |
| 9 | XFB(顺带修 D21)+ query + 回读 | ~15 | 2 | 5 | 11-14 | 中 |
| 10 | swapchain / default FBO(SwapchainObject.cpp:276-330 的写变 on_surface_changed) |
~4 | 0 | 7 | 4-5 | 中 |
| 11 | 删 pull 路径 | — | — | — | 4-6 | 低 |
| 合计 | 169 | ~34 | 42 | 85-111 |
Espryt 的子系统 1 与 Magma 的子系统 1 作为一个里程碑一起做(合计 6-9 天),这样同一个接口调用在两个 backend 上同时被证明。它们是两个 backend 里最便宜的转换,而且它们就是整个机制本身。
6.6 strangler 顺序(风险最小化),每步的门
0a getter 移出(AdvertisedLimitsScenario,6 个测试;严格 no-op)
0b 字节/调用计数器落地 ← 树里今天没有任何 per-frame 字节度量
0c 清工作树 per-draw fprintf(DirectGLES.cpp:290-303、Managers.cpp:875-877)
0d handle 基建:slot 分配器 + registry 变数组 + 删 TwinLookupMemo/OwnerEquals/g_fbSlotCache/GC
1 渲染状态(两个 backend 一起) ← 机制证明,最便宜的面
2 buffer + BufferBackendOps ← 泛化已存在的模式;不碰 AcquirePersistentMap
3 VAO / vertex elements
4 framebuffer(**在纹理之前**) ← 它发布四个派生 mask
5 纹理 / sampler / image unit ← 最大;删除量也最大
6 program + constant buffer ← 需要 4 与 5 才能退役陈旧性判定的 4-6、8-9 条
7 XFB + query + 回读 ← 与 5/6 并行(第二个工程师)
8 emulation
9 删 pull 路径;纯度门转绿
framebuffer 必须在纹理之前(D-B3)。0b 必须在任何迁移之前:所有 ring 尺寸、批处理阈值、render-state wire 粒度决策否则都是猜测。0c 必须在基线之前:那两处 per-draw fprintf 污染每一次测量,而 Feat/CS-Delta-IPC 的 d96be9f3 提交过同类东西,导致该分支上每一次测量都跑在每 draw 一次 stderr 写的构建上。
6.7 A/B:旧路径怎么保留
MOBILEGL_PIPE_PUSH = <子系统位图> # 0 = 全 pull;每位一个子系统
MOBILEGL_PIPE_VERIFY = 0|1 # 影子比对(~5-10x 慢,永不出货)
MOBILEGL_PIPE_STATS = 0|1 # 字节/调用/roundtrip/纹理拉取计数器
在 init 时刻锁存,与 MOBILEGL_BACKEND_TYPE 完全同一套机制(ConfigLoader.cpp:212-225:一个 Config 字段 + 一行 QueryEnvUint32),并与树里已有的 ~40 个 MOBILEGL_* 开关并列(Config.h:70-303,其中已有多个是运行时路径切换:MOBILEGL_ESPRYT_DISABLE_UPLOAD_RING :186-191、_UBO_RING :177-180、MOBILEGL_ESPRYT_MULTIDRAW_MODE :237-241)。
pull 路径一直编译在里面直到 P13,所以:
- 任何一次提交都能在同一份二进制上按子系统 A/B,两个 backend 都行。这比整后端级开关细得多。
- 设备回归可以二分到"哪个子系统"而不只是"哪个提交"。
- trace 工装本来就有 per-case 行为开关的先例(
TRACE_COHERENT_AS_FLUSH→--coherent-as-flush,run_trace_case.cmake:26-29),--pipe-push照抄那三行。
P13 删除 pull 路径时:删 SnapshotFromGLContext()、MGB_CTX 宏、MOBILEGL_PIPE_PUSH(推送成为唯一路径),保留 MOBILEGL_PIPE_VERIFY 的工装给后续工作;static_assert(sizeof(ResidualValueBlock) == 0) 必须编译通过;纯度门(§4.7.2)转绿。