From 84d489a9aee665a1b92ad4909b2be637e5a1823d Mon Sep 17 00:00:00 2001 From: Bryce Adelstein Lelbach Date: Tue, 21 Jul 2026 13:25:25 +0000 Subject: [PATCH] PyHPC: Rewrite mpi4py lesson with heat stencil. Signed-off-by: Bryce Adelstein Lelbach --- tutorials/pyhpc/.gitignore | 8 +- tutorials/pyhpc/README.md | 4 +- tutorials/pyhpc/notebooks/06__mpi4py.ipynb | 856 +++++++++--------- .../solutions/06__mpi4py__SOLUTION.ipynb | 799 ++++++++++++++++ ..._kernels_mpi_jax_omp_interop__2_days.ipynb | 2 +- tutorials/pyhpc/test/test_notebooks.py | 5 +- 6 files changed, 1256 insertions(+), 418 deletions(-) create mode 100644 tutorials/pyhpc/notebooks/solutions/06__mpi4py__SOLUTION.ipynb diff --git a/tutorials/pyhpc/.gitignore b/tutorials/pyhpc/.gitignore index 5071e2ed..ac7b8741 100644 --- a/tutorials/pyhpc/.gitignore +++ b/tutorials/pyhpc/.gitignore @@ -6,8 +6,8 @@ notebooks/CMakeLists.txt notebooks/swe_step.cpython-*.so notebooks/timings.json -# Kernel / asynchrony notebooks (03-05): %%writefile scripts, Nsight -# profiler reports, the downloaded text corpus, and rendered plots. +# Kernel, asynchrony, and MPI notebooks (03-06): %%writefile scripts, +# MPI results, Nsight profiler reports, the downloaded corpus, and plots. # Patterns are applied at the notebooks/ root and under solutions/, since # executing the solution notebooks (e.g. in CI) writes the same artifacts there. notebooks/*.py @@ -25,6 +25,10 @@ notebooks/solutions/books__15m.txt notebooks/test.png notebooks/solutions/test.png +# MPI notebook (06): verified field saved for the notebook visualization. +notebooks/heat_equation_result.npz +notebooks/solutions/heat_equation_result.npz + # Generated C/C++ from %%writefile cells (nanobind / cffi / cppjit / interop # exercises). The checked-in tutorial source is re-included below. notebooks/solutions/*.c diff --git a/tutorials/pyhpc/README.md b/tutorials/pyhpc/README.md index 410a32bc..d24bd28a 100644 --- a/tutorials/pyhpc/README.md +++ b/tutorials/pyhpc/README.md @@ -17,7 +17,7 @@ Brev Launchables of this tutorial should use: ## Notebooks -Each exercise notebook that has a paired solution carries `# TODO:` cells with `...` placeholders; the solution fills them in. The intro/reference notebook (07), the synthesis notebook (12), and the mpi4py walkthrough (06) are complete as written and have no separate solution. +Each exercise notebook that has a paired solution carries clearly marked `# TODO:` placeholders; the solution fills them in. The intro/reference notebook (07) and synthesis notebook (12) are complete as written and have no separate solution. ### Fundamentals @@ -39,7 +39,7 @@ Each exercise notebook that has a paired solution carries `# TODO:` cells with ` | # | Notebook | Link | Solution | |---|----------|------|----------| -| 06 | mpi4py | [![](https://colab.research.google.com/assets/colab-badge.svg)](https://colab.research.google.com/github/NVIDIA/accelerated-computing-hub/blob/main/tutorials/pyhpc/notebooks/06__mpi4py.ipynb) | | +| 06 | mpi4py | [![](https://colab.research.google.com/assets/colab-badge.svg)](https://colab.research.google.com/github/NVIDIA/accelerated-computing-hub/blob/main/tutorials/pyhpc/notebooks/06__mpi4py.ipynb) | [![](https://colab.research.google.com/assets/colab-badge.svg)](https://colab.research.google.com/github/NVIDIA/accelerated-computing-hub/blob/main/tutorials/pyhpc/notebooks/solutions/06__mpi4py__SOLUTION.ipynb) | ### Programming models and interoperability diff --git a/tutorials/pyhpc/notebooks/06__mpi4py.ipynb b/tutorials/pyhpc/notebooks/06__mpi4py.ipynb index 7495ad91..07dc4c6b 100644 --- a/tutorials/pyhpc/notebooks/06__mpi4py.ipynb +++ b/tutorials/pyhpc/notebooks/06__mpi4py.ipynb @@ -2,575 +2,611 @@ "cells": [ { "cell_type": "markdown", - "id": "40bbb627", + "id": "mpi-title", "metadata": {}, "source": [ - "## mpi4py" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "P8UcZACXA4i4", - "metadata": { - "id": "P8UcZACXA4i4" - }, - "outputs": [], - "source": [ - "MPIRUN = \"mpirun.mpich -launcher fork\"\n", + "## mpi4py\n", "\n", - "import os\n", + "### Table of Contents\n", "\n", - "# Install necessary packages if running in Google Colab.\n", - "if os.getenv(\"COLAB_RELEASE_TAG\") and not os.path.exists(\"/accelerated-computing-hub-installed\"):\n", - " print(\"Installing PIP packages.\")\n", - " !pip install \"mpi4py\" > /dev/null 2>&1\n", - " open(\"/accelerated-computing-hub-installed\", \"a\").close()\n", - " print(\"All packages installed.\")\n", + "1. [Environment Setup](#1-environment-setup)\n", + "2. [One Program, Many Processes](#2-one-program-many-processes)\n", + "3. [Exercise 1: Distributed Sum of Squares](#3-exercise-1-distributed-sum-of-squares)\n", + "4. [Domain Decomposition and Halo Exchange](#4-domain-decomposition-and-halo-exchange)\n", + "5. [The Serial Heat Equation Baseline](#5-the-serial-heat-equation-baseline)\n", + "6. [Exercise 2: Distributed Heat Equation](#6-exercise-2-distributed-heat-equation)\n", + "7. [Verification and Visualization](#7-verification-and-visualization)\n", + "8. [Key Takeaways](#8-key-takeaways)\n", "\n", - "import IPython.display" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "32b1f077-779c-432d-b807-98f5646fd9f4", - "metadata": { - "id": "32b1f077-779c-432d-b807-98f5646fd9f4" - }, - "outputs": [], - "source": [ - "%%writefile hello_world.py\n", + "---\n", "\n", - "from mpi4py import MPI\n", + "### 1. Environment Setup\n", "\n", - "print(\"Hello World!\")" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "11291ac1-088b-4c97-b32c-9dee22f0e1d1", - "metadata": { - "id": "11291ac1-088b-4c97-b32c-9dee22f0e1d1" - }, - "outputs": [], - "source": [ - "!$MPIRUN -n 2 python hello_world.py" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "ca7a23c9-9bbf-489b-b697-a10b9e24ebc0", - "metadata": { - "id": "ca7a23c9-9bbf-489b-b697-a10b9e24ebc0" - }, - "outputs": [], - "source": [ - "!$MPIRUN -n 4 python hello_world.py" + "[MPI](https://www.mpi-forum.org/) (Message Passing Interface) lets independent processes cooperate by sending data to one another. [mpi4py](https://mpi4py.readthedocs.io/en/stable/) exposes MPI in Python while supporting efficient communication directly from NumPy arrays.\n", + "\n", + "In this notebook, we'll learn to:\n", + "\n", + "1. Launch the same Python program as several MPI processes.\n", + "2. Combine distributed results with a collective operation.\n", + "3. Split a two-dimensional grid into row-wise subdomains.\n", + "4. Exchange halo rows between neighboring processes.\n", + "5. Verify a distributed heat-equation stencil against a serial reference.\n", + "\n", + "First, let's make sure the MPI launcher and Python environment are ready. The setup selects MPICH's local `fork` launcher in the CSCS environment and Open MPI's oversubscription mode in Colab or a local tutorial environment. MPI does not use the GPU in this lesson; the standard GPU notebook environment is retained so this notebook composes with the rest of the tutorial." ] }, { "cell_type": "code", "execution_count": null, - "id": "06e5d0d5-797f-40b1-a69d-365126f2ea67", - "metadata": { - "id": "06e5d0d5-797f-40b1-a69d-365126f2ea67" - }, + "id": "mpi-setup", + "metadata": {}, "outputs": [], "source": [ - "%%writefile hello_world_with_ranks.py\n", "import os\n", - "from mpi4py import MPI\n", - "comm = MPI.COMM_WORLD\n", - "size = comm.Get_size()\n", - "rank = comm.Get_rank()\n", - "name = MPI.Get_processor_name()\n", - "pid = os.getpid()\n", - "print(f\"Hello World! I am process {rank} of {size} on {name} \"\n", - " \"with pid {pid}.\\n\")" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "bfa7013c-c3ee-4a74-a7e3-94985ce04c0b", - "metadata": { - "id": "bfa7013c-c3ee-4a74-a7e3-94985ce04c0b" - }, - "outputs": [], - "source": [ - "!$MPIRUN -n 2 python hello_world_with_ranks.py" + "import shutil\n", + "import subprocess\n", + "import sys\n", + "from pathlib import Path\n", + "\n", + "# Install Open MPI and mpi4py if running in Google Colab.\n", + "colab_marker = Path(\"/tmp/accelerated-computing-hub-mpi4py-installed\")\n", + "if os.getenv(\"COLAB_RELEASE_TAG\") and not colab_marker.exists():\n", + " print(\"Installing Open MPI and mpi4py.\")\n", + " subprocess.run([\"apt-get\", \"-qq\", \"update\"], check=True)\n", + " subprocess.run(\n", + " [\"apt-get\", \"-qq\", \"install\", \"-y\", \"openmpi-bin\", \"libopenmpi-dev\"],\n", + " check=True,\n", + " stdout=subprocess.DEVNULL,\n", + " )\n", + " subprocess.run(\n", + " [sys.executable, \"-m\", \"pip\", \"install\", \"mpi4py\"],\n", + " check=True,\n", + " stdout=subprocess.DEVNULL,\n", + " )\n", + " colab_marker.touch()\n", + " print(\"Open MPI and mpi4py installed.\")\n", + "\n", + "# Open MPI protects against accidental root launches. Colab and the tutorial\n", + "# container are isolated environments where explicitly allowing this is safe.\n", + "os.environ.setdefault(\"OMPI_ALLOW_RUN_AS_ROOT\", \"1\")\n", + "os.environ.setdefault(\"OMPI_ALLOW_RUN_AS_ROOT_CONFIRM\", \"1\")\n", + "\n", + "# Match the launcher to the MPI library against which mpi4py was built.\n", + "vendor_result = subprocess.run(\n", + " [\n", + " sys.executable,\n", + " \"-c\",\n", + " \"from mpi4py import MPI; print(MPI.get_vendor()[0])\",\n", + " ],\n", + " check=True,\n", + " capture_output=True,\n", + " text=True,\n", + ")\n", + "MPI_VENDOR = vendor_result.stdout.strip()\n", + "\n", + "if MPI_VENDOR == \"MPICH\":\n", + " mpi_launcher = (\n", + " shutil.which(\"mpirun.mpich\")\n", + " or shutil.which(\"mpiexec.mpich\")\n", + " or shutil.which(\"mpiexec\")\n", + " )\n", + " if mpi_launcher is None:\n", + " raise RuntimeError(\"No MPICH launcher was found\")\n", + " # Do not delegate this nested launch back to Slurm on CSCS.\n", + " MPI_LAUNCHER = [mpi_launcher, \"-launcher\", \"fork\"]\n", + "elif MPI_VENDOR == \"Open MPI\":\n", + " mpi_launcher = shutil.which(\"mpirun.openmpi\") or shutil.which(\"mpirun\")\n", + " if mpi_launcher is None:\n", + " raise RuntimeError(\"No Open MPI launcher was found\")\n", + " MPI_LAUNCHER = [mpi_launcher, \"--oversubscribe\"]\n", + "else:\n", + " mpi_launcher = shutil.which(\"mpiexec\")\n", + " if mpi_launcher is None:\n", + " raise RuntimeError(f\"No launcher was found for {MPI_VENDOR}\")\n", + " MPI_LAUNCHER = [mpi_launcher]\n", + "\n", + "\n", + "def run_program(command):\n", + " '''Run a child program, display its output, and fail on errors.'''\n", + " result = subprocess.run(\n", + " command,\n", + " capture_output=True,\n", + " text=True,\n", + " timeout=180,\n", + " )\n", + " print(result.stdout, end=\"\")\n", + " if result.stderr:\n", + " print(result.stderr, end=\"\", file=sys.stderr)\n", + " result.check_returncode()\n", + "\n", + "\n", + "def run_mpi(rank_count, script):\n", + " '''Run a Python script under the selected MPI implementation.'''\n", + " command = [\n", + " *MPI_LAUNCHER,\n", + " \"-n\",\n", + " str(rank_count),\n", + " sys.executable,\n", + " \"-u\",\n", + " script,\n", + " ]\n", + " run_program(command)\n", + "\n", + "\n", + "def run_python(script):\n", + " '''Run a serial Python reference with the notebook's interpreter.'''\n", + " run_program([sys.executable, \"-u\", script])" ] }, { "cell_type": "markdown", - "id": "c07f2dce-588f-434f-b0ae-7d670d0dca99", + "id": "mpi-model", "metadata": {}, "source": [ - "### Exercise 1\n", + "### 2. One Program, Many Processes\n", "\n", - "Use the rank to print “fizz” if it divisible by 2, ”buzz” if divisible by 3, and “fizzbuzz” if both." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "253f0bf6-494f-4b3a-89d5-7b736b7e3b61", - "metadata": {}, - "outputs": [], - "source": [ - "%%writefile fizzbuzz.py\n", + "MPI follows the **single program, multiple data** model: every process runs the same script, but each process has a different integer **rank**. The processes belong to a **communicator**; `MPI.COMM_WORLD` contains every process launched for the program.\n", + "\n", + "The notebook kernel itself is a single process, so MPI examples need to be written to Python files and launched with `mpirun`. Passing `4` to the `run_mpi` helper below adds `-n 4` to the launcher and creates four independent Python processes. Each process discovers:\n", + "\n", + "- its own rank with `comm.Get_rank()`;\n", + "- the communicator size with `comm.Get_size()`; and\n", + "- its host name with `MPI.Get_processor_name()`.\n", "\n", - "# TODO" + "Calling `gather` is a **collective operation**: every rank participates, and rank 0 receives one value from each rank. Gathering the values before printing makes the rank order deterministic, even though host names depend on the system running the notebook." ] }, { "cell_type": "code", "execution_count": null, - "id": "4868efe3-8d58-4707-acb9-84178c98065b", + "id": "mpi-hello-write", "metadata": {}, "outputs": [], "source": [ - "!$MPIRUN -n 4 python fizzbuzz.py" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "1a032e45-682a-4c55-9a34-e7cdc01f8a2d", - "metadata": { - "id": "1a032e45-682a-4c55-9a34-e7cdc01f8a2d" - }, - "outputs": [], - "source": [ - "%%writefile send_recv.py\n", + "%%writefile hello_mpi.py\n", "\n", "from mpi4py import MPI\n", "\n", + "\n", "comm = MPI.COMM_WORLD\n", - "size = comm.Get_size()\n", "rank = comm.Get_rank()\n", + "size = comm.Get_size()\n", + "\n", + "message = f\"rank {rank} of {size} on {MPI.Get_processor_name()}\"\n", + "messages = comm.gather(message, root=0)\n", + "\n", "if rank == 0:\n", - " data = {'a': 7, 'b': 3.14}\n", - " comm.send(data, dest=1, tag=11)\n", - "elif rank == 1:\n", - " data = comm.recv(source=0, tag=11)\n", - " print(data)" + " print(f\"Launched {size} MPI processes:\")\n", + " for message in messages:\n", + " print(f\" {message}\")" ] }, { "cell_type": "code", "execution_count": null, - "id": "ab2db951-10f5-4570-8717-d9c4ed88acab", - "metadata": { - "id": "ab2db951-10f5-4570-8717-d9c4ed88acab" - }, + "id": "mpi-hello-run", + "metadata": {}, "outputs": [], "source": [ - "!$MPIRUN -n 2 python send_recv.py" + "run_mpi(4, \"hello_mpi.py\")" ] }, { - "cell_type": "code", - "execution_count": null, - "id": "ab6096d9-d134-490e-9c92-3f800d9a5c8e", - "metadata": { - "id": "ab6096d9-d134-490e-9c92-3f800d9a5c8e" - }, - "outputs": [], + "cell_type": "markdown", + "id": "mpi-simple-intro", + "metadata": {}, "source": [ - "%%writefile send_recv_eff.py\n", + "### 3. Exercise 1: Distributed Sum of Squares\n", "\n", - "import numpy as np\n", - "from mpi4py import MPI\n", - "comm = MPI.COMM_WORLD\n", - "size = comm.Get_size()\n", - "rank = comm.Get_rank()\n", - "if rank == 0:\n", - " data = np.arange(10, dtype='i')\n", - " comm.Send([data, MPI.INT], dest=1, tag=77)\n", - "elif rank == 1:\n", - " data = np.empty(10, dtype='i')\n", - " comm.Recv([data, MPI.INT], source=0, tag=77)\n", - " print(data)\n" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "6e0c463e-72a6-4bd8-8c12-f80ed5831823", - "metadata": { - "id": "6e0c463e-72a6-4bd8-8c12-f80ed5831823" - }, - "outputs": [], - "source": [ - "!$MPIRUN -n 2 python send_recv_eff.py" + "Our first collective gathered Python strings. Now let's distribute numerical work. Rank 0 creates the integers 1 through 23 and `scatter` sends one NumPy chunk to each rank. The chunks may have different lengths, which the lowercase object-based `scatter` method handles naturally.\n", + "\n", + "**Simple exercise:** Each rank already computes its local sum of squares. Your task is to combine those partial results on rank 0.\n", + "\n", + "**TODO:** Replace the placeholder with one call to `comm.reduce`. Use `op=MPI.SUM` and `root=0`.\n", + "\n", + "The unchanged validation checks both the one-rank and four-rank results independently against NumPy." ] }, { "cell_type": "code", "execution_count": null, - "id": "896b3bcd-d66b-49aa-9f16-4318c9fdb9fb", - "metadata": { - "id": "896b3bcd-d66b-49aa-9f16-4318c9fdb9fb" - }, + "id": "mpi-simple-write", + "metadata": {}, "outputs": [], "source": [ - "%%writefile send_recv_numpy.py\n", - "from mpi4py import MPI\n", + "%%writefile distributed_sum_squares.py\n", + "\n", "import numpy as np\n", + "from mpi4py import MPI\n", + "\n", "\n", "comm = MPI.COMM_WORLD\n", - "size = comm.Get_size()\n", "rank = comm.Get_rank()\n", + "size = comm.Get_size()\n", + "\n", + "values = np.arange(1, 24, dtype=np.int64) if rank == 0 else None\n", + "chunks = np.array_split(values, size) if rank == 0 else None\n", + "local_values = comm.scatter(chunks, root=0)\n", + "\n", + "local_sum_squares = np.dot(local_values, local_values)\n", + "\n", + "# TODO: Sum the partial results on rank 0 with comm.reduce.\n", + "raise NotImplementedError(\"TODO: reduce the local sums\")\n", "\n", "if rank == 0:\n", - " data = np.arange(10, dtype=np.float64)\n", - " comm.Send(data, dest=1, tag=13)\n", - "elif rank == 1:\n", - " data = np.empty(10, dtype=np.float64)\n", - " comm.Recv(data, source=0, tag=13)\n", - " print(data)" + " expected = np.dot(values, values)\n", + " assert global_sum_squares == expected\n", + " print(f\"sum(i**2 for i in 1..23) = {global_sum_squares}\")" ] }, { "cell_type": "code", "execution_count": null, - "id": "4ba204e8-ee72-4ef2-a04f-96de7a779e2b", - "metadata": { - "id": "4ba204e8-ee72-4ef2-a04f-96de7a779e2b" - }, + "id": "mpi-simple-run", + "metadata": {}, "outputs": [], "source": [ - "!$MPIRUN -n 2 python send_recv_numpy.py" + "run_mpi(1, \"distributed_sum_squares.py\")\n", + "run_mpi(4, \"distributed_sum_squares.py\")" ] }, { - "cell_type": "code", - "execution_count": null, - "id": "4107702e-c9bf-4917-a5e0-4b87a505fc66", - "metadata": { - "id": "4107702e-c9bf-4917-a5e0-4b87a505fc66" - }, - "outputs": [], + "cell_type": "markdown", + "id": "mpi-think", + "metadata": {}, "source": [ - "%%writefile barrier.py\n", + "#### Think About It\n", "\n", - "from mpi4py import MPI\n", - "comm = MPI.COMM_WORLD\n", - "rank = comm.Get_rank()\n", - "for r_id in range(comm.Get_size()):\n", - " if rank == r_id:\n", - " print(f\"Hello from proc:\")\n", - "comm.Barrier()\n", - "print(f\"proc {rank} after barrier\")" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "a10c0baf-b6a6-415e-8083-ba7ade5273a4", - "metadata": { - "id": "a10c0baf-b6a6-415e-8083-ba7ade5273a4" - }, - "outputs": [], - "source": [ - "!$MPIRUN -n 4 python barrier.py" + "What does `global_sum_squares` contain on ranks other than rank 0? When would `allreduce` be more appropriate than `reduce`?" ] }, { - "cell_type": "code", - "execution_count": null, - "id": "711866b0-b432-4252-80c2-25d722bab297", - "metadata": { - "id": "711866b0-b432-4252-80c2-25d722bab297" - }, - "outputs": [], + "cell_type": "markdown", + "id": "mpi-decomposition", + "metadata": {}, "source": [ - "%%writefile scatter.py\n", + "### 4. Domain Decomposition and Halo Exchange\n", "\n", - "from mpi4py import MPI\n", - "comm = MPI.COMM_WORLD\n", - "rank = comm.Get_rank()\n", - "size = comm.Get_size()\n", + "Collectives move data across an entire communicator. Stencil computations have a more local communication pattern: each grid point depends only on nearby points. We can split a $66 \\times 66$ grid by rows and give each of four ranks 16 of the 64 interior rows:\n", "\n", - "if rank == 0:\n", - " data = [(i+1)**2 for i in range(size)]\n", - "else:\n", - " data = None\n", + "```text\n", + "global row: 0 | 1 ... 16 | 17 ... 32 | 33 ... 48 | 49 ... 64 | 65\n", + "owner: boundary | rank 0 | rank 1 | rank 2 | rank 3 | boundary\n", + "```\n", "\n", - "data = comm.scatter(data, root=0)\n", - "assert data == (rank+1)**2\n", + "Every rank stores two extra **halo rows** around its owned rows:\n", "\n", - "print(f\"data on rank %d is: {rank}, {data}\")" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "d72dd770-b215-4eed-8f2a-86e25f6c2c93", - "metadata": { - "id": "d72dd770-b215-4eed-8f2a-86e25f6c2c93" - }, - "outputs": [], - "source": [ - "!$MPIRUN -n 4 python scatter.py" + "```text\n", + " top halo\n", + " +----------------+\n", + " | owned rows |\n", + " +----------------+\n", + " bottom halo\n", + "```\n", + "\n", + "Before each stencil update, adjacent ranks exchange their edge rows. `MPI.PROC_NULL` represents a missing neighbor at a physical boundary: communication with it completes immediately and leaves the receive buffer unchanged. Using `Sendrecv` pairs a send and receive in one operation, avoiding the deadlock risks of separate blocking sends.\n", + "\n", + "For NumPy arrays, mpi4py's uppercase methods such as `Sendrecv` and `Gather` communicate typed buffers directly. The lowercase methods used above can serialize general Python objects; uppercase methods are the natural choice for fixed-shape numerical arrays." ] }, { - "cell_type": "code", - "execution_count": null, - "id": "37422960-07af-4bde-9191-3a7e5c086740", - "metadata": { - "id": "37422960-07af-4bde-9191-3a7e5c086740" - }, - "outputs": [], + "cell_type": "markdown", + "id": "mpi-serial-intro", + "metadata": {}, "source": [ - "%%writefile gather.py\n", + "### 5. The Serial Heat Equation Baseline\n", "\n", - "from mpi4py import MPI\n", - "comm = MPI.COMM_WORLD\n", - "rank = comm.Get_rank()\n", - "size = comm.Get_size()\n", + "The two-dimensional heat equation is\n", "\n", - "data = (rank+1)**2\n", - "print(f\"before gather, data on rank %d is: {rank}, {data}\")\n", + "$$\n", + "\\frac{\\partial u}{\\partial t}\n", + "= \\kappa \\left(\\frac{\\partial^2 u}{\\partial x^2}\n", + "+ \\frac{\\partial^2 u}{\\partial y^2}\\right).\n", + "$$\n", "\n", - "comm.Barrier()\n", - "data = comm.gather(data, root=0)\n", - "if rank == 0:\n", - " for i in range(size):\n", - " assert data[i] == (i+1)**2\n", - "else:\n", - " assert data is None\n", + "For equal grid spacing $h = \\Delta x = \\Delta y$, define the dimensionless diffusion number\n", "\n", - "print(f\"data on rank: {rank} is: {data}\")\n" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "45ef2e24-2634-4243-974e-2fc5e2719a15", - "metadata": { - "id": "45ef2e24-2634-4243-974e-2fc5e2719a15" - }, - "outputs": [], - "source": [ - "!$MPIRUN -n 4 python gather.py" + "$$r = \\frac{\\kappa\\,\\Delta t}{h^2}.$$\n", + "\n", + "A five-point finite-difference stencil then advances one time step:\n", + "\n", + "$$\n", + "u^{n+1}_{i,j} = u^n_{i,j} + r\\left(\n", + "u^n_{i-1,j} + u^n_{i+1,j} + u^n_{i,j-1} + u^n_{i,j+1}\n", + "- 4u^n_{i,j}\\right).\n", + "$$\n", + "\n", + "We use $r=0.2$, below the two-dimensional explicit stability limit $r \\leq 1/4$, and hold the outer boundary at zero. The complete serial implementation below gives us a trusted reference before communication is introduced." ] }, { "cell_type": "code", "execution_count": null, - "id": "b1b25c4b-1331-4c1e-9d71-67cb820af2f4", - "metadata": { - "id": "b1b25c4b-1331-4c1e-9d71-67cb820af2f4" - }, + "id": "mpi-serial-write", + "metadata": {}, "outputs": [], "source": [ - "%%writefile reduce.py\n", + "%%writefile heat_reference.py\n", "\n", - "from mpi4py import MPI\n", - "comm = MPI.COMM_WORLD\n", - "rank = comm.Get_rank()\n", - "size = comm.Get_size()\n", + "import numpy as np\n", "\n", - "sendmsg = rank\n", - "recvmsg1 = comm.reduce(sendmsg, op=MPI.SUM, root=0)\n", - "recvmsg2 = comm.allreduce(sendmsg)\n", "\n", - "print(recvmsg2)\n" + "NY = 66\n", + "NX = 66\n", + "STEPS = 120\n", + "DIFFUSION_NUMBER = 0.2\n", + "\n", + "\n", + "def initial_temperature_rows(global_rows, ny=NY, nx=NX):\n", + " \"\"\"Create the initial hot disk for selected global rows.\"\"\"\n", + " y = np.asarray(global_rows)[:, np.newaxis]\n", + " x = np.arange(nx)[np.newaxis, :]\n", + " center_y = (ny - 1) / 2\n", + " center_x = (nx - 1) / 2\n", + " hot = (y - center_y) ** 2 + (x - center_x) ** 2 <= 6**2\n", + " temperature = hot.astype(np.float64)\n", + " temperature[:, 0] = 0.0\n", + " temperature[:, -1] = 0.0\n", + " return temperature\n", + "\n", + "\n", + "def initial_temperature(ny=NY, nx=NX):\n", + " \"\"\"Create the full initial field with zero-valued boundaries.\"\"\"\n", + " temperature = np.zeros((ny, nx), dtype=np.float64)\n", + " temperature[1:-1] = initial_temperature_rows(range(1, ny - 1), ny, nx)\n", + " return temperature\n", + "\n", + "\n", + "def advance(temperature, diffusion_number=DIFFUSION_NUMBER):\n", + " \"\"\"Apply one serial five-point stencil update.\"\"\"\n", + " next_temperature = np.zeros_like(temperature)\n", + " center = temperature[1:-1, 1:-1]\n", + " next_temperature[1:-1, 1:-1] = center + diffusion_number * (\n", + " temperature[:-2, 1:-1]\n", + " + temperature[2:, 1:-1]\n", + " + temperature[1:-1, :-2]\n", + " + temperature[1:-1, 2:]\n", + " - 4.0 * center\n", + " )\n", + " return next_temperature\n", + "\n", + "\n", + "def solve_serial(steps=STEPS):\n", + " \"\"\"Evolve the complete grid on one process.\"\"\"\n", + " temperature = initial_temperature()\n", + " for _ in range(steps):\n", + " temperature = advance(temperature)\n", + " return temperature\n", + "\n", + "\n", + "if __name__ == \"__main__\":\n", + " temperature = solve_serial()\n", + " assert np.isfinite(temperature).all()\n", + " assert np.all(temperature >= 0.0)\n", + " assert np.all(temperature <= 1.0)\n", + " assert np.all(temperature[[0, -1], :] == 0.0)\n", + " assert np.all(temperature[:, [0, -1]] == 0.0)\n", + " print(\n", + " f\"Serial reference passed: shape={temperature.shape}, \"\n", + " f\"maximum={temperature.max():.6f}\"\n", + " )" ] }, { "cell_type": "code", "execution_count": null, - "id": "44f164e9-d833-4a7a-8972-06a63ccae1a3", - "metadata": { - "id": "44f164e9-d833-4a7a-8972-06a63ccae1a3" - }, + "id": "mpi-serial-run", + "metadata": {}, "outputs": [], "source": [ - "!$MPIRUN -n 4 python reduce.py" + "run_python(\"heat_reference.py\")" ] }, { "cell_type": "markdown", - "id": "20a0b643-481c-4d61-a8a6-0320d977a69c", - "metadata": { - "id": "20a0b643-481c-4d61-a8a6-0320d977a69c" - }, + "id": "mpi-advanced-intro", + "metadata": {}, "source": [ - "### Compute Pi Example\n", + "### 6. Exercise 2: Distributed Heat Equation\n", "\n", - "The following example is completely self-contained to simplify reuse in another script. You can switch between running the code in parallel and serial by executing an %autopx cell." + "The distributed solver retains the serial stencil but stores only one row slab per rank.\n", + "\n", + "**Advanced exercise:** Complete its two missing pieces:\n", + "\n", + "1. **Exchange halo rows.** Add two `comm.Sendrecv` calls to `exchange_halos`:\n", + " - send the first owned row (`temperature[1]`) to `above` while receiving the bottom halo (`temperature[-1]`) from `below`, using tag 0;\n", + " - send the last owned row (`temperature[-2]`) to `below` while receiving the top halo (`temperature[0]`) from `above`, using tag 1.\n", + "2. **Advance the local stencil.** Fill `next_temperature[1:-1, 1:-1]` with the same five-point update as the serial baseline. The halo rows make the slice expression identical even at rank boundaries.\n", + "\n", + "**TODO:** Fill both placeholders and remove their `NotImplementedError` lines. The communication calls use this keyword form:\n", + "\n", + "```python\n", + "comm.Sendrecv(\n", + " sendbuf=..., dest=..., sendtag=...,\n", + " recvbuf=..., source=..., recvtag=...,\n", + ")\n", + "```\n", + "\n", + "The decomposition, initialization, gather, and serial-reference check are already complete." ] }, { "cell_type": "code", "execution_count": null, - "id": "86b7cdb2-f430-4b5b-a24f-ced21cad2a37", - "metadata": { - "id": "86b7cdb2-f430-4b5b-a24f-ced21cad2a37" - }, + "id": "mpi-heat-write", + "metadata": {}, "outputs": [], "source": [ - "%%writefile compute_pi.py\n", + "%%writefile heat_mpi.py\n", "\n", + "import numpy as np\n", "from mpi4py import MPI\n", - "import math\n", "\n", - "def compute_pi(n, start=0, step=1):\n", - " h = 1.0 / n\n", - " s = 0.0\n", - " for i in range(start, n, step):\n", - " x = h * (i + 0.5)\n", - " s += 4.0 / (1.0 + x**2)\n", - " return s * h\n", + "from heat_reference import (\n", + " DIFFUSION_NUMBER,\n", + " NX,\n", + " NY,\n", + " STEPS,\n", + " initial_temperature,\n", + " initial_temperature_rows,\n", + " solve_serial,\n", + ")\n", "\n", - "comm = MPI.COMM_WORLD\n", - "nprocs = comm.Get_size()\n", - "myrank = comm.Get_rank()\n", - "if myrank == 0:\n", - " n = 10\n", - "else:\n", - " n = None\n", "\n", - "n = comm.bcast(n, root=0)\n", + "def decompose_rows(comm):\n", + " \"\"\"Return the local row count and first owned global row.\"\"\"\n", + " interior_rows = NY - 2\n", + " if interior_rows % comm.Get_size() != 0:\n", + " raise ValueError(\"The interior row count must be divisible by the rank count\")\n", + " local_rows = interior_rows // comm.Get_size()\n", + " first_global_row = 1 + comm.Get_rank() * local_rows\n", + " return local_rows, first_global_row\n", "\n", - "mypi = compute_pi(n, myrank, nprocs)\n", "\n", - "pi = comm.reduce(mypi, op=MPI.SUM, root=0)\n", + "def exchange_halos(temperature, comm):\n", + " \"\"\"Exchange edge rows with the ranks above and below.\"\"\"\n", + " rank = comm.Get_rank()\n", + " above = rank - 1 if rank > 0 else MPI.PROC_NULL\n", + " below = rank + 1 if rank + 1 < comm.Get_size() else MPI.PROC_NULL\n", "\n", - "if myrank == 0:\n", - " error = abs(pi - math.pi)\n", - " print(\"pi is approximately %.16f\\nerror is %.16f\" % (pi, error))" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "d4294276-7306-4a17-8315-85704c439a7c", - "metadata": { - "id": "d4294276-7306-4a17-8315-85704c439a7c" - }, - "outputs": [], - "source": [ - "!$MPIRUN -n 4 python compute_pi.py" - ] - }, - { - "cell_type": "markdown", - "id": "57a66360-777b-4492-9094-324176a32659", - "metadata": { - "id": "57a66360-777b-4492-9094-324176a32659" - }, - "source": [ - "### Mandelbrot Set Example\n", + " # TODO: Exchange the first owned row for the bottom halo (tag 0),\n", + " # then the last owned row for the top halo (tag 1).\n", + " raise NotImplementedError(\"TODO: exchange halo rows\")\n", "\n", - "The following example is completely self-contained to simplify reuse in another script." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "19ad96dd-f8fc-4a89-8d11-c50411944662", - "metadata": { - "id": "19ad96dd-f8fc-4a89-8d11-c50411944662" - }, - "outputs": [], - "source": [ - "%%writefile compute_mandelbot.py\n", "\n", - "import matplotlib.pyplot as plt\n", - "import matplotlib.cm as cm\n", + "def advance_local(temperature):\n", + " \"\"\"Apply one five-point update to the locally owned rows.\"\"\"\n", + " next_temperature = np.zeros_like(temperature)\n", "\n", - "from mpi4py import MPI\n", - "import numpy as np\n", + " # TODO: Apply the stencil to next_temperature[1:-1, 1:-1].\n", + " raise NotImplementedError(\"TODO: implement the local stencil\")\n", "\n", - "def mandelbrot (x, y, maxit):\n", - " c = x + y*1j\n", - " z = 0 + 0j\n", - " it = 0\n", - " while abs(z) < 2 and it < maxit:\n", - " z = z**2 + c\n", - " it += 1\n", - " return it\n", + " return next_temperature\n", "\n", - "x1, x2 = -2.0, 1.0\n", - "y1, y2 = -1.0, 1.0\n", - "w, h = 250, 200\n", - "maxit = 127\n", "\n", - "comm = MPI.COMM_WORLD\n", - "size = comm.Get_size()\n", - "rank = comm.Get_rank()\n", + "def gather_field(local_temperature, local_rows, comm):\n", + " \"\"\"Gather equal-sized row slabs and restore the physical boundaries.\"\"\"\n", + " owned_rows = np.ascontiguousarray(local_temperature[1:-1])\n", + " gathered = None\n", + " if comm.Get_rank() == 0:\n", + " gathered = np.empty((comm.Get_size(), local_rows, NX), dtype=np.float64)\n", "\n", - "# number of rows to compute here\n", - "N = h // size + (h % size > rank)\n", + " comm.Gather(owned_rows, gathered, root=0)\n", "\n", - "# first row to compute here\n", - "start = comm.scan(N)-N\n", + " if comm.Get_rank() != 0:\n", + " return None\n", "\n", - "# array to store local result\n", - "Cl = np.zeros([N, w], dtype='i')\n", + " temperature = np.zeros((NY, NX), dtype=np.float64)\n", + " temperature[1:-1] = gathered.reshape(NY - 2, NX)\n", + " return temperature\n", "\n", - "# compute owned rows\n", "\n", - "dx = (x2 - x1) / w\n", - "dy = (y2 - y1) / h\n", + "def main():\n", + " comm = MPI.COMM_WORLD\n", + " local_rows, first_global_row = decompose_rows(comm)\n", + " global_rows = np.arange(first_global_row, first_global_row + local_rows)\n", "\n", - "for i in range(N):\n", - " y = y1 + (i + start) * dy\n", - " for j in range(w):\n", - " x = x1 + j * dx\n", - " Cl[i, j] = mandelbrot(x, y, maxit)\n", + " temperature = np.zeros((local_rows + 2, NX), dtype=np.float64)\n", + " temperature[1:-1] = initial_temperature_rows(global_rows)\n", "\n", - "# gather results at root (process 0)\n", - "counts = comm.gather(N, root=0)\n", - "C = None\n", - "if rank == 0:\n", - " C = np.zeros([h, w], dtype='i')\n", + " for _ in range(STEPS):\n", + " exchange_halos(temperature, comm)\n", + " temperature = advance_local(temperature)\n", "\n", - "# here we create a custom datatype for sending/receiving rows of data.\n", - "rowtype = MPI.INT.Create_contiguous(w)\n", - "rowtype.Commit()\n", + " distributed = gather_field(temperature, local_rows, comm)\n", "\n", - "comm.Gatherv(sendbuf=[Cl, MPI.INT], recvbuf=[C, (counts, None), rowtype],root=0)\n", + " if comm.Get_rank() == 0:\n", + " reference = solve_serial()\n", + " np.testing.assert_allclose(distributed, reference, rtol=1e-13, atol=1e-13)\n", + " assert np.isfinite(distributed).all()\n", + " assert np.all(distributed[[0, -1], :] == 0.0)\n", + " assert np.all(distributed[:, [0, -1]] == 0.0)\n", + " np.savez(\n", + " \"heat_equation_result.npz\",\n", + " initial=initial_temperature(),\n", + " final=distributed,\n", + " steps=STEPS,\n", + " )\n", + " print(\n", + " \"Distributed heat equation matches the serial reference: \"\n", + " f\"shape={distributed.shape}, maximum={distributed.max():.6f}\"\n", + " )\n", + "\n", + "\n", + "if __name__ == \"__main__\":\n", + " main()" + ] + }, + { + "cell_type": "markdown", + "id": "mpi-verify-intro", + "metadata": {}, + "source": [ + "### 7. Verification and Visualization\n", "\n", - "rowtype.Free()\n", + "Correctness comes before interpretation. The MPI program gathers the distributed row slabs on rank 0, compares every value with `solve_serial()` using `numpy.testing.assert_allclose`, checks the fixed boundaries, and only then saves the initial and final fields for visualization.\n", "\n", - "if rank==0:\n", - " # Do the plotting on rank 0\n", - " plt.figure()\n", - " plt.imshow(C, cmap=cm.Spectral)\n", - " plt.savefig('test.png')" + "Let's run the advanced exercise with the four ranks used by our decomposition." ] }, { "cell_type": "code", "execution_count": null, - "id": "ac4a537d-942a-4c09-a2db-21f6d0fc9d33", - "metadata": { - "id": "ac4a537d-942a-4c09-a2db-21f6d0fc9d33" - }, + "id": "mpi-heat-run", + "metadata": {}, "outputs": [], "source": [ - "!$MPIRUN -n 4 python compute_mandelbot.py" + "result_path = Path(\"heat_equation_result.npz\")\n", + "result_path.unlink(missing_ok=True)\n", + "run_mpi(4, \"heat_mpi.py\")\n", + "assert result_path.exists()" ] }, { "cell_type": "code", "execution_count": null, - "id": "1e83bd55-8a6d-4bbb-bd88-cdfd916690ab", - "metadata": { - "id": "1e83bd55-8a6d-4bbb-bd88-cdfd916690ab" - }, + "id": "mpi-plot", + "metadata": {}, "outputs": [], "source": [ - "IPython.display.Image(filename='test.png')" + "import matplotlib.pyplot as plt\n", + "import numpy as np\n", + "\n", + "with np.load(\"heat_equation_result.npz\") as result:\n", + " initial = result[\"initial\"]\n", + " final = result[\"final\"]\n", + " steps = int(result[\"steps\"])\n", + "\n", + "fig, axes = plt.subplots(1, 2, figsize=(10, 4), constrained_layout=True)\n", + "for axis, field, title in zip(\n", + " axes,\n", + " (initial, final),\n", + " (\"Initial temperature\", f\"After {steps} steps\"),\n", + "):\n", + " image = axis.imshow(field, origin=\"lower\", cmap=\"inferno\", vmin=0.0, vmax=1.0)\n", + " axis.set_title(title)\n", + " axis.set_xlabel(\"x\")\n", + " axis.set_ylabel(\"y\")\n", + "\n", + "fig.colorbar(image, ax=axes, label=\"temperature\")\n", + "plt.show()" + ] + }, + { + "cell_type": "markdown", + "id": "mpi-takeaways", + "metadata": {}, + "source": [ + "### 8. Key Takeaways\n", + "\n", + "- Every MPI rank runs the same program with private memory and a distinct rank.\n", + "- Collectives such as `scatter`, `reduce`, and `Gather` coordinate all ranks in a communicator.\n", + "- A row-wise decomposition turns a global stencil into local NumPy work plus two neighbor exchanges.\n", + "- Halo rows make the local stencil expression match the serial expression.\n", + "- `Sendrecv` and `MPI.PROC_NULL` express boundary-safe neighbor communication without extra synchronization.\n", + "- Comparing against a small trusted serial implementation catches communication and indexing mistakes.\n", + "\n", + "The same decomposition pattern extends to larger grids, uneven partitions with `Gatherv`, and higher-dimensional process topologies." ] } ], diff --git a/tutorials/pyhpc/notebooks/solutions/06__mpi4py__SOLUTION.ipynb b/tutorials/pyhpc/notebooks/solutions/06__mpi4py__SOLUTION.ipynb new file mode 100644 index 00000000..a90bcc5f --- /dev/null +++ b/tutorials/pyhpc/notebooks/solutions/06__mpi4py__SOLUTION.ipynb @@ -0,0 +1,799 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "mpi-title", + "metadata": {}, + "source": [ + "## mpi4py - SOLUTION\n", + "\n", + "### Table of Contents\n", + "\n", + "1. [Environment Setup](#1-environment-setup)\n", + "2. [One Program, Many Processes](#2-one-program-many-processes)\n", + "3. [Solution 1: Distributed Sum of Squares](#3-solution-1-distributed-sum-of-squares)\n", + "4. [Domain Decomposition and Halo Exchange](#4-domain-decomposition-and-halo-exchange)\n", + "5. [The Serial Heat Equation Baseline](#5-the-serial-heat-equation-baseline)\n", + "6. [Solution 2: Distributed Heat Equation](#6-solution-2-distributed-heat-equation)\n", + "7. [Verification and Visualization](#7-verification-and-visualization)\n", + "8. [Key Takeaways](#8-key-takeaways)\n", + "\n", + "---\n", + "\n", + "### 1. Environment Setup\n", + "\n", + "[MPI](https://www.mpi-forum.org/) (Message Passing Interface) lets independent processes cooperate by sending data to one another. [mpi4py](https://mpi4py.readthedocs.io/en/stable/) exposes MPI in Python while supporting efficient communication directly from NumPy arrays.\n", + "\n", + "In this notebook, we'll learn to:\n", + "\n", + "1. Launch the same Python program as several MPI processes.\n", + "2. Combine distributed results with a collective operation.\n", + "3. Split a two-dimensional grid into row-wise subdomains.\n", + "4. Exchange halo rows between neighboring processes.\n", + "5. Verify a distributed heat-equation stencil against a serial reference.\n", + "\n", + "First, let's make sure the MPI launcher and Python environment are ready. The setup selects MPICH's local `fork` launcher in the CSCS environment and Open MPI's oversubscription mode in Colab or a local tutorial environment. MPI does not use the GPU in this lesson; the standard GPU notebook environment is retained so this notebook composes with the rest of the tutorial." + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "id": "mpi-setup", + "metadata": { + "execution": { + "iopub.execute_input": "2026-07-21T13:23:58.479544Z", + "iopub.status.busy": "2026-07-21T13:23:58.479350Z", + "iopub.status.idle": "2026-07-21T13:23:58.747402Z", + "shell.execute_reply": "2026-07-21T13:23:58.746728Z" + } + }, + "outputs": [], + "source": [ + "import os\n", + "import shutil\n", + "import subprocess\n", + "import sys\n", + "from pathlib import Path\n", + "\n", + "# Install Open MPI and mpi4py if running in Google Colab.\n", + "colab_marker = Path(\"/tmp/accelerated-computing-hub-mpi4py-installed\")\n", + "if os.getenv(\"COLAB_RELEASE_TAG\") and not colab_marker.exists():\n", + " print(\"Installing Open MPI and mpi4py.\")\n", + " subprocess.run([\"apt-get\", \"-qq\", \"update\"], check=True)\n", + " subprocess.run(\n", + " [\"apt-get\", \"-qq\", \"install\", \"-y\", \"openmpi-bin\", \"libopenmpi-dev\"],\n", + " check=True,\n", + " stdout=subprocess.DEVNULL,\n", + " )\n", + " subprocess.run(\n", + " [sys.executable, \"-m\", \"pip\", \"install\", \"mpi4py\"],\n", + " check=True,\n", + " stdout=subprocess.DEVNULL,\n", + " )\n", + " colab_marker.touch()\n", + " print(\"Open MPI and mpi4py installed.\")\n", + "\n", + "# Open MPI protects against accidental root launches. Colab and the tutorial\n", + "# container are isolated environments where explicitly allowing this is safe.\n", + "os.environ.setdefault(\"OMPI_ALLOW_RUN_AS_ROOT\", \"1\")\n", + "os.environ.setdefault(\"OMPI_ALLOW_RUN_AS_ROOT_CONFIRM\", \"1\")\n", + "\n", + "# Match the launcher to the MPI library against which mpi4py was built.\n", + "vendor_result = subprocess.run(\n", + " [\n", + " sys.executable,\n", + " \"-c\",\n", + " \"from mpi4py import MPI; print(MPI.get_vendor()[0])\",\n", + " ],\n", + " check=True,\n", + " capture_output=True,\n", + " text=True,\n", + ")\n", + "MPI_VENDOR = vendor_result.stdout.strip()\n", + "\n", + "if MPI_VENDOR == \"MPICH\":\n", + " mpi_launcher = (\n", + " shutil.which(\"mpirun.mpich\")\n", + " or shutil.which(\"mpiexec.mpich\")\n", + " or shutil.which(\"mpiexec\")\n", + " )\n", + " if mpi_launcher is None:\n", + " raise RuntimeError(\"No MPICH launcher was found\")\n", + " # Do not delegate this nested launch back to Slurm on CSCS.\n", + " MPI_LAUNCHER = [mpi_launcher, \"-launcher\", \"fork\"]\n", + "elif MPI_VENDOR == \"Open MPI\":\n", + " mpi_launcher = shutil.which(\"mpirun.openmpi\") or shutil.which(\"mpirun\")\n", + " if mpi_launcher is None:\n", + " raise RuntimeError(\"No Open MPI launcher was found\")\n", + " MPI_LAUNCHER = [mpi_launcher, \"--oversubscribe\"]\n", + "else:\n", + " mpi_launcher = shutil.which(\"mpiexec\")\n", + " if mpi_launcher is None:\n", + " raise RuntimeError(f\"No launcher was found for {MPI_VENDOR}\")\n", + " MPI_LAUNCHER = [mpi_launcher]\n", + "\n", + "\n", + "def run_program(command):\n", + " '''Run a child program, display its output, and fail on errors.'''\n", + " result = subprocess.run(\n", + " command,\n", + " capture_output=True,\n", + " text=True,\n", + " timeout=180,\n", + " )\n", + " print(result.stdout, end=\"\")\n", + " if result.stderr:\n", + " print(result.stderr, end=\"\", file=sys.stderr)\n", + " result.check_returncode()\n", + "\n", + "\n", + "def run_mpi(rank_count, script):\n", + " '''Run a Python script under the selected MPI implementation.'''\n", + " command = [\n", + " *MPI_LAUNCHER,\n", + " \"-n\",\n", + " str(rank_count),\n", + " sys.executable,\n", + " \"-u\",\n", + " script,\n", + " ]\n", + " run_program(command)\n", + "\n", + "\n", + "def run_python(script):\n", + " '''Run a serial Python reference with the notebook's interpreter.'''\n", + " run_program([sys.executable, \"-u\", script])" + ] + }, + { + "cell_type": "markdown", + "id": "mpi-model", + "metadata": {}, + "source": [ + "### 2. One Program, Many Processes\n", + "\n", + "MPI follows the **single program, multiple data** model: every process runs the same script, but each process has a different integer **rank**. The processes belong to a **communicator**; `MPI.COMM_WORLD` contains every process launched for the program.\n", + "\n", + "The notebook kernel itself is a single process, so MPI examples need to be written to Python files and launched with `mpirun`. Passing `4` to the `run_mpi` helper below adds `-n 4` to the launcher and creates four independent Python processes. Each process discovers:\n", + "\n", + "- its own rank with `comm.Get_rank()`;\n", + "- the communicator size with `comm.Get_size()`; and\n", + "- its host name with `MPI.Get_processor_name()`.\n", + "\n", + "Calling `gather` is a **collective operation**: every rank participates, and rank 0 receives one value from each rank. Gathering the values before printing makes the rank order deterministic, even though host names depend on the system running the notebook." + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "id": "mpi-hello-write", + "metadata": { + "execution": { + "iopub.execute_input": "2026-07-21T13:23:58.749846Z", + "iopub.status.busy": "2026-07-21T13:23:58.749655Z", + "iopub.status.idle": "2026-07-21T13:23:58.753243Z", + "shell.execute_reply": "2026-07-21T13:23:58.752548Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Writing hello_mpi.py\n" + ] + } + ], + "source": [ + "%%writefile hello_mpi.py\n", + "\n", + "from mpi4py import MPI\n", + "\n", + "\n", + "comm = MPI.COMM_WORLD\n", + "rank = comm.Get_rank()\n", + "size = comm.Get_size()\n", + "\n", + "message = f\"rank {rank} of {size} on {MPI.Get_processor_name()}\"\n", + "messages = comm.gather(message, root=0)\n", + "\n", + "if rank == 0:\n", + " print(f\"Launched {size} MPI processes:\")\n", + " for message in messages:\n", + " print(f\" {message}\")" + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "id": "mpi-hello-run", + "metadata": { + "execution": { + "iopub.execute_input": "2026-07-21T13:23:58.754770Z", + "iopub.status.busy": "2026-07-21T13:23:58.754603Z", + "iopub.status.idle": "2026-07-21T13:23:59.047302Z", + "shell.execute_reply": "2026-07-21T13:23:59.046593Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Launched 4 MPI processes:\n", + " rank 0 of 4 on brev-9ox5z6vdv\n", + " rank 1 of 4 on brev-9ox5z6vdv\n", + " rank 2 of 4 on brev-9ox5z6vdv\n", + " rank 3 of 4 on brev-9ox5z6vdv\n" + ] + } + ], + "source": [ + "run_mpi(4, \"hello_mpi.py\")" + ] + }, + { + "cell_type": "markdown", + "id": "mpi-simple-intro", + "metadata": {}, + "source": [ + "### 3. Solution 1: Distributed Sum of Squares\n", + "\n", + "Our first collective gathered Python strings. Now let's distribute numerical work. Rank 0 creates the integers 1 through 23 and `scatter` sends one NumPy chunk to each rank. The chunks may have different lengths, which the lowercase object-based `scatter` method handles naturally.\n", + "\n", + "Each rank computes a local sum of squares. A single `reduce` with `op=MPI.SUM` then combines those partial results on rank 0. This is the only communication needed for the calculation." + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "id": "mpi-simple-write", + "metadata": { + "execution": { + "iopub.execute_input": "2026-07-21T13:23:59.049090Z", + "iopub.status.busy": "2026-07-21T13:23:59.048943Z", + "iopub.status.idle": "2026-07-21T13:23:59.052153Z", + "shell.execute_reply": "2026-07-21T13:23:59.051552Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Writing distributed_sum_squares.py\n" + ] + } + ], + "source": [ + "%%writefile distributed_sum_squares.py\n", + "\n", + "import numpy as np\n", + "from mpi4py import MPI\n", + "\n", + "\n", + "comm = MPI.COMM_WORLD\n", + "rank = comm.Get_rank()\n", + "size = comm.Get_size()\n", + "\n", + "values = np.arange(1, 24, dtype=np.int64) if rank == 0 else None\n", + "chunks = np.array_split(values, size) if rank == 0 else None\n", + "local_values = comm.scatter(chunks, root=0)\n", + "\n", + "local_sum_squares = np.dot(local_values, local_values)\n", + "\n", + "# Add every rank's partial result and return the total to rank 0.\n", + "global_sum_squares = comm.reduce(local_sum_squares, op=MPI.SUM, root=0)\n", + "\n", + "if rank == 0:\n", + " expected = np.dot(values, values)\n", + " assert global_sum_squares == expected\n", + " print(f\"sum(i**2 for i in 1..23) = {global_sum_squares}\")" + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "id": "mpi-simple-run", + "metadata": { + "execution": { + "iopub.execute_input": "2026-07-21T13:23:59.053841Z", + "iopub.status.busy": "2026-07-21T13:23:59.053704Z", + "iopub.status.idle": "2026-07-21T13:23:59.959851Z", + "shell.execute_reply": "2026-07-21T13:23:59.959204Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "sum(i**2 for i in 1..23) = 4324\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "sum(i**2 for i in 1..23) = 4324\n" + ] + } + ], + "source": [ + "run_mpi(1, \"distributed_sum_squares.py\")\n", + "run_mpi(4, \"distributed_sum_squares.py\")" + ] + }, + { + "cell_type": "markdown", + "id": "mpi-think", + "metadata": {}, + "source": [ + "#### Think About It\n", + "\n", + "What does `global_sum_squares` contain on ranks other than rank 0? When would `allreduce` be more appropriate than `reduce`?\n", + "\n", + "\n", + "**SOLUTION:** On every non-root rank, `global_sum_squares` is `None`. Only rank 0 needs the total for validation, so `reduce` avoids sending the same answer back to every rank. `allreduce` would be appropriate if subsequent work on every rank required the global total." + ] + }, + { + "cell_type": "markdown", + "id": "mpi-decomposition", + "metadata": {}, + "source": [ + "### 4. Domain Decomposition and Halo Exchange\n", + "\n", + "Collectives move data across an entire communicator. Stencil computations have a more local communication pattern: each grid point depends only on nearby points. We can split a $66 \\times 66$ grid by rows and give each of four ranks 16 of the 64 interior rows:\n", + "\n", + "```text\n", + "global row: 0 | 1 ... 16 | 17 ... 32 | 33 ... 48 | 49 ... 64 | 65\n", + "owner: boundary | rank 0 | rank 1 | rank 2 | rank 3 | boundary\n", + "```\n", + "\n", + "Every rank stores two extra **halo rows** around its owned rows:\n", + "\n", + "```text\n", + " top halo\n", + " +----------------+\n", + " | owned rows |\n", + " +----------------+\n", + " bottom halo\n", + "```\n", + "\n", + "Before each stencil update, adjacent ranks exchange their edge rows. `MPI.PROC_NULL` represents a missing neighbor at a physical boundary: communication with it completes immediately and leaves the receive buffer unchanged. Using `Sendrecv` pairs a send and receive in one operation, avoiding the deadlock risks of separate blocking sends.\n", + "\n", + "For NumPy arrays, mpi4py's uppercase methods such as `Sendrecv` and `Gather` communicate typed buffers directly. The lowercase methods used above can serialize general Python objects; uppercase methods are the natural choice for fixed-shape numerical arrays." + ] + }, + { + "cell_type": "markdown", + "id": "mpi-serial-intro", + "metadata": {}, + "source": [ + "### 5. The Serial Heat Equation Baseline\n", + "\n", + "The two-dimensional heat equation is\n", + "\n", + "$$\n", + "\\frac{\\partial u}{\\partial t}\n", + "= \\kappa \\left(\\frac{\\partial^2 u}{\\partial x^2}\n", + "+ \\frac{\\partial^2 u}{\\partial y^2}\\right).\n", + "$$\n", + "\n", + "For equal grid spacing $h = \\Delta x = \\Delta y$, define the dimensionless diffusion number\n", + "\n", + "$$r = \\frac{\\kappa\\,\\Delta t}{h^2}.$$\n", + "\n", + "A five-point finite-difference stencil then advances one time step:\n", + "\n", + "$$\n", + "u^{n+1}_{i,j} = u^n_{i,j} + r\\left(\n", + "u^n_{i-1,j} + u^n_{i+1,j} + u^n_{i,j-1} + u^n_{i,j+1}\n", + "- 4u^n_{i,j}\\right).\n", + "$$\n", + "\n", + "We use $r=0.2$, below the two-dimensional explicit stability limit $r \\leq 1/4$, and hold the outer boundary at zero. The complete serial implementation below gives us a trusted reference before communication is introduced." + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "id": "mpi-serial-write", + "metadata": { + "execution": { + "iopub.execute_input": "2026-07-21T13:23:59.961945Z", + "iopub.status.busy": "2026-07-21T13:23:59.961794Z", + "iopub.status.idle": "2026-07-21T13:23:59.965867Z", + "shell.execute_reply": "2026-07-21T13:23:59.965259Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Writing heat_reference.py\n" + ] + } + ], + "source": [ + "%%writefile heat_reference.py\n", + "\n", + "import numpy as np\n", + "\n", + "\n", + "NY = 66\n", + "NX = 66\n", + "STEPS = 120\n", + "DIFFUSION_NUMBER = 0.2\n", + "\n", + "\n", + "def initial_temperature_rows(global_rows, ny=NY, nx=NX):\n", + " \"\"\"Create the initial hot disk for selected global rows.\"\"\"\n", + " y = np.asarray(global_rows)[:, np.newaxis]\n", + " x = np.arange(nx)[np.newaxis, :]\n", + " center_y = (ny - 1) / 2\n", + " center_x = (nx - 1) / 2\n", + " hot = (y - center_y) ** 2 + (x - center_x) ** 2 <= 6**2\n", + " temperature = hot.astype(np.float64)\n", + " temperature[:, 0] = 0.0\n", + " temperature[:, -1] = 0.0\n", + " return temperature\n", + "\n", + "\n", + "def initial_temperature(ny=NY, nx=NX):\n", + " \"\"\"Create the full initial field with zero-valued boundaries.\"\"\"\n", + " temperature = np.zeros((ny, nx), dtype=np.float64)\n", + " temperature[1:-1] = initial_temperature_rows(range(1, ny - 1), ny, nx)\n", + " return temperature\n", + "\n", + "\n", + "def advance(temperature, diffusion_number=DIFFUSION_NUMBER):\n", + " \"\"\"Apply one serial five-point stencil update.\"\"\"\n", + " next_temperature = np.zeros_like(temperature)\n", + " center = temperature[1:-1, 1:-1]\n", + " next_temperature[1:-1, 1:-1] = center + diffusion_number * (\n", + " temperature[:-2, 1:-1]\n", + " + temperature[2:, 1:-1]\n", + " + temperature[1:-1, :-2]\n", + " + temperature[1:-1, 2:]\n", + " - 4.0 * center\n", + " )\n", + " return next_temperature\n", + "\n", + "\n", + "def solve_serial(steps=STEPS):\n", + " \"\"\"Evolve the complete grid on one process.\"\"\"\n", + " temperature = initial_temperature()\n", + " for _ in range(steps):\n", + " temperature = advance(temperature)\n", + " return temperature\n", + "\n", + "\n", + "if __name__ == \"__main__\":\n", + " temperature = solve_serial()\n", + " assert np.isfinite(temperature).all()\n", + " assert np.all(temperature >= 0.0)\n", + " assert np.all(temperature <= 1.0)\n", + " assert np.all(temperature[[0, -1], :] == 0.0)\n", + " assert np.all(temperature[:, [0, -1]] == 0.0)\n", + " print(\n", + " f\"Serial reference passed: shape={temperature.shape}, \"\n", + " f\"maximum={temperature.max():.6f}\"\n", + " )" + ] + }, + { + "cell_type": "code", + "execution_count": 7, + "id": "mpi-serial-run", + "metadata": { + "execution": { + "iopub.execute_input": "2026-07-21T13:23:59.967536Z", + "iopub.status.busy": "2026-07-21T13:23:59.967386Z", + "iopub.status.idle": "2026-07-21T13:24:00.087733Z", + "shell.execute_reply": "2026-07-21T13:24:00.087058Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Serial reference passed: shape=(66, 66), maximum=0.308333\n" + ] + } + ], + "source": [ + "run_python(\"heat_reference.py\")" + ] + }, + { + "cell_type": "markdown", + "id": "mpi-advanced-intro", + "metadata": {}, + "source": [ + "### 6. Solution 2: Distributed Heat Equation\n", + "\n", + "The distributed solver retains the serial stencil but stores only one row slab per rank. Two `Sendrecv` calls fill the top and bottom halos before every update. Because all four ranks own the same number of rows, one buffer-based `Gather` reconstructs the field on rank 0.\n", + "\n", + "The communication is deliberately isolated in `exchange_halos`, while `advance_local` contains only the numerical stencil. Keeping those responsibilities separate makes both pieces easier to reason about and verify." + ] + }, + { + "cell_type": "code", + "execution_count": 8, + "id": "mpi-heat-write", + "metadata": { + "execution": { + "iopub.execute_input": "2026-07-21T13:24:00.089877Z", + "iopub.status.busy": "2026-07-21T13:24:00.089711Z", + "iopub.status.idle": "2026-07-21T13:24:00.094249Z", + "shell.execute_reply": "2026-07-21T13:24:00.093546Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Writing heat_mpi.py\n" + ] + } + ], + "source": [ + "%%writefile heat_mpi.py\n", + "\n", + "import numpy as np\n", + "from mpi4py import MPI\n", + "\n", + "from heat_reference import (\n", + " DIFFUSION_NUMBER,\n", + " NX,\n", + " NY,\n", + " STEPS,\n", + " initial_temperature,\n", + " initial_temperature_rows,\n", + " solve_serial,\n", + ")\n", + "\n", + "\n", + "def decompose_rows(comm):\n", + " \"\"\"Return the local row count and first owned global row.\"\"\"\n", + " interior_rows = NY - 2\n", + " if interior_rows % comm.Get_size() != 0:\n", + " raise ValueError(\"The interior row count must be divisible by the rank count\")\n", + " local_rows = interior_rows // comm.Get_size()\n", + " first_global_row = 1 + comm.Get_rank() * local_rows\n", + " return local_rows, first_global_row\n", + "\n", + "\n", + "def exchange_halos(temperature, comm):\n", + " \"\"\"Exchange edge rows with the ranks above and below.\"\"\"\n", + " rank = comm.Get_rank()\n", + " above = rank - 1 if rank > 0 else MPI.PROC_NULL\n", + " below = rank + 1 if rank + 1 < comm.Get_size() else MPI.PROC_NULL\n", + "\n", + " # Send the first owned row up and receive the bottom halo from below.\n", + " comm.Sendrecv(\n", + " sendbuf=temperature[1],\n", + " dest=above,\n", + " sendtag=0,\n", + " recvbuf=temperature[-1],\n", + " source=below,\n", + " recvtag=0,\n", + " )\n", + "\n", + " # Send the last owned row down and receive the top halo from above.\n", + " comm.Sendrecv(\n", + " sendbuf=temperature[-2],\n", + " dest=below,\n", + " sendtag=1,\n", + " recvbuf=temperature[0],\n", + " source=above,\n", + " recvtag=1,\n", + " )\n", + "\n", + "\n", + "def advance_local(temperature):\n", + " \"\"\"Apply one five-point update to the locally owned rows.\"\"\"\n", + " next_temperature = np.zeros_like(temperature)\n", + " center = temperature[1:-1, 1:-1]\n", + " next_temperature[1:-1, 1:-1] = center + DIFFUSION_NUMBER * (\n", + " temperature[:-2, 1:-1]\n", + " + temperature[2:, 1:-1]\n", + " + temperature[1:-1, :-2]\n", + " + temperature[1:-1, 2:]\n", + " - 4.0 * center\n", + " )\n", + " return next_temperature\n", + "\n", + "\n", + "def gather_field(local_temperature, local_rows, comm):\n", + " \"\"\"Gather equal-sized row slabs and restore the physical boundaries.\"\"\"\n", + " owned_rows = np.ascontiguousarray(local_temperature[1:-1])\n", + " gathered = None\n", + " if comm.Get_rank() == 0:\n", + " gathered = np.empty((comm.Get_size(), local_rows, NX), dtype=np.float64)\n", + "\n", + " comm.Gather(owned_rows, gathered, root=0)\n", + "\n", + " if comm.Get_rank() != 0:\n", + " return None\n", + "\n", + " temperature = np.zeros((NY, NX), dtype=np.float64)\n", + " temperature[1:-1] = gathered.reshape(NY - 2, NX)\n", + " return temperature\n", + "\n", + "\n", + "def main():\n", + " comm = MPI.COMM_WORLD\n", + " local_rows, first_global_row = decompose_rows(comm)\n", + " global_rows = np.arange(first_global_row, first_global_row + local_rows)\n", + "\n", + " temperature = np.zeros((local_rows + 2, NX), dtype=np.float64)\n", + " temperature[1:-1] = initial_temperature_rows(global_rows)\n", + "\n", + " for _ in range(STEPS):\n", + " exchange_halos(temperature, comm)\n", + " temperature = advance_local(temperature)\n", + "\n", + " distributed = gather_field(temperature, local_rows, comm)\n", + "\n", + " if comm.Get_rank() == 0:\n", + " reference = solve_serial()\n", + " np.testing.assert_allclose(distributed, reference, rtol=1e-13, atol=1e-13)\n", + " assert np.isfinite(distributed).all()\n", + " assert np.all(distributed[[0, -1], :] == 0.0)\n", + " assert np.all(distributed[:, [0, -1]] == 0.0)\n", + " np.savez(\n", + " \"heat_equation_result.npz\",\n", + " initial=initial_temperature(),\n", + " final=distributed,\n", + " steps=STEPS,\n", + " )\n", + " print(\n", + " \"Distributed heat equation matches the serial reference: \"\n", + " f\"shape={distributed.shape}, maximum={distributed.max():.6f}\"\n", + " )\n", + "\n", + "\n", + "if __name__ == \"__main__\":\n", + " main()" + ] + }, + { + "cell_type": "markdown", + "id": "mpi-verify-intro", + "metadata": {}, + "source": [ + "### 7. Verification and Visualization\n", + "\n", + "Correctness comes before interpretation. The MPI program gathers the distributed row slabs on rank 0, compares every value with `solve_serial()` using `numpy.testing.assert_allclose`, checks the fixed boundaries, and only then saves the initial and final fields for visualization.\n", + "\n", + "Let's run the distributed solution with the four ranks used by our decomposition." + ] + }, + { + "cell_type": "code", + "execution_count": 9, + "id": "mpi-heat-run", + "metadata": { + "execution": { + "iopub.execute_input": "2026-07-21T13:24:00.095879Z", + "iopub.status.busy": "2026-07-21T13:24:00.095737Z", + "iopub.status.idle": "2026-07-21T13:24:00.619325Z", + "shell.execute_reply": "2026-07-21T13:24:00.618472Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Distributed heat equation matches the serial reference: shape=(66, 66), maximum=0.308333\n" + ] + } + ], + "source": [ + "result_path = Path(\"heat_equation_result.npz\")\n", + "result_path.unlink(missing_ok=True)\n", + "run_mpi(4, \"heat_mpi.py\")\n", + "assert result_path.exists()" + ] + }, + { + "cell_type": "code", + "execution_count": 10, + "id": "mpi-plot", + "metadata": { + "execution": { + "iopub.execute_input": "2026-07-21T13:24:00.621052Z", + "iopub.status.busy": "2026-07-21T13:24:00.620859Z", + "iopub.status.idle": "2026-07-21T13:24:01.841750Z", + "shell.execute_reply": "2026-07-21T13:24:01.840936Z" + } + }, + "outputs": [ + { + "data": { + "image/png": "iVBORw0KGgoAAAANSUhEUgAAA7QAAAGbCAYAAAD0otxkAAAAOnRFWHRTb2Z0d2FyZQBNYXRwbG90bGliIHZlcnNpb24zLjEwLjksIGh0dHBzOi8vbWF0cGxvdGxpYi5vcmcvJkbTWQAAAAlwSFlzAAAPYQAAD2EBqD+naQAAYbxJREFUeJzt3Xl8VNX5x/HvTcgChIRFSNgERBQVEA0aIygukdSFSlFBSwVxt4BKfrRCWxY3UFsRWxCUVrEWi6LFWhcUI6AVBEGoWBRRUVBJAJUEgmSZOb8/aEaGe4BZQuZO8nn7ui/JmbucMzOZM0/OPc9xjDFGAAAAAADEmYRYVwAAAAAAgEgQ0AIAAAAA4hIBLQAAAAAgLhHQAgAAAADiEgEtAAAAACAuEdACAAAAAOISAS0AAAAAIC4R0AIAAAAA4hIBLQAAAAAgLhHQImYcx9GkSZNC2rdjx4665pprwr7GF198IcdxNGfOnLCPBQCgtj311FPq2rWrkpKS1LRp01hXBwA8j4AWEZszZ44cx9GqVatq5HzLli3TpEmTtHPnzho5XzjWr1+vSZMm6Ysvvqj1a3vdK6+8EvIfHgAAB/fII4/IcRzl5ORYH//44491zTXXqHPnzpo9e7Yee+wx7dmzR5MmTdKSJUtqta4zZ87UFVdcoaOPPlqO4xz0j8qFhYW69tprddxxx6lRo0Y65phjdP3112vr1q3W/ZctW6Y+ffqoUaNGysrK0q233qrdu3fXSJ1j+T0CQOw0iHUFUH/98MMPatDgx7fgsmXLdOedd+qaa65x/VV6w4YNSkg4cn9/Wb9+ve68806dc8456tix4xG7Tjx65ZVXNGPGDIJaAIjS3Llz1bFjR61cuVKffvqpjj322KDHlyxZIr/fr4cffjjw2I4dO3TnnXdKks4555xaq+v999+vXbt26fTTTz9ocCpJd9xxh7777jtdccUV6tKliz7//HNNnz5dL730ktauXausrKzAvmvXrtX555+vE044QVOnTtVXX32lP/zhD9q4caNeffXVqOt8qO8RAOouAlrETGpqasj7pqSkHMGa1C9lZWVq3LhxrKvhmXoAQG3YtGmTli1bpn/84x+66aabNHfuXE2cODFon23btklSrQRjh/sMXrp0aWB0Ni0t7aD7TZ06VX369An6o/NPfvIT9e3bV9OnT9c999wTKP/Nb36jZs2aacmSJUpPT5e0b0rRDTfcoNdff139+vWrgZYBqG+45Rg16pprrlFaWpq+/vprDRgwQGlpaWrZsqXGjBkjn88XtO/+c2gnTZqkX/3qV5KkTp06yXEcOY4TuAX4wDm03333ncaMGaPu3bsrLS1N6enpuvDCC/Wf//wn7DrPmTNHV1xxhSTp3HPPDVx7/9u7Xn31VZ111llq3LixmjRpoosvvlj//e9/rW3fvHmzLrnkEqWlpalt27aaMWOGJGndunU677zz1LhxY3Xo0EFPP/20qx6O4+itt97STTfdpBYtWig9PV1Dhw7V999/76p3OHX67LPPdNFFF6lJkyYaMmSIJOntt98O3E6WkpKi9u3ba/To0frhhx+Cjq+uf/Xz4jiOpH0jCQc+T5J93vKh6uH3+zVt2jSddNJJSk1NVWZmpm666SZrmwEgXs2dO1fNmjXTxRdfrMsvv1xz584Nerxjx46BALdly5aB23xbtmwpSbrzzjsDn8H73zHz8ccf6/LLL1fz5s2VmpqqXr166cUXXww6d3X/snTpUv3yl79Uq1at1K5du0PWt0OHDoHP+0M5++yzXXdQnX322WrevLk++uijQFlpaakWLVqkX/ziF4FgVpKGDh2qtLQ0Pfvss4e91p/+9CeddNJJatSokZo1a6ZevXoF+tLDfY+QpL/97W/Kzs5Ww4YN1bx5c1155ZXasmVL0DXOOeccdevWTatXr9aZZ56phg0bqlOnTpo1a1ZY9QFQexihRY3z+XzKz89XTk6O/vCHP+iNN97Qgw8+qM6dO+uWW26xHjNw4EB98skn+vvf/66HHnpIRx11lCQFOvIDff7553rhhRd0xRVXqFOnTiouLtajjz6qvn37av369WrTpk3I9T377LN166236o9//KN+85vf6IQTTpCkwP+feuopDRs2TPn5+br//vu1Z88ezZw5U3369NGaNWuCblH2+Xy68MILdfbZZ+uBBx7Q3LlzNXLkSDVu3Fi//e1vNWTIEA0cOFCzZs3S0KFDlZubq06dOgXVZ+TIkWratKkmTZqkDRs2aObMmfryyy8DAWS4daqqqlJ+fr769OmjP/zhD2rUqJEkaf78+dqzZ49uueUWtWjRQitXrtSf/vQnffXVV5o/f74k6aabbtI333yjRYsW6amnngr5ObU5WD1uuukmzZkzR8OHD9ett96qTZs2afr06VqzZo3eeecdJSUlRXVdAPCCuXPnauDAgUpOTtZVV12lmTNn6r333tNpp50mSZo2bZr++te/asGCBZo5c6bS0tLUvXt3nXHGGbrlllv0s5/9TAMHDpQk9ejRQ5L03//+V71791bbtm01duxYNW7cWM8++6wGDBig559/Xj/72c+C6vDLX/5SLVu21IQJE1RWVnbE2rp7927t3r070JdL+/6oW1VVpV69egXtm5ycrJ49e2rNmjWHPOfs2bN166236vLLL9dtt92mvXv36oMPPtCKFSv085///LDfI+69916NHz9egwYN0vXXX6/t27frT3/6k84++2ytWbMmaFT8+++/10UXXaRBgwbpqquu0rPPPqtbbrlFycnJuvbaa0OqD4BaZIAIPfHEE0aSee+99wJlw4YNM5LMXXfdFbTvKaecYrKzs4PKJJmJEycGfv79739vJJlNmza5rtWhQwczbNiwwM979+41Pp8vaJ9NmzaZlJSUoGtv2rTJSDJPPPHEIdsyf/58I8ksXrw4qHzXrl2madOm5oYbbggqLyoqMhkZGUHl1W2fPHlyoOz77783DRs2NI7jmHnz5gXKP/74Y1f7q5/P7OxsU1FRESh/4IEHjCTzz3/+M+I6jR071tXmPXv2uMqmTJliHMcxX375ZaBsxIgRxvZRsXjxYutzZnvOD1aPt99+20gyc+fODSpfuHChtRwA4tGqVauMJLNo0SJjjDF+v9+0a9fO3HbbbUH7TZw40Ugy27dvD5Rt377d1V9UO//880337t3N3r17A2V+v9+ceeaZpkuXLoGy6v6lT58+pqqqKuz6N27cOKgPPpy7777bSDKFhYWBsup+9q233nLtf8UVV5isrKxDnvPSSy81J5100iH3Odj3iC+++MIkJiaae++9N6h83bp1pkGDBkHlffv2NZLMgw8+GCgrLy83PXv2NK1atQr0z6HUB0Dt4JZjHBE333xz0M9nnXWWPv/88xo7f0pKSuAWJ5/Pp2+//VZpaWk6/vjj9f7779fYdRYtWqSdO3fqqquu0o4dOwJbYmKicnJytHjxYtcx119/feDfTZs21fHHH6/GjRtr0KBBgfLjjz9eTZs2tT4nN954Y9Co5C233KIGDRrolVdeibhOtpHxhg0bBv5dVlamHTt26Mwzz5Qx5rB/KY/UgfWYP3++MjIy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fLTBZv+sj8bERzv+7//+zyxZssRs2rTJvPPOOyYvL88cddRRZtu2bcaY+GjDypUrTYMGDcy9995rNm7caObOnWsaNWpk/va3vwX28frvNryN/i426kKfR3/nHfR33vndRnyoFwGtMcb86U9/MkcffbRJTk42p59+unn33XdjXaVDWrx4sZHk2oYNG2aM2ZfufPz48SYzM9OkpKSY888/32zYsCG2ld6Pre6SzBNPPBHY54cffjC//OUvTbNmzUyjRo3Mz372M7N169bYVdri2muvNR06dDDJycmmZcuW5vzzzw907sbERxtsDuzg46EdgwcPNq1btzbJycmmbdu2ZvDgwebTTz8NPB4PbTDGmH/961+mW7duJiUlxXTt2tU89thjQY97/Xcb3kd/V/vqQp9Hf+cd9HdAeBxjjKm98WAAAAAAAGpGnZ9DCwAAAAComwhoAQAAAABxiYAWAAAAABCXCGgBAAAAAHGJgBYAAAAAEJcIaAEAAAAAcYmAFgAAAAAQlwhoAQAAAABxiYAWAAAAABCXCGgBAAAAAHGJgBYAAAAAEJcIaIEjaPv27crKytLkyZMDZcuWLVNycrIKCwtjWDMAAGoWfR6AWHCMMSbWlQDqsldeeUUDBgzQsmXLdPzxx6tnz5669NJLNXXq1FhXDQCAGkWfB6C2EdACtWDEiBF644031KtXL61bt07vvfeeUlJSYl0tAABqHH0egNpEQAvUgh9++EHdunXTli1btHr1anXv3j3WVQIA4IigzwNQm5hDC9SCzz77TN988438fr+++OKLWFcHAIAjhj4PQG1ihBY4wioqKnT66aerZ8+eOv744zVt2jStW7dOrVq1inXVAACoUfR5AGobAS1whP3qV7/Sc889p//85z9KS0tT3759lZGRoZdeeinWVQMAoEbR5wGobdxyDBxBS5Ys0bRp0/TUU08pPT1dCQkJeuqpp/T2229r5syZsa4eAAA1hj4PQCwwQgsAAAAAiEuM0AIAAAAA4hIBLQAAAAAgLhHQAgAAAADiEgEtAAAAACAuEdACAAAAAOISAS0AAAAAIC4R0AIAAAAA4hIBLQAAAAAgLhHQAgAAAADiEgEtAAAAACAuEdACAAAAAOLS/wPycjEq9aFUpgAAAABJRU5ErkJggg==", + "text/plain": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "import matplotlib.pyplot as plt\n", + "import numpy as np\n", + "\n", + "with np.load(\"heat_equation_result.npz\") as result:\n", + " initial = result[\"initial\"]\n", + " final = result[\"final\"]\n", + " steps = int(result[\"steps\"])\n", + "\n", + "fig, axes = plt.subplots(1, 2, figsize=(10, 4), constrained_layout=True)\n", + "for axis, field, title in zip(\n", + " axes,\n", + " (initial, final),\n", + " (\"Initial temperature\", f\"After {steps} steps\"),\n", + "):\n", + " image = axis.imshow(field, origin=\"lower\", cmap=\"inferno\", vmin=0.0, vmax=1.0)\n", + " axis.set_title(title)\n", + " axis.set_xlabel(\"x\")\n", + " axis.set_ylabel(\"y\")\n", + "\n", + "fig.colorbar(image, ax=axes, label=\"temperature\")\n", + "plt.show()" + ] + }, + { + "cell_type": "markdown", + "id": "mpi-takeaways", + "metadata": {}, + "source": [ + "### 8. Key Takeaways\n", + "\n", + "- Every MPI rank runs the same program with private memory and a distinct rank.\n", + "- Collectives such as `scatter`, `reduce`, and `Gather` coordinate all ranks in a communicator.\n", + "- A row-wise decomposition turns a global stencil into local NumPy work plus two neighbor exchanges.\n", + "- Halo rows make the local stencil expression match the serial expression.\n", + "- `Sendrecv` and `MPI.PROC_NULL` express boundary-safe neighbor communication without extra synchronization.\n", + "- Comparing against a small trusted serial implementation catches communication and indexing mistakes.\n", + "\n", + "The same decomposition pattern extends to larger grids, uneven partitions with `Gatherv`, and higher-dimensional process topologies." + ] + } + ], + "metadata": { + "accelerator": "GPU", + "colab": { + "gpuType": "T4", + "provenance": [], + "toc_visible": true + }, + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.11.7" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/tutorials/pyhpc/notebooks/syllabi/pyhpc__cupy_kernels_mpi_jax_omp_interop__2_days.ipynb b/tutorials/pyhpc/notebooks/syllabi/pyhpc__cupy_kernels_mpi_jax_omp_interop__2_days.ipynb index 0c500355..797c7e99 100644 --- a/tutorials/pyhpc/notebooks/syllabi/pyhpc__cupy_kernels_mpi_jax_omp_interop__2_days.ipynb +++ b/tutorials/pyhpc/notebooks/syllabi/pyhpc__cupy_kernels_mpi_jax_omp_interop__2_days.ipynb @@ -17,7 +17,7 @@ "| 03 | [Power Iteration - CuPy - Asynchrony](../03__power_iteration__cupy__asynchrony.ipynb) | [Click](../solutions/03__power_iteration__cupy__asynchrony__SOLUTION.ipynb) | CuPy, Nsight Systems |\n", "| 04 | [Copy - Kernel Authoring](../04__copy__kernel_authoring.ipynb) | [Click](../solutions/04__copy__kernel_authoring__SOLUTION.ipynb) | Numba CUDA, Nsight Compute |\n", "| 05 | [Book Histogram - Kernel Authoring](../05__book_histogram__kernel_authoring.ipynb) | [Click](../solutions/05__book_histogram__kernel_authoring__SOLUTION.ipynb) | Numba CUDA, Nsight Compute |\n", - "| 06 | [mpi4py](../06__mpi4py.ipynb) | - | mpi4py |\n", + "| 06 | [mpi4py](../06__mpi4py.ipynb) | [Click](../solutions/06__mpi4py__SOLUTION.ipynb) | mpi4py |\n", "| 07 | [SWE - Intro](../07__swe__intro.ipynb) | - | NumPy |\n", "| 08 | [SWE - JAX](../08__swe__jax.ipynb) | [Click](../solutions/08__swe__jax__SOLUTION.ipynb) | JAX |\n", "| 09 | [SWE - PyOMP](../09__swe__pyomp.ipynb) | [Click](../solutions/09__swe__pyomp__SOLUTION.ipynb) | PyOMP |\n", diff --git a/tutorials/pyhpc/test/test_notebooks.py b/tutorials/pyhpc/test/test_notebooks.py index d7e8b688..7331ce61 100644 --- a/tutorials/pyhpc/test/test_notebooks.py +++ b/tutorials/pyhpc/test/test_notebooks.py @@ -3,9 +3,8 @@ The notebooks run as an ordered ladder (00 to 13). For each rung we prefer the filled-in solution notebook when one exists, and otherwise fall back to -the exercise notebook (the intro/reference notebook 07, the synthesis -notebook 12, and the mpi4py walkthrough 06 have no separate solution and are -complete as written). +the exercise notebook (the intro/reference notebook 07 and synthesis notebook +12 have no separate solution and are complete as written). Ordering matters for the SWE sub-ladder: notebooks 07 to 11 each append a row to timings.json and 12 reads them, so 07-11 must run before 12. pytest