33
44## Summary
55
6- We would like to add a Tensor type to Vortex as an extension over ` FixedSizeList ` . This RFC proposes
7- the design of a fixed-shape tensor with contiguous backing memory.
6+ We would like to add a ` FixedShapeTensor ` type to Vortex as an extension over ` FixedSizeList ` . This
7+ RFC proposes the design of a fixed-shape tensor with contiguous backing memory.
88
99## Motivation
1010
@@ -18,7 +18,7 @@ name just a few examples:
1818- Multi-dimensional sensor or time-series data
1919- Embedding vectors from language models and recommendation systems
2020
21- #### Tensors in Vortex
21+ #### Fixed-shape tensors in Vortex
2222
2323In the current version of Vortex, there are two ways to represent fixed-shape tensors using the
2424` FixedSizeList ` ` DType ` , and neither seems satisfactory.
@@ -54,44 +54,44 @@ fully described here. However, we do know enough that we can present the general
5454### Storage Type
5555
5656Extension types in Vortex require defining a canonical storage type that represents what the
57- extension array looks like when it is canonicalized. For tensors, we will want this storage type to
58- be a ` FixedSizeList<p, s> ` , where ` p ` is a numeric type (like ` u8 ` , ` f64 ` , or a decimal type), and
59- where ` s ` is the product of all dimensions of the tensor.
57+ extension array looks like when it is canonicalized. For fixed-shape tensors, we will want this
58+ storage type to be a ` FixedSizeList<p, s> ` , where ` p ` is a primitive type (like ` u8 ` , ` f64 ` , etc.),
59+ and where ` s ` is the product of all dimensions of the tensor.
6060
6161For example, if we want to represent a tensor of ` i32 ` with dimensions ` [2, 3, 4] ` , the storage type
6262for this tensor would be ` FixedSizeList<i32, 24> ` since ` 2 x 3 x 4 = 24 ` .
6363
64- This is equivalent to the design of Arrow's canonical Tensor extension type. For discussion on why
65- we choose not to represent tensors as nested FSLs (for example
64+ This is equivalent to the design of Arrow's canonical Fixed Shape Tensor extension type. For
65+ discussion on why we choose not to represent tensors as nested FSLs (for example
6666` FixedSizeList<FixedSizeList<FixedSizeList<i32, 2>, 3>, 4> ` ), see the [ alternatives] ( #alternatives )
6767section.
6868
6969### Element Type
7070
71- We restrict tensor element types to ` Primitive ` and ` Decimal ` . Tensors are fundamentally about dense
72- numeric computation, and operations like transpose, reshape, and slicing rely on uniform, fixed-size
71+ We restrict tensor element types to ` Primitive ` . Tensors are fundamentally about dense numeric
72+ computation, and operations like transpose, reshape, and slicing rely on uniform, fixed-size
7373elements whose offsets are computable from strides.
7474
75- Variable-size types (like strings) would break this model entirely. ` Bool ` is excluded as well
76- because Vortex bit-packs boolean arrays, which conflicts with byte-level stride arithmetic. This
77- matches PyTorch, which also restricts tensors to numeric types.
75+ Variable-size types (like strings) would break this model entirely. ` Bool ` is excluded because
76+ Vortex bit-packs boolean arrays, which conflicts with byte-level stride arithmetic. ` Decimal ` is
77+ excluded because there are no fast implementations of tensor operations (e.g., matmul) for
78+ fixed-point types. This matches PyTorch, which also restricts tensors to floating-point and integer
79+ primitive types.
7880
79- Theoretically, we could allow more element types in the future, but it should remain a very low
80- priority.
81+ We could allow more element types in the future if a compelling use case arises, but it should
82+ remain a very low priority.
8183
8284### Validity
8385
84- We define two layers of nullability for tensors: the tensor itself may be null (within a tensor
85- array), and individual elements within a tensor may be null. However, we do not support nulling out
86- entire sub-dimensions of a tensor (e.g., marking a whole row or slice as null).
86+ Nullability exists only at the tensor level: within a tensor array, an individual tensor may be
87+ null, but elements within a tensor may not be. This is because tensor operations like matmul cannot
88+ be efficiently implemented over nullable elements, and most tensor libraries (e.g., PyTorch) do not
89+ support per-element nulls either.
8790
88- The validity bitmap is flat (one bit per element) and follows the same contiguous layout as the
89- backing data (just like ` FixedSizeList ` ). This keeps stride-based access straightforward while still
90- allowing sparse values within an otherwise dense tensor.
91+ Since the storage type is ` FixedSizeList ` , the validity of the tensor array is inherited from the
92+ ` FixedSizeList ` 's own validity bitmap (one bit per tensor, not per element).
9193
92- Note that this design is specifically for a dense tensor. A sparse tensor would likely need to have
93- a different representation (or different storage type) in order to compress better (likely ` List ` or
94- ` ListView ` since it can compress runs of nulls very well).
94+ This is a restriction we can relax in the future if a compelling use case arises.
9595
9696### Metadata
9797
@@ -100,12 +100,13 @@ likely also want two other pieces of information, the dimension names and the pe
100100which mimics the [ Arrow Fixed Shape Tensor] ( https://arrow.apache.org/docs/format/CanonicalExtensions.html#fixed-shape-tensor )
101101type (which is a Canonical Extension type).
102102
103- Here is what the metadata of an extension Tensor type in Vortex will look like (in Rust):
103+ Here is what the metadata of the ` FixedShapeTensor ` extension type in Vortex will look like (in
104+ Rust):
104105
105106``` rust
106- /// Metadata for a [`Tensor `] extension type.
107+ /// Metadata for a [`FixedShapeTensor `] extension type.
107108#[derive(Debug , Clone , PartialEq , Eq , Hash )]
108- pub struct TensorMetadata {
109+ pub struct FixedShapeTensorMetadata {
109110 /// The shape of the tensor.
110111 ///
111112 /// The shape is always defined over row-major storage. May be empty (0D scalar tensor) or
@@ -126,7 +127,7 @@ pub struct TensorMetadata {
126127}
127128```
128129
129- #### Stride
130+ ### Stride
130131
131132The stride of a tensor defines the number of elements to skip in memory to move one step along each
132133dimension. Rather than storing strides explicitly as metadata, we can efficiently derive them from
@@ -145,19 +146,53 @@ For example, a tensor with shape `[2, 3, 4]` and no permutation has strides `[12
145146step along dimension 0 skips 12 elements, along dimension 1 skips 4, and along dimension 2 skips 1.
146147The element at index ` [i, j, k] ` is located at memory offset ` 12*i + 4*j + k ` .
147148
148- When a permutation is present, the logical strides are simply the row-major strides permuted
149- accordingly. Continuing the ` [2, 3, 4] ` example with row-major strides ` [12, 4, 1] ` , applying the
150- permutation ` [2, 0, 1] ` yields logical strides ` [1, 12, 4] ` . This reorders which dimensions are
151- contiguous in memory without copying any data.
149+ ### Physical vs. logical shape
150+
151+ When a permutation is present, stride derivation depends on whether ` shape ` is stored as physical
152+ or logical (see [ unresolved questions] ( #unresolved-questions ) ). If ` shape ` is ** physical**
153+ (matching Arrow's convention), the process is straightforward: compute row-major strides over the
154+ stored shape, then permute them to get logical strides
155+ (` logical_stride[i] = physical_stride[perm[i]] ` ).
156+
157+ Continuing the example with physical shape ` [2, 3, 4] ` and permutation ` [2, 0, 1] ` , the physical
158+ strides are ` [12, 4, 1] ` and the logical strides are
159+ ` [physical_stride[2], physical_stride[0], physical_stride[1]] ` = ` [1, 12, 4] ` .
160+
161+ If ` shape ` is ** logical** , we must first invert the permutation to recover the physical shape
162+ (` physical_shape[perm[l]] = shape[l] ` ), compute row-major strides over that, then map them back to
163+ logical order.
164+
165+ For the same example with logical shape ` [4, 2, 3] ` and permutation ` [2, 0, 1] ` :
166+ the physical shape is ` [2, 3, 4] ` , physical strides are ` [12, 4, 1] ` , and logical strides are
167+ ` [1, 12, 4] ` .
168+
169+ We want to emphasize that this is the same result, but with an extra inversion step. In either case,
170+ logical strides are always a permutation of the physical strides.
171+
172+ The choice of whether ` shape ` stores physical or logical dimensions also affects interoperability
173+ with [ Arrow] ( #arrow ) and [ NumPy/PyTorch] ( #numpy-and-pytorch ) (see those sections for details), as
174+ well as stride derivation complexity.
175+
176+ Physical shape favors Arrow compatibility and simpler stride math. Logical shape favors
177+ NumPy/PyTorch compatibility and is arguably more intuitive for our users since Vortex has a logical
178+ type system.
179+
180+ The cost of conversion in either direction is a cheap O(ndim) permutation at the boundary, so the
181+ difference is more about convention than performance.
152182
153183### Conversions
154184
155185#### Arrow
156186
157- Since our storage type and metadata are designed to match Arrow's Fixed Shape Tensor canonical
158- extension type, conversion to and from Arrow is zero-copy (for tensors with at least one dimension).
159- The ` FixedSizeList ` backing memory, shape, dimension names, and permutation all map directly between
160- the two representations.
187+ Our storage type and metadata are designed to closely match Arrow's Fixed Shape Tensor canonical
188+ extension type. The ` FixedSizeList ` backing buffer, dimension names, and permutation pass through
189+ unchanged, making the data conversion itself zero-copy (for tensors with at least one dimension).
190+
191+ Arrow stores ` shape ` as ** physical** (the dimensions of the row-major layout). Whether the ` shape `
192+ field passes through directly depends on the outcome of the
193+ [ physical vs. logical shape] ( #physical-vs-logical-shape ) open question. If Vortex adopts the same
194+ convention, shape maps directly. If Vortex stores logical shape instead, conversion requires a
195+ cheap O(ndim) scatter: ` arrow_shape[perm[i]] = vortex_shape[i] ` .
161196
162197#### NumPy and PyTorch
163198
@@ -169,18 +204,28 @@ memory with the original without copying. However, this means that non-contiguou
169204anywhere, and kernels must handle arbitrary stride patterns. PyTorch supposedly requires many
170205operations to call ` .contiguous() ` before proceeding.
171206
172- Since Vortex tensors are always contiguous, we can always zero-copy _ to_ NumPy and PyTorch since
173- both libraries can construct a view from a pointer, shape, and strides. Going the other direction,
174- we can only zero-copy _ from_ NumPy/PyTorch tensors that are already contiguous.
207+ NumPy and PyTorch store ` shape ` as ** logical** (the dimensions the user indexes with). If Vortex
208+ also stores logical shape, the shape field passes through unchanged. If Vortex stores physical
209+ shape, a cheap O(ndim) permutation is needed at the boundary (see
210+ [ physical vs. logical shape] ( #physical-vs-logical-shape ) ).
211+
212+ Since Vortex fixed-shape tensors always have dense backing memory, we can always zero-copy _ to_
213+ NumPy and PyTorch by passing the buffer pointer, logical shape, and logical strides. A permuted
214+ Vortex tensor will appear as a non-C-contiguous view in these libraries, which they handle natively.
175215
176- Our proposed design for Vortex Tensors will handle non-contiguous operations differently than the
216+ Going the other direction, we can zero-copy _ from_ any NumPy/PyTorch tensor whose memory is dense
217+ (no gaps), even if it is not C-contiguous. A Fortran-order or otherwise permuted tensor can be
218+ represented by deriving the appropriate permutation from its strides. Only tensors with actual
219+ memory gaps (e.g., strided slices like ` arr[::2] ` ) require a copy.
220+
221+ Our proposed design for Vortex ` FixedShapeTensor ` will handle operations differently than the
177222Python libraries. Rather than mutating strides to create non-contiguous views, operations like
178- slicing, indexing, and ` .contiguous() ` (after a permutation) would be expressed as lazy
179- ` Expression ` s over the tensor.
223+ slicing, indexing, and reordering dimensions would be expressed as lazy ` Expression ` s over the
224+ tensor.
180225
181226These expressions describe the operation without materializing it, and when evaluated, they produce
182- a new contiguous tensor. This fits naturally into Vortex's existing lazy compute system, where
183- compute is deferred and composed rather than eagerly applied.
227+ a new tensor with dense backing memory . This fits naturally into Vortex's existing lazy compute
228+ system, where compute is deferred and composed rather than eagerly applied.
184229
185230The exact mechanism for defining expressions over extension types is still being designed (see
186231[ RFC #0005 ] ( https://github.com/vortex-data/rfcs/pull/5 ) ), but the intent is that tensor-specific
@@ -197,7 +242,7 @@ elements in a tensor is the product of its shape dimensions, and that the
197242
1982430D tensors have an empty shape ` [] ` and contain exactly one element (since the product of no
199244dimensions is 1). These represent scalar values wrapped in the tensor type. The storage type is
200- ` FixedSizeList<p, 1> ` (which is identical to a flat ` PrimitiveArray ` or ` DecimalArray ` ).
245+ ` FixedSizeList<p, 1> ` (which is identical to a flat ` PrimitiveArray ` ).
201246
202247#### Size-0 dimensions
203248
@@ -225,12 +270,12 @@ leave this as an open question.
225270### Scalar Representation
226271
227272Once we add the ` ScalarValue::Array ` variant (see tracking issue
228- [ vortex #6771 ] ( https://github.com/vortex-data/vortex/issues/6771 ) ), we can easily pass around tensors
229- as ` ArrayRef ` scalars as well as lazily computed slices.
273+ [ vortex #6771 ] ( https://github.com/vortex-data/vortex/issues/6771 ) ), we can easily pass around
274+ fixed-shape tensors as ` ArrayRef ` scalars as well as lazily computed slices.
230275
231276The ` ExtVTable ` also requires specifying an associated ` NativeValue<'a> ` Rust type that an extension
232- scalar can be unpacked into. We will want a ` NativeTensor <'a>` type that references the backing
233- memory of the Tensor , and we can add useful operations to that type.
277+ scalar can be unpacked into. We will want a ` NativeFixedShapeTensor <'a>` type that references the
278+ backing memory of the tensor , and we can add useful operations to that type.
234279
235280## Compatibility
236281
@@ -242,8 +287,8 @@ compatibility concerns.
242287- ** Fixed shape only** : This design only supports tensors where every element in the array has the
243288 same shape. Variable-shape tensors (ragged arrays) are out of scope and would require a different
244289 type entirely.
245- - ** Yet another crate** : We will likely implement this in a ` vortex-tensor ` crate, which means even
246- more surface area than we already have.
290+ - ** Yet another crate** : We will likely implement this in a ` vortex-tensor ` crate, which means
291+ even more surface area than we already have.
247292
248293## Alternatives
249294
@@ -301,6 +346,12 @@ _Note: This section was Claude-researched._
301346 shape and stride metadata. Our design is a subset of this model — we always require contiguous
302347 memory and derive strides from shape and permutation, as discussed in the
303348 [ conversions] ( #conversions ) section.
349+ - ** [ xarray] ( https://docs.xarray.dev/en/stable/ ) ** extends NumPy with named dimensions and
350+ coordinate labels. Its
351+ [ data model] ( https://docs.xarray.dev/en/stable/user-guide/terminology.html ) attaches names to each
352+ dimension and associates "coordinate" arrays along those dimensions (e.g., latitude and longitude
353+ values for the rows and columns of a temperature matrix). Our ` dim_names ` metadata is a subset of
354+ xarray's model; coordinate arrays could be a future extension.
304355- ** [ ndindex] ( https://quansight-labs.github.io/ndindex/index.html ) ** is a Python library that
305356 provides a unified interface for representing and manipulating NumPy array indices (slices,
306357 integers, ellipses, boolean arrays, etc.). It supports operations like canonicalization, shape
@@ -312,11 +363,14 @@ _Note: This section was Claude-researched._
312363
313364- ** TACO (Tensor Algebra Compiler)** separates the tensor storage format from the tensor program.
314365 Each dimension can independently be specified as dense or sparse, and dimensions can be reordered.
315- The Vortex approach of storing tensors as flat contiguous memory with a permutation is one specific
316- point in TACO's format space (all dimensions dense, with a specific dimension ordering).
366+ The Vortex approach of storing tensors as flat contiguous memory with a permutation is one
367+ specific point in TACO's format space (all dimensions dense, with a specific dimension ordering).
317368
318369## Unresolved Questions
319370
371+ - Should ` shape ` store physical dimensions (matching Arrow) or logical dimensions (matching
372+ NumPy/PyTorch)? See the [ physical vs. logical shape] ( #physical-vs-logical-shape ) discussion in
373+ the stride section. The current RFC assumes physical shape, but this is not finalized.
320374- Are two tensors with different permutations but the same logical values considered equal? This
321375 affects deduplication and comparisons. The type metadata might be different but the entire tensor
322376 value might be equal, so it seems strange to say that they are not actually equal?
@@ -333,8 +387,26 @@ like batched sequences of different lengths.
333387
334388#### Sparse tensors
335389
336- A similar Sparse Tensor type could use ` List ` or ` ListView ` as its storage type to efficiently
337- represent tensors with many null or zero elements, as noted in the [ validity] ( #validity ) section.
390+ A sparse tensor type could use ` List ` or ` ListView ` as its storage type to efficiently represent
391+ tensors with many zero or absent elements.
392+
393+ #### A unified ` Tensor ` type
394+
395+ This RFC proposes ` FixedShapeTensor ` as a single, concrete extension type. However, tensors
396+ naturally vary along two axes: shape (fixed vs. variable) and density (dense vs. sparse). Both a
397+ variable-shape tensor (fixed dimensionality, variable shape per element) and a sparse tensor would
398+ need a different storage type, since it needs to efficiently skip over zero or null regions (and
399+ for both this would likely be ` List ` or ` ListView ` ).
400+
401+ Each combination would be its own extension type (` FixedShapeTensor ` , ` VariableShapeTensor ` ,
402+ ` SparseFixedShapeTensor ` , etc.), but this proliferates types and fragments any shared tensor logic.
403+ With the matching system on extension types, we could instead define a single unified ` Tensor ` type
404+ that covers all combinations, dispatching to the appropriate storage type and metadata based on the
405+ specific variant. This would be more complex to implement but would give users a single type to work
406+ with and a single place to define tensor operations.
407+
408+ For now, ` FixedShapeTensor ` is the only variant we need. The others can be added incrementally
409+ as use cases arise.
338410
339411#### Tensor-specific encodings
340412
0 commit comments