Skip to content

Add list_transform expression#8663

Draft
mhk197 wants to merge 2 commits into
developfrom
mk/list-transform
Draft

Add list_transform expression#8663
mhk197 wants to merge 2 commits into
developfrom
mk/list-transform

Conversation

@mhk197

@mhk197 mhk197 commented Jul 7, 2026

Copy link
Copy Markdown
Contributor

Adds list_transform — DuckDB's list_transform(l, lambda x: x + 1) — as a Vortex expression, the first higher-order list function. The design doc (research into DuckDB/ClickHouse/Spark/Velox/Polars/DataFusion lambda implementations plus the Vortex design rationale) is included at vortex-array/src/scalar_fn/fns/list_transform/design.md.

Design

Two decisions carry the feature (see design.md §4 for the trade-off tables):

  • The lambda body is an ordinary Expression stored in the fn's Options, not a child. Children are evaluated eagerly at row cardinality, but the body must run at element cardinality against a scope that only exists inside execute. Options already support this (Expression is Eq + Hash, and options serde has the session hook needed to deserialize a nested expression). This is DuckDB's bind-data approach; scope-independent properties (fallibility, serialization, body optimization) delegate explicitly.
  • root() is the lambda parameter. Within the body, root() refers to the element — no variable nodes or name resolution. x -> x.a + 1 is checked_add(get_item("a", root()), lit(1)). Nested transforms rebind root naturally; captures and the (x, i) index form have a forward path via a struct scope (design.md §8) with no v1 changes.

Semantics & execution

  • Offsets (or the fixed size) and list validity pass through untouched; only the elements child is rewritten via a deferred apply(body) — dictionary-encoded elements evaluate the body over dictionary values only.
  • Null list → null; empty list → empty; null elements flow through the body's null semantics. FixedSizeList structure is preserved (no cast to List).
  • A fallible body is only evaluated over elements some visible list references: ListView gaps/overlaps and ranges under null lists (which Arrow permits to hold arbitrary values) are gathered out first. Known limitation: FSL strides under null rows can't be dropped (documented).

Rewrites

  • Identity: list_transform(l, x -> x)l (typed, gated on the input being list-typed so type errors still surface).
  • Fusion: list_transform(list_transform(l, f), g)list_transform(l, g[root := f]), collapsing whole chains in one rewrite, skipped for bodies with >1 root reference (substitution would duplicate f per occurrence).
  • list_length(list_transform(l, f))list_length(l) — the composition reads offsets only.
  • The body is optimized against its element scope from the typed simplify hook (it's invisible to the optimizer's children traversal by design).

The second commit addresses findings from an 8-angle adversarial review (each finding independently verified with repro tests): the fallible-body/unreferenced-elements gather path, a zero-row constant guard, the fusion chain/occurrence guards, type-error preservation, and assorted cleanups.

mhk197 added 2 commits July 6, 2026 14:22
Adds a ListTransform scalar fn implementing DuckDB-style list_transform(l,
lambda x: ...) as a Vortex expression. The lambda body is an ordinary
Expression stored in the fn's options, evaluated with the list's elements
array as its root scope (root() inside the body refers to the element).
Execution rewraps the list structure around a deferred apply() of the body
to the elements child, so offsets and list validity pass through untouched
and no element values are computed eagerly.

Includes:
- identity (body == root) and fusion (transform-of-transform) rewrites in
  simplify_untyped, plus list_length(list_transform(l, f)) -> list_length(l)
- proto serde of the options-embedded body via the session registry
- fallibility delegation to the body, since generic tree walks cannot see
  options-embedded expressions
- design doc under scalar_fn/fns/list_transform/design.md

Signed-off-by: Matt Katz <mhkatz97@gmail.com>
Correctness:
- A fallible body is no longer evaluated over element positions that no
  visible list references. ListView inputs (gaps, overlaps) and Lists with
  null rows (whose ranges Arrow permits to hold arbitrary values) now gather
  exactly the referenced elements into a compact List before applying the
  body. Infallible bodies keep the zero-cost structure-preserving path.
- Zero-row batches short-circuit before the constant fast path, which could
  eagerly evaluate a fallible body over elements no row observes.
- Fusion now collapses a whole transform chain in one rewrite (deep chains
  previously exhausted the optimizer's per-node iteration budget and errored
  on valid expressions) and skips bodies with more than one root reference
  (substitution previously duplicated the inner body per occurrence,
  compounding exponentially across fusion steps).
- The identity rewrite moved to the typed simplify hook, gated on the input
  actually being list-typed, so ill-typed expressions still fail
  return_dtype instead of being rewritten into well-typed non-list ones.
- serialize() now honors the vtable contract by returning Ok(None) when the
  body contains a non-serializable fn, via a new
  Expression::try_serialize_proto helper.

Also:
- The typed simplify hook optimizes the body against its element scope,
  since the options-embedded body is invisible to the optimizer's children
  traversal.
- is_fallible uses a short-circuiting walk instead of a full label_tree
  labeling per call.
- fmt_sql reuses the options Display impl so the lambda rendering lives in
  one place.
- Regression tests for all of the above, including one pinning that the
  null-scalar branch protects fallible bodies from a null FixedSizeList
  constant's default-filled placeholder elements.
- Test hygiene: imports at the top of the tests module, vortex_bail instead
  of panic, removed an assertion subsumed by assert_arrays_eq.

Signed-off-by: Matt Katz <mhkatz97@gmail.com>
@codspeed-hq

codspeed-hq Bot commented Jul 7, 2026

Copy link
Copy Markdown

Merging this PR will improve performance by 12.85%

⚠️ Different runtime environments detected

Some benchmarks with significant performance changes were compared across different runtime environments,
which may affect the accuracy of the results.

Open the report in CodSpeed to investigate

⚡ 10 improved benchmarks
❌ 1 regressed benchmark
✅ 1596 untouched benchmarks
⏩ 42 skipped benchmarks1

Warning

Please fix the performance issues or acknowledge them on CodSpeed.

Performance Changes

Mode Benchmark BASE HEAD Efficiency
Simulation chunked_varbinview_canonical_into[(1000, 10)] 154.7 µs 190.2 µs -18.68%
Simulation bitwise_not_vortex_buffer_mut[128] 273.6 ns 215.3 ns +27.1%
Simulation chunked_varbinview_opt_canonical_into[(1000, 10)] 206.2 µs 168.8 µs +22.11%
Simulation bitwise_not_vortex_buffer_mut[1024] 333.9 ns 275.6 ns +21.17%
Simulation chunked_varbinview_opt_into_canonical[(1000, 10)] 219.9 µs 182.8 µs +20.24%
Simulation bitwise_not_vortex_buffer_mut[2048] 427.8 ns 369.4 ns +15.79%
Simulation encode_varbin[(1000, 8)] 161.2 µs 142.2 µs +13.39%
Simulation encode_varbin[(1000, 4)] 159.4 µs 141.3 µs +12.82%
Simulation encode_varbin[(1000, 32)] 164.9 µs 147.3 µs +11.92%
Simulation chunked_varbinview_opt_canonical_into[(100, 100)] 341 µs 305.1 µs +11.79%
Simulation encode_varbin[(1000, 2)] 156.3 µs 140.8 µs +10.98%

Tip

Investigate this regression by commenting @codspeedbot fix this regression on this PR, or directly use the CodSpeed MCP with your agent.


Comparing mk/list-transform (c915742) with develop (a6e3d3e)

Open in CodSpeed

Footnotes

  1. 42 benchmarks were skipped, so the baseline results were used instead. If they were deleted from the codebase, click here and archive them to remove them from the performance reports.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant