J On The Beach, Torremolinos Malága; 29-30 October 2026

Rust ate Spark's lunch: DataFusion pipelines without the JVM

Vitthal
Software Engineer - Data Platforms

Rust ate Spark's lunch, Really?

Apache Spark is still at the table.


Let's check the receipts before splitting the bill.


flowchart LR Y["Where is time spent<br/>between the files?"] --> H["Follow the batches<br/>and the plan"] H --> W["Check the answer<br/>Count the cost"] class Y input class H work class W result

Vitthal Mirji

Software Engineer · Data platforms · Stuttgart, Germany

Data Engineering, Scala & functional programming.

The assignment: systems-level automotive POCs in Rust.

Rust was the assignment. DataFusion was the second date.

Would Rust help the data-pipelines we already know?

vitthalmirji.com

GitHubvim89

Choose the engine and the work you will own

Apache Spark

Data-processing engine and platform.

Keep distributed job coordination.

Distributed ETL · Structured Streaming

Apache DataFusion

Embeddable query engine, written in Rust.

Reuse a query engine inside your app.

Data tools · bounded ETL · query services

Both plan and execute queries. Choose by workload and operating needs.

Where does the time go between the disk I/O?

SELECT region, SUM(amount) AS revenue
FROM orders
WHERE order_date = DATE '2026-10-01'
GROUP BY region;

Orders partitioned by order_date · illustrative query

Parquet at both ends. Mind the middle.

flowchart LR P[(Parquet<br/>columnar)] --> S[scan] --> F[filter] --> J[project] J --> X([shuffle]) --> A[hash-aggregate] --> O[(Parquet<br/>columnar)] class P input class S,F,J,A work class X boundary class O result
flowchart LR E[Executor1] --shuffle--> E2[Executor2] --shuffle--> E D[Driver/Client] --> E D --> E2 class D input class E,E2 work

Parquet columns still need decoding

flowchart LR P[(Parquet: id, amount, placed_at, note)] -->|select amount, leaf 1| D[Decode selected pages] D --> B[Arrow RecordBatch: amount] class P input class D work class B result

Projected Parquet reader · shortened repository example

// This fixture stores amount at leaf index 1.
let projection = ProjectionMask::leaves(
    builder.parquet_schema(), [1]
);
let reader = builder
    .with_projection(projection)
    .build()?;
for decoded_batch in reader {
    let batch: RecordBatch = decoded_batch?;
}

Read amount, not every field.

Stored pages become usable Arrow columns.

Mechanism only; not a Parquet-vs-IPC speedup.

Different layouts add handoff work

Spark 4.1.3 physical-plan rule · shortened

// The next operator needs batches
if (!plan.supportsColumnar)
  RowToColumnarExec(
    ensureOutputsRowBased(plan)
  )

// The next operator needs rows
if (plan.supportsColumnar &&
    !plan.supportsRowBased)
  ColumnarToRowExec(
    ensureOutputsColumnar(plan)
  )

Rows are packed into column vectors.

Batch values are read as rows.

Same answer. Different work to reach it.

SELECT COUNT(*), SUM(amount)
FROM orders
WHERE amount > 50.00;
flowchart LR Q[Same query] -->|row path, per row| R1[Read one row] R1 --> R2[Test: amount > 50.00] --> R3[Update: count+1, sum+=amount] Q -->|batch path, once per batch| B[Scan amount values] B --> F[Apply amount > 50.00 to the batch] --> A[Update: count+=matches, sum+=batch total] class Q input class R1,R2,B,F work class R3,A result

Schematic paths. Operators may fuse or mix row and batch work. With GROUP BY/HAVING, the batch path groups once per batch instead of updating one global total — mechanism on slide 180.

Python submits a plan. A UDF moves data.

PySpark 4.1.3 · non-null integer amount · not measured

df.select(
    (col("amount") + 1).alias("next")
)

@udf("long", useArrow=False)
def bump(x): return x + 1
df.select(bump("amount"))

@udf("long", useArrow=True)
def bump_arrow(x): return x + 1
df.select(bump_arrow("amount"))

Built-in expression: no Python row UDF.

Pickle transfer to a Python worker.

Arrow transfer; this UDF still sees Python scalars.

A Python DataFrame API call is not a per-row Python round trip.

One row-only step can need two conversions

SQL → Catalyst logical and physical planning

SELECT amount + 1 AS next_amount
FROM orders
WHERE amount > 50;

Illustrative row loop · non-null BIGINT amount

