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 product is silently correct. A user never sees a wrong number; at worst they get the unoptimized path, which is the path the value was defined by anyway.
- The test suite treats any such diagnostic as a failure. It is loud in continuous integration and invisible in the product — which is exactly the right way round.
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:
- Bit-exact in IEEE-754
f64for every input — including thenapaths, signed zeros and infinities. No floating-point reassociation, ever. - 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.
- 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.
- The ABI is untouchable. A rule may never eliminate, merge, retype or reorder an
inputnode — the parameter block is a contract with the host, and an optimizer that “helpfully” dropped an unused knob would break every saved chart. - 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).
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
−0arises constantly in real data (a difference that rounds to zero from below), it compares equal to+0, and it prints as0— 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 host declares the set. Which scripts are co-active is an explicit declaration, not an inference from what happens to be alive. The set has a canonical key (its members, sorted), so the order they were opened in is unobservable and the same set reuses the same module.
- Knobs never merge.
inputnodes are excluded from elimination across the boundary, so two scripts that both expose a “period” knob keep two independent settings. The graph is shared; the parameter block is not. - The oracle stays per script. The gate does not compare an evaluation of the merged graph. It compares each script’s own unoptimized evaluation against that script’s slice of the merged module’s sinks — batch, live, mixed-seed, bit-exact. That is the stronger check, and it is the cheaper one: a divergence names the guilty script instead of pointing at a graph nobody wrote.
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 tosum(x, p) ÷ p— unconditionally, and not because a division is cheaper. It is a normalization: it makes ansmaand asumover 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 search space is already exhausted. The shipped rules are orthogonal — their left-hand sides are structurally disjoint and left-linear — which makes the rewrite system confluent, and a confluent system’s direct fixpoint reaches the unique normal form. There is no better form for an e-graph to find. This is not an accident of the current table: it is forced by the correctness law. The rewrite classes where saturation actually shines — reassociation, commutativity, distribution — are exactly the classes the bit-exactness law forbids, because they change IEEE rounding. A rule table that may not be generative is a rule table an e-graph cannot help.
- The one place extraction could differ is a phantom. The
sma → sum ÷ pcanonicalization above looks like the case for cost-driven extraction: for a lonesma, the static cost model scoressum ÷ phigher, so an e-graph would extract thesmaform back. It would gain nothing. The emitter lowers both forms to the same instruction sequence, and thesmaform carries an extranaguard on top — so the “cheaper” extraction is marginally bigger, and the measured difference at fifty thousand bars is under one percent. Extraction would have optimized an artifact of the cost model, not the program.
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 identities —
x·1 → x,x÷1 → x,−(−x) → x. Multiplying or dividing by exactly1.0returnsxuntouched, and negation flips a sign bit, so twice is the identity —±0,±∞andnaincluded. - Strength reduction — a division by a power of two,
x ÷ 2^k, becomes a multiply by its reciprocal,x · 2^-k. Division is the expensive instruction; a power of two and its reciprocal are both exact, so the multiply lands on the same bits. A÷3is left alone, because1/3is not representable. (WAT below.) - Constant folding — an arithmetic, comparison or logic operation over constants collapses to
one constant, through the same
f64operations the interpreter and the module run. Transcendentals fold too:log,exp,sin…powover a constant are evaluated at compile time by the same pinned libm the run uses, so the folded bits are the bits a bar would have produced. - Boolean-domain peepholes —
a < bunder a negation becomesa ≥ b; a double negation¬¬bbecomesb; and the everydayif b then 1 else 0becomesb. Each holds only where the compiler has proved the value is0,1orna— the output of a comparison, a logic op or a crossing. The proof is the guard: off that domain,¬¬5is1, not5. - Kernel normalization —
sma(x, p) → sum(x, p) ÷ p. Not a speed rewrite: the normalization discussed under the cross-script merge above, the one that makes ansmaand asumover the same source and period the same node, so they can share one ring.
The passes with no rule table — they are the machine.
- Common-subexpression elimination is the rebuilder. Every pass reconstructs the graph through one structural key and hash-conses as it goes, so an identical sub-expression is one node however many times it was written. There is nothing to run: the rebuild is the merge. (WAT below.)
- Dead code is swept. A node no sink reaches is never emitted, and the memory plan is re-derived from what remains.
- The schedule is nudged to help the memory plan. After rewriting, liveness (the A13 plan) is recomputed, and the ready nodes are reordered greedily so each is emitted when it frees the most slots — never-worse: if the peak would not drop, the original order stands and no bytes churn. Buffers with disjoint lifetimes then share a slot. See memory model.
Strength reduction, ÷2 → ×0.5. The average of the bar’s high and low —
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.mulThe 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 —
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.addThe 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:
- A pack is never sealed from a fallback. If the optimizer was discarded during a build, the build refuses to seal rather than shipping the unoptimized artifact. Otherwise a verifier — whose own gate would discard the optimizer on the same divergence — would recompile to different bytes and accuse an honest pack of lying. A discarded optimizer at build time is a bug to fix, not to ship around.
- A hash mismatch is diagnosed, not just reported. When a verification finds different bytes, it also recompiles without the optimizer and compares against that, so it can say “built by an older or unoptimized toolchain” instead of the useless “this pack does not match its source”.
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:
- A bare
rsi(close, 14)has nothing to optimize. It already is the native kernel. - The gain grows with the complexity of the script and with the number of co-active scripts — which is where the redundancy actually lives.
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
- Verification — the canonical home of the hostile corpus, the harness and the reproducible-build check this page’s gate runs.
- Compiler and runtime — the two conforming implementations (I7) the gate compares, and the pinned routines.
- Memory model — the liveness plan, which is itself a T0–T1 pass.
- Concurrency — the scheduler, its shared cost model, and why parallelism cannot change a value.
- compute — the fusion and the pushdowns, at the dataframe level.
- Guarantees — what “verified at every compilation” means for a reader who must trust it.