Prestissimo, version 1
This is the language definition. The editor runs the real compiler in your browser, and the examples below open in it.
Start here
Three programs, in order. Each opens in the editor; press Run.
A first program
fn main() -> i32 {
let name = "Prestissimo"
print(f"hello from {name}")
let mut total = 0
for i in 1..11 {
total = total + i * i
}
print(f"the first ten squares sum to {total}")
return 0
}
A kernel that must vectorise
# `vec for` is a contract. If the compiler cannot prove the iterations
# independent, the build fails; it never quietly falls back to a slow loop.
#
# Change `i` to `i-1` on the right and press Check.
fn saxpy(alpha: f32, x: view [f32], y: uniq [f32], n: i32)
guarantees no_alloc
{
vec for i in 0..n {
y[i] = fma(alpha, x[i], y[i])
}
}
fn main() -> i32 {
let n = 16
let mut x: [f32] = alloc(n)
let mut y: [f32] = alloc(n)
for k in 0..n {
x[k] = f32(k)
y[k] = 1.0
}
saxpy(2.0, x, y, n)
print(f"y[3] = {y[3]} y[15] = {y[15]}")
return 0
}
Reporting failure without exceptions
# There are no exceptions. A function returns what happened alongside
# what it produced, and `match` makes the compiler check every case.
#
# Add a fourth variant to ParseError and press Check.
enum ParseError { None, Empty, BadDigit }
struct Parsed {
err: ParseError
value: i32
}
fn parse_int(s: str) -> Parsed {
if len(s) == 0 {
return Parsed(err: ParseError.Empty, value: 0)
}
let mut acc = 0
for i in 0..len(s) {
let c = s[i]
if c < 48 or c > 57 {
return Parsed(err: ParseError.BadDigit, value: 0)
}
acc = acc * 10 + (i32(c) - 48)
}
return Parsed(err: ParseError.None, value: acc)
}
fn show(s: str) {
let r = parse_int(s)
match r.err {
ParseError.None { print(f" '{s}' is {r.value}") }
ParseError.Empty { print(f" '{s}' was empty") }
ParseError.BadDigit { print(f" '{s}' is not a number") }
}
}
fn main() -> i32 {
show("1234")
show("")
show("12a4")
return 0
}
This is the specification. It defines what the language accepts, what it means, and what it refuses. Where the specification and the implementation disagree, the implementation is wrong and should be fixed.
REFERENCE.txt teaches the language. This document defines it.
Contents
- Scope
- Lexical structure
- Grammar
- Types
- Declarations
- Statements
- Expressions
- Buffers and uniqueness
- Vectorisation
- Contracts
- Scoped memory
- Programs of several files
- Built-in functions
- Diagnostics
- Compilation targets
- Deliberate omissions
1. Scope
Prestissimo compiles numeric kernels. A kernel is a loop over arrays of scalars whose iterations do not depend on one another. The language exists to make three properties checkable at compile time rather than hoped for at run time:
- A loop marked
vec foris vectorised, or the build fails. - Two writable references never reach the same memory.
- A function marked
guarantees no_allocallocates nothing, transitively.
Everything else in the language exists to support those three, or to make programs that use them writable.
A compiled unit targets a CPU through LLVM. A subset also targets a GPU through WGSL. Section 15 defines the subset.
2. Lexical structure
2.1 Encoding
Source is UTF-8. Identifiers, keywords and operators are ASCII. String literals may contain any UTF-8 byte sequence other than a NUL byte.
2.2 Comments
A # begins a comment that runs to the end of the line. There are no block comments.
2.3 Identifiers
ident = (letter | "_") { letter | digit | "_" }
Identifiers are case sensitive. An identifier may not be a keyword.
2.4 Keywords
alloc and arena bool break continue elif else enum f32 false fn for guarantees i32 if in len let match mut no_alloc not or print return str struct true u8 uniq unsafe using vec view void while write
import is reserved. Using it produces a diagnostic directing the reader to using. It has no other meaning.
2.5 Literals
int = digit { digit }
float = digit { digit } "." digit { digit }
bool = "true" | "false"
string = '"' { char | escape } '"'
fstring = 'f"' { char | escape | hole } '"'
hole = "{" expression [ ":" format ] "}"
format = [ "<" | ">" ] [ width ] [ "." precision ] [ "f" ]
An integer literal has type i32. A float literal has type f32. There are no suffixes and no hexadecimal, octal or binary forms.
Escapes inside a string are \n, \t, \\, \", \0 and \e. \e is the ASCII escape character, 27, provided for terminal control sequences. An unrecognised escape is the character following the backslash.
Inside an f-string, {{ and }} produce literal braces. A " inside a hole terminates the string; bind the value to a name first.
Adjacent string literals are concatenated. This permits a long line to be broken inside the brackets of print or write.
2.6 Operators
-> .. == != <= >=
+ - * / % =
< > ( ) [ ]
{ } , : .
The list is matched longest first, so .. is one token and not two ..
2.7 Line structure
A newline ends a statement. A newline does not end a statement when any of the following holds:
- the newline is inside
()or[]; - the line ends in an operator that requires a right operand, or in
and,orornot; - the next non-blank, non-comment line begins with one of
== != <= >= + * / % < >or withandoror.
The third rule omits - and not because a line beginning with either is a valid statement on its own.
A closing } also ends a statement, so if x { return 1 } is one line.
Two statements may not share a line. There are no semicolons.
3. Grammar
Terminals are quoted. { x } means zero or more, [ x ] means optional.
program = { using | struct_def | enum_def | function }
using = "using" ident NEWLINE
struct_def = "struct" ident "{" { field } "}" NEWLINE
field = ident ":" type [ "," ] NEWLINE
enum_def = "enum" ident "{" { variant } "}" NEWLINE
variant = ident [ "=" [ "-" ] int ] [ "," ] NEWLINE
function = "fn" ident "(" [ params ] ")" [ "->" type ]
[ "guarantees" "no_alloc" ] block
params = param { "," param }
param = ident ":" [ "view" | "uniq" ] type
type = scalar | array | ident
scalar = "i32" | "f32" | "u8" | "bool" | "str" | "void"
array = "[" type "]" | "[[" type "]]" | "[[[" type "]]]"
block = "{" { statement } "}"
statement = let | assign | if | while | for | match | arena
| unsafe | return | break | continue | output | call
let = "let" [ "mut" ] ident [ ":" type ] "=" expression
assign = place "=" expression
place = ident | ident "." ident | index
index = ident [ "." ident ] "[" expression { "," expression } "]"
if = "if" expression block { "elif" expression block }
[ "else" block ]
while = "while" expression block
for = [ "vec" ] "for" ident "in" expression ".." expression block
match = "match" expression "{" { arm } "}"
arm = ( value { "," value } | "_" ) block
value = [ "-" ] int | ident "." ident
arena = "arena" block
unsafe = "unsafe" block
return = "return" [ expression ]
output = ( "print" | "write" ) "(" [ args ] ")"
call = ident "(" [ args ] ")"
args = expression { "," expression }
expression = or_expr
or_expr = and_expr { "or" and_expr }
and_expr = not_expr { "and" not_expr }
not_expr = "not" not_expr | cmp_expr
cmp_expr = add_expr [ ( "==" | "!=" | "<" | "<=" | ">" | ">=" )
add_expr ]
add_expr = mul_expr { ( "+" | "-" ) mul_expr }
mul_expr = unary { ( "*" | "/" | "%" ) unary }
unary = "-" unary | atom
atom = literal | ident | index | call | struct_lit
| "len" "(" ident [ "," int ] ")"
| "alloc" "(" expression { "," expression } ")"
| "(" expression ")"
struct_lit = ident "(" ident ":" expression
{ "," ident ":" expression } ")"
Comparison does not chain. a < b < c is a type error, not a range test.
4. Types
4.1 Scalars
| type | width | meaning |
|---|---|---|
i32 | 32 bit | signed integer, wrapping on overflow |
f32 | 32 bit | IEEE 754 binary32 |
u8 | 8 bit | storage only, see 4.2 |
bool | 1 bit | true or false, no arithmetic |
str | ptr | immutable NUL-terminated bytes, see 4.4 |
void | none | the absence of a value, valid only as a return |
Integer overflow wraps in both the interpreter and a compiled binary. Integer division truncates toward zero. Division by zero is undefined for i32 and produces an IEEE value for f32.
4.2 The byte type
u8 supports storage, comparison and conversion. It supports no arithmetic. To compute with bytes, widen, compute, and narrow:
dst[i] = u8(f32(src[i]) * gain + lift)
u8(x) saturates: values below 0 become 0 and values above 255 become 255. This is the only conversion in the language that is not a truncation.
4.3 Arrays
An array has an element type and a rank of 1, 2 or 3. It is one contiguous buffer plus one length per dimension.
[f32] rank 1 [[f32]] rank 2 [[[u8]]] rank 3
a[i, j] is rewritten to a[i * dim1 + j] before any analysis runs. The layout is row major, so the last subscript is the contiguous one.
len(a) is the size of dimension 0. len(a, k) is the size of dimension k. An axis at or above the rank is E-SHAPE.
Elements may be i32, f32, u8, bool or an enum. They may not be str, and they may not be a struct (E-STRUCT-AOS, see 8.5).
4.4 Strings
A str is a pointer to immutable NUL-terminated bytes. It supports len, indexing to u8, ==, !=, and being returned from a function. It supports no concatenation, no ordering, no mutation, and there is no [str].
Concatenation is absent because it allocates, and this language allocates only where the source says alloc.
4.5 Structs
A struct is a flat, named group of fields. Its kind is decided by its fields, with no keyword:
- a value struct holds only scalars and enums, and is passed by value;
- a buffer struct holds at least one array, is created with
alloc, and is passedvieworuniq.
A struct may not contain another struct. A field of a buffer struct is rank 1, because alloc on a struct supplies one size per field.
A struct variable is its fields. Declaring p: Points declares p.x, p.y and so on, and every rule in this specification applies to those names. There is no runtime layout: a struct never exists as a single value in memory, so the aliasing rules and the vectoriser see ordinary buffers.
A value struct may be returned. A buffer struct may not (E-STRUCT-RETURN): the arrays inside it would outlive the scope that owns them.
4.6 Enums
An enum is a set of named values stored as i32.
enum Colour { Red, Green, Blue } # 0, 1, 2
enum Status { Ok = 0, Warn = 1, Fail = 9 }
An enum is a distinct type. It does not compare with i32 and supports no arithmetic. i32(c) yields the number. == and != between the same enum are the only operators.
print and f-strings show the variant name.
Enums may be parameters, return types, struct fields and array elements.
4.7 Conversion
There is no implicit conversion between any two types. Every conversion is written: i32(x), f32(x), u8(x).
An integer literal in a position expecting f32 is a type error. Write 1.0, not 1.
5. Declarations
5.1 Names
Functions, structs and enums share one namespace at file scope, and that namespace spans every file in a program. A name defined twice is an error naming both files.
A variable may not take the name of a function, struct or enum.
Within a function, a name is declared once. There is no shadowing, so two loops in one function cannot both use i.
Struct fields and enum variants are scoped to their own type and may repeat freely across types.
5.2 Variables
let count = 1 # immutable let mut total = 2 # mutable let mut buffer: [f32] = alloc(n)
let requires an initialiser. A type annotation is required when the initialiser is alloc, and otherwise optional.
5.3 Functions
fn name(p: view [f32], q: uniq [f32], n: i32) -> f32
guarantees no_alloc
{
...
}
A function with no -> returns void. Falling off the end of a function that returns a value yields the zero of that type, which is defined behaviour and not an error.
Parameters are immutable except through a uniq array.
A main returning i32 is the entry point of a program. A file with no main is a library.
6. Statements
6.1 Assignment
The left side is a variable, a struct field, or an indexed element. Only a mutable binding or a uniq array may be assigned.
6.2 Conditionals
if, elif and else take a bool condition. There is no truthiness: an i32 is not a condition.
6.3 Loops
for i in lo..hi { ... }
while cond { ... }
for counts up by one and is exclusive of hi. If hi <= lo the body does not execute. The loop variable is immutable inside the body.
break and continue apply to the innermost loop.
6.4 Match
match n {
0 { ... }
1, 2 { ... }
_ { ... }
}
The subject is an i32 or an enum. Arms are integer literals, or variants of the subject's enum. There is no fallthrough. A value may not appear in two arms. A _ arm must come last.
A match on an enum must cover every variant or have a _ arm (E-MATCH-PARTIAL). Adding a variant later therefore breaks every match that needs updating.
6.5 Output
let count = 3 let ratio = 1.5 print(count, ratio) # space separated, then a newline write(count, ratio) # the same, without the newline
Both accept i32, f32, u8, str, enums and f-strings. print() with no arguments prints an empty line.
An f-string is valid only inside print or write. Using one as a value would require building a string, which allocates.
7. Expressions
7.1 Precedence
From loosest to tightest:
or and not == != < <= > >= + - * / % - (unary) call, index, field access
7.2 Short circuit
and and or short circuit outside a vec for. Inside a vec for both sides are evaluated, because a vector lane has no branch. Expressions inside a vec for are free of side effects, so this is not observable.
7.3 Arithmetic
Both operands of a binary arithmetic operator must have the same type. % is defined for i32 only. u8 supports no arithmetic (4.2).
8. Buffers and uniqueness
These rules are the reason the language exists. They are checked, not declared.
8.1 The rules
R1. Every array buffer has exactly one owning binding. Binding a buffer to a second name is E-UNIQ-ALIAS.
R2. A buffer parameter is either shared and read-only (view) or exclusive and writable (uniq). Writing through a view is E-UNIQ-WRITE.
R3. One buffer may be passed to several view parameters in the same call.
R4. A buffer passed as uniq may not appear again in the same call, as uniq or as view (E-UNIQ-ALIAS).
R5. For the duration of a call, a uniq argument suspends every view of that buffer.
R6. A view may not be returned or stored. Lifetimes are therefore unnecessary: no reference outlives the call it was made for.
R7. alloc appears only where written, and never inside a function that guarantees no_alloc.
8.2 Structs and identity
A buffer struct is a bundle of buffers. Passing it as uniq claims every array in it, so neither the struct nor any of its fields may appear again in that call. f(p, p.x) is E-UNIQ-ALIAS; f(p.x, p.y) is not, because those are different buffers.
8.3 What the rules buy
The compiler hands LLVM noalias on every uniq pointer and readonly on every view. Those attributes are load-bearing for the optimiser, and in C the equivalent (restrict) is a promise the compiler does not check. Here the type system checks it.
Alignment is not claimed on parameters. A caller in another language passes its own allocation, which may be less aligned than this one's arena provides. Buffers from alloc are 32-byte aligned and the compiler states that at the allocator, where it is true.
8.4 Arrays of structs
let mut a: [Vec3] = alloc(n) # E-STRUCT-AOS
a[i].x would have stride 3, and a vector load needs stride 1. The diagnostic names the struct of arrays to write instead. This refusal is the central design decision of the language, not a limitation of the implementation.
9. Vectorisation
9.1 The contract
vec for i in 0..n { ... }
The compiler proves the loop's iterations independent and emits vector instructions, or it refuses to build. There is no third outcome, and no silent fallback to a scalar loop.
9.2 Width
The vector width is 256 bits divided by the widest element type in the loop:
- a loop touching only
u8runs 32 lanes; - a loop touching any
i32orf32runs 8 lanes.
Index arithmetic does not affect the width. An index is an address, not an element.
9.3 Interleaving
Several lanes are kept in flight to hide instruction latency. Lanes are emitted interleaved: every node of the expression for all lanes, then the next node. Emitting a whole lane at a time produces a dependent chain with nothing between its links.
The number of lanes is chosen per loop from the number of array reads that must stay live in registers, clamped to between 2 and 8.
This section describes the implementation's current strategy. It is not part of the language's meaning, and a conforming implementation may choose differently.
9.4 What is permitted in the body
- assignment to an array element, at an index affine in the loop variable with stride 1;
if,elifandelse, compiled to a masked select;letbindings of scalars, which are inlined;- the mathematical built-ins of section 13;
- conversion between scalar types;
- reduction into a scalar declared outside the loop, using
+,*,minormax.
9.5 What is refused
| code | cause |
|---|---|
E-VEC-CARRIED | an iteration reads what another wrote |
E-VEC-STRIDE | an index whose step is not 1 |
E-VEC-AFFINE | an index not affine in the loop variable |
E-VEC-INDIRECT | an index read from another array |
E-VEC-UNPROVEN | two indexes into one buffer that may overlap |
E-VEC-BODY | a call, a nested loop, a break, or other control |
E-VEC-REDUCE | a reduction the compiler cannot recognise |
Each diagnostic names the line, the buffer, and where possible the distance of the dependency.
9.6 Reductions
let mut s = 0.0
vec for i in 0..n {
s = s + a[i] * b[i]
}
Each lane keeps its own partial value, and the lanes are folded once when the loop ends. A floating point reduction is therefore not bit identical to the equivalent scalar loop, because the additions are grouped differently. It is usually more accurate, since each lane accumulates a shorter chain.
Implementations must document this. Programs that require a specific summation order must use a plain for.
10. Contracts
10.1 no_alloc
fn f(...) guarantees no_alloc { ... }
The function, and every function it calls, and every function those call, contains no alloc. The check follows the whole call graph. A violation names the full chain from the contract to the allocation.
The proof looks inside every construct that holds statements, including match arms and arena blocks.
10.2 unsafe
An unsafe block may not appear inside a function that carries a contract (E-CONTRACT-UNSAFE). A contract that can be waived inside the function carrying it is not a contract.
11. Scoped memory
alloc(n) takes memory from a bump arena and returns it zeroed. There is no free.
arena {
let mut t: [f32] = alloc(n)
...
}
Everything allocated inside the block is released at the closing brace, by restoring the arena pointer. A name declared inside the block is dead after it; using one is E-ARENA-RELEASED.
mem_bytes() reports memory currently held. mem_peak() reports the high-water mark.
An allocation made outside any arena is never released. This is intentional for version 1: a kernel library allocates at startup and then runs under no_alloc.
12. Programs of several files
using geom
reads geom.prst from the directory of the file that names it. Names come in flat: write dot(a, b, n), not geom.dot(...). There is no qualified form and no namespace per file.
A name defined in two files is an error naming both. This is the no-shadowing rule of section 5.1 applied across files.
Each file is read once, so a cycle or a diamond is harmless. There is no search path beyond the directory, and no package manager.
A diagnostic arising inside a file that was brought in by using is reported against that file, with its own line and caret.
Every command follows using: a program spread across files still produces one binary, one header and one set of bindings.
13. Built-in functions
13.1 Memory and shape
alloc(n) a rank-1 array of n zeroed elements alloc(d0, d1) rank 2 alloc(d0, d1, d2) rank 3 len(a) the size of dimension 0 len(a, k) the size of dimension k
13.2 Conversion
i32(x) f32(x) u8(x)
u8 saturates. The others truncate toward zero.
13.3 Mathematics
sqrt(x) f32 -> f32 floor(x) f32 -> f32 sin(x) cos(x) f32 -> f32 pow(a, b) f32, f32 -> f32 fma(a, b, c) f32, f32, f32 -> f32, computing a*b + c with one rounding abs(x) f32 -> f32, or i32 -> i32 min(a, b) both operands of one type max(a, b) both operands of one type
All of these are permitted inside a vec for.
fma is explicit because fusing a multiply and an add changes the result. A C compiler performs that fusion by default and does not say so.
13.4 Measurement
clock_ms() milliseconds since the program started mem_bytes() arena memory currently held mem_peak() the high-water mark
13.5 Input and output
input_i32() read one whitespace-separated integer input_f32() read one whitespace-separated float
There is no input_str, because reading text requires a buffer to hold it.
13.6 Terminal
sleep_ms(n) flush output, then wait canvas_init() permit a console to interpret escape sequences
canvas_init is required once on Windows, whose consoles ignore escape sequences until asked. It does nothing elsewhere.
sleep_ms flushes before waiting so that a drawn frame appears before the pause rather than after it.
14. Diagnostics
Every diagnostic carries a message, a file, a line, a column, and a caret under the offending token. Most carry a note pointing at a second location, and a suggestion.
The codes are stable and may be relied upon:
E-UNIQ-ALIAS two writable references to one buffer E-UNIQ-WRITE a write through a shared reference E-CONTRACT-ALLOC an allocation inside a no_alloc function E-CONTRACT-UNSAFE an unsafe block inside a function with a contract E-VEC-CARRIED a loop-carried dependency E-VEC-STRIDE a stride other than 1 E-VEC-AFFINE an index not affine in the loop variable E-VEC-INDIRECT an index read from another array E-VEC-UNPROVEN overlap that cannot be ruled out E-VEC-BODY a statement that has no vector form E-VEC-REDUCE an unrecognised reduction E-ARENA-RELEASED use of memory released at the end of an arena E-SHAPE wrong rank, wrong subscript count, or a missing axis E-STRUCT-AOS an array of structs E-STRUCT-RETURN returning a struct that holds arrays E-MATCH-PARTIAL a match on an enum missing a variant E-GPU a kernel that cannot be dispatched, and why
A diagnostic is a refusal to guess. The compiler does not warn and proceed.
15. Compilation targets
15.1 CPU
The reference implementation emits LLVM IR text and hands it to clang. The default target is x86-64-v3 (AVX2), which is a fixed target: the same source produces the same machine code on every machine. --native tunes for the compiling machine and is not reproducible. --baseline targets a CPU without AVX2 and runs at half the vector width.
15.2 GPU
A vec for is a loop whose iterations the compiler has proved independent, which is exactly what a compute dispatch requires. The GPU target therefore needs no new syntax: no thread index, no workgroup declaration, no dispatch shape.
view -> var<storage, read> uniq -> var<storage, read_write> scalars and array dimensions -> one uniform structure a masked if -> select()
A kernel is one vec for, or one plain for whose body is one vec for, which becomes a two-dimensional dispatch. Leading scalar bindings are permitted. Struct parameters cross as their fields.
A reduction crosses without atomics: each workgroup folds its slice in workgroup memory using barriers, writes one partial result, and the host folds the partials. The tree has the same shape on every run, so the answer is repeatable, which an atomic accumulation would not be.
Refused, each with E-GPU and a reason: u8, because a packed byte write would race between invocations; work beside the vec for, which would run once per column instead of once per row; work after a reduction, which the host folding partials cannot know about; and any other shape.
A file is compiled to the kernels that dispatch, and the rest are reported by name. One reduction in a library does not stop the others being emitted.
15.3 Interpreter
The reference implementation includes a tree-walking interpreter with the same meaning, used when no toolchain is present. Both back ends share the front end, so they can differ only in evaluation. A program the interpreter accepts and the compiler rejects is a defect in the implementation.
The interpreter checks array bounds. Compiled code does not.
16. Deliberate omissions
The following are absent by decision, not by oversight. Each entry gives the reason.
Implicit conversion. A conversion that changes a value should be visible where it happens.
String concatenation. It allocates, and no function that concatenates could carry no_alloc.
Arrays of structs. Stride 3 defeats vectorisation, which is the point of the language.
Exceptions. Control flow that leaves a line without appearing in the source, unwinding tables that enlarge the binary, and an allocation on throw. A function that might throw could not carry no_alloc. Failure is returned instead, as a status enum or a struct holding both an outcome and a value.
Shadowing. One name means one thing in one function.
Automatic fusion of multiply and add. It changes the rounding. Write fma and the change is yours.
i64 and f64. The vector width is defined in terms of 32-bit elements. Adding 64-bit types means a second width rule.
Unsigned integers beyond u8. u8 exists for image and audio data. Wider unsigned arithmetic has no user in this language yet.
Slices. A view into part of a buffer is a reference, and rule R6 exists precisely to avoid references that outlive their scope. Slices require extending R6 rather than ignoring it, and that is version 2 work.
Generics, closures, traits, inheritance. None of them help write a loop over an array of floats.
A garbage collector. See section 11.
A package manager. A kernel library is a few files in a directory.