Skip to content

Commit 3da10e3

Browse files
committed
perf(profiling): reduce profiler arena memory footprint
1 parent 8ac7358 commit 3da10e3

6 files changed

Lines changed: 311 additions & 34 deletions

File tree

libdd-alloc/src/chain.rs

Lines changed: 165 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
use crate::LinearAllocator;
55
use crate::{AllocError, Allocator};
66
use core::alloc::Layout;
7-
use core::cell::UnsafeCell;
7+
use core::cell::{Cell, UnsafeCell};
88
use core::mem::size_of;
99
use core::ptr::NonNull;
1010

@@ -17,11 +17,16 @@ use core::ptr::NonNull;
1717
/// [ChainAllocator] creates a new [LinearAllocator] when the current one
1818
/// doesn't have enough space for the requested allocation, and then links the
1919
/// new [LinearAllocator] to the previous one, creating a chain. This is where
20-
/// its name comes from.
20+
/// its name comes from. Each successful growth doubles the target chunk size
21+
/// up to a cap, so small arenas retain a low initial footprint while larger
22+
/// workloads quickly converge to larger chunks.
2123
pub struct ChainAllocator<A: Allocator + Clone> {
2224
top: UnsafeCell<ChainNodePtr<A>>,
23-
/// The size hint for the linear allocator's chunk.
24-
node_size: usize,
25+
/// The size hint for the next linear allocator chunk.
26+
node_size: Cell<usize>,
27+
/// The maximum size hint used for routine geometric growth. Individual
28+
/// oversized allocations can still request larger chunks.
29+
max_node_size: usize,
2530
allocator: A,
2631
}
2732

