Repository navigation
Expand file tree
/
Copy pathpipeline.py
More file actions
1765 lines (1518 loc) · 67 KB
/
Copy pathpipeline.py
File metadata and controls
1765 lines (1518 loc) · 67 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
#!/usr/bin/env python3
# Copyright (c) 2026 ETH Zurich.
# All rights reserved.
#
# Use of this source code is governed by a BSD-style license that can be
# found in the LICENSE file.
#
# Author: Leonard Schmidt
"""
RPEG — optimization pipeline entry point.
Reads a YAML configuration file and runs the full optimization pipeline:
baseline evaluation -> local-search optimizer -> report.
Usage:
python pipeline.py --config configs/pod.yaml
See configs/ for annotated example configuration files.
"""
from __future__ import annotations
import argparse
import copy
import os
import random
import re
import sys
import time
import yaml
from typing import Dict, List, Optional, Tuple
# Path setup
_ROOT = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, os.path.join(_ROOT, "RPEG"))
from core.log import init_logging, print_run_header
from core.types import packing_geometry, Allocation, DeviceSpace, Model, Role, TemplateVertex
from ir.dpo import build_DPO_RPEG
from ir.grpo import build_GRPO_RPEG
from ir.plan import ExecutionPlan
from ir.ppo import build_PPO_RPEG
from ir.rpeg import RPEG
from ir.serialize import save_plan
from model.cost import NodeCostModel
from model.hardware import Hardware
from model.memory import _device_peaks, mem_worst_violation, NodeMemoryModel
from model.models import ModelSpec, ModelSuite
from model.workload import Workload
from passes.lower import lower, ExecNodeType
from passes.unroll import unroll
from schedule.scheduler import schedule
from search.cache import EvalCache
from search.evaluate import evaluate_cached, Configuration
from search.inter_model import DEFAULT_T_CAND
from search.moves import build_candidate_directory
from search.optimizer import optimize, polish, OptimizerConfig
from scripts.visualize import dot_to_img, exec_to_dot, rpeg_to_dot, unrolled_to_dot, safe_unlink, trace_to_gantt
# Configuration loading and validation
def _make_yaml_loader():
"""
Return a SafeLoader that accepts bare scientific notation (e.g., 450e9).
PyYAML's default resolver requires a sign character in the exponent (e.g., 4.5e+11) or a decimal
point (e.g., 450.0). This loader relaxes that so users can write natural values like 450e9 or
989e12 directly.
"""
_FLOAT_RE = re.compile(
r'''^(?:
[-+]?(?:[0-9][0-9_]*)\.?[0-9_]*(?:[eE][-+]?[0-9]+)?
|[-+]?\.(?:inf|Inf|INF)
|\.(?:nan|NaN|NAN)
)$''', re.X)
class _Loader(yaml.SafeLoader):
pass
_Loader.add_implicit_resolver("tag:yaml.org,2002:float", _FLOAT_RE,
list("-+0123456789."))
return _Loader
def load_config(path: str) -> Dict:
"""
Load configuration from a yaml file.
:param path: Path to the input yaml file.
:type path: str
:return: Content of the yaml file as a dictionary.
:rtype: Dict
"""
with open(path) as f:
return yaml.load(f, Loader=_make_yaml_loader())
def _require(d: Dict, *keys: str, section: str = "") -> None:
"""
Checks whether the keys are present in the dictionary.
:param d: Input dictionary.
:type d: Dict
:param *keys: Keys to check.
:type *keys: str
:param section: Identifier for the dictionary. Defaults to an empty string.
:type section: str
:raises ValueError: If a key is not present in the dictionary.
"""
prefix = f"[{section}] " if section else ""
for k in keys:
if k not in d:
raise ValueError(f"{prefix}Missing required field: '{k}'")
def validate_config(cfg: Dict) -> None:
"""
Validate a configuration.
:param cfg: Dictionary with the configuration.
:type cfg: Dict
:raises ValueError: If the validated constraints are not met.
"""
_require(cfg, "hardware", "models", "workload", "optimizer")
# Hardware
hw = cfg["hardware"]
_require(hw, "racks", "nodes_per_rack", "gpus_per_node",
"flops_per_gpu", "memory_per_gpu", "memory_bandwidth",
section="hardware")
if hw["racks"] < 1 or hw["nodes_per_rack"] < 1 or hw["gpus_per_node"] < 1:
raise ValueError("[hardware] racks, nodes_per_rack, gpus_per_node must all be >= 1")
for f in ("alpha_coll_g", "beta_coll_g", "alpha_coll_n", "beta_coll_n",
"alpha_coll_r", "beta_coll_r", "alpha_p2p_g", "beta_p2p_g",
"alpha_p2p_n", "beta_p2p_n", "alpha_p2p_r", "beta_p2p_r"):
_require(hw, f, section="hardware")
# Policy — read early as model validation can depend on algorithm
algo = cfg.get("policy", {}).get("algorithm", "ppo")
# Models — required roles depend on algorithm:
# PPO: actor, critic, reward (+ reference = actor architecture)
# GRPO: actor, reward (no critic)
# DPO: actor (no critic, no reward)
models = cfg["models"]
if algo == "dpo":
required_roles = ("actor",)
elif algo == "grpo":
required_roles = ("actor", "reward")
else:
required_roles = ("actor", "critic", "reward")
for role in required_roles:
_require(models, role, section="models")
_require(models[role], "layers", "hidden_dim", "ffn_dim", "vocab_size",
section=f"models.{role}")
# Workload
w = cfg["workload"]
_require(
w,
"batch_size",
"samples_per_prompt",
"context_length",
"generation_length",
section = "workload"
)
# Optimizer
o = cfg["optimizer"]
_require(o, "k_eval", section="optimizer")
if o["k_eval"] < 1:
raise ValueError("[optimizer] k_eval must be >= 1")
# Policy (optional section — validated only if present)
algo = cfg.get("policy", {}).get("algorithm", "ppo")
if algo not in ("ppo", "grpo", "dpo"):
raise ValueError(
f"[policy] Unsupported algorithm '{algo}'. Currently supported: 'ppo', 'grpo', 'dpo'."
)
# Staleness candidates (optional)
sc = o.get("staleness_candidates")
if sc is not None:
valid_names = {"actor", "critic", "ac_share"}
for name in sc:
if name not in valid_names:
raise ValueError(
f"[optimizer.staleness_candidates] Unknown model '{name}'. "
f"Valid names: {sorted(valid_names)}"
)
# Object builders
def build_device_space(cfg: Dict) -> DeviceSpace:
"""
Initialize the DeviceSpace class from the configuration dictionary.
:param cfg: Configuration dictionary.
:type cfg: Dict
:return: Initialized DeviceSpace class.
:rtype: DeviceSpace
"""
hw = cfg["hardware"]
return DeviceSpace(R=hw["racks"], N=hw["nodes_per_rack"], G=hw["gpus_per_node"])
def build_hardware(cfg: Dict) -> Hardware:
"""
Initialize the Hardware class from the configuration dictionary.
:param cfg: Configuration dictionary.
:type cfg: Dict
:return: Initialized Hardware class.
:rtype: Hardware
"""
hw = cfg["hardware"]
return Hardware(
flops_per_gpu = hw["flops_per_gpu"],
memory_per_gpu = hw["memory_per_gpu"],
memory_bandwidth = hw["memory_bandwidth"],
alpha_coll_g = hw["alpha_coll_g"],
beta_coll_g = hw["beta_coll_g"],
alpha_coll_n = hw["alpha_coll_n"],
beta_coll_n = hw["beta_coll_n"],
alpha_coll_r = hw["alpha_coll_r"],
beta_coll_r = hw["beta_coll_r"],
alpha_p2p_g = hw["alpha_p2p_g"],
beta_p2p_g = hw["beta_p2p_g"],
alpha_p2p_n = hw["alpha_p2p_n"],
beta_p2p_n = hw["beta_p2p_n"],
alpha_p2p_r = hw["alpha_p2p_r"],
beta_p2p_r = hw["beta_p2p_r"],
gemm_m_half = hw.get("gemm_m_half", 5.0),
gemm_k_half = hw.get("gemm_k_half", 0.0),
decode_bw_eff = hw.get("decode_bw_eff", 0.15),
non_gemm_constant = hw.get("non_gemm_constant", 0.0),
non_gemm_scaling = hw.get("non_gemm_scaling", 0.0),
non_gemm_batch_cal = hw.get("non_gemm_batch_cal", 64),
)
def build_suite(cfg: Dict) -> ModelSuite:
"""
Initialze the models and their specifications from the configuration dictionary.
:param cfg: Configuration dictionary.
:type cfg: Dict
:return: Initialized suite of models.
:rtype: ModelSuite
"""
m = cfg["models"]
algo = cfg.get("policy", {}).get("algorithm", "ppo")
def _spec(role: str) -> ModelSpec:
"""
Set the model specification according to its role.
:param role: Model role.
:type role: str
:return: Model specification.
:rtype: ModelSpec
"""
s = m[role]
return ModelSpec(
L = s["layers"],
d = s["hidden_dim"],
d_ff = s["ffn_dim"],
V = s["vocab_size"],
d_head = s.get("head_dim", 128)
)
actor_spec = _spec("actor")
# Reference always shares the actor architecture.
specs = {
Model.ACTOR: actor_spec,
Model.REFERENCE: actor_spec,
}
if algo in ("ppo", "grpo"):
# PPO and GRPO use a reward model; PPO also has a critic.
specs[Model.REWARD] = _spec("reward")
if algo == "ppo":
specs[Model.CRITIC] = _spec("critic")
# Fused actor-critic uses actor architecture - set default here, will later be disabled if
# necessary.
specs[Model.AC_SHARE] = actor_spec
# DPO: only actor + reference — no reward, no critic.
return ModelSuite(specs)
def build_workload(cfg: Dict) -> Workload:
"""
Initialize the Workload class from the configuration dictionary.
:param cfg: Configuration dictionary.
:type cfg: Dict
:return: Initialized Workload class.
:rtype: Workload
"""
w = cfg["workload"]
gq = w.get("gen_quantiles", None)
gen_quantiles = tuple(tuple(q) for q in gq) if gq else None
return Workload(
B = w["batch_size"],
K = w["samples_per_prompt"],
S = w["context_length"],
T = w["generation_length"],
gen_quantiles = gen_quantiles,
)
def build_optimizer_config(
cfg: Dict,
allow_ac_share: bool,
algorithm: str = "ppo"
) -> OptimizerConfig:
"""
Initialize the optimizer configuration from the general configuration dictionary.
:param cfg: Configuration dictionary.
:type cfg: Dict
:param allow_ac_share: Flag indicating whether the actor and critic can share parts of their
weights.
:type allow_ac_share: bool
:param algorithm: Policy optimization algorithm to use. Defaults to 'ppo'.
:type algorithm: str
:return: Initialized OptimizerConfig class.
:rtype: OptimizerConfig
"""
o = cfg["optimizer"]
# Build T_cand
sc = o.get("staleness_candidates")
if algorithm in ("grpo", "dpo"):
# GRPO/DPO: only actor is trainable — no critic, no AC-share.
t_cand = {Model.ACTOR: {0, 1}}
if sc is not None and "actor" in sc:
t_cand[Model.ACTOR] = set(sc["actor"])
elif sc is None and allow_ac_share:
# Use the built-in defaults unchanged.
t_cand = None
else:
# Start from built-in defaults and apply user overrides.
_name_to_model = {
"actor": Model.ACTOR,
"critic": Model.CRITIC,
"ac_share": Model.AC_SHARE,
}
t_cand = copy.deepcopy(DEFAULT_T_CAND)
if sc is not None:
for name, vals in sc.items():
t_cand[_name_to_model[name]] = set(vals)
if not allow_ac_share:
# Empty set -> _iter_staleness yields no AC-share combinations.
t_cand[Model.AC_SHARE] = set()
return OptimizerConfig(
K_x = o.get("k_x", 6),
T_max = o.get("t_max", 50),
E_max = o.get("e_max", 5000),
T_init = o.get("t_init", 0.30),
T_warm = o.get("t_warm", 0.10),
T_min = o.get("t_min", 0.03),
E_struct = o.get("e_struct", 400),
T_cand = t_cand,
lambda_ac = o.get("lambda_ac", 0.15),
score_eps = o.get("score_eps", 1e-4),
stag_window = o.get("stag_window", 20),
alpha_pair = o.get("alpha_pair", 0.28),
max_throughput_loss = o.get("max_throughput_loss", 0.0),
algorithm = algorithm,
# Ablation flags
fixed_mb = o.get("fixed_mb", False),
uniform_vertex = o.get("uniform_vertex", False),
no_polish = o.get("no_polish", False),
hard_oom = o.get("hard_oom", False),
geometric_cooling = o.get("geometric_cooling", False)
)
# Baseline plan constructors
def build_valid_baseline_plan(
rpeg: RPEG,
device_space: DeviceSpace,
suite: ModelSuite,
workload: Workload,
cost_model: NodeCostModel,
mem_model: NodeMemoryModel
) -> ExecutionPlan:
"""
Greedy-starting point: select for each vertex the best local plan.
Baseline plan guaranteed to be valid (divisibility, packing, memory).
Picks the fastest valid strategy for each vertex from the candidate directory (index-0 after
ascending sort by per-node execution time) and places it at origin (0,0,0).
:param rpeg: RLM Pipeline Execution Graph.
:type rpeg: RPEG
:param device_space: Available devices and their hierarchy.
:type device_space: DeviceSpace
:param suite: Model hyperparameters.
:type suite: ModelSuite
:param workload: Workload settings.
:type workload: Workload
:param cost_model: Cost model to estimate the time for the graph nodes.
:type cost_model: NodeCostModel
:param mem_model: Memory model for the graph nodes.
:type mem_model: NodeMemoryModel
:return: Execution plan.
:rtype: ExecutionPlan
:raises RuntimeError: If no valid candidates for a vertex are found.
"""
# Returns per-node strategies that are valid and mem_feasible
table, _ = build_candidate_directory(rpeg, suite, device_space, workload, cost_model, mem_model)
sigma, pi, mb = {}, {}, {}
for v in rpeg.vertices:
if not table[v]:
raise RuntimeError(f"No valid candidates for vertex {v.id}; cannot construct baseline plan")
dp, pp, tp, r_ext, n_ext, mb_v, _ = table[v][0] # greedy per-vertex selection
geom = packing_geometry(dp, pp, tp, device_space.G)
g_ext = geom[0] if geom else min(pp, device_space.G // tp) * tp
sigma[v] = Allocation(origin=(0, 0, 0), extent=(r_ext, n_ext, g_ext))
pi[v] = (dp, pp, tp)
mb[v] = mb_v
return ExecutionPlan(sigma, pi, mb)
# Concurrent-aware baseline.
def _find_concurrent_groups(rpeg: RPEG) -> List[List[TemplateVertex]]:
"""
Partition RPEG vertices into sequential phases of mutually-independent vertices.
Uses the data-edge DAG to compute the longest-path depth of each vertex. Vertices at the same
depth are mutually independent (anti-chain) and can execute concurrently on disjoint device
subsets.
:param rpeg: RLM Pipeline Execution Graph.
:type rpeg: RPEG
:return: List of groups ordered by depth (phase 0, phase 1, ...).
:rtype: List[List[TemplateVertex]]
"""
# Build adjacency from data edges only (pub edges are inter-step).
vset = set(rpeg.vertices)
children: dict[TemplateVertex, list[TemplateVertex]] = {v: [] for v in rpeg.vertices}
parents: dict[TemplateVertex, list[TemplateVertex]] = {v: [] for v in rpeg.vertices}
for e in rpeg.data_edges:
if e.src in vset and e.dst in vset:
children[e.src].append(e.dst)
parents[e.dst].append(e.src)
# Longest-path depth: depth(v) = 0 if no parents, else max(depth(p) for p in parents) + 1.
depth: dict[TemplateVertex, int] = {}
def _depth(v: TemplateVertex) -> int:
"""
Determine the depth of a vertex if it was not already computed.
:param v: Vertex to determine the depth for.
:type v: TemplateVertex
:return: Depth of the vertex.
:rtype: int
"""
if v in depth:
return depth[v]
if not parents[v]:
depth[v] = 0
else:
depth[v] = max(_depth(p) for p in parents[v]) + 1
return depth[v]
for v in rpeg.vertices:
_depth(v)
# Group by depth.
max_d = max(depth.values()) if depth else 0
groups: list[list[TemplateVertex]] = [[] for _ in range(max_d + 1)]
for v in rpeg.vertices:
groups[depth[v]].append(v)
return groups
def _enumerate_device_subsets(device_space: DeviceSpace) -> List[Allocation]:
"""
Enumerate useful contiguous device subsets for concurrent placement.
Returns allocations representing natural partition units: full space, multi-rack slices,
per-rack slices, multi-node slices, per-node slices, half-node slices.
Multi-node and multi-rack slices use powers-of-2 groupings aligned
to their size (e.g. nodes 0-1, 2-3, 0-3, 4-7, ...).
:param device_space: Available devices and their hierarchy.
:type device_space: DeviceSpace
:return: List of possible allocations in the order described above.
:rtype: List[Allocation]
"""
R, N, G = device_space.R, device_space.N, device_space.G
seen: set[tuple[tuple[int,int,int], tuple[int,int,int]]] = set()
subsets: list[Allocation] = []
def _add(origin: Tuple[int, int, int], extent: Tuple[int, int, int]) -> None:
"""
Add an allocation to the list if it is not already part of the list.
:param origin: Start device of the allocation.
:type origin: Tuple[int, int, int]
:param extent: Range of the allocation in all three dimensions.
:type extent: Tuple[int, int, int]
"""
key = (origin, extent)
if key not in seen:
seen.add(key)
subsets.append(Allocation(origin=origin, extent=extent))
# Full device space
_add((0, 0, 0), (R, N, G))
# Multi-rack slices (powers-of-2 rack groups, each with all N nodes)
rk = 1
while rk <= R:
if rk < R: # skip full (already added)
for r0 in range(0, R, rk):
if r0 + rk <= R:
_add((r0, 0, 0), (rk, N, G))
rk *= 2
# Within each rack: multi-node slices (powers-of-2 node groups)
for r in range(R):
nk = 1
while nk <= N:
for n0 in range(0, N, nk):
if n0 + nk <= N:
_add((r, n0, 0), (1, nk, G))
nk *= 2
# Half-node slices (G/2 GPUs) — only if G is even and >= 4
if G >= 4 and G % 2 == 0:
half = G // 2
for r in range(R):
for n in range(N):
_add((r, n, 0), (1, 1, half))
_add((r, n, half), (1, 1, half))
return subsets
def _allocs_disjoint(allocs: List[Allocation]) -> bool:
"""
Return True if all allocations in the list occupy non-overlapping GPU sets.
:param allocs: List of allocations to check.
:type allocs: List[Allocation]
:return: Flag indicating whether the allocations occupy non-overlapping GPU sets.
:rtype: bool
"""
seen: set[tuple[int, int, int]] = set()
for a in allocs:
ro, no, go = a.origin
re, ne, ge = a.extent
for r in range(ro, ro + re):
for n in range(no, no + ne):
for g in range(go, go + ge):
dev = (r, n, g)
if dev in seen:
return False
seen.add(dev)
return True
def _best_strategy_in_alloc(
v: TemplateVertex,
table: Dict,
alloc: Allocation,
device_space: DeviceSpace,
mem_model: Optional[NodeMemoryModel] = None
) -> Optional[Tuple]:
"""
Find the fastest memory-feasible strategy for vertex v within the given allocation.
:param v: Vertex to find the strategy for.
:type v: TemplateVertex
:param table: Dictionary with a list of strategies for the vertices.
:type table: Dict
:param alloc: Potential hardware that can be used for the strategy.
:type alloc: Allocation
:param device_space: Available devices and their hierarchy.
:type device_space: DeviceSpace
:param mem_model: Memory model for the graph nodes. Defaults to None.
:type mem_model: Optional[NodeMemoryModel]
:return: Tuple of (dp, pp, tp, r_ext, n_ext, mb, time, alloc_with_correct_origin) or None if no
strategy is found.
:rtype: Optional[Tuple]
"""
from search.moves import _make_mock_node
G = device_space.G
a_ro, a_no, a_go = alloc.origin
a_re, a_ne, a_ge = alloc.extent
for strat_with_time in table[v]: # sorted ascending by time
dp, pp, tp, r_ext, n_ext, mb_v, t = strat_with_time
# Check that the strategy's extent fits within the allocation.
if r_ext > a_re or n_ext > a_ne:
continue
geom = packing_geometry(dp, pp, tp, G)
if geom is None:
continue
g_ext_strat, _ = geom
if g_ext_strat > a_ge:
continue
# Compute a valid origin within the allocation bounds.
r_o = a_ro
n_o = a_no
g_o = a_go
if r_o + r_ext > a_ro + a_re:
continue
if n_o + n_ext > a_no + a_ne:
continue
if g_o + g_ext_strat > a_go + a_ge:
continue
fitted_alloc = Allocation(origin=(r_o, n_o, g_o), extent=(r_ext, n_ext, g_ext_strat))
# Per-vertex memory feasibility check (catches single-vertex OOM).
# Full plan-level OOM is caught after plan assembly in build_concurrent_baseline_plan.
if mem_model is not None:
mock = _make_mock_node(v, dp, pp, tp, r_ext, n_ext, mb_v, g_ext_strat)
if not mem_model.is_locally_feasible(mock):
continue
return (dp, pp, tp, r_ext, n_ext, mb_v, t, fitted_alloc)
return None # no strategy fits
def _enumerate_group_partitions(
group: List[TemplateVertex],
device_space: DeviceSpace,
) -> List[List[Allocation]]:
"""
Enumerate partition schemes for a concurrent group.
Returns a list of partition options. Each option is a list of Allocations, one per vertex in the
group (same order as the group list).
Includes: full serialization, 2-way splits, and k-way splits.
Uses partition-scheme enumeration: enumerate allocation sizes (how many nodes each group gets)
rather than all triples of positioned subsets. Two allocations with the same extent but
different origin produce the same score (_best_strategy_in_alloc only depends on extent, not
origin), so enumerating positions is redundant. This reduces k=3 candidates from O(S³) to
O(log²(N)) — e.g. 2M → ~250 at 128 GPUs.
:param group: List of vertices in the concurrent group.
:type group: List[TemplateVertex]
:param device_space: Available devices and their hierarchy.
:type device_space: DeviceSpace
:return: List of partition options as described above.
:rtype: List[List[Allocation]]
"""
from itertools import permutations as _perms
R, N, G = device_space.R, device_space.N, device_space.G
k = len(group)
# Always include: full serialization (everyone gets the full device space).
full_alloc = Allocation(origin=(0, 0, 0), extent=(R, N, G))
partitions: list[list[Allocation]] = [[full_alloc] * k]
if k == 1:
return partitions # nothing to split
total_nodes = R * N
# Power-of-2 node counts that fit in the cluster
node_sizes: list[int] = []
s = 1
while s <= total_nodes:
node_sizes.append(s)
s *= 2
def _extent_for_nodes(n_nodes: int) -> Tuple[int, int, int]:
"""
Compute (r_ext, n_ext, g_ext) for an allocation of n_nodes nodes.
:param n_nodes: Number of nodes within the allocation.
:type n_nodes: int
:return: Tuple with the extent in the device space.
:rtype: Tuple[int, int, int]
"""
if n_nodes <= N:
return (1, n_nodes, G)
else:
r_needed = (n_nodes + N - 1) // N # ceil(n_nodes / N)
return (r_needed, N, G)
def _pack_disjoint(sizes: List[int]) -> Optional[List[Allocation]]:
"""
Greedily pack disjoint allocations of given node-sizes.
Returns a list of Allocations (one per size) or None if they do not fit. Packing is
first-fit aligned along node dimension, spilling to next rack.
:param sizes: List of vertex sizes.
:type sizes: List[int]
:return: List of allocations or None if they do not fit.
:rtype: Optional[List[Allocation]]
"""
allocs: list[Allocation] = []
cursor_r, cursor_n = 0, 0
for n_nodes in sizes:
r_ext, n_ext, _ = _extent_for_nodes(n_nodes)
if r_ext > 1:
# Multi-rack allocation: start at a rack-aligned boundary.
if cursor_n > 0:
cursor_r += 1
cursor_n = 0
if cursor_r % r_ext != 0:
cursor_r += r_ext - (cursor_r % r_ext)
if cursor_r + r_ext > R:
return None
allocs.append(Allocation(origin=(cursor_r, 0, 0), extent=(r_ext, N, G)))
cursor_r += r_ext
cursor_n = 0
else:
# Single-rack: align to n_ext boundary within current rack.
if cursor_n % n_ext != 0:
cursor_n += n_ext - (cursor_n % n_ext)
if cursor_n + n_ext > N:
cursor_r += 1
cursor_n = 0
if cursor_r >= R:
return None
allocs.append(Allocation(origin=(cursor_r, cursor_n, 0), extent=(1, n_ext, G)))
cursor_n += n_ext
return allocs
# Build partitions from size schemes
seen: set[tuple] = set() # deduplicate by (extent_per_vertex) tuple
def _try_disjoint(sizes: List[int]) -> None:
"""
Try all vertex permutations of a fully-disjoint size scheme.
Updates the seen set for checking whether the partition has already been processed and
updates the partition list.
:param sizes: List of vertex sizes.
:type sizes: List[int]
"""
packed = _pack_disjoint(sizes)
if packed is None:
return
for perm in _perms(range(k)):
key = tuple(packed[perm[i]].extent for i in range(k))
if key in seen:
continue
seen.add(key)
partitions.append([packed[perm[i]] for i in range(k)])
def _try_2way(s_solo: int, s_shared: int, k: int) -> None:
"""
Try 2-way splits: one vertex solo, the rest serialized together.
Updates the seen set for checking whether the partition has already been processed and
updates the partition list.
:param s_solo: Size of a solo allocation for a single graph node.
:type s_solo: int
:param s_shared: Size of the serialized allocation.
:type s_shared: int
:param k: Number of groups.
:type k: int
"""
packed = _pack_disjoint([s_solo, s_shared])
if packed is None:
return
solo_alloc, shared_alloc = packed[0], packed[1]
for solo_idx in range(k):
part = [shared_alloc] * k # all share by default (same object = serialized)
part[solo_idx] = solo_alloc
key = tuple(a.extent for a in part) + (solo_idx,)
if key in seen:
continue
seen.add(key)
partitions.append(part)
# Also try reversed assignment
part2 = [solo_alloc] * k # all share the solo alloc (serialized)
# Actually: reverse means solo gets the shared region, others get the solo region
packed_rev = _pack_disjoint([s_shared, s_solo])
if packed_rev is None:
continue
part2 = [packed_rev[1]] * k
part2[solo_idx] = packed_rev[0]
key2 = tuple(a.extent for a in part2) + (solo_idx,)
if key2 in seen:
continue
seen.add(key2)
partitions.append(part2)
if k == 2:
for s1 in node_sizes:
for s2 in node_sizes:
if s1 + s2 <= total_nodes:
_try_disjoint([s1, s2])
# Half-node splits (G/2 GPUs each, same node)
if G >= 4 and G % 2 == 0:
half = G // 2
for r in range(R):
for n in range(N):
a = Allocation(origin=(r, n, 0), extent=(1, 1, half))
b = Allocation(origin=(r, n, half), extent=(1, 1, half))
partitions.append([a, b])
partitions.append([b, a])
elif k == 3:
# 3-way disjoint: all three on separate partitions
for s1 in node_sizes:
for s2 in node_sizes:
if s1 + s2 > total_nodes:
continue
for s3 in node_sizes:
if s1 + s2 + s3 <= total_nodes:
_try_disjoint([s1, s2, s3])
# 2-way: one vertex solo, two serialized on the same partition
for s_solo in node_sizes:
for s_shared in node_sizes:
if s_solo + s_shared <= total_nodes:
_try_2way(s_solo, s_shared, k)
else:
# k > 3: full serialization (already added) + per-node splits
if total_nodes >= k:
node_allocs = []
for r in range(R):
for n in range(N):
node_allocs.append(Allocation(origin=(r, n, 0), extent=(1, 1, G)))
if len(node_allocs) >= k:
partitions.append(node_allocs[:k])
return partitions
def _score_group_partition(
group: List[TemplateVertex],
allocs: List[Allocation],
table: Dict,
device_space: DeviceSpace,
mem_model: Optional[NodeMemoryModel] = None
) -> Tuple[float, Optional[Dict]]:
"""
Score a partition for a concurrent group.
For vertices sharing the same allocation (serialized): sum their times. For vertices on disjoint
allocations: maximum of their times.
Returns (group_time, assignment_dict) where assignment_dict maps vertex -> (dp, pp, tp, mb,
Allocation) or None if infeasible.
:param group: List of vertices in the concurrent group.
:type group: List[TemplateVertex]
:param allocs: List of allocations for the concurrent group.
:type allocs: List[Allocation]
:param table: Dictionary with a list of strategies for the vertices.
:type table: Dict
:param device_space: Available devices and their hierarchy.
:type device_space: DeviceSpace
:param mem_model: Memory model for the graph nodes. Defaults to None.
:type mem_model: Optional[NodeMemoryModel]
:return: Tuple of execution time of the group and assignment dictionary as described above or
'inf', None, if infeasible.
:rtype: Tuple[float, Optional[Dict]]
"""
# Group vertices by their allocation (same allocation = serialized).
from collections import defaultdict
alloc_groups: dict[Allocation, list[tuple[TemplateVertex, int]]] = defaultdict(list)
for idx, (v, a) in enumerate(zip(group, allocs)):
alloc_groups[a].append((v, idx))
assignment: dict[TemplateVertex, tuple] = {}
alloc_times: list[float] = []
for alloc, members in alloc_groups.items():
serial_time = 0.0
for v, idx in members:
result = _best_strategy_in_alloc(v, table, alloc, device_space, mem_model)
if result is None:
return float('inf'), None # infeasible
dp, pp, tp, r_ext, n_ext, mb_v, t, fitted_alloc = result
assignment[v] = (dp, pp, tp, mb_v, fitted_alloc)
serial_time += t
alloc_times.append(serial_time)
# Vertices on disjoint allocations run concurrently: group_time = max.
group_time = max(alloc_times)
return group_time, assignment
def build_concurrent_baseline_plan(
rpeg: RPEG,
device_space: DeviceSpace,
suite: ModelSuite,
workload: Workload,
cost_model: NodeCostModel,
mem_model: NodeMemoryModel
) -> Tuple[ExecutionPlan, bool]:
"""
Concurrency-aware baseline: partitions device space among independent vertices.
Identifies concurrent groups (anti-chains) from the RPEG DAG, enumerates device partitions per
group, and picks the combination that minimizes the estimated step time (sum of per-group
bottleneck times).
Falls back to build_valid_baseline_plan if no concurrent plan improves on the serialized
baseline.
:param rpeg: RLM Pipeline Execution Graph.
:type rpeg: RPEG
:param device_space: Available devices and their hierarchy.
:type device_space: DeviceSpace
:param suite: Model hyperparameters.
:type suite: ModelSuite
:param workload: Workload settings.
:type workload: Workload
:param cost_model: Cost model to estimate the time for the graph nodes.
:type cost_model: NodeCostModel
:param mem_model: Memory model for the graph nodes.
:type mem_model: NodeMemoryModel
:return: Tuple of the execution plan and a flag indicating whether the plan was found by the
concurrency-aware baseline.
:rtype: Tuple[ExecutionPlan, bool]
"""
table, _ = build_candidate_directory(rpeg, suite, device_space, workload, cost_model, mem_model)
groups = _find_concurrent_groups(rpeg)
print(f"\n Concurrent groups ({len(groups)} phases):")
for i, g in enumerate(groups):
print(f" phase {i}: [{', '.join(v.id for v in g)}]")
# For each group, enumerate all feasible partitions ranked by concurrency (most concurrent
# first, then by time within same concurrency level). This lets us cascade: if 3-way concurrent
# OOMs the assembled plan, try 2-way, then serialized — without falling back to a weak greedy
# baseline.
ranked_per_group: list[list[tuple[float, dict, str]]] = [] # per group: [(time, assign, label), ...]
for gi, group in enumerate(groups):
partitions = _enumerate_group_partitions(group, device_space)
candidates: list[tuple[int, float, dict, str]] = [] # (neg_concurrency, time, assign, label)
for pi_idx, allocs in enumerate(partitions):
t, assign = _score_group_partition(group, allocs, table, device_space, mem_model)
if assign is None:
continue
unique_allocs = set(id(a) for a in allocs)
n_concurrent = len(unique_allocs)
if n_concurrent == 1:
label = "serialized"
elif n_concurrent == len(group):
label = f"{len(group)}-way concurrent"
else:
label = f"{n_concurrent}-way concurrent"
# Sort key: prefer more concurrency (negative for descending), then lower time.
candidates.append((-n_concurrent, t, assign, label))
candidates.sort(key=lambda x: (x[0], x[1])) # most concurrent first, fastest within tier
ranked_per_group.append([(t, assign, label) for _, t, assign, label in candidates])
if not candidates:
print(f" ⚠ No feasible partition for concurrent group {gi}: "
f"[{', '.join(v.id for v in group)}]. Falling back to greedy baseline.")
return build_valid_baseline_plan(rpeg, device_space, suite, workload, cost_model, mem_model), False
# Assemble the plan using the best (most concurrent) partition per group. If the assembled plan
# is OOM, cascade: try the next-best partition for each group until we find a memory-feasible
# combination. Strategy: start with index [0, 0, ..., 0] (best per group). On OOM, increment the
# index of the group with the largest allocation footprint (most likely to cause the OOM). This
# avoids enumerating all combinations.
group_indices = [0] * len(groups)
max_attempts = sum(len(r) for r in ranked_per_group) # upper bound
for attempt in range(max_attempts):
# Check if current indices are valid
valid = all(group_indices[gi] < len(ranked_per_group[gi])
for gi in range(len(groups)))
if not valid:
break
# Assemble plan from current indices
sigma, pi_map, mb_map = {}, {}, {}