Skip to content

perf(btreemap): add insert_many for batched insertion#440

Open
hpeebles wants to merge 5 commits into
dfinity:mainfrom
hpeebles:insert-many
Open

perf(btreemap): add insert_many for batched insertion#440
hpeebles wants to merge 5 commits into
dfinity:mainfrom
hpeebles:insert-many

Conversation

@hpeebles

@hpeebles hpeebles commented Jul 25, 2026

Copy link
Copy Markdown
Contributor

insert writes the target node to stable memory on every call, and when a node splits it writes the new sibling and the parent too. Inserting a run of keys therefore rewrites the same nodes repeatedly: building a 10k-entry map one key at a time performs 15,976 node writes across just 1,997 distinct nodes.

insert_many keeps the current root-to-leaf path in memory and saves a modified node to stable memory only once the input has moved past it. The same 10k keys come down to 1,997 writes — exactly the number of distinct nodes, so every node is written once and none is rewritten. The header is likewise written once per call rather than once per key.

map.insert_many((0..1_000_000).map(|i| (i, compute(i))));

The iterator is consumed lazily and never collected, so the memory cost is one tree path however many pairs are supplied. BTreeSet::insert_many delegates to it.

Ordering

Coalescing pays off when consecutive keys land in the same node, so keys should be supplied in sorted order. Direction is irrelevant: a descending run unwinds the path leftwards instead of rightwards and produces an identical write count.

Out-of-order keys stay correct rather than corrupting the tree. Each level of the held path records the key range its node owns at both ends, so a key arriving early unwinds to an ancestor that covers it, as far as the root if necessary. Tracking the lower bound as well as the upper one costs a comparison per key.

Those recorded ranges are also what lets the fast path skip re-searching the ancestors it is still holding. A key stored in an ancestor is one of that ancestor's separators, and the separators are exactly the exclusive bounds handed down to the children, so a key that any held node covers cannot be sitting in a node above it.

Measurements

canbench. The first five rows insert 10k u64 pairs; the last overwrites 2k existing keys whose 1 KiB values live on V2 overflow pages.

workload insert loop insert_many
ascending, empty map 333.94M 35.88M 9.3x
descending, empty map 386.40M 52.53M 7.4x
unordered, caller sorts 356.18M 45.42M 7.8x
unordered, fed as-is 356.18M 336.93M 1.1x
ascending run into 100k 293.82M 49.33M 6.0x
overwrite, 1 KiB values 74.29M 10.08M 7.4x

The sort in the third row is inside the measured section. Unordered input fed straight in forfeits the coalescing, but is not slower than the insert loop it replaces.

The overwrite row gains from a second effect. insert has to read the displaced value back in order to return it, which for a value on overflow pages means reading it out of stable memory; insert_many returns nothing, so the new Node::set_value replaces the value without loading the old one. Measured separately, coalescing alone accounts for 74.29M -> 14.64M and skipping the discarded reads takes it the rest of the way to 10.08M.

Implementation notes

Holding the whole path rather than only the leaf is what makes this work. Two thirds of the writes in a split-heavy workload are the sibling and the parent, and a held parent absorbs many child splits before it is written; holding only the leaf coalesces 2x rather than 9x.

Splits are handled entirely in place. A leaf split promotes a median into its parent, which may itself be full, so the split cascades: split_leaf walks up to the topmost full level, grows a new root if the whole path is full, then splits top-down — each split leaves its node half empty, so the level below always has room for the median it promotes. The levels below a split keep the bounds they already hold, since the separators either side of them survive and simply land in one half or the other; only their index among the parent's children moves, by the number of children that stayed with the left half.

The batch machinery lives in src/btreemap/bulk_insert.rs, leaving BTreeMap::insert_many in btreemap.rs as a thin wrapper over it. Dropping BulkInsert writes back whatever it still holds, which is how a batch is completed: the drop is load-bearing on every call.

insert is untouched — the diff to btreemap.rs is purely additive. BulkInsert carries the value unserialized and encodes it at the point of use. The new benchmarks name their plain-loop baselines insert_loop_* so they read as the counterparts of insert_many_*. All 252 pre-existing benchmarks are unchanged to within noise.

