Skip to content

Commit a875747

Browse files
committed
improve allocation for numpy
1 parent 501a3fc commit a875747

6 files changed

Lines changed: 100 additions & 24 deletions

File tree

.github/workflows/packaging_wheels.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ jobs:
3030
strategy:
3131
fail-fast: false
3232
matrix:
33-
python: [ cp314 ]
33+
python: [ cp311, cp314 ]
3434
platform:
3535
- { os: windows-2022, arch: amd64, cibw_system: win }
3636
- { os: windows-11-arm, arch: ARM64, cibw_system: win }

CMakeLists.txt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ endif()
3939
# internally)
4040
find_package(
4141
Python
42-
COMPONENTS Interpreter Development.Module
42+
COMPONENTS Interpreter Development.Module NumPy
4343
REQUIRED)
4444
# Nanobind ships its CMake config inside site-packages/nanobind/cmake, so
4545
# find_package() can't discover it unless we set it. (scikit-build-core does

pyproject.toml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,10 @@ requires = [
6565
"scikit-build-core>=0.11.4",
6666
"nanobind>=2.0",
6767
"setuptools_scm>=8.0",
68+
# numpy C API headers (PyArray_Empty in the result path). Building against numpy 2.x yields a
69+
# binary compatible with numpy >=1.19 AND 2.x at runtime (numpy 2.0 backward-compat), so the
70+
# unpinned runtime numpy range is preserved. Build-time only; the runtime numpy dep is unchanged.
71+
"numpy>=2.0",
6872
]
6973

7074
[tool.scikit-build]

src/include/duckdb_python/numpy/numpy_array.hpp

Lines changed: 6 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,6 @@
1111
#include "duckdb_python/nb/casters.hpp"
1212
#include "duckdb.hpp"
1313

14-
#include <unordered_map>
15-
1614
namespace duckdb {
1715

1816
namespace numpy_internal {
@@ -37,24 +35,12 @@ inline PyTypeObject *NumpyNdarrayType() {
3735
return cached;
3836
}
3937

40-
//! Allocate an uninitialized 1-D numpy array of `count` elements with the given numpy dtype string.
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.
45-
inline nb::object NumpyEmpty(idx_t count, const string &dtype) {
46-
static PyObject *empty_fn = []() -> PyObject * {
47-
nb::object fn = nb::module_::import_("numpy").attr("empty");
48-
return fn.release().ptr();
49-
}();
50-
static auto &dtype_cache = *new std::unordered_map<string, PyObject *>();
51-
PyObject *&descr = dtype_cache[dtype];
52-
if (!descr) {
53-
nb::object d = nb::module_::import_("numpy").attr("dtype")(dtype);
54-
descr = d.release().ptr();
55-
}
56-
return nb::borrow<nb::object>(empty_fn)(count, nb::handle(descr));
57-
}
38+
//! Allocate an uninitialized 1-D numpy array of `count` elements with the given numpy dtype string
39+
//! (e.g. "int64", "float32", "object", "datetime64[us]") via the numpy C API (PyArray_Empty). The
40+
//! parsed np.dtype objects are cached to avoid a dtype-string parse on every call. This is hot: a
41+
//! LIST/ARRAY column allocates one array per row. Defined in numpy_array.cpp (the single TU that
42+
//! pulls in the numpy C API). Only ever called on the single-threaded, GIL-held result path.
43+
nb::object NumpyEmpty(idx_t count, const string &dtype);
5844

5945
} // namespace numpy_internal
6046

src/numpy/CMakeLists.txt

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,17 @@
11
# this is used for clang-tidy checks
22
add_library(
33
python_numpy OBJECT
4-
type.cpp numpy_scan.cpp array_wrapper.cpp raw_array_wrapper.cpp
5-
numpy_bind.cpp numpy_result_conversion.cpp)
4+
type.cpp
5+
numpy_scan.cpp
6+
array_wrapper.cpp
7+
raw_array_wrapper.cpp
8+
numpy_bind.cpp
9+
numpy_result_conversion.cpp
10+
numpy_array.cpp)
611

