同一份源码有两种编法,第 1 章已经见过这两个驱动。dawn 是 JVM 后端:dawn run 编成
字节码再起一个 JVM,dawn build app -o app.jar 写出可执行 jar,能跟着第 11 章走进
use java 的也只有它。dawnc 是 C 后端:它发出 C 交给 cc,所以 dawnc build app -o app
是一个哪里都没有 JVM 的 native 可执行文件,dawnc run 则是编出来马上运行。check、
test、fmt、doc、add、lsp 两边都有。
其中有两条都叫 native,它们是两条不同的路:
| 命令 | 得到什么 | use java |
|---|---|---|
dawn build app -o app.jar | 装在 jar 里的 JVM 字节码 | 可以 |
dawn build app --native -o app | 上面那个 jar,由 GraalVM native-image 预先编译 | 可以 |
dawnc build app -o app | 由 cc 编译的 C,全程没有 JVM | 不行 |
不调用 Java 的程序在两边打印出相同的字节。这是验过的,不是指望:仓库让语料走两个后端, 逐字节比对输出,而且 C 后端编得动编译器自己。
WebAssembly 与 reactor#
dawnc 还有一个目标。dawnc build --target wasm app -o app.wasm 用 clang 把同一份 C
编到 wasm32-wasip1(要一个带 WASI sysroot 的 clang;DAWN_WASM_CC 可以指定别的,
比如 wasi-sdk 的)。产物是一个普通的 WASI 命令模块:运行时调一次它的 _start,程序
一直跑到结束。
浏览器里的页面要的是另一种形状:一个一直活着、每来一个事件就被调一次的模块,--reactor
编的就是它。这种模块没有 _start,导出的恰好是三项:memory、_initialize 和 dawn_turn。
宿主实例化模块之后先调一次 _initialize,之后每来一条消息就调一次 dawn_turn,每次调用
跑一遍 main(spec.md §12.5)。把状态从这一轮带到下一轮的是 std/reactor 的 serve:它读
一行,连同到目前为止的状态一起交给你的 step 函数,再留下 step 返回的状态。站点的
演示页就是三个这样的 reactor(一个计数器、
一个待办列表和站内搜索),各自用 dawnc build --target wasm --reactor 编出,由
packages/tea-dom 从 JavaScript 驱动。wasm 这边只读写消息,从不碰 DOM 节点;同一个程序
在 shell 里也能应答:echo '{"op":"init"}' | dawn run examples/projects/tea_dom_counter。
设计见 dom-bridge-design.md。
GPU:设备是一个效果#
std/gpu 给 GPU 程序的宿主一侧套上第 17 章一直在搭的那个形状:一个 Gpu 效果,它的操作
分配缓冲区、往里上传、按名字启动 kernel、等待、再下载。驱动设备的函数写 !Gpu,由哪台
设备应答取决于装 handler 的人。with_gpu_fake 用宿主内存里的一张表应答,启动 kernel 就是
调用登记在这个名字下的宿主参考函数。它是纯的,所以下面这段在哪儿都能跑,不要 GPU、不要
驱动,也不要 !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}
# GPU 程序的宿主一半:分配、上传、启动、等待、读回。
# 它唯一的效果是 `!Gpu`;由哪台设备应答,是调用方的选择。
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 = {
# 假设备:宿主内存里的一张表,所以在哪儿都能跑,而且是纯的
let sum = with_gpu_fake(reference_kernels(), () => vector_add([1.0, 2.0, 3.0], [10.0, 20.0, 30.0]))
println("${sum}")
# 设备不认识的 kernel 会被拒绝,而不是瞎猜
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() 是假设备自带的表(vadd、vadd_bf16 和 sum)。同一个
vector_add 一字不改,就能在真卡上跑在 with_gpu_real(kernels, body) 底下:它用 CUDA
驱动应答同样的操作,kernels 把每个名字映到编译好的模块。这个 handler 需要 C 后端
(在 JVM 上每个操作都答 gpu.unsupported_backend),还需要一台装了 NVIDIA 驱动的机器。
copy_from_host 与 copy_to_host 搬的是缓冲元素类型的值,不是每种格式都过一遍 Float:DeviceBuffer[Float] 收
List[Float],DeviceBuffer[Int] 收 Int 且每一位都保留(超出 2^53 的 i64 也在内),DeviceBuffer[BF16] 收 BF16
值,所以上传时什么也不舍入。没有缓冲的格式搬不了:上传 DeviceBuffer[Bool] 是类型错误,不是运行期拒绝。
kernel 本身也是 Dawn,对着 packages/tileir 写:它的 Dev 效果把 kernel 执行的操作记录
下来,记录再编码成 NVIDIA 的 Tile IR 字节码,由 tileiras 汇编成 with_gpu_real 装载的
模块。记录同样是纯的,所以这里就能看它发生。这个例子要用 packages/tileir,所以它是一个
[deps] 里写着 tileir 的项目(第 18 章),不是单个文件;它也没有 Playground 链接,因为
Playground 只跑单个文件:
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}
# 每个 tile 块读 `x` 里属于自己的那一格,写进 `out` 里属于自己的那一格。
# 这里一个数也没拷:函数体只跑一次,跑在一个把它记录下来的 handler 底下。
fn copy(x: Param[Float], out: Param[Float]) -> Unit !Dev = store_cell(out, load_cell(x))
# 一行 Tile IR 执行的操作:`=` 后面的那个词,如果有的话。
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 个元素,每格 128:两个 tile 块
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 从头到尾没见过一个数。trace2 在一个 handler 底下把它的函数体跑了一次,handler 把每个
Dev 操作记下来;render 把这份记录打印成 Tile IR 文本,一行一个操作,上面的程序只留下每行的
操作名。两个参数怎么切由 In 和 Out 标记说,所以 kernel 体里一个形状也不写:每个 tile 块
找到自己的格子(get_tile_block_id),载入,再存回去。
第 20 章讲怎么写 kernel:标记说了什么、记录拒绝什么、循环、归约,以及宿主程序怎样在假设备 和真卡上跑它。
另见:spec.md §12.1、§12.3