English | 中文
This tutorial is for readers who know one mainstream programming language and have never seen Camel. When you have finished it you will be able to write and run general-purpose Camel programs, you will know where Camel differs most from other languages, and you will be able to use passes on the command line to look at the graph, optimise it, change the back end, export it and take gradients.
The program in every section is in the examples/ directory, and the outputs shown are their real outputs. All commands are run from the repository root.
Camel is a graph-native general-purpose programming language: a program expresses only computational intent — what to compute, and what depends on what; every element of the language corresponds directly to a piece of structure in the computation graph. As you read, keep asking this question: what is this code in the graph?
Contents: 1 Your first program · 2 Values and types · 3 Functions · 4 Branches and match · 5 Arrays and higher-order operators · 6 Evaluation by dependency, sync and the function body · 7 Closures and function values · 8 Generics · 9 Error handling · 10 Nullable values · 11 Maps and sets · 12 Modules and the standard library · 13 Looking at the graph · 14 Changing the passes · 15 Tensors and exporting a model · 16 Taking gradients · Where to go next
Chapters already written to the new outline (Chinese and English together, each on its own page):
1 Five minutes to see the graph ·
2 Values, types and let ·
3 Functions and pipes ·
4 Branches and match ·
5 Arrays and loops ·
6 Structs, tuples, maps and sets ·
7 Missing and failing ·
8 Modules, the standard library, and a small tool ·
9 Evaluation by dependency ·
10 Effects and order ·
18 Teaching the compiler a law
First get the camel executable: build it from source by following setup.en.md, and add out/latest/bin to your PATH. Once camel --version prints a version, you are ready to start.
func main(): int sync {
println('Hello, Camel!')
return 0
}
camel docs/tutorial/examples/01_hello.cmlHello, Camel!
- One
.cmlfile is one module, andmodule hellogives it a name (this can be omitted). - The program starts at
main. Theintthatmainreturns is the exit code of the process. - The
syncafter the return type means that the function body is a sync block: the statements it writes directly run in the order they are written (a sync block does not inherit inward; section 6 explains). For now, just remember "a function that prints hassyncbefore its body"; section 6 explains why. - A line break is enough between statements; no semicolons are needed.
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
Key points:
- Types are static and can usually be inferred, so you do not have to write them. The basic types are
int,float,boolandstring; the compound types are arraysT[], tuples(A, B)and structs{ x: A }.typegives a type a name. - There is no implicit conversion between numeric types.
n * ratiois a compile-time error; writefloat(n) * ratio. letis an immutable name, andvaris a mutable variable. Camel favours immutability: most programs hardly ever needvar.- A variable can be assigned to, and so can one of its fields or elements (
p.x = 5andxs[0] = 5both work): that gives the whole variable a new value with the change in it, and afterlet a = p, changingpleavesaas it was. Withoutvaryou can also build a new value directly:{ x: 5, y: p.y },concat(xs, [4]). - Strings can use either single or double quotes. Strings are measured in bytes:
len('中文')is 6. Functions for splitting, trimming, searching and so on are in thestrmodule (section 12). - Two details of how code is written: the parenthesis of a call must directly follow the function name (
f(1), notf (1)); and when a statement is continued on the next line, leave the operator at the end of the line, or start the next line with an operator that can only be binary such as->,.,*,&&(a line that starts with-or+is a new statement).
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
- A function is a subgraph, and a call is a node in the graph. You will see this with your own eyes in section 13.
- Repeated computation has three forms: the
loopexpression, recursion, and the higher-order operators of the next section. Aloopwrites its state in its head, and the body's value is the next round's state, as inloop acc = 0 for x in xs do acc + x * x; it can also take awhilecondition (language reference 6.8.6), and its rounds do not count toward the call depth. A call in tail position does not take a stack frame, sosum_torecursing a hundred thousand levels deep is no problem; for non-tail recursion (such asfib), the number of calls open at the same time may not exceed 1000 by default (adjustable with--max-recursion-depth). - Two kinds of parameters. The ones in parentheses are the inputs the function processes; the ones in
with <...>are the properties the function is configured with (context parameters), and at a call they are written in angle brackets. Configuration that other languages put in the members of an object is awithparameter in Camel. - The pipe
->passes the value on its left as the argument of the function on its right;a->f->gisg(f(a)). Data flow written as a chain is exactly a path in the graph.
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 ...is an expression and has a value; the two arms must have the same type.matchmatches on a name (which is whygradefirst writeslet band = ...). A pattern is a literal or_, and onecasecan list several patterns with|.case _is required: amatchwithout a default case is a compile-time error (except on aboolthat lists bothtrueandfalse).
In the graph, each arm of a branch is a subgraph of its own. So a return written in the block of an arm gives the value of that branch; it does not leave the function. examples/04_block_return.cml:
// 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) gives 'reached the end'. The compiler can see this pattern and gives one warning for each of the two returns:
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:6:21
|
6 | if x < 0 then { return 'negative' } else { return 'not negative' }
| ^^^^^^^^^^^^^^^^^
note: the value of this 'if' is not used
There is no "early return" in Camel. Make the branch the last expression of the function body, or write return in front of it as right does.
func main(): int sync {
let xs = 1..11 // [1, ..., 10]
let squares = xs->map<(x) => x * x>
let evens = squares->filter<(x) => 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[0:3], [99])
println(ys, len(ys), ys[0])
// foreach runs its function for each element, in order.
['a', 'b', 'c']->foreach<(s) => 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 * xis a lambda. The function passed tomapis written in angle brackets because it is awithparameter ofmap: the array is the data flowing through, and the function is the configuration.- The elements of a
mapor afilterhave no order among themselves, so a parallel back end can compute them at the same time, and the results are still arranged by position;reduceandforeachrun in element order. - The lambda in
foreachis written=> sync { ... }: a function body that is to produce an effect must be a sync block. The next section explains.
There is no agreed order of execution between the elements of a map. When the element function is pure this does not matter; once it calls something stateful, the result depends on which element is computed first. examples/05_map_order.cml demonstrates this with the random number generator (each call of randint advances the hidden state by one step):
import { seed, randint, next_key } from random
func main(): int sync {
seed(42)
// Wrong tool: next_key advances a hidden state, so each element depends on which ran first.
let drawn = (0..8)->map<(i) => next_key()->randint<0, 100>>
println(drawn)
seed(42)
// reduce runs in element order on every back end.
let ordered = (0..8)->reduce<(acc: int[], i: int): int[] => concat(acc, [next_key()->randint<0, 100>]), [0][0:0]>
println(ordered)
return 0
}
On the sequential back ends (the default std::nvm, and also std::fvm and std::jit), the two lines are the same, and the same on every run:
[22, 72, 89, 99, 60, 96, 66, 2]
[22, 72, 89, 99, 60, 96, 66, 2]
Switch to parallel scheduling, camel docs/tutorial/examples/05_map_order.cml std::prl, and the order of the first line is no longer guaranteed and may differ from run to run; below is the output of one such run:
[2, 66, 96, 60, 99, 89, 72, 22]
[22, 72, 89, 99, 60, 96, 66, 2]
This is behaviour laid down by the language (design principles, 3.6), not a defect: it is precisely because there is no order between the elements that map can run in parallel. The reduce used for the second line runs in element order, and its result is the same on all back ends. The rule is simple: keep the functions passed to map and filter pure; do what must be done in order with foreach, reduce or tail recursion.
This is where Camel differs most from other languages, and it is worth reading slowly.
In most languages, statements run one after another. In Camel, by default, evaluation is driven by dependencies: an expression is evaluated because someone needs its result. Expressions that do not depend on each other have no order and can run in parallel.
This has a consequence: outside a sync block, a call whose result nobody uses does not run, even if it has side effects. examples/06_unused_effect.cml:
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:5:5
|
5 | 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
Only 1 is printed. The return value 1 of greet does not depend on that println, so in the graph it is a node that nobody needs. This is not an error; it is a rule of the language. The compiler gives a warning so that a forgotten sync gets seen.
A sync written before a block makes it a sync block: the statements it writes directly run in the order they are written, as if the compiler had added control edges between adjacent statements. examples/06_sync_block.cml changes only the body of greet into a sync block:
func greet(): int sync {
println('hello from greet')
return 1
}
hello from greet
1
In a sync block it does not matter what you call or whether it has side effects, because there is already an order. Input and output are written in sync blocks.
A sync block does not inherit inward. It orders only the statements it writes directly: the branch arms, loop bodies and nested blocks written inside it follow their own spelling, and to synchronise one of them, write sync there (design principles 3.4). This is the same rule as "a function's behaviour is decided by the function itself" in the next section, one level in:
func f(c: bool): int sync {
if c then { println(1); println(2) } // the arm is not a sync block: only println(2) executes
return 0
}
The if itself is on the sync chain, its arm is not: the arm is evaluated by dependency, the println(1) whose value nobody uses does not execute, and println(2) is the arm's value, which is the if's value, so it executes. To print both, write sync on the arm: if c then sync { println(1); println(2) }.
The body of main is a sync block, so will a println inside a function it calls run? No. A function is compiled into a subgraph of its own, and a call only places that subgraph node into a larger graph; it does not change its structure. examples/06_callee_decides.cml:
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
The two calls of log did run in order (b is 3), but println(x) belongs to the body of log itself, and that is not a sync block (the compiler gives the EffectNotExecuted warning all the same). To print, declare log as func log(x: int): int sync { ... }.
When sync is written in the function body position (func g(): int sync { ... }), it modifies the block that is the function body: the statements of the block execute in the order they are written. A function body without sync is not a sync block, and effects such as println in it do not run (evaluation by dependency). examples/06_sync_body.cml:
func f(): int {
println('f') // not executed: the body is not a sync block
return 1
}
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
When the body of a lambda has to run in order, write the sync before the block: (x: int) => sync { ... }. A lambda head has no sync.
In Camel every value is passed by reference. Writing var before a parameter means that the function will modify it; the modification is a write to the caller's variable. Passing a let to a var parameter is a compile-time error (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:10:10
|
10 | 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.
Once there are mutable variables, there is a question of order: which write must a read come after? The answer is the sync block: the statements written in one sync block execute in the order they are written, so two writes and a later read have a determined order. examples/06_var_order.cml:
func bump(var counter: int): int sync {
counter += 1
return counter
}
func twice(): int sync {
var c = 0
bump(c)
bump(c)
return c
}
func main(): int sync {
println(twice())
return 0
}
2
The body of twice is a sync block, so the two calls of bump and return c execute in the order they are written. A few more points:
- A write that is not in a sync block is still ordered after the reads written before it (a read sees the value of the write written before it), but it is not guaranteed to run: with nothing reading the variable afterwards it does not run, and the compiler gives the warning
SemanticDiag::IgnoredSideEffect. - If nobody reads the variable any more after the write, that modification does not run — this is still evaluation by dependency.
The everyday advice is simple: write computation as lets and pure functions; put input and output in sync blocks; keep var for the few places that really need in-place updates.
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<(x) => sync { total = total + x }>
println(total)
return 0
}
2 12
15
[11, 20]
6
- Functions are values: they can be bound to a name, passed as arguments, returned, and put in an array. A function type is written
(x: int) => int. - A lambda captures variables from the enclosing scope, and the capture is by reference. The last lambda writes to the captured
total, so it has the function modifiersyncin front of it (declaring that it has side effects), and its body is also a sync block (=> sync { ... }). - Functions with the same name and different parameter types are overloads; a name that stands for a set of overloads can be used as a value only where the expected function type selects one of them (passed to a function whose parameter type is written out); elsewhere wrap it in a lambda. A library operator with one signature (such as
trim) is a value directly:xs->map<trim>.
type Pair<T> = (T, T)
func first::<T>(xs: T[]): T {
return xs[0]
}
func swap::<T>(p: Pair<T>): Pair<T> {
return (p.1, p.0)
}
func my_map::<T, U>(xs: T[], f: (x: T) => 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!]
- There is no separate type parameter list: type parameters are declared after the function's name in
::<…>, and a function with it is generic, one without is not. The signature (parameters and return type) and the body then writeTdirectly. A name declared at the head takes precedence over a type of the same name inside the function. type Pair<T> = (T, T)is a type declaration with a parameter:Pair<int>is(int, int).- In the graph, a generic function is a subgraph template. Each time it is called with a set of concrete types, a subgraph whose types are all concrete is instantiated: the program above has the two subgraphs
first<int>andfirst<string>(you can see them withcamel docs/tutorial/examples/08_generics.cml std::rgir). The graph contains only concrete types, and no type information is needed at run time. - A generic function may also be passed wherever a function is expected (
apply(id, 3),xss->map<reverse>,let f: (x: int) => int = id): the compiler builds the instance once the position fixes its types. Where nothing does (let g = id), it is a compile-time error that says to write the type.
A declaration can also say where it applies: when after the signature is a compile-time condition over the declaration's type parameters. double_it below applies to numbers and to nothing else; when no candidate of a call meets its condition, the error at the call site lists the condition each candidate wrote. is_numeric is not a name the compiler knows: it is an ordinary function written with match type (the standard library's seven type predicates live in types), and a condition calls a function the module can see, so the snippet writes it in the same module.
func is_numeric::<T>(): bool {
return match type T {
case int | i32 | float | f32 => true
case _ => false
}
}
func double_it::<T>(x: T): T when is_numeric::<T>() {
return x + x
}
func main(): int sync {
println(double_it(21), double_it(1.5))
return 0
}
Camel has no exceptions and no try/catch. A function that may fail declares its return type as T!: its value is either a T or an error. examples/09_errors.cml:
func safe_div(a: int, b: int): int! {
return if b == 0 then {
return 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
- An error value is handed out by
return e: it does not unwind the call stack; what the caller gets is simply a value of typeint!. - An
int!cannot be used as anint; it must be unwrapped first:a !! b: takesbon an error, otherwise the value inside;x -> f: on an error passes the error on unchanged, otherwise callsf; these can be chained;matchwithcase error e(e.kind,e.message,e.location) andcase v.
- In the graph, all three forms are a branch whose condition is a one-bit tag on the value; it is the same kind of structure as
if.
Errors and faults are two different things. Division by zero, an index out of range and recursion that is too deep are faults, not error values: the program terminates with a source location, and !! cannot catch them. examples/09_fault.cml reads xs[5]:
before
error[RuntimeDiag::IndexOutOfRange]: Index 5 is out of range for an array of length 3.
--> docs/tutorial/examples/09_fault.cml:7:15
|
7 | println(xs[5])
| ^
help: Ensure the index is within the valid range of the collection.
A value of type T? is either null or a T. examples/10_nullable.cml:
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: takesbwhenais null.matchusescase nullandcase v.- An ordinary
intis put into anint?wherever one is expected: the two arms offindarenullandi, so the branch is anint?; an argument (show(5)), a declaration with a type (let maybe: int? = 5) andreturnwork the same way. Not the other way round: to use anint?as anint, open it with??ormatch.
Map<K, V> and Set<T> are built-in types. There are no dedicated literals: write an array, then convert it with as. examples/11_collections.cml:
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}
- Every operation returns a new value; the original map or set is unchanged.
m[k]is a lookup and its result is aV?, so it is followed by?? default.- Iteration: first take out an array with
keys(m)oritems(m), then usemap,reduceorforeach.
examples/shapes.cml is a library module; it marks the names visible from outside with export:
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 uses it and the standard module math:
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.4142135623730951
5.0
10.0
import { a, b } from mbrings in the listed names;import mbrings in only the namespace, and names are accessed asm::name..cmlfiles in the same directory are found by file name.
The modules that come with Camel are str (strings), array (arrays), math, statistics, random, time, fs, json, process, env, re, hash, para, this, the tensor family tensor, nn, autodiff, onnx, and python, which embeds Python (language reference 5.2.1). The standard library reference lists the functions of the first fourteen modules one by one: signature, whether it is pure, behaviour on failure, examples. examples/12_stdlib.cml:
import { split, trim, to_upper, replace, index_of } from str
import { sort_by, take, sum, reverse, unique } from array
import { abs, hypot, bit_and, pi } from math
import { mean } from array
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) => 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'))
// A result that may not exist is a T?.
let at_l = 'hello'->index_of<'l'> ?? -1
let at_z = 'hello'->index_of<'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) => 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 5
2 -1
10 [5, -1, 4, -1, 3] [3, 1, 4, 1, 5]
2.3333333333333335 5.0 2
3.1416
- The calling convention: the value a function works on is its ordinary parameter, and the values that configure the call are
withparameters. So every library function can be attached to a pipe:s->trim->split<','>,xs->unique->sort_by<key>->take<2>. - A string is always valid UTF-8 and is measured in code points:
len('héllo')is 5, ands[i]takes out thei-th character. Binary data isBytes. - A function that may have no result returns
T?(such asfindwhen nothing is found), taken out with??; out-of-range arguments and arguments outside the domain are faults. - A detail of writing: angle brackets touch the name before them (
find<x>), and the comparison<has a space before it (a < b). The two are never confused, and one argument list may hold several calls with angle brackets:f<a>(x), g<b>(y). - The
arraymodule consists of generic functions written in Camel, which are visible subgraphs on the graph; the functions ofstrandmathare C++ operators, each a single node on the graph.
To find out which operators a program can use, and whether each is declared pure, ask the compiler:
camel inspect --ops docs/tutorial/examples/12_stdlib.cmlEach overload prints one line, for example:
OPERATOR {"module":"str","name":"split","uri":"str:split","with":1,"norm":1,"purity":"pure","signature":"<string> (string) => string[]"}
Pure operators can be evaluated ahead of time, merged and removed by optimisation passes, and can be executed concurrently by parallel scheduling; operators with side effects execute only in a sync block, or when their result is used.
Up to here we have kept saying "a function is a subgraph, a call is a node". Now let us see it with our own eyes. examples/13_graph.cml:
func add(a: int, b: int): int {
return a + b
}
func main(): int sync {
let x = add(1, 2)
println(x)
return 0
}
Write the pass std::rgir after the file name, and the program does not run; instead the graph is printed as Graphviz DOT text:
camel docs/tutorial/examples/13_graph.cml std::rgirHere is the output (for readability, the source-location attributes origin, span and srcfile on each node have been removed):
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"];
}
Read it side by side with the source:
addis a subgraph (cluster_F02): the twoPORTnodes are the parametersaandb, the oneOPERnode:op/add_lis 64-bit integer addition, and its result is connected toEXIT.mainis also a subgraph (cluster_F01). The twoDATAnodes are the constants1and2; theFUNCnodeaddis that call, and twoNORM(data) edges carry the arguments into it; its result is in turn carried by a data edge to:io/println.- A sync block is control edges. Nobody uses the result of
println, andreturn 0does not depend on it. What makes it run all the same is thatCTRLedge:N06 -> N08, fromprintlntoGATE, andGATElets the return value0through toEXIT. Remove thesyncfrommainand look again: this edge is gone, and so is the reachability of theprintlnnode — this is what the rules of section 6 look like in the graph.
If you have Graphviz installed, camel ... std::rgir | dot -Tsvg -o graph.svg gives you a picture; std::gir outputs a styled version.
std::rgir is a pass. Camel models compilation, optimisation, execution and export all as transformations of the graph, and what you write after the file name is the passes to run, in order. The same source, with a different set of passes, does a different thing. Using examples/14_passes.cml:
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 # default: the node VM, std::nvm
camel docs/tutorial/examples/14_passes.cml std::fvm # bytecode VM
camel docs/tutorial/examples/14_passes.cml std::jit # JIT compilation
camel docs/tutorial/examples/14_passes.cml std::prl # parallel scheduling
camel docs/tutorial/examples/14_passes.cml std::opt std::fvm # optimise first, then runEvery one of them prints:
333833500
Seeing what each pass did to the graph. std::stats prints statistics about the graph and passes the graph on unchanged to the next pass:
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
The two lines of numbers differ: std::opt changed the graph (there are fewer direct calls and more operator nodes — functions were inlined at their call sites), while the result of the program is unchanged. To save the whole graph pass by pass, use --dump-after-each-pass:
camel docs/tutorial/examples/14_passes.cml --dump-after-each-pass snap std::opt::inline std::opt::fold std::fvmThe directory snap then contains 00_input.rgir.dot, 01_std.opt.inline.rgir.dot, 02_std.opt.fold.rgir.dot and the corresponding .stats.json files: the input graph, and the graph after each pass.
A few rules:
- Passes run in the order they are written. The passes that execute the graph (
std::nvm,std::fvm,std::jit,std::prl) and the passes that transpile it (std::rgir,onnx::export) consume the graph, and passes written after them do not run. - If you write no pass at all, or none of the passes you write consumes the graph, the default
std::nvmruns at the end. - The language does not restrict the order of passes, and the order can affect the result: passes change the graph and can have side effects. The optimisation passes of the standard library aim not to change the result of the program; this is ensured by tests, and is not a guarantee at the level of the language.
Tensors come from the tensor module. examples/15_tensor.cml:
import { Tensor, ones, zeros, shape, sum, transpose } from tensor
func main(): int sync {
let a = ones::<f32>([2, 3])
let b = ones::<f32>([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]
[[3.0], [3.0]]
18.0
[3, 2]
A minimal model. In its true form, a Camel program expresses only computation: main is the model, and its parameters are the inputs of the model. examples/15_model.cml:
import { Tensor, ones, relu, sum } from tensor
func main(x: f32[2, 3]): f32[..] {
let w = ones::<f32>([3, 1])
return relu(x @ w)
}
main prints nothing and has no sync: it is pure. The type of the parameter x states the element type and the shape.
Running it: the values of the parameters are given on the command line, and a tensor comes from a .npy file (examples/x.npy is [[1, -2, 3], [-4, -5, 6]]); the value that main returns is printed to standard output:
camel docs/tutorial/examples/15_model.cml --arg x=docs/tutorial/examples/x.npy[[2.0], [0.0]]
Exporting it: the same source, with a different set of passes. Nothing in the program was written for the export; not even import onnx is needed:
camel docs/tutorial/examples/15_model.cml std::macro std::opt 'onnx::export out="model.onnx"'onnx::export: wrote model.onnx
The resulting model.onnx has one input x of shape [2, 3] and one output output; feeding the same x.npy to it with ONNX Runtime gives the same [[2.], [0.]].
The export pass translates whatever it can translate, and where it cannot (the program has effects such as printing or reading files) it reports an error and points to the location. A program that needs to time things and print as well as export should instead call onnx.export_model inside the program to export one of its own functions (cli.md, "ONNX export as a pass (onnx::export)").
Automatic differentiation is a library built on top of the pass mechanism: grad(f) rewrites the graph of f into the graph of its derivative. The first with parameter of f is the weight it differentiates; ordinary parameters are data. examples/16_grad.cml:
import { grad, with_grad } from autodiff
with <x: float>
func f(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 = 2xy = 12
println(with_grad(f)<3.0>(2.0)) // (value, gradient) = (20, 12)
return 0
}
camel docs/tutorial/examples/16_grad.cml std::macro20.0
12.0
(20.0, 12.0)
grad(f)returns a function that differentiates the firstwithparameter off; the remainingwithparameters are constants and ordinary parameters are data.- To differentiate data (an adversarial example, say), make it a
withparameter:grad(<x: Tensor> (m: Model): float => loss(m, x))<x>. with_grad(f)returns the value together with the gradient. In a loop, bind the transform once and call it with the weights:let loss_with_grad = with_grad(loss), thenlet (l, g) = loss_with_grad<model>(x, y)inside the loop.gradis a macro operator. Thestd::macroin the pipeline expands it into the graph of the derivative before anything runs, and the optimisation passes after it can go on to optimise that graph. This is a precondition of this library, not a constraint of the language.- The function being differentiated is written as pure data flow with
let; when a gradient would have to pass through reads and writes of avar, it is rejected outright. When the firstwithparameter is avar, has no tangent, or the function has nowithparameter, compilation fails withSemanticDiag::NotDifferentiablenaming the reason. - One training step is "gradient + update": the weights of the model are written as
withparameters,grad(loss)<model>(x, y)differentiates with respect to the weights, andsgdfrom thennmodule gives the updated weights. For a complete example seeexamples/ml/train_linear.cml.
- Examples: short programs organised by task — JSON, files, word-frequency counting, sorting, command-line tools, parallelism, simulation, training.
- Language reference: the precise rules for every construct.
- Standard library reference: every function of every module.
- Command line: all options, passes, diagnostic formats, resource limits;
camel --explain <diagnostic name>explains one diagnostic. - Design principles: why the language is the way it is.