개요

쉐이더에서 if 문이 성능에 좋지 않다는 것은 프로그래머가 아니더라도 많이들 알고 있는 사실이다.

중요한 것은 단순히 if 문이 느리다는 것이 아니다, if 문이 필요해서 if 문을 쓰는 것인데 느리다는 사실을 아는 것만으로는 별로 도움이 되지 않는다. 왜 if 문이 느린 것인지를 아는 것이 중요하고, 그것을 제대로 알아야 if 문을 우회하는데 도움이 될 수 있다1.

최적화 이슈에서 이유를 잘못 알고 어떤 결과만 아는 것은 독이 될 수도 있다. 잘못된 결론을 내버리면 잘못된 최적화로 최적화가 안될 뿐 더러 오히려 더 느려질 수도 있다. 따라서 왜 느린지를 제대로 아는 것은 특히 프로그래머라면 매우 중요하다.

GPU의 동작 방식

CPU의 코어는 개별적으로 도는데에 비해 GPU의 코어는 묶음으로 돈다. SIMT(Single Instruction, Multiple Threads)라는 용어가 그 특성을 잘 설명한다. GPU에서는 여러 쓰레드(코어)가 동시에 하나의 명령어를 수행한다2. 일반적으로 이 묶음은 32개 혹은 64개다3. 동시에 32개를 계산하니 빠름과 동시에 여러 단점도 존재한다. 대표적인 예가 if/else 문에 의한 분기 발산(brance divergence)이다4.

// 코드 1
if (cond) // line 1.
{
    finalColor = hairShading(); // line 3. 머리카락 쉐이딩 계산 (무거움)
}
else
{
    finalColor = clothShading(); // line 7. 옷 쉐이딩 계산 (무거움)
}

위의 극단적인 코드를 보자. cond의 상태에 따라 머리카락 쉐이딩을 계산하거나 옷 쉐이딩을 계산한다. 여기서 머리카락이면서 옷인 경우는 없다. GPU는 이런 계산을 할 때 32개(64개도 있지만 여기서는 32개로 고정하겠다) 쓰레드가 동시에 같은 명령어를 처리하는데 위에서 설명한 GPU의 분기 발산의 상황에서는 hairShading과 clothShading을 모두 호출해서 계산하게 된다. 여기서 이 두 함수는 매우 무거운 함수다.

구체적으로 말하면 다음과 같은 순서로 수행 된다.

  1. cond 에 해당하는 플래그를 각 쓰레드의 레지스터에 넣는다. (line 1)
  2. hairShading 을 수행한다. 이때 위에서 저장한 cond 플래그가 true 인 경우만 finalColor 변수에 결과를 넣는다. true 가 아닌 쓰레드도 있을 텐데 그 쓰레드도 일단 동시에 계산한다. 그리고 true 아닌 쓰레드의 결과 값은 버린다. (line 3)
  3. clothShading 을 수행한다. 이때 위에서 저장한 cond 플래그가 false 인 경우만 finalColor 변수에 결과를 넣는다. 마찬가지로 true 인 쓰레드는 계산만 하고 다른 것은 버린다. (line 7)

2번과 3번에서 플래그가 맞지 않은 계산은 계산은 하고 그냥 버린다. GPU는 동시에 계산을 해야하기 때문에 일단 계산은 하고 조건에 맞는 결과만 저장한다. 즉, 분기 발산에서는 모든 분기를 불필요하게 다 계산한다. 물론 CPU에서는 이럴 필요가 없다. if 문 조건에 맞아 수행을 하면 GOTO 문으로 분기를 빠져나간다. GPU에서 처럼 모든 분기를 다 수행하는 것이 분기 발산이다.

여기서 오해를 할 수도 있으므로 다른 코드를 보자.

// 코드 2
float4 hairShadingResult = hairShading();
float4 clothShadingResult = clothShading();

if (cond)
{
    finalColor = hairShadingResult;
}
else
{
    finalColor = clothShadingResult;
}

비싼 계산을 if/else 안에서 하지 않고 밖에서 한 후 if/else 안에서는 대입만 했다. 이런 경우 분기 양쪽을 계산한다고 해도 대입만 하기 때문에 브랜칭이 별거 아닌 것처럼 오해할지도 모른다. 하지만 각 분기에 필요한 모든 계산을 한다는 점을 보면 이전 코드와 전혀 다를게 없어서 전혀 이득이 없다.

이때 어셈블리 수준에서는 if + mov 명령어가 movc 하나로 바뀌는데 어셈블리 수준에서 if + mov 명령어가 느린 것은 아니기 때문에 이 명령어 몇개 줄어든다고 차이가 성능 이점을 가져올 가능성은 사실상 없다. 오히려 분기 발산을 명확하게 만들어버리기 때문에 성능에 큰 하락을 가져온다.

