Skip to content

Commit e954bcf

Browse files
bench(profiling): measure live heap tracking paths
1 parent 17a0855 commit e954bcf

4 files changed

Lines changed: 174 additions & 56 deletions

File tree

profiling/Cargo.toml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,10 @@ perfcnt = "0.8.0"
6161
name = "stack_walking"
6262
harness = false
6363

64+
[[bench]]
65+
name = "heap_live_tracking"
66+
harness = false
67+
6468
[features]
6569
default = ["io_profiling"]
6670
debug_stats = []
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
use core::cell::Cell;
2+
use criterion::{black_box, criterion_group, criterion_main, BatchSize, Criterion};
3+
4+
// Compile the production tracker without linking the PHP extension executable.
5+
#[allow(dead_code)]
6+
#[path = "../src/profiling/live_heap.rs"]
7+
mod live_heap;
8+
use live_heap::LiveHeapTracker;
9+
10+
const BASE_ADDRESS: usize = 0x1000_0000;
11+
const TRACKED_ALLOCATIONS: usize = 2048;
12+
const ADDRESS_STRIDE: usize = 64;
13+
14+
fn address(index: usize) -> usize {
15+
BASE_ADDRESS + index % TRACKED_ALLOCATIONS * ADDRESS_STRIDE
16+
}
17+
18+
fn untracked_address(index: usize) -> usize {
19+
address(index) + 8
20+
}
21+
22+
fn populated_tracker() -> LiveHeapTracker<[usize; 4]> {
23+
let tracker = LiveHeapTracker::new();
24+
for index in 0..TRACKED_ALLOCATIONS {
25+
assert!(tracker.track(address(index), [0; 4]));
26+
}
27+
tracker
28+
}
29+
30+
fn benchmark(c: &mut Criterion) {
31+
let mut group = c.benchmark_group("heap_live_tracking");
32+
33+
{
34+
let tracker = populated_tracker();
35+
let next = Cell::new(0);
36+
group.bench_function("allocate_tracked", |b| {
37+
b.iter_batched(
38+
|| {
39+
let index = next.get();
40+
next.set(index + 1);
41+
let ptr = untracked_address(index);
42+
let _ = tracker.untrack(ptr);
43+
(ptr, [0; 4])
44+
},
45+
|(ptr, sample)| black_box(tracker.track(ptr, sample)),
46+
BatchSize::PerIteration,
47+
)
48+
});
49+
}
50+
51+
{
52+
let tracker = populated_tracker();
53+
let next = Cell::new(0);
54+
group.bench_function("free_tracked", |b| {
55+
b.iter_batched(
56+
|| {
57+
let index = next.get();
58+
next.set(index + 1);
59+
let ptr = untracked_address(index);
60+
assert!(tracker.track(ptr, [0; 4]));
61+
ptr
62+
},
63+
|ptr| black_box(tracker.untrack(ptr)),
64+
BatchSize::PerIteration,
65+
)
66+
});
67+
}
68+
69+
{
70+
let tracker = populated_tracker();
71+
let next = Cell::new(0);
72+
group.bench_function("free_untracked", |b| {
73+
b.iter_batched(
74+
|| {
75+
let index = next.get();
76+
next.set(index + 1);
77+
let ptr = untracked_address(index);
78+
let _ = tracker.untrack(ptr);
79+
ptr
80+
},
81+
|ptr| black_box(tracker.untrack(ptr)),
82+
BatchSize::PerIteration,
83+
)
84+
});
85+
}
86+
87+
group.finish();
88+
}
89+
90+
criterion_group!(benches, benchmark);
91+
criterion_main!(benches);
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
use dashmap::DashMap;
2+
use rustc_hash::FxBuildHasher;
3+
use std::sync::atomic::{AtomicUsize, Ordering};
4+
5+
/// Maximum number of allocations to track for live heap profiling.
6+
const MAX_SIZE: usize = 4096;
7+
8+
/// Tracks live heap samples by allocation address. FxHasher spreads sequential
9+
/// ZendMM addresses across DashMap's shards without hashing already-randomized
10+
/// pointer bytes with SipHash.
11+
pub(super) struct LiveHeapTracker<T> {
12+
allocations: DashMap<usize, T, FxBuildHasher>,
13+
count: AtomicUsize,
14+
}
15+
16+
impl<T> LiveHeapTracker<T> {
17+
pub(super) fn new() -> Self {
18+
Self {
19+
allocations: DashMap::with_hasher(FxBuildHasher),
20+
count: AtomicUsize::new(0),
21+
}
22+
}
23+
24+
pub(super) fn len(&self) -> usize {
25+
self.count.load(Ordering::Relaxed)
26+
}
27+
28+
pub(super) fn clear(&self) {
29+
self.allocations.clear();
30+
self.count.store(0, Ordering::Relaxed);
31+
}
32+
33+
pub(super) fn track(&self, ptr: usize, sample: T) -> bool {
34+
// Best-effort cap: in ZTS the count check and insert still race, so
35+
// the map can briefly exceed MAX_SIZE.
36+
if self.len() >= MAX_SIZE {
37+
return false;
38+
}
39+
40+
if self.allocations.insert(ptr, sample).is_none() {
41+
self.count.fetch_add(1, Ordering::Relaxed);
42+
}
43+
true
44+
}
45+
46+
pub(super) fn untrack(&self, ptr: usize) -> Option<T> {
47+
let result = self.allocations.remove(&ptr).map(|(_, sample)| sample);
48+
if result.is_some() {
49+
self.count.fetch_sub(1, Ordering::Relaxed);
50+
}
51+
result
52+
}
53+
}
54+
55+
impl<T: Clone> LiveHeapTracker<T> {
56+
pub(super) fn snapshot(&self) -> Vec<T> {
57+
self.allocations
58+
.iter()
59+
.map(|entry| entry.value().clone())
60+
.collect()
61+
}
62+
}
63+
64+
impl<T> Default for LiveHeapTracker<T> {
65+
fn default() -> Self {
66+
Self::new()
67+
}
68+
}

