This chapter describes the values Mica computes with as mathematics: vectors, matrices and tensors of any rank, whose shapes are part of their types or arrive with their values, the operations between them, the gradient tape that records a computation and walks it backwards to its gradients, and the placement of a tensor on the GPU. Each construct names its rule in the grammar, says what it means, and quotes what the compiler answers when a source breaks the rule. Vectors and matrices, dynamic tensors, tracked values and the first GPU program teach the same ground from the start.
Every example is a complete program the website’s gate compiles with the compiler as it is written here. The output shown beneath an example is the output the program prints; the GPU example needs a card to run, and the chapter says what it computes.
The tensor types
A tensor’s type states its rank and its element type, and a static tensor’s type states its extents as well (tensor-type, dynamic-tensor-type):
| Written | The value |
|---|---|
vector[3] of int64 | three elements, v[0] to v[2] |
matrix[2, 3] of float64 | two rows of three columns, a[0, 0] to a[1, 2] |
tensor[2, 3, 4] of float32 | a tensor of rank three, one extent for each axis |
vector of float64 | a vector whose length the value carries |
matrix of float64 | a matrix whose rows and columns the value carries |
A tensor is indexed from zero on every axis, whatever its type, and a constant
index outside an extent the type states is refused where it is written, while
a variable index outside it stops the program with index_out_of_range. A
dynamic tensor starts empty: Resize(address grid, 2, 3) gives a matrix two
rows and three columns, every element zero, and Rows, Columns and Length
read its extents. A tensor is a value, as a record and an array are:
assignment copies the whole of it, and a change to the copy leaves the
original as it was. A literal writes a vector whole, v := (1, 2, 3).
| Written | Code | The compiler’s words |
|---|---|---|
a tensor without its rank, x : tensor of float64; | 2070 | 'tensor of' spells no rank, and an extent-dynamic tensor keeps its rank in the type: spell 'vector of T' or 'matrix of T', or give the tensor its static shape as 'tensor[d1, ..., dn] of T' |
v[3] := 1.0;, v a vector[3] of float64 | 5128 | constant index expression at position 1 in selection operation 'index' has value '3' outside array index domain 'subrange[0..2] of int64' |
Operations
The operators between tensors are the ones of linear algebra:
| Written | What it computes | Its operands |
|---|---|---|
u + v, u - v | the sum and the difference, element by element | two tensors of one shape |
u ⊙ v | the product element by element | two tensors of one shape |
-v | every element negated | one tensor |
3 * v, v * 3 | every element scaled | a scalar of the element type and a tensor, in either order |
a * v, a * b | the matrix product, of a matrix and a vector or of two matrices | the left operand’s columns as many as the right operand’s rows |
Dot(u, v) | the sum of the elementwise products, a scalar | two vectors of one length |
Transpose(a) | the matrix with its rows and columns exchanged | a matrix |
An operation computes in one element type. An integer literal takes the
element type of the tensor it meets, and every other scalar has the element
type already: a constant declared without a type is an int32, so a constant
that scales a vector of int64 is declared Factor : int64 = 3. Between two
vectors, * and / mean nothing: the product of two vectors is Dot, and no
operator divides by a matrix. Sum, Transpose, Dot and the rest of the
family arrive by imp from the math unit, and
the math unit lists them.
Where a static tensor’s extents disagree, the compiler refuses the operation.
Where a dynamic tensor’s do, nothing is known until the program runs, so
every operation on a dynamic tensor compares the extents first, on every tier,
and a disagreement stops the program at the operation with
tensor_shape_mismatch: the product of two matrices of two rows and three
columns, c := a * b, stops there, at its line and column.
| Written | Code | The compiler’s words |
|---|---|---|
v + w, a Vec3 and a Vec2 | 5333 | 'addition' combines tensors cell by cell, so both operands must share one shape: found 'Vec3' and 'Vec2' |
s * v, s a float64 and v a vector[2] of float32 | 5335 | a tensor operation computes in exactly one element type, found 'float64' and 'vector[2] of float32': convert the odd operand explicitly so no widening hides inside an operator |
a * w, a Mat23 and a Vec2 | 5334 | the product 'multiplication' requires the left operand's columns to meet the right operand's rows, and a batched pairing's leading axes to agree slice for slice: found 'Mat23' and 'Vec2' |
v * u, two vectors | 5336 | the operator 'multiplication' has no meaning on 'Vec3' and 'Vec3': tensors define '+', '-' and '⊙' elementwise and '*' as the linear-algebra product — the dot product is the 'Dot' function, a matrix quotient would smuggle a hidden inverse, and a plain storage array joins no algebra |
{
Shapes - vectors and matrices whose shapes are part of their types, and one whose extents arrive at run time.
'v' and 'u' are vectors of three integers and 'a' a matrix of two rows and three columns: '+' and '⊙' combine two
vectors cell by cell, a scalar scales one, '*' is the product of a matrix and a vector or of two matrices, and 'Dot'
is the product of two vectors. 'grid' and 'other' are dynamic matrices, whose product's shape is read from them.
}
program Shapes;
imp
WriteLn, Resize, Rows, Columns : std;
Dot, Transpose : math;
const
RowCount = 2;
ColumnCount = 3;
Factor : int64 = 3;
Half = 0.5;
VectorLine = "%ls %lld %lld %lld";
PairLine = "%ls %lld %lld";
DotLine = "Dot(v, v) %lld";
GramLine = "a * Transpose(a) %lld %lld / %lld %lld";
ShapeLine = "grid * other is %lld x %lld, its first cell %lf";
VectorTitle = "v ";
SumTitle = "v + v ";
ScaledTitle = "3 * (v + v) ";
CellTitle = "u ⊙ v ";
ProductTitle = "a * v ";
type
{ A vector of three integers, a vector of two, and the matrices the products answer. }
Triple = vector[3] of int64;
Pair = vector[2] of int64;
Wide = matrix[2, 3] of int64;
Square = matrix[2, 2] of int64;
var
v, u : Triple;
w : Pair;
a : Wide;
gram : Square;
grid, other, product : matrix of float64;
row, column : int64;
begin
v := (1, 2, 3);
for row := 0 to RowCount - 1 do
for column := 0 to ColumnCount - 1 do
a[row, column] := row * ColumnCount + column + 1;
WriteLn(VectorLine, VectorTitle, v[0], v[1], v[2]);
u := v + v;
WriteLn(VectorLine, SumTitle, u[0], u[1], u[2]);
u := Factor * u;
WriteLn(VectorLine, ScaledTitle, u[0], u[1], u[2]);
u := u ⊙ v;
WriteLn(VectorLine, CellTitle, u[0], u[1], u[2]);
w := a * v;
WriteLn(PairLine, ProductTitle, w[0], w[1]);
WriteLn(DotLine, Dot(v, v));
gram := a * Transpose(a);
WriteLn(GramLine, gram[0, 0], gram[0, 1], gram[1, 0], gram[1, 1]);
Resize(address grid, RowCount, ColumnCount);
Resize(address other, ColumnCount, RowCount);
for row := 0 to RowCount - 1 do
for column := 0 to ColumnCount - 1 do
begin
grid[row, column] := Half;
other[column, row] := Half;
end;
product := grid * other;
WriteLn(ShapeLine, Rows(product), Columns(product), product[0, 0]);
end.It prints:
v 1 2 3
v + v 2 4 6
3 * (v + v) 6 12 18
u ⊙ v 6 24 54
a * v 14 32
Dot(v, v) 14
a * Transpose(a) 14 32 / 32 77
grid * other is 2 x 2, its first cell 0.750000The tape
A tracked value is a value paired with its place on the program’s gradient
tape: tracked before a type makes a variable, a parameter, a field or a
result of the pair
(value-type). The type behind it is a
float, float16, bfloat16, float32 or float64, or a tensor of any rank
over one. Ordinary code reads and writes the value half alone: an assignment of
a plain value writes it, and an assignment of the tracked variable to a plain
one reads it.
A tape window, tape and its statements and end, records every operation on
tracked values that runs inside it
(tape-window): the operators
+, -, * and /, negation, and the recorded verbs of the math unit,
which tracked values lists.
A window starts a new record, so a value recorded in an earlier window has no
gradient in this one. A routine whose parameters or result are tracked records
its body when a window calls it, and computes plainly anywhere else.
After the window, Backward walks the record backwards from one scalar, the
loss, once, and Gradient then reads the derivative of the loss with respect
to a tracked value: a scalar for a scalar and a tensor of the same shape for a
tensor. A tracked value the window never recorded has a gradient of zero. Both
come from the language’s own unit, imp Backward, Gradient : std;, and the
order is checked while the program runs:
| Written | The program stops with |
|---|---|
Backward(y); twice after one window | tape_misuse, with the line Mica runtime message: Backward was called twice on one tape window |
g := Gradient(x); after a window, before Backward | tape_misuse, with the line Mica runtime message: Gradient was read before Backward walked the tape window |
The tape records one window at a time, in one task, so a window stands in the
body of a routine or of the program, outside every other window and every
concurrent block, and a task takes no tracked parameter.
| Written | Code | The compiler’s words |
|---|---|---|
x : tracked int64; | 5461 | the tracked variable 'x' pairs a value with its tape node id, and the recorded walk computes in the float family: 'int64' is not a float or a tensor over a float, the inner types the tape records — track a float or a float tensor of any rank, and derive what you need from its gradient |
| a window inside a window | 5465 | the tape window cannot stand inside another window: the tape records one window at a time, and its transcript is one task's linear story — open the window in the body of a routine or of the unit, outside every other window and every concurrent block, and let a window call the routines it records rather than open a second window |
a window inside a concurrent block | 5465 | the tape window cannot stand inside a concurrent block: the tape records one window at a time, and its transcript is one task's linear story — open the window in the body of a routine or of the unit, outside every other window and every concurrent block, and let a window call the routines it records rather than open a second window |
Backward(v);, v a tracked Vec2 | 5472 | 'Backward' walks the tape backwards from one scalar seed, got a 'tracked Vec2': a tensor has no single direction to walk back from — fold the value to the scalar the walk wants, its loss, with 'Dot' or 'Sum', and hand that to Backward |
r := Row(m, 0); in a window, m a tracked matrix | 5503 | 'Row' does not record onto the tape: the recorded vocabulary carries only the verbs whose derivative rules the reverse walk knows, and the tracked argument at position 1 cannot travel a call outside it — read the value half into a plain variable by whole assignment, and hand 'Row' that plain value |
s := Softmax(m); in a window, m a tracked matrix[2, 2] of float64 | 5473 | 'Softmax' on tracked operands records onto the tape, and the recorded form wants a vector over one float element type: got 'matrix[2, 2] of float64' — the recorded vocabulary keeps each verb's plain signature, judged over the value halves |
task T(x : tracked float64); | 5318 | task parameter 'x' cannot be copied into the task: 'x' is a tracked value, which names an entry on the program's tape - a task receives its own copy of every value it is handed, so pass the value itself or a plain handle |
{
Descent - a weight walked towards its target by the slope the tape computes.
Each round records the squared error of 'weight' in a tape window, walks the window backwards from the loss, and
reads the slope of the loss with respect to the weight, which moves the weight a quarter of the slope downhill. The
last lines record the length of a vector squared, whose gradient is twice the vector. Every value is a sum of
halves and quarters, so every digit is exact.
}
program Descent;
imp
WriteLn, Backward, Gradient : std;
Dot : math;
const
Rounds = 4;
Rate = 0.25;
Target = 3.0;
Start = 1.0;
RoundLine = "round %lld: weight %lf, loss %lf, slope %lf";
VectorLine = "the gradient of Dot(p, p) at (%lf, %lf) is (%lf, %lf)";
var
weight, loss, length : tracked float64;
point : tracked vector[2] of float64 := (2.0, 3.0);
plainWeight, plainLoss, slope : float64;
plainPoint, slopes : vector[2] of float64;
round : int64;
{ The squared distance of an estimate from the target, recorded on the tape when a window calls it. }
function SquaredError(estimate : tracked float64) : tracked float64;
begin
SquaredError := (estimate - Target) * (estimate - Target);
end;
begin
weight := Start;
for round := 1 to Rounds do
begin
tape
loss := SquaredError(weight);
end;
Backward(loss);
slope := Gradient(weight);
plainWeight := weight;
plainLoss := loss;
WriteLn(RoundLine, round, plainWeight, plainLoss, slope);
weight := plainWeight - Rate * slope;
end;
tape
length := Dot(point, point);
end;
Backward(length);
slopes := Gradient(point);
plainPoint := point;
WriteLn(VectorLine, plainPoint[0], plainPoint[1], slopes[0], slopes[1]);
end.It prints:
round 1: weight 1.000000, loss 4.000000, slope -4.000000
round 2: weight 2.000000, loss 1.000000, slope -2.000000
round 3: weight 2.500000, loss 0.250000, slope -1.000000
round 4: weight 2.750000, loss 0.062500, slope -0.500000
the gradient of Dot(p, p) at (2.000000, 3.000000) is (4.000000, 6.000000)Tensors on the GPU
on gpu behind a tensor’s type places its storage in the memory of an NVIDIA
GPU (tensor-type), and behind a
float’s name places a single value there
(device-scalar-type). The
placement moves the storage and nothing else: matrix[2, 2] of float32 on gpu
has the shape, the operators and the refusals of the matrix without it, and an
operation whose operands are on the device runs on the device. ToDevice
copies a value from the processor’s memory to the card’s and ToHost copies it
back, and they are the only roads between the two: an operation or an
assignment that would mix the two memories is refused. A device tensor holds
float32, float64, float16 or bfloat16, and the tape records on the
device as it does on the processor.
| Written | Code | The compiler’s words |
|---|---|---|
d : matrix[2, 2] of int64 on gpu; | 5475 | a device tensor's element type must be 'float32', 'float64', 'float16', or 'bfloat16', found 'int64': the device kernel library is defined over the float families the training workload computes in — keep integer data host-side, and move the float tensors |
da := a * da;, a on the processor and da on the card | 5480 | the operator 'multiplication' cannot mix storage worlds: got 'matrix[2, 2] of float32' and 'matrix[2, 2] of float32 on gpu' — an operation across placements would smuggle the very transfer the placement axis makes visible; move one operand with ToDevice or ToHost, so the crossing is written where it costs |
da := a; | 5481 | the assignment crosses storage worlds, 'matrix[2, 2] of float32' into 'matrix[2, 2] of float32 on gpu': a copy between host and device memory is a transfer, and a transfer is a verb — write ToDevice or ToHost, so the crossing is visible where it costs |
A program that places a tensor on the GPU builds on a machine without a card,
and running it there names what it looked for. Running it needs an NVIDIA GPU
of compute capability 7.5 or newer, NVIDIA’s driver, cuBLAS and NVRTC, as
the first GPU program says, and the
gpu runtime flag chooses the card on a machine with several
(runtime flags). A device tensor does not cross
into a routine’s parameter, and a program that places a tensor on the GPU
links in the single-task flavor only, which is a
known limitation.
A debug build waits for every kernel and every product the card runs, so a
failing one is named where it failed. An optimizing build queues them and lets
the program go on, and a copy back to the processor waits for their results;
a failure is then reported at the next act that waits, with a note that says
so.
{
OnTheCard - one sentence of matrix arithmetic, computed on the processor and on the GPU.
'a' and 'b' live in the processor's memory and 'da' and 'db' in the card's. 'ToDevice' and 'ToHost' are the only
roads between the two, and the same expression over the device's operands runs on the device. Every input is a
multiple of one eighth, so the two lines agree to the last digit.
}
program OnTheCard;
imp
WriteLn, ToDevice, ToHost : std;
const
Size = 2;
Eighth : float32 = 0.125;
Rate : float32 = 0.5;
HostLine = "host = %f %f %f %f";
DeviceLine = "device = %f %f %f %f";
var
a, b, c : matrix[2, 2] of float32;
da, db, dc : matrix[2, 2] of float32 on gpu;
row, column : int64;
begin
for row := 0 to Size - 1 do
for column := 0 to Size - 1 do
begin
a[row, column] := ((row * Size + column + 1) as float32) * Eighth;
b[row, column] := ((Size - row - column) as float32) * Eighth;
end;
c := (a * b) * Rate + a;
WriteLn(HostLine, c[0, 0], c[0, 1], c[1, 0], c[1, 1]);
da := ToDevice(a);
db := ToDevice(b);
dc := (da * db) * Rate + da;
c := ToHost(dc);
WriteLn(DeviceLine, c[0, 0], c[0, 1], c[1, 0], c[1, 1]);
end.The program computes one sentence twice, on the processor and on the card, and prints each result on a line of its own. Every input is a multiple of one eighth, so every product and every sum in it is exact.
The floating-point flags
Two words join an --optimize set of a program that computes on the GPU, and
each changes the digits its products give, so neither belongs to a level:
| Word | What it lets the card do |
|---|---|
fp_tensor_float32, as in O2,fp_tensor_float32 | read the inputs of every float32 matrix product, the tape’s reverse products among them, in TF32 on the card’s tensor cores: ten bits of mantissa with float32’s range, the sums kept in float32 |
fp_half_intermediates | hold the intermediates of a float16 or bfloat16 operation in the half: a half product’s partial sums added in the half, and an attention’s weights and score gradients rounded to it before the products that read them |
A product’s digits are the program’s decision for every library it links, so
a library’s build refuses both words: The optimization 'fp_tensor_float32' requires a program as output binary: linking aborted.
The command line describes each optimization word.