diff --git a/accelforge/model/_looptree/accesses.py b/accelforge/model/_looptree/accesses.py index d54f2999..a643a5d6 100755 --- a/accelforge/model/_looptree/accesses.py +++ b/accelforge/model/_looptree/accesses.py @@ -5,7 +5,7 @@ import islpy as isl -from accelforge.model._looptree.reuse.isl import IslReuseAnalysisOutput +from accelforge.model._looptree.reuse.isl.des import IslReuseAnalysisOutput from accelforge.util._frozenset import oset from accelforge.model._looptree.reuse.symbolic import ( BuffetStats, diff --git a/accelforge/model/_looptree/latency/latency.py b/accelforge/model/_looptree/latency/latency.py index ab1810bd..9cdfadc4 100755 --- a/accelforge/model/_looptree/latency/latency.py +++ b/accelforge/model/_looptree/latency/latency.py @@ -4,7 +4,7 @@ # from accelforge.model._looptree._isl.singular import get_value_from_singular_qpolynomial from accelforge.frontend.arch import Compute from accelforge.model._looptree.latency.processors import LATENCY_PROCESSORS -from accelforge.model._looptree.reuse.isl import IslReuseAnalysisOutput +from accelforge.model._looptree.reuse.isl.des import IslReuseAnalysisOutput from accelforge.model._looptree.reuse import SymbolicAnalysisOutput from accelforge.util._sympy.broadcast_max import max_nonzero diff --git a/accelforge/model/_looptree/latency/memory.py b/accelforge/model/_looptree/latency/memory.py index aeafabea..ab0d8471 100755 --- a/accelforge/model/_looptree/latency/memory.py +++ b/accelforge/model/_looptree/latency/memory.py @@ -8,7 +8,7 @@ from accelforge.frontend.mapping import TensorHolder as MappingTensorHolder from accelforge.model._looptree.accesses import isl_buffer_accesses_from_buffet_actions -from accelforge.model._looptree.reuse.isl import IslReuseAnalysisOutput +from accelforge.model._looptree.reuse.isl.des import IslReuseAnalysisOutput from accelforge.model._looptree.reuse import SymbolicAnalysisOutput from accelforge.model._looptree.types import Buffet diff --git a/accelforge/model/_looptree/reuse/isl/__init__.py b/accelforge/model/_looptree/reuse/isl/__init__.py index e295e773..e69de29b 100755 --- a/accelforge/model/_looptree/reuse/isl/__init__.py +++ b/accelforge/model/_looptree/reuse/isl/__init__.py @@ -1,5 +0,0 @@ -from .des import IslReuseAnalysisOutput - -__all__ = [ - "IslReuseAnalysisOutput", -] diff --git a/accelforge/model/_looptree/reuse/isl/des.py b/accelforge/model/_looptree/reuse/isl/des.py index a7890af3..7b8d2de4 100755 --- a/accelforge/model/_looptree/reuse/isl/des.py +++ b/accelforge/model/_looptree/reuse/isl/des.py @@ -1,6 +1,5 @@ """ -TODO: Is this file still necessary? It is referenced elsewhere but is no longer - the format we are looking for. +Deserializes LoopTree reuse-analysis output into ISL objects. """ from dataclasses import dataclass, field diff --git a/accelforge/model/_looptree/reuse/isl/distributed/README.md b/accelforge/model/_looptree/reuse/isl/distributed/README.md new file mode 100644 index 00000000..696c7f77 --- /dev/null +++ b/accelforge/model/_looptree/reuse/isl/distributed/README.md @@ -0,0 +1,529 @@ +# Transfer models: `HypercubeMulticastModel` and how to write a new one + +This directory holds **distributed / network cost models** for spatial reuse analysis. This +document explains how `HypercubeMulticastModel` works and gives a step-by-step recipe (plus a fully +worked example) for adding your own model. + +--- + +## 1. Overview + +A **transfer model** estimates the on-chip data-movement cost of a mapping: given where data +*lives* (occupancy) and where it is *needed* (fills), it computes how many network *hops* the +delivery costs and which transfers are fulfilled peer-to-peer vs. read from a parent. + +All transfer models implement one abstract interface, `TransferModel`. The concrete +implementations: + +| Model | File | Shape | +|-------|------|-------| +| `SimpleLinkTransferModel` | [`../spatial.py`](../spatial.py) | Neighbor-to-neighbor mesh; no constructor state | +| `HypercubeMulticastModel` | [`distributed_buffers.py`](distributed_buffers.py) | Distance-aware worst-case multicast; takes a `dist_fn` | +| `FullyConnectedMulticastModel` | [`distributed_buffers.py`](distributed_buffers.py) | Fully-connected fabric; 1 hop per fabric crossing (see §6) | +| `XYRoutingMulticastModel` | [`distributed_buffers.py`](distributed_buffers.py) | XY / dimension-order routing on a 2-D mesh; X-then-Y multicast tree; per-link `EdgePressure` (see §7–§8) | +| `StarMulticastModel` | [`distributed_buffers.py`](distributed_buffers.py) | Star / central-switch — the spokes realization of a fully-connected fabric; per-spoke `EdgePressure` (see §8). Parameter-free constant regime only, like XY | + +> **There is no registry or factory.** Models are constructed directly +> (`HypercubeMulticastModel(dist_fn)`) and applied via `.apply(...)`. The only current usage is the +> test suite — [`tests/isl/distributed/test_multicast.py`](../../../../../../tests/isl/distributed/test_multicast.py). +> The suite lives at `tests/isl/distributed/` and runs as part of CI. + +> **Module layout.** This directory splits into three files: the four model classes above and +> the shared `MulticastModel` base they all inherit (one `apply()`, one abstract `_transfer_cost` +> hook per model) live in [`distributed_buffers.py`](distributed_buffers.py); the `EdgePressure` +> per-link-load abstraction and its scalar-extraction helpers live in +> [`edge_pressure.py`](edge_pressure.py); multicast-network construction (`identify_mesh_casts` +> and the helpers built on its result) lives in [`mesh_casts.py`](mesh_casts.py). + +--- + +## 2. The `TransferModel` contract + +Defined in [`../spatial.py`](../spatial.py). You implement exactly one abstract method: + +```python +class TransferModel(ABC): + @abstractmethod + def apply(self, buff: MappingNode, fills: Fill, occs: Occupancy) -> TransferInfo: + ... +``` + +### Inputs + +- **`buff: MappingNode`** — the buffer being analyzed. (`HypercubeMulticastModel` ignores it and + relies on its `dist_fn` instead; `SimpleLinkTransferModel` uses it to find spatial dims.) +- **`fills: Fill`** — a *tagged* `isl.Map` `{ [spacetime] -> [data] }` describing what each element + needs from its parent over time. +- **`occs: Occupancy`** — a *tagged* `isl.Map` `{ [spacetime] -> [data] }` describing what each + element holds over time. + +`Fill` and `Occupancy` are `TaggedMap`s (see +[`../mapping_to_isl/types.py`](../mapping_to_isl/types.py)): the raw relation is `.map_`, and +`.tags` is a list of `Tag`s labeling each **input** dimension. Both constructors assert +`len(tags) == map_.dim(isl.dim_type.in_)`. The relevant tags are: + +- `TemporalTag()` — that dimension spreads over time. +- `SpatialTag(spatial_dim, buffer)` — that dimension spreads over space, in `buffer`. + +### Output: `TransferInfo` + +A frozen dataclass (in `../spatial.py`) you must fully populate: + +| Field | Type | Meaning | +|-------|------|---------| +| `fulfilled_fill` | `Transfers` | Fills satisfied by peer-to-peer transfers (a tagged map) — the fills *covered* by a matched multicast source. | +| `unfulfilled_fill` | `Fill` | Fills *not* satisfied — no source held the datum, so it must come from higher in the hierarchy. | +| `parent_reads` | `Reads` | Fills satisfied by parent-to-child reads. | +| `hops` | `isl.PwQPolynomial` | The transfer cost metric across spacetime. | +| `link_transfer` | `bool` | Metadata flag — whether this used link transfers. | +| `edge_pressure` | `Optional[EdgePressure]` | Per-directed-edge load backing `hops` (see §8), for models that define a per-link topology. Defaults to `None`. | + +`fulfilled_fill` and `unfulfilled_fill` **partition the fills exactly**: with +`covered = _covered_fills(mcs)` (the `{ dst -> data }` pairs `identify_mesh_casts` matched to a +source), `fulfilled = fills ∩ covered` and `unfulfilled = fills − covered`. A destination requesting +a datum that *no* source holds simply never appears in the multicast networks, so it lands in +`unfulfilled_fill` — it is never silently treated as fulfilled. + +`edge_pressure` is populated by the models with an explicit per-link topology +(`XYRoutingMulticastModel`: mesh links; `StarMulticastModel`: spokes) inside `apply()`, from the +same `identify_mesh_casts` result as `hops`. `HypercubeMulticastModel` and +`FullyConnectedMulticastModel` leave it `None` — a convex bounding box has no notion of individual +links, and a contention-free full mesh (one dedicated link per pair) has no shared-link pressure to +report. + +`Transfers` and `Reads` are thin `TaggedMap` subclasses; construct them as +`Transfers(tags, map_)` / `Reads(tags, map_)`. + +--- + +## 3. How `HypercubeMulticastModel` works + +> **Worst-case multicast.** It assumes every multicast broadcasts to the *convex hypercube* that +> encloses all of its sources and destinations — an upper bound on the real cost. + +### Constructor + +```python +HypercubeMulticastModel(dist_fn: isl.Map) +``` + +`dist_fn` is a distance function `{ [dst -> src] -> [hops] }` — note the orientation: its domain is +a **`[dst -> src]` pair** (destination first), because `identify_mesh_casts` composes it as +`fills_to_matches.apply_range(dist_fn)` onto `{ ... -> [dst -> src] }` maps. Two further caller +contracts: the tuple names in `dist_fn`'s domain must match the spacetime tuple names of +`fills`/`occs` (ISL raises on a name mismatch when `dist_fn` is applied), and its range tuple must +be named `hops` (the fully-connected and star models filter on `{ hops[h] : h >= 1 }` to tell +self-deliveries apart from fabric crossings). Two assumptions are baked in: + +1. **Orthogonal dimensions / Manhattan distance** — each unit move along a dimension costs 1 hop, + and dimensions are orthogonal in the metric space. +2. **Translational invariance** — distance depends only on the displacement: if + `|src − dst| = |src' − dst'|` then `dist_fn(dst, src) = dist_fn(dst', src')`. + +These come from `calculate_extents_per_dim`, which the model relies on. + +### `apply(...)` data flow + +``` +occs.map_ { [spacetime] -> [data] } fills.map_ { [spacetime] -> [data] } + \ / + \ / + identify_mesh_casts(occs, fills, dist_fn) + | + v + mcs : { [data] -> [dst -> src] } (each datum's nearest-source multicast network) + | + _cost_mesh_cast_hypercube(mcs) + | + v + hops : isl.PwQPolynomial (total upper-bound hop count) +``` + +**Step A — `identify_mesh_casts(src_occupancy, dst_fill, dist_fn)`** +([`mesh_casts.py`](mesh_casts.py)). + +For every datum, it pairs the destinations that request it with the *nearest* source that holds it. +Conceptually: + +1. Reverse occupancy to `{ [data] -> [src] }` (which elements hold each datum). +2. Match each fill `{ [dst] -> [data] }` against the sources holding that datum → + `{ [dst -> data] -> [dst -> src] }`. +3. Apply `dist_fn` and take `lexmin` over distance to keep only the closest source per + `(dst, data)`. +4. Regroup as `{ [data] -> [dst -> src] }` and `lexmin` again to *devolve to a single source* when + several are equidistant. + +The result is the set of **multicast networks** (`mcns`): per datum, the destinations grouped with +their chosen source. + +**Step B — extents per dimension: `calculate_extents_per_dim(mcns)`** +([`mesh_casts.py`](mesh_casts.py)). + +For each multicast network it unions the sources with the destinations, then for each NoC dimension +projects away the others and takes `dim_max − dim_min`. That difference is the **extent** (the side +length of the bounding box) along that dimension, returned as one `isl.PwAff` per dimension. + +**Step C — hypercube cost: `_cost_mesh_cast_hypercube(mcns)`** +([`distributed_buffers.py`](distributed_buffers.py)). + +It folds the per-dimension extents into a single cost, then `.sum()`s over all networks. + +> ⚠️ **Known discrepancy — this is the bug currently being tracked.** +> The docstring/comment says the cost is +> `(∏_i extent_i) − 1` (the number of *interior* points of the bounding box minus one — the +> standard hypercube broadcast cost). **But the code actually computes** +> +> ``` +> cost = (∏_i (extent_i + 1)) − 1 +> ``` +> +> because the loop multiplies by `dim_extent.add(one)` (`extent_i + 1`), not `extent_i`. Since +> `extent_i = max − min` is already *one less* than the number of points along a dimension, adding +> 1 back double-counts the span. This is consistent with the observed **~3× overestimate of hops in +> all-to-all** topologies noted in recent work. Treat the formula in the code as *not yet correct*; +> a new template should decide deliberately whether it wants `extent_i` or `extent_i + 1`. + +### Return value + +```python +# { dst -> data } fills actually covered by a matched source. +covered: isl.Map = _covered_fills(mcs) + +return TransferInfo( + fulfilled_fill=Transfers(fills.tags, fills.map_.intersect(covered)), + parent_reads=Reads(occs.tags, mcs), # the multicast map + unfulfilled_fill=Fill(fills.tags, fills.map_.subtract(covered)), + hops=result, # the PwQPolynomial cost + link_transfer=True, + # No per-link decomposition defined for the hypercube abstraction. + edge_pressure=None, +) +``` + +Note the partition idiom: `_covered_fills(mcs)` reshapes the multicast networks +`{ [data] -> [dst -> src] }` into `{ dst -> data }` (via +`range_reverse().uncurry().domain_factor_domain().reverse()` — "forget which source served it, keep +dst and data"), which lines up with `fills.map_` so `intersect`/`subtract` split the fills into the +covered and uncovered halves. Earlier revisions returned the *entire* fill map as `fulfilled_fill` +and an always-empty `unfulfilled_fill` (`fills.map_.subtract(fills.map_)`); that silently +mis-reported fills whose datum no source holds — do not copy that idiom. + +--- + +## 4. Contrast: `SimpleLinkTransferModel` + +For comparison (the *other* valid shape — no constructor state), `SimpleLinkTransferModel` +([`../spatial.py`](../spatial.py)): + +- Takes **no `dist_fn`**; constructed as `SimpleLinkTransferModel()`. +- Asserts `fills.tags == occs.tags`. +- Builds a **neighbor-to-neighbor mesh** via `make_mesh_connectivity` (only 1 or 2 spatial dims + supported — raises otherwise). +- Data reachable from a neighbor is `fulfilled_fill`; the rest is `unfulfilled_fill`. +- `hops` is **1 per neighbor-filled element** (`PwQPolynomial.one_on_domain(...)`), not a + distance-weighted sum. +- Has an early-out: if there is no temporal dimension or no spatial dimension, nothing moves, so it + returns an empty/zero `TransferInfo`. + +So these two models bracket the design space: **stateful + distance-aware** (hypercube) vs. +**stateless + fixed-topology** (simple link). + +--- + +## 5. How to create a new template + +1. **Pick a home.** Add your class under `reuse/isl/`. Put distributed / NoC / mesh models in this + `distributed/` directory next to `distributed_buffers.py`. +2. **Subclass `MulticastModel`** if your model is distance-driven multicast (one `dist_fn`, + nearest-source matching) — the base owns the constructor, the single `identify_mesh_casts` + call, the fill partition, and the `TransferInfo` assembly. Only subclass `TransferModel` + directly (and write your own `apply`) for a different shape entirely, like the stateless + `SimpleLinkTransferModel`. +3. **Implement `_transfer_cost(self, mcs) -> isl.PwQPolynomial | EdgePressure`.** `mcs` is the + `{ [data] -> [dst -> src] }` multicast networks, computed once by the base `apply`. Reuse the + existing kernels: + - `calculate_extents_per_dim(mcns)` if you want per-dimension bounding-box extents. + - `_fabric_crossing(mcns, self.dist_fn)` to keep only deliveries that cross the fabric. + - `_edge_pressure_from_links(...)` if you report a per-link `EdgePressure` (see §8) — return + it directly and the base derives `hops` from its total; return a bare `isl.PwQPolynomial` + and the base leaves `edge_pressure` at `None`. +4. **Honor the invariants.** The base guarantees the big ones structurally: the fill partition + (`fulfilled = fills ∩ covered`, `unfulfilled = fills − covered`), one `identify_mesh_casts` + call per `apply`, and `hops == EdgePressure.total()` whenever you return a pressure. What is + left to you: build the cost over the correct domain, and decide deliberately whether your + topology defines a per-link decomposition or not. +5. **Add a test** mirroring + [`tests/isl/distributed/test_multicast.py`](../../../../../../tests/isl/distributed/test_multicast.py): + a YAML-driven gamut of `(dims, fill, occ, dist_fn, expected_hops)` cases. + +### Copy-paste skeleton + +```python +import islpy as isl + +from accelforge.model._looptree.reuse.isl.distributed.distributed_buffers import ( + MulticastModel, +) + + +class MyMulticastModel(MulticastModel): + """One-line description of the topology/assumptions this model encodes.""" + + def _transfer_cost(self, mcs: isl.Map) -> isl.PwQPolynomial: + # `mcs` is { [data] -> [dst -> src] } from `identify_mesh_casts`, + # computed once by `MulticastModel.apply`; the fill partition and the + # `TransferInfo` assembly are already handled there. + # TODO: your cost kernel. Return an `EdgePressure` instead (see §8) if + # your topology defines per-link loads -- `apply` then derives `hops` + # from its total automatically. + raise NotImplementedError +``` + +(A non-multicast model — different matching, no `dist_fn` — instead subclasses `TransferModel` +directly and implements the full `apply`; use `SimpleLinkTransferModel` in +[`../spatial.py`](../spatial.py) as the reference for that shape.) + +**Checklist** + +- [ ] Subclasses `MulticastModel` and implements `_transfer_cost` (only non-multicast shapes + subclass `TransferModel` and hand-roll `apply`). +- [ ] Constructor state matches what the cost kernel needs (the base stores `dist_fn`; override + `__init__` only to add state). +- [ ] `_transfer_cost` returns an `isl.PwQPolynomial` over the right domain — or an + `EdgePressure` for per-link topologies, making `hops == Σ_edges load` structural. +- [ ] A YAML-driven gamut test exists. + +--- + +## 6. Worked example: `FullyConnectedMulticastModel` + +A real, tested model for a **fully-connected fabric** (e.g. an NVSwitch-style all-to-all). It lives +next to the hypercube model in [`distributed_buffers.py`](distributed_buffers.py). + +On a fully-connected fabric every cross-node delivery costs **one hop regardless of distance**, and a +self-delivery (source node == destination node) costs **zero**. So the cost of a mapping is just the +number of deliveries that actually traverse the fabric: + +``` +cost = | { (data, dst, src) ∈ mcs : dist_fn(dst, src) ≥ 1 } | +``` + +`dist_fn` is used *only* to tell self-deliveries (0 hops) apart from fabric-crossing ones — its hop +*magnitude* never enters the cost. This sidesteps the hypercube extent overestimate (see §3) entirely. + +```python +class FullyConnectedMulticastModel(MulticastModel): + """Multicast cost on a fully-connected fabric: 1 hop per fabric crossing.""" + + def _transfer_cost(self, mcs: isl.Map) -> isl.PwQPolynomial: + return self._cost_fully_connected(mcs) + + def _cost_fully_connected(self, mcns: isl.Map) -> isl.PwQPolynomial: + """Count the deliveries in `mcns` that traverse the fabric (dist >= 1).""" + # [dst -> src] pairs that actually traverse the fabric (>= 1 hop). + crossing: isl.Map = _fabric_crossing(mcns, self.dist_fn) + return crossing.wrap().card() +``` + +How the kernel works: `_fabric_crossing` ([`mesh_casts.py`](mesh_casts.py)) computes +`dist_fn.intersect_range({ hops[h] : h ≥ 1 }).domain()` — the set of `[dst -> src]` pairs that +cross the fabric — and intersects `mcns`' range with it, keeping only crossing deliveries; +`.wrap().card()` then counts the `(data, dst, src)` points as an `isl.PwQPolynomial` (constant +when there are no parameters). Everything else — the single `identify_mesh_casts` call, the fill +partition, `edge_pressure=None` — is `MulticastModel.apply`'s job, not this class's. + +**Verified numbers** (8-GPU one-hot encoding, from the test below): + +| Case | `FullyConnectedMulticastModel` | `HypercubeMulticastModel` | +|------|-------------------------------|---------------------------| +| all-to-all (64 chunks, 8 self) | **56** | 168 | +| single unicast GPU0 → GPU3 | **1** | 3 | +| self chunk GPU5 → GPU5 | **0** | 0 | + +The all-to-all column is the headline: **56 vs 168 is exactly the ~3× overestimate** the hypercube +model incurs on a fully-connected fabric (each unicast costed as a `(1+1)(1+1) − 1 = 3` hypercube +instead of a single crossing — see the discrepancy in §3). + +This example is exercised by a real test: +[`tests/isl/distributed/test_fully_connected.py`](../../../../../../tests/isl/distributed/test_fully_connected.py) +with cases in +[`tests/isl/distributed/fully_connected/test_cases.yaml`](../../../../../../tests/isl/distributed/fully_connected/test_cases.yaml). +Run it (m4 on `PATH` per the islpy-barvinok setup): + +```bash +PATH="$HOME/.local/bin:$PATH" .venv/bin/python -m pytest \ + tests/isl/distributed/test_fully_connected.py -q +``` + +--- + +## 7. XY (dimension-order) routing: `XYRoutingMulticastModel` + +`XYRoutingMulticastModel` ([`distributed_buffers.py`](distributed_buffers.py)) models **XY routing** +on a 2-D mesh: every packet travels **along X first, then Y**. A multicast from one source is +therefore a rigid tree: + +1. an **X segment** along the source's row, reaching every column that holds a destination, then +2. an independent **Y segment** down each of those columns, *starting from the source's row*. + +The cost of one tree (source `s = (xs, ys)`, destinations `D`) is: + +``` +x_extent({xs} ∪ {dst columns}) + + Σ over destination columns xd of y_extent({ys} ∪ {dst y's in column xd}) +``` + +Source selection is **per destination** (`identify_mesh_casts` pairs each destination with its +nearest source, devolving ties); destinations sharing a source form one tree, and the model sums +over all trees and all data. + +### Where it sits: `extent_DOR` (floor) ≤ XY ≤ hypercube + +Because each column's Y segment restarts from the source row instead of sharing a trunk, XY is an +**upper bound on free routing** and a **lower bound on the hypercube** (which reaches every node in +the bounding box). Note `extent_DOR_hops` in the test yamls is the **free-routing floor**, *not* the +XY cost — it assumes routing can move freely between dimensions. Worked example (verified by the +model): source `(1,0)` casting to `(0,2)` and `(2,2)`: + +``` + y=2 D . D + y=1 | . | + y=0 +-S-+ S=(1,0); X covers cols 0..2 (2 links) + x=0 1 2 then Y down col 0 and col 2 from row 0 (2 + 2 links) + + floor (extent_DOR) = 4 ≤ XY = 6 ≤ hypercube = 8 +``` + +### Cost kernel + +There is no standalone cost method: the cost *is* the per-edge decomposition, aggregated. The +whole model is one hook — + +```python +def _transfer_cost(self, mcs: isl.Map) -> EdgePressure: + return _edge_pressure_from_links(self._directed_mesh_links(mcs)) +``` + +— because it returns an `EdgePressure` (see §8), `MulticastModel.apply` sets `edge_pressure` to +it and derives `hops = _const_pwq(pressure.total())` in one place: one aggregation path, so the +scalar and the per-edge view can never disagree (Σ_edges load == total link count, structurally). + +`_directed_mesh_links` builds the X segments explicitly along each source row (out to every +destination column, split rightward/leftward at the source column `xs`) and the Y segments down +each destination column from the source row (split upward/downward at `ys`), keeping every link's +identity — see §8 for the edge naming. + +Notes / limitations: +- **2-D node tuples only** (no temporal dims); `apply` raises a `ValueError` ("XYRoutingMulticastModel + requires a 2-D node tuple (X then Y)...") for any other arity — N-D dimension-order routing is a + TODO. The tuple *name* is generic: it is read off the maps at `apply` time (via + `_mesh_node_tuple`), so `noc[x, y]`, `pe[x, y]`, etc. all work as long as `fills`, `occs`, and + `dist_fn` agree on it (see the caller contract in §3). +- Each per-column/per-row link set is built explicitly and counted via `card()` rather than summing + a min/max polynomial — the latter trips a barvinok `summate` assertion at scale (e.g. the 8×8 + case). +- Returns a **parameter-free constant** (the validated regime); parametric spacetimes are future work. + +### Tested + +[`tests/isl/distributed/test_xy_routing.py`](../../../../../../tests/isl/distributed/test_xy_routing.py) +with hand-derived cases in +[`tests/isl/distributed/xy_routing/test_cases.yaml`](../../../../../../tests/isl/distributed/xy_routing/test_cases.yaml) +(there is **no XY oracle in the repo**, so the expected values are hand-derived and each case carries +its geometry). Cases: unicast `4`, the `(1,0)` discriminator `6`, three-corner `6`, 1-D column `3`, +replicated-source `4`, and an 8×8 scale case `448`. Run: + +```bash +PATH="$HOME/.local/bin:$PATH" .venv/bin/python -m pytest \ + tests/isl/distributed/test_xy_routing.py -q +``` + +--- + +## 8. Per-edge memory pressure (link load): `EdgePressure` + +`hops` collapses a whole routing to one scalar, which hides a real constraint: **each physical edge +has a finite bandwidth**, so the *busiest* link is what saturates first. `EdgePressure` keeps the +load broken out per directed physical edge — `{ edge -> number-of-trees-crossing-it }` — which is +exactly the quantity the production symbolic path calls `max_traffic` and feeds into the `Network` +latency formula `max(max_hops·latency, max_link_traffic / throughput)` +([`frontend/arch/components.py`](../../../../frontend/arch/components.py)). It is exposed as the +`TransferInfo.edge_pressure` field: `model.apply(buff, fills, occs).edge_pressure`, an +`Optional[EdgePressure]` populated by `XYRoutingMulticastModel` and `StarMulticastModel` inside +`apply()` (from the same `identify_mesh_casts` result as `hops`, so the two are always consistent) +and `None` for `HypercubeMulticastModel` / `FullyConnectedMulticastModel` (see §2). There is no +standalone `edge_pressure(fills, occs)` method. + +### The primitive + +After `identify_mesh_casts` fixes the `[dst -> src]` pairs, build, per directed edge type, a map +`M = { [data -> src] -> edge }` associating each multicast **tree** with every edge its route +crosses. Then (`_edge_pressure_from_links` does exactly this, unioning the per-type results) + +```python +load = M.reverse().card() # { edge -> #trees crossing it } +``` + +The key is the tree `(data, src)`, **not** the destination: within one tree a link is traversed once +regardless of how many leaves hang off it, so this counts *pressure* (distinct flows on a link), not +summed hops. `EdgePressure.total()` sums the load over every edge (this is what XY/Star `hops` is +built from, via `_const_pwq`); `bottleneck()` returns the max load (enumerating the finite, +parameter-free edge domain); `eval_edge(name, coords)` looks up one edge. + +### XY routing → directed mesh edges + +`XYRoutingMulticastModel._directed_mesh_links` decomposes each tree onto four directed link types: +`xedge_r`/`xedge_l` along the source row (split at the source column `xs`) and `yedge_u`/`yedge_d` +down each destination column (split at the source row `ys`). This **is** the §7 cost kernel — every +link is built explicitly with its identity kept (not via `calculate_extents_per_dim`, which only +yields a scalar length and discards *which* links are used), and the scalar `hops` is just +`EdgePressure.total()` over these maps. + +Decisive cross-check (no oracle needed): **`Σ_edges load == total XY hops`**. The per-edge loads must +sum back to the already-trusted A–F totals (4/6/6/3/4/448), so a wrong decomposition fails. Worked F +geometry (8×8, datum `(d0,d1)` held at node `(d0,d1)`, requested by all of column `x=d0`): every +datum of column `c` floods all 7 vertical links of column `c`, so `yedge_u[c,t]` load = `t+1` and +`yedge_d[c,t]` = `7−t`; the busiest directed link is `7` (the physical link total is `8`). Cases A–E +are single trees, so every edge load is `1`. + +### Star / fully-connected → spokes + +`StarMulticastModel` **is** the spokes realization of the fully-connected fabric (NVSwitch-style: each +node has one link to a central switch). A delivery routes `src → switch → dst`, so each datum loads +its source's egress spoke once (multicast fans out *at the switch*) and each destination's ingress +spoke once; self-deliveries (0 hops) load nothing. `_spoke_loads` returns `spoke_in[n]` (ingress) and +`spoke_out[n]` (egress) loads. For an N-way all-to-all every node receives `N−1` and sources `1`, so +the **ingress spokes are hottest at `N−1`** — this is where bandwidth actually bites, whereas +`FullyConnectedMulticastModel`'s contention-free full-mesh view has no hotspot. The two are tied by +**`Σ_nodes ingress == FullyConnected crossing count`** (every crossing delivery is one node's +ingress; e.g. 8-GPU all-to-all: `56`). The star scalar `hops` = `Σ egress + Σ ingress` (injections + +deliveries). + +### Tested + +[`tests/isl/distributed/test_edge_pressure.py`](../../../../../../tests/isl/distributed/test_edge_pressure.py) +— the XY `Σ load == hops` invariant over A–F (with the pressure taken from +`apply(...).edge_pressure`), F's bottleneck/edge loads (`7`, `yedge_u[0,6]=7`, `yedge_d[0,1]=6`), +single-tree unit bottlenecks, and the star spoke loads / `Σ ingress == FC count` invariant for 4- +and 8-GPU all-to-all (star `hops` = injections + deliveries: `16`/`64`, ingress bottleneck +`3`/`7`). Run: + +```bash +PATH="$HOME/.local/bin:$PATH" .venv/bin/python -m pytest \ + tests/isl/distributed/test_edge_pressure.py -q +``` + +--- + +## 9. References + +- Interface + `SimpleLinkTransferModel` + `TransferInfo`: [`../spatial.py`](../spatial.py) +- `MulticastModel` base + the four model classes, `HypercubeMulticastModel`'s + `_cost_mesh_cast_hypercube`: [`distributed_buffers.py`](distributed_buffers.py) +- `identify_mesh_casts`, `calculate_extents_per_dim`, `_covered_fills`, `_mesh_node_tuple`: + [`mesh_casts.py`](mesh_casts.py) +- `EdgePressure`, `_eval_const`, `_const_pwq`, `_edge_pressure_from_links`: + [`edge_pressure.py`](edge_pressure.py) +- Tagged-map / tag types: [`../mapping_to_isl/types.py`](../mapping_to_isl/types.py) +- Example test harness: + [`tests/isl/distributed/test_multicast.py`](../../../../../../tests/isl/distributed/test_multicast.py) diff --git a/accelforge/model/_looptree/reuse/isl/distributed/bind.py b/accelforge/model/_looptree/reuse/isl/distributed/bind.py index e95b2ca5..7da0a0e4 100644 --- a/accelforge/model/_looptree/reuse/isl/distributed/bind.py +++ b/accelforge/model/_looptree/reuse/isl/distributed/bind.py @@ -1,6 +1,6 @@ """Applies the binding layer into one that can be used for later analysis,""" -from accelforge.frontend.binding import Binding +from accelforge.frontend._binding import Binding from accelforge.frontend.mapping import Mapping from accelforge.frontend.workload import Workload diff --git a/accelforge/model/_looptree/reuse/isl/distributed/distributed_buffers.py b/accelforge/model/_looptree/reuse/isl/distributed/distributed_buffers.py index 35d87cb0..92614f56 100644 --- a/accelforge/model/_looptree/reuse/isl/distributed/distributed_buffers.py +++ b/accelforge/model/_looptree/reuse/isl/distributed/distributed_buffers.py @@ -1,15 +1,22 @@ """ Models for handling calculating the cost of a Workload on distributed buffer architectures. + +The shared shape (one `dist_fn`, one `identify_mesh_casts` call per `apply`, +one `TransferInfo` assembly) lives on the `MulticastModel` base class; each +concrete model below contributes only its cost kernel via `_transfer_cost`. +The underlying primitives -- `EdgePressure` and its scalar-extraction helpers, +and the multicast-network construction (`identify_mesh_casts` and friends) -- +live in `edge_pressure.py` and `mesh_casts.py` respectively. """ -import logging +from abc import abstractmethod + +from typing import Optional import islpy as isl from accelforge.frontend.mapping import MappingNode -from accelforge.model._looptree.reuse.isl.isl_functions import dim_projector_mask -from accelforge.model._looptree.reuse.isl.mapping_to_isl import DUMP_ISL_IR from accelforge.model._looptree.reuse.isl.mapping_to_isl.types import Fill, Occupancy from accelforge.model._looptree.reuse.isl.spatial import ( Reads, @@ -18,174 +25,74 @@ TransferModel, ) +from accelforge.model._looptree.reuse.isl.distributed.edge_pressure import ( + EdgePressure, + _const_pwq, + _edge_pressure_from_links, + _union_pwqs, +) +from accelforge.model._looptree.reuse.isl.distributed.mesh_casts import ( + identify_mesh_casts, + calculate_extents_per_dim, + _covered_fills, + _mesh_node_tuple, + _per_src, + _fabric_crossing, +) -def identify_mesh_casts( - src_occupancy: isl.Map, dst_fill: isl.Map, dist_fn: isl.Map -) -> isl.Map: - """ - Given srcs with data, fills to destinations, and a distance function, identify per data - the srcs delivering that data to dsts. - - Parameters - ---------- - src_occupancy: - An isl.Map of the form { [src] -> [data] } corresponding to the data held - at the buffer at space `src`. - dst_fill: - An isl.Map of the form { [dst] -> [data] } corresponding to the data requested - at the element at space `dst`. - dist_fn: - A distance function { [src -> dst] -> [hops] } that accepts two points in - space, corresponding to the `src` and `dst`, and returns the distance - between the two points in terms of `hops`, a quantized atomic distance of - data transmission cost. - - Returns - ------- - { [data] -> [dst -> src] } where { [dst] -> [data] } and { [src] -> [data] } are in - `src_occupancy` and `dst_fill` respectively, and where `[dst -> src]` is the infimum of - `dst_fn(src, dst), ∀ src, dst s.t. { [src] -> [data] } ∈ `src_occupancy` and - `{ [dst] -> [data] }` ∈ `dst_fill`. - """ - # Makes { [dst -> data] -> [dst -> data] } - fill_to_fill: isl.Map = dst_fill.wrap().identity() - if DUMP_ISL_IR: - logging.info(f"fill_to_fill: {fill_to_fill}") - - # Inverts src_occupancy s.t. data -> src. - # i.e. { [xs, ys] -> [d0, d1] } to { [d0, d1] -> [xs, ys] } - data_presence: isl.Map = src_occupancy.reverse() - - # { [dst -> data] -> [dst -> src] } where src contains data. - fills_to_matches: isl.Map = ( - fill_to_fill.uncurry() # { [[dst -> data] -> dst] -> data } - .apply_range(data_presence) # { [[dst -> data] -> dst] -> src } - .curry() - ) # { [[dst -> data] -> [dst -> src] } - if DUMP_ISL_IR: - logging.info(f"fills_to_matches: {fills_to_matches}") - - # Calculates the distance of a fill to the nearest src satisfying the fill. - # { [dst -> data] -> [dist] } - fill_min_dist: isl.Map = fills_to_matches.apply_range(dist_fn).lexmin() - # Isolates the relevant minimal pairs. - # { [dst -> data] -> [dst -> src] :.dst -> src is minimized distance } - minimal_pairs: isl.Map = ( - fill_min_dist.apply_range( - # Note: Need to match fill -> min_dist with min_dist -> [fill -> match] as lexmin over - # fill and match will minimize distance over the tuple (src, dst, data), but that - # overconstrains the optimization as we want to minimize over distance (dst, data) - # only for all src. - fills_to_matches.range_map() - .apply_range(dist_fn) - .reverse() - ) - .range() - .unwrap() - ) - if DUMP_ISL_IR: - logging.info(f"minimal_pairs: {minimal_pairs}") - - # Isolates the multicast networks. - # { [data] -> [dst -> src] : dst -> src is minimized distance } - multicast_networks: isl.Map = minimal_pairs.curry().range().unwrap() - # Devolves to a single source if multiple sources per domain point. - multicast_networks = multicast_networks.uncurry().lexmin().curry() - - return multicast_networks - - -def calculate_extents_per_dim(mcns: isl.Map) -> list[isl.PwAff]: - """ - Parameters - ---------- - mcns: - Mesh cast-networks, or networks in which all dsts per data are grouped with - the closest src containing the data. - - Returns - ------- - A list of `isl.PwAff` that gives the max extent (length) along dim_i per mcn, - where i is the i-th `isl.PwAff`. - - Preconditions - ------------- - `mcns` were generated with a Manhattan distance `dst_fn` by `identify_mesh_casts` - s.t. all dimensions are orthogonal to each other in a metric space, where each - unit movement in a dimension counts as 1 hop. - We also assume `dst_fn` is translationally invariant (i.e., ∀src, dst, - src', dst' ∈ space, if |src - dst| = |src' - dst'|, - dst_fn(src, dst) = dst_fn(src', dst'). +class MulticastModel(TransferModel): """ - # Makes mcns from { [data] -> [dst -> src] } to { [data -> src] -> [dst] } - potential_srcs: isl.Map = mcns.range_reverse().uncurry() - # Sources are part of the extents, so we union it with the destinations. - # { [data -> src] -> [src] } - srcs: isl.Map = potential_srcs.domain().unwrap().range_map() - # { [data -> src] -> [spacetime] } - casting_extents: isl.Map = srcs.union(potential_srcs) - - # Projects away all dimensions but one to find their extent for hypercube. - dims: int = potential_srcs.range_tuple_dim() - # Creates a mask of what to project out. - project_out_mask: list[bool] = [True] * dims - dim_extents: list[isl.PwAff] = [None] * dims - - # Gets the extents of all dimensions - for noc_dim in range(dims): - # Project out all the dimensions of the output besides noc_dim. - project_out_mask[noc_dim] = False - # { [spacetime] -> [dimension] } - extent_mapper: isl.Map = dim_projector_mask( - casting_extents.range().get_space(), project_out_mask - ).reverse() - dim_extent_space: isl.Map = casting_extents.apply_range(extent_mapper) - project_out_mask[noc_dim] = True - - # Finds max(noc_dim) - min(noc_dim) for each [data -> src] - max_extent: isl.PwAff = dim_extent_space.dim_max(0) - min_extent: isl.PwAff = dim_extent_space.dim_min(0) - - # Subtracts the max from the min to get the extent per [data -> src] - dim_extents[noc_dim] = max_extent.sub(min_extent).coalesce() - - return dim_extents - - -class HypercubeMulticastModel(TransferModel): - """ - Does distributed multicasting a mesh using worst-case multicasting - behavior by assuming all multicasts are broadcasting to the convex - hypercube that encapsulates all their destinations and sources. + Common shape shared by every distance-driven multicast transfer model. + + Every concrete model below (`HypercubeMulticastModel`, + `FullyConnectedMulticastModel`, `XYRoutingMulticastModel`, + `StarMulticastModel`) is built from one `dist_fn` and computes `apply` + identically up to its cost kernel: call `identify_mesh_casts` exactly + once, derive the cost from that single result, partition the fills, and + assemble a `TransferInfo`. This base class owns that shared shape; a + subclass need only implement `_transfer_cost`. + + Because `hops` and `edge_pressure` are both derived here from the same + `_transfer_cost` return value, `Σ_edges load == hops` (for models that + report an `EdgePressure`) is structural rather than an invariant each + subclass has to maintain by hand: whenever `_transfer_cost` returns an + `EdgePressure` `p`, `hops` is *always* `_const_pwq(p.total())`. """ def __init__(self, dist_fn: isl.Map): """ - Initializes the HypercubeMulticastModel with the distance function - over the metric space. - - Because we are using calculate_extents_per_dim(mcns), we inherit the - following requirements: - `dst_fn` holds all dimensions are orthogonal to each other in a metric space, - where each unit movement in a dimension counts as 1 hop. - - We also assume `dst_fn` is translationally invariant (i.e., ∀src, dst, - src', dst' ∈ space, if |src - dst| = |src' - dst'|, - dst_fn(src, dst) = dst_fn(src', dst'). + Parameters + ---------- + dist_fn: + A distance function { [dst -> src] -> [hops] } that accepts two + points in space, corresponding to `dst` and `src`, and returns + the distance between them in `hops`, a quantized atomic distance + of data transmission cost. Stored and passed straight through to + `identify_mesh_casts` on every `apply` call. + + Caller contract (inherited from `identify_mesh_casts`): the + tuple names of `fills`/`occs`'s domains (the spacetime/node + tuple, e.g. `noc[x, y]`) must match the corresponding tuple + names in `dist_fn`'s domain -- ISL raises on a name mismatch + when `dist_fn` is applied. `dist_fn`'s range tuple must be named + `hops` (`_fabric_crossing`, used by `FullyConnectedMulticastModel` + and `StarMulticastModel`, filters on `{ hops[h] : h >= 1 }` to + distinguish self-deliveries from fabric-crossing ones). """ self.dist_fn = dist_fn def apply(self, buff: MappingNode, fills: Fill, occs: Occupancy) -> TransferInfo: """ Given a buffer, its fills across time, and its occupancies across time, - calculate the spatial transfers." + calculate the spatial transfers. Parameters ---------- buff: - The buffer whose spatial analysis is being considered. Currently, - we rely on dist_fn to deal with this rather than buffer. + The buffer whose spatial analysis is being considered. Not used + by any current subclass (they all rely on `dist_fn` instead); + kept for interface symmetry with `TransferModel.apply`. fills: The fill of `buffer` across time from parents. occs: @@ -193,24 +100,88 @@ def apply(self, buff: MappingNode, fills: Fill, occs: Occupancy) -> TransferInfo Returns ------- - Fills that were fulfilled, Fills that were unfilled, and parent reads per - position in spacetime. Then, gets hops per timestep. + A `TransferInfo` whose `fulfilled_fill`/`unfulfilled_fill` partition + `fills` by whether `identify_mesh_casts` found a matched source for + that (dst, data) pair -- a fill with no source holding its datum is + `unfulfilled_fill`, never silently treated as fulfilled. `hops` and + `edge_pressure` both come from `_transfer_cost(mcs)`: if it returns + an `EdgePressure` `p`, `edge_pressure=p` and + `hops=_const_pwq(p.total())`; otherwise `edge_pressure=None` and + `hops` is the returned polynomial directly (the topology has no + per-link decomposition to report). """ + # `identify_mesh_casts` is called exactly once; every output below is + # derived from this single `mcs`, so they cannot fall out of sync. mcs: isl.Map = identify_mesh_casts(occs.map_, fills.map_, self.dist_fn) - result: isl.PwQPolynomial = self._cost_mesh_cast_hypercube(mcs) + cost: isl.PwQPolynomial | EdgePressure = self._transfer_cost(mcs) + edge_pressure: Optional[EdgePressure] + hops: isl.PwQPolynomial + if isinstance(cost, EdgePressure): + edge_pressure = cost + hops = _const_pwq(cost.total()) + else: + edge_pressure = None + hops = cost + # { dst -> data } fills actually covered by a matched source. + covered: isl.Map = _covered_fills(mcs) - # TODO: Read once from all buffers, assert that - # card(mcs) == tensor_size * duplication factor - n_meshcasts: int = mcs.card() return TransferInfo( - fulfilled_fill=Transfers(fills.tags, fills.map_), + fulfilled_fill=Transfers(fills.tags, fills.map_.intersect(covered)), parent_reads=Reads(occs.tags, mcs), - unfulfilled_fill=Fill(fills.tags, fills.map_.subtract(fills.map_)), - hops=result, + unfulfilled_fill=Fill(fills.tags, fills.map_.subtract(covered)), + hops=hops, link_transfer=True, + edge_pressure=edge_pressure, ) - def _cost_mesh_cast_hypercube(self, mcns: isl.Map) -> int: + @abstractmethod + def _transfer_cost(self, mcs: isl.Map) -> isl.PwQPolynomial | EdgePressure: + """ + Compute this topology's transfer cost from a fixed multicast-network map. + + Parameters + ---------- + mcs: + Multicast networks { [data] -> [dst -> src] } from + `identify_mesh_casts`, computed once by `apply` and shared with + every other output `apply` derives. + + Returns + ------- + Either a bare `isl.PwQPolynomial` (a topology with no per-link + decomposition -- `apply` sets `hops` to exactly this polynomial and + leaves `edge_pressure` at `None`), or an `EdgePressure` (a topology + with one -- `apply` sets `hops` to `_const_pwq(edge_pressure.total())`, + structurally keeping the scalar and the per-edge view consistent). + """ + raise NotImplementedError + + +class HypercubeMulticastModel(MulticastModel): + """ + Does distributed multicasting a mesh using worst-case multicasting + behavior by assuming all multicasts are broadcasting to the convex + hypercube that encapsulates all their destinations and sources. + + `edge_pressure` is always `None` on the resulting `TransferInfo`: the + hypercube model costs a convex bounding box, which has no notion of + individual links to report pressure on. + + Preconditions + ------------- + Because the cost kernel uses `calculate_extents_per_dim(mcns)`, `dist_fn` + must hold all dimensions orthogonal to each other in a metric space, + where each unit movement in a dimension counts as 1 hop, and must be + translationally invariant (i.e., ∀src, dst, src', dst' ∈ space, if + |src - dst| = |src' - dst'|, dist_fn(src, dst) = dist_fn(src', dst')). + """ + + def _transfer_cost(self, mcs: isl.Map) -> isl.PwQPolynomial: + # TODO: Read once from all buffers, assert that + # card(mcs) == tensor_size * duplication factor + return self._cost_mesh_cast_hypercube(mcs) + + def _cost_mesh_cast_hypercube(self, mcns: isl.Map) -> isl.PwQPolynomial: """ Given a multicast_network, calculate the hypercube. @@ -259,3 +230,358 @@ def _cost_mesh_cast_hypercube(self, mcns: isl.Map) -> int: # Return the hypercube cost as a piecewise polynomial. return hypercube_costs.sum() + + +class FullyConnectedMulticastModel(MulticastModel): + """ + Multicast cost model for a fully-connected fabric (e.g. an NVSwitch-style + all-to-all interconnect). + + Every cross-node delivery costs exactly one hop regardless of distance, and + a self-delivery (source node == destination node) costs zero: + + cost = | { (data, dst, src) in mcs : dist_fn(dst, src) >= 1 } | + + This is distance-independent in magnitude (one hop per crossing); `dist_fn` + is used only to tell self-deliveries (0 hops) apart from fabric-crossing ones. + + `edge_pressure` is always `None` on the resulting `TransferInfo`: this + model treats the fabric as a contention-free full mesh (one dedicated + link per pair), so there is no per-link pressure to report. + """ + + def _transfer_cost(self, mcs: isl.Map) -> isl.PwQPolynomial: + return self._cost_fully_connected(mcs) + + def _cost_fully_connected(self, mcns: isl.Map) -> isl.PwQPolynomial: + """ + Count the deliveries in `mcns` that traverse the fabric. + + Parameters + ---------- + mcns: + Multicast networks { [data] -> [dst -> src] } from `identify_mesh_casts`. + + Returns + ------- + The number of (data, dst, src) deliveries with dist_fn(dst, src) >= 1, as a + piecewise quasi-polynomial (constant when there are no parameters). + """ + # [dst -> src] pairs that actually traverse the fabric (>= 1 hop). + crossing: isl.Map = _fabric_crossing(mcns, self.dist_fn) + return crossing.wrap().card() + + +# Directed-edge vocabulary + the four name-independent link-emitting maps for +# XY routing (parsed once at import). `_XY_EDGE_TYPES` is the single source +# of truth for the edge names: it feeds both the maps below and +# `XYRoutingMulticastModel.EDGE_TYPES`, so the class constant and the parsed +# maps can never name the edges differently. +_XY_EDGE_TYPES: tuple[str, str, str, str] = ( + "xedge_r", + "xedge_l", + "yedge_u", + "yedge_d", +) +_XEDGE_R, _XEDGE_L, _YEDGE_U, _YEDGE_D = _XY_EDGE_TYPES + +# { [[col[x] -> span[t]] -> ysv[ys]] -> yedge_u/d[x, t] : ... } -- re-key a +# column's spanned vertical links (see `_span_links`) onto the up/down edge +# tuples, splitting direction at the source row `ys`. +_YEDGE_U_MAP: isl.Map = isl.Map.read_from_str( + isl.DEFAULT_CONTEXT, + "{ [[col[x] -> span[t]] -> ysv[ys]] -> %s[x, t] : t >= ys }" % _YEDGE_U, +) +_YEDGE_D_MAP: isl.Map = isl.Map.read_from_str( + isl.DEFAULT_CONTEXT, + "{ [[col[x] -> span[t]] -> ysv[ys]] -> %s[x, t] : t < ys }" % _YEDGE_D, +) +# { [span[t] -> xy[xs, ys]] -> xedge_r/l[t, ys] : ... } -- re-key the source +# row's spanned horizontal links onto the right/left edge tuples, splitting +# direction at the source column `xs`. +_XEDGE_R_MAP: isl.Map = isl.Map.read_from_str( + isl.DEFAULT_CONTEXT, + "{ [span[t] -> xy[xs, ys]] -> %s[t, ys] : t >= xs }" % _XEDGE_R, +) +_XEDGE_L_MAP: isl.Map = isl.Map.read_from_str( + isl.DEFAULT_CONTEXT, + "{ [span[t] -> xy[xs, ys]] -> %s[t, ys] : t < xs }" % _XEDGE_L, +) + + +def _span_links(coords: isl.Map) -> isl.Map: + """ + Build the set of link indices spanned by a per-key coordinate map. + + Parameters + ---------- + coords: + { key -> name[c] }, one point per (key, coordinate) pair to span -- + e.g. every destination-plus-source y within one tree's column, or + every destination-plus-source column along one tree's row. `name` is + read directly off `coords`'s range tuple, so this works for any + single-dimension range tuple. + + Returns + ------- + { key -> span[t] : min(key) <= t < max(key) } -- the contiguous run of + link indices between each key's minimum and maximum coordinate. Built as + an explicit lexmin/lexmax intersection (a `card()`-friendly link set) + rather than by summing a min/max polynomial, which trips a barvinok + `summate` assertion at scale (e.g. the 8x8 case). + """ + name: str = coords.get_tuple_name(isl.dim_type.out) + lo: isl.Map = coords.lexmin().apply_range( + isl.Map.read_from_str( + isl.DEFAULT_CONTEXT, "{ %s[lo] -> span[t] : t >= lo }" % name + ) + ) + hi: isl.Map = coords.lexmax().apply_range( + isl.Map.read_from_str( + isl.DEFAULT_CONTEXT, "{ %s[hi] -> span[t] : t < hi }" % name + ) + ) + return lo.intersect(hi) + + +class XYRoutingMulticastModel(MulticastModel): + """ + Multicast cost model for XY (dimension-order) routing on a 2-D mesh. + + XY routing constrains every packet to travel along the X dimension first and + only then along the Y dimension, so a multicast from one source forms a rigid + tree: + + 1. an X segment along the source's row, reaching every column that holds a + destination, and + 2. an independent Y segment down each of those columns, starting from the + source's row. + + The hop cost of one such tree (source `s = (xs, ys)` with destination set + `D`) is therefore:: + + x_extent({xs} u {xd : (xd, yd) in D}) + + sum over destination columns xd of + y_extent({ys} u {yd : (xd, yd) in D}) + + Because the Y segments restart from the source row in every column rather than + sharing a trunk, this is an upper bound on free (any-monotone-path) routing and + a lower bound on the hypercube model, giving the ordering:: + + extent_DOR (floor) <= XY routing <= hypercube + + For example, source `(1, 0)` casting to `(0, 2)` and `(2, 2)` costs 4 + (floor), 6 (XY), and 8 (hypercube) respectively. + + Source selection is per destination: `identify_mesh_casts` pairs each + destination with its nearest source (devolving ties), and destinations that + share a source form one tree; the cost sums over all such trees and all data. + `dist_fn` is expected to be Manhattan and translationally invariant, like the + hypercube model (see `HypercubeMulticastModel`'s Preconditions), since nearest- + source selection relies on the same distance shape. + + Its four directed link types are declared once as the class constant + `EDGE_TYPES = ("xedge_r", "xedge_l", "yedge_u", "yedge_d")`. + + Preconditions + ------------- + The NoC is two-dimensional (no temporal dimensions in the spacetime), with + the first coordinate routed before the second (X then Y). The node tuple's + name is generic -- read off `fills`/`occs` at `apply` time, so any name + works (e.g. `noc[x, y]` or `pe[x, y]`) as long as `fills`, `occs`, and + `self.dist_fn` all agree on it (see the caller contract on + `identify_mesh_casts`'s `dist_fn` parameter). The tuple must be exactly + 2-D; `apply` raises `ValueError` otherwise. N-dimensional dimension-order + routing is a future extension. The returned cost is a parameter-free + constant (the validated regime); parametric spacetimes are not yet + supported. + """ + + EDGE_TYPES = _XY_EDGE_TYPES + + def _transfer_cost(self, mcs: isl.Map) -> EdgePressure: + return _edge_pressure_from_links(self._directed_mesh_links(mcs)) + + def _directed_mesh_links(self, mcns: isl.Map) -> list[isl.Map]: + """ + Decompose every multicast tree in `mcns` onto the directed mesh links it + traverses under XY routing. + + Each returned map is { [data -> src] -> edge } for one directed edge + type, associating a tree with every link of that type on its route. The + X phase runs along the source row out to every destination column; the Y + phase runs down each destination column from the source row. Directions + split at the source: rightward/leftward in X (at the source column `xs`) + and upward/downward in Y (at the source row `ys`). + + Parameters + ---------- + mcns: + Multicast networks { [data] -> [dst -> src] } from `identify_mesh_casts`. + + Returns + ------- + `[xedge_r, xedge_l, yedge_u, yedge_d]` maps (see `EDGE_TYPES`). An + edge `xedge_r[t, ys]` is the rightward link in row `ys` between + columns `t` and `t + 1`; `yedge_u[x, t]` is the upward link in + column `x` between rows `t` and `t + 1` (and `_l` / `_d` the + opposite directions). + + Preconditions + ------------- + The node tuple embedded in `mcns` must be exactly 2-D; every map string + below assumes a 2-D `name[x, y]` node tuple. Raises `ValueError` + otherwise. + """ + ctx = isl.DEFAULT_CONTEXT + # Reads the node tuple's name (and validates its dimensionality) off + # `mcns` instead of assuming the literal 'noc', so this method works + # for any 2-D node tuple. + name, dims = _mesh_node_tuple(mcns) + if dims != 2: + raise ValueError( + "XYRoutingMulticastModel requires a 2-D node tuple " + f"(X then Y); got tuple '{name}' with {dims} dimensions. " + "N-dimensional dimension-order routing is not yet supported." + ) + + # { [data -> src] -> dst name[x, y] } and a handle on the source per tree. + per_src, keymap = _per_src(mcns) # keymap: { [data->src] -> src } + + # --- Y phase: vertical links per (tree, destination column). --- + # Key each destination's y by its column: { [data->src->col] -> yv[y] }. + dst_y: isl.Map = per_src.apply_range( + isl.Map.read_from_str( + ctx, + "{ %s[x, y] -> [col[x'] -> yv[y']] : x' = x and y' = y }" % name, + ) + ).uncurry() + # Inject the source row ys into every destination column so each Y segment + # starts from the source. + src_y: isl.Map = keymap.apply_range( + isl.Map.read_from_str(ctx, "{ %s[xs, ys] -> yv[ys] }" % name) + ) + src_row: isl.Map = dst_y.domain().unwrap().range_product(src_y).uncurry() + col_ys: isl.Map = dst_y.union(src_row) + # Every link spanned in a column, keyed by the column (see + # `_span_links`): { [data->src->col] -> span[t] }. + ylinks: isl.Map = _span_links(col_ys) + # Re-key links by the tree and carry ys so direction splits at the source: + # { [data->src] -> [[col[x] -> span[t]] -> ysv[ys]] }. + y_with_ys: isl.Map = ylinks.curry().range_product( + keymap.apply_range( + isl.Map.read_from_str(ctx, "{ %s[xs, ys] -> ysv[ys] }" % name) + ) + ) + yedge_u: isl.Map = y_with_ys.apply_range(_YEDGE_U_MAP) + yedge_d: isl.Map = y_with_ys.apply_range(_YEDGE_D_MAP) + + # --- X phase: horizontal links along the source row. --- + # Columns spanned per tree = {src column} u {destination columns}. (Built + # explicitly, not from calculate_extents_per_dim, which keeps only the + # extent length and discards which links are used.) + col_x: isl.Map = per_src.apply_range( + isl.Map.read_from_str(ctx, "{ %s[x, y] -> cx[x] }" % name) + ).union( + keymap.apply_range( + isl.Map.read_from_str(ctx, "{ %s[xs, ys] -> cx[xs] }" % name) + ) + ) + # Every link spanned along the row, keyed by the tree (see + # `_span_links`): { [data->src] -> span[t] }. + xlinks: isl.Map = _span_links(col_x) + # Carry the source (xs, ys) so direction splits at xs and the row ys is + # part of the edge identity: { [data->src] -> [span[t] -> xy[xs, ys]] }. + x_with_src: isl.Map = xlinks.range_product( + keymap.apply_range( + isl.Map.read_from_str(ctx, "{ %s[xs, ys] -> xy[xs, ys] }" % name) + ) + ) + xedge_r: isl.Map = x_with_src.apply_range(_XEDGE_R_MAP) + xedge_l: isl.Map = x_with_src.apply_range(_XEDGE_L_MAP) + + return [xedge_r, xedge_l, yedge_u, yedge_d] + + +# Spoke vocabulary for the star model, declared once and shared with +# `StarMulticastModel.EDGE_TYPES`. +_STAR_EDGE_TYPES: tuple[str, str] = ("spoke_in", "spoke_out") + + +class StarMulticastModel(MulticastModel): + """ + Multicast cost model for a star / central-switch fabric -- the spokes + realization of a fully-connected interconnect (e.g. an NVSwitch, where every + GPU connects to a shared switch rather than to a full mesh of peers). + + Every node has exactly one spoke (its bidirectional link to the switch). A + delivery routes `src -> switch -> dst`: the source injects each datum once + up its egress spoke (multicast fan-out happens at the switch, so one copy per + datum regardless of how many destinations want it), and every destination + receives its datum down its ingress spoke. Self-deliveries (a node already + holding the datum) never cross the fabric and so load no spoke; `dist_fn`'s + hop magnitude otherwise never enters the spoke load -- on a star every + crossing is one switch hop each way, so `dist_fn` is used only to pick each + destination's nearest source and to classify self- vs. fabric-crossing + deliveries (`_fabric_crossing`). + + Its two spoke directions are declared once as the class constant + `EDGE_TYPES = ("spoke_in", "spoke_out")`. + """ + + EDGE_TYPES = _STAR_EDGE_TYPES + + def _transfer_cost(self, mcs: isl.Map) -> EdgePressure: + return EdgePressure(_union_pwqs([*self._spoke_loads(mcs)])) + + def _spoke_loads( + self, mcns: isl.Map + ) -> tuple[isl.PwQPolynomial, isl.PwQPolynomial]: + """ + Per-spoke ingress and egress load for the multicast networks `mcns`. + + Parameters + ---------- + mcns: + Multicast networks { [data] -> [dst -> src] } from `identify_mesh_casts`. + + Returns + ------- + `(ingress, egress)` where `ingress` is { spoke_in[n] -> #data the node + receives } and `egress` is { spoke_out[n] -> #data the node sources }, + counting only fabric-crossing (>= 1 hop) deliveries. + """ + # Reads the node tuple's name off `mcns` instead of assuming the + # literal 'noc'; unlike XY, the star model has no dimensionality + # requirement. + name, _dims = _mesh_node_tuple(mcns) + + # Keep only deliveries that actually cross the fabric (>= 1 hop); a node + # already holding its datum loads no spoke. + crossing: isl.Map = _fabric_crossing(mcns, self.dist_fn) + + # { dst -> [src -> data] }: regroup so each delivery is keyed by destination. + cur: isl.Map = crossing.reverse().curry() + # Distinct (node, data) pairs per direction. A source injects each datum + # once (multicast fans out at the switch); a destination receives each once. + ingress_nodes: isl.Map = cur.range_factor_range() # { name[dst] -> data } + egress_nodes: isl.Map = cur.range().unwrap() # { name[src] -> data } + + # Relabel the node tuple to the directed spoke edge, then count data per + # spoke. { spoke_in[n] -> #data } and { spoke_out[n] -> #data }. + dims: int = ingress_nodes.dim(isl.dim_type.in_) + idx: str = ", ".join(f"i{k}" for k in range(dims)) + spoke_in, spoke_out = self.EDGE_TYPES + ingress: isl.PwQPolynomial = ingress_nodes.apply_domain( + isl.Map.read_from_str( + isl.DEFAULT_CONTEXT, + "{ %s[%s] -> %s[%s] }" % (name, idx, spoke_in, idx), + ) + ).card() + egress: isl.PwQPolynomial = egress_nodes.apply_domain( + isl.Map.read_from_str( + isl.DEFAULT_CONTEXT, + "{ %s[%s] -> %s[%s] }" % (name, idx, spoke_out, idx), + ) + ).card() + return ingress, egress diff --git a/accelforge/model/_looptree/reuse/isl/distributed/edge_pressure.py b/accelforge/model/_looptree/reuse/isl/distributed/edge_pressure.py new file mode 100644 index 00000000..629a7cfc --- /dev/null +++ b/accelforge/model/_looptree/reuse/isl/distributed/edge_pressure.py @@ -0,0 +1,251 @@ +""" +Per-edge memory pressure (link load) for distributed transfer models: +`EdgePressure` and the scalar-extraction / accumulation helpers that build and +consume it. + +Leaf module -- depends only on `islpy`, `functools`, `dataclasses`, and +`typing`, so every other module in this package (`mesh_casts.py`, +`distributed_buffers.py`, and `../spatial.py`) can import from here without +risk of a cycle. +""" + +import functools + +from dataclasses import dataclass + +from typing import Optional + +import islpy as isl + + +@dataclass(frozen=True) +class EdgePressure: + """ + Per-edge memory pressure (link load) for a spatial transfer. + + Where a `TransferModel`'s `hops` collapses a whole routing to a single + scalar, `EdgePressure` keeps the load broken out per physical edge: how many + multicast trees cross each individual link -- the quantity a per-link + bandwidth limit acts on, and what the symbolic network model calls + `max_traffic`. + + "Edge" is a directed physical link, identified by the tuple name and + coordinates of `load`'s domain, e.g. `yedge_u[x, t]` (the upward vertical + link in column `x` between rows `t` and `t + 1`, XY mesh) or `spoke_in[n]` / + `spoke_out[n]` (a node's ingress / egress link to the switch, star model). + + The load is keyed on the multicast tree `(data, src)`, not on individual + destinations: within one tree a link is traversed once regardless of how + many leaves hang off it, so `load` measures pressure (distinct flows over a + link) rather than summed hops -- e.g. for XY routing, sum over edges of load + == total hops. + + Preconditions + ------------- + `bottleneck` and `eval_edge` assume the load is piecewise constant (the + parameter-free regime the distributed models are validated in); there is no + clean ISL "max of a quasi-polynomial over its domain" primitive, so the + bottleneck is obtained by enumerating pieces. + """ + + # { edge -> number-of-trees } spanning every directed edge type the model + # emits. + load: isl.UnionPwQPolynomial + + @functools.cached_property + def _pieces(self) -> list[isl.PwQPolynomial]: + """ + Cache `load`'s decomposition into per-edge-type pieces. + + Returns + ------- + The list of `isl.PwQPolynomial` pieces `load` unions together, in + `foreach_pw_qpolynomial` enumeration order. + """ + # Note: `cached_property` works on this frozen dataclass only because + # it is not `slots=True` -- the cache is written straight into the + # instance `__dict__`, bypassing the frozen `__setattr__`. + pieces: list[isl.PwQPolynomial] = [] + self.load.foreach_pw_qpolynomial(pieces.append) + return pieces + + def total(self) -> int: + """ + Sum the load over every edge. + + For a single-direction (or single edge-type) pressure this is the total + traffic; for the full mesh pressure it equals the model's total `hops` + (sum over edges of load == total hops), which is the primary cross-check + that the per-edge decomposition is correct. + """ + total = 0 + for pwq in self._pieces: + # `.sum()` collapses the edge-indexed domain away (summing over every + # edge of this piece), leaving a 0-set-dim, parameter-free + # polynomial -- exactly `_eval_const`'s precondition. + total += _eval_const(pwq.sum()) + return total + + def bottleneck(self) -> int: + """ + Return the load on the single most-pressured edge. + + This is the bandwidth-binding quantity: with a uniform per-link + bandwidth, the most-congested edge saturates first, so its load sets the + transfer's bandwidth-bound latency. + + The per-edge load is generally not constant across an edge type (e.g. a + flooded column's upward link `yedge_u[x, t]` carries `t + 1` trees), so + the maximum is found by enumerating the (finite, parameter-free) edge + domain and evaluating the load at each edge -- sampling one point per + piece would under-report a monotone load. + """ + best = 0 + for pwq in self._pieces: + edges: list[isl.Point] = [] + pwq.domain().foreach_point(edges.append) + for edge in edges: + best = max(best, _eval_at(pwq, edge)) + return best + + def eval_edge(self, name: str, coords: list[int]) -> int: + """ + Look up the load on one named edge, e.g. `eval_edge("yedge_u", [0, 6])`. + + Returns 0 if no flow crosses that edge (it is outside the load's support). + + Parameters + ---------- + name: + The edge tuple name (`xedge_r`/`xedge_l`/`yedge_u`/`yedge_d` + for the mesh model, `spoke_in`/`spoke_out` for the spokes model). + coords: + The integer edge coordinates within that tuple. + """ + for pwq in self._pieces: + if pwq.domain().get_space().get_tuple_name(isl.dim_type.set) != name: + continue + point: isl.Point = isl.Set.read_from_str( + isl.DEFAULT_CONTEXT, + "{ %s[%s] }" % (name, ", ".join(str(c) for c in coords)), + ).sample_point() + return _eval_at(pwq, point) + return 0 + + +def _eval_at(pwq: isl.PwQPolynomial, point: isl.Point) -> int: + """ + Evaluate a piecewise quasi-polynomial at one point and return a Python `int`. + + Parameters + ---------- + pwq: + The polynomial to evaluate. + point: + The point to evaluate at. Must lie in a space compatible with `pwq`'s + domain (matching tuple name and dimensionality). + + Returns + ------- + `pwq`'s value at `point`, as a Python `int`. + + Note: isl.Val has no direct int() conversion in this islpy build (it + raises TypeError); round-tripping through str() is the working idiom + used throughout this module. + """ + return int(str(pwq.eval(point))) + + +def _eval_const(pwq: isl.PwQPolynomial) -> int: + """ + Evaluate a parameter-free, already-reduced piecewise quasi-polynomial to its + scalar value. + + Preconditions + ------------- + `pwq` has no free set dimensions and no parameters -- e.g. the output of + `.card()` on a parameter-free map/set, `.sum()` on a parameter-free + polynomial, or a constant built by `_const_pwq`. Evaluated only at the + space's zero point, so a `pwq` that still varies over domain points or + parameters gives a meaningless result; callers must reduce to a true + constant first (via `.sum()`/`.card()`). + + Returns + ------- + The polynomial's constant value as a Python `int`. + """ + return _eval_at(pwq, isl.Point.zero(pwq.domain().get_space())) + + +def _const_pwq(value: int) -> isl.PwQPolynomial: + """ + Build a 0-dimensional, parameter-free constant `isl.PwQPolynomial` equal to + `value`. + + Parameters + ---------- + value: + The integer the returned polynomial evaluates to everywhere (it has no + domain dimensions or parameters to vary over). + + Returns + ------- + An `isl.PwQPolynomial` over the empty (0-dim, 0-param) space, suitable + wherever a parameter-free `hops` cost is expected. Round-trips through + `_eval_const` back to `value`. + """ + ctx = isl.DEFAULT_CONTEXT + zero_dim: isl.Space = isl.Space.set_alloc(ctx, 0, 0) + return isl.PwQPolynomial.from_qpolynomial( + isl.QPolynomial.val_on_domain(zero_dim, isl.Val(value, ctx)) + ) + + +def _union_pwqs(pwqs: list[isl.PwQPolynomial]) -> isl.UnionPwQPolynomial: + """ + Accumulate a list of piecewise quasi-polynomials into one union. + + Parameters + ---------- + pwqs: + The polynomials to sum, e.g. one per directed edge type. May be + empty. + + Returns + ------- + The union-add of every `pwqs` entry (each promoted to an + `isl.UnionPwQPolynomial` first). An empty `pwqs` yields the empty union + `{ }`, so a caller with zero edge types (or a degenerate empty mesh) does + not need to special-case the accumulation. + """ + acc: Optional[isl.UnionPwQPolynomial] = None + for pwq in pwqs: + contribution = isl.UnionPwQPolynomial.from_pw_qpolynomial(pwq) + acc = contribution if acc is None else acc.add(contribution) + if acc is None: + acc = isl.UnionPwQPolynomial.read_from_str(isl.DEFAULT_CONTEXT, "{ }") + return acc + + +def _edge_pressure_from_links(edge_maps: list[isl.Map]) -> EdgePressure: + """ + Turn directed flow maps into an `EdgePressure`. + + Parameters + ---------- + edge_maps: + A list of { [data -> src] -> edge } maps, one per directed edge type, + each associating a multicast tree with every edge its route traverses. + + Returns + ------- + An `EdgePressure` whose `load` is { edge -> number-of-trees }: for each map + we reverse it and take the cardinality (`reverse().card()` counts, per + edge, how many distinct `(data, src)` trees cross it), then union the + per-type results into one `UnionPwQPolynomial` (via `_union_pwqs`). + """ + # { edge -> #trees crossing it }, one per edge type. + per_edge_loads: list[isl.PwQPolynomial] = [ + edge_map.reverse().card() for edge_map in edge_maps + ] + return EdgePressure(_union_pwqs(per_edge_loads)) diff --git a/accelforge/model/_looptree/reuse/isl/distributed/mesh_casts.py b/accelforge/model/_looptree/reuse/isl/distributed/mesh_casts.py new file mode 100644 index 00000000..c6318a7f --- /dev/null +++ b/accelforge/model/_looptree/reuse/isl/distributed/mesh_casts.py @@ -0,0 +1,268 @@ +""" +Multicast-network construction for distributed transfer models: matching each +requested datum to its nearest source (`identify_mesh_casts`) and the +shared-shape helpers built on top of its result (fill-partition recovery, +per-dimension extents, tree re-keying, and fabric-crossing filtering). + +Imports `islpy`, `isl_functions.dim_projector_mask`, and +`mapping_to_isl.DUMP_ISL_IR` only -- no dependency on `edge_pressure.py`, +`spatial.py`, or `distributed_buffers.py`, so this module cannot participate +in the import cycle those would otherwise risk. +""" + +import logging + +import islpy as isl + +from accelforge.model._looptree.reuse.isl.isl_functions import dim_projector_mask +from accelforge.model._looptree.reuse.isl.mapping_to_isl import DUMP_ISL_IR + +# [dst -> src] pairs whose distance is >= 1 hop, i.e. every fabric-crossing +# delivery (as opposed to a self-delivery, which never traverses a link). +# Parsed once at import rather than per `_fabric_crossing` call -- the string +# encodes `identify_mesh_casts`'s `dist_fn` contract (range tuple named +# `hops`), not anything instance- or call-specific. +_CROSSING_HOPS: isl.Set = isl.Set.read_from_str( + isl.DEFAULT_CONTEXT, "{ hops[h] : h >= 1 }" +) + + +def identify_mesh_casts( + src_occupancy: isl.Map, dst_fill: isl.Map, dist_fn: isl.Map +) -> isl.Map: + """ + Given srcs with data, fills to destinations, and a distance function, identify per data + the srcs delivering that data to dsts. + + Parameters + ---------- + src_occupancy: + An isl.Map of the form { [src] -> [data] } corresponding to the data held + at the buffer at space `src`. + dst_fill: + An isl.Map of the form { [dst] -> [data] } corresponding to the data requested + at the element at space `dst`. + dist_fn: + A distance function { [dst -> src] -> [hops] } that accepts two points in + space, corresponding to the `dst` and `src`, and returns the distance + between the two points in terms of `hops`, a quantized atomic distance of + data transmission cost. + + Caller contract: the tuple names of `dst_fill`'s and `src_occupancy`'s + domains (the spacetime/node tuple, e.g. `noc[x, y]`) must match the + corresponding tuple names in `dist_fn`'s domain -- ISL will raise on a + name mismatch when `dist_fn` is applied. `dist_fn`'s range tuple must be + named `hops` (consumers such as `FullyConnectedMulticastModel` and + `StarMulticastModel` filter on `{ hops[h] : h >= 1 }` to distinguish + self-deliveries from fabric-crossing ones). + + Returns + ------- + { [data] -> [dst -> src] } where { [dst] -> [data] } and { [src] -> [data] } are in + `src_occupancy` and `dst_fill` respectively, and where `[dst -> src]` is the infimum of + `dst_fn(src, dst), ∀ src, dst s.t. { [src] -> [data] } ∈ `src_occupancy` and + `{ [dst] -> [data] }` ∈ `dst_fill`. + """ + # Makes { [dst -> data] -> [dst -> data] } + fill_to_fill: isl.Map = dst_fill.wrap().identity() + if DUMP_ISL_IR: + logging.info(f"fill_to_fill: {fill_to_fill}") + + # Inverts src_occupancy s.t. data -> src. + # i.e. { [xs, ys] -> [d0, d1] } to { [d0, d1] -> [xs, ys] } + data_presence: isl.Map = src_occupancy.reverse() + + # { [dst -> data] -> [dst -> src] } where src contains data. + fills_to_matches: isl.Map = ( + fill_to_fill.uncurry() # { [[dst -> data] -> dst] -> data } + .apply_range(data_presence) # { [[dst -> data] -> dst] -> src } + .curry() + ) # { [[dst -> data] -> [dst -> src] } + if DUMP_ISL_IR: + logging.info(f"fills_to_matches: {fills_to_matches}") + + # Calculates the distance of a fill to the nearest src satisfying the fill. + # { [dst -> data] -> [dist] } + fill_min_dist: isl.Map = fills_to_matches.apply_range(dist_fn).lexmin() + # Isolates the relevant minimal pairs. + # { [dst -> data] -> [dst -> src] :.dst -> src is minimized distance } + minimal_pairs: isl.Map = ( + fill_min_dist.apply_range( + # Note: Need to match fill -> min_dist with min_dist -> [fill -> match] as lexmin over + # fill and match will minimize distance over the tuple (src, dst, data), but that + # overconstrains the optimization as we want to minimize over distance (dst, data) + # only for all src. + fills_to_matches.range_map() + .apply_range(dist_fn) + .reverse() + ) + .range() + .unwrap() + ) + if DUMP_ISL_IR: + logging.info(f"minimal_pairs: {minimal_pairs}") + + # Isolates the multicast networks. + # { [data] -> [dst -> src] : dst -> src is minimized distance } + multicast_networks: isl.Map = minimal_pairs.curry().range().unwrap() + # Devolves to a single source if multiple sources per domain point. + multicast_networks = multicast_networks.uncurry().lexmin().curry() + + return multicast_networks + + +def _covered_fills(mcs: isl.Map) -> isl.Map: + """ + Recover the { dst -> data } fills actually covered by a matched multicast + source, from `identify_mesh_casts`'s result. + + Parameters + ---------- + mcs: + { [data] -> [dst -> src] }, the multicast-network map returned by + `identify_mesh_casts`. + + Returns + ------- + { dst -> data } -- every (destination, datum) pair that has a matched + source in `mcs`. A model's fill partition is then + `fulfilled = fills.map_.intersect(covered)` and + `unfulfilled = fills.map_.subtract(covered)`. + """ + return ( + mcs.range_reverse() # { data -> [src -> dst] } + .uncurry() # { [data -> src] -> dst } + .domain_factor_domain() # Drops src, keeps -> dst. { data -> dst } + .reverse() # { dst -> data } + ) + + +def _mesh_node_tuple(mcns: isl.Map) -> tuple[str, int]: + """ + Read the spacetime/node tuple's name and dimensionality off a multicast + network map, instead of assuming a hardcoded name. + + Parameters + ---------- + mcns: + { [data] -> [dst -> src] }, the multicast-network map returned by + `identify_mesh_casts`. `dst` and `src` share the same node tuple, so + it suffices to read the name/dims off one side (`dst`, via + `.range().unwrap()`'s domain). + + Returns + ------- + `(name, dims)`: the node tuple's ISL tuple name (e.g. "noc" or "pe") and + its dimensionality. + """ + # Note: correct even when mcns is empty -- ISL preserves space/tuple-name + # information on empty relations, so this does not require data to be + # present. + node_space: isl.Map = mcns.range().unwrap() + name: str = node_space.get_tuple_name(isl.dim_type.in_) + dims: int = node_space.dim(isl.dim_type.in_) + return name, dims + + +def _per_src(mcns: isl.Map) -> tuple[isl.Map, isl.Map]: + """ + Re-key multicast networks by tree `(data, src)`, plus a source lookup. + + Parameters + ---------- + mcns: + Multicast networks { [data] -> [dst -> src] } from `identify_mesh_casts`. + + Returns + ------- + `(per_src, keymap)`: + - `per_src`: { [data -> src] -> dst } -- every destination grouped under + its tree's key. + - `keymap`: { [data -> src] -> src } -- the tree's own source point, + recovered from `per_src`'s (wrapped) domain. Used to inject/carry the + source's own coordinates (e.g. its row/column) into computations keyed + on the same tree. + """ + per_src: isl.Map = mcns.range_reverse().uncurry() # { [data -> src] -> dst } + keymap: isl.Map = per_src.domain().unwrap().range_map() # { [data -> src] -> src } + return per_src, keymap + + +def _fabric_crossing(mcns: isl.Map, dist_fn: isl.Map) -> isl.Map: + """ + Filter multicast networks down to fabric-crossing deliveries (>= 1 hop). + + Parameters + ---------- + mcns: + Multicast networks { [data] -> [dst -> src] } from `identify_mesh_casts`. + dist_fn: + The same distance function { [dst -> src] -> [hops] } passed to + `identify_mesh_casts` -- see its caller contract on the `hops` range + tuple name, which this filters on. + + Returns + ------- + { [data] -> [dst -> src] }, restricted to entries whose `[dst -> src]` + pair has `dist_fn(dst, src) >= 1`, i.e. every delivery that actually + crosses the fabric. Self-deliveries (0 hops) are dropped, since they load + no fabric link. + """ + crossing_pairs: isl.Set = dist_fn.intersect_range(_CROSSING_HOPS).domain() + return mcns.intersect_range(crossing_pairs) + + +def calculate_extents_per_dim(mcns: isl.Map) -> list[isl.PwAff]: + """ + Parameters + ---------- + mcns: + Mesh cast-networks, or networks in which all dsts per data are grouped with + the closest src containing the data. + + Returns + ------- + A list of `isl.PwAff` that gives the max extent (length) along dim_i per mcn, + where i is the i-th `isl.PwAff`. + + Preconditions + ------------- + `mcns` were generated with a Manhattan distance `dst_fn` by `identify_mesh_casts` + s.t. all dimensions are orthogonal to each other in a metric space, where each + unit movement in a dimension counts as 1 hop. + + We also assume `dst_fn` is translationally invariant (i.e., ∀src, dst, + src', dst' ∈ space, if |src - dst| = |src' - dst'|, + dst_fn(src, dst) = dst_fn(src', dst'). + """ + # Makes mcns from { [data] -> [dst -> src] } to { [data -> src] -> [dst] } + potential_srcs, srcs = _per_src(mcns) + # Sources are part of the extents, so we union it with the destinations. + # { [data -> src] -> [spacetime] } + casting_extents: isl.Map = srcs.union(potential_srcs) + + # Projects away all dimensions but one to find their extent for hypercube. + dims: int = potential_srcs.range_tuple_dim() + # Creates a mask of what to project out. + project_out_mask: list[bool] = [True] * dims + dim_extents: list[isl.PwAff] = [None] * dims + + # Gets the extents of all dimensions + for noc_dim in range(dims): + # Project out all the dimensions of the output besides noc_dim. + project_out_mask[noc_dim] = False + # { [spacetime] -> [dimension] } + extent_mapper: isl.Map = dim_projector_mask( + casting_extents.range().get_space(), project_out_mask + ).reverse() + dim_extent_space: isl.Map = casting_extents.apply_range(extent_mapper) + project_out_mask[noc_dim] = True + + # Finds max(noc_dim) - min(noc_dim) for each [data -> src] + max_extent: isl.PwAff = dim_extent_space.dim_max(0) + min_extent: isl.PwAff = dim_extent_space.dim_min(0) + + # Subtracts the max from the min to get the extent per [data -> src] + dim_extents[noc_dim] = max_extent.sub(min_extent).coalesce() + + return dim_extents diff --git a/accelforge/model/_looptree/reuse/isl/mapping_to_isl/tiling.py b/accelforge/model/_looptree/reuse/isl/mapping_to_isl/tiling.py index 7da28fed..122d4219 100644 --- a/accelforge/model/_looptree/reuse/isl/mapping_to_isl/tiling.py +++ b/accelforge/model/_looptree/reuse/isl/mapping_to_isl/tiling.py @@ -345,8 +345,9 @@ def consumer_based_tile_shape_inference( # For each tensor read by this einsum, tile that tensor's producers. for tensor in workload.einsums[einsum].input_tensor_names: + # The einsums that write `tensor` -- its producers in this workload. producer_einsums: oset[EinsumName] = oset( - [e for e in workload.einsums[einsum].output_tensor_names] + [e.name for e in workload.einsums if tensor in e.output_tensor_names] ) if len(producer_einsums) > 1: raise NotImplementedError( diff --git a/accelforge/model/_looptree/reuse/isl/spatial.py b/accelforge/model/_looptree/reuse/isl/spatial.py index 47d6fa42..7c6ad5a5 100644 --- a/accelforge/model/_looptree/reuse/isl/spatial.py +++ b/accelforge/model/_looptree/reuse/isl/spatial.py @@ -9,6 +9,9 @@ import islpy as isl from accelforge.frontend.mapping import MappingNode +from accelforge.model._looptree.reuse.isl.distributed.edge_pressure import ( + EdgePressure, +) from accelforge.model._looptree.reuse.isl.isl_functions import ( insert_equal_dims_map, reorder_projector, @@ -37,9 +40,17 @@ class TransferInfo: # Crucial information to transfer info. fulfilled_fill: Transfers - """Fills done by peer-to-peer transfers.""" + """Fills done by peer-to-peer transfers. + + Restricted to fills covered by a matched multicast source (see + `identify_mesh_casts`); see `unfulfilled_fill`. + """ unfulfilled_fill: Fill - """Fills not performed.""" + """Fills not performed. + + `fills - covered`, the complement of `fulfilled_fill` within the original + fill set (the two partition `fills` exactly). + """ parent_reads: Reads """Fills done by parent-to-child transfers.""" hops: isl.PwQPolynomial @@ -48,6 +59,18 @@ class TransferInfo: # Metadata on what is occurring. link_transfer: bool + # Optional, model-specific per-link decomposition -- last field / defaulted + # so this addition does not disturb any existing positional or keyword + # construction site (`grep`-verified: every `TransferInfo(...)` call in the + # tree already uses keyword arguments). + edge_pressure: Optional[EdgePressure] = None + """Per-directed-edge load backing `hops`, for models that define one. + + Populated by `XYRoutingMulticastModel` (mesh links) and + `StarMulticastModel` (spokes); `None` where a model has no per-link + decomposition (`HypercubeMulticastModel`, `FullyConnectedMulticastModel`). + """ + class TransferModel(ABC): """ diff --git a/tests/not_working/distribuffers/__init__.py b/notebooks/astrasim2_correlation/mapping/all_reduce.binding.yaml similarity index 100% rename from tests/not_working/distribuffers/__init__.py rename to notebooks/astrasim2_correlation/mapping/all_reduce.binding.yaml diff --git a/notebooks/astrasim2_correlation/mapping/all_reduce.mapping.yaml b/notebooks/astrasim2_correlation/mapping/all_reduce.mapping.yaml new file mode 100644 index 00000000..5be5aae6 --- /dev/null +++ b/notebooks/astrasim2_correlation/mapping/all_reduce.mapping.yaml @@ -0,0 +1,34 @@ +mapping: + nodes: + - !Storage + tensors: [I, S] + component: Network + - !Spatial + rank_variable: nodes + # bound is nodes + tile_shape: {{ nodes }} + - !Storage + tensors: [S, G] + component: NV100 + - !Sequential + nodes: + - !Nested + nodes: + - !Temporal + rank_variable: vec_len + # Bound is vec_len. + tile_shape: {{ vec_len }} + component: MAC + - !Compute + einsum: scatter + component: MAC + - !Gather + nodes: + - !Temporal + rank_variable: vec_len + # Bound is vec_len. + tile_shape: {{ vec_len }} + component: MAC + - !Compute + einsum: all_gather + component: MAC diff --git a/notebooks/astrasim2_correlation/mapping/all_reduce.workload.yaml b/notebooks/astrasim2_correlation/mapping/all_reduce.workload.yaml new file mode 100644 index 00000000..d00907d5 --- /dev/null +++ b/notebooks/astrasim2_correlation/mapping/all_reduce.workload.yaml @@ -0,0 +1,15 @@ +workload: + iteration_space_shape: + vec_len: 0 <= vec_len < {{ collective_size / (data_width * nodes) }} + nodes: 0 <= nodes < {{ nodes }} + einsums: + - name: scatter + tensor_accesses: + - {name: I, projection: [ vec_len, nodes ]} + - {name: W_scatter, projection: [ vec_len, nodes ]} + - {name: S, projection: [ nodes ], output: True} + - name: all_gather + tensor_accesses: + - {name: S, projection: [nodes]} + - {name: W_gather, projection: [vec_len. nodes]} + - {name: G, projection: [vec_len, nodes]} diff --git a/notebooks/astrasim2_correlation/mapping/nvidiav100.arch.yaml b/notebooks/astrasim2_correlation/mapping/nvidiav100.arch.yaml new file mode 100644 index 00000000..c5894704 --- /dev/null +++ b/notebooks/astrasim2_correlation/mapping/nvidiav100.arch.yaml @@ -0,0 +1,44 @@ +# Based on examples/arches/nvdla.yaml +arch: + nodes: + - !Memory + name: MainMemory + size: inf + leak_power: 0 + actions: + # Energy is upper end of the range from the TPU paper. The lower end came from + # their reference, and they said it left out some things. Latency is 38.4 GB/s. + # DDR5-4800. Chip runs at at 1GHz, so divide to get per-cycle bandwidth. + # https://www.jedec.org/news/pressreleases/jedec-updates-standard-low-power-memory-devices-lpddr5 + - {name: read, energy: 7.03e-12, latency: 1 / (8 * 38.4e9)} + - {name: write, energy: 7.03e-12, latency: 1 / (8 * 38.4e9)} + tensors: {keep: ~Intermediates, may_keep: All} + + - !Memory + name: GlobalBuffer + size: 1024*64*8 # 64 kB + total_latency: max(read_latency, write_latency) # Separate ports + leak_power: 0 + actions: + # 512 GB/s read, 128 GB/s write + - {name: read, energy: 0.249e-12, latency: 1 / 512e9 / 8} + - {name: write, energy: 0.293e-12, latency: 1 / 128e9 / 8} + tensors: {keep: All} + + - !Container + name: ProcessingElement + spatial: + - {name: reuse_input, fanout: 32, may_reuse: input, reuse: input, min_usage: 1} + - {name: reuse_output, fanout: 192, may_reuse: output, reuse: output, min_usage: 1} + + - !Memory + name: Register + size: weight.bits_per_value if weight else 1 + component_class: Dummy + tensors: {keep: weight} + + - !Compute + name: MAC + leak_power: 0 + actions: + - {name: compute, energy: 0.084e-12, latency: 1 / 1e9} diff --git a/notebooks/astrasim2_correlation/mapping/v100_torus.arch.yaml b/notebooks/astrasim2_correlation/mapping/v100_torus.arch.yaml new file mode 100644 index 00000000..96d60735 --- /dev/null +++ b/notebooks/astrasim2_correlation/mapping/v100_torus.arch.yaml @@ -0,0 +1,32 @@ +# Based on examples/arches/nvdla.yaml +arch: + nodes: + - !Memory + name: Network + size: inf + leak_power: 0 + actions: + # TODO: Get the network energies for NVLink. + # 150 GB/s NVLink transfer speeds. + - {name: read, energy: 0, latency: 1 / (150e9 * 8)} + - {name: write, energy: 0, latency: 1 / (150e9 * 8)} + tensors: {keep: All, may_keep: All} + + - !Memory + name: NV100 + size: 16*1024*1024*8 # 16 GB + total_latency: max(read_latency, write_latency) # Separate ports + leak_power: 0 + actions: + # Prima facie network analysis assuming NV100 mem hierarchy characteristics + # are dominated by the NVLink connection. + - {name: read, energy: 0, latency: 0} + - {name: write, energy: 0, latency: 0} + tensors: {keep: All} + + - !Compute + name: MAC + leak_power: 0 + actions: + # Prima facie assume compute is dominated by NVLink data mvmt. + - {name: compute, energy: 0, latency: 0} diff --git a/tests/isl/distributed/fully_connected/test_cases.yaml b/tests/isl/distributed/fully_connected/test_cases.yaml new file mode 100644 index 00000000..90672f94 --- /dev/null +++ b/tests/isl/distributed/fully_connected/test_cases.yaml @@ -0,0 +1,103 @@ +################################################### +# 8-GPU fully-connected (NVLink/NVSwitch-style) # +# all-to-all, one-hot GPU encoding. # +# # +# GPU i sits at one-hot coordinate e_i. A # +# delivery costs 1 hop iff it crosses the fabric # +# (src != dst) and 0 otherwise, so: # +# all-to-all -> 56 crossings (64 - 8 self) # +# one unicast -> 1 # +# one self -> 0 # +# Compare the hypercube model: 168 / 3 / 0 (3x). # +# dist_fn is unit-cost (matching only). # +################################################### + +# All-to-all over 8 GPUs: each GPU holds chunks data[self, d] and requests data[s, self]. +- occ: | + { + noc[gs0, gs1, gs2, gs3, gs4, gs5, gs6, gs7] -> data[s, d] : + 0 <= gs0 <= 1 and 0 <= gs1 <= 1 and 0 <= gs2 <= 1 and 0 <= gs3 <= 1 and 0 <= gs4 <= 1 and 0 <= gs5 <= 1 and 0 <= gs6 <= 1 and 0 <= gs7 <= 1 and + gs0 + gs1 + gs2 + gs3 + gs4 + gs5 + gs6 + gs7 = 1 and + s = 1*gs1 + 2*gs2 + 3*gs3 + 4*gs4 + 5*gs5 + 6*gs6 + 7*gs7 and 0 <= d < 8 + } + fill: | + { + noc[gd0, gd1, gd2, gd3, gd4, gd5, gd6, gd7] -> data[s, d] : + 0 <= gd0 <= 1 and 0 <= gd1 <= 1 and 0 <= gd2 <= 1 and 0 <= gd3 <= 1 and 0 <= gd4 <= 1 and 0 <= gd5 <= 1 and 0 <= gd6 <= 1 and 0 <= gd7 <= 1 and + gd0 + gd1 + gd2 + gd3 + gd4 + gd5 + gd6 + gd7 = 1 and + d = 1*gd1 + 2*gd2 + 3*gd3 + 4*gd4 + 5*gd5 + 6*gd6 + 7*gd7 and 0 <= s < 8 + } + dims: &8d_onehot_spatial + - type: Spatial + spatial_dim: 0 + target: 0 + - type: Spatial + spatial_dim: 1 + target: 0 + - type: Spatial + spatial_dim: 2 + target: 0 + - type: Spatial + spatial_dim: 3 + target: 0 + - type: Spatial + spatial_dim: 4 + target: 0 + - type: Spatial + spatial_dim: 5 + target: 0 + - type: Spatial + spatial_dim: 6 + target: 0 + - type: Spatial + spatial_dim: 7 + target: 0 + dist_fn: &fully_connected_unit | + { + [noc[xd0, xd1, xd2, xd3, xd4, xd5, xd6, xd7] -> noc[xs0, xs1, xs2, xs3, xs4, xs5, xs6, xs7]] -> hops[0] : + xd0 = xs0 and xd1 = xs1 and xd2 = xs2 and xd3 = xs3 and xd4 = xs4 and xd5 = xs5 and xd6 = xs6 and xd7 = xs7; + [noc[xd0, xd1, xd2, xd3, xd4, xd5, xd6, xd7] -> noc[xs0, xs1, xs2, xs3, xs4, xs5, xs6, xs7]] -> hops[1] : + (xd0 < xs0) or (xd0 > xs0) or (xd1 < xs1) or (xd1 > xs1) or (xd2 < xs2) or (xd2 > xs2) or (xd3 < xs3) or (xd3 > xs3) or (xd4 < xs4) or (xd4 > xs4) or (xd5 < xs5) or (xd5 > xs5) or (xd6 < xs6) or (xd6 > xs6) or (xd7 < xs7) or (xd7 > xs7) + } + expected: + fully_connected_hops: 56 + +# Single chunk GPU0 -> GPU3: one unicast crossing, cost 1. +- occ: | + { + noc[gs0, gs1, gs2, gs3, gs4, gs5, gs6, gs7] -> data[s, d] : + 0 <= gs0 <= 1 and 0 <= gs1 <= 1 and 0 <= gs2 <= 1 and 0 <= gs3 <= 1 and 0 <= gs4 <= 1 and 0 <= gs5 <= 1 and 0 <= gs6 <= 1 and 0 <= gs7 <= 1 and + gs0 + gs1 + gs2 + gs3 + gs4 + gs5 + gs6 + gs7 = 1 and + s = 1*gs1 + 2*gs2 + 3*gs3 + 4*gs4 + 5*gs5 + 6*gs6 + 7*gs7 and s = 0 and d = 3 + } + fill: | + { + noc[gd0, gd1, gd2, gd3, gd4, gd5, gd6, gd7] -> data[s, d] : + 0 <= gd0 <= 1 and 0 <= gd1 <= 1 and 0 <= gd2 <= 1 and 0 <= gd3 <= 1 and 0 <= gd4 <= 1 and 0 <= gd5 <= 1 and 0 <= gd6 <= 1 and 0 <= gd7 <= 1 and + gd0 + gd1 + gd2 + gd3 + gd4 + gd5 + gd6 + gd7 = 1 and + d = 1*gd1 + 2*gd2 + 3*gd3 + 4*gd4 + 5*gd5 + 6*gd6 + 7*gd7 and s = 0 and d = 3 + } + dims: *8d_onehot_spatial + dist_fn: *fully_connected_unit + expected: + fully_connected_hops: 1 + +# Self chunk GPU5 -> GPU5: never crosses the fabric, cost 0. +- occ: | + { + noc[gs0, gs1, gs2, gs3, gs4, gs5, gs6, gs7] -> data[s, d] : + 0 <= gs0 <= 1 and 0 <= gs1 <= 1 and 0 <= gs2 <= 1 and 0 <= gs3 <= 1 and 0 <= gs4 <= 1 and 0 <= gs5 <= 1 and 0 <= gs6 <= 1 and 0 <= gs7 <= 1 and + gs0 + gs1 + gs2 + gs3 + gs4 + gs5 + gs6 + gs7 = 1 and + s = 1*gs1 + 2*gs2 + 3*gs3 + 4*gs4 + 5*gs5 + 6*gs6 + 7*gs7 and s = 5 and d = 5 + } + fill: | + { + noc[gd0, gd1, gd2, gd3, gd4, gd5, gd6, gd7] -> data[s, d] : + 0 <= gd0 <= 1 and 0 <= gd1 <= 1 and 0 <= gd2 <= 1 and 0 <= gd3 <= 1 and 0 <= gd4 <= 1 and 0 <= gd5 <= 1 and 0 <= gd6 <= 1 and 0 <= gd7 <= 1 and + gd0 + gd1 + gd2 + gd3 + gd4 + gd5 + gd6 + gd7 = 1 and + d = 1*gd1 + 2*gd2 + 3*gd3 + 4*gd4 + 5*gd5 + 6*gd6 + 7*gd7 and s = 5 and d = 5 + } + dims: *8d_onehot_spatial + dist_fn: *fully_connected_unit + expected: + fully_connected_hops: 0 diff --git a/tests/isl/distributed/helpers.py b/tests/isl/distributed/helpers.py new file mode 100644 index 00000000..2fed1f87 --- /dev/null +++ b/tests/isl/distributed/helpers.py @@ -0,0 +1,128 @@ +""" +Shared test helpers for the ISL distributed-buffer multicast model test suite. + +1. ``construct_spacetime`` -- turning a yaml ``dims`` list into ``Tag`` objects. +2. ``run_hops_gamut`` -- the "load a yaml of test cases, build a ``Fill`` + /``Occupancy``/``dist_fn`` triple, run one ``TransferModel``, and compare + ``.hops`` against an expected key" loop shared by ``test_multicast.py``, + ``test_fully_connected.py``, and ``test_xy_routing.py``, which differ only + in which model class, which yaml directory, and which ``expected`` key + (``hypercube_hops`` / ``fully_connected_hops`` / ``xy_routing_hops``) is + used. + +Import note +----------- +This module (and every test module in this package) imports the canonical +``load_solutions`` helper via the *absolute* path ``tests.isl.util``. That +import only resolves if the repository root is on ``sys.path``, which is the +case when the suite is invoked from the repo root as + + PATH="$HOME/.local/bin:$PATH" .venv/bin/python -m pytest tests/isl/distributed/ -q + +(pytest inserts the current working directory / rootdir onto ``sys.path``). +Running this module or its dependents from a different working directory, or +via a bare module path without the repo root on ``sys.path``, will raise +``ModuleNotFoundError: No module named 'tests'``. +""" + +from pathlib import Path + +import islpy as isl + +from accelforge.model._looptree.reuse.isl.distributed.edge_pressure import _eval_const +from accelforge.model._looptree.reuse.isl.mapping_to_isl.types import ( + # Data movement descriptors. + Fill, + Occupancy, + # Tags + Tag, + SpatialTag, + TemporalTag, +) +from accelforge.model._looptree.reuse.isl.spatial import TransferInfo, TransferModel + +from tests.isl.util import load_solutions + + +def construct_spacetime(dims: list[dict]) -> list[Tag]: + """ + Convert a yaml ``dims`` list (each entry a dict with a ``type`` key, plus + ``spatial_dim``/``target`` for spatial entries) into the corresponding + ``Tag`` objects, in order. + + Parameters + ---------- + dims: + The list of dim-tag dicts as loaded from a test-case yaml, e.g. + ``[{"type": "Spatial", "spatial_dim": 0, "target": 0}, ...]``. + + Returns + ------- + ``list[Tag]`` where ``list[i]`` is the tag corresponding to ``dims[i]``. + """ + spacetime: list[Tag] = [] + for dim in dims: + if dim["type"] == "Temporal": + spacetime.append(TemporalTag()) + elif dim["type"] == "Spatial": + spacetime.append(SpatialTag(dim["spatial_dim"], dim["target"])) + + return spacetime + + +def run_hops_gamut(model_cls: type[TransferModel], yaml_path: Path, expected_key: str) -> None: + """ + Run every test case in a yaml gamut file through ``model_cls`` and assert + the resulting ``TransferInfo.hops`` matches the case's expected value. + + Parameters + ---------- + model_cls: + A ``TransferModel`` subclass constructible as ``model_cls(dist_fn)`` + (every model in ``distributed_buffers.py`` follows this signature). + yaml_path: + Path to a yaml file of test cases, each a dict with ``dims``, ``fill``, + ``occ``, ``dist_fn``, and ``expected`` keys (see any + ``*/test_cases.yaml`` under this package for the schema). + expected_key: + The key within each case's ``expected`` dict holding the oracle hop + count for this model (e.g. ``"xy_routing_hops"``). A ``None`` value at + this key marks a case as still in progress: rather than failing, the + case's inputs/output are printed for manual inspection. + + Raises + ------ + AssertionError + If a case's expected value is not ``None`` and the model's computed + ``hops`` does not match it. + """ + testcases: dict = load_solutions(yaml_path) + for test in testcases: + # Reads test case parameters and constructs the necessary objects. + dim_tags: list[Tag] = construct_spacetime(test["dims"]) + fill: Fill = Fill(dim_tags, test["fill"]) + occ: Occupancy = Occupancy(dim_tags, test["occ"]) + dist_fn: isl.Map = test["dist_fn"] + model: TransferModel = model_cls(dist_fn) + + # Applies the model. + info: TransferInfo = model.apply(0, fill, occ) + # Checks the results. `hops` is parameter-free (the validated regime + # every distributed model targets, see `_eval_const`'s precondition), + # so this is a scalar extraction, not a real evaluation-at-a-point. + sum_extract: int = _eval_const(info.hops) + + expected = test["expected"][expected_key] + # The block is used for debugging test cases not yet implemented. + if expected is None: + print("~~~Test case in progress:~~~") + print(f"Fill: {fill}") + print(f"Occ: {occ}") + print(f"Dist Fn: {dist_fn}") + print(f"Returned: {sum_extract}") + else: + assert sum_extract == expected, ( + f"{model_cls.__name__} hops mismatch: got {sum_extract}, " + f"expected {expected} ({expected_key})\n" + f"Fill: {fill}\nOcc: {occ}\nDist Fn: {dist_fn}" + ) diff --git a/tests/isl/distributed/multicast/test_cases.yaml b/tests/isl/distributed/multicast/test_cases.yaml index 32dbc8eb..00afc2e0 100755 --- a/tests/isl/distributed/multicast/test_cases.yaml +++ b/tests/isl/distributed/multicast/test_cases.yaml @@ -479,4 +479,121 @@ # dist_fn: *ring_dist_size_8 # expected: # latency: 1 -# total_hops: 4 \ No newline at end of file +# total_hops: 4 +################################################### +# 8-GPU fully-connected (NVLink/NVSwitch-style) # +# all-to-all, one-hot GPU encoding. # +# # +# GPU i sits at one-hot coordinate e_i so every # +# src!=dst cast has extent 1 along exactly the # +# src and dst dims: cost = (1+1)(1+1)-1 = 3, # +# uniform across all pairs (fully-connected). # +# Self-chunks never cross the fabric (cost 0). # +# dist_fn is unit-cost (matching only). # +################################################### + +# All-to-all over 8 GPUs: each GPU holds chunks data[self, d] and requests data[s, self]. +- occ: | + { + noc[gs0, gs1, gs2, gs3, gs4, gs5, gs6, gs7] -> data[s, d] : + 0 <= gs0 <= 1 and 0 <= gs1 <= 1 and 0 <= gs2 <= 1 and 0 <= gs3 <= 1 and 0 <= gs4 <= 1 and 0 <= gs5 <= 1 and 0 <= gs6 <= 1 and 0 <= gs7 <= 1 and + gs0 + gs1 + gs2 + gs3 + gs4 + gs5 + gs6 + gs7 = 1 and + s = 1*gs1 + 2*gs2 + 3*gs3 + 4*gs4 + 5*gs5 + 6*gs6 + 7*gs7 and 0 <= d < 8 + } + fill: | + { + noc[gd0, gd1, gd2, gd3, gd4, gd5, gd6, gd7] -> data[s, d] : + 0 <= gd0 <= 1 and 0 <= gd1 <= 1 and 0 <= gd2 <= 1 and 0 <= gd3 <= 1 and 0 <= gd4 <= 1 and 0 <= gd5 <= 1 and 0 <= gd6 <= 1 and 0 <= gd7 <= 1 and + gd0 + gd1 + gd2 + gd3 + gd4 + gd5 + gd6 + gd7 = 1 and + d = 1*gd1 + 2*gd2 + 3*gd3 + 4*gd4 + 5*gd5 + 6*gd6 + 7*gd7 and 0 <= s < 8 + } + dims: &8d_onehot_spatial + - type: Spatial + spatial_dim: 0 + target: 0 + - type: Spatial + spatial_dim: 1 + target: 0 + - type: Spatial + spatial_dim: 2 + target: 0 + - type: Spatial + spatial_dim: 3 + target: 0 + - type: Spatial + spatial_dim: 4 + target: 0 + - type: Spatial + spatial_dim: 5 + target: 0 + - type: Spatial + spatial_dim: 6 + target: 0 + - type: Spatial + spatial_dim: 7 + target: 0 + dist_fn: &fully_connected_unit | + { + [noc[xd0, xd1, xd2, xd3, xd4, xd5, xd6, xd7] -> noc[xs0, xs1, xs2, xs3, xs4, xs5, xs6, xs7]] -> hops[0] : + xd0 = xs0 and xd1 = xs1 and xd2 = xs2 and xd3 = xs3 and xd4 = xs4 and xd5 = xs5 and xd6 = xs6 and xd7 = xs7; + [noc[xd0, xd1, xd2, xd3, xd4, xd5, xd6, xd7] -> noc[xs0, xs1, xs2, xs3, xs4, xs5, xs6, xs7]] -> hops[1] : + (xd0 < xs0) or (xd0 > xs0) or (xd1 < xs1) or (xd1 > xs1) or (xd2 < xs2) or (xd2 > xs2) or (xd3 < xs3) or (xd3 > xs3) or (xd4 < xs4) or (xd4 > xs4) or (xd5 < xs5) or (xd5 > xs5) or (xd6 < xs6) or (xd6 > xs6) or (xd7 < xs7) or (xd7 > xs7) + } + expected: + latency: null + total_hops: null + multicast_hops: null + hypercube_hops: 168 + extent_DOR_hops: null + +# Single chunk GPU0 -> GPU3: one unicast cast, cost (1+1)(1+1)-1 = 3. +- occ: | + { + noc[gs0, gs1, gs2, gs3, gs4, gs5, gs6, gs7] -> data[s, d] : + 0 <= gs0 <= 1 and 0 <= gs1 <= 1 and 0 <= gs2 <= 1 and 0 <= gs3 <= 1 and 0 <= gs4 <= 1 and 0 <= gs5 <= 1 and 0 <= gs6 <= 1 and 0 <= gs7 <= 1 and + gs0 + gs1 + gs2 + gs3 + gs4 + gs5 + gs6 + gs7 = 1 and + s = 1*gs1 + 2*gs2 + 3*gs3 + 4*gs4 + 5*gs5 + 6*gs6 + 7*gs7 and s = 0 and d = 3 + } + fill: | + { + noc[gd0, gd1, gd2, gd3, gd4, gd5, gd6, gd7] -> data[s, d] : + 0 <= gd0 <= 1 and 0 <= gd1 <= 1 and 0 <= gd2 <= 1 and 0 <= gd3 <= 1 and 0 <= gd4 <= 1 and 0 <= gd5 <= 1 and 0 <= gd6 <= 1 and 0 <= gd7 <= 1 and + gd0 + gd1 + gd2 + gd3 + gd4 + gd5 + gd6 + gd7 = 1 and + d = 1*gd1 + 2*gd2 + 3*gd3 + 4*gd4 + 5*gd5 + 6*gd6 + 7*gd7 and s = 0 and d = 3 + } + dims: *8d_onehot_spatial + + dist_fn: *fully_connected_unit + + expected: + latency: null + total_hops: null + multicast_hops: null + hypercube_hops: 3 + extent_DOR_hops: null + +# Self chunk GPU5 -> GPU5: never crosses the fabric, cost 0. +- occ: | + { + noc[gs0, gs1, gs2, gs3, gs4, gs5, gs6, gs7] -> data[s, d] : + 0 <= gs0 <= 1 and 0 <= gs1 <= 1 and 0 <= gs2 <= 1 and 0 <= gs3 <= 1 and 0 <= gs4 <= 1 and 0 <= gs5 <= 1 and 0 <= gs6 <= 1 and 0 <= gs7 <= 1 and + gs0 + gs1 + gs2 + gs3 + gs4 + gs5 + gs6 + gs7 = 1 and + s = 1*gs1 + 2*gs2 + 3*gs3 + 4*gs4 + 5*gs5 + 6*gs6 + 7*gs7 and s = 5 and d = 5 + } + fill: | + { + noc[gd0, gd1, gd2, gd3, gd4, gd5, gd6, gd7] -> data[s, d] : + 0 <= gd0 <= 1 and 0 <= gd1 <= 1 and 0 <= gd2 <= 1 and 0 <= gd3 <= 1 and 0 <= gd4 <= 1 and 0 <= gd5 <= 1 and 0 <= gd6 <= 1 and 0 <= gd7 <= 1 and + gd0 + gd1 + gd2 + gd3 + gd4 + gd5 + gd6 + gd7 = 1 and + d = 1*gd1 + 2*gd2 + 3*gd3 + 4*gd4 + 5*gd5 + 6*gd6 + 7*gd7 and s = 5 and d = 5 + } + dims: *8d_onehot_spatial + + dist_fn: *fully_connected_unit + + expected: + latency: null + total_hops: null + multicast_hops: null + hypercube_hops: 0 + extent_DOR_hops: null diff --git a/tests/isl/distributed/test_contract.py b/tests/isl/distributed/test_contract.py new file mode 100644 index 00000000..1b6c6b59 --- /dev/null +++ b/tests/isl/distributed/test_contract.py @@ -0,0 +1,365 @@ +""" +Regression tests for contract fixes to ``distributed_buffers.py``'s multicast +models (see the ``_mesh_node_tuple``/``_covered_fills``/ +``identify_mesh_casts`` docstrings in ``mesh_casts.py`` for the full +rationale). Each class below pins down one previously-broken-or-undocumented +contract so a future change cannot silently regress it: + +- ``TestTupleNameGenericity``: a spacetime/node tuple named anything + other than the literal ``'noc'`` (e.g. ``pe[x, y]``) used to crash deep + inside an opaque ISL assertion in ``XYRoutingMulticastModel``'s and + ``StarMulticastModel``'s helpers, which hardcoded ``'noc'`` into every map + string they built. Both now read the tuple name off the caller's own maps. +- ``TestXYDimensionalityGuard``: a non-2-D node tuple through + ``XYRoutingMulticastModel`` now raises a clear ``ValueError`` instead of the + same kind of opaque ISL assertion. +- ``TestUnfulfilledPartition``: ``fulfilled_fill``/``unfulfilled_fill`` + are a true partition of ``fills`` by whether ``identify_mesh_casts`` found a + matched source, for all four models -- previously ``unfulfilled_fill`` was + unconditionally empty regardless of whether a fill actually had a source. +- ``TestAsymmetricDistFnOrientation``: ``identify_mesh_casts``'s + ``dist_fn`` is documented to be applied in the + ``{ [dst -> src] -> [hops] }`` orientation; this pins down that an + asymmetric ``dist_fn`` (direction-dependent cost) actually selects the + source that is *cheapest to reach from the destination*, not some other + pairing that a swapped orientation would silently produce. + +See accelforge/model/_looptree/reuse/isl/distributed/README.md for the models' +background. +""" + +import unittest + +import islpy as isl + +from accelforge.model._looptree.reuse.isl.mapping_to_isl.types import ( + Fill, + Occupancy, + SpatialTag, +) +from accelforge.model._looptree.reuse.isl.distributed.distributed_buffers import ( + HypercubeMulticastModel, + FullyConnectedMulticastModel, + XYRoutingMulticastModel, + StarMulticastModel, +) +from accelforge.model._looptree.reuse.isl.distributed.edge_pressure import _eval_const + +_CTX = isl.DEFAULT_CONTEXT + + +def _manhattan_2d(name: str) -> isl.Map: + """ + Build a 2-D Manhattan ``{ [dst -> src] -> [hops] }`` distance map over a + node tuple named ``name`` (e.g. ``"noc"`` or ``"pe"``), identical in shape + to the ``2d_manhattan`` fixture in ``xy_routing/test_cases.yaml`` but with + the tuple name parametrized so it can be reused for the tuple-name + genericity regression tests below. + """ + return isl.Map.read_from_str( + _CTX, + ( + "{{ [{n}[xd, yd] -> {n}[xs, ys]] -> hops[(xd - xs) + (yd - ys)] :" + " xd >= xs and yd >= ys;" + " [{n}[xd, yd] -> {n}[xs, ys]] -> hops[-(xd - xs) + -(yd - ys)] :" + " xd < xs and yd < ys;" + " [{n}[xd, yd] -> {n}[xs, ys]] -> hops[-(xd - xs) + (yd - ys)] :" + " xd < xs and yd >= ys;" + " [{n}[xd, yd] -> {n}[xs, ys]] -> hops[(xd - xs) + -(yd - ys)] :" + " xd >= xs and yd < ys }}" + ).format(n=name), + ) + + +def _manhattan_1d(name: str) -> isl.Map: + """ + Build a 1-D Manhattan ``{ [dst -> src] -> [hops] }`` distance map over a + node tuple named ``name``, for the line-of-nodes fixtures below. + """ + return isl.Map.read_from_str( + _CTX, + ( + "{{ [{n}[nd] -> {n}[ns]] -> hops[nd - ns] : nd >= ns;" + " [{n}[nd] -> {n}[ns]] -> hops[ns - nd] : nd < ns }}" + ).format(n=name), + ) + + +def _all_to_all(name: str, n: int) -> tuple[Fill, Occupancy, isl.Map]: + """ + Build ``(fill, occ, dist_fn)`` for an ``n``-node all-to-all on a 1-D line + of nodes tagged under the tuple name ``name``. + + Node ``g`` starts holding only ``data[g]`` and requests every other node's + datum, exactly the ``_all_to_all`` fixture in ``test_edge_pressure.py`` + but with the tuple name parametrized for the genericity checks. + """ + tags = [SpatialTag(0, 0)] + occ = Occupancy( + tags, + isl.Map.read_from_str( + _CTX, f"{{ {name}[g] -> data[d] : 0 <= g < {n} and d = g }}" + ), + ) + fill = Fill( + tags, + isl.Map.read_from_str( + _CTX, + f"{{ {name}[g] -> data[d] : 0 <= g < {n} and 0 <= d < {n} and d != g }}", + ), + ) + return fill, occ, _manhattan_1d(name) + + +class TestTupleNameGenericity(unittest.TestCase): + """ + A node tuple named anything other than ``'noc'`` must produce the same + numbers as the ``'noc'``-named equivalent, for every model whose helpers + used to hardcode the literal ``'noc'`` into ISL map strings. + """ + + def test_xy_non_noc_tuple_name_matches_noc(self): + """ + XY routing case B (source (1,0) -> (0,2),(2,2), 6 hops -- see + ``xy_routing/test_cases.yaml``) run under both ``noc[x, y]`` and + ``pe[x, y]`` must produce the identical hop count. Previously, `'pe'` + crashed inside ``_directed_mesh_links``'s hardcoded ``'noc[...]'`` map + strings with a raw ISL tuple-name-mismatch assertion. + """ + tags = [SpatialTag(0, 0), SpatialTag(1, 0)] + for name in ("noc", "pe"): + with self.subTest(name=name): + occ = Occupancy( + tags, + isl.Map.read_from_str( + _CTX, f"{{ {name}[x, y] -> data[c] : c = 0 and x = 1 and y = 0 }}" + ), + ) + fill = Fill( + tags, + isl.Map.read_from_str( + _CTX, + f"{{ {name}[x, y] -> data[c] : c = 0 and" + f" ((x = 0 and y = 2) or (x = 2 and y = 2)) }}", + ), + ) + model = XYRoutingMulticastModel(_manhattan_2d(name)) + hops = _eval_const(model.apply(0, fill, occ).hops) + self.assertEqual(hops, 6) + + def test_star_and_fully_connected_non_noc_tuple_name_match_noc(self): + """ + The 8-node all-to-all oracle (FullyConnected 56, Star 64 -- see + ``TestStarSpokePressure`` in ``test_edge_pressure.py``) run under both + ``noc[g]`` and ``pe[g]`` must produce identical numbers. Previously, + ``StarMulticastModel._spoke_loads`` hardcoded ``'noc'`` the same way + ``_directed_mesh_links`` did (``FullyConnectedMulticastModel`` never + hardcoded a tuple name, so it is included here only as a same-input + cross-check, not because it was previously broken). + """ + for name in ("noc", "pe"): + with self.subTest(name=name): + fill, occ, dist_fn = _all_to_all(name, 8) + fc_hops = _eval_const( + FullyConnectedMulticastModel(dist_fn).apply(0, fill, occ).hops + ) + star_hops = _eval_const( + StarMulticastModel(dist_fn).apply(0, fill, occ).hops + ) + self.assertEqual(fc_hops, 56) + self.assertEqual(star_hops, 64) + + +class TestXYDimensionalityGuard(unittest.TestCase): + """ + ``XYRoutingMulticastModel`` only supports exactly 2-D node tuples, and + must say so with a clear ``ValueError`` rather than an opaque ISL abort. + """ + + def test_three_dimensional_node_tuple_raises_value_error(self): + """ + A 3-D node tuple (``noc[x, y, z]``) must raise ``ValueError`` from + ``apply`` (via ``_directed_mesh_links``), naming the offending tuple + and its dimensionality, instead of failing deep inside an + ``isl.Map.read_from_str``/``apply_range`` call the way it did before + ``_mesh_node_tuple``'s dimensionality check was added. The + `dist_fn` here is a trivial constant (correctness of routing over a + 3-D mesh is out of scope -- this test only exercises the guard). + """ + tags = [SpatialTag(0, 0), SpatialTag(1, 0), SpatialTag(2, 0)] + occ = Occupancy( + tags, + isl.Map.read_from_str( + _CTX, "{ noc[x, y, z] -> data[c] : c = 0 and x = 0 and y = 0 and z = 0 }" + ), + ) + fill = Fill( + tags, + isl.Map.read_from_str( + _CTX, "{ noc[x, y, z] -> data[c] : c = 0 and x = 1 and y = 0 and z = 0 }" + ), + ) + dist_fn = isl.Map.read_from_str( + _CTX, "{ [noc[xd, yd, zd] -> noc[xs, ys, zs]] -> hops[0] }" + ) + model = XYRoutingMulticastModel(dist_fn) + with self.assertRaisesRegex( + ValueError, r"2-D node tuple.*'noc'.*3 dimensions" + ): + model.apply(0, fill, occ) + + +class TestUnfulfilledPartition(unittest.TestCase): + """ + ``fulfilled_fill``/``unfulfilled_fill`` must be a true partition of + ``fills`` by whether ``identify_mesh_casts`` matched a source, for every + model. Shared geometry: node 0 holds only ``data[0]``; node 1 requests + both ``data[0]`` (has a source -- fulfilled) and ``data[1]`` (no source + anywhere -- unfulfilled). Previously, every model reported + ``unfulfilled_fill`` as unconditionally empty (``fills - fills``) and + ``fulfilled_fill`` as the entire (uncovered) fill map. + """ + + @staticmethod + def _geometry(name: str, two_d: bool): + """ + Build ``(tags, occ, fill, dist_fn, expected_fulfilled, + expected_unfulfilled)`` for the shared node0/node1 geometry, in either + a 1-D node tuple (for models with no dimensionality requirement) or a + 2-D one (required by ``XYRoutingMulticastModel``). + """ + if two_d: + tags = [SpatialTag(0, 0), SpatialTag(1, 0)] + occ = Occupancy( + tags, + isl.Map.read_from_str( + _CTX, f"{{ {name}[x, y] -> data[c] : x = 0 and y = 0 and c = 0 }}" + ), + ) + fill = Fill( + tags, + isl.Map.read_from_str( + _CTX, + f"{{ {name}[x, y] -> data[c] : x = 1 and y = 0 and" + f" (c = 0 or c = 1) }}", + ), + ) + dist_fn = _manhattan_2d(name) + expected_fulfilled = isl.Map.read_from_str( + _CTX, f"{{ {name}[x, y] -> data[c] : x = 1 and y = 0 and c = 0 }}" + ) + expected_unfulfilled = isl.Map.read_from_str( + _CTX, f"{{ {name}[x, y] -> data[c] : x = 1 and y = 0 and c = 1 }}" + ) + else: + tags = [SpatialTag(0, 0)] + occ = Occupancy( + tags, isl.Map.read_from_str(_CTX, f"{{ {name}[n] -> data[c] : n = 0 and c = 0 }}") + ) + fill = Fill( + tags, + isl.Map.read_from_str( + _CTX, f"{{ {name}[n] -> data[c] : n = 1 and (c = 0 or c = 1) }}" + ), + ) + dist_fn = _manhattan_1d(name) + expected_fulfilled = isl.Map.read_from_str( + _CTX, f"{{ {name}[n] -> data[c] : n = 1 and c = 0 }}" + ) + expected_unfulfilled = isl.Map.read_from_str( + _CTX, f"{{ {name}[n] -> data[c] : n = 1 and c = 1 }}" + ) + return tags, occ, fill, dist_fn, expected_fulfilled, expected_unfulfilled + + def test_partition_across_models(self): + """ + For each model, ``unfulfilled_fill.map_`` must be exactly + ``{ dst -> data[1] }`` (node 1's unmatched request), and + ``fulfilled_fill.map_`` must be exactly ``{ dst -> data[0] }`` (the + covered rest). ``hops`` is checked too, as a sanity cross-check that + only the matched (data[0]) delivery is costed -- 1 for the models that + count a single crossing, 2 for ``StarMulticastModel`` (its `hops` is + injection + delivery, i.e. egress + ingress, for the one crossing + delivery). + """ + # (model class, needs-2-D node tuple, expected `hops` for the one + # matched delivery). + cases = [ + (HypercubeMulticastModel, False, 1), + (FullyConnectedMulticastModel, False, 1), + (StarMulticastModel, False, 2), + (XYRoutingMulticastModel, True, 1), + ] + for model_cls, two_d, expected_hops in cases: + with self.subTest(model=model_cls.__name__): + _, occ, fill, dist_fn, expected_fulfilled, expected_unfulfilled = ( + self._geometry("noc", two_d) + ) + info = model_cls(dist_fn).apply(0, fill, occ) + self.assertTrue( + info.unfulfilled_fill.map_.is_equal(expected_unfulfilled), + f"unfulfilled_fill was {info.unfulfilled_fill.map_}, " + f"expected {expected_unfulfilled}", + ) + self.assertTrue( + info.fulfilled_fill.map_.is_equal(expected_fulfilled), + f"fulfilled_fill was {info.fulfilled_fill.map_}, " + f"expected {expected_fulfilled}", + ) + self.assertEqual(_eval_const(info.hops), expected_hops) + + +class TestAsymmetricDistFnOrientation(unittest.TestCase): + """ + ``identify_mesh_casts`` applies ``dist_fn`` in the + ``{ [dst -> src] -> [hops] }`` orientation (the docstring previously said + the opposite, though the code itself was never wrong -- the fix was + documentation-only). This pins down the now-correctly-documented + behavior with a ``dist_fn`` that is *directionally* asymmetric (cost + depends on which side is source vs. destination, not just on distance), + so a caller who mis-orients their own ``dist_fn`` -- or a future change + that swaps `identify_mesh_casts`'s composition order -- would select the + wrong source and fail this test. + """ + + def test_nearest_source_selected_by_directional_cost_not_distance(self): + """ + Three nodes on a line: 0, 1, 2. Nodes 0 and 2 both hold ``data[0]``; + node 1 requests it. Both candidate sources are Euclidean-distance 1 + away, so a distance-based tiebreak could not distinguish them -- the + ``dist_fn`` instead makes the *direction* the deciding factor: a + source below the destination (``src < dst``) costs 1, a source above + (``src > dst``) costs 5. The nearest (min-cost) source is therefore + node 0, not node 2. `identify_mesh_casts`'s `parent_reads` records + exactly which source served the delivery, so this is checked directly + against the expected ``{ data[0] -> [noc[1] -> noc[0]] }`` mapping, + not just inferred from a hop count. + """ + tags = [SpatialTag(0, 0)] + occ = Occupancy( + tags, isl.Map.read_from_str(_CTX, "{ noc[n] -> data[c] : c = 0 and (n = 0 or n = 2) }") + ) + fill = Fill(tags, isl.Map.read_from_str(_CTX, "{ noc[n] -> data[c] : c = 0 and n = 1 }")) + # `{ [dst -> src] -> hops }`: cheap (1) if src is below dst, expensive + # (5) if src is above -- direction, not magnitude, decides. + dist_fn = isl.Map.read_from_str( + _CTX, + "{ [noc[nd] -> noc[ns]] -> hops[0] : nd = ns;" + " [noc[nd] -> noc[ns]] -> hops[1] : ns < nd;" + " [noc[nd] -> noc[ns]] -> hops[5] : ns > nd }", + ) + model = FullyConnectedMulticastModel(dist_fn) + info = model.apply(0, fill, occ) + + expected_match = isl.Map.read_from_str(_CTX, "{ data[0] -> [noc[1] -> noc[0]] }") + self.assertTrue( + info.parent_reads.map_.is_equal(expected_match), + f"parent_reads was {info.parent_reads.map_}, expected {expected_match} " + "(source node 0, the directionally-cheap side)", + ) + # A single crossing delivery, regardless of the asymmetric magnitude. + self.assertEqual(_eval_const(info.hops), 1) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/isl/distributed/test_edge_pressure.py b/tests/isl/distributed/test_edge_pressure.py new file mode 100644 index 00000000..6abac5ec --- /dev/null +++ b/tests/isl/distributed/test_edge_pressure.py @@ -0,0 +1,194 @@ +""" +Tests for per-edge memory pressure (link load) of the distributed transfer models. + +Where a model's ``hops`` is a single scalar, ``EdgePressure`` breaks the load out +per *physical* directed edge: how many multicast trees cross each link. Two models +are covered: + +- ``XYRoutingMulticastModel``'s ``edge_pressure`` (reached via + ``model.apply(0, fill, occ).edge_pressure``) -- directed mesh + links (``xedge_r``/``xedge_l``/``yedge_u``/``yedge_d``). ``test_load_sums_to_hops`` + checks the per-edge loads sum back to the already-validated A-F hop totals + (4/6/6/3/4/448). Bottlenecks and a couple of individual edge loads + (hand-derived, geometry documented inline) pin the shape. + +- ``StarMulticastModel``'s ``edge_pressure`` -- + the spokes realization of a fully-connected fabric (``spoke_in[n]`` ingress, + ``spoke_out[n]`` egress). For an N-way all-to-all each node receives N-1 and + sources 1, so the ingress spokes are hottest at N-1. Tied to + ``FullyConnectedMulticastModel`` by + ``sum over nodes of ingress == FullyConnected crossing count``. + +See accelforge/model/_looptree/reuse/isl/distributed/README.md. +""" + +import unittest +from pathlib import Path + +import islpy as isl + +from accelforge.model._looptree.reuse.isl.mapping_to_isl.types import ( + Fill, + Occupancy, + SpatialTag, +) +from accelforge.model._looptree.reuse.isl.distributed.distributed_buffers import ( + XYRoutingMulticastModel, + StarMulticastModel, + FullyConnectedMulticastModel, +) +from accelforge.model._looptree.reuse.isl.distributed.edge_pressure import _eval_const + +from .helpers import construct_spacetime, load_solutions + + +# Fully-connected (NVSwitch-style) distance for the star/all-to-all cases: every +# distinct node pair is exactly 1 hop apart, a node is 0 hops from itself. +_FULLY_CONNECTED: str = ( + "{ [noc[gd] -> noc[gs]] -> hops[1] : gd != gs;" + " [noc[gd] -> noc[gs]] -> hops[0] : gd = gs }" +) + + +class TestXYRoutingEdgePressure(unittest.TestCase): + """Per-mesh-edge pressure for XY routing, reusing the XY hop test geometries.""" + + TEST_CASES_FILE: str = Path(__file__).parent / "xy_routing" / "test_cases.yaml" + testcases: dict = load_solutions(TEST_CASES_FILE) + + def test_load_sums_to_hops(self): + """ + Summed per-edge load matches the trusted A-F hop totals. + + `MulticastModel.apply` computes `hops = _const_pwq(pressure.total())` + from the same `EdgePressure` it reports, so `pressure.total() == + info.hops` is true by construction. The regression value here is the + comparison against the yaml's hand-derived `xy_routing_hops` oracle -- + an independent, human-computed number, unrelated to how the code is + wired -- checked per case. + """ + for test in self.testcases: + expected = test["expected"]["xy_routing_hops"] + if expected is None: + continue + dim_tags = construct_spacetime(test["dims"]) + fill = Fill(dim_tags, test["fill"]) + occ = Occupancy(dim_tags, test["occ"]) + model = XYRoutingMulticastModel(test["dist_fn"]) + pressure = model.apply(0, fill, occ).edge_pressure + assert pressure.total() == expected, ( + f"Σ edge load {pressure.total()} != hops {expected}" + ) + + def test_case_F_bottleneck_and_edges(self): + """ + Case F (8x8, datum (d0,d1) at node (d0,d1), requested by all of column + x=d0): every datum of column c floods all of column c's 7 vertical links. + The upward link yedge_u[c,t] carries data with d1<=t (load t+1), the + downward yedge_d[c,t] carries data with d1>t (load 7-t); the busiest + directed link is 7 (the physical link total is 8). + """ + f = next(t for t in self.testcases if t["expected"]["xy_routing_hops"] == 448) + dim_tags = construct_spacetime(f["dims"]) + model = XYRoutingMulticastModel(f["dist_fn"]) + pressure = model.apply( + 0, Fill(dim_tags, f["fill"]), Occupancy(dim_tags, f["occ"]) + ).edge_pressure + assert pressure.bottleneck() == 7 + assert pressure.eval_edge("yedge_u", [0, 6]) == 7 # top link, all 7 below + assert pressure.eval_edge("yedge_d", [0, 1]) == 6 # links below row 1 + assert pressure.eval_edge("xedge_r", [0, 0]) == 0 # no horizontal traffic + + def test_monotone_overlap_bottleneck(self): + """ + Regression: the directed load is non-constant (a flooded up-link carries + ``t + 1`` trees), so the bottleneck must enumerate the edge domain, not + sample one point per piece. Three sources stacked up column 0 (rows 2, 1, + 0) all casting to (0, 3): the top up-link (rows 2->3) carries all three, so + the bottleneck is 3 even though lower links carry 1 and 2. + """ + tags = [SpatialTag(0, 0), SpatialTag(1, 0)] + manhattan = isl.Map.read_from_str(isl.DEFAULT_CONTEXT, ( + "{ [noc[xd,yd]->noc[xs,ys]]->hops[(xd-xs)+(yd-ys)] : xd>=xs and yd>=ys;" + " [noc[xd,yd]->noc[xs,ys]]->hops[-(xd-xs)+-(yd-ys)] : xdnoc[xs,ys]]->hops[-(xd-xs)+(yd-ys)] : xd=ys;" + " [noc[xd,yd]->noc[xs,ys]]->hops[(xd-xs)+-(yd-ys)] : xd>=xs and yddata[c] : (c=0 and x=0 and y=2) or (c=1 and x=0 and y=1)" + " or (c=2 and x=0 and y=0) }"))) + fill = Fill(tags, isl.Map.read_from_str( + isl.DEFAULT_CONTEXT, "{ noc[x,y]->data[c] : x=0 and y=3 and 0<=c<3 }")) + pressure = XYRoutingMulticastModel(manhattan).apply(0, fill, occ).edge_pressure + assert pressure.eval_edge("yedge_u", [0, 2]) == 3 # top link, all three + assert pressure.bottleneck() == 3 # NOT 1 or 2 + + def test_single_tree_cases_have_unit_bottleneck(self): + """ + Cases A-E are single multicast trees, so no link is shared by two trees and + every edge carries load exactly 1. + """ + for test in self.testcases: + exp = test["expected"]["xy_routing_hops"] + if exp is None or exp == 448: # F has overlapping trees + continue + dim_tags = construct_spacetime(test["dims"]) + model = XYRoutingMulticastModel(test["dist_fn"]) + pressure = model.apply( + 0, Fill(dim_tags, test["fill"]), Occupancy(dim_tags, test["occ"]) + ).edge_pressure + assert pressure.bottleneck() == 1 + + +class TestStarSpokePressure(unittest.TestCase): + """Per-spoke pressure for the star (FC spokes) model on N-way all-to-all.""" + + @staticmethod + def _all_to_all(n: int): + """Build (fill, occ, dist_fn) for an N-GPU all-to-all on a fully-connected fabric.""" + tags = [SpatialTag(0, 0)] + occ = Occupancy(tags, isl.Map.read_from_str( + isl.DEFAULT_CONTEXT, + f"{{ noc[g] -> data[d] : 0 <= g < {n} and d = g }}")) + fill = Fill(tags, isl.Map.read_from_str( + isl.DEFAULT_CONTEXT, + f"{{ noc[g] -> data[d] : 0 <= g < {n} and 0 <= d < {n} and d != g }}")) + dist_fn = isl.Map.read_from_str(isl.DEFAULT_CONTEXT, _FULLY_CONNECTED) + return fill, occ, dist_fn + + def test_all_to_all_spoke_loads(self): + """ + N-way all-to-all: every node receives N-1 (ingress) and sources 1 (egress); + the bottleneck spoke is the ingress at N-1. + """ + for n in (4, 8): + fill, occ, dist_fn = self._all_to_all(n) + pressure = StarMulticastModel(dist_fn).apply(0, fill, occ).edge_pressure + for node in range(n): + assert pressure.eval_edge("spoke_in", [node]) == n - 1 + assert pressure.eval_edge("spoke_out", [node]) == 1 + assert pressure.bottleneck() == n - 1 + + def test_ingress_sum_equals_fully_connected_count(self): + """ + Cross-model invariant: Σ ingress over spokes == FullyConnected crossing + count (every crossing delivery is exactly one node's ingress). + """ + for n in (4, 8): + fill, occ, dist_fn = self._all_to_all(n) + pressure = StarMulticastModel(dist_fn).apply(0, fill, occ).edge_pressure + total_ingress = sum(pressure.eval_edge("spoke_in", [g]) for g in range(n)) + fc_hops = _eval_const( + FullyConnectedMulticastModel(dist_fn).apply(0, fill, occ).hops + ) + assert total_ingress == fc_hops == n * (n - 1) + + def test_star_hops_is_injections_plus_deliveries(self): + """Star scalar hops == Σ egress + Σ ingress == N + N(N-1).""" + for n in (4, 8): + fill, occ, dist_fn = self._all_to_all(n) + hops = _eval_const(StarMulticastModel(dist_fn).apply(0, fill, occ).hops) + assert hops == n + n * (n - 1) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/isl/distributed/test_fully_connected.py b/tests/isl/distributed/test_fully_connected.py new file mode 100644 index 00000000..3cc0f9af --- /dev/null +++ b/tests/isl/distributed/test_fully_connected.py @@ -0,0 +1,34 @@ +""" +Tests for the FullyConnectedMulticastModel. + +The model costs each delivery that crosses the fabric at one hop and self- +deliveries at zero, so an 8-GPU one-hot all-to-all costs 56 hops (64 chunks - 8 +self-deliveries) -- a third of the HypercubeMulticastModel's 168 on the same +input. See accelforge/model/_looptree/reuse/isl/distributed/README.md. +""" + +import unittest +from pathlib import Path + +from accelforge.model._looptree.reuse.isl.distributed.distributed_buffers import ( + FullyConnectedMulticastModel, +) +from .helpers import run_hops_gamut + + +class TestFullyConnectedMulticastModel(unittest.TestCase): + """ + Tests the FullyConnectedMulticastModel with a series of premade test cases. + """ + + TEST_CASES_FILE: Path = ( + Path(__file__).parent / "fully_connected" / "test_cases.yaml" + ) + + def test_gamut(self): + """ + Tests the entire gamut of test cases we have specified in the yaml. + """ + run_hops_gamut( + FullyConnectedMulticastModel, self.TEST_CASES_FILE, "fully_connected_hops" + ) diff --git a/tests/isl/distributed/test_multicast.py b/tests/isl/distributed/test_multicast.py new file mode 100644 index 00000000..61a41ced --- /dev/null +++ b/tests/isl/distributed/test_multicast.py @@ -0,0 +1,26 @@ +""" +File ported from here: +https://github.com/rengzhengcodes/timeloop/blob/distributed-multicast-dev/src/unit-test/multicast/test-multicast.cpp +""" + +import unittest +from pathlib import Path + +from accelforge.model._looptree.reuse.isl.distributed.distributed_buffers import ( + HypercubeMulticastModel, +) +from .helpers import run_hops_gamut + + +class TestHypercubeMulticastModel(unittest.TestCase): + """ + Tests the HypercubeMulticastModel with a series of premade test cases. + """ + + TEST_CASES_FILE: Path = Path(__file__).parent / "multicast" / "test_cases.yaml" + + def test_gamut(self): + """ + Tests the entire gamut of test cases we have specified in the yaml. + """ + run_hops_gamut(HypercubeMulticastModel, self.TEST_CASES_FILE, "hypercube_hops") diff --git a/tests/isl/distributed/test_xy_routing.py b/tests/isl/distributed/test_xy_routing.py new file mode 100644 index 00000000..6fcfe5f9 --- /dev/null +++ b/tests/isl/distributed/test_xy_routing.py @@ -0,0 +1,33 @@ +""" +Tests for the XYRoutingMulticastModel (dimension-order routing on a 2-D mesh). + +XY routing sends each packet along X then Y, so a multicast tree is an X-segment +along the source row plus an independent Y-segment down each destination column. +The expected hop counts here are HAND-DERIVED (the repo has no XY oracle -- the +yaml's extent_DOR_hops is a free-routing floor, not the XY cost); each case's +geometry is documented in fully_connected/../xy_routing/test_cases.yaml so the +numbers can be re-checked. See +accelforge/model/_looptree/reuse/isl/distributed/README.md. +""" + +import unittest +from pathlib import Path + +from accelforge.model._looptree.reuse.isl.distributed.distributed_buffers import ( + XYRoutingMulticastModel, +) +from .helpers import run_hops_gamut + + +class TestXYRoutingMulticastModel(unittest.TestCase): + """ + Tests the XYRoutingMulticastModel with a series of premade test cases. + """ + + TEST_CASES_FILE: Path = Path(__file__).parent / "xy_routing" / "test_cases.yaml" + + def test_gamut(self): + """ + Tests the entire gamut of test cases we have specified in the yaml. + """ + run_hops_gamut(XYRoutingMulticastModel, self.TEST_CASES_FILE, "xy_routing_hops") diff --git a/tests/isl/distributed/xy_routing/test_cases.yaml b/tests/isl/distributed/xy_routing/test_cases.yaml new file mode 100644 index 00000000..ff192655 --- /dev/null +++ b/tests/isl/distributed/xy_routing/test_cases.yaml @@ -0,0 +1,83 @@ +################################################### +# XY (dimension-order) routing test cases. # +# # +# Packets route along X first, then Y, so a # +# multicast tree = X-segment along the source row # +# + an independent Y-segment down each destination # +# column from the source row. # +# cost = x_extent({xs} u dst columns) # +# + sum_col y_extent({ys} u dsts in col) # +# # +# Expected xy_routing_hops are HAND-DERIVED (no # +# oracle exists in the repo); each case lists the # +# geometry so the number can be re-checked. # +# # +# Ordering: extent_DOR (floor) <= XY <= hypercube. # +################################################### + +# A. Unicast (0,0) -> (2,2): X=2 + col2 Y={0,2}=2 -> 4 (== floor). +- occ: "{ noc[x, y] -> data[c] : c = 0 and x = 0 and y = 0 }" + fill: "{ noc[x, y] -> data[c] : c = 0 and x = 2 and y = 2 }" + dims: &2d_spatial + - type: Spatial + spatial_dim: 0 + target: 0 + - type: Spatial + spatial_dim: 1 + target: 0 + dist_fn: &2d_manhattan | + { + [noc[xd, yd] -> noc[xs, ys]] -> hops[(xd - xs) + (yd - ys)] : + xd >= xs and yd >= ys; + [noc[xd, yd] -> noc[xs, ys]] -> hops[-(xd - xs) + -(yd - ys)] : + xd < xs and yd < ys; + [noc[xd, yd] -> noc[xs, ys]] -> hops[-(xd - xs) + (yd - ys)] : + xd < xs and yd >= ys; + [noc[xd, yd] -> noc[xs, ys]] -> hops[(xd - xs) + -(yd - ys)] : + xd >= xs and yd < ys + } + expected: + xy_routing_hops: 4 + +# B. Source (1,0) -> (0,2),(2,2): X={0,1,2}=2 + col0 {0,2}=2 + col2 {0,2}=2 -> 6. +# Discriminator: floor 4 < XY 6 < hypercube 8. +- occ: "{ noc[x, y] -> data[c] : c = 0 and x = 1 and y = 0 }" + fill: "{ noc[x, y] -> data[c] : c = 0 and ((x = 0 and y = 2) or (x = 2 and y = 2)) }" + dims: *2d_spatial + dist_fn: *2d_manhattan + expected: + xy_routing_hops: 6 + +# C. Three corners from (0,0) -> (2,0),(0,2),(2,2): X={0,2}=2 + col0 {0,2}=2 + col2 {0,2}=2 -> 6. +- occ: "{ noc[x, y] -> data[c] : c = 0 and x = 0 and y = 0 }" + fill: "{ noc[x, y] -> data[c] : c = 0 and ((x = 2 and y = 0) or (x = 0 and y = 2) or (x = 2 and y = 2)) }" + dims: *2d_spatial + dist_fn: *2d_manhattan + expected: + xy_routing_hops: 6 + +# D. 1-D column (0,1) -> column x=0, y=0..3: X=0 + col0 {0..3}=3 -> 3 (== floor). +- occ: "{ noc[x, y] -> data[c] : c = 0 and x = 0 and y = 1 }" + fill: "{ noc[x, y] -> data[c] : c = 0 and x = 0 and 0 <= y < 4 }" + dims: *2d_spatial + dist_fn: *2d_manhattan + expected: + xy_routing_hops: 3 + +# E. Replicated source (0,0)&(2,0) -> (0,2),(2,2): per-dst nearest splits into two +# self-column trees, each 2 -> 4 (a single-root tree would cost 6). +- occ: "{ noc[x, y] -> data[c] : c = 0 and ((x = 0 and y = 0) or (x = 2 and y = 0)) }" + fill: "{ noc[x, y] -> data[c] : c = 0 and ((x = 0 and y = 2) or (x = 2 and y = 2)) }" + dims: *2d_spatial + dist_fn: *2d_manhattan + expected: + xy_routing_hops: 4 + +# F. Scale: 8x8, each datum data[d0,d1] held at (d0,d1), requested by column x=d0. +# Per datum: X=0 + Y=7 -> 7; 64 data -> 448. +- occ: "{ noc[xs, ys] -> data[d0, d1] : d0 = xs and d1 = ys and 0 <= xs < 8 and 0 <= ys < 8 }" + fill: "{ noc[xd, yd] -> data[d0, d1] : d0 = xd and 0 <= d1 < 8 and 0 <= xd < 8 and 0 <= yd < 8 }" + dims: *2d_spatial + dist_fn: *2d_manhattan + expected: + xy_routing_hops: 448 diff --git a/tests/not_working/isl/mapper/__init__.py b/tests/isl/mapper/__init__.py similarity index 100% rename from tests/not_working/isl/mapper/__init__.py rename to tests/isl/mapper/__init__.py diff --git a/tests/not_working/isl/mapper/configs/conv1d/conv1d.mapping.yaml b/tests/isl/mapper/configs/conv1d/conv1d.mapping.yaml similarity index 100% rename from tests/not_working/isl/mapper/configs/conv1d/conv1d.mapping.yaml rename to tests/isl/mapper/configs/conv1d/conv1d.mapping.yaml diff --git a/tests/not_working/isl/mapper/configs/conv1d/conv1d.workload.yaml b/tests/isl/mapper/configs/conv1d/conv1d.workload.yaml similarity index 100% rename from tests/not_working/isl/mapper/configs/conv1d/conv1d.workload.yaml rename to tests/isl/mapper/configs/conv1d/conv1d.workload.yaml diff --git a/tests/not_working/isl/mapper/configs/two_conv1d/two_conv1d.expected.yaml b/tests/isl/mapper/configs/two_conv1d/two_conv1d.expected.yaml similarity index 100% rename from tests/not_working/isl/mapper/configs/two_conv1d/two_conv1d.expected.yaml rename to tests/isl/mapper/configs/two_conv1d/two_conv1d.expected.yaml diff --git a/tests/not_working/isl/mapper/configs/two_conv1d/two_conv1d.mapping.yaml b/tests/isl/mapper/configs/two_conv1d/two_conv1d.mapping.yaml similarity index 100% rename from tests/not_working/isl/mapper/configs/two_conv1d/two_conv1d.mapping.yaml rename to tests/isl/mapper/configs/two_conv1d/two_conv1d.mapping.yaml diff --git a/tests/not_working/isl/mapper/configs/two_conv1d/two_conv1d.workload.yaml b/tests/isl/mapper/configs/two_conv1d/two_conv1d.workload.yaml similarity index 100% rename from tests/not_working/isl/mapper/configs/two_conv1d/two_conv1d.workload.yaml rename to tests/isl/mapper/configs/two_conv1d/two_conv1d.workload.yaml diff --git a/tests/not_working/isl/mapper/test_isl_functions.py b/tests/isl/mapper/test_isl_functions.py similarity index 100% rename from tests/not_working/isl/mapper/test_isl_functions.py rename to tests/isl/mapper/test_isl_functions.py diff --git a/tests/not_working/isl/mapper/test_mapping_to_isl.py b/tests/isl/mapper/test_mapping_to_isl.py similarity index 100% rename from tests/not_working/isl/mapper/test_mapping_to_isl.py rename to tests/isl/mapper/test_mapping_to_isl.py diff --git a/tests/not_working/isl/mapper/test_spatial_reuse_analysis.py b/tests/isl/mapper/test_spatial_reuse_analysis.py similarity index 100% rename from tests/not_working/isl/mapper/test_spatial_reuse_analysis.py rename to tests/isl/mapper/test_spatial_reuse_analysis.py diff --git a/tests/not_working/isl/mapper/test_temporal_reuse_analysis.py b/tests/isl/mapper/test_temporal_reuse_analysis.py similarity index 100% rename from tests/not_working/isl/mapper/test_temporal_reuse_analysis.py rename to tests/isl/mapper/test_temporal_reuse_analysis.py diff --git a/tests/not_working/distribuffers/multicast/test_cases.yaml b/tests/not_working/distribuffers/multicast/test_cases.yaml deleted file mode 100755 index 32dbc8eb..00000000 --- a/tests/not_working/distribuffers/multicast/test_cases.yaml +++ /dev/null @@ -1,482 +0,0 @@ -- occ: "{ noc[xs, ys] -> data[d0, d1] : d0=xs and d1=ys and 0 <= xs < 8 and 0 <= ys < 8 }" - fill: "{ noc[xd, yd] -> data[d0, d1] : d0=xd and 0 <= d1 < 8 and 0 <= xd < 8 and 0 <= yd < 8 }" - dims: &2d_spatial - - type: Spatial - spatial_dim: 0 - target: 0 - - type: Spatial - spatial_dim: 1 - target: 0 - dist_fn: &2d_manhattan | - { - [noc[xd, yd] -> noc[xs, ys]] -> hops[(xd - xs) + (yd - ys)] : - xd >= xs and yd >= ys; - [noc[xd, yd] -> noc[xs, ys]] -> hops[-(xd - xs) + -(yd - ys)] : - xd < xs and yd < ys; - [noc[xd, yd] -> noc[xs, ys]] -> hops[-(xd - xs) + (yd - ys)] : - xd < xs and yd >= ys; - [noc[xd, yd] -> noc[xs, ys]] -> hops[(xd - xs) + -(yd - ys)] : - xd >= xs and yd < ys - } - expected: &equivalent_class_1 - latency: 7 - total_hops: 1344 - multicast_hops: 448 - hypercube_hops: 448 - extent_DOR_hops: 448 -- occ: "{ noc[xs, ys] -> data[d0, d1] : d0=xs and d1=ys and 0 <= xs < 8 and 0 <= ys < 8 }" - fill: "{ noc[xd, yd] -> data[d0, d1] : 0 <= d0 < 8 and d1=yd and 0 <= xd < 8 and 0 <= yd < 8 }" - dims: *2d_spatial - dist_fn: *2d_manhattan - expected: *equivalent_class_1 -- occ: "{ noc[xs, ys] -> data[d0, d1] : d0 = xs and 0 <= d1 < 8 and 0 <= xs < 8 and 0 <= ys < 8 }" - fill: "{ noc[xd, yd] -> data[d0, d1] : d0 = xd and d1 = yd and 0 <= xd < 8 and 0 <= yd < 8 }" - dims: *2d_spatial - dist_fn: *2d_manhattan - expected: &equivalent_class_2 - latency: 0 - total_hops: 0 - multicast_hops: 0 - hypercube_hops: 0 - extent_DOR_hops: 448 -- occ: "{ noc[xs, ys] -> data[d0, d1] : 0 <= d0 < 8 and d1 = ys and 0 <= xs < 8 and 0 <= ys < 8 }" - fill: "{ noc[xd, yd] -> data[d0, d1] : d0 = xd and d1 = yd and 0 <= xd < 8 and 0 <= yd < 8 }" - dims: *2d_spatial - dist_fn: *2d_manhattan - expected: *equivalent_class_2 - -####################### -# Temporal Test Cases # -####################### -##@section 1x1 dummy case to catch temporal integration issues. -# Edge to Src -- occ: &1x1_edge | - { - noc[tm, tn, xs, ys] -> A[m, k] : - 0 <= tm < 1 and 0 <= tn < 1 and - xs=0 and ys=0 and - 0 <= m < 1 and 0 <= k < 1 - } - fill: | - { - noc[tm, tn, xd, yd] -> A[m, k] : - 0 <= tm < 1 and 0 <= tn < 1 and tn = 0 and - 0 <= xd < 1 and 0 <= yd < 1 and - m = (1 * tm) + xd and 0 <= k < 1 - } - dims: &2t-dim_2s-dim - - type: Temporal - - type: Temporal - - type: Spatial - spatial_dim: 0 - target: 0 - - type: Spatial - spatial_dim: 1 - target: 0 - dist_fn: &2t-dim_2s-dim_manhattan | - { - [noc[tm, tn, xd, yd] -> noc[tm, tn, xs, ys]] -> hops[(xd - xs) + (yd - ys)] : - xd >= xs and yd >= ys; - [noc[tm, tn, xd, yd] -> noc[tm, tn, xs, ys]] -> hops[-(xd - xs) + -(yd - ys)] : - xd < xs and yd < ys; - [noc[tm, tn, xd, yd] -> noc[tm, tn, xs, ys]] -> hops[-(xd - xs) + (yd - ys)] : - xd < xs and yd >= ys; - [noc[tm, tn, xd, yd] -> noc[tm, tn, xs, ys]] -> hops[(xd - xs) + -(yd - ys)] : - xd >= xs and yd < ys - } - expected: - latency: null - total_hops: null - multicast_hops: null - hypercube_hops: 0 - extent_DOR_hops: null -# Src to Dst -- occ: | - { - noc[tm, tn, tk, xs, ys] -> A[m, k] : - 0 <= tm < 1 and 0 <= tn < 1 and 0 <= tk < 1 and - 0 <= xs < 1 and 0 <= ys < 1 and - m= (1 * tm) + xs and 0 <= k < 1 - } - fill: &1x1_fill | - { - noc[tm, tn, tk, xd, yd] -> A[m, k] : - 0 <= tm < 1 and 0 <= tn < 1 and 0 <= tk < 1 and - 0 <= xd < 1 and 0 <= yd < 1 and - m= (1 * tm) + xd and k=tk - } - dims: &3t-dim_2s-dim - - type: Temporal - - type: Temporal - - type: Temporal - - type: Spatial - spatial_dim: 0 - target: 0 - - type: Spatial - spatial_dim: 1 - target: 0 - dist_fn: &3t-dim_2s-dim_manhattan | - { - [noc[tm, tn, tk, xd, yd] -> noc[tm, tn, tk, xs, ys]] -> hops[(xd - xs) + (yd - ys)] : - xd >= xs and yd >= ys; - [noc[tm, tn, tk, xd, yd] -> noc[tm, tn, tk, xs, ys]] -> hops[-(xd - xs) + -(yd - ys)] : - xd < xs and yd < ys; - [noc[tm, tn, tk, xd, yd] -> noc[tm, tn, tk, xs, ys]] -> hops[-(xd - xs) + (yd - ys)] : - xd < xs and yd >= ys; - [noc[tm, tn, tk, xd, yd] -> noc[tm, tn, tk, xs, ys]] -> hops[(xd - xs) + -(yd - ys)] : - xd >= xs and yd < ys - } - expected: - latency: null - total_hops: null - multicast_hops: null - hypercube_hops: 0 - extent_DOR_hops: null -##@section 8x8 case for a bigger chip. -##@brief Full duplication to make sure temporal handling is correct. -# Edge to Src -- occ: &8x8_edge | - { - noc[tm, tn, xs, ys] -> A[m, k] : - 0 <= tm < 8 and 0 <= tn < 8 and - xs=0 and ys=0 and - 0 <= m < 64 and 0 <= k < 64 - } - fill: | - { - noc[tm, tn, xd, yd] -> A[m, k] : - 0 <= tm < 8 and 0 <= tn < 8 and tn = 0 and - 0 <= xd < 8 and 0 <= yd < 8 and - m = (8 * tm) + xd and 0 <= k < 64 - } - dims: *2t-dim_2s-dim - dist_fn: *2t-dim_2s-dim_manhattan - expected: - latency: null - total_hops: null - multicast_hops: null - hypercube_hops: 143360 - extent_DOR_hops: null -# Src to Dst -- occ: | - { - noc[tm, tn, tk, xs, ys] -> A[m, k] : - 0 <= tm < 8 and 0 <= tn < 8 and 0 <= tk < 64 and - 0 <= xs < 8 and 0 <= ys < 8 and - m = (8 * tm) + xs and 0 <= k < 64 - } - fill: &8x8_pe | - { - noc[tm, tn, tk, xd, yd] -> A[m, k] : - 0 <= tm < 8 and 0 <= tn < 8 and 0 <= tk < 64 and - 0 <= xd < 8 and 0 <= yd < 8 and - 0 <= m < 64 and m = (8 * tm) + xd and - 0 <= k < 64 and k=tk - } - dims: *3t-dim_2s-dim - dist_fn: *3t-dim_2s-dim_manhattan - expected: - latency: null - total_hops: null - multicast_hops: null - hypercube_hops: 0 - extent_DOR_hops: null -##@brief No duplication, one time step to ensure spatial handling is correct. -# Edge to Src, only 1 time step. -- occ: *8x8_edge - fill: | - { - noc[tm, tn, xd, yd] -> A[m, k] : - 0 <= tm < 8 and tm = 0 and 0 <= tn < 8 and tn = 0 and - 0 <= xd < 8 and 0 <= yd < 8 and - 0 <= m < 64 and m = (8 * tm) + xd and - 0 <= k < 64 and 0 = (yd - k) % 8 - } - dims: *2t-dim_2s-dim - dist_fn: *2t-dim_2s-dim_manhattan - expected: - latency: null - total_hops: null - multicast_hops: null - hypercube_hops: 9856 - extent_DOR_hops: null -# Src to Dst, only 1 time step. -- occ: | - { - noc[tm, tn, tk, xd, yd] -> A[m, k] : - 0 <= tm < 8 and tm = 0 and 0 <= tn < 8 and tn = 0 and 0 <= tk < 64 and - 0 <= xd < 8 and 0 <= yd < 8 and - 0 <= m < 64 and m = (8 * tm) + xd and - 0 <= k < 64 and 0 = (yd - k) % 8 - } - fill: | - { - noc[tm, tn, tk, xd, yd] -> A[m, k] : - 0 <= tm < 8 and tm = 0 and 0 <= tn < 8 and tn = 0 and 0 <= tk < 64 and tk = 0 and - 0 <= xd < 8 and 0 <= yd < 8 and - m = (8 * tm) + xd and k=tk - } - dims: *3t-dim_2s-dim - dist_fn: *3t-dim_2s-dim_manhattan - expected: - latency: null - total_hops: null - multicast_hops: null - hypercube_hops: 56 - extent_DOR_hops: null -##@brief No duplication, one spatial dimension to ensure temporal handling is correct. -##@note, tn = 0 here because otherwise we recast every tn, which is not fundamental to the problem. -# Edge to Src, only 1 spatial dimension. -- occ: *8x8_edge - fill: | - { - noc[tm, tn, xd, yd] -> A[m, k] : - 0 <= tm < 8 and 0 <= tn < 8 and tn = 0 and - 0 <= xd < 8 and xd = 0 and 0 <= yd < 8 and - 0 <= m < 64 and m = (8 * tm) + xd and - 0 <= k < 64 and 0 = (yd - k) % 8 - } - dims: *2t-dim_2s-dim - dist_fn: *2t-dim_2s-dim_manhattan - expected: - latency: null - total_hops: null - multicast_hops: null - hypercube_hops: 1792 - extent_DOR_hops: null -# Src to Dst, only 1 spatial dimension. -- occ: | - { - noc[tm, tn, tk, xd, yd] -> A[m, k] : - 0 <= tm < 8 and 0 <= tn < 8 and 0 <= tk < 64 and - 0 <= xd < 8 and xd = 0 and 0 <= yd < 8 and - 0 <= m < 64 and m = (8 * tm) + xd and - 0 <= k < 64 and 0 = (yd - k) % 8 - } - fill: | - { - noc[tm, tn, tk, xd, yd] -> A[m, k] : - 0 <= tm < 8 and 0 <= tn < 8 and 0 <= tk < 64 and - 0 <= xd < 8 and xd = 0 and 0 <= yd < 8 and - m = (8 * tm) + xd and k=tk - } - dims: *3t-dim_2s-dim - dist_fn: *3t-dim_2s-dim_manhattan - expected: - latency: null - total_hops: null - multicast_hops: null - hypercube_hops: 28672 - extent_DOR_hops: null -##@brief No duplication, full chip to ensure temporal and spatial handling is correct. -# Edge to Src, full chip. -- occ: *8x8_edge - fill: | - { - noc[tm, tn, xd, yd] -> A[m, k] : - 0 <= tm < 8 and 0 <= tn < 8 and tn = 0 and - 0 <= xd < 8 and 0 <= yd < 8 and - 0 <= m < 64 and m = (8 * tm) + xd and - 0 <= k < 64 and 0 = (yd - k) % 8 - } - dims: *2t-dim_2s-dim - dist_fn: *2t-dim_2s-dim_manhattan - expected: - latency: null - total_hops: null - multicast_hops: null - hypercube_hops: 78848 - extent_DOR_hops: null -# Src to Dst, full chip. -- occ: | - { - noc[tm, tn, tk, xd, yd] -> A[m, k] : - 0 <= tm < 8 and 0 <= tn < 8 and 0 <= tk < 64 and - 0 <= xd < 8 and 0 <= yd < 8 and - 0 <= m < 64 and m = (8 * tm) + xd and - 0 <= k < 64 and 0 = (yd - k) % 8 - } - fill: *8x8_pe - dims: *3t-dim_2s-dim - dist_fn: *3t-dim_2s-dim_manhattan - expected: - latency: null - total_hops: null - multicast_hops: null - hypercube_hops: 229376 - extent_DOR_hops: null -# - occ: "{ noc[xs, ys] -> data[d0, d1] : 0 <= d0 < 8 and 0 <= d1 < 8 and (xs=0 or 3<=xs<=4 or xs=7) and (ys=0 or 3<=ys<=4 or ys=7) }" -# fill: "{ noc[xd, yd] -> data[d0, d1] : d0 = xd and d1 = yd and 0 <= xd < 8 and 0 <= yd < 8 }" -# dims: *2d_spatial -# dist_fn: *2d_manhattan -# expected: -# latency: 2 -# total_hops: 64 -# multicast_hops: null -# - occ: "{ noc[xs, ys] -> data[d0, d1] : d0 = xs and d1 = ys and 0 <= xs < 8 and 0 <= ys < 8 }" -# fill: "{ noc[xd, yd] -> data[d0, d1] : 0 <= d0 < 8 and 0 <= d1 < 8 and (xd=0 or 3<=xd<=4 or xd=7) and (yd=0 or 3<=yd<=4 or yd=7) }" -# dist_fn: *2d_manhattan -# dims: *2d_spatial -# expected: -# latency: 14 -# total_hops: 5632 -# multicast_hops: null -# - occ: | -# { noc[xs, ys] -> data[d0, d1] : -# 0 <= d0 < 3 and 0 <= d1 < 3 and -# 0 <= xs < 9 and 0 <= ys < 9 and xs % 3 = 1 and ys % 3 = 1 -# } -# fill: "{ noc[xd, yd] -> data[d0, d1] : d0 = xd % 3 and d1 = yd % 3 and 0 <= xd < 9 and 0 <= yd < 9 }" -# dims: *2d_spatial -# dist_fn: *2d_manhattan -# expected: -# latency: 2 -# total_hops: 108 -# multicast_hops: null -# - occ: "{ noc[xs, ys] -> data[d0, d1] : d0 = xs % 3 and d1 = ys % 3 and 0 <= xs < 9 and 0 <= ys < 9 }" -# fill: | -# { noc[xd, yd] -> data[d0, d1] : -# 0 <= d0 < 3 and 0 <= d1 < 3 and -# 0 <= xd < 9 and 0 <= yd < 9 and xd % 3 = 1 and yd % 3 = 1 -# } -# dims: *2d_spatial -# dist_fn: *2d_manhattan -# expected: -# latency: 2 -# total_hops: 108 -# - occ: "{ [xs, ys] -> [d0] : d0=xs and 0 <= xs < 8 and ys = 0 }" -# fill: "{ [xd, yd] -> [d0] : d0=xd and 0 <= xd < 8 and 0 <= yd < 8 }" -# dims: *2d_spatial -# dist_fn: *2d_manhattan -# expected: -# latency: 7 -# total_hops: 224 -# - occ: "{ [xs, ys] -> [d0] : d0=xs and 0 <= xs < 8 and 0 <= ys < 8 }" -# fill: "{ [xd, yd] -> [d0] : d0=xd and 0 <= xd < 8 and yd = 0 }" -# dims: *2d_spatial -# dist_fn: *2d_manhattan -# expected: -# latency: 0 -# total_hops: 0 -# - occ: | -# { [xs, ys, z1s, z2s] -> [d0, d1, d2, d3] : -# d0 = xs and d1 = ys and d2 = z1s and d3 = z2s and -# 0 <= xs < 8 and 0 <= ys < 8 and 0 <= z1s < 8 and 0 <= z2s < 8 -# } -# fill: | -# { [xd, yd, z1d, z2d] -> [d0, d1, d2, d3] : -# 0 <= d0 < 8 and 0 <= d1 < 8 and 0 <= d2 < 8 and 0 <= d3 < 8 and -# xd=0 and yd=0 and z1d=0 and z2d=0 -# } -# dims: &4d_spatial -# - Spatial -# - 0 -# - 0 -# - Spatial -# - 1 -# - 0 -# - Spatial -# - 2 -# - 0 -# - Spatial -# - 3 -# - 0 -# dist_fn: &4d_manhattan | -# { -# [[xd, yd, z1d, z2d] -> [xs, ys, z1s, z2s]] -> -# [(xd - xs) + (yd - ys) + (z1d - z1s) + (z2d - z2s)] : -# (xd >= xs) and (yd >= ys) and (z1d >= z1s) and (z2d >= z2s); - -# [[xd, yd, z1d, z2d] -> [xs, ys, z1s, z2s]] -> -# [-(xd - xs) + (yd - ys) + (z1d - z1s) + (z2d - z2s)] : -# (xd < xs) and (yd >= ys) and (z1d >= z1s) and (z2d >= z2s); -# [[xd, yd, z1d, z2d] -> [xs, ys, z1s, z2s]] -> -# [-(xd - xs) + -(yd - ys) + (z1d - z1s) + (z2d - z2s)] : -# (xd < xs) and (yd < ys) and (z1d >= z1s) and (z2d >= z2s); -# [[xd, yd, z1d, z2d] -> [xs, ys, z1s, z2s]] -> -# [-(xd - xs) + -(yd - ys) + -(z1d - z1s) + (z2d - z2s)] : -# (xd < xs) and (yd < ys) and (z1d < z1s) and (z2d >= z2s); -# [[xd, yd, z1d, z2d] -> [xs, ys, z1s, z2s]] -> -# [-(xd - xs) + -(yd - ys) + -(z1d - z1s) + -(z2d - z2s)] : -# (xd < xs) and (yd < ys) and (z1d < z1s) and (z2d < z2s); - -# [[xd, yd, z1d, z2d] -> [xs, ys, z1s, z2s]] -> -# [(xd - xs) + -(yd - ys) + (z1d - z1s) + (z2d - z2s)] : -# (xd >= xs) and (yd < ys) and (z1d >= z1s) and (z2d >= z2s); -# [[xd, yd, z1d, z2d] -> [xs, ys, z1s, z2s]] -> -# [(xd - xs) + -(yd - ys) + -(z1d - z1s) + (z2d - z2s)] : -# (xd >= xs) and (yd < ys) and (z1d < z1s) and (z2d >= z2s); -# [[xd, yd, z1d, z2d] -> [xs, ys, z1s, z2s]] -> -# [(xd - xs) + -(yd - ys) + -(z1d - z1s) + -(z2d - z2s)] : -# (xd >= xs) and (yd < ys) and (z1d < z1s) and (z2d < z2s); - -# [[xd, yd, z1d, z2d] -> [xs, ys, z1s, z2s]] -> -# [(xd - xs) + (yd - ys) + -(z1d - z1s) + (z2d - z2s)] : -# (xd >= xs) and (yd >= ys) and (z1d < z1s) and (z2d >= z2s); -# [[xd, yd, z1d, z2d] -> [xs, ys, z1s, z2s]] -> -# [(xd - xs) + (yd - ys) + -(z1d - z1s) + -(z2d - z2s)] : -# (xd >= xs) and (yd >= ys) and (z1d < z1s) and (z2d < z2s); - -# [[xd, yd, z1d, z2d] -> [xs, ys, z1s, z2s]] -> -# [(xd - xs) + (yd - ys) + (z1d - z1s) + -(z2d - z2s)] : -# (xd >= xs) and (yd >= ys) and (z1d >= z1s) and (z2d < z2s); - -# [[xd, yd, z1d, z2d] -> [xs, ys, z1s, z2s]] -> -# [-(xd - xs) + (yd - ys) + -(z1d - z1s) + -(z2d - z2s)] : -# (xd < xs) and (yd >= ys) and (z1d < z1s) and (z2d < z2s); -# [[xd, yd, z1d, z2d] -> [xs, ys, z1s, z2s]] -> -# [-(xd - xs) + -(yd - ys) + (z1d - z1s) + -(z2d - z2s)] : -# (xd < xs) and (yd < ys) and (z1d >= z1s) and (z2d < z2s); -# [[xd, yd, z1d, z2d] -> [xs, ys, z1s, z2s]] -> -# [-(xd - xs) + (yd - ys) + (z1d - z1s) + -(z2d - z2s)] : -# (xd < xs) and (yd >= ys) and (z1d >= z1s) and (z2d < z2s); - -# [[xd, yd, z1d, z2d] -> [xs, ys, z1s, z2s]] -> -# [-(xd - xs) + (yd - ys) + -(z1d - z1s) + (z2d - z2s)] : -# (xd < xs) and (yd >= ys) and (z1d < z1s) and (z2d >= z2s); -# [[xd, yd, z1d, z2d] -> [xs, ys, z1s, z2s]] -> -# [(xd - xs) + -(yd - ys) + (z1d - z1s) + -(z2d - z2s)] : -# (xd >= xs) and (yd < ys) and (z1d >= z1s) and (z2d < z2s) -# } -# expected: -# latency: 28 -# total_hops: null -# - occ: | -# { [xs, ys, z1s, z2s] -> [d0, d1, d2, d3] : -# 0 <= d0 < 8 and 0 <= d1 < 8 and 0 <= d2 < 8 and 0 <= d3 < 8 and -# xs=0 and ys=0 and z1s=0 and z2s=0 -# } -# fill: | -# { [xd, yd, z1d, z2d] -> [d0, d1, d2, d3] : -# d0 = xd and d1 = yd and d2 = z1d and d3 = z2d and -# 0 <= xd < 8 and 0 <= yd < 8 and 0 <= z1d < 8 and 0 <= z2d < 8 -# } -# dims: *4d_spatial -# dist_fn: *4d_manhattan -# expected: -# latency: 28 -# total_hops: null -# - occ: "{ [xs] -> [d0] : 0 <= d0 < 8 and xs = 0 }" -# fill: "{ [xd] -> [d0] : d0 = xd and 0 <= xd < 8 }" -# dist_fn: &ring_dist_size_8 | -# { -# [[xd] -> [xs]] -> [(xd-xs) % 8] : -# (xd-xs)%8 <= (xs-xd)%8; -# [[xd] -> [xs]] -> [(xs-xd) % 8] : -# (xd-xs)%8 > (xs-xd)%8 -# } -# expected: -# latency: 4 -# total_hops: 16 -# - occ: "{ [xs] -> [d0] : 0 <= xs < 8 and d0 = xs }" -# fill: "{ [xd] -> [d0] : 0 <= d0 < 8 and xd = 0 }" -# dist_fn: *ring_dist_size_8 -# expected: -# latency: 4 -# total_hops: 16 -# - occ: "{ [xs] -> [d0] : xs <= d0 <= xs + 1 and xs % 2 = 0}" -# fill: "{ [xd] -> [d0] : d0 = xd and 0 <= xd < 8 }" -# dist_fn: *ring_dist_size_8 -# expected: -# latency: 1 -# total_hops: 4 \ No newline at end of file diff --git a/tests/not_working/distribuffers/spec/binding/valid_bindings.yaml b/tests/not_working/distribuffers/spec/binding/valid_bindings.yaml deleted file mode 100755 index 5e1c21c4..00000000 --- a/tests/not_working/distribuffers/spec/binding/valid_bindings.yaml +++ /dev/null @@ -1,97 +0,0 @@ -- binding: - nodes: - - logical: - name: ProcessingElement - l_dims: [i] - physical: - name: ProcessingElement - p_dims: [x, y] - relations: - tensorA: i = x + y * 2 # This is a dimension-major compression into the logical. It is bijective. - tensorB: i = x + y * 2 # This is a dimension-major compression into the logical. It is bijective. - - logical: - name: Scratchpad - l_dims: [x, y] - physical: - name: GLB - p_dims: [a, b] - relations: - tensorA: x = a and y = b - tensorB: x = b and y = a - solution: - nodes: - - - tensorA: | - { - [ - tensorA_ranks[c, h, w, p, q, r, s] -> - l_ProcessingElement_dims[i] - ] -> - p_ProcessingElement_dims[x, y] : - i = x + 2y - } - tensorB: | - { - [ - tensorB_ranks[c, h, w, p, q, r, s] -> - l_ProcessingElement_dims[i] - ] -> - p_ProcessingElement_dims[x, y] : - i = x + (y * 2) - } - - - tensorA: | - { - [ - tensorA_ranks[c, h, w, p, q, r, s] -> - l_Scratchpad_dims[x, y] - ] -> - p_GLB_dims[ a, b ] : - x = a and y = b - } - tensorB: | - { - [ - tensorB_ranks[c, h, w, p, q, r, s] -> - l_Scratchpad_dims[x, y] - ] -> - p_GLB_dims[ a, b ] : - x = b and y = a - } - -- binding: - nodes: - - logical: - name: DRAM - l_dims: [i] - physical: - name: DRAM - p_dims: [i] - relation: # Compression relation where less DRAM chips than planned. - weights: i = i // 2 - inputs: i = i // 2 - outputs: i = i // 2 - - logical: - name: Scratchpad - l_dims: [i] - physical: - name: GLB - p_dims: [x, y, z] - relation: # weight stationary relation - weights: c = x and h = y and w = z - inputs: i=x and i=y and i=z - outputs: i=x and i=y and i=z - - logical: - name: PE_Buffer - l_dims: [i] - physical: - name: GLB - l_dims: [x, y, z] - relation: # Some weird bypass shenanigans. - weights: - inputs: - outputs: - - - -- diff --git a/tests/not_working/distribuffers/spec/distributed.yaml b/tests/not_working/distribuffers/spec/distributed.yaml deleted file mode 100755 index 8f7267be..00000000 --- a/tests/not_working/distribuffers/spec/distributed.yaml +++ /dev/null @@ -1,100 +0,0 @@ -networks: - - name: L2 - topology: Mesh - dims: - - x - - y - constraints: - - 0 <= x < 2 - - 0 <= y < 3 - affine: | - { - [L2[x, y] -> L2[x', y']] -> hops[(x - x') + (y - y')] : - x >= x' and y >= y'; - [L2[x, y] -> L2[x', y']] -> hops[-(x - x') + -(y - y')] : - x < x' and y < y'; - [L2[x, y] -> L2[x', y']] -> hops[-(x - x') + (y - y')] : - x < x' and y >= y'; - [L2[x, y] -> L2[x', y']] -> hops[(x - x') + -(y - y')] : - x >= x' and y < y' - } - domain_restriction: | - { - [L2[x, y] -> L2[x', y']] -> hops[(x - x') + (y - y')] : - x >= x' and y >= y' and 0 <= x < 2 and 0 <= x' < 2 and 0 <= y < 3 and 0 <= y' < 3; - [L2[x, y] -> L2[x', y']] -> hops[-(x - x') + (y - y')] : - x < x' and y >= y' and 0 <= x < 2 and 0 <= x' < 2 and 0 <= y < 3 and 0 <= y' < 3; - [L2[x, y] -> L2[x', y']] -> hops[(x - x') + -(y - y')] : - x >= x' and y < y' and 0 <= x < 2 and 0 <= x' < 2 and 0 <= y < 3 and 0 <= y' < 3; - [L2[x, y] -> L2[x', y']] -> hops[-(x - x') + -(y - y')] : - x < x' and y < y' and 0 <= x < 2 and 0 <= x' < 2 and 0 <= y < 3 and 0 <= y' < 3 - } - - name: L1 - topology: Mesh - dims: - - x - - y - - z - constraints: - - 0 <= x < 2027 - - 0 <= y < 2029 - - 0 <= z < 17 - affine: | - { - [L1[x, y, z] -> L1[x', y', z']] -> hops[ (x - x') + (y - y') + (z - z') ] : - x >= x' and y >= y' and z >= z'; - [L1[x, y, z] -> L1[x', y', z']] -> hops[ (x - x') + (y - y') - (z - z') ] : - x >= x' and y >= y' and z < z'; - [L1[x, y, z] -> L1[x', y', z']] -> hops[ (x - x') - (y - y') + (z - z') ] : - x >= x' and y < y' and z >= z'; - [L1[x, y, z] -> L1[x', y', z']] -> hops[ -(x - x') + (y - y') + (z - z') ] : - x < x' and y >= y' and z >= z'; - [L1[x, y, z] -> L1[x', y', z']] -> hops[ -(x - x') - (y - y') + (z - z') ] : - x < x' and y < y' and z >= z'; - [L1[x, y, z] -> L1[x', y', z']] -> hops[ (x - x') - (y - y') - (z - z') ] : - x >= x' and y < y' and z < z'; - [L1[x, y, z] -> L1[x', y', z']] -> hops[ -(x - x') + (y - y') - (z - z') ] : - x < x' and y >= y' and z < z'; - [L1[x, y, z] -> L1[x', y', z']] -> hops[ -(x - x') - (y - y') - (z - z') ] : - x < x' and y < y' and z < z' - } - domain_restriction: | - { - [L1[x, y, z] -> L1[x', y', z']] -> hops[ (x - x') + (y - y') + (z - z') ] : - x >= x' and y >= y' and z >= z' and - 0 <= x < 2027 and 0 <= y < 2029 and 0 <= z < 17 and - 0 <= x' < 2027 and 0 <= y' < 2029 and 0 <= z' < 17; - [L1[x, y, z] -> L1[x', y', z']] -> hops[ (x - x') + (y - y') - (z - z') ] : - x >= x' and y >= y' and z < z' and - 0 <= x < 2027 and 0 <= y < 2029 and 0 <= z < 17 and - 0 <= x' < 2027 and 0 <= y' < 2029 and 0 <= z' < 17; - [L1[x, y, z] -> L1[x', y', z']] -> hops[ (x - x') - (y - y') + (z - z') ] : - x >= x' and y < y' and z >= z' and - 0 <= x < 2027 and 0 <= y < 2029 and 0 <= z < 17 and - 0 <= x' < 2027 and 0 <= y' < 2029 and 0 <= z' < 17; - [L1[x, y, z] -> L1[x', y', z']] -> hops[ -(x - x') + (y - y') + (z - z') ] : - x < x' and y >= y' and z >= z' and - 0 <= x < 2027 and 0 <= y < 2029 and 0 <= z < 17 and - 0 <= x' < 2027 and 0 <= y' < 2029 and 0 <= z' < 17; - [L1[x, y, z] -> L1[x', y', z']] -> hops[ -(x - x') - (y - y') + (z - z') ] : - x < x' and y < y' and z >= z' and - 0 <= x < 2027 and 0 <= y < 2029 and 0 <= z < 17 and - 0 <= x' < 2027 and 0 <= y' < 2029 and 0 <= z' < 17; - [L1[x, y, z] -> L1[x', y', z']] -> hops[ (x - x') - (y - y') - (z - z') ] : - x >= x' and y < y' and z < z' and - 0 <= x < 2027 and 0 <= y < 2029 and 0 <= z < 17 and - 0 <= x' < 2027 and 0 <= y' < 2029 and 0 <= z' < 17; - [L1[x, y, z] -> L1[x', y', z']] -> hops[ -(x - x') + (y - y') - (z - z') ] : - x < x' and y >= y' and z < z' and - 0 <= x < 2027 and 0 <= y < 2029 and 0 <= z < 17 and - 0 <= x' < 2027 and 0 <= y' < 2029 and 0 <= z' < 17; - [L1[x, y, z] -> L1[x', y', z']] -> hops[ -(x - x') - (y - y') - (z - z') ] : - x < x' and y < y' and z < z' and - 0 <= x < 2027 and 0 <= y < 2029 and 0 <= z < 17 and - 0 <= x' < 2027 and 0 <= y' < 2029 and 0 <= z' < 17 - } -placement: - - level: DRAM - placement: "DRAM[i] -> L2[x, y] : i = x and y = 0" - - level: MAC - placement: "MAC[i] -> L1[x, y, z] : i = x and y = 0 and z = 0" \ No newline at end of file diff --git a/tests/not_working/distribuffers/spec/logical_arch.yaml b/tests/not_working/distribuffers/spec/logical_arch.yaml deleted file mode 100755 index 51053db7..00000000 --- a/tests/not_working/distribuffers/spec/logical_arch.yaml +++ /dev/null @@ -1,32 +0,0 @@ -arch: - nodes: - - !Component - name: DRAM # offchip DRAM is the source of all datatypes - class: DRAM # assume DRAM is large enough to store all the data, so no depth specification needed - attributes: - width: 64 # width in bits - - - !Container - name: ProcessingElement - spatial: {meshX: 1, meshY: 2} - - # registers for the mac unit - - !Component - name: weight_reg - class: reg_storage - attributes: {depth: 1, width: 8} - - - !Component - name: input_activation_reg - class: reg_storage - attributes: {depth: 1, width: 8} - - - !Component - name: output_activation_reg - class: reg_storage - attributes: {depth: 1, width: 8} - - - !Component - name: mac - class: mac_compute - attributes: {n_pipline_stages: 2} \ No newline at end of file diff --git a/tests/not_working/distribuffers/spec/physical_arch.yaml b/tests/not_working/distribuffers/spec/physical_arch.yaml deleted file mode 100755 index ab835911..00000000 --- a/tests/not_working/distribuffers/spec/physical_arch.yaml +++ /dev/null @@ -1,69 +0,0 @@ -arch: - name: "test_distributed_spec" - nodes: - - !Container - name: system_arch - attributes: - # Top-level attributes inherited by all components unless overridden - technology: "45nm" - cycle_period: 1e-9 - - - !Network - name: L2 - topology: Mesh - dims: - - x - constraints: - - 0 <= x < 2 - - !Network - name: L1 - topology: Mesh - dims: - - x - - y - constraints: - - 0 <= x < 1 - - -1 <= y <= 1 - port: | - {L2[x] -> L1[x', y'] : x = 1 and x' = 0 and y' = 0} - - - !Component - name: DRAM # offchip DRAM is the source of all datatypes - class: DRAM # assume DRAM is large enough to store all the data, so no depth specification needed - attributes: - width: 64 # width in bits - network: - level: L2 - placement: "{ DRAM[i] -> L2[i] | i = 1 }" - - - !Container - name: ProcessingElement - network: - level: L1 - placement: | - { - ProcessingElement[i, j] -> L1[x, y] : - j = y and x < 0 and i = -x - 1; - j = y and x > 0 and i = x; - } - - # registers for the mac unit - - !Component - name: weight_reg - class: reg_storage - attributes: {depth: 1, width: 8} - - - !Component - name: input_activation_reg - class: reg_storage - attributes: {depth: 1, width: 8} - - - !Component - name: output_activation_reg - class: reg_storage - attributes: {depth: 1, width: 8} - - - !Component - name: mac - class: mac_compute - attributes: {n_pipline_stages: 2} \ No newline at end of file diff --git a/tests/not_working/distribuffers/test_binding.py b/tests/not_working/distribuffers/test_binding.py deleted file mode 100755 index 528c93f4..00000000 --- a/tests/not_working/distribuffers/test_binding.py +++ /dev/null @@ -1,49 +0,0 @@ -from pathlib import Path -import unittest - -from typing import Dict, List -from islpy import DEFAULT_CONTEXT, Map -from ruamel.yaml import YAML - -from accelforge.frontend._binding import Binding, BindingNode - -TESTS_DIR = Path(__file__).parent / "spec" / "binding" -yaml = YAML(typ="safe") - - -class TestBindingMapper(unittest.TestCase): - def test_valid_bindings(self): - """ - Tests that the valid bindings translate into the appropriate - ISL strings. - """ - specs_file: str = TESTS_DIR / "valid_bindings.yaml" - with open(specs_file, mode="r", encoding="utf-8") as f: - specs: List = yaml.load(f) - - spec: Dict - for spec in specs: - binding: Binding = Binding.model_validate(spec["binding"]) - - soln: Dict = spec["solution"] - - soln_node: Dict[str, str] - binding_node: BindingNode - for soln_node, binding_node in zip(soln["nodes"], binding.nodes): - isl_relations: Dict[str, Map] = binding_node.isl_relations - assert soln_node.keys() == isl_relations.keys(), ( - "Not all isl_relations read in properly. Missing \n" - f"{set(soln_node.keys()).difference(isl_relations.keys())} " - "\nfrom isl_relations and \n" - f"{set(isl_relations.keys()).difference(soln_node.keys())} " - "\nfrom solutions.\n" - ) - - tensor: str - for tensor in soln_node: - soln_relation: Map = Map.read_from_str( - DEFAULT_CONTEXT, soln_node[tensor] - ) - assert soln_relation.is_equal( - isl_relations[tensor] - ), f"\n{soln_relation} != \n{isl_relations[tensor]}" diff --git a/tests/not_working/test_multicast.py b/tests/not_working/test_multicast.py deleted file mode 100644 index 6916de5b..00000000 --- a/tests/not_working/test_multicast.py +++ /dev/null @@ -1,86 +0,0 @@ -""" -File ported from here: -https://github.com/rengzhengcodes/timeloop/blob/distributed-multicast-dev/src/unit-test/multicast/test-multicast.cpp -""" - -import unittest -from pathlib import Path - -import islpy as isl - -from accelforge.model._looptree.reuse.isl.mapping_to_isl.types import ( - # Data movement descriptors. - Fill, - Occupancy, - # Tags - Tag, - SpatialTag, - TemporalTag, -) -from accelforge.model._looptree.reuse.isl.distributed.distributed_buffers import ( - HypercubeMulticastModel, -) -from accelforge.model._looptree.reuse.isl.spatial import TransferInfo -from .util import load_solutions - - -def construct_spacetime(dims: list) -> list[Tag]: - """ - Given a list of dimension tags as strings, convert them into the proper `Tag` - objects. - - Parameters - ---------- - dims: - The list of dim tags as strings. - - Returns - ------- - list[Tag] where list[i] is the tag corresponding to dims[i]. - """ - spacetime: list[Tag] = [] - for dim in dims: - if dim["type"] == "Temporal": - spacetime.append(TemporalTag()) - elif dim["type"] == "Spatial": - spacetime.append(SpatialTag(dim["spatial_dim"], dim["target"])) - - return spacetime - - -class TestHypercubeMulticastModel(unittest.TestCase): - """ - Tests the HypercubeMulticastModel with a series of premade test cases. - """ - - TEST_CASES_FILE: str = Path(__file__).parent / "multicast" / "test_cases.yaml" - testcases: dict = load_solutions(TEST_CASES_FILE) - - def test_gamut(self): - """ - Tests the entire gamut of test cases we have specified in the yaml. - """ - for test in self.testcases: - # Reads test case parameters and constructs the necessary objects. - dim_tags: list[Tag] = construct_spacetime(test["dims"]) - fill: Fill = Fill(dim_tags, test["fill"]) - occ: Occupancy = Occupancy(dim_tags, test["occ"]) - dist_fn: isl.Map = test["dist_fn"] - multicast_model: HypercubeMulticastModel = HypercubeMulticastModel(dist_fn) - - # Applies the model. - info: TransferInfo = multicast_model.apply(0, fill, occ) - # Checks the results. - sum_extract: int = info.hops.eval( - isl.Point.zero(info.hops.domain().get_space()) - ) - - # The block is used for debugging test cases not yet implemented. - if test["expected"]["hypercube_hops"] is None: - print("~~~Test case in progress:~~~") - print(f"Fill: {fill}") - print(f"Occ: {occ}") - print(f"Dist Fn: {dist_fn}") - print(f"Returned: {sum_extract}") - else: - assert sum_extract == test["expected"]["hypercube_hops"]