From 7c25687c9ba35e4730effbb9cc2f2c4f8629e423 Mon Sep 17 00:00:00 2001 From: Simo Lin Date: Sat, 10 Jan 2026 18:15:21 -0800 Subject: [PATCH] fix(gateway): rewrite gauge_histogram.rs for zero-allocation hot path (#16878) --- .../src/observability/gauge_histogram.rs | 693 ++++++++++++++---- 1 file changed, 541 insertions(+), 152 deletions(-) diff --git a/sgl-model-gateway/src/observability/gauge_histogram.rs b/sgl-model-gateway/src/observability/gauge_histogram.rs index e00701032..8ed78cd44 100644 --- a/sgl-model-gateway/src/observability/gauge_histogram.rs +++ b/sgl-model-gateway/src/observability/gauge_histogram.rs @@ -1,194 +1,583 @@ -//! Gauge with gt/le bucket labels for Grafana heatmap visualization. +//! Non-cumulative gauge histogram for Grafana heatmap visualization. //! -//! Unlike Prometheus Histogram which uses cumulative buckets, this uses -//! non-cumulative buckets (gt < value <= le) suitable for heatmap display. +//! Unlike Prometheus Histogram which uses cumulative `le` buckets, this emits +//! non-cumulative bucket counts with `(gt, le]` ranges suitable for heatmaps. //! -//! Note: Keep in sync with Python implementation in -//! python/sglang/srt/utils/gauge_histogram.py +//! # Design: True Zero-Allocation Hot Path +//! +//! The key insight is that `gauge!` returns a `Gauge` handle that can be stored. +//! By pre-registering all gauge handles at startup, the hot path becomes just +//! N+1 atomic `gauge.set()` calls with zero allocations. +//! +//! # Performance Characteristics +//! +//! Setup (once per label combination): +//! - `register()`: N+1 gauge registrations, N+1 String allocations for gt/le +//! +//! Hot path (`set_counts()`): +//! - **Zero heap allocations** +//! - **Zero key lookups** (handles are pre-registered) +//! - N+1 atomic `gauge.set()` calls +//! +//! # Example +//! +//! ```ignore +//! use crate::observability::gauge_histogram::{BucketBounds, GaugeHistogramVec}; +//! +//! // Define at module level +//! static BOUNDS: BucketBounds<10> = BucketBounds::new([1, 2, 3, 5, 7, 10, 20, 50, 100, 200]); +//! static HISTOGRAM: GaugeHistogramVec<10> = GaugeHistogramVec::new("smg_request_dist", &BOUNDS); +//! +//! // At startup: register for each label combination +//! let handle = HISTOGRAM.register(&[("router", "round_robin"), ("model", "llama")]); +//! +//! // Pre-allocate counts buffer +//! let mut counts = vec![0usize; BOUNDS.bucket_count()]; +//! +//! // Hot path: TRUE zero allocation +//! fn update(handle: &GaugeHistogramHandle, counts: &mut [usize], observations: &[u64]) { +//! BOUNDS.compute_counts_into(counts, observations); +//! handle.set_counts(counts); // Just N+1 atomic gauge.set() calls! +//! } +//! ``` -use metrics::gauge; +use std::sync::Arc; -pub struct BucketLabels { - upper_bounds: &'static [u64], - le_labels: Vec<&'static str>, - gt_labels: Vec<&'static str>, +use dashmap::DashMap; +use metrics::{gauge, Label}; + +// ============================================================================= +// BUCKET BOUNDS +// ============================================================================= + +/// Static bucket boundary configuration. +/// +/// Uses const generics to define bucket bounds at compile time with validation. +/// The bounds define `N` upper limits, creating `N + 1` buckets: +/// `(0, b[0]], (b[0], b[1]], ..., (b[N-1], +Inf]`. +#[derive(Debug)] +pub struct BucketBounds { + bounds: [u64; N], } -impl BucketLabels { - pub fn new(upper_bounds: &'static [u64]) -> Self { - let leak_str = |n: u64| Box::leak(n.to_string().into_boxed_str()) as &'static str; +impl BucketBounds { + /// Create new bucket bounds from a sorted array of upper limits. + /// + /// # Panics + /// + /// Panics at compile time (in const context) or runtime if bounds are not + /// strictly ascending. + #[must_use] + pub const fn new(bounds: [u64; N]) -> Self { + let mut i = 1; + while i < N { + assert!( + bounds[i] > bounds[i - 1], + "bucket bounds must be strictly ascending" + ); + i += 1; + } + Self { bounds } + } - let mut le_labels: Vec<&'static str> = upper_bounds.iter().map(|&b| leak_str(b)).collect(); - le_labels.push("+Inf"); + /// Returns the number of buckets (one more than the number of bounds). + #[inline] + #[must_use] + pub const fn bucket_count(&self) -> usize { + N + 1 + } - let mut gt_labels: Vec<&'static str> = vec!["0"]; - gt_labels.extend(upper_bounds.iter().map(|&b| leak_str(b))); + /// Returns the number of bounds. + #[inline] + #[must_use] + pub const fn bound_count(&self) -> usize { + N + } - Self { - upper_bounds, - le_labels, - gt_labels, + /// Get the bounds array. + #[inline] + #[must_use] + pub const fn bounds(&self) -> &[u64; N] { + &self.bounds + } + + /// Find the bucket index for a value. O(log N). + #[inline] + #[must_use] + pub fn bucket_index(&self, value: u64) -> usize { + self.bounds.partition_point(|&bound| bound < value) + } + + /// Get the upper bound for a bucket index, or None for the +Inf bucket. + #[inline] + #[must_use] + pub const fn upper_bound(&self, idx: usize) -> Option { + if idx < N { + Some(self.bounds[idx]) + } else { + None } } - pub fn len(&self) -> usize { - self.le_labels.len() + /// Get the lower bound for a bucket index (0 for the first bucket). + #[inline] + #[must_use] + pub const fn lower_bound(&self, idx: usize) -> u64 { + if idx == 0 { + 0 + } else { + self.bounds[idx - 1] + } } - pub fn iter(&self) -> impl Iterator + '_ { - std::iter::zip(&self.gt_labels, &self.le_labels).map(|(>, &le)| (gt, le)) - } - - /// Compute bucket counts from observations. - pub fn compute_bucket_counts(&self, observations: &[u64]) -> Vec { - let mut counts = vec![0usize; self.len()]; + /// Compute bucket counts into a pre-allocated buffer. **Zero allocation.** + /// + /// # Panics + /// + /// Panics if `counts.len() < bucket_count()`. + #[inline] + pub fn compute_counts_into(&self, counts: &mut [usize], observations: &[u64]) { + debug_assert!( + counts.len() >= self.bucket_count(), + "counts buffer too small" + ); + counts[..self.bucket_count()].fill(0); for &value in observations { - // Equivalent to Python's bisect.bisect_left. O(log n). - let idx = self.upper_bounds.partition_point(|&bound| bound < value); + let idx = self.bucket_index(value); counts[idx] += 1; } + } + + /// Compute bucket counts, allocating a new Vec. + /// + /// Prefer `compute_counts_into` in hot paths to avoid allocation. + #[must_use] + pub fn compute_counts(&self, observations: &[u64]) -> Vec { + let mut counts = vec![0usize; self.bucket_count()]; + self.compute_counts_into(&mut counts, observations); counts } } -pub struct GaugeHistogram { - name: &'static str, - buckets: &'static BucketLabels, +// ============================================================================= +// GAUGE HISTOGRAM HANDLE (pre-registered, zero-alloc hot path) +// ============================================================================= + +/// Pre-registered gauge handles for a histogram with specific labels. +/// +/// This is what you use in the hot path. Calling `set_counts()` does only +/// N+1 atomic `gauge.set()` operations - zero allocations, zero lookups. +#[derive(Clone)] +pub struct GaugeHistogramHandle { + gauges: Vec, } -impl GaugeHistogram { - pub const fn new(name: &'static str, buckets: &'static BucketLabels) -> Self { - Self { name, buckets } - } - - /// Set bucket counts directly. - pub fn set_raw(&self, values: &[usize]) { - for ((gt, le), &count) in self.buckets.iter().zip(values.iter()) { - gauge!(self.name, "gt" => gt, "le" => le).set(count as f64); +impl GaugeHistogramHandle { + /// Set bucket counts. **TRUE zero allocation.** + /// + /// Just N+1 atomic `gauge.set()` calls - no key lookup, no allocation. + #[inline] + pub fn set_counts(&self, counts: &[usize]) { + debug_assert_eq!( + counts.len(), + self.gauges.len(), + "counts length must match bucket count" + ); + for (gauge, &count) in self.gauges.iter().zip(counts.iter()) { + gauge.set(count as f64); } } - /// Compute bucket counts from observations and set them. - pub fn set_by_current_observations(&self, observations: &[u64]) { - let counts = self.buckets.compute_bucket_counts(observations); - self.set_raw(&counts); + /// Number of buckets. + #[inline] + pub fn bucket_count(&self) -> usize { + self.gauges.len() } - pub fn buckets(&self) -> &BucketLabels { - self.buckets + /// Zero out all gauges. **Zero allocation.** + #[inline] + pub fn zero_counts(&self) { + for gauge in &self.gauges { + gauge.set(0.0); + } } } +// ============================================================================= +// GAUGE HISTOGRAM VEC (factory for registering handles) +// ============================================================================= + +/// Factory for creating pre-registered histogram handles. +/// +/// Define as a static constant, then call `register()` for each label combination +/// you need. The returned `GaugeHistogramHandle` provides zero-allocation updates. +#[derive(Debug)] +pub struct GaugeHistogramVec { + name: &'static str, + bounds: &'static BucketBounds, +} + +impl GaugeHistogramVec { + /// Create a new gauge histogram factory. + /// + /// This just stores the name and bounds - no allocation or registration yet. + #[must_use] + pub const fn new(name: &'static str, bounds: &'static BucketBounds) -> Self { + Self { name, bounds } + } + + #[inline] + pub const fn name(&self) -> &'static str { + self.name + } + + #[inline] + pub const fn bounds(&self) -> &BucketBounds { + self.bounds + } + + /// Register gauges for a specific label combination. + /// + /// Call this once per unique label combination (at startup or when first seen). + /// The returned handle can be cloned cheaply (just Arc clones internally). + /// + /// # Arguments + /// + /// - `labels`: Static key-value label pairs for this histogram instance + /// + /// # Example + /// + /// ```ignore + /// let handle = HISTOGRAM.register(&[("router", "round_robin"), ("model", "llama")]); + /// ``` + pub fn register(&self, labels: &[(&'static str, &str)]) -> GaugeHistogramHandle { + let bucket_count = self.bounds.bucket_count(); + let mut gauges = Vec::with_capacity(bucket_count); + + for i in 0..bucket_count { + // Build gt/le labels for this bucket + let gt_str = if i == 0 { + "0".to_string() + } else { + self.bounds.bounds[i - 1].to_string() + }; + + let le_str = if i < N { + self.bounds.bounds[i].to_string() + } else { + "+Inf".to_string() + }; + + // Build complete label set + let mut all_labels: Vec