A NumPy-like Array in C++
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
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。
例如 (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:
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:
(4,3) 因此有 12 個 logical elements。它沒有說明元素是否相鄰、走訪方向,
也不能推導 backing span 的 byte size。這些資訊屬於 stride 與 origin。
C-contiguous array 的 default element strides 可由 shape 從右往左累乘:
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:
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 + shape + stride + logical origin 完整決定一個 view。
importer 先計算所有 axes 可到達的最小與最大 byte offsets。empty view 沒有 reachable element,因此 span 會另外歸零:
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_ptr 是 base + 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 間接保活:
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);
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:
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:
return py::array(
dtype,
shape,
byte_stride,
sarr.logical_data(),
py::cast(sarr.buffer().shared_from_this()));
可以用雙向 mutation 驗證沒有發生 copy:
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。