Skip to content

ENH: Begin overhaul of ufunc machinery #20260

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 3 commits into from
Mar 23, 2024
Merged

ENH: Begin overhaul of ufunc machinery #20260

merged 3 commits into from
Mar 23, 2024

Conversation

izaid
Copy link
Contributor

@izaid izaid commented Mar 16, 2024

Hi all! This is a work in progress PR, and it is really meant as an example of one way we can go in the special overhaul. What I'm hoping to achieve is a discussion on the ultimate plan for special's build process. It's quite complex as is, and I don't think we need that complexity. Here we go.

I think most agree that the ultimate goal of the SciPy special module should be to provide a bunch of ufuncs, and we want the simplest possible method that turns the C++ scalar functions into ufuncs. So what is it? I see only two real options: using the NumPy C API directly or continuing to use Cython. Here I've created an example of using the NumPy C API.

What I have done is taken one example from specfun, specifically expi, and shown how to directly turn it into a ufunc using only NumPy's C API. To demonstrate overloading, I've overloaded the expi ufunc to also accept its complex version cexpi, which is also in specfun. The example module is specfun2.cpp in special. Most of that code is some functionality which will go in a common header. Everything works fine. No extra wrapper functions are needed, we can go right from the C++ function itself to the NumPy ufunc.

Is this the way people want to go? I believe @ilayn has been thinking along these lines at #19964, so this PR is a natural continuation of that issue. If we can achieve consensus, I will do this for all of specfun that was just converted.

Closes #19964

cc @ilayn @steppi

@izaid izaid requested review from person142 and steppi as code owners March 16, 2024 10:35
@github-actions github-actions bot added scipy.special C/C++ Items related to the internal C/C++ code base Meson Items related to the introduction of Meson as the new build system for SciPy RFC Request for Comments; typically used to gather feedback for a substantial change proposal labels Mar 16, 2024
@izaid izaid changed the title [RFC] Overall of ufuncs and cython_special machinery [RFC] Overhaul of ufuncs and cython_special machinery Mar 16, 2024
@lucascolley lucascolley removed the Meson Items related to the introduction of Meson as the new build system for SciPy label Mar 16, 2024
@ev-br
Copy link
Member

ev-br commented Mar 16, 2024

Could you show how to add a docstring, too?

@izaid
Copy link
Contributor Author

izaid commented Mar 16, 2024

@ev-br Done! It's just the second last argument in PyUFunc_FromFuncAndData.

@ilayn
Copy link
Member

ilayn commented Mar 16, 2024

There are two separate items regarding Cython (the first one I am not 100% informed):

  1. Use Cython machinery to expose the functions to the ufuncs machinery through some black magic
  2. Use Cython to expose the module cython_special for low-level implementations and also expose the functions to the Python API.

I think item 2 is here to stay regardless what we choose and the item 1 is the part handled here. And for that I think it would be great if we cut the fat out and use NumPy C API. But maintainability is a real concern since item 1 is already a very complicated machinery. So if we go down this path we should be exceptionally verbose in commenting and documenting for posterity. It is already very difficult for me to read even this much of code even though I feel what is happening. But I don't know why we are doing what we are doing in the individual steps.

@izaid
Copy link
Contributor Author

izaid commented Mar 16, 2024

Thanks for the thoughts, @ilayn! I agree we should try and make this as clear and well-documented as possible, and we can work towards that. And yes, cython_special is something else. We will keep that working as is using Cython. At this point, it's really just exposing the scalar functions to Cython.

Effectively what we would be doing is using NumPy's machinery to expose the special functions as ufuncs and, elsewhere, using Cython's machinery to expose the special functions as Cython-compatible cimports for cython_special. It makes sense to me to do both separately, and not mix them. We're just working with the provided tools of each.

@steppi
Copy link
Contributor

steppi commented Mar 16, 2024

@izaid, have you looked at the ufunc definitions generated by _generated_pyx.py? Check here. It basically looks exactly like what you have, but is written is Cython. My thought was to keep the functions.json and _add_newdocs.py, but to change the ufunc generation code to generate C++ instead of Cython.

