Distributing work with MPI
sisl can split work across the processes of an MPI job. The functionality
lives in sisl.mpi and requires mpi4py.
Without mpi4py (or with SISL_MPI=0, see Environment variables) every
communicator is a serial stand-in: rank 0 of 1 processes, where all operations
are no-ops. Code written for MPI therefore also runs serially, unchanged.
Scripts are run in parallel through the MPI launcher, e.g.:
mpirun -n 4 python script.py
The communicator
get_comm returns the communicator sisl uses. It is created on the first
call, and is the same object on every subsequent call:
from sisl.mpi import get_comm
comm = get_comm()
print(comm.rank, comm.size)
if comm.on_rank0():
print("only printed once")
All mpi4py operations are available directly on it (comm.Allreduce,
comm.Split, …), with mpi4py’s own signatures.
Distributions
A Distribution decides which process (rank) owns which of n indices.
It knows nothing about what the indices refer to; they may be rows of a
matrix, k-points, energy points or anything else that can be counted.
The same distribution can therefore be re-used on any object of the same
length.
BlockCyclicDistribution deals out blocks of blocksize consecutive
indices to the ranks in turn:
from sisl.mpi import BlockCyclicDistribution
# 10 indices on 2 ranks
BlockCyclicDistribution(10) # blocksize=1: rank 0 [0 2 4 6 8], rank 1 [1 3 5 7 9]
BlockCyclicDistribution(10, 0) # contiguous: rank 0 [0 1 2 3 4], rank 1 [5 6 7 8 9]
BlockCyclicDistribution(10, 2) # blocks of 2: rank 0 [0 1 4 5 8 9], rank 1 [2 3 6 7]
Which layout to choose depends on the work:
blocksize=0one contiguous block per rank, balanced to within one index. Keeps neighbouring indices together, which is useful for k-points or energies that are close to each other, and for the sparsity pattern of a matrix.
blocksize=1(default)plain round-robin. Balances the load when the cost varies systematically along the indices, at the cost of locality.
blocksize>1the block-cyclic layout ScaLAPACK expects (typically 32 or 64).
A distribution answers questions about the layout. Without a rank
argument, the rank of the current process is used:
dist = BlockCyclicDistribution(10, 2)
dist.indices() # indices owned by this process
dist.range() # the same, as contiguous (start, stop) runs
dist.count() # how many indices this process owns
dist.counts # how many indices every rank owns
dist.owner([0, 3]) # which rank owns indices 0 and 3
dist.n, dist.size # number of indices, and number of ranks
Using another communicator
The communicator defaults to get_comm, but any communicator can be passed
through the keyword-only comm argument. This allows distributing over a
subset of the processes, e.g. to distribute energy points within groups of
ranks that each handle a different k-point:
comm = get_comm()
group = comm.Split(comm.rank % 2)
dist = BlockCyclicDistribution(len(E), 0, comm=group)
Two distributions are only equal if they have the same layout and the same communicator.
Distributing an object
Calling a distribution with an object hands the object over to the object’s
own __sisl_distribute__ method:
dist(obj, *args, **kwargs)
# is the same as
obj.__sisl_distribute__(dist, *args, **kwargs)
The object, not the distribution, decides what distributing it means and
which arguments it accepts. An object that does not implement
__sisl_distribute__ raises a TypeError.
Implementing the protocol
Any class can be made distributable. A typical pattern is to return a generator over the local indices in batches:
import numpy as np
from mpi4py import MPI
from sisl.mpi import BlockCyclicDistribution, get_comm
class Energies:
def __init__(self, E):
self.E = np.asarray(E)
def __sisl_distribute__(self, dist, batch=1):
idx = dist.indices()
for i in range(0, len(idx), batch):
yield idx[i : i + batch], self.E[idx[i : i + batch]]
E = Energies(np.linspace(-1, 1, 101))
dist = BlockCyclicDistribution(len(E.E), 0)
dos = np.zeros(len(E.E))
for idx, e in dist(E, batch=4):
dos[idx] = ... # calculate for the local energies only
# every rank only filled its own entries, sum them up
get_comm().Allreduce(MPI.IN_PLACE, dos)
Sparse matrices
sisl.SparseCSR implements the protocol by distributing its rows.
dist(csr) moves every stored element to the rank owning its row, in
place, and returns the matrix. The matrix keeps its full shape; afterwards
each rank only stores the rows it owns.
dist = BlockCyclicDistribution(csr.shape[0], 0)
dist(csr)
The op argument decides what happens when several ranks hold the same
element:
op="single"(default)keep one of them. Use this when the ranks hold the same data, e.g. a matrix that was read or built on every rank.
op="sum"sum them. Use this when each rank holds a part of the matrix, e.g. when the contributions to the elements were computed in parallel.
Warning
Distributing a matrix that is replicated on every rank with op="sum"
multiplies its values by the number of ranks.
Changing a distributed matrix
Changes to the sparsity pattern do not communicate. Any rank may write to any row, also rows it does not own; the matrix is only marked as changed. Calling the distribution again ships the changes to their owners, once, no matter how many changes were made:
for i, j, v in contributions:
csr[i, j] = v # local, no communication
dist(csr, op="sum") # one collective exchange
Calling the distribution on an unchanged matrix does nothing. Calling a different distribution moves every element to its new owner, so a matrix can be re-distributed into another layout, or onto another communicator.
Note
Distributing is a collective operation: every rank of the communicator must call it, otherwise the program hangs.