"""Draw the SIP topology diagram, color-coded by parallelism group. Color scheme: - CP ring: orange arrows (unchanged) - Inter-SIP: red arrows across SIP boundaries - PP stage: cube border color (each PP stage gets a distinct hue) - TP group: PE fill color (each TP group within a cube gets one hue, matches its cube's PP stage but slightly desaturated) - EP group: a small badge on the cube (for MoE) - DP replica: cubes of each DP replica get a hatched pattern """ from __future__ import annotations import matplotlib.pyplot as plt import matplotlib.patches as patches from matplotlib.lines import Line2D from .model_config import FullConfig MESH_W = 4 MESH_H = 4 PE_COLS = 4 PE_ROWS = 2 def _group_label(cfg, group_idx: int) -> str: """Label for a cube-level group under the current placement. - cp_placement=cube -> CP rank (or CP/TP if both on cube) - cp_placement=pe, tp_placement=cube -> TP rank - both on pe -> single group ("PE") """ parts = [] if cfg.topo.cp_placement == "cube": cp_r = group_idx % max(1, cfg.topo.cp) parts.append(f"CP{cp_r}") if cfg.topo.tp_placement == "cube": div = cfg.topo.cp if cfg.topo.cp_placement == "cube" else 1 tp_r = (group_idx // max(1, div)) % max(1, cfg.topo.tp) parts.append(f"TP{tp_r}") return "/".join(parts) if parts else "PE" # Fully-distinct palette for (PP stage x CP rank) — 24 unique colors. # Each cube's (pp, cp) pair maps to one entry; wraps if you exceed 24 groups. _GROUP_PALETTE = [ "#e6194b", "#3cb44b", "#ffe119", "#4363d8", "#f58231", "#911eb4", "#42d4f4", "#f032e6", "#bfef45", "#fabed4", "#469990", "#dcbeff", "#9a6324", "#fffac8", "#800000", "#aaffc3", "#808000", "#ffd8b1", "#000075", "#a9a9a9", "#004d40", "#c62828", "#4527a0", "#00695c", ] # Kept for backward-compat / PP-stage arrows _PP_HUES = _GROUP_PALETTE[:8] def _shade(hue_hex: str, t: float) -> str: """Mix hue_hex toward white by t in [0, 1] (0 = original, 1 = white).""" h = hue_hex.lstrip("#") r, g, b = int(h[0:2], 16), int(h[2:4], 16), int(h[4:6], 16) rn = int(r + (255 - r) * t) gn = int(g + (255 - g) * t) bn = int(b + (255 - b) * t) return f"#{rn:02x}{gn:02x}{bn:02x}" def _cp_color(pp_stage: int, cp_rank: int, cp_size: int) -> tuple[str, str]: """Return (border_color, pe_fill_color) for a cube at (pp_stage, cp_rank). Each unique (pp, cp) pair gets a **fully distinct color** from the 24-entry palette. Border is the pure color; PE fill is a lightened version so the cube border stays visible around the PE grid. """ idx = pp_stage * max(1, cp_size) + cp_rank color = _GROUP_PALETTE[idx % len(_GROUP_PALETTE)] pe_fill = _shade(color, 0.30) # slightly lightened for PE fill return color, pe_fill # Inactive (unused) colors. _INACTIVE_CUBE_FACE = "#f7f7f7" _INACTIVE_CUBE_EDGE = "#c8c8c8" _INACTIVE_PE = "#eaeaea" def _snake_path(n: int, mesh_w: int, mesh_h: int) -> list[int]: path: list[int] = [] for r in range(mesh_h): cols = range(mesh_w) if r % 2 == 0 else range(mesh_w - 1, -1, -1) for c in cols: path.append(r * mesh_w + c) if len(path) == n: return path return path[:n] def _rect_shape(n: int, max_w: int, max_h: int) -> tuple[int, int]: """Return (rows, cols) for n cubes packed as square as possible in max_wxmax_h.""" if n <= 0: return (0, 0) # Prefer near-square shapes; cap at mesh dims. best = None for cols in range(1, min(n, max_w) + 1): rows = (n + cols - 1) // cols if rows > max_h: continue # Score: penalise non-squareness + wasted cells waste = rows * cols - n aspect = abs(rows - cols) score = aspect * 10 + waste if best is None or score < best[0]: best = (score, rows, cols) if best is None: # Fallback: single row return (1, min(n, max_w)) return best[1], best[2] def _pack_groups_2d(items_per_group: int, n_groups: int, mesh_w: int, mesh_h: int, layout_mode: str = "compact") -> list[tuple[int, int]]: """Return list of (row, col) positions for n_groups × items_per_group cubes in a mesh_w x mesh_h mesh. layout_mode: - "compact": each group is a near-square rectangle; groups are also tiled 2D as near-square. Ideal for 2x2, 2x4 etc. - "linear": each group is a single row of cubes; groups tile down as rows (original sequential layout). """ if n_groups == 0 or items_per_group == 0: return [] if layout_mode == "linear": # Each group is a single horizontal row of cubes. tp_rows, tp_cols = 1, min(items_per_group, mesh_w) else: # compact tp_rows, tp_cols = _rect_shape(items_per_group, mesh_w, mesh_h) if tp_rows == 0: return [] # Group grid: how many groups per row/col of GROUPS max_group_cols = max(1, mesh_w // tp_cols) max_group_rows = max(1, mesh_h // tp_rows) if layout_mode == "linear": # Row-major: fill each mesh row with groups, then wrap down. g_cols = max_group_cols g_rows = max_group_rows else: g_rows, g_cols = _rect_shape(n_groups, max_group_cols, max_group_rows) positions: list[tuple[int, int]] = [] for g in range(n_groups): gr = g // g_cols gc = g % g_cols base_row = gr * tp_rows base_col = gc * tp_cols for i in range(items_per_group): r = i // tp_cols c = i % tp_cols row = base_row + r col = base_col + c if row >= mesh_h or col >= mesh_w: fallback = (g * items_per_group + i) row = fallback // mesh_w col = fallback % mesh_w positions.append((row, col)) return positions def _draw_one_sip(ax, cfg: FullConfig, sip_idx: int, sip_x0: float, sip_y0: float, sip_width: float, sip_height: float, cube_pp_cp: dict[int, tuple[int, int]], ring_paths: dict[int, list[int]], tp_group_cubes: dict[int, list[int]]): """Draw one SIP with PP hue + CP shade + TP group boundaries.""" cube_size = min(sip_width / MESH_W, sip_height / MESH_H) * 0.85 cube_gap = cube_size * 0.15 total_w = MESH_W * (cube_size + cube_gap) - cube_gap total_h = MESH_H * (cube_size + cube_gap) - cube_gap ox = sip_x0 + (sip_width - total_w) / 2 oy = sip_y0 + (sip_height - total_h) / 2 sip_rect = patches.FancyBboxPatch( (sip_x0, sip_y0), sip_width, sip_height, boxstyle="round,pad=0.02", facecolor="#f8f9fa", edgecolor="#212529", linewidth=1.5, ) ax.add_patch(sip_rect) ax.text(sip_x0 + sip_width / 2, sip_y0 + sip_height + 0.05, f"SIP {sip_idx}", ha="center", va="bottom", fontsize=10, fontweight="bold") def _cube_center(cube_local: int) -> tuple[float, float]: r = cube_local // MESH_W c = cube_local % MESH_W x = ox + c * (cube_size + cube_gap) y = oy + (MESH_H - 1 - r) * (cube_size + cube_gap) return x, y for cube_local in range(MESH_W * MESH_H): x, y = _cube_center(cube_local) info = cube_pp_cp.get(cube_local, None) is_used = info is not None if is_used: pp_stage, cp_rank = info cube_edge, pe_fill = _cp_color( pp_stage, cp_rank, max(1, cfg.topo.inter_cube_dims), ) cube_face = _shade(cube_edge, 0.85) cube_lw = 2.0 else: cube_edge = _INACTIVE_CUBE_EDGE cube_face = _INACTIVE_CUBE_FACE cube_lw = 0.6 pe_fill = _INACTIVE_PE rect = patches.FancyBboxPatch( (x, y), cube_size, cube_size, boxstyle="round,pad=0.01", facecolor=cube_face, edgecolor=cube_edge, linewidth=cube_lw, ) ax.add_patch(rect) if is_used: pp_stage, cp_rank = info # Small cube ID above ax.text(x + cube_size / 2, y + cube_size + 0.01, f"cube {cube_local}", ha="center", va="bottom", fontsize=5.5, color=cube_edge, fontweight="bold") # Group label (CP/TP depending on placement) _lbl = _group_label(cfg, cp_rank) ax.text(x + 0.03, y + cube_size - 0.03, _lbl, ha="left", va="top", fontsize=11, color=cube_edge, fontweight="bold", bbox=dict(boxstyle="round,pad=0.15", facecolor="white", edgecolor=cube_edge, linewidth=1.0, alpha=0.9)) if cfg.topo.pp > 1: ax.text(x + cube_size - 0.03, y + cube_size - 0.03, f"PP{pp_stage}", ha="right", va="top", fontsize=9, color=cube_edge, fontweight="bold", bbox=dict(boxstyle="round,pad=0.1", facecolor="white", edgecolor=cube_edge, linewidth=0.8, alpha=0.9)) else: ax.text(x + cube_size / 2, y + cube_size + 0.008, f"{cube_local}", ha="center", va="bottom", fontsize=6, color=cube_edge) # PEs pe_pad = cube_size * 0.08 pe_gap = cube_size * 0.02 inner_w = cube_size - 2 * pe_pad inner_h = cube_size - 2 * pe_pad pe_w = (inner_w - (PE_COLS - 1) * pe_gap) / PE_COLS pe_h = (inner_h - (PE_ROWS - 1) * pe_gap) / PE_ROWS pes_used = cfg.topo.pes_per_cube_used if is_used else 0 for pr in range(PE_ROWS): for pc in range(PE_COLS): pe_id = pr * PE_COLS + pc px = x + pe_pad + pc * (pe_w + pe_gap) py = y + pe_pad + (PE_ROWS - 1 - pr) * (pe_h + pe_gap) fill = pe_fill if pe_id < pes_used else _INACTIVE_PE pe_rect = patches.Rectangle( (px, py), pe_w, pe_h, facecolor=fill, edgecolor="#666", linewidth=0.3, ) ax.add_patch(pe_rect) if is_used and cfg.topo.ep > 1: ax.text(x + cube_size - 0.02, y + cube_size - 0.02, f"EP{cfg.topo.ep}", ha="right", va="top", fontsize=6, color="#5f0f40", fontweight="bold") # (CP ring arrows removed per user request) # Draw TP group bounding boxes (RED dashed) when TP > 8 for tp_g, cubes in tp_group_cubes.items(): if len(cubes) <= 1: continue # TP fits in one cube, redundant with cube border # Bounding box around all cubes in this TP group xs, ys = [], [] for cube_local in cubes: x, y = _cube_center(cube_local) xs.extend([x, x + cube_size]) ys.extend([y, y + cube_size]) pad = 0.03 bbox_x = min(xs) - pad bbox_y = min(ys) - pad bbox_w = (max(xs) - min(xs)) + 2 * pad bbox_h = (max(ys) - min(ys)) + 2 * pad tp_rect = patches.Rectangle( (bbox_x, bbox_y), bbox_w, bbox_h, fill=False, edgecolor="#d90429", linewidth=1.8, linestyle="--", ) ax.add_patch(tp_rect) ax.text(bbox_x + bbox_w / 2, bbox_y - 0.05, f"TP group {tp_g}", ha="center", va="top", color="#d90429", fontsize=7, fontweight="bold") def _sip_grid_layout(n_sips: int, topology: str) -> tuple[int, int]: """(rows, cols) for arranging SIPs on the page. - ring: horizontal chain (1 x N) - mesh2d / torus2d: near-square grid """ if n_sips <= 0: return 0, 0 if topology == "ring" or n_sips <= 2: return 1, n_sips import math cols = int(math.ceil(math.sqrt(n_sips))) rows = (n_sips + cols - 1) // cols return rows, cols def _draw_sip_links(ax, sip_xy: list[tuple[float, float]], topology: str, sip_w: float, sip_h: float, grid_rows: int, grid_cols: int): """Draw inter-SIP interconnect lines. Solid lines: adjacent (grid-neighbor) links. Dashed lines: wrap-around links (ring, torus). """ n = len(sip_xy) if n <= 1: return color = "#c62828" lw = 2.0 def center(i): x, y = sip_xy[i] return x + sip_w / 2, y + sip_h / 2 if topology == "ring": # 1D chain (adjacent solid) for i in range(n - 1): x1, y1 = center(i) x2, y2 = center(i + 1) ax.plot([x1 + sip_w / 2, x2 - sip_w / 2], [y1, y2], color=color, linewidth=lw) # wrap-around from last back to first (dashed arc below) if n > 2: x_first, y_first = center(0) x_last, y_last = center(n - 1) arc_y = min(y_first, y_last) - sip_h / 2 - 0.35 ax.plot([x_first, x_first], [y_first - sip_h / 2, arc_y], color=color, linewidth=lw, linestyle="--") ax.plot([x_first, x_last], [arc_y, arc_y], color=color, linewidth=lw, linestyle="--") ax.plot([x_last, x_last], [arc_y, y_last - sip_h / 2], color=color, linewidth=lw, linestyle="--") elif n == 2: # 2 SIPs: single link x1, y1 = center(0) x2, y2 = center(1) # wrap link: dashed loop below arc_y = y1 - sip_h / 2 - 0.35 ax.plot([x1, x1], [y1 - sip_h / 2, arc_y], color=color, linewidth=lw, linestyle="--") ax.plot([x1, x2], [arc_y, arc_y], color=color, linewidth=lw, linestyle="--") ax.plot([x2, x2], [arc_y, y2 - sip_h / 2], color=color, linewidth=lw, linestyle="--") return # mesh2d / torus2d: grid neighbours def idx(r, c): return r * grid_cols + c for r in range(grid_rows): for c in range(grid_cols): i = idx(r, c) if i >= n: continue # right if c + 1 < grid_cols and idx(r, c + 1) < n: x1, y1 = center(i) x2, y2 = center(idx(r, c + 1)) ax.plot([x1 + sip_w / 2, x2 - sip_w / 2], [y1, y2], color=color, linewidth=lw) # down (visually — grid row+1 is drawn below) if r + 1 < grid_rows and idx(r + 1, c) < n: x1, y1 = center(i) x2, y2 = center(idx(r + 1, c)) ax.plot([x1, x2], [y1 - sip_h / 2, y2 + sip_h / 2], color=color, linewidth=lw) if topology == "torus2d": # row wrap: last col -> first col in each row (dashed arc BELOW that row) for r in range(grid_rows): left_i = idx(r, 0) right_i = idx(r, grid_cols - 1) if left_i >= n or right_i >= n or left_i == right_i: continue x1, y1 = center(left_i) x2, y2 = center(right_i) arc_y = min(y1, y2) - sip_h / 2 - 0.35 ax.plot([x1, x1], [y1 - sip_h / 2, arc_y], color=color, linewidth=lw, linestyle="--") ax.plot([x1, x2], [arc_y, arc_y], color=color, linewidth=lw, linestyle="--") ax.plot([x2, x2], [arc_y, y2 - sip_h / 2], color=color, linewidth=lw, linestyle="--") # col wrap: last row -> first row in each col (dashed arc to the RIGHT) for c in range(grid_cols): top_i = idx(0, c) bot_i = idx(grid_rows - 1, c) if top_i >= n or bot_i >= n or top_i == bot_i: continue x1, y1 = center(top_i) # top row (higher y) x2, y2 = center(bot_i) # bottom row (lower y) arc_x = max(x1, x2) + sip_w / 2 + 0.35 ax.plot([x1 + sip_w / 2, arc_x], [y1, y1], color=color, linewidth=lw, linestyle="--") ax.plot([arc_x, arc_x], [y1, y2], color=color, linewidth=lw, linestyle="--") ax.plot([arc_x, x2 + sip_w / 2], [y2, y2], color=color, linewidth=lw, linestyle="--") def draw_topology(cfg: FullConfig, ax=None, layout_mode: str = "linear"): """Draw one or more SIPs. Each (PP,CP) group gets a unique color. SIP grid arrangement + inter-SIP links depend on cfg.topo.sip_topology: "ring" -> 1D horizontal chain, wrap arc below "mesh2d" -> near-square grid, no wrap "torus2d" -> grid + row/col wrap arcs """ sips_used = max(1, cfg.topo.sips_used) total_pes = cfg.topo.total_pes pes_per_stage = cfg.topo.pes_per_stage # CP*TP n_replicas = cfg.topo.dp n_pp_stages = cfg.topo.pp pes_per_cube = cfg.topo.pes_per_cube_hw # Placement-aware: how many cubes per "group" (was: cubes-per-TP). # A group is one cube-level unit (one CP rank if cp on cube, or one TP # rank if only tp on cube, etc.). Cubes per group == intra-cube spill. cubes_per_group = max( 1, (cfg.topo.intra_cube_dims + pes_per_cube - 1) // pes_per_cube ) n_groups_per_stage = max(1, cfg.topo.inter_cube_dims) cubes_per_stage = cfg.topo.cubes_per_stage cubes_used = cfg.topo.cubes_used cubes_per_sip = MESH_W * MESH_H sip_topo = cfg.topo.sip_topology grid_rows, grid_cols = _sip_grid_layout(sips_used, sip_topo) sip_w = 4.0 sip_h = 4.0 sip_gap = 0.6 fig_w = grid_cols * sip_w + (grid_cols - 1) * sip_gap + 1.5 fig_h = grid_rows * sip_h + (grid_rows - 1) * sip_gap + 2.0 if ax is None: fig, ax = plt.subplots(figsize=(max(6, fig_w), max(6, fig_h))) else: fig = ax.figure # 2D packing: each group's cubes form a rectangle inside the SIP mesh. # If more groups than fit in one SIP, spill to next SIP. tp_rows, tp_cols = _rect_shape(cubes_per_group, MESH_W, MESH_H) if tp_rows == 0: tp_rows, tp_cols = 1, 1 groups_per_sip = max(1, (MESH_W // tp_cols) * (MESH_H // tp_rows)) cube_pp_cp_global: dict[int, tuple[int, int]] = {} ring_globals_by_pp: dict[int, list[int]] = {} tp_group_cubes_global: dict[int, list[int]] = {} current_sip = 0 for rep in range(n_replicas): for pp_s in range(n_pp_stages): # Chunk cube-level groups into per-SIP batches. for chunk_start in range(0, n_groups_per_stage, groups_per_sip): chunk = min(groups_per_sip, n_groups_per_stage - chunk_start) positions = _pack_groups_2d(cubes_per_group, chunk, MESH_W, MESH_H, layout_mode=layout_mode) for local_gi in range(chunk): group_idx = chunk_start + local_gi tp_gid = ((rep * n_pp_stages) + pp_s) * n_groups_per_stage + group_idx cubes_here: list[int] = [] for i in range(cubes_per_group): idx_in_group = local_gi * cubes_per_group + i r, c = positions[idx_in_group] local_cube = r * MESH_W + c gc = current_sip * cubes_per_sip + local_cube cube_pp_cp_global[gc] = (pp_s, group_idx) cubes_here.append(gc) tp_group_cubes_global[tp_gid] = cubes_here ring_globals_by_pp.setdefault(pp_s, []).append(cubes_here[0]) current_sip += 1 # next SIP for the next chunk (or (rep, pp)) # Partition (pp, cp) map by SIP using cube_positions_global cubes_by_sip: list[dict[int, tuple[int, int]]] = [dict() for _ in range(sips_used)] for gc, ppcp in cube_pp_cp_global.items(): sip_idx = gc // cubes_per_sip local_cube = gc % cubes_per_sip if sip_idx < sips_used: cubes_by_sip[sip_idx][local_cube] = ppcp # Per-SIP snake-path CP rings (organized by pp_stage) ring_by_sip: list[dict[int, list[int]]] = [dict() for _ in range(sips_used)] for pp_s, gc_list in ring_globals_by_pp.items(): by_sip_local: dict[int, list[int]] = {} for gc in gc_list: si = gc // cubes_per_sip lc = gc % cubes_per_sip by_sip_local.setdefault(si, []).append(lc) for si, locals_list in by_sip_local.items(): if si < sips_used: # Snake through the packed positions in order ring_by_sip[si][pp_s] = locals_list # TP group cubes by SIP tp_by_sip: list[dict[int, list[int]]] = [dict() for _ in range(sips_used)] for tp_g, gc_list in tp_group_cubes_global.items(): by_sip_local: dict[int, list[int]] = {} for gc in gc_list: si = gc // cubes_per_sip lc = gc % cubes_per_sip by_sip_local.setdefault(si, []).append(lc) for si, locals_list in by_sip_local.items(): if si < sips_used: tp_by_sip[si][tp_g] = locals_list # Compute (x, y) top-left for each SIP based on grid layout. # Row 0 is drawn at the TOP visually, so y decreases with row index. sip_xy: list[tuple[float, float]] = [] for sip_idx in range(sips_used): r = sip_idx // grid_cols c = sip_idx % grid_cols x = c * (sip_w + sip_gap) y = (grid_rows - 1 - r) * (sip_h + sip_gap) sip_xy.append((x, y)) for sip_idx in range(sips_used): x0, y0 = sip_xy[sip_idx] _draw_one_sip( ax, cfg, sip_idx, sip_x0=x0, sip_y0=y0, sip_width=sip_w, sip_height=sip_h, cube_pp_cp=cubes_by_sip[sip_idx], ring_paths=ring_by_sip[sip_idx], tp_group_cubes=tp_by_sip[sip_idx], ) # Inter-SIP interconnect links per user-selected topology. _draw_sip_links(ax, sip_xy, sip_topo, sip_w, sip_h, grid_rows, grid_cols) total_w = grid_cols * sip_w + (grid_cols - 1) * sip_gap total_h = grid_rows * sip_h + (grid_rows - 1) * sip_gap # Extra room: bottom for wrap arcs, right for torus col-wrap arcs, top for SIP labels ax.set_xlim(-0.3, total_w + 0.9) ax.set_ylim(-0.9, total_h + 0.7) ax.set_aspect("equal") ax.axis("off") # Legend — one entry per unique (PP, cube-group) + TP boundary + SIP links legend_handles = [] for pp_s in range(n_pp_stages): for gi in range(n_groups_per_stage): color, _ = _cp_color(pp_s, gi, n_groups_per_stage) legend_handles.append(Line2D( [], [], color=color, marker="s", linestyle="", markersize=10, label=(f"PP{pp_s} " if n_pp_stages > 1 else "") + _group_label(cfg, gi), )) if cfg.topo.tp > cfg.topo.pes_per_cube_hw: legend_handles.append(Line2D( [], [], color="#d90429", marker="s", linestyle="--", markersize=10, markerfacecolor="none", markeredgewidth=1.5, label="TP group (dashed red)", )) if cfg.topo.ep > 1: legend_handles.append(Line2D( [], [], color="#5f0f40", marker="s", linestyle="", markersize=8, label=f"EP={cfg.topo.ep}", )) if sips_used > 1: _topo_label = { "ring": "Inter-SIP: ring (chain + wrap)", "mesh2d": "Inter-SIP: 2D mesh (no wrap)", "torus2d": "Inter-SIP: 2D torus (grid + wrap)", }.get(sip_topo, f"Inter-SIP: {sip_topo}") legend_handles.append(Line2D( [], [], color="#c62828", linewidth=2.0, label=_topo_label, )) # Legend below the figure; wrap ncols so it's readable n_items = len(legend_handles) ncol = min(6, max(3, n_items)) ax.legend(handles=legend_handles, loc="upper center", bbox_to_anchor=(0.5, -0.02), ncol=ncol, fontsize=8, frameon=False) title = ( f"Layout: {total_pes} PEs = {n_replicas} DP replica(s) x " f"{n_pp_stages} PP stage(s) x {cubes_per_stage} cubes (CP={cfg.topo.cp}) " f"x TP={cfg.topo.tp} | cubes: {cubes_used}, SIPs: {sips_used}" ) ax.set_title(title, fontsize=10) return fig