Apache Spark is still at the table.
Let's check the receipts before splitting the bill.
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.
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.
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.
SELECT COUNT(*), SUM(amount)
FROM orders
WHERE amount > 50.00;
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.
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.
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)
}
Loop illustrates row processing, not generated Spark code. Bridges depend on the actual plan.
Spark plans; the executor hosts the native library
Historical row-based writer fallback
The JVM stays. Coverage and conversion boundaries decide the gain.
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(¬e)?;
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.
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.
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.
| Case | Columnar | Row round trip | Ratio |
|---|---|---|---|
| 1 column | 0.370 ms | 1.250 ms | 3.4× |
| 50 columns | 15.30 ms | 217.98 ms | 14.2×Wide rows |
| 100 columns | 32.19 ms | 388.36 ms | 12.1× |
| List, 5 elements/row | 0.18 ms | 11.58 ms | 64.6×Lists |
| 5 nesting levels | 57.33 µs | 7.31 ms | 127.5×Deep nesting |
measured M2 Max · synthetic, in-process · flat: mean of 5; nested: median of 3.
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.
5M input rows
Spark baseline
Spark + Comet
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
Standalone Rust + DataFusion · no Spark or Comet
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.
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.
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.
Standalone DataFusion experiment · Spark users: think memory-related task failure
| Recorded DataFusion run | Outcome |
|---|---|
| Hash join with a 40 GB pool | Failed: ResourcesExhausted |
| Rerun preferring sort-merge join | Completed 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.
Compile-time using Macros : Rust → DataFusion · Scala → Catalyst
Bad declaration or selected SQL: build stops. Bad batch: stop, or quarantine by policy.
Separate SQL POCs: Rust → DataFusion · Scala → Catalyst
Build checks shapes and selected SQL. Runtime: stop or quarantine bad batches.
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.
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.
Two macros · different guarantees
Catalyst checks SQL. The Rust prototype also checks the sink contract.
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.
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.
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.
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.
Matched Spark command timings · 91 GB suite
| Query | Spark | +Comet |
|---|---|---|
| Revenue aggregate | 45.25 s (2) | 13.94 s (2) |
| Group-by region | 153.879 s (1) | 60.603 s (1) |
| Fraud filter | 101.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
| Query | DataFusion | Ballista |
|---|---|---|
| Revenue aggregate | 5.84 s (2) | 4.30 s (2) |
| Order ⋈ shipment | 160.13 s (1) | 443.76 s (1) |
| Group-by region | 12.97 s (2) | 19.52 s (2) |
| Fraud filter | 14.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.
// 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.
| Path | Median reads/s | Gain |
|---|---|---|
| Original SQL | 818 | 1× |
| Tuned fresh SQL | 5,139 | 6.28× |
| Experimental rebind | 12,268 | 15.00× |
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.
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.
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.
Vitthal Mirji
vitthalmirji.com
Which workload merits an engine change —
and which boundary should be explicit
regardless of engine?
| Path | Next step |
|---|---|
| Comet within Spark | Pilot supported workloads |
| Standalone DataFusion | Evaluate one bounded job |
| DataFusion Python | Evaluate query authoring |
| Hybrid hot step | Measure boundary costs |
| One box replaces a cluster | Untested |
| Ballista replacement | Not established |
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.
Evidence-driven order, not dogma: measure which step hurts before deciding how much to replace.
| Metric | Meaning |
|---|---|
| Decoder rows pruned | Rows, not network bytes |
| Engine bytes scanned | Engine accounting |
| Range requests | Attempted calls, including errors |
| Unexposed wire bytes | Unavailable, not zero |
| Admitted Arrow bytes | Not process RSS |
| Encoding | Retained | Decode |
|---|---|---|
| Dictionary · 100 distinct | 2,000,640 B | 1.07 ms |
| Dictionary · all distinct | 4,000,240 B | 1.01 ms |
| Run-end · 10,000 per run | 576 B | 3.56 ms |
| Run-end · 2 per run | 2,000,176 B | 12.95 ms |
wrapping_add_simd takes Int32Array only. Every row above decodes first.
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.