The model
One call optimizes a module; everything else is for callers who want control:
optimizeruns the default pipeline on every function with a body (Tier 1).Optimizerchooses the passes and their order, the number of sweeps, the work budget, validation between passes, and compaction (Tier 2).run_passruns onePassKindon anir_lang::Builderwith aBudget, for callers with their own pipeline (Tier 3).Statsreports sizes before and after, sweeps, budget exhaustion, and per-pass counts;OptErrorsays why optimization stopped.
The passes:
| Pass | What it does |
|---|---|
SimplifyCfg |
Folds branches on constants and branches with identical edges, prunes switch cases, removes unreachable blocks, threads jumps through empty blocks, merges straight-line blocks. |
Sccp |
Sparse conditional constant propagation over block parameters and the edges that can be taken, including switch and check edges; folds a check to its normal edge, its error edge (with the OPS code), or a trap; folds operations with only some operands known (x ** 0, x ** 1, x * 0, ...); turns a check that cannot fail into the plain operation when ir-lang's placement rule allows it, x ** 2 into x * x, and otherwise points a dead error edge at an unreachable block. |
CopyProp |
Replaces a block parameter that receives the same value on every edge (loops included) and removes it. |
Gvn |
Dominator-based value numbering of pure operations, with commutative operands canonicalized and a few exact integer identities (x + 0, x * 1, not(not(x)), ...). |
Licm |
Hoists pure loop-invariant operations into the loop's preheader, creating one when needed, inner loops first. |
Dce |
Removes dead instructions and dead block parameters (dead loop variables included), keeping every side effect. |
What it guarantees, and how each guarantee is checked:
| Guarantee | How it is held |
|---|---|
Optimized code behaves exactly like the original: same return values, same trap codes, same check error codes, same exceptions, the same import calls with the same arguments in the same order, and the same final memory. |
Differential tests generate random SSA programs (loops, switch, calls, invoke and landing pads, resume, checked arithmetic under every policy, floats including NaN, stack and global memory, volatile accesses, atomics, memcpy/memset) and run them before and after optimization in the ir-lang reference interpreter: the whole pipeline, each pass alone, and random pass sequences. |
| Constant folding is exact, and never folds an operation that would raise into a value. | Every operation, type, and policy (OPS v2 pow and saturate included) is folded and compared with the interpreter's eval_* functions over the OPS edge values and random bits; partial folds (one operand known) are property-tested the same way; float rounding and square root are implemented in core and checked bit for bit against std; float pow is ir-lang's shared ls_pow. |
An error edge that can be taken is never removed, and a check becomes a plain instruction only when ir-lang accepts the plain form. |
The fallibility rule is tested equal to ir-lang's can_error for every operation, policy, and type; the validator runs after every pass; per-pass tests pin the pow and saturate rewrites. |
| The IR stays valid, including the GC safepoint rules. | The ir-lang validator runs after every pass in debug builds and in every test. Random programs that carry GC references through safepoints, branches, and loops, and random programs built with ir-lang's variable API whose statepoints come from insert_statepoints, are optimized, validated, and run; insert_statepoints run again on the optimized function must find nothing to add. |
| Every pass is idempotent, and the pipeline reaches its fixpoint. | A second run of each pass, and of the pipeline, changes nothing, on every generated program. |
| No input makes a pass recurse or run away. | Every algorithm is iterative; 40,000-deep block chains, 10,000-deep dominator trees, 300-deep loop nests, 20,000-case switches, 3,000-parameter blocks, 5,000-long check pow chains, and 10,000 pows by u64::MAX are optimized in the tests, and any budget (zero included) leaves valid IR with unchanged behaviour. |
| Work is linear in the function. | Each pass is linear by construction (the dominator tree is O(E log V)); the whole pipeline on a function of 100k or of 200k instructions fits in 160 budget steps per unit of size (a scale test). |
Installation
[]
= "0.3"
= "=2.0.0-alpha.3"
opt-lang takes and returns ir_lang types, so use the same ir-lang release.
Without the standard library (optimization results are identical):
[]
= { = "0.3", = false }
Quick start
use ;
// fn f(x: i64) -> i64 { let a = 2 + 3; (x * a) + (x * a) }
let mut m = new;
let f = m.declare_function?;
let mut b = m.build?;
let x = b.param?;
let = ;
let a = b.add?;
let p = b.mul?;
let q = b.mul?;
let r = b.add?;
b.ret?;
let stats = optimize?;
assert_eq!;
let text = m.display_function.to_string;
assert!;
assert!;
assert!;
# Ok::
Policies decide what may be folded
A check holds an operation whose policy can raise. With constant operands it
folds to exactly what would happen at run time:
use ;
// i64::MAX + 1 with overflow=error: the error edge, with code 1 (E0001).
let mut m = new;
let f = m.declare_function?;
let mut b = m.build?;
let max = b.iconst?;
let one = b.iconst?;
let ok = b.create_block?;
let err = b.create_block?;
b.check?;
b.switch_to?;
let sum = b.block_param?;
b.ret?;
b.switch_to?;
let code = b.block_param?;
let wide = b.convert?;
b.ret?;
optimize?;
let text = m.display_function.to_string;
assert!;
assert!;
# Ok::
The same addition with overflow=trap stays an addition (it traps at run
time); with overflow=wrap it folds to i64::MIN.
OPS v2: pow and saturate
ir-lang treats a signed pow as always fallible (a negative exponent raises
NegativeExponent under every policy), so it is always a check. With a
constant exponent the optimizer still simplifies it, without ever producing IR
that ir-lang would refuse:
use ;
// fn f(x: i64) -> i64 { x ** 2 }
let mut m = new;
let f = m.declare_function?;
let mut b = m.build?;
let x = b.param?;
let two = b.iconst?;
let ok = b.create_block?;
let err = b.create_block?;
b.check?;
b.switch_to?;
let sq = b.block_param?;
b.ret?;
b.switch_to?;
b.trap?;
optimize?;
let text = m.display_function.to_string;
// x * x traps on exactly the same inputs; nothing can raise any more.
assert!;
# Ok::
x ** 0 becomes 1, x ** 1 becomes x, a negative constant exponent goes
straight to the error edge with code 6, and x ** 3 under wrap keeps its
check (the only valid form) but its error edge, which cannot be taken, is
pointed at an unreachable block so the error handler is deleted. The full
table is in docs/API.md.
Choosing the passes
use ;
use ;
let mut m = new;
let f = m.declare_function?;
let mut b = m.build?;
let x = b.param?;
let y = b.add?;
let z = b.add?;
let w = b.mul?;
b.ret?;
// Tier 2: value numbering and dead code only, at most two sweeps, a fixed
// budget, and the validator after every pass.
let stats = new
.passes
.max_iterations
.budget
.validate
.run?;
assert_eq!;
assert_eq!;
// Tier 3: one pass on a builder.
let mut budget = unlimited;
assert!;
# Ok::
Examples
Runnable programs in examples/:
| Example | What it shows |
|---|---|
optimize |
The one-call path on a function with a constant expression, a recomputed value, a constant branch, and a loop-invariant product; prints the IR before and after. cargo run --example optimize |
pipeline |
A configured Optimizer with per-pass counts, and single passes run by hand from one budget. cargo run --example pipeline |
variables |
Input lowered the way irgen-lang lowers: mutable locals through ir-lang's SSA construction, a signed pow in a check, a GC reference across a call with statepoints from insert_statepoints; the square becomes a mul and leaves the loop. cargo run --example variables |
Performance
Every pass is a constant number of linear walks over the function (plus the
O(E log V) dominator tree), so time grows linearly with size; the cost per
instruction rises once the function no longer fits in cache (about 1.6x for
the pipeline between 100k and 1M instructions). Measured with the benchmarks
in benches/ on functions of the typical shape (see below),
x86_64, Rust 1.95 stable, release profile, Windows 11, on a machine also
running other builds; each figure is one run of one pass, or the whole
pipeline to its fixpoint, on the unoptimized function. Library-only figures:
building the IR is not included.
| Benchmark | 100k instructions (76,674 live) | 1M instructions (766,674 live) |
|---|---|---|
pipeline/optimize (all passes, to the fixpoint: 3 sweeps) |
~43 ms (1.8 M inst/s) | ~0.89 s (0.86 M inst/s) |
pass/simplify-cfg |
~11 ms | ~217 ms |
pass/sccp |
~17 ms | ~209 ms |
pass/copy-prop |
~6.0 ms | ~82 ms |
pass/gvn |
~11 ms | ~141 ms |
pass/licm |
~6.1 ms | ~102 ms |
pass/dce |
~5.4 ms | ~111 ms |
These are 0.3.0 figures (ir-lang 2.0.0-alpha.3). Against 0.2.0's code on the
same ir-lang and machine, the moves through Builder::move_inst made
simplify-cfg about 23% and licm 18-28% faster; the other passes are within
this machine's run-to-run spread (about ±10%). Two bulk-edit benchmarks,
edit/simplify-cfg-merge (merging a 50,000-block chain) and
edit/dce-dead-params (removing 350,000 block parameters), measure the
rewrites alone: ~31 ms (-32% to -53% over two runs) and ~34 ms (no change to
-16% over two runs).
Every figure includes the input validation each optimizer run performs (about 4 ms at 100k and 60 ms at 1M instructions, measured for 0.2.0). The pipeline compacts a function between passes once most of its arena is tombstones, which keeps later sweeps on dense tables.
The typical shape is what a front end lowers ordinary code into: arithmetic with a constant subexpression, the same value computed twice, a dead value, a diamond joined by a block parameter, a branch on a constant, and every fourth unit a counted loop with an invariant product. On it the pipeline removes 63.8% of the instructions (76,674 to 27,787 at 100k; 766,674 to 277,787 at 1M) and 63.0% of the blocks (30,004 to 11,114), in three sweeps. Those are the numbers for that shape only; real code has less redundancy.
Criterion writes per-benchmark reports to target/criterion/; the reduction
figures are printed to stderr. Numbers vary by CPU; use the trend across runs,
not a single absolute.
Design notes
- Analysis first, then rewrite. Each pass computes everything it will do
before it changes the function, and the IR is rewritten only through the
ir-lang
Builder, which type-checks every insertion. A pass that cannot be paid for from the budget changes nothing. - Bulk edits in linear time. Instructions are moved with
Builder::move_inst(O(1); handle and results kept) and block parameters are removed in bulk withBuilder::remove_block_params(one linear pass), both added to ir-lang for this crate. - GC references are never moved. A
ref, or a value derived from one, is never made a constant, value-numbered, or hoisted: moving it across a safepoint would break the IR's statepoint rules. No pass moves a call or safepoint or edits a GC list, so statepoints placed byinsert_statepointsstay valid and it does not need to run again (the tests check that it would add nothing). - Folding without
std.corelacksfloor,round,sqrt, and their kin on the MSRV; the folder implements them from the bit representation (rounding by clearing fraction bits, the square root by an exact integer root), sono_stdbuilds fold exactly whatstdbuilds fold. A float operation whose result is a NaN is not folded: OPS lets a machine produce any quiet NaN, and folding would fix one payload at compile time. - The pipeline is pass-lang. The sweep loop is a
pass_lang::PassManagerrun to a fixpoint. pass-lang's errors carry only a message, so the typedOptErrortravels beside it and is what callers see.
What is not done yet, plainly: no memory optimizations (loads are not
value-numbered or forwarded across stores, dead stores are not removed), no
inlining, no SROA, no loop canonicalization, no strength reduction beyond
x ** 2 to x * x, and fma is not constant-folded. ir-lang alpha.3's
validator, which runs on every input, is quadratic on a deep chain of blocks
whose error edges join one handler (about 0.3 s at 5,000 and 6.7 s at 20,000
such blocks in a debug build); opt-lang's own passes are linear there. Calls are never removed, even to functions marked
readnone (the attribute is an unchecked hint). See
dev/ROADMAP.md.
Testing
tests/differential.rs is the core of the suite:
random programs, run in the ir-lang reference interpreter before and after
optimization. tests/gc.rs does the same for programs with
GC references and safepoints, tests/variables.rs for
programs built with ir-lang's variable API and automatic statepoints, tests/passes.rs pins down
each pass on hand-written IR, tests/hostile.rs feeds
deep, long, and wide functions and tiny budgets, and
tests/scale.rs checks the figures quoted above. Every
rust example in this README and in docs/API.md is compiled
and run as a doctest.
Cross-platform support
- Linux (x86_64, aarch64)
- macOS (x86_64, Apple Silicon)
- Windows (x86_64)
The crate uses no operating-system facilities and no platform-specific code; a function optimizes to the same IR on every platform.
Contributing
See REPS.md for the engineering standards every change is held to, dev/DIRECTIVES.md for the definition of done, and dev/ROADMAP.md for what comes next. Before a PR: cargo fmt --all, cargo clippy --all-targets --all-features -- -D warnings, and cargo test --all-features must be clean.