Phoenix / What it costs
What it costs
Measured on this machine with bench/run.sh and phx --stats. Every number
here is reproducible, and the command that produces it is beside it. The step
counts are exact; the times are not.
The question worth asking was never how fast. It was whether the work grows in proportion to the input — because a PEG with no memoisation need not, and Phoenix has no memoisation at all. A constant factor is a decision; a bad complexity is a defect.
Measured four times now, on three grammars that are as unalike as the project has: Pascal’s shallow ladder, awk’s fourteen rungs and juxtaposition, and SolVM assembly, which has no expression grammar at all.
Nine shapes, three grammars, one curve
A step is one attempt to match one grammar node at one position. Steps are deterministic, so this is the shape of the curve exactly rather than a measurement of it.
Pascal — sh bench/run.sh
| shape | what it is | steps per token | depth |
|---|---|---|---|
width |
n statements one after another, n = 100 → 1600 | 23.8 → 23.8 | 37 |
expr |
one n-term expression, n = 100 → 1600 | 12.6 → 11.6 | 37 |
nest |
n nested parentheses, n = 100 → 800 | 30.1 → 30.9 | 1,127 → 8,827 |
blocks |
n nested begin/end, n = 100 → 800 |
22.0 → 22.0 | 827 → 6,427 |
awk — sh bench/run.sh languages/awk/awk.phx bench/generate-awk.awk "width expr" "..."
| shape | steps per token | depth |
|---|---|---|
width, n = 50 → 800 |
416.0 → 408.5 | 60 |
expr, n = 25 → 200 |
285.7 → 221.4 | 60 |
| the corpus (awk other people wrote) | 370 – 2,638 | ≤ 150 |
SolVM assembly — sh bench/run.sh languages/solvm/solvm.phx bench/generate-solvm.awk "width labels blocks"
| shape | steps per token | depth |
|---|---|---|
width, n instructions, n = 100 → 1600 |
25.5 → 25.3 | 13 |
labels, n labels each jumped to, n = 100 → 1600 |
11.7 → 11.6 | 13 |
blocks, n nested .blocks, n = 100 → 1600 |
11.0 → 10.7 | 410 → 6,410 |
Sixteen times the input, the same work per token, in every shape of every grammar. Two of the nine get cheaper per token as they grow. Nothing rises.
The constant spans 240×, and the curve does not move
| grammar | steps per token | what its grammar is |
|---|---|---|
| SolVM assembly | 11 – 25 | no expression grammar at all; the first token settles which rule matches |
| Pascal | 12 – 31 | a shallow ladder, and an LL(1) shape |
| awk | 221 – 2,638 | fourteen rungs, concatenation with no operator, and a print needing six rungs duplicated |
That is the useful result of measuring a third grammar. The constant tracks how deep the ordered choice has to go before it commits; the curve does not track anything. The assembler is the control: it is what this matcher costs when a grammar asks nothing of it, and it is not meaningfully cheaper per token than Pascal.
The depth column says the same thing from the other side. A flat grammar reaches depth 13 on 4,803 tokens and depth 13 on 300 — depth is a fact about the input’s nesting, not its length.
Where the depth limit actually binds
nest and blocks do not reach n = 1600, and the reason is worth stating
precisely because the number is easy to misread. The limit is 10,000
levels of grammar recursion, and a construct costs more than one:
| construct | levels each | limit reached at |
|---|---|---|
| a nested parenthesis in Pascal | 11 | ~900 |
a nested begin/end |
8 | ~1,200 |
a nested .block |
4 | ~2,500 |
So ten thousand levels is about nine hundred parentheses, not ten thousand
of them. Both are far past anything written by a person — gcd.pas reaches 62,
features.pas 67, primes.pas 99 — and --stats reports the depth a file
reached, so the headroom is a thing anybody can check rather than a thing this
page claims.
What it takes in time
20,000 lines of Pascal, all the way from source to a running program:
phx, parse + typecheck + emit C |
189 ms |
| parse alone | 90 ms |
gcd.pas, 94 lines, end to end |
6 ms |
the largest awk anybody else wrote — et_c.awk, 269 lines, 1,452 tokens, 2.9M match-steps |
50 ms to running C |
The node count for et_c.awk is the more surprising figure: 70,204 values
built for 1,452 tokens, most of them made and dropped inside alternatives that
failed. That is what ordered choice costs, and what memoisation would save.
Against fpc
Not like-for-like and should not be read as one: fpc -Miso does native code
generation and linking, and Phoenix emits C and hands both to cc. On 2,000
lines fpc takes about 290 ms of CPU for all of that, and Phoenix takes 31 ms
to reach C — after which cc takes 156 ms.
The one comparison that is fair: on the 20,000-line file, fpc fails —
“Procedure too complex, it requires too many registers” — and Phoenix does
not, because register allocation was never Phoenix’s problem.
What measuring found
A segmentation fault, the first time. Matching is recursive descent, so the C stack is proportional to how deeply the input nests, and about 3,400 nested parentheses exhausted it. The program died with a signal, which tells the person nothing and cannot be caught — and input is not a thing a compiler gets to trust. There is a depth limit now, and past it a message with a position.
A benchmark that could not fail, the fourth time. bench/run.sh did not
check whether phx had succeeded. On error --stats prints nothing and the
diagnosis arrives on the same stream, so awk '{print $9}' picked a word out of
the error message and the row looked like a measurement. Two numbers on this
page came from exactly that — nest and blocks at n = 1600, for shapes that
cannot complete.
A measurement harness that cannot report failure will report something else. The numbers it invented were plausible, which is why they survived three readings of this page.
It checks the exit status now, prints the reason where the numbers would go,
and refuses a --stats line whose fields are not numbers — so a change to that
format is a loud failure rather than a quiet one.
And a generator that did not exist. The awk figures had no generator in
bench/; nothing in the repository could reproduce them. bench/generate-awk.awk
is that, now, and its constant differs from what the page used to claim because
the grammar has since gained a rung — | as an expression operator, for
getline. That is the sort of drift a reproducible number shows and an
unreproducible one hides.