Skip to content

Commit ee8db17

Browse files
committed
feat: amd: rank the matrix entries by issues, with a place
for cycles Choosing the narrowest entry that fits is a guess, not a rule. Thirteen output columns take four 4x4 issues or one 16x16 that wastes three of its sixteen, and which is faster is a property of the two instructions rather than of the waste. So the selection is a ranking. issues() counts what an entry takes for the whole contraction across its four axes; rank() orders by that and multiplies in CYCLES where it knows an entry. CYCLES is empty and stays empty until the numbers are read off the ISA and checked like every other row in the catalogue -- guessing them would make a count read as a cost model. Filling it is then a table edit rather than a rewrite. spare_products() is the axis predicate the packing work needed and packing.py deliberately does not carry: a term product occupies every output column, so an entry holds a further one only where it is that much wider. Nine columns leave 23 of a 32-wide entry, which is two more products; 7 of a 16-wide one, which is none. The contraction axis has the small quantum and no spare, and the lane axis costs a cross-lane reduction, so the output axis is the one worth spending. All three schemes enumerate candidates now, not only the emulated one -- exchange_ops and emu_tiles beside the singular selections, with the conditions still stated once. ComputeShape carries the leading dimension and the depth so a ranking can read them; an extent of 0 counts as one tile, so a caller that does not know them ranks by what it does. emu_tiles keeps the emitter's k == m condition and now says what it costs: the 32-wide entry, where three term products would share an issue instead of one. AI-generated. Model: Opus 5
1 parent c98abaf commit ee8db17

7 files changed

Lines changed: 226 additions & 29 deletions

File tree

‎src/tensorforge/backend/instructions/compute/multilinear.py‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -610,7 +610,9 @@ def _plan(self) -> Tuple[Span, ...]:
610610
shape = ComputeShape(threads=self._num_threads,
611611
accumulator=self._idest.get_fptype(),
612612
sparse=self._second_operand_is_sparse(),
613-
explicit_simd=_explicit_simd(self._context))
613+
explicit_simd=_explicit_simd(self._context),
614+
lead=self._ns[0][1] - self._ns[0][0],
615+
depth=math.prod(mx - mi for mi, mx in self._ks))
614616
chosen = choose_strategy(
615617
legal_strategies(module.strategies(shape, self._context)),
616618
self._context.get_vm().get_hw_descr().vendor)

