Skip to content

Commit aee985b

Browse files
committed
README update
1 parent 9fcb38c commit aee985b

1 file changed

Lines changed: 89 additions & 80 deletions

File tree

README.md

Lines changed: 89 additions & 80 deletions
Original file line numberDiff line numberDiff line change
@@ -1,30 +1,35 @@
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

1419
The graph in `examples/binary_increment.py` parses a binary string and increments
1520
it, carry propagation included. Compiling it produces an ordinary Hugging Face
16-
model:
21+
checkpoint:
1722

1823
```python
1924
from examples.binary_increment import create_network_parts, D_MODEL, D_HEAD
2025
from torchwright import compile_hf_bundle
2126

2227
output_node, embedding = create_network_parts()
2328
compile_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`
2833
checkpoint:
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

4449
A torchwright program is a computation graph: ordinary Python wiring op calls into
4550
a 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
4954
ops (latch a value at a marker position, read a fixed offset back,
5055
argmax/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
5459
choosing an activation means choosing an op library. `torchwright.ops.relu`
5560
constructs every nonlinear op from ReLU sublayers; `torchwright.ops.swiglu`
5661
constructs 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
5863
that 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
115125
node-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

122132
Twelve 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

146156
Examples 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

Comments
 (0)