Skip to main content

rudb_vector/
fsst.rs

1//! FSST, the string encoding.
2//!
3//! Fast Static Symbol Table, from the 2020 paper by Boncz, Neumann and Leis. A table of at most 255
4//! symbols of one to eight bytes each, and compression is replacing the longest matching symbol at
5//! each position with its one byte code. A byte that no symbol covers is escaped, which costs two
6//! bytes, so the table has to be good or the output is larger than the input.
7//!
8//! ## Why this and not a general compressor
9//!
10//! `spec/06-compression.md` section 6.2 is blunt about it. FSST compresses text about 2x, which is
11//! worse than what zstd does to the same bytes, and the ratio is not why it is here. Two other
12//! properties are.
13//!
14//! The first is random access. Every string in a column is compressed independently against a
15//! shared table, so reading row 4,000,000 does not mean decompressing the four million before it. A
16//! block compressor gives up that property and gets it back by cutting the data into blocks, which
17//! means reading one string decompresses a block.
18//!
19//! The second is that a substring search can run against the compressed bytes. Compress the needle
20//! with the same symbol table and look for the compressed needle in the compressed haystack. That is
21//! what turns `URL LIKE '%google%'` from a decompress and scan into a scan, and section 6.7 says it
22//! is worth more on the ClickBench workload than any ratio improvement. It needs care, because the
23//! greedy match that compresses a needle standing alone can segment it differently from the way the
24//! same bytes were segmented inside a longer string, so a hit is a candidate and a miss is not a
25//! proof. The scan that uses it is M3 work and lives with the rest of encoded execution.
26//!
27//! ## Training
28//!
29//! The table is built from a sample rather than from the whole column, and the algorithm is the
30//! paper's: start with nothing, so every byte escapes, then repeat five times. Compress the sample
31//! with the table you have, count how often each symbol is used and how often each pair of adjacent
32//! symbols occurs, and build the next table from the best 255 of the symbols and the concatenations
33//! by gain, where gain is how many bytes of input the symbol accounts for. Five generations is what
34//! the paper found, and the shape of the thing is that the first generation learns single bytes, the
35//! second learns pairs, and the fifth is finding eight byte symbols like `https://`.
36//!
37//! ## Matching
38//!
39//! Three lookups in a fixed order, longest first. A hash table on the first three bytes for symbols
40//! of three bytes and up, a flat table indexed by the first two bytes, and a flat table indexed by
41//! the first one. The hash table probes eight slots and keeps the longest symbol that matches rather
42//! than the first, because several symbols share a three byte prefix and taking the first would make
43//! the ratio depend on insertion order.
44
45use std::cell::RefCell;
46use std::sync::OnceLock;
47
48use rudb_common::{Error, Result};
49
50/// The code that means the next byte is a literal. 255 rather than 0 so that the 255 real codes are
51/// a contiguous range starting at zero and a code is its own index into the symbol table.
52pub const ESCAPE: u8 = 255;
53
54/// How many real symbols a table can hold.
55pub const MAX_SYMBOLS: usize = 255;
56
57/// The longest a symbol can be. Eight, so that a symbol is a `u64` and a match is a mask and a
58/// compare rather than a loop over bytes.
59pub const MAX_SYMBOL_LEN: usize = 8;
60
61/// How many generations the trainer runs. The paper's number.
62const GENERATIONS: usize = 5;
63
64/// Slots in the prefix hash table. A power of two, and four times the largest number of symbols that
65/// can be in it, which keeps the eight slot probe from filling up on a full table.
66const HASH_SLOTS: usize = 1024;
67
68/// How far a lookup probes before giving up. A miss here costs ratio and not correctness.
69const PROBE: usize = 8;
70
71/// One symbol. The bytes are in the low end of `value` in the order they appear, so that a match
72/// against the next eight bytes of input is one mask and one compare.
73#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
74struct Symbol {
75    value: u64,
76    len: u8,
77}
78
79impl Symbol {
80    fn new(bytes: &[u8]) -> Self {
81        let len = bytes.len().min(MAX_SYMBOL_LEN);
82        let mut value = 0u64;
83        for (index, byte) in bytes[..len].iter().enumerate() {
84            value |= u64::from(*byte) << (8 * index);
85        }
86        Self { value, len: len as u8 }
87    }
88
89    fn single(byte: u8) -> Self {
90        Self { value: u64::from(byte), len: 1 }
91    }
92
93    fn len(self) -> usize {
94        self.len as usize
95    }
96
97    fn mask(self) -> u64 {
98        mask_of(self.len())
99    }
100
101    fn bytes(self) -> Vec<u8> {
102        (0..self.len()).map(|index| (self.value >> (8 * index)) as u8).collect()
103    }
104
105    /// The two symbols end to end, cut off at eight bytes.
106    fn concat(self, other: Self) -> Self {
107        if self.len() >= MAX_SYMBOL_LEN {
108            return self;
109        }
110        let len = (self.len() + other.len()).min(MAX_SYMBOL_LEN);
111        let value = self.value | (other.value << (8 * self.len()));
112        Self { value: value & mask_of(len), len: len as u8 }
113    }
114}
115
116fn mask_of(len: usize) -> u64 {
117    if len >= 8 { u64::MAX } else { (1u64 << (8 * len)) - 1 }
118}
119
120/// A trained symbol table, and everything needed to compress and decompress against it.
121pub struct SymbolTable {
122    /// Code to symbol. At most [`MAX_SYMBOLS`] long.
123    symbols: Vec<Symbol>,
124    /// What compressing looks symbols up in, built the first time something is compressed.
125    ///
126    /// Decompressing only ever reads `symbols`, and a table read back from a file is almost always
127    /// read back to decompress. Building these eagerly filled a hundred and thirty kilobytes of
128    /// pair slots and sorted the symbols for every block of a text column a scan decoded, which on
129    /// `SELECT COUNT(*) FROM hits WHERE URL LIKE '%google%'` was six percent of the query.
130    lookup: OnceLock<Lookup>,
131}
132
133/// The three tables the matcher reads, all of them built from the symbols and holding nothing else.
134struct Lookup {
135    /// First byte to code, or [`ESCAPE`] when no one byte symbol covers it.
136    single: Vec<u8>,
137    /// First two bytes to code, or `u16::MAX` when there is no two byte symbol for them.
138    pair: Vec<u16>,
139    /// Open addressed, keyed on the first three bytes, holding every symbol of three bytes or more.
140    hash: Vec<Option<(Symbol, u8)>>,
141}
142
143impl std::fmt::Debug for SymbolTable {
144    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
145        // The lookup tables are 64k entries and printing them is never what anybody wanted.
146        formatter
147            .debug_struct("SymbolTable")
148            .field("symbols", &self.symbols.len())
149            .field("bytes", &self.serialized_len())
150            .finish()
151    }
152}
153
154/// Two tables are equal when they hold the same symbols in the same order.
155///
156/// The three lookup tables are built from the symbols when a table is built and hold nothing the
157/// symbols do not, so comparing them would be comparing the same information a second time over
158/// sixty five thousand entries. A vector in FSST form carries a table, and a vector is compared for
159/// equality all over the tests, so this is on a path that gets walked.
160impl PartialEq for SymbolTable {
161    fn eq(&self, other: &Self) -> bool {
162        self.symbols == other.symbols
163    }
164}
165
166impl Eq for SymbolTable {}
167
168impl SymbolTable {
169    /// How many bytes of memory this table is holding.
170    ///
171    /// Mostly the hash table, which is sixty five thousand slots however few symbols are in it. That
172    /// is the number a vector in FSST form reports, and it is why the form is a decision about a
173    /// page rather than about a chunk: one table over a hundred chunks is nothing per chunk and one
174    /// table per chunk is a megabyte.
175    #[must_use]
176    pub fn footprint(&self) -> usize {
177        size_of::<Self>()
178            + self.symbols.capacity() * size_of::<Symbol>()
179            + self.lookup.get().map_or(0, |lookup| {
180                lookup.single.capacity()
181                    + lookup.pair.capacity() * size_of::<u16>()
182                    + lookup.hash.capacity() * size_of::<Option<(Symbol, u8)>>()
183            })
184    }
185
186    /// A table with no symbols, which escapes everything and doubles its input. The starting point
187    /// of training, and what a column of nothing but unique bytes ends up with.
188    #[must_use]
189    pub fn empty() -> Self {
190        Self::build(Vec::new())
191    }
192
193    /// Trains a table on a sample.
194    ///
195    /// The caller picks the sample. Section 6.3 says a systematic sample across the chunk rather
196    /// than the first N rows, because column data is frequently clustered, and that decision belongs
197    /// to whoever knows what the chunk is rather than to this function.
198    #[must_use]
199    pub fn train(samples: &[&[u8]]) -> Self {
200        // The counts are a megabyte of pair slots, and a load trains a table for every block of
201        // every text column it writes. So each thread keeps one and clears the slots it used, rather
202        // than asking for a fresh megabyte of zeroes and faulting it in on every block.
203        thread_local! {
204            static COUNTS: RefCell<Option<Counts>> = const { RefCell::new(None) };
205        }
206        COUNTS.with(|held| match held.try_borrow_mut() {
207            Ok(mut held) => Self::train_with(samples, held.get_or_insert_with(Counts::new)),
208            Err(_) => Self::train_with(samples, &mut Counts::new()),
209        })
210    }
211
212    fn train_with(samples: &[&[u8]], counts: &mut Counts) -> Self {
213        let mut table = Self::empty();
214        for _ in 0..GENERATIONS {
215            counts.clear();
216            for sample in samples {
217                table.count(sample, counts);
218            }
219            let next = counts.best(&table);
220            if next.is_empty() {
221                break;
222            }
223            table = Self::build(next);
224        }
225        table
226    }
227
228    /// How many symbols are in the table.
229    #[must_use]
230    pub fn len(&self) -> usize {
231        self.symbols.len()
232    }
233
234    /// Whether the table has no symbols, in which case every byte of every string escapes.
235    #[must_use]
236    pub fn is_empty(&self) -> bool {
237        self.symbols.is_empty()
238    }
239
240    /// How many bytes [`serialize`](Self::serialize) writes. At most 2049 for a full table, and
241    /// that is the number section 6.4 is weighing when it says a shared symbol table is cheaper
242    /// than a shared dictionary.
243    #[must_use]
244    pub fn serialized_len(&self) -> usize {
245        1 + self.symbols.iter().map(|symbol| 1 + symbol.len()).sum::<usize>()
246    }
247
248    /// Writes the table itself, which has to travel with the data it compressed.
249    pub fn serialize(&self, out: &mut Vec<u8>) {
250        out.push(self.symbols.len() as u8);
251        for symbol in &self.symbols {
252            out.push(symbol.len);
253            out.extend_from_slice(&symbol.bytes());
254        }
255    }
256
257    /// Reads back what [`serialize`](Self::serialize) wrote, and says how many bytes it consumed.
258    ///
259    /// # Errors
260    ///
261    /// If the bytes are truncated or describe a symbol of zero or more than eight bytes.
262    pub fn deserialize(bytes: &[u8]) -> Result<(Self, usize)> {
263        let count = *bytes.first().ok_or_else(|| truncated("a symbol table header"))? as usize;
264        let mut at = 1;
265        let mut symbols = Vec::with_capacity(count);
266        for _ in 0..count {
267            let len = *bytes.get(at).ok_or_else(|| truncated("a symbol length"))? as usize;
268            if len == 0 || len > MAX_SYMBOL_LEN {
269                return Err(Error::internal(format!("a symbol of {len} bytes is not a symbol")));
270            }
271            at += 1;
272            let end = at + len;
273            if end > bytes.len() {
274                return Err(truncated("a symbol"));
275            }
276            symbols.push(Symbol::new(&bytes[at..end]));
277            at = end;
278        }
279        Ok((Self::build(symbols), at))
280    }
281
282    /// Compresses one string, appending to `out`.
283    ///
284    /// Strings are compressed one at a time against a shared table rather than as one stream,
285    /// because that is what keeps random access, which is the first of the two reasons this encoding
286    /// was chosen at all.
287    pub fn compress(&self, input: &[u8], out: &mut Vec<u8>) {
288        let mut at = 0;
289        while at < input.len() {
290            let (code, len) = self.match_at(input, at);
291            if code == ESCAPE {
292                out.push(ESCAPE);
293                out.push(input[at]);
294            } else {
295                out.push(code);
296            }
297            at += len;
298        }
299    }
300
301    /// Decompresses one string, appending to `out`.
302    ///
303    /// A symbol goes out as all eight of the bytes its `u64` holds, and then the cursor steps back
304    /// over the ones that were not part of it. Eight is a length the compiler knows, so that is one
305    /// store. The length a symbol really has is only known at run time, so copying exactly that
306    /// many bytes is a call into `memcpy` for one to eight of them, and building a `Vec` to copy
307    /// them out of, which is what this used to do, is a heap allocation and a free on top.
308    ///
309    /// That mattered more than anything else in the engine. `SELECT COUNT(*) FROM hits WHERE URL
310    /// LIKE '%google%'` over ClickBench spends almost all of its time here, because the search
311    /// itself runs once per distinct URL and finding those means decompressing the column, and the
312    /// allocation, the free and the copy together were 41% of the query.
313    ///
314    /// # Errors
315    ///
316    /// If the input ends on an escape byte, or holds a code the table does not have.
317    pub fn decompress(&self, input: &[u8], out: &mut Vec<u8>) -> Result<()> {
318        // What the table is trained to reach, plus room for the tail of the last symbol, so the
319        // loop below mostly finds the space already there. Nothing here depends on the guess being
320        // right: too small and the growth happens where it always did.
321        out.reserve(input.len().saturating_mul(2).saturating_add(MAX_SYMBOL_LEN));
322        let mut at = 0;
323        while at < input.len() {
324            let code = input[at];
325            at += 1;
326            if code == ESCAPE {
327                let literal = *input.get(at).ok_or_else(|| truncated("an escaped byte"))?;
328                out.push(literal);
329                at += 1;
330            } else {
331                let symbol = *self
332                    .symbols
333                    .get(code as usize)
334                    .ok_or_else(|| Error::internal(format!("code {code} is not in the table")))?;
335                out.extend_from_slice(&symbol.value.to_le_bytes());
336                out.truncate(out.len() - (MAX_SYMBOL_LEN - symbol.len()));
337            }
338        }
339        Ok(())
340    }
341
342    /// Decompresses one string into `out` from `at`, handing back where it ended.
343    ///
344    /// [`Self::decompress`] for a caller that has already made the buffer as long as the output
345    /// is going to be, so a symbol is one eight byte store and a step of the cursor with no length
346    /// to keep and no capacity to check. The store is eight bytes whatever the symbol's length,
347    /// which is why `out` needs [`MAX_SYMBOL_LEN`] bytes of room past the end of the string.
348    ///
349    /// # Errors
350    ///
351    /// As [`Self::decompress`], and if `out` runs out of room, which a string that really
352    /// decompresses to the length the caller sized for never does.
353    ///
354    /// Inlined because a caller replaying a chunk asks for about nine short runs per value, and as
355    /// a call the pushes, pops and return around each one were a third of the time spent in here.
356    #[inline]
357    pub fn decompress_at(&self, input: &[u8], out: &mut [u8], mut at: usize) -> Result<usize> {
358        let symbols = self.symbols.as_slice();
359        let mut codes = input.iter();
360        while let Some(&code) = codes.next() {
361            if code == ESCAPE {
362                let Some(&literal) = codes.next() else {
363                    return Err(truncated("an escaped byte"));
364                };
365                let Some(slot) = out.get_mut(at) else {
366                    return Err(out_of_room());
367                };
368                *slot = literal;
369                at += 1;
370            } else {
371                let Some(symbol) = symbols.get(code as usize) else {
372                    return Err(not_in_table(code));
373                };
374                let Some(slot) = out.get_mut(at..at + MAX_SYMBOL_LEN) else {
375                    return Err(out_of_room());
376                };
377                slot.copy_from_slice(&symbol.value.to_le_bytes());
378                at += symbol.len();
379            }
380        }
381        Ok(at)
382    }
383
384    /// The code and how many input bytes it covers. [`ESCAPE`] and 1 when nothing matches.
385    fn match_at(&self, input: &[u8], at: usize) -> (u8, usize) {
386        let remaining = input.len() - at;
387        let word = load(input, at);
388        // Written as a nested `if` rather than as a chained `if let` because the minimum supported
389        // Rust version is 1.85 and let chains landed in 1.88.
390        if remaining >= 3 {
391            if let Some((symbol, code)) = self.probe(word, remaining) {
392                return (code, symbol.len());
393            }
394        }
395        if remaining >= 2 {
396            let code = self.lookup().pair[(word & 0xffff) as usize];
397            if code != u16::MAX {
398                return (code as u8, 2);
399            }
400        }
401        let code = self.lookup().single[(word & 0xff) as usize];
402        if code == ESCAPE { (ESCAPE, 1) } else { (code, 1) }
403    }
404
405    /// The longest symbol of three bytes or more matching here, if any.
406    ///
407    /// Longest rather than first, because several symbols share a three byte prefix and taking
408    /// whichever the probe reached first would make the compression ratio depend on the order the
409    /// table was built in.
410    fn probe(&self, word: u64, remaining: usize) -> Option<(Symbol, u8)> {
411        let hash = &self.lookup().hash;
412        let mut slot = hash_of(word);
413        let mut best: Option<(Symbol, u8)> = None;
414        for _ in 0..PROBE {
415            match hash[slot] {
416                None => break,
417                Some((symbol, code)) => {
418                    if symbol.len() <= remaining
419                        && word & symbol.mask() == symbol.value
420                        && best.is_none_or(|(found, _)| symbol.len() > found.len())
421                    {
422                        best = Some((symbol, code));
423                    }
424                }
425            }
426            slot = (slot + 1) & (HASH_SLOTS - 1);
427        }
428        best
429    }
430
431    /// Runs the matcher over a sample without producing output, recording what it used. This is the
432    /// counting half of a training generation.
433    fn count(&self, input: &[u8], counts: &mut Counts) {
434        let mut at = 0;
435        let mut previous: Option<u16> = None;
436        while at < input.len() {
437            let (code, len) = self.match_at(input, at);
438            let id = if code == ESCAPE { 256 + u16::from(input[at]) } else { u16::from(code) };
439            counts.one(id);
440            if let Some(previous) = previous {
441                counts.two(previous, id);
442            }
443            previous = Some(id);
444            at += len;
445        }
446    }
447
448    fn build(symbols: Vec<Symbol>) -> Self {
449        Self { symbols, lookup: OnceLock::new() }
450    }
451
452    fn lookup(&self) -> &Lookup {
453        self.lookup.get_or_init(|| Lookup::of(&self.symbols))
454    }
455}
456
457impl Lookup {
458    fn of(symbols: &[Symbol]) -> Self {
459        let mut table = Self {
460            single: vec![ESCAPE; 256],
461            pair: vec![u16::MAX; 65536],
462            hash: vec![None; HASH_SLOTS],
463        };
464        // Longest first, so that a short symbol never displaces a long one out of the probe window
465        // and the flat tables get the lowest code for a duplicate.
466        let mut order: Vec<(Symbol, u8)> =
467            symbols.iter().enumerate().map(|(code, symbol)| (*symbol, code as u8)).collect();
468        order.sort_by_key(|(symbol, code)| (std::cmp::Reverse(symbol.len()), *code));
469        for (symbol, code) in order {
470            match symbol.len() {
471                1 => {
472                    let index = (symbol.value & 0xff) as usize;
473                    if table.single[index] == ESCAPE {
474                        table.single[index] = code;
475                    }
476                }
477                2 => {
478                    let index = (symbol.value & 0xffff) as usize;
479                    if table.pair[index] == u16::MAX {
480                        table.pair[index] = u16::from(code);
481                    }
482                }
483                _ => {
484                    let mut slot = hash_of(symbol.value);
485                    for _ in 0..PROBE {
486                        if table.hash[slot].is_none() {
487                            table.hash[slot] = Some((symbol, code));
488                            break;
489                        }
490                        slot = (slot + 1) & (HASH_SLOTS - 1);
491                    }
492                }
493            }
494        }
495        table
496    }
497}
498
499/// The next eight bytes as a little endian word, zero padded at the end of the input.
500///
501/// The padding is why every match checks the remaining length as well as the mask. Without that
502/// check a two byte symbol ending in a zero byte would match the last byte of a string.
503fn load(input: &[u8], at: usize) -> u64 {
504    if at + 8 <= input.len() {
505        let bytes: [u8; 8] = input[at..at + 8].try_into().expect("eight bytes were checked");
506        u64::from_le_bytes(bytes)
507    } else {
508        let mut word = 0u64;
509        for (index, byte) in input[at..].iter().enumerate() {
510            word |= u64::from(*byte) << (8 * index);
511        }
512        word
513    }
514}
515
516/// Hashes the first three bytes. The multiply and shift is the standard Fibonacci hash, which
517/// spreads a three byte key across the whole slot range where a mask of the low bits would put every
518/// symbol starting with the same letter in the same neighbourhood.
519fn hash_of(word: u64) -> usize {
520    let key = word & 0xff_ffff;
521    ((key.wrapping_mul(0x9e37_79b9_7f4a_7c15)) >> (64 - HASH_SLOTS.trailing_zeros())) as usize
522}
523
524/// What one training generation counts. Symbol ids below 256 are codes in the current table and ids
525/// from 256 up are escaped literal bytes, which is how a generation learns single bytes it does not
526/// have yet.
527struct Counts {
528    single: Vec<u32>,
529    /// Indexed by `first * IDS + second`. Flat rather than hashed because every id is under 512, so
530    /// every pair fits in a quarter million slots, and counting pairs is most of what training does.
531    pairs: Vec<u32>,
532    /// The pair slots that are not zero, so that reading and clearing them costs the pairs seen
533    /// rather than the whole array.
534    seen: Vec<u32>,
535    /// The candidates of the generation being ranked, kept so a thread sizes them once.
536    gains: Gains,
537}
538
539/// Every candidate symbol once with its summed gain, which [`Counts::best`] ranks.
540#[derive(Default)]
541struct Gains {
542    gains: Vec<(Symbol, u64)>,
543    /// Where each symbol sits in `gains`, open addressed on the symbol, `u32::MAX` where empty.
544    places: Vec<u32>,
545    /// The slots of `places` in use, so that emptying it costs the symbols rather than the table.
546    taken: Vec<u32>,
547}
548
549impl Gains {
550    /// Empties the candidates, with room for `most` of them.
551    fn clear(&mut self, most: usize) {
552        for place in self.taken.drain(..) {
553            self.places[place as usize] = u32::MAX;
554        }
555        let wanted = (most * 2).next_power_of_two();
556        if self.places.len() < wanted {
557            self.places = vec![u32::MAX; wanted];
558        }
559        self.gains.clear();
560    }
561
562    /// Adds `count` uses of `symbol` to its gain, making it a candidate if it is not one yet.
563    fn add(&mut self, symbol: Symbol, count: u64) {
564        let gain = count * symbol.len() as u64;
565        let mask = self.places.len() - 1;
566        let mut place = gain_hash(symbol) & mask;
567        loop {
568            let at = self.places[place];
569            if at == u32::MAX {
570                self.places[place] = self.gains.len() as u32;
571                self.taken.push(place as u32);
572                self.gains.push((symbol, gain));
573                return;
574            }
575            if self.gains[at as usize].0 == symbol {
576                self.gains[at as usize].1 += gain;
577                return;
578            }
579            place = (place + 1) & mask;
580        }
581    }
582}
583
584/// How many symbol ids there are: 256 codes and 256 escaped bytes.
585const IDS: usize = 512;
586
587impl Counts {
588    fn new() -> Self {
589        Self {
590            single: vec![0; IDS],
591            pairs: vec![0; IDS * IDS],
592            seen: Vec::new(),
593            gains: Gains::default(),
594        }
595    }
596
597    fn clear(&mut self) {
598        self.single.fill(0);
599        for slot in self.seen.drain(..) {
600            self.pairs[slot as usize] = 0;
601        }
602    }
603
604    fn one(&mut self, id: u16) {
605        self.single[id as usize] += 1;
606    }
607
608    fn two(&mut self, first: u16, second: u16) {
609        let slot = first as usize * IDS + second as usize;
610        if self.pairs[slot] == 0 {
611            self.seen.push(slot as u32);
612        }
613        self.pairs[slot] += 1;
614    }
615
616    /// The 255 best symbols for the next generation.
617    ///
618    /// Gain is how many bytes of input a symbol accounts for, which is its length times how often it
619    /// was used. A concatenation is scored on the length it would have, so a pair of four byte
620    /// symbols scores as eight and a pair of six byte ones also scores as eight, because that is
621    /// what it would be cut down to.
622    fn best(&mut self, table: &SymbolTable) -> Vec<Symbol> {
623        // Different ids can spell the same symbol, a code and the pair it was learned from for one,
624        // and two pairs whose concatenation runs past eight bytes for another, so the gains are
625        // summed per symbol before anything is ranked. This was a sort of every candidate by symbol
626        // followed by a dedup, and on a ClickBench load that sort was a quarter of training.
627        self.gains.clear(IDS + self.seen.len());
628        for (id, count) in self.single.iter().enumerate() {
629            if *count == 0 {
630                continue;
631            }
632            let symbol = symbol_of(table, id as u16);
633            self.gains.add(symbol, u64::from(*count));
634        }
635        for slot in &self.seen {
636            let slot = *slot as usize;
637            let (first, second) = ((slot / IDS) as u16, (slot % IDS) as u16);
638            let symbol = symbol_of(table, first).concat(symbol_of(table, second));
639            self.gains.add(symbol, u64::from(self.pairs[slot]));
640        }
641        let gains = &mut self.gains.gains;
642        // Gain first, then the symbol itself, so that two symbols with the same gain come out in the
643        // same order on every host and the table is a function of the sample and nothing else. The
644        // symbols are distinct by now, so the order is total and an unstable sort gives one answer.
645        let order = |left: &(Symbol, u64), right: &(Symbol, u64)| {
646            right.1.cmp(&left.1).then(left.0.cmp(&right.0))
647        };
648        if gains.len() > MAX_SYMBOLS {
649            gains.select_nth_unstable_by(MAX_SYMBOLS - 1, order);
650            gains.truncate(MAX_SYMBOLS);
651        }
652        gains.sort_unstable_by(order);
653        gains.iter().map(|(symbol, _)| *symbol).collect()
654    }
655}
656
657/// Where a symbol's search for its place in [`Gains::places`] starts.
658fn gain_hash(symbol: Symbol) -> usize {
659    ((symbol.value ^ u64::from(symbol.len)).wrapping_mul(0x9e37_79b9_7f4a_7c15) >> 32) as usize
660}
661
662fn symbol_of(table: &SymbolTable, id: u16) -> Symbol {
663    if (id as usize) < table.symbols.len() {
664        table.symbols[id as usize]
665    } else {
666        Symbol::single((id.saturating_sub(256)) as u8)
667    }
668}
669
670#[cold]
671fn not_in_table(code: u8) -> Error {
672    Error::internal(format!("code {code} is not in the table"))
673}
674
675#[cold]
676fn out_of_room() -> Error {
677    Error::internal("a string decompresses to more than its length says")
678}
679
680#[cold]
681fn truncated(what: &str) -> Error {
682    Error::internal(format!("the input ended in the middle of {what}"))
683}
684
685#[cfg(test)]
686mod tests {
687    use super::*;
688
689    #[test]
690    fn a_table_read_back_to_decompress_builds_no_lookup_tables() {
691        let urls = urls();
692        let samples: Vec<&[u8]> = urls.iter().map(Vec::as_slice).collect();
693        let trained = SymbolTable::train(&samples);
694        let mut compressed = Vec::new();
695        trained.compress(&urls[7], &mut compressed);
696        let mut stored = Vec::new();
697        trained.serialize(&mut stored);
698        let (read, _) = SymbolTable::deserialize(&stored).expect("a table it wrote");
699        let mut out = Vec::new();
700        read.decompress(&compressed, &mut out).expect("a string it compressed");
701        assert_eq!(out, urls[7]);
702        assert!(read.lookup.get().is_none(), "decompressing reads the symbols alone");
703        assert!(read.footprint() < trained.footprint());
704    }
705
706    /// A few hundred URLs in the shape ClickBench `hits` has them, which is the workload this
707    /// encoding was chosen for. Repetitive in the way real URLs are: a handful of hosts, a handful
708    /// of path shapes, and query strings that differ in a number.
709    fn urls() -> Vec<Vec<u8>> {
710        let hosts = ["www.example.com", "shop.example.com", "news.other.example.org"];
711        let paths = ["/index.html", "/catalog/item", "/search", "/user/profile/settings"];
712        let mut out = Vec::new();
713        for index in 0..600 {
714            let host = hosts[index % hosts.len()];
715            let path = paths[(index / 3) % paths.len()];
716            out.push(
717                format!("http://{host}{path}?session={}&ref=google&page={}", index * 7, index % 20)
718                    .into_bytes(),
719            );
720        }
721        out
722    }
723
724    fn borrow(strings: &[Vec<u8>]) -> Vec<&[u8]> {
725        strings.iter().map(Vec::as_slice).collect()
726    }
727
728    fn round_trip(table: &SymbolTable, strings: &[Vec<u8>]) -> (usize, usize) {
729        let mut raw = 0;
730        let mut compressed = 0;
731        for string in strings {
732            let mut bytes = Vec::new();
733            table.compress(string, &mut bytes);
734            let mut back = Vec::new();
735            table.decompress(&bytes, &mut back).unwrap();
736            assert_eq!(back, *string, "{}", String::from_utf8_lossy(string));
737            raw += string.len();
738            compressed += bytes.len();
739        }
740        (raw, compressed)
741    }
742
743    #[test]
744    fn urls_compress_by_more_than_half_and_come_back_unchanged() {
745        // The number the paper reports on text is around 2x, and URLs are more repetitive than
746        // text. Anything under 2x here means the trainer is not finding the long symbols.
747        let strings = urls();
748        let table = SymbolTable::train(&borrow(&strings));
749        let (raw, compressed) = round_trip(&table, &strings);
750        let ratio = raw as f64 / compressed as f64;
751        assert!(ratio > 2.5, "{ratio:.2}x, {raw} to {compressed}");
752        assert!(table.len() > 100, "{} symbols", table.len());
753    }
754
755    #[test]
756    fn the_trainer_finds_the_long_repeated_pieces() {
757        let strings = urls();
758        let table = SymbolTable::train(&borrow(&strings));
759        let found: Vec<String> = (0..table.len())
760            .map(|code| String::from_utf8_lossy(&table.symbols[code].bytes()).into_owned())
761            .collect();
762        // Not a specific symbol, since which eight bytes win is a property of the sample, but the
763        // table has to be mostly long symbols or it has not learned anything.
764        let long = found.iter().filter(|symbol| symbol.len() >= 6).count();
765        assert!(long > 60, "only {long} symbols of six bytes or more: {found:?}");
766    }
767
768    #[test]
769    fn english_text_round_trips_and_shrinks() {
770        let text: Vec<Vec<u8>> = "the quick brown fox jumps over the lazy dog while the other dog \
771             watches the fox and the dog and the fox go over the hill together"
772            .split(' ')
773            .map(|word| word.as_bytes().to_vec())
774            .collect();
775        let table = SymbolTable::train(&borrow(&text));
776        let (raw, compressed) = round_trip(&table, &text);
777        assert!(compressed < raw, "{raw} to {compressed}");
778    }
779
780    #[test]
781    fn incompressible_bytes_round_trip_and_cost_what_escaping_costs() {
782        // The worst case, and it has to be a correct worst case. Every byte escapes at two bytes
783        // each unless the trainer finds single byte symbols, which it will for the 255 most common
784        // of the 256 values.
785        let mut state = 0x1234_5678_9abc_def0u64;
786        let strings: Vec<Vec<u8>> = (0..100)
787            .map(|_| {
788                (0..64)
789                    .map(|_| {
790                        state ^= state << 13;
791                        state ^= state >> 7;
792                        state ^= state << 17;
793                        state as u8
794                    })
795                    .collect()
796            })
797            .collect();
798        let table = SymbolTable::train(&borrow(&strings));
799        let (raw, compressed) = round_trip(&table, &strings);
800        assert!(compressed < raw * 2, "{raw} to {compressed}");
801    }
802
803    #[test]
804    fn an_empty_table_escapes_everything_and_still_round_trips() {
805        let table = SymbolTable::empty();
806        let strings = vec![b"hello".to_vec(), Vec::new(), b"x".to_vec()];
807        let (raw, compressed) = round_trip(&table, &strings);
808        assert_eq!(compressed, raw * 2);
809    }
810
811    #[test]
812    fn an_empty_string_compresses_to_nothing() {
813        let table = SymbolTable::train(&[b"abcabcabc"]);
814        let mut out = Vec::new();
815        table.compress(b"", &mut out);
816        assert!(out.is_empty());
817        let mut back = Vec::new();
818        table.decompress(&out, &mut back).unwrap();
819        assert!(back.is_empty());
820    }
821
822    #[test]
823    fn a_string_shorter_than_the_symbols_does_not_read_past_its_end() {
824        // The load pads with zeros, so without the length check a two byte symbol whose second byte
825        // is zero would match the last byte of a string and swallow a byte that is not there.
826        let table = SymbolTable::train(&[b"ab\0ab\0ab\0ab\0", b"abcdefgh"]);
827        for string in [b"a".to_vec(), b"ab".to_vec(), b"abc".to_vec()] {
828            let mut bytes = Vec::new();
829            table.compress(&string, &mut bytes);
830            let mut back = Vec::new();
831            table.decompress(&bytes, &mut back).unwrap();
832            assert_eq!(back, string);
833        }
834    }
835
836    #[test]
837    fn a_table_survives_being_written_and_read_back() {
838        let strings = urls();
839        let table = SymbolTable::train(&borrow(&strings));
840        let mut bytes = Vec::new();
841        table.serialize(&mut bytes);
842        assert_eq!(bytes.len(), table.serialized_len());
843        let (read, consumed) = SymbolTable::deserialize(&bytes).unwrap();
844        assert_eq!(consumed, bytes.len());
845        assert_eq!(read.symbols, table.symbols);
846
847        // And the read back table compresses to the same bytes, which is the property that matters,
848        // since the lookup structures are rebuilt rather than stored.
849        let mut first = Vec::new();
850        let mut second = Vec::new();
851        table.compress(&strings[7], &mut first);
852        read.compress(&strings[7], &mut second);
853        assert_eq!(first, second);
854    }
855
856    #[test]
857    fn a_full_table_is_two_kilobytes_at_the_very_most() {
858        let strings = urls();
859        let table = SymbolTable::train(&borrow(&strings));
860        assert!(table.serialized_len() <= 1 + MAX_SYMBOLS * (1 + MAX_SYMBOL_LEN));
861        assert!(table.serialized_len() <= 2049);
862    }
863
864    #[test]
865    fn a_truncated_symbol_table_is_an_error() {
866        let strings = urls();
867        let table = SymbolTable::train(&borrow(&strings));
868        let mut bytes = Vec::new();
869        table.serialize(&mut bytes);
870        for len in 1..bytes.len().min(40) {
871            let error = SymbolTable::deserialize(&bytes[..len]).unwrap_err();
872            assert!(error.message().contains("ended in the middle"), "{error}");
873        }
874    }
875
876    #[test]
877    fn a_symbol_of_zero_bytes_is_an_error() {
878        let error = SymbolTable::deserialize(&[1, 0]).unwrap_err();
879        assert!(error.message().contains("is not a symbol"), "{error}");
880    }
881
882    #[test]
883    fn a_short_symbol_does_not_drag_the_rest_of_its_word_out_with_it() {
884        // Decompression writes all eight bytes of a symbol and steps back over the ones that were
885        // not part of it, so a table of short symbols is where that would show. `ab` and `cd` are
886        // two bytes each and sit in a `u64` with six zero bytes above them, and if the step back
887        // were wrong those zeros would be in the answer. Appending twice checks it again at an
888        // offset, since the second write lands where the first one left the cursor.
889        let table = SymbolTable::train(&[b"abcdabcdabcdabcd"]);
890        let mut compressed = Vec::new();
891        table.compress(b"abcdabcd", &mut compressed);
892        let mut out = Vec::new();
893        table.decompress(&compressed, &mut out).expect("decompresses");
894        table.decompress(&compressed, &mut out).expect("decompresses");
895        assert_eq!(out, b"abcdabcdabcdabcd");
896    }
897
898    #[test]
899    fn a_dangling_escape_is_an_error_and_not_a_panic() {
900        let table = SymbolTable::train(&[b"abcabcabc"]);
901        let error = table.decompress(&[ESCAPE], &mut Vec::new()).unwrap_err();
902        assert!(error.message().contains("escaped byte"), "{error}");
903    }
904
905    #[test]
906    fn a_code_the_table_does_not_have_is_an_error() {
907        let table = SymbolTable::train(&[b"abcabcabc"]);
908        let code = table.len() as u8;
909        let error = table.decompress(&[code], &mut Vec::new()).unwrap_err();
910        assert!(error.message().contains("not in the table"), "{error}");
911    }
912
913    #[test]
914    fn training_twice_on_the_same_sample_gives_the_same_table() {
915        // A table that differs run to run would make every size in the M1 report unreproducible.
916        let strings = urls();
917        let first = SymbolTable::train(&borrow(&strings));
918        let second = SymbolTable::train(&borrow(&strings));
919        assert_eq!(first.symbols, second.symbols);
920    }
921
922    #[test]
923    fn the_longest_match_wins_rather_than_the_first_one_found() {
924        let table = SymbolTable::build(vec![
925            Symbol::new(b"abc"),
926            Symbol::new(b"abcdef"),
927            Symbol::new(b"abcd"),
928        ]);
929        let mut out = Vec::new();
930        table.compress(b"abcdef", &mut out);
931        assert_eq!(out, vec![1]);
932    }
933
934    #[test]
935    fn a_symbol_longer_than_what_is_left_is_not_used() {
936        let table = SymbolTable::build(vec![Symbol::new(b"abcdef"), Symbol::new(b"ab")]);
937        let mut out = Vec::new();
938        table.compress(b"abcd", &mut out);
939        // "ab" then two escapes, rather than a six byte symbol over four bytes of input.
940        assert_eq!(out, vec![1, ESCAPE, b'c', ESCAPE, b'd']);
941    }
942
943    #[test]
944    fn concatenation_stops_at_eight_bytes() {
945        let long = Symbol::new(b"abcdef");
946        assert_eq!(long.concat(Symbol::new(b"ghijkl")).bytes(), b"abcdefgh");
947        assert_eq!(Symbol::new(b"ab").concat(Symbol::new(b"cd")).bytes(), b"abcd");
948    }
949
950    /// The trainer the way it was written first, with the pairs and the gains in hash maps and one
951    /// stable sort over everything, kept here to check the flat counts pick the same symbols.
952    fn train_with_maps(samples: &[&[u8]]) -> SymbolTable {
953        use std::collections::HashMap;
954        let mut table = SymbolTable::empty();
955        for _ in 0..GENERATIONS {
956            let mut single = [0u32; IDS];
957            let mut pairs: HashMap<(u16, u16), u32> = HashMap::new();
958            for sample in samples {
959                let mut at = 0;
960                let mut previous: Option<u16> = None;
961                while at < sample.len() {
962                    let (code, len) = table.match_at(sample, at);
963                    let id =
964                        if code == ESCAPE { 256 + u16::from(sample[at]) } else { u16::from(code) };
965                    single[id as usize] += 1;
966                    if let Some(previous) = previous {
967                        *pairs.entry((previous, id)).or_insert(0) += 1;
968                    }
969                    previous = Some(id);
970                    at += len;
971                }
972            }
973            let mut gains: HashMap<Symbol, u64> = HashMap::new();
974            for (id, count) in single.iter().enumerate().filter(|(_, count)| **count > 0) {
975                let symbol = symbol_of(&table, id as u16);
976                *gains.entry(symbol).or_insert(0) += u64::from(*count) * symbol.len() as u64;
977            }
978            for ((first, second), count) in &pairs {
979                let symbol = symbol_of(&table, *first).concat(symbol_of(&table, *second));
980                *gains.entry(symbol).or_insert(0) += u64::from(*count) * symbol.len() as u64;
981            }
982            let mut ranked: Vec<(Symbol, u64)> = gains.into_iter().collect();
983            ranked.sort_by(|left, right| right.1.cmp(&left.1).then(left.0.cmp(&right.0)));
984            ranked.truncate(MAX_SYMBOLS);
985            if ranked.is_empty() {
986                break;
987            }
988            table = SymbolTable::build(ranked.into_iter().map(|(symbol, _)| symbol).collect());
989        }
990        table
991    }
992
993    #[test]
994    fn flat_counts_train_the_same_table_as_hash_maps() {
995        let mut state = 0x9e37_79b9_7f4a_7c15u64;
996        let mut next = move || {
997            state ^= state << 13;
998            state ^= state >> 7;
999            state ^= state << 17;
1000            state
1001        };
1002        let mut shapes: Vec<Vec<Vec<u8>>> = vec![urls(), Vec::new(), vec![Vec::new(); 3]];
1003        // Few letters, so that many pairs tie on gain and the tie break decides the table.
1004        shapes.push(
1005            (0..400).map(|_| (0..12).map(|_| b"abc"[(next() % 3) as usize]).collect()).collect(),
1006        );
1007        // Every byte value, so that escapes of all 256 bytes are counted.
1008        shapes.push((0..300).map(|_| (0..40).map(|_| next() as u8).collect()).collect());
1009        // Long repeats, so that concatenations reach eight bytes and get cut.
1010        shapes.push(
1011            (0..200)
1012                .map(|index| format!("prefix-{}-suffix-{}", index % 7, index % 5).into_bytes())
1013                .collect(),
1014        );
1015        // One after another on one thread, so every table after the first is trained on the counts
1016        // the one before it left behind, which is what a load does block after block.
1017        for strings in &shapes {
1018            let samples = borrow(strings);
1019            assert_eq!(SymbolTable::train(&samples).symbols, train_with_maps(&samples).symbols);
1020        }
1021    }
1022}