Source code for openoptics.OpticalRouting

# 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 json
import heapq
import networkx as nx
from typing import List, Dict, Optional
import queue
import copy
import logging
import warnings

from openoptics.TimeFlowTable import Path, Step

# Tool funcs


[docs] def find_send_port(topo: nx.Graph, src, dst): # To-do: move to OpticalTopo """ Helper function to find the send port given the src and dst nodes in the topo. Args: topo: Network topology graph src: Source node dst: Destination node Returns: The send port of src """ if not topo.has_edge(src, dst): return None return topo[src][dst].get("port1")
[docs] def find_direct_path( slice_to_topo: Dict[int, nx.Graph], node1: int, node2: int ) -> List[Path]: """ Helper function to find the direct paths between two nodes for all time slices. Can be used by direct routing and VLB. Args: slice_to_topo: Dictionary mapping time slices to topology graphs node1: Source node node2: Destination node Returns: List of paths between the two nodes """ if node1 == node2: print("src and dst must be different.") return [] paths = [] slices = sorted(slice_to_topo.keys()) # Find the first direct connection. Construct paths from the next slice. start_ts = None for ts in slices: cur_topo = slice_to_topo[ts] if cur_topo.has_edge(node1, node2): start_ts = (ts + 1) % len(slices) if start_ts is None: return [] search_order = slices[start_ts:] + slices[:start_ts] # Save the pending arrival time slice, generate path when a path is found arrival_time_slices = [] for ts in search_order: arrival_time_slices.append(ts) cur_topo = slice_to_topo[ts] if cur_topo.has_edge(node1, node2): send_port = find_send_port(cur_topo, node1, node2) for arrival_ts in arrival_time_slices: paths.append( Path( src=node1, arrival_ts=arrival_ts, dst=node2, steps=[ Step( cur_node=node1, send_port=send_port, send_node=node2, send_ts=ts, ) ], ) ) arrival_time_slices = [] return paths
[docs] def find_n_hop_path_node_pair(slice_to_topo: Dict[int, nx.Graph], src, dst, max_hop): """ Helper function to find the path between src and dst with the max hop of max_hop. Used by hoho and ucmp routing. Args: slice_to_topo: Dictionary mapping time slices to topology graphs src: Source node dst: Destination node max_hop: Maximum number of hops allowed Returns: List of paths between source and destination with maximum hops constraint """ # Set up logging logger = logging.getLogger(__name__) """ logger.setLevel(logging.DEBUG) handler = logging.StreamHandler() handler.setLevel(logging.DEBUG) logger.addHandler(handler) """ all_feasible_paths = [] nb_ts = len(slice_to_topo.keys()) #dst_arrival_ts = nb_ts - 1 # To-do: find paths from different dst_arrival_ts for dst_arrival_ts in range(nb_ts): paths = [] path_buffer = queue.Queue() path_buffer.put( Path( src=src, arrival_ts=dst_arrival_ts, dst=dst, steps=[Step(dst, send_ts=dst_arrival_ts)], ) ) while not path_buffer.empty(): # print(f"Current queue {[ele for ele in list(path_buffer.queue)]}") cur_path = path_buffer.get() cur_node = cur_path.steps[0].cur_node cur_search_ts = cur_path.arrival_ts try: shortest_path = nx.shortest_path( slice_to_topo[cur_search_ts], source=src, target=cur_node ) #print(f"Find shortest_path {shortest_path}") cur_path.arrival_ts = cur_search_ts - 1 if len(shortest_path) - 1 + len(cur_path.steps) - 1 <= max_hop: # We have found a path found_path = copy.deepcopy(cur_path) found_path.arrival_ts = cur_search_ts shortest_path.reverse() # dst, ..., src for next_node, cur_node in zip(shortest_path[:-1], shortest_path[1:]): found_path.steps.insert( 0, Step( cur_node=cur_node, send_port=find_send_port( slice_to_topo[cur_search_ts], cur_node, next_node ), send_ts=cur_search_ts, send_node=next_node, ), ) # Remove the dst hop found_path.steps = found_path.steps[:-1] #print(f"Find a path! {found_path}") paths.append(found_path) continue # When we find a valid path, we don't need to continue search else: logger.debug(f"The shortest path reach the max hop {max_hop}.") except nx.NetworkXNoPath: logger.debug( f"No path found for slice{cur_search_ts} between node{src} and node{cur_node}!" ) # The packet hop-off on this node and wait for one time slice. hop_off_path = copy.deepcopy(cur_path) hop_off_path.arrival_ts = (cur_search_ts - 1 + nb_ts) % nb_ts if hop_off_path.arrival_ts != dst_arrival_ts: # print(f"Add wait path {cross_slice_path}") path_buffer.put(hop_off_path) else: logger.debug( f"Try adding wait path. But have searched over one cycle. Drop path {cur_path}" ) # We haven't found a valid path to src. Add neibours in the path. cur_path.arrival_ts = (cur_search_ts - 1 + nb_ts) % nb_ts # print(f"No path found at time slice {cur_search_ts}. Add neighbors to the intermidiate paths.") if len(cur_path.steps) >= max_hop: logger.debug( f"Reach max hop {max_hop}. Cannot search neighbors. Drop path {cur_path}" ) elif cur_path.arrival_ts == dst_arrival_ts: logger.debug(f"Have searched over one cycle. Drop path {cur_path}") else: neighbors = slice_to_topo[cur_search_ts].neighbors(cur_node) for neighbor in neighbors: if neighbor != dst or neighbor not in [ step.cur_node for step in cur_path.steps ]: # Skip visited nodes to avoid loop candidate_path = copy.deepcopy(cur_path) candidate_path.steps.insert( 0, Step( cur_node=neighbor, send_port=find_send_port( slice_to_topo[cur_search_ts], neighbor, cur_node ), send_ts=cur_search_ts, send_node=cur_node, ), ) # print(f"Add neighbor {neighbor}. New path: {candidate_path}") path_buffer.put(candidate_path) else: logger.debug( f"Node {neighbor} has been visited in the path {cur_path}Skip." ) if len(paths) == 0: raise Exception(f"No path found between node{src} and node{dst}") paths = list(dict.fromkeys(paths)) #print(f"Paths from {src} to {dst}: {paths}") # paths we get have discrete arrival time slice, we need to generate paths for all time slice all_feasible_paths.extend(paths) # Remove multi-path #print(f"All feasible paths: {all_feasible_paths}") optimal_paths = remove_suboptimal_paths(all_feasible_paths, nb_ts) #print(f"{optimal_paths=}") paths_for_all_arrival_time_slices = extend_paths_to_all_time_slice(optimal_paths, nb_ts) #print(f"Paths for all available time slices: {paths_for_all_arrival_time_slices}") return paths_for_all_arrival_time_slices
def remove_suboptimal_paths(paths: List[Path], nb_ts: int): """ Remove suboptimal paths for given a feasiable paths from a src to a dst. First, for each arrival_ts, keep only the Path whose last step has the earliest send_ts. Second, removed path A if A's arrival ts < B's arrival ts, and A's last send ts < B's send ts. Args: paths: A list of paths with the same src and dst nb_ts: Number of total time slices Returns: A list of paths with suboptimal paths removed """ assert [path.src == paths[0].src for path in paths], "Src node of all paths must be the same" assert [path.dst == paths[0].dst for path in paths], "Dst node of all paths must be the same" earliest_paths: Dict[int, Path] = {} for path in paths: if path.arrival_ts not in earliest_paths.keys(): earliest_paths[path.arrival_ts] = path else: start_ts = path.arrival_ts old_ts = earliest_paths[path.arrival_ts].steps[-1].send_ts old_path_duration = (old_ts + nb_ts - start_ts) % nb_ts new_ts = path.steps[-1].send_ts new_path_duration = (new_ts + nb_ts - start_ts) % nb_ts if old_path_duration > new_path_duration: earliest_paths[path.arrival_ts] = path elif old_path_duration == new_path_duration: # If path duration is the same, choose the path with less hops if len(earliest_paths[path.arrival_ts].steps) > len(path.steps): earliest_paths[path.arrival_ts] = path return list(earliest_paths.values())
[docs] def extend_paths_to_all_time_slice(paths: List[Path], nb_ts: int): """ Helper function used by opera and hoho routing. These routings do not provide paths for all arrival time slices. This function fills the missing ones by letting packets wait for the nearest available path. Args: paths: A list of paths whose arrive time slices may be not continuous nb_ts: Number of total time slices Returns: The list of paths whose arrive time slices cover all time slices Raises: AssertionError: If no paths are provided """ assert len(paths) > 0, "No paths to extend" extended_paths = [] # Make sure path is ordered by arrival time slice paths.sort(key=lambda path: path.arrival_ts) src = paths[-1].src dst = paths[-1].dst start_ts = paths[-1].arrival_ts assert start_ts < nb_ts, f"Invalid time slice {start_ts}" # if there is the last path at slice 4, start filling paths from slice 5 start_ts = (start_ts + 1) % nb_ts search_order = list(range(start_ts, nb_ts)) + list(range(start_ts)) # Start adding paths based on the earliest (closest to time slice 0) path cur_path_id = 0 for ts in search_order: if ts != paths[cur_path_id].arrival_ts: cur_path = copy.deepcopy(paths[cur_path_id]) cur_path.arrival_ts = ts extended_paths.append(cur_path) if ts == paths[cur_path_id].arrival_ts: extended_paths.append(paths[cur_path_id]) cur_path_id += 1 return extended_paths
[docs] def routing_direct(slice_to_topo: Dict[int, nx.Graph]) -> List[Path]: """ Direct routing. Args: slice_to_topo: Topology for each time slice Returns: A list of paths for direct routing """ paths = [] nodes = slice_to_topo[0].nodes() for node1 in nodes: for node2 in nodes: if node1 == node2: continue paths.extend(find_direct_path(slice_to_topo, node1, node2)) return paths
def _dijkstra_to_dst(slice_to_topo: Dict[int, nx.Graph], dst, max_hop): """ Backward 3D-state Dijkstra on the time-expanded schedule graph, from a fixed destination. For every ``(src_tor, cur_slice, h)`` state it finds the shortest-wait plan to ``dst`` using at most ``h`` transmit edges. Optimal substructure does **not** hold across h levels: at the same ``(T, C)``, two different h values can pick different next-hops (both optimal at that h, but disagreeing in physical path). See ``_dijkstra_to_dst_unbounded`` for the substructure-respecting variant used when ``routing_hoho(max_hop=None)``. Time-expanded graph ------------------- States: ``(T, C, h)`` where T is a tor, C is a cur_slice, and h is the number of transmit edges still available (0 ≤ h ≤ max_hop). Forward edges: * wait: ``(T, C, h) -> (T, (C+1) % nb_ts, h)`` with cost 1 * transmit: ``(T, C, h) -> (T', C, h-1)`` with cost 0, iff ``slice_to_topo[C]`` has an edge (T, T') and h > 0 Goal states: any ``(dst, *, *)``. Returns ------- parent : dict ``state -> (next_state, edge_type, port_or_None)``. For each non-goal state this points at the first forward edge along the shortest path. ``edge_type`` is ``"wait"`` or ``"tx"``; ``port`` is only set for transmit edges. Goal states map to ``None``. dist : dict ``state -> shortest forward duration to dst`` (in slices). """ nb_ts = len(slice_to_topo) any_topo = next(iter(slice_to_topo.values())) nodes = set(any_topo.nodes()) is_directed = any_topo.is_directed() if hasattr(any_topo, "is_directed") else False INF = float("inf") dist: Dict[tuple, float] = {} parent: Dict[tuple, tuple] = {} pq: List[tuple] = [] # Initial goal states: already at dst, any slice, any hop budget. for c in range(nb_ts): for h in range(max_hop + 1): state = (dst, c, h) dist[state] = 0 parent[state] = None heapq.heappush(pq, (0, state)) while pq: d, state = heapq.heappop(pq) if d > dist.get(state, INF): continue T, C, h = state # Relax reverse-wait: forward (T, (C-1) % nb_ts, h) --wait--> (T, C, h) prev_C = (C - 1 + nb_ts) % nb_ts ns = (T, prev_C, h) nd = d + 1 if nd < dist.get(ns, INF): dist[ns] = nd parent[ns] = (state, "wait", None) heapq.heappush(pq, (nd, ns)) # Relax reverse-transmit: forward (T', C, h+1) --tx--> (T, C, h) # iff edge (T', T) exists at slot C. The forward transmit from # (T', C, h+1) uses one hop, so only allow it if h + 1 <= max_hop. if h < max_hop: slot_topo = slice_to_topo[C] if is_directed: candidates = slot_topo.predecessors(T) else: candidates = slot_topo.neighbors(T) for T_prime in candidates: if T_prime == T: continue port = find_send_port(slot_topo, T_prime, T) if port is None: continue ns = (T_prime, C, h + 1) nd = d # transmit cost 0 if nd < dist.get(ns, INF): dist[ns] = nd parent[ns] = (state, "tx", port) heapq.heappush(pq, (nd, ns)) return parent, dist def _reconstruct_full_path(parent, start_state): """ Walk the Dijkstra parent chain from ``start_state`` forward to the destination, collecting one ``Step`` per transmit edge. Wait edges are implicit in the gap between the starting ``cur_slice`` and each transmit's ``send_ts``. Returns a list of ``Step`` objects (empty list if ``start_state`` is already at the destination, ``None`` if the parent chain is broken — which shouldn't happen if ``start_state`` was reachable in the Dijkstra relaxation). """ steps: List[Step] = [] cur = start_state # A bounded walk guards against pathological cycles; in practice each # wait strictly advances cur_slice and each tx strictly decrements the # remaining-hop budget, so the walk terminates in O(nb_ts + max_hop). for _ in range(1024): entry = parent.get(cur) if entry is None: # Reached a goal state (we're at dst). return steps next_state, edge_type, port = entry if edge_type == "tx": T, C, _h = cur next_T, _next_C, _next_h = next_state steps.append( Step( cur_node=T, step_type="port", send_port=port, send_ts=C, send_node=next_T, ) ) # "wait" edges advance cur without emitting a step. cur = next_state return None def _dijkstra_to_dst_unbounded(slice_to_topo: Dict[int, nx.Graph], dst): """2D-state backward Dijkstra over ``(T, C)`` — no hop budget. Each ``(T, C)`` has a single parent in the resulting tree, so optimal substructure holds by construction: walking the parent chain from any ``(src, cs)`` traverses the same edges that an intermediate's own stored ``(inter, cs')`` plan would walk. Returns ``parent``, ``dist`` keyed on 2-tuples ``(T, C)``. """ nb_ts = len(slice_to_topo) any_topo = next(iter(slice_to_topo.values())) is_directed = any_topo.is_directed() if hasattr(any_topo, "is_directed") else False INF = float("inf") dist: Dict[tuple, float] = {} parent: Dict[tuple, tuple] = {} pq: List[tuple] = [] for c in range(nb_ts): state = (dst, c) dist[state] = 0 parent[state] = None heapq.heappush(pq, (0, state)) while pq: d, state = heapq.heappop(pq) if d > dist.get(state, INF): continue T, C = state # Relax reverse-wait prev_C = (C - 1 + nb_ts) % nb_ts ns = (T, prev_C) nd = d + 1 if nd < dist.get(ns, INF): dist[ns] = nd parent[ns] = (state, "wait", None) heapq.heappush(pq, (nd, ns)) # Relax reverse-tx slot_topo = slice_to_topo[C] if is_directed: candidates = slot_topo.predecessors(T) else: candidates = slot_topo.neighbors(T) for T_prime in candidates: if T_prime == T: continue port = find_send_port(slot_topo, T_prime, T) if port is None: continue ns = (T_prime, C) nd = d if nd < dist.get(ns, INF): dist[ns] = nd parent[ns] = (state, "tx", port) heapq.heappush(pq, (nd, ns)) return parent, dist def _reconstruct_full_path_2d(parent, start_state): """Walk a 2D parent chain from ``start_state = (T, C)`` to dst.""" steps: List[Step] = [] cur = start_state for _ in range(1024): entry = parent.get(cur) if entry is None: return steps next_state, edge_type, port = entry if edge_type == "tx": T, C = cur next_T, _next_C = next_state steps.append( Step( cur_node=T, step_type="port", send_port=port, send_ts=C, send_node=next_T, ) ) cur = next_state return None
[docs] def routing_hoho( slice_to_topo: Dict[int, nx.Graph], max_hop: Optional[int] = None, ) -> list: """ HoHo routing — shortest-path forwarding over the time-expanded schedule graph. For every ``(src, cur_slice, dst)`` the emitted path represents the minimum-duration forwarding plan from ``(src, cur_slice)`` to ``dst``. Default (``max_hop=None``): unbounded 2D-state Dijkstra over ``(T, C)``. Each state has a single parent in the resulting tree, so optimal substructure holds by construction — the per-hop entry at any intermediate is the same as the SR-stamped tail at that intermediate, making Per-hop and Source forwarding modes physically equivalent. With ``max_hop=int``: 3D-state Dijkstra over ``(T, C, h)`` that bounds paths to at most ``max_hop`` transmit edges. **Optimal substructure no longer holds**: at intermediate ``X``, the per-hop table stores ``X``'s own ``(h=max_hop)`` plan, but the SR-stamped tail uses the ``(h-1)``-plan. When the two differ, Per-hop forwarding takes a different physical path than Source for transit traffic. Use ``routing_mode="Source"`` if a hop bound is required. Args: slice_to_topo: Topology for each time slice max_hop: Optional max transmit hops per path. ``None`` (default) = unbounded, optimal substructure. ``int`` = bounded, breaks substructure (warning emitted). Returns: A list of ``Path`` objects. Each path's ``steps`` contains one ``Step`` per transmit hop (``path2entries`` trims to the first step for Per-hop routing and keeps all steps for Source routing). """ nb_ts = len(slice_to_topo) any_topo = next(iter(slice_to_topo.values())) nodes = sorted(any_topo.nodes()) paths: List[Path] = [] if max_hop is None: for dst in nodes: parent, _dist = _dijkstra_to_dst_unbounded(slice_to_topo, dst) for src in nodes: if src == dst: continue for cs in range(nb_ts): start = (src, cs) if start not in parent: continue steps = _reconstruct_full_path_2d(parent, start) if not steps: continue paths.append( Path(src=src, arrival_ts=cs, dst=dst, steps=steps) ) return paths warnings.warn( "routing_hoho(max_hop=int) breaks optimal substructure: Per-hop and " "Source forwarding take different physical paths for transit traffic. " "Use routing_mode=\"Source\" if a hop bound is required, or pass " "max_hop=None (the default) for the unbounded 2D-state Dijkstra " "that preserves substructure by construction.", stacklevel=2, ) for dst in nodes: parent, _dist = _dijkstra_to_dst(slice_to_topo, dst, max_hop) for src in nodes: if src == dst: continue for cs in range(nb_ts): start = (src, cs, max_hop) if start not in parent: # Unreachable within max_hop transmits at this (cs). continue steps = _reconstruct_full_path(parent, start) if not steps: # (src, cs) is trivially the destination — shouldn't happen # because src != dst. continue paths.append( Path(src=src, arrival_ts=cs, dst=dst, steps=steps) ) return paths
[docs] def routing_vlb(slice_to_topo: Dict[int, nx.Graph], tor_to_ocs_port: List[int], random: bool = False) -> List[Path]: """ VLB routing. Args: slice_to_topo: Topology for each time slice tor_to_ocs_port: Port mapping from ToR to OCS random: If True, emit all-255 sentinel for the first hop so that the data plane picks a random port at runtime (requires Tofino Random<> support). If False (default), use a deterministic port selection from tor_to_ocs_port. Returns: A list of paths for VLB routing """ paths = [] nodes = slice_to_topo[0].nodes() slices = slice_to_topo.keys() for node1 in nodes: for node2 in nodes: if node1 == node2: continue for ts in slices: send_port = find_send_port(slice_to_topo[ts], node1, node2) if send_port is not None: # There is direct connection at this time slice. path = Path( src=node1, arrival_ts=ts, dst=node2, steps=[ Step(cur_node=node1, step_type="port", send_port=send_port, send_ts=ts, ), ] ) else: #There is no direct connection at this time slice. Send to a random node. if random: first_step = Step( cur_node=255, step_type="port", send_port=255, send_ts=255, ) else: first_step = Step( cur_node=node1, step_type="port", send_port=tor_to_ocs_port[ts%len(tor_to_ocs_port)], send_ts=ts, ) path = Path( src=node1, arrival_ts=ts, dst=node2, steps=[ first_step, Step(cur_node=255, step_type="node", send_node=node2), ], ) paths.append(path) return paths
def routing_vlb_all_random(slice_to_topo: Dict[int, nx.Graph], tor_to_ocs_port) -> List[Path]: """ VLB routing. Args: slice_to_topo: Topology for each time slice tor_to_ocs_port: Port mapping from ToR to OCS Returns: A list of paths for VLB routing """ paths = [] nodes = slice_to_topo[0].nodes() slices = slice_to_topo.keys() for node1 in nodes: for node2 in nodes: if node1 == node2: continue for ts in slices: path = Path( src=node1, arrival_ts=ts, dst=node2, steps=[ Step( cur_node=node1, step_type="port", send_port=tor_to_ocs_port[0], send_ts=ts, ), Step(cur_node=255, step_type="node", send_node=node2), ], ) paths.append(path) return paths
[docs] def routing_ksp(slice_to_topo: Dict[int, nx.Graph]) -> List[Path]: """ Opera routing by searching the shortest path for each time slice. Args: slice_to_topo: Topology for each time slice Returns: A list of paths for direct routing """ paths = [] slices = slice_to_topo.keys() nodes = slice_to_topo[0].nodes() for node1 in nodes: for node2 in nodes: if node1 == node2: continue for ts in slices: try: path_nodes = nx.shortest_path(slice_to_topo[ts], node1, node2) except nx.NetworkXNoPath: print( f"No path found for slice{ts} between node{node1} and node{node2}!" ) continue steps = [] for cur_node, next_node in zip(path_nodes[:-1], path_nodes[1:]): send_port = slice_to_topo[ts][cur_node][next_node].get("port1") steps.append( Step(cur_node=cur_node, send_port=send_port, send_ts=ts) ) path = Path(src=node1, arrival_ts=ts, dst=node2, steps=steps) paths.append(path) return paths
[docs] def routing_direct_ta(slice_to_topo: Dict[int, nx.Graph]) -> List[Path]: """ Direct routing for traffic-aware. Args: slice_to_topo: Topology for each time slice Returns: A list of paths for direct routing Raises: AssertionError: If more than one time slice is provided """ paths = [] assert len(slice_to_topo.keys()) == 1, ( "Only supports TA architecture with single time slice." ) nodes = slice_to_topo[0].nodes() for node1 in nodes: for node2 in nodes: if node1 == node2: continue # There could be direct path between any nodes. # We send pkts to the default port 0. # They will be buffered in corresbonding queues. paths.append( Path( src=node1, arrival_ts=0, dst=node2, steps=[Step(cur_node=node1, send_port=0)], ) ) return paths
def make_json(tor_id, tor_tb): """ Generate JSON configuration for ToR switch. Args: tor_id: ToR switch ID tor_tb: ToR table entries """ jsons = [] table_name = f"tor{tor_id % 4}_pipe.Ingress.tb_forwarding_table" action = "Ingress.enhqueue.set_send_slice_tor" for entry in tor_tb: item = { "table_name": table_name, "action": action, "key": { "time_slice": entry["time_slice"], "dst": entry["dst"], }, "data": { "send_slice": entry["send_slice"], "next_tor": entry["next_tor"], }, } jsons.append(item) with open(f"tables/tor{tor_id}.json", "w") as outfile: json.dump(jsons, outfile, indent=2)