Skip to content
326 changes: 198 additions & 128 deletions benchmarks/btreemap/canbench_results.yml

Large diffs are not rendered by default.

155 changes: 155 additions & 0 deletions benchmarks/btreemap/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -441,6 +441,161 @@ pub fn btreemap_v2_get_10mib_values() -> BenchResult {
})
}

// Batch insertion. Every `insert_many` benchmark has an `insert_loop` counterpart over
// exactly the same keys and values, so the two are directly comparable. The pairs are kept
// adjacent below; the one exception is `insert_many_seq`, whose counterpart is the
// pre-existing `btreemap_v2_insert_seq_u64_u64`.

#[bench(raw)]
pub fn btreemap_v2_insert_many_seq_u64_u64() -> BenchResult {
let mut btree = BTreeMap::new(DefaultMemoryImpl::default());
let count = 10_000u64;
let mut rng = Rng::from_seed(0);

// Same keys and values as `btreemap_v2_insert_seq_u64_u64`.
let items: Vec<(u64, u64)> = (0..count).map(|i| (i, rng.rand_u64())).collect();
bench_fn(|| {
btree.insert_many(items);
})
}

// Descending keys. The held path should unwind leftwards just as it unwinds rightwards for
// ascending keys, so this ought to coalesce about as well.
#[bench(raw)]
pub fn btreemap_v2_insert_loop_desc_u64_u64() -> BenchResult {
let mut btree = BTreeMap::new(DefaultMemoryImpl::default());
let count = 10_000u64;
let mut rng = Rng::from_seed(0);
let items: Vec<(u64, u64)> = (0..count).rev().map(|i| (i, rng.rand_u64())).collect();
bench_fn(|| {
for (key, value) in items {
btree.insert(key, value);
}
})
}

#[bench(raw)]
pub fn btreemap_v2_insert_many_desc_u64_u64() -> BenchResult {
let mut btree = BTreeMap::new(DefaultMemoryImpl::default());
let count = 10_000u64;
let mut rng = Rng::from_seed(0);
let items: Vec<(u64, u64)> = (0..count).rev().map(|i| (i, rng.rand_u64())).collect();
bench_fn(|| {
btree.insert_many(items);
})
}

// The baseline for both unordered variants below.
#[bench(raw)]
pub fn btreemap_v2_insert_loop_random_u64_u64() -> BenchResult {
let mut btree = BTreeMap::new(DefaultMemoryImpl::default());
let mut rng = Rng::from_seed(0);
let items = generate_random_kv::<u64, u64>(10_000, &mut rng);
bench_fn(|| {
for (key, value) in items {
btree.insert(key, value);
}
})
}

// `insert_many` wants ascending keys, so a caller holding unordered data sorts it first.
// The sort is inside the measured section.
#[bench(raw)]
pub fn btreemap_v2_insert_many_sorted_random_u64_u64() -> BenchResult {
let mut btree = BTreeMap::new(DefaultMemoryImpl::default());
let mut rng = Rng::from_seed(0);
let items = generate_random_kv::<u64, u64>(10_000, &mut rng);
bench_fn(|| {
let mut items = items;
items.sort_by(|(a, _), (b, _)| a.cmp(b));
btree.insert_many(items);
})
}

// Feeding unordered keys forfeits the coalescing: the held path unwinds on almost every
// key, so this should land close to the plain insert loop rather than beating it.
#[bench(raw)]
pub fn btreemap_v2_insert_many_unsorted_u64_u64() -> BenchResult {
let mut btree = BTreeMap::new(DefaultMemoryImpl::default());
let mut rng = Rng::from_seed(0);
let items = generate_random_kv::<u64, u64>(10_000, &mut rng);
bench_fn(|| {
btree.insert_many(items);
})
}

// Inserting a sorted run into a map that already holds 100k entries: the batch lands in
// leaves that mostly have room, which is where coalescing pays most.
#[bench(raw)]
pub fn btreemap_v2_insert_loop_into_existing_u64_u64() -> BenchResult {
let mut btree = BTreeMap::new(DefaultMemoryImpl::default());
for i in 0..100_000u64 {
btree.insert(i * 10, i);
}
let items: Vec<(u64, u64)> = (0..10_000u64).map(|i| (i * 10 + 5, i)).collect();
bench_fn(|| {
for (key, value) in items {
btree.insert(key, value);
}
})
}

#[bench(raw)]
pub fn btreemap_v2_insert_many_into_existing_u64_u64() -> BenchResult {
let mut btree = BTreeMap::new(DefaultMemoryImpl::default());
for i in 0..100_000u64 {
btree.insert(i * 10, i);
}
let items: Vec<(u64, u64)> = (0..10_000u64).map(|i| (i * 10 + 5, i)).collect();
bench_fn(|| {
btree.insert_many(items);
})
}

