Week 7 · MPI V · Companion to lecture 07

Who Am I
Talking To?

Every call so far said MPI_COMM_WORLD. Today: rows, columns, a hand-picked few, and a grid MPI lays out for you. Two of the four ideas come with a bug that passes at small p.

Concept map

Every call since Week 3 has involved every rank. Real programs need a collective over some of them: a row of a matrix, the ranks on one node, the ranks a library owns. Click a node to jump to it.

todaylater
Two of today's three bugs passed a test at small p. Choose the test p from the grid's shape, not from habit.

Split: color picks the group, key orders it

12 ranks as a 3 × 4 grid. Every row needs its own sum. MPI_Allreduce takes one communicator; on WORLD it sums all 12.

The grid. Click a rank.

world rank shown; color = row

What would a call need from each rank to build one communicator per row? Concretely: rank 6, which row are you in, and which place in that row?

Each rank has to say which group it wants (a label) and where it wants to sit in it (an order). Those are exactly MPI_Comm_split's color and key. Rank 6 on a 4-wide grid: row 6 / 4 = 1, place 6 % 4 = 2.

Rank 6:  row 1, rowRank 2 of 4.

Same process, new number. Ask the new communicator; never reuse a WORLD rank.

int color = rank / COLS;               // my row: 0, 1, 2
int key   = rank;                      // order inside the row
MPI_Comm row;

// collective on WORLD: every rank calls it
MPI_Comm_split(MPI_COMM_WORLD, color, key, &row);

MPI_Comm_rank(row, &rowRank);          // rank 6 -> 2
MPI_Comm_size(row, &rowSize);          //        -> 4

MPI_Allreduce(&v, &rowSum, 1, MPI_INT, MPI_SUM, row);

MPI_Comm_free(&row);                   // create, use, free

// columns: color = rank % COLS     rank 6 -> col 2, colRank 1
// key = -rank: each row numbered right to left

Split explorer

Equal colors land in the same new communicator. Key sorts the ranks inside it; ties break by old rank. The table is what split_Rows, split_Cols and split_KeyReversed print.

Split is collective on WORLD: every rank calls it, even a rank that wants no new communicator. Same lifecycle as Week 6's datatypes: create once, outside the loop; use it as often as you like; free it. Split is itself a collective, so it costs more than a message.

Same ranks, same tag, never crossed

context_Dup(). What a library does first: duplicate the communicator it is handed.

MPI_Comm lib;
MPI_Comm_dup(MPI_COMM_WORLD, &lib);     // what a library does first

if (rank == 0) {                        // Isend: Send could deadlock
    MPI_Isend(&x111, 1, MPI_INT, 1, 0, lib, &req);        // sent FIRST
    MPI_Send (&x222, 1, MPI_INT, 1, 0, MPI_COMM_WORLD);
    MPI_Wait(&req, &st);
}

if (rank == 1) {                        // wildcards on source AND tag
    MPI_Recv(&got, 1, MPI_INT, MPI_ANY_SOURCE, MPI_ANY_TAG,
             MPI_COMM_WORLD, &st);
}
Rank 0 sends 111 first (on lib), then 222 (on WORLD). Both go to rank 1 with tag 0. Rank 1 receives on WORLD with MPI_ANY_SOURCE and MPI_ANY_TAG. What is got?
got = 222
context_Dup   CHECK ok   (got == 222, and the lib receive then gets 111)

111 was sent first, from the same rank, with the same tag. The WORLD receive cannot see it. A wildcard matches source and tag, never communicator.

A communicator is a group (who is in it) and a context (a hidden tag MPI adds, unique to each communicator). Split and dup both make a new context, even when the group is identical: a dup of WORLD has exactly WORLD's ranks. A library that dups WORLD can never steal your messages, and you can never steal its.

Why MPI_Isend for the first message: rank 1 receives on WORLD first. A blocking MPI_Send on lib may wait until its receive is posted, and that receive comes second: a Send-then-Recv deadlock. Eager vs rendezvous is Mon Oct 12.

The ragged two-stage sum

sum_RowThenCol(). v = rank + 1. Row Allreduce, then a column Allreduce of rowSum. At p = 12 (3 × 4) every rank prints 78 = 12·13/2.

The idea is sound: a two-stage reduction over rows then columns is how 2-D codes reduce. Same log p steps in total, each on a smaller communicator, and you get the row sums along the way. At p = 12: row sums 10, 26, 42; each column holds one member of each row, so each column sums 10 + 26 + 42 = 78.

Now p = 6, still 4 wide. Draw the grid first.

p = 6, COLS = 4: row 1 is short. The answer is in the picture.

Does every rank get 21?
world  0  total 21
world  1  total 21
world  2  total 10
world  3  total 10
world  4  total 21
world  5  total 21
sum_RowThenCol  expect 21 (ragged)  CHECK 2 of 6 WRONG
sum_RowLeaders  total 21            CHECK ok

Column 2 is {2}. Column 3 is {3}. Nobody below them. Their column sum sees row 0 only: 1 + 2 + 3 + 4 = 10. Ranks 0, 1, 4, 5 are in full columns and get the right answer, which is why the program looks mostly right.

Explore the shape

The same two stages, computed here. Full columns are drawn solid, short columns hatched; a rank whose total is wrong is outlined in red.


    
It is deterministic, not a race: wrong on every run whenever COLS does not divide p and p > COLS. A test at p = 4, 8 or 12 (the numbers people pick) never finds it. Same lesson as Week 6's padding bug at p = 1. Who should do the second stage? Exactly one rank per row. Any one; rowRank 0 is the convention.

Leaders only; everyone else opts out

sum_RowLeaders(). Three moves: reduce to the row leader, leaders-only allreduce, broadcast back down the row.

MPI_Comm_split(MPI_COMM_WORLD, rank / COLS, rank, &row);

int color = (rowRank == 0) ? 0 : MPI_UNDEFINED;
MPI_Comm_split(MPI_COMM_WORLD, color, rank, &leaders);

MPI_Reduce(&v, &rowSum, 1, MPI_INT, MPI_SUM, 0, row);

if (leaders != MPI_COMM_NULL) {
    MPI_Allreduce(&rowSum, &total, 1, MPI_INT, MPI_SUM, leaders);
}

MPI_Bcast(&total, 1, MPI_INT, 0, row);      // back down the row

if (leaders != MPI_COMM_NULL) {
    MPI_Comm_free(&leaders);
}
MPI_Comm_free(&row);

Step through the three moves

Leaders (rowRank 0) are outlined. Every other rank passed MPI_UNDEFINED and holds MPI_COMM_NULL for leaders.

Non-leaders still call Split. They pass MPI_UNDEFINED and get MPI_COMM_NULL. Never use or free MPI_COMM_NULL. The two guards are the discipline. The next screen shows what happens without the second one.

Boost: bmpi::communicator row = world.split(world.rank() / COLS); returns a communicator; its destructor frees it. The key defaults to the old rank. boost_Split in the file.

Groups: the hand-picked few

group_Hardcoded(), from the old Communicators code. When the ranks you want are not a function of your own rank (so no color formula), build a group and make a communicator from it.

MPI_Group worldGroup, primeGroup;
MPI_Comm_group(MPI_COMM_WORLD, &worldGroup);

const int ranks[4] = {2, 3, 5, 7};
MPI_Group_incl(worldGroup, 4, ranks, &primeGroup);

MPI_Comm primeComm;
MPI_Comm_create_group(MPI_COMM_WORLD, primeGroup, 0, &primeComm);

MPI_Comm_free(&primeComm);             // every rank
MPI_Group_free(&primeGroup);
MPI_Group_free(&worldGroup);

A group is a set of ranks, no context. You cannot send on it. MPI_Comm_create_group is collective over the group, not over WORLD: only the members have to call it. A non-member may call it too; it returns at once with MPI_COMM_NULL.

