use std::fmt::Write as _;
use ergon_tune::{
CheckId, Component, Ethtool, EthtoolCache, Finding, Host, HostCheckError, HostCheckPolicy,
HotSet, Needs, Netlink, Nic, Role, SILENCE, Severity, Status, Thread, carrier_changes,
host_check_line, k8s_checks, n1_adaptive_rx, n2_deferred_irqs, n3_azure_vf, t1_isolation,
t2_nic_irqs, t3_thp, t7_sched_policy, t8_smt_siblings, t9_cpu_quota, t10_smt_on,
thread_affinity,
};
use crate::backend::{Backend, Config};
use crate::endpoint::ConnId;
use crate::placement::{self, CpuError, Placement, Why};
use crate::route::{Flow, Route};
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum LoopCpu {
Configured(u32),
Pinned(u32),
Unpinned(String),
Unknown(String),
}
impl LoopCpu {
#[must_use]
pub const fn cpu(&self) -> Option<u32> {
match self {
Self::Configured(c) | Self::Pinned(c) => Some(*c),
Self::Unpinned(_) | Self::Unknown(_) => None,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct HostReport {
pub loop_cpu: LoopCpu,
pub hot: HotSet,
pub findings: Vec<Finding>,
pub helpers: Result<Placement, CpuError>,
}
impl HostReport {
#[must_use]
pub fn lines(&self) -> Vec<String> {
self.lines_under(HostCheckPolicy::On)
}
#[must_use]
pub fn lines_under(&self, policy: HostCheckPolicy) -> Vec<String> {
let mut out = Vec::new();
match &self.loop_cpu {
LoopCpu::Unpinned(cpus) => out.push(format!(
"ergon-io info: the thread creating the driver is not pinned to one CPU (it may \
run on CPUs {cpus}) and loop_cpu is not set, so the polling loop's CPU is not \
known: T1 (isolation), T8 (SMT siblings) and T2 (NIC interrupts) are skipped, \
and the helper threads may include the CPU the loop ends up on. Pin the polling \
thread to an isolated CPU before Driver::new, or set loop_cpu."
)),
LoopCpu::Unknown(why) => out.push(format!(
"ergon-io info: cannot read the affinity of the thread creating the driver \
({why}) and loop_cpu is not set, so the polling loop's CPU is not known: T1 \
(isolation), T8 (SMT siblings) and T2 (NIC interrupts) are skipped, and the \
helper threads may include the CPU the loop ends up on. Set loop_cpu."
)),
LoopCpu::Configured(_) | LoopCpu::Pinned(_) => {}
}
out.extend(policy.lines("ergon-io", &self.findings));
if let Some(cpu) = self.only_cpu()
&& policy != HostCheckPolicy::Off
{
out.push(format!(
"ergon-io warning: this process may use the polling CPU only (CPU {cpu}: a pod \
with one CPU), so ergon-io's helper threads (connect_background's dial helper, the \
warning thread, uring-sqpoll's SQPOLL thread) have no other core. \
connect_background refuses (or, with inline_connect_fallback, connects as \
Driver::connect does) and uring-sqpoll refuses: Driver::connect does the \
handshake on this core, about 0.7 ms of CPU per TLS handshake. The warning \
thread shares it, parked until there is work. For production give the pod 2 \
CPUs: Guaranteed QoS, the kubelet's static CPU manager policy, whole-number CPU \
requests (on x86 with full-pcpus-only, one core's two hyperthreads). {SILENCE}"
));
}
out
}
#[must_use]
pub fn refusal(&self, policy: HostCheckPolicy) -> Option<HostCheckError> {
let mut r = policy.refusal(&self.findings);
if policy == HostCheckPolicy::Require && self.only_cpu().is_some() {
if let Some(HostCheckError::Warnings { count, ids }) = &mut r {
*count += 1;
ids.push_str(", helpers");
} else {
r = Some(HostCheckError::Warnings {
count: 1,
ids: "helpers".to_owned(),
});
}
}
r
}
fn only_cpu(&self) -> Option<u32> {
if let Ok(p) = &self.helpers
&& let (Why::NoSpareCpu, Some(cpu)) = (p.why, p.polling)
&& p.allowed.iter().eq([cpu])
{
Some(cpu)
} else {
None
}
}
#[must_use]
pub fn helper_line(&self) -> Option<String> {
let p = self.helpers.as_ref().ok().filter(|p| !p.cpus.is_empty())?;
let cpus =
|s: &ergon_tune::CpuSet| format!("CPU{} {s}", if s.len() == 1 { "" } else { "s" });
let sqpoll = self
.hot
.iter()
.find(|h| h.role == Role::SqPoll)
.map_or_else(String::new, |h| {
if p.cpus.contains(h.cpu) {
" Uring-sqpoll's SQPOLL thread goes there too.".to_owned()
} else {
format!(
" Uring-sqpoll's SQPOLL thread goes on CPU {} of the process's own, off \
those (never a spinner on shared CPUs).",
h.cpu
)
}
});
Some(format!(
"ergon-io info: connect_background's dial helper and the warning thread go on {}: \
{}.{} This process may use {} ({}){}.",
cpus(&p.cpus),
p.why,
sqpoll,
cpus(&p.allowed),
p.source,
p.polling.map_or_else(
|| {
"; the polling CPU is unknown (no loop_cpu, and the creating thread is not \
pinned to one CPU): these may include it. Set loop_cpu or pin the loop \
thread before Driver::new"
.to_owned()
},
|c| format!("; the polling CPU is {c}")
)
))
}
}
#[must_use]
pub fn host_report(host: &Host, cfg: &Config) -> HostReport {
let loop_cpu = cfg.loop_cpu.map_or_else(
|| match thread_affinity(host, Thread::Current) {
Ok(set) => match (set.len(), set.iter().next()) {
(1, Some(c)) => LoopCpu::Pinned(c),
_ => LoopCpu::Unpinned(set.to_string()),
},
Err(e) => LoopCpu::Unknown(e.to_string()),
},
LoopCpu::Configured,
);
let env = host
.is_live()
.then(|| std::env::var(placement::HELPER_CPUS_ENV).ok())
.flatten();
let helpers = placement::of(host, &cfg.helper_cpus, env.as_deref(), loop_cpu.cpu());
let mut cpus = HotSet::new();
let mut findings = Vec::new();
if let Some(cpu) = loop_cpu.cpu() {
cpus.push(cpu, Role::Loop);
let sqpoll = cfg.sqpoll_cpu.or_else(|| {
helpers
.as_ref()
.ok()
.and_then(|p| placement::sqpoll_cpu(p, None).ok())
});
if let (Backend::UringSqpoll, Some(sq)) = (cfg.backend, sqpoll) {
cpus.push(sq, Role::SqPoll);
}
findings.extend(t1_isolation(host, &cpus));
findings.extend(t8_smt_siblings(host, &cpus));
}
findings.push(t7_sched_policy(host, Thread::Current, Role::Loop));
findings.push(t9_cpu_quota(host));
findings.extend(t3_thp(host, Component::Io));
findings.push(t10_smt_on(host));
let mut needs = Needs::new(Thread::Current, Role::Loop);
if cfg.backend.polls_nic() || cfg.prefer_busy_poll || cfg.busy_poll_us > 0 {
needs = needs.busy_polls();
}
findings.extend(k8s_checks(host, &cpus, &needs));
HostReport {
loop_cpu,
hot: cpus,
findings,
helpers,
}
}
#[must_use]
pub fn line(f: &Finding) -> Option<String> {
host_check_line("ergon-io", f)
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DevContext {
pub hot: HotSet,
pub interrupt_driven: bool,
}
impl DevContext {
#[must_use]
pub const fn new(cfg: &Config, hot: HotSet) -> Self {
Self {
hot,
interrupt_driven: !cfg.backend.polls_nic(),
}
}
}
pub const SETTLE_READS: u8 = 2;
#[derive(Debug)]
struct Watch {
dev: String,
ctx: DevContext,
carrier: Option<u64>,
moved: Option<(u64, u8)>,
}
#[derive(Debug)]
pub struct DevChecks<E: Ethtool = Netlink> {
cache: EthtoolCache<E>,
watches: Vec<Watch>,
}
impl DevChecks<Netlink> {
#[must_use]
pub const fn new() -> Self {
Self::with_cache(EthtoolCache::new())
}
}
impl Default for DevChecks<Netlink> {
fn default() -> Self {
Self::new()
}
}
impl<E: Ethtool> DevChecks<E> {
#[must_use]
pub const fn with_cache(cache: EthtoolCache<E>) -> Self {
Self {
cache,
watches: Vec::new(),
}
}
pub fn check(&mut self, host: &Host, dev: &str, ctx: &DevContext) -> Vec<Finding> {
if self.watches.iter().any(|w| w.dev == dev && w.ctx == *ctx) {
return Vec::new();
}
if let Err(why) = visible(host, dev) {
return vec![invisible(dev, why)];
}
self.watches.push(Watch {
dev: dev.to_owned(),
ctx: ctx.clone(),
carrier: carrier_changes(host, dev).ok(),
moved: None,
});
self.run(host, dev, ctx)
}
pub fn recheck(&mut self, host: &Host) -> Vec<Finding> {
let mut due = Vec::new();
for w in &mut self.watches {
let Ok(now) = carrier_changes(host, &w.dev) else {
continue;
};
if w.carrier == Some(now) {
w.moved = None;
continue;
}
match w.moved {
Some((seen, held)) if seen == now => {
if held + 1 >= SETTLE_READS {
w.carrier = Some(now);
w.moved = None;
due.push((w.dev.clone(), w.ctx.clone()));
} else {
w.moved = Some((seen, held + 1));
}
}
_ => w.moved = Some((now, 0)),
}
}
due.iter()
.flat_map(|(dev, ctx)| self.run(host, dev, ctx))
.collect()
}
#[must_use]
pub const fn watching(&self) -> bool {
!self.watches.is_empty()
}
fn run(&mut self, host: &Host, dev: &str, ctx: &DevContext) -> Vec<Finding> {
if let Err(why) = visible(host, dev) {
return vec![invisible(dev, why)];
}
let mut out = Vec::new();
if !ctx.hot.spinning().is_empty() {
out.extend(t2_nic_irqs(host, &Nic::Interface(dev.to_owned()), &ctx.hot));
}
out.push(n1_adaptive_rx(host, dev, &mut self.cache));
if ctx.interrupt_driven {
out.extend(n2_deferred_irqs(host, dev, Some(false)));
}
out.push(n3_azure_vf(host, dev));
out
}
}
fn invisible(dev: &str, why: String) -> Finding {
Finding {
id: CheckId::T2,
severity: Severity::Warn,
status: Status::Unknown(why),
message: format!(
"cannot check {dev}'s interrupts, interrupt moderation, deferral or \
accelerated-networking VF (T2, N1, N2, N3)"
),
evidence: "",
}
}
fn visible(host: &Host, dev: &str) -> Result<(), String> {
if host.is_live() && !cfg!(target_os = "linux") {
return Err("not Linux".to_owned());
}
if dev.is_empty() || dev.contains('/') || dev == "." || dev == ".." {
return Err(format!("{dev:?} is not an interface name"));
}
let base = host.path(&format!("/sys/class/net/{dev}"));
let lower = || {
std::fs::read_dir(&base).is_ok_and(|d| {
d.filter_map(Result::ok)
.any(|e| e.file_name().to_string_lossy().starts_with("lower_"))
})
};
if base.join("device").exists() || lower() {
Ok(())
} else {
Err(format!(
"{dev} has no device here (no /sys/class/net/{dev}/device and no lower_* \
interface): a pod's veth, a bridge or a tunnel, whose NIC is in the host's network \
namespace"
))
}
}
#[must_use]
pub fn rcvbuf_line(requested: u32, granted: u32) -> Option<String> {
if u64::from(granted) >= 2 * u64::from(requested) {
return None;
}
Some(format!(
"ergon-io warning: SO_RCVBUF asked {requested} bytes, granted {}: the kernel caps it at \
net.core.rmem_max (socket(7)), and setting SO_RCVBUF also turns the kernel's \
receive-buffer autotuning off (tcp(7)), so connections get the smaller, fixed buffer. \
Fix: `sysctl -w net.core.rmem_max={requested}` (in the host's network namespace), or \
rcvbuf=0 to leave the buffer to autotuning.",
granted / 2
))
}
#[must_use]
pub fn cork_line(conn: ConnId) -> String {
format!(
"ergon-io warning: connection {} has TCP_CORK set: partial frames wait up to 200 ms \
before they are sent (tcp(7)). Clear it (setsockopt TCP_CORK 0) before Driver::add.",
conn.index()
)
}
#[must_use]
pub fn napi_line(
backend: Backend,
conn: ConnId,
flow: Option<&Flow>,
route: &Route,
napi_id: Option<u32>,
) -> Option<String> {
if !backend.polls_nic() || route.is_local() || napi_id != Some(0) {
return None;
}
let mut who = format!("connection {}", conn.index());
if let Some(f) = flow {
let _ = write!(who, " (TCP {} -> {})", f.local, f.peer);
}
let dev = route
.dev
.as_deref()
.map_or_else(String::new, |d| format!(" on {d}"));
Some(format!(
"ergon-io warning: {who} is on {backend}, which polls the NIC, but its socket had no \
NAPI id after the handshake: busy polling reaches no NIC queue{dev} (a device without \
NAPI, such as a pod's veth), so the loop waits for interrupts. Fix: give the loop a \
NIC with NAPI (the host's network namespace, or a CNI that passes the NIC through), \
or use an interrupt-driven backend (epoll-spin, uring-spin), which is what this one \
amounts to here."
))
}