<!-- .. title: Array Libraries Interoperability .. slug: array-libraries-interoperability .. date: 2021-09-18 17:04:54 UTC-05:00 .. author: Anirudh Dagar .. tags: SciPy, PyTorch, NumPy, CuPy, uarray, Array API .. category: .. link: .. description: .. type: text --> In this blog post I talk about the work that I was able to accomplish during my internship at Quansight Labs and the efforts being made towards making array libraries more interoperable. Going ahead, I'll assume basic understanding of array and tensor libraries with their usage in the Python Scientific and Data Science software stack. As someone who's been involved in the Data Science domain for more than four years now, I can't think of any of my projects without using an array/tensor library. Over time, `NumPy` (more than 15 years old today) has become a blind import before starting any Python project involving manipulation of arrays. ***\*\*\*\*\*\*\*Enter Boodha Numpy Image\*\*\*\*\*\*\*\**** Quite recently with the Deep Learning buzz, more and more libraries have been developed with some extra sweetness on top of the NumPy like nd-array computation functionality. Namely this extra sweetness is the multi-device (`CPU`, `GPU`, `XLA` etc.) support and autograd engine for the array/tensor object. One such framework is `PyTorch`, and it has been my go-to for research in machine learning during the last couple of years. The reason simply is due to this extra saccharinity along with some other machine learning specific modules (`torch.nn`, `torch.optim` etc.) it has to offer. A few eminent array/tensor libraries include CuPy, Tensorflow, MXNet, JAX etc. Henceforth I'll just call all of them **"array provider"** libraries. <p align="center"> <img alt="Ninja Turtles Array Libraries" src="https://i.ibb.co/5jG2FBy/ninja-arrays.png"> <i>Old Ninja Turtle NumPy and young Tensor Turtles</i> </p> Libraries like [`SciPy`(https://github.com/scipy/scipy), [`scikit-learn`](https://github.com/scikit-learn/scikit-learn), [`einops`](https://github.com/arogozhnikov/einops) etc. are built on top of these "array providers" and they integrate specific features through their own methods which conventionally ingest these array/tensors as their arguments. We'll refer to these set of libraries as **"array consumer"** libraries. ## What's the catch? The pain now is that an extremely stable and popular consumer library like SciPy is cut off from the sweet features which the new array/tensor frameworks have to offer. Albeit a user might prefer SciPy's domain specific features and stable API for their scientific stack, but because the underlying project is not using NumPy, and is built on top of say PyTorch, they are left at sea and are unable to use SciPy. After some thought, one may end up choosing either of the following options. 1. Convert the inputs to NumPy before feeding them in the SciPy function which is where you lose the "extra sweetness". 2. Find some other consumer library (if exists) mimicking SciPy's API but built on top of the array/tensor library that was used in the initial project (`PyTorch`, in this case). This way the user enjoys the extra features, but this comes at the cost of learning and getting used to the new library API which mimics SciPy functionality. The APIs (function signatures) for these "array providers" are also not the same all the time which is also a problem in itself. Imagine a scientiest who has experience using NumPy for about 10 years. When they move into the PyTorch ecosystem, they are still doing a lot of similar computations, but now will need to learn a new API. Life is hard, ain't it! But as Rocky Balboa said ([actual quote :P](https://youtu.be/8xFEqdkO5UI?t=13)) > "NumPy, CuPy, PyTorch or SciPy is not gonna hit as hard as all of them when used together. But it ain't about finding a way to use them individually; it's about making them work together. That's how interoperable science is done." OK sorry, that's just me using Rocky's motivational lines to make a point. To define the issue more concretely, the question is, can we do something like the following?! ```python import torch ... # Do something with PyTorch x = torch.rand(3000) # End up with some torch tensor # Estimate power spectral density using Welch’s method. # SciPy offers a method `welch` for doing exactly that. from scipy.signal import welch f, Pxx = welch(x, fs=10e3, nperseg=1024) ``` Our world would be a better place if we could pass any kind of array/tensor object in these consumer libraries and let them do their magic, finally spitting out the same array/tensor type as the input. <font style="color: red">(Can rephrase this?)</font> What if there is a way? What if there are existing ways to achieve this? Not kidding, it is possible with recent efforts made towards this direction. ## Protocols for Interoperable Behaviour Now that the problem and motivation is pellucid, let's dive into array libraries interoperability and understanding the protocols making this a reality. Enter [NEP 18 (`__array_function__` protocol)](https://numpy.org/neps/nep-0018-array-function-protocol.html), which was one of the first efforts to address the issue of interoperability in NumPy. In a nut shell NEP 18 allows arguments of NumPy functions to define how that function operates on them. This enables using NumPy as a high level API for efficient multi-dimensional array operations, even with array implementations that differ greatly from `numpy.ndarray`. I suggest reading the [NEP](https://numpy.org/neps/nep-0018-array-function-protocol.html) itself for detailed understanding, but I'll try to expound the motivation with a simple example taken from [this](https://www.youtube.com/watch?v=HVLPJnvInzM) insightful talk by [Dr. Ralf Gommers](https://github.com/rgommers) at PyData Amsterdam. ```python import numpy as np import cupy def some_func(x): return np.log(np.sin(x) + 5) x = np.random.random((100000, 100000)) some_func(x) # Runs on CPU, might be slow # Now can we use some_func with CuPy arrays # designed to work on NVIDIA GPUs and are # faster at parallelized matrix operations. x_cupy = cupy.array(x) # NEP 18 enables this possibility some_func(x_cupy) # Runs on GPU, orders of magnitude fast ``` Since NEP 18, there have been a few other protocols like [NEP 35](https://numpy.org/neps/nep-0035-array-creation-dispatch-with-array-function.html), [NEP 37](https://numpy.org/neps/nep-0037-array-module.html) endeavouring to address some of the issues and shortcomings with NEP 18. For the sake of this blog, we'll limit our focus on [NEP 31 or `uarray`](https://uarray.org/en/latest/) and [NEP 47 or Array API (`__array_namespace__`)](https://numpy.org/neps/nep-0047-array-api-standard.html) which are some of the most recent protocols with a goal to ameliorate interoperability. ## Array API or NEP 47 Before I start describing Python Array API in the context of this blog, I urge you to read: 1. [Announcing the consortium](https://data-apis.org/blog/announcing_the_consortium/) 2. [First release of the Array API Standard](https://data-apis.org/blog/array_api_standard_release/) These two official blogs from the [Consortium for Python Data API Standards](https://data-apis.org/) describe the protocol, giving a high level overview of its existence. With the introduction and compliance of Array API in all the major array/tensor libraries, consumer libraries will be able to support more than one array framework and become truly interoperable. Let's see how the use of Array API might look in practice. ```python import torch as xp # or import numpy.array_api as xp a = xp.arange(3) b = xp.ones(3) c = a + b ``` Technically, the only changes involved for a NumPy end user to port their code in PyTorch would be to change their import statement and refactor: `np.` -> `xp.`. Where `xp` represents any array/tensor implementation compliant with the Array API. Doing something like this is duck soup compared to some other array interoperability protocols taking a much more convoluted approach. A couple of the concrete use cases mentioned in the [Array API Spec](https://data-apis.org/array-api/latest/use_cases.html#concrete-use-cases) are: * Use case 1: add hardware accelerator and distributed support to SciPy * Use case 2: simplify einops by removing the backend system Since we started by talking about consumer libraries like SciPy, let's continue with the same example. We've built a [demo](https://anirudhdagar.ml/array-api-demo/intro.html) around Array API showcasing the use of PyTorch Tensors with Scipy. The [Array API Demo](https://anirudhdagar.ml/array-api-demo/intro.html) walks you through the details and processes involved to make an array consumer library like SciPy more interoperable with array provider libraries. The demo is built keeping two different perspectives in mind, one that of an end user and the other of an open-source developer/maintainer looking to incorporate and use Array API within their array consumer library. In essence, the demo is built on top of the 2017 Nobel prize winning work for Physics about [LIGO-Virgo detector noise and extraction of transient gravitational-wave signals](https://inspirehep.net/literature/1751757). The original tutorial ([collab link](https://colab.research.google.com/github/losc-tutorial/Data_Guide/blob/master/Guide_Notebook.ipynb)) was built using NumPy, SciPy, Matplotlib and other open source libraries in the PyData ecosystem. But, instead of using NumPy arrays we switch to a PyTorch based implementation with minimal changes in the codebase. Let's dive into the exact formulation and python code that allows this behaviour. ### get_namespace The demo shows the implementation of a dummy [`get_namespace`](https://anirudhdagar.ml/array-api-demo/GW_Demo_Array_API.html#get-namespace) method which is the first function to be called inside any SciPy method. One can see how it works below, simply returning the namespace, which can be used later for calling any Array API specified methods. ```python def get_namespace(*xs): # `xs` contains one or more arrays, or possibly Python scalars (accepting # those is a matter of taste, but doesn't seem unreasonable). namespaces = { x.__array_namespace__() if hasattr(x, '__array_namespace__') else None for x in xs if not isinstance(x, (bool, int, float, complex)) } if not namespaces: # one could special-case np.ndarray above or use np.asarray here if # older numpy versions need to be supported. # This can happen when lists are sent as an input, for eg. some # SciPy functions coerce lists into ndarrays internally. raise ValueError("Unrecognized array input") if len(namespaces) != 1: raise ValueError(f"Multiple namespaces for array inputs: {namespaces}") xp, = namespaces if xp is None: raise ValueError("The input is not a supported array type") return xp ``` This should be possible with the Array API, for the end-user in a future release of SciPy and PyTorch. Please read more on the [demo](https://anirudhdagar.ml/array-api-demo/intro.html) page itself. Interestingly in the process, with an effort to make PyTorch more [array api](https://data-apis.org/array-api/latest/) compatible, I also started contributing to PyTorch. My first PyTorch PR [#62560](https://github.com/pytorch/pytorch/pull/62560) started with adding an alias `torch.concat` for `torch.cat` which is how concatenation is defined in the array api spec and ended with fixing a bunch of other things related to concatenation in PyTorch. I worked on adding and improving compatibility for Array API to the `torch.linalg` module as well recently. See [pytorch/pytorch#63285](https://github.com/pytorch/pytorch/pull/63285) and [pytorch/pytorch#63227](https://github.com/pytorch/pytorch/pull/63227). ## uarray ``uarray`` is a backend system for Python that allows you to separately define an API, along with backends that contain separate implementations of that API. For uarray I've been working on adding support to more SciPy modules. This [uarray backend compatibility tracker](https://github.com/scipy/scipy/issues/14353) in SciPy issue sums up the plan and current state of uarray in SciPy. Precisely I've been working on adding uarray support in the `ndimage` module for the last couple months. See [scipy/scipy#14356](https://github.com/scipy/scipy/pull/14356) for more details on the discussion. With the `ndimage` ```python import numpy as np import cupy def some_func(x): return np.log(np.sin(x) + 5) x = np.random.random((100000, 100000)) some_func(x) # Runs on CPU, might be slow # Now can we use some_func with CuPy arrays # designed to work on NVIDIA GPUs and are # faster at parallelized matrix operations. x_cupy = cupy.array(x) # NEP 18 enables this possibility some_func(x_cupy) # Runs on GPU, orders of magnitude fast ``` From the perspective of interoperability I list below some Pros & Cons for these two protocols. * `uarray` * It can handle compiled code. * Involves lot of setup machinery to make it work * The dispatching mechanism is not implicit. The user needs to register the backend before he/she can use the array library of choice. See the disucssion we opened for auto registering the backend but that is probably something maintainers don't really want. * Array API & `__array_namespace__` protocol * Extremely easy for libraries to add support to array api and make their methods compatible with multiple "array providers". * One can make use of the consumer library functions without thinking about any other registration etc. and passing the array implementation of their choice. Additionally array api also makes it easier for the user to switch between different array implementations since the signatures are standardized across frameworks. * Can't handle compiled code, works for python/numpy based consumer library methods only. ## What's Next? I've absolutely enjoyed the Scientefic Python Open-Source community and plan to continue working on projects including SciPy and PyTorch voluntarily going forward. More specifically, I plan to work on improving interoperability with other libraries in `PyTorch` with `Python Array API` compliance, which is aimed for a release in `1.11` and also improving NumPy support. There are a lot of interesting gaps that I aim to fill in the `OpInfo` testing module and in general trying to catch a few bugs through the improved testing framework. With the recent migration to `Structured Kernels`, I also plan to help out with porting of some Ops to `structured`. In `SciPy`, I aim to continue adding and improving `uarray` backend support for more modules. This also extends work into libraries like `CuPy`, `Dask` etc. where the `uarray` backend needs to be enabled. Apart from `uarray`, I'd also like to explore and contribute to a few more intersting tracks currently being focussed on in SciPy. These include the work involving ## References * [Python Data API Standards](https://data-apis.org/) * [uarray](https://uarray.org/en/latest/) * [NEP 18 — A dispatch mechanism for NumPy’s high level array functions (`__array_function__` Protocol)](https://numpy.org/neps/nep-0018-array-function-protocol.html) * [NEP 31 — Context-local and global overrides of the NumPy API](https://numpy.org/neps/nep-0031-uarray.html) * [NEP 47 — Adopting the array API standard](https://numpy.org/neps/nep-0047-array-api-standard.html) * [Ralf Gommers: The evolution of array computing in Python | PyData Amsterdam 2019](https://www.youtube.com/watch?v=HVLPJnvInzM)