ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • [번역] More compute shaders | Anteru's Blog
    Graphics/번역 2022. 7. 28. 02:24

    개인 공부용으로 번역한 거라 잘못 번역된 내용이 있을 수 있습니다.
    또한 원작자의 동의 없이 올려서 언제든 글이 내려갈 수 있습니다.
    출처 : https://anteru.net/blog/2018/more-compute-shaders/

    More compute shaders

    저번 주에 나는 compute shaders 를 다뤘습니다, 그리고 나는 subgroup 을 더 다루기 위해서 하드웨어 쪽으로 더 자세히 가보자는 요청을 받았습니다. 그러나 시작하기 전에, compute unit 이 어떻게 생겼고 거기서 무슨 일이 일어나는지 다시 요약해 보겠습니다.
    이전 글에서, 나는 하드웨어는 많은 item 이 같은 프로그램을 실행하는데 최적화 되어있다고 설명했습니다. 그 결과 아주 넓은 SIMD unit(AMD GCN 의 경우 16-wide) 을 사용하였으며, 메모리 지연을 작업 전환으로 숨기고, 마스킹에 의존하는 branching model 을 사용했습니다. 나는 이런 것들에 대해 너무 자세한 내용은 설명하지 않았으며, 이제 할 거에요, 그리고 그 후에, 우리가 찾은 새로운 지식으로 무엇을 할 것인지 찾아볼 것입니다.

    SIMD execution

    당신의 코드가 실행 될때, GCN compute unit 은 당신이 주의해야 할 2개의 주요 구성 요소(main building block)이 있습니다: 여러 SIMD unit 과 scalar unit. SIMD unit 은 각각 16 wide 이며, 이것은 한 번에 16개 요소를 처리하는 것을 의미합니다. 그러나, 그들의 지연은 1 cycle 이 아닙니다 - 그들은 single clock cycle 에 명령 실행을 완료하지 않습니다. 대신에, 명령을 시작부터 끝까지 처리하는데 4 cycle 을 사용합니다(몇몇 경우는 더 긴 경우도 있지만, 지금은 모두 4 cycle 로 둡시다). 4 cycle 은 fused-multiply-add 같은 것에서 예상하는 속도며, 이것은 3개의 오퍼랜드를 register file 로 fetch 하고, 곱셈과 덧셈을 수행하고, 다시 결과를 기록합니다(고도로 최적화된 CPU 디자인 또한 4 cycle을 사용합니다, Agner Fog’s instruction tables 여기에서 볼 수 있습니다).

    지연은 처리량과 같지 않습니다. 4 의 지연을 가진 명령어의 처리량은 만약 적절하게 파이프라인 되어있다면, 여전히 1 일 수 있습니다. 예제를 봅시다:

    non-pipelined unit 과 pipeline unit 의 비교. 두 경우 모두 지연은 4 cycle 입니다, 그러나 한번 파이프라인이 채워지면, pipelined unit 의 처리량은 cycle 당 1 명령이 됩니다.

    이것은 만약 당신이 single SIMD 에 대해서 수행할 작업이 충분하다면, 우리는 cycle 당 16 FMA 실행된 명령을 얻을 수 있다는 의미입니다. 만약 우리가 매 cycle 마다 다른 명령을 제출할 수 있다면, 몇몇 작업이 완료되지 않는 것 같은 다른 문제를 다뤄야 합니다. 우리의 architecture 가 모든 명령을 4 cycle 지연 처리하고 single-cycle dispatch(매 cycle 마다 새로운 명령을 파이프라인에 추가 할 수 있다는 의미)를 한다고 합시다. 이제, 우리는 이 가상 코드를 실행하는 것을 원합니다:

    v_add_f32 r0, r1, r2
    v_mul_f32 r3, r0, r2

    우리는 첫번째와 두 번째 명령에 종속성을 가집니다 - 두 번째 명령은 첫 번째가 끝나기 전에 시작할 수 없습니다, r0 이 준비되길 기다려야 하기 때문입니다. 이것은 다른 명령을 제출하기 전에 3 cycle 지연(stall) 된다는 의미입니다. GCN architect 는 이것을 SIMD 의 매 4 cycle 마다 SIMD에 한 개의 명령을 제출하여 이 문제를 해결했습니다. 추가적으로, 16 요소에서 1개 연산을 실행하고 다음 명령으로 전환하는 대신에, GCN 은 총 64 개 요소에 대해서 같은 명령을 4 번 실행합니다. 이것이 하드웨어에 요구하는 유일한 실질적 변화는 SIMD unit 보다 레지스터의 폭을 더 넓게 하는 것입니다. 이제 당신을 기다리지 않을 것입니다, v_mul_f32 가 16 요소에서 처음 시작될 때까지, v_add_f32 가 끝납니다:

    single GCN SIMD 에 명령 제출. 한개의 명령이 매 4 clock cycle 마다 제출됩니다, 그리고 명령은 즉시 차례로 시작 할 수 있습니다.

    당신은 wait cycle 이 있다는 것을 즉시 알 수 있을 것 입니다, 그리고 명백히 unit 이 대부분을 대기하는데 시간을 보내는 것은 좋지 않습니다. 이것을 채우기 위해서, GCN designer 는 4개의 SIMD unit 을 사용합니다, 그래서 실제 하드웨어에서 그림은 아래와 같습니다:

    SIMD 를 4배로 복제해서, 한개의 명령을 매 clock cycle 당 입력할 수 있습니다, 이것은 총 처리량을 64 element(요소)/clock (4 개 SIMD 는 각각 16 element/SIMD/clock)으로 해줍니다.

    이 64-wide 구조를 “wavefront” 또는 “wave” 라 부르며 이것은 가장 작은 실행 unit 입니다. wave는 SIMD 에서 실행하기 위해서 스케줄링될 수 있습니다, 그리고 각 thread group 은 적어도 한 개의 wave 로 구성됩니다.

    Scalar code

    휴, 꽤 길었네요, 그리고 불행히도 아직 끝나지 않았습니다. 지금까지, 우리는 모든 것이 SIMD 에서 실행되는 척했습니다, 그러나 내가 이전에 쓴 내용인 실행 하는데 관련된 2개의 블록이 있다는 것을 기억하시나요? 이제 다른 일을 시작할 시간입니다.
    만약 당신이 원격으로 복잡한 것을 프로그래밍한다면, 당신은 두 가지 종류의 변수가 있다는 것을 알 수 있습니다: Uniform values 값은 모든 요소들에서 상수입니다, 그리고 non-uniform 값은 lane 별로 다릅니다.
    non-uniform 변수는 예를 들어 laneId 가 될 것입니다. 우리는 g_laneId 라는 특별한 레지스터가 있다고 가정하고, 그런 다음 아래의 코드를 실행하길 원합니다:

    if (laneId & 1) {
        result += 2;
    } else {
        result *= 2;
    }

    이 예에서, 우리는 conditional move 에 관해서 이야기하지 않을 것입니다, 그래서 branch 로 컴파일 되어야 합니다. GPU 에서 이것은 어떻게 보일까요? 우리가 배운 것에 따르면, execution mask 라 불리는 것이 있고 이것은 어떤 lane 이 활성화되는지 조작합니다(divergent control flow 로 알려짐). 이 개념으로, 이 코드는 아래 코드처럼 적절하게 컴파일될 것입니다:

    v_and_u32 r0, g_laneId, 1   ; r0 = laneId & 1
    v_cmp_eq_u32 exec, r0, 1    ; exec[lane] = r0[lane] == 1
    v_add_f32 r1, r1, 2         ; r1 += 2
    v_invert exec               ; exec[i] = !exec[i]
    v_mul_f32 r1, r1, 2         ; r1 *= 2
    v_reset exec                ; exec[i] = 1

    정확한 명령은 중요하지 않습니다, 중요한 것은 우리가 보고 있는 것 모든 값은 보고 있는 그대로가 아니라 lane 당 값이라는 것입니다. 즉, g_laneIdr1 과 같이 모든 single lane 에 대해 다른 값입니다. 이것은 “non-uniform” 값입니다, 그리고 각 lane 은 벡터 레지스터에 자신의 슬롯을 가지고 있기 때문에 기본 케이스입니다.
    이제, 만약 control flow 가 아래와 같고, cb 가 상수 버퍼로부터의 값입니다:

    if (cb == 1) {
        result += 2
    } else {
        result *= 2;
    }

    이것은 다음과 같은 단순한 코드가 됩니다:

    v_cmp_eq_u32 exec, cb, 1    ; exec[i] = cb == 1
    v_add_f32 r1, r1, 2         ; r1 += 2
    v_invert exec               ; exec[i] = !exec[i]
    v_mul_f32 r1, r1, 2         ; r1 *= 2
    v_reset exec                ; exec[i] = 1

    이전 코드에서는 없던 문제가 갑자기 생겼습니다 - cb 는 모든 lane 에서 상수입니다, 그러나 그렇지 않은 것처럼 하고 있습니다. cb 가 uniform 값일 때, 1 과 비교하는 것은 lane 당 수행하는 것 대신 한번 수행될 수 있습니다. 이것이 벡터 명령이 새로 추가된 CPU 에서 당신이 수행하는 것입니다. 당신은 아마도 일반 conditional jump 를 할 것입니다 (다시 한번, 지금은 conditional move 를 무시하세요), 그리고 각 branch 에서 벡터 명령을 호출합니다. GCN 에서는 vector 대신 single scalar 에서 수행하기 때문에 “scalar” 로 이름 붙인 “non-vector” 개념을 가집니다. GCN 어셈블리에서, 다음과 같이 코드가 컴파일될 것입니다:

    s_cmp_eq_u32 cb, 1          ; scc = (cb == 1)
    s_cbranch_scc0 else         ; jump to else if scc == 0
    v_add_f32 r1, r1, 2         ; r1 += 2
    s_branch end                ; jump to end
    else:
    v_mul_f32 r1, r1, 2         ; r1 *= 2
    end:

    이것은 우리에게 무엇을 해주나요? 가장 큰 장점은 scalar unit 과 레지스터는 vector unit 에 비해서 비용이 아주 싸다는 것입니다. vector 레지스터는 64x32 bit 크기인 반면, scalar 레지스터는 32 bit 입니다, 그래서 우리는 벡터보다 더 많은 scalar 레지스터를 칩에서 던질 수 있습니다(몇몇 하드웨어는 특별한 predicate 레지스터를 동일한 이유로 가집니다, lane 당 한 비트는 전체 벡터 레지스터에 비해 훨씬 적은 저장공간입니다). 우리는 이것을 CU 당 64 번 인스턴스 화할 필요가 없기 때문에 scalar unit 을 위해서 특이한 비트 조작 명령을 또한 추가할 수 있습니다. 최종적으로, 우리는 더 적은 power 를 사용합니다. 왜냐하면 scalar unit 은 이동시키고 작업하는데 더 적은 데이터를 가지기 때문입니다.


    Putting it all together

    이제 하드웨어 전문가가 되어서, 최종적으로 어떻게 우리가 이런 개념을 잘 사용할지 알아봅시다. 우리는 wavefront wide 명령으로 시작할 것입니다, 이것은 GPU 프로그래머에게 인기 있는 주제입니다. Wavefront wide 는 우리가 lane 당 아니 아닌 모든 lane 에 대해서 작업한다는 의미입니다. 그게 뭘까요?

    Scalar optimizations

    처음으로 우리가 해보고 싶은 것은 아마도 execution mask 를 갖고 노는 것일 것입니다. 모든 하드웨어는 명시적이든 predication mask 이든 어떤 형태로 가집니다. 이것으로, 우리는 깔끔한 최적화를 할 수 있습니다. 우리는 다음과 같은 코드가 있다고 가정합시다:

    if (distanceToCamera < 10) {
        return sampleAllTerrainLayers ();
    } else {
        return samplePreblendedTerrain ();
    }

    충분히 문제없어 보입니다만 두 함수 모두 메모리를 샘플링하기 때문에 더 비쌉니다. 우리가 배운 것처럼, 만약 우리가 divergent control flow 를 가진다면, GPU 는 두 경로를 모두 실행할 것입니다. 설상가상으로, 컴파일러는 이 코드를 아래와 같은 수도 코드로 컴파일할 것입니다:

    VectorRegisters<0..32> allLayerCache = loadAllTerrainLayers();
    VectorRegisters<32..40> simpleCache = loadPreblendedTerrainLayers();
    if (distanceToCamera < 10) {
        blend (allLayerCache);
    } else {
        blend (sampleCache);
    }

    기본적으로, 그것은 가능한 한 메모리 접근을 선로드(front-load) 시도할 것입니다, 그래서 우리가 다른 경로에 도달할 때쯤에 로드 완료될 가능성이 높습니다. 그러나, 개발자로서 우리는 모든 레이어의 변경(역주 : loadAllTerrainLayers() 를 말하는 듯합니다)이 더 높은 품질이라는 것을 알고 있습니다, 그래서 이 접근은 어떤가요: 만약 어떤 하나의 lane 이 높은 품질의 경로로 가려면, 우리는 모든 lane 을 높은 품질의 경로로 보냅니다. 우리는 전체적으로 약간 더 높은 품질을 얻을 것입니다, 게다가 그에 대해서 두 가지 최적화를 얻을 수 있습니다:
    - 컴파일러는 선로드(front-load)를 하지 않아서 더 적은 레지스터를 사용할 수 있습니다.
    - 컴파일러는 scalar branch 를 사용할 수 있습니다.

    이것은 위한 많은 함수들이 있습니다, 이 모든 것은 execute mask(또는 predicate register, 여기서는 execution mask 로 가정할 것입니다) 에서 실행됩니다. 여기에 3개의 함수가 있습니다:
    - ballot ()exec mask 를 반환함
    - any ()exec != 0 반환
    - all()~exec == 0 반환

    이를 활용하기 위해서 코드를 변경하는 코드는 아주 적습니다:

    if (any (distanceToCamera < 10)) {
        return sampleAllTerrainLayers ();
    } else {
        return samplePreblendedTerrain ();
    }

    또 다른 일반적인 최적화는 atomics 에 적용하는 것입니다. 만약 우리가 global atomic 을 lane 당 한 번씩 증가시키길 원한다면, 우리는 이렇게 할 수 있습니다:

    atomic<int> globalAtomic;
    if (someDynamicCondition) {
        ++globalAtomic;
    }

    이것은 GCN 에서 64 번의 atomic increments 를 요구합니다(GCN 은 전체 wavefront 에서 이들을 합치지 않습니다). 이것은 꽤 비쌉니다, 우리는 이것을 다음과 같이 변환하면 훨씬 더 낫습니다:

    atomic<int> globalAtomic;
    var ballotResult = ballot (someDynamicCondition);
    if (laneId == 0) {
        globalAtomic += popcount (ballotResult);
    }

    popcount 는 설정된 비트의 수를 셉니다. 이것은 atomics 호출 64 개를 줄여줍니다. 실제로, 당신이 압축을 하는 동안 아마도 lane 당 값을 여전히 원할 수도 있습니다(you probably still want to have a per-lane value if you’re doing a compaction). 그리고 이런 경우가 너무 일반적이라서 GCN 은 별도의 opcode 를 가집니다(v_mbcnt). 이것은 atomics 를 수행할 때 컴파일러에 의해서 자동으로 사용됩니다.

    마지막으로 scalar unit 에 대해서 하나 더 봅시다, 우리가 어떤 drawId 를 전달하는 vertex shader 를 갖고 있다고 합시다, 그리고 pixel shader 는 그것의 보간 된 값을 얻습니다. 이 경우 (barring cross-stage optimization and vertex-input-layout optimization), 이런 코드는 문제를 일으킬 것입니다:

    var materialProperties = materials [drawId];

    컴파일러는 drawId 가 uniform 인지 알지 못하기 때문에, 그것을 non-uniform 으로 가정합니다, 그래서 vector register 로 vector load 를 수행합니다. 만약 우리가 그것이 uniform 인 것을 안다면 - 여기서 dynamically uniform 은 특정 용어임 - 우리는 컴파일러에게 이것에 관해 알려 줄 수 있습니다. GCN 은 이를 표현하기 위한 "표준" 이 되는 방법인 특수 명령어를 갖고 있습니다 - v_readfirstlane. Read-first-lane 은 첫 번째 활성 lane 을 읽고, 그 값을 모든 다른 lane 에 브로드케스트 합니다. 이 별도의 scalar register 를 사용하는 이 architecture 에서, 그 값이 scalar register 에 로드될 수 있다는 것을 의미합니다. 최적의 코드는 그래서 다음과 같습니다:

    var materialProperties = materials [readFirstLane (drawId)];

    이제 materialProperties 는 scalar register 에 저장됩니다. 이것은 vector register 압박을 줄여주고 Properties 를 참조하는 branch 또한 scalar branch 로 변환시켜줍니다.


    Vector fun

    scalar unit 에 관해서는 이 정도로 하고, vector unit 으로 넘어갑시다, 왜냐하면 여기는 굉장히 재미있기 때문입니다. pixel shader 는 연산에 아주 큰 영향을 미칩니다, 왜냐하면 하드웨어가 정말 파격적인 것을 하도록 강요하기 때문입니다 - lane 이 서로 통신하도록 하는 것 같은. 우리가 지금까지 GPU 에 관해 배운 모든 것은 LDS 또는 scalar register 를 통해 무언가를 브로드케스팅하여 (또는 single lane 을 읽어서) 통신하는 것 말고는 lane 들 끼리 서로 통신할 수 없을 거라고 합니다. pixel shader 는 아주 특수한 요구사항 있습니다 - 그것은 derivatives 입니다. GPU 는 derivative 를 quad 를 사용하여 구현합니다, 즉. 2x2 pixel, 그리고 그들 간의 데이터를 dynamic varying 방식으로 교환합니다. 충격인가요?

    Pixel shader 는 ddx(), ddy() 명령을 통해서 이웃 lane 에 접근할 수 있습니다. 각 lane 은 1개의 픽셀을 처리합니다, 그리고 4 lane 내에서, derivative 가 동작하게 하기 위해서 많은 교환이 필요합니다. 오른쪽에, 초기 패킹을 볼 수 있습니다, 그리고 어떻게 dervative 가 4 lane 내에서 데이터를 교환하는지 볼 수 있습니다.

    이것은 보통 quad swizzle 이라고 불립니다, 그리고 이것을 수행하지 않는 GPU 를 찾는 것은 어려울 것입니다. 그러나, 대부분의 GPU 는 더 나아갑니다, 그리고 간단히 4가 lane 간에 섞어주는 것(swizzling) 보다 더 한 것을 제공합니다. GCN 은 DPP(data parallel primitives) 를 도입한 이후로 상당히 멀리 갔습니다. DPP 는 섞어주는 것(swizzling)을 뛰어넘어 cross-lane operand sourcing 을 제공합니다. 단지 quad 내의 순서를 바꾸는 것 대신에, 한 lane 이 다른 lane 의 명령의 입력으로 사용하는 것을 허용합니다, 그것을 이렇게 표현할 수 있습니다:

    v_add_f32 r1, r0, r0 row_shr:1


    이것은 뭘 하는 걸까요? 이것은 이 lane 에서 현재 r0 값 그리고 같은 SIMD 의 오른쪽에 있는 값(row-shift-right 1로 설정됨)을 얻습니다, 그리고 그것을 현재 lane 에 저장합니다. 이것은 새로운 대기 상태를 도입하는 아주 심각한 기능입니다, 그리고 또한 어떤 lane 으로 브로드케스팅 할 수 있는지와 같은 다양한 제한사항이 있습니다. 이 모든 것은 구현에 관해 아주 잘 알고 있어야 하며 그것은 벤더마다 그들이 어떻게 lane 간에 데이터를 교환할 수 있는지 다르기 때문입니다, 고수준 언어는 min 등과 같은 일반적인 wave-wide 감소를 노출합니다(the high level languages expose general wave-wide reductions like min etc). 이것은 값을 얻기 위해서 swizzle또는 DPP 와 같은 것을 사용합니다. 이런 기능으로, 당신은 몇 단계를 거쳐 메모리에 접근하지 않고 wavefront 간에 이동하는 값을 줄일 수 있습니다 - 이것은 더 빠르고 여전히 사용하기 쉽습니다; 좋아하지 않을 이유가 없죠!


    Summary

    나는 당신이 이것이 어떻게 실제로 작동하는지 알게 되었길 바랍니다. GCN 의 경우 이제 정말 다루지 않은 것이 많이 남지 않았습니다, 내 생각에 별도의 execution port 와 wait 를 어떻게 처리하는지 정도만 생각납니다, 우리는 아마도 이후 글에서 그것을 다뤄볼 수 있겠죠? 읽어주셔서 감사합니다, 그리고 만약 질문이 있다면, 주저하지 말고 저에게 연락 주세요.

    댓글

Designed by Tistory & scahp.