SPIR-V
고친 사람 github-actions[bot]
SPIR-V 는 그래픽 카드에서 돌릴 프로그램을 숫자로 된 명령으로 미리 번역해 담습니다. 개발자는 빌드할 때 셰이더 소스를 SPIR-V 로 바꿔 둡니다. 프로그램은 실행할 때 소스 대신 그 결과를 드라이버에 건넵니다. 드라이버는 셰이더 언어의 문법을 해석하지 않아도 됩니다.
쉽고 빠른 이해
SPIR-V 는 그래픽 카드용 프로그램을 숫자 명령의 줄로 적어 두는 약속입니다. 픽셀 색을 정하는 셰이더 하나를 빌드하면 32비트 숫자가 줄줄이 이어진 덩어리가 나옵니다. 파일로 둘 때는 이름 끝에 .spv 를 붙이는 것이 관례입니다.
이게 없으면 드라이버마다 셰이더 언어의 컴파일러를 하나씩 품어야 합니다. 셰이더 언어는 SPIR-V 보다 빈틈없이 정해 두기 어렵습니다. 그래서 여러 드라이버가 똑같이 해석하게 맞추기도 어렵습니다.
- 빌드할 때 컴파일러가 셰이더 소스를 SPIR-V 로 바꿉니다
- 실행할 때 프로그램이 그 결과를 Vulkan 같은 그래픽 표준을 거쳐 드라이버에 넘깁니다
- 드라이버가 그것을 읽어 자기 그래픽 카드의 기계어로 마무리합니다
대가도 있습니다. 숫자의 줄이라 사람이 바로 못 읽습니다. 들여다보려면 도구로 글자로 되돌려야 합니다. 마지막 번역은 실행 중에 남아서 컴파일 시간이 다 없어지지는 않습니다.
상세
SPIR-V 가 무엇을 담는지와 왜 소스 대신 이것을 넘기는지를 여기서 끝냅니다. 따라갈 대상은 셰이더 하나가 소스에서 그래픽 카드의 기계어가 되기까지의 길입니다. 끝으로 SPIR-V 의 한 단위인 모듈과 SPIR-V 가 뒤를 이은 앞선 규격을 봅니다.
셰이더와 GPU 와 드라이버
그래픽 카드에 든 처리 장치를 GPU(Graphics Processing Unit, 그래픽 처리 장치)라고 부릅니다. GPU 에서 도는 프로그램 가운데 그림을 그리는 과정의 한 단계를 맡는 것이 셰이더입니다. 점의 위치를 옮기거나 픽셀 하나의 색을 정하는 일이 그런 단계입니다. 그림 없이 계산만 시키는 GPU 프로그램은 커널입니다.
사람은 셰이더를 GLSL(OpenGL Shading Language, OpenGL 셰이딩 언어) 같은 고수준 셰이더 언어로 씁니다. GPU 가 읽는 것은 그 GPU 의 기계어입니다. 기계어는 GPU 마다 다릅니다.
운영체제 안에서 그래픽 카드를 다루는 소프트웨어가 디바이스 드라이버입니다. 아래에서는 그냥 드라이버라고 적습니다. 셰이더를 그 GPU 의 기계어로 옮기는 마지막 번역은 드라이버가 맡습니다.
애플리케이션은 셰이더를 넘길 때 Vulkan · OpenGL · OpenCL 같은 API(Application Programming Interface, 응용 프로그램 인터페이스)를 부릅니다. 이렇게 셰이더를 받아 드라이버로 넘기는 API 를 클라이언트 API 라고 부릅니다.
소스와 기계어 사이의 이진 중간 언어
SPIR-V 는 그래픽 셰이더와 계산 커널을 담는 이진 중간 언어입니다. 중간 언어는 사람이 쓴 소스와 칩이 읽는 기계어 사이에 끼워 두는 표현입니다. 이진이라 글자가 아니라 32비트 숫자의 줄로 적습니다. SPIR-V 는 클라이언트 API 가 셰이더와 커널을 드라이버에 넘길 때 쓰는 형식입니다.
셰이더 하나는 이 길을 지납니다.
flowchart TD
A["셰이더 소스 · GLSL"] --> B["컴파일러 · 빌드할 때 돈다"]
B --> C["SPIR-V"]
C --> D["클라이언트 API · Vulkan · OpenGL · OpenCL"]
D --> E["드라이버 · 실행할 때 돈다"]
E --> F["그 GPU 의 기계어"]
위 두 칸은 개발자가 빌드할 때 끝냅니다. 아래 세 칸은 사용자의 기계에서 프로그램이 돌 때 지납니다. 배포에 들어가는 것은 가운데 SPIR-V 입니다. 셰이더나 커널의 소스를 배포물에 그대로 내보이지 않아도 됩니다.
컴파일의 앞 단계를 빌드로 옮긴 까닭
SPIR-V 의 목표 가운데 하나는 컴파일의 첫 단계들을 오프라인에서 끝내게 하는 것입니다. 오프라인은 프로그램이 도는 중이 아니라 그 전, 곧 빌드 때를 말합니다.
그러면 드라이버가 고수준 셰이더 언어의 컴파일러 앞단(프런트엔드)을 품을 필요가 없어집니다. 드라이버 안에서 글자를 쪼개고 문법을 해석하는 일이 모두 빠집니다. 드라이버는 그만큼 단순해집니다. 아래에서는 프런트엔드를 앞단으로만 적습니다.
만들어야 할 번역기의 수도 달라집니다. 하드웨어 플랫폼마다 자기 고수준 언어 번역기를 갖출 필요가 없습니다.
번역기는 고수준 언어를 SPIR-V 로 옮기는 쪽입니다. 뒷단은 SPIR-V 를 받아 그 GPU 의 기계어로 옮기는 쪽, 곧 드라이버 안의 컴파일러입니다. 번역기의 수는 뒷단의 수보다 훨씬 적을 수 있습니다. 둘이 어떻게 갈리는지는 아래 그림이 보여 줍니다.
flowchart TD
subgraph 앞["앞쪽 · 고수준 언어"]
L1["GLSL"]
L2["HLSL"]
L3["새 언어의 앞단"]
end
L1 --> S["SPIR-V · 가운데 하나"]
L2 --> S
L3 --> S
subgraph 뒤["뒤쪽 · GPU 마다 다른 드라이버"]
D1["드라이버 하나"]
D2["드라이버 둘"]
D3["드라이버 셋"]
end
S --> D1
S --> D2
S --> D3
그림의 HLSL(High-Level Shader Language, 고수준 셰이더 언어)은 GLSL 과 다른 셰이더 언어입니다. 새로운 고수준 언어도 SPIR-V 를 내는 앞단을 만들면 이 길에 올라탈 수 있습니다.
해석이 갈리는 문제도 줄어듭니다. 고수준 언어보다 SPIR-V 를 빈틈없이 적어 두기가 쉽습니다. 그래서 따로 만든 구현들이 같은 SPIR-V 를 똑같이 처리하게 맞추기도 그만큼 쉬워집니다.
모듈이라는 단위
SPIR-V 의 한 단위를 모듈이라고 부릅니다. 모듈 하나에 진입점이 여럿 들어갈 수 있습니다. 진입점은 실행이 시작되는 함수입니다. 진입점 하나는 실행 모델 하나에 묶입니다. 실행 모델은 그림 그리기의 한 단계이거나 OpenCL 커널입니다.
모듈 하나가 쓰는 기능의 목록은 하나뿐입니다. 이 목록은 「맞물림」에서 다룹니다.
모듈 안에서 타입 · 변수 · 함수 · 계산 결과는 저마다 번호를 하나씩 받습니다. 이 번호가 id 입니다. 명령이 만들어 낸 결과에 붙은 id 는 결과 id 입니다. 다른 명령은 값을 다시 적지 않고 이 id 로 그 결과를 가리킵니다.
이진 모듈을 사람이 읽는 글자 형태로 되돌린 것이 디스어셈블리입니다. 디스어셈블리는 id 앞에 % 를 붙여 %9 처럼 적습니다. 아래에서 % 가 붙은 번호는 전부 id 입니다.
모듈은 워드가 이어진 줄입니다. 워드는 32비트 크기의 수 하나입니다. 파일에 담을 때는 바이트의 줄로 저장합니다. 이진 모듈 파일 이름에는 .spv 를, 글자 형태로 적은 파일에는 .spvasm 을 붙이는 것이 관례입니다.
SPIR-V 가 뒤를 이은 앞선 규격
SPIR-V 는 크로노스 그룹(Khronos Group)이 정합니다. Vulkan · OpenGL · OpenCL 을 정하는 표준 단체입니다.
크로노스 그룹은 SPIR-V 전에 SPIR(OpenCL Standard Portable Intermediate Representation)를 먼저 냈습니다. SPIR 1.2 와 2.0 은 OpenCL C 언어로 쓴 프로그램을 LLVM IR(LLVM 컴파일러 기반의 Intermediate Representation, 중간 표현)로 옮깁니다. SPIR 는 OpenCL 장치 프로그램만 다뤘습니다.
SPIR-V 는 SPIR 를 고친 판이 아니라 새로 정의한 언어입니다. 그러면서 SPIR 의 뒤를 잇습니다. 받는 API 도 OpenCL 하나에서 Vulkan · OpenGL · OpenCL 로 넓어졌습니다. SPIR-V 의 설계는 LLVM IR 의 짜임에서 여러 면을 가져왔습니다.
SPIR-V 의 이름 전체를 풀어 쓴 공식 풀이는 확인되지 않았습니다(미확인). Standard Portable Intermediate Representation 은 앞선 SPIR 의 풀이입니다.
표현 한계
SPIR-V 모듈에는 담기지 않는 것과 모듈만으로는 정해지지 않는 것이 있습니다. 디버그 정보 · 원래 소스 · 기계어 · 받는 환경 · 수치 상한 다섯을 차례로 봅니다. 소리 없이 사라지는 것이 먼저입니다.
이름과 줄 번호
SPIR-V 는 타입 · 변수 · 함수 같은 거의 모든 것에 글자 이름을 붙일 수 있습니다. 소스의 파일 이름 · 줄 · 칸 같은 위치 정보도 명령 사이에 끼워 넣을 수 있습니다. 이런 것이 디버그 정보입니다.
디버그 정보를 싣는 명령은 셋으로 갈립니다.
| 무엇 | 명령 |
|---|---|
| 소스 코드 글자 | OpString · OpSource · OpSourceContinued |
| 이름 | OpName · OpMemberName |
| 줄 번호 | OpLine · OpNoLine |
이 명령을 모두 지워도 모듈의 뜻은 하나도 바뀌지 않습니다. 그래서 떼어 낸 모듈도 똑같이 돕니다. 대신 떼어 낸 모듈을 글자로 되돌리면 변수가 %9 같은 번호로만 남습니다. 이름은 오류 하나 없이 사라집니다.
바깥에서 보이는 이름만은 글자로 붙이는 것이 필수입니다. 「예시」의 진입점 줄 OpEntryPoint 는 이름 "main" 을 디버그 명령과 따로 직접 담습니다.
원래 소스
원래 소스는 디버그 정보로 함께 실을 수 있습니다. 실어도 떼어 낼 수 있으므로 모듈에 남는다는 보장은 없습니다.
소스를 떼어 낸 모듈에서 소스를 되살리려면 역공학을 거쳐야 합니다. SPIR-V 는 일부러 그만큼 낮은 수준으로 정해졌습니다. 도구 없이는 이진 모듈을 읽기도 고치기도 어렵습니다.
완성된 기계어가 아님
SPIR-V 는 GPU 가 바로 도는 기계어가 아닙니다. 레지스터 배정은 값을 칩 안의 어느 저장 칸에 둘지 정하는 일입니다. 명령 배치는 명령을 어떤 순서로 내보낼지 정하는 일입니다. 그 GPU 에 맞춘 이 두 일은 대개 실행 중에 도는 드라이버의 컴파일러 뒷단이 합니다.
SPIR-V 는 실행 중 컴파일 시간을 줄이는 것을 목표로 합니다. 그러나 실행 중 컴파일 시간의 대부분을 없애는 것은 목표가 아닙니다. 레지스터 배정과 명령 배치는 여전히 상당한 시간이 들 것으로 봅니다. 최적화도 일부만 오프라인에서 할 수 있습니다.
받는 환경
SPIR-V 하나만으로는 이 모듈이 어디서 받아들여지는지 정해지지 않습니다. 모듈을 받아들이는 규칙 전체는 SPIR-V 와 그 클라이언트 API 의 환경 명세를 합쳐야 나옵니다. 클라이언트 API 는 자기가 받는 SPIR-V 에 규칙 · 제한 · 기능을 더 얹습니다. 어떻게 얹는지는 「맞물림」에서 봅니다.
수치 상한
검사기(validator)는 모듈이 SPIR-V 의 규칙을 지키는지 확인하는 도구입니다. SPIR-V 는 모든 구현과 검사기가 적어도 받아야 하는 최소 상한을 둡니다. 구현은 더 큰 값을 받아도 됩니다. 클라이언트 API 가 최소 상한을 더 크게 정할 수도 있습니다. 검사기는 이 상한을 넘으면 알립니다.
| 대상 | 최소 상한 | 16진 |
|---|---|---|
| 문자열 리터럴 하나의 글자 수 | 65,535 | FFFF |
| 결과 id 의 상한 | 4,194,303 | 3FFFFF |
| 함수 선언 하나의 매개변수 수 | 255 | FF |
| 구조체 하나의 멤버 수 | 16,383 | 3FFF |
이 수를 넘는 모듈도 써 둘 수는 있습니다. 다만 모든 구현이 받아 준다는 보장이 이 선까지입니다.
명령 하나의 워드 수는 이 표에 없습니다. 워드 수는 첫 워드의 16비트 칸에 적혀 그 자체로 이미 제한되기 때문입니다.
예시
이 절은 실물 둘을 봅니다. 하나는 GLSL 셰이더 하나를 SPIR-V 로 바꾼 뒤 글자로 되돌린 모습입니다. 다른 하나는 명령 세 줄을 워드 값 그대로 16진수로 찍은 것입니다.
GLSL 프래그먼트 셰이더 하나
프래그먼트 셰이더는 픽셀 하나의 색을 정하는 셰이더입니다. 아래 소스는 조건에 따라 색을 고릅니다. 그다음 그 색에 multiplier 를 네 번 곱합니다.
#version 450
in vec4 color1;
in vec4 multiplier;
noperspective in vec4 color2;
out vec4 color;
struct S {
bool b;
vec4 v[5];
int i;
};
uniform blockName {
S s;
bool cond;
};
void main()
{
vec4 scale = vec4(1.0, 1.0, 2.0, 1.0);
if (cond)
color = color1 + s.v[2];
else
color = sqrt(color2) * scale;
for (int i = 0; i < 4; ++i)
color *= multiplier;
}
이 셰이더를 SPIR-V 로 바꾼 모듈은 이진이라 그대로는 못 읽습니다. 아래는 그 모듈의 디스어셈블리에서 몇 줄씩 골라 온 것입니다. 왼쪽의 %1 · %4 같은 꼬리표가 결과 id 입니다. 모듈 안에서는 32비트 숫자 하나입니다.
맨 위 다섯 줄은 헤더 다섯 워드를 풀어 적은 것입니다.
; Magic: 0x07230203 (SPIR-V)
; Version: 0x00010000 (Version: 1.0.0)
; Generator: 0x00080001 (Khronos Glslang Reference Front End; 1)
; Bound: 63
; Schema: 0
판은 1.0 입니다. 이 모듈을 만든 도구는 glslang 입니다. Bound 가 63 이므로 이 모듈의 id 는 모두 63 보다 작습니다. 실제로 가장 큰 id 는 %62 입니다.
헤더 바로 뒤 명령 다섯 줄입니다.
OpCapability Shader
%1 = OpExtInstImport "GLSL.std.450"
OpMemoryModel Logical GLSL450
OpEntryPoint Fragment %4 "main" %31 %33 %42 %57
OpExecutionMode %4 OriginLowerLeft
첫 줄은 이 모듈이 그래픽 셰이더 기능(Shader)을 쓴다고 밝힙니다. 둘째 줄은 sqrt 같은 함수가 든 확장 명령 집합 GLSL.std.450 을 불러와 %1 이라는 번호를 붙입니다.
셋째 줄은 메모리 모델입니다. 메모리 모델은 모듈이 메모리를 어떻게 다루는지 밝히는 선언입니다. 모듈마다 반드시 하나 있어야 합니다. 주소 지정 방식을 골라서 일반 포인터 연산을 쓸 수 있는지 아니면 메모리 접근이 순전히 논리적인지를 정합니다. 이 모듈은 Logical 과 GLSL450 을 골랐습니다.
넷째 줄은 진입점입니다. 프래그먼트 단계에서 함수 %4 를 "main" 이라는 이름으로 부릅니다. 뒤의 번호 넷은 이 진입점이 쓰는 입출력 변수입니다.
다섯째 줄은 실행 모드입니다. 실행 모드는 모듈이 실행 환경과 어떻게 맞물릴지 밝히는 선언입니다. 대개 모듈 안의 뜻은 바꾸지 않습니다. 이 줄의 OriginLowerLeft 가 그 선언입니다.
실행 모드는 진입점이 묶이는 실행 모델과 다른 것입니다. 실행 모델은 Fragment 처럼 진입점이 어느 단계인지를 말합니다. 실행 모드는 모듈이 실행 환경과 맞물리는 방식을 말합니다.
번호마다 사람이 읽을 이름을 붙인 디버그 정보 줄입니다.
OpName %4 "main"
OpName %9 "scale"
OpName %31 "color"
OpName %33 "color1"
OpName %42 "color2"
OpName %57 "multiplier"
이 줄들을 지워도 모듈은 똑같이 돕니다. 지우면 되돌린 글자에서 scale 같은 이름이 사라지고 번호만 남습니다.
소스의 vec4(1.0, 1.0, 2.0, 1.0) 은 타입과 상수 다섯 줄이 됩니다.
%6 = OpTypeFloat 32 ; 32-bit float
%7 = OpTypeVector %6 4 ; vec4
%10 = OpConstant %6 1
%11 = OpConstant %6 2
%12 = OpConstantComposite %7 %10 %10 %11 %10 ; vec4(1.0, 1.0, 2.0, 1.0)
32비트 실수 타입을 %6 으로 한 번 정합니다. 그 실수 넷을 묶은 벡터 타입이 %7 입니다. 상수 1 과 2 도 한 번씩만 만듭니다. 마지막 줄은 그 번호를 다시 가리켜 벡터 하나를 짓습니다. %10 을 세 번 가리킬 뿐 값 1 을 세 번 적지 않습니다.
소스의 else 갈래 color = sqrt(color2) * scale; 은 이렇게 됩니다.
%41 = OpLabel ; else
%43 = OpLoad %7 %42
%44 = OpExtInst %7 %1 Sqrt %43 ; extended instruction sqrt
%45 = OpLoad %7 %9
%46 = OpFMul %7 %44 %45
OpStore %31 %46
첫 줄 %41 = OpLabel 은 블록의 시작입니다. 블록은 함수 몸체를 이루는 명령 묶음입니다. 함수 하나는 블록들로 짜인 제어 흐름 그래프를 담습니다. 블록은 언제나 OpLabel 로 시작해 블록을 끝내는 명령으로 닫힙니다. 그 OpLabel 의 결과 id %41 이 이 블록의 이름표입니다.
나머지 줄은 계산입니다. 변수 color2(%42)를 읽어 %43 에 둡니다. %1 의 Sqrt 로 제곱근을 구합니다. scale(%9)을 읽어 두 값을 곱합니다. 결과를 color(%31)에 씁니다.
변수는 읽기(OpLoad)와 쓰기(OpStore)로 다룹니다. 계산 결과는 새 번호를 하나씩 받습니다. 한 번 번호가 붙은 결과에는 다른 값을 다시 넣지 않습니다. 이 방식을 SSA(Static Single Assignment, 정적 단일 할당)라고 부릅니다. 변수는 메모리에 있어서 여러 번 써도 됩니다.
명령 세 줄의 워드 값
이번에는 글자 세 줄과 그 줄들이 모듈 안에서 차지하는 워드 값을 나란히 봅니다.
%2 = OpTypeVoid
%3 = OpTypeFunction %2
%6 = OpTypeInt 32 0
0x00020013 0x00000002
0x00030021 0x00000003 0x00000002
0x00040015 0x00000006 0x00000020 0x00000000
한 줄이 명령 하나입니다. 줄마다 첫 워드의 위 16비트가 그 명령의 워드 수입니다. 0x0002 · 0x0003 · 0x0004 로 줄의 워드 개수와 맞습니다. 아래 16비트 0x0013 · 0x0021 · 0x0015 가 각 명령의 명령 코드입니다.
셋째 줄 %6 = OpTypeInt 32 0 을 워드마다 풀면 이렇습니다.
| 워드 | 값 | 뜻 |
|---|---|---|
| 0 | 0x00040015 |
위 16비트 0x0004 는 워드 넷짜리 명령 · 아래 16비트 0x0015 는 OpTypeInt |
| 1 | 0x00000006 |
이 명령의 결과 id %6 |
| 2 | 0x00000020 |
정수의 폭 32비트(16진 20 은 32) |
| 3 | 0x00000000 |
부호 표시 0 · 부호 없는 정수 |
둘째 줄의 마지막 워드 0x00000002 는 첫째 줄이 만든 %2 를 가리킵니다. 이름도 글자도 없이 번호 하나로 앞 명령의 결과를 씁니다.
형태
SPIR-V 모듈을 워드 번호 순서로 엽니다. 헤더 다섯 워드 · 명령 하나의 워드 배치 · 모듈 안에서 명령이 놓이는 차례 셋을 봅니다. 그 앞에 모듈을 세는 단위인 워드와 바이트 순서를 먼저 짚습니다.
워드와 바이트 순서
SPIR-V 의 단위는 32비트 워드입니다. 모듈은 바이트의 줄이 아니라 워드의 줄로 정의됩니다.
파일처럼 바이트의 줄로 저장할 때는 워드 하나를 바이트 넷으로 나눠 놓아야 합니다. 여러 바이트로 된 수를 어느 바이트부터 놓는지의 순서가 엔디언입니다. SPIR-V 헤더에는 엔디언을 적는 칸이 따로 없습니다. 모듈을 읽는 도구는 첫 워드의 매직 넘버 0x07230203 이 어떤 바이트 순서로 읽히는지를 보고 엔디언을 알아낼 수 있습니다.
Vulkan 과 OpenCL 은 넘겨받은 모듈을 그 기계의 엔디언으로 된 32비트 워드의 줄로 읽습니다.
헤더 다섯 워드
모듈은 헤더 다섯 워드로 시작합니다. 여섯째 워드부터 끝까지는 명령만 이어집니다.
block-beta columns 5 h0["0 · 매직 넘버"] h1["1 · 판 번호"] h2["2 · 생성기 번호"] h3["3 · Bound"] h4["4 · 예약 0"] i1["5 부터 · 명령 · 명령 · 명령 · 모듈 끝까지"]:5
| 워드 | 담는 것 | 앞 예시의 값 |
|---|---|---|
| 0 | 매직 넘버. SPIR-V 모듈임을 알리는 고정 값 | 0x07230203 |
| 1 | 판 번호. 위 바이트부터 0 · 주 번호 · 부 번호 · 0 |
0x00010000 (1.0) |
| 2 | 생성기 번호. 모듈을 만든 도구. 뜻에 영향이 없고 0 이어도 된다 | 0x00080001 (glslang) |
| 3 | Bound. 모듈의 모든 id 가 0 < id < Bound 를 지킨다 |
63 |
| 4 | 0. 명령 스키마를 위해 비워 둔다 | 0 |
판 번호 워드는 주 번호와 부 번호만 담습니다. 1.3 판은 0x00010300 입니다. 판 번호의 세 번째 수인 리비전은 모듈에 적지 않습니다.
주 번호는 1 로 고정돼 있습니다. 부 번호가 바뀐 판에서는 기능이 더해지거나 폐기되거나 빠졌습니다. 리비전만 바뀐 판은 설명을 다듬거나 오류를 고쳤습니다. 리비전에서 기능을 폐기 예고하더라도 빼지는 않았습니다.
Bound 는 작게 잡기를 권하는 값입니다. id 를 0 근처에 빽빽하게 매기면 Bound 가 작아집니다.
명령 하나의 워드 배치
헤더 뒤는 길이가 제각각인 명령의 줄입니다. 명령마다 첫 워드가 32비트를 반으로 나눠 씁니다.
packet-beta 0-15: "명령 코드 · 아래 16비트" 16-31: "워드 수 · 위 16비트"
워드 수는 첫 워드까지 포함한 그 명령 전체의 워드 개수입니다. 나머지 워드는 이렇게 이어집니다.
| 명령 안의 워드 | 담는 것 |
|---|---|
| 0 | 워드 수(위 16비트)와 명령 코드(아래 16비트) |
| 1 | 결과 타입 id. 명령 코드에 따라 있거나 없다 |
| 다음 | 결과 id. 명령 코드에 따라 있거나 없다 |
| 그다음부터 | 피연산자 1 · 2 · … N |
명령 하나는 피연산자보다 앞에 워드 한 개에서 세 개를 먼저 둡니다. 명령 코드 · 결과 타입 id · 결과 id 가 그 앞 워드입니다. 피연산자 수 N 은 워드 수에서 그 앞 워드 개수를 뺀 값입니다.
피연산자 하나는 워드 하나입니다. 피연산자가 id 인지 값인지는 명령 코드가 정합니다. 앞 예시의 %12 = OpConstantComposite %7 %10 %10 %11 %10 이면 결과 타입 id 가 %7, 결과 id 가 %12, 피연산자가 넷입니다.
모든 명령이 워드 수로 시작하므로 읽는 도구는 명령 코드를 해석하지 않고도 모듈 끝까지 걸어갈 수 있습니다. 도구는 자기가 처리하지 않아도 되는 명령을 건너뛰기를 권합니다. 모르는 명령을 만나도 그 워드 수만큼 건너뛰면 다음 명령입니다. 그래서 기존 도구를 깨지 않고 새 명령을 더하기가 쉬워집니다.
block-beta columns 10 a["명령 · 3워드"]:3 b["모르는 명령 · 5워드"]:5 c["명령 · 2워드"]:2 space:3 d["워드 수 5 만큼 건너뛴다"]:5 space:2
글자 문자열은 길이를 따로 적지 않습니다. UTF-8(Unicode Transformation Format 8-bit) 바이트를 워드 하나에 넷씩 담습니다. 첫 바이트가 워드의 가장 낮은 8비트에 갑니다.
packet-beta 0-7: "첫 바이트" 8-15: "둘째 바이트" 16-23: "셋째 바이트" 24-31: "넷째 바이트"
문자열 끝에는 값 0 인 바이트를 둡니다. 마지막 워드의 남은 칸은 0 으로 채웁니다.
block-beta columns 5 l0["워드 하나"] a["바이트"] b["바이트"] c["바이트"] d["바이트"] l1["끝 워드"] e["바이트"] f["nul 0"] g["0 채움"] h["0 채움"]
32비트를 넘는 수 리터럴은 낮은 자릿수의 워드가 먼저 옵니다.
명령이 놓이는 차례
모듈 안의 명령은 정해진 차례를 지켜야 합니다. 함수 정의보다 앞선 구간에는 그 구간에 정한 명령 말고는 놓을 수 없습니다.
| 차례 | 명령 | 앞 예시의 줄 |
|---|---|---|
| 1 | 쓰는 기능 선언 OpCapability 전부 |
OpCapability Shader |
| 2 | 확장 선언 OpExtension (있으면) |
없음 |
| 3 | 확장 명령 집합 불러오기 OpExtInstImport (있으면) |
%1 = OpExtInstImport "GLSL.std.450" |
| 4 | 메모리 모델 OpMemoryModel 하나 |
OpMemoryModel Logical GLSL450 |
| 5 | 진입점 OpEntryPoint 전부 |
OpEntryPoint Fragment %4 "main" … |
| 6 | 실행 모드 OpExecutionMode 전부 |
OpExecutionMode %4 OriginLowerLeft |
| 7 | 디버그 명령 — 소스 · 이름 · 처리 기록 순 | OpSource GLSL 450 · OpName %4 "main" |
| 8 | 데코레이션 명령 | OpDecorate %18 Block |
| 9 | 타입 · 상수 · 전역 변수 | %2 = OpTypeVoid … |
| 10 | 몸체 없는 함수 선언 | 없음 |
| 11 | 몸체 있는 함수 정의 | %4 = OpFunction %2 None %3 … |
데코레이션 명령은 이미 선언한 타입이나 변수에 성질을 덧붙이는 주석 명령입니다. 앞 예시의 OpDecorate %18 Block 이 그런 줄입니다. 블록 이름표는 앞 예시의 %41 = OpLabel 처럼 블록을 여는 OpLabel 의 결과 id 입니다.
9번 구간의 명령은 쓰는 피연산자가 모두 앞에서 선언돼 있어야 합니다. 앞에서 아직 안 나온 id 를 가리키는 일은 몇 가지 경우에만 허용됩니다. 진입점이 적는 id 목록 · 함수 · 블록 이름표 · 데코레이션 명령의 피연산자가 그런 경우입니다. 그런 경우가 몇 가지 더 있습니다. 어느 경우든 타입 정보가 충분해서 모듈을 앞에서 한 번 훑는 것만으로 변환할 수 있습니다.
맞물림
SPIR-V 모듈을 사이에 두고 크로노스 그룹의 다른 규격과 도구가 어느 방향으로 붙는지 봅니다. 모듈이 앞머리에 밝히는 기능 선언 · 모듈을 받는 클라이언트 API 셋 · 모듈을 만들고 검사하고 되돌리는 도구 셋이 그 상대입니다.
flowchart TD
G["glslang · 셰이더 소스를 번역한다"] --> M["SPIR-V 모듈"]
M --> A["클라이언트 API · Vulkan · OpenGL · OpenCL"]
M --> T["SPIRV-Tools · 검사와 최적화"]
T --> M
M --> X["SPIRV-Cross · 읽어서 되돌린다"]
X --> S["GLSL · HLSL · MSL 소스"]
모듈을 받는 쪽은 클라이언트 API 입니다. SPIRV-Tools 로 검사와 최적화를 거치는 길은 그 옆에 따로 있습니다. SPIRV-Cross 는 방향이 거꾸로라 모듈에서 소스 쪽으로 나갑니다. 그림의 MSL(Metal Shading Language)은 GLSL · HLSL 과 또 다른 셰이더 언어입니다.
기능 선언과 환경 명세
모듈이 쓰는 기능은 capability 라는 묶음으로 갈립니다. 모듈은 앞머리의 OpCapability 명령으로 자기가 쓰는 기능을 밝힙니다. 앞 예시의 OpCapability Shader 가 그 선언입니다. 상위 기능 하나만 밝히면 그 기능이 기대는 하위 기능은 함께 밝힌 것으로 칩니다.
이 선언을 둘이 읽습니다. 검사기는 모듈이 밝힌 기능만 쓰는지 확인할 수 있습니다. 클라이언트 API 는 자기가 지원하지 않는 기능을 밝힌 모듈을 거부해도 됩니다.
모듈을 받는 규칙은 클라이언트 API 마다 따로 정합니다. 클라이언트 API 는 어느 기능을 지원하거나 요구하는지 · SPIR-V 규칙 위에 더 얹는 검사 규칙 · 더 큰 최소 상한을 자기 환경 명세에 적습니다. 그래서 같은 모듈이 한 클라이언트 API 에서는 받아들여지고 다른 API 에서는 거부될 수 있습니다.
Vulkan 의 셰이더 모듈
Vulkan 은 vkCreateShaderModule 호출로 모듈을 받습니다. 애플리케이션은 VkShaderModuleCreateInfo 의 pCode 에 모듈의 시작 주소를, codeSize 에 바이트 크기를 넣습니다. SPIR-V 이면 codeSize 는 4 의 배수여야 합니다. Vulkan 1.4 구현은 SPIR-V 1.0 부터 1.6 까지 받아야 합니다.
받을 수 있는 기능은 Vulkan 의 SPIR-V 환경 부록이 표로 정합니다. 모듈은 Shader 기능을 밝혀야 합니다. 표의 기능은 조건을 채울 때만 쓸 수 있습니다. 해당 Vulkan 기능이나 확장을 켰거나 장치가 해당 Vulkan 판을 지원하는 경우가 그렇습니다. 조건은 몇 가지 더 있습니다.
GLSL 소스를 Vulkan 에 직접 넘기는 길은 엔비디아 확장 VK_NV_glsl_shader 하나뿐이었습니다. 이 확장을 켜지 않았으면 pCode 는 SPIR-V 여야 합니다.
이 확장은 비준되지 않았습니다. 대체 없이 폐기됐습니다. 이 일은 Vulkan 의 범위 밖이라 glslang 같은 컴파일러 라이브러리가 맡는 편이 낫다는 것이 폐기 사유입니다. 그래서 셰이더를 SPIR-V 로 바꾸는 일은 애플리케이션 쪽이 맡습니다.
OpenGL 의 ShaderBinary
OpenGL 은 셰이더 소스 글자를 ShaderSource 로 받아 왔습니다. SPIR-V 모듈을 받는 길은 확장 GL_ARB_gl_spirv 가 열었습니다. 이 확장은 2016년 7월 22일 비준됐습니다. OpenGL 3.3 이상을 요구합니다. 2017년 7월 31일에 나온 OpenGL 4.6 은 이 확장을 새 기능 목록에 넣었습니다.
애플리케이션은 ShaderBinary 에 형식 값 SHADER_BINARY_FORMAT_SPIR_V 와 모듈을 넘깁니다. 그다음 SpecializeShader 를 반드시 불러야 합니다. 이 호출이 모듈에서 그 단계의 진입점 이름을 고릅니다. 모듈 안 특수화 상수의 값도 이 호출에서 정합니다. 특수화 상수는 모듈을 넘겨받은 뒤에 값을 정하는 상수입니다.
OpenGL 은 모듈이 이미 검사를 통과했다고 기대합니다. 잘못된 곳을 발견하면 오류를 낼 수 있습니다. 잘못된 모듈은 정의되지 않은 동작으로 이어져도 됩니다.
OpenCL 의 SPIR-V 환경
OpenCL 은 SPIR-V 환경 명세를 따로 둡니다. 이 문서가 OpenCL 이 반드시 지원할 기능 · 일부 명령에 더하는 뜻 · 모듈이 지킬 추가 검사 규칙을 정합니다.
OpenCL 이 받는 모듈은 계산 커널을 담습니다. SPIR-V 1.0 을 지원하는 OpenCL 환경은 Kernel · Addresses · Linkage · Int8 · Int16 같은 기능을 밝힌 모듈을 받아야 합니다. 그래픽 셰이더 모듈이 밝히는 Shader 와는 다른 기능 묶음입니다.
받는 판은 OpenCL 판에 매여 있습니다.
| OpenCL 판 · 장치 | 받아야 하는 SPIR-V 판 |
|---|---|
OpenCL 2.1 · cl_khr_il_program 확장 지원 장치 |
1.0 |
| OpenCL 2.2 | 1.0 · 1.1 · 1.2 |
| OpenCL 3.0 | 요구 없음 |
| OpenCL 3.1 | 1.0 ~ 1.4 |
OpenCL 3.0 장치는 SPIR-V 를 아예 안 받아도 됩니다. 커널을 SPIR-V 로만 배포하면 그런 장치에서는 받아들여지지 않을 수 있습니다.
glslang · SPIRV-Tools · SPIRV-Cross
모듈을 만드는 도구로는 glslang 이 있습니다. glslang 은 크로노스 그룹의 참조 앞단입니다. GLSL 소스를 AST(Abstract Syntax Tree, 추상 구문 트리)로 읽은 뒤 그 트리를 SPIR-V 로 옮깁니다. 앞 예시 헤더의 생성기 번호 0x00080001 이 glslang 을 가리킵니다.
glslang 의 HLSL 앞단은 2026년 4월에 폐기 예고됐습니다. glslang 의 다음 메이저 버전에서 빠질 예정입니다. 예고 뒤로 적어도 18개월을 남겨 둡니다.
SPIRV-Tools 는 모듈을 다루는 도구 묶음입니다. 어셈블러 spirv-as 는 글자 형태를 이진 모듈로 바꿉니다. 디스어셈블러 spirv-dis 는 그 반대입니다. 검사기와 최적화기도 들어 있습니다.
모듈을 만들거나 바꾸는 도구는 자기 출력을 검사기로 확인하기를 권합니다. 모듈을 받는 도구도 잘못된 입력을 막으려고 검사기를 쓸 수 있습니다. 다만 검사기는 구현한 규칙을 어겼을 때만 오류를 냅니다. 통과했다고 모든 규칙을 지켰다는 뜻은 아닙니다.
SPIRV-Cross 는 모듈을 읽는 쪽입니다. SPIR-V 모듈을 읽어 GLSL · HLSL · MSL 소스로 바꿉니다. 모듈의 짜임 정보를 읽어 내는 리플렉션도 합니다. 그 결과를 JSON(JavaScript Object Notation) 형식으로 뽑을 수 있습니다.
출력은 사람이 쓴 것처럼 읽히도록 애씁니다. 그래도 원래 소스를 되살린 것은 아닙니다.
관련 항목
앞에서 번역해 넣는 셰이더 언어
셰이더 · 셰이더 언어 · GLSL · HLSL · WGSL · MSL
모듈을 받는 클라이언트 API
Vulkan · OpenGL · OpenCL · 클라이언트 API
모듈을 만들고 검사하고 되돌리는 도구
셰이더 컴파일 · glslang · SPIRV-Tools · SPIRV-Cross · 컴파일러 · 크로스 컴파일 · 어셈블러 · 디스어셈블러
모듈을 이루는 구성 요소
워드 · 매직 넘버 · 엔디언 · 진입점 · 정적 단일 할당 · 제어 흐름 그래프 · 기본 블록 · 특수화 상수 · 확장 명령 집합
모듈이 지나가는 실행 경로
GPU · 디바이스 드라이버 · 그래픽스 파이프라인 · 정점 셰이더 · 프래그먼트 셰이더 · 지오메트리 셰이더 · 컴퓨트 셰이더
이 규격이 속하는 상위 분류
중간 표현 · 바이트코드 · 가상 명령어 집합 · 포맷
같은 몫을 다른 형태로 푸는 이웃
DXIL · Direct3D · Metal · WebGPU
이 규격이 뒤를 이은 앞선 규격과 그 바탕
SPIR · LLVM IR · OpenCL C
규격을 세운 단체와 그 이웃 규격
크로노스 그룹 · OpenGL ES · glTF · EGL
다른 이름: SPIRV · spir-v · Standard Portable Intermediate Representation · SPIR-V 모듈