ADR-009: Parallel Parsing — Frames, Monoid Folds, and Format Assumptions

Status: Superseded by ADR-296 — its D1-D7 are D9-D15 there Related: ADR-007 (the grammar protocol), ADR-008 (container-level tethering, which is why a chunked parse costs no atomics), ADR-005 D5 (sync and scoped parallelism), ADR-010 (settles §5’s first item)

1. Context

Splitting a buffer at line boundaries, one chunk per core, can be written by hand:

let chunks = data.split_aligned(cpu::count(), "\n")
let summaries = chunks.par_iter().map fn { dsl Measurements from a } .collect()

Specifying split_aligned would close that gap and get the shape wrong. The user is asked to restate, as an argument, something the grammar already knows — that a measurement ends at "\n" — and then to hand-write the pipeline that follows mechanically from it.

The fast reference implementations of this workload all rest on format assumptions exploited at the byte level: the separator can be found without parsing, fields are short, numbers are fixed width, keys are few. Those assumptions are legitimate. The question for a language is where they may be written down, and hand-written SWAR in user code is not an answer: unreadable, unportable, unverifiable.

The grammar is the right place, because it is already the file’s specification. This ADR decides which assumptions a grammar may state, what the compiler may then generate, and what it must verify first.

2. Decisions

D1 — @frame declares a resynchronisation unit, and the compiler checks it.

@frame(boundary: "\n")
rule MEASUREMENT -> Reading = name:NAME ";" => temp:TENTHS frame_end -> { … }

@frame says: an occurrence of this rule can be found from an arbitrary offset by scanning to the next boundary — no parse state from earlier in the file is needed.

Not a promise the user makes; a property the compiler checks. Resynchronisation is sound exactly when the boundary cannot occur inside a frame. The frontend walks the terminals reachable from the frame rule and rejects the marker otherwise. The canonical failure is CSV with quoted fields: a newline inside "…" means the split lands mid-record. That grammar gets a diagnostic naming the rule that can swallow the boundary, not a data-dependent wrong answer at 3 a.m.

Two verdicts, and no third:

construct verdict
a literal without the boundary; a built-in whose alphabet excludes it (digit1, ident); lookahead safe
until(t) whose terminator covers the boundary — an alternative is frame_end, the boundary literal or a prefix of it, or line_ending for a newline safe
a literal containing the boundary; a built-in that can consume it (any, multispace0); the implicit whitespace of a syntactic rule rejected, naming rule and pattern
until(t) that does not cover the boundary rejected — the message says until(… \| frame_end)
recover(…) rejected — the skip would have to stop at the boundary and the sync token consume it. Recover per frame instead: (item \| until(frame_end)) frame_end

The compiler checks the grammar; it never rewrites it. The naive version of the check rejects the grammar it exists for, because NAME = until(";") consumes a newline too. The tempting repair is to generate an until reached from a "\n" frame as “until ; or newline” without the grammar saying so. That is refused: it makes the same rule text parse differently depending on what reached it, changes a rule shared between a frame and something else for the something else, and needs per-rule mutable state in the generator. It is the parsing-side twin of “Nikaia never inserts a clone the user didn’t write” — a pattern means what it says, wherever it is written.

frame_end is the built-in that resolves this honestly: it names the boundary of the frame the rule is reached from, written once in the attribute and referenced rather than copied. As an until alternative it covers the boundary by construction. It resolves statically — writing it outside a frame is an error, and so is being reached from two frames with different boundaries. Writing the literal stays legal and means the same.

The attribute is keyed, and has an escape hatch. @frame, @frame(boundary: "\n"), @frame(boundary: "\n", unchecked). unchecked skips the walk: the author asserts the invariant, and the word is greppable — it is to a frame what the reserved unsafe is to FFI. It is needed because a boundary is a byte string, and formats exist whose cut points cannot be found by searching for one: quoted fields (quote parity is a stateful pre-scan, not a search), records recognisable by how they start, escaped boundaries, a scanner of the author’s own. Each is a future key (quote, start, escape, scan) that changes nothing about the existing ones; until one exists, such a grammar says so in the grammar.

Two consequences stay normative. A frame rule and everything it reaches is lexical (uppercase, or wrapped in lex(…)) unless the grammar defines a WS that leaves the boundary alone — which for a data format with no whitespace between fields is the honest spelling anyway. A frame must end in its boundary, so that the piece that finds a frame’s end is the one that owns it (D3).

Named @frame rather than sync rule: sync already means “pure CPU, cannot pause” and names the resynchronisation token of recover(rule, sync). A third meaning would be the worst kind of overload.

D2 — Parallel parsing is a frame plus a monoid: par_fold.

pub rule file -> Summary =
    par_fold(MEASUREMENT, Summary::new, fn(acc, m) { acc.record(m) }, Summary::merge)

