Performance¶
Quill is fast on two fronts: the tree-walking interpreter is competitive with the Go standard library on small templates, and the compile-to-Go backend takes the hot path several times faster still. This page presents the benchmark methodology, the numbers, and how to reproduce them.
Absolute numbers are machine-dependent
The nanosecond figures below were measured on one machine (Apple M2 Max, Go
1.26, darwin/arm64). Absolute latencies vary with CPU, Go version, and
build flags; the ratios between engines measured in the same run are the
portable signal. Reproduce them on your own hardware with the commands under
Reproducing.
The benchmark harness¶
The benchmarks live in the bench/
directory, a separate nested module so the engine itself stays
standard-library-only: the peer engines are dependencies of the harness alone,
never of Quill. Three workloads exercise different cost centers, each rendering
the same logical input across engines:
- Tiny is a single interpolation with one filter:
Hello {{ name | upper }}!. - Loop is a
@forover records, emitting an indexed line per row with anupperfilter and two field reads. - Compose is
@extends+ overriding@blocks +parent()+ a loop, the template-inheritance path.
A verification test asserts that every engine renders byte-identical output for the shared data before any timing is taken, so the benchmarks compare engines doing the same work.
Engines compared¶
The default build measures Quill against the Go standard library only; the four
third-party peers are gated behind the thirdparty build tag so the default
suite runs offline with zero external dependencies.
| Group | Engines | Build |
|---|---|---|
| Quill | interpreter (default), compiled (compile backend) |
offline |
| Standard library | text/template, html/template |
offline |
| Third-party peers | pongo2, stick, jet, quicktemplate | -tags thirdparty |
The peers run in the same Go runtime, so the timing is fair, but their feature model differs from Quill (Twig/Jinja semantics, HTML autoescape defaults, compile-ahead codegen for quicktemplate), so treat them as a same-runtime peer comparison rather than a like-for-like language comparison. See Comparison for the capability matrix.
Methodology¶
b.Loopdrives every render benchmark (Go 1.24+), which keeps the timed work alive against dead-code elimination and amortizes loop overhead more predictably than a manualb.Nloop.SetBytes-> MB/s: each render benchmark renders once outside the timed loop to sizeb.SetBytes(len(output)), sogo testreports render throughput in MB/s alongside ns/op, a size-normalized figure that stays comparable as a workload grows.- Size-parameterized scaling: the Loop workload sweeps
n = 1, 10, 100, 1000rows as sub-benchmarks (.../n=100), so per-engine scaling from a single row to a thousand is visible rather than pinned to one size. b.ReportAllocsis on for every benchmark, so allocations/op ships next to the timing.- Parse once, render many: templates are parsed outside the timed loop, so
the render benchmarks measure the render phase, not one-time compilation. The
_Loadbenchmarks measure the parse/compile phase separately. - benchstat: run the suite repeatedly (
-count) and summarize the distribution (mean +/- variation) with benchstat rather than trusting a single noisy run. It is wired up astask bench:stat(see Reproducing).
Results¶
The table below is illustrative, not a guarantee. It was measured with go
test -bench on one machine (Apple M2 Max, Go 1.26, darwin/arm64), render phase
only (templates parsed once, outside the timed loop), medians of six runs.
Absolute numbers vary by machine and Go version; reproduce them locally with
task bench:all and read the same-run ratios, not the nanoseconds:
| Workload | Quill interpreter | Quill compiled | Go text/template |
|---|---|---|---|
| Tiny (render) | ~0.31 us | -- | ~0.41 us |
| Loop, 100 rows (render) | ~41.5 us | ~10.2 us | ~88.4 us |
| Compose (render) | ~11.3 us | -- | -- |
Reading the table (directionally, from that run):
- The interpreter is competitive with or beats
text/templateon the tiny template (~0.31 us vs ~0.41 us) and on the 100-row loop (~41.5 us vs ~88.4 us), while carrying a larger feature set (gradual types, whitespace control, composition). - The compiled loop is several times faster than the interpreter on the same
workload (~10.2 us vs ~41.5 us, roughly 4x here), because the generated Go emits
body literals as constants, inlines
loop.index, and skips the per-node dispatch,Context, and copy-on-write the interpreter pays.
The third-party peers (pongo2, stick, jet, quicktemplate) are omitted from this
static table on purpose: their standing against Quill shifts with workload and
version, so run task bench:all and compare in your own environment rather than
trusting a frozen ranking here.
The compiled figure is the real shipped compile backend: the render function
the quill compile command emits, the same unit WithCompiled installs,
benchmarked by BenchmarkCompiledReal_Loop_Render over the committed generated
source in bench/compiled_loop_gen.go. A staleness test regenerates that source
in-memory and fails if it drifts from the backend's current output, and a parity
test pins its bytes to the interpreter's, so the number tracks the actual
generated code rather than a description of it.
The harness also keeps a hand-written proof-of-ceiling
(BenchmarkCompiled_Loop_Render in bench/compiled_poc_test.go, ~14.4 us),
which renders the loop the way the backend lowers it as an independent bound on
what the compiled path can reach; the shipped backend meets and beats it here
because it buffers its writes. See Architecture for how the
compiled path dispatches.
The compile-to-Go backend¶
Templates run on the tree-walking interpreter by default. For the hot path, the
compile-to-Go backend (reached through the quill compile command) generates
Go source (a render function plus a dispatch manifest) that you install with
WithCompiled:
The Environment serves a by-name render through the generated function only when its fingerprint (escape strategy, undefined-handling mode, tab width, seed) matches the Environment's configuration and every member template's source byte- equals what the loader currently serves; anything unprovable falls back to the interpreter. So installing a compiled unit can change render speed but never rendered bytes. Generate a unit with the CLI:
See the CLI for the compile subcommand and Architecture
for the backend design.
Why the interpreter is already fast¶
The tree-walking interpreter pays a Kind-switch and a value boxing per node per
render, which is well within budget for typical render workloads: a parse is
memoized in the cache and reused across renders, the postfix conditional desugars
to a ternary at parse time (no extra node kind), and coverage instrumentation is a
single nil-check when no collector is attached. The value layer spends its
correctness budget once in the runtime package (one equality, one ordering,
one truthiness, one ToText), so the hot path does no per-site algorithm
selection.
Reproducing the numbers¶
The Taskfile
wraps every run so you do not have to remember the flags or cd bench (each
bench:* target runs inside the nested module for you):
| Task | What it runs |
|---|---|
task bench |
offline suite (Quill + stdlib), zero external deps |
task bench:all |
full suite including the four thirdparty peers (-tags thirdparty) |
task bench:stat |
full suite -count=10, summarized with benchstat (mean +/- variation) |
task bench:compare |
full suite vs the committed bench/baseline.txt, deltas via benchstat |
task bench:baseline |
regenerate bench/baseline.txt (same run as bench:stat) |
task bench:profile |
one hot benchmark with CPU/mem profiles into bench/prof/ |
task bench runs the offline Quill-vs-stdlib benchmarks with zero external
dependencies, including BenchmarkCompiledReal_Loop_Render (the shipped compile
backend) and BenchmarkCompiled_Loop_Render (the proof-of-ceiling).
The generated render function bench/compiled_loop_gen.go is committed, so the
real-backend benchmark builds with no manual pre-step; regenerate it after a
compiler change with go generate ./... (or go run genloop.go) from the
bench directory. A plain go test ./... in bench runs the parity and
staleness guards that keep that generated source honest.
task bench:all adds the third-party peers (pongo2, stick, jet, quicktemplate)
via the thirdparty build tag; their module dependencies resolve automatically
from bench/go.mod. Equivalent by hand:
Stable numbers with benchstat¶
A single -bench run is noisy. task bench:stat runs the suite ten times and
pipes the output through
benchstat, which reports
each benchmark's mean and run-to-run variation so you can tell a real change from
sampling noise. (task install:tools installs benchstat; the target falls back
to go run if it is not on PATH.)
To track deltas over time, bench/baseline.txt holds a committed reference run.
task bench:compare measures the current tree against it with benchstat, and
task bench:baseline regenerates that file. It is committed and refreshed
intentionally. After a deliberate performance change, regenerate it on a
quiet machine and commit the update so the comparison stays meaningful. Because
absolute numbers are machine-specific, bench:compare is most useful for a
before/after on the same machine.
To profile a hot path, task bench:profile runs the loop-render benchmark with
-cpuprofile/-memprofile into bench/prof/ and prints the go tool pprof
command to open them.
Next¶
- Comparison: the neutral capability matrix vs other Go engines.
- Architecture: the interpreter and the compile-to-Go backend.
- CLI: the
compilesubcommand.