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.
| Python | OCaml | |
|---|---|---|
| getting started | familiar to most people, nothing to install past Python 3.10 | a steeper start: opam, dune, and a type checker that says no often |
| matching on IR | match with class patterns works, but nothing checks you covered every case | variants and exhaustive match, so a forgotten op is a compiler warning |
| i32 and f32 | ints never overflow and floats are doubles, so you wrap and round by hand on every op | Int32 wraps like C, but floats are still doubles and need rounding to f32 |
| speed | slower, though fast enough for every test if you memoize | compiled to native code, rarely the bottleneck |
| reading real compilers | tinygrad itself is Python, so its source reads like your own | a 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
- The default
eq=Truehashes 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. Witheq=Falsethesrctuple hashes its children by identity. - Python thinks
0.0 == -0.0andTrue == 1. They also hash the same, so a dict mergesconst -0.0intoconst 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 thearg_keyabove), so0.0,-0.0,0andFalseall 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
==
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.
- i32. After each op, keep the low 32 bits (mask with
0xFFFFFFFF) and read the top bit as a sign.ctypes.c_int32(n).valuedoes the same and makes a good reference for a test. - Division.
//floors and%takes the divisor's sign, andint(a / b)goes through a double. Build truncating division from the quotient of the absolute values. - f32. Round a double to f32 by packing and unpacking it:
struct.unpack("<f", struct.pack("<f", x))[0]. Do it after every op and on every literal, not once at the end.
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 *)
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:
- Avoid a catch-all
| _ ->in passes that must handle every op. List the ops that need nothing, so the next new op still warns. - Turn the warning into an error in your
dunefile:(flags (:standard -w +8 -warn-error +8)).
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
tempfile.TemporaryDirectory()in awithblock gives a fresh folder and deletes it afterward, even on an error.subprocess.run([cc, "-O2", src, "-o", exe], capture_output=True, text=True, timeout=10)takes a list, so paths with spaces need no quoting, andtimeoutsaves you from a kernel that hangs. Checkreturncodeand showstderr.- Read a float back by its bits, never by parsing a decimal:
int(h, 16), thenstruct.pack("<I", n)andstruct.unpack("<f", ...).str.split()with no argument also strips the\rof Windows line endings.
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
- tinygrad's
uop/ops.py. The real UOp: a dataclass witheq=False, a hash-consing cache keyed on its op, dtype, sources and arg, andUPatandPatternMatcherfor rewrite rules. Read it after week 1 and compare with yours. - tinygrad documentation. The overview of how the pieces in that file fit into a full ML framework.
- PEP 636: Structural Pattern Matching tutorial. The friendliest walk through
match, including class patterns and guards. - The match statement reference. The exact rules, such as why a literal pattern uses
==. - dataclasses. What
frozen,eq, andslotsgenerate, and how they interact with__hash__. - struct. Format codes and byte order for reading and writing f32 bits.
- float.hex and float.fromhex. The exact hex float format your renderer prints.
- Floating-Point Arithmetic: Issues and Limitations. Short, and it explains why Python's floats are doubles and what
reprpromises. - functools.
cache,reduce, andsingledispatch, each useful somewhere in a compiler. - Enum HOWTO. Beyond
auto(): members with values, and iterating over all ops. - subprocess. Running clang and your kernel, with timeouts and captured output.
- Crafting Interpreters (Robert Nystrom). A free book on scanners, parsers, and tree-walking interpreters, written in a style that maps well onto Python.
- Fluent Python (Luciano Ramalho, 2nd edition). The book on how Python's data model works: hashing, equality, dataclasses, and pattern matching.
- Real World OCaml. A free book that goes well past the crash course. The chapters on variants, functors, maps and hash tables, and testing line up with this course.
- OCaml Programming: Correct + Efficient + Beautiful (Cornell CS 3110). A full course textbook with videos. Its chapters on modules and on interpreters are the best preparation for weeks 1 and 2.
- The OCaml manual. The language reference, including patterns, the exact matching rules behind your rewrites.
- The standard library pages for Int32, Hashtbl (and Hashtbl.Make), and Printf. The three modules you'll use most, with every function's edge cases.
- ocaml.org: Functors and Hash Tables. Short tutorials on the two features behind hash consing with a custom key.
- dune: writing and running tests. Test stanzas, inline tests, and expect tests.
- ocaml.org: Profiling and Debugging. perf on native code, backtraces, and tracing.
- tinygrad. Python, not OCaml, but it's the design this course follows. Reading its UOp and pattern matcher shows the same ideas in another language.
Tools mentioned above: Alcotest, ppx_expect, QCheck, and landmarks.
Tools mentioned above: pytest, Hypothesis, py-spy, and cProfile.