이 코드의 근본적인 문제는 hairShadingResult와 clothShadingResult의 계산 자체가 느린 것이다. 하나의 쉐이더 안에서 불필요하게 두번 하는 것이 문제다. 머리카락이면서 옷인 재질이라서 위 함수를 모두 수행해야한다면 어쩔 수 없지만 둘 중 하나만 처리해도 되는 상황에서 둘다 처리하는 것이 분기 발산의 문제점이다. 이는 실제로 성능에 굉장히 큰 영향을 준다(두 종류가 아니라 더 여러 종류라면 상황은 더 심각하다)5.

참고로 코드 2번의 경우 혹시나 어떤 방식으로든 GPU가 최적화를 해줄 수도 있지 않을까 해서 최신 하드웨어에서 실제로 테스트 해보았다(RTX 3080). 실제로 불필요한 분기를 계산하는 것을 확인했다.

다이나믹 브랜칭 (dynamic branching)

분기 발산 자체가 문제지만 if 문을 사용한 분기가 항상 문제가 되는 것은 아니다. 만약 32개의 쓰레드의 cond 값이 모두 true 이거나 false 이면 걱정할 필요가 없다.

if (cond) // cond 는 모두 ture 이거나 false
{
    finalColor = hairShading();
}
else
{
    finalColor = clothShading();
}

32개의 쓰레드에 대해서 모두 cond 값이 같을 경우는 브랜칭 문제가 생기지 않는다. 가령 32개의 쓰레드의 cond 가 모두 동시에 true 이면 hairShading 만 처리하고 굳이 clothShading 은 처리 할 필요가 없다는 것을 GPU가 런타임에 알 수 있기 때문에 else 부분은 수행하지 않고 넘어간다. 이것을 다이나믹 브랜칭 (dynamic branching)이라고 부른다. 물론 이런 최적화는 처음부터 되었던건 아니다. 옛날 GPU는 cond 의 상태와 무관하게 둘다 동작했지만 지금은 똑똑하게 처리해준다6. 참고로 이렇게 동일한 분기로 처리가 될 때 응집성(coherence)이 좋다고 말한다.

코드 2번은 사실상 다이나믹 브랜칭으로 효과적으로 계산 될 가능성이 사라지고 분기 발산(branch divergence)이 고정 되었기 때문에 느리다고 할 수 있다. 고성능 CPU나 미래의 GPU는 동적 분기 예측(dynamic branch prediction)을 통해 코드 2번도 효과적으로 처리할 수 있겠지만 현재(그리고 근 미래)의 GPU는 이를 아직 지원하지 않는다.

문제의 상황

다이나믹 브랜칭 덕에 상황은 나아졌지만 진짜 문제가 되는 상황은 32개의 쓰레드 내에서 머리카락이거나 옷이 섞여 있을 경우다. 일반적으로 여기 예시와 같은 옷과 머리카락의 쉐이딩 처리는 매우 무겁다. 머리카락과 옷이 떨어져 있어서 섞여 있지 않다면 대체적으로 문제가 없겠지만 섞여 있는 부분이 화면에 많으면 많을 수록 심각하게 느려지는 요인이 된다. 실제로 프로파일링을 해보면 옷과 머리카락이 섞여 있는 경계 부분에 성능을 많이 잡아 먹는 것을 프로파일러를 이용하여 시각적으로도 쉽게 확인할 수 있다.

해결 방법

해결 방법은 생각보다 쉽지 않다. 이미 언급했듯이 if 문은 필요에 의해 쓰는 것이기 때문에 쉽게 없앨 수 있는 것은 아니다.

그래도 몇가지 방법이 있다.

1. 다이나믹 브랜칭을 방해하는 코드를 짜지 말 것

2번과 같은 코드를 의미한다. 2번과 같은 코드는 movc 로 어셈블리 명령어가 줄어들지만 이 성능 차이는 상대적으로 매우 무의미하고 컴파일러가 최적화 할 수 있는 것을 방해한다. 그냥 if 문이 느리다, 생각보다 빠르다 같이 단순한 결론만 가지고 판단을 하면 안되고 왜 느린지를 알아야 느린 if 문을 피할 수 있다. 2번과 같은 코드는 if 문이 왜 느린지를 모르고 잘못작성 한 것이기 때문에 더 느려지는 것이다7.

2. 가능한 공용 코드는 한번만 계산한다

