diff --git a/.gitmodules b/.gitmodules
new file mode 100644
index 00000000..6ea0a84a
--- /dev/null
+++ b/.gitmodules
@@ -0,0 +1,4 @@
+[submodule "notebooks/astrasim2_correlation/correlation"]
+ path = notebooks/astrasim2_correlation/correlation
+ url = git@github.com:rengzhengcodes/aws-gpu-profiler.git
+ branch = main
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/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/notebooks/astrasim2_correlation/correlation b/notebooks/astrasim2_correlation/correlation
new file mode 160000
index 00000000..36e6fffb
--- /dev/null
+++ b/notebooks/astrasim2_correlation/correlation
@@ -0,0 +1 @@
+Subproject commit 36e6fffb6d1b4773b961604238a2db1c1a13d8d5
diff --git a/notebooks/astrasim2_correlation/correlation.ipynb b/notebooks/astrasim2_correlation/correlation.ipynb
new file mode 100644
index 00000000..ceecd4ad
--- /dev/null
+++ b/notebooks/astrasim2_correlation/correlation.ipynb
@@ -0,0 +1,2279 @@
+{
+ "cells": [
+ {
+ "cell_type": "markdown",
+ "id": "3aa122f8",
+ "metadata": {},
+ "source": [
+ "# Network Sim Correlation\n",
+ "\n",
+ "The following are attempts to correlate the network model in accelforge to actual networks measured in real architectures."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 1,
+ "id": "e3a04b7c",
+ "metadata": {
+ "execution": {
+ "iopub.execute_input": "2026-09-17T16:44:34.011575Z",
+ "iopub.status.busy": "2026-09-17T16:44:34.011502Z",
+ "iopub.status.idle": "2026-09-17T16:44:34.765381Z",
+ "shell.execute_reply": "2026-09-17T16:44:34.764565Z"
+ }
+ },
+ "outputs": [],
+ "source": [
+ "import accelforge as af\n",
+ "import matplotlib.pyplot as plt"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "95cf92d9",
+ "metadata": {},
+ "source": [
+ "## We are testing an 8 GPU All-to-All to Correlate Later, simulating an NVLink Switch\n"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "195ecb91",
+ "metadata": {},
+ "source": [
+ "### Encoding: one-hot GPU coordinates on a fully-connected fabric\n",
+ "\n",
+ "GPU $i$ sits at one-hot coordinate $e_i$ of an $N$-dimensional `noc` space. `data[s, d]` is the chunk sent by GPU $s$ to GPU $d$: each GPU *holds* the chunks it sends (occupancy) and *requests* the chunks addressed to it (fill).\n",
+ "\n",
+ "Every $s \\neq d$ cast then has extent 1 along exactly the src and dst dimensions, so `HypercubeMulticastModel`'s bounding-box cost is $(1+1)(1+1)-1 = 3$ **uniformly for every pair** — the fully-connected property (no pair privileged). The distance function is unit-cost (0 if same GPU, 1 otherwise) and only influences source matching in `identify_mesh_casts`. Self-chunks match at distance 0 and cost 0 hops: they never cross the fabric."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 2,
+ "id": "c5b2c7bf",
+ "metadata": {
+ "execution": {
+ "iopub.execute_input": "2026-09-17T16:44:34.767780Z",
+ "iopub.status.busy": "2026-09-17T16:44:34.767580Z",
+ "iopub.status.idle": "2026-09-17T16:44:34.780820Z",
+ "shell.execute_reply": "2026-09-17T16:44:34.780423Z"
+ }
+ },
+ "outputs": [],
+ "source": [
+ "import math\n",
+ "\n",
+ "import islpy as isl\n",
+ "import pandas as pd\n",
+ "\n",
+ "from accelforge.model._looptree.reuse.isl.distributed.distributed_buffers import (\n",
+ " HypercubeMulticastModel,\n",
+ ")\n",
+ "from accelforge.model._looptree.reuse.isl.mapping_to_isl.types import (\n",
+ " Fill,\n",
+ " Occupancy,\n",
+ " SpatialTag,\n",
+ ")\n",
+ "\n",
+ "CTX = isl.DEFAULT_CONTEXT"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 3,
+ "id": "e343cac3",
+ "metadata": {
+ "execution": {
+ "iopub.execute_input": "2026-09-17T16:44:34.782482Z",
+ "iopub.status.busy": "2026-09-17T16:44:34.782401Z",
+ "iopub.status.idle": "2026-09-17T16:44:34.786211Z",
+ "shell.execute_reply": "2026-09-17T16:44:34.785562Z"
+ }
+ },
+ "outputs": [],
+ "source": [
+ "def onehot_constraints(prefix: str, n: int) -> str:\n",
+ " \"\"\"One-hot constraints over dims ``{prefix}0..{prefix}{n-1}``.\"\"\"\n",
+ " bounds = \" and \".join(f\"0 <= {prefix}{i} <= 1\" for i in range(n))\n",
+ " hot = \" + \".join(f\"{prefix}{i}\" for i in range(n)) + \" = 1\"\n",
+ " return f\"{bounds} and {hot}\"\n",
+ "\n",
+ "\n",
+ "def linear_id(prefix: str, n: int) -> str:\n",
+ " \"\"\"Affine recovery of the GPU id from a one-hot vector: id = sum i*g_i.\"\"\"\n",
+ " return \" + \".join(f\"{i}*{prefix}{i}\" for i in range(1, n))\n",
+ "\n",
+ "\n",
+ "def all_to_all_maps(n: int) -> tuple[isl.Map, isl.Map, isl.Map]:\n",
+ " \"\"\"Build (occupancy, fill, dist_fn) for an N-GPU fully-connected all-to-all.\n",
+ "\n",
+ " data[s, d] is the chunk sent by GPU s to GPU d. Each GPU holds the\n",
+ " chunks it sends (occ) and requests the chunks addressed to it (fill).\n",
+ " \"\"\"\n",
+ " gs = \", \".join(f\"gs{i}\" for i in range(n))\n",
+ " gd = \", \".join(f\"gd{i}\" for i in range(n))\n",
+ " occ = isl.Map.read_from_str(\n",
+ " CTX,\n",
+ " f\"{{ noc[{gs}] -> data[s, d] : {onehot_constraints('gs', n)} \"\n",
+ " f\"and s = {linear_id('gs', n)} and 0 <= d < {n} }}\",\n",
+ " )\n",
+ " fill = isl.Map.read_from_str(\n",
+ " CTX,\n",
+ " f\"{{ noc[{gd}] -> data[s, d] : {onehot_constraints('gd', n)} \"\n",
+ " f\"and d = {linear_id('gd', n)} and 0 <= s < {n} }}\",\n",
+ " )\n",
+ "\n",
+ " xd = \", \".join(f\"xd{i}\" for i in range(n))\n",
+ " xs = \", \".join(f\"xs{i}\" for i in range(n))\n",
+ " same = \" and \".join(f\"xd{i} = xs{i}\" for i in range(n))\n",
+ " diff = \" or \".join(f\"(xd{i} < xs{i}) or (xd{i} > xs{i})\" for i in range(n))\n",
+ " dist_fn = isl.Map.read_from_str(\n",
+ " CTX,\n",
+ " f\"{{ [noc[{xd}] -> noc[{xs}]] -> hops[0] : {same}; \"\n",
+ " f\" [noc[{xd}] -> noc[{xs}]] -> hops[1] : {diff} }}\",\n",
+ " )\n",
+ " return occ, fill, dist_fn"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 4,
+ "id": "a2a2df5f",
+ "metadata": {
+ "execution": {
+ "iopub.execute_input": "2026-09-17T16:44:34.788502Z",
+ "iopub.status.busy": "2026-09-17T16:44:34.788416Z",
+ "iopub.status.idle": "2026-09-17T16:44:34.791393Z",
+ "shell.execute_reply": "2026-09-17T16:44:34.790761Z"
+ }
+ },
+ "outputs": [],
+ "source": [
+ "def eval_total(poly: isl.PwQPolynomial) -> int:\n",
+ " \"\"\"Evaluate a fully-summed PwQPolynomial (zero-dimensional domain).\"\"\"\n",
+ " return int(poly.eval(isl.Point.zero(poly.domain().get_space())).to_python())\n",
+ "\n",
+ "\n",
+ "def model_hops_per_element(n: int) -> int:\n",
+ " \"\"\"Run the real HypercubeMulticastModel on the all-to-all at V=1.\"\"\"\n",
+ " occ_map, fill_map, dist_fn = all_to_all_maps(n)\n",
+ " tags = [SpatialTag(i, 0) for i in range(n)]\n",
+ " model = HypercubeMulticastModel(dist_fn)\n",
+ " info = model.apply(0, Fill(tags, fill_map), Occupancy(tags, occ_map))\n",
+ " return eval_total(info.hops)\n",
+ "\n",
+ "\n",
+ "def single_chunk_hops(n: int, src: int, dst: int) -> int:\n",
+ " \"\"\"Hops for one (src, dst) chunk — uniformity / self-chunk probe.\"\"\"\n",
+ " occ_map, fill_map, dist_fn = all_to_all_maps(n)\n",
+ " chunk = isl.Set.read_from_str(CTX, f\"{{ data[{src}, {dst}] }}\")\n",
+ " tags = [SpatialTag(i, 0) for i in range(n)]\n",
+ " model = HypercubeMulticastModel(dist_fn)\n",
+ " info = model.apply(\n",
+ " 0,\n",
+ " Fill(tags, fill_map.intersect_range(chunk)),\n",
+ " Occupancy(tags, occ_map.intersect_range(chunk)),\n",
+ " )\n",
+ " return eval_total(info.hops)"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 5,
+ "id": "06d40ac3",
+ "metadata": {
+ "execution": {
+ "iopub.execute_input": "2026-09-17T16:44:34.793373Z",
+ "iopub.status.busy": "2026-09-17T16:44:34.793297Z",
+ "iopub.status.idle": "2026-09-17T16:44:34.794935Z",
+ "shell.execute_reply": "2026-09-17T16:44:34.794652Z"
+ }
+ },
+ "outputs": [],
+ "source": [
+ "NODES = 8 # GPUs on the fabric\n",
+ "LINK_BW_GBPS = 150.0 # per-GPU per-direction NVLink bandwidth, GB/s (V100 generation)\n",
+ "ALPHA_S = 0.0 # per-operation latency overhead, seconds\n",
+ "MIN_MIB, MAX_MIB = 1, 1024 # collective size sweep bounds, powers of 2\n",
+ "PER_RANK = False # interpret swept sizes as per-rank NCCL sizes instead of totals"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 6,
+ "id": "60364739",
+ "metadata": {
+ "execution": {
+ "iopub.execute_input": "2026-09-17T16:44:34.796321Z",
+ "iopub.status.busy": "2026-09-17T16:44:34.796249Z",
+ "iopub.status.idle": "2026-09-17T16:44:35.954144Z",
+ "shell.execute_reply": "2026-09-17T16:44:35.952723Z"
+ }
+ },
+ "outputs": [
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "ISL network model: 8-GPU fully-connected all-to-all (one-hot)\n",
+ " hops/element : 168 (uniform 3 per pair)\n",
+ " fabric-crossing chunks: 56 of 64\n"
+ ]
+ }
+ ],
+ "source": [
+ "n = NODES\n",
+ "\n",
+ "# --- Run the tool at V=1 and validate the fully-connected structure. ---\n",
+ "hops_per_elem = model_hops_per_element(n)\n",
+ "expected = 3 * n * (n - 1)\n",
+ "assert hops_per_elem == expected, (\n",
+ " f\"model returned {hops_per_elem} hops/element, expected {expected} \"\n",
+ " f\"(= 3 per off-diagonal pair x {n*(n-1)} pairs)\"\n",
+ ")\n",
+ "probe_off = single_chunk_hops(n, 0, min(3, n - 1))\n",
+ "probe_self = single_chunk_hops(n, n - 1, n - 1)\n",
+ "assert probe_off == 3, f\"off-diagonal chunk cost {probe_off} != 3\"\n",
+ "assert probe_self == 0, f\"self chunk cost {probe_self} != 0\"\n",
+ "crossing_chunks = hops_per_elem // 3 # = N(N-1) fabric-crossing chunks\n",
+ "\n",
+ "print(f\"ISL network model: {n}-GPU fully-connected all-to-all (one-hot)\")\n",
+ "print(f\" hops/element : {hops_per_elem} (uniform 3 per pair)\")\n",
+ "print(f\" fabric-crossing chunks: {crossing_chunks} of {n*n}\")"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "c89a8e17",
+ "metadata": {},
+ "source": [
+ "### Size and latency conventions\n",
+ "\n",
+ "`collective_size` $S$ = **total** bytes moved by the collective across all ranks (ASTRA-sim style). Per-rank NCCL buffer size = $S/N$; each $(s, d)$ pair exchanges a chunk of $S/N^2$ bytes. Set `PER_RANK = True` to interpret the swept sizes as per-rank NCCL sizes instead.\n",
+ "\n",
+ "Latency derives from the model's hop count: hops are uniform 3 per fabric-crossing chunk, so crossing chunks/element $= \\mathrm{hops}/3 = N(N-1)$, i.e. each GPU receives $N-1$ chunks $\\Rightarrow$ per-GPU wire bytes $= S(N-1)/N^2$. With a non-blocking switch and full-duplex ports (send overlaps receive), port drain time gives\n",
+ "\n",
+ "$$ t = \\alpha + \\frac{S\\,(N-1)}{N^2 \\cdot BW} $$\n",
+ "\n",
+ "NCCL conventions: $\\mathrm{algbw} = (S/N)/t$, $\\mathrm{busbw} = \\mathrm{algbw} \\cdot (N-1)/N$. With $\\alpha = 0$, busbw $\\equiv$ BW exactly — used as a per-row sanity invariant below.\n",
+ "\n",
+ "**Correlation notes** — this is a flat bandwidth model with no launch/protocol overhead, so expect empirical underprediction at small sizes; calibrate `ALPHA_S` (per-operation latency) and `LINK_BW_GBPS` (effective port bandwidth) against nccl-tests `alltoall_perf`, and overlay the ASTRA-sim 2.0 (FullyConnected) and EC2 series on the plot below."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 7,
+ "id": "6d5e39c9",
+ "metadata": {
+ "execution": {
+ "iopub.execute_input": "2026-09-17T16:44:35.956080Z",
+ "iopub.status.busy": "2026-09-17T16:44:35.955986Z",
+ "iopub.status.idle": "2026-09-17T16:44:35.966990Z",
+ "shell.execute_reply": "2026-09-17T16:44:35.965678Z"
+ }
+ },
+ "outputs": [
+ {
+ "data": {
+ "text/html": [
+ "
\n",
+ "\n",
+ "
\n",
+ " \n",
+ "
\n",
+ "
\n",
+ "
nodes
\n",
+ "
collective_size_bytes
\n",
+ "
per_rank_bytes
\n",
+ "
per_gpu_wire_bytes
\n",
+ "
hops_per_tile_coordinate
\n",
+ "
fabric_chunks_per_tile_coordinate
\n",
+ "
model_latency_s
\n",
+ "
algbw_GBps
\n",
+ "
busbw_GBps
\n",
+ "
\n",
+ " \n",
+ " \n",
+ "
\n",
+ "
0
\n",
+ "
8
\n",
+ "
1048576
\n",
+ "
131072
\n",
+ "
114688
\n",
+ "
168
\n",
+ "
56
\n",
+ "
7.645867e-07
\n",
+ "
171.428571
\n",
+ "
150.0
\n",
+ "
\n",
+ "
\n",
+ "
1
\n",
+ "
8
\n",
+ "
2097152
\n",
+ "
262144
\n",
+ "
229376
\n",
+ "
168
\n",
+ "
56
\n",
+ "
1.529173e-06
\n",
+ "
171.428571
\n",
+ "
150.0
\n",
+ "
\n",
+ "
\n",
+ "
2
\n",
+ "
8
\n",
+ "
4194304
\n",
+ "
524288
\n",
+ "
458752
\n",
+ "
168
\n",
+ "
56
\n",
+ "
3.058347e-06
\n",
+ "
171.428571
\n",
+ "
150.0
\n",
+ "
\n",
+ "
\n",
+ "
3
\n",
+ "
8
\n",
+ "
8388608
\n",
+ "
1048576
\n",
+ "
917504
\n",
+ "
168
\n",
+ "
56
\n",
+ "
6.116693e-06
\n",
+ "
171.428571
\n",
+ "
150.0
\n",
+ "
\n",
+ "
\n",
+ "
4
\n",
+ "
8
\n",
+ "
16777216
\n",
+ "
2097152
\n",
+ "
1835008
\n",
+ "
168
\n",
+ "
56
\n",
+ "
1.223339e-05
\n",
+ "
171.428571
\n",
+ "
150.0
\n",
+ "
\n",
+ "
\n",
+ "
5
\n",
+ "
8
\n",
+ "
33554432
\n",
+ "
4194304
\n",
+ "
3670016
\n",
+ "
168
\n",
+ "
56
\n",
+ "
2.446677e-05
\n",
+ "
171.428571
\n",
+ "
150.0
\n",
+ "
\n",
+ "
\n",
+ "
6
\n",
+ "
8
\n",
+ "
67108864
\n",
+ "
8388608
\n",
+ "
7340032
\n",
+ "
168
\n",
+ "
56
\n",
+ "
4.893355e-05
\n",
+ "
171.428571
\n",
+ "
150.0
\n",
+ "
\n",
+ "
\n",
+ "
7
\n",
+ "
8
\n",
+ "
134217728
\n",
+ "
16777216
\n",
+ "
14680064
\n",
+ "
168
\n",
+ "
56
\n",
+ "
9.786709e-05
\n",
+ "
171.428571
\n",
+ "
150.0
\n",
+ "
\n",
+ "
\n",
+ "
8
\n",
+ "
8
\n",
+ "
268435456
\n",
+ "
33554432
\n",
+ "
29360128
\n",
+ "
168
\n",
+ "
56
\n",
+ "
1.957342e-04
\n",
+ "
171.428571
\n",
+ "
150.0
\n",
+ "
\n",
+ "
\n",
+ "
9
\n",
+ "
8
\n",
+ "
536870912
\n",
+ "
67108864
\n",
+ "
58720256
\n",
+ "
168
\n",
+ "
56
\n",
+ "
3.914684e-04
\n",
+ "
171.428571
\n",
+ "
150.0
\n",
+ "
\n",
+ "
\n",
+ "
10
\n",
+ "
8
\n",
+ "
1073741824
\n",
+ "
134217728
\n",
+ "
117440512
\n",
+ "
168
\n",
+ "
56
\n",
+ "
7.829367e-04
\n",
+ "
171.428571
\n",
+ "
150.0
\n",
+ "
\n",
+ " \n",
+ "
\n",
+ "
"
+ ],
+ "text/plain": [
+ " nodes collective_size_bytes per_rank_bytes per_gpu_wire_bytes \\\n",
+ "0 8 1048576 131072 114688 \n",
+ "1 8 2097152 262144 229376 \n",
+ "2 8 4194304 524288 458752 \n",
+ "3 8 8388608 1048576 917504 \n",
+ "4 8 16777216 2097152 1835008 \n",
+ "5 8 33554432 4194304 3670016 \n",
+ "6 8 67108864 8388608 7340032 \n",
+ "7 8 134217728 16777216 14680064 \n",
+ "8 8 268435456 33554432 29360128 \n",
+ "9 8 536870912 67108864 58720256 \n",
+ "10 8 1073741824 134217728 117440512 \n",
+ "\n",
+ " hops_per_tile_coordinate fabric_chunks_per_tile_coordinate \\\n",
+ "0 168 56 \n",
+ "1 168 56 \n",
+ "2 168 56 \n",
+ "3 168 56 \n",
+ "4 168 56 \n",
+ "5 168 56 \n",
+ "6 168 56 \n",
+ "7 168 56 \n",
+ "8 168 56 \n",
+ "9 168 56 \n",
+ "10 168 56 \n",
+ "\n",
+ " model_latency_s algbw_GBps busbw_GBps \n",
+ "0 7.645867e-07 171.428571 150.0 \n",
+ "1 1.529173e-06 171.428571 150.0 \n",
+ "2 3.058347e-06 171.428571 150.0 \n",
+ "3 6.116693e-06 171.428571 150.0 \n",
+ "4 1.223339e-05 171.428571 150.0 \n",
+ "5 2.446677e-05 171.428571 150.0 \n",
+ "6 4.893355e-05 171.428571 150.0 \n",
+ "7 9.786709e-05 171.428571 150.0 \n",
+ "8 1.957342e-04 171.428571 150.0 \n",
+ "9 3.914684e-04 171.428571 150.0 \n",
+ "10 7.829367e-04 171.428571 150.0 "
+ ]
+ },
+ "execution_count": 7,
+ "metadata": {},
+ "output_type": "execute_result"
+ }
+ ],
+ "source": [
+ "bw = LINK_BW_GBPS * 1e9 # bytes/s\n",
+ "\n",
+ "rows = []\n",
+ "mib = MIN_MIB\n",
+ "while mib <= MAX_MIB:\n",
+ " size = mib * (1 << 20)\n",
+ " total = size * n if PER_RANK else size\n",
+ " per_rank = total / n\n",
+ " chunk_bytes = total / (n * n)\n",
+ " # Latency derived from the model output: hops/3 crossing chunks\n",
+ " # spread evenly, (N-1) into each GPU's port.\n",
+ " per_gpu_wire = (crossing_chunks / n) * chunk_bytes\n",
+ " t = ALPHA_S + per_gpu_wire / bw\n",
+ " # Closed form cross-check: t = alpha + S(N-1)/(N^2 BW)\n",
+ " assert math.isclose(t, ALPHA_S + total * (n - 1) / (n * n * bw), rel_tol=1e-12)\n",
+ " algbw = per_rank / t / 1e9\n",
+ " busbw = algbw * (n - 1) / n\n",
+ " rows.append((n, int(total), int(per_rank), int(per_gpu_wire),\n",
+ " hops_per_elem, crossing_chunks, t, algbw, busbw))\n",
+ " mib *= 2\n",
+ "\n",
+ "sweep = pd.DataFrame(rows, columns=[\n",
+ " \"nodes\", \"collective_size_bytes\", \"per_rank_bytes\", \"per_gpu_wire_bytes\",\n",
+ " \"hops_per_tile_coordinate\", \"fabric_chunks_per_tile_coordinate\",\n",
+ " \"model_latency_s\", \"algbw_GBps\", \"busbw_GBps\",\n",
+ "])\n",
+ "sweep\n",
+ "\n",
+ "# TODO: Needs to account for links sharing the same \\\n",
+ "# Currently: doing an ALL GATHER, not all to all.\n",
+ "# All to all: reshufflling tile shards to be on 1 gpu.\n",
+ "# Hops per element * per rank byte (document somewhere)\n",
+ "\n",
+ "# NOTE: Can think of nvlink as one hop. 0,0 as router. Every time you want to send something, have to go through 0,0.\n",
+ "# NOTE: How do we route for star routing? Instead of figuring out all routings, should we have a set of routing choices?\n",
+ "# NOTE: We support various different topologie. However, you have to specify the routing chosen for multicast?\n",
+ "# NOTE: It is a simple multicast.\n",
+ " # What if that's the contribution? For regular workloads, superimposing multicast trees would balance traffic.\n",
+ " # Don't need fancy things (cycle accurate simulation), and still account for congestion (bw pressure on links).\n",
+ " # Hard to frame contribution in terms of coordinates"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 8,
+ "id": "cbdaefb9",
+ "metadata": {
+ "execution": {
+ "iopub.execute_input": "2026-09-17T16:44:35.968638Z",
+ "iopub.status.busy": "2026-09-17T16:44:35.968546Z",
+ "iopub.status.idle": "2026-09-17T16:44:35.972405Z",
+ "shell.execute_reply": "2026-09-17T16:44:35.971113Z"
+ }
+ },
+ "outputs": [
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "busbw invariant holds: busbw == 150 GB/s on every row\n"
+ ]
+ }
+ ],
+ "source": [
+ "if ALPHA_S == 0:\n",
+ " assert (sweep[\"busbw_GBps\"] - LINK_BW_GBPS).abs().max() < 1e-9 * LINK_BW_GBPS\n",
+ " print(f\"busbw invariant holds: busbw == {LINK_BW_GBPS:g} GB/s on every row\")"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 9,
+ "id": "11751aab",
+ "metadata": {
+ "execution": {
+ "iopub.execute_input": "2026-09-17T16:44:35.974202Z",
+ "iopub.status.busy": "2026-09-17T16:44:35.974110Z",
+ "iopub.status.idle": "2026-09-17T16:44:36.196411Z",
+ "shell.execute_reply": "2026-09-17T16:44:36.195159Z"
+ }
+ },
+ "outputs": [
+ {
+ "data": {
+ "image/png": "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KpUVltM8YnD69Gl0794dLi4uCAkJwS+//AIA2Lt3Lzp06ABnZ2c0b9683McXTp48iX79+sHNzQ3169dHr169cPjwYb1y586dQ8+ePeHs7IzGjRvjww8/hFqtLrdfW7duRdeuXVGvXj24urqif//+OHfuXIXnUVbv3r1x4MABZGRkGFS+pjBJqyGa57nGjRuHpKQk3Lp1C2vWrMGXX36JiRMnGvSXzaZNmxASEoIOHTqY3Z+rV68CADw9Pc0+FgB4e3tj5cqVaNGiBRo3boyVK1di5cqVCA0NtcjxNR555BF07twZP/zwg96+H374Aa6urnjqqacqPcaKFSvQv39/ZGRkYNq0afjkk08QGRmp88t5xYoVGD58OGQyGebMmYOXX34Zv/76K7p06YLMzEyd46lUKsTGxsLT0xOfffYZunfvjvnz52PZsmV6bf/444+YN28eXn31VXz44Ye4fv06Bg8ejJKSEm2Zc+fOoWPHjjh//jymTp2K+fPno169ehg0aJBOgpObm4uuXbvi888/R58+fbB48WK89tpruHDhAv755x+EhoZi1qxZAErv2mo+E80vw127dqFbt27Izs5GQkICPv74Y2RmZqJnz544cuSItp0zZ86gT58+SE9Px4wZMzBmzBgkJCRUmWw97Nlnn0WzZs0wa9asSr9mHzx4MJycnMr96uHHH39EYGAgOnfubFTbFTlw4ADGjx+PkSNHYu7cuSgsLMSQIUNw//79CutcvXoV3bp1g6urK/bs2aP3h1dZR48excGDBzFy5EgsWbIEr732Gnbu3Ino6Gijn0UtT1WfsTFxLEb/gdI/rsLDww26M6r5FuGff/6xSNsVMfRn+tVXX8Xbb7+Nzp07Y/HixRgzZgx++OEHxMbGan+mFy1ahMaNG6NFixbaz+e9996Du7s7wsPDsW/fPu3xDhw4AIlEgoyMDCQnJ2u379+/H127dtW+NuZzVSqViI2NhY+PDz777DMMGTKk3HPetGkTBg4ciGHDhmHVqlVV3nl+8OABnnzySXTo0AFz586Fo6MjRo4ciTVr1mDkyJF44okn8MknnyAvLw9Dhw5FTk6Otu65c+fQtWtXnDp1Cu+88w4++OADpKSkIDo6Gn///be2XGpqKnr06IGkpCRMnToVkydPxvfff4/Fixfr9WflypXo378/6tevj08//RQffPABkpOT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",
+ "text/plain": [
+ "
"
+ ]
+ },
+ "metadata": {},
+ "output_type": "display_data"
+ }
+ ],
+ "source": [
+ "fig, ax = plt.subplots(figsize=(7, 5))\n",
+ "ax.loglog(sweep[\"collective_size_bytes\"], sweep[\"model_latency_s\"] * 1e6, \"o-\",\n",
+ " label=f\"ISL model ({LINK_BW_GBPS:g} GB/s links)\")\n",
+ "ax.set_xlabel(\"total collective size (bytes)\")\n",
+ "ax.set_ylabel(\"all-to-all latency (µs)\")\n",
+ "ax.set_title(f\"{n}-GPU fully-connected NVLink all-to-all (ISL network model)\")\n",
+ "ax.grid(True, which=\"both\", alpha=0.3)\n",
+ "ax.legend() # ASTRA-sim 2.0 and EC2 nccl-tests series overlay here later\n",
+ "plt.show()"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "449a7991",
+ "metadata": {},
+ "source": [
+ "## Torus leg: 2×2×2 logical torus (3-cube) all-to-all\n",
+ "\n",
+ "GPU $r$ sits at binary coordinates $(g_0, g_1, g_2) \\in \\{0, 1\\}^3$ of a 3-dimensional `noc` space, one coordinate per torus dimension. Rank is recovered row-major with the **last** dimension fastest-varying, $r = 4g_0 + 2g_1 + g_2$ — matching the `torus_bench` benchmark's rank$\\leftrightarrow$coordinate convention.\n",
+ "\n",
+ "Every dimension here has extent 2, so the $\\pm 1 \\pmod 2$ torus wraparound edge coincides with the only other value that coordinate can take: wraparound degenerates exactly to the hypercube edge, and `HypercubeMulticastModel` applies directly. Its precondition — a Manhattan, translation-invariant `dist_fn` — is satisfied by Hamming distance on the binary coordinates (ISL has no `!=`/`<>` operator, so per-axis inequality is written as the disjunction `(a < b or a > b)`).\n",
+ "\n",
+ "Cost per $(s, d)$ pair at Hamming distance $k$ is $2^k - 1$ (the bounding-box multicast-tree cost `HypercubeMulticastModel` computes), summing to **152** hops over all 64 `data[s, d]` chunks. A dimension-ordered *minimal unicast* routing — one packet per hop instead of a shared multicast tree, which is what the empirical torus benchmark actually implements — would instead traverse $\\sum_{s,d} \\mathrm{hamming}(s, d) = $ **96** hops. The two series bracket real routing behavior; the gap between them is itself a correlation observable, quantifying how far a multicast-aware model diverges from point-to-point unicast."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 10,
+ "id": "d166f6c4",
+ "metadata": {
+ "execution": {
+ "iopub.execute_input": "2026-09-17T16:44:36.198246Z",
+ "iopub.status.busy": "2026-09-17T16:44:36.198146Z",
+ "iopub.status.idle": "2026-09-17T16:44:36.203304Z",
+ "shell.execute_reply": "2026-09-17T16:44:36.202401Z"
+ }
+ },
+ "outputs": [],
+ "source": [
+ "def binary_bounds(prefix: str, k: int) -> str:\n",
+ " \"\"\"Bit constraints over dims ``{prefix}0..{prefix}{k-1}``.\"\"\"\n",
+ " return \" and \".join(f\"0 <= {prefix}{i} <= 1\" for i in range(k))\n",
+ "\n",
+ "\n",
+ "def rank_expr(prefix: str, k: int) -> str:\n",
+ " \"\"\"Affine recovery of the rank from binary torus coordinates.\n",
+ "\n",
+ " Row-major with the LAST coordinate fastest-varying:\n",
+ " ``rank = sum_i 2**(k-1-i) * g_i``, matching the ``torus_bench``\n",
+ " benchmark's rank<->coordinate convention (see the markdown above).\n",
+ " \"\"\"\n",
+ " return \" + \".join(f\"{2 ** (k - 1 - i)}*{prefix}{i}\" for i in range(k))\n",
+ "\n",
+ "\n",
+ "def torus_all_to_all_maps(\n",
+ " dims: tuple[int, ...] = (2, 2, 2),\n",
+ ") -> tuple[isl.Map, isl.Map, isl.Map]:\n",
+ " \"\"\"Build (occupancy, fill, dist_fn) for a logical-torus all-to-all.\n",
+ "\n",
+ " Mirrors ``all_to_all_maps`` above but keys GPUs by binary torus\n",
+ " coordinates instead of a one-hot id. ``data[s, d]`` is the chunk sent\n",
+ " by GPU ``s`` to GPU ``d``; each GPU holds the chunks it sends (occ)\n",
+ " and requests the chunks addressed to it (fill).\n",
+ "\n",
+ " Parameters\n",
+ " ----------\n",
+ " dims : tuple of int, default (2, 2, 2)\n",
+ " Per-dimension extents of the logical torus. Every entry must\n",
+ " equal 2 (see Raises); ``K = len(dims)`` sets the dimensionality\n",
+ " of the ``noc`` space and the number of ``SpatialTag`` axes the\n",
+ " caller must build to match.\n",
+ "\n",
+ " Returns\n",
+ " -------\n",
+ " occ : isl.Map\n",
+ " ``noc[g0..g_{K-1}] -> data[s, d]``: the GPU at ``(g0..g_{K-1})``\n",
+ " holds the chunk it is the source of, for every destination ``d``.\n",
+ " fill : isl.Map\n",
+ " ``noc[g0..g_{K-1}] -> data[s, d]``: the GPU at ``(g0..g_{K-1})``\n",
+ " requests the chunk addressed to it, for every source ``s``.\n",
+ " dist_fn : isl.Map\n",
+ " Piecewise Hamming distance between two ``noc`` points, encoded as\n",
+ " a disjunction over the ``2**K`` equal/differ patterns per axis.\n",
+ "\n",
+ " Raises\n",
+ " ------\n",
+ " ValueError\n",
+ " If any entry of ``dims`` is not 2. General (non-power-of-two)\n",
+ " torus extents need a genuine wraparound-aware Manhattan distance\n",
+ " function, not the hypercube-degeneracy shortcut used here — see\n",
+ " the markdown cell above for why extent 2 is special.\n",
+ " \"\"\"\n",
+ " if any(d != 2 for d in dims):\n",
+ " raise ValueError(\n",
+ " f\"torus_all_to_all_maps only supports extent-2 dims (hypercube \"\n",
+ " f\"wraparound degeneracy), got dims={dims!r}; general torus \"\n",
+ " f\"extents need a wraparound-aware Manhattan dist_fn\"\n",
+ " )\n",
+ " k = len(dims)\n",
+ " n = 2 ** k\n",
+ "\n",
+ " gs = \", \".join(f\"gs{i}\" for i in range(k))\n",
+ " gd = \", \".join(f\"gd{i}\" for i in range(k))\n",
+ " occ = isl.Map.read_from_str(\n",
+ " CTX,\n",
+ " f\"{{ noc[{gs}] -> data[s, d] : {binary_bounds('gs', k)} \"\n",
+ " f\"and s = {rank_expr('gs', k)} and 0 <= d < {n} }}\",\n",
+ " )\n",
+ " fill = isl.Map.read_from_str(\n",
+ " CTX,\n",
+ " f\"{{ noc[{gd}] -> data[s, d] : {binary_bounds('gd', k)} \"\n",
+ " f\"and d = {rank_expr('gd', k)} and 0 <= s < {n} }}\",\n",
+ " )\n",
+ "\n",
+ " # Piecewise Hamming distance: one disjunct per subset of axes that\n",
+ " # differ (2**K sign patterns), cost = popcount of the subset. ISL has\n",
+ " # no `!=`, so per-axis inequality is the disjunction (a < b or a > b).\n",
+ " pieces = []\n",
+ " for mask in range(2 ** k):\n",
+ " dist = bin(mask).count(\"1\")\n",
+ " conds = [\n",
+ " f\"(gd{i} < gs{i} or gd{i} > gs{i})\" if mask & (1 << i) else f\"gd{i} = gs{i}\"\n",
+ " for i in range(k)\n",
+ " ]\n",
+ " pieces.append(\n",
+ " f\"[noc[{gd}] -> noc[{gs}]] -> hops[{dist}] : \" + \" and \".join(conds)\n",
+ " )\n",
+ " dist_fn = isl.Map.read_from_str(CTX, \"{ \" + \"; \".join(pieces) + \" }\")\n",
+ "\n",
+ " return occ, fill, dist_fn"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 11,
+ "id": "256ea183",
+ "metadata": {
+ "execution": {
+ "iopub.execute_input": "2026-09-17T16:44:36.205363Z",
+ "iopub.status.busy": "2026-09-17T16:44:36.205267Z",
+ "iopub.status.idle": "2026-09-17T16:44:36.352174Z",
+ "shell.execute_reply": "2026-09-17T16:44:36.351561Z"
+ }
+ },
+ "outputs": [
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "ISL network model: 8-GPU 2x2x2 logical torus all-to-all (binary coords)\n",
+ " bounding-box multicast hops: 152 (Sigma 2^hamming(s,d) - 1)\n",
+ " min-route unicast hops : 96 (Sigma hamming(s,d))\n",
+ " routing gap : 56 hops (1.58x)\n"
+ ]
+ }
+ ],
+ "source": [
+ "dims_torus = (2, 2, 2)\n",
+ "k = len(dims_torus)\n",
+ "n_t = 2 ** k # = 8 GPUs, same physical count as the FC leg above\n",
+ "\n",
+ "occ_t, fill_t, dist_fn_t = torus_all_to_all_maps(dims_torus)\n",
+ "tags_t = [SpatialTag(i, 0) for i in range(k)]\n",
+ "model_t = HypercubeMulticastModel(dist_fn_t)\n",
+ "\n",
+ "\n",
+ "def torus_pair_hops(src: int, dst: int) -> int:\n",
+ " \"\"\"Hops for a single (src, dst) chunk under the torus (Hamming) dist_fn.\"\"\"\n",
+ " chunk = isl.Set.read_from_str(CTX, f\"{{ data[{src}, {dst}] }}\")\n",
+ " info = model_t.apply(\n",
+ " 0,\n",
+ " Fill(tags_t, fill_t.intersect_range(chunk)),\n",
+ " Occupancy(tags_t, occ_t.intersect_range(chunk)),\n",
+ " )\n",
+ " return eval_total(info.hops)\n",
+ "\n",
+ "\n",
+ "def hamming(a: int, b: int) -> int:\n",
+ " \"\"\"Hamming distance between the binary representations of two ints.\"\"\"\n",
+ " return bin(a ^ b).count(\"1\")\n",
+ "\n",
+ "\n",
+ "# --- Run the tool over the full all-to-all and validate against the\n",
+ "# verified reference numbers (see torus_hops_check.py pre-verification). ---\n",
+ "info_t = model_t.apply(0, Fill(tags_t, fill_t), Occupancy(tags_t, occ_t))\n",
+ "torus_hops = eval_total(info_t.hops)\n",
+ "assert torus_hops == 152, f\"model returned {torus_hops} total hops, expected 152\"\n",
+ "\n",
+ "probes = {(0, 1): 1, (0, 3): 3, (0, 7): 7, (5, 5): 0}\n",
+ "for (src, dst), expected in probes.items():\n",
+ " got = torus_pair_hops(src, dst)\n",
+ " assert got == expected, f\"pair ({src}->{dst}) cost {got} != {expected}\"\n",
+ "\n",
+ "# Dimension-ordered minimal unicast routing (what the empirical torus\n",
+ "# benchmark implements): one hop per differing bit, summed over every\n",
+ "# ordered (s, d) pair. Computed programmatically -- not hardcoded -- so\n",
+ "# it stays correct if dims_torus ever changes.\n",
+ "minroute_hops = sum(hamming(s, d) for s in range(n_t) for d in range(n_t))\n",
+ "assert minroute_hops == 96, f\"minroute_hops {minroute_hops} != 96\"\n",
+ "\n",
+ "print(f\"ISL network model: {n_t}-GPU 2x2x2 logical torus all-to-all (binary coords)\")\n",
+ "print(f\" bounding-box multicast hops: {torus_hops} (Sigma 2^hamming(s,d) - 1)\")\n",
+ "print(f\" min-route unicast hops : {minroute_hops} (Sigma hamming(s,d))\")\n",
+ "print(f\" routing gap : {torus_hops - minroute_hops} hops \"\n",
+ " f\"({torus_hops / minroute_hops:.2f}x)\")"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 12,
+ "id": "8f3f4946",
+ "metadata": {
+ "execution": {
+ "iopub.execute_input": "2026-09-17T16:44:36.353191Z",
+ "iopub.status.busy": "2026-09-17T16:44:36.353103Z",
+ "iopub.status.idle": "2026-09-17T16:44:36.358942Z",
+ "shell.execute_reply": "2026-09-17T16:44:36.358366Z"
+ }
+ },
+ "outputs": [
+ {
+ "data": {
+ "text/html": [
+ "
\n",
+ "\n",
+ "
\n",
+ " \n",
+ "
\n",
+ "
\n",
+ "
nodes
\n",
+ "
collective_size_bytes
\n",
+ "
per_rank_bytes
\n",
+ "
per_link_bytes_model
\n",
+ "
model_latency_s
\n",
+ "
minroute_latency_s
\n",
+ "
\n",
+ " \n",
+ " \n",
+ "
\n",
+ "
0
\n",
+ "
8
\n",
+ "
1048576
\n",
+ "
131072
\n",
+ "
1.037653e+05
\n",
+ "
6.917689e-07
\n",
+ "
4.369067e-07
\n",
+ "
\n",
+ "
\n",
+ "
1
\n",
+ "
8
\n",
+ "
2097152
\n",
+ "
262144
\n",
+ "
2.075307e+05
\n",
+ "
1.383538e-06
\n",
+ "
8.738133e-07
\n",
+ "
\n",
+ "
\n",
+ "
2
\n",
+ "
8
\n",
+ "
4194304
\n",
+ "
524288
\n",
+ "
4.150613e+05
\n",
+ "
2.767076e-06
\n",
+ "
1.747627e-06
\n",
+ "
\n",
+ "
\n",
+ "
3
\n",
+ "
8
\n",
+ "
8388608
\n",
+ "
1048576
\n",
+ "
8.301227e+05
\n",
+ "
5.534151e-06
\n",
+ "
3.495253e-06
\n",
+ "
\n",
+ "
\n",
+ "
4
\n",
+ "
8
\n",
+ "
16777216
\n",
+ "
2097152
\n",
+ "
1.660245e+06
\n",
+ "
1.106830e-05
\n",
+ "
6.990507e-06
\n",
+ "
\n",
+ "
\n",
+ "
5
\n",
+ "
8
\n",
+ "
33554432
\n",
+ "
4194304
\n",
+ "
3.320491e+06
\n",
+ "
2.213660e-05
\n",
+ "
1.398101e-05
\n",
+ "
\n",
+ "
\n",
+ "
6
\n",
+ "
8
\n",
+ "
67108864
\n",
+ "
8388608
\n",
+ "
6.640981e+06
\n",
+ "
4.427321e-05
\n",
+ "
2.796203e-05
\n",
+ "
\n",
+ "
\n",
+ "
7
\n",
+ "
8
\n",
+ "
134217728
\n",
+ "
16777216
\n",
+ "
1.328196e+07
\n",
+ "
8.854642e-05
\n",
+ "
5.592405e-05
\n",
+ "
\n",
+ "
\n",
+ "
8
\n",
+ "
8
\n",
+ "
268435456
\n",
+ "
33554432
\n",
+ "
2.656393e+07
\n",
+ "
1.770928e-04
\n",
+ "
1.118481e-04
\n",
+ "
\n",
+ "
\n",
+ "
9
\n",
+ "
8
\n",
+ "
536870912
\n",
+ "
67108864
\n",
+ "
5.312785e+07
\n",
+ "
3.541857e-04
\n",
+ "
2.236962e-04
\n",
+ "
\n",
+ "
\n",
+ "
10
\n",
+ "
8
\n",
+ "
1073741824
\n",
+ "
134217728
\n",
+ "
1.062557e+08
\n",
+ "
7.083713e-04
\n",
+ "
4.473924e-04
\n",
+ "
\n",
+ " \n",
+ "
\n",
+ "
"
+ ],
+ "text/plain": [
+ " nodes collective_size_bytes per_rank_bytes per_link_bytes_model \\\n",
+ "0 8 1048576 131072 1.037653e+05 \n",
+ "1 8 2097152 262144 2.075307e+05 \n",
+ "2 8 4194304 524288 4.150613e+05 \n",
+ "3 8 8388608 1048576 8.301227e+05 \n",
+ "4 8 16777216 2097152 1.660245e+06 \n",
+ "5 8 33554432 4194304 3.320491e+06 \n",
+ "6 8 67108864 8388608 6.640981e+06 \n",
+ "7 8 134217728 16777216 1.328196e+07 \n",
+ "8 8 268435456 33554432 2.656393e+07 \n",
+ "9 8 536870912 67108864 5.312785e+07 \n",
+ "10 8 1073741824 134217728 1.062557e+08 \n",
+ "\n",
+ " model_latency_s minroute_latency_s \n",
+ "0 6.917689e-07 4.369067e-07 \n",
+ "1 1.383538e-06 8.738133e-07 \n",
+ "2 2.767076e-06 1.747627e-06 \n",
+ "3 5.534151e-06 3.495253e-06 \n",
+ "4 1.106830e-05 6.990507e-06 \n",
+ "5 2.213660e-05 1.398101e-05 \n",
+ "6 4.427321e-05 2.796203e-05 \n",
+ "7 8.854642e-05 5.592405e-05 \n",
+ "8 1.770928e-04 1.118481e-04 \n",
+ "9 3.541857e-04 2.236962e-04 \n",
+ "10 7.083713e-04 4.473924e-04 "
+ ]
+ },
+ "execution_count": 12,
+ "metadata": {},
+ "output_type": "execute_result"
+ }
+ ],
+ "source": [
+ "# 8 nodes * 3 distinct neighbors/node. Extent-2 wraparound means the +1\n",
+ "# and -1 neighbor along a dim coincide (mod 2), so each node has exactly\n",
+ "# one neighbor per torus dimension, not two -- hence 3, not 6.\n",
+ "DIRECTED_LINKS_TORUS = n_t * k\n",
+ "\n",
+ "rows_t = []\n",
+ "mib = MIN_MIB\n",
+ "while mib <= MAX_MIB:\n",
+ " size = mib * (1 << 20)\n",
+ " total = size * n_t if PER_RANK else size\n",
+ " per_rank = total / n_t\n",
+ " chunk_bytes = total / (n_t * n_t)\n",
+ " # By symmetry, all-to-all traffic loads all 24 directed torus links\n",
+ " # uniformly: total hop-bytes (chunk_bytes * hops, summed over the 64\n",
+ " # chunks) divides evenly across the links. Two series bracket\n",
+ " # routing: `torus_hops` (bounding-box multicast) vs. `minroute_hops`\n",
+ " # (dimension-ordered unicast) -- see the markdown cell above.\n",
+ " per_link_bytes_model = (torus_hops / DIRECTED_LINKS_TORUS) * chunk_bytes\n",
+ " per_link_bytes_minroute = (minroute_hops / DIRECTED_LINKS_TORUS) * chunk_bytes\n",
+ " model_latency_s = ALPHA_S + per_link_bytes_model / bw\n",
+ " minroute_latency_s = ALPHA_S + per_link_bytes_minroute / bw\n",
+ " # Closed-form cross-check: 152/(64*24) = 19/192, 96/(64*24) = 1/16.\n",
+ " assert math.isclose(model_latency_s, ALPHA_S + total * 19 / (192 * bw), rel_tol=1e-12)\n",
+ " assert math.isclose(minroute_latency_s, ALPHA_S + total / (16 * bw), rel_tol=1e-12)\n",
+ " rows_t.append((n_t, int(total), int(per_rank), per_link_bytes_model,\n",
+ " model_latency_s, minroute_latency_s))\n",
+ " mib *= 2\n",
+ "\n",
+ "sweep_torus = pd.DataFrame(rows_t, columns=[\n",
+ " \"nodes\", \"collective_size_bytes\", \"per_rank_bytes\", \"per_link_bytes_model\",\n",
+ " \"model_latency_s\", \"minroute_latency_s\",\n",
+ "])\n",
+ "sweep_torus"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "38679dd0",
+ "metadata": {},
+ "source": [
+ "## All-scatter of a matmul operand: tensor sizes and the ISL model\n",
+ "\n",
+ "*All-scatter* (every GPU performs its own scatter; also called all-to-all personalized exchange or complete exchange, NCCL's `alltoall`) is the collective that re-shards a matmul operand. For $C = A \\cdot B$ with $A$ of shape $M \\times K$ spread over $N = 8$ GPUs, switching $A$ from **row-sharded** (GPU $s$ holds rows $[s M_b, (s+1) M_b)$ with $M_b = M/N$) to **column-sharded** (GPU $d$ needs columns $[d K_b, (d+1) K_b)$ with $K_b = K/N$) is an all-scatter: every GPU keeps the $M_b \\times K_b$ block on its own diagonal and sends the other $N-1$ blocks of its shard to their column owners.\n",
+ "\n",
+ "Below the ISL model is run on **this actual data space** `A[m, k]` (not the abstract per-pair `data[s, d]` used above) with the shard sizes $M_b, K_b$ left as ISL **parameters**, so `HypercubeMulticastModel` returns the hop count as a closed-form polynomial in $(M_b, K_b)$ (an ISL `PwQPolynomial`) that is then evaluated at each matmul shape. Bytes moved: total $S = M K \\cdot \\mathrm{bytes(dtype)}$, per-rank NCCL buffer $S/N$, and each GPU's port drains $S(N-1)/N^2$ bytes.\n",
+ "\n",
+ "The profiled node is a `p5.48xlarge` (8x H100 SXM behind NVSwitch), whose NVLink is rated at 450 GB/s per direction per GPU (`NVLINK_H100_GBPS` below). `LINK_BW_GBPS` above (150 GB/s, the V100-era value matching `mapping/v100_torus.arch.yaml`) is left untouched for the earlier sweeps; the empirical fit further down calibrates the effective bandwidth anyway, so the nominal value only positions the reference line."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 13,
+ "id": "9eed83d8",
+ "metadata": {
+ "execution": {
+ "iopub.execute_input": "2026-09-17T16:44:36.360380Z",
+ "iopub.status.busy": "2026-09-17T16:44:36.360294Z",
+ "iopub.status.idle": "2026-09-17T16:44:36.362669Z",
+ "shell.execute_reply": "2026-09-17T16:44:36.362133Z"
+ }
+ },
+ "outputs": [
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "18 matmul operand shapes, (128, 256) .. (65536, 65536)\n"
+ ]
+ }
+ ],
+ "source": [
+ "NVLINK_H100_GBPS = 450.0 # H100 SXM5 NVLink: 900 GB/s bidirectional = 450 GB/s per direction per GPU\n",
+ "DTYPE_BYTES = {\"bf16\": 2, \"fp16\": 2, \"fp32\": 4}\n",
+ "MATMUL_DTYPE = \"bf16\"\n",
+ "\n",
+ "# One operand shape per nccl-tests sweep point: M*K = 2**e for e = 15..32, split as\n",
+ "# M = 2**(e//2), K = 2**(e - e//2) (square for even e, 1:2 rectangular for odd e).\n",
+ "# Total all-scatter bytes S = M*K*2 then runs 64 KiB .. 8 GiB, i.e. per-rank 8 KiB .. 1 GiB,\n",
+ "# which is exactly the -f 2 sweep produced by\n",
+ "# aws-gpu-profiler run --workload nccl-tests --param collective=alltoall --param min_bytes=8192 --param max_bytes=1073741824 --data-dir correlation/data\n",
+ "MATMUL_SHAPES = [(2 ** (e // 2), 2 ** (e - e // 2)) for e in range(15, 33)]\n",
+ "print(f\"{len(MATMUL_SHAPES)} matmul operand shapes, {MATMUL_SHAPES[0]} .. {MATMUL_SHAPES[-1]}\")"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 14,
+ "id": "65b7418e",
+ "metadata": {
+ "execution": {
+ "iopub.execute_input": "2026-09-17T16:44:36.363707Z",
+ "iopub.status.busy": "2026-09-17T16:44:36.363638Z",
+ "iopub.status.idle": "2026-09-17T16:44:50.242544Z",
+ "shell.execute_reply": "2026-09-17T16:44:50.241200Z"
+ }
+ },
+ "outputs": [
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "ISL model hop count for the matmul re-shard, parametric in the shard size:\n",
+ " [Mb, Kb] -> { 168 * Mb * Kb : Mb > 0 and Kb > 0 }\n",
+ " matches 3 * (N-1)/N * M*K = 168 * Mb * Kb at every probe\n"
+ ]
+ }
+ ],
+ "source": [
+ "def matmul_reshard_maps(n: int) -> tuple[isl.Map, isl.Map]:\n",
+ " \"\"\"Build (occupancy, fill) for re-sharding A[m, k] from row blocks to column blocks.\n",
+ "\n",
+ " GPU ``s`` (one-hot coordinate e_s) holds rows ``[s*Mb, (s+1)*Mb)`` of ``A``\n",
+ " (occupancy); GPU ``d`` requests columns ``[d*Kb, (d+1)*Kb)`` (fill). ``Mb``\n",
+ " and ``Kb`` are ISL *parameters*, so the model's hop count comes back as a\n",
+ " quasi-polynomial in them.\n",
+ "\n",
+ " Parameters\n",
+ " ----------\n",
+ " n : int\n",
+ " Number of GPUs on the fully-connected fabric (one one-hot dimension\n",
+ " each).\n",
+ "\n",
+ " Returns\n",
+ " -------\n",
+ " occ : isl.Map\n",
+ " ``[Mb, Kb] -> { noc[g0..g_{n-1}] -> A[m, k] }``, the rows each GPU holds.\n",
+ " fill : isl.Map\n",
+ " ``[Mb, Kb] -> { noc[g0..g_{n-1}] -> A[m, k] }``, the columns each GPU needs.\n",
+ "\n",
+ " Notes\n",
+ " -----\n",
+ " Ownership is written as a disjunction over the ``n`` constant GPU ids\n",
+ " (``gs_i = 1 and i*Mb <= m < (i+1)*Mb``) because ``id * Mb`` with ``id`` a\n",
+ " variable would be a bilinear term, which ISL rejects.\n",
+ " \"\"\"\n",
+ " gs = \", \".join(f\"gs{i}\" for i in range(n))\n",
+ " gd = \", \".join(f\"gd{i}\" for i in range(n))\n",
+ " own_rows = \" or \".join(f\"(gs{i} = 1 and {i}*Mb <= m < {i + 1}*Mb)\" for i in range(n))\n",
+ " own_cols = \" or \".join(f\"(gd{i} = 1 and {i}*Kb <= k < {i + 1}*Kb)\" for i in range(n))\n",
+ " occ = isl.Map.read_from_str(\n",
+ " CTX,\n",
+ " f\"[Mb, Kb] -> {{ noc[{gs}] -> A[m, k] : Mb >= 1 and Kb >= 1 and \"\n",
+ " f\"{onehot_constraints('gs', n)} and 0 <= k < {n}*Kb and ({own_rows}) }}\",\n",
+ " )\n",
+ " fill = isl.Map.read_from_str(\n",
+ " CTX,\n",
+ " f\"[Mb, Kb] -> {{ noc[{gd}] -> A[m, k] : Mb >= 1 and Kb >= 1 and \"\n",
+ " f\"{onehot_constraints('gd', n)} and 0 <= m < {n}*Mb and ({own_cols}) }}\",\n",
+ " )\n",
+ " return occ, fill\n",
+ "\n",
+ "\n",
+ "def eval_params(poly: isl.PwQPolynomial, **params: int) -> int:\n",
+ " \"\"\"Evaluate a parametric, fully-summed PwQPolynomial at integer parameter values.\"\"\"\n",
+ " space = poly.domain().get_space()\n",
+ " point = isl.Point.zero(space)\n",
+ " for name, value in params.items():\n",
+ " idx = space.find_dim_by_name(isl.dim_type.param, name)\n",
+ " point = point.set_coordinate_val(\n",
+ " isl.dim_type.param, idx, isl.Val.int_from_si(CTX, value)\n",
+ " )\n",
+ " return int(poly.eval(point).to_python())\n",
+ "\n",
+ "\n",
+ "occ_mm, fill_mm = matmul_reshard_maps(n)\n",
+ "_, _, dist_fn_fc = all_to_all_maps(n) # same unit-cost dist_fn as the FC leg above\n",
+ "tags_mm = [SpatialTag(i, 0) for i in range(n)]\n",
+ "info_mm = HypercubeMulticastModel(dist_fn_fc).apply(\n",
+ " 0, Fill(tags_mm, fill_mm), Occupancy(tags_mm, occ_mm)\n",
+ ")\n",
+ "assert info_mm.unfulfilled_fill.map_.is_empty(), \"every requested block must have a holder\"\n",
+ "hops_mm = info_mm.hops\n",
+ "print(\"ISL model hop count for the matmul re-shard, parametric in the shard size:\")\n",
+ "print(\" \", hops_mm)\n",
+ "\n",
+ "# Closed form: every element whose row owner differs from its column owner crosses the\n",
+ "# fabric at the uniform 3-hop FC cost -> 3 * (N-1)/N * M*K = 168 * Mb * Kb for N = 8.\n",
+ "HOPS_PER_SHARD_ELEMENT = 3 * (n - 1) * n\n",
+ "for Mb, Kb in [(1, 1), (2, 2), (128, 128), (512, 2048)]:\n",
+ " got = eval_params(hops_mm, Mb=Mb, Kb=Kb)\n",
+ " expected = HOPS_PER_SHARD_ELEMENT * Mb * Kb\n",
+ " assert got == expected, f\"hops(Mb={Mb}, Kb={Kb}) = {got} != {expected}\"\n",
+ "print(f\" matches 3 * (N-1)/N * M*K = {HOPS_PER_SHARD_ELEMENT} * Mb * Kb at every probe\")"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 15,
+ "id": "55ff9b48",
+ "metadata": {
+ "execution": {
+ "iopub.execute_input": "2026-09-17T16:44:50.244558Z",
+ "iopub.status.busy": "2026-09-17T16:44:50.244459Z",
+ "iopub.status.idle": "2026-09-17T16:44:50.253466Z",
+ "shell.execute_reply": "2026-09-17T16:44:50.252321Z"
+ }
+ },
+ "outputs": [
+ {
+ "data": {
+ "text/html": [
+ "
\n",
+ "\n",
+ "
\n",
+ " \n",
+ "
\n",
+ "
\n",
+ "
M
\n",
+ "
K
\n",
+ "
dtype
\n",
+ "
elements
\n",
+ "
collective_size_bytes
\n",
+ "
per_rank_bytes
\n",
+ "
model_hops
\n",
+ "
per_gpu_wire_bytes
\n",
+ "
model_latency_us
\n",
+ "
\n",
+ " \n",
+ " \n",
+ "
\n",
+ "
0
\n",
+ "
128
\n",
+ "
256
\n",
+ "
bf16
\n",
+ "
32768
\n",
+ "
65536
\n",
+ "
8192
\n",
+ "
86016
\n",
+ "
7168
\n",
+ "
0.015929
\n",
+ "
\n",
+ "
\n",
+ "
1
\n",
+ "
256
\n",
+ "
256
\n",
+ "
bf16
\n",
+ "
65536
\n",
+ "
131072
\n",
+ "
16384
\n",
+ "
172032
\n",
+ "
14336
\n",
+ "
0.031858
\n",
+ "
\n",
+ "
\n",
+ "
2
\n",
+ "
256
\n",
+ "
512
\n",
+ "
bf16
\n",
+ "
131072
\n",
+ "
262144
\n",
+ "
32768
\n",
+ "
344064
\n",
+ "
28672
\n",
+ "
0.063716
\n",
+ "
\n",
+ "
\n",
+ "
3
\n",
+ "
512
\n",
+ "
512
\n",
+ "
bf16
\n",
+ "
262144
\n",
+ "
524288
\n",
+ "
65536
\n",
+ "
688128
\n",
+ "
57344
\n",
+ "
0.127431
\n",
+ "
\n",
+ "
\n",
+ "
4
\n",
+ "
512
\n",
+ "
1024
\n",
+ "
bf16
\n",
+ "
524288
\n",
+ "
1048576
\n",
+ "
131072
\n",
+ "
1376256
\n",
+ "
114688
\n",
+ "
0.254862
\n",
+ "
\n",
+ "
\n",
+ "
5
\n",
+ "
1024
\n",
+ "
1024
\n",
+ "
bf16
\n",
+ "
1048576
\n",
+ "
2097152
\n",
+ "
262144
\n",
+ "
2752512
\n",
+ "
229376
\n",
+ "
0.509724
\n",
+ "
\n",
+ "
\n",
+ "
6
\n",
+ "
1024
\n",
+ "
2048
\n",
+ "
bf16
\n",
+ "
2097152
\n",
+ "
4194304
\n",
+ "
524288
\n",
+ "
5505024
\n",
+ "
458752
\n",
+ "
1.019449
\n",
+ "
\n",
+ "
\n",
+ "
7
\n",
+ "
2048
\n",
+ "
2048
\n",
+ "
bf16
\n",
+ "
4194304
\n",
+ "
8388608
\n",
+ "
1048576
\n",
+ "
11010048
\n",
+ "
917504
\n",
+ "
2.038898
\n",
+ "
\n",
+ "
\n",
+ "
8
\n",
+ "
2048
\n",
+ "
4096
\n",
+ "
bf16
\n",
+ "
8388608
\n",
+ "
16777216
\n",
+ "
2097152
\n",
+ "
22020096
\n",
+ "
1835008
\n",
+ "
4.077796
\n",
+ "
\n",
+ "
\n",
+ "
9
\n",
+ "
4096
\n",
+ "
4096
\n",
+ "
bf16
\n",
+ "
16777216
\n",
+ "
33554432
\n",
+ "
4194304
\n",
+ "
44040192
\n",
+ "
3670016
\n",
+ "
8.155591
\n",
+ "
\n",
+ "
\n",
+ "
10
\n",
+ "
4096
\n",
+ "
8192
\n",
+ "
bf16
\n",
+ "
33554432
\n",
+ "
67108864
\n",
+ "
8388608
\n",
+ "
88080384
\n",
+ "
7340032
\n",
+ "
16.311182
\n",
+ "
\n",
+ "
\n",
+ "
11
\n",
+ "
8192
\n",
+ "
8192
\n",
+ "
bf16
\n",
+ "
67108864
\n",
+ "
134217728
\n",
+ "
16777216
\n",
+ "
176160768
\n",
+ "
14680064
\n",
+ "
32.622364
\n",
+ "
\n",
+ "
\n",
+ "
12
\n",
+ "
8192
\n",
+ "
16384
\n",
+ "
bf16
\n",
+ "
134217728
\n",
+ "
268435456
\n",
+ "
33554432
\n",
+ "
352321536
\n",
+ "
29360128
\n",
+ "
65.244729
\n",
+ "
\n",
+ "
\n",
+ "
13
\n",
+ "
16384
\n",
+ "
16384
\n",
+ "
bf16
\n",
+ "
268435456
\n",
+ "
536870912
\n",
+ "
67108864
\n",
+ "
704643072
\n",
+ "
58720256
\n",
+ "
130.489458
\n",
+ "
\n",
+ "
\n",
+ "
14
\n",
+ "
16384
\n",
+ "
32768
\n",
+ "
bf16
\n",
+ "
536870912
\n",
+ "
1073741824
\n",
+ "
134217728
\n",
+ "
1409286144
\n",
+ "
117440512
\n",
+ "
260.978916
\n",
+ "
\n",
+ "
\n",
+ "
15
\n",
+ "
32768
\n",
+ "
32768
\n",
+ "
bf16
\n",
+ "
1073741824
\n",
+ "
2147483648
\n",
+ "
268435456
\n",
+ "
2818572288
\n",
+ "
234881024
\n",
+ "
521.957831
\n",
+ "
\n",
+ "
\n",
+ "
16
\n",
+ "
32768
\n",
+ "
65536
\n",
+ "
bf16
\n",
+ "
2147483648
\n",
+ "
4294967296
\n",
+ "
536870912
\n",
+ "
5637144576
\n",
+ "
469762048
\n",
+ "
1043.915662
\n",
+ "
\n",
+ "
\n",
+ "
17
\n",
+ "
65536
\n",
+ "
65536
\n",
+ "
bf16
\n",
+ "
4294967296
\n",
+ "
8589934592
\n",
+ "
1073741824
\n",
+ "
11274289152
\n",
+ "
939524096
\n",
+ "
2087.831324
\n",
+ "
\n",
+ " \n",
+ "
\n",
+ "
"
+ ],
+ "text/plain": [
+ " M K dtype elements collective_size_bytes per_rank_bytes \\\n",
+ "0 128 256 bf16 32768 65536 8192 \n",
+ "1 256 256 bf16 65536 131072 16384 \n",
+ "2 256 512 bf16 131072 262144 32768 \n",
+ "3 512 512 bf16 262144 524288 65536 \n",
+ "4 512 1024 bf16 524288 1048576 131072 \n",
+ "5 1024 1024 bf16 1048576 2097152 262144 \n",
+ "6 1024 2048 bf16 2097152 4194304 524288 \n",
+ "7 2048 2048 bf16 4194304 8388608 1048576 \n",
+ "8 2048 4096 bf16 8388608 16777216 2097152 \n",
+ "9 4096 4096 bf16 16777216 33554432 4194304 \n",
+ "10 4096 8192 bf16 33554432 67108864 8388608 \n",
+ "11 8192 8192 bf16 67108864 134217728 16777216 \n",
+ "12 8192 16384 bf16 134217728 268435456 33554432 \n",
+ "13 16384 16384 bf16 268435456 536870912 67108864 \n",
+ "14 16384 32768 bf16 536870912 1073741824 134217728 \n",
+ "15 32768 32768 bf16 1073741824 2147483648 268435456 \n",
+ "16 32768 65536 bf16 2147483648 4294967296 536870912 \n",
+ "17 65536 65536 bf16 4294967296 8589934592 1073741824 \n",
+ "\n",
+ " model_hops per_gpu_wire_bytes model_latency_us \n",
+ "0 86016 7168 0.015929 \n",
+ "1 172032 14336 0.031858 \n",
+ "2 344064 28672 0.063716 \n",
+ "3 688128 57344 0.127431 \n",
+ "4 1376256 114688 0.254862 \n",
+ "5 2752512 229376 0.509724 \n",
+ "6 5505024 458752 1.019449 \n",
+ "7 11010048 917504 2.038898 \n",
+ "8 22020096 1835008 4.077796 \n",
+ "9 44040192 3670016 8.155591 \n",
+ "10 88080384 7340032 16.311182 \n",
+ "11 176160768 14680064 32.622364 \n",
+ "12 352321536 29360128 65.244729 \n",
+ "13 704643072 58720256 130.489458 \n",
+ "14 1409286144 117440512 260.978916 \n",
+ "15 2818572288 234881024 521.957831 \n",
+ "16 5637144576 469762048 1043.915662 \n",
+ "17 11274289152 939524096 2087.831324 "
+ ]
+ },
+ "execution_count": 15,
+ "metadata": {},
+ "output_type": "execute_result"
+ }
+ ],
+ "source": [
+ "bw_h100 = NVLINK_H100_GBPS * 1e9 # bytes/s\n",
+ "dtype_bytes = DTYPE_BYTES[MATMUL_DTYPE]\n",
+ "\n",
+ "rows_mm = []\n",
+ "for M, K in MATMUL_SHAPES:\n",
+ " assert M % n == 0 and K % n == 0, f\"{M}x{K} does not shard evenly over {n} GPUs\"\n",
+ " Mb, Kb = M // n, K // n\n",
+ " hops = eval_params(hops_mm, Mb=Mb, Kb=Kb)\n",
+ " elements = M * K\n",
+ " total_bytes = elements * dtype_bytes # S\n",
+ " crossing_elements = hops // 3 # uniform 3 hops per fabric-crossing element\n",
+ " per_gpu_wire_bytes = (crossing_elements // n) * dtype_bytes # (N-1)/N of each GPU's shard\n",
+ " t_model = ALPHA_S + per_gpu_wire_bytes / bw_h100\n",
+ " # Closed-form cross-check: t = alpha + S (N-1) / (N^2 BW)\n",
+ " assert math.isclose(\n",
+ " t_model, ALPHA_S + total_bytes * (n - 1) / (n * n * bw_h100), rel_tol=1e-12\n",
+ " )\n",
+ " rows_mm.append((M, K, MATMUL_DTYPE, elements, total_bytes, total_bytes // n, hops,\n",
+ " per_gpu_wire_bytes, t_model * 1e6))\n",
+ "\n",
+ "matmul_sweep = pd.DataFrame(rows_mm, columns=[\n",
+ " \"M\", \"K\", \"dtype\", \"elements\", \"collective_size_bytes\", \"per_rank_bytes\",\n",
+ " \"model_hops\", \"per_gpu_wire_bytes\", \"model_latency_us\",\n",
+ "])\n",
+ "matmul_sweep"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "5e209b22",
+ "metadata": {},
+ "source": [
+ "## Empirical overlay: EC2 nccl-tests + torus_bench\n",
+ "\n",
+ "CSV files under `correlation/data/` (inside the `aws-gpu-profiler` submodule, where fetched results are versioned: `data///results.csv` for new runs; the 2026-09-04 run keeps the older `data//fc/csv/*.csv` layout, and the glob below reads both) are produced by the `aws-gpu-profiler` tool (`aws-gpu-profiler run --workload nccl-tests ... --data-dir correlation/data`, checked out as a git submodule at `correlation/`; not part of this notebook) and share a unified schema: `source, topology, dims, collective, size_bytes, count, dtype, time_us, algbw_GBps, busbw_GBps, wrong`.\n",
+ "\n",
+ "Size normalization to the total collective size $S$ used by `sweep` / `sweep_torus` above differs by source: for `source == \"torus_bench\"` rows, `size_bytes` is **already** the total $S$. For `source == \"nccl-tests\"` rows with `collective == \"alltoall\"`, `size_bytes` is the *per-rank* message size, so $S = N \\times \\texttt{size\\_bytes}$ — verify this against the nccl-tests version actually in use, since the size semantics nccl-tests reports differ per collective. Only `alltoall` is normalized and correlated against the model here; other collectives are inventoried below for later work."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 16,
+ "id": "4715980a",
+ "metadata": {
+ "execution": {
+ "iopub.execute_input": "2026-09-17T16:44:50.255471Z",
+ "iopub.status.busy": "2026-09-17T16:44:50.255374Z",
+ "iopub.status.idle": "2026-09-17T16:44:50.269543Z",
+ "shell.execute_reply": "2026-09-17T16:44:50.268333Z"
+ }
+ },
+ "outputs": [
+ {
+ "data": {
+ "text/html": [
+ "
\n",
+ "\n",
+ "
\n",
+ " \n",
+ "
\n",
+ "
\n",
+ "
\n",
+ "
rows
\n",
+ "
min_size
\n",
+ "
max_size
\n",
+ "
\n",
+ "
\n",
+ "
topology
\n",
+ "
collective
\n",
+ "
\n",
+ "
\n",
+ "
\n",
+ "
\n",
+ " \n",
+ " \n",
+ "
\n",
+ "
fc
\n",
+ "
all_gather
\n",
+ "
18
\n",
+ "
8192
\n",
+ "
1073741824
\n",
+ "
\n",
+ "
\n",
+ "
all_reduce
\n",
+ "
18
\n",
+ "
8192
\n",
+ "
1073741824
\n",
+ "
\n",
+ "
\n",
+ "
alltoall
\n",
+ "
18
\n",
+ "
8192
\n",
+ "
1073741824
\n",
+ "
\n",
+ "
\n",
+ "
broadcast
\n",
+ "
18
\n",
+ "
8192
\n",
+ "
1073741824
\n",
+ "
\n",
+ "
\n",
+ "
reduce_scatter
\n",
+ "
18
\n",
+ "
8192
\n",
+ "
1073741824
\n",
+ "
\n",
+ "
\n",
+ "
sendrecv
\n",
+ "
18
\n",
+ "
8192
\n",
+ "
1073741824
\n",
+ "
\n",
+ " \n",
+ "
\n",
+ "
"
+ ],
+ "text/plain": [
+ " rows min_size max_size\n",
+ "topology collective \n",
+ "fc all_gather 18 8192 1073741824\n",
+ " all_reduce 18 8192 1073741824\n",
+ " alltoall 18 8192 1073741824\n",
+ " broadcast 18 8192 1073741824\n",
+ " reduce_scatter 18 8192 1073741824\n",
+ " sendrecv 18 8192 1073741824"
+ ]
+ },
+ "metadata": {},
+ "output_type": "display_data"
+ }
+ ],
+ "source": [
+ "from pathlib import Path\n",
+ "\n",
+ "# Design: prefer the path relative to the notebook's own directory (the\n",
+ "# normal case when the notebook is run in place), but fall back to the\n",
+ "# repo-root-relative path so `jupyter execute` from a different cwd (e.g.\n",
+ "# CI running from the repo root) still finds real data when it exists,\n",
+ "# instead of silently taking the empty-data path below.\n",
+ "DATA_DIR = (\n",
+ " Path(\"correlation/data\") if Path(\"correlation/data\").is_dir()\n",
+ " else Path(\"notebooks/astrasim2_correlation/correlation/data\")\n",
+ ")\n",
+ "\n",
+ "UNIFIED_CSV_COLUMNS = [\n",
+ " \"source\", \"topology\", \"dims\", \"collective\", \"size_bytes\", \"count\",\n",
+ " \"dtype\", \"time_us\", \"algbw_GBps\", \"busbw_GBps\", \"wrong\",\n",
+ "]\n",
+ "_NUMERIC_COLUMNS = [\"size_bytes\", \"count\", \"time_us\", \"algbw_GBps\", \"busbw_GBps\"]\n",
+ "\n",
+ "def _has_unified_schema(path):\n",
+ " \"\"\"True if the CSV's header carries every unified column.\n",
+ "\n",
+ " Design: the submodule's data/ tree also holds other workloads' results\n",
+ " (e.g. data//nvlink_probe/results.csv from the fabric probes, with\n",
+ " its own 43-column schema); only the collective-benchmark CSVs belong\n",
+ " in `emp`, so files are selected by header rather than by path.\n",
+ " \"\"\"\n",
+ " with open(path, encoding=\"utf-8\") as fh:\n",
+ " header = fh.readline().strip().split(\",\")\n",
+ " return all(col in header for col in UNIFIED_CSV_COLUMNS)\n",
+ "\n",
+ "files = sorted(f for f in DATA_DIR.glob(\"**/*.csv\") if _has_unified_schema(f))\n",
+ "if files:\n",
+ " emp = pd.concat(\n",
+ " [pd.read_csv(f, dtype={\"wrong\": str}) for f in files],\n",
+ " ignore_index=True,\n",
+ " )\n",
+ " for col in _NUMERIC_COLUMNS:\n",
+ " emp[col] = pd.to_numeric(emp[col], errors=\"coerce\")\n",
+ "else:\n",
+ " # Design: keep the unified schema even when no files were found, so\n",
+ " # downstream cells can reference emp[...] columns inside their\n",
+ " # `if HAVE_EMPIRICAL:` guards without ever hitting a KeyError.\n",
+ " emp = pd.DataFrame(columns=UNIFIED_CSV_COLUMNS)\n",
+ "\n",
+ "HAVE_EMPIRICAL = len(files) > 0 and not emp.empty\n",
+ "\n",
+ "if not HAVE_EMPIRICAL:\n",
+ " print(\"no empirical data yet -- run aws-gpu-profiler run --workload nccl-tests --data-dir correlation/data; \"\n",
+ " \"overlay cells below will no-op\")\n",
+ "else:\n",
+ " inventory = emp.groupby([\"topology\", \"collective\"]).agg(\n",
+ " rows=(\"size_bytes\", \"count\"),\n",
+ " min_size=(\"size_bytes\", \"min\"),\n",
+ " max_size=(\"size_bytes\", \"max\"),\n",
+ " )\n",
+ " display(inventory)"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 17,
+ "id": "a2e080ce",
+ "metadata": {
+ "execution": {
+ "iopub.execute_input": "2026-09-17T16:44:50.271074Z",
+ "iopub.status.busy": "2026-09-17T16:44:50.270991Z",
+ "iopub.status.idle": "2026-09-17T16:44:50.444123Z",
+ "shell.execute_reply": "2026-09-17T16:44:50.443843Z"
+ }
+ },
+ "outputs": [
+ {
+ "data": {
+ "image/png": "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T9IEQRAEQRB8ECnSBEEQBEEQfBAp0gRBEARBEHwQKdIEQRAEQRB8ECnSBEEQBEEQfBAp0hoAycnJdOnShW7dutG/f//6DqdRkpqaSv/+/enYsSNnnXUWhYWF9R1So2HHjh1069bN8RMUFMQnn3xS32E1KqZPn06nTp3o2LEjDzzwgKEXRwue5cUXX6RTp0507tyZt99+u77DaRRcffXVREZGct1111WRL1myhPbt29OuXTtee+21WvmQIzgaAMnJyWzZsoXQ0ND6DqXRctFFF/H0009z4YUXkp2dTXh4OH5+jf6ta3VOQUEBycnJ7Nu3j5CQkPoOp1GQlZXFeeedx9atW/H396dv3768+OKLnH/++fUdWqPh999/JyUlhdWrV6OUon///ixdupQmTZrUd2gNmpUrV5Kfn8+CBQv48MMPAaioqKBjx4589913RERE0KNHD1avXk10dLRbPuRJmiDUkuN/nC688EIAoqKipECrJz777DMGDBggBVodU1FRQUlJCeXl5ZSXlxMXF1ffITUq/vjjD84//3wCAwMJCgqia9euLF26tL7DavD069ePsLCwKrJ169bRqVMnmjdvTmhoKIMHD+abb75x24cUafXMqlWrGDJkCAkJCWiaVu3XNLNmzSI5OZnAwEDOPfdc1q1bV6Vd0zQuuugizjnnHBYuXFhHkTccansPdu7cSWhoKEOGDOHss89mypQpdRj96Y8nxsBx3n//fW688UYvR9ywqG3+Y2NjefTRR2nZsiUJCQkMHDiQNm3a1GEPTn9qew86d+7MypUrOXbsGDk5OaxcuZJDhw7VYQ9OPzw575zI4cOHad68ueP35s2b1+peSJFWzxQWFtK1a1dmzZpVbfuiRYsYO3YsEydOZOPGjXTt2pVBgwaRmZnp0Pnxxx/ZsGEDn332GVOmTOG3336rq/AbBLW9BxUVFfzwww/897//Zc2aNSxbtoxly5bVZRdOazwxBqDyvXqrV6/m8ssvr4uwGwy1zX9OTg5Llixh7969HDp0iNWrV7Nq1aq67MJpT23vwfG1gBdffDHXXHMN5513HlartS67cNrhqXnH6yjBZwDUxx9/XEXWq1cvNXr0aMfvdrtdJSQkqKlTp1Zr49FHH1Xz5s3zYpQNG3fuwerVq9Wll17qaH/++efV888/XyfxNjRqMwbefPNNNXz48LoIs8Hi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9ttvWbZsGffddx8vvPAC33//Pf7+/gBkZ2cTEhIiBZrQ6JAiTRAaCAEBAdjt9iqyDh068NFHH6GUcjxN++mnnwgLC6NFixY1XvfTTz/Ru3dv7rvvPods9+7dpuLp0qULy5cvZ/Lkyae0tWnThoCAAH766SeSkpKAyqdF69evN3wcyNlnn82OHTto27Ztjf4PHjzIn3/+We3TtOr6XR29e/cmMTGRRYsW8dVXX3H99dc7ioeOHTtis9nYv38/F110kaG4ofIp1cCBAxk4cCATJ06kSZMmrFixotpCZtq0abz//vusXr2a6OjoKm2uclAd3bt3Z/bs2VU+E8f5+eef6du3LwAVFRVs2LCBMWPGONovv/xyQkJCmD17NkuXLmXVqlVVrvf39z8lp0ZiDAoKYsiQIQwZMoTRo0dz5pln8vvvv3P22WcDsGXLlhqfTApCQ0aKNEFoICQnJ7N27Vr27t1LaGgoUVFR3HfffcyYMYP777+fMWPGsGPHDiZOnMjYsWOxWCw1XteuXTvefPNNvv76a1q1asVbb73F+vXradWqleF4xo8fz1lnncV9993HP/7xDwICAvjuu++4/vrriYmJ4d577+X//u//iIqKomXLljz//PMUFRUZPhpiwoQJXHnllbRs2ZLrrrsOi8XC5s2b2bJlC08//TQXXXQRffv25dprr2XatGm0bduW7du3o2kal112GcnJyRQUFLB8+XK6du1KcHBwjU+FbrnlFubMmcOff/7Jd99955CHhYXx6KOP8vDDD6PrOhdccAG5ubn89NNPhIeHk5KScoqtJUuWsGfPHvr27UtkZCRffvkluq7Tvn37U3S//fZbHnvsMWbNmkVMTAzp6elAZVETERHhMgfV0b9/fwoKCti6dSudO3eu0jZr1izatWtHhw4dmD59Ojk5Odx+++2OdqvVysiRIxk/fjzt2rWr8vUoVH6Wli9fTp8+fbDZbERGRrqMcf78+djtds4991yCg4N5++23CQoKchTvAD/88AOXXnpptf0RhAZNfS+KEwTBM+zYsUOdd955KigoSAEqNTVVKaXUypUr1TnnnKMCAgJU06ZN1T//+U9VXl7u9LqSkhI1cuRIFRERoZo0aaLuvfdeNW7cONW1a1fHda42Dhz33bt3b2Wz2VSTJk3UoEGDHBsBiouL1f33369iYmKUzWZTffr0UevWrXNc62rjgFJKLV26VPXu3VsFBQWp8PBw1atXryo7A48ePapGjRqloqOjVWBgoOrcubNasmSJo/0f//iHio6OVoBjx+uJGweOs23bNgWopKSkKpswlFJK13U1Y8YM1b59e+Xv769iY2PVoEGD1Pfff19tTn744Qd10UUXqcjISBUUFKS6dOlSZdfsiRsHJk6cqIBTflJSUgznoDpuuOEGNW7cOMfvxzcOvPPOO6pXr14qICBAdezYUa1YseKUa3fv3q0AxwaPE/nss89U27ZtlZ+fX5XdmM5i/Pjjj9W5556rwsPDVUhIiDrvvPPUt99+67j24MGDyt/fXx04cMBpnwShIaIppVT9lIeCIAhCffDbb79xySWXsHv3bkJDQ01d+8MPPzBgwAAOHDhAfHy8lyL8m3/+85/k5OQwd+5cr/sSBF9DDrMVBEFoZHTp0oXnnnuO1NRUw9eUlpZy8OBBJk2axPXXX18nBRpAXFyc11+TJQi+ijxJEwRBEFwyf/587rjjDrp168Znn312ym5WQRA8jxRpgiAIgiAIPoh83SkIgiAIguCDSJEmCIIgCILgg0iRJgiCIAiC4INIkSYIgiAIguCDSJEmCIIgCILgg0iRJgiCIAiC4INIkSYIgiAIguCDSJEmCIIgCILgg0iRJgiCIAiC4IP8PxPmXamKIgg3AAAAAElFTkSuQmCC",
+ "text/plain": [
+ "
"
+ ]
+ },
+ "metadata": {},
+ "output_type": "display_data"
+ }
+ ],
+ "source": [
+ "fig, ax = plt.subplots(figsize=(7, 5))\n",
+ "\n",
+ "ax.loglog(sweep[\"collective_size_bytes\"], sweep[\"model_latency_s\"] * 1e6, \"o-\",\n",
+ " label=f\"FC model ({LINK_BW_GBPS:g} GB/s links)\")\n",
+ "ax.loglog(sweep_torus[\"collective_size_bytes\"], sweep_torus[\"model_latency_s\"] * 1e6, \"s-\",\n",
+ " label=\"torus model, 152 hops (bounding-box multicast)\")\n",
+ "ax.loglog(sweep_torus[\"collective_size_bytes\"], sweep_torus[\"minroute_latency_s\"] * 1e6, \"s--\",\n",
+ " label=\"torus min-route, 96 hops (dimension-ordered unicast)\")\n",
+ "\n",
+ "if HAVE_EMPIRICAL:\n",
+ " alltoall = emp[emp[\"collective\"] == \"alltoall\"]\n",
+ " fc_rows = alltoall[alltoall[\"topology\"] == \"fc\"]\n",
+ " torus_rows = alltoall[alltoall[\"topology\"] == \"torus\"]\n",
+ " if not fc_rows.empty:\n",
+ " # nccl-tests alltoall size_bytes is the PER-RANK message size (see\n",
+ " # markdown above); scale to total S to match `sweep`'s convention.\n",
+ " ax.scatter(fc_rows[\"size_bytes\"] * NODES, fc_rows[\"time_us\"],\n",
+ " marker=\"x\", color=\"tab:red\", label=\"EC2 nccl-tests alltoall (fc)\")\n",
+ " if not torus_rows.empty:\n",
+ " # torus_bench size_bytes is ALREADY total collective size S.\n",
+ " ax.scatter(torus_rows[\"size_bytes\"], torus_rows[\"time_us\"],\n",
+ " marker=\"+\", color=\"tab:green\", label=\"EC2 torus_bench alltoall (torus)\")\n",
+ "\n",
+ "ax.set_xlabel(\"total collective size (bytes)\")\n",
+ "ax.set_ylabel(\"all-to-all latency (µs)\")\n",
+ "ax.set_title(\"Fully-connected vs. 2x2x2 torus all-to-all: model vs. empirical\")\n",
+ "ax.grid(True, which=\"both\", alpha=0.3)\n",
+ "ax.legend()\n",
+ "plt.show()"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 18,
+ "id": "a8ec5d63",
+ "metadata": {
+ "execution": {
+ "iopub.execute_input": "2026-09-17T16:44:50.445869Z",
+ "iopub.status.busy": "2026-09-17T16:44:50.445786Z",
+ "iopub.status.idle": "2026-09-17T16:44:50.451384Z",
+ "shell.execute_reply": "2026-09-17T16:44:50.450987Z"
+ }
+ },
+ "outputs": [
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "FC calibration : alpha = 74.227 us, BW_eff = 339.48 GB/s, R^2 = 0.9996\n",
+ "torus calibration : fewer than 2 alltoall rows, skipping fit\n"
+ ]
+ }
+ ],
+ "source": [
+ "import numpy as np\n",
+ "\n",
+ "\n",
+ "def linear_fit(S: np.ndarray, t: np.ndarray) -> tuple[float, float, float]:\n",
+ " \"\"\"Ordinary-least-squares fit of ``t = intercept + slope * S``.\n",
+ "\n",
+ " Parameters\n",
+ " ----------\n",
+ " S : np.ndarray\n",
+ " Total collective size in bytes for each empirical sample.\n",
+ " t : np.ndarray\n",
+ " Measured latency in seconds for each empirical sample. Must be\n",
+ " the same length as `S`.\n",
+ "\n",
+ " Returns\n",
+ " -------\n",
+ " intercept : float\n",
+ " Fitted per-operation latency overhead (the model's alpha), seconds.\n",
+ " slope : float\n",
+ " Fitted dt/dS, seconds/byte.\n",
+ " r_squared : float\n",
+ " Coefficient of determination of the linear fit; NaN if `t` has\n",
+ " zero total variance (R^2 is undefined in that degenerate case).\n",
+ " \"\"\"\n",
+ " slope, intercept = np.polyfit(S, t, 1)\n",
+ " t_pred = slope * S + intercept\n",
+ " ss_res = float(np.sum((t - t_pred) ** 2))\n",
+ " ss_tot = float(np.sum((t - np.mean(t)) ** 2))\n",
+ " r_squared = 1.0 - ss_res / ss_tot if ss_tot > 0 else float(\"nan\")\n",
+ " return float(intercept), float(slope), r_squared\n",
+ "\n",
+ "\n",
+ "def bw_from_slope(slope: float, slope_factor: float) -> float:\n",
+ " \"\"\"Translate a fitted t-vs-S slope into an effective bandwidth, bytes/s.\n",
+ "\n",
+ " Given the model form ``t = alpha + S * slope_factor / BW``, the\n",
+ " fitted slope is ``dt/dS = slope_factor / BW``, so\n",
+ " ``BW = slope_factor / slope``.\n",
+ " \"\"\"\n",
+ " return slope_factor / slope\n",
+ "\n",
+ "\n",
+ "if HAVE_EMPIRICAL and (emp[\"collective\"] == \"alltoall\").any():\n",
+ " alltoall = emp[emp[\"collective\"] == \"alltoall\"].dropna(\n",
+ " subset=[\"size_bytes\", \"time_us\"]\n",
+ " )\n",
+ "\n",
+ " fc_rows = alltoall[alltoall[\"topology\"] == \"fc\"]\n",
+ " if len(fc_rows) >= 2:\n",
+ " # fc slope_factor = (N-1)/N^2 = 7/64 (N=8), from the closed form\n",
+ " # in the \"Size and latency conventions\" cell above.\n",
+ " S_fc = fc_rows[\"size_bytes\"].to_numpy(dtype=float) * NODES\n",
+ " t_fc = fc_rows[\"time_us\"].to_numpy(dtype=float) * 1e-6\n",
+ " alpha_fc, slope_fc, r2_fc = linear_fit(S_fc, t_fc)\n",
+ " bw_fc = bw_from_slope(slope_fc, 7 / 64)\n",
+ " print(f\"FC calibration : alpha = {alpha_fc * 1e6:.3f} us, \"\n",
+ " f\"BW_eff = {bw_fc / 1e9:.2f} GB/s, R^2 = {r2_fc:.4f}\")\n",
+ " else:\n",
+ " print(\"FC calibration : fewer than 2 alltoall rows, skipping fit\")\n",
+ "\n",
+ " torus_rows = alltoall[alltoall[\"topology\"] == \"torus\"]\n",
+ " if len(torus_rows) >= 2:\n",
+ " # torus slope_factor = hops/(64*24) = hops/1536; report BW under\n",
+ " # both routing hypotheses (h=152 bounding-box, h=96 min-route)\n",
+ " # from a single fit, since alpha/R^2 don't depend on slope_factor\n",
+ " # -- only the slope-to-BW translation does.\n",
+ " S_t = torus_rows[\"size_bytes\"].to_numpy(dtype=float)\n",
+ " t_t = torus_rows[\"time_us\"].to_numpy(dtype=float) * 1e-6\n",
+ " alpha_t, slope_t, r2_t = linear_fit(S_t, t_t)\n",
+ " bw_t_152 = bw_from_slope(slope_t, 152 / 1536)\n",
+ " bw_t_96 = bw_from_slope(slope_t, 96 / 1536)\n",
+ " print(f\"torus calibration : alpha = {alpha_t * 1e6:.3f} us, R^2 = {r2_t:.4f}\")\n",
+ " print(f\" BW_eff @ h=152 (bounding-box model): {bw_t_152 / 1e9:.2f} GB/s\")\n",
+ " print(f\" BW_eff @ h=96 (dimension-ordered) : {bw_t_96 / 1e9:.2f} GB/s\")\n",
+ " else:\n",
+ " print(\"torus calibration : fewer than 2 alltoall rows, skipping fit\")\n",
+ "else:\n",
+ " print(\"calibration: no empirical alltoall rows yet\")"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "2325bd49",
+ "metadata": {},
+ "source": [
+ "## Linear fit: ISL network model vs. profiled GPUs (all-scatter, fully connected)\n",
+ "\n",
+ "Each nccl-tests `alltoall` row from the FC leg gives a measured latency $t_{meas}$ at per-rank size `size_bytes`, i.e. total $S = N \\cdot$ `size_bytes`. The ISL model at nominal H100 NVLink bandwidth predicts $t_{model}(S) = S(N-1)/(N^2 \\cdot BW_{nom})$ with $\\alpha = 0$. The fit below is ordinary least squares of\n",
+ "\n",
+ "$$ t_{meas} = a + b \\cdot t_{model} $$\n",
+ "\n",
+ "so $a$ is the per-operation latency overhead $\\alpha$ that the flat bandwidth model lacks, and $b = BW_{nom}/BW_{eff}$ is the ratio of nominal to achieved port bandwidth; equivalently $t = \\alpha + S(N-1)/(N^2 \\cdot BW_{eff})$ with $BW_{eff} = BW_{nom}/b$. OLS in linear space is dominated by the largest sizes, so the per-size table also reports measured/calibrated to show where a single straight line stops describing the data (NCCL's protocol switches at small sizes are the usual culprit). Rows whose $S$ equals a `MATMUL_SHAPES` operand are labelled with that shape.\n",
+ "\n",
+ "Data: `correlation/data//...` (versioned in the `aws-gpu-profiler` submodule), produced from this notebook's directory by\n",
+ "`aws-gpu-profiler run --workload nccl-tests --param collective=alltoall --param min_bytes=8192 --param max_bytes=1073741824 --data-dir correlation/data`. With no data present the cells below print a notice and draw nothing."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 19,
+ "id": "0714f2dd",
+ "metadata": {
+ "execution": {
+ "iopub.execute_input": "2026-09-17T16:44:50.453751Z",
+ "iopub.status.busy": "2026-09-17T16:44:50.453585Z",
+ "iopub.status.idle": "2026-09-17T16:44:50.463289Z",
+ "shell.execute_reply": "2026-09-17T16:44:50.462245Z"
+ }
+ },
+ "outputs": [
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "linear fit t_meas = a + b * t_model (18 FC alltoall points)\n",
+ " a = 74.23 us b = 1.3256 R^2 = 0.9996\n",
+ " => alpha = 74.23 us, BW_eff = 339.5 GB/s (75.4% of 450 GB/s nominal)\n",
+ " => t = alpha + S * (N-1) / (N^2 * BW_eff)\n"
+ ]
+ },
+ {
+ "data": {
+ "text/html": [
+ "