19. Backends and targets

  • chapter 19 of 20
  • 6 min read

One source compiles two ways, and chapter 1 has already met both drivers. dawn is the JVM backend: dawn run compiles to bytecode and starts a JVM, dawn build app -o app.jar writes an executable jar, and it is the only one that can follow chapter 11 into use java. dawnc is the C backend: it emits C and hands it to cc, so dawnc build app -o app is a native executable with no JVM anywhere, and dawnc run builds one and runs it at once. check, test, fmt, doc, add and lsp exist on both.

Two of these are called native, and they are different roads:

CommandWhat you getuse java
dawn build app -o app.jarJVM bytecode in a jaryes
dawn build app --native -o appthat jar, compiled ahead of time by GraalVM native-imageyes
dawnc build app -o appC compiled by cc, with no JVM at allno

A program that does not call Java prints the same bytes under both. That is checked rather than hoped for: the repository runs its corpora through both backends and compares the output byte for byte, and the C backend compiles the compiler itself.

WebAssembly, and reactors

dawnc has one more target. dawnc build --target wasm app -o app.wasm compiles the same C for wasm32-wasip1 with clang (one with a WASI sysroot; DAWN_WASM_CC names another, such as wasi-sdk's). The result is an ordinary WASI command module: a runtime calls its _start once and the program runs to the end.

A page in a browser wants the other shape, a module that stays alive and is called once per event, and --reactor builds that. The module has no _start; it exports exactly three things: memory, _initialize and dawn_turn. The host calls _initialize once, after instantiating the module and before anything else, then calls dawn_turn with each message, and each call runs main once (spec.en.md §12.5). std/reactor's serve is what carries state from one turn to the next: it reads a line, hands it to your step function together with the state so far, and keeps the state the step returns. The site's Demo page is three of these (a counter, a to-do list and the site search), each built with dawnc build --target wasm --reactor and driven from JavaScript by packages/tea-dom. The wasm side only ever reads and writes messages, never a DOM node, and the same program answers a shell: echo '{"op":"init"}' | dawn run examples/projects/tea_dom_counter. The design is in dom-bridge-design.md, in Chinese.

GPUs: a device is an effect

std/gpu gives the host side of a GPU program the shape chapter 17 has been building: a Gpu effect whose operations allocate a buffer, upload to it, launch a kernel by name, wait, and download. A function that drives a device says !Gpu, and which device answers is up to whoever installs the handler. with_gpu_fake answers from a table in host memory, where launching a kernel calls the host reference function registered under its name. It is pure, so this runs anywhere, with no GPU, no driver and no !io:

use std/gpu.{Gpu, alloc, copy_from_host, copy_to_host, launch, sync, free, buffer_arg, erase, with_gpu_fake, reference_kernels}
use std/dtype.{F64}

# The host half of a GPU program: allocate, upload, launch, wait, read back.
# Its only effect is `!Gpu`; which device answers is the caller's choice.
fn vector_add(xs: List[Float], ys: List[Float]) -> Result[List[Float], ForeignError] !Gpu = {
  let n = len(xs)
  let a = alloc(F64, n)?
  let b = alloc(F64, n)?
  let out = alloc(F64, n)?
  copy_from_host(a, xs)?
  copy_from_host(b, ys)?
  launch("vadd", 1, [erase(buffer_arg(a)), erase(buffer_arg(b)), erase(buffer_arg(out))])?
  sync()?
  let got = copy_to_host(out)?
  free(a)?
  free(b)?
  free(out)?
  Ok(got)
}

fn unknown_kernel() -> Result[Unit, ForeignError] !Gpu = {
  let h = alloc(F64, 1)?
  launch("vmul", 1, [erase(buffer_arg(h))])
}

pub fn main() -> Unit !io = {
  # the fake device: a table in host memory, so this runs anywhere, and purely
  let sum = with_gpu_fake(reference_kernels(), () => vector_add([1.0, 2.0, 3.0], [10.0, 20.0, 30.0]))
  println("${sum}")
  # a kernel the device does not know is refused, not guessed at
  match with_gpu_fake(reference_kernels(), () => unknown_kernel()) {
    Ok(_) -> println("ran")
    Err(e) -> println("refused: ${e.kind}")
  }
}
Ok([11.0, 22.0, 33.0])
refused: gpu.no_kernel

reference_kernels() is the fake device's built-in table (vadd, vadd_bf16 and sum). The same vector_add, unchanged, runs on a real card under with_gpu_real(kernels, body), which answers the same operations from the CUDA driver, and whose kernels maps each name to its compiled module. That handler needs the C backend (on the JVM every operation answers gpu.unsupported_backend) and a machine with an NVIDIA driver.

copy_from_host and copy_to_host move values of the buffer's element type, not Floats under every format: a DeviceBuffer[Float] takes a List[Float], a DeviceBuffer[Int] takes Ints and keeps every bit of them (an i64 beyond 2^53 included), and a DeviceBuffer[BF16] takes BF16 values, so nothing is rounded on the way in. A format with no buffer cannot be moved: uploading a DeviceBuffer[Bool] is a type error, not a refusal at run time.

The kernels are Dawn as well, written against packages/tileir: its Dev effect records the operations a kernel performs, and the record is encoded as NVIDIA's Tile IR bytecode, which tileiras assembles into the module with_gpu_real loads. Recording is pure as well, so you can watch it here. This example needs packages/tileir, so it is a project with tileir under its [deps] (chapter 18) rather than one file, and it has no Playground link, because the Playground runs one file:

dawn · tileir
use std/dtype.{F64}
use std/str
use tileir/dev.{Dev, Param, load_cell, store_cell}
use tileir/prog.{trace2, cells, In, Out}
use tileir/render.{render}

# One tile block reads its cell of `x` and writes it to its cell of `out`.
# Nothing is copied here: the body runs once, under a handler that records it.
fn copy(x: Param[Float], out: Param[Float]) -> Unit !Dev = store_cell(out, load_cell(x))

# The operation a line of Tile IR performs: the word after `=`, if it has one.
fn op_of(line: String) -> Option[String] = match str.split_once(line, " = ") {
  Some((_results, rest)) -> Some(str.split(rest, " ")[0])
  None -> None
}

pub fn main() -> Unit !io = {
  let g = cells([256], [128])     # 256 elements in cells of 128: two tile blocks
  let (prog, _entry) = trace2("copy", In(F64, g), Out(F64, g), copy)
  for line in str.split(render(prog), "\n") {
    match op_of(line) {
      Some(op) -> println(op)
      None -> ()
    }
  }
}
make_token
assume
make_tensor_view
make_partition_view
get_tile_block_id
load_view_tko
assume
make_tensor_view
make_partition_view
store_view_tko

copy never saw a number. trace2 ran its body once under a handler that wrote each Dev operation down, and render prints that record as Tile IR text, one operation per line; the program above keeps only each line's operation. The In and Out markers say how the two parameters are cut, so the kernel body says nothing about shape: each tile block finds its cell (get_tile_block_id), loads it and stores it.

Chapter 20 is about writing kernels: what the markers say, what the recording refuses, loops, reductions, and how a host program runs one on the fake device and on a real card.

See also: spec.en.md §12.1, §12.3