性能优化-TBDR 与 Subpass:片上数据复用与生命周期

文章目录

在移动端优化渲染管线时,经常能看到这样的解释:

“普通 Layout Transition 会把 tile 数据写回 DRAM、刷新缓存并清空管线;换成 Subpass 后,只改几个寄存器,数据直接进入 ALU,几乎没有成本。”

这个说法抓住了一个真实机会,却把机会描述成了硬件保证。

真实机会是:一个像素的中间结果,如果只交给后续同位置的片元使用,并且用完就能丢弃,那么驱动可能完全没有必要把这个结果保存成一张供后续阶段访问的外存图像。

但能不能做到、做到以后帧时是否下降,都不是由 vkCmdNextSubpass 这个函数名决定的。

这篇文章从“数据为什么要出去”开始,逐层解释怎样让它留下来,以及怎样证明这种改动值得进入正式管线。


一、先把 Layout、同步和驻留位置拆开

讨论底层前,要分清三个问题。

层次 它回答什么 它不直接保证什么
Layout 这个 image subresource 当前允许以什么用途访问 数据现在一定在 SRAM、L2 或 DRAM
同步依赖 哪些操作必须先完成,哪些写入需要对后续读取可见 一定执行某条固定的 cache flush 指令
物理实现 使用 GMEM/tile buffer 还是系统内存,是否合并、压缩、保留或溢出 所有 GPU、驱动与内容都采用同一策略

COLOR_ATTACHMENT_OPTIMALSHADER_READ_ONLY_OPTIMAL 是访问状态合同,不是“SRAM 地址”和“DRAM 地址”的两个别名。Vulkan 的 Layout 定义

还要避免另一个混淆:VkImageCreateInfo::tilingVkImageLayout 不是同一件事。把图像切到 shader-read layout,不等于把 VK_IMAGE_TILING_OPTIMAL 的图像改造成线性纹理。图像创建参数

反过来,即使始终使用 GENERAL,写后读依赖也不会消失。枚举没有变化,不代表前面的写入已经对后面的读取可见。

本文所说的“Subpass 内 Transition”,严格地说是同一个 render pass 内,subpass 之间的自动布局转换与数据依赖vkCmdNextSubpass 是推进到下一个 subpass,不是在同一个 subpass 里任意改变布局的通用工具。


二、普通 Layout Transition:重的到底是什么?

考虑一条常见的延迟着色链路:

1
2
3
4
5
6
7
RenderPass A:Geometry 写 G-Buffer

结束 A,保留后面需要的中间结果

布局与内存依赖:ColorAttachmentWrite → SampledRead

RenderPass B:Lighting 读取 G-Buffer,写 HDR

这个结构的昂贵之处,是中间结果要跨越两个独立渲染阶段的生命周期,而不只是多写了一个 barrier。

1. Tile Store 往往来自“结果还得活着”

在采用分块路径的设备上,如果 A 结束后 B 还要从普通纹理访问 G-Buffer,A 通常需要把相关结果保留下来。后续采样可能经过片上缓存,也可能访问外部内存。

在一个容易理解的模型中,它表现为:

1
片上颜色/深度结果 → 保存到图像存储 → 后续纹理读取

但不能把这个模型写成“每次改变 Layout 都必然发生整图 DRAM 写入和读取”。是否真正访问 DRAM,受 render-pass 结束方式、Store 策略、覆盖率、缓存和实现优化影响。

移动 UMA 场景中,通常应说“外部系统 DRAM”,而不是暗示存在一块独立显卡 VRAM。

2. AFBC / UBWC 不是每条 Transition 都要重做的包装工序

压缩图像确实涉及数据块和元数据,但它们怎样被维护,是具体实现的工作,不能从一对 Layout 枚举推导出固定的“重新压缩整屏”流程。

一个有用的反例是 Khronos 的 Mali 布局转换示例:在该示例讨论的 transaction-elimination 条件下,color-attachment 和 shader-read-only 都属于可以保持签名有效的布局。经由不合适的布局反而可能破坏优化。Layout transitions 示例

这里的 transaction-elimination 签名,与 AFBC/UBWC 的压缩头也不是同一种元数据,不能混为一谈。

3. Availability / Visibility 不等于清空所有 L1/L2

应用真正需要表达的是:指定的颜色写入要完成,并对后续指定的 shader 读取可见。

实现可能据此刷新、失效或协调相关缓存,也可能利用已有一致性路径完成。Vulkan 没有要求应用把每一次这样的依赖理解成“整颗 GPU 的 L1、L2 全部失效”。