for (row <- rows) {
  val amount = row.getLong(0)
  if (amount > 50L)
    emit(amount + 1L)
}
flowchart LR C[Columnar batch] --> T[To rows] --> R[Row-only step] R --> U[To batches] --> O[Columnar batch] class C input class T,U boundary class R work class O result

Loop illustrates row processing, not generated Spark code. Bridges depend on the actual plan.

Keep the coordination your job depends on

Plan

SQL parser + DataFrame API

Catalyst + AQE

Run

Task scheduling + shuffle coordination

Fault recovery + cluster-manager integration

Photon (Databricks, C++) and Comet (DataFusion, Rust) accelerate supported execution paths.

Keep Spark’s reach. Change the hot path.

Keep Spark. Accelerate supported work.

Spark plans; the executor hosts the native library

flowchart LR P[Spark plan / AQE] --> T[JVM executor task] T -->|eligible subtree| J[Rust / Datafusion Comet JNI] J --> D[DataFusion] --> A[Arrow batches] class P input class T,D work class J boundary class A result

Historical row-based writer fallback

flowchart LR A[Comet Arrow batch] --> R[ColumnarToRow] --> J[Java writer] class A input class R boundary class J result

The JVM stays. Coverage and conversion boundaries decide the gain.

Why columnar wins, checked at the instruction level

The CPU likes a boring loop

Memory

Adjacent amount values favor cache lines and hardware prefetch.

Compute

One typed operation across the batch, not a row-at-a-time loop over UnsafeRow.

Branch prediction sees regular loop control; WHERE still depends on data.

SIMD naturally gets enabled when the kernel and CPU support it.

The filter query we measured

WHERE amount > 50.00
  AND note IS NOT NULL

Two predicates, one AND.

Arrow kernels · Datum wrapping omitted

let big  = gt(&amount, &fifty)?;
let kept = is_not_null(&note)?;
let mask = and(&big, &kept)?;

One flat pass each, then a bitwise AND of the two masks. No branch per row.

Arrow chooses the layout; Rust checks ownership. Python/JVM can use Arrow too. No promised 4×/8× speedup.

Four instructions. Eight additions. No miracle.

Add one to each value; produce a new Arrow array

let incremented =
    Float64Array::from_iter_values(
        leaf.values().iter()
            .map(|value| value + 1.0)
    );

Recorded release binary · Apple Silicon

fadd.2d  v0, v0, v4
fadd.2d  v1, v1, v4
fadd.2d  v2, v2, v4
fadd.2d  v3, v3, v4

v0–v3: our data, two doubles each · v4: the constant 1.0, broadcast once

Two f64 additions per instruction.

Eight additions. Four vector instructions.

inspected binary Not a cross-platform speedup.

Packing took 414 ms. The Rust phase took 25 ms.

flowchart LR O[Scala Order objects] -->|pack value| A[Arrow vector] A -->|IPC file| R[Rust subprocess] R -->|IPC file| B[Arrow vector] B -->|rebuild| O2[Scala Order objects] class O input class A,B work class R boundary class O2 result

valueColumn is an Arrow IntVector · local name clarified

orders.zipWithIndex.foreach {
  case (order, i) =>
    valueColumn.set(i, order.value)
}
// later, in writeIpc:
writer.writeBatch()

Object → Arrow packing.

Arrow → IPC serialization.

measured 500k objects: 414 ms pack · 25 ms Rust subprocess.

50 columns: 15.30 ms versus 217.98 ms

CaseColumnarRow round tripRatio
1 column0.370 ms1.250 ms3.4×
50 columns15.30 ms217.98 ms14.2×Wide rows
100 columns32.19 ms388.36 ms12.1×
List, 5 elements/row0.18 ms11.58 ms64.6×Lists
5 nesting levels57.33 µs7.31 ms127.5×Deep nesting

measured M2 Max · synthetic, in-process · flat: mean of 5; nested: median of 3.

DataFusion builds rows too — on purpose

Arrow RowConverter API · initialized inputs omitted

// Configured for the key fields.
let keys = converter.convert_columns(&columns)?;

let ordering = keys.row(left)
    .cmp(&keys.row(right));

Sort, group-by and top-k build comparison keys.

Compare encoded keys instead of dispatching comparisons field by field.

Spark bridge: feed a row-based operator. Here: encode comparison keys.

Avoid the row detour: 3-5x here

5M input rows

Spark baseline

