Intermediate representations¶
This page is the reference for Scaly's two intermediate representations (IRs), the expression dialect and the program dialect. The architecture overview explains why there are two and what an IR node is. Each section below lists what a dialect contains and shows how its constructs look when printed.
The expression dialect¶
Expression nodes¶
An Expr has these fields:
| Field | Meaning |
|---|---|
op |
The ExprOp operation tag |
args |
Ordered operand nodes |
type |
A TensorType: shape, data type and the diff flag |
attrs |
Operation-specific data, such as a callee or transpose axes |
name |
The input name, or None for other nodes |
value |
The NumPy array of a constant, or None for other nodes |
lowering |
The auto, scalar or block hint |
sc.format_expr(expr) prints the graph below one node, and
sc.render_expr_assembly(fn) prints a Function and every function it calls.
The assembly numbers nodes in traversal order, so two runs print the same text.
Types¶
A TensorType holds a fixed shape of non-negative integers, a data type and a
diff flag. Shape () is a scalar.
| Data type | C type | Bytes |
|---|---|---|
sc.dtypes.bool_ |
uint8_t |
1 |
sc.dtypes.int32 |
int32_t |
4 |
sc.dtypes.int64 |
int64_t |
8 |
sc.dtypes.float32 |
float |
4 |
sc.dtypes.float64 |
double |
8 |
The default is float64. An operation on two data types fails when you build
it, and sc.cast converts one explicitly:
x = sc.sym("x", 3)
n = sc.sym("n", 3, dtype=sc.dtypes.int64, diff=False)
x + n # TypeError: mixed dtypes float64 and int64; convert one with sc.cast
x + sc.cast(n, "float64") # float64
sc.cast follows NumPy's astype: a float becomes an integer by dropping its
fraction, and a cast to bool is x != 0, so NaN is true. A comparison,
sc.isfinite, &, | and ~ give bool. // and % take integers and
round the quotient down, as NumPy does, so -7 // 2 is -4 and -7 % 2 is
1. A zero divisor gives 0 for both, as in NumPy. / takes floating
operands only. Arithmetic on bool raises and asks for a cast.
A Python number or list beside an expression takes that expression's data type.
An int may join an integer or floating expression, but it raises
OverflowError outside an integer type's range, and a float beside an integer
expression raises TypeError. NumPy arrays and scalars keep their own data
type, so they must match the expression. sc.const follows the same rule: it
keeps a NumPy value's data type and makes anything else float64.
NumPy's unsigned byte dtype, uint8, is not supported for expressions.
A cast, symbol, constant or TensorType requesting that dtype
raises ValueError at construction.
n + 1 # int64
x * np.int64(2) # TypeError: mixed dtypes float64 and int64
sc.const(np.arange(3)) # an int64 constant
sc.const([0, 1, 2]) # a float64 constant
A numerical call converts each argument to its leaf's data type when NumPy's
same_kind casting rule allows it, as from int64 to float64,
and raises TypeError for anything else, such as a complex array.
Data types matter inside the graph and the generated procedures. Lowering and
numerical evaluation require every Function input and output leaf to be
float64. Boolean and integer values inside a function are allowed. The exported
C entry reads and writes double buffers, as described in
the pointer ABI.
The diff flag says whether a value can carry a derivative. Inputs have it
unless created with diff=False. Constants and non-differentiable operations
never have it, and any other operation has it when one of its operands does:
x.type.diff # True
sc.const(2.0).type.diff # False
x.floor().type.diff # True
(x * x.floor()).type.diff # True
The flag is metadata. The differentiation rules give floor and ceil zero
derivative and split ties equally for minimum and maximum. The
derivative conventions also
cover abs and extremum reductions.
A sparse matrix also carries its stored pattern in its type, and so does a sparse derivative, which is a sparse matrix whose stored values are built from the derivative's coloring.
Operations¶
Arity is the number of operands.
| Family | Operations | Arity | Notes |
|---|---|---|---|
| Sources | input, const |
0 | A named input or a constant array |
| Arithmetic | neg, add, sub, mul, div, pow |
1 or 2 | Binary operations broadcast like NumPy |
| Trigonometric | sin, cos, tan, asin, acos, atan, atan2 |
1 or 2 | Elementwise |
| Hyperbolic | sinh, cosh, tanh |
1 | Elementwise |
| Other math | erf, exp, log, sqrt, abs |
1 | Elementwise |
| Non-smooth | floor, ceil, minimum, maximum |
1 or 2 | Zero slopes or equal shares at ties |
| Comparison | lt, le, eq, ne, isfinite |
1 or 2 | A bool result, no derivative |
| Logic | and, or, not |
1 or 2 | bool operands |
| Selection | select |
3 | sc.where, derivative of the chosen branch |
| Conversion | cast |
1 | A derivative between floating types only |
| Integer | floordiv, mod |
2 | Rounding down, as in NumPy |
| Reduction | reduce |
1 | Sum, maximum or minimum over chosen axes |
| Shape | reshape, transpose |
1 | Size-preserving reshape, axis permutation |
| Indexing | slice, gather, scatter |
1, 2 or 3 | Gather and scatter take int64 index operands |
| Assembly | stack, concat |
Variable | Along one axis |
| Linear algebra | matmul |
2 | NumPy's rules, leading axes broadcast |
| Function calls | call, loop |
Variable | An ordinary call or a loop |
dot, sumsqr, norm_1, norm_2, norm_inf, argmin, argmax, mean,
diag and vec are builders that expand into these operations. There are no matrix decompositions
or interpolation tables, and no expm1 or log1p.
Calls and mapped calls¶
A symbolic call to a Function adds one call node, and sc.vmap adds one
loop node without carries or a condition, whatever the size of the callee.
Here a stage model like the one in
Getting started is mapped over ten stages and
the last state goes through a terminal cost:
@sc.function(sc.arg("z", 2), sc.arg("u", 1), outputs=sc.arg("znext"))
def model(z: sc.Expr, u: sc.Expr) -> sc.Expr:
return z + 0.1 * sc.concat([z[1:], u])
@sc.function(sc.arg("z", 2), outputs=sc.arg("cost"))
def terminal(z: sc.Expr) -> sc.Expr:
return sc.sumsqr(z)
@sc.function(sc.arg("zs", 20), sc.arg("us", 10), outputs=sc.arg("J"))
def stages(zs: sc.Expr, us: sc.Expr) -> sc.Expr:
znexts = sc.vmap(model, 10)(zs.reshape((10, 2)), us.reshape((10, 1))).vec()
return terminal(znexts[18:])
print(sc.render_expr_assembly(stages))
The module lists model and terminal first. This is the part for stages:
expr.func @stages(%zs: tensor<20xfloat64 diff>, %us: tensor<10xfloat64 diff>) -> (%J: tensor<float64 diff>) {
%0 = expr.input {lowering="auto", name="zs"} : tensor<20xfloat64 diff>
%1 = expr.input {lowering="auto", name="us"} : tensor<10xfloat64 diff>
%2 = expr.loop(%0, %1) {callee="model", cond=None, in_place=[], index=False, length=10, n_carry=0, output=0, reverse=False, starts=[0, 0], strides=[2, 1]} : tensor<20xfloat64 diff>
%3 = expr.reshape(%2) {shape=[10, 2]} : tensor<10x2xfloat64 diff>
%4 = expr.reshape(%3) {shape=[20]} : tensor<20xfloat64 diff>
%5 = expr.slice(%4) {index=[slice(18, None, None)]} : tensor<2xfloat64 diff>
%6 = expr.call(%5) {callee="terminal", output=0} : tensor<float64 diff>
expr.return %6
}
Both nodes keep the callee as an attribute and select one of its outputs with
output. The loop node records the leading-axis slicing as reads of the flat
operands. Iteration i reads zs[2i : 2i+2] and us[i : i+1], from starts
and strides, and writes two values of the flat output. Outer operands
of a mapped loop are always one-dimensional, so the reshapes in stages leave no
node behind. The
functions guide covers the
slicing rules. The program dialect shows what
these two nodes become.
Verification¶
Builders reject most invalid nodes as you write them. x.reshape(4) on a
three-element x raises a ValueError before any node exists. sc.verify_expr
checks a whole graph against the rule table in ir/expr_spec.py, which matters
after a pass has rebuilt it. Function construction runs this check automatically,
including on graphs rewritten before lowering. To see a failure, build a bad node
directly with the Expr constructor, which skips the builder's checks:
x = sc.sym("x", 3)
bad = sc.Expr(sc.ExprOp.RESHAPE, (x,), sc.TensorType((4,)), attrs={"shape": (4,)})
sc.verify_expr((bad * 2.0).sum())
# VerifyError: verify_expr: node %128283723843408 op=reshape failed rule 'reshape-size': RESHAPE source size 3 != target size 4
The message names the node, its operation, the rule and the reason. An unnamed
node is labelled with its Python object id, which changes from run to run.
Rules that apply to every node, such as the arity check, are in
spec_expr_shared. spec_expr adds the rules for each operation.
The program dialect¶
A ProgramNode is a statement, a declaration or a scalar calculation. You do
not build programs yourself. Lowering produces them when Scaly generates C, and
the listings below show the optimized program for each example.
Program operations¶
| Operation | Meaning |
|---|---|
program |
A module of procedures |
proc |
A procedure |
buffer |
Storage with a shape, data type and address space |
view |
An indexed location in a buffer |
load, store |
Read or write one scalar at a view |
store_pair |
Write two values to adjacent float64 elements |
assign |
Assign a scalar variable, optionally declaring it |
block |
A sequence of statements |
range, for |
A loop domain and a loop over it |
call |
Call another procedure |
const_int, const_float, var |
Scalar constants and variables |
Scalar arithmetic reuses the expression dialect's elementwise operations, with
two integer divisions. floordiv and floormod round down, as the expression
dialect's floordiv and mod do. div and mod on integers round toward zero
as C does, and serve index arithmetic, whose operands are never negative.
Calls and loops after lowering¶
Lowering stages from the expression example turns
the mapped loop into a program loop around one call and keeps the call:
prog.module {
prog.proc @model(%z: memref<2xfloat64>, %u: memref<1xfloat64>, %znext: memref<2xfloat64>) {device=host, input_count=2, lowering="auto", scalarize_mode="procedure", scalarized=True, sz_w=0, w_self=0} {
%v0 = prog.assign prog.load %z[1] : float64 {declare=True}
prog.store_pair prog.add(prog.load %z[0], prog.mul(0.1, %v0)), prog.add(%v0, prog.mul(0.1, prog.load %u[0])), %znext[0] : float64
}
prog.proc @terminal(%z: memref<2xfloat64>, %cost: memref<1xfloat64>) {device=host, input_count=1, lowering="auto", scalarize_mode="procedure", scalarized=True, sz_w=0, w_self=0} {
%v0 = prog.assign prog.load %z[0] : float64 {declare=True}
%v1 = prog.assign prog.load %z[1] : float64 {declare=True}
prog.store prog.add(prog.mul(%v0, %v0), prog.mul(%v1, %v1)), %cost[0] : float64
}
prog.proc @stages(%zs: memref<20xfloat64>, %us: memref<10xfloat64>, %J: memref<1xfloat64>) {device=host, input_count=2, lowering="auto", scalarize_mode="disabled", sz_w=0, w_self=0} {
%s0 = prog.buffer : memref<20xfloat64, private>
prog.for %it_t0 = 0 to 10 step 1 {kind=global} {
prog.call @model(%zs[prog.mul(2, %it_t0)], %us[%it_t0], %s0[prog.mul(%it_t0, 2)]) {callee_needs_w=False, n_in=2, n_out=1, w_self=0}
}
%t1 = prog.buffer : memref<2xfloat64, private> = prog.alias %s0 offset 18
%s1 = prog.buffer : memref<1xfloat64, private>
prog.call @terminal(%t1, %s1) {callee_needs_w=False, n_in=1, n_out=1, w_self=0}
prog.store prog.load %s1[0], %J[0] : float64
}
}
The starts and strides of the loop node became the views
%zs[2 * it] and %us[it]. A view passed to a call is an address, not a load,
and the C for stages shows it as pointer arithmetic (trimmed to the loop and
the call):
double s0[20];
const double* t1 = s0 + 18;
double s1[1];
for (long long it_t0 = 0; it_t0 < 10; ++it_t0) {
model_raw((arg[0] + (2 * it_t0)), (arg[1] + it_t0), (s0 + (it_t0 * 2)), NULL);
}
terminal_raw(t1, s1, NULL);
The two small callees were expanded into scalar statements, which the
scalarized=True attribute records. The optimizer may also expand a callee into
its caller. If stages returns znexts directly, the loop body contains the
arithmetic of model and no call remains. A call node therefore does not
promise a separate C function. Lowering and optimization says
when each happens.
Reading a lowered program¶
The attributes on a prog.proc line are these:
| Attribute | Meaning |
|---|---|
input_count |
How many leading parameters are inputs, rendered as const pointers |
lowering |
The combined hint of the function's nodes |
scalarize_mode |
Whether the procedure may be expanded into scalar statements |
scalarized |
Present once it has been |
sz_w |
Workspace size the caller must provide, callees included |
w_self |
The part of that workspace the procedure uses itself |
Views and aliases¶
A view names a location without reading it, and a buffer can alias another
buffer at an offset. Together they let a slice or a reshape of a computed value
reuse its storage. Here a slice and a reshape feed a callee that takes a matrix:
@sc.function(sc.arg("M", (2, 3)), outputs=sc.arg("s"))
def trace2(M: sc.Expr) -> sc.Expr:
return M[0, 0] + M[1, 1]
@sc.function(sc.arg("x", 8), outputs=sc.arg("t"))
def f(x: sc.Expr) -> sc.Expr:
y = x.sin()
return trace2(y[2:].reshape((2, 3)))
prog.module {
prog.proc @trace2(%M: memref<2x3xfloat64>, %s: memref<1xfloat64>) {device=host, input_count=1, lowering="auto", scalarize_mode="procedure", scalarized=True, sz_w=0, w_self=0} {
prog.store prog.add(prog.load %M[0], prog.load %M[4]), %s[0] : float64
}
prog.proc @f(%x: memref<8xfloat64>, %t: memref<1xfloat64>) {device=host, input_count=1, lowering="auto", scalarize_mode="disabled", sz_w=0, w_self=0} {
%s0 = prog.buffer : memref<8xfloat64, private>
prog.for %i_t0 = 0 to 8 step 1 {kind=global} {
prog.store prog.sin(prog.load %x[%i_t0]), %s0[%i_t0] : float64
}
%t1 = prog.buffer : memref<6xfloat64, private> = prog.alias %s0 offset 2
%s1 = prog.buffer : memref<1xfloat64, private>
prog.call @trace2(%t1, %s1) {callee_needs_w=False, n_in=1, n_out=1, w_self=0}
prog.store prog.load %s1[0], %t[0] : float64
}
}
%t1 owns no storage. It is %s0 from element 2, and the C is a single pointer
(trimmed):
The reshape costs nothing at all. Buffers are flat and row-major, so the
six-element alias is passed as the 2x3 parameter unchanged, and M[1, 1]
becomes the flat index 4 inside trace2. Because an alias reads its source's
storage, the workspace packer treats a read of %t1 as a read of %s0 when it
decides which temporaries can share memory.
Every buffer also has an address space. Parameters are global, which the
listing leaves out, temporaries are private, and constant tables are
constant.
Loop kinds¶
Each range carries a RangeKind, printed as kind= on a prog.for. The set
is modelled on tinygrad's AxisType1. Lowering and lane
widening produce these kinds:
| Kind | Produced by | Meaning |
|---|---|---|
global |
Lowering | Independent iterations, as in elementwise loops and vmap |
reduce |
Lowering | Iterations accumulating into one location, as in sum and matmul |
serial, vector |
Lane widening | An outer loop over chunks and an inner loop over vector lanes |
The C renderer emits a global or reduce loop as a plain for. The kind
matters to the passes. Loop fusion leaves reduce loops alone, and lane
widening splits an independent loop, or a reduction whose order it can keep,
into serial chunks of vector lanes. With
lanes=4, the loop of x.sin() * x over 64 elements becomes this (trimmed to
the loop headers):
prog.for %i_y_start = 0 to 64 step %SCALY_WIDTH_4_4_4_4 {kind=serial} {
prog.for %i_y_lane = 0 to prog.minimum(%SCALY_WIDTH_4_4_4_4, prog.sub(64, %i_y_start)) step 1 {kind=vector} {
The width is a C macro rather than a number. It is the smaller of the requested lanes and a cap derived from the target's vector registers, so the C preprocessor settles it for each target. Lowering and optimization describes lane widening.
Program verification¶
verify_program applies the same kind of rule table to programs, from
ir/program_spec.py. It checks buffer attributes, that views index with
scalars, that loads and stores take views, and that loop kinds are valid. A
whole module must also have unique procedure names. Lowering verifies the
program before and after optimization, so a malformed program fails before any
C exists.
-
tinygrad,
AxisTypeintinygrad/uop/ops.py, https://github.com/tinygrad/tinygrad. The two sets have since diverged. ↩