Skip to content

PERF: use scaler class instead of lambda for unit scale conversions - #20183

Open
mhvk wants to merge 33 commits into
astropy:mainfrom
mhvk:units-use-scaler-class
Open

PERF: use scaler class instead of lambda for unit scale conversions#20183
mhvk wants to merge 33 commits into
astropy:mainfrom
mhvk:units-use-scaler-class

Conversation

@mhvk

@mhvk mhvk commented Aug 1, 2026

Copy link
Copy Markdown
Contributor

This PR introduces a C-based scaler class, which has two advantages over the lambda that is currently used in unit conversions: it is inspectable, and it is faster, with in particular nice optimizations for scalars and contiguous arrays:

import astropy.units as u
import numpy as np

c = u.km.get_converter(u.m)
%timeit c(10.0)
# 45.7 ns -> 17.6 ns (14.1 ns w/o limited API)

b = np.arange(10.0)
%timeit c(b)
# 392 ns -> 86 ns (84 ns w/o limited API)

(With the static type one gets an ~10 ns extra speed-up, but that does not seem worth it in the scheme of things.)

Note that while this propagates to Quantity operations, the effect is only at the 10% level since there are other quite large overheads:

q1 = 1 * u.m
q2 = np.arange(10.0) << u.km
%timeit q1 + q2
# 4.19 μs -> 3.66 μs
# EDIT: with cached scaler now in main: 2.98 μs

q3 = np.arange(30.0)*u.deg
%timeit np.sin(q3)
# 4.12 μs -> 3.55 μs
# EDIT: with cached scaler now in main: 2.96 μs

Also: time needed for construction of the scaler does not change (for that, would need to do caching):

%timeit u.km.get_converter(u.m)
# 704 ns -> 704 ns
# EDIT: now changed with caching: 272 ns

Finally, @neutrinoceros - I developed the class separately from astropy, and kept its source and the tests separate here, to help the eventual move of C based code outside of astropy.

  • By checking this box, the PR author has requested that maintainers do NOT use the "Squash and Merge" button. Maintainers should respect this when possible; however, the final decision is at the discretion of the maintainer that merges the PR.

@mhvk mhvk added this to the v8.1.0 milestone Aug 1, 2026
@mhvk
mhvk requested review from neutrinoceros and nstarman August 1, 2026 02:10
@github-actions

github-actions Bot commented Aug 1, 2026

Copy link
Copy Markdown
Contributor

Thank you for your contribution to Astropy! 🌌 This checklist is meant to remind the package maintainers who will review this pull request of some common things to look for.

  • Do the proposed changes actually accomplish desired goals?
  • Do the proposed changes follow the Astropy coding guidelines?
  • Are tests added/updated as required? If so, do they follow the Astropy testing guidelines?
  • Are docs added/updated as required? If so, do they follow the Astropy documentation guidelines?
  • Is rebase and/or squash necessary? If so, please provide the author with appropriate instructions. Also see instructions for rebase and squash.
  • Did the CI pass? If no, are the failures related? If you need to run daily and weekly cron jobs as part of the PR, please apply the "Extra CI" label. Codestyle issues can be fixed by the bot.
  • Is a change log needed? If yes, did the change log check pass? If no, add the "no-changelog-entry-needed" label. If this is a manual backport, use the "skip-changelog-checks" label unless special changelog handling is necessary.
  • Is this a big PR that makes a "What's new?" entry worthwhile and if so, is (1) a "what's new" entry included in this PR and (2) the "whatsnew-needed" label applied?
  • At the time of adding the milestone, if the milestone set requires a backport to release branch(es), apply the appropriate "backport-X.Y.x" label(s) before merge.

@mhvk

mhvk commented Aug 1, 2026

Copy link
Copy Markdown
Contributor Author

p.s. Forgot to mention: there is another part still to come, that helps with larger arrays (>10**5 elements), by allowing numpy to do the scaling as part of the iterator. Since that avoids a memory copy, that has quite a big impact for those larger arrays (and is somewhat helped by this PR).

@mhvk

mhvk commented Aug 1, 2026

Copy link
Copy Markdown
Contributor Author

I'll move this to draft while I try to figure out why builds fail on older python and windows/mac/aarch64...

@mhvk
mhvk marked this pull request as draft August 1, 2026 02:37
@mhvk
mhvk force-pushed the units-use-scaler-class branch 3 times, most recently from 743cb88 to 6b35323 Compare August 1, 2026 16:20
@nstarman

nstarman commented Aug 1, 2026

Copy link
Copy Markdown
Member

there is a big chunk to deal with python 3.11; it would be nice to just follow SPEC 0 and drop it

Agreed. I guess targeting 8.1, like this?
Do you want to get that in first, or this, with the compatibility code?

@pllim pllim added the benchmark Run benchmarks for a PR label Aug 3, 2026
Comment thread docs/changes/units/20183.perf.rst
@neutrinoceros

Copy link
Copy Markdown
Contributor

In the process, I decided to adopt the python limited API

wait the phrasing is either misleading or revealing a misunderstanding; this isn't a choice we can make at the module level: if a single module in astropy isn't compliant, we cannot ship abi3 wheels at all, which is the whole point. So in fact the decision has already been made before you opened this PR and we must comply with the limited API in astropy.

Comment thread astropy/units/_scaler/src/scaler.c Outdated
#include <stddef.h> // for offsetof()

// Once we're at 3.12, move SCALER_TP_FLAGS to its use, and remove this whole block.
#if Py_LIMITED_API + 0 >= 0x030C0000

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why + 0 ?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I saw that done in CPython, but to be honest have no idea why. Maybe simply if someone by mistake simply does #define Py_LIMITED_API? Which I guess would only be relevant for header files, not C code...

Comment thread astropy/units/_scaler/tests/test_scaler.py Outdated
sc1 = Scaler(10.0)
sc1_2 = Scaler(10.0)
assert sc1_2 == sc1
assert not (sc1_2 != sc1) # noqa: SIM202

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

possibly naive question: isn't __ne__ automatically derived for any object subclass ?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure that is the case in C -- I get passed Py_EQ or Py_NE and deal with them more or less separately.

Comment thread astropy/units/_scaler/src/scaler.c Outdated
Comment thread astropy/units/_scaler/src/scaler.c Outdated
@mhvk

mhvk commented Aug 4, 2026

Copy link
Copy Markdown
Contributor Author

In the process, I decided to adopt the python limited API

wait the phrasing is either misleading or revealing a misunderstanding; this isn't a choice we can make at the module level: if a single module in astropy isn't compliant, we cannot ship abi3 wheels at all, which is the whole point. So in fact the decision has already been made before you opened this PR and we must comply with the limited API in astropy.

A bit of both: I started not following the limited API, but then remembered vaguely that it was good to do so, so followed it. This is partially (but not only) why there is such a large hack for python 3.11. In the next PR, I fear the need to work around a numpy issue that was only solved in 2.6 (i.e., -dev) means I have to calculate offsets in structures...

But I do like the idea of the limited API very much, in particular that one can have just one package for all supported versions of python and numpy -- saves a lot of compute, bandwidth and storage if nothing else!

@nstarman nstarman left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The non-C code LGTM.

@mhvk
mhvk force-pushed the units-use-scaler-class branch 4 times, most recently from 6fa80c8 to be73429 Compare August 5, 2026 17:02
@nstarman

nstarman commented Aug 7, 2026

Copy link
Copy Markdown
Member

Since I'm only middling at C, I asked Claude Opus 5 to Do not post or push. Only report here. Find any bugs in https://github.com/astropy/astropy/pull/20183. It found enough things I think it's worth posting in full, not making select comments.

It found the bump to numpy v2.0 :)

Click to expand

Bug review — astropy PR #20183

PR: PERF: use scaler class instead of lambda for unit scale conversions
Author: Marten van Kerkwijk (mhvk) · branch units-use-scaler-classmain
Diff: +790 / −55 across 9 files

Method: the PR's C sources were checked out and built as a standalone extension
(CPython 3.12.10, NumPy 2.5.1, clang/arm64) so every claim below is reproduced, not inferred.
The PR's own test suite passes (79 tests; the single test_pickle failure is an artifact of the
standalone module name). None of items 1–8 are covered by it.


Blockers

1. Use-after-free in Scaler_dealloc — segfaults

astropy/units/_scaler/src/scaler.c:303-309

static void Scaler_dealloc(PyObject *self)
{
    PyObject_GC_UnTrack(self);
    Scaler_clear(self);
    PyObject_GC_Del(self);
    Py_DECREF(Py_TYPE(self));   // reads self->ob_type from freed memory
}

