A tour of Vx
This chapter covers the ordinary parts of the language — everything you would need to write a command-line program, with no accelerator in sight. If you have written Rust, most of this will look familiar; the differences are called out where they matter.
Values and types
#![allow(unused)]
fn main() {
let x : i32 = 21; // annotated
let y = 21; // inferred from context
let mut count = 0; // mutable
}
Bindings are immutable unless you write mut.
The primitive types are the ones you would expect: i8 i16 i32 i64, u8 u16 u32 u64,
f16 f32 f64, and bool. For machine learning there are also bf16, the 4-bit integers i4 and
u4, and the fp8 and fp4 types in Low-precision numbers.
There are no implicit numeric conversions. An i32 does not become an i64 because the context
wants one; you write the conversion. Integer literals infer to the type the context requires, so
let n : i64 = 5; is fine, but mixing two differently-typed values in one expression is an error.
This is deliberate — silent widening is a common source of both bugs and unintended performance
cliffs.
A const at the top level names a value known while compiling. Its type is required, and each use
is replaced by the value:
#![allow(unused)]
fn main() {
const SIDES : i32 = 6;
const EDGES : i32 = SIDES * 2;
}
The value is a number or a bool: a literal, or arithmetic on literals and other consts.
A const can also be a table of numbers. It is stored once, read-only, and read one number at
a time:
#![allow(unused)]
fn main() {
const POWERS : Tensor<i64, [4]> = [1, 10, 100, 1000];
fn power_of_ten(i : i64) -> i64 {
return POWERS[i];
}
}
Functions
#![allow(unused)]
fn main() {
fn add(a : i32, b : i32) -> i32 {
return a + b;
}
}
Parameter types and the return type are both mandatory — there is no inference for either, and a
function with no useful result is written -> void. A function may end with a bare expression
instead of return, as in Rust.
Control flow
#![allow(unused)]
fn main() {
if x > 10 {
// ...
} else if x == 10 {
// ...
} else {
// ...
}
for i in 0..n {
// ...
}
while i < n {
// until the condition is false
i = i + 1;
}
loop {
// forever, until you break
if done { break; }
}
}
The three loops are for over a range, while with a condition checked before every turn, and
bare loop with an explicit break.
0..n is a half-open range: it includes 0 and excludes n.
Structs
struct Point {
x: i32,
y: i32,
}
fn main() -> i32 {
let p = Point { x: 3, y: 4 };
let a = p.x;
return a;
}
Methods go in an impl block, and the receiver is written out in full — there is no implicit
self:
#![allow(unused)]
fn main() {
impl Point {
fn magnitude_squared(self: &Point) -> i32 {
return self.x * self.x + self.y * self.y;
}
fn translate(self: &mut Point, dx: i32, dy: i32) -> void {
self.x += dx;
self.y += dy;
}
}
}
A return type is never optional. A function that produces no useful result returns void, and
exits early with a bare return;.
&Point borrows immutably, &mut Point mutably. A method with no self parameter is an associated
function, called as Point::make(...).
Tuples
A tuple groups a few values without naming a struct for them. Its elements are read by
position, or taken apart with let:
fn divmod(a: i64, b: i64) -> (i64, i64) {
return (a / b, a % b);
}
fn main() -> i32 {
let (q, r) = divmod(17, 5);
let pair = (q, r);
return (pair.0 + pair.1) as i32;
}
A tuple has two to six elements. Patterns nest, let ((a, _), c) = t;, and _ skips an element.
match takes a tuple apart too. A literal in a pattern must equal that element, and a name binds
it. The last arm must match every value, such as _ or (x, y):
#![allow(unused)]
fn main() {
fn sign_pair(a: i64, b: i64) -> i64 {
match (a, b) {
(0, 0) => { return 0; },
(0, y) => { return y; },
(x, _) => { return x; }
}
}
}
An element of a pattern can also be an enum variant, which binds its payload:
#![allow(unused)]
fn main() {
fn add(a: Option<i64>, b: Option<i64>) -> i64 {
match (a, b) {
(Option<i64>::Some(x), Option<i64>::Some(y)) => { return x + y; },
(Option<i64>::Some(x), _) => { return x; },
_ => { return 0; }
}
}
}
Enums and pattern matching
Enums carry data:
#![allow(unused)]
fn main() {
enum Result {
Ok(i32),
Err(i32),
}
enum Color {
Red, Green, Blue,
}
}
Construct a variant with ::, and take it apart with match:
#![allow(unused)]
fn main() {
fn unwrap_or(r: Result, default: i32) -> i32 {
let mut out = default;
match r {
Result::Ok(val) => { out = val; },
Result::Err(code) => { out = -code; },
}
return out;
}
}
A data-carrying enum is laid out as a tag plus a payload.
A match can also be used as a value directly, with each arm evaluating to a result:
#![allow(unused)]
fn main() {
fn pick(x : i32) -> i32 {
match x { 0 => { 7 }, _ => { 9 } }
}
}
Stopping a program
panic(msg) prints panic: and the message, then stops the program; abort() stops it with no
message. A function declared -> ! never returns, like core::panic’s unreachable(), todo()
and unimplemented(). A function can end with a call to one, and the call can stand where any
value is expected:
#![allow(unused)]
fn main() {
import core::panic;
fn get(ok: bool, v: i64) -> i64 {
let x: i64 = if ok { v } else { todo() };
return x;
}
}
Option::expect(msg) and Result::expect(msg) stop the program with the caller’s message.
Arrays and tensors
An array literal is a tensor:
#![allow(unused)]
fn main() {
let a : Tensor<f32, [4]> = [ 1.0, 2.0, 3.0, 4.0 ];
let first = a[0];
}
The shape is part of the type. Tensor<f32, [4]> has four elements, known at compile time.
A ? marks a dimension that is only known at runtime:
#![allow(unused)]
fn main() {
fn matmul(a : Tensor<f32, [?, ?]>, b : Tensor<f32, [?, ?]>) -> Tensor<f32, [?, ?]> {
let mut result : Tensor<f32, [?, ?]> =
unsafe { Tensor<f32, [?, ?]>::uninit([a.extent(0), b.extent(1)]) };
for i in 0..a.extent(0) {
for j in 0..b.extent(1) {
result[i][j] = 0.0;
for k in 0..a.extent(1) {
result[i][j] += a[i][k] * b[k][j];
}
}
}
return result;
}
}
.extent(n) reads the size of dimension n. Shapes that are known statically get checked
statically — a matmul whose inner dimensions disagree is a compile error rather than a runtime one.
m[i] is row i, and a row has the size of the last dimension: for a [2, 3] tensor, m[0][j]
needs j < 3 and does not reach into the next row. An index the compiler can see is too large,
such as m[0][5] or m[0][j] inside for j in 0..6, is a compile error. To walk all the
elements in one loop, .reshape the tensor to one dimension.
.reshape(shape) gives the same elements in a new shape, without copying them. .pad(shape) makes
a larger tensor with zeros after each axis, which is how a matrix is rounded up to a tile size:
#![allow(unused)]
fn main() {
let m : Tensor<f32, [30, 30]> = Tensor<f32, [30, 30]>::fill(1.0);
let tiled : Tensor<f32, [32, 32]> = m.pad([32, 32]); // m[i][j] is tiled[i][j]
let row : Tensor<f32, [900]> = m.reshape([900]);
}
The language reference lists the other forms, which keep or drop elements in row order.
Collections
The standard library ships the usual containers. Vec<T> is a growable array:
import std::vec;
fn main() -> i32 {
let mut v = Vec<i32>::new();
v.push(10);
v.push(32);
return v.get(0) + v.get(1);
}
Also available: HashMap, HashSet, Option, Result, String, Box, and iterator adaptors.
See the standard library for the full list.
Modules
One file is one module. import pulls another in:
#![allow(unused)]
fn main() {
import std::vec;
import std::io;
import graph::traversal;
}
std:: resolves against the standard library shipped with your toolchain. Anything else resolves
against the library search path and then the current directory.
An import brings in everything the module declares, and everything it imports. A module’s own
declaration wins over an imported one with the same name, so declaring struct Range does not
disturb the library code that uses core::iter’s. When two imports declare the same name, using
it is an error, since nothing says which one is meant.
Unsafe
Raw pointers exist, and the operations that can go wrong with them require unsafe:
extern "C" {
fn malloc(size : i64) -> *mut i8;
fn free(p : *mut i8) -> void;
}
fn main() -> i32 {
unsafe {
let p = malloc(4);
p[0] = 42;
print(p[0]);
free(p);
}
return 0;
}
Dereferencing a raw pointer, indexing through one, reading a field through one, and calling an
unsafe fn all require an unsafe block. A function that takes a caller’s raw pointer and
dereferences it should be declared unsafe fn, so the obligation is visible in its signature
rather than buried in its body. Marking a function unsafe does not allow raw pointer operations
in its body. Each one still needs an unsafe block, as in Unsafe and FFI.
extern "C" blocks declare foreign functions. Vx’s C ABI interop is zero-overhead: there is no
marshalling layer.
Comptime
if comptime selects a branch at compile time. The branches not taken are pruned before semantic
analysis, so they may refer to things that do not exist on the target being compiled for:
#![allow(unused)]
fn main() {
let mut val = 0;
if comptime Topology::Current == Topology::CPU {
val = 1;
} else if comptime Topology::Current == Topology::CPU_AVX512 {
val = 2;
} else {
val = 3;
}
}
Topology::Current is the topology the current region compiles for. This is how one source file
carries code for several targets without the dead paths having to typecheck against all of them.
if is also an expression:
#![allow(unused)]
fn main() {
return if val == 1 { 0 } else { 1 };
}
Const generics let a value appear in a type — Tensor<f32, [N]> for a const N : i32 — and are
resolved by monomorphization.
What is next
- Ownership and borrowing — the memory model.
- Generics and traits — abstraction without runtime cost.
- Topologies and memory — the reason Vx exists.