셰이더 최적화 (1) - 셰이더 성능의 원리 - soo:bak
작성일 :
셰이더의 비용 구조
조명과 그림자, 후처리(Post-Processing) 효과는 화면을 사실적으로 만드는 대표적인 기법입니다. 이 기법들은 모두 GPU에서 실행되는 프로그램, 즉 셰이더(Shader) 안에서 수행됩니다. 조명 모델이 복잡할수록, 후처리 패스가 많을수록 셰이더가 수행해야 하는 연산량이 늘어나고, 그만큼 프레임 시간이 증가합니다.
렌더링 기초 (3)에서 셰이더가 머티리얼의 동작을 정의하는 프로그램이라는 점을 다루었고, GPU 아키텍처 (1)에서 GPU가 셰이더를 수천 개의 스레드로 병렬 실행하는 구조를 확인했습니다. 이 글에서는 한 단계 더 들어가, 셰이더 내부의 어떤 연산이 비용이 높은지, 그리고 모바일 GPU에서 셰이더 성능을 좌우하는 핵심 요인이 무엇인지를 다룹니다.
셰이더의 비용 구조를 이해하면, 특정 셰이더가 느린 원인을 파악하고 최적화 방향을 잡을 수 있습니다.
셰이더 컴파일 파이프라인
Unity에서 셰이더를 작성할 때는 .shader 파일(ShaderLab 문법) 또는 Shader Graph를 사용합니다. 이 소스 코드가 화면에 픽셀을 찍기까지는 여러 단계의 변환을 거칩니다.
이 변환 과정을 이해하면, 같은 셰이더라도 플랫폼마다 성능이 다를 수 있는 이유를 파악할 수 있습니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
셰이더 컴파일 파이프라인
┌──────────────────────────────────────────────────────────┐
│ 소스 코드 작성 │
│ │
│ ┌─────────────────┐ ┌─────────────────┐ │
│ │ ShaderLab │ │ Shader Graph │ │
│ │ (.shader 파일) │ │ (비주얼 노드) │ │
│ └────────┬────────┘ └────────┬────────┘ │
│ │ │ │
│ │ │ (내부 변환) │
│ │ ▼ │
│ │ ┌─────────────────┐ │
│ └──────────────►│ HLSL 코드 │ │
│ └────────┬────────┘ │
└────────────────────────────────────┼─────────────────────┘
│
Unity 셰이더 컴파일러
(에디터/빌드 시점)
│
┌──────────────────┼──────────────────┐
▼ ▼ ▼
┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│ SPIR-V │ │ GLSL │ │ Metal SL │
│ (Vulkan) │ │ (OpenGL ES) │ │ (iOS) │
└──────┬───────┘ └──────┬───────┘ └──────┬───────┘
│ │ │
중간 표현 (IR) 중간 표현 (IR) 중간 표현 (IR)
│ │ │
▼ ▼ ▼
┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│ GPU 드라이버 │ │ GPU 드라이버 │ │ GPU 드라이버 │
│ (Adreno 등) │ │ (Mali 등) │ │ (Apple GPU) │
└──────┬───────┘ └──────┬───────┘ └──────┬───────┘
│ │ │
▼ ▼ ▼
┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│ GPU 전용 │ │ GPU 전용 │ │ GPU 전용 │
│ 기계어 │ │ 기계어 │ │ 기계어 │
└──────────────┘ └──────────────┘ └──────────────┘
각 단계의 역할
소스 코드 단계. 개발자가 작성하는 코드입니다. Unity에서는 ShaderLab 문법 안에 HLSL(High-Level Shading Language) 코드를 포함시키는 것이 일반적입니다. Shader Graph를 사용하면 노드를 연결하여 시각적으로 셰이더를 구성하지만, 내부적으로는 HLSL 코드로 변환됩니다. Shader Graph에서 “Generated Shader” 버튼을 누르면 변환된 HLSL 코드를 확인할 수 있습니다.
Unity 컴파일러 단계. Unity 에디터 또는 빌드 시점에 실행됩니다. HLSL 코드를 대상 플랫폼의 그래픽스 API에 맞는 중간 표현(Intermediate Representation, IR)으로 변환합니다. Vulkan 대상이면 SPIR-V, OpenGL ES 대상이면 GLSL, Metal 대상이면 Metal Shading Language로 변환됩니다. 이 단계에서 키워드 조합에 따른 셰이더 배리언트(variant)도 생성됩니다.
GPU 드라이버 단계. 중간 표현을 특정 GPU 하드웨어의 기계어(ISA, Instruction Set Architecture)로 최종 변환합니다. 같은 GLSL 코드라도 ARM Mali GPU와 Qualcomm Adreno GPU는 서로 다른 기계어를 생성합니다. 이 변환은 앱 실행 시점(런타임) 또는 설치 시점에 수행되며, 드라이버의 최적화 수준에 따라 최종 성능이 달라질 수 있습니다.
1
2
3
4
5
6
7
8
9
10
11
12
같은 HLSL 코드에서 플랫폼별로 다른 최종 코드가 나오는 이유
HLSL 소스 (공통)
│
├──► SPIR-V ──► Adreno 드라이버 ──► Adreno 기계어
│
├──► GLSL ──► Mali 드라이버 ──► Mali 기계어
│
└──► Metal ──► Apple 드라이버 ──► Apple GPU 기계어
→ 동일한 셰이더 소스라도 최종 실행 코드는 GPU마다 다름
→ 특정 GPU에서만 느려지는 현상이 발생할 수 있음
이 구조에서 개발자가 직접 제어할 수 있는 것은 소스 코드 단계뿐입니다. GPU 드라이버의 최적화는 제어할 수 없습니다. 따라서 셰이더 최적화는 소스 코드 수준에서 불필요한 연산을 줄이고, GPU가 효율적으로 실행할 수 있는 패턴으로 코드를 작성하는 데 집중해야 합니다.
셰이더의 세 가지 비용
셰이더 소스가 GPU 기계어로 변환되는 과정을 확인했습니다. 이제 변환된 셰이더가 GPU에서 실행될 때 어떤 비용이 발생하는지를 다룹니다.
셰이더가 실행될 때 GPU에서 발생하는 비용은 크게 세 가지로 분류됩니다. 셰이더의 어느 부분이 병목인지 파악하려면, 이 세 가지를 구분하는 것이 선행되어야 합니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
셰이더 비용의 세 가지 축
══════════════════════════════════════════════════════════
┌──────────────────────────────────────────────────┐
│ 1. ALU 연산 │
│ GPU 연산 유닛에서 수행하는 수학 계산 │
│ +, -, *, /, sin, cos, dot, normalize, lerp │
├──────────────────────────────────────────────────┤
│ 2. 텍스처 샘플링 │
│ 텍스처 메모리에서 텍셀을 읽어오는 연산 │
│ 메모리 접근이 필요하므로 ALU 연산보다 느림 │
├──────────────────────────────────────────────────┤
│ 3. 메모리 대역폭 │
│ 텍스처/버퍼 데이터의 메모리 ↔ GPU 전송량 │
│ 해상도와 텍스처 크기에 비례하여 증가 │
└──────────────────────────────────────────────────┘
세 축 중 어느 쪽이 병목인지에 따라 최적화 방향이 달라짐
══════════════════════════════════════════════════════════
ALU 연산 (Arithmetic Logic Unit)
ALU는 GPU의 연산 유닛입니다. 셰이더 코드에서 수행하는 모든 수학적 계산이 ALU에서 처리됩니다.
1
2
3
4
5
6
7
8
9
ALU에서 처리하는 대표적인 연산
기본 산술: + - * /
삼각함수: sin() cos() tan()
벡터 연산: dot() cross() normalize() length()
보간: lerp() smoothstep() saturate()
거듭제곱: pow() exp() log() sqrt()
행렬 곱셈: mul(matrix, vector)
비교/분기: if step() max() min() clamp()
모든 ALU 연산이 같은 비용은 아닙니다. 덧셈과 곱셈은 GPU가 한 사이클에 처리할 수 있는 가장 기본적인 연산입니다.
반면 sin(), cos(), pow() 같은 초월함수는 내부적으로 여러 사이클이 필요합니다.
normalize()도 내부적으로는 각 성분의 제곱 합산과 역수 제곱근 계산을 포함하는 복합 연산이므로, 단순한 덧셈보다 비용이 큽니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
ALU 연산의 상대적 비용 (대략적 기준)
연산 상대 비용 (사이클)
──────────────────────────────────
add, mul 1
mad (a*b+c) 1 ← GPU가 한 사이클에 처리
min, max, clamp 1
dot (float4) 1
lerp 1
rsqrt 1 ~ 2
rcp (역수) 1 ~ 2
normalize 2 ~ 3 ← dot + rsqrt + mul
sin, cos 2 ~ 4
pow 3 ~ 5 ← exp(y * log(x))
log, exp 2 ~ 3
GPU는 a * b + c 형태의 곱셈-덧셈 조합을 한 사이클에 처리하도록 설계되어 있습니다. 이 연산을 mad(multiply-add)라고 합니다.
셰이더 컴파일러는 가능한 한 많은 계산을 mad 형태로 변환하려고 시도합니다. 예를 들어, 조명 계산에서 dot(normal, lightDir) * intensity + ambient는 곱셈과 덧셈이 결합된 전형적인 mad 패턴이므로, GPU가 한 사이클에 처리할 수 있습니다.
셰이더에서 ALU 연산의 수가 너무 많아 GPU의 연산 유닛이 병목이 되는 상태를 ALU-bound 또는 compute-bound라고 합니다. 복잡한 조명 모델, 다수의 광원, 절차적 텍스처 생성(noise 함수 등)이 ALU-bound의 원인이 됩니다.
텍스처 샘플링
텍스처 샘플링은 셰이더가 텍스처에서 색상 값(텍셀)을 읽어오는 연산입니다. HLSL에서 tex2D(), SAMPLE_TEXTURE2D() 같은 함수가 이에 해당합니다.
ALU 연산은 이미 레지스터에 올라와 있는 값을 계산하므로 연산 유닛 내부에서 완결됩니다.
반면 텍스처 샘플링은 연산 유닛 바깥의 텍스처 메모리에서 데이터를 가져와야 합니다. 이 메모리 접근은 연산 유닛 내부의 계산보다 수십~수백 배 느리기 때문에, 텍스처 샘플링이 셰이더 비용에서 큰 비중을 차지합니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
ALU 연산 — 데이터가 바로 옆에 있음
┌─────────────────────────┐
│ 연산 유닛 │
│ ┌───────────────────┐ │
│ │ 레지스터 (데이터) │ │ ← 데이터가 유닛 내부에 이미 존재
│ └───────────────────┘ │ 1~5 사이클 (수 나노초)
└─────────────────────────┘
텍스처 샘플링 — 데이터를 멀리서 가져와야 함
┌──────────────┐ ┌──────────────┐
│ 연산 유닛 │ ── 메모리 버스 ──► │ 텍스처 │
│ │ ◄── 데이터 전송 ── │ 메모리 │
└──────────────┘ └──────────────┘
│ │
요청 후 대기 캐시 적중: ~10 사이클
캐시 미스: 수백 사이클
GPU는 텍스처 샘플링의 지연 시간을 숨기기 위해 텍스처 캐시를 사용합니다. 인접한 프래그먼트는 텍스처의 인접한 영역을 참조하는 경우가 많으므로, 한 번 읽어온 텍스처 블록을 캐시에 유지하면 다음 프래그먼트가 같은 블록을 다시 읽을 때 메모리 접근 없이 캐시에서 가져올 수 있습니다.
렌더링 기초 (2)에서 다룬 밉맵도 텍스처 캐시 효율을 높이는 기법입니다. 적절한 밉맵 레벨을 사용하면 텍스처의 좁은 영역만 읽으면 되므로, 캐시 적중률(hit rate)이 올라갑니다.
텍스처 샘플링의 비용을 좌우하는 요인은 여러 가지입니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
텍스처 샘플링 비용에 영향을 주는 요인
요인 비용이 커지는 방향 비용을 줄이는 방향
─────────────────────────────────────────────────────────────────
텍스처 수 셰이더당 텍스처가 많을수록 사용하지 않는 샘플 제거
샘플링 횟수 비례 증가
텍스처 해상도 고해상도 → 캐시 미스 증가 적절한 해상도 선택
밉맵 미사용 시 캐시 미스 증가 밉맵 사용 → 캐시 적중률 향상
필터링 모드 bilinear: 4텍셀 읽기 낮은 필터링 모드 선택
trilinear: 8텍셀 읽기
anisotropic: 최대 16텍셀+
텍스처 압축 비압축: 원본 크기 전송 ASTC/ETC2 압축 → 대역폭 절약
─────────────────────────────────────────────────────────────────
필터링 모드는 텍셀 간 보간 방식입니다.
Bilinear 필터링은 대상 좌표 주변의 4개 텍셀을 읽어 가중 평균을 계산합니다.
Trilinear 필터링은 이 bilinear 샘플링을 인접한 두 밉맵 레벨에서 한 번씩 수행합니다. 레벨 N에서 4개, 레벨 N+1에서 4개를 읽은 뒤 두 결과를 보간하므로, 총 8개의 텍셀을 읽습니다.
Anisotropic 필터링은 표면이 카메라에 비스듬히 보일 때 품질을 높이기 위해, 시야 방향을 따라 추가 샘플을 수행합니다. 설정에 따라 최대 16개 이상의 텍셀을 읽을 수 있습니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
필터링 모드별 텍셀 읽기 수
Bilinear — 밉맵 레벨 1개에서 4텍셀
┌───┬───┐
│ T │ T │ ← 대상 좌표 주변 4개
├───┼───┤
│ T │ T │
└───┴───┘
Trilinear — 밉맵 레벨 2개에서 각 4텍셀 = 총 8텍셀
레벨 N 레벨 N+1
┌───┬───┐ ┌───┬───┐
│ T │ T │ │ T │ T │
├───┼───┤ ├───┼───┤
│ T │ T │ │ T │ T │
└───┴───┘ └───┴───┘
└──── 보간 ────┘
모바일에서는 필터링 모드가 성능에 미치는 영향이 큽니다. UI 텍스처나 픽셀 아트처럼 밉맵 레벨 간 전환이 눈에 띄지 않는 텍스처에는 bilinear을 사용하고, anisotropic 필터링 레벨을 낮추면 대역폭을 절약할 수 있습니다.
메모리 대역폭
대역폭은 단위 시간당 메모리와 GPU 사이에 전송할 수 있는 데이터의 양입니다.
앞에서 텍스처 샘플링이 메모리에서 데이터를 읽어오는 연산이라고 했는데, 대역폭은 그 데이터가 이동하는 통로(메모리 버스)의 전송 용량입니다.
1
2
3
4
5
6
7
8
9
10
11
데스크톱 GPU vs 모바일 GPU 대역폭 비교
데스크톱 GPU
메모리 버스 폭: 256 ~ 384 bit
대역폭: 수백 GB/s
모바일 GPU
메모리 버스 폭: 32 ~ 64 bit
대역폭: 수십 GB/s
→ 모바일은 데스크톱 대비 대역폭이 1/5 ~ 1/10 수준
GPU 아키텍처 (2)에서 다룬 것처럼, 이 제한된 대역폭을 텍스처 읽기, 프레임버퍼 읽기/쓰기, 정점 데이터 읽기 등이 모두 공유합니다.
앞에서 다룬 텍스처 샘플링 요인들(텍스처 수, 해상도, 필터링 모드)은 각각 메모리 버스를 통해 전송해야 하는 데이터의 양을 늘립니다. 이 전송량이 대역폭을 초과하면 GPU 연산 유닛이 데이터 도착을 기다리는 시간이 늘어나 전체 처리 속도가 저하됩니다. 이 상태를 대역폭 바운드(bandwidth-bound)라고 합니다.
렌더링 기초 (2)에서 다룬 텍스처 압축(ASTC, ETC2)은 대역폭 소비를 줄이는 직접적인 수단입니다. 2048x2048 비압축 RGBA 텍스처는 16MB이지만, ASTC 6x6으로 압축하면 약 1.78MB로 줄어듭니다. 같은 텍스처를 읽더라도 메모리 버스를 통과하는 데이터가 약 1/9이 됩니다.
세 가지 비용의 관계
실제 셰이더에서는 세 가지 비용이 동시에 작용합니다. 셰이더가 느릴 때 원인이 ALU 연산 과다인지, 텍스처 샘플링 과다인지, 대역폭 부족인지를 구분해야 적절한 최적화를 적용할 수 있습니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
병목 유형별 원인과 대응
병목 유형 원인 조건 최적화 방향
────────────────────────────────────────────────────────────────────
ALU-bound pow, sin 등 고비용 연산이 연산 단순화
(연산 병목) 많은 셰이더 LUT 사용, half 정밀도
Texture-bound 텍스처를 여러 장 읽거나 텍스처 수 줄이기
(텍스처 병목) 높은 필터링 모드 사용 아틀라스, 밉맵 활용
Bandwidth-bound 고해상도 비압축 텍스처로 텍스처 압축(ASTC/ETC2)
(대역폭 병목) 메모리 전송량 과다 해상도·필터링 레벨 조정
────────────────────────────────────────────────────────────────────
GPU 프로파일링 도구(Qualcomm Snapdragon Profiler, ARM Mali Offline Compiler, Xcode GPU Profiler 등)를 사용하면 셰이더의 병목 유형을 확인할 수 있습니다. 예를 들어 Mali Offline Compiler는 셰이더 코드를 분석하여 ALU 사이클, 텍스처 사이클, 로드/스토어 사이클을 각각 출력합니다. GPU는 이 세 작업을 병렬로 처리하므로, 가장 큰 사이클 수가 셰이더의 총 실행 시간을 결정합니다.
프래그먼트 셰이더가 모바일 병목인 이유
GPU 아키텍처 (1)의 렌더링 파이프라인에서, 버텍스 셰이더는 정점 하나당 한 번 실행되고, 프래그먼트 셰이더는 프래그먼트(픽셀 후보) 하나당 한 번 실행됩니다. 일반적인 모바일 씬에서 프래그먼트 수는 정점 수보다 10~40배 많으므로, 프래그먼트 셰이더의 비용이 전체 성능을 좌우합니다.
정점 수 vs 프래그먼트 수
화면 해상도가 1920x1080이면 한 프레임에 약 207만 개의 프래그먼트가 존재합니다. 오버드로우 없이 모든 픽셀을 한 번만 그릴 때의 수치입니다. 반면 화면에 그려지는 정점은 일반적인 모바일 씬에서 5만~20만 개 수준입니다.
이 차이는 명령어 하나가 실행되는 횟수로 보면 더 명확해집니다.
오버드로우가 없는 최선의 경우에도, 프래그먼트 셰이더에서 명령어 하나를 줄이면 207만 번의 실행이 줄어듭니다. 버텍스 셰이더에서 같은 명령어를 줄이면 10만 번만 줄어듭니다.
1
2
3
4
5
6
7
8
9
셰이더 단계별 명령어 1개의 실행 횟수 (1920x1080 기준)
실행 대상 명령어 1개당 실행 횟수
──────────────────────────────────────────────────────────────
버텍스 셰이더 정점 100,000개 100,000회
프래그먼트 셰이더 프래그먼트 2,073,600개 2,073,600회
──────────────────────────────────────────────────────────────
→ 프래그먼트 셰이더의 명령어 1개 ≈ 버텍스 셰이더의 명령어 약 20개
오버드로우의 영향
현실적인 씬에서는 여러 오브젝트가 화면상에서 겹칩니다. 같은 픽셀 위치에 프래그먼트 셰이더가 여러 번 실행되는 것을 오버드로우(Overdraw)라고 합니다.
불투명 오브젝트는 Early-Z 테스트(GPU 아키텍처 (1) 참고)로 가려진 프래그먼트를 건너뛸 수 있으므로, 오버드로우가 크게 증가하지 않습니다.
반면 반투명 오브젝트(파티클, UI 오버레이, 반투명 효과 등)는 뒤에 있는 오브젝트와 색상을 혼합해야 하므로 셰이딩을 건너뛸 수 없습니다. 반투명 오브젝트가 오버드로우의 주된 원인입니다.
오버드로우가 2배라면 프래그먼트 셰이더 실행 횟수도 2배가 됩니다.
1
2
3
4
5
6
7
8
오버드로우에 따른 프래그먼트 수 (1920 x 1080)
오버드로우 없음 (1.0x): 약 200만 프래그먼트
오버드로우 1.5x: 약 300만 프래그먼트
오버드로우 2.0x: 약 400만 프래그먼트
오버드로우 3.0x: 약 600만 프래그먼트
반투명 이펙트가 많은 씬에서는 오버드로우 3.0x 이상도 흔함
모바일 GPU의 필레이트 한계
필레이트(Fill Rate)는 GPU가 초당 처리할 수 있는 프래그먼트의 수입니다.
1
2
3
4
5
6
7
8
9
GPU 유형별 필레이트 비교 (대략적 수치)
GPU 유형 필레이트 (GPixel/s)
─────────────────────────────────────────────
데스크톱 (고성능) 100 ~ 300+
모바일 (플래그십) 15 ~ 40
모바일 (중급) 5 ~ 15
─────────────────────────────────────────────
데스크톱 대비 모바일: 약 1/5 ~ 1/20
이 필레이트가 실제 프레임 예산에서 어떤 의미인지 계산해봅니다.
1
2
3
4
5
6
7
8
9
10
프레임 예산 계산 (1920 x 1080, 60fps, 오버드로우 2x)
프래그먼트 수: 1920 x 1080 x 2.0 = 4,147,200
셰이더 명령어: 30개/프래그먼트
초당 프레임: 60
초당 명령어 실행: 4,147,200 x 30 x 60 ≈ 75억
명령어를 30개 → 20개로 줄이면:
초당 명령어 실행: 4,147,200 x 20 x 60 ≈ 50억 (33% 감소)
중급 모바일 GPU의 필레이트는 5~15 GPixel/s입니다.
프래그먼트당 명령어 수가 늘어날수록 실질 필레이트는 떨어지므로, 프래그먼트 셰이더의 명령어 하나를 줄이는 것이 모바일에서 가장 효과적인 최적화입니다.
위 계산처럼 명령어를 30개에서 20개로 줄이면 초당 약 25억 번의 실행이 줄어듭니다.
정밀도: half vs float
앞 절에서 프래그먼트 셰이더가 수백만 번 실행되며, 명령어 하나의 비용이 버텍스 셰이더보다 수십 배 크다는 점을 확인했습니다.
프래그먼트 셰이더의 비용을 줄이는 직접적인 방법 중 하나가 변수의 정밀도(precision) 를 적절하게 선택하는 것입니다.
float와 half의 차이
셰이더에서 사용하는 부동소수점 타입은 크게 두 가지로 나뉩니다.
float는 IEEE 754 32비트 표준을 따르고, half는 IEEE 754 16비트 표준을 따릅니다. (fixed라는 11비트 타입도 있지만, 현재 대부분의 GPU에서 내부적으로 half와 동일하게 처리되므로 구분할 실익이 없습니다.)
1
2
3
4
5
6
7
셰이더 부동소수점 타입 비교
타입 비트 수 표현 가능 범위 소수점 이하 정밀도
──────────────────────────────────────────────────────────
float 32비트 ±3.4 x 10^38 약 7자리
half 16비트 ±65,504 약 3자리
──────────────────────────────────────────────────────────
half는 비트 수가 절반이므로 표현 범위와 정밀도가 제한됩니다. 65,504를 넘는 값은 표현할 수 없고, 소수점 이하 3자리를 넘어가면 오차가 발생합니다.
이 제한이 문제가 되지 않는 곳에서는 half를 사용하여 비용을 줄일 수 있습니다.
데스크톱 GPU: half와 float의 차이가 없다
데스크톱 GPU(NVIDIA GeForce, AMD Radeon 등)의 연산 유닛은 내부적으로 모든 부동소수점 연산을 32비트로 처리합니다.
셰이더 코드에서 half로 선언하더라도, GPU 하드웨어는 이를 float로 승격(promotion)시켜 실행합니다.
1
2
3
4
5
6
7
데스크톱 GPU에서의 정밀도 처리
셰이더 코드: half4 color = tex2D(_MainTex, uv);
GPU 실행: float4 color = tex2D(_MainTex, uv); ← 내부적으로 32비트
→ half로 선언해도 성능 이점 없음
→ 데스크톱에서는 float만 사용해도 무관
이 때문에 데스크톱에서만 테스트하면 half와 float의 성능 차이를 관찰할 수 없습니다. 모바일 기기에서 테스트해야 실제 차이를 확인할 수 있습니다.
모바일 GPU: half가 float의 2배 빠르다
모바일 GPU(ARM Mali, Qualcomm Adreno 등)는 16비트 연산 유닛을 물리적으로 갖추고 있습니다. half 타입의 연산은 이 16비트 유닛에서 처리되고, float 타입의 연산은 32비트 유닛에서 처리됩니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
같은 32비트 폭의 연산 유닛에서의 처리 차이
float 연산:
┌────────────────────────────────┐
│ 32비트 값 1개 │ ← 한 사이클에 1개 처리
└────────────────────────────────┘
half 연산:
┌───────────────┬────────────────┐
│ 16비트 값 A │ 16비트 값 B │ ← 한 사이클에 2개 처리
└───────────────┴────────────────┘
→ 같은 하드웨어 폭에 half 2개가 들어가므로 처리량 2배
이처럼 같은 폭의 연산 유닛에 두 개의 16비트 값을 채워 동시에 처리하는 것을 벡터 패킹(vector packing)이라고 합니다. half 연산의 처리량은 float의 약 2배입니다.
처리량 외에 레지스터 사용량도 차이가 있습니다. half 변수는 float 변수의 절반 크기의 레지스터만 사용합니다.
GPU의 레지스터 파일은 한정되어 있고, 실행 중인 모든 스레드가 이 레지스터 파일을 나누어 사용합니다. 스레드 하나가 차지하는 레지스터가 적을수록 같은 레지스터 파일에 더 많은 스레드를 동시에 배치할 수 있습니다. 동시에 실행 가능한 스레드(Warp/Wavefront) 수의 비율을 점유율(occupancy)이라고 합니다.
점유율이 높으면 GPU가 텍스처 샘플링 등의 메모리 지연을 대기하는 동안 다른 스레드를 실행할 수 있으므로, 연산 유닛의 유휴 시간이 줄어듭니다. half를 사용하면 스레드당 레지스터 사용량이 절반으로 줄어 점유율이 높아집니다.
1
2
3
4
5
6
7
8
9
10
half vs float: 모바일 GPU에서의 성능 차이 요약
항목 float half
──────────────────────────────────────────
레지스터 크기 32비트 16비트
ALU 처리량 기준 (1x) 약 2배 (2x)
레지스터 사용 기준 (1x) 절반 (0.5x)
점유율 기준 (1x) 향상 (레지스터 절감분)
메모리 대역폭 기준 (1x) 절반 (0.5x)
──────────────────────────────────────────
Apple GPU (A-시리즈, M-시리즈)
Apple GPU도 half와 float를 구분하여 처리합니다. 16비트 유닛이 있으며 half 사용 시 성능 이점이 있습니다. 다만 Mali나 Adreno에 비하면 차이가 작습니다. Apple GPU는 내부적으로 넓은 SIMD 폭과 높은 효율의 스케줄링을 갖추고 있어, 정밀도 차이에 의한 성능 변동이 상대적으로 완만합니다. 그래도 half를 사용하면 레지스터 압박이 줄어들고 대역폭이 절약되므로, Apple GPU에서도 half 사용은 권장됩니다.
어디에 half를 사용할 수 있는가
모든 변수를 half로 바꿀 수 있는 것은 아닙니다. half의 범위(±65,504)와 정밀도(소수점 약 3자리)가 충분한 경우에만 사용 가능합니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
정밀도 선택 기준
half 사용 가능 — 값의 범위가 좁고 3자리 정밀도로 충분한 경우
──────────────────────────────────────────────────────────────
색상 (RGB, RGBA) 0~1 범위, 3자리면 256단계 이상
텍스처 좌표 (UV) 0~1 범위가 대부분 (타일링 UV는 주의)
법선 벡터 (Normal) -1~1 범위, 방향 정보만 필요
조명 계산 중간값 dot product 등 대부분 0~1 범위
float 필요 — 범위가 넓거나 미세한 정밀도가 필요한 경우
──────────────────────────────────────────────────────────────
위치 (Position) 월드 좌표가 수천 이상, 정밀도 부족 시 떨림
깊이 (Depth) 0~1이지만 미세한 차이로 Z-fighting 발생
행렬 연산 (MVP 변환) 곱셈이 연쇄되어 정밀도 손실이 누적됨
float가 필요한 대표적인 경우가 위치(position) 계산입니다.
half의 유효 숫자는 약 3자리이므로, 값이 커질수록 소수점 이하 정밀도가 줄어듭니다. 월드 좌표가 (1000, 50, 800) 같은 값을 가질 때, half는 소수점 이하 약 1자리밖에 보장하지 않습니다.
카메라가 이동하면서 좌표를 계산하면 정밀도 부족으로 오브젝트가 떨리는(jittering) 현상이 나타납니다.
반면 half가 적합한 데이터는 값의 범위 자체가 좁습니다. 색상은 0~1 범위이고, 소수점 3자리는 1/1000 단위의 구분이 가능하므로 8비트 색상(256단계)보다 정밀합니다.
법선 벡터(-1~1)와 Phong Shading Model에서 다룬 조명 계산의 중간값(dot product, pow 결과)도 대부분 -1~1 또는 0~1 범위이므로 half의 정밀도로 충분합니다.
실전 적용 예시
앞에서 다룬 기준을 실제 셰이더 코드에 적용한 예시입니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
정밀도 혼합 사용 예시
// 버텍스 셰이더 출력 (보간기)
struct v2f
{
float4 pos : SV_POSITION; // 위치: float 필수
half2 uv : TEXCOORD0; // UV: half 충분
half3 normal : TEXCOORD1; // 법선: half 충분
half3 viewDir : TEXCOORD2; // 뷰 방향: half 충분
};
// 프래그먼트 셰이더
half4 frag(v2f i) : SV_Target
{
// 텍스처 샘플링 결과: half
half4 albedo = SAMPLE_TEXTURE2D(_MainTex, sampler_MainTex, i.uv);
// 조명 계산: half
half3 n = normalize(i.normal);
half NdotL = saturate(dot(n, _MainLightDirection.xyz));
half3 diffuse = albedo.rgb * NdotL * _MainLightColor.rgb;
// 최종 색상: half
return half4(diffuse, albedo.a);
}
SV_POSITION만 float이고 나머지는 모두 half입니다. half로 선언된 ALU 연산은 벡터 패킹을 통해 같은 사이클에 2배의 값을 처리할 수 있으므로, 이 셰이더에서 SV_POSITION을 제외한 대부분의 연산이 그 혜택을 받습니다.
다만 이 코드에서 UV를 half로 선언한 것은 UV가 0~1 범위일 때만 유효합니다. 타일링(Tiling)으로 UV가 0~10 같은 값을 가지면, half의 정밀도가 부족하여 텍스처가 뭉개지거나 어긋날 수 있습니다. 타일링 배수가 큰 경우에는 UV에 float를 사용해야 합니다.
1
2
3
4
5
6
7
타일링 UV와 half 정밀도
UV 범위 half 정밀도 타일당 구분 단계
─────────────────────────────────────────────
0~1 ≈ 0.001 약 1024단계
0~10 ≈ 0.008 약 128단계 (뭉개짐 시작)
0~100 ≈ 0.06 약 16단계 (심각한 왜곡)
명령어 수와 성능의 관계
앞에서 셰이더 비용의 세 가지 축(ALU, 텍스처 샘플링, 대역폭)과 half/float 정밀도 선택을 다루었습니다.
하지만 셰이더 두 개를 놓고 어느 쪽이 더 비싼지 판단하려면 정량적인 지표가 필요합니다.
가장 기본적인 지표는 명령어 수(instruction count)입니다.
셰이더 컴파일러가 HLSL을 GPU 기계어로 변환한 결과에서 명령어 수를 확인할 수 있습니다. 명령어가 적을수록 GPU가 처리할 작업이 줄어들므로, 셰이더 간 비용을 비교하는 기본 기준이 됩니다.
Unity에서 명령어 수 확인
Unity에서 셰이더 파일을 선택하고 Inspector의 “Compile and show code”를 누르면, 대상 플랫폼으로 컴파일된 결과에서 명령어 수를 확인할 수 있습니다.
1
2
3
4
5
6
컴파일된 셰이더 출력 (예시)
// OpenGL ES 3.0 (Mali GPU 대상)
//
// Vertex shader: 8 math, 0 texture 총 8 명령어
// Fragment shader: 12 math, 3 texture 총 15 명령어
모바일 프래그먼트 셰이더의 실용적인 명령어 수 기준입니다.
1
2
3
4
5
6
7
8
모바일 프래그먼트 셰이더 명령어 수 가이드
명령어 수 복잡도 적합한 용도
──────────────────────────────────────────────────────
5 ~ 15 가벼운 셰이더 단색, 텍스처 1장 + 간단한 연산
15 ~ 30 보통 셰이더 텍스처 1~2장 + 조명 계산
30 ~ 60 무거운 셰이더 텍스처 3장 이상 + 복잡한 조명
60 이상 과중 셰이더 모바일에서 프레임 예산 초과 가능
명령어 수는 절대적인 기준이 아닙니다.
앞에서 다룬 것처럼 텍스처 샘플링은 캐시 적중 여부에 따라 소요 사이클이 크게 달라지므로, 같은 15 명령어라도 텍스처 샘플링이 많은 셰이더와 ALU 연산만 있는 셰이더는 실제 성능이 다릅니다.
하지만 같은 유형의 셰이더를 비교할 때는 명령어 수가 유용한 상대 지표입니다.
버텍스 셰이더 vs 프래그먼트 셰이더의 비용 비율
프래그먼트는 정점보다 10~40배 많으므로, 같은 연산이라도 프래그먼트 셰이더에서 실행하면 그만큼 비용이 커집니다. 비용이 큰 연산을 버텍스 셰이더로 옮기면 실행 횟수를 크게 줄일 수 있습니다.
1
2
3
4
5
6
7
8
normalize(viewDir) 실행 횟수 비교
프래그먼트 셰이더에서 계산:
200만 프래그먼트 × normalize 1회 = 200만 번 실행
버텍스 셰이더에서 계산 후 보간:
10만 정점 × normalize 1회 = 10만 번 실행
래스터화가 결과를 프래그먼트마다 자동 보간
실행 횟수가 200만에서 10만으로, 95% 줄어듭니다.
보간된 값은 프래그먼트마다 직접 계산한 결과와 약간 다르지만, 법선 벡터나 뷰 방향처럼 메쉬 표면에서 부드럽게 변하는 값은 선형 보간이 좋은 근사가 되므로 시각적 차이가 거의 보이지 않습니다.
단, 보간된 법선 벡터는 단위 벡터가 아닐 수 있습니다. 두 정점의 법선이 (1, 0, 0)과 (0, 1, 0)일 때, 중간 지점의 보간 결과는 (0.5, 0.5, 0)이고 길이가 약 0.707입니다.
정확한 조명 계산이 필요하면 프래그먼트 셰이더에서 normalize()를 한 번 더 수행해야 하므로, 절약한 비용의 일부를 다시 소모하게 됩니다.
셰이더 비용을 높이는 흔한 패턴
지금까지 셰이더 비용의 구조(ALU, 텍스처, 대역폭)와 정밀도, 명령어 수를 개별적으로 살펴보았습니다. 실제 프로젝트에서는 이 요소들이 복합적으로 작용하여 성능 문제를 일으킵니다.
불필요한 고정밀도 연산
프래그먼트 셰이더에서 모든 변수를 float로 선언하면, 색상이나 법선처럼 half로 충분한 값까지 32비트로 처리됩니다. 앞에서 다룬 벡터 패킹을 활용할 수 없으므로, half를 사용했을 때 대비 처리량이 절반으로 떨어집니다.
프래그먼트 셰이더의 과도한 수학 연산
pow(), sin(), cos() 같은 초월함수를 프래그먼트 셰이더에서 여러 번 사용하면 ALU 비용이 급증합니다. 이런 연산의 결과가 입력 값에 따라 부드럽게 변하는 경우, LUT(Look-Up Table) 텍스처로 대체할 수 있습니다. LUT는 함수의 결과를 미리 계산하여 텍스처에 저장해 두고, 런타임에는 텍스처 샘플링으로 값을 가져오는 기법입니다.
1
2
3
4
5
6
7
8
9
10
pow() → LUT 텍스처 대체
수학 연산:
half specular = pow(NdotH, _Shininess);
pow 내부: exp(y * log(x)), 3~5 사이클
LUT 텍스처:
half specular = SAMPLE_TEXTURE2D(_SpecLUT, sampler_SpecLUT,
half2(NdotH, _Shininess)).r;
텍스처 1회 샘플링, 캐시 적중 시 ALU보다 효율적
LUT를 위한 추가 텍스처 슬롯과 메모리가 필요하므로, pow()처럼 ALU 비용이 높은 연산을 대체할 때 유리합니다.
텍스처 과다 사용
셰이더 하나에서 텍스처를 5장 이상 읽는 경우가 있습니다.
Albedo, Normal, Metallic, Roughness, Occlusion, Emission 등 PBR(Physically Based Rendering) 워크플로우에서는 텍스처 수가 자연스럽게 증가합니다.
모바일에서는 텍스처를 채널 패킹(Channel Packing)하여 텍스처 수를 줄일 수 있습니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
채널 패킹 예시
패킹 전 (텍스처 3장, 샘플링 3회):
텍스처 1: Metallic (R 채널만 사용)
텍스처 2: Roughness (R 채널만 사용)
텍스처 3: Occlusion (R 채널만 사용)
패킹 후 (텍스처 1장, 샘플링 1회):
R = Metallic, G = Roughness, B = Occlusion
half3 packed = SAMPLE_TEXTURE2D(_PackedTex, sampler_PackedTex, uv).rgb;
half metallic = packed.r;
half roughness = packed.g;
half occlusion = packed.b;
Metallic, Roughness, Occlusion은 각각 단일 값(그레이스케일)이므로 R 채널 하나만 사용하고, 나머지 G, B 채널은 비어 있습니다. 이 빈 채널에 다른 데이터를 채우면 텍스처 수와 메모리를 동시에 줄일 수 있습니다. Unity의 Standard Shader도 Metallic과 Smoothness를 하나의 텍스처에 패킹하여 사용합니다.
Warp 내 분기 발산
GPU 아키텍처 (1)에서 Warp 내 분기 발산이 양쪽 경로를 모두 실행하게 만든다는 것을 확인했습니다. 프래그먼트 셰이더에서 if문을 사용할 때, 인접한 프래그먼트 사이에서 분기 방향이 갈리면 성능 저하가 발생합니다.
1
2
3
4
5
6
7
8
9
분기를 피하는 패턴
조건 분기 (분기 발산 가능):
if (brightness > 0.5)
color *= 2.0;
산술 대체 (분기 없음):
color *= lerp(1.0, 2.0, step(0.5, brightness));
step()과 lerp()은 ALU 연산이므로 분기 없이 같은 결과를 얻을 수 있고, Warp 내 모든 스레드가 동일한 코드 경로를 실행합니다.
단, 분기 안의 연산이 가벼우면 산술 대체가 오히려 명령어를 늘리므로, 분기 비용이 크고 Warp 내 발산이 잦은 경우에만 효과적입니다.
마무리
- ALU 연산, 텍스처 샘플링, 메모리 대역폭이 셰이더 비용의 세 축이며, 병목 유형에 따라 최적화 방향이 달라집니다.
- HLSL 소스는 플랫폼별로 다른 GPU 기계어로 변환되므로, 최종 성능은 실제 모바일 기기에서 확인해야 합니다.
- 프래그먼트 셰이더는 화면 해상도에 비례하여 수백만 번 실행되므로, 명령어 하나의 비용이 버텍스 셰이더보다 수십 배 큽니다. 오버드로우는 이 비용을 2~3배로 늘립니다.
- 모바일 GPU에서 half는 float 대비 ALU 처리량 2배, 레지스터 사용량 절반의 이점이 있습니다. 색상, UV, 법선에는 half, 위치와 깊이에는 float를 사용합니다.
- 채널 패킹, LUT 텍스처, 버텍스 셰이더로의 연산 이동으로 프래그먼트 셰이더의 부하를 줄일 수 있습니다.
셰이더 최적화는 그래픽 품질을 희생하는 것이 아니라, 같은 품질을 더 적은 자원으로 달성하는 것입니다.
프래그먼트 셰이더에서 명령어 하나를 줄이면 200만 번의 실행이 사라지고, half 정밀도로 전환하면 같은 시간에 2배의 픽셀을 처리할 수 있습니다. 이런 선택들이 모여 60fps를 유지할 수 있는지, 30fps로 떨어지는지를 결정합니다.
하지만 개별 셰이더의 복잡도만이 전체 성능을 결정하지는 않습니다.
Unity에서는 키워드 조합에 따라 하나의 셰이더 소스에서 수백~수천 개의 셰이더 배리언트(variant)가 생성될 수 있으며, 배리언트 수가 수천 개를 넘으면 빌드 시간, 메모리, 로딩 시간이 모두 증가합니다.
Part 2에서는 배리언트 관리와 모바일 셰이더 기법을 다룹니다.
관련 글
시리즈
- 셰이더 최적화 (1) - 셰이더 성능의 원리 (현재 글)
- 셰이더 최적화 (2) - 셰이더 배리언트와 모바일 기법
전체 시리즈
- 게임 루프의 원리 (1) - 프레임의 구조
- 게임 루프의 원리 (2) - CPU-bound와 GPU-bound
- 렌더링 기초 (1) - 메쉬의 구조
- 렌더링 기초 (2) - 텍스처와 압축
- 렌더링 기초 (3) - 머티리얼과 셰이더 기초
- GPU 아키텍처 (1) - GPU 병렬 처리와 렌더링 파이프라인
- GPU 아키텍처 (2) - 모바일 GPU와 TBDR
- Unity 렌더 파이프라인 (1) - Built-in과 URP의 구조
- Unity 렌더 파이프라인 (2) - 드로우콜과 배칭
- Unity 렌더 파이프라인 (3) - 컬링과 오클루전
- 스크립트 최적화 (1) - C# 실행과 메모리 할당
- 스크립트 최적화 (2) - Unity API와 실행 비용
- 메모리 관리 (1) - 가비지 컬렉션의 원리
- 메모리 관리 (2) - 네이티브 메모리와 에셋
- 메모리 관리 (3) - Addressables와 에셋 전략
- UI 최적화 (1) - 캔버스와 리빌드 시스템
- UI 최적화 (2) - UI 최적화 전략
- 조명과 그림자 (1) - 실시간 조명과 베이크
- 조명과 그림자 (2) - 그림자와 후처리
- 셰이더 최적화 (1) - 셰이더 성능의 원리 (현재 글)
- 셰이더 최적화 (2) - 셰이더 배리언트와 모바일 기법
- 물리 최적화 (1) - 물리 엔진의 실행 구조
- 물리 최적화 (2) - 물리 최적화 전략
- 파티클과 애니메이션 (1) - 파티클 시스템 최적화
- 파티클과 애니메이션 (2) - 애니메이션 최적화
- 프로파일링 (1) - Unity Profiler와 Frame Debugger
- 프로파일링 (2) - 모바일 프로파일링
- 모바일 전략 (1) - 발열과 배터리
- 모바일 전략 (2) - 빌드와 품질 전략