Columnar storage¶
This page documents columnar.rs in depth. It is the storage layer that makes
TableBuilder fast and Arrow export cheap.
StrColumn¶
This is exactly the Arrow StringArray layout (offsets + bytes + null bitmap)
without per-cell String allocation.
Fields¶
-
data: Vec<u8>: One contiguous arena. Every string's bytes are appended sequentially. No per-cell allocation. -
offsets: Vec<i32>:len + 1entries.offsets[i]..offsets[i+1]is the byte range for valuei. Initialized with[0]sopushcan compute the next offset asdata.len(). -
validity: ValidityBitmap: One bit per row.truemeans present;falsemeans null (no bytes for that slot, but offsets still advance by 0).
Operations¶
-
with_capacity(cap): Preallocatesoffsetswithcap + 1anddatawithcap * 16(heuristic 16 bytes per string). -
push(Option<&str>): ExtendsdataifSome, pushesdata.len()tooffsets, pushesis_sometovalidity. No per-cell allocation beyond arena growth. -
pop: Undoes the last push: pops validity, pops offsets, truncates data to the last offset. -
get(i): Checks validity, slicesdata[offsets[i]..offsets[i+1]], returnsOption<&str>. -
append(&mut self, other): Merges another column by base-shifting offsets:base = self.data.len() as i32, then copiesother.dataintoself.dataand extends offsets. O(n) in both bytes and offsets; the base shift only avoids recomputing offsets. -
to_arrow(): Builds ArrowStringArrayby wrapping three buffers withOffsetBuffer,ScalarBuffer,Buffer, andNullBuffer. Block copy, not per-cell.
ValidityBitmap¶
One bit per row packed into bytes. Supports push (allocates new byte every
8 rows), pop, split_off, append. Converts to Arrow NullBuffer via
into_arrow().
ColumnBuilder¶
pub(crate) enum ColumnBuilder {
String(StrColumn),
Int64(PrimColumn<i64>),
Float64(PrimColumn<f64>),
Boolean(PrimColumn<bool>),
Date32(PrimColumn<i32>),
Timestamp(TimeUnit, Option<Box<str>>, PrimColumn<i64>),
Decimal128(u8, PrimColumn<i128>),
Dictionary {
codes: NullableColumn<i32>,
data: Vec<u8>,
offsets: Vec<i32>,
index: FxHashMap<Box<str>, i32>,
},
}
Each variant stores a PrimColumn<T> (or StrColumn for strings).
Timestamp carries the unit and an optional chrono format string (tried
before the built-in ISO layouts when parsing); Decimal128 carries the
scale, storing value * 10^scale as i128. Created by
ColumnBuilder::with_capacity(est, &ty) in TableBuilder::ensure_columns,
where ty comes from ExecutionPlan::column_type.
PrimColumn¶
Flat contiguous array. to_arrow() moves the buffers out via
std::mem::take but converts per element
(data.into_iter().map(A::Native::from).collect()), so it allocates a new
ScalarBuffer rather than moving the Vec wholesale. Boolean specialization
via to_arrow_bool() packs values into a BooleanBuffer.
Push paths¶
push_value(Value<'_>) is called for every field:
Value::Str(s)→push_str(Some(s))→ parse according to column type (Decimal128 parses to a scaledi128viaparse_decimal128)Value::Int64(i)into Int64 is native, into Float64 widensValue::Float64(f)into Float64 is native, into Int64 narrows (f as i64)- Typed values (
Int64/Float64/Bool/Date32/Timestamp) into String columns are formatted as text ("42","true", ISO dates and timestamps); into Dictionary columns the same text is dict-encoded - Other cross-type mismatches become
None
dict_code(dict, index, v) does hash lookup + insert. Average O(1).
Auto dictionary¶
try_upgrade_to_dict(min_rows, max_ratio, max_size) upgrades String to
Dictionary when cardinality is low:
- Only String builders; others are no-ops.
- If
len < min_rows(512 default), stay as String. - Count distinct via
FxHashSet<&str>overiter().flatten()(skip nulls). - Compute cap:
min(max(16, len * max_ratio), max_size). Floor of 16 lets tiny columns upgrade; cap respectsdict_threshold(default 0.05) anddict_max_size(default 256). - If distinct > cap, stay as String.
- Otherwise build dict/index/codes from the old StrColumn via
dict_code.
Called after each chunk parse when plan.auto_dict is true, and after merge
via TableBuilder::auto_dict_upgrade.
Incremental dictionary unification¶
When auto_dict=True in parallel mode, chunks may produce different
dictionaries. The incremental path:
- Per-chunk
auto_dict_upgradein parallel - Find first divergent dictionary across chunks
- Build
SeedDictfrom first chunk unify_dictionaries: global dict + per-chunk remap tables (O(dict_size))remap_codes: in-place code remap via bounds-checkedget(serial loop over chunk engines; only step 1 runs under rayon)replace_dict: swap local dict for unified dict
This avoids the serial merge path while keeping the fast export path.
Merging and promotion¶
extend_owned(other) merges by consuming other. Both must be same variant:
- String via
StrColumn::append(base shift) - Numeric via
Vec::append - Timestamp checks
unit_a == unit_belseError::Merge - Decimal128 checks
scale_a == scale_belseError::Merge - Dictionary remaps via
dict_codeper value
unify_variants(a, b) reconciles: same→same, int64+float64→float64,
string+dictionary→dictionary, else None.
promote_to_variant(target) mutates in place: Int64→Float64, String→Dict.
TypedValue¶
pub(crate) enum TypedValue<'a> {
Str(&'a str), Int64(i64), Float64(f64),
Bool(bool), Date32(i32), Timestamp(i64),
}
Borrowed view for filter evaluation. get_typed_value borrows directly from
storage without allocation. The string-filter path tries get_filter_view
first, which borrows &str with no allocation for String and Dictionary
columns; typed columns return None there and fall back to
get_filter_value, which formats as String (dates via format_date32,
timestamps via format_timestamp).
Related helpers: is_type_at backs the IsType predicate, parses_as
checks whether a string parses into a declared FieldType for strict-types
mode, and variant_key returns the storage-variant tag (including timestamp
unit and decimal scale) used to keep schemas consistent across chunks.
Performance characteristics¶
String push¶
push_str for the String variant does:
- Compute offset:
data.len() - Extend data:
data.extend_from_slice(s.as_bytes()) - Push offset:
offsets.push(data.len() as i32) - Push validity:
validity.push(true)
Cost: ~10 ns per string (arena append + 3 pushes). No per-cell allocation.
Numeric push¶
push_value for Int64/Float64 does:
- Match on Value variant
lexical::parseor direct cast- Push to
PrimColumn::data(Vec push) - Push to
validity
Cost: ~5 ns per numeric value. No parsing overhead for typed values.
Dictionary push¶
dict_code does:
index.get(v): HashMap lookup (~5 ns)- If missing:
dict.push(v.to_owned())+index.insert(~20 ns amortized) codes.push(Some(code)): NullableColumn push
Cost: ~5 ns per value (amortized O(1) lookup).
Arrow export¶
to_arrow_array uses std::mem::take to move internal buffers out, but
zero-copy holds only for StrColumn (and the dictionary data/offsets
buffers): PrimColumn<T> converts per element
(data.into_iter().map(A::Native::from).collect()) into a new allocation,
and booleans are additionally repacked into a bit-packed BooleanBuffer.
For StrColumn: moves data, offsets, and validity into StringArray.
For PrimColumn<T>: per-element conversion of data into the Arrow native
type; validity moves into the null buffer. Decimal128 exports as Arrow
Decimal128(38, scale).
For Dictionary: moves codes, dict into DictionaryArray.
Cost: ~100 ns per column for StrColumn/Dictionary (buffer moves + schema
construction); typed columns pay one linear conversion pass.