Public API added

  • BTreeMap::insert_many
  • BTreeSet::insert_many

Testing

Differential proptests and unit tests build the same map with repeated insert and with insert_many, and require the results to be indistinguishable: across node cache sizes 0/1/16, the V1, V1-migrated-to-V2 and V2 layouts, unbounded values large enough to spill onto overflow pages, duplicate and overwriting batches, and batch sizes chosen to hit the cascade at the node capacity boundaries. Ordering is covered by ascending, descending, zigzag, sawtooth and shuffled inputs. Batches large enough to grow the root repeatedly are drained back to empty afterwards, so a mislinked child or a stale index would show up as chunks the allocator never gets back.

🤖 Generated with Claude Code

`insert` writes the target node to stable memory on every call, and when
a node splits it writes the new sibling and the parent too. Inserting a
run of keys therefore rewrites the same nodes repeatedly: building a
10k-entry map one key at a time performs 15,976 node writes across just
1,997 distinct nodes.

`insert_many` keeps the current root-to-leaf path in memory and writes a
node out only once the input has moved past it, bringing those same 10k
keys down to 4,032 writes. The header is likewise written once per call
rather than once per key.

    map.insert_many((0..1_000_000).map(|i| (i, compute(i))));

The iterator is consumed lazily and never collected, so the memory cost
is one tree path however many pairs are supplied.

## Ordering

Coalescing pays off when consecutive keys land in the same node, so keys
should be supplied in sorted order. Direction is irrelevant: a
descending run unwinds the path leftwards instead of rightwards and
produces an identical write count.

Out-of-order keys stay correct rather than corrupting the tree. Each
level of the held path records the key range its node owns at both ends,
so a key arriving early unwinds to an ancestor that covers it, as far as
the root if necessary. Tracking the lower bound as well as the upper one
costs a comparison per key.

## Measurements

canbench, 10k u64 pairs each:

  ascending, empty map      334.19M -> 76.91M instructions  (4.3x)
  descending, empty map     386.65M -> 96.43M instructions  (4.0x)
  unordered, caller sorts   356.43M -> 86.45M instructions  (4.1x)
  unordered, fed as-is      356.43M -> 341.07M instructions (1.0x)
  ascending run into 100k   294.07M -> 49.06M instructions  (6.0x)

The sort in the third row is inside the measured section. Unordered
input fed straight in forfeits the coalescing, but is not slower than
the insert loop it replaces.

## Implementation notes

Holding the whole path rather than only the leaf is what makes this
work. Two thirds of the writes in a split-heavy workload are the sibling
and the parent, and a held parent absorbs many child splits before it is
written; holding only the leaf coalesces 2x rather than 4x.

Node splits are handled in place while the path is held, except in two
cases that fall back to the ordinary insert path: a full root leaf, and
a parent with no room for a promoted median. Those fire 277 times per
10k ascending keys, about one insert in 36. A fallback flushes the path,
so nodes it had buffered are written again when the batch re-descends
into them; that accounts for the whole gap between the 4,032 writes
above and the 1,997 that writing each dirty node exactly once would
take. Handling the split cascade in place would close it, at the cost of
considerably more intricate code in the riskiest part of the tree, so it
is left as a possible follow-up.

The batch machinery lives in `src/btreemap/bulk_insert.rs`, leaving
`BTreeMap::insert_many` in `btreemap.rs` as a thin wrapper over it.
Dropping `BulkInsert` writes back whatever it still holds, which is how
a batch is completed: the drop is load-bearing on every call.

`insert` is unchanged; its body moves into `insert_serialized` so the
fallback can reuse it with an already-serialized value. All 252
pre-existing benchmarks are unchanged to within noise.

## Testing

Differential proptests and unit tests build the same map with repeated
`insert` and with `insert_many`, and require the results to be
indistinguishable: across node cache sizes 0/1/16, the V1,
V1-migrated-to-V2 and V2 layouts, unbounded values large enough to spill
onto overflow pages, duplicate and overwriting batches, and batch sizes
chosen to hit both fallbacks. Ordering is covered by ascending,
descending, zigzag, sawtooth and shuffled inputs. Allocator chunk counts
return to zero after draining a map built by `insert_many`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@hpeebles
hpeebles requested a review from a team as a code owner July 25, 2026 22:09
@github-actions

github-actions Bot commented Jul 25, 2026

Copy link
Copy Markdown

canbench 🏋 (dir: ./benchmarks/nns) 3a514f6 2026-07-26 10:24:03 UTC

./benchmarks/nns/canbench_results.yml is up to date
📦 canbench_results_nns.csv available in artifacts

---------------------------------------------------

Summary:
  instructions:
    status:   No significant changes 👍
    counts:   [total 16 | regressed 0 | improved 0 | new 0 | unchanged 16]
    change:   [max 0 | p75 0 | median 0 | p25 0 | min 0]
    change %: [max 0.00% | p75 0.00% | median 0.00% | p25 0.00% | min 0.00%]

  heap_increase:
    status:   No significant changes 👍
    counts:   [total 16 | regressed 0 | improved 0 | new 0 | unchanged 16]
    change:   [max 0 | p75 0 | median 0 | p25 0 | min 0]
    change %: [max 0.00% | p75 0.00% | median 0.00% | p25 0.00% | min 0.00%]

  stable_memory_increase:
    status:   No significant changes 👍
    counts:   [total 16 | regressed 0 | improved 0 | new 0 | unchanged 16]
    change:   [max 0 | p75 0 | median 0 | p25 0 | min 0]
    change %: [max 0.00% | p75 0.00% | median 0.00% | p25 0.00% | min 0.00%]

---------------------------------------------------
CSV results saved to canbench_results.csv

@github-actions

github-actions Bot commented Jul 25, 2026

Copy link
Copy Markdown

canbench 🏋 (dir: ./benchmarks/memory_manager) 3a514f6 2026-07-26 10:23:49 UTC

./benchmarks/memory_manager/canbench_results.yml is up to date
📦 canbench_results_memory-manager.csv available in artifacts

---------------------------------------------------

Summary:
  instructions:
    status:   No significant changes 👍
    counts:   [total 3 | regressed 0 | improved 0 | new 0 | unchanged 3]
    change:   [max 0 | p75 0 | median 0 | p25 0 | min 0]
    change %: [max 0.00% | p75 0.00% | median 0.00% | p25 0.00% | min 0.00%]

  heap_increase:
    status:   No significant changes 👍
    counts:   [total 3 | regressed 0 | improved 0 | new 0 | unchanged 3]
    change:   [max 0 | p75 0 | median 0 | p25 0 | min 0]
    change %: [max 0.00% | p75 0.00% | median 0.00% | p25 0.00% | min 0.00%]

  stable_memory_increase:
    status:   No significant changes 👍
    counts:   [total 3 | regressed 0 | improved 0 | new 0 | unchanged 3]
    change:   [max 0 | p75 0 | median 0 | p25 0 | min 0]
    change %: [max 0.00% | p75 0.00% | median 0.00% | p25 0.00% | min 0.00%]

---------------------------------------------------
CSV results saved to canbench_results.csv

@github-actions

github-actions Bot commented Jul 25, 2026

Copy link
Copy Markdown

canbench 🏋 (dir: ./benchmarks/btreeset) 3a514f6 2026-07-26 10:24:29 UTC

./benchmarks/btreeset/canbench_results.yml is up to date
📦 canbench_results_btreeset.csv available in artifacts

---------------------------------------------------

Summary:
  instructions:
    status:   No significant changes 👍
    counts:   [total 100 | regressed 0 | improved 0 | new 0 | unchanged 100]
    change:   [max 0 | p75 0 | median 0 | p25 0 | min 0]
    change %: [max 0.00% | p75 0.00% | median 0.00% | p25 0.00% | min 0.00%]

  heap_increase:
    status:   No significant changes 👍
    counts:   [total 100 | regressed 0 | improved 0 | new 0 | unchanged 100]
    change:   [max 0 | p75 0 | median 0 | p25 0 | min 0]
    change %: [max 0.00% | p75 0.00% | median 0.00% | p25 0.00% | min 0.00%]

  stable_memory_increase:
    status:   No significant changes 👍
    counts:   [total 100 | regressed 0 | improved 0 | new 0 | unchanged 100]
    change:   [max 0 | p75 0 | median 0 | p25 0 | min 0]
    change %: [max 0.00% | p75 0.00% | median 0.00% | p25 0.00% | min 0.00%]

---------------------------------------------------
CSV results saved to canbench_results.csv

@github-actions

github-actions Bot commented Jul 25, 2026

Copy link
Copy Markdown

canbench 🏋 (dir: ./benchmarks/io_chunks) 3a514f6 2026-07-26 10:25:15 UTC

./benchmarks/io_chunks/canbench_results.yml is up to date
📦 canbench_results_io_chunks.csv available in artifacts

---------------------------------------------------

Summary:
  instructions:
    status:   No significant changes 👍
    counts:   [total 18 | regressed 0 | improved 0 | new 0 | unchanged 18]
    change:   [max 0 | p75 0 | median 0 | p25 0 | min 0]
    change %: [max 0.00% | p75 0.00% | median 0.00% | p25 0.00% | min 0.00%]

  heap_increase:
    status:   No significant changes 👍
    counts:   [total 18 | regressed 0 | improved 0 | new 0 | unchanged 18]
    change:   [max 0 | p75 0 | median 0 | p25 0 | min 0]
    change %: [max 0.00% | p75 0.00% | median 0.00% | p25 0.00% | min 0.00%]

  stable_memory_increase:
    status:   No significant changes 👍
    counts:   [total 18 | regressed 0 | improved 0 | new 0 | unchanged 18]
    change:   [max 0 | p75 0 | median 0 | p25 0 | min 0]
    change %: [max 0.00% | p75 0.00% | median 0.00% | p25 0.00% | min 0.00%]

---------------------------------------------------
CSV results saved to canbench_results.csv

@github-actions

github-actions Bot commented Jul 25, 2026

Copy link
Copy Markdown

canbench 🏋 (dir: ./benchmarks/btreemap) 3a514f6 2026-07-26 10:26:03 UTC

./benchmarks/btreemap/canbench_results.yml is up to date
📦 canbench_results_btreemap.csv available in artifacts

---------------------------------------------------

Summary:
  instructions:
    status:   New benchmarks added ➕
    counts:   [total 262 | regressed 0 | improved 0 | new 10 | unchanged 252]
    change:   [max +3.92M | p75 +7.14K | median 0 | p25 0 | min -5.49M]
    change %: [max +0.70% | p75 0.01% | median 0.00% | p25 0.00% | min -0.30%]

  heap_increase:
    status:   New benchmarks added ➕
    counts:   [total 262 | regressed 0 | improved 0 | new 10 | unchanged 252]
    change:   [max 0 | p75 0 | median 0 | p25 0 | min 0]
    change %: [max 0.00% | p75 0.00% | median 0.00% | p25 0.00% | min 0.00%]

  stable_memory_increase:
    status:   New benchmarks added ➕
    counts:   [total 262 | regressed 0 | improved 0 | new 10 | unchanged 252]
    change:   [max 0 | p75 0 | median 0 | p25 0 | min 0]
    change %: [max 0.00% | p75 0.00% | median 0.00% | p25 0.00% | min 0.00%]

---------------------------------------------------

