Skip to content

Tracking Issue: Define the RowFn API #9129

Description

@connortsui20

This issue tracks the author-facing RowFn API in vortex-array.

Parent Epic: #9128

Design

RowFn describes a strict scalar function through the typed rows it reads and the output it writes. Every implementation receives the standard ScalarFnVTable automatically. A type that needs custom vtable hooks can keep them on a separate public type and delegate to a private RowFn kernel through row_fn_return_dtype and execute_rows.

pub trait RowFn: 'static + Sized + Clone + Send + Sync {
    type Options: 'static + Send + Sync + Clone + Debug + Display + PartialEq + Eq + Hash;

    /// The arguments in display order. Its length is the exact arity.
    const ARG_NAMES: &'static [&'static str];

    /// Whether every row operation selected by `dispatch` is semantically infallible.
    const INFALLIBLE: bool;

    fn id(&self) -> ScalarFnId;

    /// Defaults to a non-serializable function.
    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>;
}

There is no argument or return witness. ARG_NAMES supplies the exact arity. The output type selected by dispatch supplies the result dtype. Planning reads dense safety and decode infallibility from the concrete argument tuple.

INFALLIBLE describes the semantic row operation across every legal dispatch. InputElement::DECODE_INFALLIBLE describes each decoder. On develop, decode infallibility also participates in nullable execution policy selection. #9644 proposed a separate function-wide declaration and combined vtable guarantee, but it closed without merging. This remains an API decision.

Options persistence also belongs to the function. The default is non-serializable. Registered functions can define their own wire representation through serialize and deserialize.

RowFn has strict semantics. A null in any input produces a null output. The current output forms are total over valid inputs. Their output validity is the conjunction of the input validities. This excludes functions such as list_sum and variant_get, which can return null for valid inputs.

This issue also tracks the encoding-aware escape hatch. #9347 contains the current implementation:

fn reduce_encoded(
    &self,
    options: &Self::Options,
    args: &[ArrayRef],
    ctx: &mut ExecutionCtx,
) -> VortexResult<Option<ArrayRef>>;

The executor calls this hook before row decoding. A returned array must match the planned dtype and row count. The hook cannot introduce nulls for valid input rows.

RowVisitor: select element types and an output form

RowVisitor is sealed and has three general method families:

  • visit and visit_prepared return one owned output value per row.
  • visit_into and visit_prepared_into write through an OutputSink.
  • visit_deferred and visit_prepared_deferred return owned values with OR-reduced failure evidence.

Boolean-specific visit methods build packed output directly. with_output_dtype declares a logical output dtype over the physical output type.

The prepared forms receive each batch-constant argument as Some(value). A non-constant argument appears as None. This lets a kernel move constant-only work out of the row loop.

Owned-output visits require IndexedElementTuple, which every sealed ElementTuple implements. Sink visits accept ElementTuple directly.

InputElement and ElementTuple: validate, decode, and read input rows
pub unsafe trait InputElement: 'static {
    type Column;
    type Constant;
    type View<'a>: ViewLen;
    type Elem<'a>;

    const DENSE_SAFE: bool;
    const DECODE_INFALLIBLE: bool;

    fn validate(dtype: &DType) -> VortexResult<()>;
    fn decode(array: ArrayRef, ctx: &mut ExecutionCtx) -> VortexResult<Self::Column>;
    fn decode_constant(
        array: ArrayRef,
        ctx: &mut ExecutionCtx,
    ) -> VortexResult<Self::Constant>;
    fn can_decode_null_tolerant(array: &ArrayRef) -> VortexResult<bool>;
    fn decode_null_tolerant(
        array: ArrayRef,
        ctx: &mut ExecutionCtx,
    ) -> VortexResult<Option<Self::Column>>;
    fn get(column: &Self::Column, index: usize) -> Self::Elem<'_>;
    fn get_constant(constant: &Self::Constant) -> Self::Elem<'_>;
    fn view(column: &Self::Column) -> Self::View<'_>;

    fn get_from_view<'a>(view: &Self::View<'a>, index: usize) -> Self::Elem<'a>;
    unsafe fn get_from_view_unchecked<'a>(
        view: &Self::View<'a>,
        index: usize,
    ) -> Self::Elem<'a>;
}

InputElement is unsafe because the shared indexed loop performs one length check before unchecked row reads. Implementations must make every index below ViewLen::len legal for get_from_view_unchecked.

ElementTuple is a sealed adapter over tuples of zero through twelve InputElements. It combines dense safety and decode infallibility. Its Views<'a> associated type implements ViewLen and exists only when every argument is non-constant. The tuple length validates every borrowed view before unchecked traversal.

Batch constants use a separate Constant representation. decode_constant can extract one native value without constructing a one-row decoded column. decoded_lens_match validates the varying columns. New decode primitives implement InputElement, not ElementTuple.

OutputSink and SinkResult: build the result column
pub unsafe trait OutputSink: 'static + Sized {
    type Params: 'static;
    type Rows<'a>: ViewLen where Self: 'a;
    type Row<'a> where Self: 'a;
    type WriteToken: 'static;

    fn skipped_rows_initializer() -> Option<for<'a> fn(&mut Self::Rows<'a>)> {
        None
    }

    fn storage_dtype(params: &Self::Params) -> DType;
    fn with_capacity(rows: usize, params: &Self::Params) -> VortexResult<Self>;
    fn rows(&mut self) -> Self::Rows<'_>;

    unsafe fn row_unchecked<'a>(rows: &'a mut Self::Rows<'_>, index: usize) -> Self::Row<'a>;
    unsafe fn finish(self) -> VortexResult<ArrayRef>;
}

OutputSink describes physical storage only. Params carries runtime physical values, such as a fixed-size-list width. RowVisitor::with_output_dtype declares a logical output dtype derived from the function options or argument dtypes.

The trait is unsafe because the executor trusts its row handles, write tokens, and finish implementation. A sink and every borrowed Rows view must also remain safe to drop after any callback prefix. This includes errors and unwinds during decoding, preparation, skipped-row initialization, and row execution.

SinkResult is a sealed adapter for (), InitializedElement, VortexResult<()>, and VortexResult<InitializedElement>. The result's WriteToken must match the sink's token. InitializedElement::write is unsafe because its zero-sized token cannot carry a lifetime that brands one row allocation.

Deferred failure evidence belongs to the owned visit_deferred methods. The evidence must not be wider than the output value. A wider reduction can reduce vector width. The accumulator must stay local to the loop because sink storage adds a loop-carried memory dependency.

Example: Hypot

Primitive types implement InputElement and OutputElement, so Hypot defines neither. Its complete row implementation is:

impl RowFn for Hypot {
    type Options = EmptyOptions;

    const ARG_NAMES: &'static [&'static str] = &["x", "y"];
    const INFALLIBLE: bool = true;

    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. A function needs a new element implementation only for a row representation that the framework cannot read or build.

Example: CosineSimilarity

CosineSimilarity is still a row computation. A constant operand has one norm for the batch, so the prepare step computes it once.

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),
        )
    })
}

The dispatch selects TensorRow<f16>, TensorRow<f32>, or TensorRow<f64>. Planning derives the argument properties from the selected tuple.

Steps

  • Stabilize RowFn, ARG_NAMES, and the visitor methods with representative users.
  • Stabilize the input, output, and sink contracts with conformance tests for extension types.
  • Decide the function-wide contract for semantic and decode infallibility.
  • Add and stabilize reduce_encoded.
  • Decide whether nullable row outputs are part of the initial API.
  • Preserve serialized metadata and source compatibility for migrated scalar functions.
  • Document when to use RowFn, when to keep a custom ScalarFnVTable, and how to add an element type.
  • Stabilize the public API.

Unresolved questions

  • Should nullable Option<T> outputs be supported before stabilization, or remain an additive follow-up?
    • I believe the answer is no here, as while it can fit into the current abstraction by returning an Option<T>, this is terrible for performance. Though in the future, when we deal with more complicated types, this might be beneficial. I will leave that to a future issue.
  • InputElement, OutputElement, and OutputSink are downstream extension points. ElementTuple and SinkResult remain sealed.
  • OutputSink describes physical storage. RowVisitor::with_output_dtype declares logical output types derived from options and argument dtypes.
  • Every RowFn receives the standard ScalarFnVTable implementation. A type that needs custom vtable hooks can delegate through row_fn_return_dtype and execute_rows from a separate public vtable type.
  • Unsafe InputElement and OutputSink contracts cover unchecked indexed reads, partial-drop safety, and sink finalization.

Implementation history

Activity

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

Metadata

Metadata

Assignees

Labels

tracking-issueShared implementation context for work likely to span multiple PRs.

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions