refactor(stats): own retention memory estimates
This commit is contained in:
5 files changed
+341
-27
No files matched your search
@@ -31,8 +31,6 @@ use crate::shared::client::{
|
|||||||
|
|
||||||
use irtt_stats::{StatsCollector, StatsConfig};
|
use irtt_stats::{StatsCollector, StatsConfig};
|
||||||
|
|
||||||
const FINITE_STATS_BYTES_PER_PROBE: u64 = 500;
|
|
||||||
|
|
||||||
const MIB: u64 = 1024 * 1024;
|
const MIB: u64 = 1024 * 1024;
|
||||||
|
|
||||||
const GIB: u64 = 1024 * MIB;
|
const GIB: u64 = 1024 * MIB;
|
||||||
@@ -356,9 +354,12 @@ fn finite_stats_memory_warning(args: &ClientArgs, target_count: usize) -> Option
|
|||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
let target_count = u64::try_from(target_count).unwrap_or(u64::MAX);
|
let target_count = u64::try_from(target_count).unwrap_or(u64::MAX);
|
||||||
let estimated_bytes = expected_probe_count(args.duration, args.interval)
|
let total_probe_count =
|
||||||
.saturating_mul(FINITE_STATS_BYTES_PER_PROBE)
|
expected_probe_count(args.duration, args.interval).saturating_mul(target_count);
|
||||||
.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 {
|
if estimated_bytes < FINITE_STATS_MEMORY_WARNING_BYTES {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
@@ -385,11 +386,10 @@ mod tests {
|
|||||||
};
|
};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
#[test]
|
fn long_finite_args() -> ClientArgs {
|
||||||
fn finite_stats_memory_warning_accounts_for_target_count() {
|
|
||||||
use clap::Parser;
|
use clap::Parser;
|
||||||
|
|
||||||
let args = ClientArgs::try_parse_from([
|
ClientArgs::try_parse_from([
|
||||||
"irtt-cli",
|
"irtt-cli",
|
||||||
"--duration",
|
"--duration",
|
||||||
"4200s",
|
"4200s",
|
||||||
@@ -397,33 +397,46 @@ mod tests {
|
|||||||
"100ms",
|
"100ms",
|
||||||
"127.0.0.1:2112",
|
"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!(
|
assert!(
|
||||||
finite_stats_memory_warning(&args, 1).is_none(),
|
crossing > 1,
|
||||||
"a single target's estimate should stay below the warning threshold"
|
"this run should need more than one target to cross the threshold, \
|
||||||
|
otherwise the test proves nothing about target-count accounting"
|
||||||
);
|
);
|
||||||
assert!(
|
assert!(
|
||||||
finite_stats_memory_warning(&args, 7).is_some(),
|
finite_stats_memory_warning(&args, crossing - 1).is_none(),
|
||||||
"seven targets' aggregate estimate should cross the warning threshold"
|
"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]
|
#[test]
|
||||||
fn finite_stats_memory_warning_saturates_target_count_multiplication() {
|
fn finite_stats_memory_warning_saturates_target_count_multiplication() {
|
||||||
use clap::Parser;
|
assert!(finite_stats_memory_warning(&long_finite_args(), usize::MAX).is_some());
|
||||||
|
|
||||||
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());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ use crate::{
|
|||||||
PacketCounts, RttStats, SampleMode, ServerProcessingStats, Snapshot,
|
PacketCounts, RttStats, SampleMode, ServerProcessingStats, Snapshot,
|
||||||
};
|
};
|
||||||
|
|
||||||
const CONTINUOUS_SEQUENCE_LIMIT: usize = 4096;
|
pub(crate) const CONTINUOUS_SEQUENCE_LIMIT: usize = 4096;
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq)]
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
pub(crate) struct CoreStats {
|
pub(crate) struct CoreStats {
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ mod core;
|
|||||||
mod ipdv;
|
mod ipdv;
|
||||||
mod loss;
|
mod loss;
|
||||||
mod normalization;
|
mod normalization;
|
||||||
|
mod retention;
|
||||||
mod rolling;
|
mod rolling;
|
||||||
mod time_stats;
|
mod time_stats;
|
||||||
|
|
||||||
@@ -68,6 +69,55 @@ impl StatsConfig {
|
|||||||
late_replies: LateReplyMode::Measure,
|
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)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||||
|
|||||||
@@ -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::<i128>() 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::<u32>() + size_of::<IpdvSample>()) as u64 + HASH_ENTRY_OVERHEAD;
|
||||||
|
let order_entry = size_of::<u32>() as u64;
|
||||||
|
let completed_entry = size_of::<u32>() 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::<StatsEvent>() + size_of::<ReplySample>()) 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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in new issue
Block a user