acvm/compiler/optimizers/common_subexpression/
merge_expressions.rs

1use std::collections::{BTreeSet, HashMap};
2
3use acir::{
4    AcirField,
5    circuit::{
6        Circuit, Opcode,
7        brillig::{BrilligInputs, BrilligOutputs},
8        opcodes::BlockId,
9    },
10    native_types::{Expression, Witness},
11};
12use rustc_hash::FxHashMap;
13
14use crate::compiler::{CircuitSimulator, optimizers::GeneralOptimizer};
15
16pub(crate) struct MergeExpressionsOptimizer<F: AcirField> {
17    resolved_blocks: HashMap<BlockId, BTreeSet<Witness>>,
18    modified_gates: HashMap<usize, Opcode<F>>,
19    deleted_gates: BTreeSet<usize>,
20}
21
22impl<F: AcirField> MergeExpressionsOptimizer<F> {
23    pub(crate) fn new() -> Self {
24        MergeExpressionsOptimizer {
25            resolved_blocks: HashMap::new(),
26            modified_gates: HashMap::new(),
27            deleted_gates: BTreeSet::new(),
28        }
29    }
30
31    /// This pass analyzes the circuit and identifies intermediate variables that are
32    /// only used in two `AssertZero` opcodes. It then merges the opcode which produces the
33    /// intermediate variable into the second one that uses it
34    ///
35    /// The first pass maps witnesses to the indices of the opcodes using them.
36    /// Public inputs are not considered because they cannot be simplified.
37    /// Witnesses used by `MemoryInit` opcodes are put in a separate map and marked as used by a Brillig call
38    /// if the memory block is an input to the call.
39    ///
40    /// The second pass looks for `AssertZero` opcodes having a witness which is only used by another arithmetic opcode.
41    /// In that case, the opcode with the smallest index is merged into the other one via Gaussian elimination.
42    /// For instance, if we have 'w1' used only by these two opcodes,
43    /// `5*w2*w3` and `w1`:
44    /// w2*w3 + 2*w2 + w1 + w3 = 0   // This opcode 'defines' the variable w1
45    /// 2*w3*w4 + w1 + w4 = 0        // which is only used here
46    ///
47    /// For w1 we can say:
48    /// w1 = -w2*w3 - 2*w2 - w3
49    ///
50    /// Then we will remove the first one and modify the second one like this:
51    /// 2*w3*w4 + w4 - 2*w2 - w3 - w2*w3 = 0
52    ///
53    /// Pre-condition:
54    /// - This pass is relevant for backends that can handle unlimited width and
55    ///   Plonk-ish backends. Although they have a limited width, they can potentially
56    ///   handle expressions with large linear combinations using 'big-add' gates.
57    /// - The CSAT pass should have been run prior to this one.
58    #[tracing::instrument(level = "trace", name = "merge_expressions", skip_all)]
59    pub(crate) fn eliminate_intermediate_variable(
60        &mut self,
61        circuit: &Circuit<F>,
62        acir_opcode_positions: Vec<usize>,
63    ) -> (Vec<Opcode<F>>, Vec<usize>) {
64        // Initialization
65        self.modified_gates.clear();
66        self.deleted_gates.clear();
67        self.resolved_blocks.clear();
68
69        // Keep track, for each witness, of the gates that use it
70        let circuit_io: BTreeSet<Witness> =
71            circuit.circuit_arguments().union(&circuit.public_inputs().0).copied().collect();
72
73        let mut used_witnesses: FxHashMap<Witness, BTreeSet<usize>> = FxHashMap::default();
74        for (i, opcode) in circuit.opcodes.iter().enumerate() {
75            let witnesses = self.witness_inputs(opcode);
76            if let Opcode::MemoryInit { block_id, .. } = opcode {
77                self.resolved_blocks.insert(*block_id, witnesses.clone());
78            }
79            for w in witnesses {
80                // We do not simplify circuit inputs and outputs
81                if !circuit_io.contains(&w) {
82                    used_witnesses.entry(w).or_default().insert(i);
83                }
84            }
85        }
86
87        // For each opcode, try to get a target opcode to merge with
88        for (op1, opcode) in circuit.opcodes.iter().enumerate() {
89            if !matches!(opcode, Opcode::AssertZero(_)) {
90                continue;
91            }
92            // The opcode might have been modified by an earlier merge, so use its current version.
93            let input_witnesses = match self.get_opcode(op1, circuit) {
94                Some(opcode) => self.witness_inputs(opcode),
95                None => continue,
96            };
97            for w in input_witnesses {
98                let Some(gates_using_w) = used_witnesses.get(&w) else {
99                    continue;
100                };
101                // We only consider witness which are used in exactly two arithmetic gates
102                if gates_using_w.len() == 2 {
103                    let first = *gates_using_w.first().expect("gates_using_w.len == 2");
104                    let second = *gates_using_w.last().expect("gates_using_w.len == 2");
105                    let op2 = if second == op1 {
106                        first
107                    } else {
108                        // sanity check
109                        assert!(op1 == first);
110                        second
111                    };
112
113                    // Merge the opcode with smaller index into the other one
114                    // by updating modified_gates/deleted_gates/used_witnesses.
115                    if op1 != op2 {
116                        let (source, target) = if op1 < op2 { (op1, op2) } else { (op2, op1) };
117
118                        // Compute the merged expression and the witnesses it touches from the
119                        // borrowed opcodes, collecting owned results so the borrows are released
120                        // before we mutate the optimizer state below.
121                        let merge = match (
122                            self.get_opcode(target, circuit),
123                            self.get_opcode(source, circuit),
124                        ) {
125                            (
126                                Some(Opcode::AssertZero(expr_use)),
127                                Some(Opcode::AssertZero(expr_define)),
128                            ) => Self::merge_expression(expr_use, expr_define, w).map(|expr| {
129                                let witnesses: Vec<Witness> =
130                                    CircuitSimulator::expr_witness(expr_use)
131                                        .chain(CircuitSimulator::expr_witness(expr_define))
132                                        .collect();
133                                (expr, witnesses)
134                            }),
135                            _ => None,
136                        };
137
138                        if let Some((expr, witnesses)) = merge {
139                            self.modified_gates.insert(target, Opcode::AssertZero(expr));
140                            self.deleted_gates.insert(source);
141                            // Update the 'used_witnesses' map to account for the merge.
142                            for w2 in witnesses {
143                                if !circuit_io.contains(&w2) {
144                                    used_witnesses.entry(w2).and_modify(|v| {
145                                        v.insert(target);
146                                        v.remove(&source);
147                                    });
148                                }
149                            }
150                            // We need to stop here and continue with the next opcode
151                            // because the merge invalidates the current opcode.
152                            break;
153                        }
154                    }
155                }
156            }
157        }
158
159        // Construct the new circuit from modified/deleted gates
160        let mut new_circuit = Vec::new();
161        let mut new_acir_opcode_positions = Vec::new();
162
163        for (i, opcode_position) in acir_opcode_positions.iter().enumerate() {
164            if let Some(opcode) = self.get_opcode(i, circuit) {
165                new_circuit.push(opcode.clone());
166                new_acir_opcode_positions.push(*opcode_position);
167            }
168        }
169        (new_circuit, new_acir_opcode_positions)
170    }
171
172    fn for_each_brillig_input_witness(&self, input: &BrilligInputs<F>, mut f: impl FnMut(Witness)) {
173        match input {
174            BrilligInputs::Single(expr) => {
175                for witness in CircuitSimulator::expr_witness(expr) {
176                    f(witness);
177                }
178            }
179            BrilligInputs::Array(exprs) => {
180                for expr in exprs {
181                    for witness in CircuitSimulator::expr_witness(expr) {
182                        f(witness);
183                    }
184                }
185            }
186            BrilligInputs::MemoryArray(block_id) => {
187                for witness in self.resolved_blocks.get(block_id).expect("Unknown block id") {
188                    f(*witness);
189                }
190            }
191        }
192    }
193
194    fn for_each_brillig_output_witness(&self, output: &BrilligOutputs, mut f: impl FnMut(Witness)) {
195        match output {
196            BrilligOutputs::Simple(witness) => f(*witness),
197            BrilligOutputs::Array(witnesses) => {
198                for witness in witnesses {
199                    f(*witness);
200                }
201            }
202        }
203    }
204
205    // Returns the input witnesses used by the opcode
206    fn witness_inputs(&self, opcode: &Opcode<F>) -> BTreeSet<Witness> {
207        match opcode {
208            Opcode::AssertZero(expr) => CircuitSimulator::expr_witness(expr).collect(),
209            Opcode::BlackBoxFuncCall(bb_func) => {
210                let mut witnesses = bb_func.get_input_witnesses();
211                witnesses.extend(bb_func.get_outputs_vec());
212                if let Some(w) = bb_func.get_predicate() {
213                    witnesses.insert(w);
214                }
215                witnesses
216            }
217            Opcode::MemoryOp { block_id: _, op } => [op.index, op.value].into_iter().collect(),
218
219            Opcode::MemoryInit { block_id: _, init, block_type: _ } => {
220                init.iter().copied().collect()
221            }
222            Opcode::BrilligCall { inputs, outputs, predicate, .. } => {
223                let mut witnesses = BTreeSet::new();
224                for i in inputs {
225                    self.for_each_brillig_input_witness(i, |witness| {
226                        witnesses.insert(witness);
227                    });
228                }
229                witnesses.extend(CircuitSimulator::expr_witness(predicate));
230                for i in outputs {
231                    self.for_each_brillig_output_witness(i, |witness| {
232                        witnesses.insert(witness);
233                    });
234                }
235                witnesses
236            }
237            Opcode::Call { id: _, inputs, outputs, predicate } => {
238                let mut witnesses: BTreeSet<Witness> = inputs.iter().copied().collect();
239                witnesses.extend(outputs);
240                witnesses.extend(CircuitSimulator::expr_witness(predicate));
241                witnesses
242            }
243        }
244    }
245
246    // Merge 'expr' into 'target' via Gaussian elimination on 'w'
247    // Returns None if the expressions cannot be merged
248    fn merge_expression(
249        target: &Expression<F>,
250        expr: &Expression<F>,
251        witness: Witness,
252    ) -> Option<Expression<F>> {
253        // Check that the witness is not part of multiplication terms
254        for m in &target.mul_terms {
255            if m.1 == witness || m.2 == witness {
256                return None;
257            }
258        }
259        for m in &expr.mul_terms {
260            if m.1 == witness || m.2 == witness {
261                return None;
262            }
263        }
264
265        for k in &target.linear_combinations {
266            if k.1 == witness {
267                for i in &expr.linear_combinations {
268                    if i.1 == witness {
269                        assert!(
270                            i.0 != F::zero(),
271                            "merge_expression: attempting to divide k.0 by F::zero"
272                        );
273                        let expr = target.add_mul(-(k.0 / i.0), expr);
274                        let expr = GeneralOptimizer::optimize(expr);
275                        return Some(expr);
276                    }
277                }
278            }
279        }
280        None
281    }
282
283    /// Returns a reference to the 'updated' opcode at the given index in the circuit.
284    /// The modifications to the circuit are stored with '`deleted_gates`' and '`modified_gates`'.
285    /// These structures are used to give the 'updated' opcode.
286    /// For instance, if the opcode has been deleted inside '`deleted_gates`', then it returns None.
287    fn get_opcode<'a>(&'a self, index: usize, circuit: &'a Circuit<F>) -> Option<&'a Opcode<F>> {
288        if self.deleted_gates.contains(&index) {
289            return None;
290        }
291        self.modified_gates.get(&index).or_else(|| circuit.opcodes.get(index))
292    }
293}
294
295#[cfg(test)]
296mod tests {
297    use crate::{
298        assert_circuit_snapshot,
299        compiler::{
300            CircuitSimulator,
301            optimizers::common_subexpression::merge_expressions::MergeExpressionsOptimizer,
302        },
303    };
304    use acir::{
305        AcirField, FieldElement,
306        circuit::Circuit,
307        native_types::{Expression, Witness},
308    };
309
310    fn merge_expressions(circuit: Circuit<FieldElement>) -> Circuit<FieldElement> {
311        assert!(CircuitSimulator::check_circuit(&circuit).is_none());
312        let mut merge_optimizer = MergeExpressionsOptimizer::new();
313        let acir_opcode_positions = vec![0; 20];
314        let (opcodes, _) =
315            merge_optimizer.eliminate_intermediate_variable(&circuit, acir_opcode_positions);
316        let mut optimized_circuit = circuit;
317        optimized_circuit.opcodes = opcodes;
318
319        // check that the circuit is still valid after optimization
320        assert!(CircuitSimulator::check_circuit(&optimized_circuit).is_none());
321        optimized_circuit
322    }
323
324    #[test]
325    fn merges_expressions() {
326        let src = "
327        private parameters: [w0]
328        public parameters: []
329        return values: [w2]
330        ASSERT 2*w1 = w0 + 5
331        ASSERT w2 = 4*w1 + 4
332        ";
333        let circuit = Circuit::from_str(src).unwrap();
334        let optimized_circuit = merge_expressions(circuit);
335        assert_circuit_snapshot!(optimized_circuit, @r"
336        private parameters: [w0]
337        public parameters: []
338        return values: [w2]
339        ASSERT w2 = 2*w0 + 14
340        ");
341    }
342
343    #[test]
344    fn does_not_eliminate_witnesses_returned_from_brillig() {
345        let src = "
346        private parameters: [w0]
347        public parameters: []
348        return values: []
349        BRILLIG CALL func: 0, predicate: 1, inputs: [], outputs: [w1]
350        ASSERT 2*w0 + 3*w1 + w2 + 1 = 0
351        ASSERT 2*w0 + 2*w1 + w5 + 1 = 0
352        ";
353        let circuit = Circuit::from_str(src).unwrap();
354        let optimized_circuit = merge_expressions(circuit.clone());
355        assert_eq!(circuit, optimized_circuit);
356    }
357
358    #[test]
359    fn does_not_eliminate_witnesses_returned_from_circuit() {
360        let src = "
361        private parameters: [w0]
362        public parameters: []
363        return values: [w1, w2]
364        ASSERT -w0*w0 + w1 = 0
365        ASSERT -w1 + w2 = 0
366        ";
367        let circuit = Circuit::from_str(src).unwrap();
368        let optimized_circuit = merge_expressions(circuit.clone());
369        assert_eq!(circuit, optimized_circuit);
370    }
371
372    #[test]
373    fn does_not_attempt_to_merge_into_previous_opcodes() {
374        let src = "
375        private parameters: [w0, w1]
376        public parameters: []
377        return values: []
378        ASSERT w0*w0 - w4 = 0
379        ASSERT w0*w1 + w5 = 0
380        ASSERT -w2 + w4 + w5 = 0
381        ASSERT w2 - w3 + w4 + w5 = 0
382        BLACKBOX::RANGE input: w3, bits: 32
383        ";
384        let circuit = Circuit::from_str(src).unwrap();
385
386        let optimized_circuit = merge_expressions(circuit);
387        assert_circuit_snapshot!(optimized_circuit, @r"
388        private parameters: [w0, w1]
389        public parameters: []
390        return values: []
391        ASSERT w5 = -w0*w1
392        ASSERT w3 = 2*w0*w0 + 2*w5
393        BLACKBOX::RANGE input: w3, bits: 32
394        ");
395    }
396
397    #[test]
398    fn takes_blackbox_opcode_outputs_into_account() {
399        // Regression test for https://github.com/noir-lang/noir/issues/6527
400        // Previously we would not track the usage of witness 4 in the output of the blackbox function.
401        // We would then merge the final two opcodes losing the check that the brillig call must match
402        // with `w0 ^ w1`.
403        let src = "
404        private parameters: [w0, w1]
405        public parameters: []
406        return values: [w2]
407        BRILLIG CALL func: 0, predicate: 1, inputs: [], outputs: [w3]
408        BLACKBOX::AND lhs: w0, rhs: w1, output: w4, bits: 8
409        ASSERT w3 - w4 = 0
410        ASSERT -w2 + w4 = 0
411        ";
412        let circuit = Circuit::from_str(src).unwrap();
413        let optimized_circuit = merge_expressions(circuit.clone());
414        assert_eq!(circuit, optimized_circuit);
415    }
416
417    #[test]
418    #[should_panic(expected = "merge_expression: attempting to divide k.0 by F::zero")]
419    fn merge_expression_on_zero_linear_combination_panics() {
420        let opcode_a = Expression {
421            mul_terms: vec![],
422            linear_combinations: vec![(FieldElement::one(), Witness(0))],
423            q_c: FieldElement::zero(),
424        };
425        let opcode_b = Expression {
426            mul_terms: vec![],
427            linear_combinations: vec![(FieldElement::zero(), Witness(0))],
428            q_c: FieldElement::zero(),
429        };
430        assert_eq!(
431            MergeExpressionsOptimizer::merge_expression(&opcode_a, &opcode_b, Witness(0),),
432            Some(opcode_a)
433        );
434    }
435
436    #[test]
437    fn does_not_eliminate_witnesses_used_in_brillig_call_predicates() {
438        let src = "
439        private parameters: [w2]
440        public parameters: [w0, w1]
441        return values: [w3]
442        BLACKBOX::RANGE input: w0, bits: 1
443        BLACKBOX::RANGE input: w1, bits: 1
444        BLACKBOX::RANGE input: w2, bits: 1
445        ASSERT w4 = w0*w1
446        ASSERT w5 = -w2 + 1
447        BRILLIG CALL func: 0, predicate: w4*w5, inputs: [w2], outputs: [w6]
448        ASSERT w3 = -w5 + 1
449        ";
450        let circuit = Circuit::from_str(src).unwrap();
451        let optimized_circuit = merge_expressions(circuit.clone());
452        assert_eq!(circuit, optimized_circuit);
453    }
454}