#7730·sway

Release ASM cleanup removes observable NOOP and self-MOVE status clearing across a join

Author: DicksonWu654Created Sep 11, 2026Updated Sep 14, 2026

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:

sway
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:

bash
forc test --test-threads 1
forc test --release --test-threads 1

Observed:

debug:   both assertions pass
release: both assertions fail; the returned value is 1

Replacing 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

bash
forc 0.72.1
sway 0.72.1
commit dad95cc42b0383b4e3bacdeda9766565aa584a92