Adreno 的公开 Mesa 驱动文档就区分了 CCU、UCHE 与纹理侧缓存等路径,同时明确描述了 GMEM 与 SYSMEM 两种渲染方式。这样的结构已经比“统一 L1/L2 全刷一遍”的模型复杂得多;它也不能直接代表所有 Qualcomm 私有驱动的实现。Mesa Freedreno/Turnip 架构说明

4. Pipeline Barrier 不等于 Device Wait Idle

假设 A 的 finalLayout 暂时保留为 color-attachment layout,同一 queue 上的颜色图像写后读关系可以这样表达:

1
2
3
4
5
6
7
8
9
10
结束渲染阶段 A

在 GPU 命令流中声明依赖:
资源范围 = G-Buffer 中实际使用的颜色 mip / layer
生产者 = 颜色附件输出阶段的写入
消费者 = 片元着色阶段的普通纹理读取
布局变化 = 颜色附件布局 → Shader 只读布局
队列关系 = 同一队列,不转移所有权

继续记录渲染阶段 B

关键是源端准确描述 attachment write,目标端准确描述 fragment sampled read。在支持同步 2 的 Vulkan 实现中,可用 VkImageMemoryBarrier2 表达这一关系;这里声明的是 GPU 依赖,不是让 CPU 执行一次阻塞等待。VkImageMemoryBarrier2

它会约束目标同步范围内的后续工作,但不等于要求 CPU 等待,也不等于所有 Vertex/Compute/Fragment 工作全部清空。没有被同步范围包含的阶段,可能继续与其他工作重叠。

如果 A 已经通过 finalLayout 完成转换,就不能在后面的 barrier 里继续填写过时的 oldLayout。布局转换可以有不同的声明位置,真实的资源依赖仍然需要被完整表达。

同样,UNDEFINED 只表示可以丢弃旧内容,不允许覆盖仍在被上一帧使用的图像。跨帧复用和内存 alias 的访问危险,不能靠填写 UNDEFINED 来“洗掉”。

所以,优化时应把“跨阶段保存结果的成本”与“同步范围过宽的成本”分开检查。只缩窄 barrier,未必能取消前一项;只把 layout 换成 GENERAL,也解决不了后一项。


三、Subpass 的真正价值:把一般纹理读取变成局部使用合同

把刚才的链路改成:

1
2
3
4
一个 VkRenderPass
Subpass 0:Geometry 写 G-Buffer
Subpass 1:Lighting 用 input attachment 读取同位置的 G-Buffer
结束:只保留 HDR;不再需要的 G-Buffer 丢弃

这里交给驱动的信息,比“下一步会读取这张纹理”强得多:

  • 生产者和消费者都在同一个已知的渲染范围内。
  • 消费者读取当前片元位置对应的数据,而不是任意 UV。
  • 子通道之间的写后读关系明确。
  • 某些附件在消费者结束后不再需要。

这些限制,才是保留片上结果的基础。

1. BY_REGION 描述的是 framebuffer-local,不是固定 16×16 屏障

VK_DEPENDENCY_BY_REGION_BIT 允许以重叠的 framebuffer 坐标区域表达依赖。对这里的同像素、单采样案例,可以把它理解为:Lighting 在某个像素读取前,Geometry 对应位置的写入必须已经满足依赖。

不能把它写成“规范要求先等整个 16×16 tile 的所有 Fragment Shader 完成”。tile 尺寸和调度方式由实现决定;framebuffer-local 是 API 的依赖范围,不是某款硬件 tile 大小的别名。Vulkan 同步模型

允许的局部执行关系可以示意为:

1
2
3
Tile A:Geometry(A) → 对应结果可读 → Lighting(A)
Tile B:Geometry(B) → 对应结果可读 → Lighting(B)
Tile C:Geometry(C) → 对应结果可读 → Lighting(C)

这是数据依赖示意,不是 GPU 必须逐行采用的固定调度表。

更重要的是,局部依赖仍然是内存依赖。它仍涉及执行顺序、availability 和 visibility。局部,不等于没有同步;没有 DRAM 往返,也不等于没有片上访问等待。

2. subpassLoad 限制了访问方式,但不承诺某条跨厂商指令

普通纹理允许指定坐标、LOD、过滤等访问方式。subpassLoad() 则从当前 fragment 的隐含位置读取 input attachment;多采样版本还需要指定 sample。

1
2
读取单采样输入附件 A          → 当前片元位置的值
读取多采样输入附件 B 的样本 s → 当前片元位置的第 s 个样本值

没有任意 UV,不做普通 sampler 的邻域过滤,这是语义上的差别。GLSL subpass-input 函数

但下面三件事不能画等号:

1
2
3
4
没有普通纹理采样语义
≠ 所有硬件都采用同一种物理数据通路
≠ 所有 Adreno 都生成一条名为 GMEM_READ 的公开通用 ISA 指令
≠ ALU 可以用固定几个周期把任意 tile 数据拿到寄存器

“最终进入寄存器”不表示访问由 ALU 本身完成。具体可能涉及 attachment/tile-buffer 专用路径、加载单元、依赖跟踪和后端编译选择。没有明确 GPU 代际、驱动和反汇编证据,就不应把某个指令名称或周期数写成通用结论。

普通纹理访问也不总是在做双线性插值:nearest sampler 与 texelFetch 就是反例。因此,公平的比较应先匹配两条路径的读取语义,而不是给普通纹理一方凭空加上过滤成本。

3. 自动 Layout Transition 仍然存在

同一附件可以在 Subpass 0 作为 color output,在 Subpass 1 作为 input attachment。每个 subpass 的 attachment reference 指定它需要的 layout,render pass 负责在相应使用之间安排自动转换。

对附件而言,subpass dependency 同时表达这些阶段之间的内存关系;它不是仅仅换一份描述符。VkSubpassDependency 的附件语义

理想合并时,驱动可能复用片上分配,甚至不需要为 NextSubpass 生成一个独立的硬件切换动作。但这不代表消费者 shader、数据依赖和附件访问都变成了 NOP。

NextSubpass 的命令开销可以被消去;像素之间真实的生产和消费关系不能被消去。

4. “同一个 VkRenderPass”不保证“一个硬件渲染 Pass”

驱动是否合并、采用哪条物理路径,还取决于设备、附件配置和工作负载。Qualcomm 的指导明确将 subpass 合并与 binning/GMEM 模式联系起来,并建议通过渲染阶段 trace 检查;这不是对任何 render pass 都无条件生效的承诺。Adreno 移动端最佳实践

因此更准确的对比是:

问题 独立 Pass + 普通读取 合并成功的局部读取路径
中间结果怎样存活 需要跨渲染阶段保留 可以在片上完成消费后丢弃
消费范围 可以访问任意纹理位置 经典 input attachment 限于当前片元位置
内存依赖 必须正确表达 同样必须正确表达,但可局部化
实际 DRAM 流量 受保存、缓存、压缩等因素影响 有机会取消中间结果的外存往返
切换成本 由结构、同步与实现决定 可能很低,但没有统一周期保证
是否一定更快 不保证 也不保证,必须测量

四、先挑对工作,Subpass 才有意义

在改 Vulkan 代码前,先问消费者到底需要什么。

工作 典型读取方式 适合经典 input attachment 吗?
G-Buffer → Lighting 当前像素的材质、法线、深度 适合优先验证
纯同像素颜色变换 当前像素颜色,其他输入已就绪 可以评估
普通透明叠加 固定功能混合读取目标颜色 先共用 scope,不一定需要新 subpass
Bloom、模糊、FXAA 邻域采样、过滤或缩放 不能直接替换为 subpassLoad
SSAO / GTAO、SSR 邻域深度、步进、跨像素读取 不能按普通局部输入处理
折射 偏移位置的背景颜色 不能用当前像素读取冒充
直方图 / 全局曝光统计 跨像素归约 不是经典局部依赖能独立解决的任务

一个常见陷阱是:“Tone Mapping 本身同像素,所以一定能并到前面的 Lighting。”

如果本帧 Tone Mapping 还依赖刚生成的 Bloom 或全局曝光统计,依赖链就不再只有当前像素。必须先把这些输入的生产时机算清楚。

同样,给邻域算法加一个 BY_REGION,不会使它变得局部。这样做可能只是让同步声明不再覆盖真实依赖。


五、一个可以落到工程里的双 Subpass 案例

下面采用单采样、非 multiview 的 Geometry → Lighting 案例。先把数学语义与资源合同做对,再讨论 MSAA 或更多阶段。

本例约定 Geometry 开启 depth test / depth write、使用 LESS 比较;Lighting 关闭固定功能深度/模板测试与混合,以全屏绘制定义每个输出像素。这样清到 1 的正向深度可以作为背景标记。反向 Z、light volume 或不同覆盖策略需要相应调整,不能只照搬背景判断。

1. 定义附件,而不是先写 NextSubpass

