The C array object and buffer protocol

NumPy · Expert · 20 min

Zero-copy interop with C, Cython, images, GPUs and other libraries depends on understanding this structure.

At the C level an ndarray is a PyArrayObject whose key fields are: a data pointer to the first element, nd (number of dimensions), dimensions and strides arrays, a descr (the PyArray_Descr behind a.dtype, describing item size, kind, byte order and fields), a base reference, and flags (contiguity, WRITEABLE, OWNDATA, ALIGNED). An array either owns its buffer or points into memory owned by base, which it keeps alive by reference. Every view you create is just a new header with its own dimensions, strides and data offset.

Because the header is so simple, foreign memory can become an array without copying, as long as someone describes it. Several protocols do that:

  • The buffer protocol (PEP 3118) is Python's C-level contract for exposing raw memory with a format and shape. ndarrays export it (memoryview(a), bytes(a)), and np.frombuffer(obj, dtype) or np.asarray(obj) consume it from bytes, bytearray, array.array, mmap or image buffers. Arrays built over immutable bytes are read-only.
  • __array_interface__ (Python dict) and __array_struct__ (C struct) are NumPy's own older protocols; the dict exposes data as (address, readonly).
  • DLPack (np.from_dlpack(x) and a.__dlpack__()) is the cross-framework standard for exchanging tensors, including with PyTorch, JAX and CuPy; it carries device information so libraries can refuse incompatible devices.

From C or Cython you reach the same fields with macros such as PyArray_DATA, PyArray_DIMS, PyArray_STRIDES. Code that assumes contiguity must check flags (or call PyArray_FROM_OTF with NPY_ARRAY_C_CONTIGUOUS) because a perfectly valid array might be a strided, negative-step or read-only view.

Example

import numpy as np

a = np.array([[0, 1, 2], [3, 4, 5]], dtype=np.int32)
print(a.flags["OWNDATA"], a[:, 1].flags["OWNDATA"])   # True False
print(a.__array_interface__["data"][0] == a.ctypes.data)   # True

mv = memoryview(a)                   # buffer protocol export
print(mv.format, mv.shape, mv.strides)   # i (2, 3) (12, 4)

raw = bytearray(b"\x01\x00\x02\x00\x03\x00")
u = np.frombuffer(raw, dtype="<u2")  # zero-copy view of the bytearray
raw[0] = 9
print(u)                             # [9 2 3]  sees the change

ro = np.frombuffer(b"\x00" * 8, dtype=np.float64)
print(ro.flags.writeable)            # False: bytes are immutable

b = np.from_dlpack(a)                # DLPack exchange (here NumPy->NumPy)
print(np.shares_memory(a, b))        # True

Remember

  • An ndarray is a header (data pointer, dims, strides, descr, flags) over a buffer
  • Views keep their memory owner alive through base
  • Buffer protocol, __array_interface__ and DLPack enable zero-copy exchange
  • C code must honour strides and flags, not assume contiguity

Watch out for

  • Freeing or resizing the underlying memory (e.g. a bytearray) while a NumPy view of it is still in use.

Check yourself

  1. `np.frombuffer(some_bytes, dtype=np.uint8)` returns an array you can't write to. Why?
  2. Which mechanism is designed for exchanging arrays between frameworks across devices such as GPUs?

Sign in to answer, see the explanations, and get this lesson back as a quick review later.

Official docs: numpy.org/doc/stable/reference/arrays.interface.html