Skip to content

Commit e1515b4

Browse files
author
Courtney Golden
committed
arbitrary map/reduce operators
1 parent 73cbd2b commit e1515b4

11 files changed

Lines changed: 493 additions & 48 deletions

File tree

accelforge/frontend/arch/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
"Comparison",
2828
"Component",
2929
"Compute",
30+
"ComputeAction",
3031
"Container",
3132
"Fork",
3233
"Array",

accelforge/frontend/arch/components.py

Lines changed: 42 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -227,6 +227,30 @@ def __call__(self, field, value, evaluated, symbol_table):
227227
return super()._eval_expressions(*args, **kwargs, post_calls=(MyPostCall(),))
228228

229229

230+
class ComputeAction(Action):
231+
op_kind: str = "mac"
232+
""" The semantic category of operation this action models (e.g., "mul", "add",
233+
"mac", "max"). Einsums declare `map_op`/`reduce_op`; the analysis derives an
234+
op_profile from those and binds each op_kind to the Compute action that declares
235+
it. The default "mac" preserves legacy single-action behavior. """
236+
237+
fuses: EvalableList[str] = []
238+
""" op_kinds this action coalesces into a single fire. When an einsum's op_profile
239+
contains all listed op_kinds with EQUAL counts, those entries collapse into one
240+
entry keyed under this action's `op_kind` with the shared count -- e.g. a fused MAC
241+
pairs (mul, add) into one charge per iteration. Left unset, an `op_kind="mac"`
242+
action defaults to fusing `[mul, add]` (so legacy single-MAC arches need no
243+
change); any other op_kind defaults to no fusion. See `effective_fuses`. """
244+
245+
@property
246+
def effective_fuses(self) -> list[str]:
247+
"""`fuses` if set, else the op_kind-derived default: a bare `mac` action
248+
fuses `[mul, add]` (legacy fused-MAC); every other op_kind fuses nothing."""
249+
if self.fuses:
250+
return list(self.fuses)
251+
return ["mul", "add"] if self.op_kind == "mac" else []
252+
253+
230254
_COMPONENT_MODEL_CACHE: dict[tuple, "Component"] = {}
231255

232256

@@ -894,7 +918,7 @@ def _copy_for_component_modeling(self) -> Self:
894918

895919
COMPUTE_ACTIONS = EvalableList(
896920
[
897-
Action(name="compute"),
921+
ComputeAction(name="compute", op_kind="mac"),
898922
]
899923
)
900924

@@ -1285,17 +1309,30 @@ def _render_node_color(self) -> str:
12851309

12861310

12871311
class Compute(Component, Leaf):
1288-
actions: EvalableList[Action] = COMPUTE_ACTIONS
1289-
""" The actions that this `Compute` can perform. """
1312+
actions: EvalableList[ComputeAction] = COMPUTE_ACTIONS
1313+
""" The actions that this `Compute` can perform. Each `ComputeAction` declares an
1314+
`op_kind` that einsums bind to via their `map_op`/`reduce_op`. """
12901315

12911316
skip_initial_output_write: bool = True
12921317
"""
12931318
If False, the initial value of output tensors will be fetched from above and used to
12941319
initalize outputs. If True, this initial fetch and fill is skipped.
12951320
"""
12961321

1297-
def model_post_init(self, __context__=None) -> None:
1298-
self._update_actions(COMPUTE_ACTIONS)
1322+
def action_for_op_kind(self, op_kind: str) -> ComputeAction:
1323+
"""Return the `ComputeAction` on this Compute whose `op_kind` matches.
1324+
1325+
Raises EvaluationError if no action declares this op_kind.
1326+
"""
1327+
for action in self.actions:
1328+
if getattr(action, "op_kind", None) == op_kind:
1329+
return action
1330+
declared = sorted({getattr(a, "op_kind", None) for a in self.actions})
1331+
raise EvaluationError(
1332+
f"Compute component {self.name!r} has no action with op_kind "
1333+
f"{op_kind!r}. Declared op_kinds: {declared}.",
1334+
source_field=f"{self.name}.actions",
1335+
)
12991336

13001337
def _render_node_shape(self) -> str:
13011338
return "ellipse"

accelforge/frontend/workload.py

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -456,6 +456,18 @@ class Einsum(EvalableModel):
456456
and directly place them at the location of the output tensor(s) without any
457457
computation. If the destination tensor is at the same location, then this is a
458458
no-op."""
459+
map_op: str = "mul"
460+
""" Binary operator applied to paired input-tensor values at each iteration-space
461+
point (e.g. "mul", "add", "max", "square"). Combined with `reduce_op` to derive the
462+
einsum's op_profile: the map and reduce ops are each charged once per
463+
iteration-space point. The default "mul" + "add" describes a standard
464+
sum-of-products; on an arch that declares a fused-MAC compute action this pair
465+
collapses into one MAC (see `ComputeAction.fuses`), so legacy arches stay
466+
bit-identical. """
467+
reduce_op: str = "add"
468+
""" Operator that folds mapped values into the output tensor across the reduction
469+
ranks (e.g. "add", "max"). Charged once per iteration-space point alongside
470+
`map_op`. Ignored for copy operations. """
459471
renames: RenameList[Rename] = RenameList()
460472
""" Renames of the Einsum. Renames here can be used to rename rank variables or
461473
tensors. When this Einsum is executed on an architecture, the architecture can use
@@ -582,6 +594,30 @@ def tensor2irrelevant_rank_variables(
582594
for t in self.tensor_accesses
583595
}
584596

