You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
This issue is mostly a WIP since I still need to figure out if the mechanics can be optimize further.
Design
A RowFn supplies typed row kernels. A blanket ScalarFnVTable implementation and private lifting layer turn those kernels into columnar execution. There is no separate public strict-function vtable.
The lifting owns the parts that should not be reimplemented by every function:
Read arity, dense safety, decode behavior, and fallibility off the RowFn argument witness, which a compile-time check pins against every dispatch. Run dispatch to validate the arguments and derive the result dtype from the visited OutputSink, then widen it when any input is nullable.
Short-circuit a null constant to an all-null result, or evaluate an entirely constant call once and broadcast it.
Decode each input once. A partially constant argument is decoded as one row and read with stride 0.
Derive the strict input validity and choose how the kernel sees rows behind nulls.
Execute the row kernel or an encoding-aware reduction, then reconcile the output dtype and apply validity.
Null handling has two contracts. Dense may evaluate payloads behind null rows and mask the result, so it is only available when every input element is DENSE_SAFE and execution cannot exit the loop early. Filter guarantees that the row computation only sees valid rows.
A deferred-error kernel is the exception that keeps Dense available while still being fallible. It writes a memory-safe provisional value for every row and hands back evidence of failure, which the executor OR-reduces across the batch, so there is no branch or Result discriminant in the hot loop. When such a batch is nullable and the reduction is non-zero, execution is retried over only its valid rows: success means the error came exclusively from rows that will be null anyway, and a second error is real.
Two properties of that reduction are load-bearing. Getting either wrong costs the loop its vectorization rather than producing a wrong answer, which makes both invisible to tests.
It lives in a loop-local rather than in the sink. An accumulator reached through a &mut for every row is a loop-carried memory dependence, and moving it into the sink cost the boolean kernels 2.5x to 10x.
Its width does not exceed the element's, which is why SinkResult names the word it reduces into instead of fixing one. A 64-bit accumulator bounds how many rows a vector of the reduction covers whatever the element width, and cost the primitive Mul kernel 3.1x at i8, 1.9x at i16 and 1.2x at i32.
Naming the word is also what lets a kernel report something other than a bit. Unsigned multiplication hands back the discarded high half of its widened product, because deriving a boolean from it costs a comparison that LLVM folds into llvm.umul.with.overflow, which has no vector lowering and scalarizes the whole loop.
Adaptive execution
For a mixed validity mask under Filter, the lifting chooses between two mechanisms per batch:
Branch-and-skip decodes the original columns, computes only set rows, fills skipped output slots with placeholders, and masks the result. This avoids filtering and scattering and preserves the input encodings.
Filter-and-scatter filters every input to the valid rows, executes densely, and scatters the result back. This is the fallback when null-tolerant decoding is unavailable, and can win when filtering avoids substantial per-row decode work.
The choice is invisible to the RowFn. InputElement::decode_null_tolerant first determines whether branch-and-skip is sound for the concrete arrays, and OutputSink::SUPPORTS_SKIPPED_ROWS whether the sink can finish an output whose skipped rows were never visited. ElementSink supports it by pre-filling placeholders; a builder that cannot finish a skipped row declines and the batch falls back to filter-and-scatter. DECODE_SHRINKS_WHEN_FILTERED then tells the selector whether filtering can avoid substantial decode work.
The current rule always prefers branch-and-skip for bulk decodes. For a per-row decode, it filters when fewer than 75% of rows survive. This is an experimental global threshold rather than part of the RowFn contract, and it is known to pick the slower strategy in both directions on x86. Recalibrating it depends on the cost of filter-and-scatter, which is itself a live target: see the follow-up below.
Even a Dense kernel may eventually benefit from skipping all-null mask words while keeping contiguous all-valid runs dense. That needs a skipped-row output contract and benchmarks for mask shape as well as surviving fraction.
Constant decoding and constant computation are separate. The machinery detects batch constants, decodes them once, and exposes their values to the prepare step. The function still decides which work can be hoisted and computes that state itself.
Steps
Derive the ScalarFnVTable arity, strictness, fallibility, validity, and options serialization from RowFn, and derive its result dtype through dispatch.
Implement null-constant and all-constant lifting, dense execution, filtering, scattering, and output dtype reconciliation.
Implement branch-and-skip with null-tolerant decoding and output placeholders, gated per sink by SUPPORTS_SKIPPED_ROWS.
Implement deferred-error dense execution and its valid-row retry.
Add adaptive per-batch selection, forced-strategy test controls, and bytes/geometry crossover benchmarks.
Preserve encoding-aware reductions across dense, filtered, and branch-and-skip execution.
Validate the machinery with primitive, bytes, tensor, sink, fallible, and geometry functions.
Add before-and-after benchmarks for representative constant, nullable, and ordinary row workloads.
Unresolved questions
What benchmark set and regression threshold are required before replacing an existing hand-written implementation? Wall clock on a single host has not been sufficient for this machinery: two separate interventions moved a benchmark the wrong way, and host drift between sessions exceeded the effects under measurement. The emitted IR, specifically whether an overflow intrinsic survives and how wide the reduction is, has been the more reliable gate.
What replaces the global survivor-fraction threshold: a per-element and arity-aware threshold, or a small cost model comparing estimated full-row decode work against survivor-only decode plus filter and scatter?
Non-blocking follow-ups:
Reduce the cost of filter-and-scatter itself. It gathers through a take over a full-length index array and then filters every input, and at 90% nulls over 65536 rows it spends more time producing 6553 surviving rows than dense execution spends on all of them (null_strategy_bytes, Apple M4 Max). Removing a redundant masking pass was already worth 1.13-1.53x, so the crossover this trades against is not stable yet.
Avoid probing reduce_encoded twice when branch execution is unsupported.
Revisit the survivor-fraction threshold when another element with substantial per-row decode work exists.
Allow the fallible branch loop to stop iterating immediately after the first error.
Measured dead ends, recorded so they are not retried:
Bounds-check elimination in the row loop is not available. Narrowing the varying view to the row count so the index is provably in bounds buys nothing measurable, and get_unchecked is not uniformly a win: it is worth about 10% on mul_u16 and mul_u32 and costs 22% on mul_u8.
A per-argument row source that keeps the Varying view when some other argument is batch-constant is 4x slower than the ArgColumn branch it replaces, which already vectorizes.
A batch-constant operand therefore still demotes its neighbours off the slice path. Closing that needs the row loop monomorphized over which arguments are constant, which is what the hand-written kernels do with their four match arms, and it is worth revisiting when Compare moves onto RowFn since col < literal is exactly this shape.
This is a tracking issue for the private machinery that executes a
RowFnover Vortex arrays.Parent Epic: #9128
Related API tracking issue: #9129
This issue is mostly a WIP since I still need to figure out if the mechanics can be optimize further.
Design
A
RowFnsupplies typed row kernels. A blanketScalarFnVTableimplementation and private lifting layer turn those kernels into columnar execution. There is no separate public strict-function vtable.The lifting owns the parts that should not be reimplemented by every function:
RowFnargument witness, which a compile-time check pins against every dispatch. Rundispatchto validate the arguments and derive the result dtype from the visitedOutputSink, then widen it when any input is nullable.Null handling has two contracts.
Densemay evaluate payloads behind null rows and mask the result, so it is only available when every input element isDENSE_SAFEand execution cannot exit the loop early.Filterguarantees that the row computation only sees valid rows.A deferred-error kernel is the exception that keeps
Denseavailable while still being fallible. It writes a memory-safe provisional value for every row and hands back evidence of failure, which the executor OR-reduces across the batch, so there is no branch orResultdiscriminant in the hot loop. When such a batch is nullable and the reduction is non-zero, execution is retried over only its valid rows: success means the error came exclusively from rows that will be null anyway, and a second error is real.Two properties of that reduction are load-bearing. Getting either wrong costs the loop its vectorization rather than producing a wrong answer, which makes both invisible to tests.
&mutfor every row is a loop-carried memory dependence, and moving it into the sink cost the boolean kernels 2.5x to 10x.SinkResultnames the word it reduces into instead of fixing one. A 64-bit accumulator bounds how many rows a vector of the reduction covers whatever the element width, and cost the primitiveMulkernel 3.1x ati8, 1.9x ati16and 1.2x ati32.Naming the word is also what lets a kernel report something other than a bit. Unsigned multiplication hands back the discarded high half of its widened product, because deriving a boolean from it costs a comparison that LLVM folds into
llvm.umul.with.overflow, which has no vector lowering and scalarizes the whole loop.Adaptive execution
For a mixed validity mask under
Filter, the lifting chooses between two mechanisms per batch:The choice is invisible to the
RowFn.InputElement::decode_null_tolerantfirst determines whether branch-and-skip is sound for the concrete arrays, andOutputSink::SUPPORTS_SKIPPED_ROWSwhether the sink can finish an output whose skipped rows were never visited.ElementSinksupports it by pre-filling placeholders; a builder that cannot finish a skipped row declines and the batch falls back to filter-and-scatter.DECODE_SHRINKS_WHEN_FILTEREDthen tells the selector whether filtering can avoid substantial decode work.The current rule always prefers branch-and-skip for bulk decodes. For a per-row decode, it filters when fewer than 75% of rows survive. This is an experimental global threshold rather than part of the
RowFncontract, and it is known to pick the slower strategy in both directions on x86. Recalibrating it depends on the cost of filter-and-scatter, which is itself a live target: see the follow-up below.Even a
Densekernel may eventually benefit from skipping all-null mask words while keeping contiguous all-valid runs dense. That needs a skipped-row output contract and benchmarks for mask shape as well as surviving fraction.Constant decoding and constant computation are separate. The machinery detects batch constants, decodes them once, and exposes their values to the prepare step. The function still decides which work can be hoisted and computes that state itself.
Steps
ScalarFnVTablearity, strictness, fallibility, validity, and options serialization fromRowFn, and derive its result dtype throughdispatch.SUPPORTS_SKIPPED_ROWS.Unresolved questions
Non-blocking follow-ups:
takeover a full-length index array and then filters every input, and at 90% nulls over 65536 rows it spends more time producing 6553 surviving rows than dense execution spends on all of them (null_strategy_bytes, Apple M4 Max). Removing a redundant masking pass was already worth 1.13-1.53x, so the crossover this trades against is not stable yet.reduce_encodedtwice when branch execution is unsupported.Measured dead ends, recorded so they are not retried:
get_uncheckedis not uniformly a win: it is worth about 10% onmul_u16andmul_u32and costs 22% onmul_u8.Varyingview when some other argument is batch-constant is 4x slower than theArgColumnbranch it replaces, which already vectorizes.Comparemoves ontoRowFnsincecol < literalis exactly this shape.Implementation history
None yet.