diff --git a/htsim/README.md b/htsim/README.md index f40c766..311c54f 100644 --- a/htsim/README.md +++ b/htsim/README.md @@ -112,6 +112,20 @@ Supported routing strategies: `MINIMAL`, `VALIANT`, `UGAL_L`, `SOURCE` For `SOURCE` routing, host-level routing tables are loaded automatically from the `host_table/` subdirectory within the topology path. +To generate custom Dragonfly topology assets (`dragonfly.topo`, `dragonfly.adjlist`, `host_table/`) for any `(p,a,h)`, use: + +```bash +python sim/datacenter/topologies/dragonfly/generate_dragonfly_assets.py -p

-a -H --out sim/datacenter/topologies/dragonfly/p${p}a${a}h${h} +``` + +Example for 4 hosts (`p2a1h1`): + +```bash +python sim/datacenter/topologies/dragonfly/generate_dragonfly_assets.py -p 2 -a 1 -H 1 --out sim/datacenter/topologies/dragonfly/p2a1h1 +``` + +The Dragonfly generator script is adapted from the SPCL topology-generation tooling and outputs files directly in the layout expected by `htsim_uec_df`. + ### SlimFly SlimFly topologies use a two-partition structure based on optimized graph constructions. Pre-generated topology assets are provided in `topologies/slimfly/` (e.g., `p4q5` for p=4 hosts/switch, q=5 graph parameter). @@ -124,6 +138,28 @@ Supported routing strategies: `MINIMAL`, `VALIANT`, `UGAL_L`, `SOURCE` ./htsim_uec_sf -topo topologies/slimfly/p4q5 -tm traffic.tm -routing MINIMAL -q 88 ``` +To generate custom SlimFly topology assets (`slimfly.topo`, `slimfly.adjlist`, `fib/`, `host_table/`) for any `(p,q)`, use: + +```bash +python sim/datacenter/topologies/slimfly/generate_slimfly_assets.py -p

-q --out sim/datacenter/topologies/slimfly/p${p}q${q} +``` + +Example: + +```bash +python sim/datacenter/topologies/slimfly/generate_slimfly_assets.py -p 4 -q 5 --out sim/datacenter/topologies/slimfly/p4q5 +``` + +The SlimFly generator script is adapted from the SPCL topology-generation tooling and emits all files required by `htsim_uec_sf` in this repository. + +Note: topology generation scripts require Python packages `networkx` and `sympy`. + +Quick generator validation (small checks + end-to-end SOURCE runs for Dragonfly and SlimFly): + +```bash +bash tests/run_generator_tests.sh +``` + ### Traffic Matrix Format All topologies use the same traffic matrix format: diff --git a/htsim/sim/datacenter/topologies/dragonfly/generate_dragonfly_assets.py b/htsim/sim/datacenter/topologies/dragonfly/generate_dragonfly_assets.py new file mode 100644 index 0000000..7b319c9 --- /dev/null +++ b/htsim/sim/datacenter/topologies/dragonfly/generate_dragonfly_assets.py @@ -0,0 +1,232 @@ +#!/usr/bin/env python3 + +import argparse +from pathlib import Path +from typing import Dict, List, Tuple + + +def get_group_switch(src_group: int, dst_group: int, a: int, h: int, no_groups: int) -> int: + if src_group < dst_group: + right_steps = dst_group - src_group + else: + right_steps = (no_groups + dst_group) - src_group + return (src_group * a) + (right_steps - 1) // h + + +def get_target_switch(src_switch: int, global_link: int, a: int, h: int, no_groups: int) -> int: + src_group = src_switch // a + src_switch_index = src_switch - (src_group * a) + right_steps = (src_switch_index * h) + global_link + 1 + dst_group = (src_group + right_steps) % no_groups + return (dst_group * a) + (a - 1) - src_switch_index + + +def get_src_pairs(src_switch: int, + dst_switch: int, + a: int, + h: int, + no_groups: int) -> List[Tuple[int, int]]: + src_group = src_switch // a + dst_group = dst_switch // a + + group_start = src_group * a + group_switches = list(range(group_start, group_start + a)) + + pairs: List[Tuple[int, int]] = [] + + if src_group == dst_group: + for hop_one in group_switches: + if hop_one != src_switch: + pairs.append((hop_one, 0)) + return pairs + + for link in range(h): + hop_one = get_target_switch(src_switch, link, a, h, no_groups) + pairs.append((hop_one, 0)) + + for hop_one in group_switches: + if hop_one == src_switch: + continue + for link in range(h): + hop_two = get_target_switch(hop_one, link, a, h, no_groups) + pairs.append((hop_one, hop_two)) + + deduped: List[Tuple[int, int]] = [] + seen = set() + for pair in pairs: + if pair not in seen: + seen.add(pair) + deduped.append(pair) + return deduped + + +def write_dragonfly_topo(path: Path, + p: int, + a: int, + h: int, + switch_latency_ns: int, + link_speed_gbps: int, + link_latency_global_ns: int, + link_latency_local_ns: int, + link_latency_host_ns: int) -> None: + content = ( + "# Dragonfly\n" + f"p {p}\n" + f"a {a}\n" + f"h {h}\n\n" + f"Switch_latency_ns {switch_latency_ns}\n\n" + "# Default - link speed\n" + f"Link_speed_global_Gbps {link_speed_gbps}\n" + f"Link_speed_local_Gbps {link_speed_gbps}\n" + f"Link_speed_host_Gbps {link_speed_gbps}\n\n" + "# Default - link latency\n" + f"Link_latency_global_ns {link_latency_global_ns}\n" + f"Link_latency_local_ns {link_latency_local_ns}\n" + f"Link_latency_host_ns {link_latency_host_ns}\n" + ) + (path / "dragonfly.topo").write_text(content) + + +def write_dragonfly_adjlist(path: Path, a: int, h: int, no_groups: int, no_switches: int) -> None: + lines: List[str] = [] + for src in range(no_switches): + src_group = src // a + group_start = src_group * a + locals_ = [sw for sw in range(group_start, group_start + a) if sw != src] + globals_ = [get_target_switch(src, link, a, h, no_groups) for link in range(h)] + neighbors = locals_ + globals_ + lines.append(" ".join([str(src)] + [str(x) for x in neighbors])) + (path / "dragonfly.adjlist").write_text("\n".join(lines) + "\n") + + +def write_host_tables(path: Path, p: int, a: int, h: int, no_groups: int, no_switches: int) -> None: + host_table_dir = path / "host_table" + host_table_dir.mkdir(parents=True, exist_ok=True) + + for src in range(no_switches): + out_lines: List[str] = [] + for dst in range(no_switches): + if src == dst: + continue + + out_lines.append(str(dst)) + pairs = get_src_pairs(src, dst, a, h, no_groups) + + direct = [(h1, h2) for (h1, h2) in pairs if h2 == 0] + two_hop = [(h1, h2) for (h1, h2) in pairs if h2 != 0] + + if direct: + payload = " ".join(f"{h1} {h2}" for (h1, h2) in direct) + out_lines.append(f"1 {len(direct)} {payload}") + if two_hop: + payload = " ".join(f"{h1} {h2}" for (h1, h2) in two_hop) + out_lines.append(f"2 {len(two_hop)} {payload}") + + (host_table_dir / f"{src}.lt").write_text("\n".join(out_lines) + "\n") + + +def parse_pairs_from_lt(path: Path) -> Dict[int, List[Tuple[int, int]]]: + entries: Dict[int, List[Tuple[int, int]]] = {} + current_dst = None + for raw in path.read_text().splitlines(): + line = raw.strip() + if not line: + continue + tokens = line.split() + if len(tokens) == 1: + current_dst = int(tokens[0]) + entries[current_dst] = [] + continue + if current_dst is None: + continue + for idx in range(2, len(tokens) - 1, 2): + entries[current_dst].append((int(tokens[idx]), int(tokens[idx + 1]))) + return entries + + +def compare_with_reference(generated_dir: Path, reference_dir: Path) -> None: + ref_host = reference_dir / "host_table" + gen_host = generated_dir / "host_table" + if not ref_host.exists() or not gen_host.exists(): + print("[compare] skipped: host_table missing in one of the directories") + return + + strict_subset_ok = True + coverage_stats = [] + + for gen_file in sorted(gen_host.glob("*.lt")): + ref_file = ref_host / gen_file.name + if not ref_file.exists(): + strict_subset_ok = False + continue + + gen_entries = parse_pairs_from_lt(gen_file) + ref_entries = parse_pairs_from_lt(ref_file) + + for dst, gen_pairs in gen_entries.items(): + ref_pairs = set(ref_entries.get(dst, [])) + gen_set = set(gen_pairs) + + if not gen_set.issubset(ref_pairs): + strict_subset_ok = False + + cov = 1.0 if not gen_set else len(gen_set & ref_pairs) / len(gen_set) + coverage_stats.append(cov) + + avg_cov = sum(coverage_stats) / len(coverage_stats) if coverage_stats else 0.0 + print(f"[compare] generated pairs subset of reference: {'yes' if strict_subset_ok else 'no'}") + print(f"[compare] average generated-pair coverage in reference: {avg_cov:.3f}") + + +def main() -> None: + parser = argparse.ArgumentParser(description="Generate Dragonfly topology assets") + parser.add_argument("-p", type=int, required=True, help="Hosts per switch") + parser.add_argument("-a", type=int, required=True, help="Switches per group") + parser.add_argument("-H", "--h", dest="h", type=int, required=True, + help="Global links per switch") + parser.add_argument("--out", type=Path, required=True, help="Output topology directory") + + parser.add_argument("--switch-latency-ns", type=int, default=500) + parser.add_argument("--link-speed-gbps", type=int, default=400) + parser.add_argument("--link-latency-global-ns", type=int, default=500) + parser.add_argument("--link-latency-local-ns", type=int, default=25) + parser.add_argument("--link-latency-host-ns", type=int, default=25) + + parser.add_argument("--compare-ref", type=Path, + help="Optional reference topology dir (with host_table) to compare coverage") + + args = parser.parse_args() + + if args.p <= 0 or args.a <= 0 or args.h <= 0: + raise ValueError("p, a, h must all be > 0") + + out = args.out + out.mkdir(parents=True, exist_ok=True) + + no_groups = args.a * args.h + 1 + no_switches = args.a * no_groups + no_hosts = args.p * no_switches + + write_dragonfly_topo(out, + args.p, + args.a, + args.h, + args.switch_latency_ns, + args.link_speed_gbps, + args.link_latency_global_ns, + args.link_latency_local_ns, + args.link_latency_host_ns) + + write_dragonfly_adjlist(out, args.a, args.h, no_groups, no_switches) + write_host_tables(out, args.p, args.a, args.h, no_groups, no_switches) + + print(f"Generated Dragonfly assets in: {out}") + print(f"p={args.p} a={args.a} h={args.h}") + print(f"switches={no_switches} hosts={no_hosts}") + + if args.compare_ref: + compare_with_reference(out, args.compare_ref) + + +if __name__ == "__main__": + main() diff --git a/htsim/sim/datacenter/topologies/slimfly/generate_slimfly_assets.py b/htsim/sim/datacenter/topologies/slimfly/generate_slimfly_assets.py new file mode 100644 index 0000000..ac1d064 --- /dev/null +++ b/htsim/sim/datacenter/topologies/slimfly/generate_slimfly_assets.py @@ -0,0 +1,386 @@ +#!/usr/bin/env python3 + +import argparse +import itertools +import math +import multiprocessing as mp +from collections import defaultdict +from pathlib import Path +from typing import Dict, List, Tuple + +import networkx as nx +from sympy.ntheory.primetest import isprime +from sympy.polys.domains import ZZ +from sympy.polys.galoistools import gf_add, gf_gcdex, gf_irreducible_p, gf_mul, gf_rem, gf_sub + + +def is_prime(x: int) -> bool: + return len([d for d in range(1, x + 1) if x % d == 0]) == 2 + + +def get_power_of_prime(q: int) -> Tuple[int, int]: + prime_divisors = [d for d in range(2, q + 1) if q % d == 0 and is_prime(d)] + if len(prime_divisors) != 1: + return 0, 0 + + prime = prime_divisors[0] + m = 0 + v = q + while v != 1: + if v % prime == 0: + v = v // prime + m += 1 + else: + return 0, 0 + return prime, m + + +class GF: + def __init__(self, p: int, n: int = 1): + p, n = int(p), int(n) + if not isprime(p): + raise ValueError(f"p must be prime, got {p}") + if n <= 0: + raise ValueError(f"n must be > 0, got {n}") + + self.p = p + self.n = n + + if n == 1: + self.reducing = [1, 0] + else: + for c in itertools.product(range(p), repeat=n): + poly = (1, *c) + if gf_irreducible_p(poly, p, ZZ): + self.reducing = poly + break + + self.elems = None + + def get_elems(self) -> List[List[int]]: + if self.elems is None: + self.elems = [[]] + for c in range(1, self.p): + self.elems.append([c]) + for deg in range(1, self.n): + for c in itertools.product(range(self.p), repeat=deg): + for first in range(1, self.p): + self.elems.append(list((first, *c))) + return self.elems + + def get_primitive_elem(self) -> List[int]: + for primitive in self.get_elems(): + if self.is_primitive_elem(primitive): + return primitive + raise RuntimeError("No primitive element found") + + def is_primitive_elem(self, primitive: List[int]) -> bool: + elems = [[]] + tmp = [1] + for _ in range(1, self.p**self.n): + tmp = self.mul(tmp, primitive) + elems.append(tmp) + + return len(elems) == self.p**self.n and all(elem in elems for elem in self.get_elems()) + + def add(self, x: List[int], y: List[int]) -> List[int]: + return gf_add(x, y, self.p, ZZ) + + def sub(self, x: List[int], y: List[int]) -> List[int]: + return gf_sub(x, y, self.p, ZZ) + + def mul(self, x: List[int], y: List[int]) -> List[int]: + product = gf_mul(x, y, self.p, ZZ) + return gf_rem(product, self.reducing, self.p, ZZ) + + def inv(self, x: List[int]) -> List[int]: + s, _, _ = gf_gcdex(x, self.reducing, self.p, ZZ) + return s + + +def get_slimfly(q: int) -> nx.Graph: + assert (q - 1) % 4 == 0 or (q + 1) % 4 == 0 or q % 4 == 0 + + network = nx.Graph() + prime, prime_power = get_power_of_prime(q) + if prime == 0 or prime_power == 0 or prime**prime_power != q: + raise ValueError(f"q={q} is not a prime power") + + if q % 4 == 3: + delta = -1 + elif q % 4 == 2: + raise ValueError("q is not of form 4w + delta") + else: + delta = q % 4 + + if (q - delta) % 4 != 0: + raise ValueError("invalid q, cannot derive w") + w = int((q - delta) / 4) + if w < 1: + raise ValueError("invalid w") + + gf = GF(prime, prime_power) + pe = gf.get_primitive_elem() + + pe_powers = [[1]] + for i in range(1, q): + pe_powers.append(gf.mul(pe_powers[i - 1], pe)) + + if delta == 0: + X = [pe_powers[i] for i in range(0, q - 1) if i % 2 == 0] + Xp = [pe_powers[i] for i in range(1, q) if i % 2 == 1] + elif delta == 1: + X = [pe_powers[i] for i in range(0, q - 2) if i % 2 == 0] + Xp = [pe_powers[i] for i in range(1, q - 1) if i % 2 == 1] + else: + X = [pe_powers[i] for i in range(0, 2 * w - 1) if i % 2 == 0] + X.extend([pe_powers[i] for i in range(2 * w - 1, 4 * w - 2) if i % 2 == 1]) + Xp = [pe_powers[i] for i in range(1, 2 * w) if i % 2 == 1] + Xp.extend([pe_powers[i] for i in range(2 * w, 4 * w - 1) if i % 2 == 0]) + + labels = [(v, x, y) for v in [1, 0] for x in gf.get_elems() for y in gf.get_elems()] + maps = list(zip(labels, [i for i in range(2 * q**2)])) + + for s in maps: + for t in maps: + if ( + s[0][0] == 0 + and t[0][0] == 0 + and s[0][1] == t[0][1] + and gf.sub(s[0][2], t[0][2]) in X + ): + network.add_edge(s[1], t[1]) + + if ( + s[0][0] == 1 + and t[0][0] == 1 + and s[0][1] == t[0][1] + and gf.sub(s[0][2], t[0][2]) in Xp + ): + network.add_edge(s[1], t[1]) + + if ( + s[0][0] == 0 + and t[0][0] == 1 + and s[0][2] == gf.add(gf.mul(t[0][1], s[0][1]), t[0][2]) + ): + network.add_edge(s[1], t[1]) + + return network + + +_network = None + + +def init_worker(network_edges: List[Tuple[int, int]]) -> None: + global _network + _network = nx.Graph() + _network.add_edges_from(network_edges) + + +def latency_sort_key(key: Tuple[int, int]) -> int: + return key[0] * 25 + key[1] * 500 + + +def generate_host_table_for_switch(out_dir: Path, p: int, q: int, src_switch: int) -> None: + global _network + no_switches = 2 * q**2 + + output_lines: List[str] = [] + for dst_switch in range(no_switches): + if src_switch == dst_switch: + continue + + paths = [] + paths_final = [] + + for n in _network.neighbors(src_switch): + for dst in _network.neighbors(n): + if src_switch != dst: + paths.append([src_switch, n, dst]) + + for path in paths: + n = path[-1] + shortest_path = nx.shortest_path(_network, source=n, target=dst_switch) + full = path + shortest_path[1:] + if len(full) == len(set(full)): + paths_final.append(full) + + group = defaultdict(list) + for path in paths_final: + local_hops = 0 + global_hops = 0 + for u, v in zip(path, path[1:]): + if u // q == v // q: + local_hops += 1 + else: + global_hops += 1 + group[(local_hops, global_hops)].append((path[1], path[2])) + + output_lines.append(f"{dst_switch}") + for key in sorted(group.keys(), key=latency_sort_key): + line = f"{key[0]} {key[1]}" + for x, y in group[key]: + line += f" {x} {y}" + output_lines.append(line) + + (out_dir / "host_table" / f"{src_switch}.lt").write_text("\n".join(output_lines) + "\n") + + +def generate_fib_for_switch(out_dir: Path, q: int, src_switch: int) -> None: + global _network + no_switches = 2 * q**2 + + output_lines: List[str] = [] + for dst_switch in range(no_switches): + if src_switch == dst_switch: + continue + shortest_path = nx.shortest_path(_network, source=src_switch, target=dst_switch) + output_lines.append(f"{dst_switch} {shortest_path[1]}") + + (out_dir / "fib" / f"{src_switch}.fib").write_text("\n".join(output_lines) + "\n") + + +def process_switch_slice(args: Tuple[Path, int, int, int, int]) -> None: + out_dir, p, q, start, end = args + for src_switch in range(start, end): + generate_host_table_for_switch(out_dir, p, q, src_switch) + generate_fib_for_switch(out_dir, q, src_switch) + + +def write_slimfly_topo(path: Path, + p: int, + q: int, + switch_latency_ns: int, + link_speed_gbps: int, + link_latency_global_ns: int, + link_latency_local_ns: int, + link_latency_host_ns: int) -> None: + content = ( + "# Slim Fly\n" + f"p {p}\n" + f"q {q}\n\n" + f"Switch_latency_ns {switch_latency_ns}\n\n" + "# Default - link speed\n" + f"Link_speed_global_Gbps {link_speed_gbps}\n" + f"Link_speed_local_Gbps {link_speed_gbps}\n" + f"Link_speed_host_Gbps {link_speed_gbps}\n\n" + "# Default - link latency\n" + f"Link_latency_global_ns {link_latency_global_ns}\n" + f"Link_latency_local_ns {link_latency_local_ns}\n" + f"Link_latency_host_ns {link_latency_host_ns}\n" + ) + (path / "slimfly.topo").write_text(content) + + +def parse_pairs_from_lt(path: Path) -> Dict[int, List[Tuple[int, int]]]: + entries: Dict[int, List[Tuple[int, int]]] = {} + current_dst = None + for raw in path.read_text().splitlines(): + line = raw.strip() + if not line: + continue + tokens = line.split() + if len(tokens) == 1: + current_dst = int(tokens[0]) + entries[current_dst] = [] + continue + if current_dst is None: + continue + for idx in range(2, len(tokens) - 1, 2): + entries[current_dst].append((int(tokens[idx]), int(tokens[idx + 1]))) + return entries + + +def compare_with_reference(generated_dir: Path, reference_dir: Path) -> None: + ref_host = reference_dir / "host_table" + gen_host = generated_dir / "host_table" + if not ref_host.exists() or not gen_host.exists(): + print("[compare] skipped: host_table missing in one of the directories") + return + + strict_subset_ok = True + coverage_stats = [] + + for gen_file in sorted(gen_host.glob("*.lt")): + ref_file = ref_host / gen_file.name + if not ref_file.exists(): + strict_subset_ok = False + continue + + gen_entries = parse_pairs_from_lt(gen_file) + ref_entries = parse_pairs_from_lt(ref_file) + + for dst, gen_pairs in gen_entries.items(): + ref_pairs = set(ref_entries.get(dst, [])) + gen_set = set(gen_pairs) + + if not gen_set.issubset(ref_pairs): + strict_subset_ok = False + + cov = 1.0 if not gen_set else len(gen_set & ref_pairs) / len(gen_set) + coverage_stats.append(cov) + + avg_cov = sum(coverage_stats) / len(coverage_stats) if coverage_stats else 0.0 + print(f"[compare] generated pairs subset of reference: {'yes' if strict_subset_ok else 'no'}") + print(f"[compare] average generated-pair coverage in reference: {avg_cov:.3f}") + + +def main() -> None: + parser = argparse.ArgumentParser(description="Generate SlimFly topology assets") + parser.add_argument("-p", type=int, required=True, help="Hosts per switch") + parser.add_argument("-q", type=int, required=True, help="SlimFly q parameter") + parser.add_argument("--out", type=Path, required=True, help="Output topology directory") + parser.add_argument("-n", "--num-workers", type=int, default=mp.cpu_count()) + + parser.add_argument("--switch-latency-ns", type=int, default=500) + parser.add_argument("--link-speed-gbps", type=int, default=400) + parser.add_argument("--link-latency-global-ns", type=int, default=500) + parser.add_argument("--link-latency-local-ns", type=int, default=25) + parser.add_argument("--link-latency-host-ns", type=int, default=25) + + parser.add_argument("--compare-ref", type=Path, + help="Optional reference topology dir (with host_table) to compare coverage") + + args = parser.parse_args() + if args.p <= 0 or args.q <= 0: + raise ValueError("p and q must be > 0") + + out = args.out + out.mkdir(parents=True, exist_ok=True) + (out / "host_table").mkdir(parents=True, exist_ok=True) + (out / "fib").mkdir(parents=True, exist_ok=True) + + network = get_slimfly(args.q) + nx.write_adjlist(network, out / "slimfly.adjlist") + edges = list(network.edges()) + + write_slimfly_topo(out, + args.p, + args.q, + args.switch_latency_ns, + args.link_speed_gbps, + args.link_latency_global_ns, + args.link_latency_local_ns, + args.link_latency_host_ns) + + no_switches = 2 * args.q**2 + num_workers = max(1, min(args.num_workers, no_switches)) + slice_size = math.ceil(no_switches / num_workers) + work_items = [] + for start in range(0, no_switches, slice_size): + end = min(start + slice_size, no_switches) + work_items.append((out, args.p, args.q, start, end)) + + with mp.Pool(processes=num_workers, initializer=init_worker, initargs=(edges,)) as pool: + pool.map(process_switch_slice, work_items) + + print(f"Generated SlimFly assets in: {out}") + print(f"p={args.p} q={args.q} switches={no_switches} hosts={args.p * no_switches}") + + if args.compare_ref: + compare_with_reference(out, args.compare_ref) + + +if __name__ == "__main__": + main() diff --git a/tests/run_generator_tests.sh b/tests/run_generator_tests.sh new file mode 100755 index 0000000..1b749d7 --- /dev/null +++ b/tests/run_generator_tests.sh @@ -0,0 +1,132 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +DF_GEN="$ROOT_DIR/htsim/sim/datacenter/topologies/dragonfly/generate_dragonfly_assets.py" +SF_GEN="$ROOT_DIR/htsim/sim/datacenter/topologies/slimfly/generate_slimfly_assets.py" +DF_BIN="$ROOT_DIR/htsim/sim/datacenter/htsim_uec_df" +SF_BIN="$ROOT_DIR/htsim/sim/datacenter/htsim_uec_sf" + +REF_DF="$ROOT_DIR/htsim/sim/datacenter/topologies/dragonfly/p2a4h2" + +PASS=0 +FAIL=0 + +fail() { + echo "FAIL: $1" + FAIL=$((FAIL + 1)) +} + +pass() { + echo "PASS: $1" + PASS=$((PASS + 1)) +} + +require_file() { + local path="$1" + local msg="$2" + if [[ -f "$path" ]]; then + pass "$msg" + else + fail "$msg (missing: $path)" + fi +} + +require_dir() { + local path="$1" + local msg="$2" + if [[ -d "$path" ]]; then + pass "$msg" + else + fail "$msg (missing: $path)" + fi +} + +echo "=== Generator tests (small + end-to-end) ===" + +if ! python3 -c "import networkx, sympy" >/dev/null 2>&1; then + echo "ERROR: missing Python dependencies. Install with: pip install networkx sympy" + exit 1 +fi + +if [[ ! -x "$DF_BIN" || ! -x "$SF_BIN" ]]; then + echo "ERROR: missing simulator binaries. Build HTSIM first (cmake --build ...)." + exit 1 +fi + +TMP_DIR="$(mktemp -d)" +trap 'rm -rf "$TMP_DIR"' EXIT + +echo "Using temp directory: $TMP_DIR" + +DF_OUT="$TMP_DIR/dragonfly_p2a1h1" +DF_LOG="$TMP_DIR/dragonfly_compare.log" +python3 "$DF_GEN" -p 2 -a 1 -H 1 --out "$DF_OUT" >/dev/null + +require_file "$DF_OUT/dragonfly.topo" "Dragonfly generator writes dragonfly.topo" +require_file "$DF_OUT/dragonfly.adjlist" "Dragonfly generator writes dragonfly.adjlist" +require_dir "$DF_OUT/host_table" "Dragonfly generator writes host_table directory" +require_file "$DF_OUT/host_table/0.lt" "Dragonfly host table includes switch 0" +require_file "$DF_OUT/host_table/1.lt" "Dragonfly host table includes switch 1" + +if [[ -d "$REF_DF/host_table" ]]; then + python3 "$DF_GEN" -p 2 -a 4 -H 2 --out "$TMP_DIR/dragonfly_p2a4h2" --compare-ref "$REF_DF" >"$DF_LOG" +fi + +if [[ ! -d "$REF_DF/host_table" ]]; then + pass "Dragonfly compare-ref skipped (no host_table in $REF_DF)" +elif grep -q "generated pairs subset of reference: yes" "$DF_LOG" && grep -q "average generated-pair coverage in reference: 1.000" "$DF_LOG"; then + pass "Dragonfly generator matches reference host-table coverage" +else + fail "Dragonfly compare-ref coverage check" +fi + +cat >"$TMP_DIR/df_1flow.tm" <<'EOF' +Nodes 4 +Connections 1 +0->3 start 0 size 4096 +EOF + +"$DF_BIN" -basepath "$DF_OUT" -routing SOURCE -tm "$TMP_DIR/df_1flow.tm" >"$TMP_DIR/df_run.out" 2>&1 || true +if grep -q "Done" "$TMP_DIR/df_run.out" && [[ "$(grep -c 'finished at' "$TMP_DIR/df_run.out")" -eq 1 ]]; then + pass "Dragonfly SOURCE end-to-end run succeeds with generated assets" +else + fail "Dragonfly SOURCE end-to-end run" +fi + +SF_OUT="$TMP_DIR/slimfly_p2q3" +python3 "$SF_GEN" -p 2 -q 3 --out "$SF_OUT" -n 1 >/dev/null + +require_file "$SF_OUT/slimfly.topo" "SlimFly generator writes slimfly.topo" +require_file "$SF_OUT/slimfly.adjlist" "SlimFly generator writes slimfly.adjlist" +require_dir "$SF_OUT/fib" "SlimFly generator writes fib directory" +require_dir "$SF_OUT/host_table" "SlimFly generator writes host_table directory" + +FIB_COUNT="$(ls "$SF_OUT"/fib/*.fib 2>/dev/null | wc -l | tr -d ' ')" +HT_COUNT="$(ls "$SF_OUT"/host_table/*.lt 2>/dev/null | wc -l | tr -d ' ')" +if [[ "$FIB_COUNT" -eq 18 && "$HT_COUNT" -eq 18 ]]; then + pass "SlimFly generator writes expected fib/host_table file counts for q=3" +else + fail "SlimFly output counts (fib=$FIB_COUNT host_table=$HT_COUNT, expected 18/18)" +fi + +cat >"$TMP_DIR/sf_1flow.tm" <<'EOF' +Nodes 36 +Connections 1 +0->35 start 0 size 4096 +EOF + +"$SF_BIN" -topo "$SF_OUT" -routing SOURCE -tm "$TMP_DIR/sf_1flow.tm" >"$TMP_DIR/sf_run.out" 2>&1 || true +if grep -q "Done" "$TMP_DIR/sf_run.out" && [[ "$(grep -c 'finished at' "$TMP_DIR/sf_run.out")" -eq 1 ]]; then + pass "SlimFly SOURCE end-to-end run succeeds with generated assets" +else + fail "SlimFly SOURCE end-to-end run" +fi + +echo "=========================================" +echo "Generator tests: PASS=$PASS FAIL=$FAIL" +echo "=========================================" + +if [[ "$FAIL" -gt 0 ]]; then + exit 1 +fi