Peano
Loading...
Searching...
No Matches
Offline load balancing
This documentation is autogenerated from ./tutorials/exahype2/35_offline-load-balancing.ipynb. Once you clone the Peano repository, please work against this notebook or study Peano's generic description how to generate web docu from Jupyter notebooks.

In Domain decomposition with an off-the-shelf heuristic, the load balancing decides at runtime when and how to split up the mesh. Such generic strategies work for all kinds of meshes, but they know little about the particular experiment, and they have to find out about the mesh while it is constructed.

If we know the mesh before we run the code, we can compute the decomposition ahead of time, i.e. offline, and hard-code the outcome. This is particularly simple for static meshes. Offline load balancing is a straightforward way to get a reasonable decomposition for a particular experiment, and it can serve as a baseline for something more bespoke and dynamic later on. In this tutorial, we

  • compute the decomposition of the regular mesh of Domain decomposition with an off-the-shelf heuristic with Peano's offline load balancing,
  • hard-code the outcome through the strategy toolbox::loadbalancing::strategies::Hardcoded,
  • learn how Peano's two split modes hand over cells,
  • check that Peano builds exactly the subdomains that we have planned, and
  • switch to the quantised decomposition, which reduces the number of virtual cells that the trees have to carry around.

The load balancing strategies formalise the problem that we solve here and explain what makes a good decomposition. The load balancing API documents all the Python routines that we use below.

The solver

We use exactly the same Euler solver as in the domain decomposition tutorial. We wrap it into a function, as we will need the setup twice:

import sys, os
sys.path.insert(0, os.path.abspath("../../src"))
import peano4.api
import exahype2
def create_project(directory):
"""! Euler setup of this tutorial, generated into the given directory """
namespace = ["tutorials", "exahype2", "offlineloadbalancing"],
project_name = "Euler",
directory = directory,
executable = "euler",
)
patch_size = 9
"EulerFV",
4, # unknowns
0, # auxiliary variables
)
solver.add_pde_term(exahype2.api.Flux(stateless=False))
solver.add_pde_term(exahype2.api.MaxEigenvalue(stateless=False))
solver.add_pde_term(exahype2.api.InitPointWisely(stateless=False))
solver.numerical_scheme = exahype2.api.fv.RusanovWithExplicitEuler(solver, patch_size)
solver.grid_construction = exahype2.api.fv.SetupSimulationOverRegularGrid(solver, volume_size=0.001)
solver,
first_plot_time_stamp = 0.0,
time_in_between_plots = 0.01,
)
project.add_solver(solver)
project.set_global_simulation_parameters(
dimensions = 2,
offset = [-0.1, -0.1],
size = [0.2, 0.2],
min_end_time = 0.02,
max_end_time = 0.02,
periodic_BC = [True, True, True],
)
return project
project = create_project("35_offline-load-balancing")
The conservative flux F(Q) of a hyperbolic PDE.
Definition Flux.py:8
Point-wise initial condition: given the physical position of a single degree of freedom,...
The maximum absolute eigenvalue of the flux Jacobian in a given direction, i.e.
ExaHyPE 2 project.
Definition Project.py:15
Abstract base class for all ExaHyPE 2 solvers.
Definition Solver.py:17
Plotting counterpart to RusanovWithExplicitEuler: maps the patch data of a Finite Volume scheme with ...
Finite Volumes with a Rusanov Riemann solver and explicit Euler time stepping, realised over a block-...
Setup phase counterpart to RusanovWithExplicitEuler: sets the initial condition for a Finite Volume s...

Computing the decomposition offline

The offline load balancing works on a Python representation of the spacetree. Our mesh is regular: With patches of \( 9 \times 9\) volumes of size 0.001, each cell has to be smaller than 0.009. The spacetree hence is refined three times, i.e. the finest level holds \( 27 \times 27 = 729\) cells. We do not have to read this mesh from a file, as we can synthesise it:

import toolbox.loadbalancing.api.strategies.SplitTrees as SplitTrees
import toolbox.loadbalancing.api.strategies.Decomposition as Decomposition
import toolbox.loadbalancing.api.output.Hardcoded as Hardcoded
tree = RegularGridGenerator.RegularGridGenerator(max_level=3, dimensions=2)
print(tree)
RegularGridGenerator(name=None, max_level=None, dimensions=None)
Generate an artificial regular grid with uniform refinement for testing purposes.

Next, we split the tree. We want one subtree per thread, i.e. four subtrees. The equidistant decomposition gives each subtree the same number of cells, and split_tree() assigns the cells to the subtrees. It runs through the cells along the same space-filling curve that Peano uses when it traverses the mesh: Subtree 0 gets the first cells along the curve, then subtree 1 gets the next chunk, and so forth.

Our domain is periodic. Peano never hands over cells along a periodic boundary, i.e. these cells always stay on tree 0. We therefore pass the periodic boundary conditions to split_tree(), which keeps these cells on subtree 0 and counts them towards subtree 0's share:

number_of_subtrees = 4
tree,
decomposition = Decomposition.equidistant_splitting(peano_tree=tree, number_of_subtrees=number_of_subtrees),
periodic_BC = [True, True],
)
equidistant_splitting(Tree peano_tree, int number_of_subtrees, verbose=False)
tuple[str, str] split_tree(Tree peano_tree, decomposition, str traversal="bfs_finest_first", bool enable_propagate_bottom_up=False, bool enable_optimize_hierarchy=False, bool require_all_children_for_hierarchy_optimization=True, list[bool]|None periodic_BC=None, bool verbose=False)
Split a tree using a staged load-balancing workflow.
Definition SplitTrees.py:23

Finally, we map the subtrees onto ranks and translate the outcome into the arguments of the hardcoded load balancing. As we run on one rank only, all subtrees go to rank 0. Tree 0 is the tree that all other trees are split off from, so we only need destination ranks for the other three trees. We also ask for bottom-up splits, which we discuss below:

destination_ranks = Hardcoded.assign_trees_to_ranks_round_Robin(
number_of_subtrees = tree.number_of_subtrees,
max_ranks = 1,
)
hardcoded_configuration, hardcoded_mode = Hardcoded.create_constructor_initialisation_string(
tree, destination_ranks, verbose=True, modes=Hardcoded.BottomUp
)

toolbox::loadbalancing::strategies::Hardcoded takes an enumerated list of splits, i.e. three lists plus the split mode. Entry \( k\) of the lists describes the \( k\) th split:

  • splitting_trees holds the tree which splits. Here, all subtrees are forked off tree 0.
  • number_of_cells holds the number of cells that the split hands over to the new tree, i.e. 182 cells each. Tree 0 keeps the remaining cells.
  • destination ranks holds the rank which receives the new tree.

We do not say when a split happens. The strategy hands out the splits in the order given, and it issues the \( k\) th split as soon as its splitting tree holds more than number_of_cells cells. While the mesh is constructed, tree 0 first holds 9 and then 81 cells, which is not enough. Once tree 0 holds all 729 cells, all three splits go out in one mesh sweep.

Top-down and bottom-up splits

Each split comes with a mode which tells Peano how to pick the cells that it hands over:

Peano's split modes: a sketch on a small spacetree (left) and the subdomains of the tutorial with either mode (right)
  • A top-down split (peano4::SplitInstruction::Mode::AggressiveTopDown) runs through the tree from the coarsest level and hands over whole refined cells, including all of their children, as long as the new tree still is owed cells. Splits hence come in chunks of \( 3 \times 3\) fine cells, and a tree might get a few cells more or fewer than requested.
  • A bottom-up split (peano4::SplitInstruction::Mode::BottomUp) hands over fine grid cells one by one along the space-filling curve. A coarse cell follows once all of its children have moved. The new tree gets exactly the number of cells requested.

As we want Peano to reproduce our plan cell by cell, we use bottom-up splits. create_constructor_initialisation_string() accepts either a single marker Hardcoded.TopDown or Hardcoded.BottomUp, or one marker per split. Without markers, it infers the modes from the decomposition.

More than one rank

