Skip to content

Tensors and views

Two kinds of object hold the elements of an array in strata. A tensor owns them. A view only looks at memory that someone else owns: a std::vector, a std::array, a buffer from a library, or a tensor. Both have the same interface, shapes, indexing, slices, so a function can take either.

Every code block on this page is part of examples/12_tensors.cpp, which is built and run with the tests. The output shown is its output.

using strata::block;
using strata::Dense;
using strata::Dyn;
using strata::Matrix;
using strata::MatrixX;
using strata::permute;
using strata::RowMajor;
using strata::seq;
using strata::Shape;
using strata::slice;
using strata::Strided;
using strata::Tensor;
using strata::TensorView;

Making a tensor

Tensor<T, S, O, Memory> is the tensor: T is the element type, S the shape, O the order in memory and Memory says where the elements come from. The last two have defaults, so most code uses the aliases:

  • Matrix<T, N, M>: N rows, M columns; SquareMatrix<T, N>
  • Vector<T, N>, and ColVector<T, N> and RowVector<T, M>, which are N x 1 and 1 x M
  • MatrixX<T> and VectorX<T>, whose sizes are all dynamic
// 2 x 3, every element value-initialized: 0.
Matrix<float, 2, 3> a;
// A shape known at run time, and every element 0.5.
const MatrixX<float> b{Shape<Dyn, Dyn>{2, 3}, 0.5F};
// Row 1, column 2.
a[1, 2] = 5.0F;
std::println("a:\n{}", a);
std::println("b: {:l}", b);
a:
[[0, 0, 0],
 [0, 0, 5]]
b: [[0.5, 0.5, 0.5], [0.5, 0.5, 0.5]]

A dynamic shape is given when the tensor is made; a static one is already in the type. Elements are value-initialized, as std::vector does it, so a starts at zero. a[1, 2] takes one index per axis, in the order of the shape.

Memory you are going to write before you read it can be left alone. uninitialized skips the initialization, for types that need none:

// Memory that is written before it is read.
Tensor<float, Shape<Dyn>> buffer{Shape<Dyn>{1000}, strata::uninitialized};
strata::assign(buffer, strata::linspace(0.0F, 1.0F, 1000));
std::println("buffer: {} elements, the last {}", buffer.size(), buffer[999]);
buffer: 1000 elements, the last 1

A tensor can also be made from an expression: Matrix c = a + b; computes the sum straight into new memory. Lists, nested lists and ranges make one too: Tensor<int, Shape<2, 3>> {{1, 2, 3}, {4, 5, 6}} is written as the matrix is read, row by row, and Tensor t{std::from_range, numbers} copies any range in the order of the memory. A dynamic tensor changes shape with resize, which keeps nothing, and conservative_resize, which keeps the elements that are at a position of both shapes. Example 16 goes through all of them.

Order in memory

Where element [i, j] sits in memory is a choice of the tensor's order. ColumnMajor is the default: the first axis varies fastest, as in Eigen and Fortran. RowMajor makes the last axis vary fastest, as in C and NumPy. A general order, Order<1, 0, 2>, lists the axes from the fastest to the slowest.

// The element at (i, j) is 10 i + j.
const auto value = [](std::size_t i, std::size_t j) { return static_cast<int>((10 * i) + j); };
const Tensor<int, Shape<2, 3>> column_major = strata::generate(Shape<2, 3>{}, value);
const Tensor<int, Shape<2, 3>, RowMajor> row_major = strata::generate(Shape<2, 3>{}, value);
std::println("column-major memory: {}", std::span{column_major.data(), 6});
std::println("row-major memory:    {}", std::span{row_major.data(), 6});
std::println("element (1, 2) of both: {} and {}", column_major[1, 2], row_major[1, 2]);
column-major memory: [0, 10, 1, 11, 2, 12]
row-major memory:    [0, 1, 2, 10, 11, 12]
element (1, 2) of both: 12 and 12

The order never changes what an index means: [1, 2] is the same element in both. It changes what is fast. Walk a tensor along its fastest axis when you can; strata's own loops do.

Where the elements live

By default strata aligns tensor memory to 64 bytes, a cache line. A small tensor of a static shape keeps its elements inside the object, with no allocation at all; every other tensor takes them from the heap, through an allocator that keeps the alignment.

The Memory parameter changes that: a stronger alignment (Aligned<128>), any allocator, or std::pmr::polymorphic_allocator<> for an arena (strata::pmr::Matrix and its relatives are the aliases for that). The Memory reference lists the pieces, and 04_memory shows each in a program.

Views

TensorView<T, S, Layout> is a pointer and a shape. It never owns, never allocates and never copies: making one costs nothing. With const T it only reads.

// Twelve numbers in a vector, filled through a flat view of it...
std::vector<double> numbers(12);
strata::assign(TensorView{numbers.data(), Shape<Dyn>{numbers.size()}}, strata::iota<double>(12));
// ... and seen as a row-major 3 x 4 matrix.
const TensorView<double, Shape<Dyn, Dyn>, Dense<RowMajor>> matrix{numbers.data(), Shape<Dyn, Dyn>{3, 4}};
std::println("{}", matrix);
[[ 0,  1,  2,  3],
 [ 4,  5,  6,  7],
 [ 8,  9, 10, 11]]

