Vulkan编程指南翻译 第六章 着色器和管线 第3节 管线

来源:互联网 发布:rpc java 编辑:程序博客网 时间:2024/05/01 17:15

6.3  管线

如你在前面小节所读到的,Vulkan使用着色器module来表示一系列的着色器程序通过把module代码交给vkCreateShaderModule()可以创建着色器module,但是,在它们可以在设备上被用来工作之前,你需要创建管线。在Vulkan中有两种管线:计算和图形。图形管线相对复杂,且包含许多和着色器相关的状态。然而,计算管线在概念上简单多了,且出来着色器代码也不包含其他的什么东西。

 

6.3.1 计算管线

在我们讨论创建计算管线之前,我们应该讲讲计算管线的基础知识。着色器和它的执行时Vulkan的核心。Vulkan也提供了对各种功能的固定块大小的访问,比如复制和处理像素数据。然而,着色器将会是任何有意义程序的核心。

计算着色器提供了对Vulkan设备计算能力的直接访问。设备可以被视为许多处理相关数据的宽向量处理单元的集合。一个计算着色器一般被认为会连续的,单轨道的执行。然而,有办法可以让多个轨道上一起指向。实际上,这也是大多数Vulkan设备如何被构造的。每一个执行轨道被称为一个调用。

当计算着色器被执行时,许多调用马上开始。这些调用被子和到一个固定大小的本地工作组,然后,一个或多个组被一起发射,它有时被称为全局工作组的。逻辑上,本地工作组和全局工作组都是三维的。然而,设定三维的任一维度的大小减少了组的维度。

本地工作组的大小在计算着色器内部设置。在GLSL中,这是通过使用layout限定符做到的,限定符被翻译为传递给Vulkan的着色器的OpExecutionMod描述符上的LocalSize修饰符。Listing 6.3展示了在着色器中使用的大小修饰符,Listing 6.4展示了生成的精简的SPIR-V反汇编代码

Listing 6.3: Local Size Declaration in a Compute Shader (GLSL)

#version 450 core

layout (local_size_x = 4, local_size_y = 5, local_size_z 6) in;

void main(void)

{

// Do nothing.

}

 

Listing 6.4: Local Size Declaration in a Compute Shader (SPIR-V)

...

OpCapability Shader

%1 = OpExtInstImport "GLSL.std.450"

OpMemoryModel Logical GLSL450

OpEntryPoint GLCompute %4 "main"

OpExecutionMode %4 LocalSize 4 5 6

OpSource GLSL 450

...

你可以看到,Listing 6.4OpExecutionMode指令设置着色器的本地大小为 {4, 5, 6},这在Listing 6.3中指定。

一个着色器程序的本地工作组的最大个数由调用vkGetPhysicalDeviceProperties()可以获取VkPhysicalDeviceLimits的数据的maxComputeWorkGroupSize域指定,在第一章“Vulkan简介”中解释过。还有,本地工作组的最大调用数之和也是同一个类型数据的maxComputeWorkGroupInvocations域指定的。一个Vulkan实现也许会拒绝超过这些限制条件的SPIR-V着色器,即使只是会出现一些未知的运行结果而已。

 

6.3.2 创建管线

可调用vkCreateComputePipelines()来创建一个或多个管线,函数原型如下:

VkResult vkCreateComputePipelines (

VkDevice device,

VkPipelineCache pipelineCache,

uint32_t createInfoCount,

const VkComputePipelineCreateInfo* pCreateInfos,

const VkAllocationCallbacks* pAllocator,

VkPipeline* pPipelines);

vkCreateComputePipelines()的参数device,就是负责使用管线并扶着分配管线对象的设备。pipelineCache是用来加速管线创建的一个对象的handle,在本章稍后涉及到。创建一个新管线的参数信息通过一个VkComputePipelineCreateInfo类型的数据表示。该数据结构的个数(亦即需要创建的管线的个数)通过createInfoCount传入,这写数据组成的数组的地址通过pCreateInfos传入。VkComputePipelineCreateInfo的定义如下:

 

