Skip to main content

slate_core/
cost.rs

1//! The storage-aware query-cost model.
2//!
3//! Slate-ANN is built on the thesis that approximate-nearest-neighbour search
4//! should be **storage-aware**: the physical medium and the on-disk layout are
5//! first-class terms in the query-cost objective, not an afterthought hidden
6//! behind a uniform-latency assumption. See `docs/storage-aware-search.md` for
7//! the full argument.
8//!
9//! The objective decomposes the cost of answering a query into three additive
10//! terms:
11//!
12//! ```text
13//! QueryCost = TraversalCost + StorageAccessCost + DistanceComputationCost
14//! ```
15//!
16//! This module provides the **vocabulary and an estimator** for that objective:
17//!
18//! * [`StorageProfile`] — the parameters of a storage medium (seek latency,
19//!   sequential bandwidth, block granularity), with presets for an HDD, an
20//!   NVMe SSD, and RAM.
21//! * [`DistanceCost`] — the per-operation cost of approximate and exact distance
22//!   computations on the active CPU.
23//! * [`QueryCounters`] — the **physical counters** a search accumulates as it
24//!   runs (nodes visited, seeks issued, bytes read, distance ops). These are
25//!   what the engine actually measures.
26//! * [`QueryCost`] — the three-term decomposition, obtained by pricing a set of
27//!   [`QueryCounters`] against a [`StorageProfile`] and a [`DistanceCost`].
28//!
29//! It is deliberately **not** a query planner: nothing here decides traversal
30//! order or fetch policy. It names the terms later phases optimize and lets a
31//! measured query be priced after the fact, so the paradigm is evaluated with
32//! numbers rather than asserted.
33
34use serde::{Deserialize, Serialize};
35
36/// Parameters of a storage medium, expressed in the units the cost model needs.
37///
38/// The two parameters that matter most on non-uniform storage are the **seek
39/// latency** (the fixed price of positioning at a read) and the **sequential
40/// bandwidth** (the marginal price per byte once positioned). On a 7200rpm HDD
41/// the former dominates the latter by orders of magnitude, which is precisely
42/// why minimizing *seek count* — not bytes, and not graph hops — is the lever.
43///
44/// Classical in-RAM ANN implicitly assumes [`StorageProfile::memory`], where the
45/// seek latency is ~0 and bandwidth is effectively unbounded; under that profile
46/// the storage-access term vanishes and only graph hops remain. Slate-ANN takes
47/// the profile as an *input* instead.
48#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
49pub struct StorageProfile {
50    /// Latency to position at a fresh random read, in seconds.
51    ///
52    /// For an HDD this is head-seek plus average rotational latency; for an SSD
53    /// it is a small controller/queue constant; for RAM it is ~0.
54    pub seek_latency_s: f64,
55    /// Sustained sequential transfer rate, in bytes per second.
56    pub sequential_bandwidth_bps: f64,
57    /// Natural transfer granularity of the medium, in bytes.
58    ///
59    /// Reads smaller than this still pay for at least this many bytes of
60    /// transfer (HDD sector / SSD page / OS page). Used when pricing a read
61    /// whose payload is smaller than one block.
62    pub block_bytes: u64,
63}
64
65impl StorageProfile {
66    /// A representative 7200rpm consumer hard disk drive.
67    ///
68    /// ~9 ms seek (≈4.2 ms average rotational latency at 7200rpm plus head
69    /// seek), ~160 MB/s sequential, 4 KiB minimum transfer. This is the headline
70    /// target medium: the regime where the uniform-latency assumption is most
71    /// catastrophically wrong.
72    #[inline]
73    pub const fn hdd_7200rpm() -> Self {
74        Self {
75            seek_latency_s: 0.009,
76            sequential_bandwidth_bps: 160.0 * 1_000_000.0,
77            block_bytes: 4096,
78        }
79    }
80
81    /// A representative consumer NVMe solid-state drive.
82    ///
83    /// ~100 µs effective random-access latency, ~3.5 GB/s sequential, 4 KiB
84    /// page. Random reads are cheap here, which is why SSD-tuned designs (e.g.
85    /// DiskANN) can afford one small random read per hop.
86    #[inline]
87    pub const fn ssd_nvme() -> Self {
88        Self {
89            seek_latency_s: 0.000_1,
90            sequential_bandwidth_bps: 3_500.0 * 1_000_000.0,
91            block_bytes: 4096,
92        }
93    }
94
95    /// Resident memory: the implicit medium of classical in-RAM ANN.
96    ///
97    /// Negligible seek, very high bandwidth, cache-line granularity. Under this
98    /// profile [`QueryCost::storage_access_s`] is ~0 and query cost is dominated
99    /// by traversal and distance computation — the classical special case.
100    #[inline]
101    pub const fn memory() -> Self {
102        Self {
103            seek_latency_s: 0.0,
104            sequential_bandwidth_bps: 20_000.0 * 1_000_000.0,
105            block_bytes: 64,
106        }
107    }
108
109    /// Seconds to transfer `bytes` at the medium's sequential bandwidth.
110    ///
111    /// This is the honest transfer time for an already-summed byte count: zero
112    /// bytes costs zero. Per-read block-granularity flooring lives in
113    /// [`read_s`](Self::read_s), since "a read smaller than a block still pays
114    /// for a block" is a property of an individual read, not of an aggregate.
115    #[inline]
116    pub fn transfer_s(self, bytes: u64) -> f64 {
117        bytes as f64 / self.sequential_bandwidth_bps
118    }
119
120    /// Modelled latency of a single read of `bytes`: one seek plus the transfer
121    /// of at least one block.
122    #[inline]
123    pub fn read_s(self, bytes: u64) -> f64 {
124        self.seek_latency_s + self.transfer_s(bytes.max(self.block_bytes))
125    }
126}
127
128/// Per-operation cost of distance computation on the active CPU.
129///
130/// Approximate distances are PQ/ADC table lookups over RAM-resident codes and
131/// are cheap; exact distances are full `d`-dimensional SIMD reductions over a
132/// fetched vector. Keeping the two priced separately is what makes the
133/// approximate-gate trade — spend cheap approximate ops to avoid expensive exact
134/// fetches — legible in the cost model.
135///
136/// Costs are in seconds per operation and are expected to be obtained by offline
137/// micro-benchmarking (the SIMD bench harness) for a given dimensionality,
138/// dtype, and instruction set.
139#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
140pub struct DistanceCost {
141    /// Seconds per approximate (PQ/ADC lookup) distance.
142    pub approx_s: f64,
143    /// Seconds per exact (full SIMD) distance.
144    pub exact_s: f64,
145}
146
147impl DistanceCost {
148    /// Construct an explicit per-op cost pair.
149    #[inline]
150    pub const fn new(approx_s: f64, exact_s: f64) -> Self {
151        Self { approx_s, exact_s }
152    }
153}
154
155/// Physical counters accumulated by a single query as it executes.
156///
157/// These are the quantities the engine actually measures and the quantities the
158/// storage-aware claims are stated in terms of. A hops-only view records only
159/// `nodes_visited`; the storage-access terms (`seeks`, `bytes_read`,
160/// `sequential_runs`) are exactly what that view omits.
161#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
162pub struct QueryCounters {
163    /// Nodes popped from the candidate queue (graph hops). Drives traversal cost.
164    pub nodes_visited: u64,
165    /// Approximate (PQ/ADC) distances computed.
166    pub approx_distances: u64,
167    /// Exact (full SIMD) distances computed on fetched vectors.
168    pub exact_distances: u64,
169    /// Physical positioning operations (seeks) issued to storage.
170    pub seeks: u64,
171    /// Total bytes read from storage.
172    pub bytes_read: u64,
173    /// Number of coalesced sequential runs the reads collapsed into.
174    ///
175    /// With perfect demand paging this equals `seeks`; with elevator scheduling
176    /// and graph-aware layout it can be far smaller, which is the effect the
177    /// scheduler is meant to produce.
178    pub sequential_runs: u64,
179}
180
181impl QueryCounters {
182    /// A zeroed counter set.
183    #[inline]
184    pub const fn new() -> Self {
185        Self {
186            nodes_visited: 0,
187            approx_distances: 0,
188            exact_distances: 0,
189            seeks: 0,
190            bytes_read: 0,
191            sequential_runs: 0,
192        }
193    }
194
195    /// Record a node pop (one graph hop).
196    #[inline]
197    pub fn visit_node(&mut self) {
198        self.nodes_visited += 1;
199    }
200
201    /// Record `n` approximate distance computations.
202    #[inline]
203    pub fn add_approx(&mut self, n: u64) {
204        self.approx_distances += n;
205    }
206
207    /// Record `n` exact distance computations.
208    #[inline]
209    pub fn add_exact(&mut self, n: u64) {
210        self.exact_distances += n;
211    }
212
213    /// Record a physical read of `bytes` that required `seeks` positioning
214    /// operations and collapsed into `runs` sequential runs.
215    #[inline]
216    pub fn add_read(&mut self, bytes: u64, seeks: u64, runs: u64) {
217        self.bytes_read += bytes;
218        self.seeks += seeks;
219        self.sequential_runs += runs;
220    }
221
222    /// Merge another counter set into this one (e.g. across parallel workers).
223    #[inline]
224    pub fn merge(&mut self, other: &QueryCounters) {
225        self.nodes_visited += other.nodes_visited;
226        self.approx_distances += other.approx_distances;
227        self.exact_distances += other.exact_distances;
228        self.seeks += other.seeks;
229        self.bytes_read += other.bytes_read;
230        self.sequential_runs += other.sequential_runs;
231    }
232}
233
234/// The three-term query-cost decomposition, in seconds.
235///
236/// Produced by [`QueryCost::estimate`], which prices a set of [`QueryCounters`]
237/// against a [`StorageProfile`] and a [`DistanceCost`]. The point of keeping the
238/// terms separate (rather than only their sum) is that the storage-aware claims
239/// are about how a change shifts cost *between* terms — e.g. a narrower dtype
240/// moves cost out of `storage_access_s`, a larger approximate gate moves cost
241/// out of `storage_access_s` and into `distance_s`.
242#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
243pub struct QueryCost {
244    /// `c_hop · |V|` — best-first bookkeeping over visited nodes (RAM-bound).
245    pub traversal_s: f64,
246    /// `Σ (t_seek + bytes/B_seq)` — physical reads. The term prior work assumes
247    /// uniform; the dominant term on HDD-class media.
248    pub storage_access_s: f64,
249    /// `c_dist · (|A| + |E|)` — approximate plus exact distance computation.
250    pub distance_s: f64,
251}
252
253impl QueryCost {
254    /// Per-visited-node traversal overhead, in seconds.
255    ///
256    /// Best-first bookkeeping (queue ops, adjacency expansion, visited-set
257    /// maintenance) is small and RAM-bound; this is a representative constant,
258    /// not a tuned value. Traversal is intentionally the cheap term in the
259    /// storage-aware regime.
260    pub const HOP_OVERHEAD_S: f64 = 50e-9;
261
262    /// Price `counters` against a storage `profile` and distance `cost`.
263    ///
264    /// Storage access is modelled as one seek per `seeks` counted plus transfer
265    /// of `bytes_read` at the medium's sequential bandwidth — so a query that
266    /// coalesces its reads into fewer seeks (via layout + scheduling) is priced
267    /// strictly lower at equal bytes, which is the behaviour the paradigm
268    /// predicts and the scheduler is built to produce.
269    #[inline]
270    pub fn estimate(
271        counters: &QueryCounters,
272        profile: StorageProfile,
273        cost: DistanceCost,
274    ) -> Self {
275        let traversal_s = counters.nodes_visited as f64 * Self::HOP_OVERHEAD_S;
276        let storage_access_s = counters.seeks as f64 * profile.seek_latency_s
277            + profile.transfer_s(counters.bytes_read);
278        let distance_s = counters.approx_distances as f64 * cost.approx_s
279            + counters.exact_distances as f64 * cost.exact_s;
280        Self {
281            traversal_s,
282            storage_access_s,
283            distance_s,
284        }
285    }
286
287    /// Total modelled query latency: the sum of the three terms.
288    #[inline]
289    pub fn total_s(self) -> f64 {
290        self.traversal_s + self.storage_access_s + self.distance_s
291    }
292
293    /// Fraction of total cost attributable to storage access, in `[0, 1]`.
294    ///
295    /// A diagnostic for *which regime a query is in*: near 1 means the query is
296    /// storage-bound (the paradigm's target regime), near 0 means it is
297    /// compute- or traversal-bound (the classical in-RAM regime).
298    #[inline]
299    pub fn storage_fraction(self) -> f64 {
300        let total = self.total_s();
301        if total == 0.0 {
302            0.0
303        } else {
304            self.storage_access_s / total
305        }
306    }
307}
308
309#[cfg(test)]
310mod tests {
311    use super::*;
312
313    #[test]
314    fn hdd_seek_dominates_single_vector_transfer() {
315        // One 768-dim f32 vector is ~3 KiB; on HDD the seek must dwarf the
316        // transfer, which is the entire motivation for minimizing seeks.
317        let hdd = StorageProfile::hdd_7200rpm();
318        let transfer = hdd.transfer_s(3 * 1024);
319        assert!(
320            hdd.seek_latency_s > transfer * 100.0,
321            "seek {} should dwarf transfer {}",
322            hdd.seek_latency_s,
323            transfer
324        );
325    }
326
327    #[test]
328    fn transfer_is_honest_and_read_floors_to_a_block() {
329        let hdd = StorageProfile::hdd_7200rpm();
330        // Aggregate transfer is honest: zero bytes is free, bytes scale linearly.
331        assert_eq!(hdd.transfer_s(0), 0.0);
332        assert!(hdd.transfer_s(2 * 1024) > hdd.transfer_s(1024));
333        // A single sub-block read is charged for a full block by read_s.
334        assert_eq!(hdd.read_s(1), hdd.read_s(hdd.block_bytes));
335        // A read larger than a block is charged for its true size.
336        let big = hdd.block_bytes * 10;
337        assert!(hdd.read_s(big) > hdd.read_s(hdd.block_bytes));
338    }
339
340    #[test]
341    fn read_is_seek_plus_floored_transfer() {
342        let ssd = StorageProfile::ssd_nvme();
343        let bytes = 8192; // larger than the 4 KiB block, so the floor is a no-op
344        assert_eq!(
345            ssd.read_s(bytes),
346            ssd.seek_latency_s + ssd.transfer_s(bytes)
347        );
348    }
349
350    #[test]
351    fn memory_profile_has_negligible_seek() {
352        let mem = StorageProfile::memory();
353        assert_eq!(mem.seek_latency_s, 0.0);
354        // Under the memory profile a read is pure transfer, no seek. A 64-byte
355        // read equals one block, so the floor is a no-op here.
356        assert_eq!(mem.read_s(64), mem.transfer_s(64));
357    }
358
359    #[test]
360    fn counters_accumulate_and_merge() {
361        let mut a = QueryCounters::new();
362        a.visit_node();
363        a.add_approx(10);
364        a.add_exact(2);
365        a.add_read(6144, 2, 1);
366        assert_eq!(a.nodes_visited, 1);
367        assert_eq!(a.approx_distances, 10);
368        assert_eq!(a.exact_distances, 2);
369        assert_eq!(a.bytes_read, 6144);
370        assert_eq!(a.seeks, 2);
371        assert_eq!(a.sequential_runs, 1);
372
373        let mut b = QueryCounters::new();
374        b.add_read(1024, 1, 1);
375        b.merge(&a);
376        assert_eq!(b.bytes_read, 6144 + 1024);
377        assert_eq!(b.seeks, 3);
378        assert_eq!(b.nodes_visited, 1);
379    }
380
381    #[test]
382    fn estimate_decomposes_three_terms() {
383        let mut c = QueryCounters::new();
384        c.nodes_visited = 100;
385        c.approx_distances = 1000;
386        c.exact_distances = 50;
387        c.seeks = 50;
388        c.bytes_read = 50 * 3072;
389        let cost = DistanceCost::new(5e-9, 200e-9);
390        let qc = QueryCost::estimate(&c, StorageProfile::hdd_7200rpm(), cost);
391
392        assert_eq!(qc.traversal_s, 100.0 * QueryCost::HOP_OVERHEAD_S);
393        assert_eq!(qc.distance_s, 1000.0 * 5e-9 + 50.0 * 200e-9);
394        // 50 random seeks on HDD ≈ 50 * 9 ms = 0.45 s, the dominant term.
395        assert!(qc.storage_access_s > 0.4);
396        assert!((qc.total_s()
397            - (qc.traversal_s + qc.storage_access_s + qc.distance_s))
398            .abs()
399            < 1e-12);
400    }
401
402    #[test]
403    fn coalescing_seeks_lowers_cost_at_equal_bytes() {
404        // Same bytes, same exact-distance work; the only difference is how many
405        // seeks the reads collapsed into. The storage-aware model must price the
406        // coalesced plan strictly cheaper — this is claim (3) in miniature.
407        let bytes = 64 * 3072;
408        let cost = DistanceCost::new(5e-9, 200e-9);
409        let hdd = StorageProfile::hdd_7200rpm();
410
411        let mut scattered = QueryCounters::new();
412        scattered.add_read(bytes, 64, 64);
413        let mut coalesced = QueryCounters::new();
414        coalesced.add_read(bytes, 1, 1);
415
416        let scattered_cost = QueryCost::estimate(&scattered, hdd, cost);
417        let coalesced_cost = QueryCost::estimate(&coalesced, hdd, cost);
418        assert!(scattered_cost.storage_access_s > coalesced_cost.storage_access_s);
419        // And the scattered query is firmly storage-bound.
420        assert!(scattered_cost.storage_fraction() > 0.9);
421    }
422
423    #[test]
424    fn memory_profile_collapses_the_seek_term() {
425        // The same query, priced under RAM vs HDD. The seek-driven storage cost
426        // (100 random seeks) is what the uniform-latency assumption omits; under
427        // the memory profile it collapses by orders of magnitude, leaving the
428        // query compute/traversal bound — the classical degenerate case.
429        let mut c = QueryCounters::new();
430        c.nodes_visited = 100;
431        c.exact_distances = 100;
432        c.seeks = 100;
433        c.bytes_read = 100 * 3072;
434        let cost = DistanceCost::new(5e-9, 200e-9);
435
436        let hdd = QueryCost::estimate(&c, StorageProfile::hdd_7200rpm(), cost);
437        let mem = QueryCost::estimate(&c, StorageProfile::memory(), cost);
438
439        // HDD is dominated by seeks; RAM is not storage-bound at all.
440        assert!(hdd.storage_fraction() > 0.9);
441        assert!(mem.storage_fraction() < 0.5);
442        // The storage term shrinks by orders of magnitude across the two media.
443        assert!(mem.storage_access_s < hdd.storage_access_s / 100.0);
444    }
445
446    #[test]
447    fn zero_cost_has_zero_storage_fraction() {
448        let qc = QueryCost::estimate(
449            &QueryCounters::new(),
450            StorageProfile::hdd_7200rpm(),
451            DistanceCost::new(5e-9, 200e-9),
452        );
453        assert_eq!(qc.total_s(), 0.0);
454        assert_eq!(qc.storage_fraction(), 0.0);
455    }
456}