Framebuffer 附件编号 内容 格式示例 首次 Load RenderPass 结束 Store
0 Albedo RGB + Roughness A RGBA8_UNORM CLEAR DONT_CARE
1 编码后的 Normal + 小量标记 A2B10G10R10_UNORM_PACK32 CLEAR DONT_CARE
2 Depth D32_SFLOAT CLEAR,正向 Z 清到 1 DONT_CARE
3 Lighting HDR RGBA16F DONT_CARE,须保证完整写入 STORE

这里的深度 DONT_CARE 有明确前提:后面没有 AO、SSR、透明深度测试、调试读取等消费者需要它。 若需要,就单独保留深度并重新计算收益,不能为了好看的预算强制丢弃。

G-Buffer 使用 CLEAR,是为了让全屏 Lighting 读取未覆盖背景时有定义。只有已经证明读取覆盖范围安全,才应进一步考虑 DONT_CARE。HDR 使用 DONT_CARE,则要求后续全屏输出确实覆盖整个 render area,并定义背景值。

2. 给中间结果完整的 transient 合同

1
2
3
4
5
6
7
G0 / G1 的用途 = {颜色附件, 输入附件, 瞬时资源}
Depth 的用途 = {深度附件, 输入附件, 瞬时资源}
HDR 的用途 = {颜色附件, 普通纹理读取}

生命周期:
G0 / G1 / Depth:本 render pass 内生产并消费,用完丢弃
HDR:结束后仍交给后处理,因此保留

这些中间附件不需要为了“以后可能有用”再加 SAMPLED、STORAGE 或 TRANSFER 标志。传统 transient image 的 usage 组合有明确限制;如果后来真的要把它当普通纹理、截图来源或存储图像使用,应重新定义生命周期和 usage。图像创建的 usage 约束

分配时优先选择与该图像 memory requirements 兼容的 LAZILY_ALLOCATED 类型。没有兼容类型时,可以回退到普通设备内存;算法仍应正确,但不能继续声称省掉了物理 backing allocation。

必须同时检查:

1
2
3
4
5
访问只在本 render pass 内
+ 合法的 transient usage
+ 正确的 Load/Store
+ 可用的内存类型
+ 驱动实际合并

只把一个资源标记为“临时纹理”,不构成上述任何保证。

3. 明确每个 subpass 的附件引用

先用编号说明每个阶段在读写谁,再把这些引用映射到 Vulkan 的 attachment reference:

1
2
3
4
5
6
7
8
9
10
11
Subpass 0:Geometry
颜色输出 0 → 附件 0:G0,颜色附件布局
颜色输出 1 → 附件 1:G1,颜色附件布局
深度附件 → 附件 2:Depth,可写深度布局

Subpass 1:Lighting
输入附件 0 → 附件 0:G0,Shader 只读布局
输入附件 1 → 附件 1:G1,Shader 只读布局
输入附件 2 → 附件 2:Depth,只读深度布局
颜色输出 0 → 附件 3:HDR,颜色附件布局
深度附件 → 附件 2:仍绑定同一张 Depth,但只读

这个例子保留同一个深度附件绑定,Lighting pipeline 的固定功能 depth test / depth write 均关闭;shader 通过 input attachment 读取深度。不能只改 reference 为只读,却让 pipeline 继续写深度。

建议让 G0/G1 的 initialLayout 为 UNDEFINED、finalLayout 与最后的 shader-read 使用一致;Depth 最后保持只读 layout。HDR 在本例中以 COLOR_ATTACHMENT_OPTIMAL 结束,后处理前再建立外部读取依赖。

finalLayout = SHADER_READ_ONLY_OPTIMALstoreOp = DONT_CARE 并不矛盾:一个描述最终状态,另一个表示结束后不需要保留内容。前者不赋予后续读取有效数据的权利。

4. 最关键的依赖:写的是附件,不是 storage image

1
2
3
4
5
6
7
8
9
建立依赖:Subpass 0 → Subpass 1

源阶段 = 颜色附件输出 + Early / Late 深度测试
源访问 = 颜色附件写入 + 深度附件写入

目标阶段 = Fragment Shader
目标访问 = Input Attachment 读取

同步范围 = framebuffer-local(BY_REGION)

这段依赖对应三种明确操作:颜色附件写、深度附件写、片元阶段 input attachment 读。映射到 Vulkan 时,目标访问应明确表达 INPUT_ATTACHMENT_READ,局部范围使用 VK_DEPENDENCY_BY_REGION_BITVkSubpassDependency2

不要因为双方都有 fragment shader,就机械写成 FRAGMENT_SHADER → FRAGMENT_SHADERSHADER_WRITE → SHADER_READ。颜色输出落到附件的阶段,与 shader 的 storage 写不是同一个访问类型。

