08-链接时特化与模块预编译

本篇将看看如何使用Slang进行链接时特化。

链接时特化

背景

传统的,图形开发者通过预处理器#define去特化他们的Shader代码,这种方式出现了许多问题:

  • 编译时间过长:预处理器特化位于编译阶段的极早期,当一个预处理定义改变时,编译器要重做很多工作,导致编译时间过长。
  • 减少代码可读性和可维护性:编译器无法检查宏的合法性,并且编写代码时候容易出现错误。
  • 修改成本高:如果开发者突然想去掉一个宏,那么需要重写大量的Shader代码。

而Slang则将Shader特化从预处理阶段延后至链接阶段,通过对代码的泛化处理。Slang提供了一个三步模型:预编译,链接和目标代码生成。用户可以将.slang代码预编译为.slang-module二进制IR文件,并将其进行链接和目标代码生成。

链接时常量

最简单的链接时特化可以通过链接时常量实现:

// main.slang

// Define a constant whose value will be provided in another module at link time.
extern static const int kSampleCount;

float sample(int index) {...}

RWStructuredBuffer<float> output;
void main(uint tid : SV_DispatchThreadID)
{
    [ForceUnroll]
    for (int i = 0; i < kSampleCount; i++)
        output[tid] += sample(i);
}

上述代码定义了一个计算着色器,并用kSampleCount进行特化,该常量由其他模块提供,并在链接时进行解析。接下来在另一个模块中定义上面提到的链接时常量:

// sample-count.slang
export static const int kSampleCount = 2;

然后就能将它们预编译,并链接到一起,形成链接时特化了:

slangc main.slang -o main.slang-module
slangc sample-count.slang -o sample-count.slang-module
slangc sample-count.slang-module main.slang-module -target hlsl -entry main -profile cs_6_0 -o main.hlsl

当然也能用C++ API实现:

ComPtr<slang::ISession> slangSession = ...;
ComPtr<slang::IBlob> diagnosticsBlob;

// Load the main module from file.
slang::IModule* mainModule = slangSession->loadModule("main.slang", diagnosticsBlob.writeRef());

// Load the specialization constant module from string.
const char* sampleCountSrc = R"(export static const int kSampleCount = 2;)";
auto sampleCountModuleSrcBlob = UnownedRawBlob::create(sampleCountSrc, strlen(sampleCountSrc));
slang::IModule* sampleCountModule = slangSession->loadModuleFromSource(
    "sample-count",  // module name
    "sample-count.slang", // synthetic module path
    sampleCountModuleSrcBlob);  // module source content

// Compose the modules and entry points.
ComPtr<slang::IEntryPoint> computeEntryPoint;
SLANG_RETURN_ON_FAIL(
    module->findEntryPointByName(entryPointName, computeEntryPoint.writeRef()));

std::vector<slang::IComponentType*> componentTypes;
componentTypes.push_back(mainModule);
componentTypes.push_back(computeEntryPoint);
componentTypes.push_back(sampleCountModule);

ComPtr<slang::IComponentType> composedProgram;
SlangResult result = slangSession->createCompositeComponentType(
    componentTypes.data(),
    componentTypes.size(),
    composedProgram.writeRef(),
    diagnosticsBlob.writeRef());

// Link.
ComPtr<slang::IComponentType> linkedProgram;
composedProgram->link(linkedProgram.writeRef(), diagnosticsBlob.writeRef());

// Get compiled code.
ComPtr<slang::IBlob> compiledCode;
linkedProgram->getEntryPointCode(0, 0, compiledCode.writeRef(), diagnosticBlob.writeRef());

链接时类型

和链接时常量类似,也能通过链接时类型实现链接时特化:

// common.slang
interface ISampler
{
    int getSampleCount();
    float sample(int index);
}
struct FooSampler : ISampler
{
    int getSampleCount() { return 1; }
    float sample(int index) { return 0.0; }
}
struct BarSampler : ISampler
{
    int getSampleCount() { return 2; }
    float sample(int index) { return index * 0.5; }
}

// main.slang
import common;
extern struct Sampler : ISampler;

RWStructuredBuffer<float> output;
void main(uint tid : SV_DispatchThreadID)
{
    Sampler sampler;
    [ForceUnroll]
    for (int i = 0; i < sampler.getSampleCount(); i++)
        output[tid] += sampler.sample(i);
}

Sampler就是链接时确认的类型,接下来需要编写一个模块,用于链接时确认类型:

// sampler.slang
import common;
export struct Sampler : ISampler = FooSampler;

其中,=是下列代码的语法糖:

export struct Sampler : ISampler
{
    FooSampler inner;
    int getSampleCount() { return inner.getSampleCount(); }
    float sample(int index) { return inner.sample(index); }
}

提供默认值

当定义一个extern符号作为链接时常量/类型时,允许提供一个默认值。当其他模块没有export同名符号时,链接时就会解析到默认值:

// main.slang

// Provide a default value when no other modules are exporting the symbol.
extern static const int kSampleCount = 2;
// ... 
void main(uint tid : SV_DispatchThreadID)
{
    [ForceUnroll]
    for (int i = 0; i < kSampleCount; i++)
        output[tid] += sample(i);
}

限制

不想预处理器,链接时常量/类型只能用于不被着色器参数布局影响到的地方。也就是说它们存在如下限制:

  • 链接时常量不能被用于定义数组大小;
  • 链接时类型被认为是不完整的,不能被用于ConstantBufferParameterBlockuniform等,但能被用于StructureBufferGLSLStorageBuffer

模块预编译

C++ API

在上文我们通过slangc进行Slang模块的预编译,实际上C++ API也能做。IModule类提供了将其序列化到磁盘上的方法:

/// Get a serialized representation of the checked module.
SlangResult IModule::serialize(ISlangBlob** outSerializedBlob);

/// Write the serialized representation of this module to a file.
SlangResult IModule::writeToFile(char const* fileName);

并且ISession类也提供了查询预编译模块是否和源代码的Slang版本一致、会话编译器选项一致、源代码内容一致的接口:

bool ISession::isBinaryModuleUpToDate(
    const char* modulePath,
    slang::IBlob* binaryModuleBlob);

如果在模块上次预编译后相关内容发生改变,该方法返回false。

在使用ISession::loadModule()时会优先使用预编译结果.slang-module,找不到才会现编译现用。因此如果希望编译器总是使用最新的预编译结果,可在创建会话时设置额外编译器选项CompilerOptionName::UseUpToDateBinaryModule为1。这样编译器会先验证预编译模块的时效性,如果过期了会进行重新编译。

参考资料

  • shader-slang/slang: Making it easier to work with shaders (github.com)
  • Link-time Specialization and Module Precompilation | slang