Repository navigation
feat: back the statically sized scalars with a NumPy dtype - #83
Merged
Merged
Conversation
Arrays of scalars were object arrays: a pointer per element and a Python object behind each one, so every NumPy operation was an object loop. The static variants have a fixed element size and, since #82, are exactly their data and trivially copyable, which is what a dtype needs. Measured over 10k elements of DD3Scalar: object dtype a + b 349.5 ns 2.5 ns 138x a * b 354.3 ns 3.9 ns 92x np.sqrt(a) 349.2 ns 2.8 ns 123x np.sin(a) 366.4 ns 7.0 ns 52x np.sum(a) 353.1 ns 3.8 ns 94x a @ b 673.0 ns 2.7 ns 246x One dtype per static type, generated from the same template as the bindings, which is what #81 was the prerequisite for. No parametric dtype is needed: the size follows from the type. The dynamic variants hold a std::vector and stay object arrays. 27 ufunc loops per type: the four arithmetic ones, negative and positive, arctan2, hypot, matmul, vecdot, and 19 unary mathematical functions. All of them copy through local values, which makes them safe for unaligned data at no cost. np.dot and np.linalg.norm do not work on these arrays and cannot be made to. np.dot is not a ufunc but an __array_function__ dispatcher: it dispatches on the array type rather than the dtype, and our arrays are plain ndarrays, so the protocol never fires. It then refuses anything that is not a native or an old-style dtype, before consulting any slot -- the legacy dotfunc slot exists and was tried, and is unreachable. Registering as an old-style dtype would work but builds the feature on the API NumPy is removing. `@`, np.matmul and np.vecdot compute the same and work on object arrays too, so the three tests that used np.dot now use `@`. Requirements NumPy enforces at registration, none of them documented -- each surfaced only as an error and cost an iteration: __repr__ and __str__ are mandatory, so is a cast between the DType's own instances, that cast has to handle unaligned data and declare NPY_METH_SUPPORTS_UNALIGNED, and the DType needs its own tp_new. A null type object is rejected, and so is sharing one type object between DTypes, which is why the dtype has to hang off the scalar class and picking it up in np.array is not optional. The loop registration allocates its spec on the heap for the lifetime of the module. Function-local statics would have been shared by every call with the same type and arity, and NumPy may keep pointers into the spec. Python 239 passed, 66 of them new in test_dtype.py C++ 102 test cases, 1310 assertions, Release with -Werror and under clang with ASan+UBSan all 34 generated dtypes checked for element size, boxing and arithmetic The new tests were validated by mutation, and the first version failed that: comparing a ufunc on an array against the same ufunc on scalars passes whatever the loop computes, because both go through it. They now compare against the scalar operators, and injecting subtract-computes-plus and arccosh-computes-arcsinh fails four of them. Build time is unchanged at 37.4 s; the module grows from 2.8 to 3.4 MB.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Why
Arrays of scalars were object arrays — a pointer per element and a Python object behind each one, so every NumPy operation was an object loop. The static variants have a fixed element size and, since #82, are exactly their data and trivially copyable, which is what a dtype needs.
Measured over 10 000 elements of
DD3Scalar:dtype=objecta + ba * bnp.sqrt(a)np.sin(a)np.sum(a)a @ bNumPy picks the dtype up on its own — no opt-in needed:
What
One dtype per static type, generated from the same template as the bindings — which is exactly what #81 was the prerequisite for. No parametric dtype is needed: the size follows from the type. The dynamic variants hold a
std::vectorand stay object arrays.27 ufunc loops per type: the four arithmetic ones,
negative,positive,arctan2,hypot,matmul,vecdotand 19 unary mathematical functions. All of them copy through local values, which makes them safe for unaligned data at no cost — and NumPy requires that anyway (see below).np.dotcannot be made to workI tried, including implementing the legacy
NPY_DT_PyArray_ArrFuncs_dotfuncslot, which is still active indtype_api.hwhilecastandcopyswapare commented out. It is unreachable:np.dotis not a ufunc but an_ArrayFunctionDispatcher. It dispatches via__array_function__, which looks at the array type rather than the dtype — our arrays are plainndarray, so the protocol never fires — and then rejects anything that is not a native or old-style dtype before consulting any slot.Registering as an old-style dtype via
PyArray_RegisterDataTypewould work, and I decided against it: that is the API NumPy deprecated in 2.0 and intends to remove. Building the largest new feature of a library that just modernised to C++23 on an outgoing path, and facing the same migration later with users attached, is the worse trade.@,np.matmulandnp.vecdotcompute the same thing and work on object arrays too, so the three tests that usednp.dotnow use@.np.linalg.normalso fails, inside NumPy's own Python code;np.sqrt(np.vecdot(a, a))replaces it.np.array(a, dtype=object)converts back at any time and restores the object behaviour includingnp.dot. All documented in the README.Undocumented requirements, each found only as an error
None of these are in the headers; each cost an iteration:
__repr__and__str__are mandatoryNPY_METH_SUPPORTS_UNALIGNEDtp_new;np.dtype.__new__must not be inherited6 and 7 together are why the dtype has to hang off the scalar class, and why NumPy picking it up in
np.arrayis not optional. That is the finding that ruled out the "arithmetic first, rest later" scope: a partial loop set would leavenp.sinand friends failing hard where they used to fall back to the object path (33 failing tests, measured).A mistake I made and caught
The first version of
test_dtype.pycomparedufunc(array)againstufunc(scalar). Both go through the very loop under test, so the tests confirmed themselves. Withsubtractdeliberately computingplusandarccoshcomputingarcsinh, all 66 passed.They now compare against the scalar operators (
a - b,a.acosh()), and the same two mutations fail four tests. Without the mutation check this PR would have shipped worthless tests for its core behaviour.Also fixed in my own code:
register_loopkept thePyArrayMethod_Specin function-localstaticbuffers, which every call with the same<T, TArity>would have shared. NumPy may keep pointers into the spec, so they are now heap-allocated for the lifetime of the module.Verification
-Werrorand under clang with ASan+UBSanD0Scalarat 8 bytes toDD16Scalarat 1224clang-format --Werrorandruff format --checkcleanNotes
numpy>=2.0moves intobuild-system.requires— the dtype is written against the NumPy C API, so its headers are needed at build time. It was already a runtime dependency.Deliberately not included, both mechanical follow-ups through the same machinery:
np.power— needs a mixed(T, float64)loopbool, so a different loop shape