.. _dev-numba:
Using Numba to Accelerate Methods
=================================
This is a guide to using `Numba's `_ just in time (JIT) compiler within pydmqmc.
Most of pydmqmc's computational complexity is found within the ``run()`` method
of the various :ref:`Methods`. After all, this is the function
that performs the actual model calculation, whether it is iterative or analytic.
For better performance, these calculations can be accelerated using
Numba's JIT compiler. This creates a faster version of the calculation
at the (hopefully small) cost of some initial compile time.
It is best used for functions that are used repeatedly, such as the
core of an :ref:`Iterative Method `.
Usually, Numba will compile any function to which the ``@njit``
decorator is applied:
.. code-block:: python
from numba import njit
@njit
def some_function(...):
...
Applying it to a method in a class, however, takes a few extra steps:
1. The decorated function cannot call on class members directly (i.e. a wrapper must be used to pass class attributes)
2. The ``@njit`` decorator can only be applied to `static methods`_
.. _static methods: https://docs.python.org/3/library/functions.html#staticmethod
Static methods are methods that are associated with the class itself, not an instance of it.
It cannot modify the state of the class (i.e. update class attributes). Because Numba can only
be applied to static methods (item 2), this necessarily implies item 1, but we separate them out
conceptually in this order for the sake of this guide.
Throughout this guide, we'll be using :class:`pydmqmc.methods.AsymmetricBlochDMQMC` as a case study.
We'll present code blocks that don't reflect the current state of the source code, but rather
demonstrate the logic *behind* :class:`~pydmqmc.methods.AsymmetricBlochDMQMC`'s current form.
What Makes a Good Candidate for Numba?
--------------------------------------
The :class:`~pydmqmc.methods.AsymmetricBlochDMQMC` method is an iterative one.
It belongs to the family of :ref:`methods-dmqmc`. As such, it evolves a
density matrix :math:`\rho` from a starting inverse temperature of :math:`\beta=0` to some
finite, positive :math:`\beta`. Every step of the iteration, a number of "psi particles" or
"psips" are propagated through the matrix.
This propagation is a good candidate for acceleration with Numba. Since it is the
heart of an iterative calculation, it is run many times. It is also very slow, since
psip propagation isn't easily optimized. **All told, the time it takes to compile this
method is outweighed by the speedup provided.**
Let's outline the structure of such a propagation method:
.. code-block:: python
class AsymmetricBlochDMQMC(Iterative):
...
def _propagate(self, *args, **kwargs):
"""
Return drho given the current state of the density matrix rho.
"""
# This method has an attribute called "_density_matrix"
# that was constructed elsewhere
rho = self._density_matrix
drho = np.zeros_like(self._density_matrix)
H = self.system.hamiltonian
dets = H.shape[0]
for i in range(dets):
for j in range(dets):
drho[i, j] = rho[i, j] * (H[0, 0] - H[i, j])
...
return drho
.. note::
Remember that :class:`~pydmqmc.methods.Method` objects must have a :class:`~pydmqmc.systems.System`
object associated with them. The :class:`~pydmqmc.systems.System` defines information that's necessary
for the execution of the :class:`~pydmqmc.methods.Method`; in this case, the Hamiltonian.
This method could be used inside :meth:`~pydmqmc.methods.AsymmetricBlochDMQMC.run` as highlighted below:
.. code-block:: python
:emphasize-lines: 15
from pydmqmc.utils import euler
class AsymmetricBlochDMQMC(Iterative):
...
def run(self, dbeta: float, ...)
# self._final_beta was set during the call to setup()
n_cycles = int(self._final_beta / dbeta)
# update self._density_matrix every cycle using Euler's method
for cycle in range(n_cycles):
self._density_matrix = euler(
self._propagate, # function f(dy/dt) to integrate
self._density_matrix, # dependent var y
dbeta, # stepsize dt
...
)
As we can see, ``_propagate`` will be run multiple times. This makes it a good candidate
for acceleration with Numba.
Step 1: Eliminating Calls to ``self``
-------------------------------------
As mentioned at the top of this guide, Numba's JIT compiler can only be applied
to static methods and static methods have no access to the members of the class
they're attached to. To that end, we must remove such references. Let's take
another look at our ``_propagate`` method and highlight the references that need to be removed:
.. code-block:: python
:emphasize-lines: 5,11,12,14
class AsymmetricBlochDMQMC(Iterative):
...
def _propagate(self, *args, **kwargs):
"""
Return drho given the current state of the density matrix rho.
"""
# This method has an attribute called "_density_matrix"
# that was constructed elsewhere
rho = self._density_matrix
drho = np.zeros_like(self._density_matrix)
H = self.system.hamiltonian
dets = H.shape[0]
for i in range(dets):
for j in range(dets):
drho[i, j] = rho[i, j] * (H[0, 0] - H[i, j])
...
return drho
The easiest way to remove these references is to create a **wrapper** function. This wrapper
will be able to access the class attributes and pass them to ``_propagate``. We can then
remove references to ``self`` from ``_propagate``:
.. code-block:: python
:emphasize-lines: 5,11,12,14,26,27
class AsymmetricBlochDMQMC(Iterative):
...
def _propagate(density_matrix, hamiltonian, *args, **kwargs):
"""
Return drho given the current state of the density matrix rho.
"""
# This method has an attribute called "_density_matrix"
# that was constructed elsewhere
rho = density_matrix
drho = np.zeros_like(density_matrix)
H = hamiltonian
dets = H.shape[0]
for i in range(dets):
for j in range(dets):
drho[i, j] = rho[i, j] * (H[0, 0] - H[i, j])
...
return drho
def _propagate_wrapper(self):
return self._propagate(
self._density_matrix,
self.system.hamiltonian,
*args,
**kwargs
)
.. note::
In the actual :class:`~pydmqmc.methods.AsymmetricBlochDMQMC` source code,
the wrapper function is called ``_propagate`` and the accelerated function
it wraps is called ``_propagate_core``. We chose to flip the naming convention
for this tutorial to better match the flow of the explanation.
We can now use this wrapper function with :meth:`~pydmqmc.methods.AsymmetricBlochDMQMC.run`:
.. code-block:: python
:emphasize-lines: 15
from pydmqmc.utils import euler
class AsymmetricBlochDMQMC(Iterative):
...
def run(self, dbeta: float, ...)
# self._final_beta was set during the call to setup()
n_cycles = int(self._final_beta / dbeta)
# update self._density_matrix every cycle using Euler's method
for cycle in range(n_cycles):
self._density_matrix = euler(
self._propagate_wrapper, # function f(dy/dt) to integrate
self._density_matrix, # dependent var y
dbeta, # stepsize dt
...
)
Step 2: Adding Decorators
-------------------------
The hard work is finished; now for the easy part! Our ``_propagate`` method requires
two decorators: one to declare it as a static method and the other to compile it with Numba:
.. code-block:: python
:emphasize-lines: 7,8
from numba import njit
class AsymmetricBlochDMQMC(Iterative):
...
@staticmethod
@njit
def _propagate(density_matrix, hamiltonian, *args, **kwargs):
"""
Return drho given the current state of the density matrix rho.
"""
# This method has an attribute called "_density_matrix"
# that was constructed elsewhere
rho = density_matrix
drho = np.zeros_like(density_matrix)
H = hamiltonian
dets = H.shape[0]
for i in range(dets):
for j in range(dets):
drho[i, j] = rho[i, j] * (H[0, 0] - H[i, j])
...
return drho
And with that, our :class:`~pydmqmc.methods.AsymmetricBlochDMQMC` calculations will now run faster!
Takeaways
---------
While Numba's JIT compiler can be used on functions that are members of classes, those methods
must be static methods and therefore can't actually access any of the classes other members.
It's still worth keeping these functions
inside classes for organizational purposes; for example, :class:`~pydmqmc.methods.AsymmetricBlochDMQMC`
and :class:`~pydmqmc.methods.SyymmetricBlochDMQMC` will each have slightly different propagation functions
that need to be accelerated.
The workaround is to use wrapper methods that pass the necessary class attributes to the compiled function.
This makes the code somewhat more difficult to follow, so the JIT compiler should only be applied
to functions that really need it---like those at the core of an iterative method.
Note that Numba works well with NumPy arrays but cannot handle native Python objects like lists and
dictionaries. For Python code that performs well, you should avoid using these data structures for
computationally intense operations. This is true generally, but particularly true if you want to use
Numba's JIT compiler.