如果 Lighting 还真的执行固定功能深度测试,应把相应的 early/late test 目标阶段和 DEPTH_STENCIL_ATTACHMENT_READ 加入依赖。反之,不要把不发生的访问一律扩大成 ALL_COMMANDS。

也不能把示例里的单条内部依赖当作整帧同步:前帧资源复用、外部纹理/缓冲上传、最终 HDR 后处理读取等,仍需要各自正确的同步合同。

5. 三种编号必须同时对上

input attachment 最容易出现的工程错误,往往不是 barrier,而是绑定身份错了。

数据 Framebuffer 附件编号 Subpass1 输入数组位置 GLSL descriptor 地址
Albedo / Roughness 0 0 set=1, binding=5
Normal 1 1 set=1, binding=6
Depth 2 2 set=1, binding=7
HDR 输出 3 不属于输入数组 fragment output location=0

这几套编号不是同一个命名空间。input_attachment_index=0 指向 Subpass1 输入列表第 0 项,再由那一项找到附件;它并不是“binding 0”。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
对 Lighting 的每个片元:
深度 ← 读取输入附件 2

如果深度为背景值:
输出 HDR = 黑色,alpha = 1
结束当前片元

反照率、粗糙度 ← 读取输入附件 0
编码法线 ← 读取输入附件 1
法线 ← 解码并归一化(编码法线)
世界位置 ← 用当前片元坐标、深度和逆投影变换重建

输出 HDR = 计算光照(世界位置, 法线, 反照率, 粗糙度)
输出 alpha = 1

这里的“读取输入附件”对应 subpassLoad 的语义,位置隐含为当前片元,不能换成任意 UV。光照模型和位置重建的具体算法在此省略;背景判断需与深度约定一致,位置重建也必须考虑 viewport、投影、jitter、旋转与 multiview。

descriptor layout 中,5/6/7 的类型都应为 VK_DESCRIPTOR_TYPE_INPUT_ATTACHMENT,阶段为 fragment;image view、aspect、subresource 和 imageLayout 必须与对应附件一致。没有 sampler 参数不等于没有 descriptor。Subpass 描述与输入映射

6. 记录命令只是最后一步

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
开始一个 render pass
进入 Subpass 0
选择与 Subpass 0 匹配的 Geometry pipeline
绘制场景几何,写入 G0 / G1 / Depth

推进到 Subpass 1
按预先声明的依赖和布局完成阶段衔接
选择与 Subpass 1 匹配的 Lighting pipeline
绑定三份输入附件的 descriptor
全屏计算光照,写入 HDR

结束 render pass
丢弃不再使用的 G0 / G1 / Depth
保留 HDR

在后处理读取 HDR 前,建立相应的外部依赖

两个 pipeline 必须对应兼容的 render pass / 正确的 subpass。仅仅颜色格式相同,不能据此随意复用 pipeline;使用 secondary command buffer 时,继承的 render-pass/subpass 信息也必须一致。

若采用旧版 Begin/Next/EndRenderPass API,核心合同相同。API 选择与硬件是否合并,是两个问题。


六、算两本预算:外存往返与片上工作集

只算外存,容易把所有工作都往一个 render pass 里塞;只算 tile 容量,又可能忽略最大的往返开销。两本账必须一起看。

1. 外存账:这个例子最多能取消哪一笔搬运?

沿用 1080×2340 的参考尺寸,G0、G1、Depth 各 4 字节,总共 12 B/pixel。

1
2
3
4
5
6
7
单次完整 G-Buffer 传输
= 1080 × 2340 × 12
= 28.92 MiB

一次 Store + 一次读取
= 57.84 MiB/帧
= 60 FPS 下约 3.64 GB/s 的未压缩等价吞吐

如果三个中间附件全部局部消费后丢弃,且确实走到合并的片上路径,那么这笔中间往返就是主要候选收益。

只比较这一段的数据传输,可列成:

项目 拆分路径 理想的局部读取路径
G0/G1/Depth 中间往返 57.84 MiB 可以取消
RGBA16F HDR 最终写回 19.28 MiB 19.28 MiB
以上项目合计 77.12 MiB 19.28 MiB

这不是整帧从 77 MiB 变成 19 MiB:材质、顶点、光照、阴影、其他后处理等均未计算。

实际 DRAM 也不等于这个理想账。分离路径可能有压缩和缓存复用;合并路径可能仍保留某些附件、发生额外工作或未真正合并。正确表述是取消了哪些名义往返,然后用 counter 验证物理收益。

