use clap::Parser;
use pnio::api::{ApiError, IoDevice, StartOptions};
use pnio::config::{DeviceConfig, Slot};
use pnio::data::FieldType::*;
use pnio::device::RtOptions;
use pnio::im::Im0;
use pnio::rt::Histogram;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, OnceLock};
use std::time::Duration;
#[derive(Parser)]
struct Args {
#[arg(long)]
iface: String,
#[arg(long, default_value = "pnio-dev")]
station: String,
#[arg(long)]
ip: std::net::Ipv4Addr,
#[arg(long)]
rt_priority: Option<u8>,
#[arg(long)]
cpu: Option<usize>,
#[arg(long)]
app_cpus: Option<String>,
#[arg(long)]
lock_memory: bool,
#[arg(long, default_value_t = 500)]
period_us: u64,
#[arg(long, default_value_t = 0)]
duration: u64,
#[arg(long, default_value_t = 10)]
stats_every: u64,
#[arg(long, default_value_t = 50)]
stall_ms: u64,
}
fn sample_config(station: &str) -> DeviceConfig {
DeviceConfig::builder(station)
.station_type("pnio latency probe")
.im0(Im0 {
order_id: "PNIO-SAMPLE".into(),
..Im0::default()
})
.input(Slot(1), &[Real; 16])
.input(Slot(2), &[Bool; 32])
.output(Slot(3), &[Real; 16])
.output(Slot(4), &[Bool; 32])
.build()
.expect("sample config is valid")
}
static STOP: OnceLock<Arc<AtomicBool>> = OnceLock::new();
extern "C" fn on_signal(_: libc::c_int) {
if let Some(stop) = STOP.get() {
stop.store(true, Ordering::Relaxed);
}
}
fn install_signal_handlers(stop: Arc<AtomicBool>) {
let _ = STOP.set(stop);
unsafe {
libc::signal(libc::SIGINT, on_signal as *const () as libc::sighandler_t);
libc::signal(libc::SIGTERM, on_signal as *const () as libc::sighandler_t);
}
}
fn age(now: u32, echo: u32) -> u32 {
now.wrapping_sub(echo)
}
pub fn main() {
env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")).init();
let a = Args::parse();
let app_cpus = match a.app_cpus.as_deref().map(parse_cpu_list) {
Some(Ok(cpus)) => Some(cpus),
Some(Err(e)) => {
eprintln!("--app-cpus: {e}");
std::process::exit(2);
}
None => None,
};
let dev = IoDevice::start(
sample_config(&a.station),
StartOptions {
iface: a.iface.clone(),
ip: a.ip.octets(),
rt: Some(RtOptions {
iface: a.iface.clone(),
cpu_pin: a.cpu,
rt_priority: a.rt_priority,
lock_memory: a.lock_memory,
}),
app_cpus,
im_store: None,
},
)
.expect("start (need cap_net_raw/cap_net_admin/cap_sys_nice/cap_ipc_lock)");
let stop = Arc::new(AtomicBool::new(false));
install_signal_handlers(stop.clone());
log::info!(
"latency probe up on {}, waiting for the controller",
a.iface
);
let period = Duration::from_micros(a.period_us);
let cycles_per_ms = (1000.0 / a.period_us as f64).max(0.001);
let stall_cycles = (a.stall_ms * 1000 / a.period_us).max(1) as u32;
let age_hist = Histogram::new();
let ob_gap_hist = Histogram::new();
let mut n: u32 = 0; let mut last_echo: u32 = 0;
let mut echo_frozen: u32 = 0; let mut stalls: u64 = 0;
let mut backwards: u64 = 0;
let mut ob_last: u32 = 0;
let mut ob_seen = false;
let mut ob_gap: u32 = 0; let mut ob_jumps: u64 = 0; let mut samples: u64 = 0;
let started = std::time::Instant::now();
let mut last_log = started;
while !stop.load(Ordering::Relaxed) {
n = n.wrapping_add(1);
match dev.with_inputs(Slot(1), |w| w.real(0, f32::from_bits(n))) {
Ok(()) | Err(ApiError::NoLayoutYet) => {}
Err(e) => log::warn!("write: {e}"),
}
match dev.outputs(Slot(3)) {
Ok(snap) => {
let echo = snap.real(0).map(f32::to_bits).unwrap_or(0);
let ob = snap.real(1).map(f32::to_bits).unwrap_or(0);
if echo != 0 {
samples += 1;
age_hist.record(u64::from(age(n, echo)) * 1000);
if age(n, echo) > u32::MAX / 2 {
backwards += 1; }
if echo == last_echo {
echo_frozen += 1;
if echo_frozen == stall_cycles {
stalls += 1;
}
} else {
echo_frozen = 0;
}
last_echo = echo;
}
if ob != 0 {
if ob_seen {
ob_gap += 1;
let delta = ob.wrapping_sub(ob_last);
if delta != 0 {
ob_gap_hist.record(u64::from(ob_gap + 1) * 1000);
if delta >= 2 {
ob_jumps += 1;
}
ob_gap = 0;
}
} else {
ob_seen = true;
}
ob_last = ob;
}
}
Err(ApiError::NoLayoutYet) => {}
Err(e) => log::warn!("read: {e}"),
}
if last_log.elapsed() >= Duration::from_secs(a.stats_every) {
log::info!(
"n={n} samples={samples} age p50={:?}c max={:.0}c ob_gap p50={:?}c stalls={stalls} jumps={ob_jumps} freshness={:?}",
age_hist.percentile(50.0),
age_hist.max_ns() as f64 / 1000.0,
ob_gap_hist.percentile(50.0),
dev.freshness()
);
last_log = std::time::Instant::now();
}
if a.duration > 0 && started.elapsed() >= Duration::from_secs(a.duration) {
break;
}
std::thread::sleep(period);
}
let final_stats = dev.stats();
let r = dev.stop();
let ms = |cycles: f64| cycles / cycles_per_ms;
let line = |name: &str, h: &Histogram| {
let p = |q: f64| h.percentile(q).map(|c| ms(c as f64)).unwrap_or(0.0);
eprintln!(
"{name:<10} n={:<9} p50={:>6.2}ms p99={:>6.2}ms p99.99={:>6.2}ms max={:>7.2}ms",
h.count(),
p(50.0),
p(99.0),
p(99.99),
ms(h.max_ns() as f64 / 1000.0),
);
};
eprintln!(
"--- latency_probe summary ({} s, period {} us) ---",
started.elapsed().as_secs(),
a.period_us
);
line("echo_age", &age_hist);
line("ob_period", &ob_gap_hist);
eprintln!(
"samples={samples} stalls(>{} ms)={stalls} backwards={backwards} ob_jumps={ob_jumps}",
a.stall_ms
);
eprintln!("device stats: {final_stats:?}");
if let Err(e) = r {
log::error!("device loop ended: {e}");
std::process::exit(1);
}
std::process::exit(if stalls == 0 && backwards == 0 { 0 } else { 1 });
}
fn parse_cpu_list(s: &str) -> Result<Vec<usize>, String> {
let mut cpus = Vec::new();
for part in s.split(',').map(str::trim).filter(|p| !p.is_empty()) {
match part.split_once('-') {
Some((a, b)) => {
let a: usize = a.parse().map_err(|_| format!("bad cpu '{a}'"))?;
let b: usize = b.parse().map_err(|_| format!("bad cpu '{b}'"))?;
if a > b {
return Err(format!("bad range '{part}'"));
}
cpus.extend(a..=b);
}
None => cpus.push(part.parse().map_err(|_| format!("bad cpu '{part}'"))?),
}
}
if cpus.is_empty() {
return Err("empty cpu list".into());
}
Ok(cpus)
}
#[cfg(test)]
mod tests {
use super::age;
#[test]
fn age_is_wrap_safe() {
assert_eq!(age(10, 7), 3);
assert_eq!(age(2, u32::MAX - 1), 4); assert_eq!(age(5, 5), 0);
}
}