가령 머리카락과 옷의 라이팅 계산 중 디퓨즈 계산의 경우 (처리가 동일하다면) 각 함수 안에서 하는 것 보다 밖으로 빼는게 낫다. 디퓨즈 계산 자체야 별거 안되지만 레지스터 사용량 등을 생각했을 때 굳이 안에서 할 이유는 없어보인다(물론 상황마다 다르다). 가급적 공통적인 계산은 브랜치와 무관하게 공통으로 계산하는 것이 두번째 방법이다. 하지만 이는 레지스터나 캐시의 상황에 따라 다를 수 있기 때문에 이것 역시 단순하게 판단할 부분은 아니다.

3. 쉐이더 분리 (쉐이더 퍼뮤테이션)

언리얼 엔진에서는 쉐이더 퍼뮤테이션(shader permutation)이라는 용어를 사용하는데 유니티에서는 쉐이더 베리언트(shader variants)라는 용어를 사용한다. 근본적으로는 같다. 쉐이더 코드 안에서 #define 으로 하나의 코드를 여러 코드로 분리하는 것이다.

쉐이더를 분리한다고 여전히 간단히 해결 되는 것은 아니다. hairShading / clothShading 각각을 분리한 후 공용으로 처리하는 쉐이더를 또 따로 방법도 있고, hairShading 과 clothShading 만 분리해서 겹치는 지역에는 각각 한번씩 돌리는 방법이 있다. 이 경우 스텐실 등을 이용해서 불필요한 경우 관련된 처리가 아예 돌아가지 않도록 보장 해야한다. 쉐이더가 두번 도는 오버헤드가 존재하지만 머리카락과 옷의 처리가 매우 무겁다면 이 오버헤드를 감안하고 성능향상이 있을 수 있다.

마무리 및 요약

if 문에 의한 분기는 비싸다. 하지만 중요한 것은 if 문이 비싸다는 단순한 결론 보다는 왜 비싼지를 아는 것이다. if 문 자체가 비싼 것이 아니다. 양쪽 분기를 모두 수행하는 것이 문제다. 이런 과정을 좀 더 자세히 안다면 우리는 GPU를 좀 더 이해할 수 있고 코드를 잘못 작성 하는 것을 줄일 수 있다.

다시 정리하자면 GPU에서는 32개의 쓰레드 단위로 동시에 수행하기 때문에 분기 문에서 if/else 코드를 둘다 계산해버린다. 간단한 계산이라면 큰 의미는 없겠지만 이때 관련 된 처리가 비싸다면 불필요한 성능 하락이 있을 수 있다.

그래도 응집성(coherence)이 높다면 다이나믹 브랜칭으로 성능 하락은 완화 되겠지만 응집성이 낮은 경우 심각한 성능 하락을 가져올 수 있다. 따라서 여러 비싼 처리를 분기 처리할 때는 매우 조심해야한다. 이에 대한 해결 방법은 여러가지가 있지만 항상 그렇게 단순한 것은 아니기 때문에 위험성을 항상 염두하는 것이 중요하고 엄밀한 테스트를 이용해서 상황에 맞는 방법을 잘 찾아 최적화 해야한다.

참고 문헌

  1. GPU Gems 2 - Chapter 34. GPU Flow-Control Idioms
  1. if 문은 필요에 의해서 쓰는 것이기 때문에 if 문 자체를 줄일 수 있는 방법은 많지 않다. 

  2. 비프로그래머를 위해서 비유를 들자면 여기서 쓰레드는 포스트프로세스 수행시 하나의 픽셀 계산에 해당한다고 보면 비슷하다. 

  3. 이 묶음은 NVIDIA에서는 warp 라는 단어를 사용하며 32개의 쓰레드로 구성 되어 있다. 반면 AMD는 wavefront 라는 단어를 사용하며 64개의 쓰레드다. 

  4. 여기서 branch divergence 를 분기 발산이라는 용어로 번역했다. 의미가 정확히 전달 될 좋은 번역인지는 모르겠지만, divergence 는 일반적인 수학 용법에서는 발산으로 번역한다. 

  5. 언리얼 엔진 4에는 이런 코드들이 사실상 매우 많이 있다. 가급적으로 퍼뮤테이션(#define)으로 처리하기도 하지만 어쩔 수 없이 브랜칭하는 경우도 많다. 언리얼 엔진은 이 경우 브랜칭을 안고 가기로 했고 실제로 무거운 재질이 섞여 있는 경계 부분에서는 성능 하락이 제법 있다. 

  6. 필요에 의해 의도적으로 양쪽 분기를 다 타게 만들 수도 있다. 그때 사용하는 HLSL 힌트 명령어는 flatten이다. 일반적으로는 필요 없지만 리소스 바인딩 관련된 이슈로 종종 필요할 수 있다. 

  7. 상황에 따라 다이나믹 브랜칭 보다 분기 발산이 더 나은 경우도 있다. 그것은 테스트 후 다음 기회에 다시 다루도록 하겠다.