@@ -87,38 +92,89 @@ impl<A: Allocator + Clone> ChainAllocator<A> {
8792
/// is worth it. This is somewhat arbitrarily chosen at the moment.
8893
const MIN_NODE_SIZE: usize = 4 * Self::CHAIN_NODE_OVERHEAD;
8994

90-
/// Creates a new [ChainAllocator]. The `chunk_size_hint` is used as a
91-
/// size hint when creating new chunks of the chain. Note that the
95+
/// Default cap for routine geometric growth. This preserves the historical
96+
/// chunk size used by profiling dictionaries while allowing smaller initial
97+
/// chunks to ramp up quickly.
98+
const DEFAULT_MAX_NODE_SIZE: usize = 1024 * 1024;
99+
100+
const fn normalize_node_size(size: usize) -> usize {
101+
if size < Self::MIN_NODE_SIZE {
102+
Self::MIN_NODE_SIZE
103+
} else {
104+
size
105+
}
106+
}
107+
108+
/// Creates a new [ChainAllocator]. The `chunk_size_hint` is used as the
109+
/// initial size hint for chunks of the chain. Routine growth doubles the
110+
/// chunk size up to at least `Self::DEFAULT_MAX_NODE_SIZE`. Note that the
92111
/// [ChainAllocator] will use some bytes at the beginning of each chunk of
93112
/// the chain. The number of bytes is [Self::CHAIN_NODE_OVERHEAD]. Keep
94113
/// this in mind when sizing your hint if you are trying to be precise,
95114
/// such as making sure a specific object fits.
96115
pub const fn new_in(chunk_size_hint: usize, allocator: A) -> Self {
116+
let initial_node_size = Self::normalize_node_size(chunk_size_hint);
117+
let max_node_size = if initial_node_size < Self::DEFAULT_MAX_NODE_SIZE {
118+
Self::DEFAULT_MAX_NODE_SIZE
119+
} else {
120+
initial_node_size
121+
};
122+
Self::new_capped_in(initial_node_size, max_node_size, allocator)
123+
}
124+
125+
/// Creates a new [ChainAllocator] whose routine growth starts at
126+
/// `chunk_size_hint` and doubles until reaching `max_chunk_size_hint`.
127+
/// Requests larger than the cap are still honored by allocating an
128+
/// oversized chunk for that request.
129+
pub const fn new_capped_in(
130+
chunk_size_hint: usize,
131+
max_chunk_size_hint: usize,
132+
allocator: A,
133+
) -> Self {
134+
let initial_node_size = Self::normalize_node_size(chunk_size_hint);
135+
let max_node_size = if max_chunk_size_hint < initial_node_size {
136+
initial_node_size
137+
} else {
138+
max_chunk_size_hint
139+
};
97140
Self {
98141
top: UnsafeCell::new(ChainNodePtr::new()),
99-
// max is not a const fn, do it manually.
100-
node_size: if chunk_size_hint < Self::MIN_NODE_SIZE {
101-
Self::MIN_NODE_SIZE
102-
} else {
103-
chunk_size_hint
104-
},
142+
node_size: Cell::new(initial_node_size),
143+
max_node_size,
105144
allocator,
106145
}
107146
}
108147

148+
fn next_geometric_node_size(current: usize, max: usize, align: usize) -> usize {
149+
if current >= max {
150+
return max;
151+
}
152+
153+
let Some(doubled) = current.checked_mul(2) else {
154+
return max;
155+
};
156+
let next = if doubled > max { max } else { doubled };
157+
158+
if Layout::from_size_align(next, align).is_ok() {
159+
next
160+
} else {
161+
current
162+
}
163+
}
164+
109165
#[cold]
110166
#[inline(never)]
111167
fn grow(&self, min_size: usize) -> Result<(), AllocError> {
112168
let top = self.top.get();
113169
let chain_layout = Layout::new::<ChainNode<A>>();
114170

115-
let node_size = min_size.max(self.node_size);
116-
let linear = {
117-
let layout = Layout::from_size_align(node_size, chain_layout.align())
118-
.map_err(|_| AllocError)?
119-
.pad_to_align();
120-
LinearAllocator::new_in(layout, self.allocator.clone())?
121-
};
171+
let node_size = min_size.max(self.node_size.get());
172+
let layout = Layout::from_size_align(node_size, chain_layout.align())
173+
.map_err(|_| AllocError)?
174+
.pad_to_align();
175+
let next_node_size =
176+
Self::next_geometric_node_size(layout.size(), self.max_node_size, chain_layout.align());
177+
let linear = LinearAllocator::new_in(layout, self.allocator.clone())?;
122178

123179
// This shouldn't fail.
124180
let chain_node_addr = linear
@@ -148,6 +204,7 @@ impl<A: Allocator + Clone> ChainAllocator<A> {
148204
// Additionally, references are always temporary for the top, so this
149205
// write will not violate aliasing rules.
150206
unsafe { self.top.get().write(chain_node_ptr) };
207+
self.node_size.set(next_node_size);
151208

152209
Ok(())
153210
}
@@ -382,6 +439,91 @@ mod tests {
382439
unsafe { allocator.deallocate(ptr.cast(), layout) };
383440
}
384441

442+
#[test]
443+
fn test_geometric_growth() {
444+
let allocator = ChainAllocator::new_in(4096, Global);
445+
let layout = Layout::new::<u8>();
446+
447+
let _ = allocator.allocate(layout).unwrap();
448+
let first_reserved = allocator.reserved_bytes();
449+
fill_to_capacity(&allocator);
450+
451+
let _ = allocator.allocate(layout).unwrap();
452+
let second_reserved = allocator.reserved_bytes();
453+
let second_chunk = second_reserved - first_reserved;
454+
assert!(
455+
second_chunk >= first_reserved * 2,
456+
"second chunk should grow geometrically: first={first_reserved}, second={second_chunk}"
457+
);
458+
fill_to_capacity(&allocator);
459+
460+
let _ = allocator.allocate(layout).unwrap();
461+
let third_reserved = allocator.reserved_bytes();
462+
let third_chunk = third_reserved - second_reserved;
463+
assert!(
464+
third_chunk >= second_chunk * 2,
465+
"third chunk should grow geometrically: second={second_chunk}, third={third_chunk}"
466+
);
467+
}
468+
469+
#[test]
470+
fn test_geometric_growth_clamps_to_cap() {
471+
let allocator = ChainAllocator::new_capped_in(4096, 10 * 1024, Global);
472+
let layout = Layout::new::<u8>();
473+
474+
let _ = allocator.allocate(layout).unwrap();
475+
let first_reserved = allocator.reserved_bytes();
476+
fill_to_capacity(&allocator);
477+
478+
let _ = allocator.allocate(layout).unwrap();
479+
let second_reserved = allocator.reserved_bytes();
480+
fill_to_capacity(&allocator);
481+
482+
let _ = allocator.allocate(layout).unwrap();
483+
let third_reserved = allocator.reserved_bytes();
484+
let third_chunk = third_reserved - second_reserved;
485+
486+
assert!(second_reserved - first_reserved >= first_reserved * 2);
487+
assert!(third_chunk >= 10 * 1024);
488+
assert!(third_chunk < (second_reserved - first_reserved) * 2);
489+
}
490+
491+
#[test]
492+
fn test_capped_growth_honors_initial_size_as_minimum_cap() {
493+
let allocator = ChainAllocator::new_capped_in(8192, 4096, Global);
494+
let layout = Layout::new::<u8>();
495+
496+
let _ = allocator.allocate(layout).unwrap();
497+
let first_reserved = allocator.reserved_bytes();
498+
fill_to_capacity(&allocator);
499+
500+
let _ = allocator.allocate(layout).unwrap();
501+
let second_reserved = allocator.reserved_bytes();
502+
let second_chunk = second_reserved - first_reserved;
503+
504+
assert!(second_chunk >= first_reserved);
505+
assert!(second_chunk < first_reserved * 2);
506+
}
507+
508+
#[test]
509+
fn test_next_geometric_node_size_edge_cases() {
510+
assert_eq!(
511+
4096,
512+
ChainAllocator::<Global>::next_geometric_node_size(8192, 4096, 1)
513+
);
514+
assert_eq!(
515+
usize::MAX,
516+
ChainAllocator::<Global>::next_geometric_node_size(usize::MAX / 2 + 1, usize::MAX, 1)
517+
);
518+
519+
let invalid_layout_size = isize::MAX as usize + 1;
520+
let current = invalid_layout_size / 2 + 1;
521+
assert_eq!(
522+
current,
523+
ChainAllocator::<Global>::next_geometric_node_size(current, invalid_layout_size, 1)
524+
);
525+
}
526+
385527
#[track_caller]
386528
fn fill_to_capacity<A: Allocator + Clone>(allocator: &ChainAllocator<A>) {
387529
let remaining_capacity = allocator.remaining_capacity();
@@ -401,8 +543,10 @@ mod tests {
401543

402544
let bool_layout = Layout::new::<bool>();
403545

546+
const GROWTH_ITERATIONS: usize = 16;
547+
404548
// test that it fills to capacity a few times.
405-
for _ in 0..100 {
549+
for _ in 0..GROWTH_ITERATIONS {
406550
fill_to_capacity(&allocator);
407551

408552
// This check is theoretically redundant because fill_to_capacity
@@ -426,7 +570,7 @@ mod tests {
426570
let reserved_bytes = allocator.reserved_bytes();
427571
// The allocations can theoretically be over-allocated, so use >= to
428572
// do the comparison.
429-
assert!(reserved_bytes >= page_size * 100);
573+
assert!(reserved_bytes >= page_size * GROWTH_ITERATIONS);
430574

431575
// Everything is filled to capacity except the last iteration.
432576
let used_bytes = allocator.used_bytes();

libdd-profiling/src/collections/string_table/mod.rs

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -83,10 +83,13 @@ impl StringTable {
8383
// Keep in mind 32-bit .NET. There is only 2 GiB of virtual memory
8484
// total available to an application, and we're not the application,
8585
// we're just a piece inside it. Additionally, there may be 2 or more
86-
// string tables in memory at a given time. Talk to .NET profiling
87-
// engineers before making this any bigger.
88-
const SIZE_HINT: usize = 4 * 1024 * 1024;
89-
let bytes = ChainAllocator::new_in(SIZE_HINT, VirtualAllocator {});
86+
// string tables in memory at a given time. Larger profiles grow
87+
// geometrically up to the historical 4 MiB chunk size, while common
88+
// profiles fit comfortably below this initial size. Talk to .NET
89+
// profiling engineers before making this any bigger.
90+
const SIZE_HINT: usize = 512 * 1024;
91+
const MAX_SIZE_HINT: usize = 4 * 1024 * 1024;
92+
let bytes = ChainAllocator::new_capped_in(SIZE_HINT, MAX_SIZE_HINT, VirtualAllocator {});
9093

9194
let mut strings = HashSet::with_hasher(Hasher::default());
9295
// It varies by implementation, but frequently I've noticed that the

libdd-profiling/src/profiles/collections/parallel/slice_set.rs

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,9 @@ use std::ops::Deref;
99

1010
/// Number of shards used by the parallel slice set and (by extension)
1111
/// the string-specific parallel set. Kept as a constant so tests and
12-
/// related code can refer to the same value.
13-
pub const N_SHARDS: usize = 16;
12+
/// related code can refer to the same value. Four shards keep enough
13+
/// concurrency for low-thread-count profilers while lowering the arena floor.
14+
pub const N_SHARDS: usize = 4;
1415

1516
/// The initial capacities for Rust's hash map (and set) currently go
1617
/// like this: 3, 7, 14, 28. We want to avoid some of the smaller sizes so
@@ -64,9 +65,9 @@ impl<T: Copy + hash::Hash + Eq + 'static> ParallelSliceSet<T> {
6465
pub const fn select_shard(hash: u64) -> usize {
6566
// Use lower bits for shard selection to avoid interfering with
6667
// Swiss tables' internal SIMD comparisons that use upper 7 bits.
67-
// Using 4 bits provides resilience against hash function deficiencies
68-
// and optimal scaling for low thread counts.
69-
(hash & 0b1111) as usize
68+
// N_SHARDS is a power of two, so this masks the lower log2(N_SHARDS)
69+
// bits and keeps the shard index in bounds.
70+
(hash as usize) & (N_SHARDS - 1)
7071
}
7172

7273
/// Tries to create a new parallel slice set.

libdd-profiling/src/profiles/collections/set.rs

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,10 @@ pub struct Set<T: Hash + Eq + 'static> {
6161
}
6262

6363
impl<T: Eq + Hash + 'static> Set<T> {
64-
pub const SIZE_HINT: usize = 1024 * 1024;
64+
// Keep the per-shard arena small; larger dictionaries grow
65+
// geometrically up to the historical 1 MiB chunk size.
66+
pub const SIZE_HINT: usize = 64 * 1024;
67+
pub const MAX_SIZE_HINT: usize = 1024 * 1024;
6568

6669
pub fn try_new() -> Result<Self, SetError> {
6770
Self::try_with_capacity(SET_MIN_CAPACITY)
@@ -146,7 +149,11 @@ impl<T: Hash + Eq + 'static> Drop for Set<T> {
146149

147150
impl<T: Hash + Eq + 'static> Set<T> {
148151
pub(crate) fn try_with_capacity(capacity: usize) -> Result<Self, SetError> {
149-
let arena = ChainAllocator::new_in(Self::SIZE_HINT, VirtualAllocator {});
152+
let arena = ChainAllocator::new_capped_in(
153+
Self::SIZE_HINT,
154+
Self::MAX_SIZE_HINT,
155+
VirtualAllocator {},
156+
);
150157
let mut table = HashTable::new();
151158

152159
// SAFETY: new empty table cannot require rehash, callback unreachable.

libdd-profiling/src/profiles/collections/slice_set.rs

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,10 +23,17 @@ pub struct SliceSet<T: Copy + Hash + Eq + 'static> {
2323
}
2424

2525
impl<T: Copy + Hash + Eq + 'static> SliceSet<T> {
26-
const SIZE_HINT: usize = 1024 * 1024;
26+
// Keep the per-shard arena small; larger dictionaries grow
27+
// geometrically up to the historical 1 MiB chunk size.
28+
const SIZE_HINT: usize = 64 * 1024;
29+
const MAX_SIZE_HINT: usize = 1024 * 1024;
2730

2831
pub fn try_with_capacity(capacity: usize) -> Result<Self, SetError> {
29-
let arena = ChainAllocator::new_in(Self::SIZE_HINT, VirtualAllocator {});
32+
let arena = ChainAllocator::new_capped_in(
33+
Self::SIZE_HINT,
34+
Self::MAX_SIZE_HINT,
35+
VirtualAllocator {},
36+
);
3037

3138
let mut slices = HashTable::new();
3239
// SAFETY: we just made the empty hash table, so there's nothing that

0 commit comments

Comments
 (0)