@@ -18,14 +18,11 @@ namespace duckdb {
1818namespace numpy_internal {
1919
2020// ! Mirror of the leading fields of numpy's `PyArrayObject` (stable ABI across numpy 1.x and 2.x).
21- // ! Only the buffer pointer is needed. Reading `data` is a plain struct field access -- no Python
22- // ! call, no allocation, no GIL -- exactly what pybind11's `py::array::data()` did internally via
23- // ! its own equivalent proxy struct. Obtaining the pointer this way (instead of via a `ctypes.data`
24- // ! attribute chain) is what keeps the numpy columnar path fast for LIST/ARRAY columns, whose
25- // ! per-element converter allocates a fresh array per row.
21+ // ! Reading `data` is a plain struct field access (no Python call, allocation, or GIL). Obtaining
22+ // ! the pointer this way, instead of via a `ctypes.data` attribute chain, keeps the numpy columnar
23+ // ! path fast for LIST/ARRAY columns, whose per-element converter allocates a fresh array per row.
2624struct NumpyArrayProxy {
27- PyObject_HEAD
28- char *data;
25+ PyObject_HEAD char *data;
2926};
3027
3128// ! Borrowed handle to the `numpy.ndarray` type, fetched once under the GIL and intentionally leaked
@@ -41,12 +38,10 @@ inline PyTypeObject *NumpyNdarrayType() {
4138}
4239
4340// ! Allocate an uninitialized 1-D numpy array of `count` elements with the given numpy dtype string.
44- // ! The bound `numpy.empty` and the `np.dtype` objects (a handful of distinct dtype strings) are
45- // ! cached to avoid a module import, an attribute lookup, and a dtype-string parse on every call --
46- // ! this is hot, since a LIST/ARRAY column allocates one array per row (pybind11 constructed the
47- // ! array at the C level via `py::array(py::dtype, count)`, which paid none of that). Cached handles
48- // ! are leaked for process lifetime (shutdown-safe: no Python destructor runs after finalization).
49- // ! Only ever called on the single-threaded, GIL-held result-materialization path.
41+ // ! `numpy.empty` and the `np.dtype` objects are cached to avoid a module import, attribute lookup,
42+ // ! and dtype-string parse on every call. This is hot: a LIST/ARRAY column allocates one array per
43+ // ! row. Cached handles are leaked for process lifetime (shutdown-safe: no Python destructor runs
44+ // ! after finalization). Only ever called on the single-threaded, GIL-held result path.
5045inline nb::object NumpyEmpty (idx_t count, const string &dtype) {
5146 static PyObject *empty_fn = []() -> PyObject * {
5247 nb::object fn = nb::module_::import_ (" numpy" ).attr (" empty" );
@@ -69,17 +64,15 @@ inline nb::object NumpyEmpty(idx_t count, const string &dtype) {
6964// ! object. Under nanobind there is no `nb::array` (and no `nb::dtype`); the array is held
7065// ! as a plain `nb::object` and the few buffer operations go through numpy directly.
7166// !
72- // ! Performance note: `Data()`/`MutableData()` are on the HOT path — the numpy scan calls
73- // ! `Data()` once per column per 2048-row chunk (see numpy_scan.cpp), and DuckDB drives that
74- // ! scan from multiple threads WITHOUT holding the GIL. It is also on the LIST/ARRAY result path,
75- // ! where a fresh array (and thus a fresh buffer pointer) is materialized per row. The pointer is
76- // ! read directly from the numpy array's C struct (see `numpy_internal::NumpyArrayProxy`): a plain
77- // ! field access, no Python call, no allocation, no GIL — exactly what pybind11's
78- // ! `py::array::data()` did. We compute it ONCE, eagerly, in the constructor (always invoked
79- // ! single-threaded with the GIL held at bind/result time) and cache it; the cache is invalidated
80- // ! (and recomputed) by `Resize()`, the only operation that reallocates the buffer. Reading the
81- // ! struct field is dtype-agnostic (works for the `object` dtype that DLPack/`nb::ndarray` cannot
82- // ! represent).
67+ // ! Performance note: `Data()`/`MutableData()` are on the HOT path. The numpy scan calls `Data()`
68+ // ! once per column per 2048-row chunk (see numpy_scan.cpp), and DuckDB drives that scan from
69+ // ! multiple threads WITHOUT holding the GIL. It is also on the LIST/ARRAY result path, where a
70+ // ! fresh array (and buffer pointer) is materialized per row. The pointer is read directly from the
71+ // ! numpy array's C struct (see `numpy_internal::NumpyArrayProxy`): a plain field access, no Python
72+ // ! call, allocation, or GIL. We compute it ONCE, eagerly, in the constructor (single-threaded with
73+ // ! the GIL held at bind/result time) and cache it; the cache is invalidated (and recomputed) by
74+ // ! `Resize()`, the only operation that reallocates the buffer. The struct read is dtype-agnostic
75+ // ! (works for the `object` dtype that DLPack/`nb::ndarray` cannot represent).
8376// !
8477// ! Ownership is move-only-when-asked: the ctor takes by value and moves, GetArray() hands
8578// ! back a reference, and no method copies the array buffer. The raw `cached_data_` member uses
@@ -101,8 +94,8 @@ class NumpyArray {
10194
10295public:
10396 // ! Allocate a fresh, contiguous 1-D numpy array of `count` elements with the given numpy
104- // ! dtype string (e.g. "int64", "float32", "object", "datetime64[us]"). Uninitialized —
105- // ! callers fill it immediately, matching the previous `nb::array(nb::dtype(d), count)` .
97+ // ! dtype string (e.g. "int64", "float32", "object", "datetime64[us]"). Uninitialized; callers
98+ // ! fill it immediately.
10699 static NumpyArray Allocate (const string &dtype, idx_t count) {
107100 NumpyArray result (numpy_internal::NumpyEmpty (count, dtype));
108101 result.length_ = count;
@@ -127,11 +120,11 @@ class NumpyArray {
127120 }
128121
129122 // ! Resize the underlying numpy buffer in place. This REALLOCATES the buffer, so the cached
130- // ! pointer is invalidated and recomputed (GIL is held -- this only runs on the single-threaded
131- // ! result-materialization path). Resizing to the current length is a genuine no-op in numpy;
132- // ! we skip the Python `resize` call entirely in that case (buffer and cached pointer unchanged).
133- // ! The LIST/ARRAY per-element path allocates each array at its exact final size, so its
134- // ! `ToArray()` shrink-to-count is always such a no-op -- hot, hence worth skipping.
123+ // ! pointer is invalidated and recomputed (GIL held; only runs on the single-threaded result
124+ // ! path). Resizing to the current length is a genuine no-op in numpy, so we skip the Python
125+ // ! `resize` call entirely in that case. The LIST/ARRAY per-element path allocates each array at
126+ // ! its exact final size, so its `ToArray()` shrink-to-count is always such a no-op: hot, worth
127+ // ! skipping.
135128 void Resize (idx_t count) {
136129 if (length_ != DConstants::INVALID_INDEX && count == length_) {
137130 return ;
@@ -143,7 +136,7 @@ class NumpyArray {
143136 }
144137
145138 // ! Access the underlying array, e.g. for `.attr(...)` calls, iteration, or to hand it
146- // ! back to Python. Returned by reference -- never copied.
139+ // ! back to Python. Returned by reference, never copied.
147140 nb::object &GetArray () {
148141 return array;
149142 }
@@ -154,8 +147,8 @@ class NumpyArray {
154147private:
155148 // ! Compute and cache the buffer start address of the underlying numpy array, if not already
156149 // ! cached and a numpy ndarray is held. The pointer is read directly from the array's C struct
157- // ! (dtype-agnostic, works for the `object` dtype too), matching pybind11's `py::array::data()`.
158- // ! Only ever called with the GIL held (construction / Resize).
150+ // ! (dtype-agnostic, works for the `object` dtype too). Only ever called with the GIL held
151+ // ! (construction / Resize).
159152 void EnsurePointer () {
160153 // Some NumpyArray wrappers hold non-ndarray objects (e.g. a pandas Index) whose buffer pointer is never read.
161154 // Gate the read on an actual numpy ndarray so we never reinterpret a foreign object's memory as an array.
0 commit comments