|
| 1 | +--- |
| 2 | +sidebar_position: 5 |
| 3 | +--- |
| 4 | + |
| 5 | +# GPU Pipelines with GExecution |
| 6 | + |
| 7 | +When you need to run multiple GPU programs in sequence, sharing data between them, `GExecution` provides a composable way to build GPU pipelines. The big benefit is that those pipelines, even highly complex ones, are materialized as one on the GPU and will synchronize and pass data without any operations on the CPU side. This greatly improves performance of computations. |
| 8 | + |
| 9 | +## What is GExecution? |
| 10 | + |
| 11 | +`GExecution` represents a sequence of GPU operations that can be composed together. It's a monadic abstraction that allows you to: |
| 12 | + |
| 13 | +- Chain multiple `GProgram`s together |
| 14 | +- Share buffers between pipeline stages |
| 15 | +- Transform parameters and layouts between programs |
| 16 | + |
| 17 | +Every `GProgram` is also a `GExecution`, making them directly composable. |
| 18 | + |
| 19 | +## Building a Simple Pipeline |
| 20 | + |
| 21 | +Let's build a pipeline that doubles values and then adds a constant: |
| 22 | + |
| 23 | +```scala |
| 24 | +import io.computenode.cyfra.core.{GBufferRegion, GExecution, GProgram} |
| 25 | +import io.computenode.cyfra.core.GProgram.StaticDispatch |
| 26 | +import io.computenode.cyfra.core.layout.Layout |
| 27 | +import io.computenode.cyfra.dsl.{*, given} |
| 28 | +import io.computenode.cyfra.runtime.VkCyfraRuntime |
| 29 | + |
| 30 | +// Step 1: Define individual programs |
| 31 | + |
| 32 | +case class DoubleLayout(input: GBuffer[Float32], output: GBuffer[Float32]) derives Layout |
| 33 | + |
| 34 | +val doubleProgram: GProgram[Int, DoubleLayout] = GProgram[Int, DoubleLayout]( |
| 35 | + layout = size => DoubleLayout( |
| 36 | + input = GBuffer[Float32](size), |
| 37 | + output = GBuffer[Float32](size) |
| 38 | + ), |
| 39 | + dispatch = (_, size) => StaticDispatch(((size + 255) / 256, 1, 1)), |
| 40 | + workgroupSize = (256, 1, 1), |
| 41 | +): layout => |
| 42 | + val idx = GIO.invocationId |
| 43 | + GIO.when(idx < 256): |
| 44 | + val value = GIO.read(layout.input, idx) |
| 45 | + GIO.write(layout.output, idx, value * 2.0f) |
| 46 | + |
| 47 | +case class SumParams(value: Float32) extends GStruct[SumParams] |
| 48 | +case class SumLayout( |
| 49 | + input: GBuffer[Float32], |
| 50 | + output: GBuffer[Float32], |
| 51 | + params: GUniform[AddParams] |
| 52 | +) derives Layout |
| 53 | + |
| 54 | +val sumProgram: GProgram[Int, SumLayout] = GProgram[Int, SumLayout]( |
| 55 | + layout = size => SumLayout( |
| 56 | + input = GBuffer[Float32](size), |
| 57 | + output = GBuffer[Float32](size), |
| 58 | + params = GUniform[SumParams]() |
| 59 | + ), |
| 60 | + dispatch = (_, size) => StaticDispatch(((size + 255) / 256, 1, 1)), |
| 61 | + workgroupSize = (256, 1, 1), |
| 62 | +): layout => |
| 63 | + val idx = GIO.invocationId |
| 64 | + GIO.when(idx < 256): |
| 65 | + val value = GIO.read(layout.input, idx) |
| 66 | + val addValue = layout.params.read.value |
| 67 | + GIO.write(layout.output, idx, value + addValue) |
| 68 | +``` |
| 69 | + |
| 70 | +## Composing Programs with addProgram |
| 71 | + |
| 72 | +The key to building pipelines is the `addProgram` method. It takes: |
| 73 | + |
| 74 | +1. A program to add to the execution |
| 75 | +2. A function to map pipeline parameters to program parameters |
| 76 | +3. A function to map the pipeline layout to the program's layout |
| 77 | + |
| 78 | +```scala |
| 79 | +// Step 2: Define the combined pipeline layout |
| 80 | +case class PipelineLayout( |
| 81 | + input: GBuffer[Float32], |
| 82 | + doubled: GBuffer[Float32], // Intermediate buffer |
| 83 | + output: GBuffer[Float32], |
| 84 | + sumParams: GUniform[SumParams] |
| 85 | +) derives Layout |
| 86 | + |
| 87 | +// Step 3: Compose the pipeline |
| 88 | +val doubleAndAddPipeline: GExecution[Int, PipelineLayout, PipelineLayout] = |
| 89 | + GExecution[Int, PipelineLayout]() |
| 90 | + .addProgram(doubleProgram)( |
| 91 | + size => size, // Map params: pipeline size -> program size |
| 92 | + layout => DoubleLayout(layout.input, layout.doubled) // Map layout |
| 93 | + ) |
| 94 | + .addProgram(sumProgram)( |
| 95 | + size => size, |
| 96 | + layout => SumLayout(layout.doubled, layout.output, layout.sumParams) |
| 97 | + ) |
| 98 | +``` |
| 99 | + |
| 100 | +Notice how the `doubled` buffer connects the two programs - the first program writes to it, and the second reads from it. |
| 101 | + |
| 102 | +**Pipelines can be made from any number of GPrograms that form a directed acyclic graph.** |
| 103 | + |
| 104 | +## Running the Pipeline |
| 105 | + |
| 106 | +Execute the pipeline using `GBufferRegion`: |
| 107 | + |
| 108 | +```scala |
| 109 | +@main |
| 110 | +def runDoubleAndSumPipeline(): Unit = VkCyfraRuntime.using: |
| 111 | + val size = 256 |
| 112 | + val inputData = (0 until size).map(_.toFloat).toArray |
| 113 | + val results = Array.ofDim[Float](size) |
| 114 | + |
| 115 | + val region = GBufferRegion |
| 116 | + .allocate[PipelineLayout] |
| 117 | + .map: layout => |
| 118 | + doubleAndAddPipeline.execute(size, layout) |
| 119 | + |
| 120 | + region.runUnsafe( |
| 121 | + init = PipelineLayout( |
| 122 | + input = GBuffer(inputData), |
| 123 | + doubled = GBuffer[Float32](size), |
| 124 | + output = GBuffer[Float32](size), |
| 125 | + sumParams = GUniform(SumParams(10.0f)), |
| 126 | + ), |
| 127 | + onDone = layout => layout.output.readArray(results), |
| 128 | + ) |
| 129 | +``` |
| 130 | + |
| 131 | +## GExecution Operations |
| 132 | + |
| 133 | +`GExecution` provides several composition methods: |
| 134 | + |
| 135 | +### addProgram |
| 136 | + |
| 137 | +Add another program to the pipeline, mapping parameters and layout: |
| 138 | + |
| 139 | +```scala |
| 140 | +execution.addProgram(program)( |
| 141 | + mapParams = pipelineParams => programParams, |
| 142 | + mapLayout = pipelineLayout => programLayout |
| 143 | +) |
| 144 | +``` |
| 145 | + |
| 146 | +### map |
| 147 | + |
| 148 | +Transform the result layout: |
| 149 | + |
| 150 | +```scala |
| 151 | +execution.map(resultLayout => newResultLayout) |
| 152 | +``` |
| 153 | + |
| 154 | +### flatMap |
| 155 | + |
| 156 | +Sequence executions where the second depends on the first's result: |
| 157 | + |
| 158 | +```scala |
| 159 | +execution.flatMap: resultLayout => |
| 160 | + anotherExecution |
| 161 | +``` |
| 162 | + |
| 163 | +## Example: Case-study on fs2 filtering |
| 164 | + |
| 165 | +A great read for understanding pipelines is a report from our contributor `spamegg`. It describes a process of implementing a [parallel prefix sum](https://developer.nvidia.com/gpugems/gpugems3/part-vi-gpu-computing/chapter-39-parallel-prefix-sum-scan-cuda) based filtering approach. We highly recommend reading it: [GSoC 2025: fs2 filtering through Cyfra](https://spamegg1.github.io/gsoc-2025/#fs2-filtering-through-cyfra). |
| 166 | + |
| 167 | +This article also tackles the topic of adapting GPrograms of mismatched Layouts and Parameters with `contramap` and `contramapParams`. They make it possible to connect any kinds of unrelated GPrograms into one pipeline. |
| 168 | + |
| 169 | +## Example: Navier-Stokes Fluid Simulation |
| 170 | + |
| 171 | +The `cyfra-fluids` module implements a full 3D Navier-Stokes fluid solver using GExecution pipelines. Each simulation step chains multiple GPU programs: forces, advection, diffusion, pressure projection, and boundary conditions. The GPipeline in this example is built from over 100 GPrograms. |
| 172 | + |
| 173 | + |
| 174 | + |
| 175 | +[View the implementation](https://github.com/ComputeNode/cyfra/tree/cab6b4cae3a3402a3de43272bc7cb50acf5ec67b/cyfra-fluids/src/main/scala/io/computenode/cyfra/fluids) |
0 commit comments