Files
kbz_8 589d30168f
Build / build (push) Successful in 42s
Test / build (push) Successful in 1m17s
implementing missing functions in FFI
2026-07-04 20:59:03 +02:00

16 KiB

SPIR-V Interpreter Usage

Concepts

A typical invocation has this lifecycle:

  1. Load SPIR-V bytecode.
  2. Create a Module from the bytecode.
  3. Create one or more Runtime instances from the module.
  4. Write inputs, built-ins, descriptors, push constants, or specialization constants.
  5. Execute an entry point.
  6. Read outputs or built-ins.
  7. Destroy runtimes, then destroy the module.

A Module represents parsed SPIR-V bytecode. A Runtime represents one executable invocation state. Use separate runtimes when running invocations concurrently.


Zig usage

Add the dependency

With Zig 0.16.0 or newer:

zig fetch --save git+https://git.kbz8.me/kbz_8/SPIRV-Interpreter

Then import the package module from your build.zig:

const spv_dep = b.dependency("SPIRV_Interpreter", .{
    .target = target,
    .optimize = optimize,
});

exe.root_module.addImport("spv", spv_dep.module("spv"));

In your code:

const spv = @import("spv");

Minimal Zig example

This example loads an embedded .spv file, calls the main entry point, and reads a vec4<f32> output named color.

const std = @import("std");
const spv = @import("spv");

const shader_source = @embedFile("shader.spv");

pub fn main() !void {
    var gpa: std.heap.DebugAllocator(.{}) = .init;
    defer _ = gpa.deinit();

    const allocator = gpa.allocator();

    var module = try spv.Module.init(
        allocator,
        @ptrCast(@alignCast(shader_source)),
        .{},
    );
    defer module.deinit(allocator);

    // If the shader does not execute image operations, the image API is unused.
    // For image load/store/sampling shaders, provide a real spv.Runtime.ImageAPI.
    var rt = try spv.Runtime.init(allocator, &module, undefined);
    defer rt.deinit(allocator);

    const entry = try rt.getEntryPointByName("main");
    const color_result = try rt.getResultByName("color");

    try rt.callEntryPoint(allocator, entry);

    var color: [4]f32 = undefined;
    try rt.readOutput(std.mem.sliceAsBytes(color[0..]), color_result);

    std.debug.print("color = {any}\n", .{color});
}

Writing inputs

You can write an input by result id:

const pos_result = try rt.getResultByName("pos");

const pos = [_]f32{ 10.0, 20.0 };
try rt.writeInput(std.mem.sliceAsBytes(pos[0..]), pos_result);

Or by location:

const uv = [_]f32{ 0.25, 0.75 };
try rt.writeInputLocation(std.mem.sliceAsBytes(uv[0..]), 0);

For scalar or struct data, use std.mem.asBytes:

const time: f32 = 1.5;
const time_result = try rt.getResultByName("time");

try rt.writeInput(std.mem.asBytes(&time), time_result);

Reading outputs

You can read an output by result id:

const color_result = try rt.getResultByName("color");

var color: [4]f32 = undefined;
try rt.readOutput(std.mem.sliceAsBytes(color[0..]), color_result);

Or locate an output by Location decoration:

const color_result = try rt.getResultByLocation(0, .output);

var color: [4]f32 = undefined;
try rt.readOutput(std.mem.sliceAsBytes(color[0..]), color_result);

Push constants

Push constants are passed as raw bytes:

const PushConstants = extern struct {
    time: f32,
    scale: f32,
};

const push_constants = PushConstants{
    .time = 1.0,
    .scale = 2.0,
};

try rt.populatePushConstants(std.mem.asBytes(&push_constants));

Descriptor sets

Descriptor writes are passed as raw bytes and selected by set, binding, and descriptor index:

try rt.writeDescriptorSet(
    std.mem.sliceAsBytes(buffer[0..]),
    0, // set
    1, // binding
    0, // descriptor index
);

For non-array descriptors, use descriptor index 0.

After running a shader that writes through descriptor-backed memory, flush descriptor sets before reading the backing data:

try rt.callEntryPoint(allocator, entry);
try rt.flushDescriptorSets(allocator);

Specialization constants

Specialization constants are selected by specialization id:

const value: u32 = 64;

try rt.addSpecializationInfo(
    allocator,
    .{
        .id = 0,
        .offset = 0,
        .size = @sizeOf(u32),
    },
    std.mem.asBytes(&value),
);

Add specialization constants before calling the entry point.

To copy specialization constants between runtimes:

try rt.copySpecializationConstantsFrom(allocator, &source_rt);

