Files
MobileGL/docs/Disaggregated/notes/p7/magma-b.md
T
swung0x48 8a445a3098 [docs] (P7): every cited feat/disaggregated commit is the rewritten one without the attribution trailer
- 144 commits from 785fed0b0 onward were rewritten to drop the Co-Authored-By trailer the
  project forbids; origin/feat/disaggregated moved from 1135c664 to 3c80cd62 (same trees)
- the old->new map is ~/w7/logs/sha-map-20260922.txt; every 7..40-hex citation in the
  Disaggregated docs and the MG_Remote contracts that matched an old SHA is replaced by the
  same-length prefix of its rewrite; no other content changes
2026-09-22 20:51:15 -04:00

26 KiB
Raw Blame History

P7 wave 2-B:Magma blit / copy / mip 簇(分支 p7/magma-b)

计划见 PLAN-PH-P34B-P7.md §1.3 与 §3 wave 2-B;规范见 CONTRACT-P7.md §0(规则 I / J)、§3.2(退役 vs decline 表)、§5.1 / §5.2。基线 = feat/disaggregated 上 ~/w7/pipe 的头 801f8eda (wave 0 已落地:Magma 两进程三臂、MGPipeSessionFailHook → Session::Fail 漏斗、 scripts/link_ratchet.py)。

主机口径:WSL Arch + lavapipe(/usr/share/vulkan/icd.d/lvp_icd.json,系统唯一 ICD), build-split = Release / clang / ccache / DISAGGREGATED=ON INPROC=ON PIPE_PUSH=ON BUILD_INTEGRATION_TEST=ON,ICD 与 ~/w7/pipe/build-split 逐项相同。

基线门读数(本树实测):integration-magma-split 73(71 PASS + 2 SKIP)、-spawn 52、 -tcp 54,三臂 0 红。

0. 每片一条提交,每片一次 red-once

片 名 退役 / decline 提交
1 mipmap-shader-format-or-shape 诊断 + 颜色 shader mip 回落(退役一半:可渲染格式的不可附着图像) 见 §1
2 depth-stencil-mipmap@P7 + 烘焙 (A)(D) DEPTH-only 退役(烘焙深度 mip 程序);D|S decline 见 §2
3 copy-image-in-place@P7 退役(单次 GENERAL 转换);同级同层矩形重叠 decline 见 §3
4 default-color-blit-shape@P7 退役(恒等回落 + 旋转 scratch);四旋转之外 decline 见 §4
5 出口门 3:OpenRA 回读竞态 修复(回读自带写可见性 barrier + 等齐所有在飞提交) 见 §5

1. 片 1:mipmap-shader-format-or-shape 的诊断与颜色回落

1.1 设备证据与它为什么主机看不见

minecraft-1.21.4-fabric-iris-iterationt-in-world 与 -iterationt-nodsa-in-world 在 Redmi (2f7cbe2e,Adreno 830v2,DirectVulkan inproc,--use-pbuffer)上确定性地让 server apply 线程死于

MGPipe: Fatal{UnmigratedVerb, "Magma:mipmap-shader-format-or-shape"}

run-ahead 与 lockstep 都复现;monolith 臂通过;WSL/lavapipe 全绿。

那一行就是全部证据,而 WireFramebuffer.inc 里这个名字下挂着五个互斥的析取项(aspect 非颜色、 1D/1D_ARRAY/3D 视图、图像缺 COLOR_ATTACHMENT usage、层范围越界、格式不是采样浮点)。设备能告诉我们的 唯一一件事——是哪一个——恰恰是它没告诉我们的。

主机看不见它,原因是结构性的:lavapipe 对本集成二进制能创建的每一个颜色格式都报 BLIT_SRC|BLIT_DST,于是 GenerateWireMipmap 的 nativeBlit 恒真,两条 shader 臂在门机器上从未 执行过一次。旁边那条看起来覆盖了 shader 臂的 F1WireScenario.GenerateMipmapPackedFloatPixels 也没有: R11F_G11F_B10F 在这里同样原生 blit。一条没有任何主机车道能到达的拒绝,就是一条会发版的拒绝。

1.2 做了什么

(a) 诊断。 名字仍是第一个词、不加前缀(census、retrace 拒绝普查与所有 log grep 都按首词键控), 其后附上驱动自己的答案:格式枚举、(视图重解释时的)存储格式、extent、levels、aspect、viewType、 usage 位、层范围,以及 optimalTilingFeatures 里缺的特征位名。实测输出形如

Magma:mipmap-shader-format-or-shape format=37 extent=8x8x1 levels=4 aspect=0x1 view=1 \
    usage=0x17 layers=0+1/1 missing=DEPTH_STENCIL_ATTACHMENT

下一次设备窗口不必再推导,读一行即可指名。

(b) 颜色回落(退役的那一半)。 原来的 shader 臂直接渲染进下一级,这要求图像创建时带 COLOR_ATTACHMENT usage;而设备拒绝把某格式作为颜色附件时,VkTextureManager 就不会给它这个位—— 于是每一个「缺 BLIT 且只采样」的纹理都落进拒绝而不是回落。现在区分两种形状:

  • 格式可渲染、图像可附着 → 原臂不变(直接渲染进目标级);
  • 格式可渲染、图像不可附着 → 以目标级尺寸创建自有 scratch 颜色附件,shader 渲染进 scratch,再 vkCmdCopyImage 原样搬进该级。纯 texel 搬运,无格式转换,texel 仍来自 GPU 内容而非 staged 影子; scratch 按 multisample resolve 臂同样的形状随提交退休(共享 scratch 在此处改尺寸会毁掉更早排队的一次 pass 仍在读的图像)。

仍然拒绝且现在指名的残余:非颜色 aspect(深度归片 2)、1D/1D_ARRAY/3D、层范围越界、非采样浮点格式、 以及格式本身就不能作颜色附件(此时没有任何 shader 臂可走)。

(c) 主机可达性(测试旋钮)。 MGITEST_MAGMA_FORCE_SHADER_MIPMAP,只读一次,只在 #if MOBILEGL_BUILD_DISAGGREGATED 内编译,不进 ConfigLoader 的 accepted-env 表:

  • 1 = 丢掉原生 blit(Adreno 对缺 BLIT_DST 格式走的那条臂);
  • 2 = 再把目标当作不可附着(Adreno 对只采样纹理走的那条臂,也就是本片之前的拒绝本身)。

1.3 场景与车道

F1WireScenario.GenerateMipmapWithoutNativeBlitPixels:8×8 RGBA8、上下两半不同色(一色铺满时,丢失 GL 行序的实现会平均成同一个数,单色断言会把它判对)、glGenerateMipmap 后读 level 1 的四行,断言下两行 是下半色、上两行是上半色(边界从不落在目标 texel 内部,两色不混)。旋钮未设时 GTEST_SKIP 并写明理由。

注册在 Fm 车道的三臂上,每臂两条(ShaderMip1. / ShaderMip2.),各自一条 ENVIRONMENT—— 不是车道级翻转:旋钮改变本进程内每一次 mipmap 的生成方式,放进共享 Fm 环境会把 GenerateMipmapPixels、GenerateMipmapPackedFloatPixels、GenerateMipmapDepthPixels 与两条 base/view 用例一起改道,四条绿条目会不再测量它们被写出来测量的那条臂。

1.4 red-once(R-16,已执行并还原)

把 scratch 臂关掉(viaScratch 恒假)重建,ctest -R ShaderMip:

CTEST_RC=8
MGPipe: Fatal{UnmigratedVerb, "Magma:mipmap-shader-format-or-shape format=37 extent=8x8x1
    levels=4 aspect=0x1 view=1 usage=0x17 layers=0+1/1 missing=DEPTH_STENCIL_ATTACHMENT"}
75% tests passed, 2 tests failed out of 8

即 tier-2 条目按名死亡,且诊断把格式与形状都说出来了。还原后同一过滤器 8/8 绿。

1.5 门

integration-magma-split 75(+2)、-spawn 54(+2)、-tcp 56(+2),三臂 0 红。


2. 片 2:深度 mip 烘焙 (A) + 新鲜度门 (D)

2.1 为什么 monolith 的回落不能直接用

monolith 臂从拆分之前就带着一条深度 mip 的 shader 回落(GenerateDepthMipmapWithShader),因为 VK_FORMAT_FEATURE_BLIT_DST 对深度格式是可选的,相当多的设备不给。wire 臂没有这条回落:深度纹理 一旦不能原生 blit,第一个析取项 resource->aspect != COLOR 就把它送进 mipmap-shader-format-or-shape(片 1 的诊断现在会把它指名为 aspect=0x2 missing=COLOR_ATTACHMENT)。

monolith 那条回落不可复用,这正是 CONTRACT-P7 §5.1 (A) 的全部内容:它要造一个 GL ProgramObject、 两个 ShaderObject 和一个 GL sampler,经 uniform manager 的 global UBO 绑定,把 uSrcTexelSize 当默认块 uniform 写——四个前端对象加一次 GL 编译器调用,落在 server 的 apply 线程上。

2.2 烘焙 (A)

  • WireDepthMipmap.vert:几何与 WireColorBlit.vert 逐字相同(同一个 quad、同一个 push-constant rect、同一个 GL-bottom-left → native-image 映射),只多一个成员 ivec2 srcTexelSize。push-constant 块 必须在同一管线的每个 stage 里声明一致,所以顶点阶段也声明它,而不是拆出第二个 range。
  • WireDepthMipmap.frag:kDepthMipmapFragmentShaderSource 的移植而非重写——同样的 2×2 texelFetch 盒、同样的 clamp、同样的平均、同样的 gl_FragDepth。§3.2 的「退役」= 与 monolith 臂同 可观测,所以连它那半个 texel 的偏移(ivec2(texCoord * srcTexelSize) 落在目标中心偏半 texel)也照搬。
  • WireDepthMipmap.inc:深度附件 render pass(loadOp = DONT_CARE,pass 写满整级)+ 管线 (depthTest/Write = TRUE、compareOp = ALWAYS、无颜色附件)+ 按格式/布局缓存的 target 表,形状与 WireColorBlit.inc 逐条对齐(同样的懒建模块、同样的每提交描述符池、同 image 时两侧都 GENERAL)。 sampler 用 NEAREST:frag 自己做 2×2 盒,任何采样器过滤都会被算两遍,而且多数设备不给深度格式 linear。
  • GenerateWireMipmap 深度分支接入;WireDepthMipmapSpirv.h 由下面的门自己烘。

2.3 depth-stencil-mipmap@P7 拆成退役 + decline

DEPTH-only 走上面的烘焙程序(退役)。D|S 合并与 stencil-only 改为 decline(MGLOG_E_ONCE + return,§3.2),并且在 GrowWireTextureMipChain 之前就 return——否则会先长出一条这个动词不会填的 链,随后又把它记成 defined。

decline 而非 Fatal 的理由:monolith 也做不到。它的 shader 回落自己断言 depth-only ("shader fallback only supports depth-only textures"),而断言在两条臂都发的 release 构建里等于零。GL 也没承诺 stencil mip;这条 pass 写 gl_FragDepth,会把它刚产出的那一级的 stencil aspect 留成未定义—— 比整级不动还糟。

2.4 新鲜度门 (D):MOBILEGL_BAKED_INTERNAL_SHADERS

MG_Test/SelfTest/BakedInternalShadersTest,表驱动,8 行(WireColorBlit.{vert,frag}、 WireDepthMipmap.{vert,frag}、DriverPostIterationRPWitness.comp、 PrimitivesGeneratedNoXfbProbe.{vert,tesc,tese}),用树内 glslang 重编每一行并逐字比较。

这个门就是烘焙器。 scripts/bake_internal_shaders.py 自己不编译任何东西:它用 MOBILEGL_BAKE_INTERNAL_SHADERS 跑这个二进制,拿它吐出的字,splice 进头文件。方向是刻意的——门走 glslang 库、烘焙器 shell 出一个 glslangValidator 二进制,就是两个编译器、没有哪个说了算;等它们 的默认值哪天分叉,门会在一个没人能重新生成的头上变红。格式按头保留而不是统一:树里的头对每行几个字 和 u 后缀意见不一,统一会把一个字的改动埋进整文件 diff。

首轮实测(这是事先没人能知道的一件事):

头 结论
WireColorBlitSpirv.h 已新鲜——手跑 glslangValidator 的字与树内 glslang 逐字相同
PrimitivesGeneratedNoXfbProbeSpv.h 已新鲜(三个数组全部)
DriverPostIterationRPWitnessSpv.h 过期,按其自己的散文所说;本片重烘(2142 → 2143 字),witness magic 0x50323033 仍在(它是 .comp 里的常量,不是字的属性)

三处 provenance 注释改为指向脚本与门。

2.5 场景

  • F1WireScenario.GenerateMipmapDepthWithoutNativeBlitPixels(旋钮 tier 1,三臂): GL_DEPTH_COMPONENT24(→ X8_D24_UNORM_PACK32)与 GL_DEPTH_COMPONENT32F(→ D32_SFLOAT)各一遍, 16×16、上下两半 0.75 / 0.25,glGenerateMipmap 后按采样附件读回 level 1 的 8 行。第 3 行不断言: 移植来的滤波器带半个 texel 偏移,两种读法下第 0..2 行只来自下半、第 4..7 行只来自上半,第 3 行是跨界 带——钉它等于钉那半个 texel 而不是钉 mip。
  • F1WireScenario.GenerateMipmapDepthStencilDeclinesAndKeepsTheSession(无旋钮,三臂): GL_DEPTH24_STENCIL8 上 glGenerateMipmap 不报 GL 错、不带走会话(回读是一次穿到 server 的往返, 一并回答会话还活着与 level 0 还在)。

2.6 red-once(R-16,三条,均已执行并还原)

  1. 新鲜度门:把 WireDepthMipmap.frag 的 0.25 改成 0.2500001(不需要重建——门在运行时读源), WireDepthMipmapFragment 行在第 279 个字变红并指名重新生成的办法;还原后 8/8 绿。
  2. 深度退役:关掉深度分支 → DirectVulkan.Split.DepthMip.* 死于 Fatal{UnmigratedVerb, "Magma:mipmap-shader-format-or-shape format=125 extent=16x16x1 levels=5 aspect=0x2 view=1 usage=0x27 layers=0+1/1 missing=COLOR_ATTACHMENT"}(格式 125 = X8_D24_UNORM_PACK32,诊断把它指名了)。
  3. D|S decline:把 MagmaWireFatal 放回去 → 该用例死于 Fatal{UnmigratedVerb, "Magma:depth-stencil-mipmap@P7"}。

还原后两条 2/2 绿。

2.7 门

integration-magma-split 77(+2)、-spawn 56(+2)、-tcp 58(+2),三臂 0 红; ctest -L unit 2410(+8 = 新鲜度门的八行)全绿;信息档 integration-magma-full-split 513, 0 红 / 59 skip(基线 510,0 红 / 57 skip——两条新增用例在无旋钮的全量档自 skip)。

G1 实测过了,而且这一条事先不是显然的(片 2):DriverPostIterationRPWitnessSpv.h 是 MG_Util/SelfTest 的头,pull 构建也编它,重烘改了 2142 → 2143 个字。pull .text 仍是 0xa52203, nm --defined-only 的名字表与 ~/w7/p7-before/pull-syms.txt 逐行相同(30570 行)——字长的变化落在 .rodata,没有改动任何一条指令的编码宽度。fatal_census 绿(79 abort 站点 / 43 家族词 / 0 无标记, 本片退掉一处 Fatal{} 用法但不动 abort 站点数);link_ratchet --assert-monotone 186 不变, p7-magma 桶 101。


3. 片 3:copy-image-in-place@P7

3.1 那条 Fatal 说的是真话,但只对它下面的代码成立

glCopyImageSubData 在同一个纹理的两个子资源之间拷是普通的、有定义的 GL(4.6 core 18.3.2 还专门 写明纹理与它的 view 是同一 image 上的两个对象、允许互拷)。wire 臂为它带走整个会话,理由写在注释里: 下面那对 TRANSFER_SRC / TRANSFER_DST 描述不了一个 image——两端共用同一个被跟踪的 VkImageLayout,第二次转换会把第一次撤销。

VK_IMAGE_LAYOUT_GENERAL 能描述它(vkCmdCopyImage 两侧都接受 GENERAL)。于是:一次把整个 image 转成 GENERAL(源级与目标级都在里面)、命令两侧都 GENERAL、一次还原;access mask 带上读写两个方向, 因为这一道 barrier 是两边共同的边。

monolith 臂至今仍把所有 in-place 拷贝整体 decline(同函数上方的 !wire && 分支),所以这一片让 wire 臂比 monolith 臂多做一件事——这正是 §3.2 要求的形状,而不是对齐。

inPlace 在 pull 构建里是 constexpr false,它守的每个分支都会折掉:这个函数也编进 monolith 镜像, 而 G1 钉着那个镜像的 .text。

3.2 留下来的那一种 decline

同 image + 同 level + 同 layer + 矩形重叠:GL 4.6 core 18.3.2 说重叠处结果未定义。GENERAL 让这条 命令可记录,不让它有意义——vkCmdCopyImage 对自己在同一 region 内的读写不排序,tiler 产出的是 每个 texel 先到的那一半。所以 MGLOG_E_ONCE + return,把这句话说一次,而不是把它发出去。

判据是三个维度同时成立:同 level、slice 区间相交、矩形相交。level 0→1、层与层不相交、同一级上两个 不相交的矩形,都是有定义的,都照做。

3.3 场景

F1WireScenario 里两条(split-only,因为 monolith 没有可对照的读数),一张 8×8×2、两级的 GL_TEXTURE_2D_ARRAY,每一个被碰到的子资源都填成互不相同的颜色(level 0 layer 0 四象限、 level 0 layer 1 洋红、level 1 layer 0 青、level 1 layer 1 白)——铺平色会让「拷到了错的 level / 错的 layer / 根本没拷」都读成对的。

  • CopyImageInPlaceAcrossLevelsAndLayers:level 0 → level 1(同层)后 level 1 layer 0 变成源矩形那个 全红象限、layer 1 仍是白;再 level 0 layer 0 → layer 1(同级、层不相交)后两层都是四象限,源不变。
  • CopyImageInPlaceOverlapDeclinesAndLeavesTheLevelAlone:同级同层 (0,0)-(4,4) → (2,2),无 GL 错, level 0 layer 0 逐 texel 不变(若真执行,(4,4) 会从黄变红,断言分得出来)。

三臂各注册两条。

3.4 red-once(R-16,已执行并还原)

把 MagmaWireFatal("copy-image-in-place@P7") 放回去 → 两条都死于 Fatal{UnmigratedVerb, "Magma:copy-image-in-place@P7"};还原后 2/2 绿。

3.5 门

integration-magma-split 79(+2)、-spawn 58(+2)、-tcp 60(+2),三臂 0 红。

本树的 tcp 端口改成了 40913。 跑本片的门时 TcpServer.Start 整条 tcp 车道红了 59/60, 原因是 40613(~/w7/pipe/build-split 的默认值)上已经有一个兄弟树的 libMobileGLServer 在监听。 这是本地 configure 选项,不影响车道语义;magma-two-process-first-run.md 里 wave 0 也为同一原因 用了专用端口。


4. 片 4:default-color-blit-shape@P7

4.1 一条 Fatal 盖住了三种互不相干的情况

那条 shader pass 是为旋转存在的:它只能采样自己的源(sampler2D、float 结果、两种 2D view type 之一)。于是 RGBA8UI 附件、3D 切片、数组层都会让 BlitWireColorToDefault 返回 false——而且在完全 没有旋转的表面上也会,尽管二十行之下那条普通的 vkCmdBlitImage 臂本来就能正确完成。

拆成三块:

  1. 恒等变换 → 回落到普通 vkCmdBlitImage 臂(它已经把 GL 的底行映射到原生图像的末行);
  2. 非恒等旋转 + 采不到的源 → 把源矩形 vkCmdCopyImage 进一张自有的 2D、单采样、与源同尺寸兼容类 的采样浮点格式 scratch,再对 scratch 做 shader blit;
  3. 四个旋转之外(镜像 / 镜像旋转)→ MGLOG_E_ONCE decline 并写明是哪个 transform:两条臂都表达 不了反射,而本包没有一台会报这种 transform 的设备来核对结果。

对整数源的重解释就是重点,GL 也同意:整数与归一化格式之间的 glBlitFramebuffer 是 INVALID_OPERATION(4.6 core 18.3.1),所以没有「正确结果」可言——只有一种以前会结束会话、现在会产出源 比特的形状。对 view type 不对的浮点源,那次 copy 就是一次切片抽取,结果完全正确。

4.2 退役顺带挖出一个真 bug

普通 blit 臂把 3D 源当成层来寻址:region.srcSubresource = {aspect, source.level, source.layer, 1}, srcOffsets[].z = 0..1。3D 图像的切片在 z 上,它唯一的 subresource 是 layer 0——copy 端点 (CopyImageSliceMapping)与上面的 multisample resolve 臂早就是这么寻址的。所以从 3D 纹理第 1 片 blit 会去读第 0 片、并用一个该图像根本没有的层。

在此之前看不见:这种形状先撞上那条 Fatal,根本走不到这条臂。本片第一次跑场景时回落臂的四个象限全错、 scratch 臂全对,bug 就是这么掉出来的。两条臂现在都对。

4.3 场景

F1WireScenario.ColorBlitToDefaultFromANonSampleableSourceDegrades:源是 3D 纹理的第 1 片 (普通合法 GL,正是 shader pass 采不到的形状),第 0 片填一个任何象限都不会出现的颜色(青)——一次忘了 z 原点的 scratch copy 会去读另一片,而那样四个象限断言仍然是在断言某些真 texel,所以必须让它可分辨。

同一条用例、每臂注册两遍:不带旋钮(走恒等回落臂)与 MGITEST_MAGMA_FORCE_DEFAULT_BLIT_SCRATCH=1 (在恒等表面上走旋转臂的 scratch 路径)。两者必须产出同样的像素,这条等式就是断言——也是 scratch 臂 唯一可被主机车道到达的方式:本车道能创建的每一个表面都是 VK_SURFACE_TRANSFORM_IDENTITY,本簇的 Redmi 证据也是 pbuffer。

4.4 red-once(R-16,已执行并还原)

把 MagmaWireFatal("default-color-blit-shape@P7") 放回去 → 两条都死于 Fatal{UnmigratedVerb, "Magma:default-color-blit-shape@P7"};还原后 2/2 绿。

4.5 门与设备债

integration-magma-split 81(+2)、-spawn 60(+2)、-tcp 62(+2),三臂 0 红。

欠设备核对:非恒等旋转下的 scratch 臂只在恒等表面上被旋钮验证过(像素与回落臂相同);真正的 ROTATE_90/180/270 几何要在手机上看一眼。四旋转之外的 decline 无设备可核(没有会报镜像 transform 的 设备),按 decline 收口。


5. 片 5(集成者追加,最高优先):出口门 3 的 OpenRA 回读竞态

5.1 机制(file:line,按本片落地前的树)

两句话:

  1. WireFramebuffer.inc 的 ReadWirePixels 先 FlushPendingCommands(),那一步把整帧的 draw 以 自己的 pooled fence 提交上队列后就返回、不等(VulkanRenderer.cpp:13534-13598,成功路径上没有 vkWaitForFences)。随后回读的 vkCmdCopyImageToBuffer 被录进另一个命令缓冲、成为后一次 提交。
  2. SubmitReadbackCommandsAndWait(VulkanRenderer.cpp:10707-10748)只等自己那一次提交的 fence, 然后 OnSubmitsCompletedUpTo(frame.lastSubmitIndex) 把更早那次也记成完成并退休它的 fence。 原注释写着「the wait proved every submission complete」——那句话对 monolith 臂成立(它不先 flush, draw 与 copy 同一次提交、同一个 fence),对 wire 臂不成立。

把两次提交绑在一起的唯一东西,是回读前那道 TransitionWireImage 的 barrier;而它的源作用域来自 图像被跟踪的布局(GetImageTransitionSourceState,VulkanRenderer.cpp:1784-1825)。其中两条臂—— VK_IMAGE_LAYOUT_UNDEFINED 与 VK_IMAGE_LAYOUT_PRESENT_SRC_KHR(:1787-1791)——返回 VK_PIPELINE_STAGE_TOP_OF_PIPE_BIT 且 access 为 0,这是 Vulkan 里「什么都不等」的写法。 刚 present 过的默认帧缓冲正处在后一种布局。

于是:pipeline 创建越慢、draw 那次提交拖得越久,copy 越可能在它还没画完时就读走——帧里最后画的那些 对象缺失,正是设备上看到的样子。

5.2 主机实测(决定性,且不依赖竞态碰运气)

在 ReadWirePixels 记录 copy 的那一刻打点(OpenRA / DirectVulkan / inproc):

MBPROBE ReadWirePixels/pre: isDefault=1 layout=1 aspect=0x1 submit=72 completed=71

submit=72 completed=71——承载整帧 draw 的第 72 次提交此刻在飞、且无人等它。这不是概率事件,是 代码结构,主机上确定性可复现。

主机不发散的原因也在这一行里:layout=1 是 VK_IMAGE_LAYOUT_GENERAL,走 GetImageTransitionSourceState 的 default: 臂(ALL_COMMANDS / MEMORY_WRITE),碰巧把 copy 排 在了 draw 之后。设备上的默认帧缓冲不是 GENERAL。

集成者给的复现路径(pipeline 延迟旋钮)在主机上不成立,这里如实记录:新增 MOBILEGL_TEST_PIPELINE_CREATE_DELAY_MS(split-only、未设即惰性, VulkanRenderer.cpp GetOrCreatePipelineWithInput 的 miss 路径)确实生效——OpenRA DirectVulkan SPLIT 从 1.88 s 变 4.53 s——但 ssim 一直是 1.000000。原因就是上一段:主机那条臂的 barrier 是满的。所以 red-once 用的是 §5.4 的计数,而不是一张发散的图。

5.3 修复(两半,都只在 wire 臂)

  1. RecordWireReadbackWriteBarrier()(WireFramebuffer.inc,新):回读在 copy 之前自己记一道 ALL_COMMANDS → TRANSFER、MEMORY_WRITE → TRANSFER_READ|MEMORY_READ 的内存 barrier,不再从布局 推断该等谁。barrier 的第一作用域是本队列提交序里更早的每一条命令,所以它跨得过提交边界。
  2. SubmitReadbackCommandsAndWait 等齐所有在飞提交(VulkanRenderer.cpp,#if MOBILEGL_BUILD_DISAGGREGATED):收集 m_inFlightSubmits 里 submitIndex <= frame.lastSubmitIndex 的每一个 fence 一起等。代价是零——回读本来就是一次完整的 CPU 停顿。 pull 构建保持单 fence 原样:它没有 wire 臂、回读前不会 flush,而且是 G1 钉住的那个镜像。

5.4 red-once(R-16,已执行并还原)

打点回读实际等了几个 fence:

fences upTo completed
修复后 2 73 71
修复前 1(只有自己那一个) 73 71

completed=71 两边都一样——即第 72 次提交确实在飞。修复前只等 1 个 fence 然后把 72 记成完成;修复后 两个都等。把 §5.3 的第 2 半还原即回到 fences=1,这是确定性的、主机上的红,不靠时序。

5.5 车道

MOBILEGL_TEST_PIPELINE_CREATE_DELAY_MS=25 下的三条回读用例(VertexIdSamplerAndScalarUniformPixels draw→回读、ComputeImageStoreFramebufferPixels compute→回读、GenerateMipmapPixels)注册到三臂, 前缀 .PipelineDelay.。它们是回归门而不是复现:钉住「路径上每次 pipeline 创建都多花 25 ms 时,回读 仍然返回那次 draw 的像素」——即答案不随时序移动。

integration-magma-split 84(+3)、-spawn 63(+3)、-tcp 65(+3),三臂 0 红; OpenRA DirectVulkan SPLIT retrace ssim 1.000000(带与不带 25 ms 延迟各两遍)。

5.6 欠设备核对

集成者要在 Redmi 上重跑三遍冷 cache(pm clear 后)。预期:0.976494 → 1.000000。若仍发散,下一个嫌疑 按序是 WireDraw.inc 里十一处静默丢弃 draw 的 return false(尤其 pipeline == VK_NULL_HANDLE 那处,它与设备日志里那三行 handing vkCreateShaderModule an INVALID module for program 2 stage 4 在同一条路径上),以及 SetImageContentDefined 在 wire 臂是画之前就置位(monolith 是 EndRenderPass 之后),这会让「没有写入记录」那条警告恰好在它该响的时候不响。两条都不在本片修的范围 内,按名记在这里。