← 개념 · 데모 가이드 | 구현 허브 | 4장. GPU · 셰이더 | GPU 병렬 처리: 셰이더는 어떻게 도는가
IMPLEMENTATION · CHAPTER 4

GPU 병렬 처리: 셰이더는 어떻게 도는가 — 한 줄 한 줄 직접 구현

셰이더 코드를 쓰기 전에, 그 코드가 GPU 위에서 물리적으로 어떻게 실행되는지를 먼저 몸으로 이해해야 합니다. 이 페이지는 코드 한 줄이 아니라 실행 모델 자체를 해부합니다. CPU와 GPU의 근본적인 차이, 수천 개의 코어가 같은 코드를 어떻게 나눠 도는지(SIMT), 워프·웨이브프론트·스레드 그룹이라는 계층, 그리고 초심자가 가장 자주 밟는 지뢰인 분기 발산(branch divergence)을 개념도·수치 예제·HLSL/C++ 대응까지 끝까지 파고듭니다. 이 모델을 이해하면 이후의 버텍스·픽셀·컴퓨트 셰이더가 "왜 그렇게 생겼는지"가 한눈에 들어옵니다.

1. CPU와 GPU는 애초에 다른 종류의 프로세서입니다

무엇을 이해하려는가: 셰이더가 "GPU 위에서 병렬로 도는 작은 프로그램"이라는 말의 진짜 의미. 왜 필요한가: 이 차이를 모르면 CPU에서 빠른 코드를 그대로 GPU에 옮겨 놓고 왜 느린지 몰라 헤매게 됩니다. GPU에 맞는 코드는 CPU와 사고방식이 다릅니다.

CPU는 소수의 강력한 코어(보통 4~16개)가 순차적인 작업을 최대한 빨리 처리하도록 설계됐습니다. 분기 예측기, 거대한 캐시, 명령어 재정렬 같은 온갖 장치가 "한 스레드의 작업을 얼마나 빨리 끝내느냐"에 투자돼 있습니다. 반면 GPU는 수천 개의 단순한 코어같은 코드를 서로 다른 데이터에 대해 동시에 실행하도록 설계됐습니다. 코어 하나하나는 CPU 코어보다 훨씬 느리고 단순하지만, 수천 개가 동시에 일하므로 "같은 계산을 아주 많은 데이터에 반복"하는 작업에서는 압도적으로 빠릅니다.

셰이더는 바로 이 "같은 계산 × 아주 많은 데이터"에 딱 맞는 물건입니다. 우리가 셰이더 한 벌을 작성하면, GPU는 그것을 수십만~수백만 번, 매번 다른 입력(정점, 픽셀, 파티클)으로 병렬 호출합니다. 즉 우리는 "한 개의 항목을 어떻게 처리할지"만 쓰고, "몇 개를, 어떻게 나눠 돌릴지"는 GPU가 알아서 합니다.

CPUGPU
코어 수적음 (수 개~수십 개)많음 (수백~수천 개)
코어 하나의 성격강력·복잡 (분기예측·거대 캐시)단순·느림 (계산에 집중)
잘하는 일순차 로직, 조건 분기 많은 코드같은 연산의 대량 병렬
대표 작업게임 로직, OS, 파일 처리정점 변환, 픽셀 셰이딩, 파티클

셰이더 스테이지별 "몇 번 도는가"

각 셰이더가 무엇 하나당 한 번 도는지를 정확히 알아야, 어디에 계산을 얹으면 몇 배로 불어나는지 감이 옵니다. 다음이 핵심 규칙입니다.

성능 "정점 수 ≪ 픽셀 수"라는 비대칭이 핵심 직관입니다. 삼각형 하나(정점 3개)가 화면 절반을 덮으면 버텍스 셰이더는 3번, 픽셀 셰이더는 100만 번 돕니다. 그래서 가능한 계산은 버텍스 셰이더로 끌어올려 보간에 맡기고, 픽셀 셰이더는 최대한 가볍게 두는 것이 렌더링 최적화의 제1원칙입니다.

2. SIMT: 스레드를 묶어서 한 명령어로 굴린다

