Skip to main content

rudb_vector/
string.rs

1//! The string representation.
2//!
3//! `spec/07-execution.md` section 7.1: a string is a 16 byte structure, 4 bytes of length, 4 bytes
4//! of prefix, and 8 bytes that are either the rest of a short string or a way to find a long one.
5//! Strings of 12 bytes or fewer live entirely inside the structure. The prefix means most
6//! comparisons and most equality tests answer without dereferencing anything, which on the string
7//! heavy queries in ClickBench is the difference between a cache hit and a cache miss per row.
8//!
9//! **Where this differs from the specification, and why.** The document says the last 8 bytes are
10//! a pointer, which is what DuckDB and Umbra do. Here they are a block index and an offset, which
11//! is what Arrow's `StringView` does. The sizes are identical, the prefix trick is identical, and
12//! the prefix trick is the part that makes it fast. The difference is one predictable load against
13//! one pointer chase on the slow path only, and in exchange the whole representation is safe code
14//! with no pinning machinery, which does not exist until the buffer manager arrives at M2. This is
15//! the kind of decision that gets remeasured rather than argued about, and it is tracked as an
16//! issue so that M3 measures it instead of inheriting it.
17
18/// The longest string that fits entirely inside a view.
19pub const INLINE_LIMIT: usize = 12;
20
21/// A 16 byte handle on a string.
22///
23/// The layout is a `u32` length and 12 bytes of payload. For a string of 12 bytes or fewer the
24/// payload is the string, zero padded. For a longer one the first 4 bytes are the prefix, the next
25/// 4 are the index of the block holding it, and the last 4 are the offset into that block.
26///
27/// A view on its own cannot produce a long string, only a short one. That is deliberate: the
28/// blocks live in the [`StringColumn`] and the borrow checker is what stops a view from outliving
29/// them, rather than a rule somebody has to remember.
30#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
31pub struct StringView {
32    length: u32,
33    payload: [u8; 12],
34}
35
36impl StringView {
37    /// A view on a string that fits inline.
38    ///
39    /// # Panics
40    ///
41    /// If the string is longer than [`INLINE_LIMIT`]. Callers that do not know the length go
42    /// through [`StringColumn::push`], which decides.
43    #[must_use]
44    pub fn inline(text: &str) -> Self {
45        assert!(text.len() <= INLINE_LIMIT, "a string of {} bytes is not inline", text.len());
46        let mut payload = [0u8; 12];
47        payload[..text.len()].copy_from_slice(text.as_bytes());
48        Self { length: text.len() as u32, payload }
49    }
50
51    /// A view on a string that lives in a block.
52    fn indirect(text: &str, block: u32, offset: u32) -> Self {
53        let mut payload = [0u8; 12];
54        payload[..4].copy_from_slice(&text.as_bytes()[..4]);
55        payload[4..8].copy_from_slice(&block.to_le_bytes());
56        payload[8..].copy_from_slice(&offset.to_le_bytes());
57        Self { length: text.len() as u32, payload }
58    }
59
60    /// The length in bytes.
61    #[must_use]
62    pub fn len(&self) -> usize {
63        self.length as usize
64    }
65
66    /// Whether the string is empty.
67    #[must_use]
68    pub fn is_empty(&self) -> bool {
69        self.length == 0
70    }
71
72    /// Whether the whole string is in the view.
73    #[must_use]
74    pub fn is_inline(&self) -> bool {
75        self.len() <= INLINE_LIMIT
76    }
77
78    /// The first four bytes, zero padded.
79    ///
80    /// This is the whole point of the representation. Two strings with different prefixes are
81    /// different, and two strings with the same prefix are usually equal, so a filter on a string
82    /// column resolves without touching the payload on almost every row.
83    #[must_use]
84    pub fn prefix(&self) -> [u8; 4] {
85        [self.payload[0], self.payload[1], self.payload[2], self.payload[3]]
86    }
87
88    /// The string, when it is short enough to be in the view.
89    #[must_use]
90    pub fn as_inline_str(&self) -> Option<&str> {
91        if !self.is_inline() {
92            return None;
93        }
94        // Every constructor takes a `&str`, so the bytes came from valid UTF-8 and a prefix of the
95        // inline payload up to the recorded length is exactly what was written.
96        std::str::from_utf8(&self.payload[..self.len()]).ok()
97    }
98
99    fn block(&self) -> usize {
100        u32::from_le_bytes([self.payload[4], self.payload[5], self.payload[6], self.payload[7]])
101            as usize
102    }
103
104    fn offset(&self) -> usize {
105        u32::from_le_bytes([self.payload[8], self.payload[9], self.payload[10], self.payload[11]])
106            as usize
107    }
108
109    /// Whether these two views are definitely different, answered from the view alone.
110    ///
111    /// A `false` here means the payloads have to be compared. A `true` means they do not, which on
112    /// a filter against a selective literal is almost every row.
113    #[must_use]
114    pub fn definitely_differs(&self, other: &Self) -> bool {
115        self.length != other.length || self.prefix() != other.prefix()
116    }
117}
118
119/// How much string data one block holds before another is started.
120///
121/// 16 KiB is four pages. Small enough that a column of short strings does not round up to
122/// something silly, large enough that the per-block bookkeeping disappears.
123const BLOCK_SIZE: usize = 16 * 1024;
124
125/// A column of strings: the views, and the blocks the long ones live in.
126///
127/// Blocks are append only and never move, so an offset recorded in a view stays correct for the
128/// life of the column. Pushing a string longer than a block gives it a block of its own rather
129/// than splitting it, which keeps every string contiguous and keeps [`Self::get`] free of a
130/// stitching path that would be wrong more often than it ran.
131#[derive(Debug, Clone, Default, PartialEq, Eq)]
132pub struct StringColumn {
133    views: Vec<StringView>,
134    blocks: Vec<Vec<u8>>,
135}
136
137impl StringColumn {
138    /// An empty column.
139    #[must_use]
140    pub fn new() -> Self {
141        Self::default()
142    }
143
144    /// An empty column with room for `capacity` strings.
145    #[must_use]
146    pub fn with_capacity(capacity: usize) -> Self {
147        Self { views: Vec::with_capacity(capacity), blocks: Vec::new() }
148    }
149
150    /// How many strings are in the column.
151    #[must_use]
152    pub fn len(&self) -> usize {
153        self.views.len()
154    }
155
156    /// Whether the column has no strings in it.
157    #[must_use]
158    pub fn is_empty(&self) -> bool {
159        self.views.is_empty()
160    }
161
162    /// The views, for a kernel that wants to compare prefixes without reading any payload.
163    #[must_use]
164    pub fn views(&self) -> &[StringView] {
165        &self.views
166    }
167
168    /// Appends a string and returns its index.
169    pub fn push(&mut self, text: &str) -> usize {
170        let view = if text.len() <= INLINE_LIMIT {
171            StringView::inline(text)
172        } else {
173            let (block, offset) = self.append_bytes(text.as_bytes());
174            StringView::indirect(text, block, offset)
175        };
176        self.views.push(view);
177        self.views.len() - 1
178    }
179
180    /// The string at `index`, or `None` past the end.
181    #[must_use]
182    pub fn get(&self, index: usize) -> Option<&str> {
183        let view = self.views.get(index)?;
184        if let Some(text) = view.as_inline_str() {
185            return Some(text);
186        }
187        let block = self.blocks.get(view.block())?;
188        let bytes = block.get(view.offset()..view.offset() + view.len())?;
189        // Written from a `&str` into a block that is append only, so the bytes are the same bytes.
190        std::str::from_utf8(bytes).ok()
191    }
192
193    /// Every string in order.
194    pub fn iter(&self) -> impl Iterator<Item = &str> {
195        (0..self.len()).filter_map(|index| self.get(index))
196    }
197
198    /// Total bytes of payload held in blocks, which is what the memory accounting wants.
199    #[must_use]
200    pub fn heap_bytes(&self) -> usize {
201        self.blocks.iter().map(Vec::len).sum()
202    }
203
204    fn append_bytes(&mut self, bytes: &[u8]) -> (u32, u32) {
205        let fits = self
206            .blocks
207            .last()
208            .is_some_and(|block| block.len() + bytes.len() <= block.capacity().max(BLOCK_SIZE));
209        if !fits {
210            self.blocks.push(Vec::with_capacity(BLOCK_SIZE.max(bytes.len())));
211        }
212        let block_index = self.blocks.len() - 1;
213        let block = &mut self.blocks[block_index];
214        let offset = block.len();
215        block.extend_from_slice(bytes);
216        // A column with more than 4 billion blocks or a block over 4 GiB is not a thing that can
217        // exist here, since a vector holds 1024 values and a row group holds 122,880.
218        (block_index as u32, offset as u32)
219    }
220}
221
222impl<'a> Extend<&'a str> for StringColumn {
223    fn extend<T: IntoIterator<Item = &'a str>>(&mut self, iter: T) {
224        for text in iter {
225            self.push(text);
226        }
227    }
228}
229
230impl<'a> FromIterator<&'a str> for StringColumn {
231    fn from_iter<T: IntoIterator<Item = &'a str>>(iter: T) -> Self {
232        let mut column = Self::new();
233        column.extend(iter);
234        column
235    }
236}
237
238#[cfg(test)]
239mod tests {
240    use super::{INLINE_LIMIT, StringColumn, StringView};
241
242    #[test]
243    fn a_view_is_sixteen_bytes_and_stays_sixteen_bytes() {
244        // The number the whole design is built around. A vector of 1024 strings is 16 KiB of
245        // views, which is the budget spec/07-execution.md section 7.1 spends on purpose.
246        assert_eq!(size_of::<StringView>(), 16);
247        assert_eq!(align_of::<StringView>(), 4);
248    }
249
250    #[test]
251    fn twelve_bytes_is_inline_and_thirteen_is_not() {
252        let mut column = StringColumn::new();
253        column.push("123456789012");
254        column.push("1234567890123");
255        assert!(column.views()[0].is_inline());
256        assert!(!column.views()[1].is_inline());
257        assert_eq!(column.get(0), Some("123456789012"));
258        assert_eq!(column.get(1), Some("1234567890123"));
259        assert_eq!(INLINE_LIMIT, 12);
260    }
261
262    #[test]
263    fn a_prefix_answers_the_comparison_without_reading_the_payload() {
264        let mut column = StringColumn::new();
265        column.push("https://example.com/a");
266        column.push("https://example.com/b");
267        column.push("mailto:someone@example.com");
268        let views = column.views();
269        // Same prefix, same length: the payloads have to be read. This is the case the prefix
270        // cannot help with, and on a URL column it is the common case, which is why the
271        // dictionary work at M3 matters more than this does.
272        assert!(!views[0].definitely_differs(&views[1]));
273        // Different prefix: answered from the view.
274        assert!(views[0].definitely_differs(&views[2]));
275    }
276
277    #[test]
278    fn a_string_longer_than_a_block_gets_a_block_of_its_own() {
279        let long = "x".repeat(40 * 1024);
280        let mut column = StringColumn::new();
281        column.push("short");
282        column.push(&long);
283        column.push("also short");
284        assert_eq!(column.get(1), Some(long.as_str()));
285        assert_eq!(column.get(2), Some("also short"));
286        assert_eq!(column.heap_bytes(), long.len());
287    }
288
289    #[test]
290    fn blocks_hold_many_strings_and_the_offsets_stay_right() {
291        let mut column = StringColumn::new();
292        let strings: Vec<String> =
293            (0..2000).map(|i| format!("value number {i} padded out")).collect();
294        for text in &strings {
295            column.push(text);
296        }
297        for (index, text) in strings.iter().enumerate() {
298            assert_eq!(column.get(index), Some(text.as_str()), "at {index}");
299        }
300        assert_eq!(column.len(), 2000);
301        assert_eq!(column.iter().count(), 2000);
302    }
303
304    #[test]
305    fn the_empty_string_is_inline_and_reads_back_empty() {
306        let mut column = StringColumn::new();
307        column.push("");
308        assert_eq!(column.get(0), Some(""));
309        assert!(column.views()[0].is_empty());
310        assert_eq!(column.heap_bytes(), 0);
311    }
312
313    #[test]
314    fn multibyte_text_survives_the_inline_boundary() {
315        // The boundary is bytes and not characters, so a four byte emoji is what decides whether
316        // a three character string is inline.
317        let mut column = StringColumn::new();
318        column.push("héllo wörld");
319        column.push("🦀🦀🦀🦀");
320        assert_eq!(column.get(0), Some("héllo wörld"));
321        assert_eq!(column.get(1), Some("🦀🦀🦀🦀"));
322        assert!(!column.views()[1].is_inline());
323    }
324
325    #[test]
326    fn reading_past_the_end_is_none_rather_than_a_panic() {
327        let column: StringColumn = ["a", "b"].into_iter().collect();
328        assert_eq!(column.get(2), None);
329        assert_eq!(column.len(), 2);
330    }
331}