Entry points and barriers

For most shaders, callEntryPoint is enough:

try rt.callEntryPoint(allocator, entry);

For shaders that may hit barriers, use beginEntryPoint and continueEntryPoint:

var status = try rt.beginEntryPoint(allocator, entry);

while (status == .barrier) {
    // Synchronize other invocations here if needed.
    status = try rt.continueEntryPoint(allocator);
}

Multiple runtimes

A module can be shared by multiple runtimes. This is the preferred model for parallel execution:

var rt_a = try spv.Runtime.init(allocator, &module, undefined);
defer rt_a.deinit(allocator);

var rt_b = try spv.Runtime.init(allocator, &module, undefined);
defer rt_b.deinit(allocator);

Do not mutate the same runtime concurrently from multiple threads. Use one runtime per worker or invocation stream.

Image operations

Shaders that use image load, image store, image sampling, or image-size queries need a real image API:

const image_api = spv.Runtime.ImageAPI{
    .readImageFloat4 = readImageFloat4,
    .readImageInt4 = readImageInt4,
    .writeImageFloat4 = writeImageFloat4,
    .writeImageInt4 = writeImageInt4,
    .sampleImageFloat4 = sampleImageFloat4,
    .sampleImageInt4 = sampleImageInt4,
    .sampleImageDref = sampleImageDref,
    .queryImageSize = queryImageSize,
};

var rt = try spv.Runtime.init(allocator, &module, image_api);

Sample callbacks receive optional explicit LOD and an integer texel offset:

fn sampleImageFloat4(
    driver_image: *anyopaque,
    driver_sampler: *anyopaque,
    dim: spv.SpvDim,
    x: f32,
    y: f32,
    z: f32,
    lod: ?f32,
    offset: spv.Runtime.ImageOffset,
) spv.Runtime.RuntimeError!spv.Runtime.Vec4(f32) {
    _ = .{ driver_image, driver_sampler, dim, x, y, z, lod, offset };
    return .{ .x = 0, .y = 0, .z = 0, .w = 1 };
}

Depth-comparison samplers call sampleImageDref and return a scalar f32.

Derivatives

Fragment shaders using derivative operations need derivative data on the source result:

try rt.setDerivativeFromMemory(
    allocator,
    input_result,
    std.mem.asBytes(&dx),
    std.mem.asBytes(&dy),
);

try rt.copyDerivative(allocator, dst_result, input_result);
rt.clearDerivative(allocator, dst_result);

For low-level integrations, setDerivative accepts interpreter Value objects directly.


C usage

Build the C FFI

Build the static C FFI library:

zig build ffi-c --release=fast

Other release modes are also supported:

zig build ffi-c --release=safe
zig build ffi-c --release=small

To build a shared library instead of a static library:

zig build ffi-c --release=fast -Dffi-build-static=false

The library is installed into:

zig-out/lib/

The public header is installed into:

zig-out/include/SpirvInterpreter.h

The source header is also available in:

ffi/SpirvInterpreter.h

Minimal C example

#include <stdio.h>
#include <SpirvInterpreter.h>

static const unsigned char shader_source[]  = {
    /* Shader bytecode */
};

int main(void)
{
    SpvModule module;
    SpvModuleOptions options;
    options.use_simd_vectors_specializations = 1;

    if(SpvInitModule(&module, (SpvWord*)shader_source, sizeof(shader_source) / 4, options) != SPV_RESULT_SUCCESS)
        return -1;

    SpvRuntime runtime;
    /**
     * A zeroed image API is only safe when the shader does not execute image
     * load/store/sample/query operations.
     */
    if(SpvInitRuntime(&runtime, module, (SpvImageAPI){0}) != SPV_RESULT_SUCCESS)
        return -1;

    SpvWord main_entry_index;
    SpvGetEntryPointByName(runtime, "main", &main_entry_index);
    SpvCallEntryPoint(runtime, main_entry_index);

    float output[4];
    SpvWord output_result;
    SpvGetResultByName(runtime, "color", &output_result);
    SpvReadOutput(runtime, (SpvByte*)output, sizeof(output), output_result);

    printf("Output: Vec4[%f, %f, %f, %f]\n", output[0], output[1], output[2], output[3]);

    SpvDeinitRuntime(runtime);
    SpvDeinitModule(module);
    return 0;
}

Module reflection from C

SpvModuleGetReflectionInfos returns shader-wide metadata collected while parsing the module:

SpvModuleReflectionInfos infos = SpvModuleGetReflectionInfos(module);

if (infos.needs_derivatives)
{
    /* Provide derivative data before executing derivative operations. */
}

if (infos.has_control_barriers)
{
    /* Use SpvBeginEntryPoint/SpvContinueEntryPoint and synchronize invocations. */
}

if (infos.has_atomics)
{
    /* Descriptor or workgroup-backed storage may be modified atomically. */
}

if (infos.early_fragment_tests)
{
    /* Fragment shader requested early fragment tests. */
}

Compute and geometry execution metadata is also available:

SpvWord local_x = infos.local_size_x;
SpvWord geometry_output_count = infos.geometry_output_count;

Writing inputs from C

Write by result id:

SpvWord pos_result = 0;

if (SpvGetResultByName(runtime, "pos", &pos_result) != SPV_RESULT_SUCCESS)
    return 1;

float pos[2] = {10.0f, 20.0f};

if (SpvWriteInput(runtime, (const SpvByte*)pos, sizeof(pos), pos_result) != SPV_RESULT_SUCCESS)
    return 1;

Write by input location:

float uv[2] = {0.25f, 0.75f};

if (SpvWriteInputLocation(runtime, (const SpvByte*)uv, sizeof(uv), 0) != SPV_RESULT_SUCCESS)
    return 1;

Reading outputs from C

Read by result id:

SpvWord color_result = 0;
float color[4] = {0};

SpvGetResultByName(runtime, "color", &color_result);
SpvReadOutput(runtime, (SpvByte*)color, sizeof(color), color_result);

Read by output location:

SpvWord color_result = 0;

SpvGetResultByLocation(runtime, 0, SPV_LOCATION_OUTPUT, &color_result);

For component-qualified locations:

SpvWord result = 0;

SpvGetResultByLocationComponent(runtime, 0, 1, SPV_LOCATION_OUTPUT, &result);

For built-ins:

SpvWord workgroup_id_result = 0;

if (SpvGetBuiltinResult(runtime, SpvBuiltInWorkgroupId, &workgroup_id_result) == SPV_RESULT_SUCCESS)
{
    unsigned int workgroup_id[3] = {0};
    SpvReadBuiltIn(runtime, (SpvByte*)workgroup_id, sizeof(workgroup_id), SpvBuiltInWorkgroupId);
}

If a result's layout depends on specialization constants or runtime-array sizes, refresh it before querying or reading by result id:

SpvRefreshResultValueLayout(runtime, result);

Push constants from C

typedef struct PushConstants
{
    float time;
    float scale;
} PushConstants;

PushConstants push_constants = {
    .time = 1.0f,
    .scale = 2.0f,
};

SpvPopulatePushConstants(runtime, (const SpvByte*)&push_constants, sizeof(push_constants));

Descriptor sets from C

SpvWriteDescriptorSet(runtime, (const SpvByte*)buffer, buffer_size,
    0, /* set */
    1, /* binding */
    0  /* descriptor index */
);

For non-array descriptors, use descriptor index 0.

You can query a descriptor binding from a module:

SpvWord result = 0;
SpvResult status = SpvModuleGetBindingResult(module, 0, 1, &result);

After a shader writes through descriptor-backed memory, flush descriptor sets before reading the backing data:

SpvCallEntryPoint(runtime, entry);
SpvFlushDescriptorSets(runtime);

Specialization constants from C

unsigned int value = 64;

SpvRuntimeSpecializationEntry entry = {
    .id = 0,
    .offset = 0,
    .size = sizeof(value),
};

SpvAddSpecializationInfo(runtime, entry, (const SpvByte*)&value, sizeof(value));

Add specialization constants before calling the entry point.

If specialization constants affect array sizes, workgroup size, or other layout-dependent metadata, apply the specialization layout after adding the values:

SpvApplySpecializationLayout(runtime);

For a fresh invocation layout, use:

SpvApplySpecializationInvocationLayout(runtime);

To duplicate specialization constants from another runtime:

SpvCopySpecializationConstantsFrom(runtime, source_runtime);

Entry points from C

Entry points can be looked up by name:

SpvWord entry = 0;
SpvGetEntryPointByName(runtime, "main", &entry);

When a module has multiple entry points with the same name, include the execution model:

SpvGetEntryPointByNameAndExecutionModel(
    runtime,
    "main",
    SpvExecutionModelGLCompute,
    &entry);

Selecting an entry point filters interface lookups such as input and output locations to that entry point:

SpvSelectEntryPoint(runtime, entry);

Derivatives from C

float dx[4] = {1.0f, 0.0f, 0.0f, 0.0f};
float dy[4] = {0.0f, 1.0f, 0.0f, 0.0f};