Py_TYPE(self) dereferences self after it has been freed. It survives under the default
allocator only because the freed block still happens to hold the type pointer.

Reproduction:

PYTHONMALLOC=debug python -c "from scaler import Scaler
for i in range(200): s = Scaler(float(i+2)); del s"
Fatal Python error: Segmentation fault

Fix — cache the type before the free (verified: the same loop then passes):

PyTypeObject *tp = Py_TYPE(self);
PyObject_GC_UnTrack(self);
Scaler_clear(self);
PyObject_GC_Del(self);
Py_DECREF(tp);

Every get_converter() result eventually reaches this path, so this is fatal for
ASAN / debug-build runs across the whole test suite.


2. setup.py NPY_TARGET_VERSION bump silently drops NumPy 1.25/1.26 support

setup.py:49

-        ext.define_macros.append(("NPY_TARGET_VERSION", "NPY_1_25_API_VERSION"))
+        ext.define_macros.append(("NPY_TARGET_VERSION", "NPY_2_0_API_VERSION"))

The loop applies to all astropy extensions, not just the new one. pyproject.toml:46 still
declares numpy>=1.25. Built that way and imported under numpy 1.26.4:

RuntimeError: module was compiled against NumPy C-API version 0x12 (NumPy 2.0)
              but the running NumPy has C-API version 0x11.
ImportError: numpy._core.multiarray failed to import

The bump is genuinely required by this module — compiling scaler.c at target 1.25 fails with
three -Wimplicit-function-declaration errors for PyUFunc_GiveFloatingpointErrors.

So this is a real dependency-floor change. It needs to be an explicit, discussed decision with a
matching pyproject.toml bump and changelog entry — not a one-line side effect of a perf PR.


Correctness bugs

3. float16 arrays get upcast to float64

astropy/units/_scaler/src/scaler.c:154-163

f2 is not in the "fast type" set, so it falls through to the cached 0-d A_factor array — a
strong float64 operand under NEP 50, unlike the Python float the old code multiplied by.

input old (scale * value) new
np.arange(3, dtype='f2') float16 float64
np.float16(1) float16 float16

Note the array and scalar paths now also disagree with each other: f2 scalars still return f16
because they take the O_factor Python-float path.


4. Complex arrays give different answers depending on memory layout

astropy/units/_scaler/src/scaler.c:74-82

The contiguous path reinterprets complex data as 2N reals and uses the real multiply loop; the
non-contiguous fallback promotes the factor to complex. For inf/nan these disagree:

c = np.array([complex(1, np.inf), complex(np.inf, 2)])

numpy  c * 10.0        : [nan+infj inf+nanj]
scaler contiguous      : [10.+infj inf+20.j]
scaler non-contiguous  : [nan+infj inf+nanj]

The real-loop answer is arguably the better one, but a single function returning two different
results for the same values based on strides is not defensible. Pick one and make both paths agree.


5. Scaler(10.0, **{}) raises SystemError

astropy/units/_scaler/src/scaler.c:254-259

if (nargs != 1 || kwds != NULL) {
    char *kwlist[] = {"", NULL};
    PyArg_ParseTupleAndKeywords(args, kwds, "d:Scaler", kwlist, &factor);
    return NULL;
}

A non-NULL but empty kwargs dict is treated as an error. The parser is then called with valid
input, succeeds, and the function returns NULL with no exception set:

SystemError: <class 'astropy.units._scaler.Scaler'> returned NULL without setting an exception

Fix: guard on kwds != NULL && PyDict_Size(kwds) > 0.


6. Multiplication order flipped: value * scale instead of scale * value

Old _get_converter returned lambda val: scale * _condition_arg(val). Every new path does
PyNumber_Multiply(obj, factor). For any duck-array reaching unit.to(other, value) this now
invokes __mul__ where it used to invoke __rmul__:

scaler(obj) -> left-mul        # old: 10.0 * obj -> right-mul

Commutative for ndarray / dask / masked arrays, but it is an unannounced API-surface change and
deserves a changelog line at minimum.


7. make_converter changes equivalency conversion results

astropy/units/core.py:484

-    return func(_condition_arg(v) / scale1) * scale2
+    return scaler2(func(scaler1(v)))        # scaler1 multiplies by 1/scale1

v / scale1 and v * (1/scale1) are not the same in floating point. Over
np.linspace(1, 2, 10001):

scale1 differing results
3.0 3342 / 10001
7.0 3573 / 10001
299792458.0 (c) 1361 / 10001
1.602176634e-19 1159 / 10001
86400 * 365.25 6252 / 10001

All 1-ulp, but astropy has ulp-level tests in coordinates/spectral. Separately, 1/scale1
overflows to inf for subnormal scale1 (1/1e-320 == inf) where v / scale1 was fine.


8. Stray fence breaks the docs build

docs/changes/units/20183.perf.rst:6

The file ends with a literal ``` line. Sphinx emits
"Inline literal start-string without end-string", and astropy's docs build runs with -W.


Design / lower severity

9. The limited-API guard is inverted

astropy/units/_scaler/src/scaler_limited_api_workarounds.h:6-14

#if defined(Py_LIMITED_API) && Py_LIMITED_API + 0 >= 0x030D0000
    /* full flags, incl. Py_TPFLAGS_BASETYPE */
#else
    #define PyType_GetModuleByDef(type, unused) PyType_GetModule(type)
    /* flags without BASETYPE */
#endif

setup_package.py never defines Py_LIMITED_API and never passes py_limited_api=True, so
astropy's actual build takes the #else branch — replacing the perfectly available
PyType_GetModuleByDef with PyType_GetModule and dropping Py_TPFLAGS_BASETYPE. Confirmed on a
non-limited build:

class Sub(Scaler): pass
TypeError: type 'astropy.units._scaler.Scaler' is not an acceptable base type

Wants #if !defined(Py_LIMITED_API) || Py_LIMITED_API >= 0x030D0000.

Also: if the entire limited-API workaround file is dead code in-tree, that is worth stating
explicitly — the PR description's "w/o limited API" benchmark numbers imply it was meant to be on.

Dormant behind the same flag: Scaler_new ignores its type argument when factor == 1.0 and
returns the base-class singleton, so Sub(1.0) would return a plain Scaler if BASETYPE is
ever enabled.


10. Free-threading

No Py_mod_gil slot, and O_factor / A_factor / A_factor_f are lazily initialised with no
synchronisation (scaler.c:100-110, scaler.c:154-162) — a benign-ish leak race, but there is a
weekly 3.13t job running with PYTHON_GIL=0 that will at least start warning.

11. PyArray_ISALIGNED is not checked

astropy/units/_scaler/src/scaler.c:149 hands PyArray_DATA straight to the ufunc inner loop
after checking only ISONESEGMENT and ISNOTSWAPPED. The ufunc machinery normally buffers
unaligned arrays before calling the loop; this bypasses that. Produced correct results on arm64 in
testing, but it is a real hazard on strict-alignment targets.

12. Undocumented (probably good) behaviour change

Scaler(10.)([True, False]) now returns [10., 0.] where _condition_arg raised ValueError.
Bool arrays already worked, so this removes an old inconsistency — it just needs a changelog line.

13. Nits

  • Py_tp_doc slot discards const — compiler warning on every build.
  • PyModule_GetState result unchecked at scaler.c:426.
  • Scaler_clear's comment says "called from finalize and dealloc", but there is no
    Py_tp_finalize slot.
  • #endif // _SCALER_PY311_WORKAROUNDS_H does not match the guard name
    _SCALER_LIMITED_API_WORKAROUNDS_H.

Summary

# Finding Severity
1 Use-after-free in Scaler_dealloc (segfault) Blocker
2 NPY_TARGET_VERSION bump drops numpy 1.25/1.26 Blocker
3 float16 arrays upcast to float64 High
4 Complex inf/nan differs by memory layout High
5 Scaler(x, **{})SystemError Medium
6 __mul__ instead of __rmul__ Medium
7 Equivalency conversions change by 1 ulp Medium
8 Stray ``` fails the -W docs build Medium
9 Limited-API guard inverted Low
10 No Py_mod_gil; unsynchronised lazy caches Low
11 PyArray_ISALIGNED unchecked Low
12 Bool lists silently accepted now Low
13 Nits Trivial

@astrofrog

Copy link
Copy Markdown
Member

