Skip to content

perf(btreemap): batch the per-field node reads and writes - #441

Open
hpeebles wants to merge 1 commit into
dfinity:mainfrom
hpeebles:v2-batched-io
Open

perf(btreemap): batch the per-field node reads and writes#441
hpeebles wants to merge 1 commit into
dfinity:mainfrom
hpeebles:v2-batched-io

Conversation

@hpeebles

Copy link
Copy Markdown
Contributor

Loading a node performed one memory read per field — every child address, key size, key, and value size separately — dozens of reads per node, and saving mirrored that with one write per field. A memory access costs a fixed amount per call plus a fee per byte, so this PR batches both directions while keeping every path that can't be batched profitably on its existing per-field behavior.

Load

The load now fetches the front of the initial page in one read and parses the fields out of the buffer, with any field beyond the read falling back to an individual read. The read is sized so that it only ever buys bytes the load will use:

  • For key types bounded by ≤ 64 bytes, the header pins down the extent of the children and key areas, and that estimate is all that is fetched.
  • For larger or unbounded key bounds, the interleaved layout offers no extent worth prefetching — the size fields the walk needs sit between payloads it skips — so those types read per-field exactly as before. (An earlier draft that read a fixed 1 KiB for them regressed the large-value benchmarks by up to 8%.)

The previous CHILDREN_BATCH_READ_THRESHOLD = 4 heuristic ("individual reads are faster for ≤ 4 children") is gone: re-measured under the new read pattern, it no longer paid for itself — the sweep below shows no regression on any internal-node-heavy benchmark.

Save

Fields are serialized into a buffer flushed in as few writes as possible, except that slices of ≥ 48 bytes are written directly, uncopied, flushing whatever is buffered around them. The threshold is measured, and lower than intuition suggests: copying into the buffer costs a few instructions per byte, while a separate write costs a fixed amount for the call and its page mapping, putting the crossover around forty bytes. (Drafts with 128- and 512-byte thresholds both measured thousands of instructions per save slower on nodes with ~128-byte keys and values.)

The save no longer writes the overflow-address field at bytes [7, 15): the NodeWriter owns it, updating the chain pointer in place as overflow pages are allocated and released, and finish() writes the NULL terminator into it whenever the node ends up with no overflow pages — so even a fresh node at a reused address cannot retain a stale pointer. (The old explicit write was redundant with that terminator.)

A new test covers the one newly reachable trap: a node placed so close to the end of memory that the batched-read estimate extends past the last byte ever written; the read clamps rather than traps.

Benchmarks

Full canbench sweep against main (canbench 0.2.0, all 252 benchmarks):

counts:   [total 252 | regressed 0 | improved 147 | new 0 | unchanged 105]
change %: [max +1.84% | p75 -0.01% | median -3.87% | p25 -11.92% | min -23.48%]

Zero regressions. The single largest positive movement (+1.84%, remove_vec_64_128) is on a code path this PR does not touch (unbounded keys take the unchanged per-field route) and is within canbench's noise threshold.

Top improvements:

benchmark Δ instructions
mem_manager_remove_u64_u64 −23.48%
mem_manager_insert_u64_u64 −22.11%
mem_manager_get_u64_u64 −19.87%
get_blob8_u64 −18.48%
get_zipf_100k_u64_u64_nocache −17.58%
insert_blob8_u64 −16.91%
remove_blob8_u64 −16.70%
mem_manager_get_u64_blob512 −16.08%
pop_last_blob_32_0 −16.01%
scan_iter_rev_1k_0b −15.59%
contains_blob_8_128 −15.53%
get_blob_4_128 −15.27%
remove_100k_u64_u64_nocache −14.70%

Memory-manager maps gain the most (−16 to −23%), followed by maps with small keys and values (−10 to −19%); sparse-node scans and range queries land around −14 to −16%. One footnote: insert_vec_8_128 shows a one-time +1 page (64 KiB) heap high-water increase from the save buffer — a peak-allocation effect, not a per-operation cost.

🤖 Generated with Claude Code

Loading a node performed one memory read per field: every child address,
key size, key, and value size separately — dozens of reads per node. A
memory access costs a fixed amount per call plus a fee per byte, so the
load now fetches the front of the initial page in one read and parses
the fields out of the buffer, with any field beyond the read falling
back to an individual read as before.

The read is sized so that it only ever buys bytes the load will use.
For key types bounded by at most 64 bytes the header pins down the
extent of the children and key areas, and that estimate is all that is
fetched. For larger or unbounded key bounds the interleaved layout
offers no extent worth prefetching — the sizes the walk needs sit
between payloads it skips — so those types read exactly as before; an
earlier draft that read a fixed 1 KiB for them regressed the large-value
benchmarks by up to 8%.

Saving mirrors this: fields are serialized into a buffer flushed in as
few writes as possible, except that slices of 48 bytes or more are
written directly, uncopied, flushing whatever is buffered around them.
The threshold is measured, and lower than intuition suggests: copying
into the buffer costs a few instructions per byte while a separate
write costs a fixed amount for the call and its page mapping, which
puts the crossover around forty bytes — earlier drafts with a 128- and
512-byte threshold both measured thousands of instructions per save
slower on nodes with ~128-byte keys and values.

The `NodeWriter` keeps sole ownership of the overflow-address field at
bytes [7, 15): it updates the chain pointer in place as overflow pages
are allocated and released, so the save skips over it rather than risk
clobbering a pointer the writer just installed. The writer's unused
field helpers are gone.

Full canbench sweep: 147 improved / 0 regressed / 105 unchanged of 252,
median -3.9%, best -23.5%; the largest positive movement is +1.8% on a
benchmark whose code path is untouched (noise). Memory-manager maps gain
the most (-16 to -23%), followed by maps with small keys and values
(-10 to -19%).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@hpeebles
hpeebles requested a review from a team as a code owner July 28, 2026 11:57
@github-actions

Copy link
Copy Markdown

canbench 🏋 (dir: ./benchmarks/memory_manager) d557611 2026-07-28 12:06:11 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

Copy link
Copy Markdown

canbench 🏋 (dir: ./benchmarks/nns) d557611 2026-07-28 12:06:18 UTC

./benchmarks/nns/canbench_results.yml is not up to date
If the performance change is expected, run canbench --persist [--csv] to update the benchmark results.
📦 canbench_results_nns.csv available in artifacts

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

Summary:
  instructions:
    status:   Improvements detected 🟢
    counts:   [total 16 | regressed 0 | improved 5 | new 0 | unchanged 11]
    change:   [max 0 | p75 0 | median -1.26K | p25 -4.66M | min -91.69M]
    change %: [max 0.00% | p75 0.00% | median -0.05% | p25 -2.55% | min -4.98%]

  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%]

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

Only significant changes:
| status | name                                  | calls |    ins |  ins Δ% | HI |  HI Δ% | SMI |  SMI Δ% |
|--------|---------------------------------------|-------|--------|---------|----|--------|-----|---------|
|   -    | vote_cascading_stable_centralized_10k |       |  1.33B |  -2.51% | 10 |  0.00% |   0 |   0.00% |
|   -    | vote_cascading_stable_centralized_1k  |       | 97.28M |  -2.67% |  1 |  0.00% |   0 |   0.00% |
|   -    | vote_cascading_stable_chain_10k_5     |       |  2.80B |  -3.17% |  5 |  0.00% |   0 |   0.00% |
|   -    | vote_cascading_stable_single_vote_1k  |       | 65.53K |  -3.71% |  0 |  0.00% |   0 |   0.00% |
|   -    | vote_cascading_stable_single_vote_10k |       | 87.97K |  -4.98% |  0 |  0.00% |   0 |   0.00% |

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

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

@github-actions

Copy link
Copy Markdown

canbench 🏋 (dir: ./benchmarks/vec) d557611 2026-07-28 12:06:08 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

@github-actions

Copy link
Copy Markdown

