Repository: noir-lang/noir (Rust)

Issue:
Rework `inc_rc`, `dec_rc` and how arrays are handled in brillig in general
### Problem

`inc_rc` and `dec_rc` are a common source of bugs in SSA code generated by the compiler. They require special handling since they are effectively implicit variables attached to each array in unconstrained code.

### Happy Case

Now that runtimes are separated before SSA we could represent arrays differently in acir & brillig. In Acir they can continue to be represented as normal arrays, while in brillig we could lower them as a tuple of `(&mut Field, [T; N])` where the first element of the tuple is the reference count represented as a mutable field value, and the second is the array itself. If we do this, reference counts now need much fewer special handling or optimizations since they'll be handled by mem2reg along with other references.

This could make it more difficult for optimizations like deduplication to insert extra `inc_rc` equivalents since they'd have to find the integer that represents the array's reference count instead of it always being attached to the array.

Uncertain how much code improvement this will bring or if it will improve optimizations so I labeled this as "experiment" with rather than a "lets definitely do this."

EDIT: See comments below for some alternative approaches.

### Workaround

None

### Workaround Description

_No response_

### Additional Context

_No response_

### Project Impact

None

### Blocker Context

_No response_

### Would you like to submit a PR for this Issue?

None

### Support Needs

_No response_


Discussion: Just to make sure I understand, you're not suggesting that `inc_rc` will disappear from the code, but rather that instead of figuring out which array it needs to act on, with the ref count being an internal part of the array in the Brillig VM, it will instead be like the length of a BoundedVec, a number passed around in the SSA together with the array used for storage. But where the  `inc_rc` and `dec_rc` are inserted into the SSA will remain a source of speculation about how the array is going to be used, as it is now. 

I'm asking because I have difficulty understanding some of the SSA generated from the test that fails in #7297 

For example `copy_mut` seems to insert an `inc_rc` because it receives a `mut array`, but it doesn't seem to account for the possibility that the array might not be mutated, and the count stays higher after the call no matter what. 

Take this example:

<details>
<summary>maybe_mut</summary>

```rust
use std::mem::array_refcount;

fn main() {
    let mut array = [0, 1, 2];
    maybe_mut(array, false);
    maybe_mut(array, true);
    println(array[0]);
    println(array_refcount(array));
}

fn maybe_mut(mut array: [Field; 3], should_mutate: bool) {
    if should_mutate {
        array[0] = 4;
    }
    println(array[0]);
}
```
It prints 3 as the final refcount in `main`, because of how `maybe_mut` looks like:

```rust
brillig(inline) fn maybe_mut f1 {
  b0(v0: [Field; 3], v1: u1):
    inc_rc v0
    v2 = allocate -> &mut [Field; 3]
    store v0 at v2
    jmpif v1 then: b1, else: b2
  b1():
    v3 = load v2 -> [Field; 3]
    v6 = array_set v3, index u32 0, value Field 4
    store v6 at v2
    jmp b2()
  b2():
    v7 = load v2 -> [Field; 3]
    v8 = array_get v7, index u32 0 -> Field
    call f2(v8)
    return
}
```

</details>

Does your proposal address the speculative nature of `inc_rc`? As it stands it's primary goal is to prevent against accidental modification, at the cost of doing more copies

I wonder if we could have a concept of `drop`, that is to understand when a reference goes out of scope, and decrement the RC at that point, and increment at the point where a borrow is made. For example I see `maybe_mut` emits an `inc_rc` because there is a possibility of mutation, while `borrow` in the test does not. Now, if I change `borrow` to _return_ the array it borrowed then it still doesn't increase the count, but the caller does on the returned value. Then, if I change it to return a struct wrapping the array, then both `borrow` and the caller increases the RC, with the two consecutive increments later being deduplicated. By contrast, we could increment the RC where in the caller when it passes an array to its callee, and decrement it if/when that reference is finally dropped, whether at the end of the called function, or when the returned structure wrapping it goes out of scope. So we would make simpler-to understand rules about inserting them, and perform the optimisation to remove them later, once we have the full picture. I can imagine identifying when a value is dropped is not trivial, though.

(The deduplication of increments is a counter example for where my assumption that observable side effects  - the printing of the ref count in this instance - should be the same between the initial and final SSA breaks down).
> But where the inc_rc and dec_rc are inserted into the SSA will remain a source of speculation about how the array is going to be used, as it is now.

No, this proposal is to remove `inc_rc` and `dec_rc`

> I wonder if we could have a concept of drop, 

I've considered trying to track when the arrays may be dropped in the past but as soon as the array gets stored in a mutable reference - which is the case with most arrays being mutated anyway - we lose almost all ability to track them since we'd have to delegate to mem2reg. You could also say we do have a drop of sorts since we do issue some `dec_rc` instructions, just only where we "know" a value won't be used afterward, e.g. an array parameter at the end of a function where no values containing arrays are returned. ("know" is in quotes due to the negative RC issue - clearly some assumptions are incorrect).

> (The deduplication of increments is a counter example for where my assumption that observable side effects - the printing of the ref count in this instance - should be the same between the initial and final SSA breaks down).

`can_be_deduplicated` should be `false` for both `inc_rc` and `dec_rc` so we shouldn't be deduplicating these instructions. We may deduplicate a constant `make_array` though - in which case we should add an extra `inc_rc`.

> we shouldn't be deduplicating these instructions.

The deduplication of consecutive RC instructions happens [here](https://github.com/noir-lang/noir/blob/25b989fe5c2a247f66cefe09587526570ce7d71a/compiler/noirc_evaluator/src/ssa/opt/die.rs#L658-L665), `can_be_deduplicated` is not involved, it's a special case. 

> we do issue some dec_rc instructions, just only where we "know" a value won't be used afterward, e.g. an array parameter at the end of a function where no values containing arrays are returned. 

As another example of why it's difficult to handle this in SSA when the RC is also in the hands of the Brillig VM, here's the SSA of such a function from my experiment:

```
    v11 = load v4 -> [Field; 3]
    inc_rc v11
    ...
    v18 = load v4 -> [Field; 3]
    v20 = array_set v18, index u32 0, value Field 6
    store v20 at v4
    v21 = load v4 -> [Field; 3]
    dec_rc v21
```
So it originally inserted an `inc_rc` and a `dec_rc` acting on the input `v4` (one of the `array: &mut [Field; 3]` parameters). What it can't know, is that `v20` may be a new array (I suppose it could, because after an `inc_rc` it will be, but in general I assume it doesn't), and that after `store v20 at v4` overwrites `v4` with this new array, `dec_rc v21` acting on a freshly loaded reference from `v4` will potentially act on an array different from what the original `inc_rc` was issued for. 

> No, this proposal is to remove inc_rc and dec_rc

Thanks for making that clear. I feared that for the same reason as above, if `dec_rc` was acting on a tuple member instead of the array it would be susceptible to fall victim of the same gotchas with forwarding rules and act on the values made by the copy-on-write procedure than the original. 
For the record, the RC rules that would make more sense to me would be along the following lines:
* Generally the caller should make the increment when they pass a borrowed value to a function, rather than the callee guessing whether to inc or not
* `fn borrow(array: [Field; 3]`: the caller should increment the RC before passing `array`, and at the end of the method it can be dropped because it goes out of scope and doesn't get stored
* `fn borrow_mut(array: &mut [Field; 3]`: unlike `borrow` I would not consider this an additional reference, just use _write_ semantics and modify the original without increasing the RC
* `fn copy_mut(mut array: [Field; 3]`: the caller should increment the RC because it's a borrow and _copy-on-write_ should be used
* `fn borrow_mut_two(array1: &mut [Field; 3], array2: &mut [Field; 3]`: like `borrow_mut`, I would argue that the RC should _not_ be increased at all (currently it increases it once for array2). That's because we want _write_ semantics and it should be the users' responsibility whether they pass reference to the same array or not, I don't think copies should be made unless there are other borrows such as `borrow` and `copy_mut` do, which imply _copy-on-write_. The effect of writing to the original seems to be the same anyway.

Then we could let the SSA figure out which inc/dec pairs it can get rid of for optimisation, but it would make it clear when the RC is increased and why. 

Returning the array or storing it in a data structure should cancel the decrement.

Is this 🌈 and 🦄 thinking?
One more observation about placing the `dec_rc` at the end is that the Brillig VM also modifies the RC of the original array during the copy procedure [here](https://github.com/noir-lang/noir/blob/25b989fe5c2a247f66cefe09587526570ce7d71a/compiler/noirc_evaluator/src/brillig/brillig_ir/procedures/array_copy.rs#L72-L73), so these two alone can end up bringing the RC to zero:

* if the final `dec_rc` acts on the original array after a copy was made, it's double decrementing
* if the final `dec_rc` acts on the copy of the array, it's bringing it from 1 to 0 immediately
* the final `dec_rc` should act on the original array, and only if no copy ended up being made

This might be fixed by https://github.com/noir-lang/noir/pull/7297#issuecomment-2640467338


> Is this 🌈 and 🦄 thinking?

Not necessarily but there are a couple of issues / practical concerns. The scheme you gave is more or less how they're intended to behave but have been changed over time as we were pressured to create smaller brillig bytecode sizes. Increasing the ref counts on each immutable function was leading to a bytecode explosion since it happens much more often and we cannot usually optimize out these cases. A related issue is that Noir doesn't have the concept of an immutable borrow which would normally be the case you'd keep the ref count the same - where a conceptual clone like we'd have where you pass by value you do normally increase it.

So we wanted to change where ref counts are to find places where less of them were needed and they were easier to optimize out. That's why we have both inc & dec within a function since then it is easier to identify when we can remove them as a pair. Versus if we had the inc outside the function we can't do that unless the function is inlined.

> Returning the array or storing it in a data structure should cancel the decrement.

How do you know if the array was returned or stored in a data structure (and returned)? Usually references are involved so you won't know if the array in this reference is the same or not. Our answer for this currently is to say if the function returns any types containing an array of the same type then we have to pessimistically assume the array in question may be returned. This should lead to counts drifting up, not down, but I point it out as something to keep in mind that we probably can't be perfect here.

Anyways - this was mostly to explain why we have our current system / how we got here since I think once you know that its easier to make changes to fix it or tear it down if necessary.

EDIT: Clarification on why we increment on mutable values currently. In the current scheme to minimize unnecessary inc/decs we:
- Assume all array values are passed as immutable references by default.
- Only when the array is taken by mutable value, copied into a let binding, or into multiple `&mut` references do we increase the ref count. This is so if you have a deeply nested series of functions with one mutation deep in the call stack, that we don't need to inc/dec the rc for every function in between.
Ah, some details I left out from our current scheme:
- Noir has no concept of moves so we approximate by incrementing ref count where an array may be copied. This is wherever a new variable binding may introduce an alias to the array. So let bindings and function parameters.
> The deduplication of consecutive RC instructions happens [here](https://github.com/noir-lang/noir/blob/25b989fe5c2a247f66cefe09587526570ce7d71a/compiler/noirc_evaluator/src/ssa/opt/die.rs#L658-L665), can_be_deduplicated is not involved, it's a special case.

Honestly, I don't see the correctness argument for that optimization - I think we should remove it entirely. Especially since counts are underflowing, one reason could be that these extra inc_rcs are required.

> As another example of why it's difficult to handle this in SSA when the RC is also in the hands of the Brillig VM, here's the SSA of such a function from my experiment:
> 
>     v11 = load v4 -> [Field; 3]
>     inc_rc v11
>     ...
>     v18 = load v4 -> [Field; 3]
>     v20 = array_set v18, index u32 0, value Field 6
>     store v20 at v4
>     v21 = load v4 -> [Field; 3]
>     dec_rc v21

Yeah, this seems bad. It makes me think that we need a larger redesign as well since right not trying to fix it up seems to me like it'd just be "garbage in, garbage out."

**Move & Copy Elision Optimization scheme**

I think going forward for more confidence in correctness & still having some optimizations we could:
- Start with a scheme we have more confidence to be correct e.g. the one you described where we don't increment rc for references but do for passing by value.
- Remove existing optimizations on ref counts. We won't need them.
- Add an analysis to determine where an array is moved versus where it needs to be copied. This will need to be done before we issue inc/decs so before ssa-gen. We should issue explicit `clone(array)` IR nodes at this point (which probably just inc_rc internally).
- Add an analysis to try to figure out which functions can immutably borrow an array (or just add immutable borrows to the language) so that those functions will not need to conceptually clone the array (inc_rc) either. This could be part of the previous pass since otherwise their ordering would be important since the move pass may issue a copy for a function that later turned out could have immutably borrowed the array. Note that an immutable borrow analysis will have to be conservative. If we have a function like `fn foo(a: [Field; 1], b: &mut [Field; 1])` we have to assume `a` and `b` could refer to the same array so `a` still can't be borrowed even if never mutated.
Sounds good! 

> since right not trying to fix it up seems to me like it'd just be "garbage in, garbage out."

I hope that this particular issue will be fixed by https://github.com/noir-lang/noir/pull/7297
**Expose immutable references directly scheme**

Spoke with @michaeljklein and @aakoshh and another option we're more strongly considering now is to add immutable references to Noir. If we do that:
- Any by-value use of an array type would incur a copy
- We would need to add a cloning operation to the language which would clone the array and unwrap it from the reference
- Where arrays are cloned becomes a user's issue for optimizing their own code.
- The compiler is simpler, we need fewer optimizations on RCs and testing on arrays becomes easier.
- We could remove RCs entirely, copying every time a full array value is used as mentioned above, or keep rcs and just increase the reference count on a clone. This later case may be an easier transition but the compiler could still be copying unnecessarily since we'd still not be able to determine in all cases when to `dec_rc` if references are involved. For the always copy case above though, you could argue users still unnecessarily copy e.g. if using arrays in generics instead of array references in generics, although this could also be considered a user problem in that case.

This is something I'd support experimenting with. We would have to update libraries to take references where possible but could compare performance afterward.



Fix (patch):
diff --git a/compiler/noirc_driver/src/lib.rs b/compiler/noirc_driver/src/lib.rs
index 6c28a4e4af6..7c098e1a5d1 100644
--- a/compiler/noirc_driver/src/lib.rs
+++ b/compiler/noirc_driver/src/lib.rs
@@ -687,7 +687,6 @@ pub fn compile_no_check(
     force_compile: bool,
 ) -> Result<CompiledProgram, CompileError> {
     let force_unconstrained = options.force_brillig;
-    let experimental_ownership = options.unstable_features.contains(&UnstableFeature::Ownership);
 
     let program = if options.instrument_debug {
         monomorphize_debug(
@@ -695,15 +694,9 @@ pub fn compile_no_check(
             &mut context.def_interner,
             &context.debug_instrumenter,
             force_unconstrained,
-            experimental_ownership,
         )?
     } else {
-        monomorphize(
-            main_function,
-            &mut context.def_interner,
-            force_unconstrained,
-            experimental_ownership,
-        )?
+        monomorphize(main_function, &mut context.def_interner, force_unconstrained)?
     };
 
     if options.show_monomorphized {
diff --git a/compiler/noirc_frontend/src/monomorphization/mod.rs b/compiler/noirc_frontend/src/monomorphization/mod.rs
index 423ab6e472a..2b7f69336ea 100644
--- a/compiler/noirc_frontend/src/monomorphization/mod.rs
+++ b/compiler/noirc_frontend/src/monomorphization/mod.rs
@@ -138,15 +138,8 @@ pub fn monomorphize(
     main: node_interner::FuncId,
     interner: &mut NodeInterner,
     force_unconstrained: bool,
-    enable_ownership: bool,
 ) -> Result<Program, MonomorphizationError> {
-    monomorphize_debug(
-        main,
-        interner,
-        &DebugInstrumenter::default(),
-        force_unconstrained,
-        enable_ownership,
-    )
+    monomorphize_debug(main, interner, &DebugInstrumenter::default(), force_unconstrained)
 }
 
 pub fn monomorphize_debug(
@@ -154,7 +147,6 @@ pub fn monomorphize_debug(
     interner: &mut NodeInterner,
     debug_instrumenter: &DebugInstrumenter,
     force_unconstrained: bool,
-    experimental_ownership: bool,
 ) -> Result<Program, MonomorphizationError> {
     let debug_type_tracker = DebugTypeTracker::build_from_debug_instrumenter(debug_instrumenter);
     let mut monomorphizer = Monomorphizer::new(interner, debug_type_tracker);
@@ -211,11 +203,7 @@ pub fn monomorphize_debug(
         debug_functions,
         debug_types,
     );
-    Ok(program.handle_ownership(
-        monomorphizer.next_local_id,
-        monomorphizer.next_ident_id,
-        experimental_ownership,
-    ))
+    Ok(program.handle_ownership())
 }
 
 impl<'interner> Monomorphizer<'interner> {
diff --git a/compiler/noirc_frontend/src/ownership/last_uses.rs b/compiler/noirc_frontend/src/ownership/last_uses.rs
index 810924c92ac..334c5ecca4e 100644
--- a/compiler/noirc_frontend/src/ownership/last_uses.rs
+++ b/compiler/noirc_frontend/src/ownership/last_uses.rs
@@ -428,46 +428,3 @@ impl LastUseContext {
         }
     }
 }
-
-#[cfg(test)]
-mod test {
-    use crate::{
-        monomorphization::ast::{IdentId, LocalId},
-        test_utils::get_monomorphized,
-    };
-
-    #[test]
-    fn smoke_test() {
-        let src = "
-        fn main(d: [Field; 2]) {
-            if len(d) == 2 {              // use 1 of d
-                if len(d) == 2 {          // use 2 of d
-                    assert(eq(d, [5, 6]));  // use 3 of d
-                }
-            } else {
-                assert(eq(d, [5, 6]));      // use 4 of d
-            }
-        }
-
-        fn eq(lhs: [Field; 2], rhs: [Field; 2]) -> bool {
-            (lhs[0] == rhs[0]) & (lhs[1] == rhs[1])
-        }
-        
-        fn len(arr: [Field; 2]) -> u32 {
-            2
-        }
-        ";
-
-        let program = get_monomorphized(src, None, crate::test_utils::Expect::Success).unwrap();
-
-        let function = program.main();
-        let last_uses = super::Context::find_last_uses_of_variables(function);
-
-        let d_local_id = LocalId(0);
-        let d_last_uses = &last_uses[&d_local_id];
-
-        // We should improve testing of this pass so that it is more clear where these ids
-        // correspond to.
-        assert_eq!(*d_last_uses, &[IdentId(7), IdentId(10)]);
-    }
-}
diff --git a/compiler/noirc_frontend/src/ownership/mod.rs b/compiler/noirc_frontend/src/ownership/mod.rs
index 4a5377ca455..2f09d4de9da 100644
--- a/compiler/noirc_frontend/src/ownership/mod.rs
+++ b/compiler/noirc_frontend/src/ownership/mod.rs
@@ -14,44 +14,8 @@
 //! Arrays in brillig have copy on write semantics which relies on us incrementing their
 //! reference counts when they are shared in multiple places. Note that while Noir has references,
 //! arrays can also be shared by value and we want to avoid clones when possible. This pass
-//! clones arrays (increments their reference counts) in the following situations:
-//! - Function parameters:
-//!   - Any arrays behind a mutable reference `&mut [T; N]` will have their reference count
-//!     incremented iff there was already a prior array of the same type passed into the same
-//!     function. E.g. if there are two parameters of type `&mut [Field; 3]` we increment only
-//!     the later. If there are 3 we increment the last two.
-//!     - This applies within struct & tuple types as well. If a function only takes 1 struct
-//!       parameter but that struct contains 2 or more mutable references to the same array
-//!       type, we increment the reference count of each instance of the type after the first.
-//!     - In the case of references to nested arrays, only the outer array has its reference count incremented.
-//!   - Arrays taken by mutable value are always cloned, e.g. in `mut x: [u32; 3]`, `x` will
-//!     have its reference count incremented.
-//! - Let bindings (`let _ = <expression which returns an array>;`):
-//!   - Binding an array to a let binding increments the reference count of the array unless
-//!     the expression is an array literal in which case it is considered to be moved.
-//! - Assignments (`x = <expression which returns an array>;`):
-//!   - Similarly, assigning an array to an existing variable will also increment the reference
-//!     count of the array unless it is an array literal.
-//! - Array literals:
-//!   - Arrays stored inside a nested array literal (e.g. both variables in `[array1, array2]`
-//!     have their reference count incremented).
-//!   - This does not apply to nested array literals since we know they are not referenced elsewhere.
-//! - Extracting an array from another array (`let inner: [_; _] = array[0];`):
-//!   - Extracting a nested array from its outer array will always increment the reference count
-//!     of the nested array.
-//!
-//! Additionally we currently only decrement reference counts at the end of the function when
-//! a parameter goes out of scope. These means reference counts likely trend upward over time
-//! until the array is eventually mutated and it is reset back to 1.
-//!
-//! --------------------------------- EXPERIMENTAL OWNERSHIP RULES ---------------------------------
-//!
-//! This pass currently contains two sets of ownership rules. There is the current default set of
-//! rules described above, and there is the set of rules enabled by `Context::experimental_ownership_feature`.
-//! The experimental ownership rules aim to be less ad-hoc than the current rules with the goal of
-//! making it easier for users to see where clones occur - and possibly forcing users to write
-//! their own clones manually. These rules treat each variable roughly as a variable in Rust which
-//! implements `Copy`:
+//! clones arrays (increments their reference counts) in the following situations which roughly
+//! correspond to where a `Copy` variable in Rust would be copied:
 //! - Variables are copied on each use, except for the last use where they are moved.
 //!   - If a variable's last use is in a loop that it was not defined in, it is copied instead of moved.
 //!   - The last use analysis isn't sophisticated on struct fields. It will count `a.b` and `a.c`
@@ -73,30 +37,19 @@
 use crate::{
     ast::UnaryOp,
     monomorphization::ast::{
-        Definition, Expression, Function, Ident, IdentId, LValue, Let, Literal, LocalId,
-        Parameters, Program, Type, Unary,
+        Definition, Expression, Function, Ident, IdentId, LValue, Literal, LocalId, Program, Type,
+        Unary,
     },
 };
 
 use fxhash::FxHashMap as HashMap;
-use fxhash::FxHashSet as HashSet;
-use noirc_errors::Location;
 
 mod last_uses;
+mod tests;
 
 impl Program {
-    pub(crate) fn handle_ownership(
-        mut self,
-        next_local_id: u32,
-        next_ident_id: u32,
-        experimental_ownership_feature: bool,
-    ) -> Self {
-        let mut context = Context {
-            experimental_ownership_feature,
-            next_ident_id,
-            next_local_id,
-            variables_to_move: Default::default(),
-        };
+    pub(crate) fn handle_ownership(mut self) -> Self {
+        let mut context = Context { variables_to_move: Default::default() };
 
         for function in self.functions.iter_mut() {
             context.handle_ownership_in_function(function);
@@ -106,29 +59,11 @@ impl Program {
 }
 
 struct Context {
-    experimental_ownership_feature: bool,
-
-    /// If `experimental_ownership_feature` is enabled, this contains each instance of a variable
-    /// we should move instead of cloning.
+    /// This contains each instance of a variable we should move instead of cloning.
     variables_to_move: HashMap<LocalId, Vec<IdentId>>,
-
-    next_ident_id: u32,
-    next_local_id: u32,
 }
 
 impl Context {
-    fn next_ident_id(&mut self) -> IdentId {
-        let id = self.next_ident_id;
-        self.next_ident_id += 1;
-        IdentId(id)
-    }
-
-    fn next_local_id(&mut self) -> LocalId {
-        let id = self.next_local_id;
-        self.next_local_id += 1;
-        LocalId(id)
-    }
-
     fn should_move(&self, definition: LocalId, variable: IdentId) -> bool {
         self.variables_to_move
             .get(&definition)
@@ -140,211 +75,10 @@ impl Context {
             return;
         }
 
-        if self.experimental_ownership_feature {
-            self.variables_to_move = Self::find_last_uses_of_variables(function);
-        }
-
+        self.variables_to_move = Self::find_last_uses_of_variables(function);
         self.handle_expression(&mut function.body);
-
-        if !self.experimental_ownership_feature {
-            let new_bindings = self.collect_parameters_to_clone(&function.parameters);
-
-            // Prepend new_bindings to the function body and insert drops for them at the end.
-            if !new_bindings.is_empty() {
-                let unit = Expression::Literal(Literal::Unit);
-                let old_body = std::mem::replace(&mut function.body, unit);
-
-                // Store anything we want to clone in let bindings first so when we later drop
-                // them we know we're dropping the same instance rather than a fresh copy.
-                let (mut new_body, new_idents) = self.create_let_bindings(new_bindings);
-
-                // Now push the clones for each parameter
-                for new_ident in &new_idents {
-                    new_body.push(Expression::Clone(Box::new(new_ident.clone())));
-                }
-
-                // Insert a `let` for the returned value so we can insert drops after it
-                let return_id = self.next_local_id();
-                let return_let = Expression::Let(Let {
-                    id: return_id,
-                    mutable: false,
-                    name: "return".to_string(),
-                    expression: Box::new(old_body),
-                });
-
-                new_body.push(return_let);
-
-                // Now drop each parameter we cloned
-                for new_ident in new_idents {
-                    new_body.push(Expression::Drop(Box::new(new_ident)));
-                }
-
-                // Finally, return the original return value we held on to
-                new_body.push(Expression::Ident(Ident {
-                    location: None,
-                    definition: Definition::Local(return_id),
-                    mutable: false,
-                    name: "return".to_string(),
-                    typ: function.return_type.clone(),
-                    id: self.next_ident_id(),
-                }));
-
-                function.body = Expression::Block(new_body);
-            }
-        }
-    }
-
-    fn create_let_bindings(
-        &mut self,
-        bindings_to_create: Vec<(String, Type, Expression)>,
-    ) -> (Vec<Expression>, Vec<Expression>) {
-        let mut bindings = Vec::with_capacity(bindings_to_create.len());
-        let mut idents = Vec::with_capacity(bindings_to_create.len());
-
-        for (name, typ, expression) in bindings_to_create {
-            let id = self.next_local_id();
-            let expression = Box::new(expression);
-            bindings.push(Expression::Let(Let {
-                id,
-                mutable: false,
-                name: String::new(),
-                expression,
-            }));
-
-            idents.push(Expression::Ident(Ident {
-                location: None,
-                definition: Definition::Local(id),
-                mutable: false,
-                name,
-                typ,
-                id: self.next_ident_id(),
-            }));
-        }
-
-        (bindings, idents)
     }
 
-    /// Returns a vector of new parameters to prepend clones to a function - if any.
-    /// Note that these may be full expressions e.g. `*param.field` so they should
-    /// be stored in a let binding before being cloned to ensure that a later drop
-    /// would be to the same value.
-    fn collect_parameters_to_clone(
-        &mut self,
-        parameters: &Parameters,
-    ) -> Vec<(String, Type, Expression)> {
-        let mut seen_array_types = HashSet::default();
-        let mut new_bindings = Vec::new();
-
-        for (parameter_id, mutable, name, parameter_type) in parameters {
-            let parameter = Expression::Ident(Ident {
-                location: None,
-                definition: Definition::Local(*parameter_id),
-                mutable: *mutable,
-                name: name.clone(),
-                typ: parameter_type.clone(),
-                id: self.next_ident_id(),
-            });
-
-            // (by-value) Mutable parameters are always cloned. Otherwise, we have to recur on the type
-            // to find a duplicate array types behind mutable references.
-            let parameter = if *mutable {
-                let name = name.clone();
-                new_bindings.push((name, parameter_type.clone(), parameter));
-                // disable cloning in recur_on_parameter, we already cloned
-                None
-            } else {
-                Some(parameter)
-            };
-
-            recur_on_parameter(
-                parameter,
-                parameter_type,
-                name,
-                &mut seen_array_types,
-                &mut new_bindings,
-                false,
-            );
-        }
-
-        new_bindings
-    }
-}
-
-/// Recur on a parameter's type, digging into any struct fields, looking for references to arrays.
-/// This will build up an Expression of the current parameter access we're doing, e.g. `*foo.bar`
-/// would correspond to a parameter `foo` with struct field `bar` that is a reference to an array.
-///
-/// This function inserts a .clone() expression to any parameter arrays behind references with
-/// repeated types since these may potentially be aliased by other parameters.
-///
-/// If `parameter` is `None` we'll still traverse the type to find any array types mentioned but we
-/// will not issue any clones. This is required e.g. on a mutable by-value parameter like `mut x: ...`
-/// since `x` may contain arrays internally that we'll need to remember in case there is another
-/// parameter which uses them. E.g. `mut x: [Field; 2], y: &mut [Field; 2]`.
-fn recur_on_parameter<'typ>(
-    parameter: Option<Expression>,
-    parameter_type: &'typ Type,
-    parameter_name: &str,
-    seen_array_types: &mut HashSet<&'typ Type>,
-    new_bindings: &mut Vec<(String, Type, Expression)>,
-    passed_reference: bool,
-) {
-    match parameter_type {
-        // These types never contain arrays
-        Type::Field | Type::Integer(..) | Type::Bool | Type::Unit | Type::Function(..) => (),
-
-        Type::Array(..) | Type::Slice(_) | Type::String(_) | Type::FmtString(..) => {
-            // If we've already seen this type and this is behind a reference
-            if !seen_array_types.insert(parameter_type) && passed_reference {
-                if let Some(parameter) = parameter {
-                    new_bindings.push((
-                        parameter_name.to_string(),
-                        parameter_type.clone(),
-                        parameter,
-                    ));
-                }
-            }
-
-            // Don't recur on the element type here, we rely on the reference count to already be
-            // incremented in the nested array case when the nested array is created.
-        }
-
-        Type::Tuple(fields) => {
-            for (i, field) in fields.iter().enumerate() {
-                let expr = parameter.clone().map(|p| Expression::ExtractTupleField(Box::new(p), i));
-                recur_on_parameter(
-                    expr,
-                    field,
-                    parameter_name,
-                    seen_array_types,
-                    new_bindings,
-                    passed_reference,
-                );
-            }
-        }
-
-        Type::Reference(element_type, _mutable) => {
-            let expr = parameter.map(|parameter| {
-                Expression::Unary(Unary {
-                    operator: UnaryOp::Dereference { implicitly_added: true },
-                    rhs: Box::new(parameter.clone()),
-                    result_type: element_type.as_ref().clone(),
-                    location: Location::dummy(), // TODO
-                })
-            });
-            recur_on_parameter(
-                expr,
-                element_type,
-                parameter_name,
-                seen_array_types,
-                new_bindings,
-                true,
-            );
-        }
-    }
-}
-
-impl Context {
     fn handle_expression(&mut self, expr: &mut Expression) {
         match expr {
             Expression::Ident(_) => self.handle_ident(expr),
@@ -378,8 +112,10 @@ impl Context {
     }
 
     /// Handle the rhs of a `&expr` unary expression.
-    /// When the experimental ownership flag is enabled variables and field accesses
-    /// in these expressions are exempt from clones.
+    /// Variables and field accesses in these expressions are exempt from clones.
+    ///
+    /// Note that this also matches on dereference operations to exempt their LHS from clones,
+    /// but their LHS is always exempt from clones so this is unchanged.
     fn handle_reference_expression(&mut self, expr: &mut Expression) {
         match expr {
             Expression::Ident(_) => (),
@@ -409,13 +145,8 @@ impl Context {
             panic!("handle_extract_expression given non-extract expression {expr}");
         };
 
-        if !self.experimental_ownership_feature {
-            return self.handle_expression(tuple);
-        }
-
-        // When experimental ownership is enabled, we may clone identifiers. We want to avoid
-        // cloning the entire object though if we're only accessing one field of it so we check
-        // here to move the clone to the outermost extract expression instead.
+        // We want to avoid cloning the entire object if we're only accessing one field of it
+        // so we check here to move the clone to the outermost extract expression instead.
         // E.g. we want to change `a.clone().b.c` to `a.b.c.clone()`.
         if let Some((should_clone, tuple_type)) = self.handle_extract_expression_rec(tuple) {
             if let Some(elements) = unwrap_tuple_type(tuple_type) {
@@ -456,15 +187,11 @@ impl Context {
         }
     }
 
-    /// Under the experimental alternate ownership scheme, whenever an ident is used it is
-    /// always cloned unless it is the last use of the ident (not in a loop).
+    /// Whenever an ident is used it is always cloned unless it is the last use of the ident (not in a loop).
     fn should_clone_ident(&self, ident: &Ident) -> bool {
-        if self.experimental_ownership_feature {
-            if let Definition::Local(local_id) = &ident.definition {
-                if contains_array_or_str_type(&ident.typ) && !self.should_move(*local_id, ident.id)
-                {
-                    return true;
-                }
+        if let Definition::Local(local_id) = &ident.definition {
+            if contains_array_or_str_type(&ident.typ) && !self.should_move(*local_id, ident.id) {
+                return true;
             }
         }
         false
@@ -481,10 +208,6 @@ impl Context {
         }
     }
 
-    /// - Array literals:
-    ///   - Arrays stored inside a nested array literal (e.g. both variables in `[array1, array2]`
-    ///     have their reference count incremented).
-    ///   - This does not apply to nested array literals since we know they are not referenced elsewhere.
     fn handle_literal(&mut self, literal: &mut Literal) {
         match literal {
             Literal::Integer(..) | Literal::Bool(_) | Literal::Unit | Literal::Str(_) => (),
@@ -492,19 +215,8 @@ impl Context {
             Literal::FmtStr(_, _, captures) => self.handle_expression(captures),
 
             Literal::Array(array) | Literal::Slice(array) => {
-                let element_type = array
-                    .typ
-                    .array_element_type()
-                    .expect("Array literal should have an array type");
-
-                if !self.experimental_ownership_feature && contains_array_or_str_type(element_type)
-                {
-                    // We have to clone nested arrays unless they are array literals
-                    for element in array.contents.iter_mut() {
-                        if !is_array_or_str_literal(element) {
-                            clone_expr(element);
-                        }
-                    }
+                for element in array.contents.iter_mut() {
+                    self.handle_expression(element);
                 }
             }
         }
@@ -519,16 +231,13 @@ impl Context {
         // Don't clone `rhs` if this is a reference or dereference expression.
         // - If this is a reference expression `&rhs`, `rhs` by definition shouldn't be cloned
         // - If this is `*rhs` we're going to clone the extracted element instead
-        if self.experimental_ownership_feature
-            && matches!(unary.operator, UnaryOp::Reference { .. } | UnaryOp::Dereference { .. })
-        {
+        if matches!(unary.operator, UnaryOp::Reference { .. } | UnaryOp::Dereference { .. }) {
             self.handle_reference_expression(&mut unary.rhs);
         } else {
             self.handle_expression(&mut unary.rhs);
         }
 
-        if self.experimental_ownership_feature
-            && matches!(unary.operator, UnaryOp::Dereference { .. })
+        if matches!(unary.operator, UnaryOp::Dereference { .. })
             && contains_array_or_str_type(&unary.result_type)
         {
             clone_expr(expr);
@@ -540,21 +249,13 @@ impl Context {
         self.handle_expression(&mut binary.rhs);
     }
 
-    /// - Extracting an array from another array (`let inner: [_; _] = array[0];`):
-    ///   - Extracting a nested array from its outer array will always increment the reference count
-    ///     of the nested array.
     fn handle_index(&mut self, index_expr: &mut Expression) {
         let crate::monomorphization::ast::Expression::Index(index) = index_expr else {
             panic!("handle_index should only be called with Index nodes");
         };
 
-        if self.experimental_ownership_feature {
-            // Don't clone the collection, cloning only the resulting element is cheaper
-            self.handle_reference_expression(&mut index.collection);
-        } else {
-            self.handle_expression(&mut index.collection);
-        }
-
+        // Don't clone the collection, cloning only the resulting element is cheaper.
+        self.handle_reference_expression(&mut index.collection);
         self.handle_expression(&mut index.index);
 
         if contains_array_or_str_type(&index.element_type) {
@@ -608,15 +309,8 @@ impl Context {
         }
     }
 
-    /// - Let bindings (`let _ = <expression which returns an array>;`):
-    ///   - Binding an array to a let binding increments the reference count of the array unless
-    ///     the expression is an array literal in which case it is considered to be moved.
     fn handle_let(&mut self, let_expr: &mut crate::monomorphization::ast::Let) {
         self.handle_expression(&mut let_expr.expression);
-
-        if !self.experimental_ownership_feature && !is_array_or_str_literal(&let_expr.expression) {
-            clone_expr(&mut let_expr.expression);
-        }
     }
 
     fn handle_constrain(
@@ -631,20 +325,15 @@ impl Context {
         }
     }
 
-    /// - Assignments (`x = <expression which returns an array>;`):
-    ///   - Assigning an array to an existing variable will also increment the reference
-    ///     count of the array unless it is an array literal.
     fn handle_assign(&mut self, assign: &mut crate::monomorphization::ast::Assign) {
         self.handle_lvalue(&mut assign.lvalue);
         self.handle_expression(&mut assign.expression);
-
-        if !self.experimental_ownership_feature && !is_array_or_str_literal(&assign.expression) {
-            clone_expr(&mut assign.expression);
-        }
     }
 
     fn handle_lvalue(&mut self, lvalue: &mut LValue) {
         match lvalue {
+            // A variable can never be moved into an LValue position so it doesn't
+            // need to be cloned or checked here.
             LValue::Ident(_) => (),
             LValue::Index { array, index, element_type: _, location: _ } => {
                 self.handle_expression(index);
@@ -669,45 +358,6 @@ fn clone_expr(expr: &mut Expression) {
     *expr = Expression::Clone(Box::new(old_expr));
 }
 
-fn is_array_or_str_literal(expr: &Expression) -> bool {
-    match expr {
-        Expression::Literal(literal) => match literal {
-            Literal::Integer(..) | Literal::Bool(_) | Literal::Unit => false,
-
-            Literal::Array(_) | Literal::Slice(_) | Literal::Str(_) | Literal::FmtStr(..) => true,
-        },
-        Expression::Block(exprs) => {
-            if let Some(expr) = exprs.last() {
-                is_array_or_str_literal(expr)
-            } else {
-                false
-            }
-        }
-
-        Expression::Unary(_)
-        | Expression::Ident(_)
-        | Expression::Binary(_)
-        | Expression::Index(_)
-        | Expression::Cast(_)
-        | Expression::For(_)
-        | Expression::Loop(_)
-        | Expression::While(_)
-        | Expression::If(_)
-        | Expression::Match(_)
-        | Expression::Tuple(_)
-        | Expression::ExtractTupleField(_, _)
-        | Expression::Call(_)
-        | Expression::Let(_)
-        | Expression::Constrain(..)
-        | Expression::Assign(_)
-        | Expression::Semi(_)
-        | Expression::Clone(_)
-        | Expression::Drop(_)
-        | Expression::Break
-        | Expression::Continue => false,
-    }
-}
-
 fn contains_array_or_str_type(typ: &Type) -> bool {
     match typ {
         Type::Field
diff --git a/tooling/nargo_cli/src/cli/check_cmd.rs b/tooling/nargo_cli/src/cli/check_cmd.rs
index 48ace1646ed..f4c0a8e260f 100644
--- a/tooling/nargo_cli/src/cli/check_cmd.rs
+++ b/tooling/nargo_cli/src/cli/check_cmd.rs
@@ -59,9 +59,7 @@ pub(crate) fn run(args: CheckCommand, workspace: Workspace) -> Result<(), CliErr
             let Some(main) = context.get_main_function(&crate_id) else {
                 continue;
             };
-            use noirc_frontend::elaborator::UnstableFeature::Ownership;
-            let ownership = args.compile_options.unstable_features.contains(&Ownership);
-            let program = monomorphize(main, &mut context.def_interner, false, ownership).unwrap();
+            let program = monomorphize(main, &mut context.def_interner, false).unwrap();
             let hash = fxhash::hash64(&program);
             println!("{}: {:x}", package.name, hash);
             continue;