19. 后端与目标

  • 第十九章,共二十章
  • 阅读约 6 分钟

同一份源码有两种编法,第 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 只跑单个文件:

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}

# 每个 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