File size: 10,785 Bytes
629b123 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 | # SPDX-FileCopyrightText: © 2026 Tenstorrent USA, Inc.
# SPDX-License-Identifier: Apache-2.0
"""Rule-based layout and blocking for `all_gather_minimal_matmul_async` (the "v3 rules").
Fitted against ~4,600 block-sweep measurements on 4x8 Blackhole Galaxy (three H3 AGMM
projection shapes, K in {5376, 7168}, N in {1344, 5376, 7168}, M from 320 to 15,356) and
validated blind on two rounds of 20 stratified-random tile-unaligned Ms each: 86/89 blind
shapes within 5% of the swept optimum (round 1: 28/29, round 2: 58/60), ~1% mean penalty.
Sweep driver: `models/tt_dit/utils/sweep_mm_block_sizes.py`; per-shape results table in the
"AGMM Block Geometry" write-up.
The rules, in the order they are applied:
Layout (`pick_layout`)
score(layout) = ceil(M_tiles / M_axis_cores) * ceil(N_tiles / N_axis_cores)
- M > N: the op auto-transposes (`transpose_core_grid = M > N`, no override exists), so the
only legal worker grid keeps the bottom device row free for the fabric muxes: (x, y-1).
- Tiny M with large N (M_tiles <= 20, N_tiles >= 100): the ring gather dominates and hides
compute imbalance; the full-width (x, y-1) grid wins even when its score is worse.
- Otherwise strict argmin over the two non-transposed grids -- (x-1, y) (muxes in the free
column) vs (x, y-1) (muxes in the free row) -- with a tie-break to full-width (x, y-1)
when within 2% (15% for fused-SwiGLU shapes, whose narrow-grid N split consistently
underperforms its score).
Blocking (`pick_blocks`): fewest, most balanced blocks per core that fit L1.
- K_block: the largest divisor of K_tiles_per_device in [6, 8] (a hard op constraint is
that K_block divides K_tiles_per_device; 6-8 amortizes reload without starving L1).
- N_block: split count j ascending; within j the largest even n <= min(per_core_N, 16)
whose last block is at least n/2 (a 16+3-style degenerate tail loses 20-35%). Odd n only
when nothing even qualifies, and never for fused SwiGLU (gate/up tile pairs interleave
along N; a block must not split a pair).
- M_block: split count i ascending (a single block only when per_core_M <= 6);
m = ceil(per_core_M / i), with odd non-divisors rounded up to even when the split count
is preserved and total padding stays <= 1.25x. Accept m <= 10 that fits L1 and meets the
demand -- at least 6, relaxed to 3 when one N block covers all of per_core_N (the
L1-tight full-strip case). A skinny m under a fat n costs up to 37% on tall shapes.
- Subblock: 2x2 when both blocks are even, else the largest h*w <= 4 dividing both
(fp32 dest accumulation halves the DEST register file to 4 tiles).
Everything here is pure Python with no ttnn/torch imports so it can be unit-tested host-only;
`utils/matmul.py` wraps the result into a `MinimalMatmulConfig`.
"""
from __future__ import annotations
TILE = 32
# Block-size candidate bounds, matching the sweep that produced the fit.
MN_BLOCK_MIN, MN_BLOCK_MAX = 2, 16
K_BLOCK_MIN = 2
# L1 circular-buffer budget used to pre-filter block combos (KB). Blackhole usable L1 is
# ~1464 KB; the margin covers kernel/firmware overhead. The rules are only validated on
# Blackhole -- callers on other archs should stay on their legacy config path.
BLACKHOLE_L1_BUDGET_KB = 1400
def estimate_l1_kb(m_blk: int, k_blk: int, n_blk: int, use_addcmul: bool = False) -> int:
"""Estimate L1 circular-buffer footprint in KB for a block config.
Mirrors minimal_matmul_program_factory.cpp CB allocation:
c_0 (in0): 2 * M * K tiles (double-buffered, bf16 = 2 KB/tile)
c_1 (in1): 2 * K * N tiles (double-buffered, bf16)
c_2 (out): 2 * M * N tiles (double-buffered, bf16)
c_3 (interm): M * N tiles (single-buffered, f32 = 4 KB/tile)
c_4 (bias): N tiles (single-buffered, bf16)
Fused addcmul adds: M * N tiles (ternary_a, bf16) + N tiles (ternary_c, bf16).
"""
bf16_kb = 2
f32_kb = 4
kb = (
2 * m_blk * k_blk * bf16_kb
+ 2 * k_blk * n_blk * bf16_kb
+ 2 * m_blk * n_blk * bf16_kb
+ m_blk * n_blk * f32_kb
+ n_blk * bf16_kb
)
if use_addcmul:
kb += m_blk * n_blk * bf16_kb + n_blk * bf16_kb
return kb
def get_k_block_candidates(k_per_device_tiles: int) -> list[int]:
"""Divisors of K_tiles_per_device (hard op constraint), floored at K_BLOCK_MIN."""
return sorted(d for d in range(K_BLOCK_MIN, k_per_device_tiles + 1) if k_per_device_tiles % d == 0)
def get_mn_block_candidates(per_core_tiles: int) -> list[int]:
"""Evens in [MN_BLOCK_MIN, MN_BLOCK_MAX] union divisors of the per-core tile count."""
evens = set(range(MN_BLOCK_MIN, MN_BLOCK_MAX + 1, 2))
divisors = set(d for d in range(MN_BLOCK_MIN, MN_BLOCK_MAX + 1) if per_core_tiles % d == 0)
return sorted(evens | divisors)
def pick_subblock(m_block: int, n_block: int, fp32_dest: bool = True) -> tuple[int, int]:
"""Best (sub_h, sub_w) with sub_h | m_block, sub_w | n_block, sub_h * sub_w <= DEST capacity.
With fp32 dest the DEST register file holds 4 tiles; 2x2 is preferred among h*w == 4
candidates (better math-LLK tile reuse than 4x1 / 1x4).
"""
max_dest_volume = 4 if fp32_dest else 8
if fp32_dest and m_block % 2 == 0 and n_block % 2 == 0:
return (2, 2)
best, best_product = (1, 1), 1
for h in range(1, min(m_block, max_dest_volume) + 1):
if m_block % h:
continue
for w in range(1, min(n_block, max_dest_volume) + 1):
if n_block % w:
continue
if h * w <= max_dest_volume and h * w > best_product:
best, best_product = (h, w), h * w
return best
def _ceil(a: int, b: int) -> int:
return -(-a // b)
def pick_layout(
M: int, K: int, N: int, full_grid: tuple[int, int] = (12, 10), fuse_swiglu: bool = False
) -> tuple[tuple[int, int], bool]:
"""Pick the AGMM worker grid for the shape. Returns ((grid_x, grid_y), transposed).
`full_grid` is the device's full worker grid; the op needs either the last column
(non-transposed muxes) or the last row (transposed muxes, and the full-width
non-transposed variant) kept free.
"""
gx, gy = full_grid
m_tiles, n_tiles = _ceil(M, TILE), _ceil(N, TILE)
full_width = (gx, gy - 1) # muxes on the free bottom row
narrow = (gx - 1, gy) # muxes in the free last column
if M > N:
return full_width, True # the op auto-transposes; M runs over grid x
if m_tiles <= 20 and n_tiles >= 100:
return full_width, False # gather-latency regime
score_narrow = _ceil(m_tiles, narrow[1]) * _ceil(n_tiles, narrow[0])
score_wide = _ceil(m_tiles, full_width[1]) * _ceil(n_tiles, full_width[0])
tie = 1.15 if fuse_swiglu else 1.02
if score_wide <= tie * score_narrow:
return full_width, False
return narrow, False
def _n_candidates(per_core_n: int, fuse_swiglu: bool) -> list[int]:
out = []
for j in range(1, per_core_n + 1):
group = [
n
for n in range(2, min(per_core_n, MN_BLOCK_MAX) + 1)
if _ceil(per_core_n, n) == j
and (j == 1 or per_core_n - (j - 1) * n >= _ceil(n, 2))
and not (fuse_swiglu and n % 2)
]
group.sort(key=lambda n: (n % 2, -n)) # evens first (descending), then odds
out.extend(group)
return out
def _m_search(per_core_m, k_blk, n_blk, m_univ, demand, use_addcmul, l1_budget_kb):
if per_core_m <= 2:
return 2 if estimate_l1_kb(2, k_blk, n_blk, use_addcmul) <= l1_budget_kb else None
i = 1 if per_core_m <= 6 else 2
while i <= per_core_m:
m = max(2, _ceil(per_core_m, i))
if (
m % 2
and per_core_m % m != 0
and m + 1 <= MN_BLOCK_MAX
and _ceil(per_core_m, m + 1) == i
and _ceil(per_core_m, m + 1) * (m + 1) <= 1.25 * per_core_m
):
m += 1
if m <= 10 and m in m_univ and estimate_l1_kb(m, k_blk, n_blk, use_addcmul) <= l1_budget_kb:
return m if m >= demand else None
if m not in m_univ or m > 10:
for d in sorted((d for d in m_univ if per_core_m % d == 0 and 2 <= d <= 10), reverse=True):
if d >= max(demand, 6) and estimate_l1_kb(d, k_blk, n_blk, use_addcmul) <= l1_budget_kb:
return d
i += 1
return None
def pick_blocks(
per_core_m: int,
per_core_n: int,
k_per_device_tiles: int,
fuse_swiglu: bool = False,
use_addcmul: bool = False,
l1_budget_kb: int = BLACKHOLE_L1_BUDGET_KB,
) -> tuple[int, int, int, int, int] | None:
"""Pick (M_block, K_block, N_block, sub_h, sub_w) for the per-core work, or None."""
k_cands = [d for d in get_k_block_candidates(k_per_device_tiles) if 6 <= d <= 8]
if not k_cands:
return None # pathological K; let the caller keep its legacy fallback
k_blk = max(k_cands)
m_univ = set(get_mn_block_candidates(per_core_m))
n_univ = set(get_mn_block_candidates(per_core_n))
n_cands = [n for n in _n_candidates(per_core_n, fuse_swiglu) if n in n_univ]
for relax in (False, True):
for n_blk in n_cands:
demand = 2 if relax else min(3 if n_blk >= per_core_n else 6, per_core_m)
m_blk = _m_search(per_core_m, k_blk, n_blk, m_univ, demand, use_addcmul, l1_budget_kb)
if m_blk:
sub_h, sub_w = pick_subblock(m_blk, n_blk)
return (m_blk, k_blk, n_blk, sub_h, sub_w)
return None
def pick_v3(
M: int,
K: int,
N: int,
cluster_size: int,
full_grid: tuple[int, int] = (12, 10),
fuse_swiglu: bool = False,
use_addcmul: bool = False,
l1_budget_kb: int = BLACKHOLE_L1_BUDGET_KB,
) -> dict | None:
"""Resolve layout + blocking for an AGMM shape. Returns None when the rules don't apply
(K not tile-aligned, K_tiles not divisible by the ring size, or no K_block in [6, 8]).
Returns {"core_grid": (x, y), "transposed": bool, "blocks": (m, k, n), "subblock": (h, w)}.
"""
if K % TILE:
return None
k_tiles = K // TILE
if k_tiles % cluster_size:
return None
(gx, gy), transposed = pick_layout(M, K, N, full_grid, fuse_swiglu)
m_tiles, n_tiles = _ceil(M, TILE), _ceil(N, TILE)
per_core_m = _ceil(m_tiles, gx if transposed else gy)
per_core_n = _ceil(n_tiles, gy if transposed else gx)
blocks = pick_blocks(per_core_m, per_core_n, k_tiles // cluster_size, fuse_swiglu, use_addcmul, l1_budget_kb)
if blocks is None:
return None
m_blk, k_blk, n_blk, sub_h, sub_w = blocks
return {
"core_grid": (gx, gy),
"transposed": transposed,
"blocks": (m_blk, k_blk, n_blk),
"subblock": (sub_h, sub_w),
}
|