17 minute read

셰이더 최적화 팁은 널려 있습니다. “분기를 피하라”, “half를 쓰라”, “텍스처 읽기를 줄여라”. 문제는 이 팁들이 왜 맞는지, 그리고 언제 틀리는지를 알려주지 않는다는 점입니다. 분기는 어떤 때는 공짜고 어떤 때는 셰이더 비용을 두 배로 만듭니다. 그 차이를 설명하는 것은 팁이 아니라 하드웨어의 구조입니다.

앞부분(1~4장)은 GPU가 실제로 일을 처리하는 방식을 정리하고, 뒷부분(5장)은 거기에서 논리적으로 따라오는 셰이더 원칙을 다룹니다.

1. CPU와 GPU는 다른 문제를 푼다

CPU는 하나의 명령 흐름을 최대한 빨리 끝내는 기계입니다. 트랜지스터 대부분을 분기 예측, 비순차 실행, 거대한 캐시에 씁니다. 전부 레이턴시를 줄이는 장치입니다.

GPU는 같은 프로그램을 수백만 번 돌리는 기계입니다. 4K 한 장이 픽셀 830만 개이고 서로 독립이라서, 한 스레드가 빨리 끝나는 것보다 초당 처리량이 중요합니다. 그래서 레이턴시를 줄이는 장치를 떼어내고 그 자리에 연산 유닛을 채웁니다. 레이턴시는 숨깁니다. 한 묶음이 메모리를 기다리는 동안 다른 묶음으로 갈아타는 식으로요.

CPU와 GPU의 트랜지스터 예산 배분 비교

CPU는 레이턴시를 줄이고, GPU는 레이턴시를 숨긴다. 셰이더 최적화의 원칙 대부분은 이 한 문장에서 파생됩니다.

왜 픽셀 셰이더에는 "옆 픽셀 읽기"가 없는가

스레드끼리 독립적이어야 하드웨어가 마음대로 스케줄할 수 있습니다. 그래서 셰이더는 “입력 → 출력”의 순수 함수 모양을 강요받습니다. 취향이 아니라 하드웨어의 요구입니다. 유일한 예외가 2×2 쿼드 안에서만 허용되는 미분 명령(ddx/ddy)이고, 3-3절에서 다룹니다.

2. 실행 모델 — 스레드는 혼자 실행되지 않는다

2-1. 웨이브: 실제 실행 단위

셰이더 코드는 “픽셀 하나”의 관점으로 쓰지만, 하드웨어는 스레드 32개(또는 64개)를 묶어 한 번에 같은 명령을 실행합니다. 이 묶음을 NVIDIA는 워프(warp), AMD는 웨이브프론트(wavefront)라고 부르고, 이 글에서는 웨이브로 통일합니다. 웨이브 하나에 명령 포인터는 하나입니다. 32개 스레드는 항상 같은 명령을 같은 순간에 실행하고, 데이터만 다릅니다. 이것이 SIMT 모델입니다.

2-2. 분기 발산 — 양쪽을 다 실행한다

균일 분기와 발산 분기의 레인 마스크와 비용

웨이브 안의 스레드가 if에서 갈리면 하드웨어는 양쪽을 차례로 실행하고, 해당 없는 레인은 실행 마스크로 꺼둡니다. 비용은 “실행된 쪽”이 아니라 양쪽의 합입니다. 반대로 모든 스레드가 같은 쪽이면 정말로 한쪽만 실행되고, 이 균일(uniform) 분기는 거의 공짜입니다.

  • 상수 버퍼 값, 셰이더 상수, 드로우 단위 플래그로 분기 → 균일. 공짜에 가깝습니다.
  • 텍스처 결과, 보간된 정점 속성, 픽셀 좌표로 분기 → 발산할 수 있습니다.
발산이 실제로 얼마나 자주 일어나는가, 그리고 컴파일러가 하는 일
  • 웨이브는 화면상 인접한 픽셀로 채워집니다. 분기 조건이 화면에서 덩어리져 있으면(그림자 안/밖, 머티리얼 영역) 경계에 걸린 웨이브만 발산합니다. 노이즈처럼 픽셀 단위로 바뀌는 조건이면 거의 모든 웨이브가 발산합니다.
  • 루프도 같습니다. 반복 횟수가 스레드마다 다르면 웨이브는 가장 오래 도는 스레드가 끝날 때까지 돌고, 먼저 끝난 스레드는 마스크된 채 기다립니다.
  • 분기 양쪽이 짧으면 컴파일러는 분기를 없애고 양쪽을 다 계산한 뒤 select합니다. 마스크 관리보다 그게 쌉니다. HLSL의 [flatten]/[branch]는 이 선택을 강제하는 힌트입니다.

2-3. 레이턴시 숨기기와 점유율

GPU 연산 유닛: GPU 전체에서 본 위치와 유닛 하나의 구성

웨이브는 연산 유닛 위에서 돕니다. GPU 하나에는 이 유닛이 수십 개 들어 있고, 유닛 하나 안에는 실제로 계산을 하는 SIMD 레인, 상주한 웨이브들이 나눠 쓰는 레지스터 파일, 다음에 돌릴 웨이브를 고르는 스케줄러, 작은 공유 메모리와 L1 캐시가 들어 있습니다. CPU의 코어에 해당하는 단위지만, 명령을 스레드 하나씩이 아니라 웨이브 단위로 가져와 레인들이 같은 명령을 한꺼번에 실행합니다. 이 절에서 말하는 “상주”와 “점유율”은 모두 이 유닛 하나를 기준으로 한 이야기입니다.

벤더마다 부르는 이름이 다르다, 그리고 "코어"라는 말의 함정
벤더 연산 유닛의 이름 비고
AMD Compute Unit (CU) RDNA는 CU 두 개를 묶은 WGP(Work Group Processor)가 공유 메모리를 함께 씀
NVIDIA Streaming Multiprocessor (SM) 안이 파티션 4개로 나뉘고 파티션마다 워프 스케줄러가 하나
Apple, ARM Mali GPU core, shader core 제품명의 “n-core GPU”가 이 개수
Intel Xe-core  

규모는 데스크톱 상위 제품 기준으로 RX 7900 XTX가 CU 96개, RTX 4090이 SM 128개, Apple M3 Max가 GPU core 40개입니다.

주의할 점은 마케팅에서 말하는 “코어” 수입니다. NVIDIA의 “CUDA 코어 16,384개”나 AMD의 “스트림 프로세서”는 연산 유닛이 아니라 그 안의 레인 하나를 센 숫자입니다. 레인은 CPU 코어처럼 혼자 명령을 가져오지 못하고, 웨이브 안의 다른 스레드와 같은 명령을 함께 실행합니다. 그래서 “GPU는 코어가 만 개”라는 말은 “CPU 코어 만 개”와는 전혀 다른 뜻입니다.

레이턴시 숨기기 타임라인과 레지스터 사용량에 따른 점유율

VRAM 접근은 수백 사이클입니다. 웨이브 A가 텍스처를 기다리는 동안 스케줄러는 같은 연산 유닛에 상주하는 B, C, D를 돌립니다. 웨이브가 충분히 많으면 연산 유닛은 한 번도 놀지 않습니다. 그래서 유닛 하나에 몇 개의 웨이브가 상주하는가, 즉 점유율(occupancy) 이 성능을 좌우합니다.

그러면 유닛 하나에 웨이브가 몇 개나 들어갈 수 있을까요. 연산 유닛을 작업실, 웨이브를 거기 들어와 일하는 팀이라고 생각하면 쉽습니다. 팀이 들어오려면 세 가지 자리가 다 있어야 합니다.

점유율을 정하는 세 가지 자리: 책상(레지스터), 창고(공유 메모리), 출입 명부(하드웨어 상한)

  1. 책상, 즉 레지스터. 작업실의 책상 수는 정해져 있고, 팀마다 쓰는 책상 수는 셰이더가 정합니다. 책상을 많이 쓰는 팀일수록 작업실에 들어올 수 있는 팀이 줄어듭니다.
  2. 창고, 즉 공유 메모리. 컴퓨트 셰이더가 groupshared를 쓰면 그룹마다 창고 한 칸을 차지합니다. 창고가 차면 책상이 남아도 더 못 들어옵니다. 창고를 안 쓰는 픽셀·버텍스 셰이더에는 이 제한이 없습니다.
  3. 출입 명부, 즉 하드웨어 상한. 책상과 창고가 남아도 명부 칸이 다 차면 끝입니다. 칩이 정한 최대 웨이브 수라서 셰이더로는 바꿀 수 없습니다.

상주 웨이브 수는 셋 중 가장 먼저 꽉 차는 것이 정합니다. 대부분의 셰이더에서 먼저 차는 것은 책상, 즉 레지스터입니다. 레지스터를 많이 쓸수록 상주 웨이브가 줄고 숨길 수 있는 레이턴시도 줍니다. 이것이 레지스터 압박이고, 프로파일러가 VGPR 개수를 제일 먼저 보여주는 이유입니다.

숫자로 보는 점유율, 그리고 점유율이 전부가 아닌 이유

AMD RDNA 2 기준으로 SIMD 하나는 VGPR 1024개를 갖고 최대 16개 웨이브가 상주합니다.

셰이더의 VGPR 사용량 상주 가능한 웨이브 수
64개 이하 16 (최대)
128개 8
256개 4

NVIDIA도 구조는 같습니다. SM 하나에 32비트 레지스터 65,536개, 상주 스레드 1,024~2,048개(세대마다 다름). 100% 점유율이면 스레드당 32~64개 안에서 써야 합니다.

다만 점유율은 수단이지 목표가 아닙니다. 메모리 접근이 거의 없는 순수 계산 셰이더는 숨길 레이턴시가 없어 낮은 점유율로도 유닛을 채웁니다. 또 웨이브 하나 안에서도 서로 의존하지 않는 명령이 연달아 있으면(텍스처 읽기 4개를 연속으로 낸 뒤 나중에 쓰는 식) 레이턴시가 겹칩니다. 이 명령 수준 병렬성이 낮은 점유율을 어느 정도 만회합니다.

2-4. 균일한 값은 한 번만 계산된다

균일 값은 스칼라 유닛에서 한 번, 스레드별 값은 레인마다

상수 버퍼 값, 드로우 인덱스, 그것들로만 계산한 결과는 웨이브 안의 모든 스레드에서 같은 값입니다. 이걸 32개 레인이 각자 계산하는 것은 낭비라서, AMD는 웨이브당 하나뿐인 스칼라 유닛과 스칼라 레지스터(SGPR)에서 한 번만 실행하고, NVIDIA도 Turing부터 비슷한 유니폼 데이터패스를 둡니다. 컴파일러가 “이 값은 균일하다”고 증명할 수 있어야 합니다.

float4 tint = g_Tints[g_ObjectIndex];   // 상수 버퍼 값 → 균일 → 스칼라 로드 1회, 브로드캐스트
float4 tint = g_Tints[materialId];      // 보간된 값 → 레인마다 다름 → 레인별 벡터 로드, 최악엔 인덱스별 루프

같은 한 줄이지만 비용은 전혀 다릅니다. 균일한 계산은 복잡해도 거의 공짜고, 스레드별 계산은 간단해도 웨이브 전체의 비용입니다. “이 값은 균일한가, 스레드마다 다른가”를 머릿속에서 구분하는 습관이 필요합니다.

3. 메모리 — 계산은 싸고, 옮기는 것이 비싸다

3-1. 계층과 비용

메모리 계층별 비용과 캐시 라인 코얼레싱

계층을 하나 내려갈 때마다 레이턴시와 에너지가 한 자릿수씩 뜁니다. FP32 곱셈 한 번과 DRAM에서 32비트를 읽는 것의 에너지 차이는 백 배가 넘습니다. 현대 GPU가 수십 TFLOPS를 광고하지만 실제 셰이더가 근처에도 못 가는 이유는 대부분 유닛이 데이터를 기다리기 때문입니다.

셰이더 비용을 추정할 때 ALU 명령 수보다 “픽셀당 몇 바이트를 읽고 쓰는가”를 먼저 센다.

대역폭을 프레임 단위로 환산해 보면

데스크톱 VRAM 대역폭은 수백 GB/s에서 1TB/s를 넘지만, 프레임으로 나누면 넉넉하지 않습니다. 4K에서 RGBA16F 렌더 타깃 4장짜리 G-버퍼를 쓰고 다시 읽는 것만으로 프레임당 500MB가 넘고, 60fps면 그것만 30GB/s입니다. 여기에 텍스처, 그림자 맵, 포스트 프로세스 체인이 전부 얹힙니다. 모바일은 수십 GB/s를 CPU와 나눠 쓰고, 대역폭이 곧 발열과 배터리입니다.

3-2. 캐시 라인과 이웃

메모리는 바이트 단위가 아니라 캐시 라인(64~128바이트) 단위로 옵니다. 웨이브 32개 스레드가 연속된 128바이트를 읽으면 트랜잭션 한 번, 흩어진 4바이트를 각자 읽으면 32번입니다. 인접한 스레드가 인접한 주소를 읽는 것(coalescing)이 GPU 메모리 접근의 제1원칙입니다.

AoS보다 SoA가 권장되는 이유

컴퓨트 셰이더에서 스레드들이 각 구조체의 position만 읽는다면, position들이 연속으로 붙어 있는 배열 구조체(SoA) 쪽이 캐시 라인을 꽉 채워 씁니다. 구조체 배열(AoS)은 라인마다 필요 없는 필드가 함께 올라와 대역폭을 버립니다.

3-3. 텍스처와 쿼드

텍스처 타일 배치와 2×2 쿼드의 헬퍼 레인

텍스처 읽기는 전용 텍스처 유닛이 주소 계산, 필터링, 블록 압축 해제를 맡고, 메모리는 2D 지역성을 위해 타일 배치로 저장됩니다. 가로세로로 인접한 텍셀이 같은 캐시 라인에 들어갑니다. 이 구조가 요구하는 것은 하나입니다. 인접한 픽셀이 인접한 텍셀을 읽어야 합니다. 밉맵은 앨리어싱 억제 이전에 텍셀:픽셀 비율을 1:1 근처로 유지해 캐시 지역성을 지키는 장치입니다.

픽셀 셰이더는 항상 2×2 쿼드 단위로 실행됩니다. 삼각형이 쿼드의 픽셀 하나만 덮어도 4개 레인이 다 돌고, 나머지 셋은 미분값 계산용 헬퍼 레인으로 결과를 버립니다. 삼각형이 픽셀 크기에 가까워지면 쿼드 효율은 25%까지 떨어져, 픽셀 셰이더 비용이 화면 픽셀 수의 4배가 될 수 있습니다.

ddx/ddy, 분기 안의 텍스처 샘플, 그리고 Nanite

밉 레벨을 고르려면 UV가 이웃 픽셀에서 얼마나 변하는지, 즉 미분값이 필요한데, GPU는 이걸 쿼드 안에서 이웃 레인의 값을 빼서 구합니다. ddx/ddy가 그 명령입니다. 발산한 분기 안에서 샘플하면 이웃 레인이 꺼져 있어 미분값이 정의되지 않습니다. 컴파일러가 거부하거나, 통과해도 경계에서 엉뚱한 밉 레벨이 나옵니다.

// 위험: 발산 가능한 분기 안의 암시적 밉 선택
if (useDetail)
    c = detailTex.Sample(s, uv);

// 안전: 미분값을 분기 밖에서 구해 명시적으로 전달
float2 dx = ddx(uv), dy = ddy(uv);
if (useDetail)
    c = detailTex.SampleGrad(s, uv, dx, dy);   // 또는 SampleLevel

쿼드 낭비는 “작은 삼각형이 많은 메시는 픽셀 셰이딩이 비싸다”의 정확한 이유이고, UE5 Nanite가 아주 작은 삼각형을 하드웨어 래스터라이저 대신 컴퓨트 셰이더로 그리는 이유 중 하나입니다.

4. 파이프라인 — 고정 기능 사이에 끼인 셰이더

4-1. 병목은 한 단계에 있다

고정 기능과 프로그래머블 단계, 그리고 Early-Z의 조건

정점 셰이더와 픽셀 셰이더 사이에는 정점 페치, 프리미티브 조립, 래스터라이저, 뎁스 테스트, 블렌딩(ROP) 같은 고정 기능 단계가 있고, 각각 처리량이 하드웨어로 정해져 있습니다. 병목이란 어느 한 단계가 꽉 차서 앞뒤가 기다리는 상태입니다. 정점 셰이더가 병목이면 픽셀 셰이더를 아무리 줄여도 프레임 시간은 안 줍니다. 래스터라이저가 병목이면(삼각형은 많은데 전부 작은 경우) 셰이더는 무관합니다. 최적화의 첫 단계는 항상 어느 단계가 병목인지 찾는 것입니다.

4-2. Early-Z — 그리기 전에 버린다

뎁스 테스트는 원래 픽셀 셰이더 뒤의 단계입니다. 하지만 셰이더가 뎁스를 바꾸지 않는다면 결과가 같으니, 하드웨어는 테스트를 앞으로 옮겨 가려지는 픽셀의 셰이딩을 통째로 건너뜁니다. 효과를 내려면 두 조건이 필요합니다.

  • 뎁스 버퍼에 이미 가까운 값이 있어야 합니다. 불투명을 앞에서 뒤로 정렬하거나 뎁스 프리패스를 두는 이유입니다.
  • 셰이더가 뎁스 결과에 관여하지 않아야 합니다. discard/clip이나 SV_Depth 출력이 있으면 “이 픽셀이 뎁스를 쓸지”를 셰이더가 끝나기 전엔 알 수 없어 테스트가 뒤로 밀립니다. 알파 테스트 머티리얼이 불투명보다 비싼 이유입니다.
Hi-Z와 보수적 뎁스

Early-Z에는 타일 단위로 뎁스 범위를 미리 비교하는 계층적 Z(Hi-Z)가 더해져, 통째로 가려진 영역은 쿼드 단위로 걸러냅니다. SV_Depth를 꼭 써야 한다면(파랄랙스, 임포스터) SV_DepthGreaterEqual 같은 보수적 뎁스 출력으로 “셰이더가 뎁스를 이 방향으로만 바꾼다”고 약속해 Early-Z를 일부 살릴 수 있습니다.

4-3. 모바일 GPU는 다르게 돈다 — TBDR

즉시 모드 렌더러와 타일 기반 렌더러의 데이터 흐름

데스크톱은 즉시 모드 렌더러(IMR)입니다. 드로우가 오면 바로 래스터라이즈하고 셰이딩해서 VRAM 프레임버퍼에 쓰고, 뎁스 테스트와 블렌딩도 VRAM을 읽고 씁니다. 모바일은 대역폭이 곧 전력이라 그럴 여유가 없습니다. 그래서 타일 기반 렌더링을 씁니다. 렌더패스의 모든 정점 셰이더를 먼저 돌려 삼각형을 타일별 목록으로 비닝하고, 타일마다 온칩 메모리에서 렌더패스 끝까지 처리한 뒤 VRAM에 한 번만 저장합니다. 오버드로우가 열 번이어도 VRAM 쓰기는 타일당 한 번이고, 밖에서 안 쓰는 뎁스는 VRAM에 아예 쓰지 않아도 됩니다.

데스크톱(IMR)에서는 모바일(TBDR)에서는
렌더 타깃 전환은 상태 변경 비용 정도 렌더패스 경계마다 타일 전체를 VRAM에 쓰고 다시 읽음. 가장 비싼 작업 중 하나
프레임버퍼를 읽으려면 텍스처로 복사 같은 렌더패스 안에서는 타일 메모리를 직접 읽음(서브패스, framebuffer fetch)
정점 출력은 온칩에서 픽셀 셰이더로 정점 출력이 메모리에 저장됨. 보간자 개수가 곧 대역폭
불투명 정렬은 Early-Z 효율 문제 Apple/PowerVR의 HSR(Hidden Surface Removal)이 그리는 순서와 상관없이 가려진 픽셀의 셰이딩을 건너뜀
뎁스/스텐실은 항상 VRAM에 존재 storeOp = DONT_CARE, 메모리리스 텍스처로 VRAM 할당 자체를 생략
HSR(Hidden Surface Removal)이 동작하는 조건

HSR은 다른 삼각형에 가려져 최종 화면에 나오지 않을 픽셀을 미리 찾아내, 그 픽셀의 픽셀 셰이더를 아예 돌리지 않는 기능입니다. Apple과 PowerVR은 HSR, Arm Mali는 FPK(Forward Pixel Kill), Qualcomm Adreno는 LRZ(Low Resolution Z)라는 이름으로 비슷한 일을 합니다.

이 기능들은 모두 “이 픽셀이 결국 보일 것인가”를 픽셀 셰이더를 돌리기 전에, 뎁스만 보고 판단할 수 있어야 동작합니다. 셰이더가 그 판단에 끼어드는 순간 기능이 꺼집니다.

  • discard: 셰이더를 돌려 봐야 이 픽셀이 남는지 버려지는지 알 수 있다.
  • 알파 블렌딩: 앞 픽셀이 반투명이라 뒤 픽셀도 보여야 하므로 “가려졌다”고 버릴 수 없다.
  • 셰이더의 뎁스 출력(SV_Depth): 셰이더를 돌려야 뎁스를 알 수 있다.

데스크톱에서 Early-Z를 깨는 것과 같은 목록이지만, 모바일에서는 손해가 더 큽니다. 뒤에 오는 삼각형의 가림 판단이 이 드로우의 셰이더 결과를 기다려야 해서 타일 처리 전체가 밀리고, 건너뛸 수 있었던 셰이딩이 그대로 전력으로 나가기 때문입니다.

5. 그래서, 셰이더를 쓸 때 무엇을 고려해야 하나

지금까지의 하드웨어 사실에서 원칙을 뽑습니다. 형식은 “하드웨어가 이렇게 생겼으므로 → 이렇게 한다” 입니다.

5-1. 분기: 균일하게 만들거나, 짧게 만든다

사실: 발산한 분기는 양쪽 비용을 모두 낸다. 균일 분기는 거의 공짜다. (2-2절)

  • 분기 조건을 상수 버퍼·드로우 단위 값으로 옮길 수 있으면 옮깁니다. 머티리얼 플래그, 기능 토글, 품질 단계는 픽셀별 값이 아닙니다.
  • 픽셀별 분기는 양쪽이 짧은지 봅니다. 짧으면 select로 바뀌어 발산 비용이 없습니다. 길다면 조건이 화면에서 덩어리지는지 생각합니다.
  • 반복 횟수가 스레드마다 다른 루프는 최대 횟수만큼 돕니다. 상한을 낮추거나 균일하게 만듭니다.
예시 코드, 그리고 균일 분기로 변형을 줄일 때의 함정
// 균일: 거의 공짜. 변형(permutation)을 줄이는 용도로 적극 사용
if (g_Material.useNormalMap) { ... }

// 픽셀별, 양쪽 짧음: 컴파일러가 select로 바꿈. 걱정할 필요 없음
float3 c = (ndotl > 0.0) ? lit : ambient;

// 픽셀별, 양쪽 길고 조건이 노이즈처럼 흩어짐: 실제 발산. 구조를 다시 생각할 것
if (noise.r > 0.5) { /* 50줄 */ } else { /* 다른 50줄 */ }

셰이더 변형 폭발을 균일 분기로 막는 것은 좋은 전략입니다. 다만 레지스터는 실행되지 않는 분기까지 포함해 최악 경로 기준으로 할당됩니다. 열 가지 기능을 한 셰이더에 넣으면 실행 비용은 없어도 점유율은 가장 무거운 경로에 맞춰 떨어집니다.

5-2. 레지스터: 살아 있는 값의 개수를 의식한다

사실: 레지스터 사용량이 점유율을 정하고, 점유율이 레이턴시를 숨길 능력을 정한다. (2-3절)

  • 컴파일러 출력에서 VGPR 수와 점유율을 확인하는 습관을 들입니다. Radeon GPU Analyzer, Nsight, Mali Offline Compiler, Xcode 셰이더 프로파일러가 보여줍니다. 숫자를 안 보고 “이 정도면 가볍겠지”라고 짐작하는 것이 가장 흔한 실수입니다.
  • 레지스터는 동시에 살아 있는 값의 최대 개수입니다. 텍스처 8장을 위에서 다 읽고 아래에서 조합하면 레이턴시는 잘 겹치지만 결과 8개가 동시에 삽니다. 읽고-쓰고-버리는 구조는 그 반대입니다. 균형은 프로파일러 숫자로 정합니다.
  • 동적으로 인덱싱되는 로컬 배열은 레지스터에 못 올라가 스크래치 메모리(VRAM)로 나갑니다. 성능이 갑자기 몇 배 나빠졌다면 첫 번째 용의자입니다.
스크래치로 나가는 패턴
float weights[32];
for (int i = 0; i < count; ++i)      // count가 런타임 값이면 인덱스가 컴파일 시점에 안 풀림
    weights[i] = ...;

크기가 작고 인덱스가 풀리도록 [unroll]하거나, 상수 버퍼·구조화 버퍼로 옮깁니다. 낮은 정밀도도 레지스터를 절반으로 줄입니다(5-3).

5-3. 정밀도: 필요한 만큼만 쓴다

사실: 16비트 값은 레지스터를 절반만 쓰고, 대부분의 GPU에서 ALU 처리량이 두 배다. 모바일에서 차이가 더 크다.

용도 권장 정밀도 이유
색, 알베도, 라이팅 중간값 16비트 눈이 구분하는 범위를 넉넉히 넘음
노멀, 탄젠트 (정규화된 벡터) 16비트 방향 표현에는 충분. 정규화 직전의 큰 값은 주의
UV 상황에 따라 큰 텍스처를 타일링하면 16비트로 부족. “떨림”으로 나타남
월드 좌표, 뎁스, 뷰 공간 위치 32비트 범위가 크고 차이가 작음. 16비트면 즉시 아티팩트
루프 카운터, 인덱스 32비트 정수 정수는 정밀도와 별개 문제
플랫폼별 표기와 변환 비용
  • 모바일은 mediump(GLSL)/half(Metal)를 기본값으로 두고 필요한 곳만 highp/float로 올립니다. 데스크톱 HLSL의 half는 16비트 타입 옵션을 켜지 않으면 그냥 float이고, min16float 또는 네이티브 16비트 옵션으로 접근합니다.
  • 16비트와 32비트를 한 식 안에서 자주 섞으면 변환 명령이 절약분을 먹습니다. 정밀도는 함수 단위, 파이프라인 구간 단위로 통일합니다.

5-4. 메모리: 적게 읽고, 이웃과 함께 읽고, 압축해서 읽는다

사실: 데이터 이동이 계산을 압도한다. 캐시 라인 단위로 오고, 텍스처는 2D 지역성에 최적화돼 있다. (3장)

  • 픽셀당 바이트 예산을 셉니다. RGBA16F가 필요한지 R11G11B10F로 되는지, 노멀을 옥타헤드럴 인코딩으로 2채널에 넣을 수 있는지. G-버퍼 설계는 “무엇을 저장할까”보다 “몇 바이트에 넣을까”의 문제입니다.
  • 텍스처는 항상 블록 압축(BC/ASTC/ETC)과 밉맵으로. 압축은 캐시 라인당 텍셀을 4~8배 늘리고, 밉맵은 멀리 있을 때의 캐시 스래싱을 막습니다.
  • 채널을 패킹합니다. 러프니스·메탈릭·AO를 R8 세 장이 아니라 RGB 한 장으로. 페치 3번이 1번이 됩니다.
  • 종속 텍스처 읽기(한 텍스처 결과로 다른 텍스처의 UV를 정하는 것)는 두 읽기의 레이턴시가 직렬로 쌓입니다. 최소화합니다.
버퍼 종류와 컴퓨트 스레드 배치
  • 웨이브 전체가 같은 위치를 읽으면 상수 버퍼(스칼라 로드 한 번), 스레드마다 다른 인덱스면 구조화 버퍼나 텍스처. 상수 버퍼를 스레드별 인덱스로 뒤지면 두 세계의 나쁜 점만 얻습니다.
  • 컴퓨트 셰이더에서는 스레드 인덱스와 메모리 주소의 대응을 설계합니다. 연속 스레드가 연속 주소를 읽는지, 2D 이미지를 처리할 때 스레드 그룹의 모양이 텍스처 캐시의 타일 모양과 맞는지.

5-5. 계산 대 조회: 요즘은 계산이 더 싸다

사실: ALU는 남고 대역폭과 레이턴시는 부족하다. (3-1절)

