06-编译Slang代码

本篇将看看如何编译Slang代码,通过命令行和C++API两种方式。

编译Slang代码

概念

在使用Slang的编译系统之前,有必要了解一些关键概念。

源代码单元

可以是磁盘上的代码文件,也可以是内存中的代码字符串。

翻译单元/模块

源代码单元被分组为不同的翻译单元,每个翻译单元会在编译时产生一个单独的模块。

翻译单元在经过编译后会形成一个Slang内部的中间语言,并序列化为.slang-module二进制文件。这个二进制文件可被C++APIISession::loadMOduleFromIRBlob()或Slang代码的import导入。

程序入口

一个翻译单元/模块可能包含0个或更多个程序入口。Slang支持两个模型,以便在编译时区分程序入口:

  • 程序入口标识(Entry Point Attributes):默认情况下,编译器会先在函数声明中扫描标识[shader(...)]的函数,它们将会被认定为程序入口。推荐这种做法。
  • 显式入口标识选项:也能在着色器源代码外显式配置程序入口,这是为了兼容性才考虑的。

着色器参数

一个翻译单元/模块可能包含0个或更多个全局着色器参数。类似的,每个程序入口可能定义0个或更多个uniform着色器参数。这些着色器参数描述了CPU程序和GPU代码的数据交互,了解它们的内存布局和通过CPU-API注册的形式很重要。

目标平台

就是编译的目标平台,一个目标平台应包含如下信息:

  • 代码生成的格式:SPIR-V,DXIL等等;
  • 代码Profile:限定目标的语言特性,如 D3D 的 Shader Model 5.1,GLSL的version 4.60等等;
  • 可选的兼容性拓展:例如特定的Vulkan GLSL拓展;
  • 影响代码生成的选项:例如浮点数严格程度,Debug信息生成层级等等;

Slang支持在同一编译会话中编译多个平台。这需要我们了解编译前端和后端的概念:

  • 编译前端:编译器前端包括预处理、解析和语义检查。前端为每个翻译单元运行一次,其结果在所有目标平台之间共享。
  • 编译后端:生成代码,在每个目标平台上跑一次。

根据编译前端的特性,如果代码中包含平台特定的#define,需要对每个平台均按不同定义编译一次。

组件

组件类型是着色器代码组合的一个单元;Modules 和 Entry Points 都是组件类型的示例。 复合组件类型由其他组件类型列表(例如,一个模块和两个入口点)组成,可用于定义要一起使用的着色器代码单元。

一旦程序员形成了他们打算一起使用的所有代码的组合,他们就可以查询该组合中着色器参数的布局,或调用链接步骤 解析所有跨模块引用。

链接

用户编写的程序可能具有可传递的模块依赖关系和模块边界之间的交叉引用。Slang 中的链接步骤是解析 IR 中的所有交叉引用并生成一个 新的独立 IR 模块,具有生成目标代码所需的一切。用户将有机会专门化预编译模块或提供额外的编译器后端选项在 链接 步骤中。

内核代码

链接程序后,用户可以请求为入口点生成内核代码。 相同的入口点可用于生成许多不同的内核。 首先,可以为不同的目标编译一个入口点,从而为每个目标生成不同格式的内核。 其次,着色器代码的不同组合会导致不同的布局,从而导致需要不同的内核。

命令行编译

slangc用于通过命令行编译Slang代码,它的所有参数的用法在这里可以查到。

例子

单平台

// hello-world.slang
StructuredBuffer<float> buffer0;
StructuredBuffer<float> buffer1;
RWStructuredBuffer<float> result;

[shader("compute")]
[numthreads(1,1,1)]
void computeMain(uint3 threadId : SV_DispatchThreadID)
{
    uint index = threadId.x;
    result[index] = buffer0[index] + buffer1[index];
}

以上面的代码为例,将其编译为SPIR-V的命令行代码如下:

slangc hello-world.slang -target spirv -o hello-world.spv

一些平台需要额外的参数,例如HLSL的:

slangc hello-world.slang -target hlsl -entry computeMain -o hello-world.hlsl

这是因为slangc目前无法自动推导除了SPIR-V,Metal,CUDA,Optix外的着色器程序入口(-entry),阶段(-stage)和类型(-profile)。

多平台多入口点

// targets.slang

struct VertexOutput
{
    nointerpolation int a : SOME_VALUE;
    float3              b : SV_Position;
};

[shader("pixel")]
float4 psMain() : SV_Target
{
    return float4(1, 0, 0, 1);
}

[shader("vertex")]
VertexOutput vsMain()
{
    VertexOutput out;
    out.a = 0;
    out.b = float4(0, 1, 0, 1);
    return out;
}

上例中,一个源文件里有两个程序入口。可以只通过一条命令将一个程序入口编译到SPIR-V Assembly和HLSL两个平台上:

slangc targets.slang -entry psMain \
-target spirv-asm -o targets.spv-asm \
-target hlsl -o targets.hlsl