If you use more than one rank, pass the number of ranks as max_ranks. The constructor string is the same on all ranks, and each rank only issues the splits of the trees that it hosts. Peano's tree numbers encode the rank which hosts a tree, i.e. tree \( t\) lives on rank \( t \bmod R\) for \( R\) ranks. The subtrees of the offline plan therefore have to be assigned to the ranks round robin, as assign_trees_to_ranks_round_Robin() does. We always assume this assignment, and create_constructor_initialisation_string() checks it.

The constructor string is all that we hand over to the project:

print(hardcoded_configuration)
project.set_load_balancing(
"toolbox::loadbalancing::strategies::Hardcoded",
hardcoded_configuration,
)

If you prefer to write C++ yourself, e.g. within your own main, the call above corresponds to

{0, 0, 0}, // splitting trees
{182, 182, 182}, // number of cells
{0, 0, 0}, // destination ranks
);
Hardcoded domain decomposition strategy.
Definition Hardcoded.h:189

You can also pass one mode per split instead of one mode for all splits.

Building and running the code

The remaining steps are the same as in the domain decomposition tutorial:

project.set_Peano4_installation(os.path.abspath("../../"), peano4.api.output.CompileMode.RELEASE)
peano4_project = project.generate_Peano4_project()
peano4_project.generate()
%%writefile 35_offline-load-balancing/EulerFV.cpp
#include "EulerFV.h"
void tutorials::exahype2::offlineloadbalancing::EulerFV::flux(
const double* __restrict__ Q, const tarch::la::Vector<Dimensions, double>& x, int normal, double* __restrict__ F
) {
const double irho = 1.0 / Q[0];
constexpr double gamma = 1.4;
const double p = (gamma - 1.0) * (Q[3] - 0.5 * irho * (Q[1] * Q[1] + Q[2] * Q[2]));
switch (normal) {
case 0:
F[0] = Q[1];
F[1] = irho * Q[1] * Q[1] + p;
F[2] = irho * Q[1] * Q[2];
F[3] = irho * Q[1] * (Q[3] + p);
break;
case 1:
F[0] = Q[2];
F[1] = irho * Q[2] * Q[1];
F[2] = irho * Q[2] * Q[2] + p;
F[3] = irho * Q[2] * (Q[3] + p);
break;
}
}
void tutorials::exahype2::offlineloadbalancing::EulerFV::maxEigenvalue(
const double* __restrict__ Q, const tarch::la::Vector<Dimensions, double>& x, int normal, double* __restrict__ lambdaMax
) {
const double irho = 1.0 / Q[0];
constexpr double gamma = 1.4;
const double p = (gamma - 1.0) * (Q[3] - 0.5 * irho * (Q[1] * Q[1] + Q[2] * Q[2]));
const double c = std::sqrt(gamma * p * irho);
// velocity, i.e. momentum divided by density
const double u = Q[1 + normal] * irho;
lambdaMax[0] = std::max(std::abs(u + c), std::abs(u - c));
}
void tutorials::exahype2::offlineloadbalancing::EulerFV::initPointWisely(
const tarch::la::Vector<Dimensions, double>& x, double* __restrict__ Q
) {
// One period of a sine density profile over the periodic domain
// [-0.1,0.1], constant velocity u and constant pressure p
constexpr double gamma = 1.4;
constexpr double p = 1.0;
constexpr double u = 1.0;
const double rho = 1.0 + 0.2 * std::sin(2.0 * M_PI * (x[0] + 0.1) / 0.2);
Q[0] = rho;
Q[1] = rho * u;
Q[2] = 0.0;
Q[3] = p / (gamma - 1) + 0.5 * rho * u * u;
}
!cd 35_offline-load-balancing && make -j > make.log 2>&1 && OMP_NUM_THREADS=4 ./euler > run.log 2>&1 && echo done

The log shows how the hardcoded load balancing hands out its splits, and the final distribution of the cells among the trees:

!grep "issued split" 35_offline-load-balancing/run.log
!grep "dumpStatistics" 35_offline-load-balancing/run.log | tail -1

Each tree reports two numbers, e.g. #1:182/410: The first one counts the cells that the tree owns and updates. The second one counts the virtual cells of the tree, i.e. cells that the tree knows about but that belong to others. Tree 0 additionally owns the coarse cells above the splits. Virtual cells do not do any calculation. They however have to be stored, traversed and kept consistent with the tree that owns them. Virtual cells arise where the subdomains of different trees meet: The more ragged the subdomain boundaries, the more coarse cells a tree has to know about to reach its own fine cells. We hence want to keep their number small (see also the load balancing strategies, which measure a decomposition by its imbalance and its surface).

Visualising the subdomains

We convert the output and open it in ParaView as described in the domain decomposition tutorial:

!cd 35_offline-load-balancing && PYTHONPATH=../../../src python3 ../../../src/tarch/plotter/api/render.py solution-EulerFV.peano-patch-file > render.log 2>&1 && echo done
The hardcoded subdomains of the Euler run in ParaView

ParaView numbers the subdomains in the order in which the trees write their output files. These numbers hence need not match Peano's tree numbers: Above, the frame belongs to tree 0, even though ParaView labels it differently.

The trees hold 183, 182, 182 and 182 cells, i.e. exactly the numbers of the offline plan. Indeed, Peano builds the planned subdomains cell by cell:

The offline plan along Peano's space-filling curve (left) and the subdomains of the run (right)

Tree 0 keeps a frame along the whole periodic boundary, plus the first cells along the space-filling curve. The other trees each get one chunk of the curve.

Two remarks are due. First, the frame belongs to tree 0 no matter what. If you split into many trees, the frame might be larger than tree 0's fair share, and the last tree then gets fewer cells. Second, Peano runs through the space-filling curve forth and back in alternating mesh sweeps. The offline load balancing ignores this: It always plans along the forward curve. If a split happens in a backward sweep, its subdomain sits at the other end of the curve, but it still gets the planned number of cells. In our example, all split-off trees request the same number of cells, so the subdomains do not change at all. It is always worth checking the actual distribution in the log.

Fewer virtual cells: the quantised decomposition

The equidistant decomposition above gives each tree the same number of cells. In 2d, it cuts 729 cells into chunks of 183, 182, 182 and 182 cells. These numbers have nothing to do with the structure of the spacetree: Along the space-filling curve, the chunk boundaries cut through the middle of \( 3 \times 3\) patches of cells and through the middle of coarser \( 9 \times 9\) patches. Each tree then has to carry virtual copies of the coarse cells it cuts.

The quantised decomposition chooses chunk sizes that match the tree. It only hands out chunks of the sizes \( 1 \cdot 3^{kd}, 2 \cdot 3^{kd}, \ldots, (3^d-1) \cdot 3^{kd}, 1 \cdot 3^{(k+1)d}, \ldots\) cells, i.e. (multiples of) complete subtrees: With \( d=2\) , the chunk sizes are multiples of 9, and then multiples of 81. Whatever cannot be handed out within these chunk sizes is added to the share of the next tree. The last tree gets the remainder. Switching to this strategy is a one-line change in the offline load balancing, as we replace Decomposition.equidistant_splitting() by Decomposition.quantised_splitting():

quantised_tree = RegularGridGenerator.RegularGridGenerator(max_level=3, dimensions=2)
quantised_decomposition = Decomposition.quantised_splitting(
peano_tree=quantised_tree, number_of_subtrees=number_of_subtrees, dimension=2
)
print("equidistant:", Decomposition.equidistant_splitting(peano_tree=quantised_tree, number_of_subtrees=number_of_subtrees))
print("quantised: ", quantised_decomposition)
quantised_splitting(Tree peano_tree, int number_of_subtrees, int dimension, verbose=False)
Quantised splitting.

The four trees now receive 162, 162, 162 and 243 cells, i.e. all chunks are multiples of 81 cells, and the chunk boundaries line up with the boundaries of \( 9 \times 9\) blocks of cells. The price is a worse balance: The largest tree holds 243 instead of 183 cells. Everything else is identical to the equidistant case, and we again split the tree, let the periodic frame stay with tree 0, and translate the outcome into a constructor string. As the chunks are complete subtrees, top-down splits (Hardcoded.TopDown) would hand over the same cells as bottom-up splits. We stick to the bottom-up splits:

quantised_tree,
decomposition = quantised_decomposition,
periodic_BC = [True, True],
)
quantised_configuration, _ = Hardcoded.create_constructor_initialisation_string(
quantised_tree,
Hardcoded.assign_trees_to_ranks_round_Robin(number_of_subtrees=quantised_tree.number_of_subtrees, max_ranks=1),
modes=Hardcoded.BottomUp,
)
print(quantised_configuration)

We build a second project with this configuration. It lives in its own directory and reuses the solver implementation of the first run:

quantised_project = create_project("35_offline-load-balancing-quantised")
quantised_project.set_load_balancing(
"toolbox::loadbalancing::strategies::Hardcoded",
quantised_configuration,
)
quantised_project.set_Peano4_installation(os.path.abspath("../../"), peano4.api.output.CompileMode.RELEASE)
quantised_project.generate_Peano4_project().generate()
!cp 35_offline-load-balancing/EulerFV.cpp 35_offline-load-balancing-quantised/EulerFV.cpp
!cd 35_offline-load-balancing-quantised && make -j > make.log 2>&1 && OMP_NUM_THREADS=4 ./euler > run.log 2>&1 && echo done
!echo "equidistant:"; grep "dumpStatistics" 35_offline-load-balancing/run.log | tail -1 | sed 's/.*info//'
!echo "quantised:"; grep "dumpStatistics" 35_offline-load-balancing-quantised/run.log | tail -1 | sed 's/.*info//'
The equidistant and the quantised decomposition of the tutorial's mesh along Peano's space-filling curve

The quantised subdomains mostly consist of complete \( 9 \times 9\) blocks, whereas the equidistant subdomains end in ragged staircases everywhere. Not all raggedness vanishes: The periodic frame stays on tree 0 and takes its cells out of the blocks, so a few blocks next to the frame are still cut.

Fewer ragged edges do not automatically mean fewer virtual cells, though. Compare the totals in the last column of the output above: In this tiny example, the quantised run reports more virtual cells than the equidistant run (1123 vs. 1075). The mesh of 729 cells is so small that the periodic frame and the coarse cells that all trees share dominate the count. The quantised decomposition pays off for bigger meshes without such a frame:

Does it pay off?

Fewer virtual cells have to be bought with a worse balance, as the quantised chunk sizes fit the number of cells less well. Whether this pays off depends on how much the virtual cells cost. We have tried this on the benchmarks/exagrype/single-black-hole benchmark (Finite Volumes for the CCZ4 equations, regular mesh, eight trees on eight threads, one rank). The benchmark offers the same switch as --decomposition quantised, and studies-load-balancing.py in the same directory runs the comparison. We measure the number of virtual cells relative to the number of cells that actually compute:

Mesh Decomposition Virtual cells / cells Cells of the largest tree Time per time step
729 cells equidistant 5.3 92 0.189 s
729 cells quantised 4.4 108 0.189 s
19,683 cells equidistant 1.6 2461 3.6 s
19,683 cells quantised 1.15 2916 3.6 s

On these meshes without a periodic frame, the quantised decomposition removes between a fifth and a third of the virtual cells, yet the runtime does not change: The better balance of the equidistant decomposition and the smaller number of virtual cells of the quantised decomposition cancel out, or neither of them dominates the runtime. In particular, the speedup of the eight-thread run over the single-thread run stays at around 4.3 for the small mesh in either case. Virtual cells hence are not the reason why this code does not scale perfectly on a node. Measure before you pay for an unbalanced decomposition.

Meshes that are not regular

For adaptive meshes, we cannot synthesise the spacetree. Instead, we run the code once without load balancing, stop it once the mesh has been constructed, and feed the resulting patch files into the offline load balancing. The script OfflineDecomposition.py reads such patch files, splits the tree, and writes the outcome into a file:

python3 ../../src/toolbox/loadbalancing/api/OfflineDecomposition.py -f my-mesh.peano-patch-file -d 2 -s 4
This file is part of the load balancing project within Peano 4.
Definition __init__.py:1

Here, -d is the number of dimensions and -s the number of subtrees. Call the script with –help to see all options. The load balancing API documents the underlying routines and further decomposition variants.