noir-optimisation · git:20260612.13b231c · 2026-06-12 · sha256 7af23bb4d8496927
noir-optimisation git:20260612.13b231cA
Immutable. This exact content is served forever at /api/v1/blob/7af23bb4d8496927.
---
name: noir-optimisation
description: >-
Cost model and optimisation reference for Noir circuits on the
UltraHonk / Aztec Barretenberg stack. Use when sizing constraint
budgets, deciding whether to push work into an `unconstrained`
Brillig hint with an in-circuit verifier, comparing Field-arith vs
typed `u64` bit operations, reading `bb gates` / `nargo info` /
`noir-profiler gates` output, or weighing ACIR-opcode against
backend-gate regressions. Companion to `noir-circuit-patterns` —
that skill covers SPARQL-primitive shapes; this one covers which
shape wins after measurement. **Always run `bb gates` before
claiming a saving.** `nargo info` is misleading on its own — see
§1. Intuition has misfired on this codebase before (PR #37 / spike
ledger `bench/SPIKES.md`).
---
# Noir optimisation — cost model + decision rules
**This is a low-level library; always optimise for minimal gate
count.** Every call site pays the constraint cost on every proof.
Per-site savings of 5-15 gates compound across thousands of call
sites in downstream callers' circuits. Readability is secondary to
constraint count: when the two trade off, take the gates and add a
comment. The only exception is when two forms compile to the same
constraints (the optimiser CSE's identical sub-expressions); in that
case pick whichever reads better.
A reference for someone already writing Noir circuits in this
workspace. Optimisation in Noir is empirical: measure with **`bb gates`**
*before* declaring a win. The 2026-05-03 round-1 of `unconstrained_ops`
for IEEE 754 had us predict an ACIR-opcode + Width win for
`count_leading_zeros_u64_verified`; PR #37 measured **both regressing**
in both isolated and composed regimes, and the follow-up
bit-decomposition spike (`c0f433d`, reverted in `63d0b95`) regressed
twice as hard again. The 2026-05-10 `shr_sticky_u64` Field-arith spike
*looked* like a 17% win on `bb gates` until we discovered the relation
was unsound — see §3.4. Don't trust the textbook shape; measure, and
measure the right number.
For *which* primitive to write — BGP matchers, joins, filters — see
`noir-circuit-patterns`. This skill picks up where that one stops:
given a candidate gadget, does the proposed implementation beat the
in-circuit-spec baseline on the metric that actually drives prover
wall-clock?
For generic Noir guidance (language syntax, the stdlib, project
setup, testing patterns, JS integration) consult the upstream skills:
`noir-developer` (circuit-dev, stdlib, workspace), `noir-idioms`
(hint-and-verify principle, ACIR-vs-Brillig basics, `bool` vs Field,
`if/else` selects, `assert_eq` style), `noir-testing` (test
attributes and organisation), `noir-js` (noir_js / bb.js
integration). This skill is intentionally narrow: the empirical,
measured findings on `bb gates` cost that the upstream skills don't
cover.
## 1. The metric that matters: `bb gates`, not `nargo info`
The single most common mistake on this codebase is reasoning about
proving cost from `nargo info` alone. **`nargo info` is not proving
cost.** The canonical proving-cost metric on the Noir + UltraHonk
stack is **the `circuit_size` field returned by `bb gates`** [1, 2].
### 1.1 Three numbers, three meanings
`nargo info` reports per-function:
- **ACIR opcodes** — count of `AssertZero`, `BlackBoxFuncCall`,
`MemoryOp`, `MemoryInit`, `BrilligCall`, `Call` instructions in the
constrained portion of the circuit [3]. Each opcode's cost in
backend gates varies by orders of magnitude — `assert(x == y)` and
`assert(x < 2^64)` are both one ACIR opcode but the second lowers to
hundreds of UltraHonk gates after black-box expansion.
- **Expression Width** — sum of witness-wire counts across all
`AssertZero` opcodes in that function [4]. It's the per-row degree
bound on PLONKish gates, not a gate count. Optimising for it can
still mislead — see PR #37 in §3.3 and the spike ledger entry for
`clz_u64`.
- **Brillig opcodes** — unconstrained-VM instructions; **free at
proof time** [1, 5]. Don't optimise for this column. If you halve
Brillig opcodes but double ACIR, you've made the prover slower.
`bb gates` reports the **post-expansion UltraHonk gate count**
(`circuit_size`), which is what the prover actually pays for. Two
empirical examples from this repo (`bench/unconstrained_gate_counts.bb_addition.json`,
commit `768448f`):
| Function | ACIR opcodes | Expression Width | `bb gates` |
|---------------------------------|-------------:|-----------------:|-----------:|
| `add_float32` | 81 | 622 | 6506 |
| `mul_float32` | 81 | 1062 | 7348 |
| `div_float64` | 83 | 4594 | 20015 |
Three functions with **the same 81-83 ACIR opcode count** span a 3×
range in actual proving cost. ACIR opcode count is a 5-50× underestimate
because each `BlackBoxFuncCall::RANGE` (every `assert_max_bit_size`,
every `u32`-typed input, every cast) expands to hundreds of UltraHonk
gates under Plookup [2].
### 1.2 The opposite trap: typed `>>`/`&` hide cost inside black boxes
Going the other way is just as misleading. A typed `u64`
`>>`/`&` baseline for `shift_right_sticky_u64` measured 34 ACIR
opcodes; a Field-arithmetic variant of the same primitive measured
158 ACIR opcodes (4.6× more). On `bb gates`, the Field-arith variant
came out **lower** — because every typed-`u64` shift/AND lowers to
`remove_bit_shifts` decomposition + range checks, and those black-box
expansions dominate the backend cost. Numbers from
`bench/unconstrained_gate_counts.bb_addition.json`,
`shr_sticky_u64_amplified_*` block:
| Variant | ACIR | `bb gates` |
|-----------------------------------------------|-----:|-----------:|
| `shr_sticky_u64_amplified_baseline` (typed) | 34 | 16899 |
| `shr_sticky_u64_amplified_field_arith` | 158 | 12308 |
| `shr_sticky_u64_amplified_verified` | 137 | 13970 |
The typed baseline has the **fewest** ACIR opcodes and the **highest**
`bb gates`. Reading only `nargo info` you would pick the worst variant.
### 1.3 Rule
- **`bb gates --include_gates_per_opcode` is the ground truth.** Use
it for every "does this optimisation work?" question.
- **`nargo info` is iteration sugar.** It's faster to run than `bb gates`,
so during a tight edit loop watching the Expression Width column is
fine — but never claim a saving without `bb gates` to back it up.
- If `bb gates` and `nargo info` disagree on which variant wins, trust
`bb gates` — and update the spike ledger entry so the next agent
doesn't repeat the analysis.
## 2. The `bb gates` workflow
### 2.1 Toolchain — `bbup`
`bb` and `nargo` are version-coupled. `bb 0.84.0` errors with
*"Length is too large"* against bytecode produced by
`nargo 1.0.0-beta.17`. Run `bbup` with no arguments — it queries the
installed `nargo`, resolves the compatible `bb`, and downloads it [6].
For Noir 1.0.0-beta.17 this resolves to bb 3.0.0-nightly.20251104
(check with `bb --version`).
```sh
bbup # auto-detect; pulls compatible bb
bb --version # confirm
nargo --version # confirm
```
If you see `Length is too large`, your `bb` is older than your `nargo`.
Run `bbup` again.
### 2.2 `bb gates` invocation
The canonical invocation, after `nargo compile`:
```sh
cd <package_root> # e.g. ieee754/
nargo compile
bb gates \
-s ultra_honk \
-b target/<package_name>.json \
--include_gates_per_opcode
```
Output is JSON. The headline number is `functions[0].circuit_size`
(the UltraHonk gate count). The breakdown `functions[0].gates_per_opcode`
is a `[u32; N]` array indexed in the same order as the ACIR opcodes
emitted by the compiler — for `add_float32` on commit `768448f` the
top three entries (3093 + 1371 + 697) consumed 79% of the function's
total gates, and they are the range-check black boxes from
`decompose_hint` + casts. **This per-opcode view is where you find the
hotspot.**
### 2.3 `noir-profiler gates` for source-attributed flamegraphs
`noir-profiler gates` wraps `bb gates` with source-location attribution
and emits an SVG flamegraph [2]:
```sh
noir-profiler gates \
--artifact-path target/<package_name>.json \
--backend-path bb \
--output target/ \
-- --include_gates_per_opcode
```
The flamegraph attributes each backend gate to the Noir source line it
originated from. This is the right tool when `bb gates` per-opcode says
"opcode #17 costs 3093 gates" and you need to know **which line of the
source generated opcode #17**. Use it before optimising a function
larger than a screenful.
### 2.4 The padding-floor warning — measure per-call cost by amplification
UltraHonk pads small circuits to a fixed gate-count floor. On the
current `bb` (3.0.0-nightly.20251104), an empty
`fn main(x: pub u64) -> pub u64 { x }` reports `circuit_size = 2842`,
and every isolated primitive below ~1200 gates of real work rounds up
to `circuit_size = 4120` (the next power-of-two plus glue rows). This
means **a microbenchmark of a single primitive call cannot measure
per-call cost** — every variant reports 4120 and the differences are
invisible.
Evidence from `bench/unconstrained_gate_counts.bb_addition.json`:
| Variant | `bb gates` |
|--------------------------------------------|-----------:|
| `shr_sticky_u64_isolated_baseline` | 4120 |
| `shr_sticky_u64_isolated_verified` | 4120 |
| `shr_sticky_u64_isolated_field_arith` | 4120 |
| `shr_sticky_u64_composed_baseline` | 4120 |
| `shr_sticky_u64_const20_baseline` | 4120 |
| `shr_sticky_u128_isolated_baseline` | 4120 |
All six different primitives hit the same floor. The numbers are
**uninformative** for per-call cost comparison.
The fix: **amplify**. Instantiate the primitive N times inside `main`
with witness-dependent inputs that defeat constant folding, then
back out the per-call cost as `(bb_gates − empty_floor) / N`. The
in-repo harness (`scripts/benchmark_gates.py`, `*_amplified_*` block,
N = 64) implements this pattern [7]:
```rust
fn main(value: pub u64, seed: pub u64) -> pub u64 {
let mut acc: u64 = 0;
for i in 0..64 {
let shift = (seed + i as u64) & 63;
acc = acc ^ shift_right_sticky_u64_verified(value + i as u64, shift);
}
acc
}
```
Per-call cost for `shift_right_sticky_u64_verified`:
`(13970 − 2842) / 64 ≈ 174 gates/call`.
Two further pitfalls when amplifying:
- **The accumulator must be witness-dependent.** A `for` loop that
XORs in a constant gets unrolled and constant-folded into a single
constant; the prover never sees the primitive. The harness uses
`value + i as u64` and `(seed + i) & 63` so each call has distinct,
witness-dependent arguments.
- **N must be large enough to dominate the floor.** With N = 64 the
floor is ~25% of the smallest amplified measurement (4120 / 16899
for the typed baseline) — within tolerance. N < 16 leaves the floor
in the same order as the signal and per-call comparison breaks down.
## 3. The `unconstrained + verified` pattern — when it pays
For the *principle* (hint a result, verify it cheaply), see the
`noir-idioms` skill, "Core Principle: Hint and Verify", and
`noir-developer/circuit-dev/unconstrained-functions.md` for the
syntax. This section covers what `noir-idioms` does NOT: the
**measured** conditions under which the pattern actually beats the
in-circuit spec on UltraHonk `bb gates`, the soundness pitfalls
encountered on this codebase, and a heuristic for when to spike a new
`_verified` variant vs skip the experiment.
### 3.1 The actual condition for a win
`bb_gates(verifier) < bb_gates(in-circuit-spec)`. The verifier must
avoid the cost sinks that defeat constant-folding:
- **(a) data-dependent shifts.** `value >> count` where `count` is a
witness lowers via `remove_bit_shifts` [8] to a runtime decomposition
that scales linearly with the maximum shift amount. A shift by a
*constant* is folded; a shift by a witness is not.
- **(b) data-dependent indexing.** `arr[i]` where `i` is a witness
lowers to `MemoryOp` plus a range check on `i`; a constant index is
folded out entirely.
- **(c) non-foldable surrounding context.** A loop with a constant
bound and no witness reads is unrolled by the `unrolling` SSA pass
and then constant-folded; the same loop with a witness-dependent
bound is *not* unrolled.
### 3.2 Patterns where the unconstrained pattern wins reliably
- **Integer division.** Witness `q, r`; verify `q * d + r == n`,
`r < d`. Verifier is one `AssertZero` plus one range check.
- **Square root.** Witness `s`; verify `s * s <= n < (s + 1) * (s + 1)`.
This is the form Noir docs give as the headline example [5].
- **Merkle inclusion.** Witness the path; recompute the root from the
leaf + path with a constant-arity hash gadget, equality-assert
against the public root.
- **Byte / bit decomposition with constant stride.** As in the Noir
doc's `u64_to_u8` example: the recombination is
`sum_i x_i * 2^(stride*i)` — every shift amount is a compile-time
constant [5].
### 3.3 Patterns where it loses — the PR #37 case study
`count_leading_zeros_u64_verified` was the predicted win that reversed.
The verifier shape was: witness `count`; assert `count <= 64`; if
`count == 64` then `value == 0`; else assert bit `(63 - count)` of
`value` is set and `value >> (count + 1) == 0`. The **dynamic
right-shift `value >> (count + 1)`** is the cost driver — the shift
amount is a witness, so cost (a) above bites.
The bit-decomposition spike that followed (witness `bits[64]` + one-hot
`is_leading` + cumulative-prefix flag) regressed even harder — it has
no data-dependent shift, but it pays N booleanity asserts plus a wide
recombination per bit. See `bench/SPIKES.md` entry
*"2026-05-03 — `clz_u64` verifier via explicit bit decomposition"*
for the full numbers and the verdict.
The merged primitive (`count_leading_zeros_u23_verified`, kept; the
u64 variant was deleted in PR #40) lives in the public API for two
reasons: (i) Lampe extraction needs a stable target name; (ii) future
helpers may compose with it under constant inputs where the verifier
*is* foldable. **No call site has been swapped.**
### 3.4 The Field-arithmetic soundness pitfall
The 2026-05-10 `shr_sticky_u64` Field-arithmetic spike is the most
recent example of how easily this trade-off goes wrong on the
soundness axis. The original Field-arith probe — measured as
`shr_sticky_u64_amplified_field_arith` in
`bench/unconstrained_gate_counts.bb_addition.json` — looked **17%
cheaper** than the typed baseline (12308 vs 16899 `bb gates`) and
was tempting to ship. It was unsound.
The unsound relation:
```rust
// Witness pow, pow_complement, quotient, remainder from a Brillig hint.
// Range-check them so the field elements behave like u64s.
pow.assert_max_bit_size::<65>();
pow_complement.assert_max_bit_size::<65>();
assert(pow * pow_complement == 0x10000000000000000); // 2^64
assert(quotient * pow + remainder == value);
(remainder * pow_complement).assert_max_bit_size::<64>();
quotient.assert_max_bit_size::<64>();
// ... sticky-direction clauses elided ...
```
What this proves: *some* power-of-two factor `pow` satisfies the
Euclidean-division relation against `value`, with `pow * pow_complement
== 2^64`. What this does **not** prove: that `pow == 2^shift` for the
*specific* `shift` the caller asked for. An adversarial prover faced
with `(value=0b1010, shift=3)` can supply `pow=4, pow_complement=2^62`
and the verifier accepts — the result corresponds to `value >> 2`, not
`value >> 3`. **Whenever you witness `pow2 = 2^k` via an `unconstrained`
hint and don't bind it back to `k`, you've broken soundness.**
The sound fix — implemented in the production
`shift_right_sticky_u64_verified` at `ieee754/src/unconstrained_ops.nr`
lines 397-613 — adds a bit-decomposition of `shift` plus a product
chain that recomposes `pow` from those bits:
```rust
// In the Brillig hint, additionally produce the six low bits of shift.
unconstrained fn shr_sticky_u64_field_hints(shift: u64)
-> (Field, Field, [Field; 6])
{
// ... pow2, pow2_complement computed as before ...
let mut bits: [Field; 6] = [0; 6];
let mut s = shift;
for i in 0..6 {
bits[i] = (s & 1) as Field;
s = s >> 1;
}
(pow2 as Field, pow2_complement as Field, bits)
}
// In the verifier:
//
// (i) bits[i] are boolean and recompose to shift.
for i in 0..6 {
assert(bits[i] * (1 - bits[i]) == 0);
}
let recomposed: Field =
bits[0]
+ bits[1] * 2
+ bits[2] * 4
+ bits[3] * 8
+ bits[4] * 16
+ bits[5] * 32;
assert(recomposed == shift as Field);
// (ii) pow == prod_i (1 + bits[i] * (2^(2^i) - 1)).
// Each factor is 1 when bit i is 0, and 2^(2^i) when bit i is 1.
let expected_pow2: Field =
(1 + bits[0] * 1)
* (1 + bits[1] * 3)
* (1 + bits[2] * 15)
* (1 + bits[3] * 255)
* (1 + bits[4] * 65535)
* (1 + bits[5] * 0xFFFFFFFF);
assert(pow2 == expected_pow2);
// (iii) onwards, the Euclidean-division clauses as before.
```
Cost of the binding for a 6-bit shift: 6 boolean checks + 5
multiplications in the product chain + 1 linear recomposition check.
After binding, the same `shr_sticky_u64_amplified_field_arith_sound`
benchmark drops to ~5% cheaper than the typed baseline. A 5% per-call
win is still a win on a low-level library: migrate the call sites
and bank the gates. The point of this section is not that 5% is
small — it's that the *unsound* 17% number was the lie, and the
sound 5% is what's actually available.
Always check: when a hint produces a value `y` that the verifier
treats as `f(x)` for some witness `x`, can the prover swap to a
different `y' = f(x') ≠ f(x)` that still passes every clause? If yes,
the verifier is unsound. Bit-decomposition of the input plus a
recomposition assertion is the standard fix; pay the cost.
### 3.5 Heuristic — when to spike vs when to skip
Spike a `_verified` variant only when **all four** of these hold:
1. The in-circuit spec contains an obviously expensive sub-computation
(a CLZ via 6 conditional shifts, a hash with witness-driven input
length, a division, ...).
2. The proposed verifier relation can be expressed *without* a
data-dependent shift, a data-dependent index, or a >100-element
loop over witnesses.
3. **The verifier binds every witnessed value back to the public
input** — no `pow2 = 2^shift` without a recomposition check on
`shift`; no `quotient = value / d` without a Euclidean-division
check; no witnessed bit-position without a uniqueness clause. The
soundness check is non-negotiable; the cost it imposes is part of
the candidate's cost.
4. There is (or will be) a Lean obligation in `proofs/<crate>/` that
pins the relation's uniqueness — otherwise the unconstrained hint
is unverified and roborev will find it (PR #36's `pub unconstrained`
hint was crate-privatised in `c7ecb8a` for exactly this reason).
If any of these fails, write the in-circuit spec directly and don't
spend the round.
## 4. When Field-arith helps vs when typed `u64` is fine
Empirical decision rules from this codebase's measurements (the
`shr_sticky_*` series in `bench/unconstrained_gate_counts.bb_addition.json`
and the `clz_*` series in `bench/SPIKES.md`):
### 4.1 Typed `u64` is already cheap
- **Bounded comparisons** (`<=`, `>=`, `<`, `==`). Plookup absorbs the
range checks. Don't migrate `assert(x <= 64)` to Field-arith --
measured constraint-equivalent and the typed form has no gate
penalty. (This is the rare two-forms-compile-to-the-same-thing
case; pick either.)
- **Constant-shift bitops** (`x >> 3`, `x & 0xFF`, `x << 8` with the
shift a literal). Constant-folded by the SSA passes; ends up as
cheap recombination. The `shr_sticky_u64_const20_*` benchmarks
confirm this — the typed baseline at `shift = 20` is 15 Width / 17
ACIR, compared to 72 / 34 in the dynamic-shift case.
- **Small dynamic shifts** (`shift < ~16`). Per-shift `remove_bit_shifts`
cost is O(M) for max-shift M; at M = 16 this is cheap enough that
Field-arith doesn't recover the overhead. `count_leading_zeros_u23`
is in this regime and kept the dynamic-shift verifier.
### 4.2 Field-arith helps
- **Witness-dependent shifts of wide registers** (`value >> shift`
where `value: u64` and `shift: u64` is a witness). The
`shr_sticky_u64` case: Field-arith via Euclidean-division saves
~5% bb gates *with* the soundness binding, ~17% without (but you
must include the binding). For `shr_sticky_u128` the gap is
larger (see the same bench entry).
- **Large multiplications used in a recomposition.** When the verifier
shape is `value == sum_i coeff_i * x_i` with coefficients known at
compile time, Field is the natural arena; the typed form would
recompute each shift.
- **Probability witnesses, inverses, division.** Anything that uses
`field_inv_or_zero(x)` as a witness can only be expressed in Field.
Don't try to write `x * x_inv == indicator` over `u64`.
### 4.3 Field-arith does **not** help
- **Small comparisons.** A `Field` cast back to `u64` to range-check
costs a `BlackBoxFunc::RANGE` you don't pay in the typed form.
- **Bit-extraction with constant stride.** `(value as Field).to_le_bits()`
is cheaper than hand-rolling, but if the stride and the count are
both constants the typed `>>` / `&` form constant-folds away.
- **Compositions where the surrounding code is typed.** Each `as Field`
/ `as u64` cast inserts a range check (§5). If 90% of the function
is typed-`u64`, casting in and out of `Field` for one operation
can cost more than the operation saved.
### 4.4 Profile before migrating
The end-to-end `bb gates` numbers for this codebase's primitives
(commit `768448f`, `bench/unconstrained_gate_counts.bb_addition.json`):
| Function | `bb gates` |
|----------------|-----------:|
| `add_float32` | 6506 |
| `sub_float32` | 6507 |
| `mul_float32` | 7348 |
| `div_float32` | 6977 |
| `add_float64` | 5602 |
| `sub_float64` | 5603 |
| `mul_float64` | 8943 |
| `div_float64` | 20015 |
`div_float64` is 3× the next-heaviest op. Before reaching for
Field-arith *anywhere*, run `bb gates --include_gates_per_opcode` on
`div_float64` and find the hotspot — global metric improvements are
much more likely to come from one expensive opcode than from a sweep
across the rest of the file.
## 5. Black-box / Plookup primitives
`BlackBoxFuncCall` opcodes lower to backend gadgets and can be either
the cheapest or the most expensive opcode in a circuit. Available
black-boxes in `nargo 1.0.0-beta.17` (stable through
`1.0.0-beta.20`) [9]:
- Logical: `AND`, `XOR`, `RANGE` — invoked via `&`, `^`,
`x.assert_max_bit_size::<N>()` or any typed-`uN` boundary check.
- Hashes: `keccakf1600`, `sha256` (compress), `blake2s`, `blake3`,
`pedersen_hash`, `pedersen_commitment`, `poseidon2_permutation`.
- Crypto: `ecdsa_secp256k1`, `ecdsa_secp256r1`, `embedded_curve_add`,
`multi_scalar_mul`, `aes128_encrypt`.
- Recursion: `recursive_aggregation`.
When to reach for a black box:
- **Hashes always go through black boxes** if the hash is one of the
supported set. Implementing Poseidon in arithmetic constraints is a
~100× regression for nothing.
- **Range checks dominate Plookup setup cost for small circuits** [2].
Don't over-assert — if a `u32` is already typed `u32`, it is
range-checked once at the function boundary, not on every use. The
`decompose_hint` range-check expansion is opcodes #1 / #2 / #3 in
`add_float32` and accounts for 79% of its 6506 gates.
- **Lookups are not generally available** — at time of writing, Noir
exposes Plookup-style lookup arguments only via the `RANGE`
black-box for arithmetic and the keyed gadgets above. There is no
general `lookup_table(...)` user-defined gadget. If you find
yourself wanting one, document the gap and use a polynomial
commitment over the table instead (see `noir-circuit-patterns`
§"Joins" — *lookup-table / set-membership* pattern).
`nargo info` does **not** distinguish black-box-call ACIR opcodes
from `AssertZero` opcodes in its summary count; the breakdown is
visible only through `bb gates --include_gates_per_opcode` or
`noir-profiler gates --include_gates_per_opcode` [2].
### 5.1 `assert_max_bit_size` is the canonical range check
Per the Noir Field docs [10]:
```rust
let x: Field = some_witness;
x.assert_max_bit_size::<32>(); // constrain x < 2^32
```
The signature is `pub fn assert_max_bit_size<let BIT_SIZE: u32>(self)`
on `Field`. Use this — rather than `assert(x < 2^32)` over `Field` —
when you've taken a `Field`-typed witness from a Brillig hint and
need to bound its size. The form `(remainder * pow_complement).assert_max_bit_size::<64>()`
in `verify_shr_sticky_u64_relation` is the canonical bounded-product
range check.
The Noir explainer also notes that `assert_max_bit_size` can replace
an explicit range check when array out-of-bound checks have already
been performed [11]: *"Converting a `Field` to `u32` can be costly
due to modulo operations. This cost can be mitigated by using
`assert_max_bit_size::<32>()` and `as u32`, especially when array
out-of-bound checks are already performed, as these checks can make
the explicit range check redundant"*.
## 6. Constant-folding rules
What the SSA optimisation passes do, in roughly the order they
run [12] (`compiler/noirc_evaluator/src/ssa/opt/`):
- `inlining` — function calls inlined; enables downstream folding.
- `unrolling` — loops with compile-time-known bounds and no `break`
on a witness condition are unrolled.
- `mem2reg` — promotes memory references to SSA values; enables
more folding.
- `constant_folding/` — folds expressions whose inputs are all
constants after SSA propagation.
- `simple_optimization` / `simplify_cfg` / `flatten_cfg` — the
`flatten_cfg` pass is **non-optional**, because ACIR has no
control-flow operators; it removes all branches, replacing them
with conditional selections [12]. After flattening, both arms of
an `if` contribute constraints.
- `remove_bit_shifts` — lowers `>>` and `<<` by a witness amount to
a bit-decomposition loop [8].
- `remove_unused_instructions` / `die/` — dead-instruction
elimination, scheduled after `flatten_cfg`.
Practical rules:
- **Static is foldable.** A constant in a shift amount, an array
index, or a loop bound is folded away.
- **Witnesses are not foldable.** Any operation whose result depends
on a `pub`/private input survives into ACIR.
- **Both branches cost.** `if` over a witness condition is flattened
to conditional select (`x = c ? a : b`), so both `a` and `b` must
be computed and constrained. Don't write
`if cheap_check { expensive } else { 0 }` to "skip" a cost.
- **Loop-unrolling thresholds.** Loops with witness-dependent bounds
are not unrolled; the body must not depend on the iteration index
for the unroller to fold the iteration count out.
- **Microbenchmarks with literal-constant inputs lie.** A
microbenchmark that passes a literal constant into a CLZ helper
measures the constant-folded happy path; it does not measure what
the prover pays at a real call site. **Use `pub` witnesses for the
inputs of every benchmark.** The `PRIMITIVE_BENCHMARKS` block in
`scripts/benchmark_gates.py` threads `pub u64` witnesses through
to defeat folding [7].
## 7. Common pitfalls
### Mixed bit-widths
Noir is strict about integer type matching for bitwise ops:
`(value as u64) & (mask as u8)` is rejected. The fix is **explicit
`u<N>` annotations on every literal and intermediate**:
```rust
let masked: u32 = value & 0x007F_FFFF_u32;
let probe: u32 = (low23 >> top_bit_pos) & 1_u32;
```
The `unconstrained_ops.nr` module's preamble documents this
discipline as a crate-wide convention [13].
### Unconstrained outputs leaking
A `pub unconstrained fn` exposes the unverified hint through the
public API, letting downstream callers bypass the verifier.
Roborev's PR #36 review caught this exact bug
(`c7ecb8a` made `count_leading_zeros_u23_unconstrained`
crate-private). **Rule: every `unconstrained` hint is private to the
module that contains its verifier.** The only sanctioned public
symbol is the `_verified` wrapper.
### Range-check density
Every `assert_max_bit_size::<N>()` (and every cast through a typed
`uN` boundary) lowers to `BlackBoxFunc::RANGE` (§5). If the upstream
type is already `uN`, the value is already range-checked at the
function boundary; re-asserting is a free regression.
### Shared-test-helper anti-pattern
A verifier copy-pasted into adversarial tests drifts. The convention
in this workspace is that the production `_verified` function and the
adversarial tests both call the same crate-private
`verify_*_relation(...)` helper (see `verify_shr_sticky_u64_relation`
in `ieee754/src/unconstrained_ops.nr` [13]). A regression in the
production verifier is then also caught by the adversarial-tests
path; with a copy, it can hide for a release.
### Both arms of `if` cost
After `flatten_cfg`, both arms of an `if` contribute constraints
even when only one is "selected" by the witness. Don't write
`if x == 0 { skip_expensive } else { expensive }` expecting to save
gates on the zero path — write a single straight-line expression and
let constant-folding handle constant inputs.
### Public-input layout drift
`main`'s public-input layout is a contract; changing the order
silently breaks every existing prover. `noir-circuit-patterns`
§"Public input layout" is the source of truth.
## 8. Decision checklist for a new `_verified` primitive
Run this before spending a round on a new candidate:
1. **What's the in-circuit-spec baseline?** Write it. Compile and run
`bb gates` against an amplified harness (N ≥ 32) with `pub`
witnesses. Record the per-call cost.
2. **What relation am I proposing for the verifier?** Sketch it on
paper. Does it have a data-dependent shift (cost (a))? A
data-dependent index (cost (b))? A loop bounded by a witness
(cost (c))? Each "yes" is a strike.
3. **Does every witnessed value bind back to a public input?** If
the hint produces `y = f(x)` and the verifier never relates `y`
to `x` other than via clauses also satisfied by `f(x') ≠ f(x)`,
the verifier is unsound. Add the binding (§3.4) and count its
cost against the candidate.
4. **Can I sketch the verifier's `bb gates` from the relation
shape?** Range checks cost ~50-200 gates each under UltraHonk; a
bounded Field multiplication is ~20-50 gates; a Field equality is
small (~5-10 gates) but each `AssertZero` opcode adds glue.
Predict; if measurement disagrees by >2×, the relation is not
what you thought it was.
5. **Is there a black-box that does this directly?** §5. Don't
reimplement Poseidon.
6. **Have I budgeted for both isolated and composed measurement?**
Run an amplified isolated harness *and* a composed harness that
mirrors a real call site. If the composed delta differs from the
isolated delta by more than ~20%, suspect a constant-folding
artefact.
7. **Does the relation match the Lean obligation in
`proofs/<crate>/`?** The Lampe extraction needs the verifier's
relation to match the `*_unique` and `*_correct` lemmas
line-for-line.
8. **Has a previous spike already tried this?** Check
`bench/SPIKES.md` and `bench/unconstrained_gate_counts*.json`.
Don't re-run a negative spike.
## 9. References
Noir documentation (retrieved 2026-05-11; pinned to docs version
`v1.0.0-beta.20` unless noted):
1. Profiler — *"Balancing proving and execution optimisations"* —
`https://noir-lang.org/docs/tooling/profiler`. Source of the
"Brillig opcodes are not a proving cost" framing and the "ACIR
opcodes are at best approximations of proving performance"
caveat.
2. Profiler — *"Profiling proving backend gates / Flamegraphing"* —
same URL. Source of the `noir-profiler gates --include_gates_per_opcode`
invocation and the `blackbox::range` Plookup-setup-cost
discussion. Verified 2026-05-11 via context7 (`/noir-lang/noir`).
3. ACIR opcodes enum — `acvm-repo/acir/src/circuit/opcodes.rs`,
`noir-lang/noir`. Six variants: `AssertZero`,
`BlackBoxFuncCall`, `MemoryOp`, `MemoryInit`, `BrilligCall`,
`Call`.
4. Issue noir-lang/noir#7525 — *"bug: expression width not honored
in certain gates"* — defines expression width as the maximum
number of witness wires per `AssertZero` gate.
5. Unconstrained Functions concept doc —
`https://noir-lang.org/docs/noir/concepts/unconstrained` —
canonical `u64_to_u8` example, 65 → 33 ACIR opcodes.
6. `bbup` — Aztec Barretenberg version-pinning tool. Run with no
arguments to detect the installed `nargo` and pull a compatible
`bb`. For `nargo 1.0.0-beta.17` this resolves to
`bb 3.0.0-nightly.20251104`; `bb 0.84.0` errors with
*"Length is too large"* against beta.17 bytecode.
7. `scripts/benchmark_gates.py` — the in-repo amplification harness.
The `PRIMITIVE_BENCHMARKS` `*_amplified_*` block instantiates
each primitive N = 64 times with witness-dependent inputs and
reports `bb gates` per amplified `main`. Output schema documented
in `bench/unconstrained_gate_counts.bb_addition.json`.
8. SSA pass `remove_bit_shifts` —
`compiler/noirc_evaluator/src/ssa/opt/remove_bit_shifts.rs`.
Lowers runtime shifts to bit-decomposition.
9. Black-box functions reference —
`https://noir-lang.org/docs/noir/standard_library/black_box_fns`
(also at `docs/versioned_docs/version-v1.0.0-beta.20/noir/standard_library/black_box_fns.md`).
10. `Field::assert_max_bit_size` — Noir Field type docs,
`docs/versioned_docs/version-v1.0.0-beta.20/noir/concepts/data_types/fields.md`.
Signature `pub fn assert_max_bit_size<let BIT_SIZE: u32>(self)`.
Verified 2026-05-11 via context7.
11. *"Writing Noir efficiently"* —
`docs/versioned_docs/version-v1.0.0-beta.20/explainers/explainer-writing-noir.md`.
Source of the `assert_max_bit_size` + array out-of-bound
redundancy guidance.
12. Noir compiler architecture — `docs/compiler/architecture.md`.
SSA pass list and ordering, including the non-optional
`flatten-cfg` pass.
13. `ieee754/src/unconstrained_ops.nr` —
- `shift_right_sticky_u64_verified` (lines 475-482): the
pub wrapper that calls the hint and asserts the relation.
- `shr_sticky_u64_field_hints` (lines 397-415): Brillig hint
producing `pow2`, `pow2_complement`, and the bit-decomposition
of `shift`.
- `verify_shr_sticky_u64_relation` (lines 492-613): the sound
verifier with the product-chain binding from `shift` to `pow2`.
- Module preamble documents the explicit-`u<N>`-annotation
discipline and the crate-private hint convention.
In-repo benchmark ledgers (read these before every optimisation
round):
- `bench/SPIKES.md` — spike-by-spike verdicts and reasoning.
Entries to date: `clz_u64` bit-decomposition (NO-GO);
`shr_sticky_u64` Euclidean-division verifier (MIXED);
`shr_sticky_u64` constant-shift verifier (NO-GO).
- `bench/unconstrained_gate_counts.bb_addition.json` — every
`*_amplified_*` and `*_isolated_*` benchmark's `bb gates` plus
`nargo info` (`expression_width`, `acir_opcodes`). The most
recent run is the source of truth for per-call cost comparisons
in this skill.
- `bench/unconstrained_gate_counts.json` — earlier ledger, pre-`bb
gates` instrumentation. Useful for historical context; do not
use for new decisions.
## 10. `comptime` evaluation — folding loops, constants, and helpers
`comptime` marks code that runs at compile time. The Noir docs list
the applicable forms: `comptime fn`, `comptime global`,
`comptime struct`, `comptime type`, `comptime { ... }` blocks,
`comptime let`, and `comptime for` [14]. The cost relevance is
simple: anything `comptime` is **not** in ACIR, so it pays zero
gates at proving time.
The canonical pattern in this codebase is a `comptime fn` that
loops over a trait associated constant to build a power-of-two
field constant:
```rust
// ieee754/src/float.nr:93-99
comptime fn implicit_bit_field() -> Field {
let mut r: Field = 1;
for _ in 0..Self::MANT_BITS {
r = r * 2;
}
r
}
```
`Self::MANT_BITS` is a `u32` trait associated constant — known at
compile time per-impl (`23` for `Float32`, `52` for `Float64`).
The `for` body executes at compile time and the call site sees a
single folded `Field` constant. The same shape repeats for
`sign_field`, `exp_max`, `exp_bias` at
`ieee754/src/float.nr:103-127`.
Verified empirically in this repo: the file-level comment at
`ieee754/src/float.nr:85-86` records that the probe
`A::exp_max() + B::exp_max() + x` compiles to **0 ACIR opcodes**
for the constant portion. The compile-time fold is real, not
hypothetical.
The contrast: `for _ in 0..shift { ... }` where `shift` is a
witness is **not** unrolled and **not** folded — see §6, "Loops
with witness-dependent bounds are not unrolled". The fact that the
loop body is identical at every iteration does not save you; the
backend pays for each constrained iteration.
Rule: when a power-of-two or other compile-time-derivable constant
appears in arithmetic, wrap its construction in `comptime fn` so
the optimiser proves to itself that the value is foldable, rather
than relying on the SSA passes to find the fold post-hoc.
## 11. Generics + monomorphisation
Noir supports const generics on free functions and trait methods
via `<let N: u32>` [15]:
```rust
struct BigInt<let N: u32> { limbs: [u32; N] }
impl<let N: u32> BigInt<N> { fn first(...) -> Self { ... } }
```
Cost-relevant facts:
- **Each distinct instantiation generates its own ACIR.** A
function called with `N=23` and `N=52` is monomorphised twice;
the constraints are independent. If you have a helper that
works at multiple widths, you pay the constraint cost of
whichever instantiations the call graph reaches.
- **Identical instantiations dedupe.** Two call sites both passing
`N=23` share a single monomorphisation. There is no per-call-site
duplication cost; the SSA `inlining` pass (see §6) then folds the
monomorphised body into the caller.
- **The IEEE 754 `IEEEFloat` trait is *not* generic in this sense.**
It uses trait associated constants (`let EXP_BITS: u32;`) instead
of `<let EXP_BITS: u32>` parameters — see `noir-circuit-patterns`
§"Trait associated constants" for the structural reason. The cost
consequence: `Float32` and `Float64` each get their own
monomorphisation of every default method, and a generic helper
like `pub(crate) fn ieee_eq<T>(a: T, b: T) -> bool where T: IEEEFloat`
at `ieee754/src/float.nr:688` is monomorphised once per `T`.
Practical implication for cost control: prefer const-generic
parameters over runtime-witness shapes where possible. A function
`fn shr<let N: u32>(x: u64) -> u64 { x >> N }` folds the shift away
because `N` is comptime; the same with `shift: u64` does not (§6
"data-dependent shifts").
## 12. Unconstrained dispatch cost — the per-`unsafe` overhead
`unconstrained fn` bodies run in Brillig and are free at proving
time (§1 "Brillig opcodes are not a proving cost"). The dispatch
itself, however, is **not** free. Each `unsafe { ... }` call site
emits an ACIR `BrilligCall` opcode. The per-call ACIR cost is
approximately **49 opcodes per `unsafe { ... }` call regardless of
the unconstrained body's size** *(verify with `bb gates` on your
exact Noir version — this is a rule of thumb from the codebase's
spike measurements, not a published figure)*. Calling 64 distinct
unconstrained helpers from one verifier costs ~64 × 49 ACIR
opcodes; calling one helper that batches 64 outputs into a single
return costs ~49.
Beta.17 lint requires a `// Safety: ...` comment above every
`unsafe` block explaining why calling the unconstrained function
is acceptable [16]. The Noir docs are explicit that the comment
may sit on the `unsafe` block itself or the statement it resides
in [16]. The convention in this codebase, exemplified at
`ieee754/src/unconstrained_ops.nr:123-126`:
```rust
// Safety: `verify_clz_u23_relation` enforces the relation that
// uniquely binds `count` to `value`. Any tampered count is
// rejected.
let count: u32 = unsafe { count_leading_zeros_u23_unconstrained(value) };
```
Practical optimisation: **batch hint outputs into one
`unconstrained fn` return** so the verifier pays one dispatch, not
N. `shr_sticky_u64_field_hints` at
`ieee754/src/unconstrained_ops.nr:397-415` returns a 3-tuple
(`pow2`, `pow2_complement`, `bits[6]`) from a single Brillig call;
splitting it into three calls would multiply dispatch overhead.
## 13. Witness-vs-comptime branches — both arms cost only for witnesses
Section §6 already covers "both arms of `if` cost" *after*
`flatten_cfg`. The cost-relevant nuance is that `flatten_cfg` only
flattens branches on witnesses. Branches on comptime-known
conditions are eliminated entirely:
```rust
// Folded at compile time -- only the chosen arm contributes ACIR.
if comptime_known { expensive_a } else { expensive_b }
// Flattened to a multiplexer -- both arms contribute ACIR.
if witness_value { expensive_a } else { expensive_b }
```
The boundary between the two: if **every** value used in the
condition can be traced back to literals, comptime globals, trait
associated constants, or `comptime fn` results, the SSA passes
fold the condition before `flatten_cfg` runs and only one arm
survives. As soon as a witness enters the condition, both arms
contribute constraints.
Rule for cost-sensitive code: hoist branches on
trait-associated-constant predicates (e.g. "if this is a 32-bit
float vs a 64-bit float") so they fold; never write
`if cheap_witness_check { skip_expensive } else { expensive }`
expecting to skip the expensive branch — that pattern adds the
check's cost on top of always-paying for the expensive arm.
## 14. Style rules from this codebase's measurements
The marketplace `noir-idioms` skill prefers `if/else { x } else { y }`
over hand-rolled indicator-muxes and recommends precomputing values
once instead of repeating expensive calls. Both are sensible
high-level defaults. For a low-level library that wants to bank
every gate, the calibration on this codebase's measurements is
that two of those defaults are wrong: the optimiser already CSE's
identical sub-expressions (so caching predicate calls is free
verbosity), and mutually-exclusive 3-way+ Field-typed branches
compile to fewer constraints as indicator-muxes than as
`if/else if/else` chains. Both differences trace to specific
commits.
### 14.1 The optimiser already deduplicates identical sub-expressions
Empirical finding from the 2026-05-11 IEEE 754 kernel refactor
(commit `142ca02`): replacing six cached predicate locals at the top
of each `*_with_rounding` wrapper
```rust
// Before: caches `a_is_nan`, `a_is_inf`, ... in locals.
let a_is_nan = self.is_nan();
let b_is_nan = other.is_nan();
let a_is_inf = self.is_infinity();
let b_is_inf = other.is_infinity();
let a_is_zero = self.is_zero();
let b_is_zero = other.is_zero();
// ... later ...
if a_is_zero & b_is_zero { ... }
if a_is_nan | b_is_nan { ... }
```
with the inline form
```rust
if self.is_zero() & other.is_zero() { ... }
if self.is_nan() | other.is_nan() { ... }
```
was **gate-count-identical** across all eight headline benchmarks
(`add`/`sub`/`mul`/`div`_float{32,64}`, ±0 gates). The constraint
optimiser CSE's repeated trait-method calls whose body is a pair of
Field comparisons. Don't cache predicates in locals just to save
"redundant" calls -- the constraints are already shared. Since the
caching is gate-cost-neutral, take whichever form reads better in
context; the inline form is usually it.
**Use the inline form when:** the call is a pure read of struct
fields (`a.is_zero()`, `a.exponent()`, `a.sign()`) or a `comptime
fn` returning a constant (`Self::implicit_bit_field()`,
`Self::exp_max()`).
**Cache in a local when:** the value comes from a verified
relation that costs gates each time it's witnessed
(`denormal_shift_verified::<T>(a.mantissa())` returns a `(Field, Field)`
pair through a Brillig hint + verifier — call it once and reuse the
binding), or a value derived from arithmetic over witness inputs
that doesn't trivially CSE.
### 14.2 `if/else` vs indicator-mux
The `noir-idioms` rule is right for binary 2-way selects over
runtime-witnessed conditions:
```rust
// Idiomatic for 2-way over a witness condition
let val = if cond { x } else { y };
```
The compiler lowers this to a conditional select that costs the same
as `cond_f * (x - y) + y` (one Field multiply + one add). Two
gate-equivalent forms; take the readable one.
**Where indicator-mux wins (measured −86 gates total across the 8
kernels, commit `6f3d204`):** mutually exclusive 3-way (or wider)
chains over **Field-typed values** with **boolean-Field
indicators**. Take the gates, write a one-line comment naming the
branches:
```rust
// Mutually-exclusive 3-way: zero / denormal / normal.
// Indicator-mux beats `if A { x } else if B { y } else { z }` here.
let zero_f: Field = operand.is_zero() as Field;
let denorm_f: Field = operand.is_denormal() as Field;
let mant: Field = operand.mantissa();
let effective = (1 - zero_f)
* (denorm_f * mant * pow2_shift
+ (1 - denorm_f) * (mant + implicit_bit));
```
The per-site win is ~5-15 gates; across a kernel's worth of mux
sites that's ~80-200 gates banked. The pattern lives in
`kernels::common::effective_mantissa` /
`effective_exponent_offset`.
**Polarity gotcha — sanity-test every mux.** This session caught two
inverted indicator-mux formulae (mul `bit_overflow` scale,
`a_is_zero` outer multiplier — see commit `b3131d4`). Both compiled
cleanly and passed `nargo check`; only unit tests caught the bug.
Before committing a mux:
1. Plug in `indicator = 0` and read off the result — is it the
correct branch's value?
2. Plug in `indicator = 1` — same check, the other branch.
3. Run `nargo test` from `ieee754_unit_tests/`. Always. The IEEE 754
suite has 147 cases that catch sign-bit and edge-case regressions
in seconds.
Don't trust intuition on polarity. The kill-indicator pattern
`(1 - kill) * (...)` reads naturally (kill = 1 zeroes the result)
but it is easy to type `kill * (...)` instead. The kernel tests
exist for this reason — they caught both bugs.
### 14.3 Mutations (`let mut x = ...; if cond { x = ... }`)
ACIR has no mutation; constrained `let mut` becomes a chain of
conditional selects after SSA flattening, equivalent to the
expression form. The cost is the same as writing the equivalent
chained `if/else`.
**Mutation is fine for:** override-style fixups where the special
cases are mutually exclusive in *practice* but the kernel computes a
"normal" value first and lets the wrapper replace it. The four
`*_with_rounding` skeletons in `crate::float` are written this way:
```rust
let mut result = normal_result;
if result_is_zero { result = Self::signed_zero(result_sign); }
if overflows_to_inf { ...; result = ...; }
if self.is_nan() | other.is_nan() { result = Self::nan(); }
result
```
Each assignment is a conditional select; the chain compiles to one
nested mux. Same cost as writing the equivalent `if/else if/else`
expression, more readable when the special cases have different
conditions.
**Avoid mutation for:** values that the kernel computes
*incrementally* under a runtime-witnessed loop condition. ACIR
flattens this badly — every iteration of an unrolled loop pays the
full mutation chain. Prefer functional folds with a single
"output" Field per iteration.
**Never** use `let mut` to simulate stateful control flow that
constant-folding would otherwise apply to. If you find yourself
writing `let mut state = 0; for ... { if cond { state = state + 1 } }`
where the iteration bound is comptime, write the closed-form
expression instead; the compiler will not deduce it from the
mutation chain.
### 14.4 Summary
| Pattern | Use when | Avoid when |
| --- | --- | --- |
| Inline predicate calls | Reads of struct fields or comptime functions | A witnessed-and-verified pair returned from a Brillig hint |
| Cached local | A verified relation costs gates per witness | A comptime-folded constant or pure field-access read |
| Binary `if/else` expr | 2-way select over a runtime-witnessed `bool` | Mutually exclusive 3-way+ chain over Field values |
| Indicator-mux | Mutually exclusive Field-typed selects, 3-way or wider | Binary selects (cost-equal -- pick the readable one) |
| `let mut result; if X { result = ... }` | Override-style special-case fixups in a wrapper | Stateful accumulation under a comptime-foldable loop |
Always measure with `bb gates`. The per-site delta in this codebase
is ~5-15 gates -- which compounds across kernels and is precisely
the kind of saving a low-level library is in the business of
banking. Polarity errors are recoverable in seconds with `nargo
test`; gate regressions are not always so cheap to catch.
### 14.5 Readability rules where the gate cost is equal
These rules apply when both forms compile to the same constraint
count -- they cost nothing to follow and improve cross-reader speed.
* **`bool as Field`, never `if b { 1 } else { 0 }`.** A `bool` is
already `{0, 1}` in the field; the cast is an identity and the
`if/else` lowers to the same conditional select. Examples in
`ieee754/src/float.nr`:
```rust
// Prefer:
Self::signed_infinity(sign as Field)
// Avoid (gate-equivalent but more characters of noise):
Self::signed_infinity(if sign { 1 } else { 0 })
```
* **`<` / `>` over `.lt()` / `.gt()` for typed integers.** `Field`
has no `<` operator (no natural total order on a finite field),
so `Field::lt` / `Field::gt` are the only option there -- use
them. For typed values (`u8`, `u32`, `u64`, ...) prefer the
built-in operator syntax: `x < y` reads better than `x.lt(y)`
and compiles to the same plookup-backed range check. Don't
cast a typed value to Field just to use `.lt()`; if the value is
already typed, keep it typed and use `<`.
* **`pow_32` over hand-rolled exponentiation loops for runtime
exponents.** Where the exponent is a witness or runtime-derived
value, `2.pow_32(exp)` is shorter and equivalent (or cheaper)
than walking a per-row power-of-two table verifier; see
`kernels::common::denormal_shift_verified` and `kernels::*`
call sites. **Exception:** comptime exponents in `comptime fn`
trait defaults -- the for-loop body
`let mut r = 1; for _ in 0..N { r = r * 2; }` folds to a Field
literal per impl, while `2.pow_32(N)` runs at runtime per call
site (measured -~700 gates per op when an `assert_lt(x,
2.pow_32(...))` attempt was reverted in `b3131d4`). See §15.1.
## 15. Keep values as Field; avoid round-trips through typed integers
Empirical finding from the 2026-05-11 IEEE 754 kernel migration
(commits `f08ba09` through `15b464f`): every `Field -> u64` cast
emits a range check (a `assert_max_bit_size::<64>` worth of
constraints — a few plookup rows). Every `u64 -> Field` cast is
free (the typed value is already known small). A round-trip
`some_field as u64 as Field` therefore pays the range check for
nothing.
**The kernels' biggest single optimisation this session was simply
not converting.** Pre-rewrite `mul_float64` was 8943 `bb gates`
because the mantissa product went through `u128` (a struct of two
`u64`s) and the per-step intermediates were `u64`-typed. Post-rewrite
it is 6282 — the product is a single Field multiplication, and the
intermediates that used to be `u64` are `Field` throughout. f64
division dropped even more drastically (20015 -> 6360, −68%) because
the long-division loop's `u128 / u64` was replaced by a single
Field-arith Euclidean witness `dividend == q * divisor + r` with
`q.assert_max_bit_size::<Q_BITS>()` and `r.lt(divisor)`. Same
correctness, no `u128` plumbing.
### 15.1 Concrete rules
- **Default to `Field` for intermediate values.** Bit-widths,
positions, shift amounts, mantissa-with-implicit-bit values, even
exponents — all `Field`. Only cast at function boundaries where
the callee insists on a typed argument.
- **Don't introduce a `u64` local just to call `&`/`>>`/`<<`.**
These typed bit-ops compile through black-box decomposition (see
§1's `bb_gates` example: 34 ACIR opcodes for a typed shift
compiled to MORE gates than 158 ACIR opcodes of Field-arith
Euclidean splits). For shift-by-power-of-two, write
`value * 2^k` (left shift) or witness `(q, r)` with `value == q *
2^k + r` (right shift); both are pure Field arithmetic.
- **Avoid `i64` entirely.** Noir's `i64` doesn't cast directly to
`Field` (compile error: "Only unsigned integer types may be casted
to Field"). Track signed quantities in **offset form** instead:
pick an `offset` larger than the maximum negative magnitude and
store `value + offset`. The mul/div/sqrt kernels do this with
`offset = T::implicit_bit_field()` for unbiased exponents (see
`kernels::common::effective_exponent_offset`). Comparisons use
`Field::lt`; the offset cancels in subtractions.
- **Use comptime-folded constants, not `pow_32`.** `T::implicit_bit_field()`,
`T::exp_max()`, `T::exp_bias()` are `comptime fn`s whose for-loop
bodies fold to Field literals per impl (verified: the probe
`A::exp_max() + B::exp_max() + x` compiles to 0 ACIR opcodes).
Calling `2.pow_32(Self::MANT_BITS)` produces the same value at
runtime, costing ~198 gates per call site (measured in §16's
primitive table; pow_32 slope = 198/call). An attempt to switch
`compose` to `assert_lt(mantissa, 2.pow_32(Self::MANT_BITS))`
cost ~700 gates per op until reverted in `b3131d4` -- that
number combined the `assert_lt` setup, the `pow_32` calls, and
several other deltas, but the broader rule stands: runtime
`pow_32` of a comptime input wastes the cost of a per-circuit
pow_32 amortisation chain that the for-loop pattern folds away.
### 15.2 When a `u64` cast IS necessary
Some helpers in this workspace take `u64` arguments out of legacy
convenience (`should_round_up`, `count_leading_zeros_u64_verified`,
`shift_right_sticky_u64_verified`). Each call site pays:
* one `assert_max_bit_size::<N>` for `Field -> u64` (cheap with
plookup, but not free);
* the cost of the typed helper's body;
* one `as Field` on the way back (free).
Empirical guidance from the migration:
* **CLZ in the underflow normaliser** (`kernels/add.nr` line ~280):
the existing `count_leading_zeros_u64_verified` saves more gates
than a hypothetical Field-typed CLZ would, so the `u64`
round-trip is justified. Documented as a known typed island in
the kernel header.
* **`should_round_up`** (called from each kernel's rounding stage):
re-implementing the directed-rounding dispatch in Field arithmetic
was measured **more expensive** than the round-trip in sqrt; the
typed helper stays. Documented similarly.
* **`shift_right_sticky_u64_verified`** in `add.nr`'s exponent
alignment: the `_field_verified` sibling exists and is preferred
in div/mul/sqrt where the surrounding code is already Field-typed.
In add the alignment shift lives inside a u64-typed mantissa
context so the round-trip is cheaper than refactoring the
surrounding context.
The rule: when you find a u64 cast in the constrained body, ask
whether the surrounding context is already Field-typed. If yes,
look for a `_field` sibling helper. If no, the round-trip may be
the right local optimum — leave it and document why.
### 15.3 Field as the working type even where u128 "should" be
f64 mantissa products are 53×53 → 106 bits, which "needs" `u128`.
But `Field` is BN254 (~2^254), so 106-bit products fit in `Field`
with 148 bits of headroom. The pre-rewrite f64 mul kernel had a
hand-rolled `mul_u64_to_u128` doing schoolbook 32-bit-limb
multiplication; the post-rewrite is
```rust
let product: Field = a_mant_full * b_mant_full;
```
Then a Brillig hint produces `(high: u64, low: u64)` and the
verifier asserts `(high * 2^64 + low) as Field == product` with
range checks on `high` and `low`. One Field multiply replaces a
30-line `mul_u64_to_u128`. Same correctness, fraction of the gates.
The same trick applies anywhere "I need more than 64 bits of
intermediate state": work in Field, range-check the split at the
boundary, and only reach for `u128` if you actually need typed
bit-ops on a 128-bit value that won't fit a Field-arith Euclidean
relation. (In practice, you almost never do.)
## 16. Primitive-operation cost table (for code generators)
Measured 2026-05-11 on `bb 3.0.0-nightly.20251104` (UltraHonk) against
Noir 1.0.0-beta.17.
**Methodology -- read carefully.** A naive "one probe, one operation"
benchmark is misleading on UltraHonk because **most primitives have a
large one-time plookup-table setup cost that dwarfs the per-call
cost**. A circuit calling `Field::lt` once measures ~2979 gates;
calling it 20 times measures 6444. The marginal cost is **183 gates
per call, not 2979**. The 2796-gate one-time overhead amortises across
all calls in the same circuit that share the same plookup table.
Numbers below are obtained by running each primitive N times in a
loop, plotting bb_gates against N, and reporting the **slope** (the
per-call marginal cost) and the **intercept** (the one-time setup).
For a code generator emitting many calls in one circuit, the slope
is the right per-op cost. For a code generator concerned about a
single isolated use, use intercept + slope.
| Operation | Per-call (slope) | One-time setup (intercept) |
| ------------------------------- | ---------------: | -------------------------: |
| `Field` `+`, `*` | 0 | 0 |
| `bool as Field` | 0 | 0 |
| `if b { 1 } else { 0 }` | 0 | 0 |
| `Field` `==`, `!=` | 1-2 | 0 |
| `assert(x == y)` (Field) | 1 | 0 |
| `if cond { x } else { y }` | 2 | 0 |
| `cond_f*x + (1-cond_f)*y` (mux) | 2 | 0 |
| `assert_max_bit_size::<8>` | ~2 | 105 |
| `assert_max_bit_size::<16>` | ~2 | 2799 |
| `assert_max_bit_size::<24>` | ~3 | 2967 |
| `assert_max_bit_size::<32>` | ~3 | 2800 |
| `assert_max_bit_size::<48>` | ~4 | 2808 |
| `assert_max_bit_size::<64>` | ~4 | 2840 |
| `u64 ==` | ~11 | 2842 |
| `u64 <` | ~13 | 2843 |
| `u64 >> const` | ~13 | 2849 |
| `u64 >> witness` | ~96 | 2796 (then floor jump at N=2 to 4120) |
| `Field::lt(x, y)` | 183 | 2796 |
| `assert_lt(x, y)` (bn254) | 183 | 2799 |
| `pow_32(x)` (runtime x) | 198 | 57 |
| `u64 &` (bitwise AND) | ~85 | 4035 (floor jump at N=2 to 8232) |
**Read this as:** the first call to `Field::lt` in a circuit
contributes ~2979 gates (setup + 1 call). The 2nd through Nth call
each contribute 183. So a circuit calling `Field::lt` 10 times costs
~2796 + 10*183 = ~4626 gates total -- not 10 * 2979 = 29,790.
Raw amortisation data (the slopes above are computed from these):
```
Field::lt N=1: 2979, N=2: 3162, N=10: 4621, N=20: 6444 -> 183/call
assert_max_bit_size::<8> N=1: 107, N=2: 110, N=10: 128, N=20: 150 -> 2.3/call, setup ~105
assert_max_bit_size::<16> N=1: 2801, N=2: 2804, N=10: 2824, N=20: 2848 -> 2.5/call, setup ~2799
assert_max_bit_size::<24> N=1: 2970, N=2: 2974, N=10: 2994, N=20: 3018 -> 2.5/call, setup ~2967
assert_max_bit_size::<32> N=1: 2803, N=2: 2806, N=10: 2828, N=20: 2855 -> 2.7/call, setup ~2800
assert_max_bit_size::<48> N=1: 2812, N=2: 2817, N=10: 2849, N=20: 2888 -> 4.0/call, setup ~2808
assert_max_bit_size::<64> N=1: 2844, N=2: 2849, N=10: 2883, N=20: 2925 -> 4.3/call, setup ~2840
u64 < N=1: 2856, N=2: 2866, N=10: 2975, N=20: 3112 -> 13/call
u64 == N=1: 2853, N=2: 2861, N=10: 2952, N=20: 3067 -> 11/call
u64 & N=1: 4120, N=2: 8232, N=100: 11346, N=200: 19871 -> ~85/call after the floor jumps
u64 >> witness N=1: 2896, N=2: 4120, N=100: 12451, N=200: 22076 -> ~96/call after the floor jumps
pow_32 N=1: 255, N=2: 453, N=10: 2037, N=20: 4017 -> 198/call
```
### 16.1 Code-generator decision rules from this table
These are the rules a generator can apply mechanically. Numbers cite
per-call costs (the slope), since a real circuit emitting these
primitives will pay the one-time setup once.
1. **Field `+`, `*`, `==`, `!=`, `assert(==)`, simple muxes are
free or near-free** (0-2 gates). Generate as many as the
algorithm needs. Don't hoist or share these.
2. **`bool as Field` and `if b { 1 } else { 0 }` are
gate-equivalent.** Both compile to the same conditional select
(0 gates over baseline). Emit `b as Field` for readability.
3. **`if/else` and indicator-mux are gate-equivalent on a single
binary Field select** (both 2 gates). The 5-15 gate per-site
saving §14.2 describes comes from chained / nested 3-way+
mutually-exclusive branches, not from individual binary muxes.
4. **`assert_max_bit_size::<N>` per-call cost is ~2-4 gates
regardless of N.** The cost discontinuity is in the *setup*, not
the per-call:
* **`<8>` setup = ~105 gates.** bb composes 8-bit range checks
from byte-decomposition tables that are already in the
circuit's standard machinery; no dedicated plookup table is
instantiated.
* **`<16>`, `<32>`, `<48>`, `<64>` setup = ~2800-2840 gates.**
Each instantiates a dedicated plookup table for that bit-width.
* **`<24>` setup = ~2967 gates.** Non-power-of-2 widths are
composed from smaller power-of-2 tables (~24 ≈ 16 + 8 or 3×8),
adding overhead vs `<32>`.
Implications for a code generator:
* **Don't skimp on range checks** for soundness -- the marginal
cost is trivial (2-4 gates).
* **Tightening N saves setup gates, not per-call gates.**
Migrating from `<64>` to `<32>` on every call saves ~40 setup
gates if the circuit already uses `<32>` somewhere; saves
nothing if both N values are needed in different places (each
bit-width table is set up independently).
* **Prefer power-of-2 bit-widths.** A circuit using `<24>` pays
~167 more setup gates than the same circuit at `<32>`. A code
generator emitting many bit-widths should round up to
`{8, 16, 32, 64}`.
* **`<8>` is functionally free** (105 setup + ~2/call). Use it
for narrow values without hesitation.
* **Don't fragment plookup tables.** A circuit using one each of
`<14>`, `<16>`, `<18>`, `<20>` range checks pays four separate
setup costs. Settle on `<16>` for all four; cost = one table.
5. **u64 comparisons (`==`, `<`) are ~11-13 gates per call** -- the
cheapest comparison primitive measured. Generator should prefer
them over `Field::lt` (~183/call) whenever the value fits a
typed integer.
6. **`Field::lt` is ~183 gates per call** -- ~14x more expensive
than `u64 <`. Use only when the value is genuinely a Field
element (e.g. an offset-form exponent that ranges beyond u64,
or a Euclidean-split remainder bounded by another Field
divisor). For values that fit in `u64`, cast and use `<`.
7. **u64 bitwise ops are ~85 gates per call for `&`, ~96 for
`>> witness`.** Moderately expensive but FAR cheaper than the
isolated-probe numbers suggested. The original §16 (now
superseded) quoted 4062 gates for `u64 &` -- that was setup +
one call. Per-additional-call cost is ~85. The generator should
still prefer Field-arith Euclidean splits where they're
structurally cheaper, but a small handful of `u64 &` calls is
not a disaster.
8. **`pow_32(x)` is ~198 gates per call** for runtime x. Use it
instead of walking a per-row power-of-two table for runtime
exponentiation. For comptime exponents the for-loop pattern
folds to a literal (0 gates per call site).
9. **Constant-shift vs witness-shift on `u64`**: ~13 vs ~96 gates
per call. Constant shifts are 7x cheaper -- meaningful for
bit-extraction loops over 64 positions, but not catastrophic
for a handful of calls.
### 16.2 What the "one-time setup" cost is actually doing
UltraHonk uses Plookup tables for range checks and certain typed
operations. The first call to e.g. `assert_max_bit_size::<64>` in a
circuit instantiates the 64-bit range-check table; subsequent calls
add only the table-lookup constraint per value. Different bit-widths
have different tables -- which is why `<32>` and `<64>` have
near-identical per-call cost (each instantiates its own table) but
neither subsumes the other, and a circuit using both pays both
setup costs.
Consequence: **a circuit's reported cost ≠ sum of its primitives'
isolated costs.** The IEEE 754 kernels measure ~5000-6500 gates
each, not the ~30,000 you'd estimate from naively multiplying
isolated per-call numbers. Always measure the actual generated
circuit. The plookup-table-fragmentation guidance is in §16.1 rule
4.
### 16.3 Reproduction recipe
To re-measure per-call costs (slope) on a different toolchain:
```bash
# Make N versions of the same probe with N=1, 2, 10, 20 calls.
for n in 1 2 10 20; do
mkdir -p "p_op_${n}/src"
cat > "p_op_${n}/Nargo.toml" <<TOML
[package]
name = "p_op_${n}"
type = "bin"
authors = [""]
[dependencies]
ieee754 = { path = "<path-to-ieee754>" }
TOML
body=""
for i in $(seq 1 $n); do
body="${body} r = r + x.lt(y + $i) as Field;\n"
done
printf 'fn main(x: pub Field, y: pub Field) -> pub Field {\n let mut r: Field = 0;\n%b r\n}\n' \
"$body" > "p_op_${n}/src/main.nr"
done
# Measure each.
for n in 1 2 10 20; do
(cd "p_op_${n}" && nargo compile 2>/dev/null && \
bb gates -s ultra_honk -b "target/p_op_${n}.json" \
| grep circuit_size | sed -E "s/.*: ([0-9]+).*/N=${n} bb_gates=\\1/")
done
```
Slope = (bb_gates(N=20) - bb_gates(N=1)) / 19, in gates per call.
Intercept = bb_gates(N=1) - slope.
## References for §10-§13
14. Noir comptime concept doc —
`docs/versioned_docs/version-v1.0.0-beta.20/noir/concepts/comptime.md`.
Lists `comptime fn`, `comptime global`, `comptime struct`,
`comptime type`, `comptime { ... }`, `comptime let`,
`comptime for`. Verified 2026-05-11 via context7.
15. Noir generics concept doc —
`docs/versioned_docs/version-v1.0.0-beta.20/noir/concepts/generics.md`.
Source of the `<let N: u32>` const-generic syntax for structs,
free functions, and `impl` blocks. Verified 2026-05-11 via
context7.
16. Noir unconstrained concept doc —
`docs/versioned_docs/version-v1.0.0-beta.20/noir/concepts/unconstrained.md`.
*"A `// Safety: ...` comment is required to explain why
calling the unconstrained function is acceptable, either on
the `unsafe` block itself or the statement it resides in."*
Verified 2026-05-11 via context7. The ~49-ACIR-opcode
per-`unsafe`-call dispatch figure is empirical from this
repo's measurements — verify on your exact Noir version with
`bb gates`.