Python and OCaml, deeper

The lessons teach enough of your language to build each week's compiler. This page is the next layer: the idioms and design patterns that make that compiler easier to write, the traps each language sets for this course in particular, and where to read more.

This page follows your track. The sidebar's Language picker switches between the Python and OCaml parts, so switch it to read the other side. You're reading the PythonOCaml version.

The sketches show a technique on a small piece. None of them is a week's solution, and several stop at ... on purpose where the lab begins. For the basics, see 0.6 OCaml crash course and 0.7 Python for this course.

How to choose

Both languages can pass every test. Pick the one you'd rather think in for five weeks.

PythonOCaml
getting startedfamiliar to most people, nothing to install past Python 3.10a steeper start: opam, dune, and a type checker that says no often
matching on IRmatch with class patterns works, but nothing checks you covered every casevariants and exhaustive match, so a forgotten op is a compiler warning
i32 and f32ints never overflow and floats are doubles, so you wrap and round by hand on every opInt32 wraps like C, but floats are still doubles and need rounding to f32
speedslower, though fast enough for every test if you memoizecompiled to native code, rarely the bottleneck
reading real compilerstinygrad itself is Python, so its source reads like your owna long line of compilers are written in ML-family languages, including OCaml's own

The honest summary: Python is the easier start and lets you read tinygrad directly, but you have to remember 32-bit wraparound and f32 rounding yourself, everywhere. OCaml's types catch a whole class of rewrite bugs before you run anything, at the price of a slower first week.

Week 1: UOps, interpreting, rewrites

Ops as an enum

An Enum gives each op one object, so a typo is an AttributeError instead of a string that silently never matches. The parser can look ops up by name.

from enum import Enum, auto

class Op(Enum):
  CONST = auto()
  PARAM = auto()
  ADD = auto()
  MUL = auto()
  # ...

Op["cmplt".upper()]   # Op.CMPLT, or a KeyError for an unknown op

Hash consing: a frozen dataclass and a dict

Make the node immutable with frozen=True, and keep one table from a key to the node already built for it. Pass eq=False too. Then == and hash use identity, which is exactly right once nodes are shared, and constant time.

from dataclasses import dataclass

@dataclass(frozen=True, eq=False)
class UOp:
  op: Op
  dtype: str
  src: tuple["UOp", ...]
  arg: object

_table: dict[tuple, UOp] = {}

def uop(op, dtype, src=(), arg=None) -> UOp:
  key = (op, dtype, src, arg_key(arg))
  ...   # return the node stored under key, or build one and store it
