ADR-372: Parallel Folding Without a Grammar - task::cores(), Par::fold, Pieces of Bytes; Pieces Are Claimed While the Work Runs, and Order Is Kept Unless the Program Says order: par::Order::Any

Status: Accepted; D9 (a fold’s step and merge return the accumulator or change it in place) added October 20, 2026 Date: October 19, 2026 Answers: #717, how a program that is not a grammar folds over all cores the way par_fold does, and how the pieces of either are handed out Refines: ADR-296 D10-D11 (par_fold: one piece per core, cut before the work starts; the name cpu::count()), ADR-235 (Par[T] has Seq[T]’s surface) Related: ADR-312 (user_parallelism), Part III 13.3b (user-pool), ADR-366 (a grammar reads Bytes), ADR-367 (a fold’s counters), Part I 5.1 (options after ;), Part II 10.7, 12.6

1. Question

let table = b.par_pieces(b'\n').fold(Table, fn(t, piece) { scan(t, piece) }, Table::merge;
                                     order: par::Order::Any)

A fold that every core runs at once - a count, a sum, a table of totals - is the most common shape of parallel work over one large input. Nikaia has it only inside a grammar (par_fold, Part II 10.7). A program that scans bytes itself cuts its own pieces, runs par_iter().map over them and merges a list of results: one accumulator per piece, made and cleared for each, pieces of equal size handed out before the work starts, and no way to ask how many cores there are.

2. How others do it

3. Decision

D1 — task::cores() is the number of workers parallel work uses.

task::cores() -> i64 is the size of the pool that runs user code: at user_parallelism = yes, user-pool from the runtime configuration, where 0 means the threads the process may run on (its CPU affinity and quota, not the machine’s total); at no, 1. It is at least 1. It answers how many pieces run at once here, which is what a program sizes its work by; the machine’s total is not that number where a container or an affinity mask limits it. The name ADR-296 D11 wrote, cpu::count(), is this function.

D2 — Par::fold(init, step, merge) folds with nothing shared.

let summary = rows.par_iter().fold(Summary, fn(acc, r) { acc.record(r) }, Summary::merge)

init makes an accumulator - a type, called with no arguments, as in par_fold, or a function fn { … } - and is called once for each accumulator, never copied from one value. step takes an accumulator and an element and returns the accumulator, or changes it in place (D9); merge takes two and returns one, or changes the first (D9). All three are what a par_iter lambda is (Part II 12.6): they may not pause (NK2209) and may not change a name outside them (NK2107). Each accumulator is used by one worker at a time and shared with nothing, so step changes it without a lock. The result is the merged accumulator; over no elements it is one made by init.

D3 — A piece of Bytes ends at a byte the program names: b.par_pieces(sep).

b.par_pieces(sep: u8) is a Par[Bytes] over b cut into pieces that each end just after a sep, the last one at b’s end; every byte of b is in exactly one piece, and no piece is empty. It is how a scan written by hand gets what a grammar’s @frame gives par_fold: whole records per piece, cut while the work runs. fold, map, for_each and the rest of Par apply as to any other.

D4 — Pieces are claimed while the work runs.

par_fold, Par::fold and par_pieces do not cut their input into one piece per worker before the work starts. A worker that is done takes more; a slow core holds back only the piece it is on. How large a piece is, the runtime decides; the option piece: (D6) bounds it. This replaces ADR-296 D11’s blind split into n ranges; its seam repair stays: a piece of a par_fold rule starts after the first boundary at or after its first byte.

D5 — The order is kept unless the program says it may go: order: par::Order::Any.

par::Order is Kept | Any; a fold’s option order: takes it, Kept by default.

Kept is the default because it asks of the program only what par_fold already asks, gives the same answer on every run, and keeps collect-shaped folds - one that gathers rows into a list - correct without a word. Any is the opt-in for a fold whose accumulator is costly to make or to merge, such as a hash table of totals: one per worker instead of one per run.

D6 — What a fold takes besides its three functions are options, and none changes the answer.

After ; (Part I 5.1), with defaults:

Option On Default Says
order: par::Order Par::fold, par_fold Kept D5
workers: i64 Par::fold, par_fold task::cores() at most this many accumulators are folded at once
piece: i64 par_iter, par_pieces, par_fold the runtime’s the size a piece is cut near: elements for a list, bytes for Bytes and a grammar’s input

workers: and piece: tune; on a program that keeps D5’s promise they change no result. A value below 1 is a panic at the call that names the option. workers: above task::cores() makes more accumulators than run at once, nothing more.

D7 — At user_parallelism = no each is its sequential fold.

Par::fold, par_pieces(…).fold and par_fold run as one fold: one accumulator, made by init, every element in order, merge never called. Both orders give that answer. The options are read and do nothing.

D8 — A grammar’s failure is the first in the input, whatever the order.

A par_fold rule whose input fails reports the failure at the smallest position, under Kept and under Any alike, as a sequential parse would: pieces before a found failure are still parsed, pieces after it may be dropped.

D9 — A fold’s step and merge return the accumulator or change it in place; their result type says which.

Changed by ADR-views-3 D3, D4: the in-place form is written fn(mut A, T), fn(mut A, A); ref mut stands only inside extern "C".

rows.par_iter().fold(Summary, fn(acc, r) { acc.with(r) }, Summary::joined)    // returns
rows.par_iter().fold(Summary, fn(acc, r) { acc.record(r) }, Summary::merge)   // in place

step and merge are each one of two forms, told apart by what they return:

The two choices are independent: a returning step may go with an in-place merge. A step or merge that returns a type other than the accumulator, or returns nothing without taking the accumulator as mut, is refused as any call with the wrong function type is (NK1102). The same holds for the step and merge of par_fold and fold in a grammar (Part II 10.7). The result is the same in both forms; which form the lowering runs is not visible.

Why. Both forms are already written: par_fold in examples/1brc.nika uses methods that change self (fn record(mut self, m: Reading)), and an accumulator that is a number or a small value reads best as a function that returns it (fn(n, _) { n + 1 }). Asking a program to write the one form as the other costs a wrapper per fold and, for a large accumulator, a move per element that the lowering must then prove away. The function’s type already says which form it is, so no keyword or second method name is needed. Rejected: returning only (as D2 first said), which rewrites every method that changes self into one that consumes and returns it; in place only (Rust’s rayon with fold_with and &mut), which makes the accumulator of a number a ref mut i64 the program cannot write (NK1257); two methods (fold/fold_mut), a second name for what the type already says.

4. Rejected alternatives

5. What this does not decide

</content> </invoke>

This site is open source. Improve this page.