par_fold is fold plus a merge, and requires its item rule to be @frame. Given both, the whole pipeline is generated — split, seam repair, one accumulator per chunk, merge — and the user’s main is let totals = dsl Measurements from data.

Parallelism stays opt-in, deliberately. The compiler could infer that a fold with an associative step may run in parallel, and must not: a merge over f64 is not associative, so the chunk count would silently change the output. par_fold is the user saying “results from different chunk counts are the same to me” — which a program earns by accumulating in integer tenths and converting at print time.

Chunk count changes neither what is accepted nor what is rejected. A par_fold rule’s entry point skips no whitespace on either side. The obvious implementation skips it once per piece rather than once per input, which loses a leading space in whichever piece happens to start there and tolerates whitespace-only text between frames that a single-piece parse rejects. The property is asserted over accept and reject, against awkward inputs at one to eight pieces.

It degrades rather than failing. At user_parallelism = no, par_fold is a sequential fold — same accumulator, same merge, same result, no threads — which is the spawn pattern applied to parsing, and keeps a par_fold grammar compiling for wasm32-*.

D3 — The lowering is a blind split, then a seam repair.

Normative, because the edge cases are where a chunked parser silently loses or double-counts rows:

  1. The input is divided into n equal byte ranges, n = cpu::count() by default. No scan.
  2. Every chunk but the first advances past the first boundary at or after its start, and ends where the next chunk’s start was repaired to — so every frame is owned by exactly one chunk, the worker that finds its end.
  3. The final chunk runs to EOF. Input not terminated by a boundary still yields its last frame; input ending in one yields no empty trailing frame.
  4. A frame longer than a chunk is not an error: a chunk whose repaired start lands at or past its end contributes nothing, and the owning worker parses through.
  5. Errors carry absolute offsets, so a diagnostic from chunk 7 points into the file. Under recover, a chunk-local failure does not abort the others.
  6. Chunk count does not change results, and is observable and overridable for benchmarking.

This is the reference implementations’ trick — split blindly, repair the seam — with the correctness conditions written down rather than left to the reader.

A par_fold rule generates a cut, a merge and a driver that parses each piece with the rule’s own parser and reports a failing piece at its offset in the whole input. The driver takes Parallelism::Off | Pieces(n) | Auto, and Off is defined to be identical to the plain parser. Nikaia chooses that value from user_parallelism; the cut and merge are public so the pieces can run on Nikaia’s own pool rather than the backend’s.

D4 — Scanning is a specified complexity, not a hoped-for optimisation.

Finding a boundary or an until terminator lowers to a word-at-a-time scan: a u64 load, an XOR against the broadcast byte, the standard (x - 0x01..) & ~x & 0x80.. mask, a trailing-zero count. Portable SWAR — no target feature, no runtime detection, works on wasm32 and therefore under wasm32. Target-gated SIMD is a multiversioning layer on top, never the baseline, so a program’s performance story does not fork by target.

until accepts an alternation, and up to three fixed alternatives are scanned in one pass; more take the position-by-position path.

The guarantee is stated as complexity, not instruction selection: scanning for a byte terminator never inspects one byte at a time. The same primitive removes recover’s byte-by-byte skip — one scan primitive, two callers.

D5 — Fixed-width numeric parsing needs bounded repetition in the grammar.

rule TENTHS -> i32 = neg:"-"? whole:digit{1,2} "." frac:digit -> { … }

The branchless integer trick is sound only where the field’s width is bounded. digit+ says unbounded, so a compiler emitting the fixed-width path would be inventing a constraint the grammar never stated. p{n,m} is the only sanctioned route: the format assumption is written by the person who knows it, checked by the parser, exploited by codegen.

Ordinary branch elimination needs none of this and is already the backend’s job.

D6 — Hash a view from its first bytes; never truncate the comparison.

A view key may be hashed from its first 8 bytes mixed with its length — sound as a hash. What several reference implementations additionally do, comparing only the first 8 or 16 bytes on a probe hit, is not sound: two keys sharing a 16-byte prefix merge silently.

ADR-008 D4 already fixed equality on views as content-based. This is stated separately because the tempting optimisation is the one that produces a plausible wrong answer rather than a crash.

D7 — Teardown of a large read-only buffer may be skipped at process exit.

A read-only mapping with no Cleanup and nothing observable attached to it may be left to the kernel at process exit. At thirteen gigabytes, unmapping page by page is measurable and changes nothing a program can see.

This is the one place ADR-006’s “cleanup always runs” is relaxed, and it is relaxed by proof of non-observability, not by a flag.

3. Rejected and deferred

4. What this makes of the workload

The user writes down the format: a record ends at a newline, summaries combine, a name runs to the semicolon, a temperature has one decimal. Everything the reference implementations hand-code, the compiler now has grounds to generate — blind split and seam repair (D3), word-at-a-time scanning (D4), per-worker accumulators with no shared state (D2), and zero allocation and zero refcount traffic per row (ADR-008).

5. Open

This site is open source. Improve this page.