ADR-033: Program Order Is a Guarantee Where It Is Observable

Status: Superseded by ADR-292 — its D2 and D3 are restated in D3 there, D4 in D3 as displaced by ADR-050 §5, D6 in D5, D9’s owed explanation in D3 and D10’s distinction in D6; D1, D5, D7, D8, D9’s refusal of a run these together block and D10’s lowering are not carried (displaced by ADR-050 D1, D2 and D7) Amended by: ADR-034, whose D2 widens this record’s D5: “no speculation” had a case it did not name Related: ADR-005 (ownership inferred and invisible — this is that, extended past memory), ADR-020 (the ledger touches joins), ADR-021 D5 (why ordering had to become a cache dimension), ADR-037 D2 (the user in user_parallelism, which D10 turns on), ADR-038 D3 and D4 (the runtime D10’s vehicle is), Part I 8.1 (async by default), Part II 12.2 (the lock rules D3 subsumes)


1. Context

Nikaia writes I/O in direct style: let a = fs::read("x") pauses, and the next line does not run until a is there. Within one task the order is exactly the written order, always. That is the whole of the current model, and it means this costs twice what it needs to:

let a = fs::read("x")     // 40 ms
let b = fs::read("y")     // 40 ms
return a.len() + b.len()  // 80 ms, for two things that never met

b does not depend on a. A CPU issuing two independent loads would overlap them without being asked. The compiler can see the independence — it is the same dataflow analysis it already runs for sync.

1.1 The objection that was raised first, and why it was the wrong caution

The obvious answer is: let the compiler reorder where it is harmless, as a CPU does. The obvious objection is that a CPU may do this and a compiler may not, because a CPU

  1. knows the whole dependency graph — registers and the addresses it tracks;
  2. has operations that are mutually effect-free except through memory it tracks;
  3. can roll back — speculate, then retire or discard.

and for I/O none of the three holds. The dependency graph lives in the world, not the program; the effect is the point; and no packet ever came back.

That argument is correct about today’s effect vocabulary, and that is its weakness. Nikaia knows exactly one thing about fs::write: it is I/O and it may pause. Two writes to different files are indistinguishable — not because I/O is unknowable, but because nobody wrote down what each call touches.

The ordering problem is not a safety property of parallelism. It is a resolution problem of the effect system.

A pure language may evaluate in any order because nothing can collide. The only reason for sequence is effect, and two effects collide only when they touch the same thing.


2. Decisions

D1 — Program order is a guarantee where it is observable, and only there.

The written order is what a reader relies on and what the compiler must honour wherever the difference can be seen. Where it cannot be seen, the order is not a promise the language ever meant to make; it is an artefact of writing one line under another.

D2 — Every operation declares what it touches. Disjoint touches are unordered.

Not “does it do I/O” — what does it reach:

fs::write(path, data)     touches  file(path)      write
fs::read(path)            touches  file(path)      read
println(x)                touches  stdout          write
counter.access fn { … }   touches  counter         write
net::post(url, body)      touches  endpoint(url)   write

And one rule:

Two operations whose touch sets are disjoint have no order between them. Everything else keeps the order it was written in.

Two reads of the same resource are also unordered — reads do not collide, which is the same rule a CPU applies to two loads.

D3 — This is the borrow checker, extended past memory to the world.

Nikaia already answers who may touch this, and may they change it for memory, completely and invisibly: no lifetime annotations, ever (ADR-005). & and &mut are exactly read and write; what is missing is only that a file, a socket, stdout and a Locked value are not in the set of things that can be pointed at.

That is the argument for this decision over any other spelling of it. It is not a new mechanism bolted to the side. It is the mechanism the language already rests on, applied to a larger set of things — and the reason the answer can stay inferred and unwritten, which everything else in Nikaia’s design depends on.

D4 — Unknown touches mean touches everything, so unknown means ordered.

A function whose touch set the compiler cannot determine — a foreign call, a library with a thin ledger, anything reached through unsafe — is treated as touching every resource. It therefore orders against everything and stays exactly where it was written.

This is ADR-010 D1’s polarity for a third question, and it is what makes the decision adoptable at all:

std.contracts growing is the mechanism by which programs get faster. That is the same shape as ADR-028 and ADR-031, where writing a contract down is what let a caller earn sync.

D5 — Only unconditional operations, and only with a bounded degree.

No speculation — an operation may be started early only if the program as written would certainly have performed it (ADR-034 D2 states this generally; what follows was the only case named here). if x { read(a) } else { read(b) } starts one of them. Running both to save latency would burn a request that was never wanted, which is the opposite of the thing this is for. Only operations that are certainly going to run may be overlapped.

Bounded concurrency. A loop over ten thousand files must not open ten thousand descriptors. The degree is bounded by the runtime, configurable, and part of this decision rather than a detail after it. Unbounded parallelism is not generosity toward a machine.

D6 — Errors and observable effects surface in program order.

If two overlapped operations both fail, the error that surfaces is the one whose operation comes first in the written program, regardless of which failed first in time. Anything else would make a diagnostic depend on a race.

println("a") followed by println("b") prints a then b — not by a special case, but because both touch stdout. That is the test of whether a model like this is worth having: it must deliver the obvious without an exception for it.

D7 — seq says an order the compiler cannot see.

Two calls whose resources look disjoint and are not — different URLs, one service behind them:

seq {
    benachrichtige(kunde)
    protokolliere(vorgang)
}

More expensive to write, rare, and visible at the place it matters. That is the distribution this whole decision is for: the default is the fast and safe thing; the exception costs.

(The keyword is provisional. It needs to read as “in this order, whatever you think”, and seq is a placeholder for a word chosen later.)

D8 — ordering in nikaia.toml, and it can be turned off.

# How strictly the written order is taken (ADR-033).
#   "effects" (default) — two operations that touch disjoint things may overlap
#   "strict"            — the written order, always; the analysis is not applied
ordering = "effects"

strict is not a debugging aid to be removed later. It is the escape for a program whose author does not want this, and for the case where a bug in the analysis has to be ruled out in the field. A semantic default that cannot be switched off is a decision imposed rather than offered.


D9 — No allow_parallel, no try_join. The compiler explains its refusals instead.

The obvious companion to D7’s seq would be its opposite: a block meaning run these together anyway. It is refused, and the reasoning is worth keeping because it will be proposed again.

try_join is out twice over. It names what the compiler already does, so writing it would make the fast path the thing you ask for — the design this decision exists to invert. And tokio::try_join! means cancel the others on the first error, which is the opposite of D6. Borrowing the name would borrow the wrong expectation.

allow_parallel serves exactly one refusal. Of everything the analysis turns down, only one is a case where the programmer knows something the compiler does not and the program is correct either way:

refusal would a keyword help?
a data dependency no, and must not
the same resource, one writing no, and must not
a resource that cannot be named (D4) no — that wants a better contract
an argument that is not a literal no — an implementation limit, not a language question
a handler that can return (ADR-034) yes, only here

And that one has a spelling already:

// Refused - the handler leaves the function, so the second read might never happen:
let k = db.lade_kunde(id) catch { return Seite::leer() }
let e = http.get("…")     catch { … }

// Overlaps, with no new keyword:
let k = db.lade_kunde(id) catch { Kunde::unbekannt() }
let e = http.get("…")     catch { Empfehlungen::leer() }
if k.ist_unbekannt() { return Seite::leer() }

The second is not merely equivalent, it is better: catch { return } conflates what to do about the failure with whether to carry on, and separating them makes the independence visible to a reader as well as to the compiler. A keyword whose only purpose is to rescue a phrasing that has a clearer form is a second way to say one thing — which is what ADR-022 removed a lambda form for.

What is owed instead. A refusal nobody can ask about is the trap the keyword would have been an escape from. So the compiler answers:

$ nikaia --input seite.nika --overlaps
main:
    together  fs::read_to_string / fs::read_to_string - they meet on nothing
    in order  fs::write / fs::read_to_string - both reach file `log.txt`, and one writes it
    in order  fs::read_to_string - its `catch` can leave the function, so the next statement
                                   might never have run - write the handler so it hands back a
                                   value instead, and check afterwards

Asked for, never volunteered — a paragraph per pair would be noise in exactly the programs that are fine. It explains a decision rather than changing one, which is what --trust already does for provenance.

Revisited if somebody writes a real program with a place where they want both to run, cannot restructure it, and can measure the difference. Until then the language has two escapes and both are restrictions — seq and ordering = "strict" — rather than permissions, which is a narrower and more honest surface than three block forms.


D10 — Two operations in flight is not two pieces of your code running. §8.2b’s degradation belongs to the vehicle, not to the ordering.

At user_parallelism = no, ordering = "effects" no longer degrades to strict. What degrades is task::both, which is the only thing that ever needed to.

§8.2b asked one question where there are two, and they have different answers:

question answer what it gates
May two pieces of the program’s own code run at the same time? user_parallelism = yes only task::both
May two operations be in flight at the same time? both settings, where the vehicle carries no code the program wrote a completion pair

§8.2b’s category distinction is what this rests on, and it stays true. Whether two operations may overlap is a question about the program; whether a vehicle exists to overlap them with is a question about the build — that is right, and it is why contracts::order still sees no switch and the emitter still answers the second half. What has stopped being true is §8.2b’s conclusion, which was that at no there is no vehicle. That was a true statement about the only vehicle there was. ADR-038 D3 and D4 built a second one: two file reads handed to the runtime are two operations the kernel performs while the program is suspended at both of them, std does the waiting, and no thread carries anything the user wrote. nikaia_std::rt’s worker inbox is a closed enum no variant of which can carry a closure, so that is a property of the types and not a convention (ADR-038 §4.2).

The promise no makes is therefore untouched, because the promise was never “one thread”. It is Part I 1.2’s nothing you wrote ever runs concurrently, and the word user in user_parallelism is what says so (ADR-037 D2, whose §4 this narrows along with §8.2b). This is the place that word finally pays for itself.

What is lowered. A pair of adjacent statements each of which is one std file read — fs::read or fs::read_to_string, a literal path, optionally a catch that hands back a value — lowers onto nikaia_std::task::read_pair. Both handlers then run where they were written, on the one thread the program has, after both answers are in: D6 holds by construction rather than by argument, and a handler’s own effects were already part of what its statement touches (§8.3). A pair that would need two closures still needs yes; a run of three reads at no overlaps its first pair and leaves the third where it stands, which is sound for the reason a group is a prefix (§6).

A pair of reads gets this vehicle at yes as well, because it is the cheaper one by two orders of magnitude — −0.87 µs a pair against +112 µs for task::both, measured end to end on one box on one day (docs/history/runtime-cost.md §6). Choosing between two vehicles with the same meaning on a measurement is an optimisation, and §7’s distinction is what makes that the right kind of reason there: both lowerings print the same bytes.

Where the machine has no completion queue, the pair is performed in the order it was written. This is the decision inside the decision, and it is not the obvious one. The blocking fallback can overlap a pair — it is a message to a worker that is already parked — and it costs ~38 µs a pair to do it (ADR-038 §4.3; ~54 µs on the box §6 of the notes was measured on). That is exactly the fixed per-pair tax §8.2 measured and called a pessimisation: it is paid unconditionally, it cannot be predicted from the source because the payload is not in the source, and it is only earned back above roughly a quarter megabyte per operation. An overlap nobody asked for may not cost anything. So:

An overlap the compiler chose is taken only where it is free.

Which is why nikaia_std::fs::read_both is left exactly as it was and the lowering names a different function. read_both is what a program asks for and it overlaps whatever the mechanism costs, because the author asked and may know the payload; task::read_pair is what the compiler chose, and it falls back to the written order rather than to a tax. The same source, the same bytes, on either mechanism — the choice changes what a pair costs and never what it means, which is the whole of what makes it the emitter’s to make.

What this costs, named rather than discovered. On a machine with no io_uring — an older kernel, a sandbox that forbids the syscalls — a program at user_parallelism = yes reading two files of a quarter megabyte or more no longer gets the overlap task::both used to give it there. That is a real loss on the unusual machine, taken deliberately to avoid a real tax on the usual one, and fs::read_both is what a program writes where it knows better. The second cost is the vocabulary: IN_FLIGHT_READS is two ledger keys hard-coded in the compiler, closed for touch::KINDS’ reason, and a third entry is a std function and a measurement rather than a line in a list.

Not decided here. Two writes in flight (the runtime has no pair vehicle for them, and ADR-038 §4.3 measured reads), and an n-ary completion run for three or more (rt::io::read_all already takes n paths, so it is a std signature and an emitter pattern away — but a run of three at yes keeps task::both today, and which of the two wins there is unmeasured).

3. What one concept replaces

question today under D2
data dependency dataflow analysis the same rule
order of output implicit in sequence both touch stdout → ordered
no pausing while locked (12.2) its own rule, enforced through sync both touch the lock → ordered
race conditions “impossible, the runtime is single-threaded” structurally impossible however much runs at once

The last row is the one worth reading twice. Freedom from races rests today on nothing the user wrote running concurrently — which makes it a property of a build setting rather than of the language. Under a touch model it is a property of the language, and holds however much runs at once.


4. What this costs

A fourth invisible inference, on top of three. This is the criticism this repository has had levelled at it — that derived truth lives outside the source, the ledger becomes the real program, and the reader is left with a surface. It applies here more strongly than anywhere else, because this one changes when things happen and not only what is permitted.

The honest distinction, and the reason to accept it anyway: the three before it buy properties you never see. Borrow inference buys safety you cannot observe; sync buys a permission you never notice. This one buys wall-clock time on the first program anyone writes. An invisible analysis whose effect can be measured is a different proposition from one that must be believed.

Debugging changes. A program whose operations overlap is harder to reason about at a breakpoint. Mitigated by ordering = "strict" (D8), which makes any such question answerable by re-running.

The ledger grows again. touches is the fourth column after sync, throws and borrows, with the same failure mode as the others: a wrong entry is wrong for every consumer. ADR-028 D5’s rule holds — an entry exists because a program asked for it, and never speculatively.


5. Considered and rejected

Mark the parallelism (async let, Swift’s answer; also in an earlier draft of this ADR). One word at the definition, join at first use. It is safe, small, and it was rejected on the project’s own terms: it makes the fast path the one you have to ask for, and leaves the default slower than it needs to be. A language that is generous with machine time by default cannot ask to be told each time.

Mark the barriers, as a CPU does. Reorder by default, write a fence where it matters. A CPU can afford this because its reordering is provably equivalent and therefore unobservable. Nothing about I/O reordering is provably equivalent, so this would require auditing every sequence for an ordering that is not there — the exact cognitive load the language exists to remove, relocated.

Infer touches from bodies rather than writing it. std’s bodies are Rust, and the compiler does not read Rust. Same case as ADR-020 D5. Where a body is Nikaia, inference is possible and is the natural extension later.

A coarse “is this pure I/O” bit instead of named resources. Cheaper, and it cannot order two writes to the same file. Any model that gets println/println wrong is not worth having (D6).


6. What is built

The pool vehicle has two arms now, and task::both below names one of them. ADR-055 §6 step 3 made std’s pausing entries async fn, and task::both is rayon::join, which takes closures — Rust has no stable async closure, so a pausing half has no shape there. A group whose halves can pause gets task::interleave, which takes two futures and polls both on this one thread. Nothing about D10’s question changes: the emitter still decides which vehicle a group gets, from the same two switches and the same ledger columns, and task::both is still what a pair that cannot pause gets — two cli::args() reads, for one. Everything below reads the same with “the pool’s vehicle” in place of the name.

The first increment. Two adjacent lets whose calls reach different files lower to nikaia_std::task::both, which runs the pair on the pool the program already has (§8.4); the emitted Rust compiles and prints what the sequential program printed. touches is a ledger column, --ordering strict turns the whole thing off, user_parallelism = no degrades it to strict because there is then nothing to overlap with (§8.2b — narrowed by D10, which is the fourth increment below), and a test holds the case together with the reasons two statements must not overlap. D9’s --overlaps is built with it, which is what makes doing without allow_parallel a decision rather than an omission.

Three things building it settled. A handler that can return makes the next statement conditional, which D5 had not named — ADR-034 does. ordering had to become a build-cache dimension, since it changes the emitted Rust and a key without it would serve --ordering strict the overlapped artifact a previous run recorded (ADR-021 D5’s own rule, met from a new direction). And a throws body needs the same pairing logic as a block’s, in one helper both call, because two loops deciding independently what may be reordered are two rules waiting to disagree.

The second increment: the shapes. §8.3’s first item — the analysis has to see more than a let of one call before its silence means anything — is built. A statement is now weighed where it is a let or a bare expression statement (println(x), fs::write(p, d): an operation the ledger can account for that binds nothing), and where its value is built out of literals and calls at any depth rather than being one call (f("a") + g("b") performs two operations and reaches the union of what they reach). An overlapped pair that binds nothing lowers to a task::both statement rather than a binding.

Widening the shapes made a fourth thing visible, and it is a correctness one. An uncaught failure makes the next statement conditional for exactly the reason a diverting handler does: if fs::write(p, d) fails, the failure leaves the function and the sequential program never performs the statement after it. The narrow analysis reached that case too and did not refuse it; a bare expression statement is the shape that makes it ordinary, so it is refused now and ADR-034 D2 is what says why.

The third increment: the rest of this list. Three of the six things above are built, and each turned out to have a soundness question inside it that the feature itself was the easy half of.

A run of any length, not a pair. A group of k statements lowers to k-1 nested task::both, so the vehicle is unchanged and changing it is still a std change (§8.4’s second reason). The question inside it: disjointness is not transitive. read("a") / read("b") / write("a") has two disjoint adjacent pairs and is not a disjoint run, and a group runs all of its members at once — so the analysis asks about every pair (i, j) with i < j rather than every adjacent one, and a group is the longest prefix that passes, because leaving a statement out of the middle would reorder the ones after it past the one left behind. D6 needs nothing extra: members that meet on nothing cannot observe one another, every accountable operation either cannot fail or catches its failure into a value, and rayon::join propagates the first closure’s panic when both panic — so nesting to the right surfaces panics in written order by construction.

seq (D7), as an expression, with the order it states reaching inward through every block written inside it. The block itself keeps its place too, which is the fail-closed answer and the only one D7 decides: a reader who wrote seq has said the compiler cannot see what the order is for. It is checked in the emitter and not in contracts::order, for §8.2b’s reason — whether two operations may overlap is a question about the program, and seq is the program having answered it. The keyword is seq and is settled, so Part I 8.1.1 states it rather than hedging it.

A vocabulary past file and stdout, and the interesting part is not the words. The words are args, for cli::args — the most-refused callee in the corpus, ten adjacent pairs, refused because nobody had written a line — and stderr, which arrived with §8.3 and had never been written down as a kind. The question is what the compiler does with a word it does not know, and the answer it had was the worst possible one. Two touches of different kinds never conflict, so a kind nobody knew was disjoint from every kind there was: stdoutt write in a hand-maintained ledger would have bought an overlap rather than failed to buy one. The kinds are a closed list now, and a word outside it is answered the way D4 answers everything it cannot read.

Which asked the same question of the words that are known, and one pair answered badly. stdout and stderr are two handles and one destination the moment anybody types 2>&1, so println / eprintln / println was a group of three whose output interleaved differently on every run of a redirected program — D1 broken by a resource whose identity nobody had checked, which is §8.3’s catch hole in a different disguise. They are compared as one destination now. It costs nothing measurable (§8.4: console writes are microsecond work, and the overlap cannot pay for those whatever carries it) and it buys back the guarantee. seq is what a program writes where the permissive reading would have been wanted.

Not built: a method call, whose ledger entry depends on the type of its receiver and therefore on the type checker (ADR-028’s own mechanism); an argument that is not a literal; a let with a written type; a call with options. Nothing infers touches from a Nikaia body yet either, so a program’s own functions are all D4’s “reaches everything”.

And three entries deliberately not written. D2’s table names endpoint(url) for net::post and a lock for counter.access fn { … }, and §3 rests Part II 12.2’s rule on the second. Neither function exists, so neither kind does: ADR-028 D5’s rule is that an entry exists because a program asked for it, and a vocabulary word with no entry behind it is the speculation that rule forbids. Part II 12.2 now carries a Status note saying that an access block is therefore ordered by ignorance rather than by contract — the right answer for the wrong reason, which stops being the right answer as soon as anything else is in the pair.

The third is the near miss, and it is worth naming because the next person will reach for it. Standard input is not in the vocabulary either, although io::read, io::lines and io::read_to_string are all in std’s ledger with everything but a touch set — the same shape println was in before §8.3. Nothing in the corpus asks: the two programs that read a pipe do it inside a for or behind a handler that returns, so no pair ever reaches the question. When one does, the entry is stdin write and not stdin read. write means it changes the resource, and reading a stream consumes it — two io::read_to_string() calls do not both get the bytes — so the read/read rule that lets two file reads overlap would be exactly wrong here. The note is in contracts::touch beside the list, because that is where somebody adding it will be standing.

The same reasoning keeps out std’s own mutating and higher-order methods: Vec::push reaches no resource, and a touches = [] on it would rest on the literal-argument restriction holding the line, because this analysis finds a data dependency by comparing what a statement binds against what the next one mentions and xs.push(a) twice binds nothing and mentions xs in both. Memory is the borrow checker’s question (D3) and this analysis does not have its answer, so those functions say nothing and keep their order.

The fourth increment: the second vehicle (D10). A pair of std file reads lowers onto nikaia_std::task::read_pair — two operations the kernel performs with no thread carrying anything the user wrote — so ordering = "effects" means something at user_parallelism = no for the first time, and a pair of reads gets this vehicle at yes too because it is measurably the cheaper one. The two questions Build::overlaps_user_code was answering at once are now two methods with two names, which is the whole of the change in the emitter; contracts::order gained the program half of it — which vehicle a pair would need — and still sees no switch.

Three things building it settled, and the third is the one to read:

Not built with it: two writes in flight, which would need a std function and a number of its own; a pair where either half is a method call, for §8.1’s standing reason; and a path that is not a literal, which is the same refusal the rest of the analysis makes.


7. On accepting this without a measurement

A reader who expects a number before believing a change is right to expect one, and twice on this project a measurement has overturned an intuition that looked obvious. There is none here, and the reason is not that it was skipped.

The expectation belongs to optimisations — choices between two implementations of the same meaning, where measurement is the only honest arbiter. This is a semantic decision: what a program means when two lines stand next to each other. There is no benchmark that decides that, and the programs that would exercise it do not exist yet, because the language cannot yet express them.

What is owed instead, and recorded here as a debt:

  1. The measurement happens once there is an application large enough to carry it — how much unconditional, independent I/O real programs actually contain. If the answer is “almost none, real programs are chains”, this decision was wrong and ordering = "strict" becomes the default rather than the escape.
  2. Until then the status line at the top of this file stays as it is. Provisional and unmeasured is what it is, and a later reader should not have to reconstruct that.

8. The first measurement, and what it says

Two questions were put to the built increment: did anything in this repository actually become parallel, and did anything get faster. Both have answers now, and neither is the hoped-for one.

8.1 Statically: nothing in the corpus overlaps

--overlaps over every .nika in examples/ weighs 127 adjacent statement pairs and refuses all 127. The emitted Rust is byte-identical under --ordering effects and --ordering strict for all ten files the bootstrap compiler can lower. Not one std::thread::scope is produced. The reasons, which are the interesting part:

pairs why the pair was refused
101 one of them is not a let of a single call
12 the catch can leave the function, so the next statement might never have run
14 nothing says what the callee reaches, so it reaches everything

The dominant reason is not “real programs are chains”. It is that the analysis only looks at a shape almost nothing is written in — a let bound to exactly one call. total = a + b, out.push(x), an if, a loop: all of them fall out before any touches set is consulted. The second group is ADR-034’s conditional-handler rule. Only the third group, 14 pairs, is the ledger’s fail-closed polarity (ADR-033 D4) doing its job on functions whose reach nobody has written down.

So the corpus does not say the decision was wrong. It says the analysis is far narrower than the decision, and the narrowness — not the shape of real code — is what produces the zero.

Measured again after §6’s second increment. The same 127 pairs, the same zero, and the 101 are gone — every refusal is now about the program rather than about the analysis not looking:

pairs why the pair was refused was
30 a method call, whose ledger entry depends on the type of its receiver —
29 one of them performs no operation at all: a loop, an if, an assignment, a let of a constant —
28 nothing says what the callee reaches, so it reaches everything (D4) 14
13 the catch can leave the function (ADR-034) 12
10 a ??, whose fallback runs only sometimes —
9 something with its own control flow — a block, an if, a match —
6 the second statement is what the function hands back, and a pair hands back a tuple —
2 a let with a written type —
— one of them is not a let of a single call 101

The rows are not a partition of the old ones: a pair prints one reason, the earlier statement’s, so widening moves pairs between rows as well as out of the first. The reading that matters is the third row. D4’s fail-closed polarity doubled, from 14 pairs to 28, because the shapes were hiding it — and the first two rows say where the rest of it is hiding still: xs.len() is a method call, and which ledger entry that is takes the type checker (ADR-028’s mechanism, not a gap in this one).

So the zero is no longer a measurement of the analysis. It is a corpus of microsecond work written in method calls and chains — and §8.4 is the other half of the answer: the overlap could not have paid for any of it even if every pair had been accounted for. §7’s debt is still owed by a program that does not exist here.

Measured again after §6’s third increment — and the table above was already stale. The catch-handler fix of §8.3 changed which reason a pair prints without anyone re-running the count: a method call stopped being its own row and became “nothing says what the callee reaches”, and a ?? over no call at all became “performs no operation”. The correction belongs here as much as the new number does, so both rows are given. The same 127 pairs, and still zero overlaps:

pairs why the pair was refused before this increment in the table above
57 one of them performs no operation at all 50 29
32 nothing says what the callee reaches, so it reaches everything (D4) 39 28 + 30
13 the catch can leave the function (ADR-034) 13 13
9 something with its own control flow 9 9
8 text with code in it, whose holes this has not parsed 8 —
6 the second statement is what the function hands back 6 6
2 a let with a written type 2 2

The seven pairs that left D4’s row are the ledger doing what D4’s closing sentence says it will: cli::args was the most-refused callee in the corpus at ten pairs, and String::new, Vec::new and html::escape were three more — all of them refused because nobody had written a line. With the lines written, the refusal moves inward to the method call beside them (cli::args().nth(1) is a nth this compiler cannot resolve) or to the other statement in the pair. Which is the third row of §8.3 restated from the other end: what is left to widen is a method call, and that takes the type checker.

The zero is still a zero, and the vocabulary did not move it. Nothing in examples/ overlaps, the emitted Rust is byte-identical under both orderings for every file the bootstrap compiler can lower, and §8.4’s reason still holds: this corpus does microsecond work. What the increment bought is not a faster corpus. It is a group of three where there was a pair and a leftover, a construct for the order a compiler cannot see, and two fail-open holes closed — a vocabulary whose typos bought overlaps, and two console handles the analysis believed were different files.

Measured again after D10, and the corpus does not move at all. The same 127 pairs, the same seven reasons, the same counts — and now the same counts at both settings of user_parallelism, which is the whole of what the table has to say:

pairs why the pair was refused after D10, at no after D10, at yes in the table above
57 one of them performs no operation at all 57 57 57
32 nothing says what the callee reaches (D4) 32 32 32
13 the catch can leave the function (ADR-034) 13 13 13
9 something with its own control flow 9 9 9
8 text with code in it, whose holes this has not parsed 8 8 8
6 the second statement is what the function hands back 6 6 6
2 a let with a written type 2 2 2
0 together — and 0 would 0 0 0

A table of nothing changing is worth printing twice over here. It says the new vehicle bought this corpus no overlap it did not have, because no pair in these eleven files is a program-level overlap at all — the zero was never the build’s doing, which is exactly what §8.2b’s degradation made impossible to see from the report. It also says the new would mark costs nothing where there is nothing to mark.

The one pair in the repository that moves is not in these eleven files. It is the control of examples/foreign-runtime/overlaps.nika, two reads of two files, which ADR-038 D7’s experiment put there precisely so that “everything is in order” could not pass for a disabled analysis. Before D10 at user_parallelism = no the report printed together under a note saying nothing below overlaps, and nothing did. After D10 it prints together and the emitted Rust contains the completion pair — at no and at yes, with no task::both in either. That is the smallest possible demonstration and it is asserted on every cargo test (a_call_into_a_foreign_crate_orders_against_everything).

So §7’s debt is unchanged and §8.4’s reason is unchanged: the first program that could justify ADR-033 is still one this language cannot write yet. What D10 changes is which build of that program will be able to show it — the default one, rather than one that had to ask.

8.2 Dynamically: the lowering is a pessimisation below ~256 KB

benches/overlap/ builds the two shapes the emitter produces for the same two reads and times them against each other (4 cores, files warm in the page cache):

bytes per file strict effects speedup
5 76 ms 2089 ms 0.04×
64 KiB 280 ms 2115 ms 0.13×
256 KiB 338 ms 290 ms 1.17×
512 KiB 778 ms 367 ms 2.12×
1 MiB 1685 ms 674 ms 2.50×
8 MiB 2253 ms 334 ms 6.75×

Two spawns and two joins cost about 100 µs per overlapped pair, paid unconditionally. The crossover sits near 256 KB of payload per operation. Below it the default ordering makes the program up to twenty-five times slower than writing the two lines in sequence; above it the win is real and superlinear in the two threads, because read_to_string validates UTF-8 and allocates, and both of those parallelise along with the copy.

This is the first hard evidence against effects as the unconditional default. The goal these decisions serve is a language that takes the fastest safe path without being asked; a fixed 100 µs tax on a pair of operations that may each take a microsecond is the opposite of that.

8.2b The overlap was running user code on two threads at user_parallelism = no

Found by asking what --user-parallelism no --ordering effects emits: std::thread::scope, ten lines above the Parallelism::Off the same file hands the parser. Part I 1.2 promises that at no nothing you wrote ever runs concurrently, and the two spawned closures are code you wrote. rayon::join is no better on wasm32-unknown, where it does not link at all (Part III 15.3).

The cause is a category error in D2’s own framing, and worth naming because it will recur. Whether two operations may overlap is a question about the program; whether a vehicle exists to overlap them with is a question about the build. The analysis answers the first and cannot answer the second, and overlap_at was consulting only ordering.

Fixed: at user_parallelism = no, effects degrades to strict — the same degradation par_fold already makes to a sequential fold (ADR-009). no_user_parallelism_never_spawns_a_thread pins it, and asserts that yes still overlaps the same program so it cannot pass for the wrong reason. --overlaps says which setting makes its report hypothetical, rather than reading as a promise the emitter is not keeping.

(D10 narrows this section. The category distinction above is what D10 rests on and is unchanged. The *degradation is not: once a vehicle exists that carries no user code — two reads the kernel performs, ADR-038 D3 — no has one, and what degrades there is task::both rather than ordering. “--overlaps says which setting” became a half-truth at the same moment, because some pairs then overlap at no and others do not; it says which per pair now. §8.5 below predicted exactly this and called it a prediction.)*

The predicate is user_parallelism, and deliberately not “does this build have threads”. It has a second thread at no too, and always did: Part I 8.4’s Runtime Sidecar offloads blocking I/O to a background thread on native (a Web Worker on WASM) so the event loop never stalls, and it is safe precisely because user code never runs there — the exchange is ownership-transferring message passing, so there is nothing to race over. The sidecar is therefore untouched by this switch, which matters far beyond a renamed predicate — see §8.5.

8.3 What this changes

Nothing yet, deliberately — §7’s debt was a measurement on real programs, and §8.1 says this corpus is still not one. But the debt is now sharper, and splits in two:

  1. The analysis has to see more shapes before its silence means anything. A zero produced by 101 refusals of the form “not a let of a single call” measures the analysis, not the code. Widening it — expression statements, a let whose initialiser is not a bare call — comes before any conclusion about whether effects earns its place.

    Done — §6’s second increment, and §8.1’s second table is the result. The zero survived it, and it now has eight named reasons instead of one that was this analysis’s own narrowness. What is left to widen is a method call, which needs the type checker, and an argument that is not a literal, which needed a decision about what may cross a thread - ADR-045 has since taken it, per value and destination, so what remains there is the widening and not a ruling.

    And done again — §6’s third increment closed the rest of §6’s own list and moved seven more pairs out of D4’s row by writing four ledger lines. §8.1’s third table is the result, and it corrects the second one, which had gone stale when the catch fix below changed which reason a pair prints. The zero survived that too.

  2. The vehicle has to beat 100 µs. §8.4 tried, and settles it: it cannot. The floor is not thread creation, it is thread wake-up, and no user-space vehicle removes it. What is left is a size or cost estimate in the ledger, or strict as the default with overlap as the opt-in — and neither should be chosen before 8.1’s first item is done.

And one hole widening the shapes turned up, now closed. The analysis looked past a catch at the call it guards, so a handler’s own effects were not in the statement’s touch set: a handler that wrote a file the statement next to it read overlapped with it and raced — D1’s guarantee broken by an effect nobody counted. A handler is code that runs, so what it reaches is part of what the statement touches, and where it cannot be read D4 answers as it answers everywhere else.

Closing it exposed the real gap, which was in the ledger rather than in the analysis. Refusing every handler whose effects cannot be read would have retired the increment, because catch { "".to_string() } is a method call — and which ledger entry a method call is needs the type checker (ADR-028). But a weaker question does not: if every ::to_string in the ledger reaches nothing, then "".to_string() reaches nothing whatever its receiver is. An over-approximation over the candidates, in D4’s own direction, and it costs no type checker.

What the ledger was missing is more embarrassing than that: println had no entry at all. D2’s table names it — println(x) touches stdout write — and D6 rests on it, since two printlns stay ordered because both touch stdout rather than by a special case. Unentered, they were ordered by ignorance instead, which is the right answer for the wrong reason and stops being the right answer as soon as anything else is in the pair. print, println, eprint, eprintln and str::to_string are entered now. That is D4’s closing sentence working as designed: std.contracts growing is the mechanism by which programs get faster.

And entering them left a second hole one level down, found by §6’s third increment and closed there: stdout and stderr were entered as two resources, which they are — and they are one destination under 2>&1, which no part of the analysis was asking. §6 has the account.

Until then --ordering strict remains the escape, benches/overlap/run.sh remains the number to beat, and the status line at the top of this file still says provisional.


8.4 The vehicle: task::both, and why changing it was not the fix

§8.3 asked for a vehicle that beats 100 µs. A build at user_parallelism = yes already links rayon — fs::map chunks its UTF-8 check across the pool and the parallel piece driver runs on it, “so a program has one” — so rayon::join cost nothing to try.

It is now what the emitter writes, through one std function rather than inline:

let (a, b) = task::both(
    || …,
    || …,
);

Three reasons, in the order they matter:

  1. Bounded concurrency. std::thread::scope asks the OS for two fresh threads per pair. A handler that overlaps two reads and serves a thousand requests at once asks for two thousand threads, and nothing in the lowering bounds that. The pool’s ceiling is the pool’s, and a program keeps one answer to “how parallel am I” instead of two.
  2. The emitter names one function. Which vehicle carries an overlap is nikaia_std::task’s decision now, not a scope-two-spawns-two-joins shape spelled into every program that uses it. The next change of vehicle is a std change.
  3. It is cheaper, and this is the smallest reason. The fixed cost of a pair with nothing in it: 90 µs for thread::scope, 46 µs for rayon::join (--bin fixed, 4 vCPU container; bare metal will be kinder to both).

And it does not move the table. Over the same two reads, join and scope measure the same within noise at every size — 0.03× at 5 bytes, crossover still near 256 KB. The halved fixed cost does not show up, because with blocking I/O the first closure blocks and the second has to be stolen, which means waking a sleeping worker. That is the same futex wake spawn pays.

So the floor is thread wake-up latency, not thread creation, and that is an OS number. No user-space vehicle removes it. The overlap can only ever pay for operations in the millisecond range — a network round-trip, a database query, a file that does not fit in the page cache — and it will lose on anything smaller no matter what carries it.

Which points back at §8.1 from the other side: the reason nothing in this repository benefits is not only that the analysis is narrow. It is that every program here does microsecond work. The first program that could justify ADR-033 is the one this language cannot write yet.


8.5 The consequence: ADR-033 may be worth more at no than at yes

Put §8.4’s floor next to §8.2b’s sidecar and the conclusion inverts.

At user_parallelism = yes the overlap is two user closures on two workers. That costs a thread wake-up per pair — 46 µs, and no user-space vehicle removes it — so it only pays above roughly a quarter megabyte of payload.

At no that is not available and must not be. But ADR-033 never asked for two threads; it asked that two operations which meet on nothing not wait for each other. Two reads handed to the sidecar, both in flight, both results collected on the main thread in the order they were written, is exactly that — and it is what an event loop is for. Concurrency without parallelism.

And it carries no per-pair wake-up at all. The sidecar thread is already running; two overlapped reads are two messages, not two thread starts. The 46 µs floor is an artefact of the vehicle yes uses, not of the decision.

So the shape of the answer is the opposite of what §8.4 implied on its own:

user_parallelism vehicle fixed cost per pair pays off from
yes rayon::join on the pool ~46 µs ~256 KB of payload
no sidecar, two in flight none in principle any pausing operation

This is a prediction, not a measurement — there is no event loop, no sidecar and no async lowering yet, so there is nothing to time. It is written down because it changes what to build next: the case for ADR-033 is not waiting on a faster thread vehicle, which §8.4 ruled out. It is waiting on the runtime no deserves, and on an operation that pauses for milliseconds rather than microseconds. Both point at the same missing piece, which is why §8.4’s closing sentence and this one name it together.

8.5.1 The prediction has an answer, and it is right for a narrower reason

All three of the missing pieces arrived: ADR-038 D4 starts the runtime before main, D3 gives files a completion path, and D10 above is the lowering. So the table can be filled in with numbers instead of principles. Measured end to end — a .nika program at user_parallelism = no, lowered twice from one source, 200 000 pairs a run, nine repeats (docs/history/runtime-cost.md §6):

user_parallelism vehicle fixed cost per pair predicted
yes rayon::join on the pool +112 µs (sd 2.1) ~46 µs
either completion pair, two in flight −0.87 µs (sd 0.14) none in principle
either blocking fallback, pair refused (D10) −0.05 µs (sd 0.10) —

The prediction holds and the word “sidecar” was wrong. It is right that ADR-033 never asked for two threads and that two operations in flight carry no wake-up — and the reason is not that the thread is already running, which is what this section said. ADR-038 §4.3 found that out first: a pre-started thread still costs ~38 µs to wake, so a pair on the fallback pays the same tax a pair on the pool pays. What makes a pair free is that io_uring has no thread in it at all. “The runtime no deserves” was the right thing to wait for; the sentence explaining why it would be free named the wrong half of it.

And the inversion is real: ADR-033 is worth more at no than it was at yes. Not because no is privileged — the same vehicle carries a pair of reads at yes now, and D10 gives it to both — but because the vehicle that had to be refused at no turns out to be the expensive one and the one that is free needed no permission. The default build is the one that gets the free overlap; the yes build got the 112 µs one for a year.

What is still owed is §7’s debt, untouched: an operation that pauses for milliseconds. A pair of page-cached 5-byte reads saves 0.87 µs a pair, which is real, measured, and nobody’s reason for writing a language. The program that makes this matter is ADR-038 §4.5’s HTTP server.

This site is open source. Improve this page.