Skip to main content

rudb_encoding/
chooser.rs

1//! How the encoder decides which candidate to keep.
2//!
3//! The encoders in [`crate::string`] and [`crate::integer`] are the work. This is the search. They
4//! are separate things and until now they were the same thing, because `encode` both offered every
5//! candidate and encoded every candidate it offered, and there was no way to have one without the
6//! other.
7//!
8//! # Why it is worth separating
9//!
10//! `cargo xtask encode` over a million rows of ClickBench `hits` says where the encoder's seconds
11//! go. String `FRONT` is 32.2 percent of them and is kept once in 142 chunks. String `FSST` is 11.2
12//! percent and is kept twice in 223. Integer `DELTA` is 10.8 percent and is kept never in 607.
13//! String `PLAIN` is 8.7 percent and is kept four times in 223. Those four are 62.9 percent of the
14//! encoder's time and they were kept seven times out of 1,195 offers.
15//!
16//! That is not a bug in any encoder. It is what an exhaustive search costs, and the search is worth
17//! something: the shapes it arrives at are five to one on `hits` and nobody wrote them down in
18//! advance. The question is how much of the search is needed, which is a question about the data
19//! and therefore a question to measure rather than argue about. F2 asks for exactly this, as "the
20//! encoder chooser as a seam, with exhaustive and sampled implementations", with the ablation being
21//! how much size the sampled one gives up.
22//!
23//! # What a chooser sees and what it does not
24//!
25//! A chooser is asked once per chunk per level of the cascade, never once per value. It is handed
26//! the values and the candidates that apply and it returns the ones worth encoding in full. It
27//! cannot invent a candidate that does not apply, so nothing it does can produce a chunk that will
28//! not decode, and the worst a bad chooser can do is pick a bigger encoding than another one would
29//! have. That is the property that makes this safe to swap.
30//!
31//! # Not a `rudb-seam` seam yet, and why
32//!
33//! `SeamId::StorageEncoder` exists and says "how a block of values is encoded on the way to disk",
34//! and this is what belongs behind it. It cannot be registered here: `rudb-seam` is rank 2 and so is
35//! this crate, so the `Strategy` supertrait every seam trait needs is not visible from here. The
36//! registry goes in `rudb-storage` at rank 5, next to the write path, and there is no write path
37//! yet. Until there is, this is a plain trait with two implementations and an ablation, which is
38//! the part that can be measured today.
39
40use crate::{integer, string};
41
42/// Which of the candidates that apply are worth encoding in full.
43///
44/// Crossed once per chunk per level of the cascade. No method here sees a single value on its own,
45/// which is the rule that lets the decision be indirect at all.
46pub trait Chooser: std::fmt::Debug + Sync {
47    /// The name that goes in a report.
48    fn name(&self) -> &'static str;
49
50    /// Which of `offered` to encode in full, for a chunk of strings at `depth`.
51    ///
52    /// `offered` is what applies, in the order the exhaustive chooser would try them. The return
53    /// has to be a subset of it and has to be non empty, because a chunk with no candidate is a
54    /// chunk that cannot be written.
55    fn narrow_strings(
56        &self,
57        values: &[&[u8]],
58        offered: &[string::Kind],
59        depth: u8,
60    ) -> Vec<string::Kind>;
61
62    /// Which of `offered` to encode in full, for a chunk of integers at `depth`.
63    fn narrow_integers(
64        &self,
65        values: &[i64],
66        offered: &[integer::Kind],
67        depth: u8,
68    ) -> Vec<integer::Kind>;
69
70    /// Whether `kind` can ever be in what [`Chooser::narrow_integers`] returns at `depth`.
71    ///
72    /// Asked before the candidates are worked out, so a kind this rules out is never tested for.
73    /// That matters because the test is not free: finding out whether a dictionary or a sparse
74    /// encoding applies used to sort a copy of the chunk, at every level of the cascade, for a
75    /// chooser that was going to throw both away. Saying yes to a kind that is then dropped only
76    /// costs the test. Saying no to a kind the narrowing would have kept changes what gets written,
77    /// so the default is yes and an implementation only says no where its narrowing always would.
78    fn considers_integer(&self, kind: integer::Kind, depth: u8) -> bool {
79        let _ = (kind, depth);
80        true
81    }
82}
83
84/// Encode every candidate that applies and keep the smallest.
85///
86/// The reference, and what `encode` has always done. It is the thing to beat rather than the thing
87/// to ship: every size this crate has ever reported came out of it, so an alternative's ablation is
88/// against this and a build that wants the old bytes exactly asks for this.
89#[derive(Debug, Clone, Copy, Default)]
90pub struct Exhaustive;
91
92/// The one of these that does not have to be constructed, since it holds nothing.
93pub const EXHAUSTIVE: Exhaustive = Exhaustive;
94
95impl Chooser for Exhaustive {
96    fn name(&self) -> &'static str {
97        "exhaustive"
98    }
99
100    fn narrow_strings(
101        &self,
102        _values: &[&[u8]],
103        offered: &[string::Kind],
104        _depth: u8,
105    ) -> Vec<string::Kind> {
106        offered.to_vec()
107    }
108
109    fn narrow_integers(
110        &self,
111        _values: &[i64],
112        offered: &[integer::Kind],
113        _depth: u8,
114    ) -> Vec<integer::Kind> {
115        offered.to_vec()
116    }
117}
118
119/// Encode every candidate on a sample, then encode only the winner on the whole chunk.
120///
121/// The bet is that a chunk of 122,880 values and a sample of 8,192 drawn from it agree about which
122/// encoding suits them, which is a bet about the data and is what the ablation settles. Where it is
123/// wrong the cost is size and never correctness, because the winner still has to apply to the whole
124/// chunk and is still encoded over all of it.
125///
126/// The sample is windows of consecutive values rather than values picked one at a time, because
127/// three of the candidates are about what a value has in common with the value before it. A sample
128/// of scattered singletons would show `FRONT` and `RLE` nothing to find and would rule them out on
129/// every column, which is the wrong answer arrived at quickly.
130///
131/// There are two guards on whether to sample at all and both of them are there because a measurement
132/// said so. A chunk with fewer values than the sample is not sampled, because encoding every
133/// candidate on something the size of the chunk and then encoding the winner on the chunk is more
134/// work than the exhaustive chooser for the same answer. A chunk holding less than a page of bytes is
135/// not sampled either, because the cost of the search scales with the bytes in the chunk and not
136/// with how many values they are spread over, so on a narrow column there is nothing to save and a
137/// sample that misses the structure gives up real size for it.
138#[derive(Debug, Clone, Copy)]
139pub struct Sampled {
140    window: usize,
141    regions: usize,
142}
143
144/// How many consecutive values one window of the sample holds.
145///
146/// The tile, which is what a bit packing kernel works in and is the smallest run of a column that
147/// has the column's local structure in it rather than one value's worth of accident.
148const WINDOW: usize = 1024;
149
150/// How many windows the sample is drawn from.
151///
152/// Eight windows of a tile each is 8,192 values, a fifteenth of a chunk. Spread across the chunk
153/// rather than taken off the front, because the front of a sorted column is one value repeated and
154/// a chooser that saw only that would pick `CONSTANT` for everything.
155const REGIONS: usize = 8;
156
157/// How few bytes a chunk can hold before sampling it is not worth the risk.
158///
159/// The ablation in #559 found `Params` at a million rows encoding to 21,782 bytes exhaustively and
160/// 128,455 bytes sampled, which is 490 percent for a column that is almost entirely empty strings.
161/// It passed the value count guard because it has a million values, and then the sample missed what
162/// little structure it had. The exhaustive search over a column that small costs almost nothing,
163/// which is the same fact from the other side, so a floor on bytes takes the whole class of column
164/// out of the sampler's hands and gives up nothing to do it.
165///
166/// 256 KiB is one page, which is the smallest unit the format moves. Below that the search is not
167/// where the time is.
168const FLOOR: usize = 256 * 1024;
169
170impl Default for Sampled {
171    fn default() -> Self {
172        Self { window: WINDOW, regions: REGIONS }
173    }
174}
175
176impl Sampled {
177    /// The default sample, which is eight windows of 1,024 values.
178    #[must_use]
179    pub fn new() -> Self {
180        Self::default()
181    }
182
183    /// A sample of a size somebody else picked, which is what the ablation sweeps.
184    #[must_use]
185    pub fn over(window: usize, regions: usize) -> Self {
186        Self { window: window.max(1), regions: regions.max(1) }
187    }
188
189    /// How many values the sample holds, which is one of the two things that decide whether
190    /// sampling is worth doing.
191    #[must_use]
192    pub fn size(self) -> usize {
193        self.window * self.regions
194    }
195
196    /// Whether a chunk of `count` values holding `bytes` bytes is worth sampling.
197    fn worth_it(self, count: usize, bytes: usize) -> bool {
198        count > self.size() && bytes >= FLOOR
199    }
200}
201
202impl Chooser for Sampled {
203    fn name(&self) -> &'static str {
204        "sampled"
205    }
206
207    fn narrow_strings(
208        &self,
209        values: &[&[u8]],
210        offered: &[string::Kind],
211        depth: u8,
212    ) -> Vec<string::Kind> {
213        let bytes = values.iter().map(|value| value.len()).sum();
214        if offered.len() < 2 || !self.worth_it(values.len(), bytes) {
215            return offered.to_vec();
216        }
217        let sample = sample(values, self.window, self.regions);
218        let mut best: Option<(string::Kind, usize)> = None;
219        for &kind in offered {
220            let Ok(Some(size)) = string::size_as(kind, &sample, depth) else {
221                continue;
222            };
223            if best.is_none_or(|(_, smallest)| size < smallest) {
224                best = Some((kind, size));
225            }
226        }
227        // Nothing applied to the sample, which should not happen and is not worth a wrong answer
228        // if it does. Hand back everything and let the exhaustive path sort it out.
229        best.map_or_else(|| offered.to_vec(), |(kind, _)| vec![kind])
230    }
231
232    fn narrow_integers(
233        &self,
234        values: &[i64],
235        offered: &[integer::Kind],
236        depth: u8,
237    ) -> Vec<integer::Kind> {
238        if offered.len() < 2 || !self.worth_it(values.len(), values.len() * 8) {
239            return offered.to_vec();
240        }
241        let sample = sample(values, self.window, self.regions);
242        let mut best: Option<(integer::Kind, usize)> = None;
243        for &kind in offered {
244            let Ok(Some(size)) = integer::size_as(kind, &sample, depth) else {
245                continue;
246            };
247            if best.is_none_or(|(_, smallest)| size < smallest) {
248                best = Some((kind, size));
249            }
250        }
251        best.map_or_else(|| offered.to_vec(), |(kind, _)| vec![kind])
252    }
253}
254
255/// Encode one shape that somebody else settled on, and do not search at all.
256///
257/// [`Sampled`] decides per chunk, which is right when a chunk is big enough to pay for the sample
258/// and when neighbouring chunks are different from each other. Neither holds for a caller that has
259/// thousands of small chunks cut out of one column, because the sample would cost as much as the
260/// encode and because the answer would come out the same thousands of times. Such a caller decides
261/// once, over as much of the column as it likes, and hands the answer here.
262///
263/// A shape is one kind per level of the cascade, which is a simplification of a real one: `FRONT`
264/// produces an integer chunk of prefixes and a string chunk of suffixes at the next level, and both
265/// are narrowed to the same entry. That is enough on real data because the tree is narrow and
266/// because the levels below the second are small. Any level the shape does not reach is searched
267/// exhaustively, which is what makes the shape a hint about the expensive part rather than a
268/// decision about all of it.
269///
270/// An entry that does not apply to a chunk is ignored and the chunk is searched instead. The kinds
271/// that apply are a property of the values, and this is a chooser rather than a way round the
272/// filter, so a shape can never produce something that will not decode.
273#[derive(Debug, Clone)]
274pub struct Settled {
275    strings: Vec<string::Kind>,
276    integers: Vec<integer::Kind>,
277}
278
279impl Settled {
280    /// A shape, outermost level first, for the string levels and the integer levels.
281    #[must_use]
282    pub fn new(strings: Vec<string::Kind>, integers: Vec<integer::Kind>) -> Self {
283        Self { strings, integers }
284    }
285
286    /// The string kinds of the shape, outermost first, which is what a report prints.
287    #[must_use]
288    pub fn strings(&self) -> &[string::Kind] {
289        &self.strings
290    }
291}
292
293impl Chooser for Settled {
294    fn name(&self) -> &'static str {
295        "settled"
296    }
297
298    fn narrow_strings(
299        &self,
300        _values: &[&[u8]],
301        offered: &[string::Kind],
302        depth: u8,
303    ) -> Vec<string::Kind> {
304        match self.strings.get(depth as usize) {
305            Some(kind) if offered.contains(kind) => vec![*kind],
306            _ => offered.to_vec(),
307        }
308    }
309
310    fn narrow_integers(
311        &self,
312        _values: &[i64],
313        offered: &[integer::Kind],
314        depth: u8,
315    ) -> Vec<integer::Kind> {
316        match self.integers.get(depth as usize) {
317            Some(kind) if offered.contains(kind) => vec![*kind],
318            _ => offered.to_vec(),
319        }
320    }
321}
322
323/// `regions` windows of `window` consecutive values each, spread evenly across the input.
324///
325/// The starts are spread over the whole range a window can start at, so the first window begins at
326/// the first value and the last one ends at the last value. A chunk of 122,880 values sampled at
327/// eight windows of 1,024 gives windows starting at 0, 17,408, 34,816 and so on up to 121,856, which
328/// crosses every part of the chunk including both ends of it.
329///
330/// Spreading to the end rather than striding by `len / regions` matters on the columns this is for.
331/// A stride would leave the last stride minus one window of the chunk unsampled, and the tail of a
332/// chunk is exactly where a column that is sorted or clustered stops looking like its front.
333pub(crate) fn sample<T: Copy>(values: &[T], window: usize, regions: usize) -> Vec<T> {
334    let wanted = window * regions;
335    if values.len() <= wanted {
336        return values.to_vec();
337    }
338    let last = values.len() - window;
339    let mut out = Vec::with_capacity(wanted);
340    for region in 0..regions {
341        let from = if regions == 1 { 0 } else { region * last / (regions - 1) };
342        out.extend_from_slice(&values[from..from + window]);
343    }
344    out
345}
346
347#[cfg(test)]
348mod tests {
349    use super::{Chooser, EXHAUSTIVE, Sampled, sample};
350    use crate::{integer, string};
351
352    #[test]
353    fn a_sample_covers_the_whole_input_and_not_one_end_of_it() {
354        let values: Vec<i64> = (0..8000).collect();
355        let taken = sample(&values, 10, 4);
356        assert_eq!(taken.len(), 40);
357        assert_eq!(taken[0], 0);
358        assert_eq!(taken[10], 2663);
359        assert_eq!(taken[20], 5326);
360        assert_eq!(taken[30], 7990);
361        assert_eq!(taken[39], 7999);
362    }
363
364    #[test]
365    fn an_input_no_bigger_than_the_sample_is_the_sample() {
366        let values: Vec<i64> = (0..30).collect();
367        assert_eq!(sample(&values, 10, 4), values);
368    }
369
370    #[test]
371    fn the_last_window_does_not_run_off_the_end() {
372        // Two windows of 40 over 100 values puts the second one at 60, which is the last start that
373        // fits. Windows that overlap because there are more of them than the input has room for is
374        // fine and double counts a few values. Reading past the end is not.
375        let values: Vec<i64> = (0..100).collect();
376        let taken = sample(&values, 40, 2);
377        assert_eq!(taken.len(), 80);
378        assert_eq!(*taken.last().expect("the sample is not empty"), 99);
379    }
380
381    #[test]
382    fn the_exhaustive_chooser_hands_back_exactly_what_it_was_offered() {
383        let offered = [string::Kind::Plain, string::Kind::Fsst, string::Kind::Dict];
384        assert_eq!(EXHAUSTIVE.narrow_strings(&[b"a".as_slice()], &offered, 0), offered);
385        let offered = [integer::Kind::Packed, integer::Kind::Delta];
386        assert_eq!(EXHAUSTIVE.narrow_integers(&[1, 2], &offered, 0), offered);
387    }
388
389    #[test]
390    fn a_chunk_no_bigger_than_the_sample_is_not_narrowed_at_all() {
391        // Sampling a chunk that is smaller than the sample would encode every candidate on
392        // something the size of the chunk and then encode the winner on the chunk, which is more
393        // work than the exhaustive chooser for the same answer.
394        let sampled = Sampled::over(4, 2);
395        let values: Vec<i64> = (0..8).collect();
396        let offered = [integer::Kind::Packed, integer::Kind::Delta];
397        assert_eq!(sampled.narrow_integers(&values, &offered, 0), offered);
398    }
399
400    #[test]
401    fn a_sampled_chooser_returns_one_of_what_it_was_offered() {
402        let sampled = Sampled::over(16, 2);
403        let values: Vec<i64> = (0..40_000).map(|index| index / 200).collect();
404        let offered = [integer::Kind::Packed, integer::Kind::Rle, integer::Kind::Dict];
405        let narrowed = sampled.narrow_integers(&values, &offered, 0);
406        assert_eq!(narrowed.len(), 1);
407        assert!(offered.contains(&narrowed[0]), "{narrowed:?}");
408    }
409
410    #[test]
411    fn a_chunk_with_plenty_of_values_and_hardly_any_bytes_is_not_sampled() {
412        // ClickBench Params at a million rows: a value per row and almost all of them empty. It
413        // passes the value count guard and the exhaustive chooser encodes it in 21,782 bytes while
414        // the sampler took 128,455, so the byte floor is what keeps it out of the sampler's hands.
415        let sampled = Sampled::over(16, 2);
416        let empty = Vec::new();
417        let values: Vec<&[u8]> = vec![empty.as_slice(); 40_000];
418        let offered = [string::Kind::Plain, string::Kind::Fsst, string::Kind::Dict];
419        assert_eq!(sampled.narrow_strings(&values, &offered, 0), offered);
420    }
421}