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
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:
co-authored by
Claude Sonnet 4.6
parent
ef786c0ab1
commit
0e1d6a74b6
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user