|
| 1 | +#!/usr/bin/env python3 |
| 2 | +# SPDX-License-Identifier: MIT |
| 3 | + |
| 4 | +""" |
| 5 | +Multi-GPU Jacobi example for Iris. |
| 6 | +
|
| 7 | +Each GPU owns a strip of rows. |
| 8 | +Halo rows carry the neighbor edge values between GPUs. |
| 9 | +""" |
| 10 | + |
| 11 | +import argparse |
| 12 | +import os |
| 13 | + |
| 14 | +import torch |
| 15 | +import torch.distributed as dist |
| 16 | +import triton |
| 17 | +import triton.language as tl |
| 18 | + |
| 19 | +import iris |
| 20 | +from iris import DeviceContext |
| 21 | +from iris.ccl import Config |
| 22 | + |
| 23 | + |
| 24 | +# This kernel does the actual Jacobi update on one rank. |
| 25 | +# Halo rows make the same four-neighbor math work at GPU boundaries. |
| 26 | +@triton.jit |
| 27 | +def jacobi_kernel(nxt, cur, err, nx: tl.constexpr, mine: tl.constexpr, BX: tl.constexpr, BY: tl.constexpr): |
| 28 | + px = tl.program_id(0) |
| 29 | + py = tl.program_id(1) |
| 30 | + x = (1 + px * BX + tl.arange(0, BX))[None, :] |
| 31 | + y = (1 + py * BY + tl.arange(0, BY))[:, None] |
| 32 | + ok = (x < nx - 1) & (y <= mine) |
| 33 | + pos = y * nx + x |
| 34 | + |
| 35 | + mid = tl.load(cur + pos, mask=ok, other=0.0) |
| 36 | + r = tl.load(cur + pos + 1, mask=ok, other=0.0) |
| 37 | + l = tl.load(cur + pos - 1, mask=ok, other=0.0) |
| 38 | + d = tl.load(cur + pos + nx, mask=ok, other=0.0) |
| 39 | + u = tl.load(cur + pos - nx, mask=ok, other=0.0) |
| 40 | + |
| 41 | + val = 0.25 * (r + l + d + u) |
| 42 | + tl.store(nxt + pos, val, mask=ok) |
| 43 | + |
| 44 | + diff = (val - mid) * (val - mid) |
| 45 | + sm = tl.sum(tl.sum(diff, axis=1), axis=0) |
| 46 | + tl.atomic_add(err, sm) |
| 47 | + |
| 48 | + |
| 49 | +# This kernel pushes edge rows straight into neighbor halo memory. |
| 50 | +# The upper destination depends on how many rows that rank owns. |
| 51 | +@triton.jit |
| 52 | +def halo_kernel( |
| 53 | + dev_ctx, |
| 54 | + buf, |
| 55 | + nx: tl.constexpr, |
| 56 | + mine: tl.constexpr, |
| 57 | + up: tl.constexpr, |
| 58 | + dn: tl.constexpr, |
| 59 | + up_n: tl.constexpr, |
| 60 | + rank: tl.constexpr, |
| 61 | + nranks: tl.constexpr, |
| 62 | + BS: tl.constexpr, |
| 63 | +): |
| 64 | + ctx = DeviceContext.initialize(dev_ctx, rank, nranks) |
| 65 | + x = 1 + tl.program_id(0) * BS + tl.arange(0, BS) |
| 66 | + ok = x < nx - 1 |
| 67 | + |
| 68 | + if up >= 0: |
| 69 | + vals = tl.load(buf + nx + x, mask=ok, other=0.0) |
| 70 | + dst = (up_n + 1) * nx + x |
| 71 | + ctx.store(buf + dst, vals, to_rank=up, mask=ok) |
| 72 | + |
| 73 | + if dn >= 0: |
| 74 | + vals = tl.load(buf + mine * nx + x, mask=ok, other=0.0) |
| 75 | + ctx.store(buf + x, vals, to_rank=dn, mask=ok) |
| 76 | + |
| 77 | + |
| 78 | +# Split only the interior rows. |
| 79 | +# Early ranks get one extra row when the split is uneven. |
| 80 | +def split_rows(ny, rank, nranks): |
| 81 | + inside = ny - 2 |
| 82 | + if inside < 1: |
| 83 | + raise ValueError("ny must contain at least one interior row") |
| 84 | + if nranks < 1 or not 0 <= rank < nranks: |
| 85 | + raise ValueError("invalid rank setup") |
| 86 | + if nranks > inside: |
| 87 | + raise ValueError("nranks cannot exceed the number of interior rows") |
| 88 | + |
| 89 | + base, extra = divmod(inside, nranks) |
| 90 | + mine = base + int(rank < extra) |
| 91 | + first = 1 + rank * base + min(rank, extra) |
| 92 | + return first, first + mine - 1, mine |
| 93 | + |
| 94 | + |
| 95 | +# Launch the remote halo writes then wait before another stencil step starts. |
| 96 | +# Without this barrier a rank could read stale neighbor data. |
| 97 | +def push_halos(ctx, dev_ctx, buf, nx, mine, up, dn, up_n, rank, nranks): |
| 98 | + bs = 256 |
| 99 | + halo_kernel[(triton.cdiv(nx - 2, bs),)](dev_ctx, buf, nx, mine, up, dn, up_n, rank, nranks, BS=bs, num_warps=4) |
| 100 | + torch.cuda.synchronize() |
| 101 | + ctx.barrier() |
| 102 | + |
| 103 | + |
| 104 | +# One distributed iteration is local compute then halo exchange then global error. |
| 105 | +# Keeping those three pieces together makes the main loop much smaller. |
| 106 | +def do_step(ctx, dev_ctx, cur, nxt, err, all_err, nx, mine, up, dn, up_n, rank, nranks): |
| 107 | + err.zero_() |
| 108 | + all_err.zero_() |
| 109 | + |
| 110 | + bx, by = 64, 8 |
| 111 | + grid = (triton.cdiv(nx - 2, bx), triton.cdiv(mine, by)) |
| 112 | + jacobi_kernel[grid](nxt, cur, err, nx, mine, BX=bx, BY=by, num_warps=4, num_stages=2) |
| 113 | + |
| 114 | + push_halos(ctx, dev_ctx, nxt, nx, mine, up, dn, up_n, rank, nranks) |
| 115 | + |
| 116 | + # Every rank needs the same residual so they all stop on the same iteration. |
| 117 | + ctx.ccl.all_reduce(all_err, err) |
| 118 | + torch.cuda.synchronize() |
| 119 | + return torch.sqrt(all_err).item() |
| 120 | + |
| 121 | + |
| 122 | +# Validation needs one normal grid instead of separate padded slabs. |
| 123 | +# All gather returns every rank slab so we can rebuild that grid on each rank. |
| 124 | +def gather_grid(ctx, cur, nx, ny, mine, max_n, nranks): |
| 125 | + send = ctx.zeros((max_n, nx), dtype=torch.float32) |
| 126 | + send[:mine].copy_(cur[1 : mine + 1]) |
| 127 | + got = ctx.zeros((nranks * max_n, nx), dtype=torch.float32) |
| 128 | + |
| 129 | + cfg = Config( |
| 130 | + block_size_m=32, |
| 131 | + block_size_n=64, |
| 132 | + comm_sms=64, |
| 133 | + num_stages=1, |
| 134 | + num_warps=4, |
| 135 | + waves_per_eu=0, |
| 136 | + use_gluon=False, |
| 137 | + ) |
| 138 | + ctx.barrier() |
| 139 | + ctx.ccl.all_gather(got, send, config=cfg) |
| 140 | + torch.cuda.synchronize() |
| 141 | + |
| 142 | + full = torch.zeros((ny, nx), dtype=torch.float32, device=cur.device) |
| 143 | + full[:, 0] = 100.0 |
| 144 | + full[:, -1] = 0.0 |
| 145 | + full[0, :] = 50.0 |
| 146 | + full[-1, :] = 0.0 |
| 147 | + |
| 148 | + for src in range(nranks): |
| 149 | + first, last, n = split_rows(ny, src, nranks) |
| 150 | + start = src * max_n |
| 151 | + full[first : last + 1].copy_(got[start : start + n]) |
| 152 | + return full |
| 153 | + |
| 154 | + |
| 155 | +# This is an independent single-GPU answer used only for validation. |
| 156 | +# It helps catch a bad split or halo exchange without depending on Iris RMA. |
| 157 | +def ref_jacobi(nx, ny, nit, dev): |
| 158 | + cur = torch.zeros((ny, nx), dtype=torch.float32, device=dev) |
| 159 | + cur[:, 0] = 100.0 |
| 160 | + cur[:, -1] = 0.0 |
| 161 | + cur[0, :] = 50.0 |
| 162 | + cur[-1, :] = 0.0 |
| 163 | + nxt = cur.clone() |
| 164 | + |
| 165 | + for _ in range(nit): |
| 166 | + nxt[1:-1, 1:-1] = 0.25 * (cur[1:-1, 2:] + cur[1:-1, :-2] + cur[2:, 1:-1] + cur[:-2, 1:-1]) |
| 167 | + cur, nxt = nxt, cur |
| 168 | + return cur |
| 169 | + |
| 170 | + |
| 171 | +# main sets up the rank layout then keeps calling do_step. |
| 172 | +# Validation is optional since gathering the whole grid is not part of the solver. |
| 173 | +def main(): |
| 174 | + p = argparse.ArgumentParser(description="Multi-GPU 2D Jacobi iteration with Iris") |
| 175 | + p.add_argument("--nx", type=int, default=512) |
| 176 | + p.add_argument("--ny", type=int, default=512) |
| 177 | + p.add_argument("--max_iterations", type=int, default=1000) |
| 178 | + p.add_argument("--tolerance", type=float, default=1e-6) |
| 179 | + p.add_argument("--heap_size", type=int, default=1 << 30) |
| 180 | + p.add_argument("-v", "--validate", action="store_true") |
| 181 | + a = p.parse_args() |
| 182 | + |
| 183 | + local = int(os.environ.get("LOCAL_RANK", 0)) |
| 184 | + torch.cuda.set_device(local) |
| 185 | + dist.init_process_group(backend="gloo") |
| 186 | + |
| 187 | + try: |
| 188 | + ctx = iris.iris(heap_size=a.heap_size) |
| 189 | + rank = ctx.get_rank() |
| 190 | + nranks = ctx.get_num_ranks() |
| 191 | + |
| 192 | + if a.nx < 3 or a.max_iterations < 1 or a.tolerance <= 0: |
| 193 | + raise ValueError("invalid Jacobi arguments") |
| 194 | + |
| 195 | + first, last, mine = split_rows(a.ny, rank, nranks) |
| 196 | + max_n = (a.ny - 2 + nranks - 1) // nranks |
| 197 | + buf_n = max_n + 2 |
| 198 | + up = rank - 1 if rank > 0 else -1 |
| 199 | + dn = rank + 1 if rank < nranks - 1 else -1 |
| 200 | + up_n = split_rows(a.ny, up, nranks)[2] if up >= 0 else 0 |
| 201 | + |
| 202 | + # Two extra rows hold the top and bottom halo. |
| 203 | + # Physical borders keep the values from issue 117. |
| 204 | + cur = ctx.zeros((buf_n, a.nx), dtype=torch.float32) |
| 205 | + cur[:, 0] = 100.0 |
| 206 | + cur[:, -1] = 0.0 |
| 207 | + if rank == 0: |
| 208 | + cur[0] = 50.0 |
| 209 | + if rank == nranks - 1: |
| 210 | + cur[mine + 1] = 0.0 |
| 211 | + nxt = ctx.zeros((buf_n, a.nx), dtype=torch.float32) |
| 212 | + nxt.copy_(cur) |
| 213 | + |
| 214 | + dev_ctx = ctx.get_device_context() |
| 215 | + err = ctx.zeros((1, 1), dtype=torch.float32) |
| 216 | + all_err = ctx.zeros((1, 1), dtype=torch.float32) |
| 217 | + ctx.info( |
| 218 | + f"rank={rank}/{nranks}: rows={first}..{last} owned={mine} storage={tuple(cur.shape)} neighbors=({up}, {dn})" |
| 219 | + ) |
| 220 | + |
| 221 | + # Fill halos once before the first stencil read. |
| 222 | + push_halos(ctx, dev_ctx, cur, a.nx, mine, up, dn, up_n, rank, nranks) |
| 223 | + |
| 224 | + l2 = float("inf") |
| 225 | + done = 0 |
| 226 | + for i in range(a.max_iterations): |
| 227 | + l2 = do_step(ctx, dev_ctx, cur, nxt, err, all_err, a.nx, mine, up, dn, up_n, rank, nranks) |
| 228 | + cur, nxt = nxt, cur |
| 229 | + done = i + 1 |
| 230 | + if rank == 0 and done % 100 == 0: |
| 231 | + ctx.info(f"Iteration {done}: L2 norm = {l2:.6e}") |
| 232 | + if l2 < a.tolerance: |
| 233 | + break |
| 234 | + |
| 235 | + if rank == 0: |
| 236 | + msg = "Converged" if l2 < a.tolerance else "Stopped" |
| 237 | + ctx.info(f"{msg} after {done} iterations: L2 norm = {l2:.6e}") |
| 238 | + |
| 239 | + if a.validate: |
| 240 | + full = gather_grid(ctx, cur, a.nx, a.ny, mine, max_n, nranks) |
| 241 | + ref = ref_jacobi(a.nx, a.ny, done, full.device) |
| 242 | + mx = (full - ref).abs().max().item() |
| 243 | + ok = torch.allclose(full, ref, atol=1e-3, rtol=1e-4) |
| 244 | + if rank == 0: |
| 245 | + ctx.info(f"Validation {'passed' if ok else 'failed'}: max absolute error = {mx:.6e}") |
| 246 | + if not ok: |
| 247 | + raise AssertionError(f"Jacobi result does not match reference: max absolute error = {mx:.6e}") |
| 248 | + |
| 249 | + ctx.barrier() |
| 250 | + finally: |
| 251 | + if dist.is_initialized(): |
| 252 | + dist.destroy_process_group() |
| 253 | + |
| 254 | + |
| 255 | +if __name__ == "__main__": |
| 256 | + main() |
0 commit comments