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),
    }