Twelve numbers in a std::vector are seen as a row-major 3 × 4 matrix, as a C array or a NumPy array would be. The third parameter, the layout, says how indices become offsets: Dense<O> is one contiguous block in order O (Dense<> is column-major), and Strided<...> takes a stride per axis, for memory with gaps in it.

A tensor becomes a view wherever one is wanted, or by tensor.view().

Slices, blocks and transposes

Taking part of a view gives another view, of the same memory:

// Row 1 and column 2: one axis less. The column steps over the rows.
const auto row = matrix | slice<0>(1);
const auto column = matrix | slice<1>(2);
// A block of 2 x 2 from (1, 1); and one with a size known only at run time: rows 0, 1 and 2.
const auto square = matrix | block(seq<2>{1}, seq<2>{1});
const auto tall = matrix | block(seq{0, 3}, seq<2>{2});
// The axes swapped: 4 x 3.
const auto transposed = matrix | permute<1, 0>;
std::println("row 1: {:l}", row);
std::println("column 2: {:l}", column);
std::println("block: {:l}", square);
std::println("tall: {:l}", tall);
std::println("transposed: {}", transposed.shape());
row 1: [4, 5, 6, 7]
column 2: [2, 6, 10]
block: [[5, 6], [9, 10]]
tall: [[2, 3], [6, 7], [10, 11]]
transposed: (4, 3)
  • slice<Axis>(i) fixes one index and drops that axis: a row or a column of a matrix.
  • block(seq<2>{i}, seq<2>{j}) is a box, with its corner at the first index of each seq. seq<2>{i} is two indices from i, a size in the type; a size known only at run time is a seq{first, last}, a half-open range.
  • permute<1, 0> reorders the axes: a transpose, for a matrix. The memory stays where it is.

Each is called on a tensor, or piped to: matrix | slice<0>(1) is slice<0>(matrix, 1).

The same three work on tensors and on expressions, and nothing is copied.

Because a view is the original memory, writing through one writes the original:

// Assigning to a view writes its memory: the top-left block becomes ten times the one at (1, 2),
// and the vector changes. `+=` and the other operators of the elements work the same way.
const auto top_left = matrix | block(seq<2>{0}, seq<2>{0});
top_left = (matrix | block(seq<2>{1}, seq<2>{2})) * 10.0;
top_left += 1.0;
std::println("numbers[0] = {}, numbers[5] = {}", numbers[0], numbers[5]);
numbers[0] = 60, numbers[5] = 110

One function for every layout

Views convert towards knowing less about the same memory: const is added, static extents become dynamic, a dense layout becomes strided. Never the other way. So a view with a dynamic shape and dynamic strides accepts them all, and a function written once serves a tensor, a row-major matrix, a block and a transpose:

// Any matrix of doubles, whatever its layout: a tensor, a block, a transpose.
using AnyMatrix = TensorView<const double, Shape<Dyn, Dyn>, Strided<Dyn, Dyn>>;

auto trace(AnyMatrix matrix) -> double {
    double total = 0;
    for (std::size_t i = 0; i < std::min(matrix.extent(0), matrix.extent(1)); ++i) {
        total += matrix[i, i];
    }
    return total;
}
const Tensor<double, Shape<Dyn, Dyn>> owned{Shape<Dyn, Dyn>{2, 2}, 1.5};
std::println("traces: view {}, block {}, transpose {}, tensor {}", trace(matrix), trace(square), trace(transposed),
             trace(owned));
traces: view 180, block 120, transpose 180, tensor 3

What is checked

An index outside its axis, a slice index past the extent, a block that does not fit: each is a precondition violation, a compile error in constant evaluation and an abort at run time in a build without NDEBUG.

A view of a temporary tensor would outlive the elements it points at, so for one the slice is not a view: it holds the tensor, and reads. A slice of a tensor that is named is a view, and writes:

// A slice of a tensor that is named is a view of its memory: reading gives a reference, writing writes.
// A slice of a temporary cannot be: its elements would be gone, so the slice holds the tensor and only
// reads.
using Square = Tensor<double, Shape<2, 2>>;
static_assert(std::is_lvalue_reference_v<decltype(slice<0>(std::declval<Square&>(), 0)[0])>);
static_assert(!std::is_lvalue_reference_v<decltype(slice<0>(std::declval<Square>(), 0)[0])>);

Writing through a view

Assigning to a view writes the memory it looks at, and never makes the view look elsewhere. = and every compound operator, += to >>=, take a scalar, a tensor or an expression of the view's shape, and combine it with the elements as the built-in operator would:

const auto top_left = matrix | block(seq<2>{0}, seq<2>{0});
top_left = (matrix | block(seq<2>{1}, seq<2>{2})) * 10.0;
top_left += 1.0;

A tensor takes them the same way. Where the expression reads the memory it writes at other positions (m = m | permute<1, 0>), it is computed into a buffer first; example 15 shows the operators one by one, and example 17 a heat equation on a plate written with overlapping blocks of one tensor.

See also