Skip to content
Open
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
313 changes: 312 additions & 1 deletion compiler/noirc_evaluator/src/ssa/validation/rc_invariant/call.rs

Large diffs are not rendered by default.

175 changes: 175 additions & 0 deletions compiler/noirc_evaluator/src/ssa/validation/rc_invariant/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -996,6 +996,181 @@
}
}

/// Whether some backward path from the call at `(call_block, call_idx)`
/// resolves `a` and `b` — the storages two argument positions of that call
/// denote — to the **same** buffer without crossing an `inc_rc` on it.
///
/// This is the path-sensitive relation the co-aliased-arguments check
/// needs. Relating the two positions' backward alias *sets* is not enough,
/// for both directions of error:
///
/// - a join may pass one buffer through both positions on one branch and
/// protect it with a branch-local `inc_rc` that dominates nothing (the
/// `valid_after_pass` fuzzer seed `0x96293a520000f025`), and
/// - a join may pass one buffer through *different* positions per branch
/// (`(a, x)` on one arm, `(y, a)` on the other), so the sets intersect
/// even though no single path carries the buffer twice.
///
/// The walk threads the *pair* `(a, b)` backward over `(block, a, b)`
/// states, mirroring [`Context::compute_uncovered_values`]: across a
/// block-parameter edge each side follows the predecessor's argument, and
/// an `array_set` result continues with its array operand (the result
/// shares the operand's storage). Walls and sinks, in the order checked:
///
/// - **`inc_rc` on either name** in the current block (limited to bumps
/// before the call in the call's own block): covered. If the two names
/// denote one buffer on this path, either bump protects it; if they
/// don't, there is no hazard on this path to begin with.
/// - **Names equal, defined here or entry reached**: one buffer reaches
/// both positions with no bump crossed — an uncovered terminal.
/// - **Names distinct and one is defined here** (non-`array_set`): the
/// two positions carry distinct storages on this path — covered. (Two
/// distinct names for one buffer via un-threaded relations, e.g. an
/// `array_get` extraction, are not modeled — consistent with the rest
/// of the alias engine, a false negative but never a false positive.)
/// - **Names distinct at an entry block**: distinct roots — covered.
///
/// An unresolvable edge argument is conservatively an uncovered terminal.
/// Phase 2 propagates "uncovered" to a fixed point exactly like
/// [`Context::compute_uncovered_values`]; a cycle with no uncovered
/// terminal stays covered.
fn pair_has_unprotected_shared_storage(
&self,
a: ValueId,
b: ValueId,
call_block: BasicBlockId,
call_idx: usize,
) -> bool {
struct Node {
successors: Vec<(BasicBlockId, ValueId, ValueId)>,
uncovered_terminal: bool,
}

let inc_rc_in_block = |value: ValueId, block: BasicBlockId, limit: Option<usize>| {
self.inc_rc_locations.get(&value).is_some_and(|locations| {
locations
.iter()
.any(|&(rc_block, i)| rc_block == block && limit.is_none_or(|limit| i < limit))
})
};

// The instruction defining `value` in `block`, if any.
let def_in_block = |value: ValueId, block: BasicBlockId| -> Option<&Instruction> {
let &(def_block, def_idx) = self.array_value_defs.get(&value)?;
(def_block == block)
.then(|| &self.function.dfg[self.function.dfg[block].instructions()[def_idx]])
};

// Phase 1: build the backward threading graph over (block, a, b) states.
let mut graph: HashMap<(BasicBlockId, ValueId, ValueId), Node> = HashMap::default();
let mut worklist: Vec<(BasicBlockId, ValueId, ValueId, Option<usize>)> =
vec![(call_block, a, b, Some(call_idx))];

while let Some((block, a, b, seed_idx)) = worklist.pop() {
if graph.contains_key(&(block, a, b)) {
continue;
}

// Wall: a bump on either name protects the buffer if the names
// coincide, and if they don't there is no hazard on this path.
if inc_rc_in_block(a, block, seed_idx) || inc_rc_in_block(b, block, seed_idx) {
graph.insert((block, a, b), Node { successors: vec![], uncovered_terminal: false });
continue;
}

// An `array_set` result shares its operand's storage; continue
// threading the pair with the operand.
if let Some(Instruction::ArraySet { array, .. }) = def_in_block(a, block) {
let array = *array;
graph.insert(
(block, a, b),
Node { successors: vec![(block, array, b)], uncovered_terminal: false },
);
worklist.push((block, array, b, seed_idx));
continue;
}
if let Some(Instruction::ArraySet { array, .. }) = def_in_block(b, block) {
let array = *array;
graph.insert(
(block, a, b),
Node { successors: vec![(block, a, array)], uncovered_terminal: false },
);
worklist.push((block, a, array, seed_idx));
continue;
}

if a == b {
// One name, both positions. If its storage originates here (a
// non-threadable definition) or the walk reached an entry with

Check warning on line 1104 in compiler/noirc_evaluator/src/ssa/validation/rc_invariant/mod.rs

View workflow job for this annotation

GitHub Actions / Code

Unknown word (threadable)
// no bump crossed, this path hands the callee one buffer twice
// unprotected.
let defined_here = def_in_block(a, block).is_some();
let mut preds = self.cfg.predecessors(block).peekable();
if defined_here || preds.peek().is_none() {
graph.insert(
(block, a, b),
Node { successors: vec![], uncovered_terminal: true },
);
continue;
}
} else if def_in_block(a, block).is_some() || def_in_block(b, block).is_some() {
// Distinct names and one storage originates here: the other
// name cannot resolve to it further back, so this path carries
// two distinct buffers.
graph.insert((block, a, b), Node { successors: vec![], uncovered_terminal: false });
continue;
}

let preds: Vec<BasicBlockId> = self.cfg.predecessors(block).collect();
if preds.is_empty() {
// Entry reached with distinct names: distinct roots (the `a ==
// b` entry case is an uncovered terminal above).
graph.insert((block, a, b), Node { successors: vec![], uncovered_terminal: false });
continue;
}

let params = self.function.dfg.block_parameters(block);
let a_pos = params.iter().position(|&p| p == a);
let b_pos = params.iter().position(|&p| p == b);
let mut successors = Vec::new();
let mut uncovered_terminal = false;
for &pred in &preds {
let resolve = |pos: Option<usize>, value: ValueId| match pos {
Some(i) => self.edge_arg(pred, block, i),
None => Some(value),
};
match (resolve(a_pos, a), resolve(b_pos, b)) {
(Some(next_a), Some(next_b)) => {
successors.push((pred, next_a, next_b));
worklist.push((pred, next_a, next_b, None));
}
// An unresolvable edge argument: conservatively treat as
// uncovered rather than silently dropping the path.
_ => uncovered_terminal = true,
}
}
graph.insert((block, a, b), Node { successors, uncovered_terminal });
}

// Phase 2: propagate "uncovered" from terminals to a fixed point.
let mut uncovered: HashSet<(BasicBlockId, ValueId, ValueId)> =
graph.iter().filter(|(_, n)| n.uncovered_terminal).map(|(k, _)| *k).collect();
let mut changed = true;
while changed {
changed = false;
for (state, node) in &graph {
if !uncovered.contains(state)
&& node.successors.iter().any(|s| uncovered.contains(s))
{
uncovered.insert(*state);
changed = true;
}
}
}

uncovered.contains(&(call_block, a, b))
}