// Pure overwrite of existing keys, with values large enough to live on V2 overflow pages.
// `insert` has to read the displaced value back in order to return it; `insert_many`
// discards it and so skips that read entirely.
#[bench(raw)]
pub fn btreemap_v2_insert_loop_overwrite_1kib_values() -> BenchResult {
let count = 2_000usize;
let mut btree = BTreeMap::new(DefaultMemoryImpl::default());
let mut rng = Rng::from_seed(0);
for (i, value) in generate_random_blocks(count, 1024, &mut rng)
.into_iter()
.enumerate()
{
btree.insert(i as u64, value);
}
let new_values = generate_random_blocks(count, 1024, &mut rng);
bench_fn(|| {
for (i, value) in new_values.into_iter().enumerate() {
btree.insert(i as u64, value);
}
})
}

#[bench(raw)]
pub fn btreemap_v2_insert_many_overwrite_1kib_values() -> BenchResult {
let count = 2_000usize;
let mut btree = BTreeMap::new(DefaultMemoryImpl::default());
let mut rng = Rng::from_seed(0);
for (i, value) in generate_random_blocks(count, 1024, &mut rng)
.into_iter()
.enumerate()
{
btree.insert(i as u64, value);
}
let new_values = generate_random_blocks(count, 1024, &mut rng);
bench_fn(|| {
btree.insert_many(
new_values
.into_iter()
.enumerate()
.map(|(i, v)| (i as u64, v)),
);
})
}

// Benchmarks for `BTreeMap::contains_key`.
// Reduced grid: contains_key traversal is identical to get, only skips value deserialization.
bench_tests! {
Expand Down
57 changes: 57 additions & 0 deletions src/btreemap.rs
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@
//! ----------------------------------------
//! ```
mod allocator;
mod bulk_insert;
mod iter;
mod node;
mod node_cache;
Expand All @@ -59,6 +60,7 @@ use crate::{
Memory, Storable,
};
use allocator::Allocator;
use bulk_insert::BulkInsert;
pub use iter::Iter;
use node::{DerivedPageSize, Entry, Node, NodeType, PageSize, Version};
use node_cache::NodeCache;
Expand Down Expand Up @@ -706,6 +708,61 @@ where
.map(V::from_bytes)
}

/// Inserts many key-value pairs, writing each modified node to stable memory at most
/// once instead of once per key.
///
/// [`insert`](Self::insert) writes the target node — and, when a node splits, its
/// sibling and parent too — on every call, so inserting a run of nearby keys rewrites
/// the same node again and again. `insert_many` instead 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. It also writes the header once for the whole call rather than once
/// per key.
///
/// The iterator is consumed lazily and never collected, so the memory cost is one tree
/// path however many pairs are supplied.
///
/// Duplicate keys are applied in the order given, so the last occurrence wins, exactly
/// as with repeated `insert` calls — the resulting map is identical, only the number of
/// writes differs.
///
/// PRECONDITION: for every pair
/// key.to_bytes().len() <= max_size(Key)
/// value.to_bytes().len() <= max_size(Value)
///
/// # Supply keys in sorted order for best performance
///
/// The coalescing pays off exactly when consecutive keys land in the same node, so
/// **feed the pairs in sorted key order**. Both ascending and descending work just as
/// well — what matters is that consecutive keys are close together, not which way the
/// run travels.
///
/// Out-of-order keys are still handled correctly — the path unwinds to an ancestor that
/// covers the key, as far as the root if necessary — but each one costs a fresh
/// descent, and shuffled input performs no better than calling
/// [`insert`](Self::insert) in a loop. Sort first if the data is not already ordered.
///
/// # Examples
///
/// ```rust
/// use ic_stable_structures::{BTreeMap, DefaultMemoryImpl};
///
/// let mut map: BTreeMap<u64, u64, _> = BTreeMap::new(DefaultMemoryImpl::default());
///
/// // Streamed straight from the iterator; nothing is materialized.
/// map.insert_many((0..1_000).map(|i| (i, i * 2)));
///
/// assert_eq!(map.len(), 1_000);
/// assert_eq!(map.get(&500), Some(1_000));
/// ```
pub fn insert_many(&mut self, entries: impl IntoIterator<Item = (K, V)>) {
let mut bulk = BulkInsert::new(self);
for (key, value) in entries {
bulk.insert(key, value);
}
// Dropping `bulk` here writes out the path it is still holding, along with the
// header. That is what completes the batch.
}

/// Inserts an entry into a node that is *not full*.
fn insert_nonfull(
&mut self,
Expand Down
Loading
Loading