English | 中文
本教程写给会一门主流编程语言、没见过 Camel 的读者。读完之后你能写出并运行通用的 Camel 程序,知道它与别的语言最不一样的地方在哪里,并且会用命令行上的遍去看图、优化、换后端、导出与求梯度。
每一节的程序都在 examples/ 目录里,文中的输出是它们的真实输出。命令都从仓库根目录执行。
Camel 是图原生的通用编程语言:程序只表达计算意图——要算什么,谁依赖谁;语言里的每个元素直接对应计算图上的一块结构。读的时候请一直带着这个问题:这段代码在图上是什么?
目录: 1 第一个程序 · 2 值与类型 · 3 函数 · 4 分支与 match · 5 数组与高阶算子 · 6 按依赖求值、sync 与 wait · 7 闭包与函数值 · 8 泛型 · 9 错误处理 · 10 可空的值 · 11 映射与集合 · 12 模块与标准库 · 13 看图 · 14 换遍 · 15 张量与模型的导出 · 16 求梯度 · 接下来
先得到 camel 可执行文件:按 setup.cn.md 从源码构建,并把 out/latest/bin 加入 PATH。camel --version 能打印版本就可以开始了。
module hello
func main(): int sync {
println('Hello, Camel!')
return 0
}
camel docs/tutorial/examples/01_hello.cmlHello, Camel!
- 一个
.cml文件是一个模块,module hello给它起名(可以省略)。 - 程序从
main开始。main返回的int是进程的退出码。 - 返回类型后面的
sync表示函数体是同步块:里面的语句按书写顺序执行。现在先记住“要打印的函数,函数体前写sync”,第 6 节解释为什么。 - 语句之间换行即可,不需要分号。
module values
type Point = { x: int, y: int }
func main(): int sync {
let n = 42 // int (64-bit)
let ratio = 0.75 // float (64-bit)
let ok = true // bool
let name = 'camel' // string
let xs = [1, 2, 3] // int[]
let pair = (1, 'one') // (int, string)
let p: Point = { x: 3, y: 4 } // a struct
println(n, ratio, ok, name)
println(xs, xs[0], len(xs))
println(pair, pair.0, pair.1)
println(p, p.x + p.y)
// No implicit numeric conversion: write float(n) or int(ratio).
println(float(n) * ratio)
println(int(3.99), string(n) + '!')
// let cannot be assigned again; var can.
var count = 0
count = count + 1
count += 10
println(count)
return 0
}
42 0.75 true camel
[1, 2, 3] 1 3
(1, one) 1 one
{ x: 3, y: 4 } 7
31.5
3 42!
11
要点:
- 类型是静态的,通常能推出来,不用写。基本类型是
int、float、bool、string;复合类型是数组T[]、元组(A, B)、结构体{ x: A }。type给类型起名字。 - 数值类型之间不做隐式转换。
n * ratio是编译期错误,要写float(n) * ratio。 let是不可变的名字,var是可变的变量。Camel 崇尚不可变:大多数程序几乎用不到var。- 字段与元素不能赋值(
p.x = 5、xs[0] = 5是错误)。要“修改”就造一个新值:{ x: 5, y: p.y }、concat(xs, [4])。 - 字符串用单引号或双引号都行。字符串按字节:
len('中文')是 6。切分、修剪、查找等函数在str模块里(第 12 节)。 - 两个书写上的细节:调用的括号要紧贴函数名(
f(1),不是f (1));一条语句写不下要换行时,把运算符留在行尾,或让下一行以->、.、*、&&这类只能是二元的运算符开头(以-、+开头的一行是新语句)。
module functions
func square(x: int): int {
return x * x
}
func fib(n: int): int {
return if n <= 1 then n else fib(n - 1) + fib(n - 2)
}
// A tail-recursive sum: this is how Camel writes a loop.
func sum_to(n: int, acc: int): int {
return if n == 0 then acc else sum_to(n - 1, acc + n)
}
// `with` parameters configure a function; ordinary parameters flow through it.
with <factor: int>
func scale(x: int): int {
return x * factor
}
func main(): int sync {
println(square(7))
println(fib(20))
println(sum_to(100000, 0))
println(scale<3>(5))
println(5->square->scale<2>) // the same as scale<2>(square(5))
return 0
}
49
6765
5000050000
15
50
- 函数是一张子图,调用是图上的一个节点。 第 13 节会亲眼看到。
- Camel 目前没有循环语法(没有
for、while)。重复的计算写成递归,或者用下一节的高阶算子。尾位置的调用不占栈帧,所以sum_to递归十万层没有问题;非尾递归(如fib)同时打开的调用默认不能超过 1000 层(--max-recursion-depth可调)。 - 两种参数。 圆括号里的是函数要处理的输入;
with <...>里的是函数被配置的属性(上下文参数),调用时写在尖括号里。别的语言里放在对象成员里的配置,在 Camel 里是with参数。 - 管道
->把左边的值作为右边函数的实参,a->f->g就是g(f(a))。数据流写成一条链,正好就是图上的一条路径。
module branches
func sign(x: int): string {
return if x > 0 then 'positive' else if x < 0 then 'negative' else 'zero'
}
func kind(c: string): string {
return match c {
case 'a' | 'e' | 'i' | 'o' | 'u' => 'vowel'
case ' ' => 'space'
case _ => 'other'
}
}
func grade(score: int): string {
let band = score / 10
return match band {
case 10 | 9 => 'A'
case 8 => 'B'
case 7 => 'C'
case _ => 'D'
}
}
func main(): int sync {
println(sign(5), sign(-2), sign(0))
println(kind('e'), kind(' '), kind('x'))
println(grade(95), grade(81), grade(42))
return 0
}
positive negative zero
vowel space other
A B D
if ... then ... else ...是表达式,有值;两臂的类型必须相同。match匹配的是一个名字(所以grade里先let band = ...),模式是字面量或_,一个case可以用|列多个模式。case _必须写:没有默认分支的match是编译期错误(对bool写全true与false时除外)。
在图上,分支的每个臂是它自己的一张子图。所以臂的块里写的 return 给出的是这个分支的值,不是离开函数。examples/04_block_return.cml:
module block_return
// The `if` is a statement whose value nobody uses, so the two `return`s have no effect.
func wrong(x: int): string {
if x < 0 then { return 'negative' } else { return 'not negative' }
return 'reached the end'
}
// The value of the `if` is what the function returns.
func right(x: int): string {
return if x < 0 then { return 'negative' } else { return 'not negative' }
}
func main(): int sync {
println(wrong(-1))
println(right(-1))
return 0
}
reached the end
negative
wrong(-1) 得到的是 'reached the end'。编译器看得出这种写法,对两个 return 各给一条警告:
warning[SemanticDiag::ExitInUnusedBranch]: This 'return' gives the value of the 'if' it is in, and nothing uses that value: it does not leave the function.
--> docs/tutorial/examples/04_block_return.cml:7:21
|
7 | if x < 0 then { return 'negative' } else { return 'not negative' }
| ^^^^^^^^^^^^^^^^^
note: the value of this 'if' is not used
Camel 里没有“提前返回”。让分支成为函数体的最后一个表达式,或者像 right 那样在它前面写 return。
module arrays
func main(): int sync {
let xs = range(1, 11) // [1, ..., 10]
let squares = xs->map<(x: int): int => x * x>
let evens = squares->filter<(x: int): bool => x % 2 == 0>
let total = evens->reduce<(acc: int, x: int): int => acc + x, 0>
println(xs)
println(squares)
println(evens)
println(total)
// Arrays are values: slice and concat return new arrays.
let ys = concat(xs->slice<0, 3>, [99])
println(ys, len(ys), head(ys))
// foreach runs its function for each element, in order.
['a', 'b', 'c']->foreach<(s: string) => sync { println('item', s) }>
return 0
}
[1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
[1, 4, 9, 16, 25, 36, 49, 64, 81, 100]
[4, 16, 36, 64, 100]
220
[1, 2, 3, 99] 4 1
item a
item b
item c
(x: int): int => x * x是 lambda。传给map的函数写在尖括号里,因为它是map的with参数:数组是流过的数据,函数是配置。map、filter的各个元素之间没有先后,并行的后端可以同时算它们,结果仍按位置排列;reduce、foreach按元素顺序执行。foreach里的 lambda 写的是=> sync { ... }:要产生效应的函数体必须是同步块。下一节解释。
map 的各个元素之间没有执行顺序的约定。元素函数是纯的时候这无关紧要;一旦它调用了有状态的东西,结果就取决于哪个元素先算。examples/05_map_order.cml 用随机数发生器演示(randint 每调用一次,隐藏的状态前进一步):
module map_order
import { seed, randint } from random
func main(): int sync {
seed(42)
// Wrong tool: randint advances a hidden state, so each element depends on which ran first.
let drawn = range(0, 8)->map<(i: int): int => randint(0, 99)>
println(drawn)
seed(42)
// reduce runs in element order on every back end.
let ordered = range(0, 8)->reduce<(acc: int[], i: int): int[] => concat(acc, [randint(0, 99)]), [0]->slice<0, 0>>
println(ordered)
return 0
}
在顺序执行的后端上(默认的 std::nvm,以及 std::fvm、std::jit),两行相同,每次运行都一样:
[75, 63, 75, 13, 90, 9, 57, 37]
[75, 63, 75, 13, 90, 9, 57, 37]
换成并行调度 camel docs/tutorial/examples/05_map_order.cml std::prl,第一行的顺序就不再有保证,每次运行可能不同;下面是其中一次的输出:
[37, 57, 9, 90, 13, 75, 63, 75]
[75, 63, 75, 13, 90, 9, 57, 37]
这是语言规定的行为(设计原则 2.7),不是缺陷:正因为元素之间没有顺序,map 才能并行。第二行用的 reduce 按元素顺序执行,在所有后端上结果相同。规则很简单:传给 map、filter 的函数保持纯;必须按顺序做的事用 foreach、reduce 或尾递归。
这是 Camel 最不同于其他语言的地方,值得慢慢读。
在大多数语言里,语句一条接一条执行。在 Camel 里,默认情况下求值由依赖驱动:一个表达式被求值,是因为有人要用它的结果。互不依赖的表达式没有先后,可以并行。
这带来一个推论:在同步块之外,一个结果没人用的调用不会执行,哪怕它有副作用。examples/06_unused_effect.cml:
module unused_effect
func greet(): int {
println('hello from greet') // not used, not in a sync block: never runs
return 1
}
func main(): int sync {
println(greet())
return 0
}
camel docs/tutorial/examples/06_unused_effect.cmlwarning[SemanticDiag::EffectNotExecuted]: The call of 'println' has side effects but is never executed: nothing depends on it and it is not in a sync block.
--> docs/tutorial/examples/06_unused_effect.cml:6:5
|
6 | println('hello from greet') // not used, not in a sync block: never runs
| ^^^^^^^
help: Evaluation is driven by dependencies: outside a sync block an expression whose result (or the
variable it writes) nothing needs does not run. This is legal. To run it, put it in a sync
block ('sync { ... }', or a function body 'func f(): T sync { ... }'), or use its result.
1
只打印了 1。greet 的返回值 1 不依赖那个 println,所以它在图上是一个没有人要的节点。这不是错误,是语言的规则;编译器给出警告,是为了让忘写的 sync 被看见。
写在块前的 sync 使它成为同步块:块里的表达式按书写顺序执行,相当于编译器在相邻的表达式之间加了控制边。examples/06_sync_block.cml 只把 greet 的函数体改成同步块:
func greet(): int sync {
println('hello from greet')
return 1
}
hello from greet
1
在同步块里调用什么、有没有副作用都无所谓,因为已经有顺序了。输入输出就写在同步块里。
main 的函数体是同步块,那它调用的函数里的 println 会执行吗?不会。函数编译成它自己的子图,调用只是把这个子图节点放进更大的图里,不改变它的结构。examples/06_callee_decides.cml:
module callee_decides
func log(x: int): int {
println(x) // result unused, body is not a sync block: not executed
return x + 1
}
func main(): int sync {
let a = log(1)
let b = log(a)
println(b) // prints 3, and nothing else
return 0
}
3
两次 log 调用按顺序执行了(b 是 3),但 println(x) 属于 log 自己的函数体,那里不是同步块(编译器照样给出 EffectNotExecuted 警告)。要打印,把 log 声明为 func log(x: int): int sync { ... }。
sync 还可以写在 func 之前。那是函数修饰符:声明这个函数有副作用、需要被同步。它不会使函数体成为同步块。examples/06_sync_func.cml:
module sync_func
sync func f(): int {
println('f') // still not executed
return 1
}
sync func g(): int sync {
println('g') // executed: the body is a sync block
return 2
}
func main(): int sync {
println(f() + g())
return 0
}
g
3
前缀的 sync 修饰函数,后缀的 sync 修饰函数体这个块;两者含义不同,可以并用。
Camel 里一切值按引用传递。形参前写 var 表示函数会修改它;修改就是写到调用方的那个变量上。把 let 传给 var 形参是编译期错误(examples/06_let_to_var.cml):
error[SemanticDiag::ImmutablePassedToVarParam]: 'k' is immutable and cannot be passed to a var parameter of 'bump'.
--> docs/tutorial/examples/06_let_to_var.cml:11:10
|
11 | bump(k)
| ^
help: Only a variable declared with 'var' (or a var parameter) can be modified. Declare it with
'var', or bind the new value to a new name.
有了可变的变量,就有了顺序问题:读要排在哪次写之后?同步块是一种回答。另一种更局部的回答是 wait:它作用于后面的一个表达式,让用到这个表达式的值、或用到被它修改的变量的前后操作与它有序。examples/06_wait.cml:
module wait_demo
sync func bump(var counter: int) {
wait counter += 1
}
// Not a sync block: the order comes from `wait` and from the data dependencies.
func twice(): int {
var c = 0
wait bump(c)
wait bump(c)
return c
}
func main(): int sync {
println(twice())
return 0
}
2
twice 的函数体不是同步块,但两次 bump 都执行了,并且在 return c 读 c 之前:wait 让编译器按对变量 c 的使用插入了控制边。几点补充:
wait靠var的使用来排序。被等待的表达式不涉及任何var(比如只是打印)时,wait没有可依据的东西;这类调用写在同步块里。- 在同步块里写不写
wait都一样。 wait modify(i)之后如果再没有人读i,这次修改不执行——还是按依赖求值。
日常的建议很简单:计算写成 let 与纯函数;输入输出放进同步块;var 与 wait 留给确实需要原地更新的少数地方。
module closures
func inc(x: int): int { return x + 1 }
func dbl(x: int): int { return x * 2 }
func apply_twice(f: (x: int) => int, x: int): int {
return f(f(x))
}
func adder(n: int): (x: int) => int {
return (x: int): int => x + n
}
func main(): int sync {
let g = inc // a function name is a value
println(g(1), apply_twice(dbl, 3))
let add5 = adder(5) // a closure that captured n
println(add5(10))
let ops = [inc, dbl] // functions in an array
println(ops->map<(f: (x: int) => int): int => f(10)>)
var total = 0 // captured by reference
[1, 2, 3]->foreach<sync (x: int) => sync { total = total + x }>
println(total)
return 0
}
2 12
15
[11, 20]
6
- 函数是值:可以赋给名字、作实参、作返回值、放进数组。函数类型写作
(x: int) => int。 - lambda 捕获外层的变量,捕获是引用。最后一个 lambda 写入了被捕获的
total,所以它前面有函数修饰符sync(声明有副作用),函数体又是同步块(=> sync { ... })。 - 同名而形参类型不同的函数是重载;代表一组重载的名字只有在期望的函数类型能选出其中一个的位置上才能当值用(传给形参类型写明的函数),其他位置写一个 lambda 包一层。只有一个签名的库算子(如
trim)可以直接当值:xs->map<trim>。
module generics
type Pair<T> = (T, T)
func first(xs: (typeas T)[]): T {
return xs[0]
}
func swap(p: Pair<typeas T>): Pair<T> {
return (p.1, p.0)
}
func my_map(xs: (typeas T)[], f: (x: T) => typeas U): U[] {
return xs->map<f>
}
func main(): int sync {
println(first([1, 2, 3]))
println(first(['a', 'b']))
println(swap((1, 2)), swap(('x', 'y')))
println(my_map([1, 2, 3], (x: int): string => string(x) + '!'))
return 0
}
1
a
(2, 1) (y, x)
[1!, 2!, 3!]
- 没有单独的类型参数表。在签名里类型变量第一次出现的位置写
typeas T,把那个位置上的类型绑定为T,之后直接用T。 type Pair<T> = (T, T)是带参数的类型声明:Pair<int>就是(int, int)。- 在图上,泛型函数是一张子图模板。每用一组具体类型调用,就实例化出一张类型全部具体的子图:上面的程序里有
first<int>与first<string>两张子图(camel docs/tutorial/examples/08_generics.cml std::rgir可以看到)。图上只有具体类型,运行时不需要类型信息。 - 泛型函数也可以出现在任何期望一个函数的位置(
apply(id, 3)、xss->map<reverse>、let f: (x: int) => int = id):编译器在所在位置确定了类型之后才实例化它。什么都确定不了时(let g = id)是编译期错误,提示写出类型。
Camel 没有异常,没有 try/catch。可能失败的函数把返回类型声明为 T!:它的值要么是一个 T,要么是一个错误。examples/09_errors.cml:
module errors
func safe_div(a: int, b: int): int! {
return if b == 0 then {
throw error('DivByZero', 'the divisor is zero')
} else {
return a / b
}
}
func inc(x: int): int { return x + 1 }
func describe(r: int!): string {
return match r {
case error e => e.kind + ': ' + e.message
case v => 'ok ' + string(v)
}
}
func main(): int sync {
println(safe_div(9, 3) !! -1) // 3
println(safe_div(9, 0) !! -1) // -1: the substitute
println(describe(safe_div(8, 2) ?-> inc))
println(describe(safe_div(8, 0) ?-> inc))
println(parse_int('12') !! 0, parse_int('twelve') !! 0)
println(safe_div(1, 0))
return 0
}
3
-1
ok 5
DivByZero: the divisor is zero
12 0
error DivByZero: the divisor is zero
throw e是返回一个错误值,与return是同一种结构。它不展开调用栈;调用方拿到的就是一个int!的值。int!不能当int用,必须先拆开:a !! b:出错取b,否则取里面的值;x ?-> f:出错就把错误原样传下去,否则调用f,可以连成一串;match的case error e(e.kind、e.message、e.location)与case v。
- 在图上,这三种写法都是一个分支,条件是值上的一位标记,与
if是同一种结构。
错误与故障是两回事。 除数为零、下标越界、递归过深是故障,不是错误值:程序带位置终止,!! 接不住。examples/09_fault.cml 读 xs[5]:
before
error[RuntimeDiag::IndexOutOfRange]: Index 5 is out of range for an array of length 3.
--> docs/tutorial/examples/09_fault.cml:8:15
|
8 | println(xs[5])
| ^
help: Ensure the index is within the valid range of the collection.
T? 的值要么是 null,要么是一个 T。examples/10_nullable.cml:
module nullable
func find(xs: int[], target: int, i: int): int? {
return if i >= len(xs) then null
else if xs[i] == target then i
else find(xs, target, i + 1)
}
func show(x: int?): string {
return match x {
case null => 'not found'
case v => 'at ' + string(v)
}
}
func main(): int sync {
let xs = [10, 20, 30]
println(find(xs, 20, 0) ?? -1)
println(find(xs, 99, 0) ?? -1)
println(show(find(xs, 30, 0)), show(find(xs, 31, 0)))
let maybe: int? = null
println(maybe)
return 0
}
1
-1
at 2 not found
null
a ?? b:为空取b。match用case null与case v。- 普通的
int进入int?的位置时自动装入:find的两条臂一条是null、一条是i,分支就是int?;实参(show(5))、带类型的声明(let maybe: int? = 5)、return同理。反过来不行:int?要当int用,必须用??或match拆开。
Map<K, V> 与 Set<T> 是内建类型。没有专门的字面量:写一个数组,再用 as 转换。examples/11_collections.cml:
module collections
func main(): int sync {
let ages = [('bob', 31), ('alice', 28)] as Map<string, int>
println(ages) // printed in key order
println(ages['alice'] ?? 0, ages['carol'] ?? 0)
let more = ages->put<'carol', 45>->remove<'bob'>
println(more, len(more))
println(ages) // unchanged: every operation returns a new value
println(keys(more), values(more))
let seen = [3, 1, 2, 3, 1] as Set<int>
println(seen, seen->contains<2>, seen->contains<9>)
println(union(seen, [9] as Set<int>), intersect(seen, [1, 9] as Set<int>))
return 0
}
{alice: 28, bob: 31}
28 0
{alice: 28, carol: 45} 2
{alice: 28, bob: 31}
[alice, carol] [28, 45]
{1, 2, 3} true false
{1, 2, 3, 9} {1}
- 所有操作返回新值,原来的映射与集合不变。
m[k]是查找,结果是V?,所以后面跟?? 默认值。- 遍历:先
keys(m)、items(m)取出数组,再用map、reduce、foreach。
examples/shapes.cml 是一个库模块,用 export 标出对外可见的名字:
module shapes
import { sqrt } from math
export type Vec = { x: float, y: float }
export func hypot(v: Vec): float {
return sqrt(v.x * v.x + v.y * v.y)
}
export func area(w: float, h: float): float {
return w * h
}
examples/12_modules.cml 使用它和标准模块 math:
module modules_demo
import { sqrt } from math
import { hypot, Vec } from shapes
import shapes
func main(): int sync {
println(sqrt(2.0))
let v: Vec = { x: 3.0, y: 4.0 }
println(hypot(v))
println(shapes::area(2.0, 5.0))
return 0
}
1.41421
5
10
import { a, b } from m引入列出的名字;import m只引入命名空间,用m::name访问。- 同目录下的
.cml文件按文件名被找到。
随 Camel 提供的模块有 str(字符串)、array(数组)、math、statistics、algo、random、time、fs、json、os、sys、re、crypto、para、this,张量一系的 tensor、nn、autodiff、onnx,以及嵌入 Python 的 python(语言参考 5.2.1)。标准库参考逐个列出前十五个模块的函数:签名、是否纯、失败时的行为、例子。examples/12_stdlib.cml:
module stdlib_demo
import { split, trim, to_upper, replace, find, char_count } from str
import { sort_by, take, sum, reverse, unique } from array
import { abs, hypot, bit_and, pi } from math
import { mean } from statistics
func main(): int sync {
// Strings: the value goes in on the left, what configures the call goes in angle brackets.
let words = ' pear, fig ,apple, fig '->trim->split<','>->map<(w: string): string => trim(w)>
println(words)
println(words->unique->sort_by<(w: string): int => len(w)>->take<2>)
println('hello'->to_upper, 'a-b-c'->replace<'-', '+'>, len('héllo'), char_count('héllo'))
// A result that may not exist is a T?.
let at_l = 'hello'->find<'l'> ?? -1
let at_z = 'hello'->find<'z'> ?? -1
println(at_l, at_z)
// Arrays and numbers.
let xs = [3, -1, 4, -1, 5]
println(sum(xs), reverse(xs), xs->map<(x: int): int => abs(x)>)
println(mean([1.0, 2.0, 4.0]), hypot(3.0, 4.0), bit_and(6, 3))
println('{:.4f}'->format<pi>)
return 0
}
[pear, fig, apple, fig]
[fig, pear]
HELLO a+b+c 6 5
2 -1
10 [5, -1, 4, -1, 3] [3, 1, 4, 1, 5]
2.3333333333333335 5.0 2
3.1416
- 调用的约定:函数要处理的值是它的普通参数,配置这次调用的值是
with参数。所以库函数都能接在管道上:s->trim->split<','>、xs->unique->sort_by<key>->take<2>。 - 字符串按字节:
len('héllo')是 6;按字符计数用char_count,是 5。s[i]取出第i个字节。 - 可能没有结果的函数返回
T?(如find找不到时),用??取出;越界、不在定义域内的实参是故障。 - 一个书写上的细节:尖括号要紧贴它前面的名字(
find<x>),而比较运算符<前面要有空格(a < b)。这样两者不会混淆,同一个实参表里可以有几个带尖括号的调用:f<a>(x), g<b>(y)。 array模块是用 Camel 写的泛型函数,在图上是看得见的子图;str、math的函数是 C++ 算子,在图上是一个节点。
想知道一个程序能用哪些算子、各自是否声明为纯,问编译器:
camel inspect --ops docs/tutorial/examples/12_stdlib.cml每个重载打印一行,例如:
OPERATOR {"module":"str","name":"split","uri":"str:split","with":1,"norm":1,"purity":"pure","signature":"<string> (string) => string[]"}
纯的算子可以被优化遍提前求值、合并、删除,可以被并行调度并发执行;有副作用的算子只在同步块里、或结果被用到时才执行。
到这里一直在说“函数是子图,调用是节点”。现在亲眼看一看。examples/13_graph.cml:
module graph_demo
func add(a: int, b: int): int {
return a + b
}
func main(): int sync {
let x = add(1, 2)
println(x)
return 0
}
在文件名后面写上遍 std::rgir,程序不运行,而是把图打印成 Graphviz 的 DOT 文本:
camel docs/tutorial/examples/13_graph.cml std::rgir下面是输出(为了易读,去掉了每个节点上的 origin、span、srcfile 源码位置属性):
digraph GraphIR {
subgraph cluster_F01 {
label="main";
subgraph cluster_F02 {
label="add";
F02 [label="ARGS", type="ARGS"];
O02 [label="EXIT", type="EXIT"];
N00 [label="a", type="PORT"];
N01 [label="b", type="PORT"];
N02 [label=":op/add_l", type="OPER"];
F02 -> N00 [label="0", type="NORM_PORT"];
F02 -> N01 [label="1", type="NORM_PORT"];
N02 -> O02 [label="0", type="EXIT"];
N00 -> N02 [label="0|0", type="NORM"];
N01 -> N02 [label="0|1", type="NORM"];
}
O01 [label="EXIT", type="EXIT"];
N03 [label="1", type="DATA"];
N04 [label="2", type="DATA"];
N05 [label="add", type="FUNC"];
N06 [label=":io/println", type="OPER"];
N07 [label="0", type="DATA"];
N08 [label="GATE", type="GATE"];
N08 -> O01 [label="0", type="EXIT"];
N03 -> N05 [label="0|0", type="NORM"];
N04 -> N05 [label="0|1", type="NORM"];
N05 -> N06 [label="0|0", type="NORM"];
N07 -> N08 [label="0|0", type="NORM"];
N06 -> N08 [label="0|0", type="CTRL"];
}
O00 [label="EXIT", type="EXIT"];
N09 [label="Function(graph=main, tupleSlots=0)", type="DATA"];
N0A [label="CALL", type="CALL"];
N0A -> O00 [label="0", type="EXIT"];
N09 -> N0A [label="0|0", type="WITH"];
}
对着源码读:
add是一张子图(cluster_F02):两个PORT节点是形参a、b,一个OPER节点:op/add_l是 64 位整数的加法,它的结果连到EXIT。main也是一张子图(cluster_F01)。两个DATA节点是常量1、2;FUNC节点add就是那次调用,两条NORM(数据)边把实参送进去;它的结果又经一条数据边送给:io/println。- 同步块是控制边。
println的结果没有人用,return 0并不依赖它。让它仍然执行的是那条CTRL边:N06 -> N08,从println指向GATE,GATE把返回值0放行到EXIT。把main的sync去掉再看一次,这条边和println节点的可达性就都没有了——第 6 节的规则在图上就是这个样子。
装了 Graphviz 的话,camel ... std::rgir | dot -Tsvg -o graph.svg 得到图片;std::gir 输出带样式的版本。
std::rgir 是一个遍。Camel 把编译、优化、执行、导出都建模为对图的变换,文件名后面写的就是要依次运行的遍。同一份源码,换一组遍,做不同的事。用 examples/14_passes.cml:
module passes_demo
func square(x: int): int {
return x * x
}
func sum_squares(n: int, acc: int): int {
return if n == 0 then acc else sum_squares(n - 1, acc + square(n))
}
func main(): int sync {
println(sum_squares(1000, 0))
return 0
}
camel docs/tutorial/examples/14_passes.cml # 默认:节点虚拟机 std::nvm
camel docs/tutorial/examples/14_passes.cml std::fvm # 字节码虚拟机
camel docs/tutorial/examples/14_passes.cml std::jit # 即时编译
camel docs/tutorial/examples/14_passes.cml std::prl # 并行调度
camel docs/tutorial/examples/14_passes.cml std::opt std::fvm # 先优化,再运行每一条都打印:
333833500
看每一遍对图做了什么。 std::stats 打印图的统计数字并把图原样传给下一个遍:
camel docs/tutorial/examples/14_passes.cml std::stats std::opt std::stats std::fvmGRAPH_STATS {"graphs":6,"nodes":26,"edges":28,"opers":5,"direct_calls":5,"indirect_calls":1,"closures":0,"branches":1}
GRAPH_STATS {"graphs":7,"nodes":70,"edges":85,"opers":18,"direct_calls":4,"indirect_calls":0,"closures":0,"branches":3}
333833500
两行数字不同:std::opt 改了图(直接调用少了,算子节点多了——函数被内联到了调用处),而程序的结果不变。要逐遍保存整张图,用 --dump-after-each-pass:
camel docs/tutorial/examples/14_passes.cml --dump-after-each-pass snap std::inline std::opt::fold std::fvm目录 snap 里得到 00_input.rgir.dot、01_std.inline.rgir.dot、02_std.opt.fold.rgir.dot 以及对应的 .stats.json:输入的图,和每一遍之后的图。
几条规则:
- 遍按书写顺序运行。执行图的遍(
std::nvm、std::fvm、std::jit、std::prl)和转译的遍(std::rgir、onnx::export)消费掉图,写在它们后面的遍不会运行。 - 不写任何遍,或者写的遍都没有消费掉图,最后运行默认的
std::nvm。 - 语言不限制遍的顺序,而顺序可以影响结果:遍会改图,可以有副作用。标准库的优化遍以不改变程序结果为目标,这由测试保证,不是语言层面的约定。
张量来自 tensor 模块。examples/15_tensor.cml:
module tensor_demo
import { Tensor, ones, zeros, shape, sum, transpose } from tensor
func main(): int sync {
let a = ones([2, 3])
let b = ones([3, 1])
let c = a @ b // matrix product: 2x3 @ 3x1
println(shape(a), shape(c))
println(c)
println(sum(a * 2.0 + 1.0))
println(shape(transpose(a)))
return 0
}
[2, 3] [2, 1]
tensor([[ 3.],
[ 3.]], dtype=float32)
18.0
[3, 2]
一个最小的模型。 Camel 程序的本来面目是只表达计算:main 就是模型,它的参数就是模型的输入。examples/15_model.cml:
module model
import { Tensor, ones, relu, sum } from tensor
func main(x: Tensor<f32, const [2, 3]>): Tensor {
let w = ones([3, 1])
return relu(x @ w)
}
main 没有打印任何东西,也没有 sync:它是纯的。参数 x 的类型写明了元素类型与形状。
运行它:参数的值从命令行给,张量来自 .npy 文件(examples/x.npy 是 [[1, -2, 3], [-4, -5, 6]]);main 返回的值打印到标准输出:
camel docs/tutorial/examples/15_model.cml --arg x=docs/tutorial/examples/x.npytensor([[ 2.],
[ 0.]], dtype=float32)
导出它:同一份源码,换一组遍。程序里没有为导出写任何东西,连 import onnx 都不需要:
CAMEL_ONNX_OUT=model.onnx camel docs/tutorial/examples/15_model.cml std::macro std::opt::simplify onnx::exportonnx::export: wrote model.onnx
得到的 model.onnx 有一个形状为 [2, 3] 的输入 x 和一个输出 output;用 ONNX Runtime 喂同一个 x.npy,得到同样的 [[2.], [0.]]。
导出遍能翻译就翻译,翻译不了(程序里有打印、读文件等效应)就报错并指出位置。既要计时、打印又要导出的程序,改为在程序里调用 onnx.export_model 导出自己的某个函数(cli.cn.md“以遍的形式导出 ONNX”)。
自动微分是建立在遍机制之上的库:grad(f) 把 f 的图改写成它的导数的图。examples/16_grad.cml:
module grad_demo
import { grad, value_and_grad } from autodiff
func f(x: float, y: float): float {
return x * x * y + y
}
func main(): int sync {
println(f(3.0, 2.0))
println(grad(f)(3.0, 2.0)) // (df/dx, df/dy) = (2xy, x^2 + 1)
println(value_and_grad(f)(3.0, 2.0))
return 0
}
camel docs/tutorial/examples/16_grad.cml std::macro20
(12, 10)
(20, (12, 10))
grad(f)返回一个函数,它对f的每个参数给出一个偏导数。grad是 macro 算子。流水线里的std::macro在运行之前就把它展开成导数的图,后面的优化遍可以继续优化这张图。这是这个库的前提,不是语言的约束。- 被求导的函数用
let写成纯的数据流;梯度需要经过var的读写时会被直接拒绝。 - 训练一步就是“梯度 + 更新”:模型的权重写成
with参数,grad(loss)<model>(x, y)对权重求导,nn模块的sgd给出更新后的权重。完整的例子见examples/ml/train_linear.cml。