Compute Shader : Optimize your game using compute

本文章標題來源於來源於AMD在4C上的一個演講: Compute Shaders: Optimize your engine using compute [3]


概念

Compute Shader是在GPU上運行的程序。雖然是老生常談了,但是我們還是要先介紹一下GPU。 眾所周知,CPU和GPU是兩種不同的架構,那麼他們之間的區別是什麼?

CPU是基於低延遲的設計

CPU有很強大算術邏輯單元,減少操作延遲;巨大的cache,為了降低內存訪問的延遲;複雜的控制器,使用分支預測來減少分支延遲,使用數據轉發減少數據延遲。

我們可以這樣說:CPU擅長邏輯控制和串列的運算。[1]

GPU是基於大吞吐量的設計

GPU有小的cache,用來促進吞吐量;簡單的控制,沒有分支預測和數據轉發;高效節能的ALU,很多延遲很長的ALU,但是為了高吞吐量被重度管線化;需要開啟大量的線程才能降低延遲。

相應地,我們可以這樣說:GPU適用於計算密集型和易於並發的程序。[1][2]

GPGPU

可以看出,CPU和GPU各有自己的擅長,那麼我們可以將二者結合起來,使用CPU做串列,而使用GPU做並行。這種技術就叫做GPGPU,也就是利用GPU進行通用計算的技術(General Purpose Computing on GPU)[1]。

但是,我們知道,通常來講,GPU是用來執行圖形渲染的。那麼,為了執行通用計算,NV推出了CUDA,Khronos推出了OpenCL,Microsoft推出了DirectCompute,也就是後來的Compute Shader,然後,各種圖形API也相繼推出了CS。[25]

支持Compute Shader的圖形API

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]。

Compute管線與圖形管線的對比

我們通過幾張圖,來簡單對比一下計算管線與傳統圖形管線有什麼不同。

我們可以看到,計算管線變得很簡單[3]。

(關於GPU Rendering Pipeline,可以參考這張圖[14] github.com/ecidevilin/B

從硬體端來看:

上圖是圖形管線在硬體端的工作流程[3]。

上圖是計算管線在硬體端的工作流程[3]。

通過對比,我們可以看出: Compute Shader可以在不通過渲染管線的情況下,利用GPU完成一些與圖形渲染不直接相關的工作,從而降低硬體的overhead。

這就是Compute Shader的優勢。


語法

如何在Unity裏使用Compute Shader?

上文中介紹了,目前有很多圖形API支持CS,但是各種API的shading language語法和API各不相同。

Unity的ShaderLab採用了跟HLSL接近的API,方便我們編寫shader。

kernel

如果我們在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填充成紅色。

  1. 首先聲明瞭一個Kernel,Kernel相當於一個main函數,是CS的入口。這應該是來源於Metal的思路[7],可以在一個資源文件裏定義不同的kernel方法,公用一些代碼,同時也可以做到相對獨立。
  2. 然後聲明瞭一個RWTexture2D,對應於C#,是RenderTexture。

  3. 在函數名上面還有一個numthreads的attribute,這個我們後面會講到。
  4. 函數的參數後面帶有一個Semantic(SV_DispatchThreadID),這個我們後面也會講到。我們暫時可以把它當作一個坐標值。
  5. 最後是函數體,是將rt中的像素設置成紅色。

Dispatch

如何執行這樣一個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是像素的坐標。

Buffer & Texture

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

使用groupshared可以將一個變數標記為組內共享(又叫TGSM[2])。

使用這種變數,就可以在thread group內進行通訊。

例如,我們可以在forward+/Deferred管線裏使用compute shader對點光源進行剔除。這個是在戰地3中使用的技術[16][21]。

Barrier

當我們在不同線程訪問同一個資源的時候,我們需要使用barrier來進行阻塞和同步。

分為以下兩種。

GroupMemoryBarrier
DeviceMemoryBarrier
AllMemoryBarrier
DeviceMemoryBarrierWithGroupSync
GroupMemoryBarrierWithGroupSync
AllMemoryBarrierWithGroupSync

GroupMemoryBarrier是等待對groupshared變數的訪問。

DeviceMemoryBarrier是等待對texture或buffer的訪問。

AllMemoryBarrier是以上兩者的和。

*WithGroupSync版本是需要同步到當前指令

Interlocked

原子操作,不會被線程調度機制打斷。

InterlockedAdd
InterlockedAnd
InterlockedCompareExchange
InterlockedCompareStore
InterlockedExchange
InterlockedMax
InterlockedMin
InterlockedOr
InterlockedXor

但是隻能用於int/uint

例如可以用於計算灰度直方圖,用於TonemappingAuto Exposure等效果[19]。

平臺差異

雖然Unity幫我們做了跨平臺的工作,但是我們仍然需要面對一些平臺差異。

  1. 數組越界,DX上會返回0,其它平臺會出錯。
  2. 變數名與關鍵字/內置庫函數重名,DX無影響,其他平臺會出錯。
  3. 如果SBuffer內結構的顯存佈局要與內存佈局不一致,DX可能會轉換,其他平臺會出錯。
  4. 未初始化的SBuffer或Texture,在某些平臺上會全部是0,但是另外一些可能是任意值,甚至是NaN。
  5. Metal不支持對紋理的原子操作,不支持對SBuffer調用GetDimensions
  6. ES 3.1在一個CS裏至少支持4個SBuffer(所以,我們需要將相關聯的數據定義為struct)。
  7. 在渲染管線中,部分號稱支持es3.1+的Android手機只支持在片元著色器內訪問StructuredBuffer

性能優化

另外,在使用CS的時候,我們還需要知道一些性能優化點。

  1. 盡量減少Group之間的交互:硬體不支持全局同步[2],不同步的話容易導致錯誤和崩潰[3]。
  2. GPU一次Dispatch會調用64(AMD成為wavefront)或32(NVIDIA稱為warp)個線程(這實際上是一種SIMD技術),所以,numthreads的乘積最好是這個值的整數倍。但是Mali不需要這種優化[8]。此外,Metal可以通過api獲取這個值[7]。
  3. 避免回讀:回讀操作在渲染管線中使用的比較少,而在CS中可能會被用到,所以重點提一下[20]。
  4. 避免分支,重點避免在thread group中間的分支,這其實跟第二點是相關的,如果在wavefront/warp整數倍的地方發生分支,消耗就會小很多[2][26]。
  5. 盡量保證內存連續性[2]。
  6. 使用[unroll]來打開循環,有些時候需要手動unroll[22]。

還有一些在渲染管線中適用的tips這裡沒有列舉出來。


應用

那麼介紹過CS之後,我們看看,目前都有哪些應用。

GPU Particle System

圖為用CS實現的GPU粒子系統,這個功能中使用CS計算粒子的運動軌跡[10]。

GPU Simulation

圖為布料模擬,使用了CS進行布料粒子的受力運動計算、碰撞檢測和反饋,以及約束計算。類似的還有頭髮模擬和海水模擬[11]。

Image Processing

圖為一個簡單的去色的圖像處理[12],將rgb與(0.299,0.587,0.114)進行dot,獲得灰度值[24]。類似的還有eye adaptation, color grading等等[3]。

Unity的PPS2中使用的histogram就是一個很好的例子,幾乎用到了CS的所有feature[23]。

Image Compression

圖為ASTC演算法壓縮過的圖像(4x4 6x6 8x8)[13]。 上面提到過,我們可以使用CS來實現基於Block的紋理壓縮演算法。

Tessellation

曲面細分[15]:默認管線中的Tessellation比較受限,雖然可以使用Displacement mapping來提升它的效果,但是仍然不夠動態。

我們配合CS一起使用,我們可以配合一些邏輯更自由更動態的生成細分頂點[14][3]。

Local lights culling

戰地3中,使用的是Deffered shading pipeline,通過cs對點光源、探照燈等光源進行剔除[16]。

Occlusion culling

圖片來源,知乎大V MaxwellGeng實現的GPU Occlusiong Culling,他使用了Hiz的方法,對cluster進行遮擋剔除[17]。

而這種思想就是GPUDRP。

GPU Driven Rendering Pipeline

圖為刺客信條大革命,在這部遊戲中使用了GPUDRP技術,並在Siggraph 2015: Advances in Real-Time Rendering in Games course中發表[18]。

還有很多很多……

Simple, but not easy.

「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的使用就可以在手機上普及。


引用

  1. Graphic Processing Processors (GPUs) Parallel Programming
  2. DirectCompute Optimizations and Best Practices
  3. Compute Shaders: Optimize your engine using compute / Lou Kramer, AMD (video)
  4. Introduction to Compute Shaders in Vulkan
  5. Compute Shader(OpenGL)
  6. Compute Shader Overview(Direct3D 11)
  7. About Threads and Threadgroups(Metal)
  8. ARM? Mali? GPU OpenCL Developer Guide(Version 3.2)
  9. Real-Time Rendering 3rd Edition. Chapter 18
  10. GPU Particles (Github)
  11. GPU Cloth Tool
  12. Compute Shader Filters
  13. Adaptive Scalable Texture Compression
  14. Introduction to 3D Game Programming with DirectX 11
  15. DirectX 11 Tessellation (NVIDIA)
  16. DirectX 11 Rendering in Battlefield 3
  17. Hi-Z GPU Occlusion Culling
  18. GPU-Driven Rendering Pipelines
  19. docs.unity3d.com
  20. Problems with ComputeBuffer Readback
  21. Volume Tiled Forward Shading (Github)
  22. Low-level Shader Optimization for Next-Gen and DX11 (ppt) (video)
  23. Post-processing Stack v2 (Github)
  24. 數字圖像處理(岡薩雷斯)
  25. General-purpose computing on graphics processing units (Wikipedia)
  26. 全局光照技術:從離線到實時渲染
  27. Mythbusters Demo GPU versus CPU ( NVIDIA )
  28. Glops

推薦閱讀:

相關文章