跳轉到

A NumPy-like Array in C++

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

arr = solvcon.SimpleArrayFloat64(array=view)
roundtrip = arr.ndarray

view 只有 12 個 logical elements,可到達的位址卻從 base[0,0] 橫跨到 base[3,4],共 23 個 double。第一個 element 位於 offset 18;換到下一列 要往回走 6 格,換到下一欄則向前走 2 格。

C++ 如果只收到 pointer 和 shape,這個 view 會立刻失去原來的意義。要在 pybind11 邊界保留它,不需要 copy values,而是需要完整保存一個 address mapping。

View

Text Only
view
[[18. 20. 22.]
 [12. 14. 16.]
 [ 6.  8. 10.]
 [ 0.  2.  4.]]

NumPy shape / byte strides       (4, 3) / (-48, 16)
SimpleArray shape / strides      (4, 3) / ( -6,  2)
logical origin                   base + 18 elements

同一個 view 在兩側保存相同資訊,只有 stride 的單位不同。NumPy 與 Python buffer protocol 使用 bytes;SimpleArray<T> 使用 elements。

Text Only
address(i, j) = logical_origin + i * (-6) + j * 2

例如 (i,j)=(1,2) 對應 18 - 6 + 4 = 16,也就是 base[2,4]

Buffer

ConcreteBuffer 管理一段 fixed-size contiguous byte span。它可以配置自己的 storage,也可以指向外部 storage 並攜帶一個 remover。typed array 則在這段 storage 上保存 descriptor:

C++
std::shared_ptr<buffer_type> m_buffer;
shape_type m_shape;

// Number of elements, not bytes, skipped along each axis.
shape_type m_stride;

// Address corresponding to index (0, ..., 0).
value_type * m_logical_data = nullptr;

因此 buffer 與 array 不是同一件事:buffer 回答「哪些 bytes 必須保持有效」, descriptor 回答「logical index 如何找到其中一個 typed value」。dtype 由模板 參數 T 決定。

對 running example,view 有 12 個 logical elements,所以 logical payload arr.nbytes == 96; 可到達的 storage span 卻是 offsets 0..22,所以 roundtrip.base.nbytes == 184。 原始 base 的 offset 23 不會被這個 view 觸及,也不需要包進 buffer。

Shape

shape 定義合法的 coordinate domain:

\[ 0 \le i_d < \operatorname{shape}[d]. \]

(4,3) 因此有 12 個 logical elements。它沒有說明元素是否相鄰、走訪方向, 也不能推導 backing span 的 byte size。這些資訊屬於 stride 與 origin。

C-contiguous array 的 default element strides 可由 shape 從右往左累乘:

C++
stride[shape.size() - 1] = 1;
for (size_t axis = shape.size() - 1; axis > 0; --axis)
{
    stride[axis - 1] = stride[axis] * shape[axis];
}

shape (4,6) 因而得到 (6,1)。slice [::-1, ::2] 不改動 buffer,只把 descriptor 改成 shape (4,3)、stride (-6,2) 與新的 logical origin。

Stride

NumPy importer 先驗證 byte stride 可由 element size 整除,再轉成 element stride:

C++
ssize_t const byte_stride = arr_in.strides(axis);
if (byte_stride % itemsize != 0)
{
    throw std::runtime_error("stride is not divisible by item size");
}
stride.push_back(byte_stride / itemsize);
representation unit running example
NumPy ndarray.strides bytes (-48, 16)
pybind11::buffer_info bytes (-48, 16)
SimpleArray::stride() elements (-6, 2)

signed stride 讓 transpose、step slice 與 reverse view 共用同一個 indexing formula。它不是特殊 case flag;每個 axis 只提供下一個 element 的 address step。

Origin

negative stride 讓 data()logical_data() 必須分開。data() 指向 retained storage span 的起點;logical_data() 指向 index (0,...,0)

四乘六 buffer 如何由 origin 18、shape 四乘三與 strides 負六正二形成 reverse step-two view
buffer + shape + stride + logical origin 完整決定一個 view。

importer 先計算所有 axes 可到達的最小與最大 byte offsets。empty view 沒有 reachable element,因此 span 會另外歸零:

C++
ssize_t byte_span_begin = 0;
ssize_t byte_span_end = 0;
bool has_element = true;
for (ssize_t axis = 0; axis < arr_in.ndim(); ++axis)
{
    if (arr_in.shape(axis) == 0)
    {
        has_element = false;
        continue;
    }
    ssize_t const delta =
        (arr_in.shape(axis) - 1) * arr_in.strides(axis);
    byte_span_begin += std::min<ssize_t>(0, delta);
    byte_span_end += std::max<ssize_t>(0, delta);
}
if (!has_element)
{
    byte_span_begin = 0;
    byte_span_end = 0;
}

char * storage_ptr = view_ptr + byte_span_begin;
size_t const data_offset = -byte_span_begin;

running example 的 view_ptrbase + 18。minimum delta 是 -18 elements,maximum delta 是 +4 elements,因此 retained span 是 base[0..22], 而 data_offset 將 logical origin 放回 span 內的 element 18

Lifetime

zero-copy 只有在 owner 活得夠久時才成立。NumPy import 會沿 .base chain 保留其中最深的 ndarray,再把它放進 buffer remover;若更底層是其他 Python object,該 ndarray 會透過自己的 .base 間接保活:

C++
auto remover =
    std::make_unique<ConcreteBufferNdarrayRemover>(owner);
auto buffer = ConcreteBuffer::construct(
    storage_nbytes, storage_ptr, std::move(remover));

return wrapped_type(
    shape, stride, buffer, data_offset, array_order);
Text Only
NumPy import

SimpleArray<T> --shared_ptr--> ConcreteBuffer --points to--> external bytes
                                      |
                                      +--remover retains--> NumPy owner

ConcreteBufferNdarrayRemover 不釋放外部 pointer;它保存一個 pybind11::array reference。當最後一個 buffer reference 消失,remover 被 destroy,NumPy owner 才能跟著釋放 storage。

反方向輸出 ndarray 時,NumPy 的 .base 會持有 ConcreteBuffer,所以即使 原本的 SimpleArray Python name 消失,C++ storage 仍然有效。

Protocol

wrapper 以 pybind11::buffer_protocol() 註冊,並由 .def_buffer() 暴露 typed view。export 時 element strides 乘回 itemsize

C++
for (ssize_t const value : array.stride())
{
    byte_stride.push_back(value * itemsize);
}

return pybind11::buffer_info(
    array.logical_data(),
    sizeof(T),
    format,
    array.ndim(),
    shape,
    byte_stride);

explicit .ndarray property 使用同一份 descriptor,並把 buffer object 放進 NumPy base slot:

C++
return py::array(
    dtype,
    shape,
    byte_stride,
    sarr.logical_data(),
    py::cast(sarr.buffer().shared_from_this()));

可以用雙向 mutation 驗證沒有發生 copy:

Python
arr[1, 2] = -1
assert base[2, 4] == -1

roundtrip[3, 1] = -2
assert base[0, 2] == -2
assert np.shares_memory(base, roundtrip)

這條 constructor 要求 exact dtype、writable pybind11::array、合法 alignment, 且每個 byte stride 必須可由 itemsize 整除。buffer protocol 是 export contract,不代表 constructor 接受任意 PEP 3118 provider。

Copies

operation storage behavior
SimpleArrayFloat64(shape=...) 配置新的 C++ storage
SimpleArrayFloat64(array=ndarray) zero-copy 包裝 NumPy storage
arr.ndarray zero-copy NumPy view
arr.clone() deep copy
arr.reshape(...) exact C-contiguous region 可 zero-copy,其他 layouts copy
arr.to_row_major() materialize independent row-major array

這些 copy boundaries 會直接影響 matmul dispatch:packing 並不是「修正」array representation,而是在 reuse 足以攤提成本時,刻意建立另一個 row-major representation。

Numerical Kernels

到這裡,pybind11 的工作結束了。後續的 numerical kernel 不需要知道 [::-1, ::2] 或 NumPy owner;它只看到 logical_data()、shape 與 physical strides。

Matmul 是其中一個 consumer。它將 trailing core axes normalize 成 M/N/K,再另建 batch mappings 表示 reuse。physical stride 回答「下一筆資料在哪裡」;mapped zero stride 回答「同一份資料如何被 broadcast 重用」。兩者是不同層次的 address rule。

Source