Skip to content

Assess native (Rust) code generation for fused expression evaluation #6485

Open

Description

@comphead

What is the problem the feature request solves?

Comet evaluates native expressions through DataFusion's PhysicalExpr tree. Each node processes the whole batch and materializes its result, so a + b * c writes a temporary array for b * c, and a filter copies the columns it keeps before the projection reads them. Spark avoids this kind of overhead with whole-stage codegen. Comet has JVM-side codegen (the Janino dispatcher from #4267), but nothing that fuses native expression evaluation.

This issue is to assess whether generating native kernels for expression trees is worth pursuing, and if so, how.

Describe the potential solution

Generate one kernel per fusable expression subtree (or filter plus projection) at native plan time, compile it, and call it from a PhysicalExpr. Options to evaluate:

Supporting pieces: run the interpreted path until a kernel is ready, cache compiled kernels (per process, per node, per cluster), pass literals as parameters so one kernel serves many queries, and let DataFusion evaluate subexpressions the generator does not support.

Additional context

Proof of concept. Hand-written kernels in the shape a generator would emit: an extern "C" function over raw Arrow buffers that writes into buffers the host allocates, called from a PhysicalExpr through a function pointer. Baselines are built the way Comet plans these expressions today (BinaryExpr, CaseExpr, filter_record_batch, arrow's checked kernels for ANSI). 8192-row batches with 10% nulls, DataFusion 55.1, arrow 59.3, Apple Silicon, single thread. Every fused result matched DataFusion's.

Expression DataFusion Fused Speedup
a * (1 - b) * (1 + c) + d (double) 10.1 µs 3.4 µs 2.9-3.0x
(x + y) * (z - w) + x * 3 (long, legacy) 13.3 µs 5.7 µs 1.9-2.3x
same, ANSI 33.8 µs 14.0 µs 2.2-2.4x
CASE WHEN ... THEN ... ELSE ... END, 1 branch 94.3 µs 8.3 µs 11.4x
CASE, 3 branches 150.7 µs 12.4 µs 12.2x
filter, then project 2 columns 21.2 µs 11.3 µs 1.9-2.1x

Times are per batch. Speedups are the range over three runs.

Most of the CASE gap does not need codegen. A plain vectorized select over 64 rows at a time brings the two CASE cases to 13.3 µs and 30.2 µs, 5 to 7x faster than CaseExpr, but that is only valid when no branch can fail. Fusion adds another 1.6 to 2.4x on top. Arrow's zip is slow on this input (63 µs and 122 µs).

Compile cost. rustc -O compiled a dependency-free generated kernel in 0.07 to 0.17 s (up to 200 fused expressions) and one using arrow in 0.14 to 0.46 s. At about 0.1 s per compile, an arithmetic kernel pays for itself only after roughly 40 to 120 million rows, so compiled kernels have to be cached and reused. A Rust toolchain is about 1.4 GB, which matters for executor images.

To assess:

  • Measure how much time real queries (TPC-H, TPC-DS) spend in expression evaluation, to bound the end-to-end gain.
  • Compare compile strategies (rustc on executors, rustc on the driver, JIT, pre-generated kernels) on latency, deployment, and code quality.
  • Decide how to handle branches that can fail (ANSI arithmetic, casts), where a vectorized plan has to filter per branch but a fused kernel can decide row by row.
  • Define how generated code is tested for Spark parity, for example differential testing against the interpreted path.
  • Consider improvements that need no codegen first: a faster primitive select in arrow's zip, and a CaseExpr fast path for branches that cannot fail.

Activity

  1. self-assigned this
    on Oct 1, 2026
  2. comphead commented on Nov 6, 2026

    @comphead
    ContributorAuthor

    End-to-end numbers for fusion. A Spark-level benchmark ran each expression on Spark, on Comet, on Comet with the PoC kernels swapped in by the native planner, and on Comet with the whole tree in the JVM codegen dispatcher. Inputs were top-level columns, a struct, and a struct of structs, and every arm's rows were checked against Spark. M5 Max, Spark 4.1, one task, 1M rows:

    • Without fusion, Comet is 2 to 3x faster than Spark on these expressions, and 1.2 to 1.7x on a 32-term weighted sum.
    • The fused kernels cut the projection's own time 1.6 to 2.6x. Expressions are only 4 to 16% of Comet's wall time, so wall time drops 0 to 5% (a * (1 - b) * (1 + c) + d: 23.7 → 23.6 ms). At 128K rows and below, the difference is noise.
    • CaseWhenExpr already evaluates branches that cannot fail over the whole batch, so over columns a fused CASE is 1.6x faster than main at the expression level, not 11x.
    • Over struct fields, CASE and AND took the lazy path because GetStructField was missing from the infallible check. perf: evaluate CASE, AND, OR and IN over struct fields for the whole batch #7538 fixes that without codegen. CASE goes 22.4 → 16.4 ms, as fast as the fused kernel (16.9 ms), and a filter then project 35.3 → 18.0 ms.
    • The PoC's CASE kernels and its vectorized select pick each row's value with a branch, so they are fast only while the same batch repeats. Rotating over 64 batches, case_1_branch goes from 5.9 to 21.2 µs per 8192 rows and the select from 8.3 to 22.1 µs. A branch-free kernel stays at 4.0 µs.
    • The JVM dispatcher is 3 to 8x slower than native unfused evaluation at the expression level, and up to 24x on wide struct trees, which it splits into many kernels.

    At 0.6 to 1.8 ns saved per row, a 0.1 s rustc compile pays off after 55M to 170M rows, so runtime compilation needs kernels cached across queries, or kernels generated at build time.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions