Manage the KV cache disc usage from preferences and stats
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
145
src/app.rs
145
src/app.rs
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user