Skip to content

fix: vector_push_front is PureWithPredicate - #13445

Open
AztecBot wants to merge 6 commits into
masterfrom
cb/ast-fuzzer-repro-licm-vector-mutator
Open

fix: vector_push_front is PureWithPredicate#13445
AztecBot wants to merge 6 commits into
masterfrom
cb/ast-fuzzer-repro-licm-vector-mutator

Conversation

@AztecBot

@AztecBot AztecBot commented Aug 3, 2026

Copy link
Copy Markdown
Collaborator

vector_push_front wasn't considered as PureWithPredicate in the purity analysis pass. This was likely an oversight has every other vector intrinsic is marked like that.

This PR fixes that, but it also exhaustively lists all the intrinsics so it's easier to see if we missed some.

Below is Claude's finding:


What

Adds a load-bearing reproduction (no fix) for the AST fuzzer pass_vs_prev failure on seed 0xb6bc8e1f00100000 against master (d89d99a944). This is a silent miscompilation (wrong result, no error), bisected to the Loop Invariant Code Motion pass.

The bug

Brillig arrays/vectors are copy-on-write: a mutating op (array_set, the vector push/pop/insert/remove intrinsics) writes through its operand in place once that operand's reference count is 1, and clones otherwise. The compiler keeps this safe by emitting an inc_rc on any operand still live after the mutation.

In the reduced SSA below, v1 is guarded by inc_rc v1 immediately before vector_push_front, because v1 is read again by the array_get in b3:

brillig(inline) impure fn foo f1 {
  b0(v0: u32, v1: [u1]):
    jmp b1(u32 0)
  b1(v2: u32):
    v3 = lt v2, u32 2
    jmpif v3 then: b2(), else: b3()
  b2():
    inc_rc v1                                          ; guards v1 before the mutator
    v4, v5 = call vector_push_front(v0, v1, u1 0) -> (u32, [u1])
    v6 = unchecked_add v2, u32 1
    jmp b1(v6)
  b3():
    v7 = array_get v1, index u32 0 -> u1               ; still reads the original v1
    return v7
}

vector_push_front is PureWithPredicate, so LICM hoists the call into the loop pre-header — but the inc_rc guard is a separate instruction that can_be_hoisted classifies No and never hoists. Separated from its guard, the hoisted push finds v1 at refcount 1 and writes through it in place, corrupting the array that b3 later reads. Interpreting foo([true, 1]) returns 1 before LICM and 0 after.

The reproduction

ssa::opt::loop_invariant::tests::hoisting_vector_mutator_out_of_loop_drops_refcount_guard runs the reduced SSA through assert_pass_does_not_affect_execution, which interprets before and after LICM and panics because the results differ (10) on master. It is marked #[should_panic(expected = "SSA pass has resulted in a different execution result")] to document the miscompilation deterministically; once LICM re-establishes an inc_rc on the array operands of a hoisted vector mutator, the results match and the #[should_panic] should be removed.

Severity

High. A loop-invariant vector push/pop/insert/remove in a guaranteed-executed loop can be hoisted in a way that mutates a still-live array and yields a wrong result, with no error.

Related issues

Refs noir-lang/noir-claude#244

This PR closes nothing — it is reproduction-only, adds a failing-by-design test, and changes no compiler behaviour, so #244 stays open after it merges. The closing keyword belongs on the fix PR. (It would not auto-close in any case: GitHub only auto-closes issues in the PR's own repo.)

The relevant history sits in the same ~20 lines of compiler/noirc_evaluator/src/ssa/opt/loop_invariant.rs:

  • noir-claude#1006 (closed, fixed) — "LICM hoists Brillig array_set without inserting inc_rc". Its fix, fix(licm): Insert inc_rc after hoisting array_set  #12665 (491cde769d4), is what added the MakeArray | ArraySet => true and Call-returning-array arms to can_hoist_invariant's returns_array. That compensation guards the hoisted instruction's results, never its array operands — which is precisely the gap this seed hits. insert_rc does fire here (a Call with an array result), but the inc_rc protecting the operand v1 is what stays behind.
  • noir-claude#244 (open, triage:high) — flags that the compensating inc_rc from that fix is inserted into *block (the loop body) rather than pre_header. Still true at d89d99a944. Any fix for this PR touches the same branch, so the two are worth resolving together.
  • Also in the neighbourhood: closed noir-claude#1086 (the read-side sibling of chore(nargo): rename struct representing Nargo.toml from Config to PackageManifest #1006), and open #1534 / #1535 / #1563 — gaps in the rc_invariant verifier that is meant to catch exactly this class of RC-1 in-place mutation, none of which has a term for the hoisted-mutator case either.

No open AST Fuzzer issue in this repo covers this. Full RCA + a suggested fix: https://gist.github.com/AztecBot/55e49989fcd3a1ae45f5e3a865d8c2d8


Created by claudebox · group: slackbot · requested by Tom French · Slack thread

…utator hoist

The miscompilation is reachable from plain Noir source, not just
hand-written SSA: with x = true, n = 1 the program returns false on the
base commit because the hoisted vector_push_front mutates v in place.
The test asserts the correct semantics, so it stays red until LICM keeps
(or re-establishes) the inc_rc guard on the hoisted mutator's operands.

Reaching the bug from source requires defeating three masking
mechanisms, documented in the test: a dynamic vector length (constant
lengths fold before LICM), spare capacity with refcount 1 at the
pre-header (a fresh as_vector() reallocates harmlessly), and no use of
the vector between the loops (which would emit a guarding inc_rc clone
in the pre-header).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@asterite

