GPU: Records and two lists
● Advanced3 min
Items can be records or tuples of 1 to 8 GPU-type fields, and two lists can run together through collections.zip.
use std.collections
type Particle {
x: Float32
v: Float32
}
gpu fn step(x: Float32, v: Float32) -> Float32 {
x + v * 0.5
}
gpu fn add(a: Float32, b: Float32) -> Float32 {
a + b
}
fn main() {
const particles = [Particle(x: 0.0, v: 2.0), Particle(x: 10.0, v: -4.0)]
print(particles.map(p => step(p.x, p.v)))
const xs: [Float32] = [1.0, 2.0, 3.0]
const ys: [Float32] = [10.0, 20.0, 30.0]
print(collections.zip(xs, ys).map(t => add(t.0, t.1)))
}The program ran and printed:
kavach run records.kav
[1.0, 8.0][11.0, 22.0, 33.0]
p => step(p.x, p.v) passes a record's fields to the gpu fn. zip pairs two lists into tuples, and t.0 and t.1 are each pair's items.
A whole record in, a whole record out
A gpu fn can also take a record and give a new one, so one map moves every item a step. In a frame loop the list stays in GPU memory from one frame to the next; only the last one comes back:
type Ball {
y: Float32
vy: Float32
}
gpu fn fall(b: Ball) -> Ball {
const vy = b.vy - 9.8 * 0.016
Ball(y: b.y + vy * 0.016, vy: vy)
}
fn main() {
let balls = [Ball(y: 100.0, vy: 0.0), Ball(y: 50.0, vy: 5.0)]
for _ in 0..60 {
balls = balls.map(fall)
}
print(balls)
}The program ran and printed:
kavach run frames.kav
[Ball(y: 95.40888, vy: -9.407997), Ball(y: 50.20893, vy: -4.4079995)]Inside, it reads fields (b.vy, or t.0 of a tuple) and makes a new record or tuple. It can't change the record it was given: the result is a new one. The answer is the same with KAVACH_GPU=off, on the CPU.
Light work, frame after frame
300 frames of a million light particles, against C++ and CUDA:
| Way | 300 frames, ms |
|---|---|
| Kavach, CPU (KAVACH_GPU=off) | 687 |
| Kavach, CPU, the step in the map's lambda | 734 |
| C++ -O2, one core | 364 |
| C++ -O3 -march=native -fopenmp, 24 threads | 552–1,691 (it varies from run to run under WSL) |
| Kavach, GPU | 239 |
| CUDA 12.4 (nvcc -O2), on Windows | 31, after 62–77 to open the GPU |
- Most of Kavach's GPU time is opening the GPU. The first
mapwaits about 235 ms for it, once per program; most of that is the GPU's driver at work. After that, a frame takes the GPU 0.23 ms, and one C++ core 1.2 ms. - CUDA's frames take 31 ms, Kavach's about 70. Both write each particle back where it was, so the particles can stay in the GPU's fast memory from frame to frame. Kavach does it when nothing but the
mapholds the list, as inps = ps.map(step); a list another variable still holds is never written over. Run on its own, the GPU code Kavach makes for the step takes 35–44 ms for the frames, as fast as the same written by hand. Written by hand to read each particle as one block of four numbers, it takes 19–22 ms. So Kavach now sends a record of four 32-bit fields to the GPU as one such block, in and out: side by side with the build before, the frames took 5–20% less time. The other 30 ms is the last line inside the timer,ps[999999]: it brings the whole list back from the GPU, 16 MB, into fresh memory the system first fills with zeros. Timed apart, that read takes 19–22 ms. Opening the GPU takes CUDA 90 ms. - A
mapwhose list stays on the GPU doesn't wait for the GPU to finish, so frames run back to back. A panic then shows a little later: see Same results as the CPU. - On the CPU,
ps = ps.map(…)is 2 times slower than one C++ core with the step in the lambda, and 1.9 times through the namedgpu fn. Either way, each frame writes each particle back where it is, as C++ does, instead of making a new list. - On the GPU, the same frames take 2.9 times less time than through the
gpu fnon the CPU, and all the ways give the same last particle. - Heavier work per item, more steps or forces between neighbours, is where the GPU wins: see the races on GPU.