Skip to content
Kavachकवच

GPU: Can I use it on the GPU?

● Advanced4 min

Pointers, inout, files and recursion stay on the CPU; helpers, loops and pipelines of maps run on the GPU. Every rule is checked when compiling.

In GPU codeAllowed?How
Calling another gpu fnYesSplit work into small gpu fns that call each other.
if, while, for over 0..nYesLoops may run as long as each item needs.
A pipeline: map after mapYesChain them in one expression, xs.map(f).map(g): they run as one pass on the GPU, and the list between them is never made.
sum, min or max after a GPU mapYesOn whole numbers they run on the GPU too, and only the result comes back.
filter after a GPU mapYesIt runs on the GPU too when its items are numbers, not yet records or tuples, and its lambda fits a gpu fn. The kept items stay there. Otherwise it runs on the CPU cores.
sort after a GPU mapYesAllowed, and it runs on the CPU cores, on the results.
Records and tuples as itemsYesOf 1 to 8 fields of GPU types: see Records and two lists.
Pointers (Ptr)NoPass the numbers themselves; map hands each item over for you.
inout parametersNoGive the new value back as the result, and map collects the results. This holds for a gpu fn called from another too: one rule for every gpu fn.
Files, network, print, AINoRead the file before the map and write the results after it: a GPU can't reach the outside world.
Text, lists or maps inside a gpu fnNoThe list is what map goes over; inside, work on one item's numbers.
RecursionNoUse a while loop: a GPU has no call stack.
try, catch, fail, or ! in the signatureNoCheck values with if and give a result for every item: on the GPU there is no error to give back.
Lambdas inside a gpu fnNoWrite another gpu fn and call it.

A pipeline of maps

gpu fn glow(x: Float32) -> Float32 {
    x * 0.5 + 0.1
}

gpu fn dim(x: Float32) -> Float32 {
    x * 0.9
}

fn main() {
    const xs: [Float32] = [1.0, 2.0, 3.0]
    const soft = xs.map(p => glow(p)).map(p => dim(p))
    print(soft)
    const lit = xs.map(p => glow(p))
    print(lit.map(p => dim(p)))
    print(soft.filter(v => v > 1.0).sum(v => v))
}

The program ran and printed:

kavach run chain.kav

[0.54, 0.99, 1.4399999][0.54, 0.99, 1.4399999]1.4399999
chain.kav: Kavach code, then what running it prints
  • Chained in one expression, xs.map(…).map(…) runs as one pass on the GPU: each item goes through both functions, and the list between them is never made, copied back or stored.
  • In two statements, the list in the middle, lit, is made, and stays only in GPU memory: only the GPU reads it, so it is never copied back.
  • Either way the numbers are the same. On a long list the filter at the end runs on the GPU too, and the sum of floats on the CPU.

A loop of maps

A map's result stays on the GPU until the CPU reads it. So a loop of 100 maps copies the list back once, at the first read after the loop. A sum, min or max of whole numbers runs on the GPU too, and only its result comes back, even from xs.map(f).sum(g) in one expression:

gpu fn step(x: Int32) -> Int32 {
    (x * 7 + 3) % 1_009
}

fn main() {
    let xs: [Int32] = []
    for i in 0..1_000 {
        xs.push(Int32(i))
    }
    for _ in 0..100 {
        xs = xs.map(x => step(x))
    }
    print(xs[7])
    print(xs.sum(x => x))
    print(xs.max() ?? 0)
}

The program ran and printed:

kavach run loop.kav

6935036081008
loop.kav: Kavach code, then what running it prints

A loop that adds up such sums doesn't wait for each one: see Sums in a loop.

You don't see any of this: every read sees the list's items. A panic in a map keeps its message and line, but may show later: see Same results as the CPU.

A GPU adds the items in another order, so it checks every partial sum. If any running sum, added in order as the CPU would, leaves the type, the CPU adds the list again in order, so the panic names the same step:

fn main() {
    const xs: [Int32] = [2_147_483_000, 600, 47, 1]
    print(xs.sum(x => x))
}

The program ran, then stopped with a panic:

kavach run sum-overflow.kav

panic: integer overflow: 2147483647 + 1 doesn't fit in Int32
sum-overflow.kav: Kavach code, then what running it prints

For speed on the GPU, keep the CPU out of the middle: chain maps in one expression, and read a GPU list only when you need its items. The less a list travels between GPU and CPU, the faster.

A file bigger than a chunk

A for chunk in fs.chunks(path, size) loop with pieces of 1 MB or more reads ahead: in a native program, the next piece is read on a helper thread while the loop works on this one. There is nothing to write. A 2 GB file of Int32s, 32 chunks of 64 MB, on Windows. Each row ran 5 times, reading ahead off and on in turns, in ms:

WorkReading ahead offOn
Read and as_list, no GPU1,817–1,9351,135–1,184
A gpu fn of 1 step, then sum3,775–4,3972,908–3,386
A gpu fn of 200 steps, then sum (stream.kav)5,801–7,7755,120–5,673
  • The file was already in the system's cache, so the disk was as fast as it gets. A file read from the disk itself isn't measured.
  • The gain on the 200 steps is larger than the disk's share of the time explains, and that isn't understood yet.

A loop that adds up what the GPU works out doesn't wait for each sum either: see Sums in a loop. Left of the plan for data streaming through the GPU: as_list as a view when the chunk is still held, and network chunks.

Helpers and loops

use std.math

gpu fn square(x: Float32) -> Float32 {
    x * x
}

gpu fn hypotenuse(a: Float32, b: Float32) -> Float32 {
    math.sqrt(square(a) + square(b))
}

fn main() {
    print(hypotenuse(3.0, 4.0))
}

The program ran and printed:

kavach run helper.kav

5.0
helper.kav: Kavach code, then what running it prints
gpu fn steps(start: Int32) -> Int32 {
    let n = start
    let count = 0
    while n != 1 {
        if n % 2 == 0 {
            n = n / 2
        } else {
            n = 3 * n + 1
        }
        count += 1
    }
    count
}

fn main() {
    const starts: [Int32] = [6, 7, 27]
    print(starts.map(s => steps(s)))
}

The program ran and printed:

kavach run collatz.kav

[8, 16, 111]
collatz.kav: Kavach code, then what running it prints

Refused when compiling

gpu fn first(p: Ptr<Float32>) -> Float32 {
    1.0
}

The compiler refused the program:

kavach run pointer.kav

error: `p` is a `Ptr<Float32>`, and a `gpu fn` works with `Float32`, `Int32`, `UInt32` and `Bool`, and records and tuples of 1 to 8 of them (spec 15.1)
pointer.kav: Kavach code, then what running it prints
gpu fn bump(inout x: Float32) {
    x = x + 1.0
}

The compiler refused the program:

kavach run inout.kav

error: a `gpu fn` can't take `inout` parameters: give the new value back instead, as `gpu fn bump(x: Float32) -> Float32` (spec 15.1)
inout.kav: Kavach code, then what running it prints
use std.fs

gpu fn load(x: Float32) -> Float32 {
    const text = fs.read_text("gains.txt") catch _ => ""
    x
}

The compiler refused the program:

kavach run files.kav

error: a `gpu fn` can't use `fs.read_text`: it reaches outside the program (files, the network, the terminal, time, AI), and on the GPU a `gpu fn` runs alone, item by item, with only its own values (spec 15.1)
files.kav: Kavach code, then what running it prints
gpu fn factorial(n: Int32) -> Int32 {
    if n <= 1 {
        return 1
    }
    n * factorial(n - 1)
}

The compiler refused the program:

kavach run recursion.kav

error: `factorial` calls itself, directly or through other `gpu fn`s: a GPU has no call stack (spec 15.1)
recursion.kav: Kavach code, then what running it prints
gpu fn half(x: Int32) -> Int32 {
    if x < 0 {
        fail Error("negative")
    }
    x / 2
}

The compiler refused the program:

kavach run fail.kav

error: a `gpu fn` can't use `try`, `catch` or `fail`: it can't fail, since on the GPU there is no error to give back. Check the value first with `if`, and give a result for every item (spec 15.1)
fail.kav: Kavach code, then what running it prints