From b93e6d0ab757a27eca6aaf34fd07bc170a4f32d1 Mon Sep 17 00:00:00 2001 From: Nils Andreas Svee Date: Sat, 15 Aug 2026 10:25:04 +0700 Subject: [PATCH] refactor(stats): own retention memory estimates --- crates/irtt-cli/src/cmd/client/run.rs | 65 ++++++++----- crates/irtt-stats/src/core.rs | 2 +- crates/irtt-stats/src/lib.rs | 50 ++++++++++ crates/irtt-stats/src/retention.rs | 117 ++++++++++++++++++++++ crates/irtt-stats/tests/retention.rs | 134 ++++++++++++++++++++++++++ 5 files changed, 341 insertions(+), 27 deletions(-) create mode 100644 crates/irtt-stats/src/retention.rs create mode 100644 crates/irtt-stats/tests/retention.rs diff --git a/crates/irtt-cli/src/cmd/client/run.rs b/crates/irtt-cli/src/cmd/client/run.rs index 08754cb..5afd8ab 100644 --- a/crates/irtt-cli/src/cmd/client/run.rs +++ b/crates/irtt-cli/src/cmd/client/run.rs @@ -31,8 +31,6 @@ use crate::shared::client::{ use irtt_stats::{StatsCollector, StatsConfig}; -const FINITE_STATS_BYTES_PER_PROBE: u64 = 500; - const MIB: u64 = 1024 * 1024; const GIB: u64 = 1024 * MIB; @@ -356,9 +354,12 @@ fn finite_stats_memory_warning(args: &ClientArgs, target_count: usize) -> Option return None; } let target_count = u64::try_from(target_count).unwrap_or(u64::MAX); - let estimated_bytes = expected_probe_count(args.duration, args.interval) - .saturating_mul(FINITE_STATS_BYTES_PER_PROBE) - .saturating_mul(target_count); + let total_probe_count = + expected_probe_count(args.duration, args.interval).saturating_mul(target_count); + // Ask the stats crate what the configuration this run will actually use + // retains; the CLI owns the probe count and the thresholds, not the + // retention model. + let estimated_bytes = stats_config(false).estimated_retained_bytes(total_probe_count); if estimated_bytes < FINITE_STATS_MEMORY_WARNING_BYTES { return None; } @@ -385,11 +386,10 @@ mod tests { }; use std::sync::Arc; - #[test] - fn finite_stats_memory_warning_accounts_for_target_count() { + fn long_finite_args() -> ClientArgs { use clap::Parser; - let args = ClientArgs::try_parse_from([ + ClientArgs::try_parse_from([ "irtt-cli", "--duration", "4200s", @@ -397,33 +397,46 @@ mod tests { "100ms", "127.0.0.1:2112", ]) - .unwrap(); + .unwrap() + } + + /// Smallest target count whose aggregate estimate reaches the warning + /// threshold, derived from the stats crate's own estimate rather than from + /// any bytes-per-probe assumption held here. + fn first_warning_target_count(args: &ClientArgs) -> usize { + let probes = expected_probe_count(args.duration, args.interval); + (1..=1024) + .find(|targets| { + let total = probes.saturating_mul(*targets as u64); + stats_config(false).estimated_retained_bytes(total) + >= FINITE_STATS_MEMORY_WARNING_BYTES + }) + .expect("a long finite run should cross the warning threshold within 1024 targets") + } + + #[test] + fn finite_stats_memory_warning_accounts_for_target_count() { + let args = long_finite_args(); + let crossing = first_warning_target_count(&args); assert!( - finite_stats_memory_warning(&args, 1).is_none(), - "a single target's estimate should stay below the warning threshold" + crossing > 1, + "this run should need more than one target to cross the threshold, \ + otherwise the test proves nothing about target-count accounting" ); assert!( - finite_stats_memory_warning(&args, 7).is_some(), - "seven targets' aggregate estimate should cross the warning threshold" + finite_stats_memory_warning(&args, crossing - 1).is_none(), + "an aggregate estimate below the threshold should not warn" + ); + assert!( + finite_stats_memory_warning(&args, crossing).is_some(), + "an aggregate estimate at the threshold should warn" ); } #[test] fn finite_stats_memory_warning_saturates_target_count_multiplication() { - use clap::Parser; - - let args = ClientArgs::try_parse_from([ - "irtt-cli", - "--duration", - "4200s", - "--interval", - "100ms", - "127.0.0.1:2112", - ]) - .unwrap(); - - assert!(finite_stats_memory_warning(&args, usize::MAX).is_some()); + assert!(finite_stats_memory_warning(&long_finite_args(), usize::MAX).is_some()); } #[test] diff --git a/crates/irtt-stats/src/core.rs b/crates/irtt-stats/src/core.rs index 6ca8b9d..814ac8a 100644 --- a/crates/irtt-stats/src/core.rs +++ b/crates/irtt-stats/src/core.rs @@ -9,7 +9,7 @@ use crate::{ PacketCounts, RttStats, SampleMode, ServerProcessingStats, Snapshot, }; -const CONTINUOUS_SEQUENCE_LIMIT: usize = 4096; +pub(crate) const CONTINUOUS_SEQUENCE_LIMIT: usize = 4096; #[derive(Debug, Clone, PartialEq)] pub(crate) struct CoreStats { diff --git a/crates/irtt-stats/src/lib.rs b/crates/irtt-stats/src/lib.rs index d76152b..047ddbc 100644 --- a/crates/irtt-stats/src/lib.rs +++ b/crates/irtt-stats/src/lib.rs @@ -15,6 +15,7 @@ mod core; mod ipdv; mod loss; mod normalization; +mod retention; mod rolling; mod time_stats; @@ -68,6 +69,55 @@ impl StatsConfig { late_replies: LateReplyMode::Measure, } } + + /// Returns an approximate estimate of the bytes a [`StatsCollector`] with + /// this configuration retains after processing `probe_count` probes. + /// + /// This is a planning aid — it exists so callers can warn about a run + /// before starting it — and deliberately not allocator accounting. It is + /// derived from the structures this crate retains and a fixed + /// capacity-headroom factor for the amortized-doubling containers those + /// structures use, so it errs toward overestimating. + /// + /// # What is included + /// + /// - Exact timing samples, for the metrics that retain them. + /// - Adjacent-sequence IPDV tracking state. + /// - Count-based rolling storage, when [`StatsConfig::rolling_count`] is + /// set, assuming a probe contributes one send event and one unique + /// reply event. + /// + /// # What is excluded + /// + /// - Time-based rolling storage. [`StatsConfig::rolling_time`] retains + /// whatever arrives inside its window, which cannot be bounded from a + /// probe count alone without assuming traffic timing, so it does not + /// contribute to this estimate. + /// - Everything outside this crate: sockets, client session state, + /// rendered output, and caller structures. + /// + /// # Sample mode + /// + /// [`SampleMode::Exact`] retains one value per metric per measurable + /// reply and keeps unbounded IPDV state, so the estimate grows with + /// `probe_count`. [`SampleMode::RunningOnly`] retains no exact samples + /// and bounds its IPDV state, so the estimate reaches a ceiling and stops + /// growing. + /// + /// # Optional measurements + /// + /// Adjusted RTT, one-way delay, and send/receive IPDV are only measured + /// when the negotiated session supplies the necessary timestamps. The + /// estimate assumes all of them are available, which is the upper bound. + /// + /// # Saturation + /// + /// The result is deterministic and computed with saturating arithmetic, so + /// an enormous `probe_count` saturates at [`u64::MAX`] rather than + /// wrapping or panicking. + pub fn estimated_retained_bytes(&self, probe_count: u64) -> u64 { + retention::estimated_retained_bytes(self, probe_count) + } } #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] diff --git a/crates/irtt-stats/src/retention.rs b/crates/irtt-stats/src/retention.rs new file mode 100644 index 0000000..34bfaf4 --- /dev/null +++ b/crates/irtt-stats/src/retention.rs @@ -0,0 +1,117 @@ +//! Approximate estimates of the storage a statistics configuration retains. +//! +//! The estimate is derived from the structures this crate actually keeps, so +//! retention changes and the planning estimate stay in one place. It models +//! live element storage plus a fixed capacity-headroom factor; it is not +//! allocator accounting. + +use std::mem::size_of; + +use crate::core::CONTINUOUS_SEQUENCE_LIMIT; +use crate::ipdv::IpdvSample; +use crate::normalization::{ReplySample, StatsEvent}; +use crate::{SampleMode, StatsConfig}; + +/// Capacity headroom applied to the live element bytes. +/// +/// `Vec`, `VecDeque`, `HashMap`, and `HashSet` all grow by doubling, so a +/// container can hold up to roughly twice the bytes its live elements need. +/// Assuming the worst case keeps the estimate conservative rather than +/// falsely precise. +const GROWTH_FACTOR: u64 = 2; + +/// Approximate per-entry control overhead for the hashed containers. +const HASH_ENTRY_OVERHEAD: u64 = 1; + +/// Exact-sample vectors that retain one value per measurable unique reply. +/// +/// In [`SampleMode::Exact`] these are the primary, raw, and adjusted RTT +/// metrics, the three IPDV metrics, and the two one-way delay metrics. +/// `send_call`, `timer_error`, and `server_processing` do not retain exact +/// samples in any mode. +/// +/// The adjusted-RTT, one-way, and send/receive IPDV metrics only receive a +/// sample when the negotiated session supplies the corresponding optional +/// measurement, so counting all eight is the upper bound. +const EXACT_SAMPLE_VECS_PER_REPLY: u64 = 8; + +/// Normalized events a probe usually contributes to a rolling window: one send +/// event and one unique reply event. +const ROLLING_EVENTS_PER_PROBE: u64 = 2; + +/// Returns the approximate bytes retained after `probe_count` probes. +pub(crate) fn estimated_retained_bytes(config: &StatsConfig, probe_count: u64) -> u64 { + let cumulative = match config.samples { + SampleMode::Exact => exact_bytes(probe_count), + SampleMode::RunningOnly => running_only_bytes(probe_count), + }; + + cumulative + .saturating_add(rolling_count_bytes(config, probe_count)) + .saturating_mul(GROWTH_FACTOR) +} + +/// Exact mode retains one sample per metric and one IPDV tracker entry for +/// every measurable reply, so its storage grows with the probe count. +fn exact_bytes(probe_count: u64) -> u64 { + let per_reply = EXACT_SAMPLE_VECS_PER_REPLY + .saturating_mul(size_of::() as u64) + .saturating_add(ipdv_tracker_bytes_per_sample()); + probe_count.saturating_mul(per_reply) +} + +/// Running-only mode retains no exact samples and bounds the IPDV tracker at a +/// fixed number of sequences, so its storage stops growing once that bound is +/// reached. +fn running_only_bytes(probe_count: u64) -> u64 { + probe_count + .min(CONTINUOUS_SEQUENCE_LIMIT as u64) + .saturating_mul(ipdv_tracker_bytes_per_sample()) +} + +/// The IPDV tracker keys a sample map by sequence and keeps a sequence order +/// queue and a completed-pair set alongside it. +fn ipdv_tracker_bytes_per_sample() -> u64 { + let sample_entry = (size_of::() + size_of::()) as u64 + HASH_ENTRY_OVERHEAD; + let order_entry = size_of::() as u64; + let completed_entry = size_of::() as u64 + HASH_ENTRY_OVERHEAD; + sample_entry + .saturating_add(order_entry) + .saturating_add(completed_entry) +} + +/// Count-based rolling windows retain whole normalized events, bounded by the +/// configured event count. +fn rolling_count_bytes(config: &StatsConfig, probe_count: u64) -> u64 { + let Some(limit) = config.rolling_count else { + return 0; + }; + let retained = probe_count + .saturating_mul(ROLLING_EVENTS_PER_PROBE) + .min(limit as u64); + retained.saturating_mul(rolling_bytes_per_event()) +} + +/// A retained event is the enum itself plus, for a unique reply, the boxed +/// reply sample it owns. +fn rolling_bytes_per_event() -> u64 { + (size_of::() + size_of::()) as u64 +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn per_sample_components_are_non_zero() { + assert!(ipdv_tracker_bytes_per_sample() > 0); + assert!(rolling_bytes_per_event() > 0); + } + + #[test] + fn running_only_stops_growing_at_the_bounded_sequence_limit() { + let limit = CONTINUOUS_SEQUENCE_LIMIT as u64; + assert_eq!(running_only_bytes(limit), running_only_bytes(limit * 100)); + assert!(running_only_bytes(limit - 1) < running_only_bytes(limit)); + } +} diff --git a/crates/irtt-stats/tests/retention.rs b/crates/irtt-stats/tests/retention.rs new file mode 100644 index 0000000..8231665 --- /dev/null +++ b/crates/irtt-stats/tests/retention.rs @@ -0,0 +1,134 @@ +//! Public behavior of the retained-memory estimate. + +use std::time::Duration; + +use irtt_stats::{SampleMode, StatsConfig}; + +#[test] +fn zero_probes_retain_nothing() { + assert_eq!(StatsConfig::finite().estimated_retained_bytes(0), 0); + assert_eq!(StatsConfig::continuous().estimated_retained_bytes(0), 0); +} + +#[test] +fn exact_mode_grows_with_probe_count() { + let config = StatsConfig::finite(); + assert_eq!(config.samples, SampleMode::Exact); + + let mut previous = config.estimated_retained_bytes(0); + for probes in [1_u64, 1_000, 100_000, 10_000_000] { + let current = config.estimated_retained_bytes(probes); + assert!( + current > previous, + "estimate should grow with probe count, but {probes} probes gave {current} \ + after {previous}" + ); + previous = current; + } +} + +#[test] +fn exact_mode_scales_linearly_with_probe_count() { + let config = StatsConfig::finite(); + let single = config.estimated_retained_bytes(1); + + assert_eq!(config.estimated_retained_bytes(1_000), single * 1_000); +} + +#[test] +fn enormous_probe_counts_saturate_instead_of_wrapping() { + for config in [StatsConfig::finite(), StatsConfig::continuous()] { + // Reaching this at all proves no panic; a wrapping multiplication + // would land near zero rather than near the maximum. + assert!( + config.estimated_retained_bytes(u64::MAX) > 0, + "a saturating estimate should not wrap to zero" + ); + } + + assert_eq!( + StatsConfig::finite().estimated_retained_bytes(u64::MAX), + u64::MAX, + "an unbounded exact-mode estimate should saturate at the maximum" + ); +} + +#[test] +fn running_only_does_not_report_unbounded_per_probe_retention() { + let continuous = StatsConfig::continuous(); + assert_eq!(continuous.samples, SampleMode::RunningOnly); + + let huge = continuous.estimated_retained_bytes(u64::MAX); + let modest = continuous.estimated_retained_bytes(1_000_000); + + assert_eq!( + huge, modest, + "running-only retention is bounded, so a far larger run should not raise the estimate" + ); + assert!( + huge < StatsConfig::finite().estimated_retained_bytes(1_000_000), + "running-only should estimate far less than exact mode for the same run" + ); +} + +#[test] +fn finite_config_estimate_is_deterministic_and_usable_for_planning() { + let config = StatsConfig::finite(); + let probes = 1_000_000; + let first = config.estimated_retained_bytes(probes); + + assert_eq!(first, config.estimated_retained_bytes(probes)); + + // A million probes should land in a range a caller can reason about: well + // over a megabyte, and well under a terabyte. The exact figure is free to + // move with the retention model. + assert!( + first > 1024 * 1024, + "a million probes should estimate more than a MiB, got {first}" + ); + assert!( + first < 1024 * 1024 * 1024 * 1024, + "a million probes should estimate less than a TiB, got {first}" + ); +} + +#[test] +fn rolling_count_adds_bounded_retention() { + let probes = 1_000_000; + let plain = StatsConfig::finite(); + let rolling = StatsConfig { + rolling_count: Some(10_000), + ..plain + }; + + let with_rolling = rolling.estimated_retained_bytes(probes); + assert!( + with_rolling > plain.estimated_retained_bytes(probes), + "a count-based rolling window retains events the plain config does not" + ); + + // The window is capped, so a larger run adds nothing further to its share. + let rolling_share = with_rolling - plain.estimated_retained_bytes(probes); + let larger = 10 * probes; + let larger_share = + rolling.estimated_retained_bytes(larger) - plain.estimated_retained_bytes(larger); + assert_eq!( + rolling_share, larger_share, + "rolling retention should stop growing once the configured count is reached" + ); +} + +#[test] +fn time_based_rolling_is_documented_as_excluded() { + let plain = StatsConfig::finite(); + let timed = StatsConfig { + rolling_time: Some(Duration::from_secs(10)), + ..plain + }; + + assert_eq!( + timed.estimated_retained_bytes(1_000_000), + plain.estimated_retained_bytes(1_000_000), + "time-based rolling cannot be bounded from a probe count and is excluded" + ); +}