Two errors. Find both. What happens at p = 4, and what happens at p = 8?
$ mpiexec -n 4 ./MPI 4 --hardcoded
*** An error occurred in MPI_Group_incl
*** MPI_ERR_RANK: invalid rank

$ mpiexec -n 8 ./MPI 4 --hardcoded
*** An error occurred in MPI_Comm_free
*** MPI_ERR_COMM: invalid communicator

Aborts at 4. Aborts at 8, too. p = 4: ranks 5 and 7 don't exist. p = 8: ranks 0, 1, 4, 6 got MPI_COMM_NULL (legally) and then freed it (illegally). Both are caught because the default handler is MPI_ERRORS_ARE_FATAL. Switch it off and the same code goes quiet. Output is abridged Open MPI 4.1.6 text; MVAPICH words it differently but aborts in the same calls.

// the fix, group_Primes():
for (int i = 0; i < p; i++) {
    if (isPrime(i)) primes.push_back(i);
}

if (primeComm != MPI_COMM_NULL) MPI_Comm_free(&primeComm);

Compute the list from p. Free only what you got.

Who is a member, who holds COMM_NULL

hardcoded {2, 3, 5, 7}

group_Primes: isPrime(i) for i < p

Aside: lecture07 calls MPI_Init, not boost::mpi::environment. The Boost environment sets MPI_ERRORS_RETURN on WORLD so it can throw C++ exceptions. Under it these unchecked C calls did not abort: p = 4 segfaulted later with no message, and p = 8 printed "success". An error handler that returns is only as good as the code that reads the return value.

Groups are the other way in: build the set, then the communicator. Split covers most real uses.

Grids: let MPI hand out the coordinates

MPI_Dims_create(p, ndims, dims) picks the shape. MPI_Cart_create builds the communicator. MPI_Cart_coords(cart, rank, maxdims, coords) fills in (x, y, z).

Dims_create: a balanced factoring

Factors p into ndims factors as balanced as it can, filling only the zero entries. The deck's three cases: 8 → 2×2×2, 12 → 3×2×2, 6 → 3×2×1. Then MPI_Cart_create numbers ranks row-major over that shape (last coordinate fastest).


  
cart_Coords2of3(), from the old Communicators code, builds a 3-D grid of 8 ranks (2 × 2 × 2 torus), then asks MPI_Cart_coords(cart, rank, 2, coords) for 2 coordinates into a 2-element array. No error, no warning at compile time. What could an MPI library do with that call?

Same source, same p. Two real MPIs picked different answers.

Open MPI 4.1.6 (a laptop, the course's test machine)

cart 0 (0,0)   cart 4 (1,0)
cart 1 (0,0)   cart 5 (1,0)
cart 2 (0,1)   cart 6 (1,1)
cart 3 (0,1)   cart 7 (1,1)

4 distinct (x,y) for 8 ranks
fine at p <= 7: dims[2] was 1

MPICH 4.2 (MVAPICH 4.1 on Cardinal is built on MPICH)

MPI_Cart_coords(..., maxdims=2, ...)
  failed
Number of dimensions 3 is too
  large (maximum is 2)

refused at every p, even p = 1

Your laptop says (0,0) twice; Cardinal says no. "It works on my laptop" is a statement about one implementation. Fix, cart_Coords(): ndims = 3 in every call, coords[3], and ask the Cartesian communicator (not WORLD) for your rank, because reorder = 1 lets MPI renumber ranks to fit the machine. MPI_Cart_rank(coords) round-trips; CHECK ok at every p.

What "fill two and stop" gives at other p

Model of the Open MPI behaviour: the shape from Dims_create above (ndims = 3), row-major numbering, the first two coordinates kept. Pairs that collide are red.

Ask for neighbours, not rank ± 1

cart_Shift(). A 1-D line. Every rank runs the same line; no if at the edge.

int dims[1] = {p}, periods[1] = {0};           // open line
MPI_Cart_create(MPI_COMM_WORLD, 1, dims, periods, 0, &line);

MPI_Cart_shift(line, 0, +1, &left, &right);   // source, dest

int got = -1;
MPI_Sendrecv(&me,  1, MPI_INT, right, 0,
             &got, 1, MPI_INT, left,  0, line, &st);

// open:     rank 0: left = MPI_PROC_NULL, got stays -1
// periodic: rank 0: left = p - 1,         got = p - 1

The shift, step by step


  
A send to MPI_PROC_NULL does nothing. A receive from it completes at once and writes nothing. That deletes the if (rank == 0) ... else if (rank == p-1) edge code from Week 4-style programs. Sendrecv, not Send then Recv: everyone sends right and receives from the left in one call; the library orders it. This is a halo exchange: every stencil code does it every step.

2-D: cart_Sub in the file does Dims_create(p, 2), Cart_create, then MPI_Cart_sub(grid, remain = {0, 1}) keeps dimension 1, so each rank lands in its row's communicator. No color math. When COLS divides p the file builds the grid as p/COLS × COLS, so these are exactly split_Rows' communicators, derived from the grid instead of from arithmetic. A Cartesian grid cannot be ragged, so for other p it falls back to Dims_create's shape.

Strips or blocks? Count the α's

Week 5's model, t(m) = α + mβ, applied to the grid's shape. n × n doubles over p ranks, one halo exchange per step, a rank's messages one after another (the conservative case). α ≈ 2 µs, β ≈ 0.05 ns/B.

Strips (1-D)

2 · (α + 8n·β)

2 neighbours (up, down), a full row of n doubles = 8n bytes each.

Blocks (√p × √p)

4 · (α + 8n/√p·β)

4 neighbours, an edge of n/√p doubles each. At p = 16: 4 · (α + 2n·β).

Calculator


    
    

Model only (α = 2 µs, β = 1/(20 GB/s)). At p = 16 the two are equal when 2α + 16nβ = 4α + 8nβ, so 8nβ = 2α and n = α / 4β = 2000 ns / 0.2 ns = 10⁴. Below it strips win: fewer α's. n = 10³: 4.8 vs 8.4 µs. n = 10⁵: 84 vs 48 µs.

Blocks send 2/√p of the bytes and twice the messages. Same shape as Week 5's crossover: below the knee you pay per message, above it per byte. A topology is a bet that you are above the knee.

What the model leaves out: with reorder = 1, MPI may place grid neighbours on the same node, where α is smaller. The standard permits that; it does not promise it. Whether Cardinal does is a measurement, not a slide: the question is left open here.

Run it on Cardinal

Branch lecture07. Every method ends in CHECK; the program prints a rank/row table. About 20 minutes.

  1. Clone once, check out the lecture branch

    ssh <your-username>@cardinal.osc.edu
    git clone -o class git@gitlab.com:cs4540-spring-2019/cs-4170-5170-fall-2026/mpi.git mpi
    cd mpi
    git fetch class
    git checkout -b lecture07 class/lecture07
  2. Load the modules, build

    Modules do not persist between logins. Load them every time.

    module load mvapich/4.1 boost/1.83.0
    cd Default && make OSC && cd ..
  3. Get nodes of your own

    The login node is shared; run nothing there. Two nodes, so a message between ranks crosses the network. Load the modules again inside the allocation.

    salloc --nodes=2 --ntasks-per-node=6 -c 2 --time=00:15:00 -A PCS0313
    module load mvapich/4.1 boost/1.83.0

    Every run: srun -c 2 -n p ./Default/MPI [COLS] [--hardcoded]. Default COLS = 4. group_Hardcoded runs only with --hardcoded.

Experiments

A · Three rows of four

srun -c 2 -n 12 ./Default/MPI
What you should see

The rank/row table: three rows of 4. Find rank 6: row 1, rowRank 2 of 4; column 2, colRank 1. split_KeyReversed numbers each row right to left. context_Dup: got = 222. sum_RowThenCol at p = 12: every rank 78, CHECK ok, because 4 divides 12. cart_Coords2of3: Cardinal's MVAPICH is MPICH-based and should refuse the maxdims = 2 call (the file sets MPI_ERRORS_RETURN on the cart communicator so the demo can print it and carry on); cart_Coords CHECK ok.

B · The ragged grid

srun -c 2 -n 6 ./Default/MPI
srun -c 2 -n 8 ./Default/MPI
What you should see

p = 6: sum_RowThenCol expect 21 (ragged) CHECK 2 of 6 WRONG, with ranks 2 and 3 printing total 10 and the others 21; sum_RowLeaders total 21 CHECK ok. p = 8: both ok, since 4 divides 8. That is the point: the test at 8 never finds it. Try -n 6 ./Default/MPI 3 (COLS = 3, which divides 6) and -n 8 ./Default/MPI 3 (which does not) and predict each before you run it.

C · The hardcoded group, twice

srun -c 2 -n 4 ./Default/MPI 4 --hardcoded
srun -c 2 -n 8 ./Default/MPI 4 --hardcoded
What you should see

Both abort. At p = 4 the failing call is MPI_Group_incl (ranks 5 and 7 do not exist); at p = 8 it is MPI_Comm_free on ranks 0, 1, 4, 6, which free MPI_COMM_NULL. The deck's transcript is Open MPI text; MVAPICH words it differently but aborts in the same calls. Without --hardcoded the run uses group_Primes, which computes the list from p and frees only what it got: CHECK ok.

D · The open line

srun -c 2 -n 5 ./Default/MPI
What you should see

cart_Shift: rank 0 receives from MPI_PROC_NULL and its got stays −1; every other rank k gets k − 1. cart_Sub at p = 5: 4 does not divide 5, so the file falls back to Dims_create's 2-D shape for the row communicators. Then exit to release the allocation.

Before R04 (posted Mon Oct 12; lab Fri Oct 16, due Sun Oct 18): R04 is derived type vs pack/unpack, Week 6's material. From today, keep one habit: pick the test p from the shape of the problem. Run experiment B at a p that your COLS does not divide before you believe a two-stage sum.

Check yourself

Six questions, instant feedback, nothing recorded.

1. 12 ranks, COLS = 4, MPI_Comm_split(WORLD, rank / COLS, rank, &row). Rank 9's color and its rank in row?
9 / 4 = 2 is the row (the color); inside that row the keys 8, 9, 10, 11 sort to 0, 1, 2, 3. Same process, new number: ask the new communicator, never reuse the WORLD rank.
2. A rank wants no part of the new communicator. What does it do with MPI_Comm_split?
Split is collective on the old communicator: every rank calls it. MPI_UNDEFINED means "I want no new communicator"; the result is MPI_COMM_NULL. Freeing it is the p = 8 abort in group_Hardcoded.
3. Rank 0 sends 111 on lib (a dup of WORLD) and then 222 on WORLD, both to rank 1, tag 0. Rank 1 receives on WORLD with ANY_SOURCE and ANY_TAG. What does it get, and why?
A communicator is a group and a context. Dup makes a new context even though the group is identical. That is why a library dups the communicator it is handed.
4. MPI_Dims_create(12, 3, dims) with dims = {0, 0, 0} gives
The most balanced factoring, in decreasing order: 3 × 2 × 2. Likewise 8 → 2×2×2 and 6 → 3×2×1. Only the zero entries are filled.
5. On an open 1-D line (periods = {0}), rank 0 calls MPI_Cart_shift(line, 0, +1, &left, &right) and then MPI_Sendrecv sending to right, receiving from left. What happens?
PROC_NULL is the whole point: no if at the edge. Periodic would give left = p − 1 and got = p − 1.
6. n × n doubles, p = 16, one halo exchange, α = 2 µs, β = 0.05 ns/B. At n = 10³, strips or blocks?
2(2 + 0.4) = 4.8 µs against 4(2 + 0.1) = 8.4 µs. At n = 10⁵ it flips: 84 vs 48 µs. A topology is a bet that you are above the knee.
← → keys also work