AMS 530 Project 2, Stony Brook Graduate School
Supervisor: Professor Yuefan Deng, AMS Department, Stony Brook University
This project implements parallel dense matrix multiplication using MPI and analyzes scaling performance on the SeaWulf supercomputer cluster.
The algorithm implemented is the ring method, or Cannon's algorithm. The algorithm is then tested on every combination of N = {64, 256, 1024, 4096} and P = {4, 8, 16}, where N is the size of the given matrices and P is the number of parallel processes (cores). For accuracy and speed, the result is compared against a serial baseline.
Problem 2.3: Design a parallel matrix multiplication using row-wise, column-wise, or block decomposition. This assignment requires implementing a parallel version using MPI, assuming square matrices. Tasks are
(1) Algorithm Design: Design a parallel matrix multiplication algorithm using MPI.
- Describe the data distribution.
- Discuss load balancing, communication overhead, and scalability for P processes.
- Assume matrices are square (size
N × N) and P divides N.
(2) Implementation: Implement the algorithm in a program.
- Input: Root process generates two random N × N matrices A and B.
- Output: Root process computes and verifies C against a serial version.
- Requirements:
- Support P =
$2^2$ ,$2^3$ ,$2^4$ . - Test for N =
$2^6$ ,$2^8$ ,$2^{10}$ ,$2^{12}$ . - Include a serial baseline for verification.
- Support P =
(3) Performance Evaluation: Create a test harness to:
- Verify correctness.
- Measure execution time.
- Compute speedup.
- Record the timing results and make relevant comments on Amdahl's law
In class, we generally went over 3 different algorithms: Cannon's Algorithm, Fox's Method, and Strassen's Algorithm. Fox's algorithm was discarded since it requires P to be a perfect square, and one of the required values of P is P = 8, thus invalidating the method. As for Strassen's algorithm, the 7 subproblems generated do not divide evenly among the values of P, so at least one rank will sit idle and thus reduce parallel efficiency. Furthermore, the benefits of Strassen's algorithm are modest at
Let
A diagram of the data distribution for each processor in a basic
As can be seen, each processor has one slice of A and one of B. After performing the dot product, for
Also note for ease of communication B is transposed once in the root, so each column strip of B becomes a row strip of
$B^T$ , which is easier to send
Since
- Does
$2N^3/P$ FLOPs - Sends and receives
$P - 1$ equally sized messages - Holds
$3N^2/P$ doubles
Since the split is fixed, no dynamic load balancing is needed. The only serial portions are generating the matrices, the transpose, scatters, gathers, and verifications.
Note that the transpose and verification, being non-crucial to the core algorithm, are outside the timing window.
Communication can be divided into three phases:
| Phase | Style | Per-Rank Data I/O Size | Times executed |
|---|---|---|---|
| Initial Scatter | Root -> All |
|
Once |
| Ring | Rank |
|
|
| Final Gather | All -> Root |
|
Once |
Since the ring communication occurs
In order to avoid deadlock, the MPI_Send and MPI_Recv commands are replaced with MPI_Sendrecv_replace, which sends and receives in one operation. If we ran
MPI_Send()
MPI_Recv()
then every rank would stall on send for large messages, and no one would ever get to the receive step and so the program would stall out.
- Speedup increases as
$N/P$ increases - Per-rank memory inversely proportional to
$P$ $P \le N$ - Root rank is bound by gather/scatter, growing with
$N^2$
MPI_Init();
rank, P = MPI_Rank, MPI_Size
read N, reps, warmup time
nb = N / P
IF rank == 0:
fill A, B with random doubles [-1, 1)
BT = transpose(B) //For ease of distribution cols -> rows
allocate A_cur, BT_loc, CT_loc //nb x N each
FUNCTION multiply():
MPI_Barrier()
MPI_Scatter(A -> A_cur)
MPI_Scatter(BT -> BT_loc)
FOR s = 0, P-1:
src = (rank + s) mod P
FOR j < nb, i < nb:
CT_loc[j][src*nb + i] = dot(A_cur[i, :], BT_loc[j, :], N)
IF s < P-1:
MPI_Sendrecv_replace(A_cur, send to rank-1, receive from rank+1)
MPI_Gather(CT_loc -> CT on root)
RETURN phase timers //scatter, compute, ring comm, gather, total
repeat multiply() for a few seconds, at least 3 //warmup, untimed
FOR rep = 1, reps:
multiply()
MPI_Reduce(MAX) for each phase time
IF rank == 0:
C = transpose(CT)
C_ref = serial_multiply(A,B)
max_err = max |C - C_ref|
print times, speedup, max_err, Pass/Fail
Matrix-Mult-Parallel/
├── src/
│ ├── kernel.c # dot product, serial baseline, transpose, and other functions
│ ├── kernel.h
│ ├── main.c # Args, data gen, scatter/gather, timing, verification, and output
│ ├── mm_ring.c # The ring algorithm
│ └── mm_ring.h
│
├── Make/
│ └── Makefile
├── Images/ # Images for README
├── Bash/
│ └── *.sh # Various bash files for testing
│
├── README.md
└── .gitignore
Run from repo root
sbatch Bash/*.sh
Where * is the experiment you wish to run.
Each experiment follows the same general structure
module load gcc/13.2.0
module load openmpi/gcc13.2/4.1.6
make
mpirun ./mm
The following arguments can be applied to the run
| Flag | Meaning |
|---|---|
-n |
Matrix size |
-r |
Number of timed repetitions |
-W |
Untimed warmup time before timed runs |
-e |
Exact mode |
-p |
Print matrices A, B, C |
-s |
Random seed |
-o |
Summary CSV name |
-w |
Per-repetition timing CSV |
-d |
Per-rank timing CSV |
| Partition | short-96core |
| Compiler | GCC 13.2.0 |
| MPI | OpenMPI 4.1.6 |
| Repetitions | 100 / 30 / 20 / 5 for N = 64 / 256 / 1024 / 4096 respectively, medians reported |
| Warmup | At least 3s of untimed running before timing begins |
| Timing | Maximum time reported across ranks, scatter -> gather |
| Serial Baseline | Same kernel dot product, naive for loop implementation |
The warmup is needed for small runs, otherwise the run would finish before the CPU would ramp up to full clock speed
In all experiments run, the error was 0, meaning all matrices were multiplied correctly, no errors occurred.
| N | Serial | |||
|---|---|---|---|---|
| 0.124 ms | 0.040 ms | 0.041 ms | 0.052 ms | |
| 11.19 ms | 2.92 ms | 1.69 ms | 1.02 ms | |
| 0.856 s | 0.218 s | 0.117 s | 0.061 s | |
| 59.05 s | 14.84 s | 8.06 s | 4.45 s |
$T(N,1)$ = Serial
| N | |||
|---|---|---|---|
| 3.14x | 3.00x | 2.41x | |
| 3.83x | 6.60x | 10.93x | |
| 3.93x | 7.33x | 14.12x | |
| 3.98x | 7.33x | 13.26x |
| N | |||
|---|---|---|---|
| 0.79 | 0.38 | 0.15 | |
| 0.96 | 0.83 | 0.68 | |
| 0.98 | 0.92 | 0.88 | |
| 0.99 | 0.92 | 0.83 |
For large N (
Amdahl's law states that if a fraction f of a program can't be parallelized, the speedup
If we reverse it to solve for f, we can estimate the fraction of the work that is serial
| N | |||
|---|---|---|---|
| 9.1% | 23.8% | 37.7% | |
| 1.5% | 3.0% | 3.1% | |
| 0.6% | 1.3% | 0.9% | |
| 0.2% | 1.3% | 1.4% |
If Amdahl's law held true,
One weird observation was the decrease in efficiency at
To answer this, I investigated each rank's compute time individually.
| P | Ranks | Compute Time per Rank (s) | Linear Compute Time ( |
Difference (s) |
|---|---|---|---|---|
| 0 - 5 | 7.94 | 7.38 | 0.56 | |
| 6 - 7 | 7.57 | 7.38 | 0.19 | |
| 0 - 11 | 4.24 | 3.69 | 0.55 | |
| 12 - 15 | 3.81 | 3.69 | 0.12 |
Reason for this particular grouping will be seen below
Each rank does identical work, however the ranks seem to split into a slower group and a faster group. The node's AMD EPYC 7643 processors are split into chiplets of 6 cores that share 32MB of L3 cache, as observed from technical specs. Ranks 0-5 and 6-11 each fill a chiplet, while ranks 6-7 at P =
