2022. 4. 26. 10:58ㆍ게임개발/(심화)컴퓨터그래픽스
이하 내용은 Khronos 위키의 글을 번역한 바입니다. 의역이 다량 포함됩니다.
Shader - OpenGL Wiki
A Shader is a user-defined program designed to run on some stage of a graphics processor. Shaders provide the code for certain programmable stages of the rendering pipeline. They can also be used in a slightly more limited form for general, on-GPU computat
www.khronos.org
GPU 실행 모형과 분기
실제로는 아니긴 하지만 CPU와 GPU의 계산 유닛이 계산기와 명령 처리기로 나뉘어 있다고 가정합시다.
이때 명령 처리기는 명령을 읽어 계산기에 지시한 후 다음 명령으로 넘어갑니다. 읽은 명령이 분기문일 때 어느 분기로 가서 명령을 읽을지 알아내는 것은 명령 처리기의 역할입니다. 조건 분기에서 명령은 조건을 확인하는 수학 계산과 그 결과를 받아 다음 명령을 읽는 것으로 나뉠 수 있습니다.
GPU가 CPU에 비해 성능상 큰 강점을 가지는 속성 중 하나는 GPU가 병렬로 아주 많은 계산을 할 수 있다는 건데요, GPU의 병렬성에 대한 사양은 표준 CPU의 스레드와는 많이 다릅니다.
실행하려는 명령 묶음이 몇 개 있습니다. 각 묶음은 그에 대한 입력 데이터를 받고 처리 후 어딘가에 출력값을 쓰는데, 이것들이 모두 서로 완전히 독립적이어서 과정 상 일말의 데이터 공유가 없습니다. 이걸 완전히 병렬로 실행하려면 각 단계마다 명령 프로세서와 계산기를 하나씩 사용해야 합니다.
여기서 조건을 바꾸겠습니다. 위의 묶음들이 입력 데이터와 출력 위치만 다르고 실행하는 계산의 내용이 완전히 같다면 어떨까요? 이런 명령을 위한 프로세서를 설계한다면, 예컨대 명령 처리기 하나로 많은 계산기에 지시하도록 할 겁니다. 여기에 각 계산기는 중간 값을 저장할 필요도 있습니다.
이런 설계는 if나 for 같은 조건 분기를 만나지만 않는다면 뭐라 할 데가 없습니다. 만약 묶음마다 다른 분기로 떨어지도록 입력이 주어진다면 어떻게 될까요? 명령 처리기는 하나뿐이라서 동시에 다른 명령을 뿌릴 수가 없습니다. 이건 어떻게 해결해야 할까요?
조건 분기로 이런 식으로 갈리면 하고 싶지 않은 일을 해야 합니다. 분기가 A와 B로 나뉜다고 할 때 명령 처리기는 B로 가게 된 계산기를 봉하고 A를 넘깁니다. 그 다음 분기로 돌아가 B로 그렇게 합니다. 그래서 실제 GPU는 명령 처리기 대비 계산기의 수가 상대적으로 작은 편입니다. 보통 명령 처리기 1개 : 계산기 32개 정도면 여러분이 원하는 크기입니다.
또한 처음에 나왔듯 실제 장치에서 계산기는 명령 처리기와 따로인 요소가 아닙니다. 결국 모든 계산기는 같은 시점에 같은 계산을 다른 값에 하고 있는 겁니다. 실제 GPU에서는 여러 데이터 입력을 가지고 같은 계산을 동시에 적용하여 중간 결과를 따로 저장하는 "계산기"를 하나 둡니다. 즉 x = 2+3과 y = 6+9가 주어지면 한 번의 덧셈 연산으로 (x, y) = (2, 3)+(6, 9)를 수행합니다. 이것이 단일 명령에 의한 여러 데이터의 처리(SIMD)의 본질입니다.
이 실행 모형 아래에서 셰이더의 각 입력에 대한 실행(invocation)은 SIMD 처리 시스템의 일부로 들어갑니다. 즉 셰이더의 각 입력에 대한 실행 여러 개가 동시에 하나의 SIMD 코어에서 계산됩니다. 위에서 제기된 바와 같이 큰 문제는 조건 분기에 의한 분산입니다. 하지만 조건 분기라고 모두 같은 것은 아닙니다. 컴파일 타임에 같은 SIMD 안에서 갈리는 것을 알 수 있는 것과 없는 것으로 구분됩니다.
컴파일러는 상수, 공유(uniform) 변수, 그리고 오직 이들로부터 유도된 값들에 의한 조건 분기에 대해서만은 한 번의 실행 내에서 서로 분기가 갈리지 않음을 확신할 수 있습니다. 이를 정적 공유 표현식(statically uniform expression)이라고 부를 수 있습니다. (이것은 동적 공유 표현식(Dynamically ...)과는 다른 개념입니다. 동적 공유 표현식은 정적 공유 표현식을 포함하는 개념으로, 한 번의 실행 내에서 서로 분기가 갈리지 않는 것은 동일합니다. 이는 짧지 않으니 아래에서 다시 다룹니다.) 정적이지 않지만 공유 표현식인 경우더라도, 컴파일러 단계에서는 알 수 없으니 이 단계에서는 갈릴 수도 있다고 가정합니다. 그러니 uniform 변수 기반의 루프 등에서는 절대 동시 실행 간에 경로가 갈리지 않습니다. 정적이지는 않지만 공유 표현식인 것은 장치에 따라 다르지만 현대의 GPU에서는 대체로 성능 문제를 일으키지 않습니다. (참고: 갈리지 않을 것이라고 확신할 수 있는 특정 입력값이 있으며, gl_DrawID는 표준상 항상 동적으로 균일해야 하기 때문에 정적으로 균일한 값입니다.)
동시 실행 간 갈릴 가능성이 있는 분기문에 대해서도 컴파일러가 완전한 성능상 손해를 방지하려는 시도를 합니다. 예를 들어 ? : 연산자는 SIMD에 대한 갈림을 거의 발생시키지 않으므로 컴파일러는 둘 모두를 실행한 후 필요 없는 쪽을 버리도록 시도합니다. 양쪽 식에 복잡한 것이 있는 게 아니라면(실제로 갈리게 하는 편이 유리할 수 있음) 컴파일러는 거의 확실히 두 경우 모두를 실행하게 만듭니다. 컴파일러에 따라서는 if에 대해서도 마찬가지입니다. 만약 조건에 따라 실행되는 코드가 짧은 경우라면 둘 모두를 실행하여 하나를 버리는 방향으로 갑니다. 재차 강조하자면 이런 판단은 정적으로 갈릴 가능성이 없다고 판단할 수 있을 때는 하지 않습니다.
동적 공유 표현식
이 글의 내용입니다.
Core Language (GLSL) - OpenGL Wiki
The OpenGL Shading Language is a C-style language, so it covers most of the features you would expect with such a language. Control structures (for-loops, if-else statements, etc) exist in GLSL, including the switch statement. This section will not cover t
www.khronos.org
역주: uniform을 공유라고 번역한 건 한 번의 invocation 내에서 상수와 uniform, 그리고 이들로부터 유도된 식은 같은 값을 가지게 되기 때문입니다.
동적 공유 표현식의 개념은 GLSL 4.00 이상 버전에서만 알아봅니다. (그 이전의 경우 상수 표현식만 인정) 동적 공유 표현식의 조건은 그것이 대응하는 값이 같은 경우입니다. 셰이더 코드만 보고는 알 수 없는 것도 있습니다. 정적 공유 표현식은 모두 동적 공유 표현식입니다.
보통 셰이더 단의 in 변수의 값은 동적 공유가 아닐 가능성이 높습니다. 하지만 입력에 의해 표현식의 값이 같게 된 경우가 생길 수 있는데 이때는 동적으로 공유된다고 볼 수 있습니다.
gl_InstanceID가 한 예입니다. 인스턴스 한 개를 그릴 때는 동적으로 uniform하겠지만 명령 한 번으로 여러 개의 인스턴스를 그릴 때는 그렇지 않습니다. glVertexAttribDivisor()에 의한 인스턴스드 배열(정점 대표 속성) 역시 마찬가지입니다.
가장 중요한 것은 루프를 수행할 때 초기식, 조건식, 갱신식이 다른 위치의 값을 사용하더라도 그 값이 동적 공유인 경우라면 루프 카운터 자체는 동적으로 공유된다고 볼 수 있는 것입니다.
텍스처 좌표가 동적 공유라면 텍스처 접근식도 동적 공유가 되지만, 이미지 변수나 아토믹 카운터는 꼭 그렇지 않습니다. 이들은 수정 가능한 메모리로 각 실행이 다른 값을 받을 가능성이 있습니다. 이미지 변수가 읽기 전용이면 이미지 좌표 등이 동적 공유일 때는 동적 공유가 될 수 있습니다.
동적 공유 표현식은 함께 묶여서 실행되는 단위(invocation group)가 기반이 됩니다. 보다 정확한 정의는 셰이더 맥락에 따라 달라집니다. 계산 셰이더의 경우 작업 묶음의 모든 실행을 말하며 렌더링 명령에 의한 셰이더는 더 복잡합니다.
렌더링 범위는 그리기 명령 한 번에 의해 실행되는 모든 셰이더 단계로 확장됩니다. 즉 렌더링 명령에 의해 각각 다른 기초 도형이 생성되었어도 같은 범위에 든 것으로 취급되고, gl_PrimitiveID는 기초 도형 한 개를 그리는 게 아닌 이상 동적 공유가 아닙니다. 다중 렌더링 명령과 그 간접 버전은 그 내부 구조 상의 각각의 단일 렌더링 당 범위 하나를 가집니다.
(위 정보는 GLSL 4.60 기준이고, 4.00부터 그 사이의 경우 이 개념의 완성도가 낮아 다를 수 있습니다. 하지만 기본적으로 하드웨어 기반의 정의이므로 대체로 같게 알고 있어도 무난합니다.)
반드시 Dynamically uniform expression이 들어가야 하는 경우가 있습니다.
- opaque type(sampler, image, atomic counter)의 배열 인덱스
- Buffer-backed 인터페이스 블록 배열의 인덱스
- 계산 셰이더의 barrier() 호출보다 앞의 표현식들
예를 한 가지 들어 봅시다.
in vec3 fromPrevious;
in uvec2 fromRange;
const int foo = 5;
const uvec2 range = uvec2(2, 5);
uniform vec2 pairs;
uniform sampler2d tex;
void main()
{
foo; // 상수이므로 동적(정적) 공유
uint value = 21; // 상수로 시작했으니 지금은 동적(정적) 공유
value = range.x; // 또 상수를 받았으니 동적(정적) 공유
value = range.y + fromRange.y; // fromRange가 동적 공유가 아니므로 지금은 동적 공유가 아님
value = 4; // 동적(정적) 공유
if (fromPrevious.y < 3.14) // fromPrevious는 공유가 아님
value = 12;
value; // fromPrevious의 영향을 받으므로 공유가 아님
float number = abs(pairs.x); // uniform 변수에 의한 것이므로 동적(정적) 공유
number = sin(pairs.y); // uniform 변수에 의한 것이므로 동적(정적) 공유
number = cos(fromPrevious.x); // fromPrevious의 영향으로 공유 아님
vec4 colors = texture(tex, pairs.xy); // 같은 텍스처에서 같은 좌표로 오므로 동적(정적) 공유
colors = texture(tex, fromPrevious.xy); // 공유 아님
for(int i = range.x; i < range.y; ++i)
{
i; // range가 고정이므로 여기는 어디에서나 2,3,4로 동적(정적) 공유
}
for(int i = fromRange.x; i < fromRange.y; ++i)
{
i; // fromRange가 공유가 아니므로 여기는 공유가 아님
}
}
요약:
- 동시 실행되는 코드(셰이더)는 구조상 invocation 간 갈리는 조건 분기를 만나면 성능에 악영향이 생김
- 분기에 의한 갈림의 조건: uniform과 상수 및 그로부터 도출된 값은 괜찮음, 런타임에 같은 값이 들어가는 표현식도 최신 기기에서는 괜찮음
- 동시 실행되는 invocation의 범위는 단일 draw 명령