flowchart TB A["Parquet scan (batches)"] --> R["ColumnarToRow"] --> F["Filter"] --> W["Parquet writer"] class A input class R,F work class W result

Spark + Comet

flowchart TB N["Native Parquet scan (Arrow batches)"] --> CF["Native filter"] --> NW["Native Parquet writer"] class N input class CF work class NW result

Native path skips Spark rows. Parquet still needs decoding and encoding.

1.66 s → ~0.79 s for this .write.parquet call.

Our plain Parquet: Spark 3.5.5 / Comet 0.16 · local[2] · 6 measured pairs · experimental writer

Parquet under Iceberg: 2.9x unpartitioned · 5.5x partitioned fanout

This write handoff needs no row conversion

Standalone Rust + DataFusion · no Spark or Comet

flowchart LR P[(Parquet)] --> D[DataFusion] --> A[RecordBatch] --> W[ArrowWriter] --> O[(Parquet)] class P input class D,A,W work class O result

Inspected read / filter / write path · shortened

let batches = query.collect().await?;
let batch: RecordBatch =
    concat_batches(&schema, &batches)?;
write_parquet(
    &batch, path, Compression::SNAPPY
)?;

Borrowed batch reaches the Parquet encoder.

Still materializes. Not zero-copy.

Rust checks the borrow lifetime. Parquet still encodes.

Share batch owners. Know when bytes are reused.

DataFusion memory source · local names clarified

let batches = self.batches.clone();
let schema = Arc::clone(&self.schema);

MemoryStream::try_new(
    batches, schema,
    self.projection.clone(),
)?

Clone batch handles, not Order objects.

Separate immutable IPC experiment · shortened

let (batch, reused) = unsafe {
    mmap_decode_single_i32_column(
        &path
    )
};

Pointer check: did IPC reuse the value buffer?

Shared ownership is not zero-copy I/O. Parquet still decodes and encodes.

Keep enough state to combine partial answers

flowchart LR B1[batch A] --> U1[update_batch] --> S1[(min, max)] B2[batch B] --> U2[update_batch] --> S2[(min, max)] S1 -->|state| M[merge_batch] --> R[range] S2 -->|state| M class B1,B2 input class U1,U2,S1,S2,M work class R result

AmountRangeAccumulator::merge_batch · after array downcasts

for min in mins.iter().flatten() {
    self.min = Some(self.min.map_or(
        min, |current| current.min(min)));
}
for max in maxes.iter().flatten() {
    self.max = Some(self.max.map_or(
        max, |current| current.max(max)));
}

Keep the smallest partial minimum.

Keep the largest partial maximum.

[4, 9] + [20, 21] → min 4, max 21 → range 17.

Repository UDAF example; the timed query uses built-in COUNT/SUM.

Out of memory budget? The query can fail.

Standalone DataFusion experiment · Spark users: think memory-related task failure

Recorded DataFusion runOutcome
Hash join with a 40 GB poolFailed: ResourcesExhausted
Rerun preferring sort-merge joinCompleted in 160.13 s; result matched

A memory budget alone does not guarantee completion. Execution strategy matters.

Spark: memory failure can trigger task retries or executor loss; repeated failures can abort the job.

The pool limits tracked allocations. It does not cap all process memory.

Steep start. Earlier feedback.

Pay upfront / New

Ownership, lifetimes, Arrow + async

Catch earlier

Moved values. Conflicting borrows. Wrong ID types.

Familiar: SQL and schema reasoning.

My experience: slower at first; less debugging later.

Count learning, builds and debugging together.

Compiles ≠ correct business logic. Rust ≠ automatic speed.

GitHubcom-vitthalmirji/datacrate

Datacrate

The compiler takes first shift.

Reject bad handoffs before they travel further

flowchart LR M["Modeler / steward<br/>expected fields"] --> D["Datacrate<br/>Rust pipeline toolkit"] E["Data engineer<br/>stage output"] --> D D --> B["Build<br/>mismatched fields fail"] D --> Q["DataFusion<br/>optional query stage"] D --> R["Run<br/>validate Arrow batches"] Q --> R class M,E input class D,Q work class B,R result

Compile-time using Macros : Rust → DataFusion · Scala → Catalyst

Bad declaration or selected SQL: build stops. Bad batch: stop, or quarantine by policy.

The model signs. The batch shows ID.