712
target_link_libraries(python_numpy PRIVATE _duckdb_dependencies)
13+
14+
# numpy_array.cpp is the single TU that uses the numpy C API (PyArray_Empty), so
15+
# it needs numpy's headers. Resolved by find_package(Python ... COMPONENTS ...
16+
# NumPy) in the top-level CMakeLists. Scoped to this object library only.
17+
target_include_directories(python_numpy PRIVATE ${Python_NumPy_INCLUDE_DIRS})

src/numpy/numpy_array.cpp

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
//===----------------------------------------------------------------------===//
2+
// DuckDB
3+
//
4+
// numpy_array.cpp
5+
//
6+
// Out-of-line definitions for the NumpyArray facade (numpy_array.hpp). This is the
7+
// ONLY translation unit that uses the numpy C API, so it does not need
8+
// PY_ARRAY_UNIQUE_SYMBOL / NO_IMPORT_ARRAY (those coordinate the C-API function
9+
// pointer table across multiple TUs).
10+
//===----------------------------------------------------------------------===//
11+
12+
#define NPY_NO_DEPRECATED_API NPY_1_7_API_VERSION
13+
#include <numpy/arrayobject.h>
14+
15+
#include "duckdb_python/numpy/numpy_array.hpp"
16+
17+
#include <stdexcept>
18+
#include <unordered_map>
19+
20+
namespace duckdb {
21+
namespace numpy_internal {
22+
23+
namespace {
24+
25+
//! Lazy, guarded one-time init of the numpy C-API function pointer table. numpy is always
26+
//! already imported by the time we allocate a result array, so import_array should succeed;
27+
//! if it does not, the returned value is false and the caller raises. Runs exactly once
28+
//! (function-local static initializer, GIL held on the result path).
29+
bool EnsureNumpyCApi() {
30+
static bool ok = []() -> bool {
31+
// import_array1(ret) expands to `return ret;` on failure, so wrap it in a lambda that
32+
// returns int and surface success via the return value.
33+
auto do_import = []() -> int {
34+
import_array1(-1);
35+
return 0;
36+
};
37+
return do_import() == 0;
38+
}();
39+
return ok;
40+
}
41+
42+
} // namespace
43+
44+
nb::object NumpyEmpty(idx_t count, const string &dtype) {
45+
// Process-lifetime cache of parsed np.dtype objects, keyed by dtype string. The parse is
46+
// otherwise repeated per call; a LIST/ARRAY column allocates one array per row. Leaked on
47+
// purpose (numpy is never unloaded; no Python destructor runs after finalization). Only ever
48+
// touched on the single-threaded, GIL-held result path.
49+
static auto &dtype_cache = *new std::unordered_map<string, PyObject *>();
50+
PyObject *&descr = dtype_cache[dtype];
51+
if (!descr) {
52+
nb::object d = nb::module_::import_("numpy").attr("dtype")(dtype);
53+
descr = d.release().ptr();
54+
}
55+
56+
if (!EnsureNumpyCApi()) {
57+
throw std::runtime_error("Failed to initialize the numpy C API (import_array failed)");
58+
}
59+
60+
npy_intp dims[1] = {static_cast<npy_intp>(count)};
61+
// PyArray_Empty STEALS a reference to descr. descr is a single cached np.dtype reused across
62+
// every allocation, so hand PyArray_Empty its own reference to consume.
63+
Py_INCREF(descr);
64+
PyObject *arr = PyArray_Empty(1, dims, reinterpret_cast<PyArray_Descr *>(descr), 0 /* C order */);
65+
if (!arr) {
66+
// PyArray_Empty consumed the stolen reference even on failure; balance the INCREF above so
67+
// the cached descr is not leaked, then surface the numpy error.
68+
Py_DECREF(descr);
69+
throw nb::python_error();
70+
}
71+
// PyArray_Empty returns a NEW reference; hand ownership to nanobind via steal.
72+
return nb::steal<nb::object>(arr);
73+
}
74+
75+
} // namespace numpy_internal
76+
} // namespace duckdb

0 commit comments

Comments
 (0)