Manage the KV cache disc usage from preferences and stats

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Georg Bauer
2026-07-26 09:10:18 +02:00
parent b182e7007c
commit 63e2a74ad5
14 changed files with 794 additions and 35 deletions

View File

@@ -15,21 +15,21 @@ use preferences::{parse_optional_gib, parse_streaming_cache};
use crate::database::{AppPreferences, Database, ProjectWithSessions, SessionState, StoredMessage};
#[cfg(target_os = "macos")]
use crate::engine::ChatTurn;
use crate::metrics::{Metrics, MetricsSnapshot};
use crate::metrics::{KvCacheReport, Metrics, MetricsSnapshot};
use crate::model::{self, DownloadOutcome, DownloadProgress, ManagedArtifactId, ModelChoice};
#[cfg(target_os = "macos")]
use crate::runtime::{ActiveGeneration, CheckpointTarget, GenerationEvent, GenerationService};
use crate::settings::{
DiagnosticPreferences, ExecutionPreferences, GIB, GenerationPreferences, ReasoningMode,
RuntimePreferences, SpeculativePreferences, SsdPreferences, SteeringPreferences,
StreamingCacheBudget,
DEFAULT_KV_BUDGET_GIB, DiagnosticPreferences, ExecutionPreferences, GIB, GenerationPreferences,
KvCachePreferences, ReasoningMode, RuntimePreferences, SpeculativePreferences, SsdPreferences,
SteeringPreferences, StreamingCacheBudget,
};
use iced::widget::{markdown, scrollable, text_input};
use iced::{Size, Subscription, Task, keyboard, mouse, window};
use rfd::AsyncFileDialog;
use std::collections::{HashMap, HashSet, VecDeque};
use std::fs;
use std::path::PathBuf;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::mpsc::{self, TryRecvError};
use std::sync::{Arc, Mutex, RwLock};
@@ -38,6 +38,8 @@ use std::time::{Duration, Instant};
const APP_ID: &str = "de.rfc1437.ds4server";
const METRICS_SAMPLE_INTERVAL: Duration = Duration::from_millis(200);
/// Disc scans are cheap but not free; the explorer does not need 5 Hz.
const CACHE_SCAN_INTERVAL: Duration = Duration::from_secs(2);
pub(super) const MIN_SIDEBAR_WIDTH: i32 = 180;
pub(super) const MAX_SIDEBAR_WIDTH: i32 = 520;
@@ -82,6 +84,9 @@ pub(crate) struct App {
metrics: Arc<Metrics>,
pub(super) metrics_snapshot: MetricsSnapshot,
pub(super) metrics_history: VecDeque<MetricsPoint>,
/// Disc usage of the KV cache directories, rescanned while Stats is open.
pub(super) kv_cache_report: KvCacheReport,
last_cache_scan: Instant,
last_http_requests: u64,
#[cfg(target_os = "macos")]
generation_service: Option<GenerationService>,
@@ -162,6 +167,10 @@ pub(crate) enum Message {
PreferenceSteeringAttnChanged(String),
PreferenceSimulatedMemoryChanged(String),
PreferenceExpertProfileChanged(String),
PreferenceKvBudgetChanged(String),
PreferenceKvMinTokensChanged(String),
PreferenceKvColdMaxChanged(String),
PreferenceKvContinuedIntervalChanged(String),
ResetPreferences,
SavePreferences,
DownloadArtifact(ManagedArtifactId),
@@ -202,6 +211,8 @@ pub(crate) enum Message {
DragSidebar(f32),
EndSidebarDrag,
MetricsTick,
DiscardCacheEntry(PathBuf),
ClearTransientCache,
}
impl App {
@@ -214,6 +225,9 @@ impl App {
Ok(draft) => draft,
Err(error) => return Self::failed(error, main_window),
};
// Before the counters are taken, so they measure what is
// actually reachable.
sweep_orphan_checkpoints(&kv_cache_path(), &projects);
let context_limit = preferences.context_tokens.max(0) as u32;
// Reopen on the project we left, with a fresh draft chat.
let last_project = preferences
@@ -264,6 +278,8 @@ impl App {
metrics,
metrics_snapshot,
metrics_history: VecDeque::with_capacity(120),
kv_cache_report: KvCacheReport::default(),
last_cache_scan: Instant::now() - CACHE_SCAN_INTERVAL,
last_http_requests: 0,
#[cfg(target_os = "macos")]
generation_service,
@@ -346,6 +362,8 @@ impl App {
metrics,
metrics_snapshot,
metrics_history: VecDeque::with_capacity(120),
kv_cache_report: KvCacheReport::default(),
last_cache_scan: Instant::now() - CACHE_SCAN_INTERVAL,
last_http_requests: 0,
#[cfg(target_os = "macos")]
generation_service,
@@ -422,7 +440,12 @@ impl App {
}
}
Message::ShowChat => self.detail_tab = DetailTab::Chat,
Message::ShowStats => self.detail_tab = DetailTab::Stats,
Message::ShowStats => {
self.detail_tab = DetailTab::Stats;
self.scan_kv_cache();
}
Message::DiscardCacheEntry(path) => self.discard_cache_entry(&path),
Message::ClearTransientCache => self.clear_transient_cache(),
// The sidebar starts at the window's left edge, so the cursor's x is
// the width the user is asking for. Only the final width is stored.
Message::StartSidebarDrag => self.sidebar_drag = true,
@@ -652,6 +675,22 @@ impl App {
self.preference_draft.expert_profile_path = value;
self.preference_error = None;
}
Message::PreferenceKvBudgetChanged(value) => {
self.preference_draft.kv_budget_gib = value;
self.preference_error = None;
}
Message::PreferenceKvMinTokensChanged(value) => {
self.preference_draft.kv_min_tokens = value;
self.preference_error = None;
}
Message::PreferenceKvColdMaxChanged(value) => {
self.preference_draft.kv_cold_max_tokens = value;
self.preference_error = None;
}
Message::PreferenceKvContinuedIntervalChanged(value) => {
self.preference_draft.kv_continued_interval_tokens = value;
self.preference_error = None;
}
Message::ResetPreferences => {
self.preference_draft.reset();
self.preference_error = None;
@@ -801,6 +840,7 @@ impl App {
self.tokens_per_second = None;
}
self.reload_projects();
self.finish_cache_change();
}
Err(error) => self.error = Some(error),
}
@@ -936,6 +976,7 @@ impl App {
match database.delete_session(session_id) {
Ok(()) => {
let _ = fs::remove_file(session_checkpoint_path(session_id));
self.finish_cache_change();
if self.session_menu == Some(session_id) {
self.session_menu = None;
}
@@ -1050,6 +1091,56 @@ impl App {
self.metrics_history.pop_front();
}
self.metrics_snapshot = snapshot;
if self.detail_tab == DetailTab::Stats
&& self.last_cache_scan.elapsed() >= CACHE_SCAN_INTERVAL
{
self.scan_kv_cache();
}
}
/// Removes one cache file. Checkpoints are disposable: the next turn on that
/// conversation prefills again.
fn discard_cache_entry(&mut self, path: &Path) {
if let Err(error) = fs::remove_file(path) {
self.error = Some(format!("Could not delete the checkpoint: {error}"));
return;
}
let _ = fs::remove_file(path.with_extension("meta"));
self.finish_cache_change();
}
/// Empties the transient store only. Session checkpoints belong to their
/// session and are removed with it.
fn clear_transient_cache(&mut self) {
let Ok(files) = fs::read_dir(transient_cache_path()) else {
return;
};
for file in files.flatten() {
if file.metadata().is_ok_and(|metadata| metadata.is_file())
&& let Err(error) = fs::remove_file(file.path())
{
self.error = Some(format!("Could not clear the transient cache: {error}"));
break;
}
}
self.finish_cache_change();
}
fn finish_cache_change(&mut self) {
self.metrics.rescan_cache(&kv_cache_path());
self.metrics_snapshot = self.metrics.snapshot();
self.scan_kv_cache();
}
fn scan_kv_cache(&mut self) {
let budget = self
.preferences
.runtime()
.map_or(DEFAULT_KV_BUDGET_GIB * GIB, |runtime| {
runtime.kv_cache.settings().budget_bytes
});
self.kv_cache_report = crate::metrics::kv_cache_report(&kv_cache_path(), budget);
self.last_cache_scan = Instant::now();
}
}
@@ -1142,16 +1233,50 @@ fn scroll_chat_to_end() -> Task<Message> {
scrollable::snap_to(chat_scroll_id(), scrollable::RelativeOffset::END)
}
/// Deletes session checkpoints whose session is gone. Deleting a session or a
/// project removes its checkpoint directly; this catches the cases that path
/// misses — an interrupted or failed delete, or files left by an older
/// database — so no unreachable checkpoint keeps occupying disc.
fn sweep_orphan_checkpoints(directory: &Path, projects: &[ProjectWithSessions]) -> bool {
let known = projects
.iter()
.flat_map(|item| item.sessions.iter().map(|session| session.id))
.collect::<HashSet<_>>();
let Ok(files) = fs::read_dir(directory) else {
return false;
};
let mut removed = false;
for file in files.flatten() {
let path = file.path();
if path.extension().is_none_or(|value| value != "bin") {
continue;
}
// Only numbered session checkpoints are ours to judge; anything else in
// the directory is left alone.
let orphan = path
.file_stem()
.and_then(|value| value.to_str())
.and_then(|value| value.parse::<i32>().ok())
.is_some_and(|id| !known.contains(&id));
if orphan {
removed |= fs::remove_file(&path).is_ok();
}
}
removed
}
fn kv_cache_path() -> PathBuf {
application_support_path().join("kv-cache")
}
fn session_checkpoint_path(session_id: i32) -> PathBuf {
application_support_path()
.join("kv-cache")
.join(format!("{session_id}.bin"))
kv_cache_path().join(format!("{session_id}.bin"))
}
/// Content-addressed KV cache for turns that belong to no stored session: the
/// HTTP endpoint and the app's own one-shot requests share it.
fn transient_cache_path() -> PathBuf {
application_support_path().join("kv-cache").join("http")
kv_cache_path().join("http")
}
pub(crate) fn app_icon() -> window::Icon {