Compute Shader

Date:     Updated:

카테고리:

태그:

홍정모님의 그래픽스 새싹코스 강의를 듣고 정리한 내용입니다.


Thread Hierarchy

Hierarchy Indexing
191650 191732

렌더링 파이프라인에 묶여 있는 pixel shader와 달리 compute shader는 일반적인 병렬 프로그램을 처리할 수 있다. 이때 compute shader에서 실행되는 연산의 기본 단위를 Thread라 하는데 Thread들이 모여 Thread Block을 구성하고, Thread Block들이 모여 Kernel을 구성한다. 렌더링 파이프라인에 나오는 개념과 비유하자면 Kernel은 Texture, Thread는 Pixel에 대응된다. 병렬 프로그래밍을 하기 위해서는 thread마다 고유의 index가 필요한데, 접근하는 방식이 여러가지 존재한다. Thread Group 단위로 index를 접근할 수 있고, group과 상관 없이 개별 thread의 index로도 접근이 가능하다. 자세한 내용은 예제에서 다시 살펴보자.


GPU Architecture

GPU Architecture Dispatch
193226 193251

Nvidia GPU는 Streaming-Multiprocessor(SM Block)들로 이루어져 있다. 각 SM Block은 하나의 Thread Block을 처리하고, 32개의 코어로 이루어져 있다. 이때 각 코어는 하나의 Thread를 담당하기 때문에, 32개의 Thread 단위를 Warp라 한다. 이를 통해 Thread Block에 포함되는 Thread 개수는 32의 배수일 때 가장 효율적임을 알 수 있다. CPU에서 Dispatch 명령을 실행하면 Compute Shader가 실행되는데, 각 Thread Block들이 SM-Block으로 분배된다.


Example

D3D11_UNORDERED_ACCESS_VIEW_DESC uavDesc;
ZeroMemory(&uavDesc, sizeof(uavDesc));
uavDesc.Format = desc.Format; 
uavDesc.ViewDimension = D3D11_UAV_DIMENSION_TEXTURE2D;
uavDesc.Texture2D.MipSlice = 0;
ThrowIfFailed(m_device->CreateUnorderedAccessView(
    backBuffer.Get(), &uavDesc, m_backUAV.GetAddressOf()));

m_context->CSSetUnorderedAccessViews(0, 1, m_backUAV.GetAddressOf(), NULL);

앞서 렌더링 파이프라인을 공부할 때 Shader에서 텍스처를 읽을 때는 Shader Resource View를, 특정 텍스처에 그릴 때에는 Render Target View를 세팅해줬다. Compute Shader에서는 읽기/쓰기가 모두 가능한 텍스처를 Unordered Access View로 세팅하게 된다. 이름에 unordered가 포함된 이유는 각 thread block들이 SM Block으로 뿌려져 실행되기 때문에 연산 순서를 보장할 수 없기 때문이다.


// compute shader
RWTexture2D<float4> gOutput : register(u0);

[numthreads(32, 32, 1)]
void main(int3 gID : SV_GroupID, uint3 tID : SV_DispatchThreadID)
{
    float offset = tID.x / 640.0;
    float3 pColor = palette(offset);
    
    if (gID.x % 2 == 0 ^ gID.y % 2 == 0)
    {
        gOutput[tID.xy] = float4(pColor, 1.0);
    }
    else
    {
        gOutput[tID.xy] = float4(1.0, 1.0, 1.0, 1.0) * scale;
    }
}
  • CSSetUnorderedAccessViews로 넘겨준 texture는 RWTexture2D로 받음
    • 레지스터도 (u) 사용.
  • [numthreads(32, 32, 1)] : Thread Block 정의
    • thread의 총 개수는 32의 배수인 것이 좋음
    • 전체 thread 개수는 1024개까지만 가능
    • block shape의 경우 캐시 적중률을 고려
  • int3 gID : SV_GroupID, uint3 tID : SV_DispatchThreadID : input parameter로 thread index 사용 가능
    • DispatchThreadID
    • GroupID
    • GroupIndex
    • GroupThreadID
  • thread index 말고도, Sampler를 가져와 접근하는 방식도 가능


m_context->Dispatch(UINT(ceil(m_screenWidth / 32.0f)), UINT(ceil(m_screenHeight / 32.0f)), 1);

ID3D11UnorderedAccessView *nullUAV[6] = {0, };
m_context->CSSetUnorderedAccessViews(0, 6, nullUAV, NULL);
  • 보통 렌더링 파이프라인에서는 DrawIndexed() 로 렌더링 명령
    • Compute shader에서는 Dispatch() 하면 Shader 실행
  • Dispatch시 Thread Group Count * Thread Block이 정확한 pixel 개수와 맞지 않아도 괜찮음
    • 실제 pixel 갯수보다 적으면 그리지 않는 부분이 생기기 때문에 ceil 이용
    • 일반적으로 인덱스가 범위를 벗어나면 오류가 발생하지만, CS에서는 0을 읽어와 쓰기를 시도하지 않게 됨
  • CPU입장에서 Compute Shader의 수행이 마무리 되었는지 확인하기가 애매 함
    • UAV를 Null로 교체하는 작업은 CS 작업이 끝나야 가능하기 때문에 이를 통해 CS 작업 사이 Barrier 역할을 수행할 수 있음


Result

190435


Group Shared Memory

143249

Compute Shader에서는 Thread Group 별로 SM Block에 올라가 코드가 실행된다. 이때 각 SM Block 들이 별도의 Cache Memory를 가지고 있기 때문에, cache memory 교체가 이루어 지지 않게 해준다면 성능이 좋아질 것이다. X 방향으로 Gaussian Blur를 하는 경우를 생각해보자. Thread Block을 Nx1x1로 잡을 경우, Blurring 연산에 필요한 pixel 정보는 [N + 2 * radius]일 것이다. Compute Shader에서는 group shared 키워드를 통해 특정 메모리를 SM Block cache에 올려줄 수 있다.


// compute shader
Texture2D<float4> inputTex : register(t0);
RWTexture2D<float4> outputTex : register(u0);

static const float weights[11] =
{
    0.05f, 0.05f, 0.1f, 0.1f, 0.1f, 0.2f, 0.1f, 0.1f, 0.1f, 0.05f, 0.05f,
};

static const int blurRadius = 5;

#define N 256
#define CACHE_SIZE (N + 2*blurRadius)

// Groupshared memory is limited to 16KB per group.
// A single thread is limited to a 256 byte region of groupshared memory for writing.

groupshared float4 groupCache[CACHE_SIZE];

[numthreads(N, 1, 1)]
void main(uint3 gID : SV_GroupID, uint3 gtID : SV_GroupThreadID,
          uint3 dtID : SV_DispatchThreadID)
{
    uint width, height;
    outputTex.GetDimensions(width, height);

    if (gtID.x < blurRadius)
    {
        int x = max(int(dtID.x) - blurRadius, 0);
        groupCache[gtID.x] = inputTex[int2(x, dtID.y)];
    }
    
    if (gtID.x >= N - blurRadius)
    {
        int x = min(dtID.x + blurRadius, width - 1);
        groupCache[gtID.x + 2 * blurRadius] = inputTex[int2(x, dtID.y)];
    }
    
    groupCache[gtID.x + blurRadius] =
        inputTex[min(dtID.xy, uint2(width, height) - 1)];

    GroupMemoryBarrierWithGroupSync();
    
    float4 blurColor = float4(0, 0, 0, 0);

    [unroll]
    for (int i = -blurRadius; i <= blurRadius; ++i)
    {
        int k = gtID.x + blurRadius + i;
        blurColor += weights[i + blurRadius] * groupCache[k];
    }

    outputTex[dtID.xy] = blurColor;
}
  • 당연하게도 group shared memory는 크기 제한이 존재
  • group shared memory를 업데이트 할 때에는 GroupThreadID 인덱싱이 유용
  • GroupMemoryBarrierWithGroupSync() 함수를 통해 캐시 메모리 업데이트가 끝나기를 기다려야 함



맨 위로 이동하기

Graphics 카테고리 내 다른 글 보러가기

댓글 남기기