This issue tracks the author-facing RowFn API in vortex-array.
Parent Epic: #9128
Design
RowFn describes a strict scalar function as a typed operation on one row. A blanket ScalarFnVTable implementation adds columnar execution. Authors do not implement another vtable.
// Default method bodies omitted.
pub trait RowFn: 'static + Sized + Clone + Send + Sync {
type Options: 'static + Send + Sync + Clone + Debug + Display + PartialEq + Eq + Hash;
const ARG_NAMES: &'static [&'static str];
const FALLIBLE: bool = false;
fn id(&self) -> ScalarFnId;
fn serialize(&self, options: &Self::Options) -> VortexResult<Option<Vec<u8>>>;
fn deserialize(
&self,
metadata: &[u8],
session: &VortexSession,
) -> VortexResult<Self::Options>;
fn dispatch<V: RowVisitor>(
&self,
options: &Self::Options,
args: &[DType],
visitor: V,
) -> VortexResult<V::VisitResult>;
fn reduce_encoded(
&self,
options: &Self::Options,
args: &[ArrayRef],
ctx: &mut ExecutionCtx,
) -> VortexResult<Option<ArrayRef>>;
}
ARG_NAMES defines the exact arity. FALLIBLE is function-wide because ScalarFnVTable::is_fallible has no input dtypes. A function can set it when only some dtype choices fail. The visitor checks each concrete dispatch against it at compile time.
dispatch validates cross-argument dtype rules and selects concrete row types. Planning and execution both call it. Its decision must depend only on options and args.
reduce_encoded is an optional bulk shortcut. It runs before row decoding when that execution path probes the given inputs. It can preserve an encoding or return a lazy result. Its output must:
- have the same row count as the invocation.
- match the planned dtype, ignoring nullability.
- not add a null where all inputs are valid.
The framework skips this hook for nullary functions.
RowVisitor: select an output form and optional prepared state
// Default method bodies omitted.
pub trait RowVisitor: Sealed + Sized {
type VisitResult;
fn visit<Args, Out>(
self,
apply: impl Fn(Args::Elems<'_>) -> Out,
) -> VortexResult<Self::VisitResult>
where
Args: IndexedElementTuple,
Out: OutputElement;
fn visit_prepared<Args, Out, Prepared>(
self,
prepare: impl FnOnce(Args::ConstElems<'_>) -> Prepared,
apply: impl Fn(&Prepared, Args::Elems<'_>) -> Out,
) -> VortexResult<Self::VisitResult>
where
Args: IndexedElementTuple,
Out: OutputElement;
fn visit_into<Args, Sink, ApplyResult>(
self,
apply: impl Fn(Args::Elems<'_>, Sink::Row<'_>) -> ApplyResult,
) -> VortexResult<Self::VisitResult>
where
Args: ElementTuple,
Sink: OutputSink,
ApplyResult: SinkResult<WriteToken = Sink::WriteToken>;
fn visit_prepared_into<Args, Sink, Prepared, ApplyResult>(
self,
prepare: impl FnOnce(Args::ConstElems<'_>) -> Prepared,
apply: impl Fn(&Prepared, Args::Elems<'_>, Sink::Row<'_>) -> ApplyResult,
) -> VortexResult<Self::VisitResult>
where
Args: ElementTuple,
Sink: OutputSink,
ApplyResult: SinkResult<WriteToken = Sink::WriteToken>;
fn visit_deferred<Args, Out, Fail>(
self,
apply: impl Fn(Args::Elems<'_>) -> (Out, Fail),
finish_failure: impl FnOnce(Fail) -> VortexResult<()>,
) -> VortexResult<Self::VisitResult>
where
Args: IndexedElementTuple,
Out: OutputElement,
Fail: Copy + Default + BitOrAssign;
fn visit_prepared_deferred<Args, Out, Prepared, Fail>(
self,
prepare: impl FnOnce(Args::ConstElems<'_>) -> Prepared,
apply: impl Fn(&Prepared, Args::Elems<'_>) -> (Out, Fail),
finish_failure: impl FnOnce(Fail) -> VortexResult<()>,
) -> VortexResult<Self::VisitResult>
where
Args: IndexedElementTuple,
Out: OutputElement,
Fail: Copy + Default + BitOrAssign;
}
The three unprepared methods delegate to their prepared forms with unit state. A function that has nothing to prepare does not need an empty prepare closure.
RowVisitor::VisitResult is the framework result. It is a plan during dtype planning and a batch execution result at run time. By contrast, the Out generic is one owned value returned for one row.
Prepared visits receive Args::ConstElems. Each entry is Some for a batch-constant argument and None for a varying argument. This lets a function compute constant-dependent state once per batch.
Deferred visits OR-reduce Fail across the row loop. Fail::default() must mean success. Fail must not be wider than Out, so failure tracking does not reduce the vector width. The framework checks the size rule at compile time.
InputElement and OutputElement: decode and build row values
// Default method bodies omitted.
pub trait InputElement: 'static {
type Column;
type Varying<'a>;
type Elem<'a>;
const DENSE_SAFE: bool = false;
const DECODE_FALLIBLE: bool = true;
fn validate(dtype: &DType) -> VortexResult<()>;
fn decode(array: ArrayRef, ctx: &mut ExecutionCtx) -> VortexResult<Self::Column>;
fn decode_null_tolerant(
array: ArrayRef,
ctx: &mut ExecutionCtx,
) -> VortexResult<Option<Self::Column>>;
fn get(column: &Self::Column, index: usize) -> Self::Elem<'_>;
fn varying(column: &Self::Column) -> Self::Varying<'_>;
fn varying_len(column: &Self::Varying<'_>) -> usize;
fn get_varying<'a>(
column: &Self::Varying<'a>,
index: usize,
) -> Self::Elem<'a>
where
Self: 'a;
}
pub trait OutputElement: 'static + Sized {
fn element_dtype() -> DType;
fn build(values: Vec<Self>) -> ArrayRef;
}
InputElement decodes one column into an O(1) row representation. It declares whether dense access is safe and whether legal input can fail to decode.
OutputElement builds a column from independent owned row values. Its dtype is fixed by its Rust type. Runtime-shaped output uses OutputSink, whose sink_dtype can inspect the input dtypes.
ElementTuple and IndexedElementTuple are sealed adapters over tuples of InputElements. Function authors select a tuple through RowVisitor but do not implement either adapter.
OutputSink, SinkResult, DeferredError, and InitializedElement: write custom outputs and report errors
// Default method bodies omitted.
pub trait OutputSink: 'static + Sized {
const ERRORS_ARE_DEFERRED: bool = false;
const SUPPORTS_SKIPPED_ROWS: bool = false;
type Rows<'a>
where
Self: 'a;
type Row<'a>
where
Self: 'a;
type WriteToken: 'static;
fn sink_dtype(args: &[DType]) -> VortexResult<DType>;
fn with_capacity(rows: usize, dtype: &DType) -> VortexResult<Self>;
fn rows(&mut self) -> Self::Rows<'_>;
fn row_count_matches(rows: &Self::Rows<'_>, row_count: usize) -> bool;
fn initialize_skipped_rows(rows: &mut Self::Rows<'_>);
fn row<'a>(rows: &'a mut Self::Rows<'_>, index: usize) -> Self::Row<'a>;
fn finish(self, error: DeferredError) -> VortexResult<ArrayRef>;
}
pub trait SinkResult: 'static + Sealed {
type WriteToken: 'static;
type Accumulated: 'static + Copy + Default;
const FALLIBLE: bool;
const DEFERRED: bool;
fn accumulate(self, accumulated: &mut Self::Accumulated) -> VortexResult<()>;
fn occurred(accumulated: Self::Accumulated) -> bool;
}
pub struct DeferredError(/* private */ bool);
pub struct InitializedElement(/* private */ ());
impl InitializedElement {
pub fn write<T>(row: &mut MaybeUninit<T>, value: T) -> Self;
}
An OutputSink can own shared batch state or provide a custom row handle. A sink can opt into deferred errors and skipped-row execution. The framework masks any placeholder values left in skipped output rows.
OutputSink::WriteToken proves that a successful closure initialized its row handle. Initialized sinks use (). UninitElementSink uses InitializedElement, which safe code can obtain only through InitializedElement::write.
SinkResult is sealed, and its WriteToken must match the sink. A sink-writing closure returns (), VortexResult<()>, VortexResult<InitializedElement>, or a Boolean or unsigned failure word. The executor keeps deferred evidence in a loop-local and passes one DeferredError to the sink after the loop.
Example: Hypot
Primitive input and output elements are already available, so Hypot only defines its row operation:
#[derive(Clone)]
struct Hypot;
impl RowFn for Hypot {
type Options = EmptyOptions;
const ARG_NAMES: &'static [&'static str] = &["x", "y"];
fn id(&self) -> ScalarFnId {
static ID: CachedId = CachedId::new("vortex.hypot");
*ID
}
fn dispatch<V: RowVisitor>(
&self,
_options: &Self::Options,
_args: &[DType],
visitor: V,
) -> VortexResult<V::VisitResult> {
visitor.visit::<(f64, f64), f64>(|(x, y)| x.hypot(y))
}
}
The framework derives type validation, batch decoding, constant handling, output allocation, null handling, and validity from this definition.
Example: CosineSimilarity
CosineSimilarity is also a row computation, but a constant operand has one norm for the whole batch. The prepared visit computes that norm once.
struct ConstNorms<T> {
lhs: Option<T>,
rhs: Option<T>,
}
fn dispatch<V: RowVisitor>(
&self,
_options: &Self::Options,
args: &[DType],
visitor: V,
) -> VortexResult<V::VisitResult> {
match_each_float_ptype!(tensor_element_ptype(args)?, |T| {
visitor.visit_prepared::<(TensorRow<T>, TensorRow<T>), T, _>(
|(lhs, rhs)| ConstNorms {
lhs: lhs.map(l2_norm_row),
rhs: rhs.map(l2_norm_row),
},
|norms, (lhs, rhs)| {
cosine_similarity_row_prepared(norms, lhs, rhs)
},
)
})
}
TensorRow<T> implements InputElement and exposes each tensor row as &[T]. The dispatch selects TensorRow<f16>, TensorRow<f32>, or TensorRow<f64> from the input dtype.
Since I was the one who implemented cosine similarity in vortex-tensor, I can confidently say that this is SIGNIFICANTLY less complex and easier to write than the implementation on develop. At the very least, it is much harder to write incorrect code like this.
Current scope
RowFn is only for strict functions. A null input makes the corresponding output null. The current row output forms cannot produce a new null from otherwise valid inputs.
Implement ScalarFnVTable directly for columnar or zero-copy kernels, shared cross-row state, or functions with non-strict validity rules.
Steps
Unresolved questions
Implementation history
The current branch separates the framework, primitive numeric integration, and focused executor benchmarks into reviewable commits.
This issue tracks the author-facing
RowFnAPI invortex-array.Parent Epic: #9128
Design
RowFndescribes a strict scalar function as a typed operation on one row. A blanketScalarFnVTableimplementation adds columnar execution. Authors do not implement another vtable.ARG_NAMESdefines the exact arity.FALLIBLEis function-wide becauseScalarFnVTable::is_falliblehas no input dtypes. A function can set it when only some dtype choices fail. The visitor checks each concrete dispatch against it at compile time.dispatchvalidates cross-argument dtype rules and selects concrete row types. Planning and execution both call it. Its decision must depend only onoptionsandargs.reduce_encodedis an optional bulk shortcut. It runs before row decoding when that execution path probes the given inputs. It can preserve an encoding or return a lazy result. Its output must:The framework skips this hook for nullary functions.
RowVisitor: select an output form and optional prepared stateThe three unprepared methods delegate to their prepared forms with unit state. A function that has nothing to prepare does not need an empty prepare closure.
RowVisitor::VisitResultis the framework result. It is a plan during dtype planning and a batch execution result at run time. By contrast, theOutgeneric is one owned value returned for one row.Prepared visits receive
Args::ConstElems. Each entry isSomefor a batch-constant argument andNonefor a varying argument. This lets a function compute constant-dependent state once per batch.Deferred visits OR-reduce
Failacross the row loop.Fail::default()must mean success.Failmust not be wider thanOut, so failure tracking does not reduce the vector width. The framework checks the size rule at compile time.InputElementandOutputElement: decode and build row valuesInputElementdecodes one column into anO(1)row representation. It declares whether dense access is safe and whether legal input can fail to decode.OutputElementbuilds a column from independent owned row values. Its dtype is fixed by its Rust type. Runtime-shaped output usesOutputSink, whosesink_dtypecan inspect the input dtypes.ElementTupleandIndexedElementTupleare sealed adapters over tuples ofInputElements. Function authors select a tuple throughRowVisitorbut do not implement either adapter.OutputSink,SinkResult,DeferredError, andInitializedElement: write custom outputs and report errorsAn
OutputSinkcan own shared batch state or provide a custom row handle. A sink can opt into deferred errors and skipped-row execution. The framework masks any placeholder values left in skipped output rows.OutputSink::WriteTokenproves that a successful closure initialized its row handle. Initialized sinks use().UninitElementSinkusesInitializedElement, which safe code can obtain only throughInitializedElement::write.SinkResultis sealed, and itsWriteTokenmust match the sink. A sink-writing closure returns(),VortexResult<()>,VortexResult<InitializedElement>, or a Boolean or unsigned failure word. The executor keeps deferred evidence in a loop-local and passes oneDeferredErrorto the sink after the loop.Example:
HypotPrimitive input and output elements are already available, so
Hypotonly defines its row operation:The framework derives type validation, batch decoding, constant handling, output allocation, null handling, and validity from this definition.
Example:
CosineSimilarityCosineSimilarityis also a row computation, but a constant operand has one norm for the whole batch. The prepared visit computes that norm once.TensorRow<T>implementsInputElementand exposes each tensor row as&[T]. The dispatch selectsTensorRow<f16>,TensorRow<f32>, orTensorRow<f64>from the input dtype.Since I was the one who implemented cosine similarity in
vortex-tensor, I can confidently say that this is SIGNIFICANTLY less complex and easier to write than the implementation on develop. At the very least, it is much harder to write incorrect code like this.Current scope
RowFnis only for strict functions. A null input makes the corresponding output null. The current row output forms cannot produce a new null from otherwise valid inputs.Implement
ScalarFnVTabledirectly for columnar or zero-copy kernels, shared cross-row state, or functions with non-strict validity rules.Steps
RowFnand its blanketScalarFnVTableimplementation.RowFnand how to add an element or sink type.Unresolved questions
Option<T>row outputs be supported before stabilization?OutputSink::sink_dtypeneed function options?Implementation history
The current branch separates the framework, primitive numeric integration, and focused executor benchmarks into reviewable commits.