This issue tracks the private machinery that executes a RowFn over Vortex arrays.
Parent Epic: #9128
Related API tracking issue: #9129
Design
A RowFn provides a typed row kernel. The batch executor adds the columnar behavior that every strict row function needs:
- Validate every input length. Then plan the output dtype and nullable-row policy from the concrete
dispatch.
- Conjoin the input validities and short-circuit all-invalid or null-constant batches.
- Probe
reduce_encoded once on the original inputs.
- Evaluate all-constant calls once and preserve batch constants as one decoded row.
- Select dense, skip-invalid, or filter-and-scatter execution.
- Validate the output length, dtype, and valid-row values. Then apply strict validity.
This machinery is private. A function author selects typed elements and an output capability. The executor owns the batch strategy.
Nullable-row policies
Planning selects one of three policies for each concrete dispatch:
Dense evaluates every row and masks the output. It requires null-safe decoding and an infallible row computation.
DenseWithRetry also evaluates every row. If it sees deferred failure evidence, it retries only the valid rows.
ValidOnly never evaluates the row closure for an invalid row. It selects skip-invalid or filter-and-scatter execution for a mixed validity mask.
The retry is necessary because dense execution can inspect payloads behind nulls. An error from such a payload is not observable. The filtered retry keeps an error from a valid row. It suppresses an error that came only from null rows.
RowExecution
The row loop returns VortexResult<RowExecution>. Together, these types represent three outcomes:
Err(error) is an immediate or structural error. Retrying cannot help.
RowExecution::Output(output) is a successful row loop.
RowExecution::DeferredError(error) is failure evidence from a completed row loop.
The third state is why row execution cannot return only VortexResult<ArrayRef>. Both row loops and reduce_encoded can defer an error. Batch execution then decides whether the failing row was valid.
Once execution contains only valid rows, From<RowExecution> for VortexResult<ArrayRef> converts the deferred error into an ordinary error.
Deferred failures
visit_deferred returns an owned output and a small failure value for each row. The executor OR-reduces those values in a loop-local. It constructs one rich error after the loop.
The failure value must not be wider than the output value. A wider reduction lowers the vector width and can make checked arithmetic much slower. Keeping the accumulator out of the output sink also avoids a loop-carried memory dependency.
Sink visits can report these result forms:
() for an infallible write into initialized storage.
InitializedElement for an infallible write into uninitialized storage.
VortexResult<()> for an immediate failure with initialized storage.
VortexResult<InitializedElement> for an immediate failure with uninitialized storage.
The result's WriteToken must match OutputSink::WriteToken. This constraint keeps the visitor methods safe. It places the per-row unsafe operation inside the uninitialized-output closure. Deferred evidence belongs to the owned visit_deferred forms, not sink execution.
Integer division returns VortexResult<InitializedElement> with UninitElementSink. Division is already scalar and expensive. An immediate check can stop at the first failure. Uninitialized dense output avoids filling every slot before the row loop.
Skip-invalid and filter-and-scatter execution
For a mixed mask under ValidOnly, the executor can compute only valid row indices in the original inputs. This path requires two contracts:
- Every
InputElement must provide a null-tolerant decode for the concrete array.
OutputSink::skipped_rows_initializer must return an initializer for legal placeholders.
The executor masks those placeholders before it returns the output. If either contract declines, the executor filters every input to the valid rows. It runs the dense kernel and scatters the result into a full-length nullable array.
Owned output visits do not have a sink that can initialize skipped slots. Their valid-only visitor uses filter-and-scatter. UninitElementSink supports skipped slots, which keeps nullable integer division on the original inputs.
The batch executor probes the original arrays for an encoding-aware reduction before strategy selection. ValidOnly then tries skip-invalid execution. If that attempt declines, it filters and scatters. There is no survivor threshold or FILTERED_DECODE_COST.
Constants and encodings
Constant decoding and prepared computation are separate. The tuple adapter stores a batch constant as one decoded row. A prepared visitor can derive shared state from that value once per batch.
reduce_encoded can return an output or a deferred error. The executor probes the original arrays once before generic all-constant broadcast, strategy selection, slicing, or filtering. Retries do not probe compacted arrays. A returned array still uses the common output checks.
Migration benchmark gate
Use pinned, alternating local x86 measurements and generated-code inspection before replacing a hand-written microkernel. CodSpeed CPU simulation measures a different cost model and does not replace native evidence. Keep a columnar fallback when RowFn produces slower native code, as the primitive comparison path does.
The current native gate uses Rust 1.97.1, LLVM 22.1.6, one codegen unit, fat LTO, and -C target-cpu=native. The full-stack comparison used two warm runs and seven alternating measured pairs on a Ryzen 9 7950X.
LLVM 22 leaves several mixed constant/per-row primitive loops scalar. Those cases are 4.6 to 8.5 times slower than develop. The same kernels vectorized with LLVM 21. Treat this as a documented compiler regression, not as evidence for more framework plumbing. Keep the affected fallbacks until the generated code improves.
Unchanged controls moved by 10% to 35% in the same whole-binary comparison. Small isolated shifts need branch-local evidence before they justify execution changes.
Steps
Unresolved questions
Follow-ups
- Reduce the allocations and passes in filter-and-scatter.
- Measure skip-invalid against filter-and-scatter for each new element with substantial decode work.
- Keep generated-code checks for deferred arithmetic alongside wall-clock benchmarks.
Implementation history
This issue tracks the private machinery that executes a
RowFnover Vortex arrays.Parent Epic: #9128
Related API tracking issue: #9129
Design
A
RowFnprovides a typed row kernel. The batch executor adds the columnar behavior that every strict row function needs:dispatch.reduce_encodedonce on the original inputs.This machinery is private. A function author selects typed elements and an output capability. The executor owns the batch strategy.
Nullable-row policies
Planning selects one of three policies for each concrete dispatch:
Denseevaluates every row and masks the output. It requires null-safe decoding and an infallible row computation.DenseWithRetryalso evaluates every row. If it sees deferred failure evidence, it retries only the valid rows.ValidOnlynever evaluates the row closure for an invalid row. It selects skip-invalid or filter-and-scatter execution for a mixed validity mask.The retry is necessary because dense execution can inspect payloads behind nulls. An error from such a payload is not observable. The filtered retry keeps an error from a valid row. It suppresses an error that came only from null rows.
RowExecutionThe row loop returns
VortexResult<RowExecution>. Together, these types represent three outcomes:Err(error)is an immediate or structural error. Retrying cannot help.RowExecution::Output(output)is a successful row loop.RowExecution::DeferredError(error)is failure evidence from a completed row loop.The third state is why row execution cannot return only
VortexResult<ArrayRef>. Both row loops andreduce_encodedcan defer an error. Batch execution then decides whether the failing row was valid.Once execution contains only valid rows,
From<RowExecution> for VortexResult<ArrayRef>converts the deferred error into an ordinary error.Deferred failures
visit_deferredreturns an owned output and a small failure value for each row. The executor OR-reduces those values in a loop-local. It constructs one rich error after the loop.The failure value must not be wider than the output value. A wider reduction lowers the vector width and can make checked arithmetic much slower. Keeping the accumulator out of the output sink also avoids a loop-carried memory dependency.
Sink visits can report these result forms:
()for an infallible write into initialized storage.InitializedElementfor an infallible write into uninitialized storage.VortexResult<()>for an immediate failure with initialized storage.VortexResult<InitializedElement>for an immediate failure with uninitialized storage.The result's
WriteTokenmust matchOutputSink::WriteToken. This constraint keeps the visitor methods safe. It places the per-row unsafe operation inside the uninitialized-output closure. Deferred evidence belongs to the ownedvisit_deferredforms, not sink execution.Integer division returns
VortexResult<InitializedElement>withUninitElementSink. Division is already scalar and expensive. An immediate check can stop at the first failure. Uninitialized dense output avoids filling every slot before the row loop.Skip-invalid and filter-and-scatter execution
For a mixed mask under
ValidOnly, the executor can compute only valid row indices in the original inputs. This path requires two contracts:InputElementmust provide a null-tolerant decode for the concrete array.OutputSink::skipped_rows_initializermust return an initializer for legal placeholders.The executor masks those placeholders before it returns the output. If either contract declines, the executor filters every input to the valid rows. It runs the dense kernel and scatters the result into a full-length nullable array.
Owned output visits do not have a sink that can initialize skipped slots. Their valid-only visitor uses filter-and-scatter.
UninitElementSinksupports skipped slots, which keeps nullable integer division on the original inputs.The batch executor probes the original arrays for an encoding-aware reduction before strategy selection.
ValidOnlythen tries skip-invalid execution. If that attempt declines, it filters and scatters. There is no survivor threshold orFILTERED_DECODE_COST.Constants and encodings
Constant decoding and prepared computation are separate. The tuple adapter stores a batch constant as one decoded row. A prepared visitor can derive shared state from that value once per batch.
reduce_encodedcan return an output or a deferred error. The executor probes the original arrays once before generic all-constant broadcast, strategy selection, slicing, or filtering. Retries do not probe compacted arrays. A returned array still uses the common output checks.Migration benchmark gate
Use pinned, alternating local x86 measurements and generated-code inspection before replacing a hand-written microkernel. CodSpeed CPU simulation measures a different cost model and does not replace native evidence. Keep a columnar fallback when RowFn produces slower native code, as the primitive comparison path does.
The current native gate uses Rust 1.97.1, LLVM 22.1.6, one codegen unit, fat LTO, and
-C target-cpu=native. The full-stack comparison used two warm runs and seven alternating measured pairs on a Ryzen 9 7950X.LLVM 22 leaves several mixed constant/per-row primitive loops scalar. Those cases are 4.6 to 8.5 times slower than
develop. The same kernels vectorized with LLVM 21. Treat this as a documented compiler regression, not as evidence for more framework plumbing. Keep the affected fallbacks until the generated code improves.Unchanged controls moved by 10% to 35% in the same whole-binary comparison. Small isolated shifts need branch-local evidence before they justify execution changes.
Steps
RowFn.Unresolved questions
Follow-ups
Implementation history