"""Figures for the fused deck — kernel/algorithm/occupancy half. Same palette and conventions as make_figs.py (deck palette, dark, tight bbox). Writes into docs/figures/ alongside the optimization figures. Occupancy numbers are not hand-computed: they come from cudaOccupancyMaxActiveBlocksPerMultiprocessor + cudaFuncGetAttributes on the real kernel (RTX 4090, sm_89), measured 2026-08-11 — see the table in build_performance_deck.py. """ import sys import matplotlib matplotlib.use("Agg") import matplotlib.pyplot as plt import numpy as np from matplotlib.colors import LinearSegmentedColormap from matplotlib.patches import Rectangle, FancyArrowPatch from pathlib import Path sys.path.insert(0, str(Path(__file__).resolve().parent)) import deckgate # noqa: E402 -- the projection floor, shared with make_figs.py OUT = Path(__file__).resolve().parent.parent / "figures" OUT.mkdir(exist_ok=True) BG = "#0B1018" PANEL = "#121A28" RULE = "#1E2836" ACCENT = "#1E90C2" AMBER = "#E8B25C" PALE = "#E7EDF4" TEXT2 = "#A5B2C4" MUTED = "#6B7A90" plt.rcParams.update({ "font.family": "DejaVu Sans", "font.size": 11.7, "text.color": PALE, "axes.labelcolor": TEXT2, "xtick.color": TEXT2, "ytick.color": TEXT2, "axes.edgecolor": RULE, "axes.facecolor": "none", "figure.facecolor": BG, "savefig.facecolor": BG, "axes.grid": False, "svg.fonttype": "none", }) # deck-native sequential map: background → accent → amber → white-hot CMAP = LinearSegmentedColormap.from_list( "deck", ["#080C12", "#10202F", ACCENT, AMBER, "#FFF3DC"]) DPI = 220 def save(fig, name): """Write the PNG, then hold it to the SAME projection floor make_figs.py uses. These three figures were unchecked for a long time and were set at 7-8 pt as a result -- roughly 8 pt on the screen, half the body size.""" path = OUT / f"{name}.png" fig.savefig(path, dpi=DPI, transparent=False, bbox_inches="tight", pad_inches=0.08) deckgate.check(fig, path, name, DPI) plt.close(fig) def bare(ax, keep=("left", "bottom")): for s in ("top", "right", "left", "bottom"): ax.spines[s].set_visible(s in keep) # ------------------------------------------------ 1. what a frame looks like def fig_frame(): """Pedestal-subtracted MOENCH frame + a zoom on one real 3×3 cluster.""" sys.path.append("/home/ferjao_k/aare/build") from aare import File base = Path("/mnt/sls_det_storage/moench_data/2603_MaxIVBeamtime/" "2026032408/process/xrf/") pd = File(base / "Cu_factor_10_pedestal_master_0.json") ped = np.mean([np.asarray(pd.read_frame(), dtype=np.float64) for _ in range(200)], axis=0) f = File(base / "Cu_factor_10_data_master_0.json") frame = np.asarray(f.read_frame(), dtype=np.float64) - ped crop = frame[40:190, 40:190] fig = plt.figure(figsize=(7.4, 3.25)) ax = fig.add_axes([0.0, 0.02, 0.44, 0.94]) im = ax.imshow(crop, cmap=CMAP, vmin=-40, vmax=1200, interpolation="nearest") ax.set_xticks([]); ax.set_yticks([]) for s in ax.spines.values(): s.set_color(RULE) ax.set_title("one frame, pedestal subtracted · 150×150 crop", color=MUTED, fontsize=10.4, pad=7) cb = fig.colorbar(im, ax=ax, fraction=0.045, pad=0.02) cb.outline.set_edgecolor(RULE) cb.ax.tick_params(labelsize=10.0, color=RULE) cb.set_label("ADU above pedestal", color=MUTED, fontsize=9.8) # Pick a clean, well-isolated charge-sharing event: a local maximum of # moderate amplitude whose 3×3 core carries the charge and whose # surrounding ring is quiet — i.e. what the algorithm is designed to find. best, win = None, None for r in range(6, frame.shape[0] - 6): for c in range(6, frame.shape[1] - 6): v = frame[r, c] if not (500 < v < 2000): continue w = frame[r - 4:r + 5, c - 4:c + 5] if w.max() > v: # must be the local max continue core = w[3:6, 3:6] ring = np.concatenate([w[:3].ravel(), w[6:].ravel(), w[3:6, :3].ravel(), w[3:6, 6:].ravel()]) if ring.max() > 80: # neighbourhood must be quiet continue share = (core.sum() - v) / core.sum() # charge outside the peak if best is None or share > best[0]: best, win = (share, r, c), w if win is None: # fallback: brightest pixel inner = frame[6:-6, 6:-6] r, c = np.unravel_index(np.argmax(inner), inner.shape) win = frame[r + 2:r + 11, c + 2:c + 11] ax2 = fig.add_axes([0.60, 0.10, 0.30, 0.78]) ax2.imshow(win, cmap=CMAP, vmin=-40, vmax=1200, interpolation="nearest") ax2.set_xticks([]); ax2.set_yticks([]) for s in ax2.spines.values(): s.set_color(RULE) ax2.add_patch(Rectangle((2.5, 2.5), 3, 3, fill=False, edgecolor=PALE, lw=1.8, zorder=5)) for dy in (-1, 0, 1): for dx in (-1, 0, 1): v = win[4 + dy, 4 + dx] # A zoom cell is ~0.25 in wide and a value can be four digits, so # these cannot clear the projection floor without colliding. They # are texture: the argument is that the charge is SPREAD, and the # 3x3 sum that proves it is set below the panel at full size. ax2.text(4 + dx, 4 + dy, f"{v:.0f}", ha="center", va="center", gid="texture", color=BG if v > 500 else PALE, fontsize=6.4, fontweight="bold" if dx == 0 and dy == 0 else "normal", zorder=6) ax2.set_title("9×9 zoom on one hit", color=MUTED, fontsize=10.4, pad=7) ax2.text(4, 9.2, f"3×3 sum = {win[3:6, 3:6].sum():.0f} ADU — one photon.\n" "The peak pixel holds only part of the charge.", ha="center", va="top", color=TEXT2, fontsize=9.8) save(fig, "fig_frame") # --------------------------------------------- 2. shared-memory tile + halo def fig_tile(): B, r = 16, 1 # 16×16 block, 3×3 cluster → 1-px halo n = B + 2 * r # 6.9 in wide, not 7.6: the deck places this at 8.36 in, so a narrower # canvas is a LARGER projected figure and larger projected type. The two # panels sit 0.06 apart rather than 0.135, which is the gap the slide was # paying for twice -- once inside the picture, once beside it. # # 3.55 tall, not 2.62: the slide ended at 5.95 and left 1.3 in of dead space # above the footer. Height is the only axis with room -- the code panel fixes # the width at 8.36 -- and it is also what the TILE wants: set_aspect("equal") # made the tile height-limited inside a 3.04 in wide axes, so 0.9 in of extra # canvas height is 0.9 in of extra tile. Type does not change: the projection # scale is 8.36/6.9 either way, so the bars grow against a fixed font. fig = plt.figure(figsize=(6.9, 3.55)) # The tile axes holds ONLY the tile. With set_aspect("equal") matplotlib # shrinks the axes box to honour the aspect, so any data-space room reserved # underneath for a legend gets squeezed and the legend is clipped. The key # is drawn in figure coordinates instead, where nothing can take it back. ax = fig.add_axes([0.015, 0.30, 0.44, 0.70]) ax.set_aspect("equal"); ax.axis("off") ax.set_xlim(-0.6, n + 0.6); ax.set_ylim(-0.6, n + 1.5) for i in range(n): for j in range(n): halo = i < r or j < r or i >= n - r or j >= n - r ax.add_patch(Rectangle((j, n - 1 - i), 0.92, 0.92, facecolor=RULE if halo else "#17394F", edgecolor="none")) # one thread's 3×3 neighbourhood ti, tj = 6, 5 for di in (-1, 0, 1): for dj in (-1, 0, 1): ax.add_patch(Rectangle((tj + r + dj, n - 1 - (ti + r + di)), 0.92, 0.92, facecolor=ACCENT, edgecolor="none")) ax.add_patch(Rectangle((tj + r, n - 1 - (ti + r)), 0.92, 0.92, facecolor=AMBER, edgecolor="none")) ax.text(n / 2, n + 0.45, "shared-memory tile · 18 × 18", ha="center", color=MUTED, fontsize=10.4) for k, (c, t) in enumerate([(AMBER, "the thread's own pixel"), (ACCENT, "its 3×3 neighbourhood"), (RULE, "halo · loaded, never centred on")]): # Anchored in INCHES below the tile axes, not in figure fractions: the # rows are 9.8 pt type and a fraction-based step grows with the canvas, # so a taller figure would space three lines of a key 0.34 in apart. fy = 0.30 - (0.30 + k * 0.21) / 3.55 fig.patches.append(Rectangle((0.020, fy - 0.018), 0.022, 0.042, transform=fig.transFigure, facecolor=c, edgecolor="none", zorder=5)) fig.text(0.055, fy, t, va="center", color=TEXT2, fontsize=9.8) # right: tile cost vs cluster size ax2 = fig.add_axes([0.52, 0.20, 0.46, 0.62]) labels = ["3×3\n18×18", "5×5\n20×20", "7×7\n22×22", "9×9\n24×24"] kb = [(16 + 2 * (k // 2)) ** 2 * 4 / 1024 for k in (3, 5, 7, 9)] ax2.bar(np.arange(4), kb, width=0.55, color=ACCENT, zorder=3) for i, v in enumerate(kb): ax2.text(i, v + 0.12, f"{v:.1f}", ha="center", color=PALE, fontsize=11.7, fontweight="bold") ax2.axhline(100, color=PALE, lw=1.2, ls="--") ax2.set_xticks(np.arange(4)); ax2.set_xticklabels(labels, color=TEXT2, fontsize=10.4) ax2.set_ylim(0, 4.4); ax2.set_yticks([]) bare(ax2, keep=("bottom",)) ax2.set_title("KB of shared memory per 16×16 block (float tile)", color=MUTED, fontsize=10.4, pad=8) ax2.text(3.55, 4.3, "100 KB available per SM on Ada:\n" "shared memory is never the limit", ha="right", va="top", color=PALE, fontsize=9.8) save(fig, "fig_tile") # ------------------------------------------------- 3. occupancy / registers def fig_occupancy(): # 1.7, not 1.5: the right panel carries three two-line tick labels and they # were touching. Widening the panel is the fix; shrinking the labels below # the projection floor is not. fig, (ax, ax2) = plt.subplots(1, 2, figsize=(11.6, 2.95), gridspec_kw={"width_ratios": [1, 1.7]}) # left — registers set the occupancy, per cluster size occ = [100.0, 33.3] ax.bar([0, 1], occ, width=0.5, color=[ACCENT, AMBER], zorder=3) for i, o in enumerate(occ): ax.text(i, o + 3, f"{o:.0f}%", ha="center", color=PALE, fontsize=15.6, fontweight="bold") ax.set_xticks([0, 1]) # Three short lines, not two long ones: the left panel is a third of the # figure and the two labels were running into each other. ax.set_xticklabels(["3×3 cluster\n38 regs/thread\n6 blocks/SM", "9×9 cluster\n128 regs/thread\n2 blocks/SM"], color=TEXT2, fontsize=10.4) ax.set_ylim(0, 122); ax.set_yticks([]) bare(ax, keep=("bottom",)) ax.set_title("achieved occupancy, 16×16 block · f32 build", color=MUTED, fontsize=11.1, pad=8) # right — block-size sweep, both cluster sizes o3 = [100.0, 100.0, 66.7] o9 = [33.3, 33.3, 0.0] halo3 = [56, 27, 13] blocks = [f"{b}\n{t}\nhalo +{h}%" for b, t, h in zip(["8×8", "16×16", "32×32"], ["64 threads", "256 threads", "1024 threads"], halo3)] x = np.arange(3); w = 0.34 ax2.bar(x - w / 2, o3, width=w, color=ACCENT, zorder=3, label="3×3 cluster") ax2.bar(x + w / 2, o9, width=w, color=AMBER, zorder=3, label="9×9 cluster") for xi, (a, b) in enumerate(zip(o3, o9)): ax2.text(xi - w / 2, a + 3, f"{a:.0f}%", ha="center", color=TEXT2, fontsize=11.1) ax2.text(xi + w / 2, b + 3, ("will not launch\n(registers)" if b == 0 else f"{b:.0f}%"), ha="left" if b == 0 else "center", va="bottom", color=AMBER if b == 0 else TEXT2, fontsize=10.4 if b == 0 else 11.1, fontweight="bold" if b == 0 else "normal") ax2.set_xticks(x); ax2.set_xticklabels(blocks, color=TEXT2, fontsize=9.8) ax2.set_ylim(0, 122); ax2.set_xlim(-0.6, 2.85); ax2.set_yticks([]) bare(ax2, keep=("bottom",)) ax2.legend(frameon=False, fontsize=11.1, labelcolor=TEXT2, loc="upper right") ax2.set_title("occupancy vs block size (halo overhead quoted for 3×3)", color=MUTED, fontsize=11.1, pad=8) fig.subplots_adjust(bottom=0.26) save(fig, "fig_occupancy") if __name__ == "__main__": fig_tile() fig_occupancy() fig_frame() print("done ->", OUT) deckgate.report()