Decisions
The choices that shaped twill, the alternative that was seriously considered in each case, and why it lost. Each entry is a decision that could reasonably have gone the other way. Where one of them costs something, the cost is stated rather than argued away.
docs/design.md says what twill is. This says what it is not, and why.
1. Reverse-mode autodiff, not forward-mode
Decision. grad is reverse-mode. Every operation that can carry a gradient
records the inputs it read and a closure that pushes the output's gradient back
onto them (track1, track2, trackN in internal/tensor/tensor.go), and
Backward walks the graph in reverse topological order from a scalar output.
Alternative. Forward-mode, propagating a derivative alongside the value as a dual number, which needs no graph, no closures and no reverse traversal. It is a substantially smaller implementation, it allocates nothing beyond the value it is already computing, and it composes trivially with itself for higher derivatives.
Why it lost. Cost scales with the wrong quantity. Forward-mode gives you one
directional derivative per pass, so gradients of a function with n inputs cost
n passes; reverse-mode gives all n in one pass and costs one pass per output.
Machine learning is the case where n is a parameter count and the output is a
single loss, which is exactly the ratio forward mode is worst at. A model with
58,000 parameters, examples/gpt.tw, would need 58,000 forward passes per step.
Forward mode is not absent, though, because for second derivatives the ratio
flips: hessian runs forward-mode jets over the core operations
(internal/tensor/jet.go), since an [n, n] Hessian is n directional
derivatives either way and the forward version does not need a graph that
survives being differentiated twice. The two modes coexist for the two regimes
that each suits.
2. The tape is the tensor graph, not a separate structure
Decision. There is no tape object. The reverse graph is the output tensors
themselves: each *Tensor holds up to two parents inline (p0, p1), a slice
for the rare third and beyond (pRest, used only by einsum and concat), and a
backward func() closure. Backward does a depth-first walk over those parent
pointers to get a topological order, then calls the closures in reverse.
Crucially the wiring only happens when an input has RequiresGrad, so ordinary
evaluation does not build it and does not pay for it.
Alternative. An explicit tape: an append-only list of operation records that a differentiation context owns, which is what PyTorch's autograd and most teaching implementations use. It makes the graph a thing you can inspect, reset, checkpoint or replay, and it separates the record of what happened from the values it happened to.
Why it lost. A separate tape needs to be threaded through everything that
might record onto it: a context, a thread-local, or an extra argument on every
operation. twill's whole numeric surface is a package of free functions taking
*Tensor, and giving all of them a context argument to serve a feature that is
off most of the time was the wrong trade for an implementation whose stated goal
is to be readable in a sitting. Storing the two common parents inline rather
than in a slice also removes a per-operation allocation from the hot path.
The cost, which is real. Without a tape there is nothing to replay, so
grad(grad(f)) cannot work and is refused rather than answered with a wrong
number. The graph is also kept alive by the output tensor, so a long-lived
result pins every intermediate that produced it; a training loop that
accumulates losses into a list without extracting their values holds the whole
history. And jacobian has to re-run the function once per output component
(internal/interp/builtins.go), because there is no recorded graph to seed
repeatedly with different cotangents. An explicit tape would make all three
cheap.
3. A static shape checker rather than runtime shape errors
Decision. twill check infers shapes across the whole program before
anything runs, and twill run refuses to start a program with a certain
mismatch. Shapes are part of a function's signature (fn mm(A: [n, k], B: [k, m]) -> [n, m]), and the checker verifies call sites against them.
Alternative. Leave shapes to the runtime, as NumPy, PyTorch and JAX do. Every operation knows its own shapes when it executes, so the error message can be exact and can name real numbers rather than inferred ones, and the language has one fewer phase, one fewer set of rules and no possibility of a false alarm.
Why it lost. The cost of a shape mistake is not the mistake, it is when you
find out. A runtime shape error in a training loop arrives after the data
loading, the initialisation and however many steps ran before the code path with
the bug was reached. That is the failure the language exists to remove, and it
cannot be removed by a better error message, only by an earlier one. The measured
price is in docs/BENCHMARKS.md, which measures checking the whole corpus in
tens of milliseconds. Cheap enough to run on save, and quoted there rather than
here so there are not two numbers to drift apart.
The cost. The checker is deliberately incomplete, and this is the compromise
entry. It reports only mismatches it is certain of and stays silent otherwise, so
it does not catch everything, and a clean check is not a proof that the program
runs. docs/CORRECTNESS.md gives a six-line program the checker accepts and
the runtime rejects. The alternative bias, reporting anything suspicious, was
rejected on the grounds that a checker which cries wolf gets turned off, and a
checker that is turned off catches nothing at all.
4. A tree-walking interpreter, not a compiler or a bytecode VM
Decision. internal/interp walks the AST directly. There is no intermediate
representation between ast.Expr and the tensor kernels: evalBinary on an
ast.Binary node dispatches straight into tensor.Add and friends.
Alternative. A bytecode VM, or lowering to native code. Both are the
conventional answer for an interpreted language that wants to be fast, and both
were on the roadmap in docs/design.md.
Why it lost, so far. Profiling said the interpreter is not the cost.
docs/perf-baseline.md reported Interp.Run, Apply, callClosure,
evalBinary and evalCall all at 0% flat time on a transformer forward pass,
and the profile in docs/BENCHMARKS.md reproduces the same result on the Monte
Carlo pricer. Time is in the kernels. twill is already a thin orchestration layer
over tensor operations in the way Python is over NumPy, so a faster dispatch
mechanism would speed up the part that costs nothing.
What changing it would take, and when it would be worth it. The regime where
this decision flips is small tensors, where per-operation overhead is a real
fraction of the work: a scalar loop, or a model narrow enough that each kernel
call is a few microseconds. The change is not the VM, it is the IR. Today an
operation is discovered, executed and forgotten in one step, so there is no
program object to optimise over, no place to fuse two elementwise operations
into one pass, and no way to allocate an output buffer once for a loop that runs
a thousand times. Getting an IR is the prerequisite for compilation, for fusion
and for the GPU work in docs/CODEGEN.md, and it is the same prerequisite in
all three cases.
5. Every float is an f64
Decision. A tensor's buffer is []float64 and always has been. Narrower
dtypes exist as semantics: a tensor carries a dtype tag, arithmetic promotes and
rounds to it, and docs/dtypes.md specifies the accumulation rules. But the
storage is f64 regardless, so a bf16 tensor is a []float64 whose every element
happens to be a value representable in bf16.
Alternative. Real packed storage, a union of typed buffers or a byte slice plus a dtype, which is what every production framework does.
Why it lost, for now. Splitting the storage touches every kernel in the
package: each one becomes a dispatch over element type, and the readable
for i := range out loops become generic or duplicated. Separating the semantics
from the layout meant the rounding rules could be specified, tested and made
bit-identical against the self-hosted implementation first, on a representation
that keeps every kernel a single loop. Getting the numerics right on a simple
layout, then changing the layout, is a smaller step than doing both at once.
The cost, and it is the largest one in this document. Quantisation currently
shrinks nothing. quantize returns a genuinely packed QTensor or QTensorI4,
so that path is real, but a bf16 tensor occupies exactly as much memory as an
f64 one, and docs/perf-baseline.md is blunt that a 7B model needs 56 GB in f64
and does not fit. Every f64 element is also eight bytes to move on a workload
that docs/BENCHMARKS.md shows is memory-bound, and half the SIMD lanes of an
f32. This is the single decision most responsible for the gap against PyTorch,
and it is tracked as NEEDS-111.
6. Determinism over peak throughput in the parallel kernels
Decision. Parallelism never changes a result. parallelSum
(internal/tensor/parallel.go) adds a slice in fixed 4096-element blocks and
combines the partials in block order, so the answer is the same on one core and
on sixteen. runChunks splits an output range into contiguous chunks whose
bodies write only their own indices.
Alternative. Let each worker accumulate its own partial and combine them in completion order, or use an atomic accumulator. Both are faster: no fixed blocking, no barrier on a specific ordering, less bookkeeping.
Why it lost. Floating-point addition is not associative, so a completion-
order reduction gives a different answer run to run, and a different answer on a
different machine. That breaks two things twill claims. The first is that a
seeded program reproduces exactly. The second is the self-hosting programme:
src/tensor.tw is checked against the Go kernels by comparing canonical float
renderings byte for byte, and a sum whose last bits move cannot be compared that
way at all. Commit 2a4b489 is what this looks like in practice, porting the
fixed block size to the self-hosted side because a plain running sum disagreed
with the bootstrap past 8192 elements.
The cost. The blocking is a constraint on how the reduction may be written, and the ordering guarantee rules out the fastest form of every reduction in the package.
7. No dependencies, in Go
Decision. The reference implementation is plain Go with no third-party
packages, including no BLAS. mm and mmNT in internal/tensor/tensor.go are
hand-written, with four accumulators over an unrolled inner product and cache
blocking sized to L2.
Alternative. Link a BLAS. OpenBLAS or Intel MKL would take the matmul from
the tens of GFLOP/s the hand-written kernel reaches to something near the
machine's peak, which is most of the gap against PyTorch on the contraction
benchmarks in docs/BENCHMARKS.md.
Why it lost. A BLAS means cgo, which means the single dependency-free binary
stops being single or dependency-free: a C toolchain to build, a platform matrix
to ship, and go install no longer being the whole install story. It also means
the numerics are no longer twill's, which collides directly with decision 6,
since a threaded BLAS reduction does not promise a reproducible last bit.
The cost, stated plainly. twill's matmul is several times slower than
PyTorch's, and docs/BENCHMARKS.md measures exactly how much. That gap is not a
missing optimisation in twill's kernel so much as the accumulated result of years
of hand-tuned assembly in the library twill declined to link.
8. Self-hosting before bootstrapping
Decision. The twill compiler written in twill (src/) runs on the Go
bootstrap and is checked against it, rather than being compiled to a standalone
binary. The output of the exercise is docs/needs.md, a numbered list of what
the language still lacks, each entry naming the file and line that reached for
it.
Alternative. Build the standalone compiler first, which is the version of this project that produces a demo.
Why it lost. The interesting question was never whether a compiler could be
written, it was what subset of the language a compiler needs, and that question
is answered by writing the compiler and recording every wall it hits. Running on
the bootstrap keeps the reference available for differential comparison at every
step, which is what makes the exercise produce evidence rather than a second
implementation nobody can check. It found real defects: the lexer panic recorded
as NEEDS-33 and fixed in d3176a9, and the reduction-ordering divergence in
2a4b489, both documented in docs/BUGS.md.
The cost. twill is not self-hosted, and will not be until the entries in
docs/needs.md are implemented. The README says so.