这条命令将两个入口点都编译为SPIR-V形式:

slangc targets.slang -entry vsMain -entry psMain \
-target spirv -o targets.spv

CPU可执行程序

Slang也能生成C++代码,详见教程对应部分。

额外选项

还有一些额外选项:

  • -D<name>-D<name>=<value>:用于引入预处理宏;

  • -I<Path>:引入搜索路径,用于#includeimport

  • -g:输出包含更详细debug信息的文件;

    不过似乎只能给人看,程序读了会崩溃。

  • -O<level>:Slang触发下游代码生成器时的优化程度;

预编译模块

可以将一个.slang文件预编译为一个二进制IR文件:

slangc my_library.slang -o my_library.slang-module

这样就不必向用户公开此.slang的源代码,并且用户也能通过import使用该库。

限制

slangc还有些限制,它无法访问到Slang提供的一些特性:

  • Slang提供反射系统,可以反射着色器参数和布局给程序运行时。目前该特性还不能被slangc输出。
  • Slang允许应用程序控制着色器模块和程序入口的组成,而slangc只是为生成着色器代码提供了默认组成方式。

使用C++API

虽然比使用slangc复杂了些,但提供了更完全的控制。

创建全局会话

全局会话使用接口slang::IGlobalSession,代表CPU应用程序和Slang API实现的连接。可用slang::createGlobalSession()简历一个全局会话:

Slang::ComPtr<slang::IGlobalSession> globalSession;
SlangGlobalSessionDesc desc = {};
slang::createGlobalSession(&desc, globalSession.writeRef());

BYD,目前Slang库的命名空间一团糟,有的是slang::,有的是Slang::,还有的甚至没有命名空间限制。难绷了,官方说他们目前没空管这个问题,只能等后续更新。

当一个全局会话被创建后,Slang系统会加载内部的Core模块给用户使用。这个过程耗时可能会很长,因此建议一个应用程序中只建立一个全局会话。但目前为止,全局会话不是线程安全的,多线程环境下需要注意线程安全。

此外,如果想要兼容GLSL,需要将SlangGlobalSessionDesc::enableGLSL设置为true。否则在编译GLSL代码时会出错。

要想销毁全局会话,需要 确保从它上创建的对象全部被析构掉

创建会话

会话使用接口slang::ISession,代表用于执行各种编译指令。同一个会话下的所有编译操作会共享:

  • 启用编译的目标列表(和编译选项);
  • 包含路径列表;
  • 预定义宏列表;

并且会话还提供了加载与重用模块的功能。在相同会话内,不必担心模块的重加载问题。要想创建一个会话,需要使用IGlobalSession::createSession()函数:

slang::SessionDesc sessionDesc;
/* ... 填写sessionDesc信息... */
Slang::ComPtr<slang::ISession> session;
globalSession->createSession(sessionDesc, session.writeRef());

其中,结构体slang::SessionDesc的定义如下:

struct SessionDesc
{
    /** The size of this structure, in bytes.
     */
    size_t structureSize = sizeof(SessionDesc);

    /** Code generation targets to include in the session.
     */
    TargetDesc const* targets = nullptr;
    SlangInt targetCount = 0;

    /** Flags to configure the session.
     */
    SessionFlags flags = kSessionFlags_None;

    /** Default layout to assume for variables with matrix types.
     */
    SlangMatrixLayoutMode defaultMatrixLayoutMode = SLANG_MATRIX_LAYOUT_ROW_MAJOR;

    /** Paths to use when searching for `#include`d or `import`ed files.
     */
    char const* const* searchPaths = nullptr;
    SlangInt searchPathCount = 0;

    PreprocessorMacroDesc const* preprocessorMacros = nullptr;
    SlangInt preprocessorMacroCount = 0;

    ISlangFileSystem* fileSystem = nullptr;

    bool enableEffectAnnotations = false;
    bool allowGLSLSyntax = false;

    /** Pointer to an array of compiler option entries, whose size is compilerOptionEntryCount.
     */
    CompilerOptionEntry* compilerOptionEntries = nullptr;

    /** Number of additional compiler option entries.
     */
    uint32_t compilerOptionEntryCount = 0;
};

在该结构体中,用户可以设置一些常用编译选项,例如搜索路径searchPath和预处理宏preprocessorMacros。接下来看看常用编译选项:

目标平台

slang::SessionDesc::targets数组用于描述应用程序想要在会话中支持的目标平台,通常只有一个。

每个目标平台由slang::TargetDesc结构体描述,它包含控制目标平台代码生成的一些选项。最重要的成员就是formatprofile了,其他的都可以留默认值不管:

  • format:应该属于SlangCompileTarget枚举类型:

    TargetDesc targetDesc;
    targetDesc.format = SLANG_SPIRV;
  • profile:必须是Slang编译器支持的profile的ID。由于ID并不是稳定的值,需要通过IGlobalSession::findProfile()来查表:

    targetDesc.profile = globalSession->findProfile("glsl_450");

初始化好目标平台后,就能赋值给SessionDesc了:

sessionDesc.targets = &targetDesc;
sessionDesc.targetCount = 1;

搜索路径

使用#includeimport时搜索的路径:

const char* searchPaths[] = { "myapp/shaders/" };
sessionDesc.searchPaths = searchPaths;
sessionDesc.searchPathCount = 1;

预定义宏

用于预处理阶段的预定义宏,结构是名字-值对:

PreprocessorMacroDesc fancyFlag = { "ENABLE_FANCY_FEATURE", "1" };
sessionDesc.preprocessorMacros = &fancyFlag;
sessionDesc.preprocessorMacroCount = 1;

附加设置

一些附加设置可在compilerOptionEntries中访问,它是CompilerOptionEntry类型的数组,是一组编译器选项键值对。详见这里

加载模块

将Slang代码添加到会话中,最简单的方法就是使用ISession::loadModule()

IModule* module = session->loadModule("MyShaders");

这行代码类似Slang中的import MyShaders,会话会搜索匹配模块的文件,将其载入并编译。loadModule()并不会对特定模块有特定的编译选项,它将完全遵循会话中设置的编译选项。

需要注意的是,使用loadModule()时,要确保被加载的slang文件中的程序入口(如果有的话)加上[shader(...)]标记。

程序入口

当模块被加载后,可以通过IModule::findEntryPointByName()查询程序入口:

Slang::ComPtr<IEntryPoint> computeEntryPoint;
module->findEntryPointByName("myComputeMain", computeEntryPoint.writeRef());

组件

应用程序可能会用loadModule()加载任意数量的模块,并且这些模块可能包含任意数量的程序入口点。在GPU段代码生成前,决定要一起应用哪几块代码很重要。

slang::IModuleslang::IEntryPoint均为slang::IComponentType的子类,因为它们都能组合成一个组件。要想创建一个组件,应使用ISession::createCompositeComponentType()

IComponentType* components[] = { module, entryPoint };
Slang::ComPtr<IComponentType> program;
session->createCompositeComponentType(components, 2, program.writeRef());

将模块和程序入口点组成一个组件很重要:

  1. 它确认着色器程序中有哪些代码应被编译;
  2. 它确认程序中代码的顺序,可被用于布局。

布局与反射

有时候需要程序反射着色器的参数和它们的布局,Slang API允许通过向IComponentType使用getLayout()获取组件的布局:

slang::ProgramLayout* layout = program->getLayout();

在目前版本的Slang中,ProgramLayout没有引用计数,这代表它的生命周期和被询问的IComponentType相绑定。因此要确保在使用ProgramLayout时,和它对应的IComponentType也要存在。

对于IModule模组的布局,是它的全局变量。对于IEntryPoint程序入口的布局,是它的入口参数(uniform和varying)。

实际上,getLayout()是有参数的——0起点的询问索引。如果当前组合中存在多个编译目标,就需要加上参数了。

有关反射,详见下一篇文章。

链接

距离生成目标代码就差一步,需要对程序进行链接。可通过IComponentType::link()IComponentType::linkWithOptions()进行链接操作:

Slang::ComPtr<IComponentType> linkedProgram;
Slang::ComPtr<ISlangBlob> diagnosticBlob;
program->link(linkedProgram.writeRef(), diagnosticBlob.writeRef());

此步骤还用来执行 链接时特化(link-time specialization),与基于预处理器的特化相比,更推荐这个。有关链接时特化的细节详见后续文章。

生成内核代码

有了链接后的IComponentType,CPU端就能基于一个程序入口点生成目标平台的着色器代码了,需要使用IComponentType::getEntryPointCode()

int entryPointIndex = 0; // only one entry point
int targetIndex = 0; // only one target
Slang::ComPtr<IBlob> kernelBlob;
linkedProgram->getEntryPointCode(
    entryPointIndex,
    targetIndex,
    kernelBlob.writeRef(),
    diagnostics.writeRef());

生成的内核代码是slang::IBlob类型,用于访问生成的代码(通过二进制或文本)。在大部分情况下,通过kernelBlob->getBufferPointer()可以直接将对应图形API着色器代码传给GPU。

捕获诊断输出

编译器在编译代码时会产生各种各样的诊断信息,包括错误、警告等。Slang中的多数操作(例如loadModule()link()getEntryPointCode())都能获取一段二进制诊断输出:

Slang::ComPtr<IBlob> diagnostics;
Slang::ComPtr<IModule> module = session->loadModule("MyShaders", diagnostics.writeRef());

loadModule()过程中产生诊断信息会存储在diagnostics内:

if(diagnostics)
{
    fprintf(stderr, "%s\n", (const char*) diagnostics->getBufferPointer());
}

参考资料

  • shader-slang/slang: Making it easier to work with shaders (github.com)
  • Compiling Code with Slang | slang