diff --git a/crates/synth-backend-riscv/src/selector.rs b/crates/synth-backend-riscv/src/selector.rs index 2c267103..c4d2518e 100644 --- a/crates/synth-backend-riscv/src/selector.rs +++ b/crates/synth-backend-riscv/src/selector.rs @@ -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, @@ -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, /// #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 @@ -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( @@ -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. @@ -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, @@ -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); diff --git a/crates/synth-cli/tests/frozen_codegen_bytes.rs b/crates/synth-cli/tests/frozen_codegen_bytes.rs index 41977f7d..74aee4ac 100644 --- a/crates/synth-cli/tests/frozen_codegen_bytes.rs +++ b/crates/synth-cli/tests/frozen_codegen_bytes.rs @@ -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); @@ -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(), @@ -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", @@ -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)" + ); + } } diff --git a/crates/synth-cli/tests/rv32_cmp_select_flip_472.rs b/crates/synth-cli/tests/rv32_cmp_select_flip_472.rs new file mode 100644 index 00000000..4eec4c09 --- /dev/null +++ b/crates/synth-cli/tests/rv32_cmp_select_flip_472.rs @@ -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 { + 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 { + 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)" + ); +}