profiling/src/profiling/mod.rs

Lines changed: 11 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
mod backtrace;
22
mod interrupts;
3+
mod live_heap;
34
mod sample_type_filter;
45
pub mod stack_walking;
56
mod thread_utils;
@@ -30,20 +31,18 @@ use core::mem::forget;
3031
use core::{ptr, str};
3132
use cpu_time::ThreadTime;
3233
use crossbeam_channel::{Receiver, Sender, TrySendError};
33-
use dashmap::DashMap;
3434
use libdd_common::tag::Tag;
3535
use libdd_profiling::api::{
3636
Function, Label as ApiLabel, Location, Period, Sample, SampleType as ApiSampleType,
3737
UpscalingInfo, ValueType as ApiValueType,
3838
};
3939
use libdd_profiling::internal::Profile as InternalProfile;
4040
use log::{debug, info, trace, warn};
41-
use rustc_hash::FxBuildHasher;
4241
use std::borrow::Cow;
4342
use std::collections::HashMap;
4443
use std::hash::Hash;
4544
use std::num::NonZeroI64;
46-
use std::sync::atomic::{AtomicBool, AtomicPtr, AtomicU64, AtomicUsize, Ordering};
45+
use std::sync::atomic::{AtomicBool, AtomicPtr, AtomicU64, Ordering};
4746
use std::sync::{Arc, Barrier, OnceLock};
4847
use std::thread::JoinHandle;
4948
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
@@ -67,13 +66,6 @@ pub const NO_TIMESTAMP: i64 = 0;
6766
// magnitude for the capacity.
6867
const UPLOAD_CHANNEL_CAPACITY: usize = 8;
6968

70-
/// HeapTracker uses FxHasher (rustc-hash) instead of the default SipHash.
71-
/// FxHasher's multiply-rotate mix fully avalanches bits, spreading sequential
72-
/// ZendMM bump-allocator addresses evenly across DashMap's 16 shards and
73-
/// avoiding lock hot-spots under concurrent ZTS workloads.
74-
/// FxBuildHasher satisfies Clone, which DashMap requires.
75-
type HeapTracker = DashMap<usize, LiveHeapSample, FxBuildHasher>;
76-
7769
/// The global profiler. Profiler gets made during the first rinit after an
7870
/// minit, and is destroyed on mshutdown.
7971
static mut PROFILER: OnceLock<Profiler> = OnceLock::new();
@@ -273,9 +265,7 @@ pub(crate) struct LiveHeapSample {
273265
pub allocation_size: i64,
274266
}
275267

276-
/// Maximum number of allocations to track for live heap profiling.
277-
/// This bounds memory usage. When full, new allocations are not tracked.
278-
pub(crate) const LIVE_HEAP_TRACKER_MAX_SIZE: usize = 4096;
268+
use live_heap::LiveHeapTracker;
279269

280270
pub struct Profiler {
281271
fork_barrier: Arc<Barrier>,
@@ -292,14 +282,8 @@ pub struct Profiler {
292282
/// through this pointer.
293283
system_settings: AtomicPtr<SystemSettings>,
294284

295-
/// Tracks sampled allocations for live heap profiling.
296-
/// Maps allocation pointer -> sample data for batched emission at export time.
297-
/// Wrapped in Arc to share with TimeCollector for batched sample emission.
298-
/// Uses a fast pointer hasher since addresses are already well-distributed.
299-
live_heap_tracker: Arc<HeapTracker>,
300-
/// Cached entry count for live_heap_tracker. A single Relaxed load replaces
301-
/// the 16 shard read-locks that DashMap::len() acquires per sampled allocation.
302-
live_heap_tracker_count: Arc<AtomicUsize>,
285+
/// Shared with TimeCollector for batched heap-live sample emission.
286+
live_heap_tracker: Arc<LiveHeapTracker<LiveHeapSample>>,
303287
}
304288

305289
struct TimeCollector {
@@ -309,9 +293,7 @@ struct TimeCollector {
309293
upload_sender: Sender<UploadMessage>,
310294
upload_period: Duration,
311295
/// Shared tracker for batched heap-live sample emission at export time.
312-
live_heap_tracker: Arc<HeapTracker>,
313-
/// See Profiler::live_heap_tracker_count.
314-
live_heap_tracker_count: Arc<AtomicUsize>,
296+
live_heap_tracker: Arc<LiveHeapTracker<LiveHeapSample>>,
315297
/// Used to build correctly-positioned sample_values for heap-live samples
316298
/// without duplicating the type-string → index mapping.
317299
sample_types_filter: SampleTypeFilter,
@@ -325,7 +307,7 @@ impl TimeCollector {
325307
profiles: &mut HashMap<Arc<ProfileIndex>, InternalProfile>,
326308
started_at: &WallTime,
327309
) {
328-
let tracker_len = self.live_heap_tracker_count.load(Ordering::Relaxed);
310+
let tracker_len = self.live_heap_tracker.len();
329311
if tracker_len == 0 {
330312
return;
331313
}
@@ -336,11 +318,7 @@ impl TimeCollector {
336318
// for the duration of the Arc::clone calls, not for handle_sample_message.
337319
// This prevents concurrent efree calls on PHP threads from stalling on
338320
// shards that the TimeCollector is reading during a full export iteration.
339-
let snapshot: Vec<LiveHeapSample> = self
340-
.live_heap_tracker
341-
.iter()
342-
.map(|entry| entry.value().clone())
343-
.collect();
321+
let snapshot = self.live_heap_tracker.snapshot();
344322

345323
for tracked in snapshot {
346324
let sample_values = self.sample_types_filter.filter(SampleValues {
@@ -837,8 +815,7 @@ impl Profiler {
837815
let interrupt_manager = Arc::new(InterruptManager::new());
838816
let (message_sender, message_receiver) = crossbeam_channel::bounded(100);
839817
let (upload_sender, upload_receiver) = crossbeam_channel::bounded(UPLOAD_CHANNEL_CAPACITY);
840-
let live_heap_tracker = Arc::new(DashMap::with_hasher(FxBuildHasher));
841-
let live_heap_tracker_count = Arc::new(AtomicUsize::new(0));
818+
let live_heap_tracker = Arc::new(LiveHeapTracker::new());
842819
let sample_types_filter = SampleTypeFilter::new(system_settings);
843820
let time_collector = TimeCollector {
844821
fork_barrier: fork_barrier.clone(),
@@ -847,7 +824,6 @@ impl Profiler {
847824
upload_sender: upload_sender.clone(),
848825
upload_period: UPLOAD_PERIOD,
849826
live_heap_tracker: live_heap_tracker.clone(),
850-
live_heap_tracker_count: live_heap_tracker_count.clone(),
851827
sample_types_filter: sample_types_filter.clone(),
852828
};
853829

@@ -888,7 +864,6 @@ impl Profiler {
888864
sample_types_filter,
889865
system_settings: AtomicPtr::new(system_settings as *const _ as *mut _),
890866
live_heap_tracker,
891-
live_heap_tracker_count,
892867
}
893868
}
894869

@@ -945,31 +920,12 @@ impl Profiler {
945920
/// Track an allocation for live heap profiling.
946921
/// Returns true if tracked, false if tracking is disabled or limit reached.
947922
pub(crate) fn track_allocation(&self, ptr: usize, sample: LiveHeapSample) -> bool {
948-
// Best-effort cap: in ZTS the count check and insert still race, so
949-
// the map can briefly exceed LIVE_HEAP_TRACKER_MAX_SIZE. A single
950-
// Relaxed load is equivalent correctness-wise to the former
951-
// DashMap::len() but avoids 16 shard read-locks per sampled alloc.
952-
if self.live_heap_tracker_count.load(Ordering::Relaxed) >= LIVE_HEAP_TRACKER_MAX_SIZE {
953-
return false;
954-
}
955-
956-
let old = self.live_heap_tracker.insert(ptr, sample);
957-
if old.is_none() {
958-
self.live_heap_tracker_count.fetch_add(1, Ordering::Relaxed);
959-
}
960-
true
923+
self.live_heap_tracker.track(ptr, sample)
961924
}
962925

963926
/// Untrack an allocation. Returns the sample if it was tracked.
964927
pub(crate) fn untrack_allocation(&self, ptr: usize) -> Option<LiveHeapSample> {
965-
let result = self
966-
.live_heap_tracker
967-
.remove(&ptr)
968-
.map(|(_, sample)| sample);
969-
if result.is_some() {
970-
self.live_heap_tracker_count.fetch_sub(1, Ordering::Relaxed);
971-
}
972-
result
928+
self.live_heap_tracker.untrack(ptr)
973929
}
974930

975931
pub fn send_local_root_span_resource(
@@ -1087,7 +1043,6 @@ impl Profiler {
10871043

10881044
// Clear live heap tracker to avoid stale entries from parent process
10891045
profiler.live_heap_tracker.clear();
1090-
profiler.live_heap_tracker_count.store(0, Ordering::Relaxed);
10911046

10921047
// But we're not 100% sure everything is safe to drop, notably the
10931048
// join handles, so we leak the rest.

0 commit comments

Comments
 (0)