@izaid
Copy link
Contributor Author

izaid commented Mar 16, 2024

@steppi That's very interesting, thanks for pointing it out. Indeed, it's basically the same. In that case, why use Cython at all in this intermediate step if we can now easily do exactly the same just with a C / C++ compiler and no intermediate step? 10 years ago, that probably wasn't true. Now I think having Cython do what C / C++ can do is just overcomplicating things. I'd argue we shouldn't use a secondary code generation step at all unless we really need it.

@steppi
Copy link
Contributor

steppi commented Mar 16, 2024

@steppi That's very interesting, thanks for pointing it out. Indeed, it's basically the same. In that case, why use Cython at all in this intermediate step if we can now easily do exactly the same just with a C / C++ compiler and no intermediate step? 10 years ago, that probably wasn't true. Now I think having Cython do what C / C++ can do is just overcomplicating things. I'd argue we shouldn't use a secondary code generation step at all unless we really need it.

I think we're all on the same page that Cython is no longer necessary here. From the comments in _generate_pyx.py, it seems like it may have been a work around for having to mix Fortran, C, and C++, but I'm not sure actually. It may just still being used because of inertia and the opportunity cost of working on the overhaul, verse other matters.

I think it's nice to be able to specify only the info needed for each function like we have in functions.json and _add_newdocs.py. From a developer perspective it seems like it would be more annoying to have to basically write out the C++ equivalent of _ufuncs.pyx by hand. Could we make things easier for ourselves with some C++ metaprogramming instead of code generation?

@izaid
Copy link
Contributor Author

izaid commented Mar 16, 2024

@steppi That's very interesting, thanks for pointing it out. Indeed, it's basically the same. In that case, why use Cython at all in this intermediate step if we can now easily do exactly the same just with a C / C++ compiler and no intermediate step? 10 years ago, that probably wasn't true. Now I think having Cython do what C / C++ can do is just overcomplicating things. I'd argue we shouldn't use a secondary code generation step at all unless we really need it.

I think we're all on the same page that Cython is no longer necessary here. From the comments in _generate_pyx.py, it seems like it may have been a work around for having to mix Fortran, C, and C++, but I'm not sure actually. It may just still being used because of inertia and the opportunity cost of working on the overhaul, verse other matters.

I think it's nice to be able to specify only the info needed for each function like we have in functions.json and _add_newdocs.py. From a developer perspective it seems like it would be more annoying to have to basically write out the C++ equivalent of _ufuncs.pyx by hand. Could we make things easier for ourselves with some C++ metaprogramming instead of code generation?

So what I showed was intended to be a reasonable mix between something simple and using template metaprogramming. If the goal is really to have one line per function, I could try and come up with an even more concise solution. I'd certainly prefer that then code generation from Python.

My thoughts on writing it out by hand, which may not be shared, are that it's just copy-and-paste of a few lines per ufunc anyway. And doing that, but keeping it in C / C++, allows to clearly see what's going on underneath.

@steppi
Copy link
Contributor

steppi commented Mar 16, 2024

My thoughts on writing it out by hand, which may not be shared, are that it's just copy-and-paste of a few lines per ufunc anyway. And doing that, but keeping it in C / C++, allows to clearly see what's going on underneath.

Would we basically just be writing out the C++ version of _ufuncs.pyx by hand and maintaining that? It would be cool if there was a way to make things simpler without resorting to code generation. I don't really mind the use of code generation in itself though; I think the biggest problem is the lack of documentation for the code generation process.

@lucascolley lucascolley removed the request for review from person142 March 16, 2024 22:44
@izaid
Copy link
Contributor Author

izaid commented Mar 16, 2024

@steppi I now believe it is possible to have a really simple solution provided we can use C++17. I've just updated the module (not totally done as is, but you get the idea). Almost everything in there would go in a single header. We could then just need to do, per ufunc:

PyObject *expi = SpecFun_UFunc<special::expi, special::cexpi>("expi", "docstring goes here");
PyModule_AddObjectRef(specfun2, "expi", expi);