如果后面还要深度,就保留深度 Store。此时仍可能省掉 G0/G1 的全部往返以及深度在 Lighting 阶段的外部读取,但不能继续把深度当成完全瞬时的数据。

2. 片上账:别只数 G-Buffer,忘了 HDR 输出

在 Lighting 阶段,输入还在被读,HDR 也已经开始写。按源格式做一个简化工作集估算:

1
2
3
4
G0 4B + G1 4B + HDR 8B + Depth 4B = 20 B/pixel

假设 16×16 tile:1× 时约 5 KiB
所有附件变为 4×:粗略约 20 KiB

这不是某台 GPU 的精确 SRAM 分配图:内部格式、对齐、压缩、保留区和实现策略都可能不同。它是一个能及时暴露危险的预算代理。

其中颜色部分已经是 32 + 32 + 64 = 128 bit/pixel,不能只看到 G0/G1 的 64 bit,就认为还有大量空余。

Khronos 的 subpass 示例讨论了部分 Mali 实现的颜色存储预算、附件数量、深度绑定和 sample count 等合并条件,并展示格式变宽可能导致合并失败。那里的 128/256-bit 数字是特定实现的经验,不是 Vulkan 对所有设备规定的统一门槛Subpass 合并与容量示例

工程上可以这样估算:

1
逻辑峰值工作集 ≈ max(每个阶段同时存活的附件字节数)

但驱动也可能按更保守的附件集合分配空间。应用认为“前面用完了”,不表示能够擅自在同一 render pass 内把两个 image 绑定到同一块内存。内存 alias、attachment preserve 和驱动片上复用,是不同合同。

3. 小改格式,有时比强行合并更多 subpass 更有效

优先问:

  • Position 能否从 Depth 重建,避免再存一张 8B 或 16B 的位置图?
  • Normal 是否需要 RGBA16F,还是更紧凑的编码已经满足误差预算?
  • 粗糙度、材质标记能否合理打包,而不是为一个标量增加整张 MRT?
  • 这个输出能否推迟到前一张输入不再存活之后?

例如,额外一张 8B/pixel 的中间图,在上述分辨率下一次写读就是约 38.56 MiB。它还会扩大 tile 工作集,甚至使整组附件失去合并机会。

收益因此可能不是线性的:一张图不只是自己贵,还可能把其他图一起挤出理想路径。 但格式压缩、法线编码和量化需要画质验证,不能把数值范围和精度当作免费预算。


七、为什么带宽下降了,GPU 时间却没下降?

因为 DRAM 只是资源约束之一。

Arm 的官方分析明确指出,subpass 合并由实现决定,减少带宽不保证更低的逐时钟执行成本;合并还会影响几何 binning 与片元阶段的调度、片上依赖及缓存压力。它同时指出,Mali-G77 起的相关架构中,纹理采样峰值吞吐高于 tile-memory 读取吞吐。因此不能把“改用 tile read”直接理解成“读指令一定更快”。Arm 对 subpass 收益与代价的分析

从工程角度,可以把新的成本分成三类检查:

  1. 暴露的等待:更大的渲染范围是否让片元工作开始得更晚,或减少了原来能够隐藏的延迟?
  2. 工作集压力:同一 tile 内是否要轮换更多 shader、纹理、buffer 和寄存器状态?
  3. 吞吐与覆盖变化:是否增加了实际片元工作,或者只是从一个功能单元瓶颈换成另一个?

这三项是分析方向,不是任何 GPU 必然执行的固定流水。

对原来已经受外存限制的场景,减少 DRAM 更可能转化成帧时改善。对本来不受外存限制的场景,价值可能首先体现为降低带宽占用和改善持续运行条件。

所以验收不应只有“FPS 有没有涨”,也不能只有“带宽有没有降”:要同时检查帧预算、功耗/温度与长时间稳定性。


八、五种最容易把收益做没的改法

1. 把全局依赖伪装成 BY_REGION

Lighting 只读当前像素,是一个好起点。但如果 shader 同时依赖整个 framebuffer 的归约结果,或者读取其他像素写出的 SSBO 数据,就不能用一条局部依赖概括所有访问。

BY_REGION 是对同步范围的描述,不是“请驱动优化”的无害提示。

2. “临时”附件后来又拿去采样、截图或调试

一开始只用于 Lighting 的法线,后来被 SSR、调试窗口或离屏输出引用,生命周期就变了。

此时正确做法是明确导出/Store 的成本,或在确实需要时选择另一条路径,而不是保留 DONT_CARE 再去读取已经不受保证的内容。

