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}