目录
- 设计原则
- 静态单一赋值(SSA)
- 内建变量
- 特化
- 语言能力(Language Capabilities)
- 术语
- 一个SPIR-V模块与指令的物理布局
- OpenCL所支持的一些设备特性
- 移动端使用SPIR-V时需要注意的一些操作
SPIR-V的概述
SPIR-V着色器被嵌入在 模块 之中。每个模块可以包含一个或多个着色器。每个着色器具有一个入口点,该入口点具有一个名字和一个着色器类型,着色器类型用于定义当前着色器跑在哪个着色 阶段 。入口点则是当前着色器开始执行的位置。一个SPIR-V模块伴随着创建信息被传递给Vulkan,然后Vulkan返回表示该模块的一个对象。该模块对象随后可以用于构造一条 流水线。这就是一单个着色器完整编译的版本,伴随着在当前设备上要运行它所需要的信息。
SPIR-V的表示
SPIR-V对于Vulkan而言是仅有的官方支持的着色语言。它在API层被接受并且最终用于构造流水线,这些流水线是配置一个Vulkan设备的对象,为你的应用完成工作。
SPIR-V被设计为对一些工具和驱动而言非常容易处理的表示。这通过不同实现之间的多样性来提升可移植性。一个SPIR-V模块的内部表示是一条32位字的流,存放在存储器中。除非你是一位工具写手或计划自己生成SPIR-V,否则的话你不太需要直接处理SPIR-V的二进制编码。而是说,你要么可以看SPIR-V的可读的文本表示,或是使用诸如 glslangvalidator 这样的官方Khronos GLSL编译工具来生成SPIR-V。
下面我们可以写一个计算着色器源文件,命名为 simpleKernel.comp
,然后拿给 glslangvalidator 去编译。其中 .comp
后缀名能告诉 glslangvalidator 该着色器将作为一个计算着色器进行编译。
#version 460 core
void main(void)
{
// Do Nothing...
}
然后我们使用以下命令行(笔者用的是Windows环境):
%VK_SDK_PATH%/Bin/glslangValidator -o simpleKernel.spv -V100 simpleKernel.comp
随后我们就能看到生成了一个名为 simpleKernel.spv 的SPIR-V二进制文件。我们可以使用SPIR-V反汇编器对该二进制文件再进行反汇编。我们使用 spirv-dis 这一官方反汇编工具。
%VK_SDK_PATH%/Bin/spirv-dis -o simpleKernel.spvasm simpleKernel.spv
以上命令将会输出人可读的汇编文件 simpleKernel.spvasm。其内容如下:
; SPIR-V
; Version: 1.0
; Generator: Khronos Glslang Reference Front End; 10
; Bound: 6
; Schema: 0
OpCapability Shader
%1 = OpExtInstImport "GLSL.std.450"
OpMemoryModel Logical GLSL450
OpEntryPoint GLCompute %main "main"
OpExecutionMode %main LocalSize 1 1 1
OpSource GLSL 460
OpName %main "main"
%void = OpTypeVoid
%3 = OpTypeFunction %void
%main = OpFunction %void None %3
%5 = OpLabel
OpReturn
OpFunctionEnd
我们可以看到SPIR-V的文本形式看上去像一种古怪的汇编语言的变种。我们可以逐行过一下上述反汇编内容,然后看看它是如何与原始的GLSL输入相关联的。上面所输出汇编的每一行表示一单条SPIR-V指令,一条指令可能会由多个符号(token)构成。此外,分号(;
)开头的语句为一条注释。
流中的第一条指令是 OpCapability Shader
,它请求开启着色器能力。SPIR-V功能被粗略地划分为 指令 和 特征。在你的着色器能使用任一这些特征之前,必须声明它将使用哪种特征作为其一部分。上述代码中的着色器是一个图形着色器并从而使用 Shader 这一能力。随着我们介绍更多SPIR-V以及Vulkan功能,我们将会介绍每种特征所依赖的各种不同的能力。
接着,我们看到 %1 = OpExtInstImport "GLSL.std.450"
。这本质上是导入额外的一组相应于GLSL版本450所包含的功能,而这往往也是原始着色器所写入的。注意,这条指令最前面写有 %1 =
。这是对当前指令的结果赋上一个ID。OpExtInstImport
的结果在效果上是一个库。当我们想调用此库中的函数时,我们就使用 OpExtInst
这条指令来实现。它具有两个操作数,分别是一个库(OpExtInstImport
指令的结果)和一个指令索引。这允许SPIR-V指令集可被任意扩展。
接着,我们看到了某些额外的声明。OpMemoryModel
为此模块指定了工作存储器模型,在上述代码例子中是对应于GLSL版本450的逻辑存储器模型。这意味着所有存储器访问所有存储器访问通过资源执行而不是通过一个物理存储器模型。而物理存储器模型访问存储器时是直接通过指针。
接着是对当前模块的入口点的声明。OpEntryPoint GLCompute %main "main"
指令意味着有一个入口点对应于OpenGL计算着色器,所导出的函数名为 main,而这里所指定的ID为 %main
,该符号将会被 spirv-as 汇编器根据上下文来分配一个具体的ID号。从上述代码中我们看到已经分配了 %1
、%3
和 %5
,而唯独没有 %2
和 %4
。而这两个ID号很有可能最终在被编译的时候会被 %main
和 下面一个符号 %void
所分配。这里的函数名 "main"
用于引用入口点,当我们将所产生的着色器模块递回给Vulkan时需要此模块的入口点。
然后我们会在后一条指令中使用上面的 %main
这个符号—— OpExecutionMode %main LocalSize 1 1 1
定义了此着色器的执行组大小为 1×1×1 个工作项。如果局部大小 layout
修饰符缺省的话,那么GLSL会隐式指定local size为 1×1×1。
下面的两条指令只是简单地提供一些信息。OpSource GLSL 460
指明当前模块是用GLSL版本460进行编译的,而 OpName %main "main"
为具有ID为 %main
的符号提供了一个名字。
现在我们就可以来看看这个 main 函数真正有料的部分。首先,%void = OpTypeVoid
声明了我们想用 %void
这一符号作为类型 void
。正如前面所述,它最终可能会被分配的ID为2。在SPIR-V中所有一切都具有一个ID,甚至是类型定义。较大的聚合类型可以通过顺序地引用更小的、更简单的类型进行构造。然而,我们需要从某个地方开始,而这里将一个类型分配给 void
正是我们开始的地方。
%3 = OpTypeFunction %void
意味着我们定义一个为3的ID作为一个函数类型,该函数类型取了 void
作为其返回类型(先前声明为 void
)并且没有任何参数。我们在下面一行使用了该类型。%main = OpFunction %void None %3
这意味着我们声明了一个符号 %main
(最终被分配的ID可能为4,而我们之前用它命名了 "main"
)作为函数3(上面那条语句)的一个实例,它返回类型为 void
,并且没有其他特别的声明。这是通过指令中的 None
进行指示,而该位置上其他可用的参数包括是否内联、或是该变量是否为常量等等。
最后,我们看到对一个标签(标签没有被使用,而只是编译器操作所留下的副作用)、隐式的返回语句、以及最终函数结束的声明。这就是我们SPIR-V的末尾。
设计原则
- 规律性:所有指令均以指示当前指令有多长的一个字的个数开头。这允许SPIR-V模块不需要去解码每个操作码。所有指令都具有一个操作码来支配所有的操作数,确定它们属于哪种操作数。对于具有可变个数操作数的指令,可变操作数的个数通过将该指令的字的总个数减去非可变的字的个数得到。
- 非组合性:没有组合类型暴露,也不需要对类型的大型编码/译码表。取而代之的是,类型是参数化的。图像类型声明了它们的维数、阵列等等。一切都是正交的,这极大地简化了代码。这对于其他类型也是类似的。这也应用于操作码。操作对于标量/向量大小都是正交的,但对于整数与浮点数之间的区别并不是正交的。
- 无模型:当指定了一个给定的执行模型(比如流水线阶段)之后,内部操作本质上是无模型的:一般来说,它遵循这个规则:“同一个拼写具有相同的语义”,并从而不会具有修改语义的模式比特位。如果对SPIR-V的改变修改了语义,那么它应该是一种不同的拼写。这使得SPIR-V的消费者更为健壮。确实存在声明的执行模式,但这些通常影响了模块与其执行环境交互的方式,而不是其内部语义。也有能力被声明,但这是要声明要被使用的功能子集,而不是去改变要被使用的任何语义。
- 声明式的:SPIR-V声明了外部可见的模式,像“写深度”,而不是具有要求从完整着色器观察进行推导的规则。它也显式地声明了将要使用什么寻址模式、执行模型、扩展指令集等等。
- SSA:中间操作的所有结果都是严格的SSA。然而,在存储器中驻留的所声明的变量、以及用于访问的加载/存储,并且这样的变量可以被存储多次。
- IO:某些存储类是用于输入/输出(IO)的,并且从根本上,IO是通过在这些存储类中所声明的变量的加载/存储来完成的。
静态单一赋值(SSA)
SPIR-V包含了一条phi指令以允许将中间结果从分裂的控制流合并在一起。这允许控制流不需要加载/存储到存储器。SPIR-V在加载/存储的使用程度上是很灵活的;不用phi指令来使用控制流也是可能的,而通过用存储器的加载/存储仍然遵循着SSA形式。
某些存储类是用于IO的,而且在根本上IO是通过存储/加载实现的,而初始的加载和最终的存储不会被消除。其他存储类是着色器本地的,这可以将它们的加载/存储进行消除。我们可以认为对这种加载/存储通过将它们搬移到SSA形式的中间结果进行巨大的消除是一种优化。
内建变量
SPIR-V以一个枚举值装饰从一个高级语言来标识内建变量。这将任一不寻常的语义分配给该变量。内建变量除此以外以它们正确的SPIR-V类型进行声明,并且跟其他变量一样对待。
特化
特化允许基于常量值的一个可移植的SPIR-V模块进行离线创建,而此常量一开始是未知的,直到后面某个时间点。比如,在创建一个模块期间,具有一个常量固定大小数组,其常量值是未知的,从而该数组的具体大小未知。但当该模块被下放到目标架构时,该常量值就已知了。
语言能力(Language Capabilities)
一个SPIR-V模块由一个客户端API进行消费,该API需要支持被SPIR-V模块所使用的 特征(features)。这些特征通过 capabilities (能力)进行分类。由一个特定SPIR-V模块所使用的能力在此模块的开头部分用 OpCapability
指令进行声明。然后:
- 一个验证器可以验证当前模块仅使用它所声明的能力。
- 一个客户端API允许拒绝模块所声明的当前客户端API无法支持的能力。
所有可用的能力以及其依赖,形成了一个能力层级。这在 Capability 章节中详细列出。只有顶层能力需要被显式声明;它们的依赖可被隐式声明。
如果一条指令、一个枚举值、或其他特征指定了多个要开启的能力,那么只需要一个这样的能力需要被声明以使用该特征。这个声明其本身并不暗示任何有关其他所开启的能力的存在:执行环境仅需要所声明的能力。
SPIR-V规范提供了普遍适用的能力特定的验证规则,在 Validation Rules 章节中描述。此外,每个客户端API包括以下要求:
- 在“能力小节”中哪些能力它能支持或是要求支持,并从而允许在一个SPIR-V模块中允许使用。
- 任何其他它所具有的额外验证规则将越过由SPIR-V规范所指定的。
- 如果这些规则越过了普遍限制(Universal Limits),则要求约束。
术语
指令
<id>
:一个用数值所表示的名字;该名字用于引用一个对象、一个类型、一个函数、一个标签,等等。一个 <id>
总数占用一个 Word
的大小。由一个模块所定义的这些 <id>
遵循 SSA。
Result <id>
:大部分指令都会定义一个结果,用一个 <id>
显式命名,由当前指令提供。Result <id>
用作为其他指令的一个操作数来引用定义它的指令。
Literal
:一个立即数值,而不是一个 <id>
。大于一个 Word
大小的字面量消耗多个操作数,每个操作数占用一个字。一条指令陈述了该字面量要被解释为何种类型。一条字符串被解释为以一个空字符结尾的字符流。所有字符串比较都是大小写敏感的。而字符集用的是UTF-8编码模式。UTF-8的8比特字节被打包成每个 Word
放四个,遵循小端协定(即,第一个字节为该字的最低8位)。最后一个字包含了该字符串的空终结字符(0),并且最终字中超过该字符串的终结字符的所有内容用0来填充。对于一个数值字面量,低位字率先出现。如果一个数值类型的位宽少于32位,那么该值出现在字的低位序比特中,而高位序比特对于一个浮点类型、或是符号位为0的整数类型必须是0,而对于符号位为1的整数类型进行符号位(用1)扩展(类似于位宽的剩余比特大于32位,但不是32位比特的倍数情况)【译者注:比如3个半精度浮点数(f16vec3
)或是3个16位整数(i16vec3
或 u16vec3
),则需要2个字。而后一个字(即高位序的字)的高16位则需要用0或是符号位进行填充】。
Operand
:一条指令的单个字的实参。例如,它可以是一个 <id>
,或是一个 Literal(或是一部分)。它所持有的是哪种形式,通过操作码总是显式可知的。
WordCount
:一条指令所采用的 Word
的完整个数,包含持有字的个数以及操作码的字,以及任何可选的操作数。一条指令的字个数是该指令所要占用的总的存储空间。
Instruction
:在头部信息之后,一个模块后面就是一组简单的指令线性序列。一条指令包含了一个 WordCount
,一个操作码,一个可选的 Result <id>
,一个可选的表示指令类型的 <id>
,还有一组可变长度的操作数列表。所有指令的操作码和语义都列在了 Instructions 这一章节。
Decoration
:辅助信息,诸如内建变量、流个数、invariance
、插值类型、宽松精度,等等。这些通过 装饰 添加到各个 <id>
或结构体类型成员。装饰,在 Binary Form 章节中的 Decoration 部分进行罗列。
Object
:一个非 void
类型的实例化,要么作为一个操作的 Result <id>
,要么通过 OpVariable
进行创建。
Memory Object:
:通过 OpVariable
所创建的一个对象。这么一个对象仅存在于一个函数的期间,如果它是一个函数变量,而否则的话存在于一次调用的期间。
Memory Object Declaration
:一个 OpVariable
,或是一个指针类型的 OpFunctionParameter
,或是一个 OpVariable
的内容,它要么保持着一个指向 PhysicalStorageBuffer
存储类的指针,要么是这种指针的一个数组。
Intermediate Object 或 Intermediate Value 或 Intermediate Result
:由一个操作所创建的一个对象(并非 OpVariable
所分配的存储器)并在其最后消费中销毁。
Constant Instruction
:要么是一条特化常量指令,要么是一条非特化常量指令:以 OpConstant
或 OpSpec
开头的指令。
[a, b]:中括号记号意味着从 a 到 b 的范围,包含 a 和 b。圆括号排除了其端点值,从而比如,(a, b] 意味着 a 到 b,但排除 a 值而包含 b 值。
无语义指令:不具有语义影响的一条指令,从而可以从该模块被安全地移除。
类型
整数类型:由 OpTypeInt
所声明的任一宽度的带符号或无符号类型。由本协定规定,最低位比特被引用为比特序号0,而最高位比特被引用为比特需要 Width - 1。
浮点类型:由 OpTypeFloat
所声明的任一宽度的类型。
标量:一个 数值类型 或 布尔类型。当讨论它们自己或是在一个 向量 内容的上下文中,标量也被称为 分量(components)。
向量:由两个或更多 标量 所构成的一个有序的同构收集。向量大小是相当受限的并依赖于执行模型。
矩阵:由向量所构成的一个有序同构收集。形成一个矩阵的向量也被称为其 列。矩阵大小是相当受限的并依赖于执行模型。【译者注:各位请注意,无论是OpenGL还是SPIR-V,矩阵类型都是以列为主的,即 column-major。因而 matrix[0]
表示第0列。】
数组:由非 void
类型对象所构成的一个有序同构聚合。形成一个数组的对象也被称为其 元素(elements)。数组大小一般不受限。
结构体:任一非 void
类型的一个有序异构聚合。形成一个结构体的对象也被称为其 成员。
图像:一个传统的纹理或图像;SPIR-V对于这两者具有单一的名称。一个图像类型用 OpTypeImage
进行声明。一个图像并不包含有关如何访问、滤波、或是对它采样的任何信息。
采样器:描述如何访问、滤波、或采样一个 图像 的设置。要么来自设置的字面量声明,要么来自对外部绑定设置的一个不透明(opaque)引用。一个采样器并不包含一个 图像。
受采样的图像:与一个 采样器 相结合的一个 图像。开启对图像内容的滤波访问。
物理指针类型:一个 OpTypePointer
,其存储类根据寻址模式使用了物理寻址。
逻辑指针类型:并非一个 物理指针类型 的一种指针类型。
实体(concrete)类型:一个 数值 标量或向量,或是 矩阵类型 ,或是 物理指针类型 ,或是包含以上这些类型的聚合。
抽象(abstract)类型:一个 OpTypeVoid
或 OpTypeBool
,或是 逻辑指针类型 ,或是包含以上这些类型的聚合。
不透明(Opaque)类型:以下类型中所列出的一种类型,或是包含它的一种类型,或是指向它的指针类型,或是包含指向它的指针的类型:
OpTypeImage
OpTypeSampler
OpTypeSampledImage
OpTypeOpaque
OpTypeEvent
OpTypeDeviceEvent
OpTypeReserveId
OpTypeQueue
OpTypePipe
OpTypeForwardPointer
OpTypePipeStorage
OpTypeNamedBarrier
Variable Pointer:由以下指令的其中之一所获得的 逻辑指针类型 的一个指针:
OpSelect
OpPhi
OpFunctionCall
OpPtrAccessChain
OpLoad
OpConstantNull
此外,取一个可变指针作为一个操作数的任一 OpAccessChain
、OpInBoundsAccessChain
、或是 OpCopyObject
也产生一个可变指针。指针类型的一个 OpFunctionParameter
是一个可变指针,如果对该函数的任一 OpFunctionCall
静态地将一个可变指针作为形参值进行传递。
计算
余数:当用 b 去除 a (即:a / b)时,定义了一个余数 r 作为满足 r + q × b = a 的一个值。这里 q 是一个整数(商)并且 |r| < |b|。
模块
模块(Module):SPIR-V的一单个单元。它可以包含多个 入口点,但只能包含一组能力。
入口点(Entry Point):一个 模块 中的一个函数,程序执行从这里开始。一单个 入口点 被限制为一单个 执行模型。一个入口点使用 OpEntryPoint
来声明。
执行模型(Execution Model):一个图形流水线阶段(stage)或是 OpenCL内核(kernel)。这些都在执行模式中被枚举。
执行模式(Execution Mode):与当前接口或是当前模块的执行环境相关的操作模式。这些都在执行模式中被枚举。一般来说,模式并不改变一个SPIR-V模块内的指令语义。
顶点处理器(Vertex Processor):用于处理顶点的任一阶段或执行模型。包括:顶点(vertex)、细分曲面控制(tessellation control)、细分曲面计算(tessellation evaluation),和几何(geometry)。显式地排除了片段(fragment)和计算(compute)执行模型。
控制流(Control Flow)
代码块(Block):以一个 OpLabel
开头的一段连续的指令序列,以一条 代码块终结指令 结束。一个 代码块 没有额外的标签,也没有代码块终结指令。
函数终结指令(Function Termination Instruction):以下指令的一种,用于终结一个函数的执行:
OpReturn
OpReturnValue
OpKill
OpUnreachable
OpTerminateInvocation
分支指令(Branch Instruction):以下指令的一种,用作为一条 代码块终结指令:
OpBranch
OpBranchConditional
OpSwitch
代码块终结指令(Block Termination Instruction):以下指令中的一种,用于终结代码块:
控制流图(Control-Flow Graph):由一个函数的一个或多个代码块或分支所形成的一个图。这些代码块是图的节点(nodes),而分支则是图的边(edges)。
CFG:控制流图 的缩写。
一个SPIR-V模块与指令的物理布局
一个SPIR-V模块是一单串线性字(word)流。开头的一些字如下表所示:
表1:物理布局的起始字
字序号 | 内容 |
---|---|
0 | 魔术数——0x07230203 |
1 | 版本号。这些字节从高位序到低位序: 0 | 主版本号 | 小版本号 | 0 从而,版本 1.3 的值即为: 0x00'01'03'00 |
2 | 生成器的魔术数。它与生成该模块的工具相关联。其值并不影响任何语义,并且允许是0。使用一个非0值是值得鼓励的,并且可以在Khronos中注册。 |
3 | 边界;这里在本模块中的所有 <id> 确保满足:0 < id < Bound 边界应当尽量小,越小越好。一个模块中的所有 <id> 应当密集地打包并从而靠近0。 |
4 | 0(为指令模式而保留,如果需要的话) |
5 | 指令流的第一个字,见下表 |
所有剩下的字是一个线性的指令序列。每条指令的字流如下:
表2:指令物理布局
指令字序号 | 内容 |
---|---|
0 | 操作码:高16位是当前指令的字个数。低16位是操作码枚举值。 |
1 | 可选的指令类型 type <id> (是否存在以操作码来决定) |
- | 可选的指令结果 Result <id> (是否存在以操作码来决定) |
- | 操作数1(如果需要) |
- | 操作数2(如果需要) |
… | … |
字个数 - 1 | 操作数 N(N 由字个数减去 1到3个字来确定,这1到3个字用于操作码、指令类型 type <id> 、以及指令结果 Result <id> )。 |
指令是可变长度的,由于具有可选的指令类型 type <id>
和 Result <id>
字,以及可变个数的操作数。
OpenCL所支持的一些设备特性
- 设备端的队列(
queue_t
类型):OpTypeQueue
pipe
类型:OpTypePipe
、OpTypePipeStorage
移动端使用SPIR-V时需要注意的一些操作
由于移动端为了提升GPU的性能,同时降低GPU计算的功耗,通常会对浮点数、甚至整数会做一些精度调整,通过降低精度来提升计算性能。因此GLSL提供了 lowp
、mediump
、highp
这些精度限定符结合类型来声明对象。而SPIR-V中没有对类型限定做那么多的分级,而且规范中也明确提到,用 OpTypeFloat
声明的类型必须遵循IEEE754标准,如下图所示。
因此,如果是从GLSL翻译到SPIR-V时,一般会把精度限定符直接忽略掉,而默认使用最高的精度进行表示。
但正如前面已经提到的,移动端在执行某些算术计算的时候,为了提升计算性能而丢失一些精度,比如对于 FMA
乘加计算反而结果不如把乘法和加法分两步进行计算的结果精度要高。所以,SPIR-V提供了 NoContraction
这一Decoration用于指明当前的计算操作不能与另一个结合在一起。我们看下图官方说明。
这里举个例子。比如有一个乘法计算操作为 %123
,随后紧接着对它的计算结果再做加法计算:
%123 = OpFMul %float %100 %101
%124 = OpFAdd %float %122 %123
那么倘若我们事先使用了 OpDecorate %123 NoContraction
这句声明,那么这两个乘法和加法操作就不能被合并成一条 FMA 指令。
而当我们将SPIR-V编译为GLSL的话会看到,有 NoContraction
所修饰的计算操作的变量会使用 precise
关键字修饰(从 GL_ARB_gpu_shader5 扩展引入)。比如:
precise float _123;
precise uint _456;
所以,如果我们在移动端用Vulkan API做3D图形渲染的话,倘若遇到一些奇怪的图形现象,比如纹理颜色有些问题,甚至某些几何被莫名裁剪之类的,可以先查看一下是否计算精度有问题。
而与上述 NoContraction
相对的是,SPIR-V还提供了 RelaxedPrecision
这一Decoration,用于指示所修饰的计算操作可以用宽松的、牺牲一些精度的计算操作。规范中指出,该限定符当前只能用于修饰32位整数或32位浮点数的计算操作,而所执行的计算精度可以在16位到32位之间某个宽松的精度。比如:OpDecorate %123 RelaxedPrecision
。