如果附件在 Subpass 0 写,Subpass 1 不使用,Subpass 2 又要读取,中间 subpass 还需要相应的 preserve 声明;preserve 不会替代真正的 0→2 数据依赖。Subpass preserve 规则

3. 把输入读取和同 subpass 反馈环混为一谈

本文的例子是 S0 写 G0,S1 读 G0、写另一张 HDR。

如果改成 S1 同时读写同一颜色附件,问题就变成了反馈环和片元之间的顺序保证。不能只复制本文的 0→1 dependency。自依赖、作用域内 barrier、rasterization-order attachment access 等机制各有要求。

尤其要注意:self-dependency 本身不是直接同步所有绘制的万能屏障;它规定的是该 subpass 内允许使用的 barrier 范围。自依赖语义

4. 一键把所有 G-Buffer 升级成 4× MSAA

片上存储增长只是第一层成本。读取多采样 G-Buffer 时,还必须定义 Lighting 使用哪个 sample,以及如何生成输出覆盖。

如果只读取 sample 0,再把同一个 Lighting 结果写给全部输出样本,可能丢失边缘不同样本对应不同表面的信息。若采用逐 sample 着色,shader 工作又可能明显增加。

因此,subpassInputMS、sample index、着色频率、深度测试和最终 resolve 必须作为一套方案设计。不能把“所有 sampleCount 改成 4”当作完成。

前向渲染与延迟渲染的 MSAA 成本也不能直接类比。前向路径可以让多个绘制阶段共享多采样颜色和深度;延迟路径还需要承担多采样 G-Buffer 的容量、读取与着色成本。是否引入 input attachment,应服从算法的数据需求,而不是反过来为了使用某项 API 增加中间目标。

5. 为了计数好看,篡改资源声明

删掉真实 sampled read、把用途不明误判为未使用、把同一资源的多个逻辑版本当成不同物理图像,确实可能让合并统计变得漂亮,也可能直接制造数据竞争。

渲染调度系统应分别记录:

1
2
3
4
5
6
Attachment Write
Local Input Attachment Read
普通 Sampled Read
Storage Read/Write
仅排序依赖
外部导出 / 后续保留

它们不能全部压成一个“读纹理”。只有先把数据使用方式说清楚,编译器才有资格判断哪些依赖可以局部化。


九、怎么证明驱动真的采用了你想要的路径?

至少准备三个层次的证据。

1. 正确性:先确认画的是同一件事

  • Split 与 Subpass 两版使用相同输入、格式、分辨率、光照和 sample count。
  • 普通纹理对照尽量使用等价的整数像素读取,避免额外的过滤、UV 偏移或色彩转换。
  • 检查背景、深度边缘、透明覆盖、移动镜头、resize 和多帧资源复用。
  • 开启 validation 与同步验证,做像素回读或图像差分。

通过 validation 证明不了 shader 数学正确;像素一致也证明不了带宽更低。两者缺一不可。

2. 执行结构:逻辑合并不是物理合并

支持 VK_EXT_subpass_merge_feedback 时,可读取 render-pass 创建反馈中的 postMergeSubpassCount 和各 subpass 的反馈信息。它比“我调用了一次 BeginRenderPass”更接近要验证的问题。创建反馈子通道反馈

创建反馈也不是“每帧所有数据始终常驻 SRAM”的证明。遇到运行时路径选择或复杂工作负载时,仍应和实际执行 trace、外存 counter 对照。

还可以把相同 subpass 图作为对照,在扩展支持时通过创建控制禁用合并,观察驱动合并前后的差异。这是诊断实验,不是应当长期打开的产品设置。创建控制

不支持反馈扩展时,用厂商 timeline / rendering-stage trace 和性能计数器交叉判断。

PTILES、fragment jobs 等属于线索,不是“一项下降多少,DRAM 就下降多少”的换算器。tile 尺寸、覆盖率、采样数和帧率变化都会影响读数。优先按每帧归一化,避免把不同 FPS 的每秒计数直接比较。

3. 产品收益:同时记录三类预算

预算 应观察的结果
带宽 external read/write bytes、各类 attachment/texture 流量
时间 GPU p50/p95、关键阶段时间、实际片元/几何工作变化
持续运行 相同环境下的温度、频率、功耗与热稳定帧时

不要让第一次 pipeline 编译、曝光收敛或动态分辨率自动降档污染对照。固定相机/内容/曝光历史,预热后测量,再恢复动态策略检查实际体验。

