feat(batch6): windowed acceptance metrics, KV INT8 quant, col/row-parallel linear
CI / Format Check (push) Failing after 6s
GPU Tests / Check GPU Availability (push) Successful in 0s
CI / Clippy Check (push) Failing after 8s
Documentation / Build User Guide (push) Successful in 8s
GPU Tests / CUDA Tests (11.8) (push) Has been skipped
GPU Tests / CUDA Tests (12.1) (push) Has been skipped
Documentation / Build API Documentation (push) Failing after 19s
CI / Build (ubuntu-latest) (push) Failing after 53s
CI / Build CPU-Only (Explicit) (push) Failing after 1m6s
Performance Benchmarks / Run Benchmarks (push) Successful in 7m57s
CI / Build (macos-latest) (push) Failing after 28s
CI / Test (macos-latest) (push) Has been skipped
CI / Test (ubuntu-latest) (push) Has been skipped
CI / Python Bindings (maturin) (macos-latest) (push) Has been skipped
CI / Python Bindings (maturin) (ubuntu-latest) (push) Has been skipped
CI / WASM Build + Size Check (push) Has been skipped
CI / Distributed Training Tests (push) Has been skipped
CI / CI Success (push) Failing after 0s
GPU Tests / Metal Tests (push) Has been skipped

Windowed acceptance rate (rtx-inference/speculative):
- WindowedAcceptanceTracker: O(1) VecDeque sliding window, p50/p95/min/max
- AcceptanceTrend enum (Rising/Falling/Stable, ±0.05 threshold)
- AcceptanceDashboard aggregator; wired into PerformanceMetrics::update()
  and dashboard(); 13 tests

KV cache INT8 quantization (rtx-inference/cache):
- KvCacheQuantMode { None, Int8 { scale_per_token }, Fp8E4M3 } enum
- KvQuantizer::encode/decode: symmetric per-block INT8 (scale=max_abs/127)
  gives 4× compression vs f32; Fp8E4M3 CPU proxy, GPU path reserved
- QuantizedKvBlock carries data+scale+mode; KvCacheConfig::quant_mode
  defaulting to None; 14 tests

ColParallel + RowParallel linear (rtx-distributed):
- ColParallelLinear: shards weight rows across TP ranks, forward_cpu()
  batch matmul + per-shard bias; no AllReduce (output shards concatenated)
- RowParallelLinear: shards weight cols across TP ranks, forward_cpu()
  partial sum + bias on rank 0 only; async forward() calls ProcessGroup
  AllReduce for real NCCL path; CPU sim is no-op
- TensorParallel::matmul() replaced zeros stub with ColParallelLinear(tp=1)
- col→row roundtrip verified within 1e-3; 9 tests

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
This commit is contained in:
Omar Sobh
2026-06-27 03:54:07 +00:00
co-authored by Claude Sonnet 4.6
parent ef786c0ab1
commit 0e1d6a74b6
6 changed files with 1628 additions and 14 deletions
@@ -1,7 +1,200 @@
//! Performance metrics for speculative decoding
use std::collections::VecDeque;
use std::time::Duration;
// ---------------------------------------------------------------------------
// WindowedAcceptanceTracker
// ---------------------------------------------------------------------------
/// Sliding-window acceptance rate over the last `window_size` decode steps.
///
/// Each element in the window is the per-step acceptance rate
/// (`accepted_tokens / draft_tokens`) for that step, clamped to `[0.0, 1.0]`.
/// Oldest entries are evicted automatically once the window is full.
///
/// # Example
/// ```
/// use rtx_inference::speculative::WindowedAcceptanceTracker;
///
/// let mut tracker = WindowedAcceptanceTracker::new(3);
/// tracker.push(0.5);
/// tracker.push(0.8);
/// tracker.push(0.7);
/// let rate = tracker.windowed_rate();
/// assert!((rate - (0.5_f32 + 0.8 + 0.7) / 3.0).abs() < 1e-5);
/// ```
#[derive(Debug, Clone)]
pub struct WindowedAcceptanceTracker {
window_size: usize,
buffer: VecDeque<f32>,
sum: f32,
}
impl WindowedAcceptanceTracker {
/// Create a new tracker with the given sliding-window capacity.
///
/// # Panics
/// Panics if `window_size` is 0.
pub fn new(window_size: usize) -> Self {
assert!(window_size > 0, "window_size must be > 0");
Self {
window_size,
buffer: VecDeque::with_capacity(window_size),
sum: 0.0,
}
}
/// Push the per-step acceptance rate for one decode step.
///
/// `step_rate` is clamped to `[0.0, 1.0]` before storage.
/// If the window is full the oldest value is evicted and its contribution
/// subtracted from the running sum before the new value is inserted.
pub fn push(&mut self, step_rate: f32) {
let clamped = step_rate.clamp(0.0, 1.0);
if self.buffer.len() == self.window_size {
// Evict the oldest entry.
if let Some(oldest) = self.buffer.pop_front() {
self.sum -= oldest;
}
}
self.buffer.push_back(clamped);
self.sum += clamped;
}
/// Mean acceptance rate over all values currently in the window.
///
/// Returns `0.0` when the window is empty.
pub fn windowed_rate(&self) -> f32 {
if self.buffer.is_empty() {
0.0
} else {
// Guard against accumulated floating-point drift going negative.
(self.sum / self.buffer.len() as f32).max(0.0)
}
}
/// The `p`-th percentile of values currently in the window (0.0 1.0).
///
/// Uses nearest-rank method: index = `(p * (len - 1)).round()`.
/// Returns `0.0` when the window is empty.
pub fn percentile(&self, p: f32) -> f32 {
if self.buffer.is_empty() {
return 0.0;
}
let mut sorted: Vec<f32> = self.buffer.iter().copied().collect();
sorted.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
let idx = (p * (sorted.len() as f32 - 1.0)).round() as usize;
sorted[idx.min(sorted.len() - 1)]
}
/// 50th-percentile (median) of the current window.
pub fn p50(&self) -> f32 {
self.percentile(0.5)
}
/// 95th-percentile of the current window.
pub fn p95(&self) -> f32 {
self.percentile(0.95)
}
/// Minimum value in the current window, or `0.0` if empty.
pub fn min(&self) -> f32 {
if self.buffer.is_empty() {
return 0.0;
}
self.buffer
.iter()
.copied()
.fold(f32::INFINITY, f32::min)
}
/// Maximum value in the current window, or `0.0` if empty.
pub fn max(&self) -> f32 {
if self.buffer.is_empty() {
return 0.0;
}
self.buffer
.iter()
.copied()
.fold(f32::NEG_INFINITY, f32::max)
}
/// Number of samples currently in the window.
pub fn count(&self) -> usize {
self.buffer.len()
}
/// `true` once `window_size` samples have been pushed (or more).
pub fn is_full(&self) -> bool {
self.buffer.len() == self.window_size
}
}
// ---------------------------------------------------------------------------
// AcceptanceTrend
// ---------------------------------------------------------------------------
/// Direction of acceptance rate movement relative to the global average.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum AcceptanceTrend {
/// Windowed rate is more than 5 percentage points above the global rate.
Rising,
/// Windowed rate is more than 5 percentage points below the global rate.
Falling,
/// Windowed rate is within ±5 percentage points of the global rate.
Stable,
}
impl AcceptanceTrend {
fn from_rates(windowed: f32, global: f32) -> Self {
const THRESHOLD: f32 = 0.05;
let delta = windowed - global;
if delta > THRESHOLD {
Self::Rising
} else if delta < -THRESHOLD {
Self::Falling
} else {
Self::Stable
}
}
}
// ---------------------------------------------------------------------------
// AcceptanceDashboard
// ---------------------------------------------------------------------------
/// Snapshot of acceptance rate statistics for real-time monitoring.
///
/// Obtained via [`PerformanceMetrics::dashboard`].
#[derive(Debug, Clone)]
pub struct AcceptanceDashboard {
/// Global running acceptance rate since the decoder started.
pub global_rate: f32,
/// Mean acceptance rate over the last `window_size` steps.
pub windowed_rate: f32,
/// Median (p50) of the windowed samples.
pub p50: f32,
/// 95th-percentile of the windowed samples.
pub p95: f32,
/// Minimum acceptance rate observed in the current window.
pub min: f32,
/// Maximum acceptance rate observed in the current window.
pub max: f32,
/// Total decode steps recorded (lifetime of the decoder).
pub total_steps: u64,
/// Configured window size.
pub window_size: usize,
/// `true` once `window_size` samples have accumulated.
pub window_full: bool,
/// Trend of the windowed rate relative to the global rate.
pub trend: AcceptanceTrend,
}
// ---------------------------------------------------------------------------
// PerformanceMetrics (extended)
// ---------------------------------------------------------------------------
/// Performance metrics for speculative decoding
#[derive(Debug, Clone, Default)]
pub struct PerformanceMetrics {
@@ -21,6 +214,14 @@ pub struct PerformanceMetrics {
pub verification_time: Duration,
/// Total time spent in speculative decoding
pub total_time: Duration,
/// Sliding-window acceptance tracker (default window = 100 steps).
pub(crate) windowed: WindowedAcceptanceTracker,
}
impl Default for WindowedAcceptanceTracker {
fn default() -> Self {
Self::new(100)
}
}
impl PerformanceMetrics {
@@ -34,6 +235,7 @@ impl PerformanceMetrics {
draft_generation_time: Duration::ZERO,
verification_time: Duration::ZERO,
total_time: Duration::ZERO,
windowed: WindowedAcceptanceTracker::new(100),
}
}
@@ -63,6 +265,11 @@ impl PerformanceMetrics {
0.0
};
// Push per-step acceptance rate into the sliding window.
let step_rate =
step_result.accepted_tokens as f32 / step_result.draft_tokens.max(1) as f32;
self.windowed.push(step_rate);
// Estimate speedup ratio
// This is simplified - in reality would compare against baseline sequential generation
let draft_efficiency =
@@ -70,6 +277,24 @@ impl PerformanceMetrics {
let acceptance_benefit = self.acceptance_rate * step_result.draft_tokens as f32;
self.speedup_ratio = 1.0 + (acceptance_benefit / draft_efficiency).min(10.0);
}
/// Build a real-time [`AcceptanceDashboard`] from the current state.
pub fn dashboard(&self) -> AcceptanceDashboard {
let windowed_rate = self.windowed.windowed_rate();
let trend = AcceptanceTrend::from_rates(windowed_rate, self.acceptance_rate);
AcceptanceDashboard {
global_rate: self.acceptance_rate,
windowed_rate,
p50: self.windowed.p50(),
p95: self.windowed.p95(),
min: self.windowed.min(),
max: self.windowed.max(),
total_steps: self.total_steps,
window_size: self.windowed.window_size,
window_full: self.windowed.is_full(),
trend,
}
}
}
/// Metrics for a single decoding step
@@ -80,3 +305,157 @@ pub(crate) struct StepMetrics {
pub verification_time: Duration,
pub total_time: Duration,
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
use std::time::Duration;
// ------------------------------------------------------------------
// WindowedAcceptanceTracker tests
// ------------------------------------------------------------------
#[test]
fn test_windowed_tracker_empty() {
let tracker = WindowedAcceptanceTracker::new(10);
assert_eq!(tracker.windowed_rate(), 0.0);
assert_eq!(tracker.count(), 0);
assert!(!tracker.is_full());
}
#[test]
fn test_windowed_tracker_push_and_evict() {
let mut tracker = WindowedAcceptanceTracker::new(3);
tracker.push(0.1); // [0.1]
tracker.push(0.2); // [0.1, 0.2]
tracker.push(0.3); // [0.1, 0.2, 0.3] — full
tracker.push(0.4); // [0.2, 0.3, 0.4] — 0.1 evicted
assert_eq!(tracker.count(), 3);
assert!(tracker.is_full());
// Sum should be 0.2 + 0.3 + 0.4 = 0.9 → mean ≈ 0.3
let rate = tracker.windowed_rate();
assert!(
(rate - 0.3).abs() < 1e-5,
"expected ~0.3, got {rate}"
);
}
#[test]
fn test_windowed_rate_accuracy() {
let mut tracker = WindowedAcceptanceTracker::new(10);
tracker.push(0.5);
tracker.push(0.8);
tracker.push(0.7);
let expected = (0.5_f32 + 0.8 + 0.7) / 3.0;
let got = tracker.windowed_rate();
assert!(
(got - expected).abs() < 1e-5,
"expected {expected}, got {got}"
);
}
#[test]
fn test_percentile_p50() {
let mut tracker = WindowedAcceptanceTracker::new(10);
tracker.push(0.4);
tracker.push(0.6);
tracker.push(0.8);
// sorted: [0.4, 0.6, 0.8]; idx = round(0.5 * 2) = 1 → 0.6
assert!((tracker.p50() - 0.6).abs() < 1e-5);
}
#[test]
fn test_percentile_p95() {
let mut tracker = WindowedAcceptanceTracker::new(100);
// Push 20 evenly spaced values: 0.0, 1/19, 2/19 ... 1.0
for i in 0..20_u32 {
tracker.push(i as f32 / 19.0);
}
// p95: idx = round(0.95 * 19) = round(18.05) = 18 → value = 18/19 ≈ 0.947
let p95 = tracker.p95();
assert!(p95 > 0.9, "p95 should be close to 1.0, got {p95}");
}
#[test]
fn test_trend_rising() {
let trend = AcceptanceTrend::from_rates(0.70, 0.50);
assert_eq!(trend, AcceptanceTrend::Rising);
}
#[test]
fn test_trend_falling() {
let trend = AcceptanceTrend::from_rates(0.50, 0.70);
assert_eq!(trend, AcceptanceTrend::Falling);
}
#[test]
fn test_trend_stable() {
let trend = AcceptanceTrend::from_rates(0.52, 0.50);
assert_eq!(trend, AcceptanceTrend::Stable);
}
#[test]
fn test_dashboard_builds_correctly() {
let mut metrics = PerformanceMetrics::new();
let step = StepMetrics {
draft_tokens: 4,
accepted_tokens: 3,
draft_time: Duration::from_millis(5),
verification_time: Duration::from_millis(2),
total_time: Duration::from_millis(7),
};
metrics.update(&step);
metrics.update(&step);
metrics.update(&step);
let dash = metrics.dashboard();
assert_eq!(dash.total_steps, 3);
assert_eq!(dash.window_size, 100);
assert!(!dash.window_full); // need 100 steps for full
// Each step: accepted=3, drafted=4 → step_rate = 0.75
assert!((dash.windowed_rate - 0.75).abs() < 1e-4);
assert!((dash.global_rate - 0.75).abs() < 1e-4);
assert_eq!(dash.trend, AcceptanceTrend::Stable);
}
#[test]
fn test_window_full_flag() {
let mut tracker = WindowedAcceptanceTracker::new(5);
for i in 0..5 {
assert!(!tracker.is_full(), "should not be full after {i} pushes");
tracker.push(0.5);
}
assert!(tracker.is_full());
}
#[test]
fn test_min_max() {
let mut tracker = WindowedAcceptanceTracker::new(10);
tracker.push(0.3);
tracker.push(0.7);
tracker.push(0.9);
assert!((tracker.min() - 0.3).abs() < 1e-5, "min got {}", tracker.min());
assert!((tracker.max() - 0.9).abs() < 1e-5, "max got {}", tracker.max());
}
#[test]
fn test_min_empty_returns_zero() {
let tracker = WindowedAcceptanceTracker::new(5);
assert_eq!(tracker.min(), 0.0, "min() of empty window must be 0.0");
assert_eq!(tracker.max(), 0.0, "max() of empty window must be 0.0");
assert_eq!(tracker.windowed_rate(), 0.0);
assert_eq!(tracker.count(), 0);
}
#[test]
fn test_clamp_out_of_range() {
let mut tracker = WindowedAcceptanceTracker::new(5);
tracker.push(-0.5); // clamped to 0.0
tracker.push(1.5); // clamped to 1.0
assert!((tracker.windowed_rate() - 0.5).abs() < 1e-5);
}
}