# Copyright (c) Max-Planck-Gesellschaft zur Förderung der Wissenschaften e.V.
# Developed at the Max Planck Institute for Informatics, Network and Cloud Systems Group
#
# Author: Yiming Lei (ylei@mpi-inf.mpg.de)
#
# This software is licensed for non-commercial scientific research purposes only.
#
# License text: Creative Commons NC BY SA 4.0
# https://creativecommons.org/licenses/by-nc-sa/4.0/deed.en
import networkx as nx
import math
import numpy as np
import itertools
import random
"""
Circuit:
[time_slice, node1, node2, port1, port2]
"""
[docs]
def static_topo(nb_node, nb_link=1):
"""
Static topology generated from a random regular graph.
Args:
nb_node: Number of nodes
nb_link: Number of links
Returns:
A list of circuits, with only one time slice,
of a random regular graph on nodes which is an expander graph with very good probability.
"""
# g = nx.maybe_regular_expander_graph(n=nb_node, d=nb_link) # 3.6
g = nx.random_regular_graph(n=nb_node, d=nb_link, seed=1)
# Figure out which port is used to connect other nodes
port_id_tracker = [0] * nb_node
circuits = []
for edge in g.edges():
node1, node2 = edge
port1, port2 = port_id_tracker[node1], port_id_tracker[node2]
circuits.append([0, node1, node2, port1, port2])
# Update the next available port.
port_id_tracker[node1] += 1
port_id_tracker[node2] += 1
assert all(port_id == nb_link for port_id in port_id_tracker)
return circuits
[docs]
def round_robin(nb_node=None, nodes=None, port1=0, port2=0,
start_time_slice = 0, self_loop=False, guardband=False) -> list:
"""
Create a round-robin topology with the circle method. Assume one upper link per node.
Args:
nb_node: Number of nodes
nodes: If not specified, create node list based on the number of nodes.
Otherwise create round-robin within given nodes.
port1: The port src node uses to connect. 0 by default
port2: The port dst node uses to connect. 0 by default
start_time_slice: The time slice to start the topology schedule. 0 by default.
self_loop: Whether to add loop-back time slice. Used for building complex topology.
guardband: Insert guardband time slices where ports change connection.
Returns:
A list of circuits.
Circuit: [time_slice, node1, node2, port1, port2]
"""
if nodes is None:
assert nb_node is not None, "Need either nb_node or node"
nodes = list(range(nb_node))
else:
nb_node = len(nodes)
if isinstance(nodes, np.ndarray):
nodes = nodes.tolist()
# assert nb_node % 2 == 0, "Round-robin needs number of nodes to be even."
if nb_node % 2 == 1:
nb_node = nb_node + 1
nodes.append(-1) # -1 indicate dummy node
circuits = []
for slice_id in range(nb_node - 1):
for i in range(nb_node // 2):
if nodes[i] == -1 or nodes[-i - 1] == -1:
# does not connect dummy node
continue
circuits.append([start_time_slice + slice_id, nodes[i], nodes[-i - 1], port1, port2])
nodes.insert(1, nodes.pop(-1))
# Add a loop-back time slice for being the building block of more complex topology
if self_loop:
for node_id in range(nb_node):
circuits.append([start_time_slice + nb_node - 1, nodes[node_id], nodes[node_id], port1, port2])
if guardband:
circuits = add_guardband(circuits)
return circuits
[docs]
def opera(nb_node, nb_link, nodes=None, disable_last_ts=False, guardband=False):
"""
Opera topology support multiple upper links per node.
In (nb_node / nb_link) time slices, each link of every node connects (nb_node / nb_link) number of nodes
We connect ports with the same index together.
Args:
nb_node: Number of nodes
nb_link: Number of links per node
nodes: List of nodes (optional)
disable_last_ts: Remove the last active time slice per port
guardband: Insert guardband time slices where ports change connection.
Returns:
List of circuits for the opera topology
"""
# First we generate a basic round robin that each node connects every other node.
base_circuits = round_robin(nb_node, nodes, self_loop=True)
# e.g. 4 nodes, 2 links
# slice0: 0(p0) <-> 3(p0), 1(p0) <-> 2(p0)
# slice1: 0(p0) <-> 2(p0), 1(p0) <-> 3(p0)
# slice2: 0(p0) <-> 1(p0), 2(p0) <-> 3(p0)
# slice3: self loop
# Randomize topo by shuffling topologies to different time slices
topo_randomize_ts(base_circuits)
# To connect all nodes in nb_node / nb_link time slice, we merge time slices, as well as connections,
# with the ratio of nb_link. The connections in (nb_link) time slices are achieved by nb_link links at one time slice.
# With two upper links, we map old_ts to new_ts by (2n -> n), (2n+1 -> n)
# With three upper links, we map old_ts to new_ts by (3n -> n), (3n+1 -> n), (3n+2 -> n)
merged_circuit = []
for ts, node1, node2, port1, port2 in base_circuits:
port_id = ts % nb_link
merged_circuit.append([ts // nb_link, node1, node2, port_id, port_id])
# e.g. 4 nodes, 2 links
# slice0: 0(p0) <-> 3(p0), 0(p1) <-> 2(p1), 1(p0) <-> 2(p0), 1(p1) <-> 3(p1)
# slice1: 0(p0) <-> 1(p0), 0&1 (p1) loop, 2(p0) <-> 3(p0), 2&3 (p1) loop
offset_circuits = port_offset(merged_circuit, disable_last_ts)
# offset_circuits = merged_circuit
if guardband:
offset_circuits = add_guardband(offset_circuits)
return offset_circuits
[docs]
def shale(nb_node, h, nodes=None, guardband=False):
"""
Create a Shale topology.
We assume num of links == h, so rr in different dimension doesn't influence each other.
Args:
nb_node: Number of nodes
h: Number of dimensions
nodes: List of nodes (optional)
guardband: Insert guardband time slices where ports change connection.
Returns:
List of circuits for the shale topology
"""
if nodes is None:
nodes = list(range(nb_node))
else:
nb_node = len(nodes)
root = int(math.pow(nb_node, 1 / h))
assert root**h == nb_node, "number of nodes need to be the power of h"
# Reshape nodes into an h-dimensional cube
nodes = np.array(nodes).reshape([root] * h)
circuits = []
for pos in range(h):
for base_indices in itertools.product(range(root), repeat=h - 1):
# base indices are (0,0), (0,1), (1,0), (1,1)
indices = base_indices[:pos] + (slice(None),) + base_indices[pos:]
# indices are (:,0,0), (:,0,1), (:,1,0), (:,1,1), (0,:,0), (0,:,1), (1,:,0)....
circuits.extend(round_robin(nodes=nodes[indices], port1=pos, port2=pos))
if guardband:
circuits = add_guardband(circuits)
return circuits
[docs]
def bipartite_matching(
nb_node, nb_link, traffic_matrix: dict, prev_circuits=None
) -> list:
"""
Create a bipartite matching topology based on traffic matrix.
Args:
nb_node: Number of nodes
nb_link: Number of links
traffic_matrix: Dictionary with traffic information between nodes
Returns:
List of circuits for the bipartite matching topology
Raises:
AssertionError: If nb_link is not 1 (only supports one link)
"""
assert nb_link == 1, "bipartite_matching supports one link only"
# print(f"metric: {traffic_matrix}", flush=True)
# If no traffic, keep the previous topology
# print(traffic_matrix)
if all(v == 0 for v in traffic_matrix.values()):
return prev_circuits
edges = []
for node1 in range(nb_node):
for node2 in range(node1 + 1, nb_node):
# Aggragate weights for bi-directional traffic
weight_1 = traffic_matrix.get((node1, node2), 0) # fill 0 if no traffic
weight_2 = traffic_matrix.get((node2, node1), 0)
edges.append((node1, node2, weight_1 + weight_2))
# print(f"metric edges: {edges}")
g = nx.Graph()
g.add_weighted_edges_from(edges)
matching = nx.max_weight_matching(g, maxcardinality=True)
circuits = []
for src, dst in matching:
circuits.append([0, src, dst, 0, 0])
# print(f"new circuits: {circuits}")
return circuits
def from_schedule(schedule_file, nb_node, nb_link):
"""
Load a schedule matrix from a text file and convert it to circuits.
The schedule is a (nb_time_slices x nb_node*nb_link) matrix where
schedule[slice][tor*nb_link + port] = destination_tor (-1 if no connection).
Args:
schedule_file: Path to the schedule text file (tab/space-separated integers).
nb_node: Number of ToR nodes.
nb_link: Number of uplink ports per node.
Returns:
List of circuits [time_slice, node1, node2, port1, port2].
"""
schedule = np.loadtxt(schedule_file, dtype=int)
nb_time_slices = schedule.shape[0]
circuits = []
seen = set()
for ts in range(nb_time_slices):
for src in range(nb_node):
for port in range(nb_link):
dst = int(schedule[ts, src * nb_link + port])
if dst == -1:
continue
if dst == src:
# Self-loop: use (ts, node, port) as key to avoid duplicates
edge_key = (ts, src, port, "self")
if edge_key not in seen:
seen.add(edge_key)
circuits.append([ts, src, src, port, port])
continue
# Avoid adding the same bidirectional edge twice
edge_key = (ts, min(src, dst), max(src, dst), port)
if edge_key in seen:
continue
# Find dst's port that connects back to src
dst_port = -1
for p in range(nb_link):
if int(schedule[ts, dst * nb_link + p]) == src:
dst_port = p
break
if dst_port == -1:
continue
seen.add(edge_key)
seen.add((ts, min(src, dst), max(src, dst), dst_port))
circuits.append([ts, src, dst, port, dst_port])
return circuits
def add_guardband(circuits):
"""
Insert guardband time slices into a circuit list.
Whenever one or more ports change their connection between consecutive
time slices, a guardband slice is inserted where the affected ports have
no circuit. Ports that do not change keep their connection from the
preceding active slice. The last-to-first slice transition is also
considered (wrap-around).
Args:
circuits: List of circuits [time_slice, node1, node2, port1, port2]
Returns:
New list of circuits with guardband slices inserted.
"""
from collections import defaultdict
ts_circuits = defaultdict(list)
for ts, n1, n2, p1, p2 in circuits:
ts_circuits[ts].append((n1, n2, p1, p2))
time_slices = sorted(ts_circuits.keys())
if len(time_slices) <= 1:
return list(circuits)
def get_connections(ts):
conn = {}
for n1, n2, p1, p2 in ts_circuits[ts]:
conn[(n1, p1)] = n2
conn[(n2, p2)] = n1
return conn
new_circuits = []
new_ts = 0
for i, ts in enumerate(time_slices):
# Active slice
for n1, n2, p1, p2 in ts_circuits[ts]:
new_circuits.append([new_ts, n1, n2, p1, p2])
# Check which ports change between this slice and the next
next_ts = time_slices[(i + 1) % len(time_slices)]
curr_conn = get_connections(ts)
next_conn = get_connections(next_ts)
all_ports = set(curr_conn.keys()) | set(next_conn.keys())
changing_ports = {p for p in all_ports if curr_conn.get(p) != next_conn.get(p)}
if changing_ports:
new_ts += 1
guardband_has_circuits = False
for n1, n2, p1, p2 in ts_circuits[ts]:
if (n1, p1) not in changing_ports and (n2, p2) not in changing_ports:
new_circuits.append([new_ts, n1, n2, p1, p2])
guardband_has_circuits = True
if not guardband_has_circuits:
# Empty guardband (all ports changing) — emit placeholder
new_circuits.append([new_ts, -1, -1, -1, -1])
new_ts += 1
return new_circuits
##########################
# Helper functions #
##########################
[docs]
def topo_randomize_ts(circuits: list):
"""
Randomize connection order (time_slice -> connections mapping) for circuits.
Args:
circuits: List of circuits to randomize
Returns:
List of randomized circuits
"""
circuits.sort(key=lambda x: x[4])
# print(circuits)
ts_set = set()
for ts, node1, node2, port1, port2 in circuits:
ts_set.add(ts)
time_slices = sorted(list(ts_set))
shuffled = time_slices.copy()
random.shuffle(shuffled)
shuffled_circuits = []
for ts, node1, node2, port1, port2 in circuits:
shuffled_circuits.append([shuffled[ts], node1, node2, port1, port2])
# Sort based on time slice
shuffled_circuits.sort(key=lambda x: x[0])
# print(shuffled_circuits)
return shuffled_circuits
[docs]
def port_offset(circuits: list, disable_last_ts=False):
"""
Helper function to transform the circuits to reconfigure topology one port per time slice.
New nb_time_slice = old nb_time_slice * nb_links
Args:
circuits: List of circuits to transform
Returns:
List of transformed circuits with port offset
"""
nb_time_slice = get_nb_time_slice_from_circuits(circuits)
nb_links = get_nb_links_from_circuits(circuits)
offset_circuits = []
for ts, node1, node2, port1, port2 in circuits:
assert port1 == port2, (
"To enable port offset, port id should be the same for both side."
)
new_ts_start = ts * nb_links + port1
new_ts_end = (ts + 1) * nb_links + port1
if disable_last_ts:
new_ts_end -= 1
for new_ts in range(new_ts_start, new_ts_end):
offset_circuits.append(
[new_ts % (nb_time_slice * nb_links), node1, node2, port1, port2]
)
return offset_circuits
[docs]
def get_nb_time_slice_from_circuits(circuits: list):
"""
Helper function to get the number of time slices from circuits.
Args:
circuits: A list of circuits
Returns:
The number of time slices
"""
max_ts = 0
for ts, node1, node2, port1, port2 in circuits:
if ts > max_ts:
max_ts = ts
return max_ts + 1
[docs]
def get_nb_links_from_circuits(circuits: list):
"""
Helper function to get the number of links from circuits.
Args:
circuits: A list of circuits
Returns:
The number of links
"""
max_port = 0
for ts, node1, node2, port1, port2 in circuits:
if port1 > max_port:
max_port = port1
if port2 > max_port:
max_port = port2
return max_port + 1
[docs]
def draw_topo(slice_to_topo):
"""
Draw the topology using matplotlib in a style that matches the dashboard
card UI (indigo accent, slate text/edges, no axes, system-ish font).
Args:
slice_to_topo: Dictionary mapping time slices to topology graphs
Returns:
matplotlib figure object
"""
import matplotlib.pyplot as plt
# Palette pulled from the dashboard CSS so the topology image reads as
# part of the same UI, not a separate matplotlib surface.
CARD_BG = "#ffffff"
NODE_FILL = "#1e3a8a" # blue-900 — related to the sidebar navy but
# distinctly blue rather than slate.
NODE_BORDER = "#1e40af" # blue-800
EDGE_COLOR = "#94a3b8" # slate-400
LABEL_COLOR = "#ffffff"
TITLE_COLOR = "#64748b" # --text-muted
# Matplotlib renders the PNG server-side; don't try to pick up the
# browser's system-font stack — it won't find those and spews warnings
# per text element. Stick with matplotlib's always-available default.
FONT_FAMILY = "DejaVu Sans"
nb_time_slices = len(slice_to_topo)
pos = nx.circular_layout(sorted(slice_to_topo[0].nodes))
xs = [p[0] for p in pos.values()]
ys = [p[1] for p in pos.values()]
pad = 0.25
xlim = (min(xs) - pad, max(xs) + pad)
ylim = (min(ys) - pad, max(ys) + pad)
fig, axs = plt.subplots(
1, nb_time_slices,
figsize=(3.4 * nb_time_slices, 3.4),
constrained_layout=True,
)
fig.patch.set_facecolor(CARD_BG)
if nb_time_slices == 1:
axs = [axs]
node_size = 1100 # passed to both nodes and edges so arrow heads stop
# at the actual node boundary, not the default 300.
for time_slice, ax in enumerate(axs):
g = slice_to_topo[time_slice]
# Self-loops render as tiny arcs occluded by the node fill, so skip
# them in the image. They remain in the graph for routing/telemetry.
edges = [(u, v) for u, v in g.edges() if u != v]
ax.set_facecolor(CARD_BG)
nx.draw_networkx_edges(
g,
pos=pos, ax=ax,
edgelist=edges,
node_size=node_size,
edge_color=EDGE_COLOR,
width=2.0,
arrowsize=22,
arrowstyle="-|>",
alpha=0.9,
)
nx.draw_networkx_nodes(
g,
pos=pos, ax=ax,
node_color=NODE_FILL,
edgecolors=NODE_BORDER,
linewidths=1.5,
node_size=node_size,
)
nx.draw_networkx_labels(
g,
pos=pos, ax=ax,
font_color=LABEL_COLOR,
font_family=FONT_FAMILY,
font_size=13,
font_weight="semibold",
)
ax.set_xlim(*xlim)
ax.set_ylim(*ylim)
ax.set_aspect("equal")
ax.set_axis_off()
ax.set_title(
f"time slice {time_slice}",
color=TITLE_COLOR,
fontdict={"family": FONT_FAMILY, "size": 10, "weight": "semibold"},
pad=6,
)
return fig