The type signatures are deduced.

@steppi
Copy link
Contributor

steppi commented Mar 16, 2024

@steppi I now believe it is possible to have a really simple solution provided we can use C++17. I've just updated the module (not totally done as is, but you get the idea). Almost everything in there would go in a single header. We could then just need to do, per ufunc:

PyObject *expi = SpecFun_UFunc<special::expi, special::cexpi>("expi", "docstring goes here");
PyModule_AddObjectRef(specfun2, "expi", expi);

The type signatures are deduced.

Nice! That's really promising. We can use C++17 now too, #19811, which wasn't the case when we got started.

@lucascolley
Copy link
Member

lucascolley commented Mar 16, 2024

c++17 should be fine, we have that as cpp_std in our top level meson.build now and the only problem so far seems to be gh-20256.

EDIT: snap

@steppi
Copy link
Contributor

steppi commented Mar 16, 2024

@izaid, does the test suite pass locally for you? If not, but you just want to push so we can see the changes, you can add [skip ci] to the end of your commit messages

@izaid
Copy link
Contributor Author

izaid commented Mar 16, 2024

It does pass locally, and should now as well.

@steppi
Copy link
Contributor

steppi commented Mar 16, 2024

It does pass locally, and should now as well.

Cool. What was the issue? It looked like every job that runs the test suite failed in CI earlier.

@steppi
Copy link
Contributor

steppi commented Mar 16, 2024

Are you building with Run python dev.py build --werror locally? It seems the issue is due to warnings getting treated as errors.

@izaid
Copy link
Contributor Author

izaid commented Mar 16, 2024

It does pass locally, and should now as well.

Cool. What was the issue? It looked like every job that runs the test suite failed in CI earlier.

I've had a few bugs, may still, but what I have now should work portably. I am building with python dev.py build --werror, but I can check the CI and sort it out.

@steppi
Copy link
Contributor

steppi commented Mar 16, 2024

Seems to be working now. NP_NO_DEPRECATED_API was already being set in the build to a different value.

scipy/scipy/meson.build

Lines 73 to 76 in 9a51186

# Don't use the deprecated NumPy C API. Define this to a fixed version instead of
# NPY_API_VERSION in order not to break compilation for released SciPy versions
# when NumPy introduces a new deprecation.
numpy_nodepr_api = ['-DNPY_NO_DEPRECATED_API=NPY_1_9_API_VERSION']

@izaid
Copy link
Contributor Author

izaid commented Mar 17, 2024

Reasonably happy with this prototype now. To reiterate, everything before static PyModuleDef specfun2_def would go in a common header file. Then, for each ufunc, we would do something like:

PyObject *expi = SpecFun_UFunc<special::expi, special::cexpi>("expi", "docstring goes here");
PyModule_AddObjectRef(specfun2, "expi", expi);

That's it.

@lucascolley
Copy link
Member

lucascolley commented Mar 17, 2024

../_lib/tests/test_public_api.py::test_all_modules_are_expected - AssertionError: Found unexpected modules: ['scipy.special.specfun2'] just needs an edit to the test function.