무엇을 이해하려는가: GPU가 수백만 개의 셰이더 호출을 "낱개"가 아니라 "묶음"으로 실행한다는 사실. 왜 필요한가: 이 묶음의 존재를 모르면 뒤에 나올 분기 발산이 왜 그렇게 비싼지, 왜 스레드 개수를 32/64의 배수로 맞추라고 하는지 전혀 이해할 수 없습니다.

수학/원리: 하나의 명령어, 여러 개의 데이터

GPU는 셰이더 호출들을 워프(warp, NVIDIA 용어) 또는 웨이브프론트(wavefront, AMD 용어)라 불리는 고정 크기 묶음으로 뭉쳐서 실행합니다. 크기는 하드웨어마다 다르지만 보통 32개(NVIDIA) 또는 64개(AMD)입니다. 한 워프에 속한 모든 스레드는 같은 명령어를, 같은 클럭 사이클에 실행합니다. 오직 다루는 데이터(레지스터 값)만 스레드마다 다릅니다.

이 방식을 SIMT(Single Instruction, Multiple Threads) — "하나의 명령어, 여러 개의 스레드" — 라고 부릅니다. 이름을 성분별로 뜯어보면 이렇습니다.

기하 직관: 32명이 같은 악보를 동시에 연주하는 오케스트라

워프를 지휘자 한 명 아래 32명의 연주자로 상상해 보세요. 악보(명령어 스트림)는 한 부이고, 지휘봉(프로그램 카운터)도 하나입니다. 지휘자가 "1번 마디"를 가리키면 32명이 동시에 1번 마디를 연주합니다. 다만 각자의 악기(데이터)가 다르니 나는 소리(결과)는 다르죠. 절대 어긋날 수 없습니다 — 악보와 지휘봉이 공유되기 때문입니다. 바로 이 "공유된 지휘봉" 때문에, 뒤에서 볼 것처럼, 연주자 절반만 다른 마디로 가야 하는 상황(=분기)이 곤란해집니다.

코드 매핑: numthreads와 워프의 관계

컴퓨트 셰이더에서는 워프의 상위 개념스레드 그룹(thread group)의 크기를 우리가 HLSL 어트리뷰트 [numthreads(x,y,z)]로 직접 지정합니다. 반면 그 그룹을 하드웨어가 다시 워프로 쪼개는 것은 자동입니다. 아래 한 줄이 그 선언입니다.

shaders/particles_cs.hlsl (발췌)hlsl
// 스레드 그룹 하나에 64개 스레드 (1차원)
[numthreads(64, 1, 1)]
void CSMain(uint3 dtid : SV_DispatchThreadID) {
    uint i = dtid.x;   // 이 스레드가 담당할 전역 인덱스
    // ... i번째 데이터를 처리 ...
}

이 두 줄을 성분 단위로 해부합니다.

주의 numthreads그룹 하나의 크기일 뿐, 전체 스레드 수가 아닙니다. 전체 스레드 수는 그룹 크기 × 그룹 개수이고, 그룹 개수는 C++에서 context->Dispatch(gx, gy, gz)로 따로 지정합니다. 이 둘을 헷갈리면 데이터의 일부만 처리되거나 배열 밖을 건드리게 됩니다. 두 값의 관계는 다음 절에서 정확한 수식으로 다룹니다.

3. 실행 계층: 디스패치 → 스레드 그룹 → 워프 → 스레드

무엇을 이해하려는가: "내가 요청한 스레드"가 어떤 계층으로 쪼개져 하드웨어까지 내려가는지. 왜 필요한가: 이 계층을 알아야 Dispatch 인자와 numthreads를 올바르게 맞물릴 수 있고, 배열 크기가 그룹 크기의 배수가 아닐 때 왜 "범위 체크"가 필요한지 이해됩니다.

아래 개념도는 위에서 아래로 갈수록 더 잘게 쪼개지는 구조를 보여줍니다. 맨 위 "디스패치 전체"는 우리가 Dispatch 한 번으로 요청하는 작업 전체이고, 맨 아래 "스레드"는 셰이더 함수가 실제로 한 번 실행되는 최소 단위입니다.

실행 계층 (개념도)text
디스패치 전체
 ├─ 스레드 그룹 (0,0)  ← context->Dispatch(gx,gy,gz) 로 개수 지정
 │    ├─ 워프/웨이브 [스레드 0..31]  ← 하드웨어가 한 덩어리로 실행 (SIMT)
 │    └─ 워프/웨이브 [스레드 32..63]
 ├─ 스레드 그룹 (1,0)
 └─ ...
   그룹 내부 스레드 수 = numthreads.x * numthreads.y * numthreads.z

계층을 한 줄씩 해부

수식과 손계산: 그룹 개수 구하기

배열이 COUNT개이고 그룹당 스레드가 LOCAL_SIZE개일 때, 필요한 그룹 개수는 "올림 나눗셈(ceiling division)"으로 구합니다. 정수 연산만으로 올림을 하려면 다음 관용구를 씁니다.

src/particles.cpp (발췌)cpp
const UINT COUNT      = 100000;   // 파티클 개수
const UINT LOCAL_SIZE = 64;       // numthreads(64,1,1) 과 반드시 일치

// 필요한 스레드 그룹 수 = ceil(COUNT / LOCAL_SIZE)
UINT groups = (COUNT + LOCAL_SIZE - 1) / LOCAL_SIZE;
context->Dispatch(groups, 1, 1);

이 공식을 성분 단위로, 실제 수치를 넣어 손계산해 봅니다.

함정 LOCAL_SIZE(C++ 상수)와 numthreads(HLSL)의 숫자는 반드시 같아야 합니다. HLSL을 [numthreads(128,1,1)]로 바꿔 놓고 C++의 LOCAL_SIZE를 64로 두면, 그룹 개수를 2배로 잡아 절반의 스레드가 남거나 데이터의 절반만 처리됩니다. 두 값은 개념적으로 하나의 상수이므로, 실무에서는 매크로나 공유 헤더로 한 곳에서만 정의하는 것이 안전합니다.

4. 분기 발산(branch divergence): 초심자의 첫 번째 지뢰

무엇을 이해하려는가: 왜 GPU에서는 if가 CPU만큼 "공짜"가 아닌지. 왜 필요한가: 이 하나만 이해해도 초보 셰이더가 몇 배씩 느려지는 원인의 대부분을 피할 수 있습니다. SIMT의 직접적인 결과이므로, 앞 두 절을 이해했다면 자연스럽게 따라옵니다.

원리: 지휘봉이 하나뿐이라 두 마디를 모두 연주해야 한다

2절에서 워프의 32개 스레드는 같은 명령어를 같은 클럭에 실행한다고 했습니다. 프로그램 카운터(지휘봉)가 워프에 하나뿐이기 때문입니다. 그런데 코드에 if (조건)이 있고, 워프 안에서 어떤 스레드는 조건이 참, 어떤 스레드는 거짓이면 어떻게 될까요? 지휘봉은 하나인데 두 갈래로 가야 하는 모순이 생깁니다.

GPU의 해결책은 이렇습니다: 두 갈래를 순차적으로 모두 실행하되, 각 단계에서 "지금 이 갈래에 해당하지 않는 스레드"는 마스킹(masking)으로 잠시 꺼 둡니다(결과를 버립니다).

  1. 먼저 if인 갈래(then 블록)를 워프 전체가 실행합니다. 이때 조건이 거짓이었던 스레드들은 계산은 하지만 결과가 버려집니다(마스크 off).
  2. 이어서 else 갈래를 워프 전체가 다시 실행합니다. 이번엔 반대로 참이었던 스레드들이 꺼집니다.
  3. 결국 워프는 then의 시간 + else의 시간둘 다 소비합니다. 각 스레드는 자기에 해당하는 결과만 남기지만, 총 실행 시간은 두 갈래의 합입니다.

이것이 "분기가 갈리면 둘 다의 비용을 낸다"는 말의 정확한 의미입니다. 반대로, 워프 안의 32개 스레드가 전부 같은 갈래로 가면(모두 참이거나 모두 거짓), 안 가는 갈래는 아예 건너뛰므로 발산 비용이 0입니다. 문제는 갈림 그 자체가 아니라, 한 워프 안에서 갈리는 것입니다.

기하 직관: 체스판처럼 뒤섞인 조건 vs. 영역으로 뭉친 조건

