Skip to main content

Documentation Index

Fetch the complete documentation index at: https://mintlify.com/noir-lang/noir/llms.txt

Use this file to discover all available pages before exploring further.

The Noir profiler (noir-profiler) generates flamegraphs that map circuit costs back to the original source code. It supports three profiling modes:
ModeWhat it measures
opcodesACIR opcode count per source location
gatesProving backend gate count per source location
execution-opcodesBrillig (unconstrained) opcode count per source location

Installation

The profiler is installed automatically with Nargo starting from noirup v0.1.4. Verify the installation:
noir-profiler --version
If not found, reinstall using noirup:
noirup

Quick circuit info with nargo info

Before reaching for the profiler, nargo info gives a fast overview of a compiled circuit’s opcode count without generating a flamegraph:
nargo info
Example output:
+-------------+----------+--------------+---------------------+
| Package     | Function | ACIR Opcodes | Brillig Opcodes     |
+-------------+----------+--------------+---------------------+
| my_program  | main     | 387          | 0                   |
+-------------+----------+--------------+---------------------+
Use nargo info to track the overall size of a circuit as you make changes. Use the profiler when you need to understand which source locations are responsible for that size.

Profiling ACIR opcodes

ACIR opcodes are the intermediate representation consumed by proving backends. Profiling ACIR opcodes gives an approximate view of which parts of your program contribute most to circuit size and proving time.

Example program

Create a new project and add the following source:
nargo new program
fn main(ptr: pub u32, mut array: [u32; 32]) -> pub [u32; 32] {
    for i in 0..32 {
        if i > ptr {
            array[i] = 0;
        }
    }
    array
}
Compile it:
nargo compile

Generating a flamegraph

noir-profiler opcodes --artifact-path ./target/program.json --output ./target/
This generates an HTML flamegraph file in ./target/. Open it in a browser for an interactive view. Flamegraph output: Each frame in the graph represents a source location. The width is proportional to the number of ACIR opcodes attributed to that location. Click on a frame to zoom in. In the example above, 387 ACIR opcodes are generated. The flamegraph reveals that the majority come from the array[i] = 0 write inside the loop — a dynamic array write that requires memory opcodes for each element.

Searching the flamegraph

Click Search in the top-right corner and type a source expression (e.g. i > ptr). The matching frames are highlighted, and the Matched percentage in the bottom-right shows the fraction of total opcodes they account for.

Optimizing based on profiler results

After identifying a bottleneck, you can apply unconstrained functions to move expensive operations out of the constrained circuit. Before optimization: 387 ACIR opcodes — dynamic array writes dominate. After optimization: 284 ACIR opcodes — array writes moved to an unconstrained function, constrained circuit only checks results.
fn main(ptr: pub u32, array: [u32; 32]) -> pub [u32; 32] {
    // Safety: Sets all elements after `ptr` in `array` to zero.
    let zeroed_array = unsafe { zero_out_array(ptr, array) };
    for i in 0..32 {
        if i > ptr {
            assert_eq(zeroed_array[i], 0);
        } else {
            assert_eq(zeroed_array[i], array[i]);
        }
    }
    zeroed_array
}

unconstrained fn zero_out_array(ptr: u32, mut array: [u32; 32]) -> [u32; 32] {
    for i in 0..32 {
        if i > ptr {
            array[i] = 0;
        }
    }
    array
}
The optimization removes the dynamic memory writes from the constrained circuit, replacing them with cheaper arithmetic assertions. Re-running the profiler confirms the reduction.
Search for memory::op in the flamegraph before and after this optimization. After the optimization, the search will have no matches because the dynamic array access has been eliminated.

Profiling proving backend gates

ACIR opcodes are only an approximation of proving cost. Different backends translate ACIR into different numbers of proving gates. Profile gates directly for an accurate picture.
This feature requires a proving backend that supports the profiler’s gate profiling API. The example below uses Barretenberg (bb).

Generating a gates flamegraph

noir-profiler gates \
  --artifact-path ./target/program.json \
  --backend-path bb \
  --output ./target/ \
  -- --include_gates_per_opcode
  • --backend-path — path to the proving backend binary. If bb is on your PATH, use bb directly; otherwise provide the absolute path.
  • Arguments after -- are passed to the backend.

Interpreting gate counts

The gate flamegraph shows how the backend’s gate count is distributed across source locations. This distribution can differ significantly from the ACIR opcode flamegraph. For example, Barretenberg’s UltraHonk uses lookup tables for range checks, which carry a fixed setup cost regardless of circuit size. This means blackbox::range may appear as a dominant contributor in small circuits but shrink to a small fraction in larger ones. Example with array size 32: blackbox::range contributes most gates. Example with array size 2048: blackbox::range contributes a much smaller percentage because the fixed setup cost is amortized over many more total gates. This illustrates why it is useful to profile with different input sizes to understand how your circuit scales.

Profiling unconstrained execution

The profiler can also measure the Brillig (unconstrained VM) opcode count for programs that run entirely in unconstrained mode.

Preparing the program

Add the unconstrained modifier to the main function from the earlier example:
unconstrained fn main(ptr: pub u32, mut array: [u32; 32]) -> pub [u32; 32] {
    for i in 0..32 {
        if i > ptr {
            array[i] = 0;
        }
    }
    array
}
Generate a Prover.toml with inputs:
nargo check
Edit Prover.toml:
ptr = 1
array = [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1]

Generating an execution flamegraph

noir-profiler execution-opcodes \
  --artifact-path ./target/program.json \
  --prover-toml-path Prover.toml \
  --output ./target/
The output is a flamegraph of Brillig opcode counts mapped to source locations. Unlike the ACIR flamegraph, this reflects actual execution cost for a specific set of inputs.

Balancing proving and execution

Moving constrained operations to unconstrained functions reduces ACIR opcodes (and proving time) but increases Brillig opcodes (and execution time). The profiler lets you measure both sides of this tradeoff. For most Noir programs, proving time dominates, so reducing ACIR opcodes is typically the right priority. However, if execution time becomes a bottleneck, use execution-opcodes profiling to identify where Brillig time is spent.

Workflow summary

1

Check overall circuit size

nargo info
2

Generate an ACIR opcode flamegraph

nargo compile
noir-profiler opcodes --artifact-path ./target/<package>.json --output ./target/
Open the generated HTML file in a browser.
3

Identify the dominant source locations

Use Search in the flamegraph to find specific expressions. Look for frames with the widest spans.
4

Apply optimizations

Common techniques:
  • Move expensive array writes into unconstrained functions and assert the results.
  • Avoid dynamic array indexing where possible.
  • Use compile-time constants instead of runtime inputs.
5

Re-profile to measure the improvement

Recompile and regenerate the flamegraph. Compare opcode counts before and after.
6

Profile backend gates if needed

noir-profiler gates --artifact-path ./target/<package>.json --backend-path bb --output ./target/ -- --include_gates_per_opcode
Use this when ACIR opcode counts do not accurately reflect proving performance for your backend.

Build docs developers (and LLMs) love