Skip to content
Kavachकवच

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]
records.kav: Kavach code, then what running it prints

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)]
frames.kav: Kavach code, then what running it prints

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:

Way300 frames, ms
Kavach, CPU (KAVACH_GPU=off)687
Kavach, CPU, the step in the map's lambda734
C++ -O2, one core364
C++ -O3 -march=native -fopenmp, 24 threads552–1,691 (it varies from run to run under WSL)
Kavach, GPU239
CUDA 12.4 (nvcc -O2), on Windows31, after 62–77 to open the GPU
  • Most of Kavach's GPU time is opening the GPU. The first map waits 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 map holds the list, as in ps = 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 map whose 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 named gpu 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 fn on 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.