화면 픽셀을 예로 들어 보겠습니다. GPU는 인접한 픽셀들을 한 워프로 묶는 경향이 있습니다. 이때 조건이 픽셀마다 체스판처럼 뒤죽박죽이면 거의 모든 워프가 참·거짓이 섞여 매번 두 갈래를 다 돌아야 합니다(최악: 워프마다 2배 비용). 반대로 화면을 큰 영역으로 나눠 왼쪽은 전부 A, 오른쪽은 전부 B처럼 조건이 영역 단위로 뭉쳐 있으면, 대부분의 워프는 한 갈래만 타므로 발산이 거의 없습니다. 같은 로직이라도 "조건이 공간적으로 얼마나 일관되냐"가 성능을 가릅니다.

코드 매핑: 발산하는 코드와 발산 없는(branchless) 코드

아래 두 스니펫은 같은 결과를 내지만, 위는 워프가 갈리면 두 갈래를 다 돌고, 아래는 분기 자체가 없어 항상 한 경로만 돕니다.

발산할 수 있는 코드 (분기 사용)hlsl
// 워프 안에서 t 가 0.5 를 기준으로 갈리면 두 갈래를 모두 실행한다
float3 color;
if (t < 0.5) {
    color = colorA;      // 절반의 스레드가 여기
} else {
    color = colorB;      // 나머지 절반이 여기 → 워프는 둘 다 실행
}
분기 없는(branchless) 대안hlsl
// step(edge, x): x < edge 면 0, 아니면 1  (분기 없이 계산으로 판정)
float sel = step(0.5, t);              // t<0.5 → 0,  t>=0.5 → 1
float3 color = lerp(colorA, colorB, sel);
// lerp(a, b, s) = a*(1-s) + b*s  →  s=0 이면 colorA, s=1 이면 colorB

아래 branchless 버전을 성분 단위로 해부합니다.

성능 워프 안의 스레드가 모두 같은 분기로 가면 발산 비용이 없습니다. 픽셀별로 조건이 뒤죽박죽 갈리는 코드보다, 화면 영역·오브젝트 단위로 조건이 일관된 코드가 훨씬 빠릅니다. 무거운 if보다 lerp()·step() 같은 분기 없는(branchless) 수식을 선호하세요. 단, 분기 안의 계산이 아주 무거운데 워프 대부분이 그 갈래를 건너뛴다면(예: 조기 종료 if (i >= uCount) return;) 분기가 오히려 이득입니다 — 핵심은 "발산이 실제로 일어나느냐"입니다.
함정 if (i >= uCount) return; 같은 경계 체크는 발산을 걱정하지 않아도 됩니다. 마지막 한 워프를 뺀 나머지 워프는 조건이 전부 거짓이라 갈리지 않고, 오직 배열 끝에 걸친 마지막 워프 하나만 살짝 갈릴 뿐이기 때문입니다. 발산이 문제가 되는 건 매 워프마다 반복적으로 조건이 섞일 때입니다. 모든 if를 겁내지 말고, "이 조건이 워프 단위로 얼마나 자주 갈리나"를 기준으로 판단하세요.

5. C++ 쪽: Dispatch로 이 병렬 실행을 실제로 시동한다

지금까지의 실행 모델은 결국 C++에서 Dispatch 한 줄로 방아쇠가 당겨집니다. 컴퓨트 셰이더를 바인딩하고 스레드 그룹 개수를 넘겨 GPU에 "이만큼 병렬로 돌려라"라고 명령하는 부분을 봅니다. (버텍스/픽셀 셰이더는 Draw 계열이 같은 역할을 하지만, 병렬 개수를 우리가 직접 숫자로 넘기는 컴퓨트가 실행 모델을 가장 투명하게 보여 줍니다.)

src/particles.cpp (요지)cpp
// computeCS = compileShader(..., "CSMain", "cs_5_0") + CreateComputeShader 로 만든 것
context->CSSetShader(computeCS.Get(), nullptr, 0);        // 컴퓨트 셰이더 바인딩
context->CSSetConstantBuffers(0, 1, simCB.GetAddressOf()); // b0 상수 버퍼 연결
UINT initCounts = 0;
context->CSSetUnorderedAccessViews(0, 1, ssboUAV.GetAddressOf(), &initCounts); // u0 UAV 연결

// 필요한 스레드 그룹 수 = ceil(COUNT / LOCAL_SIZE)
UINT groups = (COUNT + LOCAL_SIZE - 1) / LOCAL_SIZE;
context->Dispatch(groups, 1, 1);                          // ★ 병렬 실행 시동

실행 모델과 직접 연결되는 줄만 골라 해부합니다.

주의 Dispatch는 CPU가 GPU에 "명령을 큐에 넣는" 비동기 호출입니다. 이 줄이 반환됐다고 계산이 끝난 게 아닙니다. 결과를 CPU로 읽어오려면 스테이징 버퍼로 CopyResourceMap해야 하고, 다른 GPU 스테이지에서 이 버퍼를 읽으려면 먼저 UAV 바인딩을 nullptr로 해제해야 합니다 (같은 리소스가 UAV로 묶인 채 다른 스테이지에 바인딩되면 D3D 디버그 레이어가 경고하고 무시합니다). 이 상세는 4장 허브의 컴퓨트 셰이더 절에서 다룹니다.

6. DirectXMath / D3D API 대응 정리

이 페이지는 실행 모델을 다루므로 무거운 수학은 없지만, 앞으로 셰이더에서 계속 쓸 규약과 API 대응을 한 번에 정리해 둡니다. 이 규약은 이 학습 트랙 전체에서 통일해서 씁니다.

개념 / 원본HLSL / D3D API비고
스레드 그룹 개수 지정 ID3D11DeviceContext::Dispatch(gx,gy,gz) OpenGL의 glDispatchCompute에 대응
그룹당 스레드 수 선언 [numthreads(x,y,z)] GLSL의 layout(local_size_x=…)에 대응
전역 스레드 인덱스 SV_DispatchThreadID GLSL의 gl_GlobalInvocationID에 대응
컴퓨트 셰이더 바인딩 CSSetShader 버텍스/픽셀은 VSSetShader/PSSetShader
읽기·쓰기 버퍼(UAV) RWStructuredBuffer + CSSetUnorderedAccessViews OpenGL의 SSBO에 대응
행렬·벡터 라이브러리(CPU) DirectXMath (XMMATRIX, XMVECTOR) 행벡터, row-major 저장
주의 규약 통일: 이 트랙은 행벡터 v' = v * M, HLSL에서는 mul(v, M), 왼손 좌표계, 클립 공간 z[0,1], 텍스처 V축은 위→아래를 씁니다. CPU 라이브러리는 직접 구현보다 DirectXMath 같은 검증된 라이브러리를 쓰는 것을 권장하며, 이 트랙의 직접 구현 코드는 원리를 눈으로 보기 위한 학습용입니다. DirectXMath는 행벡터·row-major이므로 cbuffer로 행렬을 올리기 전 CPU에서 XMMatrixTranspose로 한 번 전치해야 셰이더의 mul(matrix, vector) 관례와 맞습니다(자세한 건 버텍스 셰이더 절 참고).

7. 한 장으로 요약

  1. CPU vs GPU: CPU는 소수의 강한 코어(순차), GPU는 수천 개의 단순 코어(대량 병렬). 셰이더는 "같은 계산 × 많은 데이터"에 최적.
  2. 스테이지별 실행 횟수: 버텍스=정점당, 픽셀=픽셀당(수백만), 컴퓨트=작업 항목당. 무거운 계산은 픽셀→버텍스로 끌어올린다.
  3. SIMT: 32/64개 스레드가 워프/웨이브로 묶여 같은 명령어를 같은 클럭에 실행. 데이터만 다르다.
  4. 계층: 디스패치 → 스레드 그룹(Dispatch) → 워프(자동) → 스레드. 그룹당 스레드 수 = numthreads의 곱.
  5. 그룹 개수: ceil(COUNT/LOCAL_SIZE)로 올림, 셰이더에서 if (i>=uCount) return;로 여분 스레드 정리.
  6. 분기 발산: 한 워프 안에서 if가 갈리면 두 갈래를 모두 실행 → 비용 2배. 조건을 공간적으로 뭉치거나 lerp/step으로 branchless 처리.

이제 실행 모델이 손에 잡혔으니, 다음 페이지에서 이 GPU 위에 실제로 셰이더를 올리는 C++ 헬퍼 (컴파일·생성·바인딩)를 만들어 봅니다.