◆ Flux

The optimizer

Flux’s optimizer is aggressive, and nobody has to trust it. Those two facts are the same fact. This page specifies why: the correctness law every rewrite is checked against, the five obligations of the rule charter, the total pass engine that runs them, the tiers that ship and the one that was rejected, and the ABI and provenance guards that keep a saved chart and a reproducible build intact across optimization.

The reason a compiler’s optimizer is usually a source of anxiety is that its correctness is argued, not checked: a rewrite looks sound, it ships, and three years later someone finds the input for which it was not. Flux takes the other road. The reference semantics of a program is the canonical evaluation of its unoptimized graph, and every compilation checks the optimized module against it, bit for bit, on hostile data. An optimizer that cannot be trusted is fine — what matters is that a miscompilation cannot ship.

New here? Start with Guide §11 — Determinism, replay and trust → — the accessible retelling of the byte-exact property this page’s gate enforces.

The law

Reference semantics = the canonical evaluation of the unoptimized graph. At every compilation, the gate runs that oracle against the emitted module of the optimized graph — in batch, then bar by bar through the live path, over a mixed-seed hostile corpus and, where it is available, over real data — and demands bit-exact equality on every sink column.

One comparison covers the optimizer and the code emitter, end to end. It is the same gate that enforces interpreter ≡ WASM (compiler and runtime), doing double duty: the oracle it compares against is the unoptimized evaluation, so a rewrite that changes a value by one bit fails the same check that a bad instruction selection would. The gate’s own machinery — the hostile corpus, the mixed-seed harness, the reproducible-build check — is specified in verification; this page describes only how that gate is pointed at the optimizer.

When the optimizer changes nothing, the gate costs nothing — an identity fast path skips it entirely. When it fires and passes, the cost is the oracle run, which was already being paid.

When a rewrite is wrong

If the optimized attempt diverges, the compile retries with the unoptimized graph and serves that, carrying a diagnostic. Two consequences, and both are deliberate:

The honest coverage bound

The gate proves equality over the corpus’s value coverage (its hostile zones: na, ±infinity, negative zero, ties, extreme magnitudes, subnormals, near-overflow) and over knob periods up to a sweep ceiling — a deliberate anti-abuse trade-off, since sweeping every period of every knob on every compile would be a denial-of-service on the compiler itself.

So a rule whose divergence only manifests at an effective period beyond that ceiling would pass the per-compile gate. That is not a gap we paper over: it is the reason the rule charter carries an explicit proof obligation for exactly that class of rule (obligation 5, below). Saying “the gate proves everything” would be more comfortable and less true.

The rule charter

Every rewrite rule must satisfy all five, and must document its argument for each:

  1. Bit-exact in IEEE-754 f64 for every input — including the na paths, signed zeros and infinities. No floating-point reassociation, ever.
  2. Purity and order. Rules rewire pure dataflow. A stateful node (a delay, a crossing, a kernel) may be shared or replaced only when the replacement provably produces the identical state trajectory — same dependencies, same parameters.
  3. Determinism. No data-dependent and no environment-dependent decision. First match by table order. Every rewrite goes through the hash-consing rebuilder, so the same graph always rebuilds the same way.
  4. The ABI is untouchable. A rule may never eliminate, merge, retype or reorder an input node — the parameter block is a contract with the host, and an optimizer that “helpfully” dropped an unused knob would break every saved chart.
  5. Period-scaling proof. Any rule that touches a kernel, a delay or shared state ships with a dedicated test at the real maximum period, because the per-compile gate only proves periods up to the sweep ceiling. Element-wise peepholes are period-independent by construction and are exempt.

The rules that look sound and are not

This table is the most useful thing on this page. Every one of these rewrites appears in textbooks; every one of them is wrong in IEEE-754, and Flux rejects all of them:

Tempting rewrite The input that kills it
x + 0 → x x = −0. Then −0 + 0 = +0, which is not −0.
0 − x → neg(x) x = +0. Then 0 − 0 = +0, while neg(+0) = −0.
select(c, x, x) → x c = na. The result is na, not x.
x − x → 0 x = na or ±∞. The result is na.
x * 0 → 0 x = na or ±∞ ⇒ na; and x = −1 ⇒ −0, not +0.
(a + b) + c → a + (b + c) Reassociation changes the rounding. Forbidden outright.

And the ones that are sound, each with its witness:

x * 1 → x · 1 * x → x · x / 1 → x (multiplication and division by exactly 1.0 are exact) · x + (−0) → x (because +0 + −0 = +0 and −0 + −0 = −0) · neg(neg(x)) → x (flipping a sign bit twice is the identity) · x + x → 2 * x (the same rounded operation).

Which algebraic rewrites the optimizer may and may not perform under IEEE-754 Figure — one law judges every rewrite: legal only if the f64 output is bit-for-bit unchanged on every input, na, signed zeros and infinities included.

Why negative zero deserves this much respect. It is not a curiosity. A value of −0 arises constantly in real data (a difference that rounds to zero from below), it compares equal to +0, and it prints as 0 — so a rewrite that turns one into the other looks correct in every test a human writes. It is only visible to a byte-level oracle, which is precisely why the byte-level oracle exists.

The pass engine

The rules are the interesting part; the engine that runs them is the part that must never be interesting. Four invariants hold it flat.

Every pass rebuilds, and the rebuilder hash-conses. A pass does not mutate the graph in place — it reconstructs it in topological order through the same structural key the lowering uses (one key function, one source). Two consequences fall out for free. Cascading common-subexpression elimination after a rewrite costs nothing: if a rule makes two sub-graphs identical, the rebuild is the merge. And a graph that arrived un-eliminated — a forged one, or one built by hand — is normalized on the way through. The single exception is the input node, which is never hash-consed: two knobs with the same default are two knobs, and collapsing them would silently merge two settings a user can move independently.

Renumbering is monotone. The relative order of the surviving nodes is preserved through the dead-code sweep. That is not cosmetic. The parameter block’s cells are laid out in input-node id order, so a pass that permuted ids would move a knob’s cell underneath a host that had already bound to it. Monotone renumbering is what keeps the knob cells stable across optimization.

Termination is bounded, and failure is the identity. Passes repeat to a fixpoint — zero rule hits and zero structural compaction — under a ceiling of eight passes. The ceiling is generous by a wide margin: the deepest rewrite cascade the rule table can produce is three deep. And the engine is total. A rule that throws, a rewrite that produces an invalid node id, a post-condition that fails — any of them returns the input graph, unchanged, flagged, and the compile serves the unoptimized path. The optimizer has no failure mode that is not “the optimizer did nothing”.

The post-conditions are re-checked, not assumed. After the last pass, the shape validator runs again on the optimized graph, and the memory plan (A13) is re-derived from it rather than carried over — a pass that changed the graph changed the liveness, and a stale plan would be a buffer-sharing bug that no value oracle could see. A remap records where each original node landed; an id absent from it is a node the optimizer proved dead.

Why the engine is total rather than correct. These two paragraphs describe an engine that is allowed to fail, at any point, for any reason — and whose failure is indistinguishable from having done nothing. That is a deliberate inversion. We do not attempt to prove the pass engine right; we make it structurally incapable of shipping a graph it is not sure about, and we point the byte-level gate at whatever it does produce. Correctness by verification, totality by construction.

The tiers

T0–T1 — bit-safe, and shipped:

Pass What it does
native kernel dispatch a leaf becomes the native kernel — the mechanism of byte-identity itself
global common-subexpression elimination the big one: identical sub-expressions are computed once
dead-code elimination a value nobody reads is never computed
constant folding const-folded literals collapse
order-preserving fusion an element-wise chain becomes one pass
recursive / windowed / batch selection choose the cheapest evaluation form — the one the native kernel already uses
buffer sharing the liveness plan: disjoint lifetimes share a slot (memory model)
zero-allocation hot loop every buffer is allocated once, from the graph
live O(1) causality makes an incremental step cheap
elimination across scripts the co-active scripts are merged into one graph, so a sub-expression they share is computed once for all of them

Elimination across scripts — the pass that actually pays

The last row of that table deserves its own section, because it is where the redundancy in a real chart lives. A chart does not run one script. It runs the handful you have open, and they overlap: two indicators both want ema(close, 200); a strategy and its filter both want the same sma.

So the co-active scripts are merged into one graph and optimized together. Elimination then crosses the script boundary without knowing there was one: an ema(close, 200) in two scripts is one node, computed once. State collapses with it — an sma in one script and a sum of the same source and period in another end up sharing a single ring buffer rather than two.

Three rules keep that sound, and each closes a specific way it could have gone wrong:

The set is bounded — at most sixteen co-active scripts, each within the ordinary node budget — because past that point the aggregate frame budget and its degradation policy (concurrency) are the right instrument, not a bigger merge.

Two honest boundaries. A closed pack ships a module and no graph, so there is nothing to merge it into: it is excluded by construction rather than by policy. And the merge applies to the batch path; the bar-by-bar live path advances each script’s own module, so a co-active set shares its compilation and its state, not its live step.

The canonicalization that looks like a pessimization. sma(x, p) is rewritten to sum(x, p) ÷ p — unconditionally, and not because a division is cheaper. It is a normalization: it makes an sma and a sum over the same source and period the same node, which is what lets two scripts share one ring. The rewrite carries a domain guard (the period must be a constant, or a knob with declared bounds), because outside that domain the two forms clamp differently — obligation 2 of the charter, discharged by restricting the rule rather than by hoping.

T2 — the aggressive tier, and what became of it

The premise was an unusual one: Flux scripts are short. A graph of a few dozen nodes fits inside a sub-16-millisecond budget even under passes that are normally infeasible — so the target could be the optimum rather than “good enough”.

Two of those passes remain designed and deferred. The third was investigated and rejected, and the rejection is worth more than the pass would have been.

Later optimizer tiers: optimal scheduling and fusion — an exhaustive search, tractable at this size — and specialization and partial evaluation from constants and kind bounds.

Equality saturation: no. The pass is the classic answer to “apply all rewrites at once”: build an e-graph, union every equivalent term into it, then extract the cheapest member under a cost model. It is provably equivalent, and on the right rule table it is genuinely stronger than a fixpoint. On this rule table it is stronger than nothing, and the case rests on two independent legs:

The verdict is a test, not a paragraph. The orthogonality that leg one rests on is a condition, and conditions rot. So it is asserted permanently, in the suite: a probe over the grammar corpus that fails the moment a future rule overlaps an existing one; an empirical confluence check that drives the rules in adversarial seeded orders and demands they all land on the same node count and cost; and a bound on the rewrite cascade depth. A failure of the first probe does not just fail a test — it invalidates this decision, and reopens the pass.

The named reopen conditions, in the order they are likely to arrive: a rule whose left-hand side overlaps another’s; a tolerance mode (@fast, below), which admits the generative classes and with them the search space saturation was built for; a rule whose two forms genuinely emit differently, collapsing the second leg; or a divergence in the confluence check. Any one of them, and the pass comes back — with cost-driven extraction, and with the tie-break below.

What a reopening would have to ship on day one. When two extractions have equal cost, the tie must be broken by the pinned lexical node identity — the same identity that anchors the memory plan and the random generator’s draw index. Otherwise two extractions of equal cost would emit different bytes, and the value oracle — which compares outputs, not layouts — would never notice. It is written down here so that it is a prerequisite rather than a discovery.

T3 — opt-in, never the default: @fast relaxes floating-point (reassociation, fused multiply-add). It is faster and it is not bit-exact, so its goldens would carry a tolerance. The default stays deterministic, because a deterministic language’s value is its no-repaint, its replay and its goldens — and all three are byte-level properties. It waits on a bench case that shows the relaxation is worth what it costs; the audit so far puts scalar f64 within a small factor of the relaxed form, which is not a case.

The rules that ship, in the WebAssembly they change

The tiers name the passes; here they are from the other side — each rule as it exists in the compiler, and, for two of them, the WebAssembly before and after. The WAT is hand-written for reading (series are shown as locals; the emitter loads them from memory columns), but the shapes are the ones it produces. Every rule carries its written IEEE argument — the charter’s first obligation — and the element-wise ones are period-independent, so the period-scaling proof does not apply to them.

The peepholes. Local rewrites, each exact for every input:

The passes with no rule table — they are the machine.

Strength reduction, ÷2 → ×0.5. The average of the bar’s high and low —

FLUX
plot (high + low) / 2

— lowers to a divide, then becomes a multiply by the exact reciprocal:

;; before — the division as written
local.get $high
local.get $low
f64.add
f64.const 2
f64.div

;; after — same bits, cheaper instruction
local.get $high
local.get $low
f64.add
f64.const 0.5
f64.mul

The two forms are bit-for-bit equal because 2 and 0.5 are both exact, so each rounds the same real number once. That equality is not argued: at every compilation the gate re-runs the unoptimized graph on the interpreter and compares. (The interpreter and the module are the FVM’s two conforming implementations, bound by I7.)

Common-subexpression elimination, one node from two. Feed the high-low range into two plots —

FLUX
plot (high - low) * 2
plot (high - low) + close

— and the naive graph would compute the subtraction twice; the rebuilder emits it once, into a slot, and both readers take it from there:

;; before — the range, recomputed
local.get $high
local.get $low
f64.sub
f64.const 2
f64.mul
local.get $high
local.get $low
f64.sub            ;; the same work, again
local.get $close
f64.add

;; after — computed once, reused
local.get $high
local.get $low
f64.sub
local.tee $hl      ;; keep the range in a slot
f64.const 2
f64.mul
local.get $hl      ;; reuse it — no second subtract
local.get $close
f64.add

The same machine, run across the scripts you have open, is what turns a shared ema(close, 200) in two indicators into one kernel and one ring — the cross-script merge above, where the redundancy in a real chart actually lives.

The table stays short on purpose. A rewrite being sound is necessary, not sufficient — it also has to earn its slot. x + x → 2·x is sound (the same rounded add), but it trades an add for an add plus a constant, so it buys nothing; x + (−0) → x is sound too, but the pattern does not arise in real graphs. Both are left out on cost, not on doubt — the same restraint that keeps the rule set orthogonal and the aggressive tier closed.

The ABI is a contract — and so is the provenance

Charter obligation 4 says a rule may never touch an input node. This section is what that obligation buys, and what enforces it when a rule author forgets.

The guard is mechanical. After optimization, the image of the input nodes must be total, injective and still input-typed — every knob still there, no two collapsed into one, none retyped. A rule that violates any of the three does not produce a diagnostic and continue: the whole optimization is discarded as the identity, and the compile serves the unoptimized graph. Lifting that guard is not a rule change; it would be a decision to version the parameter schema.

Public surfaces speak the unoptimized graph’s ids. The optimizer renumbers, but nobody outside it ever sees those numbers. Knob descriptors and parameter cells are rewritten back to the original ids on the way out, through a back-map the injectivity guard is precisely what makes well-defined. A host that saved a chart against knob 3 finds knob 3 where it left it, whatever the optimizer did in between.

Presentation and manifest derive from the unoptimized graph — on both sides. A package’s declared presentation (panes, scales, reference lines, series names) and its capability manifest are derived from the O0 graph, at build time and at verification time. The optimizer affects the module’s bytes and nothing else; no optimized graph is ever serialized or shipped. This is what keeps the two derivations comparable: a verifier that re-derived presentation from an optimized graph would be comparing against a graph the author never wrote.

Provenance holds by construction, not by promise. Build and verify go through the same compile entry point, hence the same optimizer, hence the same bytes — which is why a rebuild can be checked at all. Two consequences follow, and both are sharp:

The toolchain is part of the identity. The compiler version is stamped into the package manifest, and it is also one component of the single canonical key under which a compiled artifact is cached — alongside the pinned Binaryen version, the pinned maths library, the emitted WebAssembly feature set, and the program itself. One key, one source. From the first published artifact onward, any change to the rule table or the engine that alters emitted bytes must move that version: a cached module compiled under a different rule table is a module that was gated against a program the compiler no longer produces.

Why provenance is not correctness. It is tempting to read “the shipped bytes are the toolchain’s compilation of this source” as “the shipped bytes are correct”. It is not. Provenance guarantees the two sides ran the same compiler; it says nothing about the periods that compiler never swept (the honest coverage bound). Both sides carry the same bound. Conflating the two would be the most comfortable mistake on this page.

The honest ceiling

The kernels stay native. So the optimizer works at the graph level — redundancy, scheduling, specialization — and never inside a kernel’s arithmetic. Floating-point reassociation is forbidden by default. Therefore:

Claiming a speedup on the simple case would be marketing. The optimizer’s real job is that the complicated case does not cost what it looks like it costs.

The cost model

The cost of a node is not guessed: micro-benchmarks measure it, and the measurements calibrate the table. The same model is shared by the optimizer and the scheduler, so that “is this node worth a worker?” and “is this rewrite worth it?” are answered from one set of numbers rather than two sets of opinions.

The editor shows you the result: a cost gutter on the optimized graph, so what you read is what you pay.

See also