Skip to main content

binius_frontend/artifact/
stat.rs

1// Copyright 2025 Irreducible Inc.
2// Copyright 2026 The Binius Developers
3
4//! Circuit statistics module for analyzing constraint counts and circuit complexity.
5
6use std::fmt;
7
8use binius_core::{ConstraintSystem, InoutSegment, Operand, ShiftedValueIndex};
9use itertools::chain;
10use rustc_hash::FxHashSet;
11
12use crate::artifact::circuit::Circuit;
13
14/// Various stats of a circuit that affect the prover performance.
15pub struct CircuitStat {
16	/// Number of gates in the circuit.
17	pub n_gates: usize,
18	/// Number of instructions in the evaluation form of circuit.
19	///
20	/// Directly proportional to performance of witness filling.
21	pub n_eval_insn: usize,
22	/// Number of ZERO constraints in the circuit.
23	///
24	/// Affects performance of the shift reduction only: the Zero reduction itself carries no
25	/// sumcheck.
26	pub n_zero_constraints: usize,
27	/// Number of AND constraints in the circuit.
28	///
29	/// Affects performance of AND reduction.
30	pub n_and_constraints: usize,
31	/// Number of IMUL constraints in the circuit.
32	///
33	/// Affects performance of intmul reduction phase.
34	pub n_imul_constraints: usize,
35	/// Number of BMUL constraints in the circuit.
36	///
37	/// Affects performance of binmul reduction phase.
38	pub n_bmul_constraints: usize,
39	/// Number of distinct value indices with non-zero shift in the circuit.
40	///
41	/// Every use of a value with a distinct type and amount is counted here.
42	///
43	/// Affects performance of shift reduction phase.
44	pub distinct_shifted_value_indices: usize,
45	/// Number of distinct value indices with zero shift in the circuit.
46	///
47	/// Affects performance of shift reduction phase.
48	pub distinct_unshifted_value_indices: usize,
49	/// Length of the committed trace in words, a power of two.
50	///
51	/// Affects performance of committing.
52	pub committed_allocated: usize,
53	/// Number of constant values used by the circuit.
54	pub n_const: usize,
55	/// Number of public input values in the circuit.
56	pub n_inout: usize,
57	/// Number of private input values in the circuit.
58	pub n_witness: usize,
59	/// Number of internal values in the circuit.
60	///
61	/// Internal values are values produced by gates.
62	pub n_internal: usize,
63	/// Number of scratch values in the circuit.
64	///
65	/// Those values are not committed, those only exist during witness generation.
66	pub n_scratch: usize,
67	/// Smallest scratch segment this circuit could run with.
68	///
69	/// This is the largest number of uncommitted values alive at the same time.
70	/// It equals the segment length when slots are shared, and is a lower bound on it otherwise.
71	pub scratch_peak_live: usize,
72	/// Allocated size for ZERO constraints (power of 2, or zero when there are none)
73	pub zero_allocated: usize,
74	/// Allocated size for AND constraints (power of 2)
75	pub and_allocated: usize,
76	/// Allocated size for IMUL constraints (power of 2)
77	pub imul_allocated: usize,
78	/// Allocated size for BMUL constraints (power of 2)
79	pub bmul_allocated: usize,
80}
81
82impl CircuitStat {
83	/// Creates a new `CircuitStat` instance by collecting statistics from the given circuit.
84	pub fn collect(circuit: &Circuit) -> Self {
85		let cs = circuit.constraint_system();
86
87		// Counts as the circuit compiled them, before the prover pads anything.
88		let n_zero_constraints = cs.n_zero_constraints();
89		let n_and_constraints = cs.n_and_constraints();
90		let n_imul_constraints = cs.n_imul_constraints();
91		let n_bmul_constraints = cs.n_bmul_constraints();
92		let (distinct_shifted_value_indices, distinct_unshifted_value_indices) =
93			traverse_constraint_system(cs);
94
95		// Sizes the prover pads each constraint set to before proving.
96		//
97		// - Every set is rounded up to a power of two.
98		// - Rounding up from zero gives one, so an empty AND set still occupies a single slot.
99		// - An empty ZERO or multiply set instead stays at zero, letting its reduction be skipped
100		//   whole.
101		let pad = |n: usize| n.next_power_of_two();
102		let pad_or_skip = |n: usize| if n == 0 { 0 } else { pad(n) };
103		let zero_allocated = pad_or_skip(n_zero_constraints);
104		let and_allocated = pad(n_and_constraints);
105		let imul_allocated = pad_or_skip(n_imul_constraints);
106		let bmul_allocated = pad_or_skip(n_bmul_constraints);
107
108		// The value counts come from the layout. Neither segment is padded, so each holds exactly
109		// the values the circuit declared and there is no spare capacity to report against.
110		let layout = circuit.value_vec_layout();
111		let n_const = layout.n_const;
112		let n_inout = layout.n_inout;
113		// The prover commits only the hidden segment, zero-extended to the word-index space the
114		// shift reduction runs over. That padding is the one the circuit author can still fill.
115		let committed_allocated = 1 << cs.log_segment_words(InoutSegment::Public);
116
117		Self {
118			n_gates: circuit.n_gates(),
119			n_eval_insn: circuit.n_eval_insn(),
120			n_zero_constraints,
121			n_and_constraints,
122			n_imul_constraints,
123			n_bmul_constraints,
124			committed_allocated,
125			distinct_shifted_value_indices,
126			distinct_unshifted_value_indices,
127			n_const,
128			n_inout,
129			n_witness: layout.n_witness,
130			n_internal: layout.n_internal,
131			n_scratch: layout.n_scratch,
132			scratch_peak_live: circuit.scratch_peak_live(),
133			zero_allocated,
134			and_allocated,
135			imul_allocated,
136			bmul_allocated,
137		}
138	}
139}
140
141impl fmt::Display for CircuitStat {
142	fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
143		// Helper to format numbers with commas
144		fn fmt_num(n: usize) -> String {
145			let s = n.to_string();
146			let mut result = String::new();
147			for (i, c) in s.chars().rev().enumerate() {
148				if i > 0 && i % 3 == 0 {
149					result.push(',');
150				}
151				result.push(c);
152			}
153			result.chars().rev().collect()
154		}
155
156		// Helper to create a simple progress bar
157		fn progress_bar(used: usize, total: usize) -> String {
158			let percent = (used as f64 / total as f64 * 100.0) as usize;
159			let filled = percent / 10;
160			let mut bar = String::from("[");
161			for i in 0..10 {
162				if i < filled {
163					bar.push('▓');
164				} else {
165					bar.push('░');
166				}
167			}
168			bar.push(']');
169			bar
170		}
171
172		// Helper to get log2 of a power of 2
173		const fn log2(n: usize) -> u32 {
174			n.trailing_zeros()
175		}
176
177		// Use pre-calculated values
178		let public_used = self.n_const + self.n_inout;
179		let private_used = self.n_witness + self.n_internal;
180
181		// Gates & Instructions
182		writeln!(f, "Gates & Instructions")?;
183		writeln!(f, "├─ Number of gates: {}", fmt_num(self.n_gates))?;
184		writeln!(f, "└─ Number of evaluation instructions: {}", fmt_num(self.n_eval_insn))?;
185		writeln!(f)?;
186
187		// Reports one constraint set: how many rows it uses out of the power of two the prover pads
188		// it to. A set whose reduction is skipped when empty allocates nothing, so it reports an
189		// allocation of `0` rather than a power of two — unlike AND, which is always padded to at
190		// least one row.
191		fn constraint_line(
192			f: &mut fmt::Formatter<'_>,
193			name: &str,
194			used: usize,
195			allocated: usize,
196		) -> fmt::Result {
197			let percent = if allocated > 0 {
198				used as f64 / allocated as f64 * 100.0
199			} else {
200				0.0
201			};
202			let allocation = if allocated == 0 {
203				"0".to_string()
204			} else {
205				format!("2^{}", log2(allocated))
206			};
207			writeln!(
208				f,
209				"├─ {name} constraints: {} used ({percent:.1}% of {allocation})",
210				fmt_num(used)
211			)?;
212			writeln!(f, "│  {} spare: {}", progress_bar(used, allocated), fmt_num(allocated - used))
213		}
214
215		// Constraints
216		writeln!(f, "Constraints")?;
217		constraint_line(f, "ZERO", self.n_zero_constraints, self.zero_allocated)?;
218		constraint_line(f, "AND", self.n_and_constraints, self.and_allocated)?;
219		constraint_line(f, "IMUL", self.n_imul_constraints, self.imul_allocated)?;
220		constraint_line(f, "BMUL", self.n_bmul_constraints, self.bmul_allocated)?;
221		writeln!(
222			f,
223			"└─ Distinct value indices: {}",
224			fmt_num(self.distinct_shifted_value_indices + self.distinct_unshifted_value_indices)
225		)?;
226		writeln!(
227			f,
228			"   ├─ Distinct shifted value indices: {}",
229			fmt_num(self.distinct_shifted_value_indices)
230		)?;
231		writeln!(
232			f,
233			"   └─ Distinct unshifted value indices: {}",
234			fmt_num(self.distinct_unshifted_value_indices)
235		)?;
236		writeln!(f)?;
237
238		// Value Vector
239		writeln!(f, "Value Vector")?;
240
241		// Public Section. The segment holds exactly the values the circuit declared, and the
242		// verifier knows them, so there is neither padding nor a commitment to measure it against.
243		writeln!(f, "├─ Public Section: {} (not committed)", fmt_num(public_used))?;
244		writeln!(f, "│  ├─ Constants: {}", fmt_num(self.n_const))?;
245		writeln!(f, "│  └─ Inout: {}", fmt_num(self.n_inout))?;
246
247		// Private Section, likewise exactly the declared values.
248		writeln!(f, "├─ Private Section: {}", fmt_num(private_used))?;
249		writeln!(f, "│  ├─ Witness: {}", fmt_num(self.n_witness))?;
250		writeln!(f, "│  └─ Internal: {}", fmt_num(self.n_internal))?;
251
252		// Committed Trace: the hidden segment zero-extended to the shift reduction's word-index
253		// space. This is the only padded length left, and the one more values can grow into for
254		// free.
255		let committed_percent = private_used as f64 / self.committed_allocated as f64 * 100.0;
256		let committed_spare = self.committed_allocated - private_used;
257		writeln!(
258			f,
259			"├─ Committed Trace: {} used ({:.1}% of 2^{})",
260			fmt_num(private_used),
261			committed_percent,
262			log2(self.committed_allocated)
263		)?;
264		writeln!(
265			f,
266			"│  {} spare: {}",
267			progress_bar(private_used, self.committed_allocated),
268			fmt_num(committed_spare)
269		)?;
270
271		// Report the segment length alongside the floor it could reach if its slots were shared.
272		// Recording both pins the lifetime analysis, so a regression in it shows up here.
273		writeln!(
274			f,
275			"└─ Scratch (uncommitted): {} (peak live: {})",
276			fmt_num(self.n_scratch),
277			fmt_num(self.scratch_peak_live)
278		)?;
279		writeln!(f)?;
280
281		Ok(())
282	}
283}
284
285/// Traverses the constraint system and returns the number of distinct value indices that
286/// are shifted and unshifted, respectively.
287fn traverse_constraint_system(cs: &ConstraintSystem) -> (usize, usize) {
288	let mut cx = Cx::default();
289	let operands = chain!(
290		cs.zero_constraints.iter().flat_map(|c| &c.0),
291		cs.and_constraints.iter().flat_map(|c| &c.0),
292		cs.imul_constraints.iter().flat_map(|c| &c.0),
293		cs.bmul_constraints.iter().flat_map(|c| &c.0),
294	);
295	for operand in operands {
296		cx.visit_operand(operand);
297	}
298	(cx.shifted_terms.len(), cx.unshifted_terms.len())
299}
300
301/// The distinct terms seen so far, split by whether they carry a shift.
302#[derive(Default)]
303struct Cx {
304	shifted_terms: FxHashSet<ShiftedValueIndex>,
305	unshifted_terms: FxHashSet<ShiftedValueIndex>,
306}
307
308impl Cx {
309	/// Records every term of one operand.
310	fn visit_operand(&mut self, operand: &Operand) {
311		for term in operand {
312			if term.is_unshifted() {
313				self.unshifted_terms.insert(*term);
314			} else {
315				self.shifted_terms.insert(*term);
316			}
317		}
318	}
319}