Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 24 additions & 12 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,24 +7,36 @@ on:
env:
CARGO_TERM_COLOR: always

permissions:
contents: read

jobs:
publish:
name: Publish
runs-on: ubuntu-latest
permissions:
contents: read
id-token: write # required to mint the crates.io OIDC token
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
# Guard against publishing a version that doesn't match the release tag.
- name: Verify tag matches Cargo.toml version
with:
fetch-depth: 0 # merge-base needs real history

# A GitHub release can be cut from any commit, including one that never
# landed on main. Publishing is restricted to release tags that are
# actually contained in main.
- name: Refuse releases not contained in main
run: |
crate_version="$(cargo metadata --no-deps --format-version 1 \
| grep -o '"version":"[^"]*"' | head -1 | cut -d'"' -f4)"
tag_version="${GITHUB_REF_NAME#v}"
if [ "$crate_version" != "$tag_version" ]; then
echo "::error::tag $tag_version != Cargo.toml $crate_version"
exit 1
fi
- run: cargo test --all-features
git fetch --no-tags origin +refs/heads/main:refs/remotes/origin/main
git merge-base --is-ancestor "$GITHUB_SHA" refs/remotes/origin/main \
|| { echo "::error::release commit $GITHUB_SHA is not contained in main"; exit 1; }

- uses: dtolnay/rust-toolchain@stable
- run: cargo test

- uses: rust-lang/crates-io-auth-action@v1
id: auth

- run: cargo publish
env:
CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }}
CARGO_REGISTRY_TOKEN: ${{ steps.auth.outputs.token }}
137 changes: 137 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,143 @@ All notable changes to this crate are documented here. The format follows
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and the project
adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [0.5.0] - 2026-08-04

Correctness of the SSA rebuild and the phi transforms. On a 125 MB x86-64
reference binary (50,000 functions), pass rollbacks went from 6,094 to **0** and
verifier-reported undefined uses from ~28,960 to **0**; no function is now
floored by normalization. Output is 6% leaner (10,089,320 to 9,490,820 lowered
rows) while landing 4% more rewrites (94,257 to 98,467), because rejected work is
no longer discarded wholesale.

Every defect below shares a shape: a value substitution or a definition record
that was *stated* rather than *established* — a chain composed without resolving
to a surviving target, a label asserting a definition that did not exist, or a
repair applied on one path and not its sibling.

### Changed

- **A pass group no longer trusts a pass's own "changed" return.**
`SsaFunction` now tracks whether a checked edit mutated it, and
`PassTransaction::run_group` treats a mutated function as changed regardless of
what the pass reported. The precondition that made this necessary — a pass
editing under `SsaRollbackPolicy::Never` must report the change when the edit
fails, or the group skips both verification and rollback — was unstated,
unenforced, and had been got wrong by eight passes. It is now structural
rather than a convention: a pass that mutates and then claims otherwise is
still verified and still rolled back.

### Added

- **`SsaFunction::take_edit_dirty`** — reports whether a checked edit mutated the
function, clearing the flag. For consumers driving their own pass groups, who
need the same guarantee `PassTransaction` now provides.

- **`SsaFunction::refresh_def_sites` is public.** It recomputes every variable's
definition site from the IR as it actually stands. Front ends that build SSA
incrementally cannot always know an instruction's final index while lowering,
and a stale index that runs past the end of its block fails index-bounds
verification. This is the routine `repair_ssa` already used internally.

### Fixed

- **Trivial-phi substitution chains in rebuild mode resolved to a deleted
value.** The back-to-front composition introduced in 0.4.1 resolves each source
only through entries already inserted, so for `[(p2, x), (p1, p2)]` it records
`p1 -> p2` while `p2` is retired in the same round. Uses of `p1` were rewritten
to a phi deleted moments later. Each chain is now walked to a target that is
not itself being replaced, with a cycle guard — the repair path had always done
this. The 0.4.1 change remains correct as a performance fix; only its
composition order was wrong.

- **Self-referential phis were removed without rewriting their uses.** A phi
recorded as `(result, result)` has no other value to substitute and is
deliberately absent from the substitution map, but rebuild mode retired it
anyway, stranding every use on a variable nothing defined. It is now retired
only once nothing reads it, which is the condition the repair path already
applied.

- **`pre_clean_unreachable` inlined trivial phis without resolving chains.** Its
replacement map was applied entry by entry, so a phi inlined to a value that
was itself another phi being inlined in the same pass left the intermediate in
place — a use naming a phi the same loop had already removed. Because the map
is a `BTreeMap`, whether the chain resolved depended on key order, which made
the failure rare and order-dependent rather than reliably reproducible.

- **`repair_ssa` did not repair same-block future uses.** An instruction-scope
edit can leave a use naming a definition later in its own block. The rebuild
path repairs exactly this; the repair path did not, so the transactional guard
rejected the result as `IntraBlockCycle` and discarded the pass's work.

- **Entry replacements were labelled as phi-defined.** The stand-in
`repair_same_block_future_uses` fabricates for a use with no prior definition
is undefined by construction — it represents the value incoming to the
function. Copying the source variable's origin labelled it `Phi`, asserting
that a phi defined it. It now carries `EntryLiveIn`, whose contract is exactly
that the caller supplies it.

- **`clear_all_phis` discarded phis the rebuild could not reconstruct.** A phi
whose result belongs to no rename group, or to a group with no recorded
definition, cannot be re-placed by `place_phis`; clearing it destroyed its
definition outright. Such phis are now retained.

- **Phi results and phi-operand uses were invisible to the rebuild's def/use
collection.** `collect_defs` did not record a phi result as a definition of its
group, so a group whose only definition reaching a region was a phi contributed
no block there and no phi was re-placed where one was still needed.
`collect_uses_and_liveness` did not attribute a phi operand to the predecessor
it flows from, understating liveness on that edge.

- **`expand_phi_predecessor` could leave two operands on one edge.** A
predecessor reaching a block both directly and through the block being bypassed
had an operand added for an edge it already named. Duplicate operands have no
defined meaning and consumers disagree — `PhiNode::operand_from` returns the
first, SCCP meets them all and yields Bottom. The replaced edge is now dropped
and operands are added only for predecessors the phi does not already name.

- **Seven passes reported "unchanged" after applying edits.** `SsaEditOptions::new()`
defaults to `SsaRollbackPolicy::Never`, so a failed edit or boundary repair
leaves the edits applied. Returning `false`/`0` then tells the pass-group
transaction nothing changed, and its `Unchanged` arm returns *without verifying
and without rolling back* — so damaged IR was kept, and kept unchecked. On a
125 MB reference binary seven edit sessions failed this way and produced zero
rollbacks, meaning seven functions carried mutated, unverified IR.
`algebraic`, `ranges`, `reassociate`, `strength` and `threading` now report the
change so the transaction verifies and rolls back, as do `controlflow` and
`blockmerge` (below).

The mixed policy across passes is deliberate and unchanged: passes that verify
inside their own edit session (`copying`, `predicates`, `licm`) need
`OnFailure` for that verification to mean anything, while passes that delegate
to the transaction use `Never` to avoid a second snapshot — the transaction
already clones once per pass, where `OnFailure` clones per edit session. What
was missing was the unstated precondition that a `Never` pass must report the
change on failure.

- **`gvn` reported "unchanged" only correctly in debug builds.** Its rollback
policy is `OnFailure` under `debug_assertions` and `Never` otherwise, so
`return 0` on failure was true under test and false in release — the one
configuration where the damaged IR would ship. The return now depends on which
policy actually ran.

- **`controlflow` and `blockmerge` reported "unchanged" after applying edits.**
Under `SsaRollbackPolicy::Never` a failed boundary repair leaves the edits in
place. Reporting zero told the caller nothing had changed, and a pass-group
transaction treats "unchanged" as "nothing to verify" — so damaged IR was kept,
and kept unchecked. Both now report the applied edits, which lets the
transaction verify the function and roll it back.

### Ownership

- Recorded ATRAPS LLC as copyright holder and added a `NOTICE` file. The Apache-2.0
appendix was never filled in — it still carried the literal
`[yyyy] [name of copyright owner]` placeholder, so nothing in this repo stated
who owned it.
- Added a `repository` field. The manifest declared `documentation` but no
repository, so crates.io showed no source link for any published version.
- Dropped the deprecated `authors` field.
- Publishing now uses crates.io trusted publishing instead of a stored registry token.

## [0.4.1] - 2026-07-26

Speculative evaluation for `SsaEvaluator`. Consumers that explore alternative
Expand Down
10 changes: 5 additions & 5 deletions Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
[package]
name = "analyssa"
version = "0.4.1"
version = "0.5.0"
edition = "2024"
authors = ["Johann Kempter <admin@binflip.rs>"]
rust-version = "1.88"
license = "Apache-2.0"
description = "Target-agnostic SSA IR, analyses, and optimization pipeline"
repository = "https://github.com/ATRAPSLLC/analyssa"
documentation = "https://docs.rs/analyssa"
readme = "README.md"
keywords = ["ssa", "ir", "compiler", "analysis", "optimization"]
Expand Down Expand Up @@ -45,20 +45,20 @@ indexing_slicing = "deny"
serde = ["dep:serde"]

[dependencies]
thiserror = "2.0.19"
thiserror = "2.0.20"
boxcar = "0.2.14"
dashmap = "6.2.1"
rayon = "1.12.0"
log = "0.4.33"
num_enum = "0.7.6"
serde = { version = "1.0.228", features = ["derive"], optional = true }
serde = { version = "1.0.229", features = ["derive"], optional = true }

[dev-dependencies]
# Exercises the `serde` feature's round-trip guarantee (tests/serde.rs).
serde_json = "1.0.151"
# Measures the SSA maintenance costs the optimization pipeline pays per pass
# (benches/ssa_repair.rs).
criterion = "0.8"
criterion = "0.8.2"

[[bench]]
name = "ssa_repair"
Expand Down
2 changes: 1 addition & 1 deletion LICENSE
Original file line number Diff line number Diff line change
Expand Up @@ -186,7 +186,7 @@
same "printed page" as the copyright notice for easier
identification within third-party archives.

Copyright [yyyy] [name of copyright owner]
Copyright 2026 ATRAPS LLC

Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
Expand Down
24 changes: 24 additions & 0 deletions NOTICE
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
analyssa
Copyright 2026 ATRAPS LLC

This product includes software developed by ATRAPS LLC.

Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at

http://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.

----

This project includes the following third-party software:

Third-party dependencies are listed in Cargo.toml and their licenses
can be found in their respective repositories. All dependencies are
compatible with the Apache 2.0 license.
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,4 +84,5 @@ See [`CHANGELOG.md`](CHANGELOG.md).

## License

Apache-2.0. See [`LICENSE`](LICENSE).
Copyright 2026 ATRAPS LLC. Licensed under the Apache License,
Version 2.0. See [`LICENSE`](LICENSE) and [`NOTICE`](NOTICE).
56 changes: 40 additions & 16 deletions src/ir/function/editor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -283,11 +283,18 @@ impl<T: Target> SsaFunction<T> {
}
};

if report.changed
&& let Err(error) = finish_edit_scope(self, report.scope)
{
restore_on_failure(self, original);
return Err(error);
if report.changed {
// Set before boundary repair can fail. Under
// `SsaRollbackPolicy::Never` a failed repair leaves these edits
// applied, and the caller's own "changed" return has repeatedly
// been written to say otherwise — so the pass group reads this
// instead of trusting it. A rollback below replaces the whole
// function with its pre-edit clone, which clears the flag.
self.mark_edit_dirty();
if let Err(error) = finish_edit_scope(self, report.scope) {
restore_on_failure(self, original);
return Err(error);
}
}

if options.verify {
Expand Down Expand Up @@ -922,9 +929,9 @@ impl<'a, T: Target> SsaEditor<'a, T> {
let Some(block) = self.ssa.block_mut(block_idx) else {
return Err(Error::new(format!("missing block B{block_idx}")));
};
let Some((&first_pred, extra_preds)) = new_preds.split_first() else {
if new_preds.is_empty() {
return Ok(0);
};
}

let mut updated = 0usize;
for phi in block.phi_nodes_mut() {
Expand All @@ -937,16 +944,33 @@ impl<'a, T: Target> SsaEditor<'a, T> {
continue;
};

if let Some(operand) = phi
.operands_mut()
.iter_mut()
.find(|operand| operand.predecessor() == old_pred)
{
operand.set_predecessor(first_pred);
updated = updated.saturating_add(1);
}
// Drop the edge being replaced, then re-add one operand per new
// predecessor — but never for a predecessor the phi already names.
//
// A predecessor can reach this block *both* directly and through the
// block being bypassed; redirecting then collapses the two paths onto
// one edge. Adding an operand regardless leaves two operands for that
// single edge, which has no defined meaning and which consumers
// disagree about: `PhiNode::operand_from` returns the first and
// discards the rest, while SCCP meets them all and yields Bottom.
// `retarget_phi_predecessor` already guards this case; this is the
// same guard for the one-to-many form.
//
// When an operand for that predecessor already exists it is kept as
// it stands: it describes the direct edge, which is the edge that
// survives.
phi.operands_mut()
.retain(|operand| operand.predecessor() != old_pred);
updated = updated.saturating_add(1);

for &pred in extra_preds {
for &pred in new_preds {
if phi
.operands()
.iter()
.any(|operand| operand.predecessor() == pred)
{
continue;
}
phi.add_operand(PhiOperand::new(value, pred));
updated = updated.saturating_add(1);
}
Expand Down
Loading
Loading