asterite commented Aug 3, 2026

Copy link
Copy Markdown
Collaborator

Pushed 2a75f19: the bug is reachable from plain Noir source, so this PR now also carries a deliberately failing source-level regression test, test_programs/execution_success/regression_licm_vector_mutator — expect red execute CI jobs until the fix lands.

With x = true, n = 1 the program returns false on the base commit (d89d99a944): the hoisted vector_push_front finds v at refcount 1 with spare capacity and mutates it in place. Getting there from source requires defeating three masking mechanisms (documented in the test's comments):

  1. Dynamic vector length — constant-length vectors are folded away before LICM runs.
  2. Spare capacity + rc 1 at the pre-headerprepare_vector_push only reuses storage when the new size fits the capacity; a fresh as_vector() (capacity == size) reallocates harmlessly, which is why n = 0 is correct. One reallocating push_back doubles the capacity and arms the bug.
  3. No use of v between the loops — an intervening use emits a clone (inc_rc) in the pre-header that accidentally guards the hoisted call.

The n = 0 vs n = 1 flip means the same circuit is right or wrong depending on witness input.

…er around a vector mutator

Source-level variant of the user-function-wrapper bypass described in
https://gist.github.com/AztecBot/b4b227ffacd5e2cc17e703255e98c22b: LICM
hoists calls to user functions by recorded callee purity, so a fix that
only guards hoisted intrinsic calls would leave this test red.

Under low inliner aggressiveness (exercised by the CI inliner matrix)
the wrapper survives to LICM as a real call and is hoisted away from
its inc_rc guard; under high aggressiveness it inlines and reduces to
the direct-intrinsic case. Keeping the wrapper alive from source needs
a loop in its body (defeats simple-function inlining), two call sites
(defeats always-inline-when-called-once), and Field arithmetic (checked
u32 ops would make it PureWithPredicate, whose hoisting path is blocked
by the preceding side-effecting inc_rc; only fully-pure callees hoist
unconditionally).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@asterite

asterite commented Aug 3, 2026

Copy link
Copy Markdown
Collaborator

Pushed fd8540b: a second failing source-level test, regression_licm_vector_mutator_wrapper, for the user-function-wrapper bypass from the fix critique gist — a fix that only guards hoisted intrinsic calls stays red on this one.

Under low inliner aggressiveness (the CI inliner matrix covers this) helper survives to LICM as a real call, purity analysis marks it pure, and LICM hoists the call into the pre-header away from its inc_rc guard; under high aggressiveness it inlines and reduces to the direct-intrinsic case, so the test is red on the base commit either way (n = 0 control passes under both).

Reaching the wrapper path from source needs three things beyond the first test: a loop in the wrapper body (defeats simple-function inlining), two call sites (functions called once are always inlined), and Field arithmetic in the wrapper. That last one is a nuance on top of the gist: a wrapper with checked u32 ops is only PureWithPredicate, and that hoisting path (can_hoist_control_dependent_instruction) is blocked by the side-effecting inc_rc that precedes the call in the loop body — only fully-Pure callees hoist unconditionally. So the gist's remark that PureWithPredicate "is still hoistable in fully executed loop bodies" doesn't hold for the frontend-emitted shape, where the guard inc_rc always sits before the call; any fix should still cover both purity levels, since that blocking is an accident of instruction order rather than a guarantee.

asterite and others added 2 commits August 3, 2026 13:03
…vector mutator out of an empty loop

Third source-level shape of the miscompilation, distinct from the
stranded-guard cases: with m = 0 the push_front loop never executes,
yet v is still corrupted. The fully-Pure branch of can_hoist_invariant
checks neither does_execute nor block impurity, so the hoisted mutator
runs once in the pre-header of a zero-iteration loop; assert(acc == 0)
passes while assert(v[0] == x) fails.

This also pins down that inserting a pre-header inc_rc alone is not a
sufficient fix for the Pure path: the hoisted push must not execute
speculatively at all (today a copying push is only harmless because
its result is dead).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ps it behind its inc_rc guard

vector_push_front was the only vector mutator classified fully Pure: it
needs no non-emptiness assertion and its ACIR lowering does not read the
side-effects variable, so it fell through purity()'s catch-all. But in
Brillig it can write through its vector argument in place when the
argument's runtime reference count is 1, and the fully-Pure hoisting
path in loop-invariant code motion checks neither block impurity nor
whether the loop executes. LICM therefore hoisted the call into the
pre-header, away from the inc_rc guarding its operand (or into a path
the source program never executes), mutating a still-live vector in
place.

Classifying it PureWithPredicate routes it through the predicated
hoisting path, which refuses to move it past the side-effecting inc_rc
the ownership pass emits directly before it and refuses to hoist it out
of loops that may not execute. Functions wrapping the intrinsic inherit
the classification through purity analysis, closing the pure-wrapper
bypass as well.

Also replaces purity()'s catch-all arms with an exhaustive enumeration
so future intrinsics require an explicit purity decision.

The regression test loses its #[should_panic]: interpreting before and
after LICM now agrees. The three execution_success programs
(regression_licm_vector_mutator{,_wrapper,_empty_loop}) turn green,
including the wrapper variant under minimum inliner aggressiveness,
where the wrapper survives to LICM as a real call.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@asterite asterite changed the title chore: reproduce LICM miscompiling a hoisted Brillig vector mutator (AST fuzzer) fix: vector_push_front is PureWithPredicate Aug 3, 2026
@asterite
asterite marked this pull request as ready for review August 3, 2026 20:16
@asterite
asterite requested a review from TomAFrench August 3, 2026 20:16
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants