Zero-Copy between C++ and Python

The SimpleArray family shares memory with numpy in both directions without copying. A typed array can wrap the memory of an existing ndarray, and every array exposes its memory back to numpy through the ndarray property and the Python buffer protocol. This page defines the sharing semantics: which source layouts are accepted, which are rejected, how lifetimes are tied together, and how the complex element types map to numpy dtypes. The constructor forms themselves are defined in Construction and Data Types; this page covers the behavior of the shared memory.

Wrapping an Ndarray Zero-Copy

The typed array= constructor form wraps the memory of a numpy array instead of allocating. The wrap direction diverges from numpy: numpy.asarray converts the dtype and copies when it must, while the typed wrap never copies and never converts. It either shares the memory of the source exactly as it is laid out, or raises (see the rejection rules below). The array and the wrapper address the same elements, so a mutation made through either side is visible through the other:

import numpy as np
ndarr = np.arange(24, dtype='float64').reshape((2, 3, 4))
sarr = solvcon.SimpleArrayFloat64(array=ndarr)

sarr.ndarray.fill(1)
assert (ndarr == 1).all()
sarr[0, 0, 0] = 10
assert ndarr[0, 0, 0] == 10

Lifetime

The wrapper keeps the source memory alive. When the source is an array that owns its memory, the wrapper holds a reference to it; when the source is a view, the wrapper walks the base chain of the view and holds the outermost ndarray of the chain. When the chain ends at an ndarray that owns its memory, the held array is the memory owner; when the outermost ndarray is itself built over another exporter, the true owner stays alive transitively through the held ndarray. Either way the wrapped memory outlives the Python names of both the view and the owner:

src = np.arange(24, dtype='float64').reshape((2, 3, 4))
sarr = solvcon.SimpleArrayFloat64(array=src[1:, ::2])
del src
assert sarr[0, 0, 0] == 12.0  # the owning array is kept alive

The is_from_python property reports the provenance: True for an array wrapping numpy memory and False for an array that allocated its own buffer. clone() always allocates, so the clone of a wrapping array reports False and detaches from the numpy source.

Supported Source Layouts

The wrap accepts the strided layouts numpy produces over a non-empty block of elements, not only C-contiguous ones:

  • C-ordered (row-major) arrays.

  • Fortran-ordered (column-major) arrays.

  • Sliced views, including slices with a step and views of a subrange.

  • Views with negative strides, such as reversed slices.

  • Transposed views.

“Supported” means the wrapper records the shape and stride of the source view and addresses exactly the viewed elements: element reads agree with the source view index by index, reductions such as sum() cover only the viewed elements, and a write through the wrapper lands in the viewed region of the original array and nowhere else:

ndarr = np.arange(1000, dtype='float64').reshape((10, 10, 10))
view = ndarr[1:7:3, 6:2:-1, 3:9]
sarr = solvcon.SimpleArrayFloat64(array=view)
assert sarr.shape == (2, 4, 6)
assert sarr[0, 0, 0] == view[0, 0, 0]
assert sarr.stride == (300, -10, 1)  # elements
assert view.strides == (2400, -80, 8)  # numpy reports bytes

The wrapper records the stride of the view in elements, per the stride convention defined in Construction and Data Types, where numpy strides reports bytes; the last two lines above show the same view in both units.

The is_c_contiguous and is_f_contiguous properties report the layout of the wrapped view, and a degenerate shape reports both as True, as defined in Construction and Data Types. An empty source wraps whatever its extents are, in either memory order, and also reports both flags as True, matching how numpy flags an empty array:

sarr = solvcon.SimpleArrayFloat64(array=np.empty((0, 3), dtype='float64'))
assert sarr.shape == (0, 3)
assert sarr.stride == (0, 0)
assert sarr.is_c_contiguous and sarr.is_f_contiguous

The wrapped stride is zero in every dimension because an empty source addresses no element; (3, 0), (0, 0), and (3, 0, 2) wrap the same way.

Rejection Rules

The typed wrap validates the source before sharing its memory. The source must be writable; a read-only array, including the views produced by numpy.broadcast_to, raises ValueError:

ndarr = np.arange(6, dtype='float64')
ndarr.setflags(write=False)
solvcon.SimpleArrayFloat64(array=ndarr)
# ValueError: array is not writeable

Beyond writability, the wrap raises RuntimeError in three cases. First, the dtype of the source must equal the element type of the class; no conversion is attempted:

ndarr = np.arange(24, dtype='float64').reshape((2, 3, 4))
solvcon.SimpleArrayInt8(array=ndarr)
# RuntimeError: dtype mismatch

Second, the byte stride of every dimension must be divisible by the item size, so that the stride can be represented in whole elements:

ndarr = np.ndarray((3,), dtype='int32', buffer=bytearray(range(32)),
                   strides=(1,))
