Skip to content

Commit b56e232

Browse files
FEAT: Fat-tree generator (#420)
Closes #419. --------- Co-authored-by: Павел Ральников <112890673+PaulRalnikov@users.noreply.github.com>
1 parent ea6023a commit b56e232

3 files changed

Lines changed: 242 additions & 16 deletions

File tree

scripts/generate_image.py

Lines changed: 48 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -67,21 +67,52 @@ def add_node(device_id, style):
6767
hosts = config.get("hosts", {})
6868
host_items = sorted(hosts.items(), key = key_lambda)
6969

70+
switches = config.get("switches", {})
71+
switch_items = sorted(switches.items(), key= key_lambda)
72+
7073
DEVICE_STYLES = {
7174
"host": {"color": "#2E7D32", "icon": "🖥️", "shape": "none"},
7275
"switch": {"color": "#1565C0", "icon": "🌐", "shape": "box3d"},
7376
}
7477

75-
for host_id, host_info in host_items:
76-
add_node(host_id, DEVICE_STYLES["host"])
78+
# Define default layers for device types
79+
DEFAULT_LAYERS = {
80+
"host": 0,
81+
"switch": 1
82+
}
83+
84+
# Collect all devices with their layers
85+
all_devices = []
7786

78-
switches = config.get("switches", {})
79-
switch_items = sorted(switches.items(), key= key_lambda)
80-
for switch_id, switch_info in switch_items:
81-
add_node(switch_id, DEVICE_STYLES["switch"])
87+
# Process hosts
88+
for host_id, host_info in hosts.items():
89+
if not host_info:
90+
continue
91+
layer = host_info.get("layer", DEFAULT_LAYERS["host"])
92+
all_devices.append((host_id, "host", layer, host_info))
93+
94+
# Process switches
95+
for switch_id, switch_info in switches.items():
96+
if (not switch_info):
97+
continue
98+
layer = switch_info.get("layer", DEFAULT_LAYERS["switch"])
99+
all_devices.append((switch_id, "switch", layer, switch_info))
100+
101+
# Group devices by layer
102+
layer_groups = {}
103+
for device_id, dev_type, layer, _ in all_devices:
104+
if layer not in layer_groups:
105+
layer_groups[layer] = []
106+
layer_groups[layer].append((device_id, dev_type))
107+
108+
109+
for layer, devices in sorted(layer_groups.items()):
110+
sorted_devices = sorted(devices, key = key_lambda)
111+
for device_id, dev_type in sorted_devices:
112+
add_node(device_id, DEVICE_STYLES[dev_type])
82113

83114
presets = config.get("presets", {})
84-
link_preset = presets.get("link", {}).get("default", {})
115+
link_presets = presets.get("link", {})
85116

86117
def get_with_preset(node : dict, preset_node : dict, field_name):
87118
if field_name in node:
@@ -93,6 +124,8 @@ def get_with_preset(node : dict, preset_node : dict, field_name):
93124
for link_id, link_info in links.items():
94125
from_node = link_info["from"]
95126
to_node = link_info["to"]
127+
preset_name = link_info.get("preset-name", "default")
128+
link_preset = link_presets.get(preset_name)
96129
latency = get_with_preset(link_info, link_preset, 'latency')
97130
throughput = get_with_preset(link_info, link_preset, 'throughput')
98131
label = f"{link_id}\n"\
@@ -110,16 +143,15 @@ def get_with_preset(node : dict, preset_node : dict, field_name):
110143
**edge_style,
111144
)
112145

113-
# Create hierarchical groups
114-
with graph.subgraph() as s:
115-
s.attr(rank="max")
116-
for host_id, _ in host_items:
117-
s.node(host_id)
146+
# Create subgraphs for each layer
147+
for layer, devices in sorted(layer_groups.items()):
148+
sorted_devices = sorted(devices, key = key_lambda)
149+
with graph.subgraph() as s:
150+
s.attr(rank="same")
151+
for device_id, dev_type in sorted_devices:
152+
# Add node with appropriate style
153+
s.node(device_id)
118154

