Skip to content

Commit 741cb54

Browse files
committed
gup experiment
1 parent be841e5 commit 741cb54

12 files changed

Lines changed: 2639 additions & 0 deletions

gvecxt/cyfra docs/gpu-functions.md

Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
1+
---
2+
sidebar_position: 3
3+
---
4+
5+
# GPU Functions
6+
7+
The simplest way to use the Cyfra library is with a GFunction. In essence, it is a function that takes any input you give it, runs on the GPU, and returns the output.
8+
9+
```scala
10+
import io.computenode.cyfra.dsl.{*, given}
11+
import io.computenode.cyfra.foton.GFunction
12+
import io.computenode.cyfra.runtime.VkCyfraRuntime
13+
14+
@main
15+
def multiplyByTwo(): Unit =
16+
VkCyfraRuntime.using:
17+
val input = (0 until 256).map(_.toFloat).toArray
18+
19+
val doubleIt: GFunction[GStruct.Empty, Float32, Float32] = GFunction: x =>
20+
x * 2.0f
21+
22+
val result: Array[Float] = doubleIt.run(input)
23+
24+
println(s"Output: ${result.take(10).mkString(", ")}...")
25+
```
26+
27+
`doubleIt.run(input)` will simply take the provided input and run the GFunction on it. As a result, we will get an array of floats that are each a doubled entry from the input array.
28+
29+
## Cyfra DSL
30+
31+
When you use Cyfra, you enter a world of values that are entirely separate from standard Scala values. Float becomes `Float32`, Double becomes `Float64`, and so on. Below is a table with more examples. Those are not all the types, but this includes most important ground types (`Float32`, `Int32`, `GBoolean`, etc.). Any ground types can be then used with Vectors. Additionally any types can be composed into `GStruct`s (including other `GStruct`s).
32+
33+
| Scala Type | Cyfra Type |
34+
|------------|------------|
35+
| `Float` | `Float32` |
36+
| `Double` | `Float64` |
37+
| `Int` | `Int32` |
38+
| `Int` | `UInt32` (unsigned) |
39+
| `Boolean` | `GBoolean` |
40+
| `(Float, Float)` | `Vec2[Float32]` |
41+
| `(Float, Float, Float, Float)` | `Vec4[Float32]` |
42+
| `(Int, Int)` | `Vec2[Int32]` |
43+
44+
The operators you use stay the same, but keep in mind - for an operation to happen on the GPU, it needs to involve a Cyfra value type.
45+
46+
## Using Uniforms
47+
48+
In the previous example, the GFunction only took a float array as an input. There is, however, a way to provide additional parameters to each run. This has to do with the first type parameter of `GFunction` that was set to `GStruct.Empty` in the previous example. This is the Uniform structure that can be provided for each GFunction.
49+
50+
```scala
51+
case class FunctionParam(a: Float32) extends GStruct[FunctionParam]
52+
53+
@main
54+
def multiplyByTwo(): Unit =
55+
VkCyfraRuntime.using:
56+
val input = (0 until 256).map(_.toFloat)
57+
58+
val doubleIt: GFunction[FunctionParam, Float32, Float32] = GFunction:
59+
(params: FunctionParam, x: Float32) =>
60+
x * params.a
61+
62+
val params = FunctionParam(2.0f)
63+
64+
val result: Array[Float] = doubleIt.run(input, params)
65+
66+
println(s"Output: ${result.take(10).mkString(", ")}...")
67+
```
68+
69+
You can see that the lambda in GFunction takes `FunctionParam`. The GStruct case class can be any product of any Cyfra values (including other structs).
70+
71+
## If-else becomes when-otherwise
72+
73+
Because in Cyfra we live in a different (GPU) world, it is required to use alternative control expressions. The most basic one is the `when`(-`elseWhen`-)`otherwise`:
74+
```scala
75+
val multiplyIt: GFunction[FunctionParam, Float32, Float32] = GFunction:
76+
(params: FunctionParam, x: Float32) =>
77+
when(x < 100f):
78+
x * params.a
79+
.elseWhen(x < 200f):
80+
x * params.a * 2f
81+
.otherwise:
82+
x * params.a * 4f
83+
```
84+
85+
## GSeqs
86+
87+
To iterate and express collections, Cyfra offers a `GSeq` type. It corresponds to a `LazyList` from Scala - a lazily evaluated sequence that can be transformed and consumed with familiar functional operations.
88+
89+
### Creating a GSeq
90+
91+
Use `GSeq.gen` to create a sequence by providing an initial value and a function that produces the next element:
92+
93+
```scala
94+
// Create from a known list of elements
95+
val colors = GSeq.of(List(red, green, blue))
96+
97+
// Generate integers: 0, 1, 2, 3, ...
98+
val integers = GSeq.gen[Int32](0, n => n + 1)
99+
100+
// Generate Fibonacci-like pairs using Vec2: (0,1), (1,1), (1,2), (2,3), ...
101+
val fibonacci = GSeq.gen[Vec2[Float32]]((0.0f, 1.0f), pair => (pair.y, pair.x + pair.y))
102+
103+
// Mandelbrot iteration: z = z² + c
104+
val mandelbrot = GSeq.gen(
105+
vec2(0.0f, 0.0f),
106+
z => vec2(z.x * z.x - z.y * z.y + cx, 2.0f * z.x * z.y + cy)
107+
)
108+
```
109+
110+
You must always call `.limit(n)` before consuming a GSeq to set a maximum iteration count (infinite sequences are not supported on GPU).
111+
112+
### Map, filter, takeWhile
113+
114+
Transform and filter sequences with familiar operations:
115+
116+
```scala
117+
// Map: transform each element
118+
val doubled = GSeq.gen[Int32](0, _ + 1).limit(100).map(_ * 2)
119+
120+
// Filter: keep only matching elements
121+
val evens = GSeq.gen[Int32](0, _ + 1).limit(100).filter(n => n.mod(2) === 0)
122+
123+
// TakeWhile: stop when condition becomes false
124+
val underTen = GSeq.gen[Int32](0, _ + 1).limit(100).takeWhile(_ < 10)
125+
```
126+
127+
These can be chained together:
128+
129+
```scala
130+
// Julia set iteration: iterate until escape or limit
131+
val iterations = GSeq
132+
.gen(uv, v => ((v.x * v.x) - (v.y * v.y), 2.0f * v.x * v.y) + const)
133+
.limit(1000)
134+
.map(length) // Transform to magnitude
135+
.takeWhile(_ < 2.0f) // Stop when magnitude exceeds 2
136+
```
137+
138+
### Fold, count, lastOr
139+
140+
Terminal operations consume the sequence and produce a result:
141+
142+
```scala
143+
// Count: number of elements that passed through
144+
val iterationCount: Int32 = GSeq
145+
.gen(vec2(0f, 0f), z => vec2(z.x*z.x - z.y*z.y + cx, 2f*z.x*z.y + cy))
146+
.limit(256)
147+
.takeWhile(z => z.x*z.x + z.y*z.y < 4.0f)
148+
.count
149+
150+
// Fold: reduce with accumulator
151+
val sum: Int32 = GSeq.gen[Int32](1, _ + 1).limit(10).fold(0, _ + _)
152+
153+
// LastOr: get final element (or default if empty)
154+
val finalValue: Int32 = GSeq.gen[Int32](0, _ + 1).limit(10).lastOr(0)
155+
```
156+
157+
**Every GSeq must have a hard `limit` of maximum elements it can hold**
158+
159+
160+
## Example usage
161+
162+
GFunction may be a simple construct, but it is enough to accelerate many applications. An example is a raytracer that would otherwise take a very long time to run on a CPU. Here is the implementation of a raytracer with Cyfra:
163+
164+
![Animated Raytracing](https://github.com/user-attachments/assets/3eac9f7f-72df-4a5d-b768-9117d651c78d)
165+
166+
Source:
167+
- [ImageRtRenderer.scala](https://github.com/ComputeNode/cyfra/blob/cab6b4cae3a3402a3de43272bc7cb50acf5ec67b/cyfra-foton/src/main/scala/io/computenode/cyfra/foton/rt/ImageRtRenderer.scala)
168+
- [RtRenderer.scala](https://github.com/ComputeNode/cyfra/blob/cab6b4cae3a3402a3de43272bc7cb50acf5ec67b/cyfra-foton/src/main/scala/io/computenode/cyfra/foton/rt/RtRenderer.scala)

gvecxt/cyfra docs/gpu-pipelines.md

Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,175 @@
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+
![Fluid Simulation](/img/full_fluid_8s.gif)
174+
175+
[View the implementation](https://github.com/ComputeNode/cyfra/tree/cab6b4cae3a3402a3de43272bc7cb50acf5ec67b/cyfra-fluids/src/main/scala/io/computenode/cyfra/fluids)

0 commit comments

Comments
 (0)