1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
// RLX — versatile ML compiler + runtime.
// Copyright (C) 2026 Eugene Hauptmann, Nataliya Kosmyna.
// SPDX-License-Identifier: MIT OR Apache-2.0
//! Reusable **KV cache / context telemetry** — a model-agnostic time-series
//! recorder + analysis for how a decode loop's working set and reachable context
//! evolve over a session.
//!
//! It is deliberately decoupled from any model: it records plain per-step counts
//! (resident / evicted / retrieved / store), so it works for any generator that
//! decodes through a KV cache. [`KvRetentionManager`](crate::kv_retention::KvRetentionManager)
//! wires it in automatically (`enable_recording`), but a caller can also drive a
//! [`RetentionRecorder`] by hand to profile a plain (retention-free) cache.
//!
//! The headline it exposes: **context extension** — how far past the O(budget)
//! resident working set the system actually reached, thanks to retrieval — plus
//! the eviction/retrieval activity and (optionally) per-step decode latency, so
//! you can see whether throughput stays flat as context grows.
/// One decode step's cache/context snapshot.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct StepRecord {
/// Decode step index (0-based within the recorded session).
pub step: usize,
/// Resident (attended) positions this step — the O(budget) working set.
pub resident: usize,
/// Positions evicted from the resident set this step.
pub evicted: usize,
/// Positions retrieved back from the store into the resident set this step.
pub retrieved: usize,
/// Cold blocks currently held in the store (retrievable, not resident).
pub store_blocks: usize,
/// Tokens the store currently holds.
pub store_tokens: usize,
/// Per-step decode latency in ms, if the caller measured it.
pub decode_ms: Option<f32>,
}
impl StepRecord {
/// Total context the system can reach this step: resident + stored. This is
/// the "effective context" that grows unbounded under retrieval even while
/// `resident` stays bounded.
pub fn effective_context(&self) -> usize {
self.resident + self.store_tokens
}
}
/// Accumulates [`StepRecord`]s over a decode session and rolls them up into a
/// [`RetentionSummary`] / CSV. Cheap to clone; `Default` is an empty recorder.
#[derive(Clone, Debug, Default)]
pub struct RetentionRecorder {
steps: Vec<StepRecord>,
}
impl RetentionRecorder {
/// A fresh, empty recorder.
pub fn new() -> Self {
Self::default()
}
/// Append one step's snapshot.
pub fn push(&mut self, r: StepRecord) {
self.steps.push(r);
}
/// Attach a measured decode latency (ms) to the most-recently pushed record.
/// Call right after the step's decode finishes — the recorder itself has no
/// clock, keeping it usable in headless / deterministic contexts.
pub fn set_last_decode_ms(&mut self, ms: f32) {
if let Some(last) = self.steps.last_mut() {
last.decode_ms = Some(ms);
}
}
/// Recorded steps, in order.
pub fn records(&self) -> &[StepRecord] {
&self.steps
}
/// Number of recorded steps.
pub fn len(&self) -> usize {
self.steps.len()
}
/// Whether nothing has been recorded yet.
pub fn is_empty(&self) -> bool {
self.steps.is_empty()
}
/// Drop all recorded steps (reuse the allocation for a new session).
pub fn clear(&mut self) {
self.steps.clear();
}
/// Roll the session up into summary statistics.
pub fn summary(&self) -> RetentionSummary {
let n = self.steps.len();
if n == 0 {
return RetentionSummary::default();
}
let mut resident: Vec<usize> = self.steps.iter().map(|s| s.resident).collect();
resident.sort_unstable();
let resident_max = *resident.last().unwrap();
let resident_mean =
self.steps.iter().map(|s| s.resident as f64).sum::<f64>() as f32 / n as f32;
let store_tokens_max = self.steps.iter().map(|s| s.store_tokens).max().unwrap_or(0);
let effective_context_max = self
.steps
.iter()
.map(|s| s.effective_context())
.max()
.unwrap_or(0);
let context_extension = effective_context_max as f32 / resident_max.max(1) as f32;
let total_evicted = self.steps.iter().map(|s| s.evicted).sum();
let total_retrieved = self.steps.iter().map(|s| s.retrieved).sum();
let retrieval_steps = self.steps.iter().filter(|s| s.retrieved > 0).count();
let retrieval_rate = retrieval_steps as f32 / n as f32;
// Decode latency stats over whatever steps carry a measurement.
let mut lat: Vec<f32> = self.steps.iter().filter_map(|s| s.decode_ms).collect();
let (decode_ms_mean, decode_ms_p95) = if lat.is_empty() {
(None, None)
} else {
let mean = lat.iter().copied().sum::<f32>() / lat.len() as f32;
lat.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
(Some(mean), Some(percentile_f32(&lat, 95.0)))
};
RetentionSummary {
steps: n,
resident_mean,
resident_p50: percentile_usize(&resident, 50.0),
resident_p95: percentile_usize(&resident, 95.0),
resident_max,
store_tokens_max,
effective_context_max,
context_extension,
total_evicted,
total_retrieved,
retrieval_rate,
decode_ms_mean,
decode_ms_p95,
}
}
/// The full time series as CSV (one row per step, header included). Suitable
/// for piping into a plotter — the columns tell the story of resident vs.
/// effective context over time.
pub fn to_csv(&self) -> String {
let mut s = String::from(
"step,resident,evicted,retrieved,store_blocks,store_tokens,effective_context,decode_ms\n",
);
for r in &self.steps {
let ms = match r.decode_ms {
Some(v) => format!("{v:.3}"),
None => String::new(),
};
s.push_str(&format!(
"{},{},{},{},{},{},{},{}\n",
r.step,
r.resident,
r.evicted,
r.retrieved,
r.store_blocks,
r.store_tokens,
r.effective_context(),
ms,
));
}
s
}
}
/// Rolled-up analysis over a recorded session.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct RetentionSummary {
/// Number of decode steps recorded.
pub steps: usize,
/// Mean resident (working-set) size.
pub resident_mean: f32,
/// Median resident size.
pub resident_p50: usize,
/// 95th-percentile resident size.
pub resident_p95: usize,
/// Peak resident size (should track the policy's budget).
pub resident_max: usize,
/// Peak tokens held in the store.
pub store_tokens_max: usize,
/// Peak reachable context (resident + store).
pub effective_context_max: usize,
/// `effective_context_max / resident_max` — how far past the working set the
/// system reached. `1.0` = no extension; `> 1` = retrieval extending context.
pub context_extension: f32,
/// Total positions evicted over the session.
pub total_evicted: usize,
/// Total positions retrieved over the session.
pub total_retrieved: usize,
/// Fraction of steps that retrieved ≥ 1 block.
pub retrieval_rate: f32,
/// Mean per-step decode latency (ms), if any step carried a measurement.
pub decode_ms_mean: Option<f32>,
/// 95th-percentile per-step decode latency (ms), if measured.
pub decode_ms_p95: Option<f32>,
}
impl RetentionSummary {
/// A compact, human-readable multi-line report.
pub fn report(&self) -> String {
let mut s = format!("KV cache/context over {} decode steps\n", self.steps);
s.push_str(&format!(
" resident (working set): mean {:.1} p50 {} p95 {} max {}\n",
self.resident_mean, self.resident_p50, self.resident_p95, self.resident_max,
));
s.push_str(&format!(
" store: up to {} tokens held offline\n",
self.store_tokens_max,
));
s.push_str(&format!(
" effective context: {} tokens peak -> {:.2}x extension over resident\n",
self.effective_context_max, self.context_extension,
));
s.push_str(&format!(
" activity: {} evicted total, {} retrieved total, retrieval on {:.0}% of steps\n",
self.total_evicted,
self.total_retrieved,
self.retrieval_rate * 100.0,
));
if let (Some(mean), Some(p95)) = (self.decode_ms_mean, self.decode_ms_p95) {
let tps = if mean > 0.0 { 1000.0 / mean } else { 0.0 };
s.push_str(&format!(
" decode: mean {mean:.2} ms/step p95 {p95:.2} ms ({tps:.0} tok/s)\n",
));
}
s
}
}
/// Nearest-rank percentile of a **sorted** `usize` slice (`p` in `[0, 100]`).
fn percentile_usize(sorted: &[usize], p: f32) -> usize {
if sorted.is_empty() {
return 0;
}
let idx = ((p / 100.0) * (sorted.len() - 1) as f32).round() as usize;
sorted[idx.min(sorted.len() - 1)]
}
/// Nearest-rank percentile of a **sorted** `f32` slice (`p` in `[0, 100]`).
fn percentile_f32(sorted: &[f32], p: f32) -> f32 {
if sorted.is_empty() {
return 0.0;
}
let idx = ((p / 100.0) * (sorted.len() - 1) as f32).round() as usize;
sorted[idx.min(sorted.len() - 1)]
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn empty_recorder_yields_zero_summary() {
let rec = RetentionRecorder::new();
assert!(rec.is_empty());
let s = rec.summary();
assert_eq!(s.steps, 0);
assert_eq!(s.context_extension, 0.0);
}
#[test]
fn summary_tracks_extension_and_activity() {
let mut rec = RetentionRecorder::new();
// Resident bounded at 10; store grows to 40 as context is offloaded.
for step in 0..5 {
rec.push(StepRecord {
step,
resident: 10,
evicted: 2,
retrieved: if step >= 2 { 3 } else { 0 },
store_blocks: step * 2,
store_tokens: step * 8,
decode_ms: Some(12.0),
});
}
let s = rec.summary();
assert_eq!(s.steps, 5);
assert_eq!(s.resident_max, 10);
assert_eq!(s.resident_p50, 10);
// Peak effective context = 10 resident + 32 stored (step 4) = 42.
assert_eq!(s.effective_context_max, 42);
assert!((s.context_extension - 4.2).abs() < 1e-4);
assert_eq!(s.total_evicted, 10);
assert_eq!(s.total_retrieved, 9); // steps 2,3,4 * 3
assert!((s.retrieval_rate - 0.6).abs() < 1e-6); // 3 of 5 steps
assert_eq!(s.decode_ms_mean, Some(12.0));
}
#[test]
fn csv_has_header_and_one_row_per_step() {
let mut rec = RetentionRecorder::new();
rec.push(StepRecord {
step: 0,
resident: 8,
evicted: 0,
retrieved: 0,
store_blocks: 0,
store_tokens: 0,
decode_ms: None,
});
rec.set_last_decode_ms(9.5);
let csv = rec.to_csv();
let lines: Vec<&str> = csv.lines().collect();
assert_eq!(lines.len(), 2); // header + 1 row
assert!(lines[0].starts_with("step,resident,"));
assert!(lines[1].starts_with("0,8,0,0,0,0,8,9.500"));
}
}