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:
| Command | What you get | use java |
|---|---|---|
dawn build app -o app.jar | JVM bytecode in a jar | yes |
dawn build app --native -o app | that jar, compiled ahead of time by GraalVM native-image | yes |
dawnc build app -o app | C compiled by cc, with no JVM at all | no |
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:
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