119-
with graph.subgraph() as s:
120-
s.attr(rank="min")
121-
for switch_id, _ in switch_items:
122-
s.node(switch_id)
123155

124156
directory = os.path.dirname(output_file)
125157
file_name, extension = os.path.splitext(os.path.basename(output_file))
Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
import yaml
2+
import argparse
3+
import sys
4+
import os
5+
6+
def save_yaml(data, filename):
7+
"""Save data as YAML to a file"""
8+
with open(filename, "w") as f:
9+
yaml.dump(data, f, sort_keys=False, default_flow_style=False)
10+
11+
def load_config(config_file):
12+
try:
13+
with open(config_file, 'r') as f:
14+
return yaml.safe_load(f)
15+
except FileNotFoundError:
16+
print(f"Error: Configuration file '{config_file}' not found.")
17+
sys.exit(1)
18+
except yaml.YAMLError as e:
19+
print(f"Error: Invalid YAML in configuration file: {e}")
20+
sys.exit(1)
21+
22+
def add_bidirectional_link(config, from_node, to_node, link_counter, preset = "default"):
23+
config["links"][f"link{link_counter}"] = {
24+
"from": from_node, "to": to_node, "preset-name": preset
25+
}
26+
link_counter += 1
27+
config["links"][f"link{link_counter}"] = {
28+
"from": to_node, "to": from_node, "preset-name": preset
29+
}
30+
return link_counter + 1
31+
32+
def generate_fat_tree_config(config_params):
33+
switch_ports_count = config_params["switch_ports_count"]
34+
link_presets = config_params["link_presets"]
35+
switch_presets = config_params["switch_presets"]
36+
packet_spraying = config_params["packet_spraying"]
37+
38+
if switch_ports_count % 2 != 0 or switch_ports_count < 2:
39+
raise ValueError(f"Switch's number of ports must be an even integer >= 2, but got {switch_ports_count}")
40+
41+
num_pods = switch_ports_count
42+
edge_per_pod = switch_ports_count // 2
43+
aggr_per_pod = switch_ports_count // 2
44+
hosts_per_pod = edge_per_pod * (switch_ports_count // 2)
45+
core_switches = (switch_ports_count // 2) ** 2
46+
hosts_per_edge = switch_ports_count // 2
47+
core_per_aggr = switch_ports_count // 2
48+
49+
config = {
50+
"presets": {
51+
"link": link_presets,
52+
"switch": switch_presets
53+
},
54+
"packet-spraying": packet_spraying,
55+
"hosts": {},
56+
"switches": {},
57+
"links": {}
58+
}
59+
60+
host_name = lambda pod_idx, host_idx: f"pod{pod_idx}_host{host_idx}"
61+
aggr_name = lambda pod_idx, aggr_idx: f"pod{pod_idx}_aggr{aggr_idx}"
62+
edge_name = lambda pod_idx, edge_idx: f"pod{pod_idx}_edge{edge_idx}"
63+
core_name = lambda core_idx: f"core{core_idx}"
64+
65+
for p in range(1, num_pods + 1):
66+
for h in range(1, hosts_per_pod + 1):
67+
config["hosts"][host_name(p, h)] = {"layer": 3}
68+
69+
for i in range(1, core_switches + 1):
70+
config["switches"][core_name(i)] = {"preset-name": "core", "layer": 0}
71+
72+
for p in range(1, num_pods + 1):
73+
for a in range(1, aggr_per_pod + 1):
74+
config["switches"][aggr_name(p, a)] = {"preset-name": "aggr", "layer": 1}
75+
for e in range(1, edge_per_pod + 1):
76+
config["switches"][edge_name(p, e)] = {"preset-name": "edge", "layer": 2}
77+
78+
link_counter = 1
79+
80+
# Edge-host
81+
for pod_idx in range(1, num_pods + 1):
82+
for edge_idx in range(1, edge_per_pod + 1):
83+
for h in range(1, hosts_per_edge + 1):
84+
host_idx = (edge_idx - 1) * hosts_per_edge + h
85+
link_counter = add_bidirectional_link(
86+
config,
87+
host_name(pod_idx, host_idx),
88+
edge_name(pod_idx, edge_idx),
89+
link_counter,
90+
"edge-host"
91+
)
92+
93+
# Aggr-edge
94+
for pod_idx in range(1, num_pods + 1):
95+
for edge_idx in range(1, edge_per_pod + 1):
96+
for aggr_idx in range(1, aggr_per_pod + 1):
97+
link_counter = add_bidirectional_link(
98+
config,
99+
edge_name(pod_idx, edge_idx),
100+
aggr_name(pod_idx, aggr_idx),
101+
link_counter,
102+
"aggr-edge"
103+
)
104+
105+
# Aggr-core
106+
for pod_idx in range(1, num_pods + 1):
107+
for aggr_idx in range(1, aggr_per_pod + 1):
108+
core_offset = (aggr_idx - 1) * core_per_aggr
109+
for core_idx in range(1, core_per_aggr + 1):
110+
core_id = core_offset + core_idx
111+
link_counter = add_bidirectional_link(
112+
config,
113+
aggr_name(pod_idx, aggr_idx),
114+
core_name(core_id),
115+
link_counter,
116+
"aggr-core"
117+
)
118+
119+
return config
120+
121+
def write_config_to_file(config, output_path):
122+
with open(output_path, 'w') as f:
123+
yaml.dump(config, f, sort_keys=False, width=120, indent=2)
124+
125+
print(f"Configuration written to {output_path}")
126+
return output_path
127+
128+
if __name__ == "__main__":
129+
# Set up command-line argument parsing
130+
parser = argparse.ArgumentParser(
131+
description='Generate Fat-Tree network configuration.'\
132+
'You may see more about it here: '\
133+
'https://packetpushers.net/blog/demystifying-dcn-topologies-clos-fat-trees-part2/')
134+
curr_file_path = os.path.realpath(__file__)
135+
curr_dir_path = os.path.dirname(curr_file_path)
136+
default_config_full_path = os.path.join(curr_dir_path, "fat_tree_config.yaml")
137+
default_config_rel_path = os.path.relpath(default_config_full_path, os.getcwd())
138+
139+
parser.add_argument('-c', '--config',
140+
default=default_config_abs_path,
141+
help=f'Path to configuration file (default: {default_config_abs_path}). See given default to get format & structure of this config')
142+
143+
parser.add_argument('-o', '--output_path',
144+
default='fat_tree_topology.yaml',
145+
help='Path to the output topology config file',
146+
)
147+
148+
149+
args = parser.parse_args()
150+
151+
# Load configuration from file
152+
config_params = load_config(args.config)
153+
154+
# Generate and write the fat-tree configuration
155+
topology = generate_fat_tree_config(config_params)
156+
save_yaml(topology, args.output_path)
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
switch_ports_count: 4
2+
3+
link_presets:
4+
edge-host:
5+
latency: 1ns
6+
throughput: 1Gbps
7+
ingress_buffer_size: 4096B
8+
egress_buffer_size: 4096B
9+
aggr-edge:
10+
latency: 1ns
11+
throughput: 10Gbps
12+
ingress_buffer_size: 4096B
13+
egress_buffer_size: 4096B
14+
aggr-core:
15+
latency: 1ns
16+
throughput: 100Gbps
17+
ingress_buffer_size: 4096B
18+
egress_buffer_size: 4096B
19+
20+
switch_presets:
21+
edge:
22+
ecn:
23+
min: 0.2
24+
max: 0.3
25+
probability: 0.5
26+
aggr:
27+
ecn:
28+
min: 0.2
29+
max: 0.3
30+
probability: 0.7
31+
core:
32+
ecn:
33+
min: 0.2
34+
max: 0.3
35+
probability: 1.0
36+
37+
packet_spraying:
38+
type: ecmp

0 commit comments

Comments
 (0)