Arrow-native columnar execution¶
An optional fast path in which data travels as an Apache Arrow
RecordBatchcarried alongside the row vector onBatch<T>, letting opt-in operators run vectorised column work with no per-row materialisation.
Overview¶
clink is Arrow-native: every data frame on the operator-to-operator wire is an Arrow IPC stream, and state snapshots are Arrow IPC blobs. On top of that wire format there is a columnar execution path. A Batch<T> can carry an arrow::RecordBatch sidecar in addition to (or instead of) its std::vector<Record<T>>. A producer that sets the sidecar and leaves the row vector empty hands a columnar-aware operator the typed Arrow columns directly; that operator reads the buffers and emits another columnar batch, so no Record<T> is ever built. A row operator downstream is unaffected: the first time it touches a row accessor, the sidecar is lazily decoded into rows once.
The path is strictly opt-in and degrades cleanly. The default for every operator is row-based and byte-for-byte unchanged. Importantly, columnar processing only fires when a batch is born columnar from a columnar-native source (for example a Parquet source carrying a typed schema). A row-form source, such as the default JSON-from-Kafka decode, produces row batches and takes the row path. This page is precise about that boundary.
Where it lives¶
| Area | File | Role |
|---|---|---|
| Sidecar on the batch | include/clink/core/record.hpp |
Batch<T> columnar surface: is_columnar(), arrow(), lazy materialize_rows_(), with_arrow() |
| Wire seam | include/clink/core/arrow_batcher.hpp |
ArrowBatcher<T> (schema/build/parse), built-in batchers, binary fallback, IPC serialise/deserialise |
| Generic user-type batcher | include/clink/core/columnar_batcher.hpp |
CLINK_ARROW_FIELDS + make_columnar_arrow_batcher<T>() for plain or nested structs (recurses into nested structs, std::vector, std::map, std::optional) |
| Operator hook | include/clink/operators/operator_base.hpp |
supports_columnar() / process_columnar() virtuals and their contract |
| Terminal sink hook | include/clink/operators/operator_base.hpp, detail::try_sink_columnar() in include/clink/runtime/dag.hpp |
Sink::supports_columnar() / on_data_columnar(), so a columnar chain is not undone at the last hop |
| Runner dispatch | include/clink/runtime/dag.hpp |
detail::try_process_columnar(), called from serial and parallel runners |
| Wire framing | include/clink/runtime/network/wire.hpp, include/clink/runtime/network/network_channel.hpp |
Kind::ArrowBatch, send/recv of the IPC payload |
| Columnar shuffle | include/clink/runtime/subtask_emitter.hpp |
partition_columnar_() keeps each per-subtask sub-batch columnar |
| Example operators / sources | include/clink/operators/columnar_filter_operator.hpp, include/clink/operators/columnar_vector_source.hpp |
Reference int64 columnar filter and source |
| SQL Row columnar | include/clink/sql/row_columnar_batcher.hpp, src/sql/install.cpp |
make_row_columnar_arrow_batcher, make_row_wire_batcher, columnar SQL operators |
| Born-columnar output | include/clink/sql/row_columnar_output.hpp, enable_columnar_output() in src/sql/physical_plan.cpp |
RowColumnarOutput builds an operator's output as typed Arrow columns; the planner decides when that pays |
| Parquet source | include/clink/connectors/parquet_source.hpp |
Produces columnar batches via the batcher's parse |
How it works¶
The sidecar on Batch<T>¶
Batch<T> (in record.hpp) is two representations of the same data. It holds a std::vector<Record<T>> and, optionally, a std::shared_ptr<arrow::RecordBatch> plus a row count and a MaterializeFn closure:
| Field | Type | Notes |
|---|---|---|
records_ |
vector<Record<T>> |
row form; may be empty |
arrow_ |
shared_ptr<RecordBatch> |
columnar sidecar; may be null |
arrow_rows_ |
size_t |
row count, answered without decoding |
materialize_ |
fn(const RecordBatch&) -> vector<Record<T>> |
lazy decoder |
Two constructors distinguish the cases. The row constructor takes a vector<Record<T>>. The columnar constructor takes (shared_ptr<RecordBatch>, rows, MaterializeFn) and leaves records_ empty until needed. The surface:
is_columnar()returns true iffarrow_ != nullptr. A columnar-aware operator tests this to opt into the fast path.arrow()hands back theRecordBatch.size()/empty()answer fromarrow_rows_without decoding any rows.- Any row accessor (
operator[],begin/end,records()) callsmaterialize_rows_(), which runsmaterialize_(*arrow_)exactly once, fillsrecords_, and increments a process-widedetail::batch_materialize_counter(). That counter is the test/benchmark hook proving a path did zero row decode; a pure columnar chain never increments it. It is also published on/metricsasclink_batch_materializations_total, sampled at scrape time so the hot path keeps costing one relaxed increment - see "Verifying the path actually fires" below, because this is the number that catches a chain being undone. with_arrow(rb, rows)builds a sibling columnar batch over a differentRecordBatchreusing this batch'smaterialize_closure. The shuffle and columnar operators use it to emit derived columnar batches (a filter result, a per-subtask gather) without naming the row type's batcher.
A pure-row batch (arrow_ == null) behaves exactly as before: every row API and every existing operator keep working unchanged.
What the JSON decode costs, and what was tried¶
json_string_to_row_columnar is the largest single stage on a Kafka JSON pipeline
(34% of worker CPU on nexmark q0 after the July 2026 fixes). Its on-demand walk is
~126 ns per record on a 6-column nexmark bid row, measured in isolation with
clink_hotpath_bench decode minus clink_hotpath_bench buildonly - the latter being
the Batch<std::string> assembly the runner does, which is 22 ns per record and not
the bridge's to answer for.
What moved it, in order of what it was worth:
| change | effect |
|---|---|
compare keys with an inline byte loop instead of calling memcmp |
+11.6% |
escaped_key() instead of unescaped_key() - no unescape pass, no copy into simdjson's string buffer |
+5% |
| split the column lookup's cold scan out of line so the hot path inlines | +2% |
| total | +19% |
Two things were tried and REJECTED on measurement, recorded so they are not retried blind:
iterate_many()over the concatenated batch. This is simdjson's API for streams of small documents and it amortisesstage1(the SIMD structural prescan, 15% of decode self time) from once per record to once per batch. It measured slower - 5.85M rec/s against 6.26M - because streaming mode carries its own per-document bookkeeping that costs more than thestage1it saves at ~120-byte documents. Tried withbatch_sizeat both 1MB and the exact buffer length.- Caching
arrow::Type::typeand the decimal scale per column to avoid reaching througheffin the field loop. Measured neutral;arrow::DataType::id()is already an inline member read. Kept only because it makesappend_ondemand's signature honest about what it needs.
Two costs are near the floor for this shape: stage1 at 15% is what per-record
iterate() charges, and the 9% memmove is copying each line into a buffer with
SIMDJSON_PADDING slack, which cannot be avoided without assuming readable bytes past
a std::string's end.
The positional hint is the one hazard here. The decoder remembers which column each
field POSITION matched last time and tries that first, which is what makes the lookup
one comparison on a homogeneous stream. The guess is always verified against the name,
and a miss falls through to the scan - but a future edit that trusted it would silently
file values under the wrong column. The suite could not catch that until
ReorderedSameTypedFieldsLandInTheRightColumns was added: every other field-order test
uses columns of DIFFERENT types, so a mis-filed value fails the type check, bails the
batch to the row fallback and produces the right answer anyway. That test uses two
int64 columns, where nothing else can save it, and it was confirmed to fail against an
unverified-guess mutation.
The keyed shuffle's split¶
gather_columnar_by_target (include/clink/runtime/columnar_split.hpp) partitions one
columnar batch into a sub-batch per destination subtask without materialising rows. On
the two-node rig it was 19.3% of all worker CPU on nexmark q12, the largest non-library
compute item in the engine and twice the cost of the window aggregation it feeds.
It builds one INDEX list per target in a single pass and calls arrow::compute::Take
once per target. The earlier shape built a boolean mask per target and called
Filter per target, which appended n_out booleans for every row (one of them true) and
then walked the whole batch again for each destination - so at parallelism 4 it scanned
the batch four times to move each row once. Measured with
benchmarks/clink_shuffle_split_bench, 1024-row batches:
| targets | mask + Filter | index + Take | |
|---|---|---|---|
| 2 | 20.9 ns/row | 14.4 ns/row | -31% |
| 4 | 34.8 ns/row | 21.8 ns/row | -37% |
| 8 | 60.0 ns/row | 37.0 ns/row | -38% |
| 16 | 108.0 ns/row | 68.6 ns/row | -36% |
Both still grow with the destination count, because each destination needs its own gather and its own output allocation; the change is a consistent constant factor, not a change in asymptotics.
Order within a destination is preserved by construction: indices are appended in row order
and Take emits in index order, which is what Filter did.
This function routes a keyed shuffle, so a mistake in it is silent - a misrouted
record does not throw, it lands in the wrong subtask's keyed state and corrupts an
aggregate. It had no direct tests until tests/test_columnar_split.cpp, which compares it
against a reference split computed from the same inputs (content and order per
destination, not just counts), and covers skew, out-of-range targets, refusal on a
row-only batch, and the no-materialisation property. Those tests pass against BOTH the old
and new implementations, and three of them fail against a routing shifted by one slot.
Verifying the path actually fires¶
A columnar chain that is quietly undone costs MORE than never having been columnar: the pipeline builds the Arrow batch and then builds the rows as well. Nothing about the query's results changes, so it will not show up as a test failure - only as CPU.
Scrape clink_batch_materializations_total on a running job. On a pipeline that is
columnar end to end it stays at zero; a count that tracks the batch count means some
stage is touching a row accessor. Two real instances, both found this way:
- A duplicate factory registration replaced the counting
BlackholeRowSinkwith a per-recordFunctionSink, so nexmark q0 materialised every batch (14,377 of 14,377) and a sink whose body iscount_ += batch.size()cost 0.37 us per record - 16% of all worker CPU. Registration is latest-wins, and nothing distinguished the two implementations; identifying it took a stack trace from insidematerialize_rows_().OperatorRegistrynow logs whenever a registration replaces an existing factory. - The planner's
ingests_columnargate insrc/sql/physical_plan.cpplisted"window_row", which is not an operator type at all, while the three that do buildWindowRowOpwere absent - so producers in front of a tumbling window were held back to row output. That gate must be kept in step withsupports_columnar()ininstall.cppby hand; there is no compile-time link between them, which is exactly why it drifted.
A related habit worth keeping: an opt-in that cannot be observed will eventually be
found to have done nothing. clink_node --version and the node startup log both report
the process allocator for the same reason (CLINK_WITH_JEMALLOC), reading the version
from the loaded library rather than from a build variable.
clink_dataplane_local_hits_total and clink_dataplane_socket_fallbacks_total answer
the adjacent question for the wire: whether a shuffle edge between two subtasks in the
same process hands the element over in memory or pays to encode, socket and decode it.
On a single-worker job the fallback count should be zero.
ArrowBatcher<T>: the wire seam¶
ArrowBatcher<T> (in arrow_batcher.hpp) is the conversion seam between Batch<T> and arrow::RecordBatch. Like Codec<T>, it is a set of std::function closures rather than a class hierarchy:
schema() -> shared_ptr<arrow::Schema>build(const Batch<T>&) -> shared_ptr<arrow::RecordBatch>parse(const arrow::RecordBatch&) -> optional<Batch<T>>
Batchers are stored on the TypeRegistry next to Codec<T> and threaded through bridge and channel construction. Every built-in batcher prepends a shared nullable event_time:int64 column (arrow_event_time_field()); a null cell means the record had no event time.
There are three tiers of batcher:
-
Built-in columnar batchers for concrete shapes, hand-written one per shape.
int64_arrow_batcher()emits{event_time:int64(null), value:int64};string_arrow_batcher()emits{event_time, value:utf8}. There are alsoint32/uint32/uint64primitive batchers (via thedetail::primitive_arrow_batchertemplate), and keyed shapesint64_keyed_arrow_batcher()({event_time, key:int64, value:int64}) andstring_keyed_arrow_batcher()({event_time, key:utf8, value:int64}) that back the keyed-aggregation paths. -
The generic struct batcher,
make_columnar_arrow_batcher<T>()incolumnar_batcher.hpp. A user describes a struct once at namespace scope withCLINK_ARROW_FIELDS(Trade, id, symbol, px); the generator folds over that field list at compile time to synthesise schema/build/parse, emitting one typed Arrow column per field ({event_time, id:int64, symbol:utf8, px:float64}). Leaf field types are mapped byArrowColumnTraits(fixed-width integers 8 to 64 bit signed and unsigned,float,double,bool,std::string). Composite fields recurse: astd::optional<E>becomes a nullable column, astd::vector<E>an Arrowlist, astd::map<K,V>an Arrowmap, and a nested struct that also has aCLINK_ARROW_FIELDSdescription an Arrowstruct- to arbitrary depth (build/readdrive the nestedarrow::MakeBuildertree viadetail::arrow_datatype/append_value/read_value). An unmapped leaf is a readable compile error. This makes the wire and Parquet layout genuinely columnar and externally typed; it does not auto-vectorise operators (an operator that understands the schema is still bespoke). -
The universal binary fallback,
make_default_arrow_batcher<T>(Codec<T>). Its schema is{event_time:int64(null), value_bytes:binary}; thevalue_bytescolumn carries each record'sCodec<T>::encodeoutput verbatim, andparsedecodes it viaCodec<T>::decode. No columnar win, but every type rides unified Arrow IPC framing automatically.
Selection between tiers 2 and 3 is automatic. make_auto_arrow_batcher<T>(codec) returns the generated typed batcher when HasArrowFields<T> (the CLINK_ARROW_FIELDS opt-in), else the binary fallback. The codec-only registration paths (TypeRegistry::register_typed, PluginRegistry::register_type) and the codec-only network-channel constructors route through it, so describing a struct with CLINK_ARROW_FIELDS is sufficient to get typed columns through the ordinary registration API - the explicit register_columnar_typed / register_columnar_type helpers are only a statement of intent (and a compile error on an undescribed type). An explicit ArrowBatcher<T> passed to the 3-arg registration overrides the choice.
Opting an operator into columnar processing¶
Operator<In, Out> (in operator_base.hpp) exposes two virtuals, both no-op by default:
[[nodiscard]] virtual bool supports_columnar() const noexcept { return false; }
virtual bool process_columnar(const StreamElement<In>&, Emitter<Out>&) { return false; }
The dispatch lives in detail::try_process_columnar() (dag.hpp), called by every operator runner so the serial single-input path and the parallel wire-stage path cannot drift. The fast path is taken iff:
supports_columnar() && element.is_data() && element.as_data().is_columnar()
&& process_columnar(element, out) == true
The order matters. process_columnar returns false to mean "I cannot take this batch columnar, fall back to process()", which the runner then does. The hard contract: an operator may return false only before emitting anything, because a false return triggers a re-run on process(). Once it emits, it owns the batch and must return true. Operators decide up front, from schema and type checks, whether they can handle the batch.
A typical process_columnar body (see ColumnarFilterOperator):
process_columnar(element, out):
if not data or not columnar: return false # fall back
rb = element.as_data().arrow()
if schema/columns not as expected: return false # fall back, no emit yet
... vectorised column work over rb ...
out.emit_data(Batch<Out>{ derived_rb, n, materialize_ }) # emit columnar
return true
flowchart TD
IN["incoming StreamElement<In>"] --> TPC["try_process_columnar(op, el, out)"]
TPC --> C1{"CLINK_DISABLE_COLUMNAR=1?"}
C1 -->|yes| ROW["op->process(el, out) (row path)"]
C1 -->|no| C2{"supports_columnar()?"}
C2 -->|no| ROW
C2 -->|yes| C3{"el.as_data().is_columnar()?"}
C3 -->|"no (row-form batch)"| ROW
C3 -->|yes| PC["op->process_columnar(el, out)"]
PC --> R{"return value?"}
R -->|"false (before any emit)"| ROW
R -->|"true"| DONE["done (columnar, zero row decode)"]
The terminal hook: columnar into a sink¶
Sink<In> carries the same pair, supports_columnar() / on_data_columnar(), dispatched by detail::try_sink_columnar() from both sink runners under the same CLINK_DISABLE_COLUMNAR switch (detail::columnar_enabled()). Without it a columnar chain is undone at the last hop: on_data takes a Batch<In> and the first row accessor materialises the whole sidecar.
That mattered more than it sounds. The discard sink was a FunctionSink<Row> iterating the batch to call a callback with an empty body, so every record became a name-keyed FlatMap first. Measured on the q0 chain, materialisation cost +0.54s on top of a 1.02s decode. BlackholeRowSink now answers the hook and counts via batch.size(), which the sidecar answers without decoding.
The hook also unblocks the producer side: enable_columnar_output() treats a columnar-capable sink as a columnar consumer, lifting the rule that forbade the operator in front of a sink from emitting born-columnar. Note what this is not - a production win. blackhole exists to measure the engine without sink cost; a real Kafka or Parquet sink must still serialise per record. The seam is what a Parquet sink would use to write a RecordBatch directly.
Watermarks and barriers never go through process_columnar; the runner routes those to process() (or the operator's on_watermark / on_barrier), so a columnar operator still implements process() for control elements and for the row-only fallback.
Reference example: the int64 columnar filter¶
ColumnarFilterOperator (columnar_filter_operator.hpp) keeps rows where value >= threshold. On a columnar batch with the {event_time, value:int64} schema it builds a boolean selection mask over the value buffer with a dense, autovectorisable scan, then runs Arrow's registered filter selection kernel to gather the passing rows of both columns into a new RecordBatch (so event_time rides along), and emits that as a columnar Batch<int64> with zero row materialisation. On a row-only batch or an unexpected schema it returns false and falls back to the identical row predicate in process().
A note this operator records candidly: the comparison mask is hand-rolled rather than using a greater_equal compute kernel, because in this Arrow package the default registry exposes only the core selection kernels (filter is present) and the public arrow::compute::Initialize() that would register the arithmetic and comparison kernels is not an exported symbol. So the selection (gather) is vectorised by Arrow; the comparison is a plain scan.
Producing columnar batches at the source¶
A columnar-native source builds an Arrow RecordBatch directly and emits a columnar Batch<T> over it. ColumnarVectorSource (columnar_vector_source.hpp) builds an {event_time(all null), value:int64} batch from a vector of int64 and emits Batch<int64>{rb, n, materialize_} where materialize_ wraps int64_arrow_batcher().parse.
ParquetSource<T> (parquet_source.hpp) reads each Arrow RecordBatch from the file's RecordBatchReader and calls batcher_.parse(*rb). When the configured batcher's parse returns a columnar batch (sidecar set), the source emits columnar data. For the SQL Row channel the batcher is make_row_columnar_arrow_batcher, whose parse builds a Batch<Row> carrying the typed RecordBatch as a sidecar with rows materialised lazily, so a Parquet-sourced query rides the columnar operator fast paths instead of decoding every row at the source.
The read is column-pruned (schema-on-read): when the batcher's schema names a subset of the file's columns, the source resolves each by name (types must match exactly), reads only those columns from the file, and remaps to the batcher's order if the projected reader yields file order. The SQL planner's projection-pushdown hint (projected_columns on the source op) narrows the parquet_row_source factory's batcher to exactly the queried columns, so a narrow query over a wide Parquet file skips the unread columns entirely; the same mechanism lets a declared-narrower table read a wider file. A batcher column missing from the file errors naming the column. Connector details live in ../connectors/README.md.
Columnar across the wire and the shuffle¶
The wire carries data as Kind::ArrowBatch = 7 (wire.hpp). On send (network_channel.hpp), every data frame goes through batcher_.build(batch) and arrow_batch_to_ipc(); on receive, arrow_batch_from_ipc() decodes the IPC blob, the schema is checked against the receiver's registered batcher (mismatch rejects the frame), and batcher_.parse() produces the Batch<T>. Whether columnar survives the wire depends on the batcher:
- A built-in or struct columnar batcher's
parserebuilds a columnar batch, so columnar survives a network hop into the receiving operator. - The SQL Row channel uses
make_row_wire_batcher(row_columnar_batcher.hpp). Itsbuildships a columnar batch's typedRecordBatchverbatim (no materialise, no re-encode) and falls back to per-record JSON in avalue_bytes:binarycolumn for a row-form batch. Itsparserecognises the binary-fallback layout and JSON-decodes it, but hands a typed columnar frame downstream as a columnarBatch<Row>(zero data copy, same column arrays) so the receiving filter/project/aggregate/window chain rides the columnar fast path after the shuffle. Itsschemais intentionally empty so the receiver skips the fixed-schema equality gate, since the columnar schema varies per edge; frames are validated structurally insideparse. The per-column typed set is int64 / int32 / float / double / bool / decimal128 / string, pluslist<float32>(a VECTOR_SEARCH embedding, carried as a contiguous Arrow list rather than a stringified JSON array); any other column type falls back to a utf8 stringification.
The keyed shuffle preserves columnar form too. SubtaskEmitter (subtask_emitter.hpp) forwards a columnar batch unchanged on a single output. On a hash partition it calls partition_columnar_(): it does one typed decode to obtain the per-row values the partitioner needs, computes each row's target subtask with the same partitioner as the row path (so routing and state placement are identical), and gathers each subtask's rows with Arrow's filter kernel into a sub-RecordBatch wrapped via batch.with_arrow(...). It falls back to a row split if the columnar gather cannot proceed. The only observable difference between the columnar and row split is the sub-batch shape.
The cluster deployment's keyed edges (a chain's Hash output group routed through Dag::add_split and per-peer network bridges) partition columnar batches without any row decode at all. add_split accepts an optional columnar_split hook; the three Hash wiring sites (attach_typed_group_output in runner_helpers.hpp, its type-erased mirror in type_registry.hpp, and the legacy typed-chain path in worker.cpp) build it with make_keyed_columnar_split (runtime/columnar_split.hpp) whenever the channel registered a columnar key extractor (KeyExtractorRegistry::register_columnar_extractor / PluginRegistry::register_columnar_key_extractor). The SQL Row channel registers one that reads the __key int64 column row_compute_key appended to the sidecar, so the per-row partition keys come straight from Arrow memory; the key-group routing (key_group_for_key -> subtask_for_key_group) is byte-identical to the row selector, which is a correctness requirement rather than an optimisation - a stream can interleave columnar and row-form batches (per-batch decode fallback) and both carriers must agree on key -> subtask ownership. Any batch that cannot be keyed columnar (no sidecar, __key absent) falls back to the per-record row split.
SQL columnar operators¶
The SQL runtime (src/sql/install.cpp) implements process_columnar on several Row operators so a columnar-sourced query stays columnar end-to-end:
AggregateRowOp(GROUP BY) reads only the columns it needs (group keys, each aggregate's input column, the changelog marker) straight from the sidecar into a narrow row, or runs a fully vectorised group fold forCOUNT/ integerSUM/ integerAVGover a pure-append batch that builds no per-recordRowat all. It declaressupports_columnar()true only when the synchronous in-memory state path is active; with async/disaggregated state it routes throughprocess_asyncinstead (see ./async-state-execution.md).- A columnar windowed fold, the columnar row filter / project, and
ColumnarRowComputeKeyOp(which appends a__keycolumn to the sidecar so the keyed shuffle stays columnar) round out the SQL columnar surface. json_string_to_row_columnaris a bridge operator: it decodes JSON and attaches a typed Arrow sidecar built from the table'sschema_columns, emitting a columnar batch only when every record round-trips exactly (otherwise byte-equivalent row-form decode, with an adaptive damper: after 8 consecutive fallback batches it stops attempting the columnar parse and probes every 64th batch, so a systematically unfaithful stream pays ~1.6% wasted parse instead of up to 2x). The SQL planner emits it BY DEFAULT for a Kafka JSON table;columnar_decode='false'opts out to the plain row bridge.
It walks each document once with simdjson on-demand, dispatching each field straight to its resolved column's builder - no intermediate JsonObject, no per-column search of a sorted vector, and one grown-once padded buffer reused across records. DECIMAL columns take their exact digits from raw_json_token() during that same walk, so there is no second scan of the line to recover them. Anything the walk cannot decide it refuses, falling back to the older DOM path and then to rows; both are already proved against the row decode, so a refused case is slow rather than wrong.
This is worth knowing when reading old commentary: the DOM path it replaced claimed to decode "directly into typed Arrow column builders" while in fact building a full generic JsonObject per record and binary-searching it once per column. Profiling ranked memcmp at 974 self-weight, from_dom at 383 and pair-vector growth at 382, against simdjson's own stage1+stage2 at 410 - roughly 10% of decode. The parser was never the cost; the DOM around it was. Measured at 1.96M -> 4.88M rec/s on 4M nexmark bid lines.
The columnar-capable types are int64, int32, double, float, decimal128, bool and utf8. Float and decimal were excluded until D4 inc4 for a reason worth keeping in mind when adding a type: the faithfulness contract is measured against the row decode this operator falls back to, and that decode was not schema-aware, so it kept every JSON number as a full-precision double and never produced a dec-string - a columnar float32 truncation or exact dec-string was therefore a visible difference. Both bridges now build their decoder from the same schema_columns (row_json_text_format_for_columns), so declared FLOAT columns round to float precision and declared DECIMAL columns are ingested exactly and quantised to scale on either carrier. Adding a further type means making both sides agree first, not just widening the switch.
Born-columnar operator output¶
Everything above is about ingest: a producer attaches a sidecar and a capable operator reads columns instead of rows. An operator's own OUTPUT was row form regardless, so an operator that produced rows paid for a name-keyed FlatMap<string, JsonValue> per emitted row: one string key copy per column per row, a pair-vector allocation per row, and the malloc churn both imply.
RowColumnarOutput (include/clink/sql/row_columnar_output.hpp) closes that. An operator appends its output values straight into one typed Arrow builder per declared output column and emits the finished RecordBatch as a columnar Batch<Row>; the column names live in the schema, once, instead of in every row, and no Row is built at all. Two properties make it safe:
- The per-cell conversion is
row_columnar_detail::append_json_cell, the same function the row-batch converter (build_column) uses. The two carriers therefore produce identical Arrow cells by construction rather than by a test noticing they drifted. - The emitted batch carries
row_materialize_fn(), so a row consumer downstream decodes it lazily exactly once and sees the rows it would have received anyway. Correctness never depends on the consumer being columnar.
Whether emitting columnar is faster does depend on the consumer, because a row consumer pays the Arrow build and then the materialise. So the decision belongs to the planner, which can see the chain, and not to the operator, which cannot. enable_columnar_output() in src/sql/physical_plan.cpp promotes a producer's stashed typed output schema to a live columnar_output param only when every consumer of that producer ingests columnar; a producer feeding a sink directly is left row form, since no sink connector reads Arrow batches today.
Two operators emit born-columnar output:
EquiJoinRowOp(append-only INNER joins). An OUTER join marks every row with a changelog kind, which the declared output schema has no column for, so it stays row form by construction. If a marked row reaches an INNER join anyway,bail_columnar_to_rows_converts what has been accumulated back to rows in emission order and drops the carrier for the rest of the operator's life: a wrong planner gate costs speed, never correctness.WindowRowOp's watermark-driven fire. The declared schema must name every field a fired pane produces, including the window boundsfinalize_window_always emits, or the operator refuses it and stays row form - a column list missing a produced field would silently drop it. A pane whose group column was absent from its records also bails, because the row path omits such a column while a fixed column list would emit it as null, and those are different rows. The timer and async fire paths are row form.
Emission size decides whether the carrier pays at all. A RecordBatch costs a fixed amount per batch and saves per row, so ColumnarOutputDamper learns from the previous emissions: it backs off after four consecutive emissions below kColumnarOutputMinRows (64) and re-probes every 64th batch, mirroring the source-side decode damper. Measured on the 4M-row embedded shapes: an equi-join under a filter emits thousands of rows per batch and takes -11% CPU; a windowed fire emits a handful of panes per watermark and lost 12% when it built a batch for them, which is what the damper exists to prevent (it returns that shape to parity). CLINK_DISABLE_COLUMNAR_OUTPUT=1 keeps every operator's output row form, the A/B lever for this axis.
Snapshots and Parquet¶
The same Arrow IPC machinery underpins state snapshots. The in-memory, changelog, and sharded in-memory backends serialise their keyed state as Arrow IPC streams (see ./state-and-backends.md and ./checkpointing.md); schema-evolution metadata is carried in the IPC schema metadata. Parquet sinks and sources reuse the ArrowBatcher<T> seam directly, so files written by clink are externally readable column-by-column (pyarrow, duckdb, polars) and a columnar batch is written without a row round-trip. Parquet connector configuration is documented in ../connectors/README.md.
Key types and APIs¶
| Type / function | File | Responsibility |
|---|---|---|
Batch<T> columnar surface |
core/record.hpp |
is_columnar(), arrow(), with_arrow(), lazy materialize_rows_(), columnar constructor |
detail::batch_materialize_counter() |
core/record.hpp |
Process-wide count of lazy sidecar decodes; test/benchmark proof of a no-decode path |
ArrowBatcher<T> |
core/arrow_batcher.hpp |
schema / build / parse closures bridging Batch<T> and arrow::RecordBatch |
int64_arrow_batcher, string_arrow_batcher, primitive/keyed batchers |
core/arrow_batcher.hpp |
Built-in typed columnar schemas |
make_default_arrow_batcher<T> |
core/arrow_batcher.hpp |
Binary value_bytes fallback wrapping Codec<T> |
CLINK_ARROW_FIELDS, make_columnar_arrow_batcher<T> |
core/columnar_batcher.hpp |
Per-field typed columns for a described plain struct |
make_auto_arrow_batcher<T> |
core/columnar_batcher.hpp |
Auto-selects typed (if HasArrowFields<T>) vs binary; used by the codec-only register/channel paths |
arrow_batch_to_ipc, arrow_batch_from_ipc |
core/arrow_batcher.hpp |
IPC serialise/deserialise used by the network channel |
supports_columnar(), process_columnar() |
operators/operator_base.hpp |
The opt-in operator hook and its no-double-emit contract |
detail::try_process_columnar() |
runtime/dag.hpp |
Shared runner dispatch into the fast path |
partition_columnar_() |
runtime/subtask_emitter.hpp |
Columnar-preserving keyed shuffle |
make_row_columnar_arrow_batcher, make_row_wire_batcher |
sql/row_columnar_batcher.hpp |
Schema-driven typed Row batcher and the sidecar-preserving Row wire batcher |
append_json_cell, make_cell_builder |
sql/row_columnar_batcher.hpp |
The single Row-cell to Arrow-cell mapping both carriers share |
RowColumnarOutput, ColumnarOutputDamper, columnar_row_batch |
sql/row_columnar_output.hpp |
Born-columnar operator output and its emission-size damper |
ColumnarFilterOperator, ColumnarVectorSource |
operators/columnar_filter_operator.hpp, operators/columnar_vector_source.hpp |
Reference columnar operator and source |
Configuration and knobs¶
| Knob | Default | Effect |
|---|---|---|
CLINK_HAS_ARROW (CMake define on clink_core) |
defined (=1) |
Arrow is a required dependency; the whole columnar surface compiles in. Set unconditionally in CMakeLists.txt. |
find_package(Arrow REQUIRED) / CLINK_PINNED_ARROW_VERSION |
required, version-pinned | Build fails if the resolved Arrow version differs from scripts/versions.env. |
CLINK_DISABLE_COLUMNAR (env var) |
unset | When =1, try_process_columnar() always returns false so every operator takes process(). Diagnostic/benchmark A/B lever only; it can only make the engine slower and has no correctness effect, since process() is the authoritative path the columnar path must already match. |
CLINK_DISABLE_COLUMNAR_OUTPUT (env var) |
unset | When =1, the planner enables no born-columnar operator output, so every operator emits row form. Diagnostic/benchmark A/B lever for the emission axis; like CLINK_DISABLE_COLUMNAR it can only make the engine slower and has no correctness effect. |
columnar_output (operator param) |
set by the planner | A producer's typed output schema. Written by enable_columnar_output() only when every consumer of that producer ingests columnar; absent means row-form output. Not a user-facing option. |
columnar_decode (SQL table WITH-option) |
on | The SQL planner emits json_string_to_row_columnar (attaches a sidecar) for a Kafka JSON table by default; set 'false' to opt out to the row-form json_string_to_row. |
schema_columns (connector/source param) |
none | The typed column schema the Row columnar batchers (make_row_columnar_arrow_batcher, Parquet Row source/sink) are built from. |
Guarantees and caveats¶
- Columnar is opt-in and transparent. A pure-row batch and every existing operator behave byte-for-byte as before. A columnar batch consumed by a row operator decodes lazily once.
- The fast path fires only when a batch is born columnar. A columnar batch must originate from a columnar-native source: a Parquet (or other Arrow-native file) source carrying a typed schema, the in-tree
ColumnarVectorSource, or the SQLjson_string_to_row_columnarbridge - the default for Kafka JSON tables, so the common Kafka pipeline is born columnar for capable schemas; acolumnar_decode='false'table (or an incapable schema / unfaithful batch) takes the row path. The Row wire batcher preserves columnar across a shuffle but does not manufacture columnar from rows: a row-form batch crossing a task boundary is shipped as binary JSON and arrives row-form. Since the born-columnar output work, an append-only INNER join and a windowed fire are also origins of columnar batches when the planner has enabled it. process_columnarmust not emit before returningfalse. Afalsereturn re-runsprocess(), so emitting and then returningfalsewould double-emit. This is an explicit operator contract, not an engine safety net.- Coverage is partial. The built-in columnar operators target specific shapes (for example the int64 filter is int64-only, a single
>=). The SQL columnar operators cover GROUP BY ingest and the vectorised fold forCOUNT/ integerSUM/ integerAVG, a columnar window fold, columnar filter/project, and key computation; other operators run row-based.AggregateRowOpdisables columnar when its async/disaggregated state path is active. - Arrow compute kernel availability is limited in this package. Only the core selection kernels (notably
filter) are registered; arithmetic and comparison kernels are not auto-registered becausearrow::compute::Initialize()is not exported. Columnar operators that need a comparison hand-roll a dense scan and use Arrow only for the gather. A true comparison-kernel mask is unblocked only by an Arrow build that exportsInitialize()or auto-registers the kernels. - The generic struct batcher (
make_columnar_arrow_batcher<T>) makes the wire and Parquet layout columnar and externally typed, but it does not vectorise operators. An operator that processes that struct's columns is still hand-written. The field enumeration viaCLINK_ARROW_FIELDSis a stand-in until a compiler offers C++26 static reflection; the trait table and generator stay as they are when that lands.
Related¶
- ./architecture.md - Arrow-native component stack and where columnar sits.
- ./operator-model.md - the operator interface that
supports_columnar/process_columnarextend. - ./network-stack.md - the Arrow IPC wire,
Kind::ArrowBatch, and the shuffle. - ./checkpointing.md and ./state-and-backends.md - Arrow IPC state snapshots.
- ./async-state-execution.md - the async path that takes over from
process_columnarfor disaggregated GROUP BY state. - ./sql-frontend.md - how the planner wires columnar Row operators and the
columnar_decodeoption. - ../connectors/README.md - Parquet and other Arrow-native source/sink connectors.