feat(batch10): attention sinks (StreamingLLM), chunked prefill, per-layer LR decay
CI / Format Check (push) Failing after 26s
CI / Build (ubuntu-latest) (push) Failing after 33s
CI / Clippy Check (push) Failing after 34s
CI / Build CPU-Only (Explicit) (push) Failing after 31s
GPU Tests / Check GPU Availability (push) Successful in 0s
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 30s
Documentation / Build User Guide (push) Failing after 32s
Performance Benchmarks / Run Benchmarks (push) Successful in 1m10s
CI / Build (macos-latest) (push) Failing after 55s
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 26s
CI / Build (ubuntu-latest) (push) Failing after 33s
CI / Clippy Check (push) Failing after 34s
CI / Build CPU-Only (Explicit) (push) Failing after 31s
GPU Tests / Check GPU Availability (push) Successful in 0s
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 30s
Documentation / Build User Guide (push) Failing after 32s
Performance Benchmarks / Run Benchmarks (push) Successful in 1m10s
CI / Build (macos-latest) (push) Failing after 55s
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
- Attention sinks (arXiv:2309.17453): AttentionSinkEviction always retains first sink_size KV positions + last window_size; evicts middle band in O(evict_count); select_evict_positions/should_retain consistent; 15 tests - Chunked prefill (vLLM arXiv:2309.06180): ChunkedPrefillScheduler splits long prompts into chunk_size=512 chunks interleaved with decode steps (max 128 decode tokens/step); PrefillChunkState tracks progress/remaining/completion; drain_completed(); 14 tests including 1500-token→3-chunk coverage - Per-layer LR decay (ULMFiT / discriminative fine-tuning): LayerLrDecayConfig with base_lr * decay_rate^(num_layers-1-depth); LayerLrDecayBuilder parses layer/layers/ h/blocks/bracket notation param names; LayerLrScheduler with outer multiplier for cosine/linear schedule composition; 14 tests + 1 doctest Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
This commit is contained in:
co-authored by
Claude Sonnet 4.6
parent
ef3cdb1e1a
commit
be3e3965b1
@@ -0,0 +1,471 @@
|
|||||||
|
//! StreamingLLM-style attention sink eviction.
|
||||||
|
//!
|
||||||
|
//! In StreamingLLM (Xiao et al., arXiv:2309.17453) the first few tokens in a
|
||||||
|
//! sequence accumulate disproportionately large attention weights regardless of
|
||||||
|
//! their semantic content — these positions are called **attention sinks**. By
|
||||||
|
//! permanently retaining the first `sink_size` KV positions together with a
|
||||||
|
//! sliding window of `window_size` recent positions, a model can run inference
|
||||||
|
//! on sequences of unbounded length without memory growth.
|
||||||
|
//!
|
||||||
|
//! # Eviction semantics
|
||||||
|
//!
|
||||||
|
//! ```text
|
||||||
|
//! [ 0 .. sink_size ) — always retained (sink tokens)
|
||||||
|
//! [ sink_size .. n-window_size) — evicted when total > max_capacity()
|
||||||
|
//! [ n-window_size .. n ) — always retained (recent working context)
|
||||||
|
//! ```
|
||||||
|
//!
|
||||||
|
//! # Example
|
||||||
|
//!
|
||||||
|
//! ```rust
|
||||||
|
//! use rtx_inference::AttentionSinkEviction;
|
||||||
|
//!
|
||||||
|
//! let eviction = AttentionSinkEviction::new(4, 32);
|
||||||
|
//! assert_eq!(eviction.max_capacity(), 36);
|
||||||
|
//!
|
||||||
|
//! // Under capacity — nothing to evict.
|
||||||
|
//! assert!(eviction.select_evict_positions(36).is_empty());
|
||||||
|
//!
|
||||||
|
//! // One over capacity — exactly one middle position is evicted.
|
||||||
|
//! let to_evict = eviction.select_evict_positions(37);
|
||||||
|
//! assert_eq!(to_evict, vec![4]);
|
||||||
|
//! ```
|
||||||
|
|
||||||
|
/// StreamingLLM-style attention sink eviction.
|
||||||
|
///
|
||||||
|
/// Always retains the first `sink_size` KV positions (sink tokens) and the
|
||||||
|
/// most recent `window_size` positions (working context). All positions in the
|
||||||
|
/// middle are evicted when the sequence exceeds `sink_size + window_size`.
|
||||||
|
///
|
||||||
|
/// # Reference
|
||||||
|
///
|
||||||
|
/// StreamingLLM (Xiao et al., arXiv:2309.17453): efficient streaming inference
|
||||||
|
/// via attention sinks, enabling infinite context without memory growth.
|
||||||
|
///
|
||||||
|
/// # Example
|
||||||
|
///
|
||||||
|
/// ```rust
|
||||||
|
/// use rtx_inference::AttentionSinkEviction;
|
||||||
|
///
|
||||||
|
/// let sink = AttentionSinkEviction::new(4, 32);
|
||||||
|
///
|
||||||
|
/// // Positions 0..4 and the last 32 positions are always kept.
|
||||||
|
/// assert!(sink.should_retain(0, 100));
|
||||||
|
/// assert!(sink.should_retain(3, 100));
|
||||||
|
/// assert!(sink.should_retain(99, 100));
|
||||||
|
/// assert!(!sink.should_retain(4, 100));
|
||||||
|
/// ```
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct AttentionSinkEviction {
|
||||||
|
/// Number of leading positions treated as attention sinks (always kept).
|
||||||
|
pub sink_size: usize,
|
||||||
|
/// Number of trailing positions kept as the sliding working context.
|
||||||
|
pub window_size: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AttentionSinkEviction {
|
||||||
|
/// Create a new `AttentionSinkEviction`.
|
||||||
|
///
|
||||||
|
/// Both `sink_size` and `window_size` may be zero; a zero value for either
|
||||||
|
/// means that component contributes no protected positions.
|
||||||
|
///
|
||||||
|
/// # Examples
|
||||||
|
///
|
||||||
|
/// ```rust
|
||||||
|
/// use rtx_inference::AttentionSinkEviction;
|
||||||
|
///
|
||||||
|
/// let sink = AttentionSinkEviction::new(4, 32);
|
||||||
|
/// assert_eq!(sink.sink_size, 4);
|
||||||
|
/// assert_eq!(sink.window_size, 32);
|
||||||
|
/// ```
|
||||||
|
pub fn new(sink_size: usize, window_size: usize) -> Self {
|
||||||
|
Self {
|
||||||
|
sink_size,
|
||||||
|
window_size,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the total capacity at which eviction first activates.
|
||||||
|
///
|
||||||
|
/// Eviction occurs only when `total_positions > max_capacity()`. Sequences
|
||||||
|
/// at or below this length are stored without modification.
|
||||||
|
///
|
||||||
|
/// # Examples
|
||||||
|
///
|
||||||
|
/// ```rust
|
||||||
|
/// use rtx_inference::AttentionSinkEviction;
|
||||||
|
///
|
||||||
|
/// let sink = AttentionSinkEviction::new(4, 32);
|
||||||
|
/// assert_eq!(sink.max_capacity(), 36);
|
||||||
|
/// ```
|
||||||
|
#[inline]
|
||||||
|
pub fn max_capacity(&self) -> usize {
|
||||||
|
self.sink_size.saturating_add(self.window_size)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the number of positions that would be evicted for a sequence of
|
||||||
|
/// `total_positions` tokens.
|
||||||
|
///
|
||||||
|
/// This equals `max(0, total_positions − max_capacity())`.
|
||||||
|
///
|
||||||
|
/// # Examples
|
||||||
|
///
|
||||||
|
/// ```rust
|
||||||
|
/// use rtx_inference::AttentionSinkEviction;
|
||||||
|
///
|
||||||
|
/// let sink = AttentionSinkEviction::new(4, 32);
|
||||||
|
/// assert_eq!(sink.evict_count(36), 0);
|
||||||
|
/// assert_eq!(sink.evict_count(37), 1);
|
||||||
|
/// assert_eq!(sink.evict_count(40), 4);
|
||||||
|
/// ```
|
||||||
|
#[inline]
|
||||||
|
pub fn evict_count(&self, total_positions: usize) -> usize {
|
||||||
|
total_positions.saturating_sub(self.max_capacity())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Selects the position indices to evict for a sequence of `total_positions`
|
||||||
|
/// tokens currently in the cache.
|
||||||
|
///
|
||||||
|
/// Returns a **sorted, ascending** `Vec<usize>` of position indices to drop.
|
||||||
|
/// Returns an empty `Vec` when `total_positions <= max_capacity()`.
|
||||||
|
///
|
||||||
|
/// The evicted range is always the contiguous middle band
|
||||||
|
/// `sink_size .. (total_positions − window_size)`, which are the positions
|
||||||
|
/// that are neither sink tokens nor part of the recent sliding window.
|
||||||
|
///
|
||||||
|
/// # Invariants
|
||||||
|
///
|
||||||
|
/// - Positions `0 .. sink_size` are **never** in the returned set.
|
||||||
|
/// - Positions `(total_positions − window_size) .. total_positions` are
|
||||||
|
/// **never** in the returned set.
|
||||||
|
/// - The returned `Vec` is sorted in ascending order.
|
||||||
|
/// - If `sink_size + window_size >= total_positions`, returns `[]`.
|
||||||
|
///
|
||||||
|
/// # Examples
|
||||||
|
///
|
||||||
|
/// ```rust
|
||||||
|
/// use rtx_inference::AttentionSinkEviction;
|
||||||
|
///
|
||||||
|
/// let sink = AttentionSinkEviction::new(2, 3);
|
||||||
|
/// // 7 positions, capacity 5 → evict exactly positions [2, 3].
|
||||||
|
/// assert_eq!(sink.select_evict_positions(7), vec![2usize, 3]);
|
||||||
|
/// ```
|
||||||
|
pub fn select_evict_positions(&self, total_positions: usize) -> Vec<usize> {
|
||||||
|
let count = self.evict_count(total_positions);
|
||||||
|
if count == 0 {
|
||||||
|
return Vec::new();
|
||||||
|
}
|
||||||
|
|
||||||
|
// The evictable range starts just after the sinks and ends just before
|
||||||
|
// the recent window. The range is always non-empty here because
|
||||||
|
// `count > 0` implies `total_positions > sink_size + window_size`, so
|
||||||
|
// `evict_start < evict_end`.
|
||||||
|
let evict_start = self.sink_size;
|
||||||
|
let evict_end = total_positions.saturating_sub(self.window_size);
|
||||||
|
|
||||||
|
// Both halves (sinks + window) together are fewer than total_positions,
|
||||||
|
// so the range [evict_start, evict_end) is well-defined and non-empty.
|
||||||
|
(evict_start..evict_end).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns `true` when position `pos` should be retained in the cache given
|
||||||
|
/// that the sequence currently has `total_positions` tokens.
|
||||||
|
///
|
||||||
|
/// A position is retained if it falls in the sink region `[0, sink_size)` OR
|
||||||
|
/// in the recent-window region `[total_positions − window_size, total_positions)`.
|
||||||
|
///
|
||||||
|
/// When `total_positions <= max_capacity()` every position is retained.
|
||||||
|
///
|
||||||
|
/// # Examples
|
||||||
|
///
|
||||||
|
/// ```rust
|
||||||
|
/// use rtx_inference::AttentionSinkEviction;
|
||||||
|
///
|
||||||
|
/// let sink = AttentionSinkEviction::new(4, 32);
|
||||||
|
/// // Sink region.
|
||||||
|
/// assert!(sink.should_retain(0, 100));
|
||||||
|
/// assert!(sink.should_retain(3, 100));
|
||||||
|
/// // Recent window.
|
||||||
|
/// assert!(sink.should_retain(68, 100));
|
||||||
|
/// assert!(sink.should_retain(99, 100));
|
||||||
|
/// // Middle position evicted.
|
||||||
|
/// assert!(!sink.should_retain(4, 100));
|
||||||
|
/// assert!(!sink.should_retain(67, 100));
|
||||||
|
/// ```
|
||||||
|
#[inline]
|
||||||
|
pub fn should_retain(&self, pos: usize, total_positions: usize) -> bool {
|
||||||
|
// Nothing to evict — keep everything.
|
||||||
|
if total_positions <= self.max_capacity() {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sink region.
|
||||||
|
if pos < self.sink_size {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recent window region.
|
||||||
|
let window_start = total_positions.saturating_sub(self.window_size);
|
||||||
|
pos >= window_start
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for AttentionSinkEviction {
|
||||||
|
/// Default: 4 sink tokens, 128-token sliding window (reasonable for LLMs).
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new(4, 128)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Tests ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod attention_sink_tests {
|
||||||
|
use super::AttentionSinkEviction;
|
||||||
|
|
||||||
|
// ── select_evict_positions / evict_count ─────────────────────────────────
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_no_eviction_when_under_capacity() {
|
||||||
|
let sink = AttentionSinkEviction::new(4, 32);
|
||||||
|
// Several values below max_capacity (36) must yield an empty vec.
|
||||||
|
for n in 0..36 {
|
||||||
|
let evicted = sink.select_evict_positions(n);
|
||||||
|
assert!(
|
||||||
|
evicted.is_empty(),
|
||||||
|
"expected empty for n={n}, got {evicted:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_no_eviction_at_exact_capacity() {
|
||||||
|
let sink = AttentionSinkEviction::new(4, 32);
|
||||||
|
assert_eq!(sink.max_capacity(), 36);
|
||||||
|
assert!(
|
||||||
|
sink.select_evict_positions(36).is_empty(),
|
||||||
|
"at exact capacity nothing should be evicted"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_single_eviction_one_over() {
|
||||||
|
let sink = AttentionSinkEviction::new(4, 32);
|
||||||
|
// 37 positions, capacity 36 → exactly 1 middle position (index 4) evicted.
|
||||||
|
let evicted = sink.select_evict_positions(37);
|
||||||
|
assert_eq!(evicted, vec![4usize], "one over capacity → evict exactly index 4");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_evicts_middle_positions_only() {
|
||||||
|
let sink = AttentionSinkEviction::new(4, 8);
|
||||||
|
// capacity = 12; use 20 positions → 8 positions evicted (indices 4..12).
|
||||||
|
let evicted = sink.select_evict_positions(20);
|
||||||
|
assert_eq!(evicted.len(), 8);
|
||||||
|
|
||||||
|
// Sink positions 0..4 must never appear.
|
||||||
|
for pos in 0..4usize {
|
||||||
|
assert!(!evicted.contains(&pos), "sink position {pos} must never be evicted");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recent window positions 12..20 must never appear.
|
||||||
|
for pos in 12..20usize {
|
||||||
|
assert!(!evicted.contains(&pos), "window position {pos} must never be evicted");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Every evicted index must be in the middle band [4, 12).
|
||||||
|
for &pos in &evicted {
|
||||||
|
assert!(
|
||||||
|
(4..12).contains(&pos),
|
||||||
|
"evicted position {pos} is outside the expected middle band [4,12)"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_evict_count_formula() {
|
||||||
|
let sink = AttentionSinkEviction::new(4, 32);
|
||||||
|
let cap = sink.max_capacity();
|
||||||
|
|
||||||
|
// Below capacity: always 0.
|
||||||
|
for n in 0..=cap {
|
||||||
|
assert_eq!(
|
||||||
|
sink.evict_count(n),
|
||||||
|
0,
|
||||||
|
"evict_count({n}) should be 0 when n <= cap"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Above capacity: n - cap.
|
||||||
|
for extra in 1..=100 {
|
||||||
|
let n = cap + extra;
|
||||||
|
assert_eq!(
|
||||||
|
sink.evict_count(n),
|
||||||
|
extra,
|
||||||
|
"evict_count({n}) should equal {extra}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── should_retain ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_should_retain_sink_positions() {
|
||||||
|
let sink = AttentionSinkEviction::new(4, 8);
|
||||||
|
let total = 100;
|
||||||
|
|
||||||
|
// All sink positions must be retained regardless of `total`.
|
||||||
|
for pos in 0..4usize {
|
||||||
|
assert!(
|
||||||
|
sink.should_retain(pos, total),
|
||||||
|
"sink position {pos} must be retained"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_should_retain_recent_positions() {
|
||||||
|
let sink = AttentionSinkEviction::new(4, 8);
|
||||||
|
let total = 100;
|
||||||
|
|
||||||
|
// Positions 92..100 (last 8) must always be retained.
|
||||||
|
for pos in 92..100usize {
|
||||||
|
assert!(
|
||||||
|
sink.should_retain(pos, total),
|
||||||
|
"recent position {pos} must be retained"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_should_not_retain_middle() {
|
||||||
|
let sink = AttentionSinkEviction::new(4, 8);
|
||||||
|
let total = 100;
|
||||||
|
|
||||||
|
// Positions [4, 92) are in the evictable middle band.
|
||||||
|
for pos in 4..92usize {
|
||||||
|
assert!(
|
||||||
|
!sink.should_retain(pos, total),
|
||||||
|
"middle position {pos} must not be retained when total={total}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── edge cases: zero sink / zero window ──────────────────────────────────
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_zero_sink_size() {
|
||||||
|
// sink_size=0 means no positions are pinned at the front.
|
||||||
|
// Only the last `window_size` positions are kept.
|
||||||
|
let sink = AttentionSinkEviction::new(0, 8);
|
||||||
|
let total = 20;
|
||||||
|
|
||||||
|
let evicted = sink.select_evict_positions(total);
|
||||||
|
// capacity=8, so 12 positions evicted: indices 0..12.
|
||||||
|
assert_eq!(evicted.len(), 12);
|
||||||
|
assert_eq!(evicted[0], 0, "first evicted position should be 0 when sink_size=0");
|
||||||
|
|
||||||
|
// The last 8 positions (12..20) must all be retained.
|
||||||
|
for pos in 12..20usize {
|
||||||
|
assert!(sink.should_retain(pos, total));
|
||||||
|
}
|
||||||
|
// Position 0 is NOT a sink and NOT in window → evicted.
|
||||||
|
assert!(!sink.should_retain(0, total));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_zero_window_size() {
|
||||||
|
// window_size=0 means only the sink tokens are retained.
|
||||||
|
let sink = AttentionSinkEviction::new(4, 0);
|
||||||
|
let total = 10;
|
||||||
|
|
||||||
|
let evicted = sink.select_evict_positions(total);
|
||||||
|
// capacity=4, so 6 positions evicted: indices 4..10.
|
||||||
|
assert_eq!(evicted.len(), 6);
|
||||||
|
assert_eq!(evicted, (4..10).collect::<Vec<usize>>());
|
||||||
|
|
||||||
|
// Sink positions still retained.
|
||||||
|
for pos in 0..4usize {
|
||||||
|
assert!(sink.should_retain(pos, total));
|
||||||
|
}
|
||||||
|
// Non-sink positions not retained.
|
||||||
|
for pos in 4..10usize {
|
||||||
|
assert!(!sink.should_retain(pos, total));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── large eviction ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_large_eviction() {
|
||||||
|
let sink = AttentionSinkEviction::new(4, 32);
|
||||||
|
let total = 1000;
|
||||||
|
|
||||||
|
let evicted = sink.select_evict_positions(total);
|
||||||
|
// capacity=36 → evict 964 positions (indices 4..968).
|
||||||
|
assert_eq!(evicted.len(), 964, "expected 964 evictions for 1000 positions");
|
||||||
|
assert_eq!(evicted[0], 4, "first evicted index should be 4 (just past sinks)");
|
||||||
|
assert_eq!(evicted[963], 967, "last evicted index should be 967");
|
||||||
|
|
||||||
|
// Sink positions never evicted.
|
||||||
|
for pos in 0..4usize {
|
||||||
|
assert!(!evicted.contains(&pos));
|
||||||
|
}
|
||||||
|
// Window positions never evicted.
|
||||||
|
for pos in 968..1000usize {
|
||||||
|
assert!(!evicted.contains(&pos));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_evicted_positions_are_sorted() {
|
||||||
|
let sink = AttentionSinkEviction::new(3, 5);
|
||||||
|
|
||||||
|
// Test multiple sequence lengths above capacity.
|
||||||
|
for total in [9, 20, 50, 100, 1000] {
|
||||||
|
let evicted = sink.select_evict_positions(total);
|
||||||
|
let mut sorted = evicted.clone();
|
||||||
|
sorted.sort_unstable();
|
||||||
|
assert_eq!(
|
||||||
|
evicted, sorted,
|
||||||
|
"evicted positions for total={total} must be sorted ascending"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── additional correctness checks ─────────────────────────────────────────
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_max_capacity_arithmetic() {
|
||||||
|
assert_eq!(AttentionSinkEviction::new(0, 0).max_capacity(), 0);
|
||||||
|
assert_eq!(AttentionSinkEviction::new(1, 0).max_capacity(), 1);
|
||||||
|
assert_eq!(AttentionSinkEviction::new(0, 1).max_capacity(), 1);
|
||||||
|
assert_eq!(AttentionSinkEviction::new(4, 32).max_capacity(), 36);
|
||||||
|
assert_eq!(AttentionSinkEviction::new(128, 512).max_capacity(), 640);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_default_parameters() {
|
||||||
|
let sink = AttentionSinkEviction::default();
|
||||||
|
assert_eq!(sink.sink_size, 4);
|
||||||
|
assert_eq!(sink.window_size, 128);
|
||||||
|
assert_eq!(sink.max_capacity(), 132);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_select_evict_positions_matches_should_retain() {
|
||||||
|
let sink = AttentionSinkEviction::new(4, 8);
|
||||||
|
let total = 30;
|
||||||
|
|
||||||
|
let evicted = sink.select_evict_positions(total);
|
||||||
|
|
||||||
|
for pos in 0..total {
|
||||||
|
let retained = sink.should_retain(pos, total);
|
||||||
|
let in_evict_list = evicted.contains(&pos);
|
||||||
|
assert!(
|
||||||
|
retained != in_evict_list,
|
||||||
|
"position {pos}: should_retain={retained} but in evict list={in_evict_list} — these must disagree"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -17,6 +17,7 @@
|
|||||||
//! Zobrist-hashed token prefixes to their KV page IDs, allowing requests
|
//! Zobrist-hashed token prefixes to their KV page IDs, allowing requests
|
||||||
//! sharing a common prefix (e.g. a system prompt) to skip recomputation.
|
//! sharing a common prefix (e.g. a system prompt) to skip recomputation.
|
||||||
|
|
||||||
|
mod attention_sink;
|
||||||
mod eviction;
|
mod eviction;
|
||||||
pub mod kv_quant;
|
pub mod kv_quant;
|
||||||
mod manager;
|
mod manager;
|
||||||
@@ -27,6 +28,7 @@ mod tracker;
|
|||||||
mod types;
|
mod types;
|
||||||
|
|
||||||
// Re-export all public types
|
// Re-export all public types
|
||||||
|
pub use attention_sink::AttentionSinkEviction;
|
||||||
pub use eviction::AttentionScoreEviction;
|
pub use eviction::AttentionScoreEviction;
|
||||||
pub use kv_quant::{KvCacheQuantMode, KvQuantizer, QuantizedKvBlock};
|
pub use kv_quant::{KvCacheQuantMode, KvQuantizer, QuantizedKvBlock};
|
||||||
pub use manager::PagedKvCacheManager;
|
pub use manager::PagedKvCacheManager;
|
||||||
|
|||||||
@@ -0,0 +1,573 @@
|
|||||||
|
//! Chunked prefill scheduler — interleaves prefill chunks with decode steps.
|
||||||
|
//!
|
||||||
|
//! For long prompts, processing the entire prompt in one shot causes quadratic
|
||||||
|
//! attention memory (`O(seq^2)`) and starves decode requests waiting in queue.
|
||||||
|
//! Chunked prefill (cf. arXiv:2309.06180) solves this by:
|
||||||
|
//!
|
||||||
|
//! - Splitting each prompt into fixed-size *chunks* (default: 512 tokens).
|
||||||
|
//! - Scheduling one chunk per step, optionally co-scheduling active decode
|
||||||
|
//! requests in the same GPU step.
|
||||||
|
//! - Bounding first-token latency for long prompts while sustaining throughput.
|
||||||
|
//!
|
||||||
|
//! # Example
|
||||||
|
//!
|
||||||
|
//! ```rust
|
||||||
|
//! use rtx_inference::chunked_prefill::{
|
||||||
|
//! ChunkedPrefillConfig, ChunkedPrefillScheduler, PrefillChunkState,
|
||||||
|
//! };
|
||||||
|
//!
|
||||||
|
//! let config = ChunkedPrefillConfig {
|
||||||
|
//! chunk_size: 512,
|
||||||
|
//! max_decode_tokens: 128,
|
||||||
|
//! interleave_decode: true,
|
||||||
|
//! };
|
||||||
|
//!
|
||||||
|
//! let mut scheduler = ChunkedPrefillScheduler::new(config);
|
||||||
|
//!
|
||||||
|
//! // Enqueue a 1024-token prompt.
|
||||||
|
//! scheduler.enqueue(42, 1024);
|
||||||
|
//!
|
||||||
|
//! // First step: 512 prefill tokens + up to 128 decode tokens.
|
||||||
|
//! let step = scheduler.next_step(8);
|
||||||
|
//! assert_eq!(step.prefill_tokens, 512);
|
||||||
|
//! assert_eq!(step.decode_tokens, 8); // min(128, 8)
|
||||||
|
//! assert_eq!(step.prefill_request_id, Some(42));
|
||||||
|
//!
|
||||||
|
//! // Second step: remaining 512 tokens.
|
||||||
|
//! let step = scheduler.next_step(8);
|
||||||
|
//! assert_eq!(step.prefill_tokens, 512);
|
||||||
|
//!
|
||||||
|
//! // Prefill is now complete; drain and confirm.
|
||||||
|
//! assert_eq!(scheduler.drain_completed(), 1);
|
||||||
|
//! assert!(scheduler.is_idle());
|
||||||
|
//! ```
|
||||||
|
|
||||||
|
/// Configuration for chunked prefill scheduling.
|
||||||
|
///
|
||||||
|
/// All fields have sensible defaults via [`Default`]:
|
||||||
|
/// - `chunk_size = 512`
|
||||||
|
/// - `max_decode_tokens = 128`
|
||||||
|
/// - `interleave_decode = true`
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct ChunkedPrefillConfig {
|
||||||
|
/// Maximum number of tokens to process per prefill chunk.
|
||||||
|
///
|
||||||
|
/// Smaller values reduce peak attention memory at the cost of more
|
||||||
|
/// scheduling overhead. 512 is a good default for 80 GB A100s.
|
||||||
|
pub chunk_size: usize,
|
||||||
|
|
||||||
|
/// Maximum decode tokens to process alongside a prefill chunk.
|
||||||
|
///
|
||||||
|
/// When `interleave_decode` is `true`, each step runs at most this many
|
||||||
|
/// decode tokens in parallel with the prefill chunk. The actual count is
|
||||||
|
/// `min(max_decode_tokens, active_decode_count)`.
|
||||||
|
pub max_decode_tokens: usize,
|
||||||
|
|
||||||
|
/// Whether to interleave decode requests with prefill chunks.
|
||||||
|
///
|
||||||
|
/// Set to `false` to dedicate each GPU step entirely to prefill (useful
|
||||||
|
/// when decode latency is not a concern, e.g. offline batch processing).
|
||||||
|
pub interleave_decode: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for ChunkedPrefillConfig {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
chunk_size: 512,
|
||||||
|
max_decode_tokens: 128,
|
||||||
|
interleave_decode: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Progress state for a single request that is being chunked through prefill.
|
||||||
|
///
|
||||||
|
/// Created by [`ChunkedPrefillScheduler::enqueue`] and updated by each call
|
||||||
|
/// to [`ChunkedPrefillScheduler::next_step`].
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct PrefillChunkState {
|
||||||
|
/// Unique identifier supplied by the caller (mirrors request IDs in the
|
||||||
|
/// surrounding scheduler infrastructure).
|
||||||
|
pub request_id: u64,
|
||||||
|
|
||||||
|
/// Total prompt tokens for this request.
|
||||||
|
pub total_tokens: usize,
|
||||||
|
|
||||||
|
/// How many tokens have already been prefilled.
|
||||||
|
pub processed_tokens: usize,
|
||||||
|
|
||||||
|
/// `true` once `processed_tokens == total_tokens`.
|
||||||
|
pub is_complete: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PrefillChunkState {
|
||||||
|
/// Create a new state for `request_id` with `total_tokens` to process.
|
||||||
|
///
|
||||||
|
/// # Panics
|
||||||
|
///
|
||||||
|
/// Panics in debug builds if `total_tokens == 0`, which would be a
|
||||||
|
/// programming error (zero-length prompts should be rejected upstream).
|
||||||
|
#[must_use]
|
||||||
|
pub fn new(request_id: u64, total_tokens: usize) -> Self {
|
||||||
|
debug_assert!(total_tokens > 0, "total_tokens must be > 0");
|
||||||
|
Self {
|
||||||
|
request_id,
|
||||||
|
total_tokens,
|
||||||
|
processed_tokens: 0,
|
||||||
|
is_complete: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Advance by `n` tokens.
|
||||||
|
///
|
||||||
|
/// Clamps to `total_tokens` so callers do not need to guard against
|
||||||
|
/// overshooting. Sets `is_complete` when all tokens are processed.
|
||||||
|
pub fn advance(&mut self, n: usize) {
|
||||||
|
self.processed_tokens = self.processed_tokens.saturating_add(n).min(self.total_tokens);
|
||||||
|
self.is_complete = self.processed_tokens >= self.total_tokens;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Number of tokens still to be prefilled.
|
||||||
|
#[must_use]
|
||||||
|
pub fn remaining(&self) -> usize {
|
||||||
|
self.total_tokens.saturating_sub(self.processed_tokens)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fraction of the prompt that has been processed, in `0.0..=1.0`.
|
||||||
|
///
|
||||||
|
/// Returns `1.0` for zero-length prompts to avoid a division-by-zero.
|
||||||
|
#[must_use]
|
||||||
|
pub fn progress(&self) -> f32 {
|
||||||
|
if self.total_tokens == 0 {
|
||||||
|
return 1.0;
|
||||||
|
}
|
||||||
|
self.processed_tokens as f32 / self.total_tokens as f32
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Size of the next chunk to schedule, given `config`.
|
||||||
|
///
|
||||||
|
/// Always `<= config.chunk_size` and `<= self.remaining()`.
|
||||||
|
/// Returns `0` when `is_complete`.
|
||||||
|
#[must_use]
|
||||||
|
pub fn next_chunk_size(&self, config: &ChunkedPrefillConfig) -> usize {
|
||||||
|
self.remaining().min(config.chunk_size)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One scheduling decision returned by [`ChunkedPrefillScheduler::next_step`].
|
||||||
|
///
|
||||||
|
/// The executing engine should:
|
||||||
|
/// 1. Run `prefill_tokens` tokens of prompt `prefill_request_id`.
|
||||||
|
/// 2. Run `decode_tokens` tokens for the currently active decode requests.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct ChunkedStep {
|
||||||
|
/// Request whose prefill chunk is scheduled this step.
|
||||||
|
///
|
||||||
|
/// `None` when there is no active prefill (pure decode step).
|
||||||
|
pub prefill_request_id: Option<u64>,
|
||||||
|
|
||||||
|
/// Number of prefill tokens to process this step.
|
||||||
|
///
|
||||||
|
/// `0` when there is no active prefill request.
|
||||||
|
pub prefill_tokens: usize,
|
||||||
|
|
||||||
|
/// Number of decode tokens to process this step.
|
||||||
|
///
|
||||||
|
/// `0` when `interleave_decode` is `false` or `active_decode_count == 0`.
|
||||||
|
pub decode_tokens: usize,
|
||||||
|
|
||||||
|
/// Monotonically increasing step counter (zero-based).
|
||||||
|
pub step_index: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Scheduler that splits long prompts into fixed-size chunks and optionally
|
||||||
|
/// interleaves decode requests within each step.
|
||||||
|
///
|
||||||
|
/// # Design notes
|
||||||
|
///
|
||||||
|
/// - Uses a `Vec<PrefillChunkState>` (not a `VecDeque`) because the active
|
||||||
|
/// set is typically very small (< 10 requests). FIFO ordering is preserved
|
||||||
|
/// by always picking `active[0]` as the head.
|
||||||
|
/// - Completed states are retained until [`drain_completed`] is called, which
|
||||||
|
/// mirrors the explicit lifecycle in production schedulers.
|
||||||
|
/// - All methods are `&mut self`; the struct is **not** `Send` by default
|
||||||
|
/// because its callers embed it inside a larger struct that owns the lock.
|
||||||
|
pub struct ChunkedPrefillScheduler {
|
||||||
|
config: ChunkedPrefillConfig,
|
||||||
|
/// Requests currently being chunked (includes completed ones until
|
||||||
|
/// [`drain_completed`] is called).
|
||||||
|
active: Vec<PrefillChunkState>,
|
||||||
|
/// Monotonically increasing step counter.
|
||||||
|
step_counter: usize,
|
||||||
|
/// Cumulative number of prefill chunks emitted.
|
||||||
|
chunks_scheduled: usize,
|
||||||
|
/// Cumulative number of tokens prefilled across all requests.
|
||||||
|
tokens_prefilled: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ChunkedPrefillScheduler {
|
||||||
|
/// Create a new scheduler with the given configuration.
|
||||||
|
#[must_use]
|
||||||
|
pub fn new(config: ChunkedPrefillConfig) -> Self {
|
||||||
|
Self {
|
||||||
|
config,
|
||||||
|
active: Vec::new(),
|
||||||
|
step_counter: 0,
|
||||||
|
chunks_scheduled: 0,
|
||||||
|
tokens_prefilled: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Enqueue `request_id` for chunked prefill of `total_tokens` prompt tokens.
|
||||||
|
///
|
||||||
|
/// The request will be scheduled FIFO after any currently active prefill
|
||||||
|
/// requests.
|
||||||
|
pub fn enqueue(&mut self, request_id: u64, total_tokens: usize) {
|
||||||
|
self.active.push(PrefillChunkState::new(request_id, total_tokens));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Return the next [`ChunkedStep`] to execute.
|
||||||
|
///
|
||||||
|
/// **Prefill logic**: picks the first incomplete request in `active` and
|
||||||
|
/// schedules its next chunk. The state is advanced immediately so the
|
||||||
|
/// caller does not need to report completion back.
|
||||||
|
///
|
||||||
|
/// **Decode interleave**: when `config.interleave_decode` is `true` and
|
||||||
|
/// `active_decode_count > 0`, `decode_tokens` is set to
|
||||||
|
/// `min(config.max_decode_tokens, active_decode_count)`.
|
||||||
|
///
|
||||||
|
/// Returns a **pure decode step** (prefill fields zeroed, `prefill_request_id
|
||||||
|
/// = None`) when there is no active prefill work.
|
||||||
|
pub fn next_step(&mut self, active_decode_count: usize) -> ChunkedStep {
|
||||||
|
let step_index = self.step_counter;
|
||||||
|
self.step_counter += 1;
|
||||||
|
|
||||||
|
// Decode token count is independent of whether prefill is active.
|
||||||
|
let decode_tokens = if self.config.interleave_decode {
|
||||||
|
active_decode_count.min(self.config.max_decode_tokens)
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
};
|
||||||
|
|
||||||
|
// Find the first incomplete prefill request.
|
||||||
|
let head = self.active.iter_mut().find(|s| !s.is_complete);
|
||||||
|
|
||||||
|
match head {
|
||||||
|
Some(state) => {
|
||||||
|
let chunk = state.next_chunk_size(&self.config);
|
||||||
|
let request_id = state.request_id;
|
||||||
|
state.advance(chunk);
|
||||||
|
|
||||||
|
self.chunks_scheduled += 1;
|
||||||
|
self.tokens_prefilled += chunk;
|
||||||
|
|
||||||
|
ChunkedStep {
|
||||||
|
prefill_request_id: Some(request_id),
|
||||||
|
prefill_tokens: chunk,
|
||||||
|
decode_tokens,
|
||||||
|
step_index,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
// No active prefill — pure decode step.
|
||||||
|
ChunkedStep {
|
||||||
|
prefill_request_id: None,
|
||||||
|
prefill_tokens: 0,
|
||||||
|
decode_tokens,
|
||||||
|
step_index,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Number of requests currently tracked (including completed ones not yet
|
||||||
|
/// drained).
|
||||||
|
#[must_use]
|
||||||
|
pub fn active_count(&self) -> usize {
|
||||||
|
self.active.len()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `true` when there are no incomplete prefill requests.
|
||||||
|
///
|
||||||
|
/// Note: returns `true` on an empty scheduler (nothing to do).
|
||||||
|
#[must_use]
|
||||||
|
pub fn is_idle(&self) -> bool {
|
||||||
|
self.active.iter().all(|s| s.is_complete)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Remove all completed requests and return how many were removed.
|
||||||
|
pub fn drain_completed(&mut self) -> usize {
|
||||||
|
let before = self.active.len();
|
||||||
|
self.active.retain(|s| !s.is_complete);
|
||||||
|
before - self.active.len()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Cumulative number of prefill chunks emitted by [`next_step`].
|
||||||
|
#[must_use]
|
||||||
|
pub fn chunks_scheduled(&self) -> usize {
|
||||||
|
self.chunks_scheduled
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Cumulative number of tokens prefilled across all requests.
|
||||||
|
#[must_use]
|
||||||
|
pub fn tokens_prefilled(&self) -> usize {
|
||||||
|
self.tokens_prefilled
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Unit tests ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn default_config() -> ChunkedPrefillConfig {
|
||||||
|
ChunkedPrefillConfig::default()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
// PrefillChunkState tests
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// A 100-token request with chunk_size=512 should complete in exactly one
|
||||||
|
/// step.
|
||||||
|
#[test]
|
||||||
|
fn test_single_short_request_one_chunk() {
|
||||||
|
let config = default_config(); // chunk_size = 512
|
||||||
|
let mut scheduler = ChunkedPrefillScheduler::new(config);
|
||||||
|
scheduler.enqueue(1, 100);
|
||||||
|
|
||||||
|
let step = scheduler.next_step(0);
|
||||||
|
assert_eq!(step.prefill_request_id, Some(1));
|
||||||
|
assert_eq!(step.prefill_tokens, 100);
|
||||||
|
assert_eq!(step.step_index, 0);
|
||||||
|
|
||||||
|
// After one step the request is complete.
|
||||||
|
assert!(scheduler.is_idle());
|
||||||
|
assert_eq!(scheduler.tokens_prefilled(), 100);
|
||||||
|
assert_eq!(scheduler.chunks_scheduled(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A 1500-token request with chunk_size=512 must require exactly 3 steps:
|
||||||
|
/// 512 + 512 + 476 = 1500.
|
||||||
|
#[test]
|
||||||
|
fn test_long_request_multiple_chunks() {
|
||||||
|
let config = default_config(); // chunk_size = 512
|
||||||
|
let mut scheduler = ChunkedPrefillScheduler::new(config);
|
||||||
|
scheduler.enqueue(7, 1500);
|
||||||
|
|
||||||
|
let s0 = scheduler.next_step(0);
|
||||||
|
assert_eq!(s0.prefill_tokens, 512);
|
||||||
|
assert!(!scheduler.is_idle());
|
||||||
|
|
||||||
|
let s1 = scheduler.next_step(0);
|
||||||
|
assert_eq!(s1.prefill_tokens, 512);
|
||||||
|
assert!(!scheduler.is_idle());
|
||||||
|
|
||||||
|
let s2 = scheduler.next_step(0);
|
||||||
|
assert_eq!(s2.prefill_tokens, 476); // 1500 - 512 - 512
|
||||||
|
assert!(scheduler.is_idle());
|
||||||
|
|
||||||
|
assert_eq!(scheduler.tokens_prefilled(), 1500);
|
||||||
|
assert_eq!(scheduler.chunks_scheduled(), 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// After advancing 256 of 1024 tokens, progress() should be exactly 0.25.
|
||||||
|
#[test]
|
||||||
|
fn test_progress_fraction() {
|
||||||
|
let mut state = PrefillChunkState::new(99, 1024);
|
||||||
|
state.advance(256);
|
||||||
|
let got = state.progress();
|
||||||
|
assert!(
|
||||||
|
(got - 0.25_f32).abs() < 1e-6,
|
||||||
|
"expected 0.25, got {got}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// remaining() must equal total_tokens - processed_tokens after advancing.
|
||||||
|
#[test]
|
||||||
|
fn test_remaining_tokens() {
|
||||||
|
let mut state = PrefillChunkState::new(3, 800);
|
||||||
|
state.advance(300);
|
||||||
|
assert_eq!(state.remaining(), 500);
|
||||||
|
state.advance(500);
|
||||||
|
assert_eq!(state.remaining(), 0);
|
||||||
|
assert!(state.is_complete);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// When only a partial chunk remains, next_chunk_size must return the
|
||||||
|
/// smaller remainder rather than the full chunk_size.
|
||||||
|
#[test]
|
||||||
|
fn test_next_chunk_size_last_chunk() {
|
||||||
|
let config = ChunkedPrefillConfig {
|
||||||
|
chunk_size: 512,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let mut state = PrefillChunkState::new(5, 700);
|
||||||
|
state.advance(512); // first chunk consumed
|
||||||
|
let next = state.next_chunk_size(&config);
|
||||||
|
assert_eq!(next, 188); // 700 - 512 = 188 < 512
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
// Decode interleave tests
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// With interleave_decode=true, decode_tokens should be
|
||||||
|
/// min(max_decode_tokens, active_decode_count).
|
||||||
|
#[test]
|
||||||
|
fn test_interleave_decode_tokens() {
|
||||||
|
let config = ChunkedPrefillConfig {
|
||||||
|
chunk_size: 512,
|
||||||
|
max_decode_tokens: 128,
|
||||||
|
interleave_decode: true,
|
||||||
|
};
|
||||||
|
let mut scheduler = ChunkedPrefillScheduler::new(config);
|
||||||
|
scheduler.enqueue(10, 1000);
|
||||||
|
|
||||||
|
let step = scheduler.next_step(5); // 5 active decode requests
|
||||||
|
assert_eq!(step.decode_tokens, 5); // min(128, 5)
|
||||||
|
|
||||||
|
let step2 = scheduler.next_step(200); // more than max_decode_tokens
|
||||||
|
assert_eq!(step2.decode_tokens, 128); // clamped to max
|
||||||
|
}
|
||||||
|
|
||||||
|
/// With interleave_decode=false, decode_tokens must always be 0.
|
||||||
|
#[test]
|
||||||
|
fn test_no_decode_when_disabled() {
|
||||||
|
let config = ChunkedPrefillConfig {
|
||||||
|
chunk_size: 512,
|
||||||
|
max_decode_tokens: 128,
|
||||||
|
interleave_decode: false,
|
||||||
|
};
|
||||||
|
let mut scheduler = ChunkedPrefillScheduler::new(config);
|
||||||
|
scheduler.enqueue(20, 600);
|
||||||
|
|
||||||
|
for _ in 0..2 {
|
||||||
|
let step = scheduler.next_step(99);
|
||||||
|
assert_eq!(step.decode_tokens, 0, "decode must be 0 when disabled");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
// Lifecycle / bookkeeping tests
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// After a request is fully prefilled, drain_completed() should remove it
|
||||||
|
/// and return 1.
|
||||||
|
#[test]
|
||||||
|
fn test_drain_completed_removes_done_requests() {
|
||||||
|
let config = default_config();
|
||||||
|
let mut scheduler = ChunkedPrefillScheduler::new(config);
|
||||||
|
scheduler.enqueue(42, 100); // completes in one step (< 512)
|
||||||
|
|
||||||
|
scheduler.next_step(0); // completes the request
|
||||||
|
assert!(scheduler.is_idle());
|
||||||
|
|
||||||
|
let removed = scheduler.drain_completed();
|
||||||
|
assert_eq!(removed, 1);
|
||||||
|
assert_eq!(scheduler.active_count(), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// active_count() must reflect the number of tracked requests (including
|
||||||
|
/// completed ones not yet drained).
|
||||||
|
#[test]
|
||||||
|
fn test_active_count() {
|
||||||
|
let config = default_config();
|
||||||
|
let mut scheduler = ChunkedPrefillScheduler::new(config);
|
||||||
|
assert_eq!(scheduler.active_count(), 0);
|
||||||
|
|
||||||
|
scheduler.enqueue(1, 50);
|
||||||
|
scheduler.enqueue(2, 50);
|
||||||
|
assert_eq!(scheduler.active_count(), 2);
|
||||||
|
|
||||||
|
// Complete both requests.
|
||||||
|
scheduler.next_step(0); // req 1 done
|
||||||
|
scheduler.next_step(0); // req 2 done
|
||||||
|
|
||||||
|
// Still 2 until explicitly drained.
|
||||||
|
assert_eq!(scheduler.active_count(), 2);
|
||||||
|
scheduler.drain_completed();
|
||||||
|
assert_eq!(scheduler.active_count(), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// is_idle() must return true once all requests have been drained.
|
||||||
|
#[test]
|
||||||
|
fn test_idle_after_all_complete() {
|
||||||
|
let config = default_config();
|
||||||
|
let mut scheduler = ChunkedPrefillScheduler::new(config);
|
||||||
|
scheduler.enqueue(55, 256);
|
||||||
|
|
||||||
|
assert!(!scheduler.is_idle());
|
||||||
|
scheduler.next_step(0); // chunk_size=512 > 256, so completes in one step
|
||||||
|
assert!(scheduler.is_idle());
|
||||||
|
|
||||||
|
scheduler.drain_completed();
|
||||||
|
// Empty scheduler is also idle.
|
||||||
|
assert!(scheduler.is_idle());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// step_index must increment by 1 for every call to next_step.
|
||||||
|
#[test]
|
||||||
|
fn test_step_index_increments() {
|
||||||
|
let config = default_config();
|
||||||
|
let mut scheduler = ChunkedPrefillScheduler::new(config);
|
||||||
|
scheduler.enqueue(100, 2000);
|
||||||
|
|
||||||
|
for expected_idx in 0..5_usize {
|
||||||
|
let step = scheduler.next_step(0);
|
||||||
|
assert_eq!(
|
||||||
|
step.step_index, expected_idx,
|
||||||
|
"step_index should be {expected_idx}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// tokens_prefilled() must equal the sum of all chunk sizes emitted.
|
||||||
|
#[test]
|
||||||
|
fn test_cumulative_tokens_prefilled() {
|
||||||
|
let config = ChunkedPrefillConfig {
|
||||||
|
chunk_size: 200,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let mut scheduler = ChunkedPrefillScheduler::new(config);
|
||||||
|
// Request A: 400 tokens → 2 chunks of 200 each.
|
||||||
|
scheduler.enqueue(1, 400);
|
||||||
|
// Request B: 300 tokens → 2 chunks of 200+100.
|
||||||
|
scheduler.enqueue(2, 300);
|
||||||
|
|
||||||
|
// Drive all chunks to completion.
|
||||||
|
while !scheduler.is_idle() {
|
||||||
|
scheduler.next_step(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_eq!(scheduler.tokens_prefilled(), 700); // 400 + 300
|
||||||
|
assert_eq!(scheduler.chunks_scheduled(), 4); // 2 + 2
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
// Extra edge-case tests
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// advance() must clamp and not overflow even if n >> total_tokens.
|
||||||
|
#[test]
|
||||||
|
fn test_advance_clamps_to_total() {
|
||||||
|
let mut state = PrefillChunkState::new(9, 100);
|
||||||
|
state.advance(999); // deliberately overshooting
|
||||||
|
assert_eq!(state.processed_tokens, 100);
|
||||||
|
assert!(state.is_complete);
|
||||||
|
assert_eq!(state.remaining(), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A pure decode step (no enqueued prefill) must have zero prefill fields.
|
||||||
|
#[test]
|
||||||
|
fn test_pure_decode_step_when_no_prefill() {
|
||||||
|
let config = default_config();
|
||||||
|
let mut scheduler = ChunkedPrefillScheduler::new(config);
|
||||||
|
|
||||||
|
let step = scheduler.next_step(10);
|
||||||
|
assert_eq!(step.prefill_request_id, None);
|
||||||
|
assert_eq!(step.prefill_tokens, 0);
|
||||||
|
assert_eq!(step.decode_tokens, 10);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -17,6 +17,8 @@
|
|||||||
|
|
||||||
pub mod batch_processor;
|
pub mod batch_processor;
|
||||||
pub mod cache;
|
pub mod cache;
|
||||||
|
pub mod chunked_prefill;
|
||||||
|
pub use chunked_prefill::{ChunkedPrefillConfig, ChunkedPrefillScheduler, ChunkedStep, PrefillChunkState};
|
||||||
pub mod inference_graph;
|
pub mod inference_graph;
|
||||||
pub use inference_graph::{InferenceGraphCapture, StepMode};
|
pub use inference_graph::{InferenceGraphCapture, StepMode};
|
||||||
pub mod engine;
|
pub mod engine;
|
||||||
@@ -30,8 +32,8 @@ pub mod speculative;
|
|||||||
|
|
||||||
// Re-export key types for convenience
|
// Re-export key types for convenience
|
||||||
pub use cache::{
|
pub use cache::{
|
||||||
AttentionScoreEviction, CacheKey, CachePage, CacheStats, EvictionPolicy, KvCacheConfig,
|
AttentionScoreEviction, AttentionSinkEviction, CacheKey, CachePage, CacheStats, EvictionPolicy,
|
||||||
MemoryTier, PageId, PagedKvCache, PagedKvCacheManager, PrefixIndex,
|
KvCacheConfig, MemoryTier, PageId, PagedKvCache, PagedKvCacheManager, PrefixIndex,
|
||||||
};
|
};
|
||||||
pub use engine::{
|
pub use engine::{
|
||||||
HealthStatus, InferenceEngine, InferenceEngineConfig, MemoryStats, ModelConfig, ModelHealth,
|
HealthStatus, InferenceEngine, InferenceEngineConfig, MemoryStats, ModelConfig, ModelHealth,
|
||||||
|
|||||||
@@ -0,0 +1,563 @@
|
|||||||
|
//! Per-layer learning rate decay for discriminative fine-tuning.
|
||||||
|
//!
|
||||||
|
//! Implements the discriminative fine-tuning technique from Howard & Ruder (2018)
|
||||||
|
//! "Universal Language Model Fine-tuning for Text Classification" (ULMFiT).
|
||||||
|
//!
|
||||||
|
//! Lower transformer layers (closer to input embeddings) receive smaller learning
|
||||||
|
//! rates, while deeper layers receive the full base learning rate. This stabilises
|
||||||
|
//! fine-tuning because lower layers encode general features that should change
|
||||||
|
//! slowly, whereas higher layers are task-specific and can tolerate larger updates.
|
||||||
|
//!
|
||||||
|
//! # Formula
|
||||||
|
//!
|
||||||
|
//! ```text
|
||||||
|
//! lr(depth) = clamp(base_lr * decay_rate ^ (num_layers - 1 - depth), min_lr, base_lr)
|
||||||
|
//! ```
|
||||||
|
//!
|
||||||
|
//! Where `depth = 0` is the shallowest layer (closest to input/embeddings) and
|
||||||
|
//! `depth = num_layers - 1` is the deepest layer (closest to the output head).
|
||||||
|
//!
|
||||||
|
//! # Example
|
||||||
|
//!
|
||||||
|
//! ```rust
|
||||||
|
//! use rtx_transformers::optimizers::layer_lr_decay::{LayerLrDecayConfig, LayerLrDecayBuilder};
|
||||||
|
//!
|
||||||
|
//! let config = LayerLrDecayConfig::new(1e-3, 0.9, 12);
|
||||||
|
//! let builder = LayerLrDecayBuilder::new(config);
|
||||||
|
//!
|
||||||
|
//! let param_names = &[
|
||||||
|
//! "embeddings.weight",
|
||||||
|
//! "layer.0.weight",
|
||||||
|
//! "layer.11.weight",
|
||||||
|
//! "lm_head.weight",
|
||||||
|
//! ];
|
||||||
|
//! let groups = builder.assign(param_names);
|
||||||
|
//! for g in &groups {
|
||||||
|
//! println!("{}: lr = {:.2e}", g.name, g.lr);
|
||||||
|
//! }
|
||||||
|
//! ```
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// LayerLrDecayConfig
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Configuration for per-layer learning rate decay.
|
||||||
|
///
|
||||||
|
/// Controls how the learning rate decays from the output layer toward the
|
||||||
|
/// embedding/input layer of a transformer model.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct LayerLrDecayConfig {
|
||||||
|
/// Base (deepest/top layer) learning rate.
|
||||||
|
pub base_lr: f32,
|
||||||
|
/// Decay multiplier applied per step toward the input (`0.0..=1.0`).
|
||||||
|
///
|
||||||
|
/// `lr_for_layer(depth) = base_lr * decay_rate ^ (num_layers - 1 - depth)`
|
||||||
|
pub decay_rate: f32,
|
||||||
|
/// Total number of transformer layers (excluding embedding/head).
|
||||||
|
pub num_layers: usize,
|
||||||
|
/// Minimum LR floor — computed values are clamped to at least this value.
|
||||||
|
///
|
||||||
|
/// Defaults to `1e-7`.
|
||||||
|
pub min_lr: f32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl LayerLrDecayConfig {
|
||||||
|
/// Create a new configuration with the given base LR, decay rate, and layer count.
|
||||||
|
///
|
||||||
|
/// `min_lr` is initialised to `1e-7`.
|
||||||
|
#[must_use]
|
||||||
|
pub fn new(base_lr: f32, decay_rate: f32, num_layers: usize) -> Self {
|
||||||
|
Self {
|
||||||
|
base_lr,
|
||||||
|
decay_rate,
|
||||||
|
num_layers,
|
||||||
|
min_lr: 1e-7,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Compute the learning rate for a given layer `depth`.
|
||||||
|
///
|
||||||
|
/// `depth = 0` is the shallowest layer (closest to input/embeddings).
|
||||||
|
/// `depth = num_layers - 1` is the deepest layer (closest to the output head).
|
||||||
|
///
|
||||||
|
/// The result is clamped to `[min_lr, base_lr]`.
|
||||||
|
#[must_use]
|
||||||
|
pub fn lr_for_layer(&self, depth: usize) -> f32 {
|
||||||
|
if self.num_layers == 0 {
|
||||||
|
return self.base_lr;
|
||||||
|
}
|
||||||
|
// exponent = 0 at the top layer (depth = num_layers-1), increases toward input
|
||||||
|
let exponent = (self.num_layers - 1).saturating_sub(depth) as i32;
|
||||||
|
let raw = self.base_lr * self.decay_rate.powi(exponent);
|
||||||
|
raw.max(self.min_lr).min(self.base_lr)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Compute the learning rate for the embedding layer.
|
||||||
|
///
|
||||||
|
/// Embeddings are treated as one level below `depth = 0`, so the exponent is
|
||||||
|
/// `num_layers` (one more than for `depth = 0`).
|
||||||
|
#[must_use]
|
||||||
|
pub fn lr_for_embeddings(&self) -> f32 {
|
||||||
|
let exponent = self.num_layers as i32;
|
||||||
|
let raw = self.base_lr * self.decay_rate.powi(exponent);
|
||||||
|
raw.max(self.min_lr).min(self.base_lr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// ParamGroupLR
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Assignment of a learning rate to a named parameter group.
|
||||||
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
|
pub struct ParamGroupLR {
|
||||||
|
/// Parameter group name (e.g. `"layer.0"`, `"layer.11"`, `"embeddings"`).
|
||||||
|
pub name: String,
|
||||||
|
/// Assigned learning rate.
|
||||||
|
pub lr: f32,
|
||||||
|
/// Layer depth this group corresponds to (`None` for embeddings/head).
|
||||||
|
pub depth: Option<usize>,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Layer name parsing
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Layer prefixes recognised when extracting a depth index from a parameter name.
|
||||||
|
const LAYER_PREFIXES: &[&str] = &["layer", "layers", "h", "blocks", "block", "encoder_layer"];
|
||||||
|
|
||||||
|
/// Try to extract a numeric layer depth from a dot-separated parameter name.
|
||||||
|
///
|
||||||
|
/// Strategy: scan each consecutive pair of dot-separated segments where the first
|
||||||
|
/// segment matches one of [`LAYER_PREFIXES`] and the second parses as a `usize`.
|
||||||
|
/// Bracket notation (`name[N]`) is normalised to dots before scanning.
|
||||||
|
///
|
||||||
|
/// Returns `Some(N)` on the first match, `None` if no layer depth is found.
|
||||||
|
fn extract_layer_depth(name: &str) -> Option<usize> {
|
||||||
|
// Normalise bracket notation: "h[3].weight" → "h.3..weight"
|
||||||
|
let normalised: String = name
|
||||||
|
.chars()
|
||||||
|
.map(|c| match c {
|
||||||
|
'[' | ']' => '.',
|
||||||
|
other => other,
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let parts: Vec<&str> = normalised
|
||||||
|
.split('.')
|
||||||
|
.filter(|s| !s.is_empty())
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
for window in parts.windows(2) {
|
||||||
|
let prefix = window[0];
|
||||||
|
let candidate = window[1];
|
||||||
|
if LAYER_PREFIXES.contains(&prefix) {
|
||||||
|
if let Ok(n) = candidate.parse::<usize>() {
|
||||||
|
return Some(n);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns `true` if the parameter name refers to an embedding layer.
|
||||||
|
fn is_embedding_name(name: &str) -> bool {
|
||||||
|
let lower = name.to_lowercase();
|
||||||
|
lower.contains("embed")
|
||||||
|
|| lower.contains("wte")
|
||||||
|
|| lower.contains("wpe")
|
||||||
|
|| lower.contains("token_embedding")
|
||||||
|
|| lower.contains("position_embedding")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// LayerLrDecayBuilder
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Builds per-layer LR assignments from a list of parameter names.
|
||||||
|
///
|
||||||
|
/// Matches names against common transformer layer patterns to extract a numeric
|
||||||
|
/// depth, then applies [`LayerLrDecayConfig::lr_for_layer`]. Embedding-like
|
||||||
|
/// names use [`LayerLrDecayConfig::lr_for_embeddings`]; everything else
|
||||||
|
/// (heads, final norms, classifiers) receives `base_lr`.
|
||||||
|
pub struct LayerLrDecayBuilder {
|
||||||
|
config: LayerLrDecayConfig,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl LayerLrDecayBuilder {
|
||||||
|
/// Create a new builder from the given config.
|
||||||
|
#[must_use]
|
||||||
|
pub fn new(config: LayerLrDecayConfig) -> Self {
|
||||||
|
Self { config }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Assign learning rates to a slice of parameter names.
|
||||||
|
///
|
||||||
|
/// Returns one [`ParamGroupLR`] per input name, in the same order.
|
||||||
|
#[must_use]
|
||||||
|
pub fn assign(&self, param_names: &[&str]) -> Vec<ParamGroupLR> {
|
||||||
|
param_names
|
||||||
|
.iter()
|
||||||
|
.map(|&name| self.assign_one(name))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Deduplicated unique LR assignments ordered by first occurrence.
|
||||||
|
///
|
||||||
|
/// Two assignments are considered the same when they share the same `depth`
|
||||||
|
/// value (both `Some(N)` with equal `N`, or both `None`) **and** the same
|
||||||
|
/// LR bits. At most `num_layers + 2` entries are returned (one per
|
||||||
|
/// numbered layer, one for embeddings, one for the head).
|
||||||
|
#[must_use]
|
||||||
|
pub fn unique_groups(&self, param_names: &[&str]) -> Vec<ParamGroupLR> {
|
||||||
|
let all = self.assign(param_names);
|
||||||
|
|
||||||
|
// Use a deduplication key of (depth, lr_bits). Small N → Vec scan is fine.
|
||||||
|
let mut seen: Vec<(Option<usize>, u32)> = Vec::new();
|
||||||
|
let mut result: Vec<ParamGroupLR> = Vec::new();
|
||||||
|
|
||||||
|
for group in all {
|
||||||
|
let key = (group.depth, group.lr.to_bits());
|
||||||
|
if !seen.contains(&key) {
|
||||||
|
seen.push(key);
|
||||||
|
result.push(group);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Assign a single parameter name to its learning rate group.
|
||||||
|
fn assign_one(&self, name: &str) -> ParamGroupLR {
|
||||||
|
if let Some(depth) = extract_layer_depth(name) {
|
||||||
|
// Clamp depth to valid range in case the name exceeds configured layers.
|
||||||
|
let effective_depth = if self.config.num_layers == 0 {
|
||||||
|
0
|
||||||
|
} else {
|
||||||
|
depth.min(self.config.num_layers - 1)
|
||||||
|
};
|
||||||
|
ParamGroupLR {
|
||||||
|
name: name.to_string(),
|
||||||
|
lr: self.config.lr_for_layer(effective_depth),
|
||||||
|
depth: Some(effective_depth),
|
||||||
|
}
|
||||||
|
} else if is_embedding_name(name) {
|
||||||
|
ParamGroupLR {
|
||||||
|
name: name.to_string(),
|
||||||
|
lr: self.config.lr_for_embeddings(),
|
||||||
|
depth: None,
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Head, final norm, bias terms not inside a named layer → full base LR.
|
||||||
|
ParamGroupLR {
|
||||||
|
name: name.to_string(),
|
||||||
|
lr: self.config.base_lr,
|
||||||
|
depth: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// LayerLrScheduler
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Wraps [`LayerLrDecayBuilder`] with an outer scalar multiplier.
|
||||||
|
///
|
||||||
|
/// The multiplier is typically updated each training step from an outer cosine
|
||||||
|
/// or linear decay schedule. All effective LRs equal the config-derived LR
|
||||||
|
/// multiplied by the current multiplier.
|
||||||
|
pub struct LayerLrScheduler {
|
||||||
|
builder: LayerLrDecayBuilder,
|
||||||
|
multiplier: f32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl LayerLrScheduler {
|
||||||
|
/// Create a new scheduler with `multiplier = 1.0`.
|
||||||
|
#[must_use]
|
||||||
|
pub fn new(config: LayerLrDecayConfig) -> Self {
|
||||||
|
Self {
|
||||||
|
builder: LayerLrDecayBuilder::new(config),
|
||||||
|
multiplier: 1.0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Update the global multiplier (call from the outer LR scheduler each step).
|
||||||
|
pub fn set_multiplier(&mut self, m: f32) {
|
||||||
|
self.multiplier = m;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Effective LR for a given layer `depth` after applying the multiplier.
|
||||||
|
#[must_use]
|
||||||
|
pub fn effective_lr(&self, depth: usize) -> f32 {
|
||||||
|
self.builder.config.lr_for_layer(depth) * self.multiplier
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Effective LR for the embedding layer after applying the multiplier.
|
||||||
|
#[must_use]
|
||||||
|
pub fn effective_lr_embeddings(&self) -> f32 {
|
||||||
|
self.builder.config.lr_for_embeddings() * self.multiplier
|
||||||
|
}
|
||||||
|
|
||||||
|
/// All effective LRs from `depth = 0` (shallowest) to `depth = num_layers - 1`.
|
||||||
|
#[must_use]
|
||||||
|
pub fn all_lrs(&self) -> Vec<f32> {
|
||||||
|
(0..self.builder.config.num_layers)
|
||||||
|
.map(|d| self.effective_lr(d))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Tests
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
const BASE_LR: f32 = 1e-3;
|
||||||
|
const DECAY: f32 = 0.9;
|
||||||
|
const N: usize = 12;
|
||||||
|
|
||||||
|
fn cfg() -> LayerLrDecayConfig {
|
||||||
|
LayerLrDecayConfig::new(BASE_LR, DECAY, N)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. Top layer (deepest) gets exactly base_lr ────────────────────────────
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_lr_for_top_layer() {
|
||||||
|
let config = cfg();
|
||||||
|
let lr = config.lr_for_layer(N - 1);
|
||||||
|
// exponent = (N-1) - (N-1) = 0 → decay^0 = 1 → base_lr
|
||||||
|
assert!(
|
||||||
|
(lr - BASE_LR).abs() < 1e-9,
|
||||||
|
"top layer lr should equal base_lr={BASE_LR}, got {lr}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Bottom layer (shallowest) gets base_lr * decay^(N-1) ────────────────
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_lr_for_bottom_layer() {
|
||||||
|
let config = cfg();
|
||||||
|
let lr = config.lr_for_layer(0);
|
||||||
|
let expected = BASE_LR * DECAY.powi((N - 1) as i32);
|
||||||
|
assert!(
|
||||||
|
(lr - expected).abs() < 1e-9,
|
||||||
|
"bottom layer lr should be {expected}, got {lr}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. LR strictly increases (or stays equal) with depth ───────────────────
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_lr_decay_monotone_increasing_with_depth() {
|
||||||
|
let config = LayerLrDecayConfig::new(1e-3, 0.85, 8);
|
||||||
|
for d in 0..7_usize {
|
||||||
|
let lr_shallow = config.lr_for_layer(d);
|
||||||
|
let lr_deep = config.lr_for_layer(d + 1);
|
||||||
|
assert!(
|
||||||
|
lr_deep >= lr_shallow,
|
||||||
|
"lr should be non-decreasing with depth: lr({d})={lr_shallow} > lr({})={lr_deep}",
|
||||||
|
d + 1
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Very small decay + many layers → clamped to min_lr ─────────────────
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_lr_clamped_to_min() {
|
||||||
|
let mut config = LayerLrDecayConfig::new(1e-3, 0.1, 50);
|
||||||
|
config.min_lr = 1e-7;
|
||||||
|
// depth=0 → 1e-3 * 0.1^49 ≈ 1e-52 → clamped to 1e-7
|
||||||
|
let lr = config.lr_for_layer(0);
|
||||||
|
assert!(
|
||||||
|
(lr - 1e-7_f32).abs() < 1e-12,
|
||||||
|
"should be clamped to min_lr=1e-7, got {lr}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Embeddings: one step below layer 0 → layer0_lr * decay_rate ─────────
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_lr_for_embeddings() {
|
||||||
|
let config = cfg();
|
||||||
|
let embed_lr = config.lr_for_embeddings();
|
||||||
|
let layer0_lr = config.lr_for_layer(0);
|
||||||
|
// embed exponent = N, layer0 exponent = N-1 → embed = layer0 * decay
|
||||||
|
let expected = layer0_lr * DECAY;
|
||||||
|
assert!(
|
||||||
|
(embed_lr - expected).abs() < 1e-9,
|
||||||
|
"embed lr should be {expected}, got {embed_lr}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
embed_lr <= layer0_lr,
|
||||||
|
"embed lr {embed_lr} should be <= layer0 lr {layer0_lr}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. decay_rate = 1.0 → every layer gets base_lr ─────────────────────────
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_decay_rate_one_all_same() {
|
||||||
|
let config = LayerLrDecayConfig::new(5e-4, 1.0, 24);
|
||||||
|
for d in 0..24_usize {
|
||||||
|
let lr = config.lr_for_layer(d);
|
||||||
|
assert!(
|
||||||
|
(lr - 5e-4_f32).abs() < 1e-9,
|
||||||
|
"with decay=1.0 layer {d} should get base_lr=5e-4, got {lr}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 7. Named param "transformer.layer.3.weight" → depth-3 LR ───────────────
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_assign_layer_names() {
|
||||||
|
let config = LayerLrDecayConfig::new(1e-3, 0.9, 12);
|
||||||
|
let builder = LayerLrDecayBuilder::new(config.clone());
|
||||||
|
let groups = builder.assign(&["transformer.layer.3.weight"]);
|
||||||
|
assert_eq!(groups.len(), 1);
|
||||||
|
let g = &groups[0];
|
||||||
|
assert_eq!(g.depth, Some(3), "depth should be 3");
|
||||||
|
let expected = config.lr_for_layer(3);
|
||||||
|
assert!(
|
||||||
|
(g.lr - expected).abs() < 1e-9,
|
||||||
|
"layer.3 lr should be {expected}, got {}",
|
||||||
|
g.lr
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 8. Embedding parameter → embedding LR ───────────────────────────────────
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_assign_embed_name() {
|
||||||
|
let config = cfg();
|
||||||
|
let expected_embed_lr = config.lr_for_embeddings();
|
||||||
|
let builder = LayerLrDecayBuilder::new(config);
|
||||||
|
let groups = builder.assign(&["embeddings.weight"]);
|
||||||
|
assert_eq!(groups.len(), 1);
|
||||||
|
let g = &groups[0];
|
||||||
|
assert!(g.depth.is_none(), "embed should have no depth, got {:?}", g.depth);
|
||||||
|
assert!(
|
||||||
|
(g.lr - expected_embed_lr).abs() < 1e-9,
|
||||||
|
"embed lr should be {expected_embed_lr}, got {}",
|
||||||
|
g.lr
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 9. Head/output parameter → base_lr ──────────────────────────────────────
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_assign_head_name() {
|
||||||
|
let config = cfg();
|
||||||
|
let builder = LayerLrDecayBuilder::new(config);
|
||||||
|
let groups = builder.assign(&["lm_head.weight"]);
|
||||||
|
assert_eq!(groups.len(), 1);
|
||||||
|
let g = &groups[0];
|
||||||
|
assert!(g.depth.is_none(), "head should have no depth");
|
||||||
|
assert!(
|
||||||
|
(g.lr - BASE_LR).abs() < 1e-9,
|
||||||
|
"head lr should be base_lr={BASE_LR}, got {}",
|
||||||
|
g.lr
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 10. unique_groups yields at most num_layers + 2 entries ─────────────────
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_unique_groups_count() {
|
||||||
|
let config = LayerLrDecayConfig::new(1e-3, 0.9, 4);
|
||||||
|
let builder = LayerLrDecayBuilder::new(config);
|
||||||
|
let names: &[&str] = &[
|
||||||
|
"embeddings.weight",
|
||||||
|
"layer.0.weight",
|
||||||
|
"layer.0.bias", // duplicate depth 0 → should NOT add a new group
|
||||||
|
"layer.1.weight",
|
||||||
|
"layer.2.weight",
|
||||||
|
"layer.3.weight",
|
||||||
|
"lm_head.weight",
|
||||||
|
"norm.weight", // head-level (no layer depth, no embed keyword)
|
||||||
|
];
|
||||||
|
let groups = builder.unique_groups(names);
|
||||||
|
assert!(
|
||||||
|
groups.len() <= 6,
|
||||||
|
"unique groups should be <= num_layers+2=6, got {}",
|
||||||
|
groups.len()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 11. Multiplier = 0.5 scales every layer's effective LR by 0.5 ───────────
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_scheduler_multiplier_scales_all() {
|
||||||
|
let config = LayerLrDecayConfig::new(1e-3, 0.9, 12);
|
||||||
|
let mut scheduler = LayerLrScheduler::new(config.clone());
|
||||||
|
scheduler.set_multiplier(0.5);
|
||||||
|
for d in 0..N {
|
||||||
|
let effective = scheduler.effective_lr(d);
|
||||||
|
let base = config.lr_for_layer(d);
|
||||||
|
let expected = base * 0.5;
|
||||||
|
assert!(
|
||||||
|
(effective - expected).abs() < 1e-10,
|
||||||
|
"depth {d}: effective={effective} should equal base*0.5={expected}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 12. Scheduler ordering: deeper layer → higher effective LR ──────────────
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_scheduler_effective_lr_ordering() {
|
||||||
|
let config = LayerLrDecayConfig::new(1e-3, 0.85, 8);
|
||||||
|
let mut scheduler = LayerLrScheduler::new(config);
|
||||||
|
scheduler.set_multiplier(0.7);
|
||||||
|
let lrs = scheduler.all_lrs();
|
||||||
|
assert_eq!(lrs.len(), 8);
|
||||||
|
for i in 0..7_usize {
|
||||||
|
assert!(
|
||||||
|
lrs[i + 1] >= lrs[i],
|
||||||
|
"effective lr must be non-decreasing: lrs[{i}]={} > lrs[{}]={}",
|
||||||
|
lrs[i],
|
||||||
|
i + 1,
|
||||||
|
lrs[i + 1]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bonus A: Bracket notation "transformer.h[5].mlp.weight" → depth 5 ───────
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_bracket_notation_parsed() {
|
||||||
|
let config = cfg();
|
||||||
|
let builder = LayerLrDecayBuilder::new(config.clone());
|
||||||
|
let groups = builder.assign(&["transformer.h[5].mlp.weight"]);
|
||||||
|
assert_eq!(groups[0].depth, Some(5));
|
||||||
|
let expected = config.lr_for_layer(5);
|
||||||
|
assert!(
|
||||||
|
(groups[0].lr - expected).abs() < 1e-9,
|
||||||
|
"bracket depth 5 should give lr {expected}, got {}",
|
||||||
|
groups[0].lr
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bonus B: "blocks.7.attn.weight" (GPT-NeoX / Mamba style) → depth 7 ─────
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_blocks_prefix_parsed() {
|
||||||
|
let config = cfg();
|
||||||
|
let builder = LayerLrDecayBuilder::new(config.clone());
|
||||||
|
let groups = builder.assign(&["blocks.7.attn.weight"]);
|
||||||
|
assert_eq!(groups[0].depth, Some(7));
|
||||||
|
let expected = config.lr_for_layer(7);
|
||||||
|
assert!(
|
||||||
|
(groups[0].lr - expected).abs() < 1e-9,
|
||||||
|
"blocks depth 7 should give lr {expected}, got {}",
|
||||||
|
groups[0].lr
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,6 +14,7 @@ use tracing::debug;
|
|||||||
|
|
||||||
pub mod adam;
|
pub mod adam;
|
||||||
pub mod adamw;
|
pub mod adamw;
|
||||||
|
pub mod layer_lr_decay;
|
||||||
|
|
||||||
#[cfg(all(test, feature = "disabled_tests"))]
|
#[cfg(all(test, feature = "disabled_tests"))]
|
||||||
pub mod adam_test;
|
pub mod adam_test;
|
||||||
@@ -60,6 +61,7 @@ pub use crate::tensor_bridge::{TensorBridge, TensorBridgeStatic, TensorCompat};
|
|||||||
pub use adam::AdamOptimizer;
|
pub use adam::AdamOptimizer;
|
||||||
pub use adamw::AdamWOptimizer;
|
pub use adamw::AdamWOptimizer;
|
||||||
pub use galore::{GaLoreAdamW, GaLoreConfig, GaLoreParamState};
|
pub use galore::{GaLoreAdamW, GaLoreConfig, GaLoreParamState};
|
||||||
|
pub use layer_lr_decay::{LayerLrDecayBuilder, LayerLrDecayConfig, LayerLrScheduler, ParamGroupLR};
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// PHASE 2: ADVANCED OPTIMIZER EXPORTS (Temporarily disabled)
|
// PHASE 2: ADVANCED OPTIMIZER EXPORTS (Temporarily disabled)
|
||||||
|
|||||||
Reference in New Issue
Block a user