Chapter 04: Pipelines¶
Overview¶
A compute pipeline encapsulates the shader and its configuration. This chapter covers:
- Pipeline creation and caching
- Managing multiple pipelines
- Pipeline switching costs
- Chaining compute operations
What you'll learn:
- The anatomy of a compute pipeline
- Creating pipeline caches for faster loading
- Efficient multi-pipeline workflows
What is a Compute Pipeline?¶
A pipeline combines:
- Shader module: The compiled SPIR-V code
- Pipeline layout: Descriptor set layouts + push constants
- Specialization constants: Compile-time values
┌─────────────────────────────────────────┐
│ Compute Pipeline │
├─────────────────────────────────────────┤
│ Shader Module (SPIR-V) │
│ ├── Entry point: "main" │
│ └── Specialization constants │
├─────────────────────────────────────────┤
│ Pipeline Layout │
│ ├── Descriptor Set Layouts │
│ └── Push Constant Ranges │
└─────────────────────────────────────────┘
Creating a Compute Pipeline¶
Step 1: Load Shader Module¶
// Read SPIR-V file
size_t code_size;
uint32_t* code = read_file("shader.comp.spv", &code_size);
VkShaderModuleCreateInfo module_info = {
.sType = VK_STRUCTURE_TYPE_SHADER_MODULE_CREATE_INFO,
.codeSize = code_size,
.pCode = code
};
VkShaderModule shader_module;
vkCreateShaderModule(device, &module_info, NULL, &shader_module);
Step 2: Create Pipeline¶
VkPipelineShaderStageCreateInfo stage_info = {
.sType = VK_STRUCTURE_TYPE_PIPELINE_SHADER_STAGE_CREATE_INFO,
.stage = VK_SHADER_STAGE_COMPUTE_BIT,
.module = shader_module,
.pName = "main" // Entry point function name
};
VkComputePipelineCreateInfo pipeline_info = {
.sType = VK_STRUCTURE_TYPE_COMPUTE_PIPELINE_CREATE_INFO,
.stage = stage_info,
.layout = pipeline_layout
};
VkPipeline pipeline;
vkCreateComputePipelines(device, VK_NULL_HANDLE, 1,
&pipeline_info, NULL, &pipeline);
Cleanup¶
Shader modules can be destroyed after pipeline creation:
// Shader is compiled into pipeline, module no longer needed
vkDestroyShaderModule(device, shader_module, NULL);
Pipeline Caching¶
Pipeline creation involves shader compilation — slow! Use a pipeline cache:
// Create cache
VkPipelineCacheCreateInfo cache_info = {
.sType = VK_STRUCTURE_TYPE_PIPELINE_CACHE_CREATE_INFO,
.initialDataSize = 0,
.pInitialData = NULL
};
VkPipelineCache cache;
vkCreatePipelineCache(device, &cache_info, NULL, &cache);
// Create pipeline with cache
vkCreateComputePipelines(device, cache, 1, &pipeline_info,
NULL, &pipeline);
Saving and Loading Cache¶
// Get cache data
size_t cache_size;
vkGetPipelineCacheData(device, cache, &cache_size, NULL);
void* cache_data = malloc(cache_size);
vkGetPipelineCacheData(device, cache, &cache_size, cache_data);
// Save to file
write_file("pipeline.cache", cache_data, cache_size);
// Later: Load cache
void* loaded_data = read_file("pipeline.cache", &cache_size);
VkPipelineCacheCreateInfo cache_info = {
.sType = VK_STRUCTURE_TYPE_PIPELINE_CACHE_CREATE_INFO,
.initialDataSize = cache_size,
.pInitialData = loaded_data
};
Cache Invalidation
Cache data is driver and GPU specific. It's automatically invalidated when incompatible.
Multiple Pipelines¶
Real applications use many pipelines. Create them efficiently:
// Batch creation
VkComputePipelineCreateInfo infos[4] = {
{/* add shader */},
{/* multiply shader */},
{/* square shader */},
{/* sqrt shader */}
};
VkPipeline pipelines[4];
vkCreateComputePipelines(device, cache, 4, infos, NULL, pipelines);
Pipeline Switching¶
Switching pipelines has overhead. Minimize switches:
// Bad: Switch pipeline for each element
for (int i = 0; i < N; i++) {
vkCmdBindPipeline(cmd, ..., pipeline_a);
vkCmdDispatch(cmd, 1, 1, 1);
vkCmdBindPipeline(cmd, ..., pipeline_b);
vkCmdDispatch(cmd, 1, 1, 1);
}
// Good: Batch by pipeline
vkCmdBindPipeline(cmd, ..., pipeline_a);
for (int i = 0; i < N; i++) {
vkCmdDispatch(cmd, 1, 1, 1);
}
vkCmdBindPipeline(cmd, ..., pipeline_b);
for (int i = 0; i < N; i++) {
vkCmdDispatch(cmd, 1, 1, 1);
}
Pipeline Chains¶
Chain multiple operations with memory barriers:
// Step 1: Add 10
vkCmdBindPipeline(cmd, VK_PIPELINE_BIND_POINT_COMPUTE, add_pipeline);
vkCmdDispatch(cmd, workgroups, 1, 1);
// Barrier: Ensure add completes before multiply reads
VkMemoryBarrier barrier = {
.sType = VK_STRUCTURE_TYPE_MEMORY_BARRIER,
.srcAccessMask = VK_ACCESS_SHADER_WRITE_BIT,
.dstAccessMask = VK_ACCESS_SHADER_READ_BIT
};
vkCmdPipelineBarrier(cmd,
VK_PIPELINE_STAGE_COMPUTE_SHADER_BIT,
VK_PIPELINE_STAGE_COMPUTE_SHADER_BIT,
0, 1, &barrier, 0, NULL, 0, NULL);
// Step 2: Multiply by 2
vkCmdBindPipeline(cmd, VK_PIPELINE_BIND_POINT_COMPUTE, multiply_pipeline);
vkCmdDispatch(cmd, workgroups, 1, 1);
The Shaders¶
add.comp¶
#version 450
layout(local_size_x = 256) in;
layout(set = 0, binding = 0) buffer Data { float v[]; } data;
layout(push_constant) uniform PC { float value; } pc;
void main() {
uint idx = gl_GlobalInvocationID.x;
data.v[idx] = data.v[idx] + pc.value;
}
multiply.comp¶
#version 450
layout(local_size_x = 256) in;
layout(set = 0, binding = 0) buffer Data { float v[]; } data;
layout(push_constant) uniform PC { float value; } pc;
void main() {
uint idx = gl_GlobalInvocationID.x;
data.v[idx] = data.v[idx] * pc.value;
}
Running the Example¶
Output from an Apple M3 Pro, abridged — the program runs all four pipelines in turn before the chaining demo:
==============================================
VKCompute - Chapter 04: Pipeline Management
==============================================
Creating pipeline cache...
Creating shared layouts...
Creating 4 pipelines...
Pipeline 'Add': 1.11 ms
Pipeline 'Multiply': 0.26 ms
Pipeline 'Square': 0.23 ms
Pipeline 'Sqrt': 0.19 ms
Total pipeline creation: 1.79 ms
Pipeline cache size: 12803 bytes
=== Running All Pipelines ===
--- Add (scalar=5.0) ---
First 5 results:
[0] 1.0 -> 6.0
[1] 2.0 -> 7.0
...
=== Pipeline Chaining (Add then Multiply) ===
Chain: (input + 10.0) * 2.0
First 5 results:
[0] (1.0 + 10.0) * 2.0 = 22.0 (got 22.0) [ok]
[1] (2.0 + 10.0) * 2.0 = 24.0 (got 24.0) [ok]
[2] (3.0 + 10.0) * 2.0 = 26.0 (got 26.0) [ok]
Chapter 04 completed!
First pipeline is slowest
'Add' takes ~4x longer than the rest. The cache is empty at that point and the driver does one-time backend setup; later pipelines reuse it.
Pipeline Derivatives¶
Create related pipelines faster with derivatives:
// Base pipeline
VkComputePipelineCreateInfo base_info = {
.sType = VK_STRUCTURE_TYPE_COMPUTE_PIPELINE_CREATE_INFO,
.flags = VK_PIPELINE_CREATE_ALLOW_DERIVATIVES_BIT,
.stage = base_stage,
.layout = layout
};
VkPipeline base_pipeline;
vkCreateComputePipelines(device, cache, 1, &base_info,
NULL, &base_pipeline);
// Derived pipeline
VkComputePipelineCreateInfo derived_info = {
.sType = VK_STRUCTURE_TYPE_COMPUTE_PIPELINE_CREATE_INFO,
.flags = VK_PIPELINE_CREATE_DERIVATIVE_BIT,
.stage = derived_stage,
.layout = layout,
.basePipelineHandle = base_pipeline,
.basePipelineIndex = -1
};
Driver Dependent
The performance benefit of derivatives varies by driver. Profile to verify.
Exercises¶
-
Cache Persistence: Save the pipeline cache to disk and measure loading time on restart.
-
More Operations: Add sqrt and power shaders, chain them in different orders.
-
Performance Test: Measure the cost of pipeline switches vs. dispatch count.
Common Errors¶
VK_ERROR_INVALID_SHADER_NV¶
Shader compilation failed:
- Check GLSL syntax
- Verify SPIR-V was generated correctly with glslc
- Enable validation layers for detailed errors
Pipeline Layout Mismatch¶
Shader expects resources the layout doesn't provide:
- Ensure descriptor set layouts match shader layout() declarations
- Check push constant ranges match shader usage
What's Next?¶
We've been submitting commands and hoping they complete. In Chapter 05, we'll learn proper GPU synchronization with fences, semaphores, and barriers.