12

xucvai · 2023年12月01日

一文了解Vulkan在移动端渲染中的带宽与同步

作者 | ningfeili
来源 | 内核工匠(ID:Linux-Tech)

1.移动端GPU架构

Immediate Mode Rendering Architecture(IMR)

image.png

早先PC端的GPU大多数采用的是IMR的架构。IMR架构的大体流程如图所示,值得注意的是对于每个像素,经过数次的color/depth的读写,最终绘制到framebuffer上。这当中的每一次读写,都是直接与内存交互。所以,频繁读写内存,会消耗大量的带宽,这个过程会大量发热。对PC来说,为了更高的画质和帧率,可以用更好的风扇和散热从外部解决发热问题,但是对于空间十分有限的手机来说,肯定不可能去加装散热了。

Tile-Based Rendering Architecture(TBR)

所以为了解决带宽引起的发热问题,现在的手机GPU普遍使用的是tiled-based架构,Adreno/Mali/PowerVR的GPU在这个基础上有自家独有的优化,比如PowerVR的TBDR架构中的HSR,可以完全消灭不透明物体渲染时的overdraw,这里就不展开讨论了。

不过它们的核心思想是一致的,都是为GPU开辟了一块on-chip memory,这块on-chip memory的特点就是相比于与内存的交互,GPU读写它的开销非常小。

Tile-based rendering将一个完整的framebuffer分为若干个tile,每个tile的内容完全绘制完毕之前,GPU只读写on-chip memory,一块tile完全渲染完毕之后才会将on-chip memory中的内容一次性写入内存,下图描述了整体的绘制流程。

image.png

这样,原本绘制每个像素都需要直接读写内存,现在改为全部读写开销极低的on-chip memory,从而节约了带宽,降低了功耗。

2.为什么用Vulkan?

这也是在移动端渲染开发必然要考虑的问题,要求引擎技术团队对自己的项需求和Vulkan API,以及相关手机硬件特性都有非常清晰的认知。Vulkan API主要有以下几点特殊的优势:

1) 它的驱动更薄。Vulkan更贴近底层,驱动不会像GLES一样,进行大量的猜测和判断,所以用得好可以显著降低CPU的负载;

2) Vulkan提供显式的同步和带宽控制指令,有效而正确地使用这些指令,可以提升GPU运行效率和降低GPU功耗。

3) Vulkan 刻录并提交commandbuffer的架构,更好地支持了多线程渲染。例如Unity PC端实现了用secondary commandbuffer多线程同时渲染同一场景中的物体,UE也利用Vulkan的特性,从渲染线程中抽离出了RHI线程,平均了多核负载,提升了多核效率。

4) 因为Vulkan标准较新,且受到重视,所以在Vulkan标准定制时,直接包含了很多最新的硬件特性的使用方法。

所以在合理调度的情况下,Vulkan可以享受驱动薄和多线程带来的CPU收益。而GPU端,在充分了解硬件架构和各个驱动的特点的情况下,手动控制同步和带宽可以有效提升游戏性能。但是,往往一些项目花了非常多的时间和精力去优化渲染算法,改善渲染流程,或者添加自定义渲染管线,可仅仅是因为对某个硬件特性的不了解,或者用错了某个API,导致性能下降非常严重或者导致优化了的算法反而是负提升,甚至包括早期的Unity、Unreal默认移动端的渲染管线,在这块都或多或少有些不足。

3.游戏中的带宽与功耗

先看一个非常简单的例子。

image.png

上图是用Unity的rendering commandbuffer实现的一个自定义后处理的渲染流程。当切换rendertarget的时候,如果我们只是使用了默认的接口去设置RT,传递到底层,可以看到Vulkan renderpass的load action是load,也就是保留前面的渲染结果。

而当我们使用下面这个接口显示地设定了rendertarget的loadaction,使Vulkan的load action变为dontcare,可以看到GPU bound的情况下,帧率提升了1帧多。

image.png

这就是因为,当我们load这个rendertarget的时候,在Tile-base架构上,rendertarget从内存中被加载到on-chip memory上,所以产生了额外的开销。而如果我们显式地设置了dontcare,就减少了这次加载的时间和带宽,在GPU bound的情况下,性能的提升直接体现在了帧率上。

Tile-based Rendering在延迟渲染中的应用

image.png

传统延迟渲染的方式,Gbuffer渲染完毕后,写入内存,然后lighting阶段再sample gbuffers。而因为在lighting阶段每个像素需要的是自己像素位置上的gbuffer信息,所以我们完全可以利用subpass,将gbuffer存放在on-chip memory中,后续lighting阶段直接从on-chip memory里去读取。省去了先存内存,再sample的两次带宽消耗。

下面是一个延迟渲染的demo,左边是sample的方式,右边是则是subpass的方式:

image.png

两种情况下,lighting的算法,分辨率,灯的数量,fps包括GPU频率都是固定不变的。唯一的区别就是是否与内存交互,左边是采样内存中存下来的Gbuffer;右边则完全不与内存交互,直接写入和读取on-chip memory。

image.png

测试结果可以看到,shader的计算量是一样的,但是内存频率和带宽都是subpass方案有很大提升,这里读写带宽一共减少了4.9Gb/s,内存频率也有所降低。

image.png

在帧率、GPU使用率和频率均没有差别的情况下,纯因为带宽的减少,产生了567mW的功率差,GPU平均温度也降低了5度。

image.png

这里有一个大致的参考,内存带宽所产生的功耗几乎与GPU的功耗持平,,每消耗1GB的内存带宽,大约会产生120mW的功耗,这也和之前5GB,567mW的测试结果吻合。

不仅仅是延迟渲染,只要渲染流程中想读取当前像素位置的历史颜色、深度,都可以用subpass。比如decal,某些粒子效果的半透明渲染、MSAA抗锯齿等等,合理利用subpass,在带宽上能得到可观的收益。

当然,tile-based架构还是有一些缺点的:

比如tiling阶段需要将vs全部计算完毕,并且所有的varying要回存内存,所以相比于IMR,除了共有的fetch geometry data的开销,Tiling阶段也有额外的延迟和带宽消耗。

image.png

所以vertex data的组织也是非常重要的。比如下图这个自定义的vertex data,aColor将最终传给fragment shader用作自定义的渲染,

image.png

注意到每个数据都是有效数字只有1位的整数,却用了32位的格式去存储。在模型复杂,顶点较多的情况下,这也是一笔不小的开销。实际上16位,甚至8位的格式就够了。所以在知晓自己项目每个顶点数据的用途的情况下,我们应当使用尽量小的格式。

Index-Driven Vertex Shading(IDVS)

下图是ARM上为了减少fetch geometry data的带宽所做的优化,严格意义上它不能算是专门为TBR架构而设计的优化,但它的收益确实很大。

image.png

IDVS的思路就是vs中只先做position相关的计算,等做完剔除之后,才会去fetch其他attribute的数据,做varying相关的计算。但是这个优化要求开发者将position和其他的attribute分buffer存放,这样GPU才能单独fetch。

现在市面上绝大多数的手游都是左边的这种情况,所有vertex data存在了同一个vkbuffer中;右边则是推荐的做法,一个vkbuffer仅存position data,剩下的varying存在另一个vkbuffer中,在某些vs较重的情况下,两者的功耗差距超过了30%。所以如果顶点数据复杂,我们还是值得去拆分一下vertex data的。

4.Vulkan中的同步

Vulkan同步中一个非常重要的元素就是pipeline barriers,只要有项目修改了引擎的原生管线,就一定会涉及到pipeline barrier的修改,而实际看下来几乎所有的项目都有一些使用不恰当的地方。并且,如果项目开发周期比较长,使用的是较早版本Unity、Unreal引擎,那么原生代码也或多或少有一些使用不当的地方。

以下三点都是Vulkan spec上面提到的pipeline barrier的作用。

第一,它可以控制执行顺序,GPU真正执行指令时,并不一定是按照我们提交指令的顺序去执行的,所以在指令之间添加一个pipeline barrier,可以保证barrier之前的指令先于barrier之后的指令执行。

只保证指令开始的顺序,在并行的情况下,并不能控制指令结束的顺序,牵涉到内存修改的情况下就会出现问题。

第二,pipeline barrier还保证了指令间内存的依赖关系,这个后面会详细解读。

第三点image的layout转换其实同样重要,有兴趣的读者可以查阅相关资料。

Hazards

因为实际情况要复杂得多,这里我们把读写模型简化成GPU core - cache–内存三个部分:

image.png

写数据时,GPU core完成对cache的修改后要flush cache,将其中的数据拷贝到内存中,这个过程我们称之为make memory available。

而GPU core读取数据时,要先invalidate cache,将内存中的数据加载到cache中,这个过程,我们称之为make memory visible。

了解了这个简化模型以后,来看内存读写的三个hazards:写后读、写后写、读后写。

如果不做同步,比如说写后读,很可能读取指令执行时,前面的写命令还没来得及修改内存,从而导致渲染错误,这类情况就是需要通过同步指令去避免的。

Pipline Barrier 处理WAR、RAW、WAW的具体流程

三个问题当中最好解决的是读后写,对于它来说,只要通过pipeline barrier限制了指令执行顺序,就能保证数据读取的正确性。因为读取指令先执行,那么数据就已经被加载到了cache中,即使最坏的情况,后面的写入操作立刻完成,它修改的也仅仅是内存中的内容,无法影响到我们正在读取的已经被加载到cache中的数据。

接下来我们来看其他两个稍微复杂一点的同步。

Pipeline stage mask和access mask决定了这个pipeline barrier是对哪段内存生效。那么如果对于同一段内存进行先写后读,读取操作一定要在所有数据都写入内存之后才能进行。

image.png

所以,在pipeline barrier的保护下,整个同步流程如右上所示:

首先,读取指令会被block住,等待写指令执行完毕;srcAccessMask意味着将这段内存make available,保证data写入内存完毕。dstAccessMask意味着将这段内存make visible,也就是保证刚才被写入内存的data成功加载到cache中。

最后,执行读取指令。这样,整个内存同步就完成了。

写后写也是差不多的原理,先阻塞第二次写操作,等待第一次写操作完成,且data已经flush到了内存中,再执行后一次的写操作。

image.png

上面是一个典型的写后读的同步例子,这是一个用采样方式读取Gbuffer的延迟渲染demo,模拟是游戏中最常见的一种流程,就是先渲染一张RT,然后后续的某个fragment shader采样这个RT的结果,除了延迟渲染,常见的还有shadowmap,sss,一些实时的风场,脚印的前置流程等等。

右边是完全正确的写法,先block住lighting阶段的fs,然后把之前Gbuffer阶段color output stage的color attachment写入的这段内存make available,然后再make visible同一段要被shader读取的内存,再unblock fs,这样lighting阶段的fs就正确读取到了Gbuffer。

左边唯一的不同,是把本应等待的fragment阶段改成了vertex,这样就导致GPU会在vertex shading阶段提前block住流水线。

这个问题非常隐蔽,首先Vulkan的validation layer不会报错,因为它只是效率低,而不是一个错误。如果粗略地去测试alu,读写带宽,渲染时间,可以看到这些数据在图上的测试结果几乎没有区别。所以这个问题很容易就被忽略过去了。

测试分析工具的使用

各个Soc厂商都提供了自家GPU的分析测试工具,例如 Mali芯片的Mali  streamline,PowerVR的芯片的PVRTune,高通的snapdragon profiler。在这个例子中,我们用的手机是Mali的GPU,所以用Mali的测试工具进行分析:

image.png

如上图,计算量,读写带宽,都没有差别,唯一有变化的是这个External Bus Read Latency和External Bus Stall。

image.png

具体来看,内存总线读取数据的阻塞周期有6倍的差距,这就是由于我们的同步指令让该等待的stage提早到了vertex shading阶段。但是由于GPU频率足够高,场景也不够复杂,没有达到GPU bound,这些延迟并没有高到足以影响帧率的地步。

现在,让我们人为地限制GPU频率,模拟GPU bound的情形,这时内存总线读写延迟无法被覆盖,问题就暴露出来了。

image.png

可以看到,Bus Stall对帧率的影响还是很明显的,frametime相差了3毫秒,也就是8帧如果没有进行足够深入的测试,就很容会忽略掉这个问题,随着项目向后期推进,渲染压力逐渐变大,当问题暴露出来的时候,可能已经无法找到最初的原因了。

综上,同步问题非常重要,我们要完全理解core-cache-memory这个模型,清楚地知道管线遇到的是哪种hazard,正确地使用pipeline barrier,从而充分发挥手动控制同步带来的性能收益。

其他同步指令

当然Vulkan中还有很多其他的同步指令,例如subpass dependency,和barrier的用法几乎一样,但是只能同步renderpass内attachment相关的内存。Semaphore用于队列之间的同步,fence用于同步GPU和CPU,Event现在手游上用的比较少,目前除了一两款游戏自己对引擎进行了改动,只有unreal最新的版本在mobile shading renderer的occlusion query部分使用了它。

5.总结

本文首先介绍了移动端渲染架构及其特点,接着阐述了Vulkan API的优势,再结合实际测试结果分析了Tile-based rendering的优缺点,最后重点介绍了Vulkan的显式同步控制,结合具体场景和实测数据,给出了优化方案并分析了根因。

希望读者可以通过本文更加深入地了解Vulkan API以及移动端渲染架构,结合具体的开发场景,合理利用测试分析工具,改善移动端渲染中的功耗、带宽和同步问题。

References

The PowerVR Advantage (imgtec.com)

Synchronization Examples · KhronosGroup/Vulkan-Docs Wiki · GitHub

ARM-software/Vulkan best practice for mobile developers

SaschaWillems/Vulkan: Examples and demos for the new Vulkan API

Vulkan Usage Recommendations | Samsung Developers

baldurk/renderdoc

Snapdragon Profiler - Qualcomm Developer Network

本文作者ningfeili,首发于公众号“内核工匠”(ID:Linux-Tech),分享Linux内核相关黑科技、技术文章、技术资讯和精选教程,欢迎关注。

文章来源:OPPO内核工匠

推荐阅读

更多Arm Mali GPU相关技术干货请关注Arm Mali GPU技术专栏。欢迎添加极术小姐姐微信(id:aijishu20)加入技术交流群,请备注研究方向。
推荐阅读
关注数
101
内容数
18
Arm Mali GPU系列相关技术干货
目录
极术微信服务号
关注极术微信号
实时接收点赞提醒和评论通知
安谋科技学堂公众号
关注安谋科技学堂
实时获取安谋科技及 Arm 教学资源
安谋科技招聘公众号
关注安谋科技招聘
实时获取安谋科技中国职位信息