SpvSetDerivativeFromMemory(
    runtime,
    result,
    (const SpvByte*)dx,
    sizeof(dx),
    (const SpvByte*)dy,
    sizeof(dy));

SpvCopyDerivative(runtime, dst_result, result);
SpvClearDerivative(runtime, dst_result);

Barriers from C

For most shaders:

SpvCallEntryPoint(runtime, entry);

For shaders that may hit barriers:

SpvEntryPointStatus status = SPV_ENTRY_POINT_COMPLETED;

SpvBeginEntryPoint(runtime, entry, &status);

while (status == SPV_ENTRY_POINT_BARRIER)
{
    /* Synchronize other invocations here if needed. */
    SpvContinueEntryPoint(runtime, &status);
}

Workgroup data from C

Compute shaders may declare a static workgroup size. Query it with:

SpvWorkgroupSize size;

if (SpvGetWorkgroupSize(runtime, &size) == SPV_RESULT_SUCCESS && size.has_size)
{
    printf("workgroup size = %lu, %lu, %lu\n", size.x, size.y, size.z);
}

Workgroup storage is allocated separately so multiple runtime invocations can bind the same shared memory during barrier-based execution:

SpvWorkgroupMemory memory;

if (SpvCreateWorkgroupMemory(runtime, &memory) != SPV_RESULT_SUCCESS)
    return 1;

SpvSize count = SpvGetWorkgroupMemoryCount(memory);

for (SpvSize i = 0; i < count; ++i)
{
    SpvWorkgroupMemoryItem item;

    if (SpvGetWorkgroupMemoryItem(memory, i, &item) == SPV_RESULT_SUCCESS)
    {
        /* item.result is the Workgroup variable result id.
           item.data points to item.size bytes owned by memory. */
    }
}

SpvBindWorkgroupMemory(runtime, memory);

/* Run one or more invocations that share this workgroup memory. */

SpvDestroyWorkgroupMemory(runtime, memory);

Image API from C

Shaders that execute image operations must provide callbacks in SpvImageAPI.

static SpvResult ReadImageFloat4(
    SpvReadImageInfo info,
    SpvVec4f* dst)
{
    (void)info.driver_image;
    (void)info.dim;
    (void)info.x;
    (void)info.y;
    (void)info.z;
    (void)info.has_lod;
    (void)info.lod;

    dst->x = 0.0f;
    dst->y = 0.0f;
    dst->z = 0.0f;
    dst->w = 1.0f;

    return SPV_RESULT_SUCCESS;
}

Sampling callbacks include an explicit-LOD flag/value and an offset:

static SpvResult SampleImageFloat4(
    SpvSampleImageInfo info,
    SpvVec4f* dst)
{
    (void)info.driver_image;
    (void)info.driver_sampler;
    (void)info.dim;
    (void)info.has_lod;
    (void)info.lod;
    (void)info.offset;

    dst->x = (float)info.x;
    dst->y = (float)info.y;
    dst->z = (float)info.z;
    dst->w = 1.0f;
    return SPV_RESULT_SUCCESS;
}

Depth-comparison samplers add dref and write one float:

static SpvResult SampleImageDref(
    SpvSampleImageInfo info,
    float dref,
    float* dst)
{
    (void)info.driver_image;
    (void)info.driver_sampler;
    (void)info.dim;
    (void)info.x;
    (void)info.y;
    (void)info.z;
    (void)info.has_lod;
    (void)info.lod;
    (void)info.offset;

    *dst = dref;
    return SPV_RESULT_SUCCESS;
}

The image API table contains these callbacks:

SpvImageAPI image_api = {
    .SpvReadImageFloat4 = ReadImageFloat4,
    .SpvReadImageInt4 = ReadImageInt4,
    .SpvWriteImageFloat4 = WriteImageFloat4,
    .SpvWriteImageInt4 = WriteImageInt4,
    .SpvSampleImageFloat4 = SampleImageFloat4,
    .SpvSampleImageInt4 = SampleImageInt4,
    .SpvSampleImageDref = SampleImageDref,
    .SpvQueryImageSize = QueryImageSize,
    .SpvQueryImageLevels = QueryImageLevels,
    .SpvQueryImageSamples = QueryImageSamples,
    .SpvQueryImageLod = QueryImageLod,
};

Pass the table when creating the runtime:

SpvInitRuntime(&runtime, module, image_api);

Cleanup order

Always destroy runtimes before destroying the module they were created from:

SpvDeinitRuntime(runtime);
SpvDeinitModule(module);