solvcon.SimpleArrayInt32(array=ndarr)
# RuntimeError: NumPy byte stride 1 in dimension 0 is not divisible by
# item size 4

Third, the data pointer of the source must be aligned for the element type:

ndarr = np.ndarray((3,), dtype='int32', buffer=bytearray(range(32)),
                   offset=1)
solvcon.SimpleArrayInt32(array=ndarr)
# RuntimeError: NumPy data pointer is not aligned for item alignment 4

Writability aside, these three are the only rejections. In particular an empty source is not one: the constructor’s stride check treats a zero-extent axis as contiguous, so an empty shape wraps whatever its other extents are, as the supported-layout section above shows.

These rules apply to the typed array= form. The dtype-erased SimpleArray constructor infers the dtype from the source instead of checking it, as Construction and Data Types states.

The Ndarray Property

Every typed array exposes the ndarray property: a zero-copy numpy view of the array’s memory. The view matches numpy exactly, because it is a numpy array: it carries the dtype of the element type, the shape of the array, and the stride converted to bytes. It is writable, and a mutation made through either the view or the array is visible through the other:

sarr = solvcon.SimpleArrayFloat64((2, 3, 4))
ndarr = sarr.ndarray
assert ndarr.dtype == np.float64
assert ndarr.shape == (2, 3, 4)
sarr[0, 0, 0] = 5.0
assert ndarr[0, 0, 0] == 5.0
ndarr[1, 2, 3] = 7.0
assert sarr[1, 2, 3] == 7.0

The view holds a reference to the array’s ConcreteBuffer as its base, so the memory stays alive as long as the view does, even after the array object itself is released. The property reflects the layout of the array it is taken from: the view of a Fortran-ordered or negative-stride wrapper carries the same strides as the source, and the view of a transposed or reshaped array describes that array’s layout.

On an array carrying a ghost region, the property diverges from the array’s own indexing: the view spans the full first dimension, including the ghost region, and indexes from the start of storage. Element -nghost of the array is element zero of the view:

sarr = solvcon.SimpleArrayInt8(8)
sarr.ndarray[:] = np.arange(8, dtype='int8')
sarr.nghost = 3
assert sarr[-3] == 0   # first ghost element
assert sarr[0] == 3    # first body element
assert sarr.ndarray[0] == 0  # the view starts at the ghost region

The Buffer Protocol

The typed classes implement the Python buffer protocol, matching numpy: any consumer of the protocol sees the array’s dtype, shape, and byte strides. numpy.array with copy=False builds a shared view equivalent to the ndarray property, and memoryview exposes the same description:

sarr = solvcon.SimpleArrayFloat64((2, 3))
ndarr = np.array(sarr, copy=False)
assert ndarr.dtype == np.float64
ndarr[1, 2] = 7.0
assert sarr[1, 2] == 7.0

view = memoryview(sarr)
assert view.format == 'd'
assert view.shape == (2, 3)
assert view.strides == (24, 8)  # bytes, per the protocol

The protocol ignores a ghost region the same way the ndarray property does: the description spans the full first dimension and starts at the first ghost element, so numpy code sees one plain array and the partition exists only in the array’s own subscript arithmetic.

Without copy=False, numpy.array copies by default, and the copy does not share memory with the array. The protocol also describes non-contiguous arrays faithfully; comparing the memoryview of two arrays compares the elements they address, so a transposed view compares equal to an array transposed in place over the same memory.

Complex Dtypes

The complex element types match numpy at the interoperation boundary. SimpleArrayComplex64 maps to numpy.complex64 and SimpleArrayComplex128 to numpy.complex128: the ndarray property and the buffer protocol both present the standard numpy complex dtypes, so a shared view reads and writes numpy complex scalars directly:

sarr = solvcon.SimpleArrayComplex64(4)
sarr.fill(solvcon.complex64(real=1.5, imag=2.5))
assert sarr.ndarray.dtype == np.complex64
ndarr = np.array(sarr, copy=False)
ndarr[1] = 3 + 4j
assert complex(sarr[1]) == 3 + 4j

Element reads through the array return solvcon’s own scalar types, solvcon.complex64 and solvcon.complex128, which carry real and imag attributes and convert to the Python built-in through complex(). Element writes accept both spellings: a solvcon complex scalar or a Python or numpy complex value:

sarr = solvcon.SimpleArrayComplex128(2)
sarr[0] = solvcon.complex128(1.0, 2.0)
sarr[1] = 3 + 4j
assert type(sarr[0]) is solvcon.complex128
assert complex(sarr[1]) == 3 + 4j

Each scalar type reports its numpy dtype through the dtype() method on the class: solvcon.complex64.dtype() equals numpy.dtype('complex64') and likewise for complex128. Wrapping a complex ndarray with the array= form follows the same rules as the other dtypes, including the exact-match dtype check: a complex64 source wraps only into SimpleArrayComplex64, and passing it to SimpleArrayComplex128 raises RuntimeError.