/// Run the coverage narrowing + forward reachable-use walk for a single
/// potential in-place mutation of `source` at `(block, idx)` (the
/// instruction `mutator_id`). Returns the aliased use that would observe
Expand Down
12 changes: 10 additions & 2 deletions compiler/noirc_frontend/src/ownership/last_uses.rs
Original file line number Diff line number Diff line change
Expand Up @@ -411,11 +411,19 @@ impl LastUseContext {
let conservative = type_contains_reference(&call.return_type)
|| call.arguments.iter().any(arg_can_store_reference);

for arg in call.arguments.iter().rev() {
for (index, arg) in call.arguments.iter().enumerate().rev() {
if !conservative
&& let Expression::Unary(unary) = arg
&& matches!(unary.operator, UnaryOp::Reference { .. })
&& base_ident_of_field_access(&unary.rhs).is_some()
&& let Some(base) = base_ident_of_field_access(&unary.rhs)
// Even though the reference cannot escape the call, the *other* arguments of
// this same call are evaluated and handed to the callee while the reference is
// live. If one of them mentions `x` (e.g. `foo(&mut x, x)`), moving that use
// would let the callee's writes through the reference be observed through a
// by-value argument, so `x` must be treated as aliased.
&& !call.arguments.iter().enumerate().any(|(other_index, other_arg)| {
other_index != index && local_occurs_in(base, other_arg)
})
{
// Track the use of the variable inside the reference (for last-use analysis)
// but skip the unary handler, which would mark the variable as aliased.
Expand Down
28 changes: 28 additions & 0 deletions compiler/noirc_frontend/src/ownership/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1305,6 +1305,34 @@ fn reference_passed_alongside_struct_with_mut_ref_to_ref_prevents_move() {
");
}

#[test]
fn by_value_arg_aliasing_mut_ref_arg_of_same_call_prevents_move() {
// When passing `&mut a` and `a`, `a` needs to be cloned
let src = "
unconstrained fn main(x: [Field; 2]) -> pub ([Field; 2], [Field; 2]) {
let mut a = x;
foo(&mut a, a)
}

fn foo(r: &mut [Field; 2], b: [Field; 2]) -> ([Field; 2], [Field; 2]) {
(*r)[0] = 99;
(*r, b)
}
";

let program = get_monomorphized(src).unwrap();
insta::assert_snapshot!(program, @r"
unconstrained fn main$f0(x$l0: [Field; 2]) -> pub ([Field; 2], [Field; 2]) {
let mut a$l1 = x$l0;
foo$f1((&mut a$l1), a$l1.clone())
}
unconstrained fn foo$f1(r$l2: &mut [Field; 2], b$l3: [Field; 2]) -> ([Field; 2], [Field; 2]) {
(*r$l2)[0] = 99;
((*r$l2).clone(), b$l3)
}
");
}

#[test]
fn call_with_extract_tuple_field_args_does_not_prevent_move() {
// Mirrors the `try_resize` pattern in UHashMap: `insert(&mut new_map, entry.0, entry.1)`
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
[package]
name = "ownership_by_value_arg_aliasing_mut_ref_regression"
type = "bin"
authors = [""]

[dependencies]
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
a = "1"
b = "2"
c = "3"
i = "0"
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
// Companion test program for the ownership test by_value_arg_aliasing_mut_ref_arg_of_same_call_prevents_move

unconstrained fn f(r: &mut [Field; 3], v: [Field; 3], i: u32) -> ([Field; 3], [Field; 3]) {
(*r)[i] = 99;
(v, *r)
}

unconstrained fn main(a: Field, b: Field, c: Field, i: u32) {
let mut x = [a, b, c];
let (v, r) = f(&mut x, x, i);
// `v` must be the pre-write snapshot (the inputs fix `i = 0` and `a = 1`).
assert(v[i] == a);
assert(r[i] == 99);
}
3 changes: 2 additions & 1 deletion tooling/debugger/ignored-tests.txt
Original file line number Diff line number Diff line change
Expand Up @@ -26,4 +26,5 @@ brillig_block_mutable_reference_regression
brillig_block_mutable_reference_inner_ref_regression
brillig_block_mutable_reference_let_chain_long_regression
brillig_block_mutable_reference_let_chain_regression
brillig_block_mutable_reference_index_regression
brillig_block_mutable_reference_index_regression
ownership_by_value_arg_aliasing_mut_ref_regression

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading