Linking DXIL Binaries Using DXC

While doing some research for a different blog post, I happened to discover that DXC is capable of linking together multiple compiled DXIL binaries into single complete shader program of any stage (vertex, pixel, compute, etc.). I had known that this was a thing you could do for D3D11 with the old fxc-based compiler stack, but I wasn't aware that DXC could now do it as well. It turns out that the new lib_6_x targets aren't just usable for creating DXR state objects: you can also compile whatever you want into libraries and link them together into a full binary for a non-raytracing shader. Unfortunately there doesn't seem to be any documentation that I can find for this feature, my only reference for getting this to work was the unit tests from the DXC repo. To help out with that, I'll briefly explain the basics of how it works.

As I mentioned above, if you're going to link things then you're going to want to compile to a library target rather than one of the shader stage targets (ps_6_x, vs_6_x, etc.). Let's say we want to create a full pixel shader where we can call into an external function that we can link in later. You would first want to setup an entry point like this in a file:

// Entry.hlsl

// This is the function we're going to link to
float4 LibFunc();

// This is the entry point for our pixel shader
[shader("pixel")]
float4 PSMain(in PSInput input) : SV_Target0
{
    return LibFunc();
}

You would then want to compile this with the lib_6_6 target (or the equivalent of whatever shader model you're targetting) in order to produce a binary DXIL blob containing the compiled bytecode. For the implementation of LibFunc() we'll now want to setup another file with this code:

// Func.hlsl

export float4 LibFunc()
{
    return float4(0, 1, 0, 1);
}

Note the export keyword. By default the compiler won't give your functions external linkage when compiling to a lib, so you'll want to add that keyword for any functions that you want to link to (you can also pass -default-linkage external when compiling but that's quite a hammer). You would once again compile this to the lib_6_6 target to get another DXIL binary.

To link these two binaries together, you'll want to create an instance of IDxcLinker, register your libraries with it, and then instruct it to create a new linked binary containing a complete pixel shader:

IDxcBlob* entryLib = CompileLib("Entry.hlsl", "lib_6_6");
IDxcBlob* funcLib = CompileLib("Func.hlsl", "lib_6_6");

IDxcLinker* linker = nullptr;
CheckHResult(DxcCreateInstance(CLSID_DxcLinker, IID_PPV_ARGS(&linker)));

CheckHResult(linker->RegisterLibrary(L"EntryLib", entryLib));
CheckHResult(linker->RegisterLibrary(L"FuncLib", funcLib));

const wchar* names[] = { L"EntryLib", L"FuncLib" };

IDxcOperationResult* result = nullptr;
CheckHResult(linker->Link(L"PSMain", L"ps_6_6", names, ArraySize_(names), nullptr, 0, &result));

IDxcBlob* fullShader = nullptr;

HRESULT status = S_OK;
CheckHResult(result->GetStatus(&status));
if(SUCCEEDED(status))
{
    result->GetResult(&fullShader);
}
else
{
    IDxcBlobEncoding* linkerErrorMsg = nullptr;
    result->GetErrorBuffer(&linkerErrorMsg);

    OutputErrorMessage(linkerErrorMsg);
}

That's really it! If the operation succeeds, then fullShader will contain a complete DXIL binary that you can use to create a PSO the usual way. I haven't done any testing yet to see if linking is actually any faster than doing a full compile, but if it is faster then it could potentially be an avenue for reducing the compile times for excessive shader permutations.

One major limitation to be aware of is that resources can not be a parameter or return value for exported functions. This is a real bummer, since it will require workarounds for real code. The only way to coordinate on resources right now is to either use the fancy new dynamic resources functionality from Shader Model 6.6, or have the lib that uses the resource declare it globally. You can also declare the same resource in both libs, in which case things will merge correctly as long as they use the exact same register assignment:

// Entry.hlsl

Texture2D MyTex : register(t0);

float4 LibFunc();

[shader("pixel")]
float4 PSMain(in PSInput input) : SV_Target0
{
    return LibFunc() + MyTex[uint2(1, 1)];
}
// Func.hlsl

Texture2D MyTex : register(t0);

export float4 LibFunc()
{
    return float4(0, 1, 0, 1) * MyTex[uint2(2, 2)];
}

Happy linking!