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
65 changes: 47 additions & 18 deletions crates/synth-backend-riscv/src/selector.rs
Original file line number Diff line number Diff line change
Expand Up @@ -195,11 +195,24 @@ pub fn select_with_result_types(
// #472: read the local-promotion flag exactly once, here, so the rest of the
// selector (and its unit tests) work off a plain bool. Flag off by default →
// `compute_local_promotion` is never consulted → byte-identical baseline.
//
// HELD from the RV32 flip-wave (honest blocker, the #592 const-CSE
// pattern): the lever fails the per-function no-grow gate — its own WAR
// fixtures grow (war_set 56→64 B, war_tee 60→68 B) and promo-ALONE grows
// control_step 504→508 B. The profitability model ("≥2 accesses repay
// save/restore") under-charges: it prices neither the per-RETURN restore
// (`lw s_i` duplicated into every epilogue) nor the WAR-snapshot `mv`s.
// Flip when the decision function models both and the corpus no-grow gate
// holds at function granularity.
let promote_locals = std::env::var_os("SYNTH_RV_LOCAL_PROMO").is_some();
// #472: cmp→select fusion (the VCR-SEL-004 lever, ported from ARM). Same
// read-once discipline; flag off by default → `pending_cmp` is never set →
// `lower_select` takes the baseline branch-on-boolean path, byte-identical.
let cmp_select_fuse = std::env::var_os("SYNTH_RV_CMP_SELECT").is_some();
// read-once discipline; DEFAULT-ON since the RV32 flip-wave (no function
// grows across the RV32 corpus, control_step −12 B, execution
// differentials green on the new default BEFORE the goldens were re-pinned);
// `SYNTH_RV_CMP_SELECT=0` opts out → `pending_cmp` is never set →
// `lower_select` takes the branch-on-boolean pre-flip baseline (CI-gated
// escape hatch in frozen_codegen_bytes.rs).
let cmp_select_fuse = !std::env::var("SYNTH_RV_CMP_SELECT").is_ok_and(|v| v == "0");
select_inner(
wasm_ops,
num_params,
Expand Down Expand Up @@ -975,13 +988,15 @@ struct Selector {
/// wasm zero-init and get an `addi s_i, zero, 0` at function entry (#457).
promoted_zero_init: std::collections::HashSet<u32>,
/// #472: whether local promotion is enabled for this function. Set by the
/// public entry point from `SYNTH_RV_LOCAL_PROMO`; the env is read exactly
/// once there so the decision function stays pure and unit-testable.
/// public entry point from `SYNTH_RV_LOCAL_PROMO` (still opt-in — held
/// from the flip-wave on the no-grow blocker documented there); the env is
/// read exactly once so the decision function stays pure and unit-testable.
promote_locals: bool,
/// #472 (VCR-SEL-004 port): whether cmp→select fusion is enabled. Set by
/// the public entry point from `SYNTH_RV_CMP_SELECT`; unit tests pass it
/// explicitly. With this false, `pending_cmp` is never populated and the
/// select lowering is byte-identical to the baseline.
/// the public entry point from `SYNTH_RV_CMP_SELECT` (default-on, `=0`
/// opts out); unit tests pass it explicitly. With this false,
/// `pending_cmp` is never populated and the select lowering is
/// byte-identical to the pre-flip baseline.
cmp_select_fuse: bool,
/// #472: the comparison lowered by the PREVIOUS wasm op, if fusion is
/// enabled and its boolean is on top of the vstack. `lower_one` takes it
Expand Down Expand Up @@ -1556,8 +1571,8 @@ impl Selector {
/// the immediately preceding op AND its boolean is exactly the popped `cond`,
/// the boolean materialization is deleted and the branch tests the comparison
/// directly (`blt a, b` instead of `slt t, a, b; bne t, zero`) — saving the
/// 1–2 instructions the boolean cost. Only reachable with
/// `SYNTH_RV_CMP_SELECT` set (the record is never created otherwise).
/// 1–2 instructions the boolean cost. Unreachable under the
/// `SYNTH_RV_CMP_SELECT=0` opt-out (the record is never created then).
/// Note the fused branch is emitted before any `mv`, so the select's freshly
/// allocated `dst` aliasing a comparison operand is harmless.
fn lower_select(
Expand Down Expand Up @@ -2002,8 +2017,9 @@ impl Selector {
// ────────── Comparisons ──────────

/// #472 (VCR-SEL-004 port): record a fusible comparison for a possibly
/// following `select`. No-op unless `SYNTH_RV_CMP_SELECT` is set (the
/// flag-off path never populates `pending_cmp`, keeping it byte-identical).
/// following `select`. No-op under the `SYNTH_RV_CMP_SELECT=0` opt-out
/// (that path never populates `pending_cmp`, keeping it byte-identical to
/// the pre-flip baseline).
/// `start` is `out.len()` from BEFORE the boolean materialization was
/// emitted; `cond(rs1, rs2)` must be TRUE exactly when the wasm comparison
/// yields 1.
Expand Down Expand Up @@ -7712,10 +7728,16 @@ mod tests {

/// Flag OFF must be byte-identical to the default `select()` path, and must
/// keep the local on the frame (frame `lw`s, no promoted s-register).
/// (Local promotion is still OPT-IN — HELD from the RV32 flip-wave on the
/// no-grow blocker documented at the env read in
/// [`select_with_result_types`].)
#[test]
fn local_promotion_flag_off_is_identical_and_frame_backed_472() {
let ops = promotable_ops();
let default = s(&ops, 1); // select() — env unset in tests
// select() reads the env; promotion is opt-in, cmp→select is
// default-on (flip-wave) — promotable_ops has no select, so fusion is
// a no-op here and the env-unset default is the unpromoted lowering.
let default = s(&ops, 1);
let off = s_promo(&ops, 1, false);
assert_eq!(
default, off,
Expand Down Expand Up @@ -7892,14 +7914,21 @@ mod tests {
]
}

/// Flag OFF must be byte-identical to the default `select()` path: the
/// boolean is materialized (`slt`) and the select branches on `bool != 0`.
/// DEFAULT-ON since the flip-wave: the env-unset `select()` path must take
/// the FUSED lowering; the `SYNTH_RV_CMP_SELECT=0` opt-out (explicit
/// `fuse=false`, the same bool the entry point derives from `=0`) restores
/// the pre-flip shape — boolean materialized (`slt`), select branches on
/// `bool != 0`.
#[test]
fn cmp_select_flag_off_is_identical_and_unfused_472() {
fn cmp_select_default_on_and_opt_out_is_unfused_472() {
let ops = lt_s_select_ops();
let default = s(&ops, 2); // select() — env unset in tests
let default = s(&ops, 2); // select() — env unset in tests → default-ON
assert_eq!(
default,
s_fuse(&ops, 2, true),
"env-unset default must equal the fused path (flip-wave)"
);
let off = s_fuse(&ops, 2, false);
assert_eq!(default, off, "flag-off must equal the default path");
assert_eq!(count(&off, |op| matches!(op, RiscVOp::Slt { .. })), 1);
assert_eq!(count_branch(&off, Branch::Ne), 1, "bne bool, zero: {off:?}");
assert_eq!(count_branch(&off, Branch::Lt), 0);
Expand Down
97 changes: 91 additions & 6 deletions crates/synth-cli/tests/frozen_codegen_bytes.rs
Original file line number Diff line number Diff line change
Expand Up @@ -57,10 +57,13 @@ fn fixture(name: &str) -> std::path::PathBuf {
/// --relocatable` config the `.py` differentials use, and return the SHA-256 hex
/// of its `.text` and the section length. The default-changing opt-out flags
/// (`SYNTH_NO_CMP_SELECT_FUSE` — v0.13.0 cmp→select; `SYNTH_NO_LOCAL_PROMOTE` —
/// v0.14.0 local promotion) and `SYNTH_CONST_CSE` are explicitly removed so a flag
/// set in the environment can never silently re-freeze the gate — it locks the
/// SHIPPED lowering, which since v0.14.0 INCLUDES cmp→select fusion AND local
/// promotion (both default-on). The `object` crate reads `.text` arch-agnostically
/// v0.14.0 local promotion; `SYNTH_RV_CMP_SELECT` — #472 RV32 cmp→select,
/// `=0` opts out) and the still-opt-in levers (`SYNTH_CONST_CSE`,
/// `SYNTH_RV_LOCAL_PROMO`) are explicitly removed so a flag set in the
/// environment can never silently re-freeze the gate — it locks the SHIPPED
/// lowering, which since v0.14.0 INCLUDES cmp→select fusion AND local
/// promotion on ARM (both default-on) and since the #472 flip-wave includes
/// RV32 cmp→select fusion. The `object` crate reads `.text` arch-agnostically
/// (ARM Thumb-2 and RV32 alike).
fn text_sha256(wasm: &str, backend: &str, target: &str) -> (String, usize) {
let path = fixture(wasm);
Expand All @@ -73,6 +76,8 @@ fn text_sha256(wasm: &str, backend: &str, target: &str) -> (String, usize) {
.env_remove("SYNTH_SPILL_REALLOC")
.env_remove("SYNTH_CONST_CSE")
.env_remove("SYNTH_BASE_CSE")
.env_remove("SYNTH_RV_CMP_SELECT")
.env_remove("SYNTH_RV_LOCAL_PROMO")
.args([
"compile",
path.to_str().unwrap(),
Expand Down Expand Up @@ -339,9 +344,48 @@ fn frozen_fixtures_spill_realloc_escape_hatch_restores_old_bytes() {
/// `_riscv_differential.py` — control_step + signed_div_const. (flight_seam needs an
/// import-call relocation the RV32 skeleton does not yet emit.)
///
/// Goldens derived on main @ 57206a1 (post-#445), 2026-06-23.
/// Goldens RE-FROZEN for the SYNTH_RV_CMP_SELECT flip (#472, the RV32 lever
/// flip-wave): cmp→select fusion is now DEFAULT-ON for the RV32 selector
/// (VCR-SEL-004 port), so control_step's three fused selects drop their
/// boolean materializations (504 → 492 B, −12). signed_div_const has no
/// selects — byte-identical, hash unchanged. Execution differentials were
/// re-run green on the new default bytes BEFORE this re-pin
/// (control_step_riscv / signed_div_const_riscv / rv32_cmp_select_472_riscv /
/// rv32_local_promotion_472_riscv / if_else_result_343_riscv /
/// i64_divs_317_riscv — see the flip PR). `SYNTH_RV_CMP_SELECT=0` restores
/// the prior goldens (asserted by
/// `frozen_fixtures_rv32_cmp_select_escape_hatch_restores_old_bytes`). Prior
/// goldens (main @ 57206a1, 2026-06-23) were control_step 6e734c4c…/504,
/// signed_div_const 15fa429d…/88.
///
/// `SYNTH_RV_LOCAL_PROMO` (#472's second lever) did NOT flip — HELD on a
/// per-function no-grow blocker (its own WAR fixtures grow; see the env read
/// in `synth-backend-riscv/src/selector.rs`) — so these goldens are the
/// promo-UNSET bytes and `text_sha256` env-removes it for hygiene.
#[test]
fn frozen_fixtures_rv32_text_is_bit_identical_oracle_001() {
let cases = [
(
"control_step.wasm",
"780e427a7ce94b54e0aaad0165e4984bebc1c0c43d25def5b5e9abf6a3929fed",
492usize,
),
(
"signed_div_const.wasm",
"15fa429d5ef5474f8b65fdd9c81b3da4c70176fb077df25131e2ec3988eb999e",
88,
),
];
assert_frozen(&cases, "riscv", "rv32imac");
}

/// The RV32 flip-wave ESCAPE HATCH (#472): `SYNTH_RV_CMP_SELECT=0` must
/// restore the pre-flip RV32 goldens byte-for-byte — the rollback proof, and
/// a tripwire against the fusion path leaking into the opt-out lowering.
/// signed_div_const is fusion-inert (no selects), so its pin is identical in
/// both configurations — it guards that the opt-out stays a no-op there.
#[test]
fn frozen_fixtures_rv32_cmp_select_escape_hatch_restores_old_bytes() {
let cases = [
(
"control_step.wasm",
Expand All @@ -354,5 +398,46 @@ fn frozen_fixtures_rv32_text_is_bit_identical_oracle_001() {
88,
),
];
assert_frozen(&cases, "riscv", "rv32imac");
for &(wasm, golden, golden_len) in &cases {
let path = fixture(wasm);
let elf = format!("/tmp/frozenbytes_rv32_cmpsel_off_{wasm}.elf");
let out = Command::new(synth())
.env_remove("SYNTH_RV_LOCAL_PROMO")
.env("SYNTH_RV_CMP_SELECT", "0")
.args([
"compile",
path.to_str().unwrap(),
"-o",
&elf,
"-b",
"riscv",
"--target",
"rv32imac",
"--all-exports",
"--relocatable",
])
.output()
.expect("run synth");
assert!(out.status.success(), "compile failed for {wasm}");
let bytes = std::fs::read(&elf).expect("read elf");
let obj = object::File::parse(&*bytes).expect("parse elf");
let data = obj
.section_by_name(".text")
.expect(".text")
.data()
.expect("read .text");
assert_eq!(
data.len(),
golden_len,
"{wasm}: SYNTH_RV_CMP_SELECT=0 must restore the pre-flip length"
);
let hex: String = Sha256::digest(data)
.iter()
.map(|b| format!("{b:02x}"))
.collect();
assert_eq!(
hex, golden,
"{wasm}: SYNTH_RV_CMP_SELECT=0 must restore the pre-flip bytes (rollback broken)"
);
}
}
135 changes: 135 additions & 0 deletions crates/synth-cli/tests/rv32_cmp_select_flip_472.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
//! #472 RV32 cmp→select fusion — DEFAULT-ON flip gates (the RV32 lever
//! flip-wave; template = the ARM SYNTH_SPILL_REALLOC / SYNTH_BASE_CSE flips).
//!
//! The lever: the RV32 selector (VCR-SEL-004 port) deletes a comparison's
//! boolean materialization (`slt`/`xor+sltiu`/…) when the very next wasm op is
//! a `select` consuming that boolean, branching on the comparison directly
//! (`blt a, b` instead of `slt t, a, b; bne t, zero`). i64-operand selects
//! fuse too; i64 COMPARISONS decline (multi-word compare has no single fused
//! branch).
//!
//! Execution differentials were re-run green on the new DEFAULT bytes BEFORE
//! any golden was pinned (rv32_cmp_select_472, rv32_local_promotion_472,
//! if_else_result_343, i64_divs_317, control_step, signed_div_const — all
//! `*_riscv_differential.py`, unicorn vs wasmtime; see the flip PR). The
//! default golden + `SYNTH_RV_CMP_SELECT=0` escape hatch for the frozen RV32
//! anchors live in `frozen_codegen_bytes.rs`. What THIS file locks:
//!
//! NO-GROW + non-vacuity: across the RV32-compiling repro corpus, no
//! function grows default-vs-opt-out, and the firing fixtures genuinely
//! shrink (control_step −12 B, the sel_* family −4/−8 B each, clamp −8 B
//! at flip time).
//!
//! `SYNTH_RV_LOCAL_PROMO` (the wave's second lever) did NOT flip — HELD on a
//! per-function no-grow blocker (its own WAR fixtures grow: war_set 56→64 B,
//! war_tee 60→68 B; promo-alone grows control_step 504→508 B). The blocker is
//! documented at the env read in `synth-backend-riscv/src/selector.rs`; this
//! test env-removes the flag so a stray value can't skew the gate.

use std::collections::HashMap;
use std::path::Path;
use std::process::Command;

use object::{Object, ObjectSymbol, SymbolKind};

fn synth() -> &'static str {
env!("CARGO_BIN_EXE_synth")
}

fn fixture(rel: &str) -> std::path::PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../..")
.join("scripts/repro")
.join(rel)
}

/// Compile `rel` for RV32 exactly like the `.py` differentials do.
/// `default_on` = the shipped default (env var removed so a stray opt-out in
/// the test environment can't skew the gate); `false` = the
/// `SYNTH_RV_CMP_SELECT=0` opt-out (pre-flip bytes).
fn compile(rel: &str, out: &str, default_on: bool) -> Vec<u8> {
let mut cmd = Command::new(synth());
cmd.env_remove("SYNTH_RV_LOCAL_PROMO");
if default_on {
cmd.env_remove("SYNTH_RV_CMP_SELECT");
} else {
cmd.env("SYNTH_RV_CMP_SELECT", "0");
}
let out_status = cmd
.args([
"compile",
fixture(rel).to_str().unwrap(),
"-o",
out,
"-b",
"riscv",
"--target",
"rv32imac",
"--all-exports",
"--relocatable",
])
.output()
.expect("run synth compile");
assert!(
out_status.status.success(),
"synth compile failed ({rel}, default_on={default_on}): {}",
String::from_utf8_lossy(&out_status.stderr)
);
std::fs::read(out).expect("read ELF")
}

/// Every function symbol → its `.text` byte size (ELF .symtab, not disasm
/// text — the disassembler is host-dependent).
fn func_sizes(elf: &[u8]) -> HashMap<String, usize> {
let obj = object::File::parse(elf).expect("parse ELF");
let mut out = HashMap::new();
for sym in obj.symbols() {
if sym.kind() == SymbolKind::Text
&& sym.size() > 0
&& let Ok(name) = sym.name()
{
out.insert(name.to_string(), sym.size() as usize);
}
}
out
}

/// NO-GROW + non-vacuity: across the RV32-compiling repro corpus no function
/// grows default-vs-opt-out, and the fusion genuinely fires (≥2 functions
/// shrink — at flip time it was 14: control_step_decide, clamp, sel_i64 and
/// the eleven fusible sel_* wrappers). signed_div_const / if_else_result /
/// i64_divs / the local-promotion WAR fixtures are fusion-inert or
/// decline-shaped — they must stay EQUAL.
#[test]
fn rv32_cmp_select_no_grow_corpus_472() {
let corpus = [
"control_step.wasm",
"signed_div_const.wasm",
"rv32_cmp_select_472.wat",
"rv32_local_promotion_472.wat",
"if_else_result_343.wat",
"i64_divs_317.wat",
];
let mut fired = 0usize;
for rel in corpus {
let tag = Path::new(rel).file_stem().unwrap().to_str().unwrap();
let off = func_sizes(&compile(rel, &format!("/tmp/rvcs472_{tag}_off.o"), false));
let on = func_sizes(&compile(rel, &format!("/tmp/rvcs472_{tag}_on.o"), true));
for (name, &o) in &off {
let n = *on.get(name).unwrap_or(&o);
assert!(
n <= o,
"no function may grow under default RV32 cmp→select: \
{name} opt-out={o}B default={n}B ({rel})"
);
if n < o {
fired += 1;
}
}
}
assert!(
fired >= 2,
"non-vacuity: RV32 cmp→select must shrink the firing fixtures, \
saw {fired} shrinking function(s)"
);
}
Loading