From f357fbcb01f4dc883405e9de3c8635e2d8278bf6 Mon Sep 17 00:00:00 2001 From: Florian Engelhardt Date: Thu, 3 Sep 2026 14:52:25 +0200 Subject: [PATCH 1/7] fix(profiling): use exponential sampling in IOProfilingStats --- profiling/src/io/mod.rs | 36 +++++++++++++++++++++--------------- 1 file changed, 21 insertions(+), 15 deletions(-) diff --git a/profiling/src/io/mod.rs b/profiling/src/io/mod.rs index 7de6419bd2..cebee295c6 100644 --- a/profiling/src/io/mod.rs +++ b/profiling/src/io/mod.rs @@ -7,7 +7,7 @@ use crate::profiling::profiler::Profiler; use crate::profiling::{zend, RefCellExt, REQUEST_LOCALS}; use libc::{c_int, c_void, fstat, stat, S_IFMT, S_IFSOCK}; use rand::rngs::ThreadRng; -use rand_distr::{Distribution, Poisson}; +use rand::Rng; use rustc_hash::FxHashMap; use std::cell::RefCell; use std::mem::MaybeUninit; @@ -563,17 +563,16 @@ fn collect_file_write_size(value: u64) { pub struct IOProfilingStats { next_sample: u64, - poisson: Poisson, + mean: f64, rng: ThreadRng, } impl IOProfilingStats { - fn new(lambda: f64) -> Self { - // Safety: this will only error if lambda <= 0 - let poisson = Poisson::new(lambda).unwrap(); + fn new(mean: u64) -> Self { + assert!(mean > 0); let mut stats = IOProfilingStats { + mean: mean as f64, next_sample: 0, - poisson, rng: rand::rng(), }; stats.next_sampling_interval(); @@ -581,7 +580,14 @@ impl IOProfilingStats { } fn next_sampling_interval(&mut self) { - self.next_sample = self.poisson.sample(&mut self.rng) as u64; + // Draw inter-sample distance from an exponential distribution: -ln(U) * mean + let u: f64 = self.rng.random(); + let u = if u <= 0.0 { 1e-10 } else { u }; + let v = -u.ln() * self.mean; + // Clamp to [8, 20 * mean] matching libdatadog sampler + let vmax = 20.0 * self.mean; + let v = v.clamp(8.0, vmax); + self.next_sample = v as u64; } fn should_collect(&mut self, value: u64) -> bool { @@ -606,42 +612,42 @@ impl IOProfilingStats { thread_local! { static SOCKET_READ_TIME_PROFILING_STATS: RefCell = RefCell::new( IOProfilingStats::new( - SOCKET_READ_TIME_PROFILING_INTERVAL.load(Ordering::Relaxed) as f64, + SOCKET_READ_TIME_PROFILING_INTERVAL.load(Ordering::Relaxed), ) ); static SOCKET_WRITE_TIME_PROFILING_STATS: RefCell = RefCell::new( IOProfilingStats::new( - SOCKET_WRITE_TIME_PROFILING_INTERVAL.load(Ordering::Relaxed) as f64, + SOCKET_WRITE_TIME_PROFILING_INTERVAL.load(Ordering::Relaxed), ) ); static FILE_READ_TIME_PROFILING_STATS: RefCell = RefCell::new( IOProfilingStats::new( - FILE_READ_TIME_PROFILING_INTERVAL.load(Ordering::Relaxed) as f64, + FILE_READ_TIME_PROFILING_INTERVAL.load(Ordering::Relaxed), ) ); static FILE_WRITE_TIME_PROFILING_STATS: RefCell = RefCell::new( IOProfilingStats::new( - FILE_WRITE_TIME_PROFILING_INTERVAL.load(Ordering::Relaxed) as f64, + FILE_WRITE_TIME_PROFILING_INTERVAL.load(Ordering::Relaxed), ) ); static SOCKET_READ_SIZE_PROFILING_STATS: RefCell = RefCell::new( IOProfilingStats::new( - SOCKET_READ_SIZE_PROFILING_INTERVAL.load(Ordering::Relaxed) as f64, + SOCKET_READ_SIZE_PROFILING_INTERVAL.load(Ordering::Relaxed), ) ); static SOCKET_WRITE_SIZE_PROFILING_STATS: RefCell = RefCell::new( IOProfilingStats::new( - SOCKET_WRITE_SIZE_PROFILING_INTERVAL.load(Ordering::Relaxed) as f64, + SOCKET_WRITE_SIZE_PROFILING_INTERVAL.load(Ordering::Relaxed), ) ); static FILE_READ_SIZE_PROFILING_STATS: RefCell = RefCell::new( IOProfilingStats::new( - FILE_READ_SIZE_PROFILING_INTERVAL.load(Ordering::Relaxed) as f64, + FILE_READ_SIZE_PROFILING_INTERVAL.load(Ordering::Relaxed), ) ); static FILE_WRITE_SIZE_PROFILING_STATS: RefCell = RefCell::new( IOProfilingStats::new( - FILE_WRITE_SIZE_PROFILING_INTERVAL.load(Ordering::Relaxed) as f64, + FILE_WRITE_SIZE_PROFILING_INTERVAL.load(Ordering::Relaxed), ) ); } From 6a056e88750bb991eedc7549991cc097ac332767 Mon Sep 17 00:00:00 2001 From: Florian Engelhardt Date: Fri, 4 Sep 2026 08:16:44 +0200 Subject: [PATCH 2/7] test(profiling): stabilize I/O correctness tests Increase I/O workloads and use 6% byte margins and 3-point socket share margins. Document the sampling-noise estimates in the test notes. --- profiling/tests/correctness/io_upscaling.json | 10 +++++----- profiling/tests/correctness/io_upscaling.php | 2 +- profiling/tests/correctness/socket_io.json | 18 +++++++++--------- profiling/tests/correctness/socket_io.php | 2 +- 4 files changed, 16 insertions(+), 16 deletions(-) diff --git a/profiling/tests/correctness/io_upscaling.json b/profiling/tests/correctness/io_upscaling.json index 866dbb7a14..dbedcc635f 100644 --- a/profiling/tests/correctness/io_upscaling.json +++ b/profiling/tests/correctness/io_upscaling.json @@ -1,12 +1,12 @@ { "scale_by_duration": false, "test_name": "php_io_upscaling", - "note": "PHP currently splits each 10 KiB fread into 8 KiB and 2 KiB reads; this test intentionally does not override that default. Two sampled 10 KiB writes upscale to 215211 bytes, while two sampled 8 KiB reads upscale to 213101 bytes.", + "note": "163840 10 KiB operations produce 1677721600 bytes (1600 MiB). With a 100 KiB sampling interval, about 16000 samples per metric give roughly 0.8% standard deviation in estimated bytes; the 6% margin exceeds seven standard deviations.", "stacks": [ { "profile-type": "file-io-write-size", - "error-margin": 5, - "value-matching-sum": 215211, + "error-margin": 6, + "value-matching-sum": 1677721600, "stack-content": [ { "regular_expression": " Date: Fri, 4 Sep 2026 15:46:00 +0200 Subject: [PATCH 3/7] fix(profiling): use exponential allocation sampling Match allocation sampling to the upscaler's inclusion probability. Add a seeded regression test and workload-derived correctness totals and margins. --- profiling/src/allocation/mod.rs | 45 ++++++++++++++++--- .../correctness/allocation_time_combined.json | 9 ++-- .../correctness/allocation_time_combined.php | 3 +- profiling/tests/correctness/allocations.json | 23 ++++++---- profiling/tests/correctness/allocations.php | 20 +++------ 5 files changed, 66 insertions(+), 34 deletions(-) diff --git a/profiling/src/allocation/mod.rs b/profiling/src/allocation/mod.rs index aaeba5352f..7b3b5baabc 100644 --- a/profiling/src/allocation/mod.rs +++ b/profiling/src/allocation/mod.rs @@ -11,7 +11,7 @@ use core::cell::Cell; use core::ptr; use libc::size_t; use log::{debug, trace}; -use rand_distr::{Distribution, Poisson}; +use rand::Rng; use std::ffi::c_void; use std::num::{NonZero, NonZeroU32, NonZeroU64}; use std::sync::atomic::{AtomicU32, AtomicU64, Ordering}; @@ -150,7 +150,7 @@ unsafe extern "C" fn _zend_mm_realloc( /// Default sampling interval in bytes (4 MiB). pub const DEFAULT_ALLOCATION_SAMPLING_INTERVAL: NonZeroU32 = NonZero::new(1024 * 4096).unwrap(); -/// Sampling distance feed into poison sampling algo. This must be > 0. +/// Mean distance between allocation samples in bytes. This must be > 0. pub static ALLOCATION_PROFILING_INTERVAL: AtomicU64 = AtomicU64::new(DEFAULT_ALLOCATION_SAMPLING_INTERVAL.get() as u64); @@ -169,7 +169,7 @@ pub static ALLOCATION_PROFILING_SIZE: AtomicU64 = AtomicU64::new(0); pub struct AllocationProfilingStats { /// Number of bytes remaining until the next sample collection. next_sample: i64, - poisson: Poisson, + mean: f64, #[cfg(php_zts)] rng: ThreadRng, #[cfg(not(php_zts))] @@ -178,11 +178,9 @@ pub struct AllocationProfilingStats { impl AllocationProfilingStats { fn new(sampling_distance: NonZeroU64) -> AllocationProfilingStats { - // SAFETY: this will only error if lambda <= 0, and it's NonZeroU64. - let poisson = unsafe { Poisson::new(sampling_distance.get() as f64).unwrap_unchecked() }; let mut stats = AllocationProfilingStats { next_sample: 0, - poisson, + mean: sampling_distance.get() as f64, #[cfg(php_zts)] rng: rand::rng(), #[cfg(not(php_zts))] @@ -193,7 +191,12 @@ impl AllocationProfilingStats { } fn next_sampling_interval(&mut self) { - self.next_sample = self.poisson.sample(&mut self.rng) as i64; + // Exponential distances give the upscaler's probability: 1 - exp(-size / mean). + let u: f64 = self.rng.random(); + let u = if u <= 0.0 { 1e-10 } else { u }; + let v = -u.ln() * self.mean; + // Clamp to [8, 20 * mean], matching the libdatadog sampler. + self.next_sample = v.clamp(8.0, 20.0 * self.mean) as i64; } fn should_collect_allocation(&mut self, len: size_t) -> bool { @@ -342,6 +345,34 @@ pub fn alloc_prof_rshutdown() { allocation_ge84::alloc_prof_rshutdown(heap_live_enabled); } +#[cfg(all(test, not(php_zts)))] +mod tests { + use super::*; + + #[test] + fn allocation_sampling_matches_upscaling_probability() { + let mean = DEFAULT_ALLOCATION_SAMPLING_INTERVAL.get() as f64; + let trials = 100_000; + for ratio in [0.1, 1.1, 3.0] { + let size = (ratio * mean) as usize; + let mut stats = + AllocationProfilingStats::new(DEFAULT_ALLOCATION_SAMPLING_INTERVAL.into()); + stats.rng = StdRng::seed_from_u64(42); + stats.next_sampling_interval(); + let sampled = (0..trials) + .filter(|_| stats.should_collect_allocation(size)) + .count(); + let probability = 1.0 - (-(size as f64) / mean).exp(); + let expected = trials as f64 * probability; + let sigma = (expected * (1.0 - probability)).sqrt(); + assert!( + (sampled as f64 - expected).abs() < 8.0 * sigma, + "size={size}: sampled {sampled}, expected {expected}" + ); + } + } +} + #[cfg(php_zend_mm_set_custom_handlers_ex)] #[track_caller] fn initialization_panic() -> ! { diff --git a/profiling/tests/correctness/allocation_time_combined.json b/profiling/tests/correctness/allocation_time_combined.json index d7dc726c75..10116bd244 100644 --- a/profiling/tests/correctness/allocation_time_combined.json +++ b/profiling/tests/correctness/allocation_time_combined.json @@ -1,6 +1,7 @@ { "scale_by_duration": true, "test_name": "php_allocation_time_combined", + "note": "Each iteration allocates three equal 10 MB strings: two in str_replace and one in str_repeat. With at least 128 iterations and p = 1 - exp(-10000000 / 4194304) = 0.9078, the binomial model gives an approximate share standard deviation of at most 0.77 percentage points. The 6-point margins allow over eight standard deviations, including the analyzer's integer truncation of the exact 2/3 and 1/3 shares.", "stacks": [ { "profile-type": "alloc-size", @@ -8,12 +9,12 @@ { "regular_expression": " 0.0) { - usleep((int) ($sleep * 1_000_000)); - } } } main(); From bee49e3b5f1e4e8c615c73c9a58be958a12ebd24 Mon Sep 17 00:00:00 2001 From: Florian Engelhardt Date: Fri, 4 Sep 2026 18:12:43 +0200 Subject: [PATCH 4/7] refactor(profiling): share exponential interval sampling --- profiling/src/allocation/mod.rs | 10 ++-------- profiling/src/io/mod.rs | 12 ++---------- profiling/src/lib.rs | 8 ++++++++ 3 files changed, 12 insertions(+), 18 deletions(-) diff --git a/profiling/src/allocation/mod.rs b/profiling/src/allocation/mod.rs index 7b3b5baabc..34c53a40ec 100644 --- a/profiling/src/allocation/mod.rs +++ b/profiling/src/allocation/mod.rs @@ -6,12 +6,11 @@ use crate::profiling::bindings::{self as zend}; use crate::profiling::config::SystemSettings; use crate::profiling::module_globals; use crate::profiling::profiler::Profiler; -use crate::profiling::{RefCellExt, REQUEST_LOCALS}; +use crate::profiling::{sample_exponential_interval, RefCellExt, REQUEST_LOCALS}; use core::cell::Cell; use core::ptr; use libc::size_t; use log::{debug, trace}; -use rand::Rng; use std::ffi::c_void; use std::num::{NonZero, NonZeroU32, NonZeroU64}; use std::sync::atomic::{AtomicU32, AtomicU64, Ordering}; @@ -191,12 +190,7 @@ impl AllocationProfilingStats { } fn next_sampling_interval(&mut self) { - // Exponential distances give the upscaler's probability: 1 - exp(-size / mean). - let u: f64 = self.rng.random(); - let u = if u <= 0.0 { 1e-10 } else { u }; - let v = -u.ln() * self.mean; - // Clamp to [8, 20 * mean], matching the libdatadog sampler. - self.next_sample = v.clamp(8.0, 20.0 * self.mean) as i64; + self.next_sample = sample_exponential_interval(&mut self.rng, self.mean) as i64; } fn should_collect_allocation(&mut self, len: size_t) -> bool { diff --git a/profiling/src/io/mod.rs b/profiling/src/io/mod.rs index 2551778fff..036912a8df 100644 --- a/profiling/src/io/mod.rs +++ b/profiling/src/io/mod.rs @@ -4,10 +4,9 @@ pub mod got_elf64; pub mod got_macho; use crate::profiling::profiler::Profiler; -use crate::profiling::{zend, RefCellExt, REQUEST_LOCALS}; +use crate::profiling::{sample_exponential_interval, zend, RefCellExt, REQUEST_LOCALS}; use libc::{c_int, c_void, fstat, stat, S_IFMT, S_IFSOCK}; use rand::rngs::ThreadRng; -use rand::Rng; use rustc_hash::FxHashMap; use std::cell::RefCell; use std::mem::MaybeUninit; @@ -620,14 +619,7 @@ impl IOProfilingStats { } fn next_sampling_interval(&mut self) { - // Draw inter-sample distance from an exponential distribution: -ln(U) * mean - let u: f64 = self.rng.random(); - let u = if u <= 0.0 { 1e-10 } else { u }; - let v = -u.ln() * self.mean; - // Clamp to [8, 20 * mean] matching libdatadog sampler - let vmax = 20.0 * self.mean; - let v = v.clamp(8.0, vmax); - self.next_sample = v as u64; + self.next_sample = sample_exponential_interval(&mut self.rng, self.mean) as u64; } fn should_collect(&mut self, value: u64) -> bool { diff --git a/profiling/src/lib.rs b/profiling/src/lib.rs index 241e1f1a37..8b0a54948a 100644 --- a/profiling/src/lib.rs +++ b/profiling/src/lib.rs @@ -42,6 +42,7 @@ use libdd_common::cstr; use log::{debug, error, info, trace, warn}; use profile_tags::{ProfileTagSegment, UnifiedServiceTagSegment}; use profiler::{LocalRootSpanResourceMessage, Profiler, VmInterrupt}; +use rand::Rng; use sapi::Sapi; use std::borrow::Cow; use std::cell::{BorrowError, BorrowMutError, RefCell}; @@ -56,6 +57,13 @@ use uuid::Uuid; /// interior null bytes and must be null terminated. static PROFILER_NAME: &CStr = c"datadog-profiling"; +/// Samples `-ln(U) * mean`, clamped to `[8, 20 * mean]` like libdatadog. +fn sample_exponential_interval(rng: &mut impl Rng, mean: f64) -> f64 { + let sample: f64 = rng.random(); + let sample = if sample <= 0.0 { 1e-10 } else { sample }; + (-sample.ln() * mean).clamp(8.0, 20.0 * mean) +} + // SAFETY: PROFILER_NAME is a valid utf8 string. static PROFILER_NAME_STR: &str = match PROFILER_NAME.to_str() { Ok(s) => s, From e66a0b3b7c4741f754a7a9c804640236a1a89b16 Mon Sep 17 00:00:00 2001 From: Florian Engelhardt Date: Fri, 4 Sep 2026 18:15:03 +0200 Subject: [PATCH 5/7] docs(profiling): clarify sampling expectation --- profiling/src/lib.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/profiling/src/lib.rs b/profiling/src/lib.rs index 8b0a54948a..f200f8abba 100644 --- a/profiling/src/lib.rs +++ b/profiling/src/lib.rs @@ -57,7 +57,8 @@ use uuid::Uuid; /// interior null bytes and must be null terminated. static PROFILER_NAME: &CStr = c"datadog-profiling"; -/// Samples `-ln(U) * mean`, clamped to `[8, 20 * mean]` like libdatadog. +/// Draws the exponential sampling distance assumed by libdatadog's upscaler, +/// clamped to `[8, 20 * mean]`. fn sample_exponential_interval(rng: &mut impl Rng, mean: f64) -> f64 { let sample: f64 = rng.random(); let sample = if sample <= 0.0 { 1e-10 } else { sample }; From 1da8a8555fedfe93c10b81c7de8178673de3936a Mon Sep 17 00:00:00 2001 From: Florian Engelhardt Date: Fri, 4 Sep 2026 20:32:01 +0200 Subject: [PATCH 6/7] fix(profiling): preserve sampling at small intervals --- profiling/src/allocation/mod.rs | 17 +++++++++++------ profiling/src/io/mod.rs | 18 ++++++++++++++++-- profiling/src/lib.rs | 4 ++-- 3 files changed, 29 insertions(+), 10 deletions(-) diff --git a/profiling/src/allocation/mod.rs b/profiling/src/allocation/mod.rs index 34c53a40ec..902cc2aacf 100644 --- a/profiling/src/allocation/mod.rs +++ b/profiling/src/allocation/mod.rs @@ -345,12 +345,17 @@ mod tests { #[test] fn allocation_sampling_matches_upscaling_probability() { - let mean = DEFAULT_ALLOCATION_SAMPLING_INTERVAL.get() as f64; + let default_mean = DEFAULT_ALLOCATION_SAMPLING_INTERVAL.get() as f64; let trials = 100_000; - for ratio in [0.1, 1.1, 3.0] { - let size = (ratio * mean) as usize; - let mut stats = - AllocationProfilingStats::new(DEFAULT_ALLOCATION_SAMPLING_INTERVAL.into()); + for (mean, size) in [ + (default_mean, (0.1 * default_mean) as usize), + (default_mean, (1.1 * default_mean) as usize), + (default_mean, (3.0 * default_mean) as usize), + (1.0, 1), + (1.0, 4), + (4.0, 4), + ] { + let mut stats = AllocationProfilingStats::new(NonZeroU64::new(mean as u64).unwrap()); stats.rng = StdRng::seed_from_u64(42); stats.next_sampling_interval(); let sampled = (0..trials) @@ -361,7 +366,7 @@ mod tests { let sigma = (expected * (1.0 - probability)).sqrt(); assert!( (sampled as f64 - expected).abs() < 8.0 * sigma, - "size={size}: sampled {sampled}, expected {expected}" + "mean={mean}, size={size}: sampled {sampled}, expected {expected}" ); } } diff --git a/profiling/src/io/mod.rs b/profiling/src/io/mod.rs index 036912a8df..9c370beb9b 100644 --- a/profiling/src/io/mod.rs +++ b/profiling/src/io/mod.rs @@ -632,8 +632,8 @@ impl IOProfilingStats { // (or risking a crash) we refrain from collection I/O. return false; } - if let Some(next_sample) = self.next_sample.checked_sub(value) { - self.next_sample = next_sample; + if self.next_sample > value { + self.next_sample -= value; return false; } self.next_sampling_interval(); @@ -807,6 +807,20 @@ mod tests { assert!(!slot_fits_range(usize::MAX, 0x1000, 0x1000)); } + #[test] + fn sampling_collects_at_interval_boundary() { + let vm_interrupt = std::sync::atomic::AtomicBool::new(false); + let previous = super::REQUEST_LOCALS.with_borrow_mut(|locals| { + std::mem::replace(&mut locals.vm_interrupt_addr, &vm_interrupt) + }); + let mut stats = super::IOProfilingStats::new(100); + stats.next_sample = 8; + assert!(!stats.should_collect(0)); + assert!(!stats.should_collect(4)); + assert!(stats.should_collect(4)); + super::REQUEST_LOCALS.with_borrow_mut(|locals| locals.vm_interrupt_addr = previous); + } + #[test] fn no_hooks_are_safe_to_unload() { let mut restores = Vec::new(); diff --git a/profiling/src/lib.rs b/profiling/src/lib.rs index f200f8abba..c082319566 100644 --- a/profiling/src/lib.rs +++ b/profiling/src/lib.rs @@ -58,11 +58,11 @@ use uuid::Uuid; static PROFILER_NAME: &CStr = c"datadog-profiling"; /// Draws the exponential sampling distance assumed by libdatadog's upscaler, -/// clamped to `[8, 20 * mean]`. +/// rounded up to whole units and clamped to `[1, 20 * mean]`. fn sample_exponential_interval(rng: &mut impl Rng, mean: f64) -> f64 { let sample: f64 = rng.random(); let sample = if sample <= 0.0 { 1e-10 } else { sample }; - (-sample.ln() * mean).clamp(8.0, 20.0 * mean) + (-sample.ln() * mean).ceil().clamp(1.0, 20.0 * mean) } // SAFETY: PROFILER_NAME is a valid utf8 string. From 3d11c53178b48e04a3552d27266b2601a37978ff Mon Sep 17 00:00:00 2001 From: Florian Engelhardt Date: Fri, 4 Sep 2026 20:52:36 +0200 Subject: [PATCH 7/7] test(profiling): check generator stacks using byte shares --- profiling/tests/correctness/generators.json | 15 --------------- 1 file changed, 15 deletions(-) diff --git a/profiling/tests/correctness/generators.json b/profiling/tests/correctness/generators.json index 8c66ecbae5..49df86723e 100644 --- a/profiling/tests/correctness/generators.json +++ b/profiling/tests/correctness/generators.json @@ -16,21 +16,6 @@ "error_margin": 5 } ] - }, - { - "profile-type": "alloc-samples", - "stack-content": [ - { - "regular_expression": "