Skip to content

Commit 0b0d739

Browse files
authored
chore(aztec-nr): extract per-signature calldata unpack helpers to shrink public_dispatch (#23464)
## Summary Additional work towards resolving AztecProtocol/aztec-nr#35 - In `generate_public_dispatch`, count parameter-type signatures across the contract public functions. When at least `EXTRACTION_THRESHOLD` (4) functions share a signature, emit one `#[inline_never]` `__aztec_nr_internals__unpack_arguments_<N>` helper that does the `calldata_copy` + per-parameter `stream_deserialize` once, and rewrite those dispatch arms to call it. - Signatures below the threshold keep the previous inline shape, so we do not pay CALL/RET overhead on call sites that would not recoup it. - Follows the same pattern as #23161, but targets the calldata-deserialization boilerplate the dispatch macro inlines into every arm rather than user-written helpers. ## Bytecode impact Measured via `nargo compile --inliner-aggressiveness 0` + `bb aztec_process`, reading `public_dispatch` packed bytecode: | Contract | Baseline | Post | Δ | |---------------------------------------|----------|--------|----------------| | `public_fns_with_emit_repro_contract` | 4,601 | 4,227 | -374 (-8.1%) | | `avm_test_contract` | 51,333 | 51,048 | -285 (-0.55%) | The repro contract is the same one used in #23161, so the delta stacks on top of that one. Real contracts with many public functions sharing parameter shapes is where we see savings from this change. ## Additional Context - The threshold is a single share-count knob for now. The real break-even depends on per-site `stream_deserialize` size (one `Field` arg vs. e.g. `(AztecAddress, U128, PartialUintNote)`), but a single threshold keeps the macro readable; we can size helpers against per-site savings later if needed. - Follow-up: [F-675](https://linear.app/aztec-labs/issue/F-675/centralize-validation-for-aztec-nr-macro-generated-internal-function) tracks centralizing validation for aztec-nr macro-generated internal names. The generated-name collision class predates this PR, and the new unpack helper names are one specific instance.
1 parent a51f60a commit 0b0d739

8 files changed

Lines changed: 267 additions & 171 deletions

File tree

noir-projects/aztec-nr/aztec/src/macros/dispatch.nr

Lines changed: 189 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -5,17 +5,40 @@ use super::functions::initialization_utils::EMIT_PUBLIC_INIT_NULLIFIER_FN_NAME;
55
use super::utils::compute_fn_selector;
66
use std::panic;
77

8+
/// Minimum number of public functions that must share a parameter-type signature before we extract a shared
9+
/// unpack helper. See [compute_unpack_prelude] for the rationale behind the chosen value.
10+
global EXTRACTION_THRESHOLD: u32 = 4;
11+
812
/// Generates a `public_dispatch` function for an Aztec contract module `m`.
913
///
1014
/// The generated function dispatches public calls based on selector to the appropriate contract function. If
1115
/// `generate_emit_public_init_nullifier` is true, it also handles dispatch to the macro-generated
1216
/// `__emit_public_init_nullifier` function.
17+
///
18+
/// Alongside `public_dispatch`, this also emits one `__aztec_nr_internals__unpack_arguments_<N>` helper per
19+
/// parameter-type signature shared by enough public functions; see [compute_unpack_prelude] for the extraction
20+
/// criterion.
1321
pub comptime fn generate_public_dispatch(m: Module, generate_emit_public_init_nullifier: bool) -> Quoted {
1422
let functions = get_public_functions(m);
1523

1624
let unit = get_type::<()>();
1725

26+
// Count how many public functions share each parameter-type signature, so we can decide which signatures are
27+
// worth extracting into helpers.
28+
let signature_counts = &mut CHashMap::<Quoted, u32>::new();
29+
for function in functions {
30+
let parameters = function.parameters();
31+
if parameters.len() != 0 {
32+
let key = signature_key(parameters);
33+
let prior = signature_counts.get(key).unwrap_or(0);
34+
signature_counts.insert(key, prior + 1);
35+
}
36+
}
37+
1838
let seen_selectors = &mut CHashMap::<Field, Quoted>::new();
39+
let signature_to_helper_idx = &mut CHashMap::<Quoted, u32>::new();
40+
// The helper function definitions, in the order they were created.
41+
let unpack_helpers: &mut [Quoted] = &mut @[];
1942

2043
let mut ifs = functions.map(|function: FunctionDefinition| {
2144
let parameters = function.parameters();
@@ -35,48 +58,19 @@ pub comptime fn generate_public_dispatch(m: Module, generate_emit_public_init_nu
3558
}
3659
seen_selectors.insert(selector, fn_name);
3760

38-
let params_len_quote = get_params_len_quote(parameters);
39-
40-
let initial_read = if parameters.len() == 0 {
41-
quote {}
42-
} else {
43-
// The initial calldata_copy offset is 1 to skip the Field selector The expected calldata is the
44-
// serialization of
45-
// - FunctionSelector: the selector of the function intended to dispatch
46-
// - Parameters: the parameters of the function intended to dispatch That is, exactly what is expected for
47-
// a call to the target function, but with a selector added at the beginning.
48-
quote {
49-
let input_calldata: [Field; $params_len_quote] = aztec::oracle::avm::calldata_copy(1, $params_len_quote);
50-
let mut reader = aztec::protocol::utils::reader::Reader::new(input_calldata);
51-
}
52-
};
53-
54-
let parameter_index: &mut u32 = &mut 0;
55-
let reads = parameters.map(|param: (Quoted, Type)| {
56-
let parameter_index_value = *parameter_index;
57-
let param_name = f"arg{parameter_index_value}".quoted_contents();
58-
let param_type = param.1;
59-
let read = quote {
60-
let $param_name: $param_type = aztec::protocol::traits::Deserialize::stream_deserialize(&mut reader);
61-
};
62-
*parameter_index += 1;
63-
quote { $read }
64-
});
65-
let read = reads.join(quote { });
66-
67-
let mut args = @[];
68-
for parameter_index in 0..parameters.len() {
69-
let param_name = f"arg{parameter_index}".quoted_contents();
70-
args = args.push_back(quote { $param_name });
71-
}
61+
let (unpack_prelude, call_args) = compute_unpack_prelude(
62+
parameters,
63+
signature_counts,
64+
signature_to_helper_idx,
65+
unpack_helpers,
66+
);
7267

7368
// We call a function whose name is prefixed with `__aztec_nr_internals__`. This is necessary because the
7469
// original function is intentionally made uncallable, preventing direct invocation within the contract.
7570
// Instead, a new function with the same name, but prefixed by `__aztec_nr_internals__`, has been generated to
7671
// be called here. For more details see the `process_functions` function.
7772
let name = f"__aztec_nr_internals__{fn_name}".quoted_contents();
78-
let args = args.join(quote { , });
79-
let call = quote { $name($args) };
73+
let call = quote { $name($call_args) };
8074

8175
let return_code = if return_type == unit {
8276
quote {
@@ -93,8 +87,7 @@ pub comptime fn generate_public_dispatch(m: Module, generate_emit_public_init_nu
9387

9488
let if_ = quote {
9589
if selector == $selector {
96-
$initial_read
97-
$read
90+
$unpack_prelude
9891
$return_code
9992
}
10093
};
@@ -124,7 +117,11 @@ pub comptime fn generate_public_dispatch(m: Module, generate_emit_public_init_nu
124117
let ifs = ifs.push_back(quote { panic(f"Unknown selector {selector}") });
125118
let dispatch = ifs.join(quote { });
126119

120+
let helpers = (*unpack_helpers).join(quote { });
121+
127122
let body = quote {
123+
$helpers
124+
128125
// We mark this as public because our whole system depends on public functions having this attribute.
129126
#[aztec::macros::internals_functions_generation::abi_attributes::abi_public]
130127
pub unconstrained fn public_dispatch(selector: Field) {
@@ -136,6 +133,160 @@ pub comptime fn generate_public_dispatch(m: Module, generate_emit_public_init_nu
136133
}
137134
}
138135

136+
/// Canonical Quoted representation of a parameter list's types, used as the deduplication key for unpack helpers.
137+
comptime fn signature_key(parameters: [(Quoted, Type)]) -> Quoted {
138+
parameters
139+
.map(|param: (Quoted, Type)| {
140+
let param_type = param.1;
141+
quote { $param_type }
142+
})
143+
.join(quote { , })
144+
}
145+
146+
/// Builds the dispatch-arm prelude for a public function and the comma-separated arg list to pass through to the call.
147+
///
148+
/// If the function's signature reaches `EXTRACTION_THRESHOLD`, the prelude reuses (or creates) a shared
149+
/// `__aztec_nr_internals__unpack_arguments_<N>` helper. Otherwise the calldata read is inlined, matching the
150+
/// pre-extraction shape; this avoids paying the helper-call overhead on signatures that would not benefit from the
151+
/// shared body.
152+
///
153+
/// The real break-even depends on the size of the inlined boilerplate at each call site (which scales with the
154+
/// parameter types' `stream_deserialize` cost), not just the share count: a signature with one `Field` arg inlines
155+
/// to a few opcodes per site, while a signature like `(AztecAddress, U128, PartialUintNote)` inlines into many. For
156+
/// now we approximate that with a single share-count threshold, which is the simplest knob that keeps the macro
157+
/// readable. If we ever want to be more precise we can size each helper against its per-site savings.
158+
comptime fn compute_unpack_prelude(
159+
parameters: [(Quoted, Type)],
160+
signature_counts: &mut CHashMap<Quoted, u32>,
161+
signature_to_helper_idx: &mut CHashMap<Quoted, u32>,
162+
unpack_helpers: &mut [Quoted],
163+
) -> (Quoted, Quoted) {
164+
if parameters.len() == 0 {
165+
(quote {}, quote {})
166+
} else {
167+
let sig_key = signature_key(parameters);
168+
let count = signature_counts.get(sig_key).unwrap_or(0);
169+
let shared = count >= EXTRACTION_THRESHOLD;
170+
171+
let mut arg_names: [Quoted] = @[];
172+
for parameter_index in 0..parameters.len() {
173+
let arg_name = f"arg{parameter_index}".quoted_contents();
174+
arg_names = arg_names.push_back(arg_name);
175+
}
176+
let args = arg_names.join(quote { , });
177+
178+
let prelude = if shared {
179+
let existing_idx = signature_to_helper_idx.get(sig_key);
180+
let helper_idx = if existing_idx.is_some() {
181+
existing_idx.unwrap()
182+
} else {
183+
let new_idx = (*unpack_helpers).len();
184+
signature_to_helper_idx.insert(sig_key, new_idx);
185+
let helper_def = build_unpack_helper(new_idx, parameters);
186+
*unpack_helpers = (*unpack_helpers).push_back(helper_def);
187+
new_idx
188+
};
189+
let helper_name = f"__aztec_nr_internals__unpack_arguments_{helper_idx}".quoted_contents();
190+
if parameters.len() == 1 {
191+
quote { let arg0 = $helper_name(); }
192+
} else {
193+
quote { let ($args) = $helper_name(); }
194+
}
195+
} else {
196+
inline_unpack(parameters)
197+
};
198+
199+
(prelude, args)
200+
}
201+
}
202+
203+
/// Inlined calldata read + per-parameter `stream_deserialize` for signatures that are not worth extracting.
204+
comptime fn inline_unpack(parameters: [(Quoted, Type)]) -> Quoted {
205+
let params_len_quote = get_params_len_quote(parameters);
206+
let initial_read = quote {
207+
let input_calldata: [Field; $params_len_quote] = aztec::oracle::avm::calldata_copy(1, $params_len_quote);
208+
let mut reader = aztec::protocol::utils::reader::Reader::new(input_calldata);
209+
};
210+
211+
let mut read_quotes: [Quoted] = @[];
212+
for parameter_index in 0..parameters.len() {
213+
let arg_name = f"arg{parameter_index}".quoted_contents();
214+
let param_type = parameters[parameter_index].1;
215+
read_quotes = read_quotes.push_back(
216+
quote {
217+
let $arg_name: $param_type = aztec::protocol::traits::Deserialize::stream_deserialize(&mut reader);
218+
},
219+
);
220+
}
221+
let reads = read_quotes.join(quote { });
222+
223+
quote {
224+
$initial_read
225+
$reads
226+
}
227+
}
228+
229+
/// Emits the `#[inline_never]` helper that reads calldata for one specific parameter-type signature.
230+
///
231+
/// Returns a single value when there is only one parameter, and a tuple when there are several. Marked
232+
/// `#[inline_never]` (and therefore `unconstrained`) so each entry point can call into the same compiled body.
233+
comptime fn build_unpack_helper(idx: u32, parameters: [(Quoted, Type)]) -> Quoted {
234+
let helper_name = f"__aztec_nr_internals__unpack_arguments_{idx}".quoted_contents();
235+
let params_len_quote = get_params_len_quote(parameters);
236+
237+
// The initial calldata_copy offset is 1 to skip the Field selector. The expected calldata is the serialization
238+
// of:
239+
// - FunctionSelector: the selector of the function intended to dispatch
240+
// - Parameters: the parameters of the function intended to dispatch
241+
// That is, exactly what is expected for a call to the target function, but with a selector added at the
242+
// beginning.
243+
let initial_read = quote {
244+
let input_calldata: [Field; $params_len_quote] = aztec::oracle::avm::calldata_copy(1, $params_len_quote);
245+
let mut reader = aztec::protocol::utils::reader::Reader::new(input_calldata);
246+
};
247+
248+
let mut read_quotes: [Quoted] = @[];
249+
let mut arg_quotes: [Quoted] = @[];
250+
let mut type_quotes: [Quoted] = @[];
251+
for parameter_index in 0..parameters.len() {
252+
let arg_name = f"arg{parameter_index}".quoted_contents();
253+
let param_type = parameters[parameter_index].1;
254+
read_quotes = read_quotes.push_back(
255+
quote {
256+
let $arg_name: $param_type = aztec::protocol::traits::Deserialize::stream_deserialize(&mut reader);
257+
},
258+
);
259+
arg_quotes = arg_quotes.push_back(arg_name);
260+
type_quotes = type_quotes.push_back(quote { $param_type });
261+
}
262+
let reads = read_quotes.join(quote { });
263+
let return_args = arg_quotes.join(quote { , });
264+
265+
if parameters.len() == 1 {
266+
let only_type = parameters[0].1;
267+
quote {
268+
#[inline_never]
269+
#[contract_library_method]
270+
unconstrained fn $helper_name() -> $only_type {
271+
$initial_read
272+
$reads
273+
arg0
274+
}
275+
}
276+
} else {
277+
let return_types = type_quotes.join(quote { , });
278+
quote {
279+
#[inline_never]
280+
#[contract_library_method]
281+
unconstrained fn $helper_name() -> ($return_types) {
282+
$initial_read
283+
$reads
284+
($return_args)
285+
}
286+
}
287+
}
288+
}
289+
139290
comptime fn get_type<T>() -> Type {
140291
let t: T = std::mem::zeroed();
141292
std::meta::type_of(t)

noir-projects/contract-snapshots/tests/snapshots/compile_failure/public_function_selector_collision/snapshots__stderr.snap

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,10 +14,10 @@ error: Public function selector collision detected between functions 'fn_selecto
1414
2: aztec
1515
at <repo>/noir-projects/aztec-nr/aztec/src/macros/aztec.nr:169:27
1616
3: generate_public_dispatch
17-
at <repo>/noir-projects/aztec-nr/aztec/src/macros/dispatch.nr:20:19
17+
at <repo>/noir-projects/aztec-nr/aztec/src/macros/dispatch.nr:52:19
1818
4: [T]::map
1919
at std/vector.nr:67:33
2020
5: generate_public_dispatch
21-
at <repo>/noir-projects/aztec-nr/aztec/src/macros/dispatch.nr:32:13
21+
at <repo>/noir-projects/aztec-nr/aztec/src/macros/dispatch.nr:64:13
2222

2323
Aborting due to 1 previous error

noir-projects/contract-snapshots/tests/snapshots/expand/amm_contract/snapshots__expanded.snap

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@
22
source: tests/snapshots.rs
33
expression: stdout
44
---
5-
65
use aztec::macros::aztec;
76
use aztec::macros::aztec;
87

noir-projects/contract-snapshots/tests/snapshots/expand/avm_gadgets_test_contract/snapshots__expanded.snap

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@
22
source: tests/snapshots.rs
33
expression: stdout
44
---
5-
65
use aztec::macros::aztec;
76
use aztec::macros::aztec;
87

0 commit comments

Comments
 (0)