Only significant changes:
| status | name                                          | calls |     ins |  ins Δ% | HI |  HI Δ% | SMI |  SMI Δ% |
|--------|-----------------------------------------------|-------|---------|---------|----|--------|-----|---------|
|  new   | btreemap_v2_insert_loop_desc_u64_u64          |       | 386.40M |         |  0 |        |   8 |         |
|  new   | btreemap_v2_insert_loop_into_existing_u64_u64 |       | 293.82M |         |  0 |        |   0 |         |
|  new   | btreemap_v2_insert_loop_overwrite_1kib_values |       |  74.29M |         |  1 |        |   0 |         |
|  new   | btreemap_v2_insert_loop_random_u64_u64        |       | 356.18M |         |  0 |        |   6 |         |
|  new   | btreemap_v2_insert_many_desc_u64_u64          |       |  52.53M |         |  0 |        |   8 |         |
|  new   | btreemap_v2_insert_many_into_existing_u64_u64 |       |  49.33M |         |  0 |        |   0 |         |
|  new   | btreemap_v2_insert_many_overwrite_1kib_values |       |  10.08M |         |  0 |        |   0 |         |
|  new   | btreemap_v2_insert_many_seq_u64_u64           |       |  35.88M |         |  0 |        |   8 |         |
|  new   | btreemap_v2_insert_many_sorted_random_u64_u64 |       |  45.42M |         |  3 |        |   8 |         |
|  new   | btreemap_v2_insert_many_unsorted_u64_u64      |       | 336.93M |         |  0 |        |   6 |         |

ins = instructions, HI = heap_increase, SMI = stable_memory_increase, Δ% = percent change

---------------------------------------------------
CSV results saved to canbench_results.csv

@github-actions

github-actions Bot commented Jul 25, 2026

Copy link
Copy Markdown

canbench 🏋 (dir: ./benchmarks/vec) 3a514f6 2026-07-26 10:24:03 UTC

./benchmarks/vec/canbench_results.yml is up to date
📦 canbench_results_vec.csv available in artifacts

---------------------------------------------------

Summary:
  instructions:
    status:   No significant changes 👍
    counts:   [total 16 | regressed 0 | improved 0 | new 0 | unchanged 16]
    change:   [max 0 | p75 0 | median 0 | p25 0 | min 0]
    change %: [max 0.00% | p75 0.00% | median 0.00% | p25 0.00% | min 0.00%]

  heap_increase:
    status:   No significant changes 👍
    counts:   [total 16 | regressed 0 | improved 0 | new 0 | unchanged 16]
    change:   [max 0 | p75 0 | median 0 | p25 0 | min 0]
    change %: [max 0.00% | p75 0.00% | median 0.00% | p25 0.00% | min 0.00%]

  stable_memory_increase:
    status:   No significant changes 👍
    counts:   [total 16 | regressed 0 | improved 0 | new 0 | unchanged 16]
    change:   [max 0 | p75 0 | median 0 | p25 0 | min 0]
    change %: [max 0.00% | p75 0.00% | median 0.00% | p25 0.00% | min 0.00%]

---------------------------------------------------
CSV results saved to canbench_results.csv

hpeebles and others added 4 commits July 25, 2026 23:50
A leaf split promotes a median into its parent, so a full parent used to
end the batch: the path was flushed and the ordinary insert path grew the
tree instead. Every node the path had buffered was then written again on
the way back down, which cost roughly half of the writes `insert_many`
was saving.

The split now cascades. `split_leaf` walks up to the topmost full level,
grows a new root if the whole path is full, then splits top-down: each
split leaves its node half empty, so the level below always has room for
the median it promotes.

Writing each dirty node exactly once is now reached rather than
approached — 10k ascending keys perform 1,997 writes, matching the
number of distinct nodes in the resulting tree, down from 4,032. An
`insert` loop over the same keys performs 15,976.

  ascending, empty map      77.17M -> 35.88M instructions  (4.3x -> 9.3x)
  unordered, caller sorts   86.71M -> 45.42M instructions  (4.1x -> 7.8x)
  descending, empty map     96.58M -> 52.53M instructions  (4.0x -> 7.4x)

Nothing else moves: 262 benchmarks, 0 regressed, 3 improved. The
workloads that barely split — inserting into gaps in an existing map,
pure overwrites, unordered input — are unchanged, which is where the
saving was expected to come from.

The levels below a split keep the bounds they already hold: the
separators either side of them survive, landing in one half or the other.
Only their index among the parent's children moves, by the number of
children that stayed with the left half.

Removing the fallback also removes the last call from `BulkInsert` back
into `BTreeMap::insert`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant