Release ASM cleanup removes observable NOOP and self-MOVE status clearing across a join
Release ASM cleanup removes observable NOOP and self-MOVE status clearing across a join
Related Component
compiler
Problem
Release optimization removes explicit inline-ASM NOOP and MOVE r r instructions even when their FuelVM-defined clearing of $of or $err is observed after a control-flow join.
FuelVM implements NOOP with alu_clear() and MOVE with alu_set(), so both assign zero to the arithmetic status registers. Sway's remove_redundant_ops() knows these instructions define constant registers, but checks only whether the immediately following pseudo-op reads one. A jump or label breaks that lookahead, and the clearing instruction is removed despite a live status read in the successor block.
This produces a deterministic debug/release mismatch in ordinary contract calls: debug observes zero, while release observes the stale status set by the preceding arithmetic instruction.
Steps
Minimal $of reproducer:
contract;
use std::{
flags::{disable_panic_on_overflow, set_flags},
registers::overflow,
};
abi Probe {
fn clears_across_join(x: u64, first: bool) -> u64;
}
impl Probe for Contract {
fn clears_across_join(x: u64, first: bool) -> u64 {
let old = disable_panic_on_overflow();
if first {
let _unused = asm(a: x, b: u64::max(), r) {
add r a b;
noop;
r: u64
};
} else {
let _unused = asm(a: x, b: u64::max() - 1, r) {
add r a b;
noop;
r: u64
};
}
let status = overflow();
set_flags(old);
status
}
}
#[test]
fn noop_clears_both_predecessors() {
let p = abi(Probe, CONTRACT_ID);
assert(p.clears_across_join(2, true) == 0);
assert(p.clears_across_join(2, false) == 0);
}Run:
forc test --test-threads 1
forc test --release --test-threads 1Observed:
debug: both assertions pass
release: both assertions fail; the returned value is 1Replacing noop; with move r r; reproduces the release failure. An analogous test using division by zero with unsafe-math panic disabled reproduces stale $err. Void-ASM and no-join variants are useful negative controls and pass in both profiles.
The relevant pipeline is sway-core/src/asm_generation/fuel/optimizations/mod.rs; the one-instruction lookahead is in sway-core/src/asm_generation/fuel/optimizations/misc.rs::remove_redundant_ops().
Possible Solution(s)
Use CFG liveness for $of and $err when removing instructions that define them. A NOOP, self-MOVE, or other apparently redundant ALU instruction must remain when either status register is live on a successor path before its next definition.
A conservative short-term fix could preserve explicit inline-ASM NOOP and self-MOVE operations when an organizational operation intervenes before the next status definition. Regression tests should cover both registers, both sides of a join, and void/no-join controls.
Installed components
forc 0.72.1
sway 0.72.1
commit dad95cc42b0383b4e3bacdeda9766565aa584a92Source: FuelLabs/sway