Speeding up for
loops: the get_rate_mat
function
The purpose of the get_rate_mat
function in lattice.py
is to calculate the elements of the rate matrix which describes the rate of population transfer between the excited states of the lattice. Each element of the rate matrix takes the form
as is described in greater detail here. Looking at this summation, we see that each term in the rate matrix requires a summation over all the basis states of the system (basis states are indexed using the latin alphabet, and eigenstates using the greek alphabet). For a lattice, the number of basis states is . In addition to this, the rate matrix itself contains elements as we calculate rates between every possible pair of eigenstates. Thus, the calculation of the rate matrix scales as meaning that it limits the speed of the code for all but the smallest lattice sizes. This means that we need this function to run as quickly as possible in order to minimise the overall runtime of the code and, in this file, we will explore some of the techniques we have used to achieve this.
1) Vectorize functions
Vectorizing functions is always a good first step to speed up python code. Simply put, a vectorized function is one which can perform an operation on multiple elements of an input array at once, rather than having to act on each element in turn. Many common operators in Python are already vectorized. For example, consider the following exponentiation:
x = np.linspace(0,100,101)
y = np.zeros(len(x))
for i in range(len(x)):
y[i] = x[i]*x[i]
y = x**2
get_rate_mat
, we use the np.matmul
function (equivalent to the @
operator) to perform the summation over all of the lattice's basis states.
However, in some cases, there will not be a pre-existing function which does what you need and then it is best to write your own, vectorized function. We have done this to calculate the values of the correlation function ( in the expression for above). Our functions are saved in the redfield.py
file and we call them in get_rate_mat
via the correlation_function_real_part
function. However, to take advantage of the functions in redfield.py
, we first need to make a numpy array containing the difference in energy between each possible pair of eigenstates (i.e., the ) which we do using...
2) Generators
To make a numpy array containing the values of , we combine the numpy function np.fromiter
(see the documentation here) with a generator written using the same syntax as a list comprehension. A list comprehension is a way of rewriting for
loops which is particularly useful if the logic involved in each iteration of the for loop is simple enough to be encapsulated in a single line. For example, the code:
a = [1,2,3,4,5]
b = []
for i in range(len(a)):
b.append(a[i]**2)
a = [1,2,3,4,5]
b_list_comp = [i**2 for i in a]
b_generator = (i**2 for i in a)
In order to use our generator, we need to make it so that each element of the rate matrix is labelled by a single index, rather than needing to label both the row and the column (i.e., the values of and in ). Fortunately, numpy has a built in function (np.tril_indices_from
, see the documentation) which can generate an iterable of the indices describing the elements making up a lower (or upper) triangular matrix.
By combining these steps of vectorization and using generators, we achieved a significant speed up in the time taken to claculate the rate matrix, as we show in the plot below (the code used to generate this plot can be found in Appendix Three. Both the lines run (roughly) parallel to one another, which shows us that they scale in approximately the same way as lattice size increases. For all lattice sizes represented, the vectorized version is 3-4 times faster, which is a worthwhile speed-up.