특수화 상수
고친 사람 github-actions[bot]
특수화 상수는 셰이더 속 상수의 값을 셰이더가 그래픽 카드용 기계어로 바뀌기 직전에 정하게 해 줍니다. 셰이더를 한 번만 컴파일해 두고 값만 바꿔 여러 벌로 쓸 수 있습니다. 값이 정해지면 그래픽 드라이버가 그 값을 처음부터 박혀 있던 상수처럼 다룹니다. 그래서 화면을 그리는 동안 값을 확인하는 비용이 들지 않습니다.
쉽고 빠른 이해
무슨 일을 하나 — 셰이더 안 상수 하나에 기본값만 넣어 둡니다. 그 값은 그래픽 카드에 올리기 직전에 바꿔 끼울 수 있습니다. 흐림 효과에서 주변 픽셀을 몇 개 섞을지를 5로도 9로도 정할 수 있습니다.
왜 이렇게 하나 — 값마다 셰이더 파일을 따로 만들면 파일 수가 금방 불어납니다. 값을 실행 중에 읽게 하면 그래픽 카드가 매번 값을 확인해야 해서 느려집니다.
어떻게 도나
- 셰이더에서 상수에 번호와 기본값을 붙여 둡니다
- 애플리케이션이 그래픽 카드용 프로그램을 만들 때 번호마다 값을 넘깁니다. 이 그래픽 카드용 프로그램을 본문에서는 파이프라인이라 부릅니다
- 그래픽 카드 드라이버가 그 값을 넣은 채 코드를 다듬어 최종 기계어를 만듭니다
대가 — 값을 바꾸려면 그래픽 카드용 프로그램을 새로 만들어야 합니다. 만들 때마다 컴파일이 다시 돌아 시간이 듭니다. 그래서 품질 설정처럼 한 번 정하면 오래가는 값에 맞습니다. 매 프레임 바뀌는 값에는 안 맞습니다.
상세
명함 인쇄소의 디자인 원판에는 이름 칸에 견본 이름이 들어 있습니다. 주문이 들어오면 그 칸의 이름만 바꿔 찍습니다. 디자인을 사람마다 새로 그리지 않습니다. 찍혀 나온 명함에서는 이름을 고칠 수 없습니다.
이 절은 특수화 상수가 무엇이고 왜 필요한지를 셰이더가 그래픽 카드에 오르는 과정으로 보입니다. 이어서 값이 정해질 때 코드가 어떻게 바뀌는지, 다른 두 방법과 무엇이 다른지를 견줍니다. 끝에서는 값을 넘기는 코드와 쓰는 곳을 봅니다.
셰이더가 그래픽 카드에 오르기까지
셰이더는 GPU(Graphics Processing Unit, 그래픽 처리 장치)에서 도는 작은 프로그램입니다. GPU 는 그래픽 카드에 든 계산 장치입니다. 이 문서에서는 그래픽 카드와 GPU 를 같은 뜻으로 씁니다.
셰이더는 픽셀 하나의 색을 정하거나 꼭짓점 하나의 위치를 옮기는 일을 합니다. 같은 셰이더가 픽셀 수백만 개에 한꺼번에 돌기 때문에 셰이더 안의 작은 낭비도 크게 불어납니다.
셰이더는 두 번 컴파일됩니다. 첫 번째는 개발자의 기계에서, 두 번째는 사용자의 기계에서 일어납니다.
첫 컴파일은 개발자가 GLSL(OpenGL Shading Language) 같은 셰이더 언어로 쓴 코드를 SPIR-V 로 바꿉니다. SPIR-V 는 Standard Portable Intermediate Representation V 의 줄임말입니다. 어느 GPU 에서도 읽을 수 있게 만든 중간 표현입니다. 이 단계는 보통 게임이나 앱을 빌드할 때 끝납니다.
두 번째 컴파일은 그래픽 드라이버가 맡습니다. 그래픽 드라이버는 그래픽 카드를 다루려고 운영체제에 까는 프로그램입니다. 드라이버는 SPIR-V 를 그 GPU 가 바로 실행하는 기계어로 바꿉니다.
드라이버가 이 컴파일을 하는 때는 파이프라인을 만들 때입니다. 파이프라인은 셰이더 몇 개와 그리기 설정을 한 덩어리로 묶어 미리 만들어 두는 객체입니다.
특수화 상수는 이 두 컴파일 사이를 이용합니다. 첫 컴파일 때는 상수에 기본값만 넣어 둡니다. 그 값은 두 번째 컴파일 직전에 바꿔 끼울 수 있습니다. 앞의 명함으로 보면 SPIR-V 가 원판, 특수화 상수가 이름 칸, 파이프라인이 찍혀 나온 명함입니다.
아래 그림은 두 컴파일이 어느 기계에서 언제 일어나는지를 보입니다. 값을 넘기는 일은 사용자 기계에서 파이프라인을 만들 때 일어납니다.
flowchart TD
subgraph DEV["앱을 빌드할 때 · 개발자 기계"]
G["GLSL 소스 코드"] --> SV["SPIR-V · 상수엔 기본값만"]
end
subgraph USER["파이프라인을 만들 때 · 사용자 기계"]
F["특수화 상수 값 넘기기"] --> DR["그래픽 드라이버 컴파일"]
DR --> M["기계어"]
end
SV --> F
번호와 기본값을 단 상수
특수화 상수는 셰이더 코드에 상수로 선언하되 번호를 하나 붙인 것입니다. 이 번호가 애플리케이션 쪽에서 값을 넘길 때 짝을 찾는 열쇠입니다. 흐림 효과에서 섞을 픽셀 수를 이렇게 선언합니다.
layout(constant_id = 0)
const int TAPS = 5; // 기본값 5
constant_id = 0 이 번호입니다. 5 는 기본값입니다. 애플리케이션이 0번에 값을 안 넘기면 5 가 쓰입니다. 9 를 넘기면 이 셰이더는 처음부터 TAPS = 9 로 쓰인 것처럼 컴파일됩니다.
SPIR-V 로 바뀐 뒤에도 이 상수는 「바꿔 끼울 수 있는 상수」로 표시된 채 남습니다. 그래서 SPIR-V 파일 하나로 값이 다른 파이프라인을 여럿 만들 수 있습니다.
담을 수 있는 값은 참·거짓 값(bool), 정수, 실수 같은 하나짜리 값입니다. 구조체나 긴 배열을 통째로 넘기는 수단은 아닙니다.
값이 정해지면 바뀌는 코드
드라이버는 값을 받은 특수화 상수를 평범한 상수와 똑같이 봅니다. 그러면 컴파일러가 상수일 때만 할 수 있는 최적화를 합니다. 최적화는 실행 결과는 그대로 두고 코드를 더 빠르게 고치는 일입니다.
아래 셰이더에는 그림자 계산을 켜고 끄는 참·거짓 값 하나와 반복문 하나가 있습니다.
layout(constant_id = 1)
const bool SHADOWS = false;
if (SHADOWS) { // 늘 거짓
color *= shadow(uv); // 코드에서 빠짐
}
for (int i = 0; i < TAPS; i++) {
sum += tap(i); // 5번 펼쳐짐
}
SHADOWS 가 거짓으로 정해지면 if 안쪽은 절대 실행되지 않습니다. 컴파일러는 그 코드를 기계어에서 아예 지웁니다. 이 최적화를 데드 코드 제거라고 합니다. 분기 자체가 사라지므로 GPU 가 조건을 확인할 일도 없습니다.
반복 횟수 TAPS 도 5로 정해졌습니다. 컴파일러는 반복문을 같은 문장 다섯 줄로 펼칠 수 있습니다. 이렇게 하면 반복 횟수를 세고 비교하는 일이 사라집니다. 이 최적화가 루프 언롤링입니다.
상수끼리의 계산도 미리 끝납니다. TAPS * 2 라는 식이 있으면 컴파일러가 10 으로 바꿔 둡니다. 실행할 때 계산하지 않고 컴파일할 때 답을 넣는 이 최적화를 상수 접기라고 합니다.
값을 넘기는 다른 두 방법
셰이더 밖에서 값을 정하는 방법은 특수화 상수 말고 둘이 더 있습니다. 차이는 값이 언제 정해지느냐입니다.
첫째는 전처리기 매크로입니다. 셰이더 소스에 #define TAPS 5 를 넣고 첫 컴파일을 합니다. 값이 상수라 최적화는 똑같이 됩니다.
그런데 매크로를 쓰면 값마다 SPIR-V 파일이 하나씩 생깁니다. 켜고 끄는 기능이 열 개면 조합이 천 개를 넘습니다. 이렇게 파일 수가 불어나는 일을 셰이더 순열 폭발이라고 부릅니다.
둘째는 유니폼 버퍼(줄여서 유니폼)나 푸시 상수로 그릴 때마다 값을 넘기는 방법입니다. 파일은 하나입니다. 값도 언제든 바꿉니다. 대신 컴파일러가 값을 모르므로 분기와 반복문이 기계어에 그대로 남습니다. GPU 는 픽셀마다 값을 읽고 조건을 확인합니다.
| 전처리기 매크로 | 특수화 상수 | 유니폼 · 푸시 상수 | |
|---|---|---|---|
| 값이 정해지는 때 | 첫 컴파일 | 파이프라인을 만들 때 | 그릴 때마다 |
| SPIR-V 파일 수 | 값마다 하나 | 하나 | 하나 |
| 컴파일러가 값을 아나 | ✓ | ✓ | ✗ |
| 값을 바꾸는 비용 | 다시 빌드 | 파이프라인 새로 만들기 | 거의 없음 |
특수화 상수는 두 방법의 가운데에 섭니다. 파일은 하나로 둡니다. 그러면서 최적화는 상수만큼 받습니다.
값 한 벌에 파이프라인 하나
특수화 상수의 값은 파이프라인을 만들 때 넘기고, 그 뒤로는 바뀌지 않습니다. 값을 바꾸고 싶으면 같은 SPIR-V 로 파이프라인을 하나 더 만들어야 합니다. 아래 그림은 셰이더 소스 하나가 값 두 벌을 받아 기계어 두 벌이 되는 흐름입니다.
flowchart TD
S["셰이더 소스 · TAPS 기본값 5"] --> V["SPIR-V 파일 하나 · TAPS 바꿔 끼울 수 있음"]
V --> P1["파이프라인 만들기 · TAPS = 5"]
V --> P2["파이프라인 만들기 · TAPS = 9"]
P1 --> M1["기계어 · 다섯 번 펼친 반복문"]
P2 --> M2["기계어 · 아홉 번 펼친 반복문"]
파이프라인을 만들 때마다 드라이버의 컴파일이 다시 돕니다. 이 컴파일은 시간이 꽤 걸립니다. 화면을 그리는 도중에 하면 화면이 잠깐 멈칫합니다. 이 끊김을 스터터링이라고 부릅니다.
그래서 필요한 값 조합은 로딩 때 미리 만들어 둡니다. 컴파일 결과는 파이프라인 캐시에 모아 둡니다. 파이프라인 캐시는 드라이버가 컴파일한 결과를 담아 두는 객체입니다. 애플리케이션이 이것을 파일로 저장해 두면 다음 실행에서 컴파일을 건너뛸 수 있습니다.
이 성질이 쓰는 곳과 안 쓰는 곳을 가릅니다. 그래픽 품질 설정, 조명 개수 상한, 기능 켜고 끄기처럼 한 번 정하면 오래가는 값에 맞습니다. 시간이나 카메라 위치처럼 매 프레임 바뀌는 값에는 맞지 않습니다. 그런 값은 유니폼이나 푸시 상수로 넘깁니다.
애플리케이션에서 값을 넘기는 코드
Vulkan 은 GPU 를 다루는 그래픽스 API(Application Programming Interface, 응용 프로그램 인터페이스)의 하나입니다. 이 소절은 Vulkan 의 C 코드로 특수화 상수에 값을 넘기는 법을 봅니다.
Vulkan 에서는 값을 두 덩어리로 넘깁니다. 하나는 값들을 이어 붙인 바이트 묶음입니다. 다른 하나는 그 묶음의 어디부터 몇 바이트가 몇 번 상수인지 적은 표입니다. 먼저 표의 한 줄을 만듭니다.
int32_t taps = 9;
VkSpecializationMapEntry entry = {
.constantID = 0, // 셰이더의 0번
.offset = 0, // 묶음의 0바이트부터
.size = 4, // 4바이트
};
constantID 가 셰이더에 적은 constant_id 와 짝을 맞춥니다. offset 과 size 는 바이트 묶음에서 이 값이 차지하는 범위입니다. 상수가 여럿이면 이 줄을 여럿 만듭니다. 값도 묶음에 이어 붙입니다.
이 표와 바이트 묶음은 셰이더 단계 설정에 넣습니다. 셰이더 단계 설정은 파이프라인을 만들 때 넘기는, 셰이더 하나에 대한 설정입니다. 아래 코드의 stage 가 그것입니다. 표와 묶음을 VkSpecializationInfo 하나로 묶어 여기에 넣습니다.
VkSpecializationInfo info = {
.mapEntryCount = 1,
.pMapEntries = &entry,
.dataSize = sizeof taps,
.pData = &taps,
};
stage.pSpecializationInfo = &info;
pMapEntries 가 표를, pData 가 바이트 묶음을 가리킵니다. 꼭짓점을 옮기는 [[정점 셰이더]]와 픽셀 색을 정하는 [[프래그먼트 셰이더]]가 있으면 단계마다 값 묶음을 따로 줍니다. 그래서 같은 번호라도 단계가 다르면 다른 값을 줄 수 있습니다.
아래 그림은 상수가 둘일 때의 모양입니다. 0번 TAPS 와 1번 SHADOWS 를 함께 넘기면 표에 줄이 둘 생깁니다. 각 줄은 바이트 묶음에서 자기 범위를 가리킵니다.
flowchart TD
ST["셰이더 단계 설정 · pSpecializationInfo"] --> INFO["VkSpecializationInfo"]
subgraph T["표 · pMapEntries"]
E0["0번 · 0바이트부터 4바이트"]
E1["1번 · 4바이트부터 4바이트"]
end
subgraph B["바이트 묶음 · pData"]
D0["0~3바이트 · TAPS = 9"]
D1["4~7바이트 · SHADOWS = 참"]
end
INFO --> T
INFO --> B
E0 --> D0
E1 --> D1
반복 횟수 말고 자주 정하는 값
배열 길이에도 특수화 상수를 쓸 수 있습니다. float weights[TAPS]; 처럼 쓰면 배열 크기가 파이프라인을 만들 때 정해집니다. 유니폼으로는 이렇게 못 합니다. 배열 크기는 컴파일할 때 알아야 하기 때문입니다.
컴퓨트 셰이더는 그림 대신 일반 계산을 GPU 에 돌리는 셰이더입니다. 컴퓨트 셰이더에서는 작업 그룹 크기를 정하는 데 특수화 상수를 자주 씁니다.
작업 그룹은 GPU 가 한꺼번에 묶어 돌리는 스레드 무리입니다. 알맞은 크기는 GPU 마다 다릅니다. 그래서 크기를 특수화 상수로 남겨 둡니다. 값은 실행하는 기계를 보고 정합니다.
layout(local_size_x_id = 0) in; // 0번이 폭
local_size_x_id = 0 은 작업 그룹의 가로 크기를 0번 특수화 상수에서 가져오라는 뜻입니다. 0번에 64 를 넘기면 스레드 64개가 한 무리로 돕니다.
다른 API 의 같은 기능
같은 생각이 다른 그래픽스 API 에도 다른 이름으로 들어 있습니다. 모두 「셰이더는 하나로 두고, 값은 GPU 에 올리기 직전에 채운다」는 점이 같습니다. OpenGL 은 Vulkan 보다 먼저 나온 그래픽스 API 입니다.
| API | 이름 | 셰이더에서 쓰는 표기 |
|---|---|---|
| Vulkan · OpenGL | 특수화 상수 | layout(constant_id = N) |
| WebGPU | 파이프라인 재정의 상수 | WGSL(WebGPU Shading Language) 의 override |
| Metal | 함수 상수 | [[function_constant(N)]] |
관련 항목
특수화 상수를 담는 셰이더 언어와 형식
셰이더 · 셰이더 모듈 · SPIR-V · GLSL · HLSL · WGSL · 중간 표현 · 진입점 · 컴퓨트 셰이더 · 작업 그룹
특수화 상수의 값이 정해지는 파이프라인
파이프라인 상태 객체 · 그래픽스 파이프라인 · 컴퓨트 파이프라인 · 레이 트레이싱 파이프라인 · 파이프라인 캐시 · 파이프라인 라이브러리 · 셰이더 컴파일 · 그래픽 드라이버 · 스터터링
값을 받은 컴파일러가 하는 최적화
상수 접기 · 상수 전파 · 데드 코드 제거 · 루프 언롤링 · 컴파일러 최적화 · 기계어
셰이더에 값을 넘기는 다른 수단
유니폼 버퍼 · 푸시 상수 · 전처리기 · 셰이더 순열 · 우버 셰이더 · 셰이더 변형
다른 그래픽스 API 의 같은 기능
다른 이름: specialization constant · Specialization Constant · spec constant · 스페셜라이제이션 상수