10년 전엔 sin, pow 같은 함수를 룩업 텍스처로 바꾸는 것이 정석이었지만 지금은 대체로 반대입니다. 텍스처 읽기 하나는 수백 사이클과 캐시 라인 하나를 쓰고, 연산 유닛은 노는 경우가 많습니다. 노이즈 함수, 색 공간 변환, 간단한 커브는 계산이 빠릅니다. 판단 기준은 산술 밀도(arithmetic intensity), 즉 읽어온 바이트 하나당 연산 횟수입니다. 이 값이 낮으면 대역폭 바운드이고, 거기에 텍스처를 추가하는 것은 병목을 키우는 일입니다.

그래도 룩업이 맞는 경우

적분이 들어간 BRDF 사전 계산 테이블처럼 계산으로는 도저히 안 되는 것, 그리고 이미 다른 이유로 읽고 있는 텍스처에 채널이 남는 경우입니다.

5-6. 파이프라인 단계: 일은 가능한 앞으로, Early-Z는 지킨다

사실: 픽셀은 정점보다 훨씬 많고, 정점은 드로우보다 훨씬 많다. 가려진 픽셀은 Early-Z가 공짜로 버려 준다. (4장)

  • 선형으로 보간해도 되는 값(월드 위치, UV 변환, 라이트 방향의 일부)은 정점 셰이더에서 계산해 보간자로 넘깁니다. 단 보간자 개수도 비용(특히 TBDR에서 대역폭)이니 저울질합니다.
  • 모델·뷰·프로젝션 행렬은 CPU에서 곱해 하나로 넘깁니다. 정점 셰이더의 행렬 곱은 정점 수만큼 반복됩니다.
  • Early-Z를 지킵니다. 불투명은 앞→뒤 정렬 또는 뎁스 프리패스. discard 머티리얼은 불투명 다음에 따로. SV_Depth는 정말 필요한 곳에만, 가능하면 보수적 뎁스로.
  • 오버드로우는 픽셀 셰이더 비용을 곱해 버립니다. 반투명 파티클은 낮은 해상도 타깃에 그린 뒤 업샘플합니다. 풀스크린 패스는 패스 수 자체가 비용입니다.

5-7. 컴퓨트 셰이더: 그룹은 웨이브의 배수로, 공유 메모리는 예산 안에서

사실: 스레드 그룹은 웨이브 단위로 쪼개지고, 공유 메모리는 점유율을 제한하며, 웨이브 안의 통신은 공유 메모리 없이도 된다. (2장)

  • 스레드 그룹 크기는 64의 배수가 안전합니다. 32와 64 양쪽의 배수라 어느 하드웨어에서도 웨이브가 반쯤 비지 않습니다. 2D면 8×8이 기본값입니다.
  • groupshared는 그룹당 사용량이 상주 그룹 수의 상한입니다. 같은 뱅크를 동시에 치는 접근(스트라이드가 32의 배수)은 직렬화됩니다.
  • 웨이브 내장 함수(HLSL WaveActiveSum, QuadReadAcrossX 등, GLSL 서브그룹 연산)는 공유 메모리와 배리어 없이 웨이브 안에서 값을 교환합니다. 리덕션, 프리픽스 합, 이웃 참조에 먼저 고려합니다.
  • 같은 주소의 원자 연산은 스레드 수만큼 직렬화됩니다. 웨이브 안에서 먼저 합치고 한 번만 냅니다.
원자 연산 예시
// 나쁨: 웨이브 32개 레인이 같은 주소에 32번 원자 연산
InterlockedAdd(g_Counter[0], myCount);

// 좋음: 웨이브 안에서 합친 뒤 한 레인만 원자 연산
uint waveTotal = WaveActiveSum(myCount);
if (WaveIsFirstLane())
    InterlockedAdd(g_Counter[0], waveTotal);

5-8. 모바일(TBDR)이라면 추가로

사실: 타일을 VRAM에 쓰고 읽는 것이 가장 비싸고, 정점 출력이 메모리를 거치며, HSR(가려진 픽셀 건너뛰기)은 셰이더가 뎁스에 관여하지 않을 때만 동작한다. (4-3절)

  • 렌더패스 경계를 최소화합니다. 프레임 중간에 “잠깐 다른 타깃에 그리고 돌아오기”가 가장 흔한 손실입니다.
  • 로드·스토어 연산을 정직하게 지정합니다. 이전 내용이 필요 없으면 CLEAR나 DONT_CARE, 렌더패스 밖에서 안 쓰는 어태치먼트(대부분의 뎁스)는 STORE_OP_DONT_CARE.
  • 프레임버퍼를 읽어야 하는 작업(디퍼드 라이팅, 소프트 파티클, 프로그래머블 블렌딩)은 서브패스나 framebuffer fetch로 같은 렌더패스 안에서 처리합니다.
  • 정점 출력과 속성을 작게 유지합니다. 보간자 하나하나가 비닝 단계에서 메모리를 오갑니다.
  • discard와 알파 테스트를 최소화하고, 16비트 정밀도를 기본으로 둡니다.
