iceberg · API

iceberg.kernels

Columnar kernels: the Arrow arrays a scan produces, without a Datum in sight.

Everything here operates on parquet.mojo's ArrayData — one Arrow array, its validity bitmap and its buffers — and produces another one. A scan uses them in this order:

  1. cast — the file's physical column is retagged or widened to the type the table's current schema says the column has (int -> long, float -> double, a decimal whose precision grew, a timestamp whose unit differs, large_utf8 -> utf8);
  2. constant — a column the file does not have becomes an array of one repeated value: an identity partition value, an initial-default, a metadata column like _file, or all-null;
  3. select — deletes and the residual predicate produce one List[Bool] per batch, and filter_array applies it to every output column in one pass;
  4. concat — batches are stitched together when a caller wants one table instead of a stream.

None of this materialises a per-cell tagged value, which is the whole point: the previous implementation built a Datum for every cell of every column it read, and that cost about 70x.

Functions

is_binary_type

def is_binary_type(t: ArrowType) -> Bool

Variable-width with 32-bit offsets.

is_large_binary_type

def is_large_binary_type(t: ArrowType) -> Bool

Variable-width with 64-bit offsets.

is_var_width

def is_var_width(t: ArrowType) -> Bool

compare_class

def compare_class(t: ArrowType) -> Int

Which comparison kernel, if any, serves this Arrow type.

arrow_type_for

def arrow_type_for(kind: UInt8, precision: Int, scale: Int, length: Int) -> ArrowType

empty_array

def empty_array(name: String, field_id: Int, var t: ArrowType) -> ArrayData

constant_array

def constant_array(name: String, field_id: Int, kind: UInt8, precision: Int, scale: Int, length: Int, d: Datum, n: Int) -> ArrayData

n copies of one value — a partition constant, a default, or all-null.

encode_cell

def encode_cell(d: Datum, t: ArrowType, kind: UInt8) -> List[UInt8]

One value as the bytes Arrow stores for it.

decimal_le16

def decimal_le16(d: Datum) -> List[UInt8]

A decimal's unscaled value as Arrow's little-endian 16-byte two's complement.

int64_array

def int64_array(name: String, values: List[Int64]) -> ArrayData

A non-null int64 column — _pos, _row_id and friends.

cast_array

def cast_array(var a: ArrayData, target: ArrowType, name: String, field_id: Int) -> ArrayData

The file's column, produced at the table's current type.

Iceberg only ever widens: int -> long, float -> double, and a decimal whose precision grew (which Arrow stores at a fixed 16 bytes either way, so only the tag changes). Everything else here is a retag — the same bytes under a different Arrow type — which is what makes a full scan of an unevolved table copy nothing at all.

int_at

def int_at(a: ArrayData, i: Int) -> Int64

The integral value at i, whatever fixed width it was stored at.

float_at

def float_at(a: ArrayData, i: Int) -> Float64

value_extent

def value_extent(a: ArrayData, i: Int) -> Tuple[Int, Int]

(start, end) of the raw bytes of element i in a.values.

bytes_at

def bytes_at(a: ArrayData, i: Int) -> List[UInt8]

extract_datum

def extract_datum(a: ArrayData, i: Int, kind: UInt8, precision: Int, scale: Int, length: Int) -> Datum

Element i, typed as the table's current schema says it is.

This is the only place a scan builds a per-cell tagged value, and it is built on demand: ScanResult.value, CSV and JSON output, the partition transforms a write applies, and the handful of predicate shapes no vectorised kernel covers.

sel_uninit

def sel_uninit(n: Int) -> List[Bool]

A selection vector of n rows, every one of them uninitialised.

Zeroing a vector that the kernel about to run overwrites in full is a second pass over the same memory for nothing: at 0.23 ns/row it cost more than the comparison it preceded, and a three-predicate scan of the taxi table paid it three times. Every caller must write all n elements — the leaf paths in read.mojo do, on both the vectorised branch and the Datum fallback that follows a refusal.

sel_count

def sel_count(keep: List[Bool]) -> Int

How many rows a selection vector keeps.

A branch per row cost 0.60 ns/row — 47 ms on every 79.5M-row scan of the taxi table, filtered or not — for a number the loop that produced the vector could have carried. Summing the low bits sixteen at a time costs 0.033 ns/row instead. The per-lane accumulator is 32-bit, which cannot overflow: it sees one batch, and a batch would need 34 billion rows.

sel_any

def sel_any(keep: List[Bool]) -> Bool

Whether any row survives. Stops at the first one that does.

sel_and

def sel_and(mut left: List[Bool], right: List[Bool])

left &= right, row-wise.

sel_or

def sel_or(mut left: List[Bool], right: List[Bool])

left |= right, row-wise.

sel_not

def sel_not(mut keep: List[Bool])

keep = not keep, row-wise. Only the low bit of each byte carries.

sel_next_true

def sel_next_true(keep: List[Bool], start: Int) -> Int

The first kept row at or after start, or len(keep).

Sixteen rows are tested at a time so that a highly selective filter walks its own gaps at memory speed rather than one branch per row. On the taxi suite's most selective query that turned a 79.5M-iteration scan per output column into 5M vector tests.

sel_next_false

def sel_next_false(keep: List[Bool], start: Int) -> Int

The first dropped row at or after start, or len(keep).

sel_compare

def sel_compare[store: DType, compare: DType](values: Span[UInt8], n: Int, lit: Scalar[compare], lt: Bool, eq: Bool, gt: Bool, mut out: List[Bool])

out[r] = accept(cmp(values[r], lit)) over a fixed-width column.

store is how the column is laid out and compare is the width Iceberg orders it at: a narrow integer widens to int64 and a float widens to double, which is what makes the comparison exact rather than an approximation of the literal in the column's own width. The widening is a register move, and the loop stays bound by the values buffer.

Iceberg orders NaN above every number, where IEEE makes it unordered, so a NaN lane fails all three machine comparisons and is folded into the greater-than case by hand. A NaN literal is not handled here — the caller keeps the scalar path for that, because it inverts the rule.

Nulls are not this kernel's business: sel_apply_validity clears them afterwards, which costs one byte read per eight rows instead of a branch per row.

sel_apply_validity

def sel_apply_validity(mut out: List[Bool], validity: Span[UInt8], n: Int)

Drop the rows a comparison kernel judged without looking at nulls.

A null satisfies no comparison, so this is an unconditional clear rather than a re-evaluation. An all-valid byte is skipped and an all-null byte clears eight rows at once, which is why carrying nulls costs nothing on the columns that have none and very little on the columns that do.

sel_compact

def sel_compact[dtype: DType](values: Span[UInt8], keep: List[Bool], n: Int, n_keep: Int, mut out: List[UInt8])

Copy the kept elements of a fixed-width buffer into out, in order.

The run-based copy above is the right shape for a range predicate over a sorted column, where a run is thousands of rows and one extend moves it. It is the wrong shape for an equality predicate over a scattered column: payment_type = 2 keeps 15% of the taxi table in runs averaging barely more than one row, and the per-run bookkeeping — a capacity check and a call — then costs about twenty times the eight bytes it moves.

So this loop does not look for runs at all. It stores every element unconditionally and advances the write cursor only for the kept ones, which is branch-free, and it skips sixteen rows at a time when none of them survive, which is what keeps a highly selective filter cheap. The speculative store is why out is allocated one element longer than the answer: the cursor is written before it is advanced, so the last dropped row would otherwise write one past the end. One element, and not one per input row — sizing the buffer to the input instead cost a 26 MB allocation per column per batch and made the most selective query in the taxi suite 2.5x slower than the run-based copy it replaced.

sel_compact_width

def sel_compact_width(values: Span[UInt8], keep: List[Bool], n: Int, n_keep: Int, width: Int, mut out: List[UInt8]) -> Bool

sel_compact for whichever of Arrow's fixed widths this column has.

Returns False for a width with no specialisation — only decimal128 and an odd fixed(n), which keep the run-based copy.

filter_array

def filter_array(a: ArrayData, keep: List[Bool], n_keep: Int) -> ArrayData

The rows of a for which keep is true, in order.

Kept rows are copied in runs, not one at a time: a filter over a million rows is usually either a scattering of long runs (a range predicate over a sorted column) or a dense sample (an equality predicate over a low-cardinality one), and both come out of the same loop — find how far the run of kept rows extends, then move that whole span with one extend. A byte-at-a-time copy of a fixed-width column cost eight bounds checks and eight capacity checks per value.

concat_into

def concat_into(mut dst: ArrayData, src: ArrayData)

Append every row of src to dst; both must have the same type.

The common case — neither side has a null — never touches a validity bitmap: Arrow's "buffer absent" encoding already says every row is valid, and materialising a million all-ones bits only to clear them again was most of what concatenating a scan's batches cost.

append_key

def append_key(mut key: List[UInt8], a: ArrayData, i: Int)

Append element i of a to a canonical key buffer.

A null contributes a single 0 tag, a value a 1 tag and its Arrow bytes. Two rows produce the same key exactly when their values are equal — which is what an equality delete asks — and null matches null, as the spec requires.

hash_key

def hash_key(key: List[UInt8]) -> UInt64

FNV-1a, which is enough to bucket a delete set.

keys_equal

def keys_equal(a: List[UInt8], b: List[UInt8]) -> Bool

Aliases

comptime CMP_NONE = 0
comptime CMP_INT = 1
comptime CMP_FLOAT = 2
comptime CMP_BYTES = 3
comptime SEL_LANES = 16

How many rows one step of a selection-vector kernel handles.

Every loop in this section is bound by the buffer it reads rather than by its arithmetic — a > over a double column at this width reads 128 bytes per step and runs at about 57 GB/s on an M4 — so the width is not tuned per operation. Sixteen is one 128-bit register of List[Bool] bytes, which is what the combine, count and run-scan kernels move.

comptime COMPACT_AT_ONE_IN = 32

Above this selectivity filter_array compacts; below it, it copies runs.

The two shapes cross over between 1% and 5% on the taxi table, measured on the same file set with the same projection: at 5.0% kept the compaction is 57.7 ms against the run copy's 110.8, and at 1.06% kept it is 73.4 against 41.9. What turns over is not the copying — the compaction moves far less — but the scan of the selection vector: the run copy exits its skip loop on the first kept row and is predicted perfectly, while the compaction takes an unpredictable branch once per sixteen rows whether or not anything survives them. One in 32 sits between the two measurements and is deliberately not tuned finer than that, because the curve is flat there.