flybrain/services/flysim/crates/fly-session/src/metrics.rs
acamilo 7b7ebcdf28 session: parallel processes, a launcher and the fault behaviour
SESSION-02: one agent process per fly and one environment process under
the coordinator over the Unix-socket transport, compared against the
in-process composition and a dedicated-thread variant. The mode is the
only thing that changes; the composition, the coordinator, the workers
and the router are the same code in all three.

The launcher is the configured supervisor. It owns a total thread budget
with one allocation per participant, refused as BUSY before anything
starts when the total cannot cover it; the configured client, service,
worker and port identities, proved in Worker.Hello before the
coordinator pins a registration; Worker.Status health on the
supervisor's own monotonic clock at the ipc-v1 section 6 budgets; and
reaping, where Worker.Shutdown is the request and the operating system
is the guarantee.

The worker executable is a subcommand of this crate's one binary, which
is what implementation.md section 2 allows in place of a separate
worker crate.

The coordinator's fault behaviour: every failure names the participant
it is attributed to, every domain call has a caller-side deadline so a
dead participant is a diagnosed outcome rather than a hang, and failing
fences the epoch -- the boundary stops, the handles drop, and no further
transition or publication is allowed. Agent.Initialize now carries the
launcher's allocation, and an agent refuses one asking for more.

tests/processes.rs proves every acceptance bullet once per execution
mode, and the two section 4 rows SESSION-01 could not reach in one
process: a router restart during a world advance, and an old worker's
reply after a restart. measure compares the three modes at one, two and
four agents; its table is in the crate README, and it is not a capacity
claim.
2026-09-22 14:33:25 +00:00

176 lines
5.3 KiB
Rust

//! Latency and resource samples, for the measurements the implementation guide's section 5
//! asks every slice to report.
//!
//! These are local synthetic timings on one machine. No host capacity claim follows from any
//! number this module produces, and nothing here is a gameplay latency goal: the percentiles
//! exist so that the three execution modes can be compared against each other.
use std::collections::BTreeMap;
/// One sample set's order statistics, by nearest rank.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct Percentiles {
pub count: usize,
pub p50_ns: u64,
pub p95_ns: u64,
pub p99_ns: u64,
pub max_ns: u64,
}
impl Percentiles {
fn of(sorted: &[u64]) -> Percentiles {
let rank = |p: f64| -> u64 {
if sorted.is_empty() {
return 0;
}
let n = sorted.len() as f64;
let index = (p * n).ceil() as usize;
sorted[index.clamp(1, sorted.len()) - 1]
};
Percentiles {
count: sorted.len(),
p50_ns: rank(0.50),
p95_ns: rank(0.95),
p99_ns: rank(0.99),
max_ns: sorted.last().copied().unwrap_or_default(),
}
}
pub fn p50_us(&self) -> f64 {
self.p50_ns as f64 / 1000.0
}
pub fn p95_us(&self) -> f64 {
self.p95_ns as f64 / 1000.0
}
pub fn p99_us(&self) -> f64 {
self.p99_ns as f64 / 1000.0
}
}
/// Named duration samples. One name is one measured path: a domain method, or the
/// coordinator's whole critical path for a transition.
#[derive(Clone, Debug, Default)]
pub struct Metrics {
samples: BTreeMap<String, Vec<u64>>,
}
impl Metrics {
pub fn record(&mut self, what: &str, elapsed: std::time::Duration) {
let ns = u64::try_from(elapsed.as_nanos()).unwrap_or(u64::MAX);
self.samples.entry(what.to_owned()).or_default().push(ns);
}
pub fn percentiles(&self, what: &str) -> Option<Percentiles> {
let mut values = self.samples.get(what)?.clone();
values.sort_unstable();
Some(Percentiles::of(&values))
}
pub fn names(&self) -> Vec<String> {
self.samples.keys().cloned().collect()
}
pub fn count(&self, what: &str) -> usize {
self.samples.get(what).map(Vec::len).unwrap_or_default()
}
pub fn clear(&mut self) {
self.samples.clear();
}
}
/// This process's peak resident set, in KiB, from its own status file.
pub fn peak_rss_kib() -> Option<u64> {
peak_rss_of("/proc/self/status")
}
/// One child process's peak resident set, in KiB. `None` once the child is gone.
pub fn peak_rss_kib_of(pid: u32) -> Option<u64> {
peak_rss_of(&format!("/proc/{pid}/status"))
}
fn peak_rss_of(path: &str) -> Option<u64> {
let text = std::fs::read_to_string(path).ok()?;
for line in text.lines() {
if let Some(rest) = line.strip_prefix("VmHWM:") {
return rest.split_whitespace().next()?.parse().ok();
}
}
None
}
/// How many physical cores this machine has, counted as distinct (package, core) pairs.
///
/// Falls back to the logical count, which is what a thread budget has to use when the
/// topology cannot be read.
pub fn physical_cores() -> usize {
if let Ok(text) = std::fs::read_to_string("/proc/cpuinfo") {
let mut pairs: std::collections::BTreeSet<(String, String)> =
std::collections::BTreeSet::new();
let (mut package, mut core) = (None, None);
for line in text.lines() {
if line.trim().is_empty() {
if let (Some(p), Some(c)) = (package.take(), core.take()) {
pairs.insert((p, c));
}
continue;
}
if let Some((key, value)) = line.split_once(':') {
match key.trim() {
"physical id" => package = Some(value.trim().to_owned()),
"core id" => core = Some(value.trim().to_owned()),
_ => {}
}
}
}
if let (Some(p), Some(c)) = (package, core) {
pairs.insert((p, c));
}
if !pairs.is_empty() {
return pairs.len();
}
}
logical_cores()
}
/// How many hardware threads this machine reports.
pub fn logical_cores() -> usize {
std::thread::available_parallelism().map(|n| n.get()).unwrap_or(1)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn percentiles_use_nearest_rank() {
let mut m = Metrics::default();
for ns in 1..=100u64 {
m.record("x", std::time::Duration::from_nanos(ns));
}
let p = m.percentiles("x").expect("recorded");
assert_eq!(p.count, 100);
assert_eq!(p.p50_ns, 50);
assert_eq!(p.p95_ns, 95);
assert_eq!(p.p99_ns, 99);
assert_eq!(p.max_ns, 100);
assert!(m.percentiles("y").is_none());
}
#[test]
fn a_single_sample_is_every_percentile() {
let mut m = Metrics::default();
m.record("x", std::time::Duration::from_nanos(7));
let p = m.percentiles("x").expect("recorded");
assert_eq!((p.p50_ns, p.p95_ns, p.p99_ns, p.max_ns), (7, 7, 7, 7));
}
#[test]
fn the_machine_reports_at_least_one_core() {
assert!(physical_cores() >= 1);
assert!(logical_cores() >= 1);
assert!(peak_rss_kib().unwrap_or(1) > 0);
}
}