flowchart LR M["Steward input model<br/>dbt / Erwin subsets"] --> G["Generate Rust shape"] P["Reference Parquet footer"] --> G G --> C["cargo build: artifact ↔ input<br/>stage ↔ stage ↔ sink"] T["Steward target shape<br/>declared Rust type"] --> C U["Upstream pipelines<br/>actual Arrow batches"] --> V["Run: validate each output"] C --> V V -->|"valid"| W["External write<br/>outcome may be unknown"] V -->|"invalid"| X["Stop by default<br/>or quarantine batch"] class M,P,T,U input class G,C,V work class W,X boundary

Separate SQL POCs: Rust → DataFusion · Scala → Catalyst

Build checks shapes and selected SQL. Runtime: stop or quarantine bad batches.

Change id to text; the build stops.

A steward changes the declared type

- name: id
  data_type: varchar
  tests: [not_null]

Was bigint. Still non-null.

Producer: text. Consumer: integer.

Rust consumer expects id: i64

const _: () = SchemaConforms::<
    DbtSourceOfTruth,
    StewardInputModel,
    ExactOrdered,
>::CHECK;

The incompatible field stops the build.

FAILS pipeline expects i64; model now says String.

Model mismatch: build fails. Bad batch: stop or quarantine.

Catch the stale accessor before query planning

#[derive(Contract, Connector)]
struct Order {
    id: i64,
    amount: i32,
}

Order::col_quantity()

Renamed field: quantity → amount.

The declaration now has amount.

The stale method does not exist.

E0599 · caught by Rust, before query planning.

Reject incompatible declared connections at build

ContractPipeline::source::<Bronze, _>(
    read
)
    .transform::<Silver, Gold>(enrich)
    .sink::<Gold>(write)
    .build()?
    .run()?;

Bronze: id: i64

Silver: id: i64

Different names. Same declared shape.

Only a matching sink can attach.

No sink? No build method.

SQL is valid. The handoff is not.

Rust macro · DataFusion planner

checked_sql! {
    output: Planned,
    input: Raw,
    required: Gold,
    source: "snapshot.json",
    sql: "SELECT * FROM raw",
}

Inferred output: id, amount.

Required by the sink

#[derive(Contract)]
struct Gold {
    id: i64,
    total: i64,
}

Requires total. Build fails.

SELECT id, amount AS total FROM raw

Matching output → build passes.

Compile time: captured schema + SQL output. Runtime: actual batches + values.

Same idea. Use the engine that runs it.

Two macros · different guarantees

flowchart LR subgraph DF["Rust / DataFusion"] A["Saved Arrow<br/>schema"] --> M["Rust macro<br/>DataFusion planner"] M --> C{"Output fits<br/>sink contract?"} C -->|"yes"| P["Build passes"] end subgraph SP["Scala / Spark"] D["Declared<br/>catalog"] --> S["Scala macro<br/>Spark Catalyst"] S --> Q["Checked<br/>SQL string"] Q --> R["Runtime<br/>Spark + Comet"] end M -->|"invalid SQL"| F["Build fails"] C -->|"no"| F S -->|"invalid SQL"| F class A,D input class M,S,Q work class C,F boundary class P,R result

Catalyst checks SQL. The Rust prototype also checks the sink contract.

Bad SQL? Let the build take the hit.

Scala 3 macro · Spark Catalyst · existing Parquet

val orders = table("""
  CREATE TABLE orders (id BIGINT,
    amount DECIMAL(10,2),
    placed_at TIMESTAMP_NTZ, note STRING)
""")
val domain = catalog(orders)
val checked = domain.sql("""
  SELECT COUNT(*) AS order_count,
         SUM(amount) AS total_amount
  FROM orders WHERE amount > 50.00
""")
spark.sql(checked)

Catalyst during compilation

Invalid column, simplified plan:

'Project ['missing_column]
+- RelationV2[id] orders

Unresolved → build fails.

Comet scan/filter/partial aggregate; Spark exchange/final aggregate.

Declared catalog only · Spark 3.5.5 / Comet 0.16 · correctness check, not a speed result.

Clean is a type. Not a pinky promise.

Tested prototype · separate from ContractPipeline

Wrong return

fn clean(raw: RawBatch)
    -> Result<CleanBatch, DemoError>
{
    Ok(raw)
}

Clean and check

fn clean(raw: RawBatch)
    -> Result<CleanBatch, DemoError>
{
    raw.clean()
}

Compile error

Expected CleanBatch. Found RawBatch.

Data quality checks

Trim spaces. Reject missing or blank names.

Compile time: declared schemas and return types.

Runtime: actual batch schemas and data quality.

Wrong schema? Stop before Parquet decoding.

Tested local-file prototype · footer check runs at runtime

let source =
    ParquetSource::open(input, batch_size)?;

for raw in source {
    let clean = raw?.clean()?;
    sink(clean)?;
}

Check footer schema first.

Mismatch? Stop before decoding.

Decode → check → clean.

Trim spaces. Reject missing or blank names.

Only checked batches reach the sink.

Schema mismatch: no row decoding. Data quality failure: stop before sink.

Starting code still needs a safe multi-batch sink

flowchart LR D[dbt schema declarations] --> G[scaffold-cli] G --> R[Rust contract pipeline] class D input class G work class R result

The generated example collects eagerly and overwrites the same sink per batch.

What cannot move to compilation?

digest ^= hash_row(row);

Two equal hashes: 7 ^ 7 == 0.

Lose two identical rows? XOR cannot see it.

digest = digest
    .wrapping_add(hash_row(row));

PublicationUnknown

An error is not proof nothing happened.

Move the provable failures earlier. Name the remaining uncertainty.

Start with one job. Keep the fallback. Switch only after agreed checks pass.

What the measurements actually say

Three engines matched the independent answer

SELECT COUNT(*) AS order_count,
       SUM(amount) AS total_amount
FROM orders WHERE amount > 50.00;

correctness check 5M synthetic rows · one Parquet file · local, 21 Sep

Spark = Spark+Comet = DataFusion

2,500,000 orders · total_amount 188,750,000.00

Same Spark job: 45.25 s → 13.94 s

Matched Spark command timings · 91 GB suite

QuerySpark+Comet
Revenue aggregate45.25 s (2)13.94 s (2)
Group-by region153.879 s (1)60.603 s (1)
Fraud filter101.495 s (1)54.129 s (1)

Aggregate: 31.31 s less elapsed time, about 69%.

Join: no Spark or Comet run.

Separate clocks · context, not a matched ranking

QueryDataFusionBallista
Revenue aggregate5.84 s (2)4.30 s (2)
Order ⋈ shipment160.13 s (1)443.76 s (1)
Group-by region12.97 s (2)19.52 s (2)
Fraud filter14.17 s (2)29.80 s (2)

(n) = runs. Spark/Comet include startup; DataFusion is query-only; Ballista is client elapsed.

Spark/Comet: 7.748 GiB VM. Rust: 64 GiB host. Local M2 Max, not a cloud-bill measurement.

Comet: scale and clock change the answer

AggregateSparkSpark+Comet
91 GB · full command45.25 s (2)13.94 s (2)
5M rows · full command5.48 s (3)5.85 s (3)
5M rows · Spark “Time taken”0.97 s (2)0.71 s (2)

measured Separate runs, different timing boundaries. (n) = runs.

14 GB: too close. 91 GB: DataFusion ahead.

Same filter and aggregateResult
14 GBToo close to call across runs
91 GBDataFusion ~2.85× faster (2 runs each)

measured Same query · local M2 Max runs.

818 → 12,268 lookups/s, with specialized rebinding

// Reuse scan config + cached Parquet metadata.
let decoder_pushdown = false;
let plan = rebind(
    file_scan, decoder_pushdown, order_id
)?;
let mut output =
    execute_stream(plan, ctx.task_ctx())?;

Reuse: file/schema, reader settings, cached Parquet metadata.

Rebuild: filter + scan state for each order ID.

PathMedian reads/sGain
Original SQL8181×
Tuned fresh SQL5,1396.28×
Experimental rebind12,26815.00×
flowchart TD G["39 row groups"] -->|"min/max skips 38"| K["1 group remains"] K -->|"page index"| P["2,048 scan rows"] P -->|"FilterExec"| R["1 result"] class G input class K,P work class R result

Rust: scan APIs + shared ownership.
Both measured paths use DataFusion.

5M rows · 1 warm local immutable file · 1 client · M2 Max · 5 rounds × 128 holdout keys/path

Complete lookup; setup excluded. 15× includes specialized physical rebinding, LZ4 layout and mimalloc.

Move one job. Keep a way back.

1. Keep Spark. Pilot Comet.

Same pipeline · check results, native coverage and complete job time.

2. Port one bounded step to DataFusion.

Reuse SQL where semantics match · rebuild reader, writer and runtime wiring.

3. Run beside Spark before switching.

Check types, nulls, decimals, retries and downstream output. Keep the fallback.

Query implementations, Process, patterns and Production migration steps.

My chart cannot decide your migration

Does it hold on your cloud and cluster?

How does it behave under sustained load?

What does the complete pipeline cost to run?

Count engineering and recovery in the decision

Engineering

Compile times, async, FFI

Guarantee boundary

Declared connections in one compilation

Files and other processes still need runtime checks.

Keep the answer. Lose unnecessary work.

Less job time: 45.25 → 13.94 seconds with Comet.

91 GB aggregate · full command · 2 runs per engine.

More lookups: 818 → 12,268 per second.

Warm local 5M-row file · 1 client · 5 holdout rounds. Setup excluded. Specialized rebinding + tuned layout + mimalloc; not a general 15× gain.

Earlier rejection: incompatible declarations at build; bad Arrow batches before downstream callbacks.

Datacrate checks; separate from DataFusion's measured gains.

Try Comet within Spark first. Reach for DataFusion in one bounded application only if that ceiling isn't enough.

One job. Same answer. Keep the fallback. Earn the switch.

GitHubvim89

Questions? And thank you.

Vitthal Mirji
vitthalmirji.com

Which workload merits an engine change —
and which boundary should be explicit
regardless of engine?

So — would you move it?

One job. A fallback. A decision you can reverse.

PathNext step
Comet within SparkPilot supported workloads
Standalone DataFusionEvaluate one bounded job
DataFusion PythonEvaluate query authoring
Hybrid hot stepMeasure boundary costs
One box replaces a clusterUntested
Ballista replacementNot established

Appendix: the pain point, and the realistic migration path

The pain tuning doesn't remove

GC pause frequency/duration is tunable. The category is not.

Spark under pressure: a silent executor OOM-kill, then the whole stage retries.

DataFusion under pressure: an unbounded pool swap-thrashed 4.5+ hours, no output. Bounded, it instead fails one query, exactly: "HashJoinInput[5] ... 197.9 KB remain ... pool_size: 40.0 GB"

One query rejected cleanly beats one process killed silently.

The realistic path, in order

  1. Flip the Comet flag. Zero rewrite. 2.5×–2.6× measured, config only.
  2. Python cohort pilots DataFusion's own Python API on a query they already own. No Rust.
  3. Only if Comet's ceiling isn't enough, scope a hybrid: Spark orchestrates, DataFusion owns one hot step. Budget months, not weeks.
  4. Don't plan a full rewrite or a distributed rollout off evidence this thin.

Evidence-driven order, not dogma: measure which step hurts before deciding how much to replace.

Appendix: reject bad visible values, not hidden ones

flowchart LR B[Arrow batch] --> V[Contract validator] V --> A[Active values only] class B input class V work class A result

Skip children of null parents and values outside a slice.

Hidden values can be validly irrelevant.

Appendix: use the counter that answers your question

flowchart LR P[Plan intent] --> E[Execute] E --> M[Runtime metrics] class P input class E work class M result
MetricMeaning
Decoder rows prunedRows, not network bytes
Engine bytes scannedEngine accounting
Range requestsAttempted calls, including errors
Unexposed wire bytesUnavailable, not zero
Admitted Arrow bytesNot process RSS

Appendix: a small answer can require large work

ExtensionExample
TableProviderCustom table
ExecutionPlanPhysical execution
OptimizerRuleTest policy
Async UDFDeliberate yield

A result cap does not bound upstream work.

Appendix: decode cost comes before kernel speed

EncodingRetainedDecode
Dictionary · 100 distinct2,000,640 B1.07 ms
Dictionary · all distinct4,000,240 B1.01 ms
Run-end · 10,000 per run576 B3.56 ms
Run-end · 2 per run2,000,176 B12.95 ms

wrapping_add_simd takes Int32Array only. Every row above decodes first.

Appendix: the budget returns itself

impl Drop for Reservation {
    fn drop(&mut self) {
        if let Some((control, bytes)) = &self.0 {
            let mut state = control.lock();
            state.bytes -= bytes;
            control.changed.notify_all();
        }
    }
}

The charge is tied to the batch's lifetime, not to a release() call.

A sink changes which methods exist

Source and sink only · callbacks omitted

let pipeline: ContractPipeline<'_, Bronze> =
    ContractPipeline::source::<Bronze, _>(read);

let sink: ContractSink<'_> =
    pipeline.sink::<Bronze>(write);

let ready = sink.build()?;
ready.run()?;

Before sink: no build method.

After sink: build is available.