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 and the
25/// last 8 are the offset into the column's arena.
26///
27/// Arrow spends 4 of those 8 bytes on a buffer index and 4 on an offset within the buffer, because
28/// an Arrow array is a list of buffers. This column is one arena, so there is no buffer to name and
29/// the whole 8 bytes are the offset, which reads as one load rather than two and takes the reachable
30/// size of a column from 4 GiB to more than anything will ever put in one.
31///
32/// A view on its own cannot produce a long string, only a short one. That is deliberate: the arena
33/// lives in the [`StringColumn`] and the borrow checker is what stops a view from outliving it,
34/// rather than a rule somebody has to remember.
35#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
36pub struct StringView {
37    length: u32,
38    payload: [u8; 12],
39}
40
41impl StringView {
42    /// A view on a string that fits inline.
43    ///
44    /// # Panics
45    ///
46    /// If the string is longer than [`INLINE_LIMIT`]. Callers that do not know the length go
47    /// through [`StringColumn::push`], which decides.
48    #[must_use]
49    pub fn inline(text: &str) -> Self {
50        assert!(text.len() <= INLINE_LIMIT, "a string of {} bytes is not inline", text.len());
51        let mut payload = [0u8; 12];
52        payload[..text.len()].copy_from_slice(text.as_bytes());
53        Self { length: text.len() as u32, payload }
54    }
55
56    /// A view on a string that lives in the arena.
57    fn indirect(text: &str, offset: u64) -> Self {
58        let mut payload = [0u8; 12];
59        payload[..4].copy_from_slice(&text.as_bytes()[..4]);
60        payload[4..].copy_from_slice(&offset.to_le_bytes());
61        Self { length: text.len() as u32, payload }
62    }
63
64    /// The length in bytes.
65    #[must_use]
66    pub fn len(&self) -> usize {
67        self.length as usize
68    }
69
70    /// Whether the string is empty.
71    #[must_use]
72    pub fn is_empty(&self) -> bool {
73        self.length == 0
74    }
75
76    /// Whether the whole string is in the view.
77    #[must_use]
78    pub fn is_inline(&self) -> bool {
79        self.len() <= INLINE_LIMIT
80    }
81
82    /// The first four bytes, zero padded.
83    ///
84    /// This is the whole point of the representation. Two strings with different prefixes are
85    /// different, and two strings with the same prefix are usually equal, so a filter on a string
86    /// column resolves without touching the payload on almost every row.
87    #[must_use]
88    pub fn prefix(&self) -> [u8; 4] {
89        [self.payload[0], self.payload[1], self.payload[2], self.payload[3]]
90    }
91
92    /// The bytes, when the whole string is in the view.
93    ///
94    /// A comparison wants bytes rather than a `&str`, because SQL's string order is byte order and
95    /// because [`Self::as_inline_str`] pays for a UTF-8 validation that a comparison has no use
96    /// for. On a filter against a varchar column that validation is the whole cost of the row.
97    #[must_use]
98    pub fn inline_bytes(&self) -> Option<&[u8]> {
99        if self.is_inline() { Some(&self.payload[..self.len()]) } else { None }
100    }
101
102    /// The string, when it is short enough to be in the view.
103    #[must_use]
104    pub fn as_inline_str(&self) -> Option<&str> {
105        if !self.is_inline() {
106            return None;
107        }
108        // Every constructor takes a `&str`, so the bytes came from valid UTF-8 and a prefix of the
109        // inline payload up to the recorded length is exactly what was written.
110        std::str::from_utf8(&self.payload[..self.len()]).ok()
111    }
112
113    fn offset(&self) -> usize {
114        u64::from_le_bytes([
115            self.payload[4],
116            self.payload[5],
117            self.payload[6],
118            self.payload[7],
119            self.payload[8],
120            self.payload[9],
121            self.payload[10],
122            self.payload[11],
123        ]) as usize
124    }
125
126    /// Whether these two views are definitely different, answered from the view alone.
127    ///
128    /// A `false` here means the payloads have to be compared. A `true` means they do not, which on
129    /// a filter against a selective literal is almost every row.
130    #[must_use]
131    pub fn definitely_differs(&self, other: &Self) -> bool {
132        self.length != other.length || self.prefix() != other.prefix()
133    }
134}
135
136/// A column of strings: the views, and the one arena the long ones live in.
137///
138/// The arena is append only, so an offset recorded in a view stays correct for the life of the
139/// column even though the arena's address does not. That is the property a `Vec<u8>` has and a raw
140/// pointer into it does not, and it is the reason a view holds an offset.
141///
142/// This was a `Vec<Vec<u8>>` of fixed size blocks, which meant reading one long string was two
143/// dependent loads, the outer vector's element to find the block's data pointer and then the bytes.
144/// One arena makes it one, from a base the compiler can keep in a register across a row loop, and it
145/// deletes the case where a string longer than a block needed a block of its own. On server3, over a
146/// chunk of 1024 strings, comparing a column against a literal went from 14.9 nanoseconds a row to
147/// 13.2 at 40 bytes a string and from 14.2 to 12.9 at 120, gathering half the rows from 29.5 to 25.3
148/// and from 36.9 to 29.1, and building the column from 12.0 to 8.9 at 40 bytes.
149///
150/// # The one number that got worse, and what it actually is
151///
152/// Building a column whose payload passes 128 KiB, which at 1024 rows means strings averaging more
153/// than 128 bytes, went the other way: 14.6 nanoseconds a row to 41.0. That is not the copy and it
154/// is not the doubling, it is glibc. An allocation that size comes from `mmap` rather than the heap,
155/// so it is handed back to the kernel when the column is dropped and the next chunk faults every
156/// page of it in again, while sixteen KiB blocks come back off a free list already faulted. Run the
157/// same benchmark with `MALLOC_MMAP_THRESHOLD_` raised and the arena builds that column in 9.6
158/// nanoseconds a row against the blocks' 16.2, so the design is not what is slow there.
159///
160/// The fix is that a chunk's payload should come from a pool the engine owns rather than from
161/// `malloc` per chunk, which is the buffer manager at layer three and is where this belongs.
162/// [`Self::reserve_bytes`] is the part that is available now, and it recovers a quarter of it.
163#[derive(Debug, Clone, Default, PartialEq, Eq)]
164pub struct StringColumn {
165    views: Vec<StringView>,
166    arena: Vec<u8>,
167}
168
169impl StringColumn {
170    /// An empty column.
171    #[must_use]
172    pub fn new() -> Self {
173        Self::default()
174    }
175
176    /// An empty column with room for `capacity` strings.
177    #[must_use]
178    pub fn with_capacity(capacity: usize) -> Self {
179        Self { views: Vec::with_capacity(capacity), arena: Vec::new() }
180    }
181
182    /// How many strings are in the column.
183    #[must_use]
184    pub fn len(&self) -> usize {
185        self.views.len()
186    }
187
188    /// Whether the column has no strings in it.
189    #[must_use]
190    pub fn is_empty(&self) -> bool {
191        self.views.is_empty()
192    }
193
194    /// The views, for a kernel that wants to compare prefixes without reading any payload.
195    #[must_use]
196    pub fn views(&self) -> &[StringView] {
197        &self.views
198    }
199
200    /// Appends a string and returns its index.
201    pub fn push(&mut self, text: &str) -> usize {
202        let view = if text.len() <= INLINE_LIMIT {
203            StringView::inline(text)
204        } else {
205            let offset = self.arena.len() as u64;
206            self.arena.extend_from_slice(text.as_bytes());
207            StringView::indirect(text, offset)
208        };
209        self.views.push(view);
210        self.views.len() - 1
211    }
212
213    /// The bytes at `index`, or `None` past the end.
214    ///
215    /// This is what a comparison, a hash and an equality check all actually want, and it is worth
216    /// having separately from [`Self::get`] because that one validates UTF-8 and they do not need
217    /// it. Everything in a column arrived through [`Self::push`], which takes a `&str`, so the
218    /// bytes are valid either way and the validation is a scan of the payload that changes no
219    /// answer. On a varchar filter it was measured at most of the per row cost.
220    #[must_use]
221    pub fn bytes(&self, index: usize) -> Option<&[u8]> {
222        let view = self.views.get(index)?;
223        if let Some(inline) = view.inline_bytes() {
224            return Some(inline);
225        }
226        self.arena.get(view.offset()..view.offset() + view.len())
227    }
228
229    /// The string at `index`, or `None` past the end.
230    #[must_use]
231    pub fn get(&self, index: usize) -> Option<&str> {
232        // Written from a `&str` into a block that is append only, so the bytes are the same bytes.
233        std::str::from_utf8(self.bytes(index)?).ok()
234    }
235
236    /// Every string in order.
237    pub fn iter(&self) -> impl Iterator<Item = &str> {
238        (0..self.len()).filter_map(|index| self.get(index))
239    }
240
241    /// Total bytes of payload held in the arena, which is what the memory accounting wants.
242    #[must_use]
243    pub fn heap_bytes(&self) -> usize {
244        self.arena.len()
245    }
246
247    /// Room for `bytes` of payload, taken in one allocation rather than as the strings arrive.
248    ///
249    /// A builder that knows the total byte count, which a scan reading a page and a gather copying a
250    /// column both do, saves the doubling entirely. Nothing is wrong without it, which is why it is
251    /// a hint and not a constructor argument.
252    pub fn reserve_bytes(&mut self, bytes: usize) {
253        self.arena.reserve(bytes);
254    }
255}
256
257impl<'a> Extend<&'a str> for StringColumn {
258    fn extend<T: IntoIterator<Item = &'a str>>(&mut self, iter: T) {
259        for text in iter {
260            self.push(text);
261        }
262    }
263}
264
265impl<'a> FromIterator<&'a str> for StringColumn {
266    fn from_iter<T: IntoIterator<Item = &'a str>>(iter: T) -> Self {
267        let mut column = Self::new();
268        column.extend(iter);
269        column
270    }
271}
272
273#[cfg(test)]
274mod tests {
275    use super::{INLINE_LIMIT, StringColumn, StringView};
276
277    #[test]
278    fn a_view_is_sixteen_bytes_and_stays_sixteen_bytes() {
279        // The number the whole design is built around. A vector of 1024 strings is 16 KiB of
280        // views, which is the budget spec/07-execution.md section 7.1 spends on purpose.
281        assert_eq!(size_of::<StringView>(), 16);
282        assert_eq!(align_of::<StringView>(), 4);
283    }
284
285    #[test]
286    fn twelve_bytes_is_inline_and_thirteen_is_not() {
287        let mut column = StringColumn::new();
288        column.push("123456789012");
289        column.push("1234567890123");
290        assert!(column.views()[0].is_inline());
291        assert!(!column.views()[1].is_inline());
292        assert_eq!(column.get(0), Some("123456789012"));
293        assert_eq!(column.get(1), Some("1234567890123"));
294        assert_eq!(INLINE_LIMIT, 12);
295    }
296
297    #[test]
298    fn a_prefix_answers_the_comparison_without_reading_the_payload() {
299        let mut column = StringColumn::new();
300        column.push("https://example.com/a");
301        column.push("https://example.com/b");
302        column.push("mailto:someone@example.com");
303        let views = column.views();
304        // Same prefix, same length: the payloads have to be read. This is the case the prefix
305        // cannot help with, and on a URL column it is the common case, which is why the
306        // dictionary work at M3 matters more than this does.
307        assert!(!views[0].definitely_differs(&views[1]));
308        // Different prefix: answered from the view.
309        assert!(views[0].definitely_differs(&views[2]));
310    }
311
312    /// A string of any size goes in whole, with the short ones on either side of it still reading
313    /// back. The old layout had a size at which a string stopped fitting a block and got one of its
314    /// own, and one arena has no such size, so the case worth keeping is the one that used to be
315    /// special rather than the branch that used to handle it.
316    #[test]
317    fn a_string_far_larger_than_any_block_would_have_been_goes_in_whole() {
318        let long = "x".repeat(40 * 1024);
319        let mut column = StringColumn::new();
320        column.push("short");
321        column.push(&long);
322        column.push("also short");
323        assert_eq!(column.get(1), Some(long.as_str()));
324        assert_eq!(column.get(2), Some("also short"));
325        assert_eq!(column.heap_bytes(), long.len());
326    }
327
328    /// The property the whole arena rests on. Two thousand strings is tens of reallocations, and
329    /// every one of them moves the bytes to a new address while the offsets recorded in the views
330    /// before it stay exactly as they were. A view holding a pointer would be reading freed memory
331    /// by the end of this test.
332    #[test]
333    fn the_arena_moving_underneath_does_not_move_what_the_views_point_at() {
334        let mut column = StringColumn::new();
335        let strings: Vec<String> =
336            (0..2000).map(|i| format!("value number {i} padded out")).collect();
337        for text in &strings {
338            column.push(text);
339        }
340        for (index, text) in strings.iter().enumerate() {
341            assert_eq!(column.get(index), Some(text.as_str()), "at {index}");
342        }
343        assert_eq!(column.len(), 2000);
344        assert_eq!(column.iter().count(), 2000);
345    }
346
347    #[test]
348    fn reserving_bytes_changes_nothing_but_where_the_allocation_happens() {
349        let mut column = StringColumn::with_capacity(3);
350        column.reserve_bytes(128);
351        for text in ["a string past the limit", "another one past it", "short"] {
352            column.push(text);
353        }
354        assert_eq!(column.get(0), Some("a string past the limit"));
355        assert_eq!(column.get(1), Some("another one past it"));
356        assert_eq!(column.get(2), Some("short"));
357        assert_eq!(column.heap_bytes(), 42);
358    }
359
360    #[test]
361    fn the_empty_string_is_inline_and_reads_back_empty() {
362        let mut column = StringColumn::new();
363        column.push("");
364        assert_eq!(column.get(0), Some(""));
365        assert!(column.views()[0].is_empty());
366        assert_eq!(column.heap_bytes(), 0);
367    }
368
369    #[test]
370    fn multibyte_text_survives_the_inline_boundary() {
371        // The boundary is bytes and not characters, so a four byte emoji is what decides whether
372        // a three character string is inline.
373        let mut column = StringColumn::new();
374        column.push("héllo wörld");
375        column.push("🦀🦀🦀🦀");
376        assert_eq!(column.get(0), Some("héllo wörld"));
377        assert_eq!(column.get(1), Some("🦀🦀🦀🦀"));
378        assert!(!column.views()[1].is_inline());
379    }
380
381    #[test]
382    fn reading_past_the_end_is_none_rather_than_a_panic() {
383        let column: StringColumn = ["a", "b"].into_iter().collect();
384        assert_eq!(column.get(2), None);
385        assert_eq!(column.len(), 2);
386    }
387
388    /// The bytes and the string have to be the same string on both sides of the inline boundary
389    /// and on multibyte text, because the comparison kernels read the bytes and everything else
390    /// reads the string, and a disagreement between them would be a filter that matched a row the
391    /// projection then printed differently.
392    #[test]
393    fn the_bytes_and_the_string_are_the_same_string() {
394        let long = "x".repeat(9000);
395        let words = ["", "a", "twelve bytes", "thirteen bytes", "π is two bytes", &long];
396        let column: StringColumn = words.into_iter().collect();
397        for (index, text) in words.iter().enumerate() {
398            assert_eq!(column.bytes(index), Some(text.as_bytes()), "at {index}");
399            assert_eq!(column.get(index), Some(*text), "at {index}");
400        }
401        assert_eq!(column.bytes(words.len()), None);
402    }
403}