‎src/tensorforge/backend/instructions/compute/primitives/amd/__init__.py‎

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@
4242
from .arch import amdarch, cdna2, gfx1250, gfx1251, rdna
4343
from .caps import has_fmacdpp4, has_fmacdpp8, has_fmacdpp16
4444
from .catalog import (DEFINED_SPLITS, DEFINED_TRANSPOSES, MANTISSA,
45-
MATRIX_OPS, MFMA_TILES, emu_tile_for,
45+
MATRIX_OPS, MFMA_TILES, emu_tile_for, emu_tiles,
4646
NOT_MODELLED, Call, Fragment, MatrixOp,
4747
MfmaTile, lane_batched_ops, mfma_tile_for, ops_for,
4848
usable_mfma_tiles)
@@ -54,9 +54,10 @@
5454
accumulator_gathers, broadcast_feeds_a,
5555
fragment_cost, fragment_moves)
5656
from .codegen import hfma, matmul32, matmulemu, matmuldpp
57-
from .exchange_codegen import exchange_op, matmul_exchange
58-
from .tiling import (EMULATION, EXCHANGE, Fit, Scheme, boundary,
59-
choose, offers)
57+
from .exchange_codegen import exchange_op, exchange_ops, matmul_exchange
58+
from .tiling import (CYCLES, EMULATION, EXCHANGE, Fit, Scheme, boundary,
59+
candidates, choose, issues, offers, rank,
60+
spare_products)
6061
from .emitters import fmadpp, fmadpp4, fmadpp8, fmadpp16, fmascalar
6162
from .relayout import (BROADCAST, MOVDPP16, RELAYOUTS, TRANSPOSE4X4, Relayout,
6263
find_relayout)
@@ -70,7 +71,9 @@
7071
'FEATURE_TARGETS', 'has_feature', 'wave_size',
7172
'Call', 'Fragment', 'MatrixOp', 'MATRIX_OPS', 'MANTISSA',
7273
'DEFINED_SPLITS', 'emu_tile_for', 'matmulemu', 'EMULATION',
73-
'EXCHANGE', 'Scheme', 'Fit', 'exchange_op', 'matmul_exchange',
74+
'EXCHANGE', 'Scheme', 'Fit', 'exchange_op', 'exchange_ops',
75+
'matmul_exchange', 'emu_tiles', 'CYCLES', 'candidates', 'issues',
76+
'rank', 'spare_products',
7477
'NOT_MODELLED', 'ops_for',
7578
'MfmaTile', 'DEFINED_TRANSPOSES', 'MFMA_TILES', 'usable_mfma_tiles',
7679
'lane_batched_ops', 'mfma_tile_for',
@@ -144,7 +147,8 @@ def plan(strategy, shape, n, ctx):
144147
"""
145148
if strategy is not Strategy.MATRIX:
146149
return whole(strategy, n)
147-
fit = choose(shape.threads, shape.accumulator, ctx)
150+
fit = choose(shape.threads, shape.accumulator, ctx,
151+
columns=n, lead=shape.lead, depth=shape.depth)
148152
# Asked of the scheme that will run rather than of one of them. Only the
149153
# lane-batched one draws a boundary at all, and the threshold behind it is
150154
# a measurement against its own block width.
@@ -168,7 +172,8 @@ def matmul(writer, ops, ctx, span):
168172
if span.strategy is Strategy.BROADCAST:
169173
return broadcast.matmul(writer, ops, ctx, span)
170174
if span.strategy is Strategy.MATRIX:
171-
fit = choose(threads, dtype, ctx)
175+
fit = choose(threads, dtype, ctx, columns=N,
176+
lead=ops.lead_slots * threads, depth=K + kx)
172177
if fit is None or (ops.a, ops.b) != (fit.reads, fit.reads):
173178
# The entry was selected by what it accumulates in; what it
174179
# multiplies is a separate property of the same entry, and an

‎src/tensorforge/backend/instructions/compute/primitives/amd/catalog.py‎

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -594,7 +594,27 @@ def emu_tile_for(threads, dtype, ctx):
594594
already holds, and `layouts.position` puts them at the fragment's slots
595595
only when the two widths agree.
596596
"""
597+
found = emu_tiles(threads, dtype, ctx)
598+
return found[0] if found else None
599+
600+
601+
def emu_tiles(threads, dtype, ctx):
602+
"""Every tile an emulated path could run here, largest first.
603+
604+
Restricted to entries whose k-vector is exactly the block width. That is
605+
the *emitter's* limit and not the catalogue's: `matmulemu` hands the
606+
instruction the registers one `kk` step already holds, which are the
607+
fragment's slots only when the two widths agree. The stacking itself
608+
holds wider -- `layouts.position` says so for the 16- and 32-wide entries
609+
too -- and lifting the condition is what would reach them.
610+
611+
It is not free to leave. A term product occupies every output column, so
612+
an entry wider than the shape holds several: at nine columns the 32-wide
613+
entry takes three products per issue where these take one, which is six
614+
issues against two for a three-term split.
615+
"""
597616
from ...split import MANTISSA, terms as _terms
617+
out = []
598618
for op in ops_for(dtype, ctx, threads):
599619
if op.arithmetic is not None or op.a.dtype is dtype:
600620
continue
@@ -606,12 +626,12 @@ def emu_tile_for(threads, dtype, ctx):
606626
continue
607627
if op.a.per_lane != op.k or op.b.per_lane != op.k:
608628
continue
609-
tile = MfmaTile(op=op, transpose='tensorforge::transpose4x4b32',
629+
tile = MfmaTile(op=op, transpose=_TILE_TRANSPOSES.get(op.m, (None, False))[0],
610630
transpose_has_separate_outputs=True)
611631
if not tile.fits(threads) or tile.transpose not in DEFINED_TRANSPOSES:
612632
continue
613-
return tile, _terms(MANTISSA[op.a.dtype], dtype)
614-
return None
633+
out.append((tile, _terms(MANTISSA[op.a.dtype], dtype)))
634+
return tuple(out)
615635

616636

617637
def mfma_tile_for(threads, dtype, ctx):

‎src/tensorforge/backend/instructions/compute/primitives/amd/exchange_codegen.py‎

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,18 @@ def exchange_op(dtype, threads, ctx) -> Optional[object]:
5050
a plan. Asking all three here rather than discovering the third halfway
5151
through an emission is what lets `matmul` gate on one call.
5252
"""
53+
found = exchange_ops(dtype, threads, ctx)
54+
return found[0] if found else None
55+
56+
57+
def exchange_ops(dtype, threads, ctx) -> tuple:
58+
"""Every instruction this path could emit here, largest first.
59+
60+
The same conditions, kept in one place: a selection that reads them and a
61+
ranking that reads them again are two chances to disagree about what the
62+
path can serve.
63+
"""
64+
out = []
5365
for op in sorted(MATRIX_OPS, key=lambda o: (-o.m * o.n, o.builtin)):
5466
if op.call is not Call.MFMA or not op.available_for(dtype, ctx):
5567
continue
@@ -63,8 +75,8 @@ def exchange_op(dtype, threads, ctx) -> Optional[object]:
6375
continue
6476
if accumulator_gathers(op, 0) is None:
6577
continue
66-
return op
67-
return None
78+
out.append(op)
79+
return tuple(out)
6880

6981

7082
def _swapped(writer, value, blocks, ftype):

‎src/tensorforge/backend/instructions/compute/primitives/amd/tiling.py‎

Lines changed: 114 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -27,11 +27,11 @@
2727

2828
from dataclasses import dataclass
2929
from enum import Enum
30-
from typing import FrozenSet, Optional, Tuple
30+
from typing import Dict, FrozenSet, Iterable, List, Optional, Tuple
3131

3232
from ... import packing
33-
from .catalog import emu_tile_for, mfma_tile_for
34-
from .exchange_codegen import exchange_op
33+
from .catalog import emu_tile_for, emu_tiles, mfma_tile_for
34+
from .exchange_codegen import exchange_op, exchange_ops
3535

3636

3737
class Scheme(Enum):
@@ -119,23 +119,45 @@ def offers(threads, accumulator, ctx) -> FrozenSet[Scheme]:
119119
Scheme.LANE_BATCHED)
120120

121121

122-
def choose(threads, accumulator, ctx) -> Optional[Fit]:
123-
"""The scheme this target prefers among the ones that fit, ready to emit."""
122+
def candidates(scheme: Scheme, threads, accumulator, ctx) -> Tuple[Fit, ...]:
123+
"""Every entry this scheme could run here, unranked.
124+
125+
Each scheme answers in its own shape -- a tile, a bare entry, a tile and a
126+
term count -- and this is where that stops mattering to anyone else.
127+
"""
128+
if scheme is Scheme.LANE_BATCHED:
129+
tile = mfma_tile_for(threads, accumulator, ctx)
130+
# One, and the policy in `mfma_tile_for` is what keeps it one: the
131+
# wider tiles need a staging step that is not written.
132+
return () if tile is None else (
133+
Fit(scheme, tile.op, tile=tile, reads=tile.op.a.dtype),)
134+
if scheme is Scheme.EXCHANGE:
135+
return tuple(Fit(scheme, op, reads=op.a.dtype)
136+
for op in exchange_ops(accumulator, threads, ctx))
137+
return tuple(
138+
# What the split reads is the accumulator: the narrow type is what
139+
# comes out of it, not what goes in.
140+
Fit(scheme, tile.op, tile=tile, terms=terms, reads=accumulator)
141+
for tile, terms in emu_tiles(threads, accumulator, ctx))
142+
143+
144+
def choose(threads, accumulator, ctx, columns: int = 0, lead: int = 0,
145+
depth: int = 0) -> Optional[Fit]:
146+
"""The scheme this target prefers, and the cheapest entry it can run it on.
147+
148+
Two rankings, and they are not the same question. Which scheme is a
149+
preference between mappings and reads `ORDER`; which entry within it is a
150+
count over the shape, because an entry wider than the output wastes the
151+
difference in every issue and one narrower than it needs several.
152+
"""
124153
legal = offers(threads, accumulator, ctx)
125154
for scheme in ORDER:
126155
if scheme not in legal:
127156
continue
128-
if scheme is Scheme.LANE_BATCHED:
129-
tile = mfma_tile_for(threads, accumulator, ctx)
130-
return Fit(scheme, tile.op, tile=tile, reads=tile.op.a.dtype)
131-
if scheme is Scheme.EXCHANGE:
132-
op = exchange_op(accumulator, threads, ctx)
133-
return Fit(scheme, op, reads=op.a.dtype)
134-
tile, terms = emu_tile_for(threads, accumulator, ctx)
135-
# What the split reads, which is the accumulator: the narrow type is
136-
# what comes out of it, not what goes in.
137-
return Fit(scheme, tile.op, tile=tile, terms=terms,
138-
reads=accumulator)
157+
fits = rank(candidates(scheme, threads, accumulator, ctx),
158+
columns, lead, depth)
159+
if fits:
160+
return fits[0]
139161
return None
140162

141163

@@ -155,3 +177,79 @@ def boundary(fit: Fit, n: int) -> int:
155177
return n
156178
empty = packing.waste(n, fit.width)
157179
return ((n // fit.width) * fit.width) if empty in (0, fit.width - 1) else n
180+
181+
182+
# -- how much of an entry a shape actually uses ---------------------------- #
183+
184+
def spare_products(op, columns: int) -> int:
185+
"""Term products one issue holds on the output axis, the real one included.
186+
187+
A term product is a whole ``C += A_i x B_j`` over the tile, so it occupies
188+
every output column the shape has -- which is why partial spare is no use
189+
here and the count is a division rather than a remainder. An entry
190+
narrower than the shape holds one and no more.
191+
192+
This is the axis worth spending, and the only one of the three. The
193+
contraction axis has the small quantum -- one slot per step -- and no
194+
spare, since the contraction fills it; the lane axis has spare but a
195+
product needs the whole leading dimension there, and reclaiming it is a
196+
cross-lane reduction rather than an add chain inside a lane.
197+
"""
198+
if columns <= 0:
199+
return 1
200+
return max(1, op.m // columns)
201+
202+
203+
def issues(op, columns: int, lead: int = 0, depth: int = 0,
204+
products: int = 1) -> int:
205+
"""Instruction issues this entry takes for the whole contraction.
206+
207+
The four axes multiplied: output columns against `m`, the leading
208+
dimension against the lanes one issue spans, the contraction against `k`,
209+
and the term products against what one issue can hold beside the real one.
210+
An extent given as 0 is one the caller does not know, and counts as a
211+
single tile rather than as nothing.
212+
213+
It counts issues, not time. Two entries that differ in passes do not cost
214+
the same per issue, which is what `CYCLES` is for and why this is not
215+
called a cost.
216+
"""
217+
def tiles(demand, capacity):
218+
return packing.tiles(demand, capacity) if demand > 0 else 1
219+
220+
return (tiles(columns, op.m)
221+
* tiles(lead, op.n * op.blocks)
222+
* tiles(depth, op.k)
223+
* packing.tiles(products, spare_products(op, columns)))
224+
225+
226+
#: Issue cost per instruction, in cycles. Empty.
227+
#:
228+
#: The numbers are a hardware fact like every other row in the catalogue, and
229+
#: they have to be read off the ISA guide and checked the same way -- AMD
230+
#: documents them per instruction; for other vendors this table would need its
231+
#: own source. Guessing them is worse than not having them: the ranking below
232+
#: then reads as a cost model and is a count.
233+
#:
234+
#: Until it is filled, `rank` orders by issues alone, which is exactly wrong
235+
#: where two entries differ in passes. A 32x32 issue is not one 4x4 issue,
236+
#: and fewer issues of a longer instruction can be slower.
237+
CYCLES: Dict[str, int] = {}
238+
239+
240+
def rank(fits, columns: int, lead: int = 0, depth: int = 0):
241+
"""The candidates, cheapest first.
242+
243+
Fewest issues wins, and where `CYCLES` knows an entry its issues are
244+
weighted by it. Deliberately not "the narrowest that fits": four 4x4
245+
issues and one 16x16 issue both serve thirteen columns, the second wastes
246+
three of its sixteen, and which is faster is a property of the two
247+
instructions rather than of the waste. Stating the order here is what
248+
lets that be answered by filling a table instead of by rewriting a
249+
selection.
250+
"""
251+
def key(fit):
252+
count = issues(fit.op, columns, lead, depth, fit.terms)
253+
return (count * CYCLES.get(fit.op.builtin, 1), count, fit.op.builtin)
254+
255+
return tuple(sorted(fits, key=key))

‎src/tensorforge/backend/instructions/compute/strategy.py‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,13 @@ class ComputeShape:
8787
#: broadcast costs differs between them by more than its spelling.
8888
explicit_simd: bool
8989

90+
#: Elements the leading dimension spans, and steps the contraction takes.
91+
#: 0 where the caller does not know them -- a count that reads an extent
92+
#: of 0 treats it as one tile rather than as nothing, so an unfilled pair
93+
#: narrows what a ranking can tell apart without making it wrong.
94+
lead: int = 0
95+
depth: int = 0
96+
9097

9198
def is_contraction(operands: int, lead_width: int) -> bool:
9299
"""Whether anything but the nest could compute this at all.

‎tests/test_compute_strategy.py‎

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -286,3 +286,56 @@ def test_only_the_lane_batched_scheme_draws_a_boundary():
286286
assert tiling.boundary(swap, n) == n
287287
assert tiling.boundary(lane, 9) == 8
288288
assert tiling.boundary(lane, 10) == 10
289+
290+
291+
# -- choosing the entry within a scheme ------------------------------------ #
292+
293+
def test_the_entry_is_ranked_by_issues_not_by_fit():
294+
"""Thirteen columns take four 4x4 issues or one 16x16 that wastes three
295+
of its sixteen. Which is faster is a property of the two instructions
296+
rather than of the waste, so the count is what is stated and `CYCLES` is
297+
where the rest would go."""
298+
from tensorforge.backend.instructions.compute.primitives.amd import (
299+
MATRIX_OPS, tiling)
300+
ops = {o.builtin: o for o in MATRIX_OPS}
301+
narrow, wide = ops['mfma_f32_4x4x1f32'], ops['mfma_f32_16x16x1f32']
302+
assert tiling.issues(narrow, 13, 56, 56) == 4 * tiling.issues(
303+
wide, 13, 56, 56)
304+
assert tiling.CYCLES == {}, 'a guessed cycle count reads as a measurement'
305+
306+
307+
def test_a_term_product_takes_the_whole_output_axis():
308+
"""Which is why partial spare is no use: a product is a complete tile,
309+
not a column of one."""
310+
from tensorforge.backend.instructions.compute.primitives.amd import (
311+
MATRIX_OPS, tiling)
312+
ops = {o.builtin: o for o in MATRIX_OPS}
313+
for name, expected in (('mfma_f32_4x4x4bf16_1k', 1),
314+
('mfma_f32_16x16x4bf16_1k', 1),
315+
('mfma_f32_32x32x4bf16_1k', 3)):
316+
assert tiling.spare_products(ops[name], 9) == expected, name
317+
318+
319+
def test_an_unknown_extent_counts_as_one_tile():
320+
"""A shape that does not carry its leading dimension or its depth still
321+
ranks by what it does carry, rather than ranking everything equal."""
322+
from tensorforge.backend.instructions.compute.primitives.amd import (
323+
MATRIX_OPS, tiling)
324+
ops = {o.builtin: o for o in MATRIX_OPS}
325+
op = ops['mfma_f32_4x4x1f32']
326+
assert tiling.issues(op, 9) == tiling.issues(op, 9, lead=1, depth=1)
327+
328+
329+
def test_the_emulated_limit_has_a_number_on_it():
330+
"""The emitter takes only entries whose k-vector is the block width, and
331+
that is what keeps the 32-wide one out -- where three term products would
332+
share an issue instead of one."""
333+
from tensorforge.backend.instructions.compute.primitives.amd import (
334+
MATRIX_OPS, emu_tiles, tiling)
335+
ctx = _hip()
336+
offered = {tile.op.builtin for tile, _ in emu_tiles(64, Datatype.F32, ctx)}
337+
ops = {o.builtin: o for o in MATRIX_OPS}
338+
assert offered == {'mfma_f32_4x4x4bf16_1k'}
339+
wide = ops['mfma_f32_32x32x4bf16_1k']
340+
assert wide.k != wide.m
341+
assert tiling.spare_products(wide, 9) == 3

0 commit comments

Comments
 (0)