Skip to content

Commit e41c151

Browse files
committed
examples: add multi-GPU Jacobi iteration
1 parent 25285da commit e41c151

2 files changed

Lines changed: 342 additions & 0 deletions

File tree

‎examples/33_jacobi/example.py‎

Lines changed: 256 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,256 @@
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()

‎tests/examples/test_jacobi.py‎

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
#!/usr/bin/env python3
2+
# SPDX-License-Identifier: MIT
3+
4+
import gc
5+
import importlib.util
6+
from pathlib import Path
7+
8+
import pytest
9+
import torch
10+
import torch.distributed as dist
11+
12+
import iris
13+
14+
15+
# Load the real example file so this test follows the same code CI will run.
16+
root = Path(__file__).resolve()
17+
while not (root / "tests").is_dir() or not (root / "examples").is_dir():
18+
if root == root.parent:
19+
raise FileNotFoundError("Could not find project root")
20+
root = root.parent
21+
22+
path = root / "examples" / "33_jacobi" / "example.py"
23+
spec = importlib.util.spec_from_file_location("jacobi_example", path)
24+
jac = importlib.util.module_from_spec(spec)
25+
assert spec.loader is not None
26+
spec.loader.exec_module(jac)
27+
28+
29+
# This runs the real distributed path on a small uneven grid.
30+
# The reference comparison catches wrong row splits or halo writes.
31+
def test_multi_gpu_jacobi_matches_reference():
32+
if not dist.is_initialized():
33+
pytest.skip("torch.distributed is not initialized")
34+
35+
nranks = dist.get_world_size()
36+
if nranks < 2:
37+
pytest.skip("Jacobi halo exchange requires at least two ranks")
38+
39+
ctx = None
40+
try:
41+
ctx = iris.iris(heap_size=1 << 26)
42+
rank = ctx.get_rank()
43+
nx = 64
44+
ny = 32 * nranks + 1
45+
nit = 8
46+
47+
_, _, mine = jac.split_rows(ny, rank, nranks)
48+
max_n = (ny - 2 + nranks - 1) // nranks
49+
buf_n = max_n + 2
50+
up = rank - 1 if rank > 0 else -1
51+
dn = rank + 1 if rank < nranks - 1 else -1
52+
up_n = jac.split_rows(ny, up, nranks)[2] if up >= 0 else 0
53+
54+
cur = ctx.zeros((buf_n, nx), dtype=torch.float32)
55+
cur[:, 0] = 100.0
56+
cur[:, -1] = 0.0
57+
if rank == 0:
58+
cur[0] = 50.0
59+
if rank == nranks - 1:
60+
cur[mine + 1] = 0.0
61+
62+
nxt = ctx.zeros((buf_n, nx), dtype=torch.float32)
63+
nxt.copy_(cur)
64+
dev_ctx = ctx.get_device_context()
65+
err = ctx.zeros((1, 1), dtype=torch.float32)
66+
all_err = ctx.zeros((1, 1), dtype=torch.float32)
67+
68+
# Seed halos first then run the same real step function as main.
69+
jac.push_halos(ctx, dev_ctx, cur, nx, mine, up, dn, up_n, rank, nranks)
70+
for _ in range(nit):
71+
jac.do_step(ctx, dev_ctx, cur, nxt, err, all_err, nx, mine, up, dn, up_n, rank, nranks)
72+
cur, nxt = nxt, cur
73+
74+
full = jac.gather_grid(ctx, cur, nx, ny, mine, max_n, nranks)
75+
ref = jac.ref_jacobi(nx, ny, nit, full.device)
76+
torch.testing.assert_close(full, ref, atol=1e-3, rtol=1e-4)
77+
78+
finally:
79+
# Another rank may already have failed so cleanup barrier is best effort.
80+
if ctx is not None:
81+
try:
82+
ctx.barrier()
83+
except Exception:
84+
pass
85+
del ctx
86+
gc.collect()

0 commit comments

Comments
 (0)