Skip to main content

aisimulate_core/replay/
telemetry.rs

1// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Optional, policy-neutral telemetry sampling for offline replay.
5//!
6//! Telemetry is scheduled on the replay virtual clock independently of scaling
7//! policy ticks. Point-in-time scheduler rows are kept separate from additive
8//! interval counters so a sample remains meaningful when a worker rank retires
9//! between two samples.
10
11use serde::Serialize;
12
13/// Why a replay telemetry sample was emitted.
14#[derive(Debug, Clone, Copy, Eq, PartialEq, Serialize)]
15#[serde(rename_all = "snake_case")]
16pub enum ReplayTelemetrySampleKind {
17    /// Gauge-only state after the initial replay timestamp has settled.
18    /// Interval counters are not consumed by this sample.
19    Baseline,
20    /// A complete configured sampling interval.
21    Periodic,
22    /// The non-empty tail after the last periodic sample, including a
23    /// zero-duration tail with observations recorded at the final timestamp.
24    Final,
25}
26
27/// Point-in-time scheduler state for one live logical-worker rank.
28#[derive(Debug, Clone, PartialEq, Serialize)]
29pub struct ReplaySchedulerMetricsSnapshot {
30    pub worker_id: usize,
31    pub dp_rank: u32,
32    /// Backend-native legacy occupancy. vLLM counts active references; SGLang
33    /// counts occupied page-pool blocks, including radix-resident pages.
34    pub active_blocks: u64,
35    /// Reusable resident blocks excluded from `active_blocks` (vLLM only;
36    /// SGLang reports zero because its legacy occupancy already includes them).
37    pub inactive_blocks: u64,
38    pub total_blocks: u64,
39    /// Legacy/backend-native `active_blocks / total_blocks` utilization.
40    pub active_cache_usage: f64,
41    /// Physical resident utilization; equal to active utilization for SGLang.
42    pub physical_cache_usage: f64,
43    pub running_requests: u64,
44    pub waiting_requests: u64,
45}
46
47/// Additive scheduler observations over one telemetry interval for one role.
48///
49/// These counters include observations from ranks that retired during the
50/// interval; retired rank gauge rows are intentionally not retained.
51#[derive(Debug, Clone, Copy, Default, Eq, PartialEq, Serialize)]
52pub struct ReplaySchedulerIntervalMetrics {
53    /// SGLang scheduler cache-hit tokens observed in the interval.
54    /// Backends without an equivalent pass-local metric report zero.
55    pub cache_hit_tokens: u64,
56    /// SGLang scheduler tokens considered in the interval. A zero denominator
57    /// means scheduler reuse is unavailable, not a measured zero-percent rate.
58    pub cache_total_tokens: u64,
59    /// New scheduler preemptions observed in the interval.
60    pub preemptions: u64,
61}
62
63impl ReplaySchedulerIntervalMetrics {
64    pub(crate) const fn has_observations(&self) -> bool {
65        self.cache_hit_tokens != 0 || self.cache_total_tokens != 0 || self.preemptions != 0
66    }
67
68    pub(crate) fn checked_add_assign(&mut self, other: Self) -> anyhow::Result<()> {
69        self.cache_hit_tokens = self
70            .cache_hit_tokens
71            .checked_add(other.cache_hit_tokens)
72            .ok_or_else(|| anyhow::anyhow!("scheduler cache-hit token counter overflow"))?;
73        self.cache_total_tokens = self
74            .cache_total_tokens
75            .checked_add(other.cache_total_tokens)
76            .ok_or_else(|| anyhow::anyhow!("scheduler cache-total token counter overflow"))?;
77        self.preemptions = self
78            .preemptions
79            .checked_add(other.preemptions)
80            .ok_or_else(|| anyhow::anyhow!("scheduler preemption counter overflow"))?;
81        Ok(())
82    }
83}
84
85/// Traffic observations over one telemetry interval.
86#[derive(Debug, Clone, Default, PartialEq, Serialize)]
87pub struct ReplayTrafficMetricsSnapshot {
88    pub duration_s: f64,
89    /// Requests arriving at the replay admission boundary in this interval.
90    pub arriving_requests: usize,
91    /// Completed, non-rejected requests contributing shape observations.
92    pub completed_requests: usize,
93    /// Mean input/output lengths over `completed_requests`.
94    pub avg_isl: f64,
95    pub avg_osl: f64,
96    pub avg_ttft_ms: f64,
97    pub avg_itl_ms: f64,
98    pub ttft_count: usize,
99    pub itl_count: usize,
100    /// Mean router prefix-overlap ratio over `router_kv_hit_rate_count`.
101    pub avg_router_kv_hit_rate: f64,
102    pub router_kv_hit_rate_count: usize,
103    pub avg_accept_length: Option<f64>,
104    pub accept_length_forward_count: usize,
105}
106
107/// Policy-neutral replay state sampled after a virtual timestamp has settled.
108#[derive(Debug, Clone, PartialEq, Serialize)]
109pub struct ReplayTelemetrySnapshot {
110    pub sample_ordinal: u64,
111    pub kind: ReplayTelemetrySampleKind,
112    /// Start boundary of the interval counters represented by this sample.
113    pub interval_start_ms: f64,
114    /// Virtual timestamp at which this sample was taken.
115    pub sampled_at_ms: f64,
116    pub traffic: ReplayTrafficMetricsSnapshot,
117    /// Full gauge rows for every currently live rank. Aggregated replay reports
118    /// its single role through the decode fields.
119    pub prefill_scheduler_metrics: Vec<ReplaySchedulerMetricsSnapshot>,
120    pub decode_scheduler_metrics: Vec<ReplaySchedulerMetricsSnapshot>,
121    pub prefill_interval_metrics: ReplaySchedulerIntervalMetrics,
122    pub decode_interval_metrics: ReplaySchedulerIntervalMetrics,
123    /// Requests admitted by Replay but still awaiting worker placement.
124    pub router_pending_prefill_requests: usize,
125    pub router_pending_decode_requests: usize,
126    pub active_prefill_ids: Vec<usize>,
127    pub active_decode_ids: Vec<usize>,
128    pub starting_prefill_ids: Vec<usize>,
129    pub starting_decode_ids: Vec<usize>,
130    pub draining_prefill_ids: Vec<usize>,
131    pub draining_decode_ids: Vec<usize>,
132}
133
134/// Receives optional replay telemetry without participating in scaling.
135///
136/// `Send` lets native bindings release their language-runtime lock while the
137/// single-threaded replay loop invokes a native collector.
138pub trait ReplayTelemetryObserver: Send {
139    fn on_sample(&mut self, snapshot: ReplayTelemetrySnapshot) -> anyhow::Result<()>;
140}
141
142pub(crate) struct ReplayTelemetryRuntime {
143    observer: Box<dyn ReplayTelemetryObserver>,
144    sample_interval_ms: f64,
145    next_sample_ordinal: u64,
146    sampling_origin_ms: Option<f64>,
147    interval_start_ms: f64,
148}
149
150impl ReplayTelemetryRuntime {
151    pub(crate) fn new(sample_interval_ms: f64, observer: Box<dyn ReplayTelemetryObserver>) -> Self {
152        Self {
153            observer,
154            sample_interval_ms,
155            next_sample_ordinal: 0,
156            sampling_origin_ms: None,
157            interval_start_ms: 0.0,
158        }
159    }
160
161    pub(crate) const fn next_sample_ordinal(&self) -> u64 {
162        self.next_sample_ordinal
163    }
164
165    pub(crate) const fn interval_start_ms(&self) -> f64 {
166        self.interval_start_ms
167    }
168
169    pub(crate) fn start_at(&mut self, now_ms: f64) {
170        self.sampling_origin_ms = Some(now_ms);
171        self.interval_start_ms = now_ms;
172    }
173
174    /// Derive cadence from the baseline origin and ordinal rather than adding
175    /// repeatedly, which prevents floating-point drift over long replays.
176    pub(crate) fn next_periodic_at_ms(&self) -> anyhow::Result<f64> {
177        let origin_ms = self
178            .sampling_origin_ms
179            .ok_or_else(|| anyhow::anyhow!("replay telemetry baseline was not initialized"))?;
180        let at_ms = origin_ms + self.next_sample_ordinal as f64 * self.sample_interval_ms;
181        if !at_ms.is_finite() {
182            return Err(anyhow::anyhow!(
183                "replay telemetry sample timestamp overflow"
184            ));
185        }
186        if at_ms <= self.interval_start_ms {
187            return Err(anyhow::anyhow!(
188                "replay telemetry cadence is below virtual-clock precision at {} ms",
189                self.interval_start_ms
190            ));
191        }
192        Ok(at_ms)
193    }
194
195    pub(crate) fn publish(&mut self, snapshot: ReplayTelemetrySnapshot) -> anyhow::Result<()> {
196        self.observer.on_sample(snapshot)?;
197        self.next_sample_ordinal = self
198            .next_sample_ordinal
199            .checked_add(1)
200            .ok_or_else(|| anyhow::anyhow!("replay telemetry sample ordinal overflow"))?;
201        Ok(())
202    }
203
204    pub(crate) fn close_interval(&mut self, sampled_at_ms: f64) {
205        self.interval_start_ms = sampled_at_ms;
206    }
207}
208
209#[cfg(test)]
210mod tests {
211    use super::*;
212
213    struct NoopObserver;
214
215    impl ReplayTelemetryObserver for NoopObserver {
216        fn on_sample(&mut self, _snapshot: ReplayTelemetrySnapshot) -> anyhow::Result<()> {
217            Ok(())
218        }
219    }
220
221    fn sample(
222        sample_ordinal: u64,
223        kind: ReplayTelemetrySampleKind,
224        interval_start_ms: f64,
225        sampled_at_ms: f64,
226    ) -> ReplayTelemetrySnapshot {
227        ReplayTelemetrySnapshot {
228            sample_ordinal,
229            kind,
230            interval_start_ms,
231            sampled_at_ms,
232            traffic: ReplayTrafficMetricsSnapshot::default(),
233            prefill_scheduler_metrics: Vec::new(),
234            decode_scheduler_metrics: Vec::new(),
235            prefill_interval_metrics: ReplaySchedulerIntervalMetrics::default(),
236            decode_interval_metrics: ReplaySchedulerIntervalMetrics::default(),
237            router_pending_prefill_requests: 0,
238            router_pending_decode_requests: 0,
239            active_prefill_ids: Vec::new(),
240            active_decode_ids: Vec::new(),
241            starting_prefill_ids: Vec::new(),
242            starting_decode_ids: Vec::new(),
243            draining_prefill_ids: Vec::new(),
244            draining_decode_ids: Vec::new(),
245        }
246    }
247
248    #[test]
249    fn submillisecond_cadence_stays_anchored_to_the_sampling_origin() {
250        let mut runtime = ReplayTelemetryRuntime::new(0.1, Box::new(NoopObserver));
251        runtime.start_at(0.0);
252        runtime
253            .publish(sample(0, ReplayTelemetrySampleKind::Baseline, 0.0, 0.0))
254            .unwrap();
255
256        for ordinal in 1..=10_000 {
257            let at_ms = runtime.next_periodic_at_ms().unwrap();
258            assert_eq!(at_ms, ordinal as f64 * 0.1);
259            runtime
260                .publish(sample(
261                    ordinal,
262                    ReplayTelemetrySampleKind::Periodic,
263                    runtime.interval_start_ms(),
264                    at_ms,
265                ))
266                .unwrap();
267            runtime.close_interval(at_ms);
268        }
269    }
270
271    #[test]
272    fn cadence_rejects_intervals_below_virtual_clock_precision() {
273        let origin_ms = 1.0e20;
274        let mut runtime = ReplayTelemetryRuntime::new(0.1, Box::new(NoopObserver));
275        runtime.start_at(origin_ms);
276        runtime
277            .publish(sample(
278                0,
279                ReplayTelemetrySampleKind::Baseline,
280                origin_ms,
281                origin_ms,
282            ))
283            .unwrap();
284
285        let error = runtime.next_periodic_at_ms().unwrap_err();
286        assert!(error.to_string().contains("below virtual-clock precision"));
287    }
288}