Skip to content

Commit 40ddee2

Browse files
committed
refactor: batched level-merge for append_leaves_to_snapshot
Replaces the per-leaf frontier walk (MaxLeaves x TreeHeight poseidon hashes) with a level-by-level batched merge: the batch is prepended with the pending left sibling when it starts as a right child, dangling odd nodes become the new frontier entries, and remaining nodes are paired into the next level with lane bounds halving per level. Total cost drops to ~MaxLeaves + 3 x TreeHeight hashes (1,166 for 1024 leaves in the height-36 tree vs ~36,900 before). Same signature, semantics, and error messages. Adds an exhaustive small-tree sweep over every (start, num) combination and a fail-closed test for appending to a completely full tree.
1 parent a5bca24 commit 40ddee2

1 file changed

Lines changed: 130 additions & 27 deletions

File tree

noir-projects/noir-protocol-circuits/crates/types/src/merkle_tree/append_only_tree.nr

Lines changed: 130 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -108,15 +108,19 @@ pub fn insert_subtree_root_to_snapshot_with_hasher<let TreeHeight: u32, let Subt
108108
/// Appends up to `MaxLeaves` leaves to a poseidon append-only tree at its current (arbitrary) next-available index.
109109
///
110110
/// Unlike `insert_subtree_root_to_snapshot`, the insertion index does not need to be aligned to a power-of-two
111-
/// boundary: leaves are absorbed one at a time into the tree's frontier (the incremental-merkle-tree "filled
112-
/// subtrees"), so a compact bundle can start wherever the previous append left off. Only `leaves[0..num_leaves]` are
113-
/// inserted; the remaining lanes are no-ops and must be zero.
111+
/// boundary: the whole batch is merged into the tree's frontier (the incremental-merkle-tree "filled subtrees") level
112+
/// by level, so a compact bundle can start wherever the previous append left off. Only `leaves[0..num_leaves]` are
113+
/// inserted; the remaining lanes are no-ops and must be zero. The pair-hash lanes halve at each level as the batch
114+
/// rises, so the total cost is ~`MaxLeaves + 3 * TreeHeight` hashes rather than `MaxLeaves * TreeHeight`.
114115
///
115116
/// `frontier_hint` is the frontier at `snapshot.next_available_leaf_index`, i.e. `frontier_hint[level]` is the pending
116117
/// left-sibling node at each level. It is validated against `snapshot.root` before use, pinning it to the snapshot the
117-
/// same way sibling-path hints are pinned elsewhere. Only the hint entries at levels where the start index is a right
118-
/// child are read during the append, and those are exactly the entries the root recomputation pins, so an incorrect
119-
/// hint cannot produce a wrong result: it either fails validation or is overwritten before it is read.
118+
/// same way sibling-path hints are pinned elsewhere. The merge only ever reads hint entries at levels where the start
119+
/// index is a right child, which are exactly the entries the validation pins, so an incorrect hint cannot produce a
120+
/// wrong result: it either fails validation or is never read.
121+
///
122+
/// A completely full tree (`next_available_leaf_index == 2^TreeHeight`) has no representable frontier, so even a
123+
/// `num_leaves = 0` call against one fails hint validation. This fails closed and is accepted behavior.
120124
pub fn append_leaves_to_snapshot<let TreeHeight: u32, let MaxLeaves: u32>(
121125
snapshot: AppendOnlyTreeSnapshot,
122126
leaves: [Field; MaxLeaves],
@@ -143,11 +147,11 @@ pub fn append_leaves_to_snapshot<let TreeHeight: u32, let MaxLeaves: u32>(
143147
let start_index = snapshot.next_available_leaf_index as u64;
144148
let end_index = start_index + num_leaves as u64;
145149
// Guard against wrapping past the tree capacity, which would otherwise silently produce a wrong root.
150+
let mut tree_is_filled_exactly = false;
146151
if TreeHeight < 64 {
147-
assert(
148-
end_index <= (1 as u64 << TreeHeight as u64),
149-
"append_leaves_to_snapshot exceeds the tree capacity",
150-
);
152+
let capacity = 1 as u64 << TreeHeight as u64;
153+
assert(end_index <= capacity, "append_leaves_to_snapshot exceeds the tree capacity");
154+
tree_is_filled_exactly = end_index == capacity;
151155
}
152156

153157
// Validate the hint pins to the snapshot: recompute the root of a tree holding `start_index` leaves from the hint's
@@ -158,28 +162,59 @@ pub fn append_leaves_to_snapshot<let TreeHeight: u32, let MaxLeaves: u32>(
158162
"Frontier hint does not match the snapshot root",
159163
);
160164

161-
// Absorb each real leaf into the frontier, tracking the running root; padding lanes are skipped.
165+
// Merge the batch into the frontier one level at a time. At each level, `working` holds the batch's nodes,
166+
// starting at absolute position `s` with `c` real entries (lanes past `c` hold garbage that is never read):
167+
// - If `s` is odd the batch starts as a right child, so its pending left sibling — the validated hint entry — is
168+
// prepended via a mux shift (no hashes) to make the batch start even.
169+
// - If the count is then odd, the dangling last node sits at an even position: it is the level's new pending left
170+
// child, so it becomes the frontier entry and drops out of pairing. On an even count the old entry is left in
171+
// place; at the new tree size it is unused garbage, just like unread hint lanes.
172+
// - The remaining nodes are hashed pairwise into the next level.
162173
let mut frontier = frontier_hint;
163-
let mut index = start_index;
164-
let mut root = snapshot.root;
174+
let mut working = [0; MaxLeaves + 2];
165175
for i in 0..MaxLeaves {
166-
if i < num_leaves {
167-
let mut node = leaves[i];
168-
let mut idx = index;
169-
for level in 0..TreeHeight {
170-
if idx % 2 == 0 {
171-
frontier[level] = node;
172-
node = merkle_hash(node, zero_subtree_roots[level]);
173-
} else {
174-
node = merkle_hash(frontier[level], node);
175-
}
176-
idx = idx / 2;
177-
}
178-
root = node;
179-
index += 1;
176+
working[i] = leaves[i];
177+
}
178+
let mut s = start_index;
179+
let mut c = num_leaves;
180+
for level in 0..TreeHeight {
181+
let prepend = (s % 2 == 1) & (c != 0);
182+
let mut shifted = [0; MaxLeaves + 2];
183+
shifted[0] = if prepend { frontier[level] } else { working[0] };
184+
// The loop bounds below only need to upper-bound the batch size at this level; the shift amounts are clamped
185+
// below 32 because a u32 shift by 32+ does not const-fold, and `MaxLeaves >> 31` is already 0.
186+
for j in 1..(MaxLeaves >> std::cmp::min(level, 31)) + 2 {
187+
shifted[j] = if prepend { working[j - 1] } else { working[j] };
180188
}
189+
let s2 = s - prepend as u64;
190+
let c2 = c + prepend as u32;
191+
192+
// `c2 - 1` is only meaningful when `c2` is odd (hence >= 1); the subtraction is kept unconditionally safe.
193+
let dangling_index = c2 - (c2 != 0) as u32;
194+
frontier[level] = if c2 % 2 == 1 {
195+
shifted[dangling_index]
196+
} else {
197+
frontier[level]
198+
};
199+
200+
for i in 0..(MaxLeaves >> std::cmp::min(level + 1, 31)) + 1 {
201+
working[i] = merkle_hash(shifted[2 * i], shifted[2 * i + 1]);
202+
}
203+
s = s2 / 2;
204+
c = c2 / 2;
181205
}
182206

207+
// The frontier is now the end-state frontier, so the final root falls out of the same recomputation used to
208+
// validate the hint. The exception is an append that fills the tree exactly: a full tree has no representable
209+
// frontier, but in that case the batch was merged all the way up and `working[0]` is the root itself.
210+
let root = if num_leaves == 0 {
211+
snapshot.root
212+
} else if tree_is_filled_exactly {
213+
working[0]
214+
} else {
215+
recompute_root_from_frontier(frontier, zero_subtree_roots, end_index)
216+
};
217+
183218
AppendOnlyTreeSnapshot {
184219
root,
185220
next_available_leaf_index: snapshot.next_available_leaf_index + num_leaves as Field,
@@ -549,4 +584,72 @@ mod append_leaves_tests {
549584
let leaves = [42, 0, 99, 0, 0, 0, 0, 0];
550585
let _ = append_leaves_to_snapshot::<5, 8>(snapshot, leaves, 1, [0; 5]);
551586
}
587+
588+
/// Extracts the frontier at `index` from a height-4 reference tree: the node at each level's position
589+
/// `(index >> level) - 1` when that position is odd, i.e. the pending left sibling.
590+
unconstrained fn frontier_of_height_4_tree(tree: MerkleTree<16>, index: u32) -> [Field; 4] {
591+
// Level offsets into `MerkleTree.nodes` for N = 16: level 1 starts at 0, level 2 at 8, level 3 at 12.
592+
let offsets = [0, 8, 12];
593+
let mut frontier = [0; 4];
594+
if index % 2 == 1 {
595+
frontier[0] = tree.leaves[index - 1];
596+
}
597+
for level in 1..4 {
598+
let p = index >> level;
599+
if p % 2 == 1 {
600+
frontier[level] = tree.nodes[offsets[level - 1] + p - 1];
601+
}
602+
}
603+
frontier
604+
}
605+
606+
#[test]
607+
unconstrained fn append_exhaustive_small_tree_sweep() {
608+
// Height-4 tree (capacity 16) with MaxLeaves = 6: every (start, num) with start + num <= capacity, compared
609+
// against a reference tree built over the concatenated leaves. Exercises every parity combination of the
610+
// prepend/dangling logic, including appends that fill the tree exactly.
611+
for start in 0..16 {
612+
for num in 0..7 {
613+
if start + num <= 16 {
614+
let mut pre_leaves = [0; 16];
615+
for i in 0..start {
616+
pre_leaves[i] = (100 + i) as Field;
617+
}
618+
let pre_tree = MerkleTree::new(pre_leaves);
619+
let snapshot = AppendOnlyTreeSnapshot {
620+
root: pre_tree.get_root(),
621+
next_available_leaf_index: start as Field,
622+
};
623+
let frontier = frontier_of_height_4_tree(pre_tree, start);
624+
625+
let mut batch = [0; 6];
626+
let mut all_leaves = pre_leaves;
627+
for i in 0..num {
628+
let value = (200 + start + i) as Field;
629+
batch[i] = value;
630+
all_leaves[start + i] = value;
631+
}
632+
633+
let result = append_leaves_to_snapshot::<4, 6>(snapshot, batch, num, frontier);
634+
635+
let expected_tree = MerkleTree::new(all_leaves);
636+
assert_eq(result.root, expected_tree.get_root());
637+
assert_eq(result.next_available_leaf_index, (start + num) as Field);
638+
}
639+
}
640+
}
641+
}
642+
643+
#[test(should_fail_with = "Frontier hint does not match the snapshot root")]
644+
fn append_to_completely_full_tree_fails() {
645+
// A full tree has no representable frontier; even a num_leaves = 0 call fails hint validation (fail-closed).
646+
let mut leaves = [0; 16];
647+
for i in 0..16 {
648+
leaves[i] = (100 + i) as Field;
649+
}
650+
let tree = MerkleTree::new(leaves);
651+
let snapshot =
652+
AppendOnlyTreeSnapshot { root: tree.get_root(), next_available_leaf_index: 16 };
653+
let _ = append_leaves_to_snapshot::<4, 6>(snapshot, [0; 6], 0, [0; 4]);
654+
}
552655
}

0 commit comments

Comments
 (0)