typedef struct VkComputePipelineCreateInfo {

VkStructureType sType;

const void* pNext;

VkPipelineCreateFlags flags;

VkPipelineShaderStageCreateInfo stage;

VkPipelineLayout layout;

VkPipeline basePipelineHandle;

int32_t basePipelineIndex;

} VkComputePipelineCreateInfo;

VkComputePipelineCreateInfosType应置为VK_STRUCTURE_TYPE_COMPUTE_PIPELINE_CREATE_INFOpNext应置为nullptrflags域保留使用,在当前的Vulkan版本中应置为0stage域是一个嵌入式的结构,包含着色器本身的信息,它是VkPipelineShaderStageCreateInfo类型的一个实例,定义如下:

typedef struct VkPipelineShaderStageCreateInfo {

VkStructureType sType;

const void* pNext;

VkPipelineShaderStageCreateFlags flags;

VkShaderStageFlagBits stage;

VkShaderModule module;

const char* pName;

const VkSpecializationInfo* pSpecializationInfo;

} VkPipelineShaderStageCreateInfo;

VkPipelineShaderStageCreateInfosTypeVK_STRUCTURE_TYPE_PIPELINE_SHADER_STAGE_CREATE_INFOpNext应置为nullptrflags域保留使用,在当前的Vulkan版本中应置为0

VkPipelineShaderStageCreateInfo类型数据在所有阶段的管线创建中都使用。尽管图形管线拥有多个阶段(在第七章“图形管线”中学到),计算管线只有一个阶段,所以,stage应置为VK_SHADER_STAGE_COMPUTE_BIT

module是之前创建的着色器modulehandle,它包含了你想要创建的管线所需的代码。因为单一一个着色器module可以包含多个入口点和多个着色器,表示这个特别的管线的入口点通过VkPipelineShaderStageCreateInfopName域指定。这也是少数的几个被Vulkan使用的人可断的字符串。

 

6.3.3 特殊常量

VkPipelineShaderStageCreateInfo最后一个域是一个指向VkSpecializationInfo类型数据的指针。这个结构包含特化一个着色器所需的信息,“特化”是指包含常量的着色器构造的过程。

一个典型的Vulkan实现会管线代码的最终生成,直到调用vkCreateComputePipelines()函数。

这允许特化常量的值在着色器优化的最后一个pass中才被求值。常见使用和特化的程序使用的常量包含:

Producing special cases through branching: Including a condition on a Boolean specialization

constant will result in the final shader taking only one branch of the if statement. The nontaken

branch will probably be optimized away. If you have two similar versions of a shader that differ

in only a couple of places, this is a good way to merge them into one.

Special cases through switch statements: Likewise, using an integer specialization constant as

the tested variable in a switch statement will result in only one of the cases ever being taken in

that particular pipeline. Again, most Vulkan implementations will optimize out all the nevertaken

cases.

Unrolling loops: Using an integer specialization constant as the iteration count in a for loop

may result in the Vulkan implementation making better decisions about how to unroll the loop

or whether to unroll it at all. For example, if the loop counter ends up with a value of 1, then the

loop goes away and its body becomes straight-line code. A small loop iteration count might

result in the compiler unrolling the loop exactly that number of times. A larger iteration count

may result in the compiler unrolling the loop by a factor of the count and then looping over that

unrolled section a smaller number of times.

Constant folding: Subexpressions involving specialization constants can be folded just as with

any other constant. In particular, expressions involving multiple specialization constants may

fold into a single constant.

Operator simplification: Trivial operations such as adding zero or multiplying by one disappear,

multiplying by negative one can be absorbed into additions turning them to subtractions,

multiplying by small integers such as two can be turned into additions or even absorbed into

other operations, and so on.

 

GLSL中,特化常量被声明为一个普通的常量,在一个layout限定符中被给予一个ID。在GLSL中特化常量可以是Boolean,整型,浮点类型或者诸如数组、结构、向量、矩阵等复合类型。当被翻译为SPIR-V时,这些都变成了OpSpecConstant tokenListing 6.5展示了一个GLSL声明了一些特化常量的例子,Listing 6.6展示了GLSL编译器生成的SPIR-V

Listing 6.5

layout (constant_id = 0) const int numThings = 42;

layout (constant_id = 1) const float thingScale = 4.2f;

