跳轉到

The SimpleArray Data Model

SimpleArray has a direct goal: build a NumPy-like array that can outperform NumPy on Solvcon's numerical workloads. It does not reproduce the whole NumPy API. Instead, it keeps a simplified version of NumPy's view architecture, then exposes those facts to an operation layer that selects a kernel and independently decides whether to pack an input.

From Python to C++

Text Only
Python call
  SimpleArrayFloat64(array=view)
        ↓ pybind11 array= binding
C++ factory
  make_array_from_numpy(py::array&)
        ↓ returns
SimpleArray<double> state
        ├─ SignedStrideLayout → mdspan
        ├─ ArrayPropertyHelper
        │    → index / assign
        └─ MatmulPlan + MatmulExecutor
             → kernel + packing

The Python call constructs the C++ object directly:

Python
import numpy as np
import solvcon

base = np.arange(6, dtype=np.float64).reshape(2, 3)
view = base[::-1, ::-1]

a = solvcon.SimpleArrayFloat64(array=view)

WrapSimpleArray<double> registers the pybind11 class. This contiguous part of the binding chain connects construction, the buffer protocol, cloning, and NumPy export to C++ functions:

wrap_SimpleArray.hpp
.def(py::init(&WrapSimpleArray::make_array_from_numpy), py::arg("array"))
.def_buffer(&property_helper::get_buffer_info)
.def("clone",
     [](wrapped_type const & self)
     { return wrapped_type(self); })
.def_property_readonly(
    "ndarray",
    [](wrapped_type & self)
    { return to_ndarray(self); })

Later in the same binding chain, assignment is connected separately:

wrap_SimpleArray.hpp
.def("__setitem__", &property_helper::setitem_parser)

pybind11 converts view to py::array &, calls make_array_from_numpy(), and keeps the returned SimpleArray<double> behind the Python handle. __setitem__ calls the C++ parser, while ndarray calls to_ndarray() for the opposite direction.

How Python finds the C++ class

The compiled extension initializes the C++ bindings:

module.cpp
PYBIND11_MODULE(_solvcon, mod) // NOLINT
{
    solvcon::python::initialize(mod);
}

The buffer initializer registers SimpleArray<double> under its Python name:

wrap_SimpleArray_float.cpp
WrapSimpleArray<double>::commit(
    mod, "SimpleArrayFloat64", "SimpleArrayFloat64");

solvcon.core loads that symbol from _solvcon, and solvcon.__init__ re-exports it as solvcon.SimpleArrayFloat64.

Two terms in this guide describe roles, not additional classes. Descriptor means the state stored by SimpleArray<T>: buffer, logical origin, shape, strides, and ghost/body state. Adapter means boundary code that constructs or presents that state in another interface. make_array_from_numpy() and to_ndarray() form the NumPy boundary. as_mdspan() is the C++ view adapter, and SignedStrideLayout supplies its signed-stride mapping. WrapSimpleArray is the pybind11 registration helper, and ArrayPropertyHelper handles Python keys. The operation plan is a consumer of the descriptor, not another adapter.

The descriptor in code

SimpleArray.hpp
std::shared_ptr<buffer_type> m_buffer;
shape_type m_shape;
shape_type m_stride;

value_type * m_logical_data = nullptr;
ssize_t m_nghost = 0;
value_type * m_body = nullptr;

ConcreteBuffer owns or retains one byte span. The fields have distinct roles:

field meaning reversed-view example
data() beginning of the retained buffer root + 0
logical_data() descriptor coordinate (0,...,0) root + 5
shape valid coordinate domain (2,3)
stride element offset for one axis step (-3,-1)
body() body origin on axis 0 logical_data + nghost * stride[0]

Keeping data() and logical_data() separate is essential. The first belongs to storage lifetime; the second belongs to coordinate mapping. In the reversed example, coordinate (0,0) moves to root+5 and both axis directions change, but no values move.

SimpleArray stores the ConcreteBuffer base separately from the logical origin, shape, and signed strides of a reversed view
One retained span, one logical origin, and signed steps through storage.

Every coordinate uses the same rule:

\[ \begin{aligned} \operatorname{offset}(i) &= \sum_d i_d\operatorname{stride}[d], \\ \operatorname{address}(i) &= \operatorname{logical\_data} + \operatorname{offset}(i). \end{aligned} \]

For the reversed view:

Text Only
address(1, 2) = root + 5 + 1·(-3) + 2·(-1) = root + 0

Inside the NumPy import adapter

make_array_from_numpy() receives the py::array from the binding. NumPy reports byte strides, so the adapter converts them to element strides and finds the complete reachable byte envelope:

wrap_SimpleArray.hpp
solvcon::detail::shape_type shape;
solvcon::detail::shape_type stride;
constexpr auto itemsize = static_cast<ssize_t>(sizeof(value_type));
ssize_t byte_span_begin = 0;
ssize_t byte_span_end = 0;
bool has_element = true;
for (ssize_t i = 0; i < arr_in.ndim(); ++i)
{
    shape.push_back(arr_in.shape(i));
    ssize_t const byte_stride = arr_in.strides(i);
    if (byte_stride % itemsize != 0)
    {
        throw std::runtime_error(
            std::format("NumPy byte stride {} in dimension {} is not divisible by item size {}",
                        byte_stride, i, itemsize));
    }
    stride.push_back(byte_stride / itemsize);
    if (shape[i] == 0)
    {
        has_element = false;
        continue;
    }
    ssize_t const axis_byte_offset = (shape[i] - 1) * byte_stride;
    if (axis_byte_offset < 0)
    {
        byte_span_begin += axis_byte_offset;
    }
    else
    {
        byte_span_end += axis_byte_offset;
    }
}
if (!has_element)
{
    byte_span_begin = 0;
    byte_span_end = 0;
}

After retaining an ndarray base-chain anchor and checking alignment, the adapter constructs the storage and descriptor:

wrap_SimpleArray.hpp
char * storage_ptr = view_ptr + byte_span_begin;
const size_t storage_nbytes = has_element
                                  ? static_cast<size_t>(byte_span_end - byte_span_begin + itemsize)
                                  : 0;
const auto data_offset = static_cast<size_t>(-byte_span_begin);
auto remover = std::make_unique<ConcreteBufferNdarrayRemover>(owner);
const auto buffer = ConcreteBuffer::construct(storage_nbytes, storage_ptr, std::move(remover));
return wrapped_type(shape, stride, buffer, data_offset, array_order);

The envelope may contain padding holes, so its byte size need not equal prod(shape) * sizeof(T). logical_data is reconstructed from data_offset.

Lifetime and copy

The imported view and a NumPy round trip share the same bytes:

Python
a[1, 2] = -1
assert base[0, 0] == -1

roundtrip = a.ndarray
assert np.shares_memory(base, roundtrip)

clone() is the explicit deep-copy path. It clones the complete retained ConcreteBuffer and preserves the logical_data - data offset, shape, and strides.

Next steps

Sources