Two Python traps in that key
  • The default eq=True hashes the whole subtree. A frozen dataclass with generated equality hashes every field, recursively, on every lookup, and caches nothing. On a 300-level shared DAG that's exponential. With eq=False the src tuple hashes its children by identity.
  • Python thinks 0.0 == -0.0 and True == 1. They also hash the same, so a dict merges const -0.0 into const 0.0. That's a wrong answer, not just lost sharing. Build the key from the arg's type and, for floats, its bits (that's the arg_key above), so 0.0, -0.0, 0 and False all get different keys.

Rewrite rules with match

Python 3.10's match reads like the rule it implements. Keyword patterns work on any dataclass, and nested patterns match a whole subtree at once. Return None when no rule fires, so the engine knows to stop.

def rule(u: UOp) -> UOp | None:
  match u:
    case UOp(op=Op.ADD, dtype="i32", src=(x, UOp(op=Op.CONST, arg=0))):
      return x                       # add(x, 0) -> x, for i32 only
    case UOp(op=Op.MUL, dtype="i32", src=(x, UOp(op=Op.CONST, arg=1))):
      return x
  return None
A literal pattern compares with ==

arg=0 also matches 0.0, -0.0, and False. Without dtype="i32" the first rule would turn the f32 add(x, 0.0) into x, which is wrong when x is -0.0. Put the dtype in every pattern, and remember that cases run top to bottom, so order specific rules first.

32-bit integers and f32 floats, by hand

Python ints never overflow and Python floats are doubles. Neither matches the course's types, so give every arithmetic op one small helper and never use bare + on a value.

Memoize per call, and know what functools.cache does

functools.cache on an evaluator is one global table that lives forever. That's fine for a pure property of a node, like its dtype or shape. It's wrong for values that depend on a call's arguments: the same body called with 3 and then with 4 returns the first answer twice. Use a plain dict created at the start of each call for those.

An explicit worklist instead of deep recursion

Python stops at about 1000 nested calls (sys.getrecursionlimit()), and a long chain of ops is deeper than that. Raising the limit works until the C stack overflows and the process dies with no message. Walk the graph with your own stack instead. The trick for "sources before the node" is to push each node twice:

stack, done = [(root, False)], set()
while stack:
  u, expanded = stack.pop()
  if u in done: continue
  if expanded:
    ...                      # every source of u is finished: handle u here
    done.add(u)
  else:
    stack.append((u, True))
    stack.extend((s, False) for s in u.src)

Exit codes

An uncaught Python exception prints a traceback and exits 1, which the grader reads as "an assert failed". Catch errors at the top of main, print a line starting invalid:, and sys.exit(2). Catch RecursionError there too, so a deep graph fails loudly instead of looking like a failed assert.

Variants for ops, a record for the node

One variant constructor per op, and a record holding the shared fields. Give each node a unique id when it's built. Tables keyed by that int are cheaper and safer than tables keyed by the node itself.

type op = Const | Param | Add | Mul | Cmplt | Where (* ... *)
type dtype = I32 | F32 | Bool
type arg = No_arg | Int of int32 | Float of float | Slot of int

type uop = { id : int; op : op; dtype : dtype; src : uop list; arg : arg }

Hash consing with Hashtbl.Make

The generic Hashtbl uses structural equality, which walks whole subtrees and slows down on deep DAGs. Key the table on the sources' ids instead, with a module that says how to compare and hash keys.

module Key = struct
  type t = op * dtype * int list * arg_key (* src ids, and arg with floats as bits *)
  let equal = ( = )                        (* cheap now: no subtrees inside *)
  let hash = Hashtbl.hash
end
module Table = Hashtbl.Make (Key)

let table : uop Table.t = Table.create 1024
let make op dtype src arg = ...            (* find, or build with a fresh id and add *)
Floats inside a key

Structural equality says 0.0 = -0.0, so const -0.0 would come back as const 0.0, a wrong answer. And nan = nan is false, so a NaN constant never finds itself. Store float args by their bits in the key (Int32.bits_of_float for an f32), and compare those.

Exhaustive matches as a safety net

OCaml warns (warning 8) when a match misses a constructor. Each week adds ops, and every pass that forgot one lights up at compile time. Two habits keep the net working:

A rewrite rule is then one match arm, and when guards keep the conditions next to the pattern:

let rule u =
  match u.op, u.dtype, u.src with
  | Add, I32, [ x; { op = Const; arg = Int 0l; _ } ] -> Some x
  | Mul, I32, [ x; { op = Const; arg = Int 1l; _ } ] -> Some x
  | _ -> None   (* fine here: most nodes have no rule *)

Int32 and f32 rounding

OCaml's int is 63 bits, so it never wraps where i32 does. Int32.add, Int32.mul, and Int32.shift_left wrap like C. Int32.div and Int32.rem truncate toward zero, and Int32.div Int32.min_int (-1l) gives min_int instead of trapping, which is what the spec wants. Division by zero raises Division_by_zero, so catch it and report it. Floats are doubles, so round each f32 result with Int32.float_of_bits (Int32.bits_of_float x).

Memoize per call

Create the value table inside the function that evaluates one call, not at the top level of a module. A top-level Hashtbl is global, and the same body called with different arguments would return the first answer twice. Local tables keyed by node id are cheap to make.

Exit codes

An uncaught exception exits 2 with Fatal error: exception ..., which looks right but has no invalid: line. Wrap main in a try ... with that prints the reason and calls exit 2. While debugging, OCAMLRUNPARAM=b prints a backtrace for any exception that escapes.

Week 2: memory, bounds, and rendering C

An f-string emitter

Collect C lines in a list and join them at the end. Name each value once, in a dict from node to C name, so a shared node is computed once in the C too.

lines: list[str] = []
names: dict[UOp, str] = {}           # identity keys, thanks to eq=False

def emit(u: UOp) -> None:
  a, b = (names[s] for s in u.src)
  names[u] = f"v{len(names)}"
  lines.append(f"  int32_t {names[u]} = {a} ... {b};")   # the wrap-safe form is yours to write

Two f-string details bite in C. Literal braces must be doubled (f"void kernel(...) {{"), and a quote inside the braces needs the other quote style before Python 3.12.

Exact float literals with float.hex

str(x) does round-trip, but formats like f"{x:f}" and %g drop digits, and a decimal literal is something you have to argue is exact. x.hex() prints the bits as a C99 hex float, exact by construction: 0.1 rounded to f32 prints 0x1.99999a0000000p-4, and C reads it back with an f suffix to the same float. It doesn't cover everything, though: float("inf").hex() is inf, which C doesn't accept, so infinities and NaN need their own spelling.

Bounds with unbounded ints

Here Python's big ints help. Represent an interval as a pair of plain ints and compute lo and hi exactly, with no overflow along the way. Only at the end ask whether the result fits the buffer, or i32. In a 32-bit language you'd have to reason about the bounds' own overflow.

The runtime: subprocess, tempfile, struct

Rendering with Buffer and Printf

Append C to a Buffer.t with Printf.bprintf, and keep a table from node id to C name so a shared node is computed once. Buffer grows in amortized constant time, unlike repeated ^, which copies the whole string each time.

let b = Buffer.create 4096
let name = Hashtbl.create 64                 (* node id -> "v12" *)

let emit u =
  let v = Printf.sprintf "v%d" (Hashtbl.length name) in
  Hashtbl.replace name u.id v;
  Printf.bprintf b "  int32_t %s = ...;\n" v   (* the wrap-safe form is yours to write *)

Exact float literals with %h

Printf.sprintf "%h" x prints a hex float that C reads back exactly: 3.0 prints 0x1.8p+1. Add an f suffix for f32. %h prints infinity and nan for the special values, which C doesn't accept, so those need their own spelling. Never use %f or %g for a literal: they drop digits.

Modules to organize passes

By week 2 your compiler has a parser, a checker, a simplifier, a bounds analysis, and a renderer. Give each its own file (each file is a module), and write an .mli for it so the rest of the compiler sees only what it should. When two passes share a shape, a module type says so:

module type PASS = sig
  val name : string
  val run : uop -> uop
end

module Debug (P : PASS) : PASS = struct     (* a functor: wraps any pass *)
  let name = P.name
  let run u = let u' = P.run u in (* print u' as .uir here *) u'
end

A functor like Debug gives every pass the "print the IR after each pass" flag from the debugging guide for free.

The runtime

Lesson 2.5 covers Filename.quote_command, Sys.command, and reading bits back with Int32.of_string ("0x" ^ h). Use Fun.protect ~finally to delete temporary files, and the unix library when you need a timeout.

Week 3: loops and placement

Sets of ranges, computed once

Placement asks, for each node, which loops it depends on. That's a set, and frozenset is hashable and cheap to union. Compute it bottom up with the worklist from week 1 and store it in a dict keyed by node, or with functools.cache, since it's a pure property of the node.

Bucketing nodes by loop

collections.defaultdict(list) keyed by a loop gives the body of each loop in visit order, which is already the order sources must come first. Emit with an indentation depth you pass down, as " " * depth + line, rather than fixing whitespace afterward.

Testing a matmul by hand

Before you trust your C, compute the expected matrix in plain Python with f32 rounding after each multiply and add, in the same k order as your loop. Nested list comprehensions and zip make the reference short enough to read at a glance.

Sets of ranges with Set.Make

module IS = Set.Make (Int) gives an immutable set of range ids with union and subset. Compute each node's set once, bottom up, in a table keyed by node id. Immutable sets can be shared between nodes without copying.

Records for the loop tree

A small record such as { range : uop; mutable body : item list } per loop is easier to emit than a flat list with depth numbers. Build it, then render it with one recursive function that takes the indentation as an argument. Loop nests are only as deep as the program's ranges, so recursion is safe here.

Labelled arguments for loop bounds

Functions that take a start, a stop, and a stride are easy to call in the wrong order. Labelled arguments (~lo ~hi) make the call site say which is which, and the type checker rejects a swap.

Week 4: vectors, reduce, rangeify

Shapes as tuples

Use tuples for shapes, never lists: they're hashable, so a shape can sit in a hash-consing key, and a list there fails loudly. math.prod(shape) is the element count, and math.prod(()) is 1, which is right for a scalar.

Row-major order for free

itertools.product(*(range(d) for d in shape)) yields every index tuple in row-major order, last axis fastest. That's the order the spec uses for reduce, so an interpreter for vector values can loop over it directly.

A left fold that starts from the first element

functools.reduce(f, xs) with no initial value starts from the first element and folds left, which is the spec's order. Passing 0 as an initial value changes the answer for -0.0 and for max. Remember to round to f32 inside f.

Counting uses

Materialization starts from how many times each value is used. collections.Counter(s for u in nodes for s in u.src) counts them in one line.

Arrays for vector values

Hold a vector value as a flat float array or int32 array with its shape beside it. Array.init, Array.map2, and Array.fold_left cover most elementwise work. A float array is stored unboxed, so it's compact and fast.

Folding from the first element

Array.fold_left always takes an initial value. To fold from the first element as the spec requires, start from a.(0) and fold over the rest, rather than starting from 0.0. Round to f32 inside the step function.

A variant for each decision

Rangeify decides, per value, whether to recompute it or store it. Write that decision as a variant (type place = Fuse | Stage, say) in a table keyed by node id. Every later match on it is checked for both cases.

Week 5: movement ops at any rank

Strides and unraveling

Row-major strides are suffix products of the shape: each stride is the product of the sizes after it. list(itertools.accumulate(reversed(shape[1:]), operator.mul, initial=1))[::-1] builds them ((12, 4, 1) for (2, 3, 4)), and divmod unravels a flat index one axis at a time. Write both as small functions and test them against itertools.product on random shapes.

Index expressions are UOps

When you rangeify a movement op, build the new index with the same smart constructor you use everywhere else, not as a string or a Python int. Then hash consing shares it and your week 1 simplifier cleans it up, which keeps the rendered C small.

Property tests for permutations

Permute and its inverse should cancel, and reshape there and back should be the identity. These are perfect for a property test: generate a random shape and order, and check the round trip. random.sample(range(r), r) gives a random order.

Shapes as int lists or arrays

Pick one representation and stick to it. Lists pattern-match nicely (| [] -> for a scalar), and arrays index in constant time. List.nth is linear, so a hot loop over an axis wants an array.

A small Shape module

Collect numel, strides, unravel, and permute in one module with an .mli. Each is a few lines, and having them in one place makes it easy to test them against each other.

Index expressions are uops

Build the new index of a rangeified movement op with your smart constructors, so hash consing shares it and your week 1 simplifier cleans it up. If you added a new op this week, the exhaustive-match warnings point at every pass that needs it.

Profiling and testing

Profiling

python -m cProfile -s cumulative main.py run big.uir prints where time goes by function. py-spy record -o profile.svg -- python main.py run big.uir samples a running program with almost no slowdown and draws a flame graph. The usual finding in this course is a missing memo, a recursive __hash__, or an O(n) list lookup inside a loop.

Testing

With pytest, any function named test_* in a file named test_*.py is a test, and a plain assert reports both sides when it fails. Use it for small facts about your helpers, and keep using tools/test.py for the compiler as a whole. Hypothesis generates inputs for you and shrinks a failure to the smallest one. It's a natural fit for i32 helpers:

from hypothesis import given, strategies as st
import ctypes

i32 = st.integers(-2**31, 2**31 - 1)

@given(i32, i32)
def test_add_wraps(a, b):
  assert add_i32(a, b) == ctypes.c_int32(a + b).value

The same idea checks a rewrite rule: evaluate the program before and after on random arguments and assert the values agree. That's what the grader's simplify tests do.

Testing

A test stanza in a dune file builds a test executable, and dune test runs every one in the project.

; test/dune
(test
 (name test_compiler)
 (libraries mycompiler alcotest))

Alcotest gives you named test cases and readable failures. ppx_expect works differently: you print something and the expected output sits in the source next to it. Expect tests live in a library, not a test stanza: add (inline_tests) and (preprocess (pps ppx_expect)) to its dune file. When the output changes, dune test shows a diff and dune promote accepts it. That suits printed .uir and rendered C well.

let%expect_test "fold add" =
  print_string (to_uir (simplify (parse "...")));
  [%expect {| %out = const 3 |}]

For random inputs, QCheck generates them and shrinks a failure to the smallest case. Keep using tools/test.py for the compiler as a whole.

Profiling

On Linux, perf record --call-graph dwarf ./_build/default/bin/main.exe run big.uir then perf report works on native OCaml code directly. The landmarks library instruments chosen functions and reports time per function on any platform. The usual finding is a structural = or compare on a whole subtree, or a List.nth inside a loop.

Going further

Tools mentioned above: Alcotest, ppx_expect, QCheck, and landmarks.

Tools mentioned above: pytest, Hypothesis, py-spy, and cProfile.