layout (constant_id = 2) const bool doThat = false;

 

Listing 6.6

...

OpDecorate %7 SpecId 0

OpDecorate %9 SpecId 1

OpDecorate %11 SpecId 2

%6 = OpTypeInt 32 1

%7 = OpSpecConstant %6 42

%8 = OpTypeFloat 32

%9 = OpSpecConstant %8 4.2

%10 = OpTypeBool

%11 = OpSpecConstantFalse %10

...

 

Listing 6.6 被编辑过,删除了和特化常量的无关的代码。然而,你可以看到,, %7OpSpecConstant指令声明为一个特化常量,类型为% 6(一个32位的整型), 初始值为42。下一行,% 9被声明为一个特化常量,类型为% 832位浮点类型),初始值为4.2.最后,%11被声明为一个Boolean类型(在这个SPIR-V中类型为%10),初始值为false。注意,Boolean型被OpSpecConstantTrue OpSpecConstantFalse声明,取决于他们的初始值为true还是false

注意,在GLSL着色器和生成的SPIR-V着色器中,特化常量都被赋予了初值。实际上,它们必须要被赋初值。这些常量也许和着色器中其他常量一样的被使用。特殊情况下,它们可以被用来指定数组大小--只允许编译时常量能被使用。如果新的值没有被包含在传递给vkCreateComputePipelines()VkSpecializationInfo类型的数据中,那么这些默认值就被使用。然而,这些常量可以被创建管线时传递的新值覆盖。VkSpecializationInfo定义为:

typedef struct VkSpecializationInfo {

uint32_t mapEntryCount;

const VkSpecializationMapEntry* pMapEntries;

size_t dataSize;

const void* pData;

} VkSpecializationInfo;

VkSpecializationInfo内部,mapEntryCount包含了需要被设置新值的特化常量的个数,这也是pMapEntries所指向的VkSpecializationMapEntry类型数组元素的个数。每一个都表示一个特化常量。VkSpecializationMapEntry定义为:

typedef struct VkSpecializationMapEntry {

uint32_t constantID;

uint32_t offset;

size_t size;

} VkSpecializationMapEntry;

constantID是特化常量的ID,被用来匹配着色器module中使用常量ID。在GLSL中通过使用constant_id布局限定符来设置值,在SPIR-V中使用SpecID描述符来设置值。Offsetsize域是包含特化常量的值的原生数据的偏移量和大小。VkSpecializationInfo类型数据的pData域指向了原生数据,数据大小通过dataSize.给定。Vulkan使用此数据来初始化特化常量。当管线被构造时,如果着色器中一个或者多个特化常量没有在该数据中指定,它就会使用默认值。

当你使用完了管线并不再需要它时,可以销毁它来释放它关联的资源。可调用vkDestroyPipeline()来销毁管线对象,函数原型如下:

void vkDestroyPipeline (

VkDevice device,

VkPipeline pipeline,

const VkAllocationCallbacks* pAllocator);

 

拥有管线的设备通过device指定,需要被销毁的管线通过pipeline传递。如果在创建管线是使用了主机内存分配器,那么需要使用pAllocator来传递一个匹配的分配器;否则,pAllocator应置为nullptr

在管线被销毁后,它不应该再被使用了。这包含可能还没有完成执行的命令缓冲区中的任何对它的引用。应用程序有责任保证任何提交的引用到该管线的命令缓冲区已经完成执行,和绑定该管线的任何命令缓冲区在管线被销毁后不被提交。

 

6.3.4 加速管线的创建

创建管线可能是你的应用程序最昂贵的操作。尽管SPIR-V代码被vkCreateShaderModule()消耗,但是直到你调用vkCreateGraphicsPipelines()vkCreateComputePipelines()Vulkan才能看到所有的着色器阶段和其他的域管线相关能影响到最终在设备上运行的代码的阶段。因为此原因,一个Vulkan实现也许会延迟涉及创建一个准备执行的管线对象的工作,直到尽可能最后一刻。这包括着色器编译和代码生成,这些是典型的相当密集型的操作。

因为应用程序运行很多次也只是使用一遍又一遍的使用相同的管线,Vulkan提供了一个多次运行时缓存管线创建结果的机制。这允许应用程序在启动时就构建所有的管线来迅速启动。管线缓存通过下面函数创建的一个对象表示,

VkResult vkCreatePipelineCache (

VkDevice device,

const VkPipelineCacheCreateInfo* pCreateInfo,

const VkAllocationCallbacks * pAllocator,

VkPipelineCache* pPipelineCache);

 

用来创建管线缓存的设备由device指定。创建管线缓存所需的剩下的参数通过一个VkPipelineCacheCreateInfo类型数据的指针传递。该类型定义如下:

typedef struct VkPipelineCacheCreateInfo {

VkStructureType sType;

const void * pNext;

VkPipelineCacheCreateFlags flags;

size_t initialDataSize;

const void * pInitialData;

} VkPipelineCacheCreateInfo;

VkPipelineCacheCreateInfosType域应置为VK_STRUCTURE_TYPE_PIPELINE_CACHE_CREATE_INFOpNext应置为nullptrflags域保留使用,应置为0。如果存在程序上一次运行产生的数据,数据的地址可以通过pInitialData传递。数据的大小通过initialDataSize传递。如果没有数据,initialDataSize应置为0pInitialData应置为nullptr

当创建了缓存之后,初始出具用来填充缓存。如果有必要,Vulkan会复制一份数据。pInitialData指向的数据没有被修改。当更多的管线创建时,描述它们的数据被添加到缓存,随着时间增长。可以调用vkGetPipelineCacheData()从缓存中取出数据。其原型如下:

VkResult vkGetPipelineCacheData (

VkDevice device,

VkPipelineCache pipelineCache,

size_t* pDataSize,

void* pData);

 

拥有该管线缓存的设备通过device指定,需被获取数据的管线缓存应通过pipelineCache传递。如果pData不是nullptr,那么他指向将接受缓存数据的内存区域。这种情况下,pDataSize指向的变量的初始值是内存区域以字节为单位的大小。这个变量就会被数据的真实大小所覆盖。

如果pDatanullptr,那么pDataSize所指向的变量初始值就被忽略,变量被覆盖为用来存储缓存数据的内存的大小。可以调用vkGetPipelineCacheData()两次来存储所有的缓存数据;第一次,设置pDatanullptrpDataSize指向一个接受缓存数据大小的变量。然后,创建一个缓冲区来存储生成的缓存数据并再次调用vkGetPipelineCacheData(),这一次向pData传递一个指向这个地址区域的指针。Listing 6.7 举例说明如何保存管线数据到文件。

Listing 6.7: Saving Pipeline Cache Data to a File

VkResult SaveCacheToFile(VkDevice device, VkPipelineCache cache,

const char* fileName)

{

size_t cacheDataSize;

VkResult result = VK_SUCCESS;

// Determine the size of the cache data.

result = vkGetPipelineCacheData(device,

cache,

&cacheDataSize,

nullptr);

if (result == VK_SUCCESS && cacheDataSize != 0)

{

FILE* pOutputFile;

void* pData;

// Allocate a temporary store for the cache data.

result = VK_ERROR_OUT_OF_HOST_MEMORY;

pData = malloc(cacheDataSize);

if (pData != nullptr)

{

// Retrieve the actual data from the cache.

result = vkGetPipelineCacheData(device,

cache,

&cacheDataSize,

pData);

if (result == VK_SUCCESS)

{

// Open the file and write the data to it.

pOutputFile = fopen(fileName, "wb");

if (pOutputFile != nullptr)

{

fwrite(pData, 1, cacheDataSize, pOutputFile);

fclose(pOutputFile);

}

free(pData);

}

}

}

return result;

}

 

一旦你接收了管线数据,你可以把它存储到磁盘或者打包以供再次运行程序时使用。缓存的内容没有预定的结构,这是有实现决定的。然而,缓存数据的前几个word形成的头部可用来验证大块的数据是否为有效的缓存和哪个设备创建了它。

缓存头部的布局可以使用下面的C结构体来表示:

// This structure does not exist in official headers but is included here

// for illustration.

typedef struct VkPipelineCacheHeader {

uint32_t length;

uint32_t version;

uint32_t vendorID;

uint32_t deviceID;

uint8_t uuid[16];

} VkPipelineCacheHeader;

尽管结构体的成员都是uint32_t 类型变量,缓存的数据并不必要是uint32_t 类型的。缓存总是以小端排序的,不管主机的字节排序是那种。这意味着如果你想在大端排序的主机上解释这个结构,你需要翻转uint32_t类型域的排序。

lenth域是头部结构的大小,以字节为单位。在当前的技术规范版本中,这个长度应该为32version域是结构的版本。已定义的版本只有1vendorIDdeviceID域应该和通过调用vkGetPhysicalDeviceProperties()返回的VkPhysicalDeviceProperties结构的vendorID

deviceID域匹配。uuid域是一个不透明的字符串类型,用来唯一标识这个GPU设备。如果vendorID, deviceID, uuid域有一个不匹配Vulkan驱动期望的值,那么它会拒绝缓存数据并把它置为空。一个驱动也许会内置摘要加密或其他数据到缓存里,来保证无效的缓存数据不会加载到设备。

如果你有两个缓存对象并希望那个融合它们,可调用vkMergePipelineCaches()来完成,其原型如下:

VkResult vkMergePipelineCaches (

VkDevice device,

VkPipelineCache dstCache,

uint32_t srcCacheCount,

const VkPipelineCache* pSrcCaches);

device参数是拥有被融合缓存数据的设备的handledstCache是目标缓存的handle,它最终会变成源缓存数组中每一条的合体。将被融合的缓存的个数通过srcCacheCount指定,pSrcCaches是一个指向VkPipelineCache类型数组的指针,数组中每一个handle是需要被融合的缓存。

vkMergePipelineCaches()执行后,dstCache将包含pSrcCaches所指定的源缓存数组中每一条缓存。然后才能调用vkGetPipelineCacheData()来获取一个单一的、大型的缓存数据结构,它可表示其他所有的缓存。

这一点特别有用,例如,在多线程创建管线时。尽管对管线缓存的访问是线程安全 的,Vulkan实现也许在内部采用锁来防止对多个缓存的同时写入。如果你创建多个管线缓存--每线程一个--并在管线的创建初始时使用它们。稍后,当管线都被创建完,你可以合并多个管线,以把它们的数据存储在一个大型的资源里。

当你使用完管线并不再需要很长,就需要销毁它,因为它可能会很大。可调用vkDestroyPipelineCache()来销毁管线缓存对象,其原型是:

void vkDestroyPipelineCache (

VkDevice device,

VkPipelineCache pipelineCache,

const VkAllocationCallbacks* pAllocator);

 

device是拥有管线缓存的设备handlepipelineCache是需要被销毁的管线缓存对象。在管线缓存被销毁后,它不应该再把使用了,尽管用缓存创建的管线依然有效。通过调用vkGetPipelineCacheData()从缓存中获取到的任何数据也还是有效的,可以用来构建新的缓存。

 

6.3.5  绑定管线

在你可使用管线之前,它必须保额绑定到执行互指或分发命令的命令缓冲区。当一个命令被执行是,当前的管线(即其中所有的着色器程序)被用来处理这个命令。可调用vkCmdBindPipeline()来把管线绑定到一个命令缓冲区,其原型如下:

void vkCmdBindPipeline (

VkCommandBuffer commandBuffer,

VkPipelineBindPoint pipelineBindPoint,

VkPipeline pipeline);

正在绑定的命令缓冲区通过commandBuffer指定,被绑定的管线通过pipeline指定。在每一个命令缓冲区上有两个管线绑定点:图形和计算绑定点。计算绑定点是计算管线应该绑定的点。图形管线在下一章讲解,它应被绑定到图形管线绑定点。

把管线绑定到计算绑定点,需设置pipelineBindPointVK_PIPELINE_BIND_POINT_COMPUTE;把管线绑定到图形管线绑定点,应设置pipelineBindPointVK_PIPELINE_BIND_POINT_GRAPHICS

当前与计算和图形管线的绑定是命令缓冲区的一个状态。当新的命令缓冲区开始时,这个状态是未定义的。因此,你必须在管线被用来工作前把管线绑定到一个绑定点。

 

 

1 0