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 bytes, when the whole string is in the view.
89 ///
90 /// A comparison wants bytes rather than a `&str`, because SQL's string order is byte order and
91 /// because [`Self::as_inline_str`] pays for a UTF-8 validation that a comparison has no use
92 /// for. On a filter against a varchar column that validation is the whole cost of the row.
93 #[must_use]
94 pub fn inline_bytes(&self) -> Option<&[u8]> {
95 if self.is_inline() { Some(&self.payload[..self.len()]) } else { None }
96 }
97
98 /// The string, when it is short enough to be in the view.
99 #[must_use]
100 pub fn as_inline_str(&self) -> Option<&str> {
101 if !self.is_inline() {
102 return None;
103 }
104 // Every constructor takes a `&str`, so the bytes came from valid UTF-8 and a prefix of the
105 // inline payload up to the recorded length is exactly what was written.
106 std::str::from_utf8(&self.payload[..self.len()]).ok()
107 }
108
109 fn block(&self) -> usize {
110 u32::from_le_bytes([self.payload[4], self.payload[5], self.payload[6], self.payload[7]])
111 as usize
112 }
113
114 fn offset(&self) -> usize {
115 u32::from_le_bytes([self.payload[8], self.payload[9], self.payload[10], self.payload[11]])
116 as usize
117 }
118
119 /// Whether these two views are definitely different, answered from the view alone.
120 ///
121 /// A `false` here means the payloads have to be compared. A `true` means they do not, which on
122 /// a filter against a selective literal is almost every row.
123 #[must_use]
124 pub fn definitely_differs(&self, other: &Self) -> bool {
125 self.length != other.length || self.prefix() != other.prefix()
126 }
127}
128
129/// How much string data one block holds before another is started.
130///
131/// 16 KiB is four pages. Small enough that a column of short strings does not round up to
132/// something silly, large enough that the per-block bookkeeping disappears.
133const BLOCK_SIZE: usize = 16 * 1024;
134
135/// A column of strings: the views, and the blocks the long ones live in.
136///
137/// Blocks are append only and never move, so an offset recorded in a view stays correct for the
138/// life of the column. Pushing a string longer than a block gives it a block of its own rather
139/// than splitting it, which keeps every string contiguous and keeps [`Self::get`] free of a
140/// stitching path that would be wrong more often than it ran.
141#[derive(Debug, Clone, Default, PartialEq, Eq)]
142pub struct StringColumn {
143 views: Vec<StringView>,
144 blocks: Vec<Vec<u8>>,
145}
146
147impl StringColumn {
148 /// An empty column.
149 #[must_use]
150 pub fn new() -> Self {
151 Self::default()
152 }
153
154 /// An empty column with room for `capacity` strings.
155 #[must_use]
156 pub fn with_capacity(capacity: usize) -> Self {
157 Self { views: Vec::with_capacity(capacity), blocks: Vec::new() }
158 }
159
160 /// How many strings are in the column.
161 #[must_use]
162 pub fn len(&self) -> usize {
163 self.views.len()
164 }
165
166 /// Whether the column has no strings in it.
167 #[must_use]
168 pub fn is_empty(&self) -> bool {
169 self.views.is_empty()
170 }
171
172 /// The views, for a kernel that wants to compare prefixes without reading any payload.
173 #[must_use]
174 pub fn views(&self) -> &[StringView] {
175 &self.views
176 }
177
178 /// Appends a string and returns its index.
179 pub fn push(&mut self, text: &str) -> usize {
180 let view = if text.len() <= INLINE_LIMIT {
181 StringView::inline(text)
182 } else {
183 let (block, offset) = self.append_bytes(text.as_bytes());
184 StringView::indirect(text, block, offset)
185 };
186 self.views.push(view);
187 self.views.len() - 1
188 }
189
190 /// The bytes at `index`, or `None` past the end.
191 ///
192 /// This is what a comparison, a hash and an equality check all actually want, and it is worth
193 /// having separately from [`Self::get`] because that one validates UTF-8 and they do not need
194 /// it. Everything in a column arrived through [`Self::push`], which takes a `&str`, so the
195 /// bytes are valid either way and the validation is a scan of the payload that changes no
196 /// answer. On a varchar filter it was measured at most of the per row cost.
197 #[must_use]
198 pub fn bytes(&self, index: usize) -> Option<&[u8]> {
199 let view = self.views.get(index)?;
200 if let Some(inline) = view.inline_bytes() {
201 return Some(inline);
202 }
203 let block = self.blocks.get(view.block())?;
204 block.get(view.offset()..view.offset() + view.len())
205 }
206
207 /// The string at `index`, or `None` past the end.
208 #[must_use]
209 pub fn get(&self, index: usize) -> Option<&str> {
210 // Written from a `&str` into a block that is append only, so the bytes are the same bytes.
211 std::str::from_utf8(self.bytes(index)?).ok()
212 }
213
214 /// Every string in order.
215 pub fn iter(&self) -> impl Iterator<Item = &str> {
216 (0..self.len()).filter_map(|index| self.get(index))
217 }
218
219 /// Total bytes of payload held in blocks, which is what the memory accounting wants.
220 #[must_use]
221 pub fn heap_bytes(&self) -> usize {
222 self.blocks.iter().map(Vec::len).sum()
223 }
224
225 fn append_bytes(&mut self, bytes: &[u8]) -> (u32, u32) {
226 let fits = self
227 .blocks
228 .last()
229 .is_some_and(|block| block.len() + bytes.len() <= block.capacity().max(BLOCK_SIZE));
230 if !fits {
231 self.blocks.push(Vec::with_capacity(BLOCK_SIZE.max(bytes.len())));
232 }
233 let block_index = self.blocks.len() - 1;
234 let block = &mut self.blocks[block_index];
235 let offset = block.len();
236 block.extend_from_slice(bytes);
237 // A column with more than 4 billion blocks or a block over 4 GiB is not a thing that can
238 // exist here, since a vector holds 1024 values and a row group holds 122,880.
239 (block_index as u32, offset as u32)
240 }
241}
242
243impl<'a> Extend<&'a str> for StringColumn {
244 fn extend<T: IntoIterator<Item = &'a str>>(&mut self, iter: T) {
245 for text in iter {
246 self.push(text);
247 }
248 }
249}
250
251impl<'a> FromIterator<&'a str> for StringColumn {
252 fn from_iter<T: IntoIterator<Item = &'a str>>(iter: T) -> Self {
253 let mut column = Self::new();
254 column.extend(iter);
255 column
256 }
257}
258
259#[cfg(test)]
260mod tests {
261 use super::{INLINE_LIMIT, StringColumn, StringView};
262
263 #[test]
264 fn a_view_is_sixteen_bytes_and_stays_sixteen_bytes() {
265 // The number the whole design is built around. A vector of 1024 strings is 16 KiB of
266 // views, which is the budget spec/07-execution.md section 7.1 spends on purpose.
267 assert_eq!(size_of::<StringView>(), 16);
268 assert_eq!(align_of::<StringView>(), 4);
269 }
270
271 #[test]
272 fn twelve_bytes_is_inline_and_thirteen_is_not() {
273 let mut column = StringColumn::new();
274 column.push("123456789012");
275 column.push("1234567890123");
276 assert!(column.views()[0].is_inline());
277 assert!(!column.views()[1].is_inline());
278 assert_eq!(column.get(0), Some("123456789012"));
279 assert_eq!(column.get(1), Some("1234567890123"));
280 assert_eq!(INLINE_LIMIT, 12);
281 }
282
283 #[test]
284 fn a_prefix_answers_the_comparison_without_reading_the_payload() {
285 let mut column = StringColumn::new();
286 column.push("https://example.com/a");
287 column.push("https://example.com/b");
288 column.push("mailto:someone@example.com");
289 let views = column.views();
290 // Same prefix, same length: the payloads have to be read. This is the case the prefix
291 // cannot help with, and on a URL column it is the common case, which is why the
292 // dictionary work at M3 matters more than this does.
293 assert!(!views[0].definitely_differs(&views[1]));
294 // Different prefix: answered from the view.
295 assert!(views[0].definitely_differs(&views[2]));
296 }
297
298 #[test]
299 fn a_string_longer_than_a_block_gets_a_block_of_its_own() {
300 let long = "x".repeat(40 * 1024);
301 let mut column = StringColumn::new();
302 column.push("short");
303 column.push(&long);
304 column.push("also short");
305 assert_eq!(column.get(1), Some(long.as_str()));
306 assert_eq!(column.get(2), Some("also short"));
307 assert_eq!(column.heap_bytes(), long.len());
308 }
309
310 #[test]
311 fn blocks_hold_many_strings_and_the_offsets_stay_right() {
312 let mut column = StringColumn::new();
313 let strings: Vec<String> =
314 (0..2000).map(|i| format!("value number {i} padded out")).collect();
315 for text in &strings {
316 column.push(text);
317 }
318 for (index, text) in strings.iter().enumerate() {
319 assert_eq!(column.get(index), Some(text.as_str()), "at {index}");
320 }
321 assert_eq!(column.len(), 2000);
322 assert_eq!(column.iter().count(), 2000);
323 }
324
325 #[test]
326 fn the_empty_string_is_inline_and_reads_back_empty() {
327 let mut column = StringColumn::new();
328 column.push("");
329 assert_eq!(column.get(0), Some(""));
330 assert!(column.views()[0].is_empty());
331 assert_eq!(column.heap_bytes(), 0);
332 }
333
334 #[test]
335 fn multibyte_text_survives_the_inline_boundary() {
336 // The boundary is bytes and not characters, so a four byte emoji is what decides whether
337 // a three character string is inline.
338 let mut column = StringColumn::new();
339 column.push("héllo wörld");
340 column.push("🦀🦀🦀🦀");
341 assert_eq!(column.get(0), Some("héllo wörld"));
342 assert_eq!(column.get(1), Some("🦀🦀🦀🦀"));
343 assert!(!column.views()[1].is_inline());
344 }
345
346 #[test]
347 fn reading_past_the_end_is_none_rather_than_a_panic() {
348 let column: StringColumn = ["a", "b"].into_iter().collect();
349 assert_eq!(column.get(2), None);
350 assert_eq!(column.len(), 2);
351 }
352
353 /// The bytes and the string have to be the same string on both sides of the inline boundary
354 /// and on multibyte text, because the comparison kernels read the bytes and everything else
355 /// reads the string, and a disagreement between them would be a filter that matched a row the
356 /// projection then printed differently.
357 #[test]
358 fn the_bytes_and_the_string_are_the_same_string() {
359 let long = "x".repeat(9000);
360 let words = ["", "a", "twelve bytes", "thirteen bytes", "π is two bytes", &long];
361 let column: StringColumn = words.into_iter().collect();
362 for (index, text) in words.iter().enumerate() {
363 assert_eq!(column.bytes(index), Some(text.as_bytes()), "at {index}");
364 assert_eq!(column.get(index), Some(*text), "at {index}");
365 }
366 assert_eq!(column.bytes(words.len()), None);
367 }
368}