Torch interoperability
======================

Send data to torch
------------------
When using `ultraspy` on GPU, the objects are sent to GPU using the `cupy`
library, which is dealing with the pointers to know where to find the data
within the GPU device.

If you aim to use some Deep Learning algorithm, or simply to run your own
algorithm using the `torch` library, it is possible to send the information to
the torch library. You can do it the following way:

.. code-block:: python
    :linenos:

    import numpy as np
    import cupy as cp
    import torch

    # Some random data
    a = np.random.randint(10, size=(5, 6))

    # Send it to device using cupy, as ultraspy does
    cupy_a = cp.array(a, np.float32)

    # Transfer its pointer to torch so it can be used in both libraries
    torch_a = torch.as_tensor(cupy_a, device='cuda')


Now, the array :code:`a` is available both on CPU (the numpy array called
:code:`a`) and on GPU (accessible both by `cupy` with :code:`cupy_a` and
`torch` with :code:`torch_a`). Note that both :code:`cupy_a` and
:code:`torch_a` refers to the same data on GPU, meaning that if you update one,
the other one will also be updated. Let's say for example that you want to
convert the data as decibel using both libraries you can do:

.. code-block:: python
    :linenos:

    # Normalize the data using cupy
    cupy_a /= cupy_a.max()

    # Convert to log using torch
    torch.log10(torch_a, out=torch_a)

    # Get the data back on CPU
    cpu_cupy_a = cupy_a.get()
    cpu_torch_a = torch_a.cpu()

    # Both are equal
    print(np.allclose(cpu_cupy_a, cpu_torch_a))


All good. Few things to consider here though. It works as long as we are
modifying directly the device array. Make sure that all the operations are
inplace, and won't allocate a new array in memory in the process. If a new
array is allocated by either `cupy` or `torch`, the other lib won't be aware of
the update:

.. code-block:: python
    :linenos:

    # Operation is modifying both libraries
    cupy_a /= cupy_a.max()
    print(np.allclose(cupy_a.get(), torch_a.cpu()))  # True

    # The array in cupy is divided by max, then stored in a new array on GPU,
    # that happens to have the same name, torch and cupy are now different
    # pointers
    cupy_a = cupy_a / cupy_a.max()
    print(np.allclose(cupy_a.get(), torch_a.cpu()))  # False


Similarly in `torch`:

.. code-block:: python
    :linenos:

    # The log10, by default, is allocating a new array, but we can specify the
    # output location. If it is the same as before, it'll still be the same
    # pointer as cupy
    torch.log10(torch_a, out=torch_a)
    print(np.allclose(cupy_a.get(), torch_a.cpu()))  # True

    # A new pointer is created, cupy array is detached from torch'
    torch_a = torch.log10(torch_a)
    print(np.allclose(cupy_a.get(), torch_a.cpu()))  # False


That's it! And if you want to go the other way, and converting your torch
Tensor to a cupy array, you should use:

.. code-block:: python
    :linenos:

    a = np.random.randint(10, size=(5, 6))
    torch_a = torch.as_tensor(a)
    cupy_a = cp.asarray(torch_a)


Load a Deep Learning model
--------------------------
A good way to call a Deep Learning model from `ultraspy` could be to embed it
within a Singleton class. That way, we can ensure that the model is
instantiated only once. Any other way would work too based on your application,
but we'll focus on the Singleton in this example.

Let's imagine we've trained a network to process three tilted plane waves to
produce results as good as if we had sent 31 plane waves. A way to embed it
could be:

.. code-block:: python
    :linenos:

    class MyModelSingleton:
        # General information
        _instance = None
        _model = None
        _weights = "torch_models/my_model_weights.pth"

        def __new__(cls, *args, **kwargs):
            # Initializer, only once
            if cls._instance is None:
                cls._model = MyModelClass().to("cuda")
                cls._model.load_state_dict(torch.load(cls._weights))
                cls._model.eval()
                cls._instance = super().__new__(cls)
            return cls._instance

        def prepare_data(self, d_data):
            # Prepare the data, and transfer its pointer to torch
            return torch.as_tensor(d_data, device='cuda')

        def run_inference(self, t_data):
            # Runs the inference and return the prediction
            with torch.inference_mode():
                prediction = self._model(t_data)
            return prediction

        def back_to_cupy(self, t_data):
            # Returns the data to the cupy lib
            return cp.asarray(t_data)

That way, our function would be very simple:

.. code-block:: python
    :linenos:

    def deep_compound(d_beamformed):
        # The model is ready for use after the first instantiation
        model = CidNetSingleton()

        input_model_tensor = model.prepare_data(d_beamformed)
        prediction = model.run_inference(input_model_tensor)
        return model.back_to_cupy(prediction)
