Status: Accepted
Supersedes: the measurement in ADR-014 D3
Related: ADR-296 D19 (user_parallelism chooses the executor and
nothing else — the line D1 declines to cross), ADR-296 D10,
ADR-016 (the next cost in line)
A generated parser was running at about 4500 instructions per row against about
840 for the same aggregation written by hand in Rust. A file whose temperatures
are all negative — so that the neg:"-"? in TENTHS succeeds instead of
failing — parsed in half the time: one failing optional per row cost half the
runtime, and the hand-written parser, which also looks for a - and usually
does not find it, paid nothing.
The cost was not the failure. It was the bookkeeping the parser did about the failure, on a path where the parse then succeeded and nobody ever read it.
An entry point first parses with a zero-sized error type and, only if that
fails, parses again with the diagnosing engine — for a par_fold rule,
restarting from the item the fast pass stopped in rather than from the
beginning. The mode is a field of the parse context and defaults to replay.
Nikaia emits no choice here. The default is right for every program the lowering produces: full speed on the accepted path, a full message on the rejected one, and the second pass paid only by inputs that were going to fail anyway.
A build switch that picked a diagnostics mode would cross ADR-296 D19’s line, since turning diagnostics off changes an error’s content — a switch that changes what a program means. If a program ever needs that, it is a property of the program and belongs in its source.
digit{1,2}
is &'a str, not Vec<char> — no allocation per item. The corpus says
for d in whole.chars() where it said for d in whole, which is
ADR-013 D7 once more: the lowering is syntactic, so where a type
changed the source says so.A rule binding may not be called input. The generated parser’s closure
takes a parameter of that name, and every action ends with a statement
reaching for it, so a binding called input shadows it. Nikaia’s own grammar
binds source where it means an input.
This is Nikaia’s to carry, not the backend’s to fix. Every macro that hands a user’s block a scope reserves names in it, and the alternative — hygiene for the injected binding — would make that binding unreachable from the action that wants it, which is the point of injecting it. The reserved names are part of the protocol, and a comment at the binding says so.
On 8 million rows, the generated parser runs at about 660 instructions per row, below hand-tuned Rust’s 690, and the all-negative file now parses at the same speed per byte as the mixed one — the half is gone, which is what says this was the right fix rather than a general speed-up that happened to help. Output is byte-identical at either setting and against the naive Rust program.
A parser generated from a declarative grammar is below hand-tuned Rust on instructions per row for this workload, and ahead of both hand-written programs on the clock before any parallelism is switched on. That is the claim ADR-296 makes, supported by measurement.
It is not a claim about the benchmark’s leaderboard. The top entries there are not parsers: SWAR over raw bytes, custom hash tables sized to the key count, no UTF-8 validation. That distance is architectural. What changed here is that the normal path is no longer the thing standing in the way.
The comparison is one workload. It is a good stress for scannerless parsing and zero-copy slices, and says nothing about a grammar with backtracking across long alternatives.