../special/tests/test_nan_inputs.py::test_nan_inputs[expi] - RuntimeWarning: invalid value encountered in expi looks real though, plus there is a segfault and:

  File "D:\a\scipy\scipy\build-install\Lib\site-packages\scipy\special\tests\test_exponential_integrals.py", line 82 in TestExpi
  File "D:\a\scipy\scipy\build-install\Lib\site-packages\scipy\special\tests\test_exponential_integrals.py", line 79 in <module>
  File "C:\hostedtoolcache\windows\Python\3.9.13\x64\lib\site-packages\_pytest\assertion\rewrite.py", line 178 in exec_module
  File "<frozen importlib._bootstrap>", line 680 in _load_unlocked
  File "<frozen importlib._bootstrap>", line 986 in _find_and_load_unlocked
  File "<frozen importlib._bootstrap>", line 1007 in _find_and_load
  File "<frozen importlib._bootstrap>", line 1030 in _gcd_import
  File "C:\hostedtoolcache\windows\Python\3.9.13\x64\lib\importlib\__init__.py", line 127 in import_module
  File "C:\hostedtoolcache\windows\Python\3.9.13\x64\lib\site-packages\_pytest\pathlib.py", line 584 in import_path
  File "C:\hostedtoolcache\windows\Python\3.9.13\x64\lib\site-packages\_pytest\python.py", line 520 in importtestmodule
  File "C:\hostedtoolcache\windows\Python\3.9.13\x64\lib\site-packages\_pytest\python.py", line 573 in _getobj
  File "C:\hostedtoolcache\windows\Python\3.9.13\x64\lib\site-packages\_pytest\python.py", line [315](https://github.com/scipy/scipy/actions/runs/8311378460/job/22744973081?pr=20260#step:7:316) in obj
  File "C:\hostedtoolcache\windows\Python\3.9.13\x64\lib\site-packages\_pytest\python.py", line 589 in _register_setup_module_fixture
  File "C:\hostedtoolcache\windows\Python\3.9.13\x64\lib\site-packages\_pytest\python.py", line 576 in collect
  File "C:\hostedtoolcache\windows\Python\3.9.13\x64\lib\site-packages\_pytest\runner.py", line 388 in collect
  File "C:\hostedtoolcache\windows\Python\3.9.13\x64\lib\site-packages\_pytest\runner.py", line 340 in from_call
  File "C:\hostedtoolcache\windows\Python\3.9.13\x64\lib\site-packages\_pytest\runner.py", line 390 in pytest_make_collect_report
  File "C:\hostedtoolcache\windows\Python\3.9.13\x64\lib\site-packages\pluggy\_callers.py", line 102 in _multicall
  File "C:\hostedtoolcache\windows\Python\3.9.13\x64\lib\site-packages\pluggy\_manager.py", line 119 in _hookexec
  File "C:\hostedtoolcache\windows\Python\3.9.13\x64\lib\site-packages\pluggy\_hooks.py", line 501 in __call__
  File "C:\hostedtoolcache\windows\Python\3.9.13\x64\lib\site-packages\_pytest\runner.py", line 565 in collect_one_node
  File "C:\hostedtoolcache\windows\Python\3.9.13\x64\lib\site-packages\_pytest\main.py", line 839 in _collect_one_node
  File "C:\hostedtoolcache\windows\Python\3.9.13\x64\lib\site-packages\_pytest\main.py", line 976 in genitems
  File "C:\hostedtoolcache\windows\Python\3.9.13\x64\lib\site-packages\_pytest\main.py", line 981 in genitems
  File "C:\hostedtoolcache\windows\Python\3.9.13\x64\lib\site-packages\_pytest\main.py", line 981 in genitems
  File "C:\hostedtoolcache\windows\Python\3.9.13\x64\lib\site-packages\_pytest\main.py", line 981 in genitems
  File "C:\hostedtoolcache\windows\Python\3.9.13\x64\lib\site-packages\_pytest\main.py", line 813 in perform_collect
  File "C:\hostedtoolcache\windows\Python\3.9.13\x64\lib\site-packages\_pytest\main.py", line 349 in pytest_collection
  File "C:\hostedtoolcache\windows\Python\3.9.13\x64\lib\site-packages\pluggy\_callers.py", line 102 in _multicall
  File "C:\hostedtoolcache\windows\Python\3.9.13\x64\lib\site-packages\pluggy\_manager.py", line 119 in _hookexec
  File "C:\hostedtoolcache\windows\Python\3.9.13\x64\lib\site-packages\pluggy\_hooks.py", line 501 in __call__
  File "C:\hostedtoolcache\windows\Python\3.9.13\x64\lib\site-packages\_pytest\main.py", line 338 in _main
  File "C:\hostedtoolcache\windows\Python\3.9.13\x64\lib\site-packages\_pytest\main.py", line 285 in wrap_session
  File "C:\hostedtoolcache\windows\Python\3.9.13\x64\lib\site-packages\_pytest\main.py", line [332](https://github.com/scipy/scipy/actions/runs/8311378460/job/22744973081?pr=20260#step:7:333) in pytest_cmdline_main
  File "C:\hostedtoolcache\windows\Python\3.9.13\x64\lib\site-packages\pluggy\_callers.py", line 102 in _multicall
  File "C:\hostedtoolcache\windows\Python\3.9.13\x64\lib\site-packages\pluggy\_manager.py", line 119 in _hookexec
  File "C:\hostedtoolcache\windows\Python\3.9.13\x64\lib\site-packages\pluggy\_hooks.py", line 501 in __call__
  File "C:\hostedtoolcache\windows\Python\3.9.13\x64\lib\site-packages\xdist\remote.py", line 355 in <module>
  File "C:\hostedtoolcache\windows\Python\3.9.13\x64\lib\site-packages\execnet\gateway_base.py", line 1157 in executetask
  File "C:\hostedtoolcache\windows\Python\3.9.13\x64\lib\site-packages\execnet\gateway_base.py", line 296 in run
  File "C:\hostedtoolcache\windows\Python\3.9.13\x64\lib\site-packages\execnet\gateway_base.py", line 361 in _perform_spawn
  File "C:\hostedtoolcache\windows\Python\3.9.13\x64\lib\site-packages\execnet\gateway_base.py", line [343](https://github.com/scipy/scipy/actions/runs/8311378460/job/22744973081?pr=20260#step:7:344) in integrate_as_primary_thread
  File "C:\hostedtoolcache\windows\Python\3.9.13\x64\lib\site-packages\execnet\gateway_base.py", line 1142 in serve
  File "C:\hostedtoolcache\windows\Python\3.9.13\x64\lib\site-packages\execnet\gateway_base.py", line 1640 in serve
  File "<string>", line 8 in <module>
  File "<string>", line 1 in <module>
Windows fatal exception: access violation

@izaid
Copy link
Contributor Author

izaid commented Mar 22, 2024

Just a final question I just realized. Why do we have references to float while the function body has double implementation in some places?

We have both now. For the most part, the float32 functions that we've currently put in are just casting to float64, then back-casting to float32. This is the same behaviour as currently in SciPy. But long term, we are now in a position to enable float32 implementations easily.

@ilayn
Copy link
Member

ilayn commented Mar 22, 2024

So

template <typename T>
T ber(T x) {
    std::complex<T> Be;
    T ber, bei, ger, gei, der, dei, her, hei;

[snip]

is the double implementation? Because I only saw this part

template <>
inline float ber(float xf) {
    double x = xf;
    return ber(x);
}

C++ is really weird if it is so.

But long term, we are now in a position to enable float32 implementations easily.

I think that is more ambitious than my fortran translation and probably starting to fall into YAGNI cluster. But don't let me stop you :)

@izaid
Copy link
Contributor Author

izaid commented Mar 22, 2024

template <typename T>
T ber(T x) {
    std::complex<T> Be;
    T ber, bei, ger, gei, der, dei, her, hei;

[snip]

This is an implementation for a generic type T, where T can be instantiated with T = double, T = float, even T = std::complex<double> if the function supports it. Since we've adapted these from cephes, they're still basically the double implementation and we will work towards making them generic for floating-point types as we go.

template <>
inline float ber(float xf) {
    double x = xf;
    return ber(x);
}

And this is indeed the float implementation, which only casts to double at the moment. The template <> means we are declaring a specialisation, in particular T = float.

I think that is more ambitious than my fortran translation and probably starting to fall into YAGNI cluster. But don't let me stop you :)

Haha, understood. I think it's relatively little work to enable generic floating-point types, but we'll do that one step at a time in future PRs.

@steppi
Copy link
Contributor

steppi commented Mar 22, 2024

Haha, understood. I think it's relatively little work to enable generic floating-point types, but we'll do that one step at a time in future PRs.

I think it will be a lot of work, but doable. For functions that use minimax polynomial or rational approximations, and where ones generated for 32 bit precision aren't available, we'd want to generate new rational functions and polynomials, to different degrees. For this, Boost has an implementation of the Remez exchange algorithm we could use. I've also been in contact with Stephen Moshier, the author of cephes, who explained to me relatively straightforward updates that could be made to the implementation he used for cephes that would make it a strong contender for us to use.

There will also be other little details, like wanting to take asymptotic expansions to a different order. It's not all as simple as changing a tolerance from 2.220446049250313e-16 to 1.1920929e-07. Not that any of it would be particularly difficult, but I get the impression there would be a lot of little things to attend to.

@ilayn
Copy link
Member

ilayn commented Mar 22, 2024

This is an implementation for a generic type T, where T can be instantiated with T = double, T = float, even T = std::complex if the function supports it.

Yes but shouldn't it be actively rejecting if it does not support it? Hence my confusion, how would it downcast a complex? I think that would be a bit too liberal to allow for it.

@izaid
Copy link
Contributor Author

izaid commented Mar 22, 2024

This is an implementation for a generic type T, where T can be instantiated with T = double, T = float, even T = std::complex if the function supports it.

Yes but shouldn't it be actively rejecting if it does not support it? Hence my confusion, how would it downcast a complex? I think that would be a bit too liberal to allow for it.

Indeed it doesn't do that. If it doesn't support it, it wouldn't compile. Also we only add the functions into the ufunc that we want.

@ilayn
Copy link
Member

ilayn commented Mar 22, 2024

OK let me use an example, currently you have itairy one for float and one generic implementation. How does it stop passing complex to this function?

Is it going to reject it at runtime because it can't find the specialization?

@izaid
Copy link
Contributor Author

izaid commented Mar 22, 2024

OK let me use an example, currently you have itairy one for float and one generic implementation. How does it stop passing complex to this function?

Is it going to reject it at runtime because it can't find the specialization?

Yes, exactly that. The ufunc will reject at runtime. Why? Because we didn't put the complex implementation into the ufunc, just float and double:

PyObject *itairy = SpecFun_NewUFunc({special::itairy<float>, special::itairy<double>}, 4, "itairy", itairy_doc);
PyModule_AddObjectRef(_special_ufuncs, "itairy", itairy);

@ilayn
Copy link
Member

ilayn commented Mar 22, 2024

Ah OK if something catches this error in the machinery and converts to a proper error then we are safe.

@izaid izaid requested a review from larsoner as a code owner March 23, 2024 00:01
@steppi steppi removed the request for review from larsoner March 23, 2024 00:08
@lucascolley lucascolley added enhancement A new feature or improvement and removed RFC Request for Comments; typically used to gather feedback for a substantial change proposal labels Mar 23, 2024
@steppi steppi changed the title ENH: Overhaul of ufuncs and cython_special machinery ENH: Begin overhaul of ufunc machinery Mar 23, 2024
@steppi steppi merged commit 7b3831a into scipy:main Mar 23, 2024
@steppi
Copy link
Contributor

steppi commented Mar 23, 2024

Thanks @izaid, amazing work!

I split this into three commits so that Lucas and I don't end up all over the blame after each only authoring a few minor changes. Separating out the linting commits would have required some advanced git surgery, because there was at least one that also touched some C++ code, so I just squashed those in with others. It's not really an issue at all. I verified that the diff is exactly the same as before the force push, so felt secure merging before all of the tests finished.

@izaid izaid deleted the specext branch March 23, 2024 07:21
@j-bowhay j-bowhay added this to the 1.14.0 milestone Mar 23, 2024
@izaid
Copy link
Contributor Author

izaid commented Mar 23, 2024

Many thanks @steppi! I'm very glad to see this get in, it was the missing piece in our quest to sort out special. It should be much easier now to convert things chunk by chunk.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
C/C++ Items related to the internal C/C++ code base enhancement A new feature or improvement scipy.special
Projects
None yet
Development

Successfully merging this pull request may close these issues.

MAINT:BLD:special:Overhaul _ufuncs and cython_special machinery
8 participants