Vulkan 어태치먼트 예시
// 뎁스를 이 렌더패스 밖에서 읽지 않는다면
depth.loadOp  = VK_ATTACHMENT_LOAD_OP_CLEAR;
depth.storeOp = VK_ATTACHMENT_STORE_OP_DONT_CARE;   // 타일 → VRAM 쓰기 자체가 사라짐

Vulkan은 transient 어태치먼트와 lazily allocated 메모리, Metal은 memoryless 스토리지 모드로 VRAM 할당까지 없앨 수 있습니다.

5-9. 측정하지 않은 최적화는 최적화가 아니다

사실: 병목은 파이프라인 단계 중 하나에 있고, 그 단계가 아닌 곳을 줄이면 아무 일도 일어나지 않는다. (4-1절)

간단한 실험만으로도 병목을 좁힐 수 있습니다.

실험 프레임 시간이 크게 줄면
렌더 해상도를 절반으로 픽셀 셰이더 또는 대역폭 바운드
텍스처 해상도를 극단적으로 낮춤 (밉 바이어스) 텍스처 대역폭 바운드
픽셀 셰이더를 단색 출력으로 교체 픽셀 셰이더 ALU 바운드
정점 수를 줄임 (LOD 강제) 정점 셰이더 또는 래스터 바운드
드로우 수를 줄임 CPU 또는 드라이버 바운드 (배칭 글 참고)

그 다음은 도구입니다. RenderDoc, PIX, Nsight Graphics, Radeon GPU Profiler, Xcode Metal 디버거, Arm Streamline, Snapdragon Profiler. 이름은 다르지만 보는 것은 같습니다. VGPR 수와 점유율, 웨이브 수, 캐시 히트율, ALU/텍스처 유닛 사용률, 패스별 시간. 그리고 하드웨어마다 답이 다릅니다. 데스크톱에서 이득인 것이 모바일에서 손해일 수 있고, NVIDIA에서 무해한 것이 AMD에서 문제가 될 수 있습니다. 목표 플랫폼에서 측정한 숫자만 믿습니다.

6. 한 장 정리

하드웨어 사실 따라오는 원칙
스레드는 웨이브 단위로 같은 명령을 실행한다 분기 조건은 균일하게. 발산하는 분기는 짧게
균일한 값은 스칼라 유닛에서 한 번만 계산된다 상수 기반 계산은 공짜, 스레드별 계산만 아낀다
레지스터 파일을 상주 웨이브가 나눠 쓴다 동시에 살아 있는 값을 줄이고, VGPR 수를 확인한다
레이턴시는 다른 웨이브로 갈아타서 숨긴다 점유율을 확보한다. 단 ALU 바운드면 덜 중요
데이터 이동이 계산보다 백 배 비싸다 픽셀당 바이트를 세고, 계산으로 대체할 수 있으면 계산한다
메모리는 캐시 라인 단위로 온다 이웃 스레드가 이웃 주소를 읽게 한다
텍스처는 2D 타일 배치, 미분값은 쿼드에서 나온다 밉맵과 압축을 항상 쓰고, 분기 안 샘플은 명시적 미분값으로
픽셀 셰이더는 2×2 쿼드 단위로 돈다 픽셀보다 작은 삼각형을 피한다
뎁스 테스트는 셰이더 앞으로 당길 수 있다 앞→뒤 정렬 또는 프리패스, discard와 뎁스 출력 최소화
모바일은 타일 단위로 온칩에서 그린다 렌더패스 경계 최소화, 로드·스토어 정직하게, 프레임버퍼는 서브패스로 읽기
병목은 한 단계에 있다 먼저 어느 단계인지 측정한다

마치며

GPU는 “같은 일을 많이” 하도록 설계된 기계입니다. 스레드를 묶어 같은 명령을 실행하고, 같은 값은 한 번만 계산하고, 이웃한 데이터를 한 번에 가져오고, 기다리는 동안 다른 묶음을 돌립니다. 그래서 셰이더 최적화의 대부분은 새로운 기법을 넣는 것이 아니라 하드웨어가 이미 잘하는 방식에 일을 맞춰주는 것입니다. 균일하게, 연속적으로, 작게, 그리고 파이프라인의 앞 단계에서. 팁을 외우는 것보다 “이 코드가 웨이브 단위로 어떻게 실행될까, 몇 바이트를 읽을까, 어느 단계에서 돌까”를 상상할 수 있는 것이 훨씬 오래 갑니다.

셰이더는 픽셀 하나의 관점으로 쓰되, 비용은 웨이브와 캐시 라인과 타일의 관점으로 셀 것.

참고 자료

GPU 구조 일반

셰이더 최적화

모바일 / 타일 기반 GPU