feat: add mandatory demand mask to scalar_fn execution#8854
feat: add mandatory demand mask to scalar_fn execution#8854joseph-isaacs wants to merge 8 commits into
Conversation
Introduce ExecutionArgs::demand(), a required Mask identifying the rows whose output values the caller will observe. Undemanded rows are don't-care: any well-typed value is acceptable, and fallible scalar fns must not raise a domain error attributable solely to undemanded rows. Demand never compacts: results keep the full row count. VecExecutionArgs::new now takes the demand mask explicitly (asserting its length matches the row count) and VecExecutionArgs::all constructs the all-demanded case. All construction sites pass AllTrue except the dynamic comparison fn, which forwards its incoming demand to its Binary delegate. TypedScalarFnInstance::execute asserts mask length at the execution choke point. No producer constructs a non-trivial mask yet; demand pushdown is deferred until all fallible fns honor the contract. Signed-off-by: Joe Isaacs <joe.isaacs@live.co.uk> Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GhAvQmWLipNHBKjrZhxnKv
…legate forwarding Signed-off-by: Joe Isaacs <joe.isaacs@live.co.uk> Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GhAvQmWLipNHBKjrZhxnKv
Polar Signals Profiling ResultsLatest Run
Previous Runs (7)
Powered by Polar Signals Cloud |
Benchmarks: Vortex queries 📖Verdict: No clear signal (low confidence) How to read Verdict and Engines
datafusion / vortex-file-compressed (0.969x ➖, 0↑ 0↓)
datafusion / parquet (0.890x ✅, 1↑ 0↓)
duckdb / vortex-file-compressed (0.923x ➖, 0↑ 0↓)
duckdb / parquet (0.984x ➖, 0↑ 0↓)
No file size changes detected. |
Intersect the demand mask with the valid-lanes mask in the Binary checked arithmetic family (add/sub/mul/div) so domain errors at undemanded lanes are suppressed and one-pass kernels skip undemanded work. The all-true demand path is unchanged. Signed-off-by: Joe Isaacs <joe.isaacs@live.co.uk> Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GhAvQmWLipNHBKjrZhxnKv
…e mask buffer Signed-off-by: Joe Isaacs <joe.isaacs@live.co.uk> Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GhAvQmWLipNHBKjrZhxnKv
The operand split (PrimitiveOperand) and the validity-and-demand merge (care_lanes) both happen before the kernels run, so the three shape dispatchers and twelve shape-specific kernel wrappers reduce to a single checked_op generic over a lane accessor. Slices and constants are bound outside the closures so codegen matches the old direct-slice kernels (verified with the binary_ops divan bench). Signed-off-by: Joe Isaacs <joe.isaacs@live.co.uk> Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GhAvQmWLipNHBKjrZhxnKv
…ne dispatch" This reverts commit 32c5287897299552896518135705421b9de85df9, restoring the per-shape checked kernels. Signed-off-by: Joe Isaacs <joe.isaacs@live.co.uk> Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GhAvQmWLipNHBKjrZhxnKv
c0a843b to
a97717c
Compare
ExecutionArgs::demand now returns an ArrayRef (Bool NonNullable) instead of a vortex-mask Mask, so demand can use any array encoding: constant, run-end, or a lazy expression. Typed dispatch asserts both the length and the dtype invariant. Consumers materialize once at the kernel boundary via child_to_validity + Validity::execute_mask, which keeps the constant all-true case O(1) (verified with the binary_ops bench). Signed-off-by: Joe Isaacs <joe.isaacs@live.co.uk> Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GhAvQmWLipNHBKjrZhxnKv
Merging this PR will improve performance by 25.67%
Performance Changes
Tip Curious why this is faster? Comment Comparing Footnotes
|
Rationale for this change
This PR introduces the demand-mask API for scalar functions and migrates the first fallible fn family (checked numeric arithmetic) to honor it. Nothing produces a non-all-true mask in production yet, so there is no behavior change for existing pipelines.
A demand mask is an execution-time non-nullable boolean array (length =
row_count) telling a scalar fn which rows' output values the caller will observe. It is the non-compacting analogue of DataFusion'sPhysicalExpr::evaluate_selectionmask and Velox'sSelectivityVector. Because it is anArrayRef, demand can use any encoding — constant, run-end, or a lazy expression — and consumers materialize it once at the kernel boundary viachild_to_validity+Validity::execute_mask(O(1) for the constant all-true case). The contract, documented onExecutionArgs::demandandScalarFnVTable::execute:row_countrows — demand never compacts.ScalarFnVTable::is_fallible) must not raise a domain error attributable solely to undemanded rows. This is the same speculative-evaluation hazard theis_fallibledocs already describe for dict-values pushdown — demand is the general fix for it.Demand is not part of a scalar fn's identity: it does not affect
return_dtype,Eq/Hash, serialization, or simplification. It is purely an input toexecute.How do we ensure fns actually respect the mask? (semantic break, not a type break)
A fn that ignores the mask still compiles, returns correct values at every demanded position, and only misbehaves by erroring on rows nobody asked about. That failure mode only becomes reachable once something produces a non-all-true mask, so enforcement is staged:
dynamicfn only forwards). Demand pushdown (scan filter-conjunct loop, projection-under-filter,execute_parentkernels) is deliberately deferred to its own track, gated on step 3 completing. Until then the mask is semantically inert and there is no window for wrong behavior.compute/conformance/) asserting, per fn:filter(execute(args, demand), demand) == filter(execute(args, all_true), demand)whenever the all-true run succeeds; and for fallible fns, inputs producing domain errors only at undemanded positions must succeed. A fn "respects demand" iff it passes this harness — that is the definition, not code inspection.MIGRATED(has conformance coverage) orPENDING(known not yet migrated). Adding a new fn without choosing fails CI, and the pushdown PR's precondition becomes mechanical:PENDINGcontains no fallible fns.TypedScalarFnInstance::execute(length and non-nullable-bool dtype) catch malformed demand universally; the harness additionally fuzzes random demand masks per fn so a migrated fn that later regresses fails loudly in CI rather than mis-erroring in a scan.ScalarFnVTable::executeandExecutionArgs::demand— the two things every new fn author reads — including the fallible/infallible split.The one standing exception is the FFI vtable (
scalar_fn/foreign.rs): it cannot honor demand until the C ABI grows a demand channel, so it stays inPENDINGand future pushdown must never pass a non-trivial mask through foreign fns.Follow-up PRs
Core API + checked numeric arithmetic (this PR)cast,like,between,list_*,variant_get,json_to_variant, geo, tensor, row encode/size) — same pattern: keep the bulk kernel, consult demand only on the error pathcase_when,zip,fill_null) refining demand for their branchesExecutionResultsteps,execute_parentkernel demand transformation, then scan pushdownWhat changes are included in this PR?
Core API:
ExecutionArgs::demand() -> &ArrayRef— new required method returning a non-nullable boolean array (deliberately notOption: every execution has a demand, possibly all-true; any array encoding is allowed).VecExecutionArgs::new(inputs, row_count, demand)takes the demand array explicitly (panics on length or dtype violation);VecExecutionArgs::all(inputs, row_count)supplies a constant-true array.ScalarFnArrayexecution,scalar_at, validity execution, and thevortex-rowencoder pass constant-true;fns/dynamic.rsforwards its incoming demand to itsBinarydelegate so demand is never silently dropped mid-tree.TypedScalarFnInstance::execute(the choke point every execution flows through) asserts the demand length andBool(NonNullable)dtype alongside the existing row-count/dtype assertions.First fallible-fn migration — checked numeric arithmetic (
Binaryadd/sub/mul/div):execute_checked_typedmaterializes demand once (child_to_validity+execute_mask) and intersects it with the valid-lanes mask intocare_lanes(the lanes that must produce correct values and whose errors are observable), driving the existing kernels with it — invalid and undemanded lanes were already zero-filled/skipped by the mask machinery, so error suppression and work-skipping fall out of the same intersection. The constant-constant fold errors only when at least one lane is both valid and demanded.child_to_validity's constant fast path (AllValid→Mask::AllTrue, O(1)) and is behaviorally identical to the previous code (verified with thebinary_opsdivan bench).Tests: demand plumbing through type-erased dispatch (probe vtable), length- and nullability-invariant rejection at construction and execution,
dynamicdelegate forwarding, and 9 checked-arithmetic cases covering all operand shapes (array-array, array-constant, constant-array, constant-constant), both kernel styles (split add/sub/mul and one-pass integer div), errors suppressed at undemanded/null lanes, and errors still raised at demanded lanes.Checks run:
cargo build -p vortex-array -p vortex-rowcargo nextest run -p vortex-array(3054 passed),cargo nextest run -p vortex-row(23 passed)cargo clippy --all-targets --all-features -p vortex-array -p vortex-rowcargo +nightly fmt --all,git diff --checkcargo bench --bench binary_opsbefore/after (all-true demand path at baseline)What APIs are changed? Are there any user-facing changes?
Breaking for implementors/constructors of
ExecutionArgsonly:ExecutionArgsgains a requireddemand()method returning&ArrayRef— external implementors must provide one (holding aConstantArray::new(true, row_count)is the drop-in upgrade).VecExecutionArgs::newgains ademand: ArrayRefparameter — external callers wanting the old behavior switch toVecExecutionArgs::all.ScalarFnVTableimplementors are unaffected at the type level; they gain the documented (currently inert) semantic obligation described above. Checked numeric execution under an all-true demand is unchanged; under a partial demand it now suppresses domain errors at undemanded lanes, which is only observable to callers that pass such a mask. No file format, serialization, or FFI changes.🤖 Generated with Claude Code
https://claude.ai/code/session_01GhAvQmWLipNHBKjrZhxnKv