建议把三个实验分开:

  1. 保持结构,只修正 Layout / barrier 范围:定位同步与元数据维护收益。
  2. Split 与局部 Subpass 对照:定位中间附件往返收益。
  3. 在已正确的 Subpass 版本中缩小附件工作集:定位合并准入和容量收益。

同时修改格式、分辨率、采样数和渲染结构,得到一个更快结果,并不能告诉你究竟是哪一项生效了。


十、Dynamic Rendering Local Read:同一个目标,不是任意读写许可证

现代 Vulkan 也可以用 VK_KHR_dynamic_rendering_local_read 表达类似意图;它已纳入 Vulkan 1.4 的核心功能。单纯支持 Dynamic Rendering,不等于已经具有完整 local-read 能力。

迁移时仍要处理三件事:

  • attachment location 与 input-attachment index 的映射。
  • 支持局部读取的合法布局和 usage。
  • 绘制之间正确的 framebuffer-local 内存依赖。

该扩展允许的作用域内局部 barrier,不是任意布局转换或 queue-family transfer 的入口;被局部读取的图像应预先处于符合要求的布局。Dynamic Rendering Local Read 设计

新的 API 能减少传统 render-pass 对象组织上的负担,但不会取消同像素约束、数据依赖或片上容量问题。也不应由“有一个 local-read 布局”推导出全平台统一的指令和性能。

选传统 subpass,还是选 dynamic local read,应由目标设备覆盖、已有 shader ABI 与验证成本决定,而不是只看哪个名字更新。


十一、落地路线:按什么顺序投入才划算?

规划优化路线时,先区分三个不同目标:

  1. 多个逻辑绘制阶段,共用同一套固定功能附件和一个物理 scope。
  2. 在这个 scope 结束时做硬件 MSAA resolve。
  3. shader 通过 input attachment,在不同 subpass 之间读取局部附件。

前两项并不自动等于第三项。

一次渲染范围内包含多个逻辑阶段,不能证明 shader 已在使用 input attachment;支持硬件 resolve,也不能证明多个 subpass 已经被驱动合并。应分别记录每项能力的实现条件和验证结果,避免用一个“支持 Subpass”的开关概括不同层次的功能。

可行的投入顺序是:

第一步,先取消本来就不需要的中间图。 普通透明能直接混合,就不必为它建设一套 G-Buffer 局部读取协议。

第二步,把真正的同像素消费者识别出来。 明确记录输入索引、descriptor、附件身份、读写角色与生命周期,使调度和资源分配拥有同一份依据。

第三步,从单采样、小附件集合的案例接通真实执行。 不从十几张 MRT、4× MSAA、拾取、SSR 和多个后处理一起开始。

第四步,设置快路径准入和回退。 不支持、用途未知、需要邻域访问或收益不足时,保留可验证的原路径。

第五步,用目标设备数据决定默认策略。 在开发机上编译通过、API 使用合法或图像正确,都不能替代目标移动设备上的带宽、帧时与持续运行测试。

对于已经避免大部分 G-Buffer 的前向管线,应优先检查直接混合和附件生命周期;对于确实需要 G-Buffer 的延迟管线,再评估局部读取与紧凑编码。优化应减少算法所需的数据搬运,而不是为了“用上 Subpass”重新引入本来不需要的中间目标。


总结:Subpass 是局部性合同,不是零成本承诺

实践中,最有用的不是记住“普通 Transition 很重、Subpass Transition 很轻”,而是沿着下面的链路检查:

1
2
3
4
5
6
7
8
9
10
11
消费者是否真的只读当前像素?

能否在一个渲染范围内完成生产与消费?

同步、索引、descriptor 与 layout 是否一致?

中间结果用完后能否丢弃?

附件工作集是否适合目标设备?

驱动是否真的采用局部路径,产品指标是否受益?

真正应该删掉的,是没有必要的外存往返;真正不能删掉的,是数据依赖与正确性合同。

当数据的使用关系被准确限定为“只服务于后面同位置的计算,消费完就不需要了”,Subpass 才开始发挥它的价值。

至于它是不是只花几个周期、是不是完全绕过某个硬件单元、是不是让游戏更快,答案应当来自具体实现与测试,而不是来自一个 API 名字。

参考资料

本文作者:Berg Zha

本文链接:https://junglemanpro.com/posts/b6cb97c0/

版权声明:本博客所有文章除特别声明外,均采用 CC BY-NC-SA 4.0 许可协议。转载请注明出处!

关于

我是谁
一个热爱底层渲染的人
与我交流
bergzha@gmail.com
ESC 关闭 | 导航 | Enter 打开
输入关键词开始搜索