canbench 🏋 (dir: ./benchmarks/btreeset) d557611 2026-07-28 12:06:15 UTC

./benchmarks/btreeset/canbench_results.yml is not up to date
If the performance change is expected, run canbench --persist [--csv] to update the benchmark results.
📦 canbench_results_btreeset.csv available in artifacts

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

Summary:
  instructions:
    status:   Improvements detected 🟢
    counts:   [total 100 | regressed 0 | improved 60 | new 0 | unchanged 40]
    change:   [max +2.78M | p75 -17.36K | median -354.58K | p25 -1.41M | min -152.62M]
    change %: [max +1.56% | p75 -0.55% | median -6.42% | p25 -21.99% | min -35.38%]

  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%]

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

Only significant changes:
| status | name                                  | calls |     ins |  ins Δ% | HI |  HI Δ% | SMI |  SMI Δ% |
|--------|---------------------------------------|-------|---------|---------|----|--------|-----|---------|
|   -    | btreeset_is_disjoint_blob_256         |       |  13.16M |  -2.09% |  0 |  0.00% |   0 |   0.00% |
|   -    | btreeset_remove_blob_512              |       |   2.35B |  -2.14% |  1 |  0.00% |   0 |   0.00% |
|   -    | btreeset_is_disjoint_blob_64          |       |   4.29M |  -2.20% |  0 |  0.00% |   0 |   0.00% |
|   -    | btreeset_is_superset_blob_64          |       |   7.11M |  -2.38% |  0 |  0.00% |   0 |   0.00% |
|   -    | btreeset_insert_blob_512              |       |   2.24B |  -2.94% |  0 |  0.00% | 128 |   0.00% |
|   -    | btreeset_remove_blob_256              |       |   1.47B |  -3.61% |  0 |  0.00% |   0 |   0.00% |
|   -    | btreeset_insert_blob_256              |       |   1.40B |  -4.51% |  0 |  0.00% |   0 |   0.00% |
|   -    | btreeset_remove_blob_128              |       | 952.62M |  -5.38% |  0 |  0.00% |   0 |   0.00% |
|   -    | btreeset_insert_blob_128              |       | 941.16M |  -5.39% |  0 |  0.00% |   0 |   0.00% |
|   -    | btreeset_intersection_blob_32         |       |   4.04M |  -6.41% |  0 |  0.00% |   0 |   0.00% |
|   -    | btreeset_union_blob_32                |       |   4.04M |  -6.42% |  0 |  0.00% |   0 |   0.00% |
|   -    | btreeset_symmetric_difference_blob_32 |       |   4.01M |  -6.47% |  0 |  0.00% |   0 |   0.00% |
|   -    | btreeset_is_superset_blob_32          |       |   3.80M |  -6.81% |  0 |  0.00% |   0 |   0.00% |
|   -    | btreeset_range_u64                    |       |   6.06M |  -8.00% |  0 |  0.00% |   0 |   0.00% |
|   -    | btreeset_is_disjoint_blob_32          |       |   2.32M |  -8.88% |  0 |  0.00% |   0 |   0.00% |
|   -    | btreeset_range_blob_16                |       |   8.73M |  -8.91% |  0 |  0.00% |   0 |   0.00% |
|   -    | btreeset_is_subset_blob_64            |       |  50.47K |  -9.07% |  0 |  0.00% |   0 |   0.00% |
|   -    | btreeset_remove_blob_64               |       | 587.94M |  -9.98% |  0 |  0.00% |   0 |   0.00% |
|   -    | btreeset_insert_blob_64               |       | 557.35M | -10.69% |  0 |  0.00% |   0 |   0.00% |
|   -    | btreeset_iter_u64                     |       |   9.56M | -11.33% |  0 |  0.00% |   0 |   0.00% |
|   -    | btreeset_iter_blob_16                 |       |  12.90M | -11.64% |  0 |  0.00% |   0 |   0.00% |
|   -    | btreeset_iter_blob_8                  |       |  11.92M | -13.84% |  0 |  0.00% |   0 |   0.00% |
|   -    | btreeset_is_subset_blob_32            |       |  38.54K | -13.96% |  0 |  0.00% |   0 |   0.00% |
|   -    | btreeset_range_u32                    |       |   5.68M | -14.08% |  0 |  0.00% |   0 |   0.00% |
|   -    | btreeset_iter_u32                     |       |   9.14M | -14.10% |  0 |  0.00% |   0 |   0.00% |
|  ...   | ... 10 rows omitted ...               |       |         |         |    |        |     |         |
|   -    | btreeset_union_u64                    |       |   1.53M | -22.10% |  0 |  0.00% |   0 |   0.00% |
|   -    | btreeset_intersection_u64             |       |   1.53M | -22.15% |  0 |  0.00% |   0 |   0.00% |
|   -    | btreeset_symmetric_difference_u64     |       |   1.50M | -22.48% |  0 |  0.00% |   0 |   0.00% |
|   -    | btreeset_is_superset_u64              |       |   1.52M | -22.53% |  0 |  0.00% |   0 |   0.00% |
|   -    | btreeset_intersection_u32             |       |   1.49M | -22.64% |  0 |  0.00% |   0 |   0.00% |
|   -    | btreeset_symmetric_difference_u32     |       |   1.47M | -22.89% |  0 |  0.00% |   0 |   0.00% |
|   -    | btreeset_union_u32                    |       |   1.47M | -22.89% |  0 |  0.00% |   0 |   0.00% |
|   -    | btreeset_intersection_blob_8          |       |   2.13M | -23.54% |  0 |  0.00% |   0 |   0.00% |
|   -    | btreeset_union_blob_8                 |       |   2.13M | -23.55% |  0 |  0.00% |   0 |   0.00% |
|   -    | btreeset_symmetric_difference_blob_8  |       |   2.12M | -23.68% |  0 |  0.00% |   0 |   0.00% |
|   -    | btreeset_is_superset_blob_8           |       |   2.07M | -24.00% |  0 |  0.00% |   0 |   0.00% |
|   -    | btreeset_is_disjoint_u64              |       |   1.02M | -24.92% |  0 |  0.00% |   0 |   0.00% |
|   -    | btreeset_is_disjoint_blob_8           |       |   1.37M | -25.88% |  0 |  0.00% |   0 |   0.00% |
|   -    | btreeset_is_subset_u32                |       |  32.13K | -26.33% |  0 |  0.00% |   0 |   0.00% |
|   -    | btreeset_is_subset_blob_16            |       |  39.61K | -26.70% |  0 |  0.00% |   0 |   0.00% |
|   -    | btreeset_is_subset_u64                |       |  31.95K | -27.48% |  0 |  0.00% |   0 |   0.00% |
|   -    | btreeset_remove_blob_16               |       | 320.61M | -29.41% |  0 |  0.00% |   0 |   0.00% |
|   -    | btreeset_remove_u64                   |       | 253.39M | -30.30% |  0 |  0.00% |   0 |   0.00% |
|   -    | btreeset_insert_blob_16               |       | 310.53M | -31.43% |  0 |  0.00% |   0 |   0.00% |
|   -    | btreeset_remove_u32                   |       | 238.15M | -31.78% |  0 |  0.00% |   0 |   0.00% |
|   -    | btreeset_insert_u64                   |       | 259.17M | -31.87% |  0 |  0.00% |   0 |   0.00% |
|   -    | btreeset_is_subset_blob_8             |       |  35.97K | -32.58% |  0 |  0.00% |   0 |   0.00% |
|   -    | btreeset_insert_u32                   |       | 238.62M | -32.98% |  0 |  0.00% |   0 |   0.00% |
|   -    | btreeset_remove_blob_8                |       | 277.29M | -33.35% |  0 |  0.00% |   0 |   0.00% |
|   -    | btreeset_insert_blob_8                |       | 278.81M | -35.38% |  0 |  0.00% |   0 |   0.00% |

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

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

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