本文章标题来源于来源于AMD在4C上的一个演讲: Compute Shaders: Optimize your engine using compute [3]
Compute Shader是在GPU上运行的程序。虽然是老生常谈了,但是我们还是要先介绍一下GPU。 众所周知,CPU和GPU是两种不同的架构,那么他们之间的区别是什么?
CPU有很强大算术逻辑单元,减少操作延迟;巨大的cache,为了降低内存访问的延迟;复杂的控制器,使用分支预测来减少分支延迟,使用数据转发减少数据延迟。
我们可以这样说:CPU擅长逻辑控制和串列的运算。[1]
GPU有小的cache,用来促进吞吐量;简单的控制,没有分支预测和数据转发;高效节能的ALU,很多延迟很长的ALU,但是为了高吞吐量被重度管线化;需要开启大量的线程才能降低延迟。
相应地,我们可以这样说:GPU适用于计算密集型和易于并发的程序。[1][2]
可以看出,CPU和GPU各有自己的擅长,那么我们可以将二者结合起来,使用CPU做串列,而使用GPU做并行。这种技术就叫做GPGPU,也就是利用GPU进行通用计算的技术(General Purpose Computing on GPU)[1]。
但是,我们知道,通常来讲,GPU是用来执行图形渲染的。那么,为了执行通用计算,NV推出了CUDA,Khronos推出了OpenCL,Microsoft推出了DirectCompute,也就是后来的Compute Shader,然后,各种图形API也相继推出了CS。[25]
DX虽然从10开始支持Compute Shader/Direct Compute,但是限制比较大。DX11的Compute Shader拥有更强大的功能(当然肯定还有DX12)[6]。所以我们一般在Unity中使用CS,还是要求shader target4.5(也就是shader model 5)[19]。
OpenGL从4.3开始支持CS(但是MacOSX不支持4.3)。ES从3.1开始支持CS[5]。
Metal和Vulkan都支持CS[4][7]。
另外PS4和Xbox one(DX11.2)也支持CS[19]。
我们通过几张图,来简单对比一下计算管线与传统图形管线有什么不同。
我们可以看到,计算管线变得很简单[3]。
(关于GPU Rendering Pipeline,可以参考这张图[14] https://github.com/ecidevilin/Blogs/blob/master/IntroTo3DGPWithDX/Tessellation/pic/pipeline.jpg)
从硬体端来看:
上图是图形管线在硬体端的工作流程[3]。
上图是计算管线在硬体端的工作流程[3]。
通过对比,我们可以看出: Compute Shader可以在不通过渲染管线的情况下,利用GPU完成一些与图形渲染不直接相关的工作,从而降低硬体的overhead。
这就是Compute Shader的优势。
上文中介绍了,目前有很多图形API支持CS,但是各种API的shading language语法和API各不相同。
Unity的ShaderLab采用了跟HLSL接近的API,方便我们编写shader。
如果我们在Unity里面新建一个CS,便是如下的代码(稍作修改)。
// test.compute #pragma kernel FillWithRed // 1
RWTexture2D<float4> res; // 2
[numthreads(8,8,1)] // 3 void FillWithRed (uint3 dtid : SV_DispatchThreadID) // 4 { res[dtid.xy] = float4(1,0,0,1); // 5 }
这是一个简单的Compute Shader示例,将一个RT填充成红色。
如何执行这样一个CS代码?在C#里,调用如下代码。
public void Dispatch(int kernelIndex, int threadGroupsX, int threadGroupsY, int threadGroupsZ);
在CPU端,我们可以通过这个介面,将CS dispatch出去。Dispatch就相当于Drawcall,但是没有draw。 其中kernelIndex可以通过ComputeShader.FindKernel来获取。而threadGroupsXYZ代表线程组的数量。 那么什么又是线程组?
在CS里面,线程可以分为三个维度[2]。
上图中,最右边的表示单个线程,最左边的表示一个dispatch,而图中间的,表示一个Thread Group。
thread group是指将多个线程组合成为一个group,在这个group里面,每个线程有自己的相对位置。group内,还可以使用共享变数,相互通信。
将numthreads这个attribute声明在kernel函数的前面,就表示一个thread group中有多少个thread。
如图所示一个Dispatch中有3x2x3个thread groups,而一个group中有4x4x2个thread。
这样做的好处一个是可以利用gpu的warp/wavefront/EU-thread[2][3]。
另外,举个例子,现在很多图像压缩演算法都是基于block的,而thread group(OpenGL里叫做local size)可以为图像数据的一个block的大小(例如8x8),group数量可以是图像的尺寸除以块的尺寸。每个块被当作一个单独的work group来处理,并且group内可以共享一些信息[5]。
更进一步的,我们可以看下图[6]。
上半图代表了一个5x3x2的dispatch,图中的坐标代表一个thread group。
接著,将2,1,0的thread group打开,我们可以看到下半图。这张图代表了一个10x8x3的thread group,图中的坐标代表了一个thread。
如图所示,我们可以根据这些坐标算出GroupThreadID,GroupID,DispatchThreadID和GroupIndex。
这些id一般是用来作为索引来获取Buffer、Texture或者thread group shared memory里的数据。
例如上面举的例子,GroupThreadID就是图像的block内的坐标,GroupID是图像按块划分的坐标(图像的尺寸除以块的尺寸),而DispatchThreadID是像素的坐标。
CS可以使用一些常规的类型,标量、向量、矩阵、纹理、数组等。
除此之外,为了更灵活的使用CS,还推出了StructuredBuffer,简称SBuffer。
GPU Side CPU Side
*StructuredBuffer ComputeBuffer
RWTexture*D RenderTexture|
(SBuffer在fs里也可以使用,在其他shader里也可能可以使用。)
StructuredBuffer还包括 RWStructuredBuffer RWStructuredBuffer with counter (RW)ByteAddressBuffer AppendStructuredBuffer * ConsumeStructuredBuffer
StructuredBuffer除了可以包含各种内置的类型之外,还可以包含自定义的struct。
使用groupshared可以将一个变数标记为组内共享(又叫TGSM[2])。
使用这种变数,就可以在thread group内进行通讯。
例如,我们可以在forward+/Deferred管线里使用compute shader对点光源进行剔除。这个是在战地3中使用的技术[16][21]。
当我们在不同线程访问同一个资源的时候,我们需要使用barrier来进行阻塞和同步。
分为以下两种。
GroupMemoryBarrier DeviceMemoryBarrier AllMemoryBarrier DeviceMemoryBarrierWithGroupSync GroupMemoryBarrierWithGroupSync AllMemoryBarrierWithGroupSync
GroupMemoryBarrier是等待对groupshared变数的访问。
DeviceMemoryBarrier是等待对texture或buffer的访问。
AllMemoryBarrier是以上两者的和。
*WithGroupSync版本是需要同步到当前指令
原子操作,不会被线程调度机制打断。
InterlockedAdd InterlockedAnd InterlockedCompareExchange InterlockedCompareStore InterlockedExchange InterlockedMax InterlockedMin InterlockedOr InterlockedXor
但是只能用于int/uint
例如可以用于计算灰度直方图,用于TonemappingAuto Exposure等效果[19]。
虽然Unity帮我们做了跨平台的工作,但是我们仍然需要面对一些平台差异。
另外,在使用CS的时候,我们还需要知道一些性能优化点。
还有一些在渲染管线中适用的tips这里没有列举出来。
那么介绍过CS之后,我们看看,目前都有哪些应用。
图为用CS实现的GPU粒子系统,这个功能中使用CS计算粒子的运动轨迹[10]。
图为布料模拟,使用了CS进行布料粒子的受力运动计算、碰撞检测和反馈,以及约束计算。类似的还有头发模拟和海水模拟[11]。
图为一个简单的去色的图像处理[12],将rgb与(0.299,0.587,0.114)进行dot,获得灰度值[24]。类似的还有eye adaptation, color grading等等[3]。
Unity的PPS2中使用的histogram就是一个很好的例子,几乎用到了CS的所有feature[23]。
图为ASTC演算法压缩过的图像(4x4 6x6 8x8)[13]。 上面提到过,我们可以使用CS来实现基于Block的纹理压缩演算法。
曲面细分[15]:默认管线中的Tessellation比较受限,虽然可以使用Displacement mapping来提升它的效果,但是仍然不够动态。
我们配合CS一起使用,我们可以配合一些逻辑更自由更动态的生成细分顶点[14][3]。
战地3中,使用的是Deffered shading pipeline,通过cs对点光源、探照灯等光源进行剔除[16]。
图片来源,知乎大V MaxwellGeng实现的GPU Occlusiong Culling,他使用了Hiz的方法,对cluster进行遮挡剔除[17]。
而这种思想就是GPUDRP。
图为刺客信条大革命,在这部游戏中使用了GPUDRP技术,并在Siggraph 2015: Advances in Real-Time Rendering in Games course中发表[18]。
「Simple, but not easy」是我对Compute Shader的认识,也是对本文的总结。
ES从3.1开始支持CS,也就是说,在手机上的支持率并不是很高。
另外,手机算力还是很低。GTX 1050 Ti的算力是1.9k~2.9k Gflops(floating point operations per second),有768个core。华为P20的Mali-G72 MP12的算力是300+ Gflops,只有12个core [28]。
所以,CS在手机上的使用,是困难的。
但是,我认为它是有巨大潜力的,随著手机硬体的高速发展,我相信,用不了多久,Compute Shader的使用就可以在手机上普及。
推荐阅读: