Coverage for streamwise/model_provisioner/hexgen.py: 59%

259 statements  

« prev     ^ index     » next       coverage.py v7.15.4, created at 2026-08-09 04:47 +0000

1""" 

2HexGen algorithm for the StreamWise workflow allocation problem. 

3 

4Reference: https://arxiv.org/abs/2311.11514 

5 

6HexGen treats each model in the workflow as an independent component for optimization. 

7It tracks metrics per model and optimizes models sequentially according to MODEL_ORDER. 

8When a model's metric converges (stops dropping), it moves to the next model. 

9After the last model converges, it cycles back to the first model and allocates 

10remaining GPUs until exhausted. 

11""" 

12 

13from __future__ import annotations 

14import logging 

15from typing import Optional 

16 

17from sim_types import Result 

18from sim_types import GPUType 

19from sim_types import WorkflowConfig 

20from sim_types import PowerData 

21from sim_types import LatencyData 

22from sim_types import Model 

23from sim_types import ModelAllocation 

24from sim_types import Policy 

25from sim_types import Solver 

26from sim_types import MODEL_ORDER 

27 

28from utils import simplify_model_allocations 

29 

30from evaluator import calc_used_gpus 

31from evaluator import evaluate_model_allocation 

32 

33from .greedy import GreedyAllocator 

34 

35from actions import gen_actions 

36from actions import choose_action 

37from actions import apply_action 

38 

39from .policies import HEXGEN_POLICY 

40from .policies import MAX_ITERATIONS 

41from .policies import USE_ALL_GPUS 

42 

43 

44def _get_model_order(workflow: WorkflowConfig) -> list[Model]: 

45 """Get ordered list of models in the workflow, sorted by MODEL_ORDER.""" 

46 return sorted( 

47 [m for m in workflow.models if m in MODEL_ORDER], 

48 key=lambda m: MODEL_ORDER[m], 

49 ) 

50 

51 

52class HexGenAllocator(GreedyAllocator): 

53 """ 

54 HexGen-style allocator that optimizes models one at a time, 

55 sequentially following MODEL_ORDER. 

56 

57 Reference: https://arxiv.org/abs/2311.11514 

58 

59 Key differences from GreedyAllocator: 

60 1. Each model is treated as an independent optimization target. 

61 2. Per-model metrics are tracked separately. 

62 3. Models are optimized in MODEL_ORDER sequence. When a model's metric 

63 converges, it moves to the next model. After the last model converges, 

64 it cycles back to the first and allocates remaining GPUs. 

65 """ 

66 

67 def __init__( 

68 self, 

69 workflow: WorkflowConfig, 

70 latency_data: LatencyData, 

71 power_data: Optional[PowerData] = None, 

72 policy: Policy = HEXGEN_POLICY, 

73 ) -> None: 

74 super().__init__( 

75 workflow, 

76 latency_data, 

77 power_data, 

78 policy, 

79 ) 

80 assert self.policy.solver == Solver.HEXGEN 

81 

82 def _pick_from_single_device_mapping( 

83 self, 

84 num_gpus: int, 

85 gpu_type: GPUType, 

86 verbose: bool = False, 

87 allow_removal: bool = False, 

88 allow_merging: bool = False, 

89 look_ahead_replicas: int = 3, 

90 ) -> Result: 

91 """ 

92 HexGen-style allocation for a single GPU type (>8 GPUs). 

93 Optimizes models one at a time following MODEL_ORDER. 

94 """ 

95 from constants import NUM_GPUS_PER_SERVER 

96 

97 assert num_gpus >= NUM_GPUS_PER_SERVER[gpu_type] 

98 

99 # Initialize allocations (same as GreedyAllocator) 

100 models = self._init_single_device_models(gpu_type) 

101 

102 remaining_gpus = num_gpus - calc_used_gpus(models) 

103 assert 0 <= remaining_gpus <= num_gpus 

104 

105 # --- HexGen per-model sequential optimization --- 

106 model_order = _get_model_order(self.workflow) 

107 per_model_metrics: dict[Model, Optional[float]] = {m: None for m in model_order} 

108 

109 it = 0 

110 current_model_idx = 0 

111 cycles_without_progress = 0 # track full cycles without any improvement 

112 total_models = len(model_order) 

113 

114 while remaining_gpus > 0: 

115 if current_model_idx >= total_models: 

116 # Completed a full cycle, wrap around 

117 current_model_idx = 0 

118 cycles_without_progress += 1 

119 if cycles_without_progress >= 1: 

120 logging.debug( 

121 f"HexGen: No progress after {cycles_without_progress} full cycles.") 

122 break 

123 

124 current_model = model_order[current_model_idx] 

125 

126 if verbose: 

127 print(f"--- HexGen: Optimizing {current_model.value} " 

128 f"(model {current_model_idx + 1}/{total_models}) ---") 

129 

130 # Inner loop: keep optimizing current model until convergence 

131 inner_it = 0 

132 while remaining_gpus > 0: 

133 # Evaluate current state 

134 evaluate_model_allocation( 

135 models=models, 

136 num_gpus={gpu_type: num_gpus}, 

137 workflow=self.workflow, 

138 latency_data=self.latency_data, 

139 power_data=self.power_data, 

140 policy=self.policy, 

141 round_up_cost_to_server=False, 

142 ) 

143 

144 # Generate actions only for the current model 

145 all_actions = gen_actions( 

146 num_gpus={gpu_type: num_gpus}, 

147 latency_data=self.latency_data, 

148 power_data=self.power_data, 

149 workflow=self.workflow, 

150 models=models, 

151 policy=self.policy, 

152 ) 

153 

154 # Filter to actions targeting the current model only 

155 model_actions = [a for a in all_actions if a.model == current_model] 

156 

157 if not model_actions: 

158 logging.debug( 

159 f"HexGen: No actions for {current_model.value} after {inner_it} inner iterations.") 

160 break 

161 

162 best_action = choose_action(model_actions, self.policy.objective) 

163 

164 if not best_action: 

165 logging.debug(f"HexGen: No action selected for {current_model.value}.") 

166 break 

167 

168 new_metric = best_action.get_metric(self.policy.objective) 

169 prev_metric = per_model_metrics[current_model] 

170 

171 if self.policy.objective.is_monotonic() and prev_metric is not None and new_metric >= prev_metric: 

172 msg = ( 

173 f"HexGen: {current_model.value} converged after {inner_it} inner iterations. " 

174 f"Metric: {new_metric:.2f} >= previous {prev_metric:.2f}." 

175 ) 

176 if verbose: 

177 print(msg) 

178 logging.debug(msg) 

179 break 

180 

181 per_model_metrics[current_model] = new_metric 

182 

183 models = apply_action(best_action, models=models) 

184 models = simplify_model_allocations(models) 

185 

186 remaining_gpus = num_gpus - calc_used_gpus(models) 

187 

188 if verbose: 

189 self._print_iteration(it, models, {gpu_type: num_gpus}) 

190 print(f"HexGen: Applied action for {current_model.value}, " 

191 f"metric: {new_metric:.2f}, remaining: {remaining_gpus}") 

192 

193 it += 1 

194 inner_it += 1 

195 

196 if it > MAX_ITERATIONS: 

197 logging.debug(f"HexGen: Reached max iterations ({MAX_ITERATIONS}). Stopping.") 

198 break 

199 

200 if it > MAX_ITERATIONS: 

201 break 

202 

203 current_model_idx += 1 

204 

205 # --- USE_ALL_GPUS: fill remaining GPUs by cycling through MODEL_ORDER --- 

206 remaining_gpus = num_gpus - calc_used_gpus(models) 

207 if USE_ALL_GPUS and remaining_gpus > 0: 

208 models = self._fill_remaining_gpus_single( 

209 models=models, 

210 num_gpus=num_gpus, 

211 gpu_type=gpu_type, 

212 model_order=model_order, 

213 it=it, 

214 verbose=verbose, 

215 ) 

216 

217 # Final evaluation 

218 result = evaluate_model_allocation( 

219 models=models, 

220 num_gpus={gpu_type: num_gpus}, 

221 workflow=self.workflow, 

222 latency_data=self.latency_data, 

223 power_data=self.power_data, 

224 policy=self.policy, 

225 round_up_cost_to_server=True, 

226 ) 

227 

228 if verbose: 

229 self._print_final_allocation( 

230 models=models, 

231 used_devices=result.gpus_used, 

232 total_devices={gpu_type: num_gpus}, 

233 power_data=self.power_data, 

234 total_time_s=result.total_time_s, 

235 ttff_s=result.ttff_s, 

236 first_chunk_time=result.first_chunk_time, 

237 tbf_s=result.tbf_s, 

238 total_energy=result.total_energy if self.power_data else 0.0, 

239 cost=result.cost, 

240 ) 

241 

242 if not self.policy.is_disaggregated(Model.HF): 

243 if models[gpu_type][Model.HF_VAE]: 

244 assert models[gpu_type][Model.HF_VAE][0].get_num_gpus() == 0, \ 

245 "HF_VAE must have 0 GPUs when HF disaggregation is disabled" 

246 if not self.policy.is_disaggregated(Model.FT): 

247 if models[gpu_type][Model.FT_VAE]: 

248 assert models[gpu_type][Model.FT_VAE][0].get_num_gpus() == 0, \ 

249 "FT_VAE must have 0 GPUs when FT disaggregation is disabled" 

250 

251 num_gpus_used = result.gpus_used[gpu_type] 

252 assert num_gpus_used <= num_gpus, f"{num_gpus_used}>{num_gpus} for {gpu_type.value}" 

253 

254 return Result( 

255 total_time_s=result.total_time_s, 

256 models=models, 

257 gpus_used={gpu_type: num_gpus_used}, 

258 gpus_total={gpu_type: num_gpus}, 

259 ttff_s=result.ttff_s, 

260 tbf_s=result.tbf_s, 

261 total_energy=result.total_energy if self.power_data else 0.0, 

262 cost=result.cost, 

263 ) 

264 

265 def _pick_from_both_devices_mapping( 

266 self, 

267 num_gpus: dict[GPUType, int], 

268 verbose: bool = False, 

269 allow_removal: bool = False, 

270 allow_merging: bool = False, 

271 look_ahead_replicas: int = 3, 

272 ) -> Result: 

273 """ 

274 HexGen-style allocation for two GPU types. 

275 Optimizes models one at a time following MODEL_ORDER. 

276 """ 

277 from constants import NUM_GPUS_PER_SERVER 

278 

279 gpu_types = list(num_gpus.keys()) 

280 assert len(gpu_types) == 2 

281 gpu_type1 = gpu_types[0] 

282 gpu_type2 = gpu_types[1] 

283 assert num_gpus[gpu_type1] >= NUM_GPUS_PER_SERVER[gpu_type1] 

284 assert num_gpus[gpu_type2] >= NUM_GPUS_PER_SERVER[gpu_type2] 

285 

286 # Initialize allocations (same as GreedyAllocator) 

287 models = self._init_both_devices_models(gpu_type1, gpu_type2) 

288 

289 remaining_gpus: dict[GPUType, int] = {} 

290 for gpu_type in num_gpus.keys(): 

291 remaining_gpus[gpu_type] = num_gpus[gpu_type] - calc_used_gpus({gpu_type: models[gpu_type]}) 

292 

293 # --- HexGen per-model sequential optimization --- 

294 model_order = _get_model_order(self.workflow) 

295 per_model_metrics: dict[Model, Optional[float]] = {m: None for m in model_order} 

296 

297 if verbose: 

298 evaluate_model_allocation( 

299 models=models, 

300 num_gpus=num_gpus, 

301 workflow=self.workflow, 

302 latency_data=self.latency_data, 

303 power_data=self.power_data, 

304 policy=self.policy, 

305 round_up_cost_to_server=True, 

306 ) 

307 self._print_iteration(0, models, num_gpus) 

308 

309 it = 1 

310 current_model_idx = 0 

311 cycles_without_progress = 0 

312 total_models = len(model_order) 

313 

314 while sum(remaining_gpus.values()) > 0: 

315 if current_model_idx >= total_models: 

316 current_model_idx = 0 

317 cycles_without_progress += 1 

318 if cycles_without_progress >= 1: 

319 logging.debug( 

320 f"HexGen: No progress after {cycles_without_progress} full cycles.") 

321 break 

322 

323 current_model = model_order[current_model_idx] 

324 

325 if verbose: 

326 print(f"--- HexGen: Optimizing {current_model.value} " 

327 f"(model {current_model_idx + 1}/{total_models}) ---") 

328 

329 inner_it = 0 

330 

331 while sum(remaining_gpus.values()) > 0: 

332 evaluate_model_allocation( 

333 models=models, 

334 num_gpus=num_gpus, 

335 workflow=self.workflow, 

336 latency_data=self.latency_data, 

337 power_data=self.power_data, 

338 policy=self.policy, 

339 round_up_cost_to_server=False, 

340 ) 

341 

342 all_actions = gen_actions( 

343 workflow=self.workflow, 

344 latency_data=self.latency_data, 

345 power_data=self.power_data, 

346 num_gpus=num_gpus, 

347 models=models, 

348 policy=self.policy, 

349 ) 

350 

351 # Filter to current model 

352 model_actions = [a for a in all_actions if a.model == current_model] 

353 

354 if not model_actions: 

355 logging.debug( 

356 f"HexGen: No actions for {current_model.value} after {inner_it} inner iterations.") 

357 break 

358 

359 best_action = choose_action(model_actions, self.policy.objective) 

360 

361 if not best_action: 

362 logging.debug(f"HexGen: No action selected for {current_model.value}.") 

363 break 

364 

365 new_metric = best_action.get_metric(self.policy.objective) 

366 prev_metric = per_model_metrics[current_model] 

367 

368 if self.policy.objective.is_monotonic() and prev_metric is not None and new_metric >= prev_metric: 

369 msg = ( 

370 f"HexGen: {current_model.value} converged. " 

371 f"Metric: {new_metric:.2f} >= previous {prev_metric:.2f}." 

372 ) 

373 if verbose: 

374 print(msg) 

375 logging.debug(msg) 

376 break 

377 

378 per_model_metrics[current_model] = new_metric 

379 

380 models = apply_action(best_action, models=models) 

381 models = simplify_model_allocations(models) 

382 

383 remaining_gpus.clear() 

384 for gpu_type in num_gpus.keys(): 

385 remaining_gpus[gpu_type] = num_gpus[gpu_type] - calc_used_gpus({gpu_type: models[gpu_type]}) 

386 

387 if verbose: 

388 self._print_iteration(it, models, num_gpus) 

389 print(f"HexGen: Applied action for {current_model.value}, " 

390 f"metric: {new_metric:.2f}") 

391 print("Remaining devices:") 

392 for gt in remaining_gpus: 

393 print(f" {remaining_gpus[gt]} x {gt.value}") 

394 

395 it += 1 

396 inner_it += 1 

397 

398 if it > MAX_ITERATIONS: 

399 logging.debug(f"HexGen: Reached max iterations ({MAX_ITERATIONS}). Stopping.") 

400 break 

401 

402 if it > MAX_ITERATIONS: 

403 break 

404 

405 current_model_idx += 1 

406 

407 # --- USE_ALL_GPUS: fill remaining GPUs by cycling through MODEL_ORDER --- 

408 remaining_gpus_total = sum( 

409 num_gpus[gt] - calc_used_gpus({gt: models[gt]}) 

410 for gt in num_gpus 

411 ) 

412 if USE_ALL_GPUS and remaining_gpus_total > 0: 

413 models = self._fill_remaining_gpus_multi( 

414 models=models, 

415 num_gpus=num_gpus, 

416 model_order=model_order, 

417 it=it, 

418 verbose=verbose, 

419 ) 

420 

421 # Adjust for no disaggregation 

422 if not self.policy.is_disaggregated(Model.HF): 

423 for models_gpu in models.values(): 

424 for instance_id in range(len(models_gpu[Model.HF_VAE])): 

425 assert models_gpu[Model.HF_VAE][instance_id].get_num_gpus() == 0, \ 

426 "HF_VAE must have 0 GPUs when HF disaggregation is disabled" 

427 if not self.policy.is_disaggregated(Model.FT): 

428 for models_gpu in models.values(): 

429 for instance_id in range(len(models_gpu[Model.FT_VAE])): 

430 assert models_gpu[Model.FT_VAE][instance_id].get_num_gpus() == 0, \ 

431 "FT_VAE must have 0 GPUs when FT disaggregation is disabled" 

432 

433 # Final evaluation 

434 result = evaluate_model_allocation( 

435 models=models, 

436 num_gpus=num_gpus, 

437 workflow=self.workflow, 

438 latency_data=self.latency_data, 

439 power_data=self.power_data, 

440 policy=self.policy, 

441 round_up_cost_to_server=True, 

442 ) 

443 

444 if verbose: 

445 self._print_final_allocation( 

446 models=models, 

447 used_devices=result.gpus_used, 

448 total_devices={ 

449 gpu_type1: num_gpus.get(gpu_type1, 0), 

450 gpu_type2: num_gpus.get(gpu_type2, 0), 

451 }, 

452 power_data=self.power_data, 

453 total_time_s=result.total_time_s, 

454 ttff_s=result.ttff_s, 

455 first_chunk_time=result.first_chunk_time, 

456 tbf_s=result.tbf_s, 

457 total_energy=result.total_energy if self.power_data else 0.0, 

458 cost=result.cost, 

459 ) 

460 

461 assert result.gpus_used[gpu_type1] <= num_gpus.get(gpu_type1, 0), \ 

462 f"{gpu_type1.value}: {result.gpus_used[gpu_type1]} > {num_gpus.get(gpu_type1, 0)}" 

463 assert result.gpus_used[gpu_type2] <= num_gpus.get(gpu_type2, 0), \ 

464 f"{gpu_type2.value}: {result.gpus_used[gpu_type2]} > {num_gpus.get(gpu_type2, 0)}" 

465 

466 return Result( 

467 total_time_s=result.total_time_s, 

468 models=models, 

469 gpus_used=result.gpus_used, 

470 ttff_s=result.ttff_s, 

471 tbf_s=result.tbf_s, 

472 total_energy=result.total_energy if self.power_data else 0.0, 

473 cost=result.cost, 

474 ) 

475 

476 def _fill_remaining_gpus_single( 

477 self, 

478 models: dict[GPUType, dict[Model, list[ModelAllocation]]], 

479 num_gpus: int, 

480 gpu_type: GPUType, 

481 model_order: list[Model], 

482 it: int = 0, 

483 verbose: bool = False, 

484 ) -> dict[GPUType, dict[Model, list[ModelAllocation]]]: 

485 """ 

486 Fill remaining GPUs by cycling through MODEL_ORDER (single GPU type). 

487 Applies any available action per model, ignoring metric convergence. 

488 Stops when all GPUs are used or no model can accept more. 

489 """ 

490 remaining_gpus = num_gpus - calc_used_gpus(models) 

491 total_models = len(model_order) 

492 model_idx = 0 

493 models_exhausted: set[Model] = set() 

494 

495 if verbose: 

496 print(f"--- HexGen: USE_ALL_GPUS fill phase, {remaining_gpus} remaining ---") 

497 

498 while remaining_gpus > 0 and len(models_exhausted) < total_models: 

499 current_model = model_order[model_idx % total_models] 

500 model_idx += 1 

501 

502 if current_model in models_exhausted: 

503 continue 

504 

505 evaluate_model_allocation( 

506 models=models, 

507 num_gpus={gpu_type: num_gpus}, 

508 workflow=self.workflow, 

509 latency_data=self.latency_data, 

510 power_data=self.power_data, 

511 policy=self.policy, 

512 round_up_cost_to_server=False, 

513 ) 

514 

515 all_actions = gen_actions( 

516 num_gpus={gpu_type: num_gpus}, 

517 latency_data=self.latency_data, 

518 power_data=self.power_data, 

519 workflow=self.workflow, 

520 models=models, 

521 policy=self.policy, 

522 ) 

523 model_actions = [a for a in all_actions if a.model == current_model] 

524 

525 if not model_actions: 

526 models_exhausted.add(current_model) 

527 logging.debug(f"HexGen fill: {current_model.value} exhausted (no actions).") 

528 continue 

529 

530 best_action = choose_action(model_actions, self.policy.objective) 

531 if not best_action: 

532 models_exhausted.add(current_model) 

533 logging.debug(f"HexGen fill: {current_model.value} exhausted (no action selected).") 

534 continue 

535 

536 models = apply_action(best_action, models=models) 

537 models = simplify_model_allocations(models) 

538 remaining_gpus = num_gpus - calc_used_gpus(models) 

539 

540 if verbose: 

541 self._print_iteration(it, models, {gpu_type: num_gpus}) 

542 print(f"HexGen fill: Allocated to {current_model.value}, remaining: {remaining_gpus}") 

543 

544 it += 1 

545 if it > MAX_ITERATIONS: 

546 logging.debug(f"HexGen fill: Reached max iterations ({MAX_ITERATIONS}). Stopping.") 

547 break 

548 

549 return models 

550 

551 def _fill_remaining_gpus_multi( 

552 self, 

553 models: dict[GPUType, dict[Model, list[ModelAllocation]]], 

554 num_gpus: dict[GPUType, int], 

555 model_order: list[Model], 

556 it: int = 0, 

557 verbose: bool = False, 

558 ) -> dict[GPUType, dict[Model, list[ModelAllocation]]]: 

559 """ 

560 Fill remaining GPUs by cycling through MODEL_ORDER (multi GPU type). 

561 Applies any available action per model, ignoring metric convergence. 

562 Stops when all GPUs are used or no model can accept more. 

563 """ 

564 total_remaining = sum( 

565 num_gpus[gt] - calc_used_gpus({gt: models[gt]}) 

566 for gt in num_gpus 

567 ) 

568 total_models = len(model_order) 

569 model_idx = 0 

570 models_exhausted: set[Model] = set() 

571 

572 if verbose: 

573 print(f"--- HexGen: USE_ALL_GPUS fill phase, {total_remaining} remaining ---") 

574 

575 while total_remaining > 0 and len(models_exhausted) < total_models: 

576 current_model = model_order[model_idx % total_models] 

577 model_idx += 1 

578 

579 if current_model in models_exhausted: 

580 continue 

581 

582 evaluate_model_allocation( 

583 models=models, 

584 num_gpus=num_gpus, 

585 workflow=self.workflow, 

586 latency_data=self.latency_data, 

587 power_data=self.power_data, 

588 policy=self.policy, 

589 round_up_cost_to_server=False, 

590 ) 

591 

592 all_actions = gen_actions( 

593 workflow=self.workflow, 

594 latency_data=self.latency_data, 

595 power_data=self.power_data, 

596 num_gpus=num_gpus, 

597 models=models, 

598 policy=self.policy, 

599 ) 

600 model_actions = [a for a in all_actions if a.model == current_model] 

601 

602 if not model_actions: 

603 models_exhausted.add(current_model) 

604 logging.debug(f"HexGen fill: {current_model.value} exhausted (no actions).") 

605 continue 

606 

607 best_action = choose_action(model_actions, self.policy.objective) 

608 if not best_action: 

609 models_exhausted.add(current_model) 

610 logging.debug(f"HexGen fill: {current_model.value} exhausted (no action selected).") 

611 continue 

612 

613 models = apply_action(best_action, models=models) 

614 models = simplify_model_allocations(models) 

615 total_remaining = sum( 

616 num_gpus[gt] - calc_used_gpus({gt: models[gt]}) 

617 for gt in num_gpus 

618 ) 

619 

620 if verbose: 

621 self._print_iteration(it, models, num_gpus) 

622 print(f"HexGen fill: Allocated to {current_model.value}, remaining: {total_remaining}") 

623 

624 it += 1 

625 if it > MAX_ITERATIONS: 

626 logging.debug(f"HexGen fill: Reached max iterations ({MAX_ITERATIONS}). Stopping.") 

627 break 

628 

629 return models