From 1e1e0c1288e11741fe507bf68b46b2ceca79d1c7 Mon Sep 17 00:00:00 2001 From: Lukas Wirth Date: Wed, 31 Dec 2025 10:20:15 +0100 Subject: [PATCH] feat: Allow opt-ing out of LRU at compile time --- book/src/tuning.md | 50 +++++++++++--- .../salsa-macro-rules/src/setup_tracked_fn.rs | 26 ++++---- components/salsa-macros/src/tracked_fn.rs | 8 +++ src/function.rs | 24 +++++-- src/function/eviction.rs | 40 +++++++++++ src/function/eviction/lru.rs | 66 +++++++++++++++++++ src/function/eviction/noop.rs | 23 +++++++ src/function/fetch.rs | 3 +- src/function/lru.rs | 51 -------------- src/function/memo.rs | 1 + src/lib.rs | 1 + 11 files changed, 212 insertions(+), 81 deletions(-) create mode 100644 src/function/eviction.rs create mode 100644 src/function/eviction/lru.rs create mode 100644 src/function/eviction/noop.rs delete mode 100644 src/function/lru.rs diff --git a/book/src/tuning.md b/book/src/tuning.md index 05b0a8d5b..31b3813a2 100644 --- a/book/src/tuning.md +++ b/book/src/tuning.md @@ -1,20 +1,50 @@ # Tuning Salsa -## LRU Cache +## Cache Eviction (LRU) -You can specify an LRU cache size for any non-input query: +Salsa supports Least Recently Used (LRU) cache eviction for tracked functions. +By default, memoized values are never evicted (unbounded cache). You can enable +LRU eviction by specifying a capacity at compile time: -```rs -let lru_capacity: usize = 128; -base_db::ParseQuery.in_db_mut(self).set_lru_capacity(lru_capacity); +```rust +#[salsa::tracked(lru = 128)] +fn parse(db: &dyn Db, input: SourceFile) -> Ast { + // ... +} ``` -The default is `0`, which disables LRU-caching entirely. +With `lru = 128`, Salsa will keep at most 128 memoized values for this function. +When the cache exceeds this capacity, the least recently used values are evicted +at the start of each new revision. -Note that there is no garbage collection for keys and -results of old queries, so LRU caches are currently the -only knob available for avoiding unbounded memory usage -for long-running apps built on Salsa. +### Zero-Cost When Disabled + +When no `lru` capacity is specified (the default), Salsa uses a no-op eviction +policy that is completely optimized away by the compiler. This means there is +zero runtime overhead for functions that don't need cache eviction. + +### Runtime Capacity Adjustment + +For functions with LRU enabled, you can adjust the capacity at runtime: + +```rust +#[salsa::tracked(lru = 128)] +fn my_query(db: &dyn Db, input: MyInput) -> Output { + // ... +} + +// Later, adjust the capacity: +my_query::set_lru_capacity(db, 256); +``` + +**Note:** The `set_lru_capacity` method is only generated for functions that have +an `lru` attribute. Functions without LRU enabled do not have this method. + +### Memory Management + +There is no garbage collection for keys and results of old queries, so LRU caches +are currently the primary mechanism for avoiding unbounded memory usage in +long-running applications built on Salsa. ## Intern Queries diff --git a/components/salsa-macro-rules/src/setup_tracked_fn.rs b/components/salsa-macro-rules/src/setup_tracked_fn.rs index 97ef7177d..436aa4616 100644 --- a/components/salsa-macro-rules/src/setup_tracked_fn.rs +++ b/components/salsa-macro-rules/src/setup_tracked_fn.rs @@ -58,6 +58,9 @@ macro_rules! setup_tracked_fn { // The function used to implement `C::heap_size`. heap_size_fn: $($heap_size_fn:path)?, + // The eviction policy type for this function + eviction: $Eviction:ty, + // LRU capacity (a literal, maybe 0) lru: $lru:tt, @@ -284,6 +287,8 @@ macro_rules! setup_tracked_fn { type Output<$db_lt> = $output_ty; + type Eviction = $Eviction; + const CYCLE_STRATEGY: $zalsa::CycleRecoveryStrategy = $zalsa::CycleRecoveryStrategy::$cycle_recovery_strategy; $($values_equal)+ @@ -452,18 +457,15 @@ macro_rules! setup_tracked_fn { } } - $zalsa::macro_if! { if0 $lru { } else { - /// Sets the lru capacity - /// - /// **WARNING:** Just like an ordinary write, this method triggers - /// cancellation. If you invoke it while a snapshot exists, it - /// will block until that snapshot is dropped -- if that snapshot - /// is owned by the current thread, this could trigger deadlock. - #[allow(dead_code)] - fn set_lru_capacity(db: &mut dyn $Db, value: usize) { - $Configuration::fn_ingredient_mut(db).set_capacity(value); - } - } } + /// Sets the lru capacity + /// + /// **WARNING:** Just like an ordinary write, this method triggers + /// cancellation. If you invoke it while a snapshot exists, it + /// will block until that snapshot is dropped -- if that snapshot + /// is owned by the current thread, this could trigger deadlock. + fn set_lru_capacity(db: &mut dyn $Db, value: usize) where for<'trivial_bounds> $Eviction: $zalsa::function::HasCapacity { + $Configuration::fn_ingredient_mut(db).set_capacity(value); + } } $zalsa::attach($db, || { diff --git a/components/salsa-macros/src/tracked_fn.rs b/components/salsa-macros/src/tracked_fn.rs index daadc2191..bda35e1a1 100644 --- a/components/salsa-macros/src/tracked_fn.rs +++ b/components/salsa-macros/src/tracked_fn.rs @@ -168,6 +168,13 @@ impl Macro { let lru = Literal::usize_unsuffixed(self.args.lru.unwrap_or(0)); + // Determine the eviction policy type based on whether LRU capacity is specified + let eviction_type = if self.args.lru.is_some() { + quote!(::salsa::plumbing::function::Lru) + } else { + quote!(::salsa::plumbing::function::NoopEviction) + }; + let return_mode = self .args .returns @@ -222,6 +229,7 @@ impl Macro { values_equal: {#eq}, needs_interner: #needs_interner, heap_size_fn: #(#heap_size_fn)*, + eviction: #eviction_type, lru: #lru, return_mode: #return_mode, persist: #persist, diff --git a/src/function.rs b/src/function.rs index f7f302727..34987f107 100644 --- a/src/function.rs +++ b/src/function.rs @@ -28,15 +28,17 @@ mod accumulated; mod backdate; mod delete; mod diff_outputs; +mod eviction; mod execute; mod fetch; mod inputs; -mod lru; mod maybe_changed_after; mod memo; mod specify; mod sync; +pub use eviction::{EvictionPolicy, HasCapacity, Lru, NoopEviction}; + pub type Memo = memo::Memo<'static, C>; pub trait Configuration: Any { @@ -58,6 +60,9 @@ pub trait Configuration: Any { /// The value computed by the function. type Output<'db>: Send + Sync; + /// The eviction policy for this function's memoized values. + type Eviction: EvictionPolicy; + /// Determines whether this function can recover from being a participant in a cycle /// (and, if so, how). const CYCLE_STRATEGY: CycleRecoveryStrategy; @@ -168,8 +173,9 @@ pub struct IngredientImpl { /// tracked function's struct is a plain salsa struct or an enum `#[derive(Supertype)]`. memo_ingredient_indices: as SalsaStructInDb>::MemoIngredientMap, + /// Eviction policy - type determined by Configuration. /// Used to find memos to throw out when we have too many memoized values. - lru: lru::Lru, + eviction: C::Eviction, /// An downcaster to `C::DbView`. /// @@ -203,12 +209,12 @@ where pub fn new( index: IngredientIndex, memo_ingredient_indices: as SalsaStructInDb>::MemoIngredientMap, - lru: usize, + eviction_capacity: usize, ) -> Self { Self { index, memo_ingredient_indices, - lru: lru::Lru::new(lru), + eviction: C::Eviction::new(eviction_capacity), deleted_entries: Default::default(), view_caster: OnceLock::new(), sync_table: SyncTable::new(index), @@ -233,8 +239,12 @@ where DatabaseKeyIndex::new(self.index, key) } - pub fn set_capacity(&mut self, capacity: usize) { - self.lru.set_capacity(capacity); + /// Set eviction capacity. Only available when eviction policy supports it. + pub fn set_capacity(&mut self, capacity: usize) + where + C::Eviction: HasCapacity, + { + self.eviction.set_capacity(capacity); } /// Returns a reference to the memo value that lives as long as self. @@ -475,7 +485,7 @@ where } fn reset_for_new_revision(&mut self, table: &mut Table) { - self.lru.for_each_evicted(|evict| { + self.eviction.for_each_evicted(|evict| { let ingredient_index = table.ingredient_index(evict); Self::evict_value_from_memo_for( table.memos_mut(evict), diff --git a/src/function/eviction.rs b/src/function/eviction.rs new file mode 100644 index 000000000..ca852da29 --- /dev/null +++ b/src/function/eviction.rs @@ -0,0 +1,40 @@ +//! Pluggable cache eviction strategies for memoized function values. +//! +//! This module provides the [`EvictionPolicy`] trait that allows different +//! eviction strategies to be used for salsa tracked functions. + +mod lru; +mod noop; + +pub use lru::Lru; +pub use noop::NoopEviction; + +use crate::Id; + +/// Trait for cache eviction strategies. +/// +/// Implementations control when memoized values are evicted from the cache. +/// The eviction policy is selected at compile time via the `Configuration` trait. +pub trait EvictionPolicy: Send + Sync { + /// Create a new eviction policy with the given capacity. + fn new(capacity: usize) -> Self; + + /// Record that an item was accessed. + fn record_use(&self, id: Id); + + /// Set the maximum capacity. + fn set_capacity(&mut self, capacity: usize); + + /// Iterate over items that should be evicted. + /// + /// Called once per revision during `reset_for_new_revision`. + /// The callback `cb` should be invoked for each item to evict. + fn for_each_evicted(&mut self, cb: impl FnMut(Id)); +} + +/// Marker trait for eviction policies that have a configurable capacity. +/// +/// This trait is used to conditionally generate the `set_lru_capacity` method +/// on tracked functions. Only policies that implement this trait will expose +/// runtime capacity configuration. +pub trait HasCapacity: EvictionPolicy {} diff --git a/src/function/eviction/lru.rs b/src/function/eviction/lru.rs new file mode 100644 index 000000000..8fa0b1cfc --- /dev/null +++ b/src/function/eviction/lru.rs @@ -0,0 +1,66 @@ +//! Least Recently Used (LRU) eviction policy. +//! +//! This policy tracks the most recently accessed items and evicts +//! the least recently used ones when the cache exceeds its capacity. + +use std::num::NonZeroUsize; + +use crate::hash::FxLinkedHashSet; +use crate::sync::Mutex; +use crate::Id; + +use super::{EvictionPolicy, HasCapacity}; + +/// Least Recently Used eviction policy. +/// +/// When the number of memoized values exceeds the configured capacity, +/// the least recently accessed values are evicted at the start of each +/// new revision. +pub struct Lru { + capacity: Option, + set: Mutex>, +} + +impl Lru { + #[inline(never)] + fn insert(&self, id: Id) { + self.set.lock().insert(id); + } +} + +impl EvictionPolicy for Lru { + fn new(cap: usize) -> Self { + Self { + capacity: NonZeroUsize::new(cap), + set: Mutex::default(), + } + } + + #[inline(always)] + fn record_use(&self, id: Id) { + if self.capacity.is_some() { + self.insert(id); + } + } + + fn set_capacity(&mut self, capacity: usize) { + self.capacity = NonZeroUsize::new(capacity); + if self.capacity.is_none() { + self.set.get_mut().clear(); + } + } + + fn for_each_evicted(&mut self, mut cb: impl FnMut(Id)) { + let Some(cap) = self.capacity else { + return; + }; + let set = self.set.get_mut(); + while set.len() > cap.get() { + if let Some(id) = set.pop_front() { + cb(id); + } + } + } +} + +impl HasCapacity for Lru {} diff --git a/src/function/eviction/noop.rs b/src/function/eviction/noop.rs new file mode 100644 index 000000000..f80fbaab9 --- /dev/null +++ b/src/function/eviction/noop.rs @@ -0,0 +1,23 @@ +//! No-op eviction policy - cache grows unbounded. +//! +//! This is the default eviction policy when no LRU capacity is specified. + +use crate::{function::EvictionPolicy, Id}; + +/// No eviction - cache grows unbounded. +pub struct NoopEviction; + +impl EvictionPolicy for NoopEviction { + fn new(_cap: usize) -> Self { + Self + } + + #[inline(always)] + fn record_use(&self, _id: Id) {} + + #[inline(always)] + fn set_capacity(&mut self, _capacity: usize) {} + + #[inline(always)] + fn for_each_evicted(&mut self, _cb: impl FnMut(Id)) {} +} diff --git a/src/function/fetch.rs b/src/function/fetch.rs index 588b08bb1..439db531e 100644 --- a/src/function/fetch.rs +++ b/src/function/fetch.rs @@ -1,6 +1,7 @@ use rustc_hash::FxHashMap; use crate::cycle::{CycleHeads, CycleRecoveryStrategy, IterationCount}; +use crate::function::eviction::EvictionPolicy; use crate::function::maybe_changed_after::VerifyCycleHeads; use crate::function::memo::Memo; use crate::function::sync::ClaimResult; @@ -33,7 +34,7 @@ where // SAFETY: We just refreshed the memo so it is guaranteed to contain a value now. let memo_value = unsafe { memo.value.as_ref().unwrap_unchecked() }; - self.lru.record_use(id); + self.eviction.record_use(id); zalsa_local.report_tracked_read( database_key_index, diff --git a/src/function/lru.rs b/src/function/lru.rs deleted file mode 100644 index 41e7770de..000000000 --- a/src/function/lru.rs +++ /dev/null @@ -1,51 +0,0 @@ -use std::num::NonZeroUsize; - -use crate::hash::FxLinkedHashSet; -use crate::sync::Mutex; -use crate::Id; - -pub(super) struct Lru { - capacity: Option, - set: Mutex>, -} - -impl Lru { - pub fn new(cap: usize) -> Self { - Self { - capacity: NonZeroUsize::new(cap), - set: Mutex::default(), - } - } - - #[inline(always)] - pub(super) fn record_use(&self, index: Id) { - if self.capacity.is_some() { - self.insert(index); - } - } - - #[inline(never)] - fn insert(&self, index: Id) { - let mut set = self.set.lock(); - set.insert(index); - } - - pub(super) fn set_capacity(&mut self, capacity: usize) { - self.capacity = NonZeroUsize::new(capacity); - if self.capacity.is_none() { - self.set.get_mut().clear(); - } - } - - pub(super) fn for_each_evicted(&mut self, mut cb: impl FnMut(Id)) { - let Some(cap) = self.capacity else { - return; - }; - let set = self.set.get_mut(); - while set.len() > cap.get() { - if let Some(id) = set.pop_front() { - cb(id); - } - } - } -} diff --git a/src/function/memo.rs b/src/function/memo.rs index 234829cb1..4d259bb1d 100644 --- a/src/function/memo.rs +++ b/src/function/memo.rs @@ -539,6 +539,7 @@ mod _memory_usage { type SalsaStruct<'db> = DummyStruct; type Input<'db> = (); type Output<'db> = NonZeroUsize; + type Eviction = crate::function::eviction::NoopEviction; fn values_equal<'db>(_: &Self::Output<'db>, _: &Self::Output<'db>) -> bool { unimplemented!() diff --git a/src/lib.rs b/src/lib.rs index f90fce338..5b4cc28f7 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -160,6 +160,7 @@ pub mod plumbing { pub use crate::function::Configuration; pub use crate::function::IngredientImpl; pub use crate::function::Memo; + pub use crate::function::{EvictionPolicy, HasCapacity, Lru, NoopEviction}; pub use crate::table::memo::MemoEntryType; }