Files
helexa/crates/neuron/src/health.rs
rob thijssen a60c9f1075
All checks were successful
CI / Format (push) Successful in 30s
CI / CUDA type-check (push) Successful in 1m30s
CI / Clippy (push) Successful in 2m18s
CI / Test (push) Successful in 4m17s
CI / Build cortex SRPM (push) Has been skipped
CI / Publish cortex to COPR (push) Has been skipped
CI / Build neuron SRPM (push) Has been skipped
CI / Publish neuron to COPR (push) Has been skipped
CI / Bump version in source (push) Has been skipped
build-prerelease / Resolve version stamps + change detection (push) Successful in 33s
build-prerelease / Build neuron-blackwell (push) Successful in 1m42s
build-prerelease / Build neuron-ampere (push) Successful in 2m18s
build-prerelease / Build neuron-ada (push) Successful in 2m19s
build-prerelease / Build helexa-bench binary (push) Successful in 2m18s
build-prerelease / Lint (fmt + clippy) (push) Successful in 2m27s
build-prerelease / Build cortex binary (push) Successful in 2m45s
build-prerelease / Package helexa-neuron-ada RPM (push) Successful in 2m2s
build-prerelease / Test (push) Successful in 4m50s
build-prerelease / Package helexa-bench RPM (push) Successful in 1m18s
build-prerelease / Package cortex RPM (push) Successful in 1m22s
build-prerelease / Package helexa-neuron-ampere RPM (push) Successful in 1m37s
build-prerelease / Package helexa-neuron-blackwell RPM (push) Successful in 1m43s
build-prerelease / Publish to rpm.lair.cafe (unstable) (push) Successful in 56s
feat(#47 #53 phase 2b): expose per-model admission load in GET /health
Completes #53: the bounded scheduler's lock-free counters are now visible
to the fleet, which is what cortex's load-aware router (#55) consumes to
spread traffic across replicas and propagate honest backpressure.

- cortex-core::discovery: HealthResponse gains `models: Vec<ModelLoad>`
  (#[serde(default)] — back-compatible; older gateways/neurons interop).
  ModelLoad { id, in_flight, queue_depth }.
- LoadedHandle::load() → (in_flight, queue_depth), lock-free for both
  single-GPU and TP; CandleHarness::load_snapshot() enumerates resident
  models; the /health handler overlays it from the candle harness.

Tests: /health always exposes a models array (api integration test); a
pre-#53 payload without `models` still deserializes, and ModelLoad
round-trips (cortex-core serde tests). Local fmt/clippy/test green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 20:13:07 +03:00

80 lines
2.6 KiB
Rust

//! Cached GPU health monitoring via periodic nvidia-smi polling.
use cortex_core::discovery::HealthResponse;
use std::time::{Duration, Instant};
use tokio::sync::RwLock;
const POLL_INTERVAL: Duration = Duration::from_secs(5);
/// Thread-safe cache for the latest GPU health reading.
pub struct HealthCache {
inner: RwLock<HealthResponse>,
has_gpus: RwLock<bool>,
}
impl Default for HealthCache {
fn default() -> Self {
Self::new()
}
}
impl HealthCache {
pub fn new() -> Self {
Self {
inner: RwLock::new(HealthResponse {
uptime_secs: 0,
devices: vec![],
// The cache only owns the device-state half of /health;
// the api handler overlays activation from the tracker.
// Initialise with the default (Ready, empty lists) so a
// direct read from the cache stays a well-typed
// HealthResponse on the wire.
activation: Default::default(),
// Per-model admission load is overlaid by the api handler
// from the candle harness (#53); the cache doesn't own it.
models: Vec::new(),
}),
has_gpus: RwLock::new(false),
}
}
/// Mark whether this node has GPUs (set after discovery).
pub async fn set_has_gpus(&self, has_gpus: bool) {
*self.has_gpus.write().await = has_gpus;
}
/// Get a snapshot of the current health state.
pub async fn snapshot(&self) -> HealthResponse {
self.inner.read().await.clone()
}
/// Run forever, polling nvidia-smi every 5 seconds and updating the cache.
pub async fn poll_loop(&self, start_time: Instant) {
loop {
tokio::time::sleep(POLL_INTERVAL).await;
let uptime = start_time.elapsed().as_secs();
if !*self.has_gpus.read().await {
let mut health = self.inner.write().await;
health.uptime_secs = uptime;
continue;
}
match crate::discovery::query_health().await {
Ok(devices) => {
let mut health = self.inner.write().await;
health.uptime_secs = uptime;
health.devices = devices;
}
Err(e) => {
tracing::warn!(error = %e, "failed to poll GPU health");
// Keep last known reading, just update uptime.
let mut health = self.inner.write().await;
health.uptime_secs = uptime;
}
}
}
}
}