597+
def effective_op_profile(self) -> dict[str, int]:
598+
"""Per-iteration-space-point op counts keyed by op_kind, derived from
599+
`map_op` and `reduce_op`.
600+
601+
Copy operations have no ops. Every other einsum is charged one map and
602+
one reduce per point (matching the legacy uniform "1 op per iter"
603+
attribution); if `map_op` and `reduce_op` are the same op_kind the two
604+
entries collapse into one with count 2. These are the *raw* ops -- a
605+
fused-MAC arch coalesces a `{mul: N, add: N}` profile back into
606+
`{mac: N}` downstream via `ComputeAction.fuses`, so the default mul+add
607+
einsum stays bit-identical on legacy single-MAC arches.
608+
609+
`square` is treated as `mul` here (x*x runs on any multiplier), so a
610+
square+add reduction fuses into a MAC like an ordinary product. An arch
611+
declaring a dedicated `op_kind="square"` action would therefore not
612+
bind -- the substitution erases the distinction.
613+
"""
614+
if self.is_copy_operation:
615+
return {}
616+
map_op = "mul" if self.map_op == "square" else self.map_op
617+
if map_op == self.reduce_op:
618+
return {map_op: 2}
619+
return {map_op: 1, self.reduce_op: 1}
620+
585621
def _to_formatted_string(self, compress: bool = False) -> str:
586622
"""
587623
Returns a string representation of this Einsum for use in a Pydot graph.

accelforge/model/_looptree/energy.py

Lines changed: 20 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -52,12 +52,22 @@ def gather_actions(
5252
actions[key].total += accesses.net_total_write_actions()
5353
actions[key].max_per_unit += accesses.net_max_per_unit_write_actions()
5454

55+
# `ops.total_ops` is a per-op-kind dict ({op_kind: count}). Emit one action
56+
# key per (level, op_kind), where the action *name* is resolved from the
57+
# Compute's ComputeAction whose op_kind matches. This is what lets the
58+
# downstream `compute_energy_from_actions` look up energy via
59+
# `component.actions[key.action].energy`. With the legacy single-action
60+
# arch ({op_kind: "mac"}, name: "compute") and the default einsum profile
61+
# ({"mac": 1}), this collapses to exactly one ("compute") key per level,
62+
# bit-identical to prior behavior.
5563
for compute, ops in looptree_results.compute_stats.items():
56-
key = compute_keyer(compute, "compute")
57-
if key not in actions:
58-
actions[key] = ActionCount.default()
59-
actions[key].total += ops.total_ops
60-
actions[key].max_per_unit += ops.max_per_unit_ops
64+
for op_kind, total in ops.total_ops.items():
65+
action_name = _resolve_compute_action_name(spec, compute.level, op_kind)
66+
key = compute_keyer(compute, action_name)
67+
if key not in actions:
68+
actions[key] = ActionCount.default()
69+
actions[key].total += total
70+
actions[key].max_per_unit += ops.max_per_unit_ops.get(op_kind, 0)
6171

6272
for network, stats in looptree_results.network_stats.items():
6373
key = network_keyer(network, "hops")
@@ -70,7 +80,6 @@ def gather_actions(
7080

7181
return actions
7282

73-
7483
def _apply_actions_scale(actions, spec):
7584
components = {}
7685
for key, count in actions.items():
@@ -80,6 +89,11 @@ def _apply_actions_scale(actions, spec):
8089
count.total *= scale
8190
count.max_per_unit *= scale
8291

92+
def _resolve_compute_action_name(spec: Spec, level: str, op_kind: str) -> str:
93+
"""Map (compute level, op_kind) to the matching ComputeAction's name.
94+
"""
95+
component = spec.arch.find(level)
96+
return component.action_for_op_kind(op_kind).name
8397

8498
def _get_buffet_keyer(verbose, use_name, bindings):
8599
if not verbose:

accelforge/model/_looptree/latency/latency.py

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -47,13 +47,20 @@ def calculate_compute_latency(reuse_analysis_results, mapping, workload):
4747

4848
def compute_summarized_latency(compute_stats, mapping, workload):
4949
# TODO: this is only for single-Einsum!!!
50+
# `stats.max_latency` is a dict[op_kind, cycles]. Sum across op_kinds
51+
# within a ComputeStats entry (matching the Compute's default
52+
# total_latency = sum(*action2latency.values())), then take the max
53+
# across entries -- i.e., sum-then-max. The cross-stats max here mirrors
54+
# Max(comp_latency, ...) in get_latency(), keeping this code path
55+
# consistent with the per-component path in latency/memory.py.
5056
longest_compute_latency = 0
5157
for stats in compute_stats.values():
58+
per_iter_latency = sum(stats.max_latency.values(), 0)
5259
if longest_compute_latency == 0:
53-
longest_compute_latency = stats.max_latency
60+
longest_compute_latency = per_iter_latency
5461
else:
5562
longest_compute_latency = MaxGeqZero(
56-
longest_compute_latency, stats.max_latency
63+
longest_compute_latency, per_iter_latency
5764
)
5865
return longest_compute_latency
5966

accelforge/model/_looptree/latency/memory.py

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -103,10 +103,25 @@ def component_latency(
103103
f"Component {component} is not a TensorHolder or Compute"
104104
)
105105

106-
longest_compute_latency = Max(
107-
0, *[s.max_latency for s in looptree_results.compute_stats.values()]
108-
)
109-
component_to_actions[compute_obj.name]["compute"] = longest_compute_latency
106+
# `max_latency` is now a per-op-kind dict ({op_kind: cycles-per-iter-of-worst-iter}).
107+
# For each op_kind, take the max across compute_stats entries (different
108+
# (einsum, compute-level) keys) and inject it under the action's name,
109+
# where action_name is the ComputeAction on this Compute whose op_kind matches.
110+
# The Compute's `total_latency` expression (default sum(*action2latency.values()))
111+
# then turns the per-kind counts into per-kind latency contributions and combines
112+
# them. This implements sum-then-max: sum across op_kinds within a Compute
113+
# (via total_latency), max across compute levels (via the per-kind max here
114+
# and the Max(...) over component latencies at the get_latency layer).
115+
per_kind_max_latency: dict[str, float] = {}
116+
for s in looptree_results.compute_stats.values():
117+
for op_kind, val in s.max_latency.items():
118+
if op_kind in per_kind_max_latency:
119+
per_kind_max_latency[op_kind] = Max(per_kind_max_latency[op_kind], val)
120+
else:
121+
per_kind_max_latency[op_kind] = val
122+
for op_kind, count in per_kind_max_latency.items():
123+
action = compute_obj.action_for_op_kind(op_kind)
124+
component_to_actions[compute_obj.name][action.name] = count
110125

111126
new_component_to_actions: dict[str, list] = {}
112127
for component, action_counts in component_to_actions.items():

accelforge/model/_looptree/reuse/symbolic/_stats.py

Lines changed: 42 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -176,13 +176,39 @@ def blank(cls):
176176
stats.n_loops_above = None # Inherit from whoever is added to this
177177
return stats
178178

179+
def _scale_op_dict(d: dict[str, Any], factor: Any) -> dict[str, Any]:
180+
"""Multiply every per-op-kind value by `factor`. Identity at factor==1."""
181+
if factor == 1:
182+
return dict(d)
183+
if isinstance(factor, float) and factor == int(factor):
184+
factor = int(factor)
185+
return {k: v * factor for k, v in d.items()}
186+
187+
188+
def _sum_op_dicts(a: dict[str, Any], b: dict[str, Any]) -> dict[str, Any]:
189+
"""Per-op-kind sum. Keys present in only one operand are kept as-is."""
190+
out = dict(a)
191+
for k, v in b.items():
192+
out[k] = out[k] + v if k in out else v
193+
return out
194+
195+
196+
def _max_op_dicts(a: dict[str, Any], b: dict[str, Any]) -> dict[str, Any]:
197+
"""Per-op-kind MaxGeqZero. Keys present in only one operand are kept as-is."""
198+
out = dict(a)
199+
for k, v in b.items():
200+
out[k] = MaxGeqZero(out[k], v) if k in out else v
201+
return out
179202

180203
@dataclass
181204
class ComputeStats:
182-
total_ops: Any = field(default=0)
183-
max_per_unit_ops: Any = field(default=0)
205+
# Per-op-kind counts. Keys are op_kind strings (e.g. "mul", "add", "mac")
206+
# matching the ComputeAction.op_kind values declared on the arch's Compute
207+
# component. An empty dict means no contribution.
208+
total_ops: dict[str, Any] = field(default_factory=dict)
209+
max_per_unit_ops: dict[str, Any] = field(default_factory=dict)
184210
# "max" below refers to the longest latency of any iteration
185-
max_latency: Any = field(default=0)
211+
max_latency: dict[str, Any] = field(default_factory=dict)
186212
# Mapping from the loop-index (0 at top) to the latency of the first
187213
# iteration of that loop. "Max" because we may have loops above that and we
188214
# will take the maximum of the firsts.
@@ -194,9 +220,9 @@ def repeat_temporal(self, factor: int) -> "ComputeStats":
194220
return new
195221
if type(factor) is float and factor == int(factor):
196222
factor = int(factor)
197-
new.total_ops = new.total_ops * factor
198-
new.max_per_unit_ops = new.max_per_unit_ops * factor
199-
new.max_latency = new.max_latency * factor
223+
new.total_ops = _scale_op_dict(new.total_ops, factor)
224+
new.max_per_unit_ops = _scale_op_dict(new.max_per_unit_ops, factor)
225+
new.max_latency = _scale_op_dict(new.max_latency, factor)
200226
# NOTE: max_first_latency does not change
201227
return new
202228

@@ -206,14 +232,14 @@ def repeat_spatial(self, factor: int) -> "ComputeStats":
206232
return new
207233
if type(factor) is float and factor == int(factor):
208234
factor = int(factor)
209-
new.total_ops = new.total_ops * factor
235+
new.total_ops = _scale_op_dict(new.total_ops, factor)
210236
return new
211237

212238
def __add__(self, other: "ComputeStats") -> "ComputeStats":
213239
new = copy.copy(self)
214-
new.total_ops += other.total_ops
215-
new.max_per_unit_ops += other.max_per_unit_ops
216-
new.max_latency += other.max_latency
240+
new.total_ops = _sum_op_dicts(new.total_ops, other.total_ops)
241+
new.max_per_unit_ops = _sum_op_dicts(new.max_per_unit_ops, other.max_per_unit_ops)
242+
new.max_latency = _sum_op_dicts(new.max_latency, other.max_latency)
217243
# max_first_latency is only ever updated across loops ABOVE the loop
218244
# for which we calculated that first latency, so we should MAX
219245
new.max_first_latency = max_dict(
@@ -222,21 +248,19 @@ def __add__(self, other: "ComputeStats") -> "ComputeStats":
222248
return new
223249

224250
def combine_temporal(self, other: "ComputeStats"):
225-
self.total_ops += other.total_ops
226-
self.max_per_unit_ops += other.max_per_unit_ops
227-
self.max_latency += other.max_latency
251+
self.total_ops = _sum_op_dicts(self.total_ops, other.total_ops)
252+
self.max_per_unit_ops = _sum_op_dicts(self.max_per_unit_ops, other.max_per_unit_ops)
253+
self.max_latency = _sum_op_dicts(self.max_latency, other.max_latency)
228254
# max_first_latency is only ever updated across loops ABOVE the loop
229255
# for which we calculated that first latency, so we should MAX
230256
self.max_first_latency = max_dict(
231257
self.max_first_latency, other.max_first_latency
232258
) # FIRST LATENCY
233259

234260
def combine_spatial(self, other: "ComputeStats"):
235-
self.total_ops += other.total_ops
236-
self.max_per_unit_ops = MaxGeqZero(
237-
self.max_per_unit_ops, other.max_per_unit_ops
238-
)
239-
self.max_latency = MaxGeqZero(self.max_latency, other.max_latency)
261+
self.total_ops = _sum_op_dicts(self.total_ops, other.total_ops)
262+
self.max_per_unit_ops = _max_op_dicts(self.max_per_unit_ops, other.max_per_unit_ops)
263+
self.max_latency = _max_op_dicts(self.max_latency, other.max_latency)
240264
# max_first_latency is only ever updated across loops ABOVE the loop
241265
# for which we calculated that first latency, so we should MAX
242266
self.max_first_latency = max_dict(

0 commit comments

Comments
 (0)