What I find useful with reports from Claude like this is to ask it to actually generate some regression tests for these issues - if you provide those it can reduce effort on the part of the PR author (obviously not all things can be written as a test, but it is less work to just read a concise well written regression test and decide how to fix it)

@mhvk

mhvk commented Aug 7, 2026

Copy link
Copy Markdown
Contributor Author

@nstarman - thanks, that LLM report is both interesting and useful.

Fixed (EDIT: moved 2 to not followed)

  • 1: Confirmed and fixed.
  • 3 (float16) & 11 (aligned check): fixed and tested.
  • 4: The complex one is annoying, but rarely relevant. Decided to just use the complex loop (had that before...); tested
  • 5, 8, 9: small things that are now fixed (5 tested).

Not followed:

  • 2: I think my change on setup.py is fine; it is an oversight that we did not do that when we moved to numpy 2.0.
  • 6: Multiplication order: we had lambda x : factor * x and this is now reversed. I don't think that should matter in practice and it was a conscious choice as it is slightly faster, since objects are more likely to know how to multiply with a float than the reverse (i.e., it avoids getting NotImplemented from factor.__mul__(x)).
  • 7: I'm not worried about 1 ULP changes in equivalencies, and doing one division instead of many is faster.

Not completely sure:

  • 10: Free threading is tricky to me. I think I fixed it by introducing critical sections but am not sure. I could also simply not cache on the free-threaded built, but then I need to take care of references, making the code arguably more messy. Note that right now caching is pretty useless, since the scalers are used just once; but I do hope to get back to caching the scalers proper too (PERF: Add cache for some speed-up #20107).

@astrofrog - as you have a bit more experience with C, would you be able to review? (note that it is the follow-up that is truly worth it, doubling speed for large arrays...).

@mhvk
mhvk force-pushed the units-use-scaler-class branch from be73429 to de4a7ab Compare August 7, 2026 17:57
@nstarman

nstarman commented Aug 7, 2026

Copy link
Copy Markdown
Member

What I find useful with reports from Claude like this is to ask it to actually generate some regression tests for these issues - if you provide those it can reduce effort on the part of the PR author (obviously not all things can be written as a test, but it is less work to just read a concise well written regression test and decide how to fix it)

I generally agree, but didn't want to come back to this PR like here's 500 lines fixing everything and a full regression test suite. I could if requested.

@mhvk

mhvk commented Aug 7, 2026

Copy link
Copy Markdown
Contributor Author

@nstarman - I think this was just right, as I definitely wanted to think about the problems. Thanks!

mhvk added 9 commits August 8, 2026 14:38
…aa7fa63d44f8f'

git-subtree-dir: astropy/units/_scaler
git-subtree-mainline: 1fdfae1
git-subtree-split: 09320b2
With the inspectable C-based scaler class, which has optimizations
for scalars and contiguous arrays, conversions become quite a bit
faster
```
import astropy.units as u
import numpy as np

c = u.km.get_converter(u.m)
%timeit c(10.0)
45.7 ns -> 14.1 ns

b = np.arange(10.0)
%timeit c(b)
392 ns -> 84 ns
```
(With the static type one gets an ~10 ns extra speed-up, but that
does not seem worth it in the scheme of things.)

Note that while this propagates to `Quantity` operations, the effect is only
at the 10% level since there are other quite large overheads:
```
q1 = 1 * u.m
q2 = np.arange(10.0) << u.km
%timeit q1 + q2
4.19 μs -> 3.66 μs

q3 = np.arange(30.0)*u.deg
%timeit np.sin(q3)
4.12 μs -> 3.55 μs
```

Also: time needed for construction of the scaler does not change (for that,
would need to do caching):
```
%timeit u.km.get_converter(u.m)
704 ns -> 704 ns
```
In particular, let astropy decide on Py_LIMITED_API and NPY_TARGET_VERSION.
@mhvk
mhvk force-pushed the units-use-scaler-class branch from de4a7ab to f11e8eb Compare August 8, 2026 18:44
@mhvk

mhvk commented Aug 8, 2026

Copy link
Copy Markdown
Contributor Author

I rebased now the caching is merged, and edited the top comment to give combined benefit as well: ~30% reduction in execution time for conversion of small quantity arrays.

I'll rebase again once python 3.12 is the minimum version (see #20223), as I then can remove a lot of the work-arounds.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

benchmark Run benchmarks for a PR Performance units

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants