Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(ssa): Hoisting of array get using known induction variable maximum #6639

Merged
merged 10 commits into from
Dec 2, 2024
201 changes: 193 additions & 8 deletions compiler/noirc_evaluator/src/ssa/opt/loop_invariant.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,16 @@
//! - Already marked as loop invariants
//!
//! We also check that we are not hoisting instructions with side effects.
use fxhash::FxHashSet as HashSet;
use acvm::{acir::AcirField, FieldElement};
use fxhash::{FxHashMap as HashMap, FxHashSet as HashSet};

use crate::ssa::{
ir::{
basic_block::BasicBlockId,
function::{Function, RuntimeType},
function_inserter::FunctionInserter,
instruction::InstructionId,
instruction::{Instruction, InstructionId},
types::Type,
value::ValueId,
},
Ssa,
Expand Down Expand Up @@ -45,25 +47,51 @@ impl Function {
}

impl Loops {
fn hoist_loop_invariants(self, function: &mut Function) {
fn hoist_loop_invariants(mut self, function: &mut Function) {
let mut context = LoopInvariantContext::new(function);

for loop_ in self.yet_to_unroll.iter() {
// The loops should be sorted by the number of blocks.
// We want to access outer nested loops first, which we do by popping
// from the top of the list.
while let Some(loop_) = self.yet_to_unroll.pop() {
let Ok(pre_header) = loop_.get_pre_header(context.inserter.function, &self.cfg) else {
// If the loop does not have a preheader we skip hoisting loop invariants for this loop
continue;
};
context.hoist_loop_invariants(loop_, pre_header);

context.hoist_loop_invariants(&loop_, pre_header);
}

context.map_dependent_instructions();
}
}

impl Loop {
/// Find the value that controls whether to perform a loop iteration.
/// This is going to be the block parameter of the loop header.
///
/// Consider the following example of a `for i in 0..4` loop:
/// ```text
/// brillig(inline) fn main f0 {
/// b0(v0: u32):
/// ...
/// jmp b1(u32 0)
/// b1(v1: u32): // Loop header
/// v5 = lt v1, u32 4 // Upper bound
/// jmpif v5 then: b3, else: b2
/// ```
/// In the example above, `v1` is the induction variable
fn get_induction_variable(&self, function: &Function) -> ValueId {
function.dfg.block_parameters(self.header)[0]
}
}

struct LoopInvariantContext<'f> {
inserter: FunctionInserter<'f>,
defined_in_loop: HashSet<ValueId>,
loop_invariants: HashSet<ValueId>,
// Maps induction variable -> fixed upper loop bound
outer_induction_variables: HashMap<ValueId, FieldElement>,
}

impl<'f> LoopInvariantContext<'f> {
Expand All @@ -72,6 +100,7 @@ impl<'f> LoopInvariantContext<'f> {
inserter: FunctionInserter::new(function),
defined_in_loop: HashSet::default(),
loop_invariants: HashSet::default(),
outer_induction_variables: HashMap::default(),
}
}

Expand All @@ -88,13 +117,29 @@ impl<'f> LoopInvariantContext<'f> {
self.inserter.push_instruction(instruction_id, *block);
}

self.update_values_defined_in_loop_and_invariants(instruction_id, hoist_invariant);
self.extend_values_defined_in_loop_and_invariants(instruction_id, hoist_invariant);
}
}

// Keep track of a loop induction variable and respective upper bound.
// This will be used by later loops to determine whether they have operations
// reliant upon the maximum induction variable.
let upper_bound = loop_.get_const_upper_bound(self.inserter.function);
if let Some(upper_bound) = upper_bound {
let induction_variable = loop_.get_induction_variable(self.inserter.function);
let induction_variable = self.inserter.resolve(induction_variable);
self.outer_induction_variables.insert(induction_variable, upper_bound);
}
}

/// Gather the variables declared within the loop
fn set_values_defined_in_loop(&mut self, loop_: &Loop) {
// Clear any values that may be defined in previous loops, as the context is per function.
self.defined_in_loop.clear();
// These are safe to keep per function, but we want to be clear that these values
// are used per loop.
self.loop_invariants.clear();

for block in loop_.blocks.iter() {
let params = self.inserter.function.dfg.block_parameters(*block);
self.defined_in_loop.extend(params);
Expand All @@ -107,7 +152,7 @@ impl<'f> LoopInvariantContext<'f> {

/// Update any values defined in the loop and loop invariants after a
/// analyzing and re-inserting a loop's instruction.
fn update_values_defined_in_loop_and_invariants(
fn extend_values_defined_in_loop_and_invariants(
&mut self,
instruction_id: InstructionId,
hoist_invariant: bool,
Expand Down Expand Up @@ -143,9 +188,45 @@ impl<'f> LoopInvariantContext<'f> {
is_loop_invariant &=
!self.defined_in_loop.contains(&value) || self.loop_invariants.contains(&value);
});
is_loop_invariant && instruction.can_be_deduplicated(&self.inserter.function.dfg, false)

let can_be_deduplicated = instruction
.can_be_deduplicated(&self.inserter.function.dfg, false)
|| self.can_be_deduplicated_from_upper_bound(&instruction);

is_loop_invariant && can_be_deduplicated
}

/// Certain instructions can take advantage of that our induction variable has a fixed maximum.
///
/// For example, an array access can usually only be safely deduplicated when we have a constant
/// index that is below the length of the array.
/// Checking an array get where the index is the loop's induction variable on its own
/// would determine that the instruction is not safe for hoisting.
/// However, if we know that the induction variable's upper bound will always be in bounds of the array
/// we can safely hoist the array access.
fn can_be_deduplicated_from_upper_bound(&self, instruction: &Instruction) -> bool {
match instruction {
Instruction::ArrayGet { array, index } => {
let array_typ = self.inserter.function.dfg.type_of_value(*array);
let upper_bound = self.outer_induction_variables.get(index);
if let (Type::Array(_, len), Some(upper_bound)) = (array_typ, upper_bound) {
upper_bound.to_u128() as usize <= len
} else {
false
}
}
_ => false,
}
}

/// Loop invariant hoisting only operates over loop instructions.
/// The `FunctionInserter` is used for mapping old values to new values after
/// re-inserting loop invariant instructions.
/// However, there may be instructions which are not within loops that are
/// still reliant upon the instruction results altered during the pass.
/// This method re-inserts all instructions so that all instructions have
/// correct new value IDs based upon the `FunctionInserter` internal map.
/// Leaving out this mapping could lead to instructions with values that do not exist.
fn map_dependent_instructions(&mut self) {
vezenovm marked this conversation as resolved.
Show resolved Hide resolved
let blocks = self.inserter.function.reachable_blocks();
for block in blocks {
Expand Down Expand Up @@ -375,4 +456,108 @@ mod test {
// The code should be unchanged
assert_normalized_ssa_equals(ssa, src);
}

#[test]
fn hoist_array_gets_using_induction_variable_with_const_bound() {
// SSA for the following program:
//
// fn triple_loop(x: u32) {
// let arr = [2; 5];
// for i in 0..4 {
// for j in 0..4 {
// for _ in 0..4 {
// assert_eq(arr[i], x);
// assert_eq(arr[j], x);
// }
// }
// }
// }
//
// `arr[i]` and `arr[j]` are safe to hoist as we know the maximum possible index
// to be used for both array accesses.
// We want to make sure `arr[i]` is hoisted to the outermost loop body and that
// `arr[j]` is hoisted to the second outermost loop body.
let src = "
brillig(inline) fn main f0 {
b0(v0: u32, v1: u32):
v6 = make_array [u32 2, u32 2, u32 2, u32 2, u32 2] : [u32; 5]
inc_rc v6
jmp b1(u32 0)
b1(v2: u32):
v9 = lt v2, u32 4
jmpif v9 then: b3, else: b2
b3():
jmp b4(u32 0)
b4(v3: u32):
v10 = lt v3, u32 4
jmpif v10 then: b6, else: b5
b6():
jmp b7(u32 0)
b7(v4: u32):
v13 = lt v4, u32 4
jmpif v13 then: b9, else: b8
b9():
v15 = array_get v6, index v2 -> u32
v16 = eq v15, v0
constrain v15 == v0
v17 = array_get v6, index v3 -> u32
v18 = eq v17, v0
constrain v17 == v0
v19 = add v4, u32 1
jmp b7(v19)
b8():
v14 = add v3, u32 1
jmp b4(v14)
b5():
v12 = add v2, u32 1
jmp b1(v12)
b2():
return
}
";

let ssa = Ssa::from_str(src).unwrap();

let expected = "
brillig(inline) fn main f0 {
b0(v0: u32, v1: u32):
v6 = make_array [u32 2, u32 2, u32 2, u32 2, u32 2] : [u32; 5]
inc_rc v6
jmp b1(u32 0)
b1(v2: u32):
v9 = lt v2, u32 4
jmpif v9 then: b3, else: b2
b3():
v10 = array_get v6, index v2 -> u32
v11 = eq v10, v0
jmp b4(u32 0)
b4(v3: u32):
v12 = lt v3, u32 4
jmpif v12 then: b6, else: b5
b6():
v15 = array_get v6, index v3 -> u32
v16 = eq v15, v0
jmp b7(u32 0)
b7(v4: u32):
v17 = lt v4, u32 4
jmpif v17 then: b9, else: b8
b9():
constrain v10 == v0
constrain v15 == v0
v19 = add v4, u32 1
jmp b7(v19)
b8():
v18 = add v3, u32 1
jmp b4(v18)
b5():
v14 = add v2, u32 1
jmp b1(v14)
b2():
return
}
";

let ssa = ssa.loop_invariant_code_motion();
assert_normalized_ssa_equals(ssa, expected);
}
}
4 changes: 2 additions & 2 deletions compiler/noirc_evaluator/src/ssa/opt/unrolling.rs
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,7 @@
pub(super) struct Loop {
/// The header block of a loop is the block which dominates all the
/// other blocks in the loop.
header: BasicBlockId,
pub(super) header: BasicBlockId,

/// The start of the back_edge n -> d is the block n at the end of
/// the loop that jumps back to the header block d which restarts the loop.
Expand Down Expand Up @@ -299,7 +299,7 @@
/// v5 = lt v1, u32 4 // Upper bound
/// jmpif v5 then: b3, else: b2
/// ```
fn get_const_upper_bound(&self, function: &Function) -> Option<FieldElement> {
pub(super) fn get_const_upper_bound(&self, function: &Function) -> Option<FieldElement> {
let block = &function.dfg[self.header];
let instructions = block.instructions();
assert_eq!(
Expand Down Expand Up @@ -1023,7 +1023,7 @@
use super::{is_new_size_ok, BoilerplateStats, Loops};

/// Tries to unroll all loops in each SSA function once, calling the `Function` directly,
/// bypassing the iterative loop done by the SSA which does further optimisations.

Check warning on line 1026 in compiler/noirc_evaluator/src/ssa/opt/unrolling.rs

View workflow job for this annotation

GitHub Actions / Code

Unknown word (optimisations)
///
/// If any loop cannot be unrolled, it is left as-is or in a partially unrolled state.
fn try_unroll_loops(mut ssa: Ssa) -> (Ssa, Vec<RuntimeError>) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
// to be hoisted to the loop's pre-header block.
fn main(x: u32, y: u32) {
loop(4, x, y);
array_read_loop(4, x);
}

fn loop(upper_bound: u32, x: u32, y: u32) {
Expand All @@ -11,3 +12,15 @@ fn loop(upper_bound: u32, x: u32, y: u32) {
assert_eq(z, 12);
}
}

fn array_read_loop(upper_bound: u32, x: u32) {
let arr = [2; 5];
for i in 0..upper_bound {
for j in 0..upper_bound {
for _ in 0..upper_bound {
assert_eq(arr[i], x);
assert_eq(arr[j], x);
}
}
}
}
Loading