11# torchwright
22
3- torchwright is a compiler that transforms computation graphs into the weights of a
4- transformer. It treats a transformer as a fixed computational substrate that can
5- be programmed: the compiler sets the weights directly, without any training, so
6- that a standard decoder-only transformer — causal softmax attention, rotary
7- position embeddings, RMSNorm, a KV cache — executes the source graph. Compiled
8- models are packaged, by default, as ordinary ` transformers ` checkpoints in the
9- Phi-3 architecture: ` AutoModelForCausalLM ` loads them with no custom code and no
3+ torchwright is a compiler that transforms computation graphs into the weights of
4+ a transformer. You define a computation graph in ordinary Python, and
5+ torchwright produces the weights of a transformer that executes it. There is no
6+ training anywhere in the pipeline. Compiled models are packaged, by default, as
7+ ordinary ` transformers ` checkpoints in the Phi-3 architecture:
8+ ` AutoModelForCausalLM ` loads them with no custom code and no
109` trust_remote_code ` .
1110
11+ This README covers the tool; the [ intro
12+ post] ( https://ood.dev/posts/torchwright-intro/ ) covers how it came to exist and
13+ how the constructions were derived, from the ReLU primitives to a stock Phi-3
14+ checkpoint.
15+
16+
1217## Example
1318
1419The graph in ` examples/binary_increment.py ` parses a binary string and increments
1520it, carry propagation included. Compiling it produces an ordinary Hugging Face
16- model :
21+ checkpoint :
1722
1823``` python
1924from examples.binary_increment import create_network_parts, D_MODEL , D_HEAD
2025from torchwright import compile_hf_bundle
2126
2227output_node, embedding = create_network_parts()
2328compile_hf_bundle(output_node, embedding, " binary_increment_hf_bundle" ,
24- d = D_MODEL , d_head = D_HEAD )
29+ d = D_MODEL , d_head = D_HEAD , optimize = 1 )
2530```
2631
27- The result — safetensors, config, tokenizer — loads like any ` transformers `
32+ The result is a directory with safetensors, config, and tokenizer which loads like any ` transformers `
2833checkpoint:
2934
3035``` python
@@ -35,47 +40,53 @@ print(generate("1011\n", return_full_text=False)[0]["generated_text"])
3540# 1100
3641```
3742
38- The compiler scheduled this graph into a 20 -layer decoder at hidden size 512 (the
39- ` d=D_MODEL ` argument). Every weight was computed from the source graph; nothing
40- was trained. Compiling this example took under ten seconds on a laptop CPU.
43+ The compiler schedules this graph into a 16 -layer decoder at hidden size 512 (the
44+ ` d=D_MODEL ` argument). Every weight is computed from the source graph. Nothing
45+ was trained. Compiling this example takes under ten seconds on a laptop CPU.
4146
4247## How it works
4348
4449A torchwright program is a computation graph: ordinary Python wiring op calls into
4550a DAG of nodes, with token embeddings at the leaves and one output node at the
46- root. ` examples/adder_1digit.py ` is a 99-line worked example.
51+ root.
4752
48- Ops come in three groups: linear ops (` add ` , ` subtract ` , ` concat ` , … ), attention
53+ Ops come in three groups: linear ops (` add ` , ` subtract ` , ` concat ` , etc ), attention
4954ops (latch a value at a marker position, read a fixed offset back,
5055argmax/argmin/mean over positions), and nonlinear ops (` compare ` , ` select ` ,
51- ` multiply ` , table lookups, ` floor_int ` , ` mod_const ` , … ).
56+ ` multiply ` , etc ).
5257
53- The nonlinear ops are built from the transformer's MLP activation function, so
58+ The nonlinear ops are built from the transformer's FFN activation function, so
5459choosing an activation means choosing an op library. ` torchwright.ops.relu `
5560constructs every nonlinear op from ReLU sublayers; ` torchwright.ops.swiglu `
5661constructs the same ops from gated SwiGLU sublayers, and is what the Phi-3
57- output uses. A graph imports from exactly one — the compiler rejects a graph
62+ output uses. A graph imports from exactly one, and the compiler rejects a graph
5863that mixes them.
5964
60- The compiled computation lives in the residual stream — the fixed-width vector a
61- transformer carries between layers. Every value the graph computes owns a set of
62- columns there for as long as anything downstream still needs it; columns need not
63- be contiguous, and concatenation is logical rather than physical. A sublayer can
64- only * add* to the stream, and that one fact drives the compilation scheme: a new
65- value lands in zeroed columns (0 + new = new), a value nothing needs anymore is
66- cancelled by writing its negation (v + (−v) = 0), which frees its columns for
67- reuse, and the graph's additions are implemented on the skip connection itself,
68- costing no extra columns.
69-
70- Every op compiles down to concrete weights — attention heads, rows of MLP
71- sublayers. A scheduler assigns every node to a layer: ` optimize=0 ` , the default,
72- places nodes with a heuristic, and ` optimize=1 ` –` 3 ` hand placement to a
73- constraint-programming solver (CP-SAT, from Google OR-Tools) that minimizes layer
74- count under growing time budgets (60 to 600 seconds). Weights stream into the
75- artifact layer by layer, so peak memory during
76- compilation stays near one layer's worth regardless of depth. The same compile
77- can also target ONNX: ` compile_to_onnx ` emits the transformer as a KV-cached
78- ONNX artifact runnable outside ` transformers ` .
65+ The compiled computation lives in the residual stream. Every value the graph
66+ computes owns a set of columns there for as long as anything downstream still
67+ needs it; columns need not be contiguous, and concatenation is logical rather
68+ than physical. A sublayer can only * add* to the stream, and that one fact drives
69+ the compilation scheme: a new value lands in zeroed columns (0 + new = new), a
70+ value nothing needs anymore is cancelled by writing its negation (v + (−v) = 0),
71+ which frees its columns for reuse, and the graph's additions are implemented on
72+ the skip connection itself, costing no extra columns.
73+
74+ Every op compiles down to concrete weights living in attention heads or rows of FFN
75+ sublayers. A scheduler assigns every node to a layer:
76+ - ` optimize=0 ` , the default, places nodes with a heuristic
77+ - ` optimize=1 ` –` 3 ` uses a constraint-programming solver (CP-SAT, from Google OR-Tools) that minimizes layer
78+ count
79+
80+
81+ ## Prior work
82+
83+ The idea of hand-built transformer weights is not new.
84+ [ RASP] ( https://arxiv.org/abs/2106.06981 ) defines a language whose primitives map
85+ onto transformer sublayers, and [ Tracr] ( https://arxiv.org/abs/2301.05062 )
86+ compiles RASP programs into actual weights. torchwright differs at both ends:
87+ programs are arbitrary computation graphs written in ordinary Python instead of
88+ RASP, and the output targets a stock Phi-3 architecture, rather than a custom
89+ transformer model (with no norm).
7990
8091## Install
8192
@@ -100,47 +111,46 @@ for CPU, or the `gpu` extra for `onnxruntime-gpu`.
100111
101112## Verification
102113
103- torchwright checks that a compiled transformer faithfully executes its source
104- graph. Many ops are implemented as piecewise-linear approximations, so
105- correctness is measured and enforced rather than assumed, at four levels. Every
106- approximate op is measured against its exact-math reference, and the per-op
107- error bounds are committed to the repo (` docs/op_noise_data.json ` ); the test
108- suite fails if the committed numbers drift from what the code measures. The
109- compiler asserts its own structural invariants while compiling, each pinned by a
110- negative test. Assert predicates can be attached to graph nodes; they run on
111- exact values during reference evaluation and again on compiled values during
112- debug forward passes, so an assert that passes in exact math but fires compiled
113- pinpoints where approximation error exceeded its budget. Finally,
114+ Some of the constructions are exact, but plenty are piecewise-linear
115+ approximations. Correctness gets measured rather than assumed. Every
116+ approximated operation is checked against its exact-math reference, and the
117+ resulting error bounds are committed to the repository
118+ (` docs/op_noise_data.json ` ); the test suite fails if the committed numbers drift
119+ from what the code measures. Graph nodes can carry assert predicates. During
120+ development each assert is checked against the exact reference evaluation and
121+ again during debug forward passes of the compiled model. One that passes in
122+ exact math but fires when compiled points at exactly the operation where
123+ approximation error exceeded its budget. Finally,
114124` torchwright.debug.probe.probe_compiled ` diffs a compiled transformer
115125node-by-node against direct evaluation of the source graph.
116126
117- Per-op bounds are measured on each op's intended input ranges and are not
118- additive through chains of ops; chain-level questions go to ` probe_compiled ` .
127+ Per-op bounds are measured on each op's intended input ranges and do not compose
128+ through chains of ops; chain-level questions go to ` probe_compiled ` .
119129
120130## Examples
121131
122132Twelve example graphs live in ` examples/ ` :
123133
124- - ` adder_1digit ` — parses ` "A+B\n" ` , single-digit addition; the smallest
134+ - ` adder_1digit ` parses ` "A+B\n" ` , single-digit addition; the smallest
125135 complete program.
126- - ` adder ` — 3-digit addition: each digit pair goes through a lookup table and
136+ - ` adder ` 3-digit addition: each digit pair goes through a lookup table and
127137 carries propagate right-to-left, pencil-and-paper style.
128- - ` adder_v2 ` — the same adder a different way: digits become numbers, one add,
138+ - ` adder_v2 ` the same adder a different way: digits become numbers, one add,
129139 numbers become digits.
130- - ` binary_increment ` — the example above.
131- - ` caesar_cipher ` — shift cipher; the shift amount is a runtime input digit.
132- - ` sort_digits_v1 ` — sorts a digit string ascending, one digit per
140+ - ` binary_increment ` the example above.
141+ - ` caesar_cipher ` shift cipher; the shift amount is a runtime input digit.
142+ - ` sort_digits_v1 ` sorts a digit string ascending, one digit per
133143 autoregressive step.
134- - ` range_printer ` — a two-level loop: iterate items, and for each item iterate a
144+ - ` range_printer ` a two-level loop: iterate items, and for each item iterate a
135145 range of values.
136- - ` fibonacci ` — autoregressive: each number is computed from the model's own
146+ - ` fibonacci ` autoregressive: each number is computed from the model's own
137147 previously emitted tokens.
138- - ` calculator_simple ` — ` + ` , ` - ` , ` * ` on multi-digit integers, one lookup table
148+ - ` calculator_simple ` ` + ` , ` - ` , ` * ` on multi-digit integers, one lookup table
139149 and one fold at a time.
140- - ` calculator_advanced ` — the same functions at logarithmic depth, the way a
150+ - ` calculator_advanced ` the same functions at logarithmic depth, the way a
141151 hardware multiplier does it.
142- - ` calculator_memorize ` — computes nothing: every answer is a memorized fact.
143- - ` calculator_scratchpad ` — streams the serial carry/borrow work out as visible
152+ - ` calculator_memorize ` computes nothing: every answer is a memorized fact.
153+ - ` calculator_scratchpad ` streams the serial carry/borrow work out as visible
144154 tokens and reads it back, so compiled depth stays flat as operand length grows.
145155
146156Examples that define ` create_network_parts() ` compile to a bundle with
@@ -149,35 +159,34 @@ Examples that define `create_network_parts()` compile to a bundle with
149159## Limitations
150160
151161- Only fp32 is supported.
152- - The KV cache is static: sequence length is fixed at export (` max_seq_len ` ,
153- default 512), and overrunning it raises.
154162- With RMSNorm on (the default), supported hidden sizes (` d ` ) are any multiple
155163 of 1024 up to 16384, or any power of two; other widths raise. See
156164 ` docs/rms_norm_dmodel.md ` .
165+ - The KV cache is static: sequence length is fixed at export (` max_seq_len ` ,
166+ default 512), and overrunning it raises.
157167
158168## Further reading
159-
160- - ` docs/optimization_guide.md ` — reducing layer count and parameter cost when
169+ - [ Introducing torchwright] ( https://ood.dev/posts/torchwright-intro/ ) -- the story and the constructions, from ReLU
170+ derivations to stock Phi-3.
171+ - ` docs/optimization_guide.md ` -- reducing layer count and parameter cost when
161172 compiling a graph.
162- - ` docs/cpsat_scheduler.md ` — the constraint-programming scheduler: model,
173+ - ` docs/cpsat_scheduler.md ` -- the constraint-programming scheduler: model,
163174 objective, warm starts.
164- - ` docs/affine_bounds.md ` — how value bounds propagate through the graph.
165- - ` docs/numerical_noise.md ` — measured per-op approximation error (generated
175+ - ` docs/affine_bounds.md ` -- how value bounds propagate through the graph.
176+ - ` docs/numerical_noise.md ` -- measured per-op approximation error (generated
166177 from ` docs/op_noise_data.json ` ).
167178
168179## Development
169180
170- ` torchwright/ ` holds the package (` graph ` , ` ops ` , ` compiler ` , ` debug ` );
171- ` examples/ ` , ` tests/ ` , and ` docs/ ` are what they say. ` make lint ` (ruff + mypy)
172- and ` make test-local FILE=tests/... ` (single-file pytest) run anywhere. The full
173- suite, ` make test ` , shards across GPU containers on Modal and needs a Modal
174- account. ` make measure-noise ` regenerates the committed per-op noise data; two
175- tests pin it to the code.
176-
177- CI runs on every push and pull request: ` make lint ` , plus a smoke that compiles
178- an example on CPU and checks its generated output. The full suite additionally
179- runs on CPU weekly. Releases are tagged (` v* ` ) and published to PyPI from CI;
180- ` docs/releasing.md ` has the procedure.
181+ ` make lint ` (ruff + mypy) and ` make test-local FILE=tests/... ` (single-file
182+ pytest) run anywhere.
183+
184+ Currently the full suite, ` make test ` , is designed to run on Modal, sharded across GPU containers (and thus needs a Modal account).
185+
186+ ` make measure-noise ` regenerates the committed per-op noise data.
187+
188+ CI runs on every push and pull request: ` make lint ` , plus a smoke test that
189+ compiles an example on CPU and checks its generated output.
181190
182191## License
183192
0 commit comments