Coverage for streamwise/model_provisioner/milp.py: 84%

369 statements  

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

1""" 

2MILP formulation for the StreamWise workflow allocation problem. 

3""" 

4 

5from __future__ import annotations 

6 

7import json 

8import logging 

9 

10from typing import Callable 

11from typing import Optional 

12 

13from pyomo.environ import ConcreteModel 

14from pyomo.environ import Var 

15from pyomo.environ import Set 

16from pyomo.environ import Objective as OptObjective 

17from pyomo.environ import Binary 

18from pyomo.environ import NonNegativeIntegers 

19from pyomo.environ import NonNegativeReals 

20from pyomo.environ import minimize 

21from pyomo.environ import SolverFactory 

22from pyomo.environ import ConstraintList 

23 

24from sim_types import GPUType 

25from sim_types import Model 

26from sim_types import WorkflowConfig 

27from sim_types import LatencyData 

28from sim_types import PowerData 

29from sim_types import Result 

30from sim_types import Policy 

31from sim_types import ModelAllocation 

32from sim_types import Objective 

33from sim_types import Solver 

34 

35from models import get_model_allocation 

36 

37from model_allocator import ModelAllocator 

38 

39from constants import DEVICE_OPTIONS 

40from constants import NUM_GPUS_PER_SERVER 

41from constants import SECONDS_IN_HOUR 

42 

43from .policies import STREAMWISE_MILP_POLICY 

44 

45 

46MAX_INSTANCES = 16 

47 

48# Maximum time it can take: 24 hours in seconds 

49# Used for big-M constraints to link TTFF and makespan to instance variables 

50MAX_TIME = 24 * SECONDS_IN_HOUR 

51 

52 

53# Allocators that require quadratic (bilinear) objectives - need Gurobi 

54QUADRATIC_OBJECTIVES = [ 

55 Objective.TTFF_COST, 

56 Objective.TIME_ENERGY, 

57 Objective.ENERGY_COST, 

58] 

59 

60 

61def idx( 

62 gpu_type: GPUType, 

63 model_name: Model, 

64 instance_id: int 

65) -> tuple[str, str, int]: 

66 """Helper to convert enum to index key for instance variables.""" 

67 return (gpu_type.value, model_name.value, instance_id) 

68 

69 

70def dev_idx( 

71 gpu_type: GPUType, 

72 model_name: Model, 

73 instance_id: int, 

74 num_devices: int 

75) -> tuple[str, str, int, int]: 

76 """Helper to convert enum to index key for device variables.""" 

77 return (gpu_type.value, model_name.value, instance_id, num_devices) 

78 

79 

80class MILPAllocator(ModelAllocator): 

81 """ 

82 MILP-based allocator that computes the optimal model allocation. 

83 """ 

84 def __init__( 

85 self, 

86 workflow: WorkflowConfig, 

87 latency_data: LatencyData, 

88 power_data: Optional[PowerData] = None, 

89 policy: Policy = STREAMWISE_MILP_POLICY, 

90 ) -> None: 

91 super().__init__( 

92 workflow, 

93 latency_data, 

94 power_data, 

95 policy, 

96 ) 

97 assert self.policy.solver in [Solver.GUROBI, Solver.HIGHS] 

98 

99 def allocate( 

100 self, 

101 num_gpus: dict[GPUType, int], 

102 verbose: bool = False, 

103 running_cost: bool = False, # If True, cost = active time only; False = makespan x GPUs 

104 max_cost: Optional[float] = None, # If set, adds a constraint to limit cost 

105 max_ttff: Optional[float] = None, # If set, adds a constraint to limit TTFF 

106 max_makespan: Optional[float] = None, # If set, adds a constraint to limit makespan 

107 time_limit: Optional[int] = None, # Time limit for the solver in seconds 

108 save_solution_path: Optional[str] = None, # If set, saves the solution to a JSON file 

109 warm_start_path: Optional[str] = None, # If set, loads a warm start solution from a JSON file 

110 force_num_gpus: bool = False, # If True, adds constraints to force the use of all available GPUs 

111 skip_server_constraint: bool = False, # If True, skips the GPU-per-server constraint 

112 ) -> Result: 

113 """ 

114 Calculate the optimal model allocation and resulting metrics using MILP formulation. 

115 """ 

116 m = ConcreteModel() 

117 

118 # Options: "gurobi", "highs" 

119 solver_name = self.policy.solver.value 

120 

121 # Define index sets 

122 gpu_types = list(num_gpus.keys()) 

123 

124 model_names = [ 

125 Model.GEMMA, 

126 Model.FLUX, 

127 Model.HF, 

128 # Model.HF_VAE, 

129 Model.FT, 

130 # Model.FT_VAE, 

131 # Model.UPSCALER, 

132 Model.OTHERS, 

133 ] 

134 if self.policy.use_upscaler: 

135 model_names.append(Model.UPSCALER) 

136 if self.policy.is_disaggregated(Model.HF): 

137 model_names.append(Model.HF_VAE) 

138 if self.policy.is_disaggregated(Model.FT): 

139 model_names.append(Model.FT_VAE) 

140 

141 # Remove models not in the workflow 

142 model_names = [ 

143 model_name 

144 for model_name in model_names 

145 if model_name in self.workflow.models 

146 ] 

147 

148 instance_ids = list(range(MAX_INSTANCES)) 

149 

150 # The units of work that each model has to do 

151 work: dict[Model, int] = self.workflow.work 

152 

153 # Create Pyomo Sets 

154 m.GPU_TYPES = Set(initialize=[g.value for g in gpu_types]) 

155 m.MODEL_NAMES = Set(initialize=[mn.value for mn in model_names]) 

156 m.INSTANCES = Set(initialize=instance_ids) 

157 

158 # Create index set for device choices: (gpu_type, model_name, instance_id, device_count) 

159 device_index_set = [ 

160 (gpu_type.value, model_name.value, instance_id, num_devices) 

161 for gpu_type in gpu_types 

162 for model_name in model_names 

163 for instance_id in instance_ids 

164 for num_devices in [0] + DEVICE_OPTIONS[model_name] 

165 ] 

166 m.DEVICE_INDEX = Set(initialize=device_index_set) 

167 

168 # Create index set for instance variables: (gpu_type, model_name, instance_id) 

169 instance_index_set = [ 

170 (gpu_type.value, model_name.value, instance_id) 

171 for gpu_type in gpu_types 

172 for model_name in model_names 

173 for instance_id in instance_ids 

174 ] 

175 m.INSTANCE_INDEX = Set(initialize=instance_index_set) 

176 

177 # Define indexed variables 

178 m.device_choice = Var(m.DEVICE_INDEX, domain=Binary) 

179 m.work_device = Var(m.DEVICE_INDEX, domain=NonNegativeIntegers) # Linearization: work per device choice 

180 m.gpus = Var(m.INSTANCE_INDEX, domain=NonNegativeIntegers) 

181 m.is_active = Var(m.INSTANCE_INDEX, domain=Binary) 

182 m.is_min = Var(m.INSTANCE_INDEX, domain=Binary) 

183 m.work = Var(m.INSTANCE_INDEX, domain=NonNegativeIntegers) 

184 m.time = Var(m.INSTANCE_INDEX, domain=NonNegativeReals) 

185 m.ttff = Var(m.INSTANCE_INDEX, domain=NonNegativeReals) 

186 

187 # Objective variables 

188 m.makespan = Var(domain=NonNegativeReals) 

189 m.ttff_user = Var(domain=NonNegativeReals) 

190 m.ttff_min = Var(m.MODEL_NAMES, domain=NonNegativeReals) # Per-model minimum TTFF 

191 m.time_max = Var(m.MODEL_NAMES, domain=NonNegativeReals) # Per-model maximum time 

192 m.cost = Var(domain=NonNegativeReals) 

193 m.energy = Var(domain=NonNegativeReals) 

194 

195 # Constraint list for dynamic constraints 

196 m.constraints = ConstraintList() 

197 

198 for gpu_type in gpu_types: 

199 for model_name in model_names: 

200 for instance_id in instance_ids: 

201 key = idx(gpu_type, model_name, instance_id) 

202 

203 # GPUs used = sum of num_devices * device_choice[num_devices] 

204 m.constraints.add( 

205 m.gpus[key] == sum( 

206 num_devices * m.device_choice[dev_idx(gpu_type, model_name, instance_id, num_devices)] 

207 for num_devices in [0] + DEVICE_OPTIONS[model_name] 

208 ) 

209 ) 

210 

211 # Cannot select inactive instance as min 

212 m.constraints.add(m.is_min[key] <= m.is_active[key]) 

213 # If active = 0 -> GPUs = 0 

214 m.constraints.add(m.gpus[key] <= num_gpus[gpu_type] * m.is_active[key]) 

215 # If active = 1 -> GPUs ≥ 1 

216 m.constraints.add(m.gpus[key] >= m.is_active[key]) 

217 # If work = 0 -> active = 0 -> GPUs = 0 

218 m.constraints.add(m.is_active[key] <= m.work[key]) 

219 

220 # If device = 0 -> work = 0 

221 dev_idx_0 = dev_idx(gpu_type, model_name, instance_id, 0) 

222 m.constraints.add( 

223 m.work[key] 

224 <= work[model_name] * (1 - m.device_choice[dev_idx_0]) 

225 ) 

226 

227 # Linearization: work_device links device_choice and work 

228 # work = sum(work_device[d] for d in devices) - excludes 0 GPUs since they can't do work 

229 m.constraints.add( 

230 m.work[key] == sum( 

231 m.work_device[dev_idx(gpu_type, model_name, instance_id, num_devices)] 

232 for num_devices in DEVICE_OPTIONS[model_name] 

233 ) 

234 ) 

235 # If any non-zero device is selected, work must be >= 1 

236 m.constraints.add( 

237 m.work[key] >= sum( 

238 m.device_choice[dev_idx(gpu_type, model_name, instance_id, num_devices)] 

239 for num_devices in DEVICE_OPTIONS[model_name] 

240 ) 

241 ) 

242 # work_device[d] <= TOTAL_WORK * device_choice[d] 

243 for num_devices in [0] + DEVICE_OPTIONS[model_name]: 

244 didx = dev_idx(gpu_type, model_name, instance_id, num_devices) 

245 m.constraints.add( 

246 m.work_device[didx] <= work[model_name] * m.device_choice[didx] 

247 ) 

248 

249 # Link instance time to per-model max time 

250 m.constraints.add(m.time[key] <= m.time_max[model_name.value]) 

251 

252 # Link TTFF to per-model TTFF min 

253 # If selected → ttff_min[model] == ttff_var 

254 m.constraints.add(m.ttff_min[model_name.value] >= m.ttff[key] - MAX_TIME * (1 - m.is_min[key])) 

255 m.constraints.add(m.ttff_min[model_name.value] <= m.ttff[key] + MAX_TIME * (1 - m.is_active[key])) 

256 

257 # One device per instance 

258 for instance_id in instance_ids: 

259 m.constraints.add( 

260 sum( 

261 m.device_choice[dev_idx(gpu_type, model_name, instance_id, num_devices)] 

262 for num_devices in [0] + DEVICE_OPTIONS[model_name] 

263 ) == 1 

264 ) 

265 

266 # Symmetry breaking (fill earlier instances first) 

267 for instance_id in range(MAX_INSTANCES - 1): 

268 m.constraints.add( 

269 m.gpus[idx(gpu_type, model_name, instance_id)] 

270 >= m.gpus[idx(gpu_type, model_name, instance_id + 1)] 

271 ) 

272 

273 # Makespan is the sum of max times per model (models run sequentially) 

274 m.constraints.add(m.makespan == sum(m.time_max[model_name.value] for model_name in model_names)) 

275 

276 # User TTFF definition: sum of min TTFF per model 

277 m.constraints.add(m.ttff_user >= sum(m.ttff_min[model_name.value] for model_name in model_names)) 

278 m.constraints.add(m.ttff_user >= m.makespan - self.workflow.total_video_seconds) 

279 

280 # Select exactly 1 instance as the min TTFF instance per model 

281 for model_name in model_names: 

282 m.constraints.add( 

283 sum( 

284 m.is_min[idx(gpu_type, model_name, instance_id)] 

285 for gpu_type in gpu_types 

286 for instance_id in instance_ids 

287 ) == 1 

288 ) 

289 

290 # Resolution scaling factor for HF/VAE/FT 

291 latency_ratio = self.workflow.get_resolution_scale(self.policy.use_upscaler) 

292 

293 # Time constraints 

294 # Each model block is guarded by membership in model_names so that 

295 # the MILP can be built for a subset of models (e.g. Helix per-model). 

296 for gpu_type in gpu_types: 

297 # Gemma 

298 if Model.GEMMA in model_names and work[Model.GEMMA] > 0: 

299 model_name = Model.GEMMA 

300 for instance_id in instance_ids: 

301 key = idx(gpu_type, model_name, instance_id) 

302 # Makespan is the max time across all instances 

303 # Linearized: use work_device instead of device_choice * work 

304 if work[model_name] > 1: 

305 # Parallel: each work unit = 1 scene 

306 # Time for w scenes 

307 # = gemma_first_scene + gemma_per_scene * (w - 1) 

308 # = (gemma_first_scene - gemma_per_scene) * is_active + gemma_per_scene * work 

309 # Using linearized variables: 

310 # = (gemma_first_scene[d] - gemma_per_scene[d]) * \ 

311 # device_choice[d] + gemma_per_scene[d] * work_device[d] 

312 m.constraints.add( 

313 m.time[key] == sum( 

314 ( 

315 self.latency_data[gpu_type].gemma_first_scene[num_devices] 

316 - self.latency_data[gpu_type].gemma_per_scene[num_devices] 

317 ) 

318 * m.device_choice[dev_idx(gpu_type, model_name, instance_id, num_devices)] 

319 + self.latency_data[gpu_type].gemma_per_scene[num_devices] 

320 * m.work_device[dev_idx(gpu_type, model_name, instance_id, num_devices)] 

321 for num_devices in DEVICE_OPTIONS[model_name] 

322 ) 

323 ) 

324 else: 

325 m.constraints.add( 

326 m.time[key] == sum( 

327 ( 

328 self.latency_data[gpu_type].gemma_first_scene[num_devices] 

329 + self.latency_data[gpu_type].gemma_per_scene[num_devices] 

330 * (self.workflow.total_scenes - 1) 

331 ) 

332 * m.work_device[dev_idx(gpu_type, model_name, instance_id, num_devices)] 

333 for num_devices in DEVICE_OPTIONS[model_name] 

334 ) 

335 ) 

336 # TTFF is for 1 work unit 

337 m.constraints.add( 

338 m.ttff[key] == sum( 

339 m.device_choice[dev_idx(gpu_type, model_name, instance_id, num_devices)] 

340 * self.latency_data[gpu_type].gemma_first_scene[num_devices] 

341 * 1 # TTFF for tokens in first scene 

342 for num_devices in DEVICE_OPTIONS[model_name] 

343 ) 

344 ) 

345 

346 # Flux 

347 if Model.FLUX in model_names and work[Model.FLUX] > 0: 

348 model_name = Model.FLUX 

349 for instance_id in instance_ids: 

350 key = idx(gpu_type, model_name, instance_id) 

351 # Makespan is the max time across all instances 

352 # Linearized: use work_device instead of device_choice * work 

353 if work[model_name] > 1: 

354 # Parallel: each work unit = 1 scene 

355 # Time for w scenes = latency * num_steps_flux * w 

356 m.constraints.add( 

357 m.time[key] == sum( 

358 self.latency_data[gpu_type][model_name, num_devices] 

359 * self.workflow.num_steps[model_name] 

360 * m.work_device[dev_idx(gpu_type, model_name, instance_id, num_devices)] 

361 for num_devices in DEVICE_OPTIONS[model_name] 

362 ) 

363 ) 

364 else: 

365 # Non-parallel: single work unit covers all scenes 

366 m.constraints.add( 

367 m.time[key] == sum( 

368 self.latency_data[gpu_type][model_name, num_devices] 

369 * self.workflow.num_steps[model_name] 

370 * m.work_device[dev_idx(gpu_type, model_name, instance_id, num_devices)] 

371 for num_devices in DEVICE_OPTIONS[model_name] 

372 ) 

373 ) 

374 # TTFF is for 1 work unit 

375 m.constraints.add( 

376 m.ttff[key] == sum( 

377 m.device_choice[dev_idx(gpu_type, model_name, instance_id, num_devices)] 

378 * self.latency_data[gpu_type][model_name, num_devices] 

379 * self.workflow.num_steps[model_name] 

380 * 1 # TTFF for first work unit 

381 for num_devices in DEVICE_OPTIONS[model_name] 

382 ) 

383 ) 

384 

385 # Hunyuan FramePack 

386 if Model.HF in model_names and work[Model.HF] > 0: 

387 model_name = Model.HF 

388 for instance_id in instance_ids: 

389 key = idx(gpu_type, model_name, instance_id) 

390 

391 """ 

392 from models import HFModelAllocation 

393 HFModelAllocation( 

394 gpu_type, 

395 num_devices, 

396 replicas=1, 

397 )._calc_time_per_subscene( 

398 self.policy, 

399 self.workflow, 

400 self.latency_data[gpu_type] 

401 ) 

402 """ 

403 

404 # Makespan is the max time across all instances 

405 # Linearized: use work_device instead of device_choice * work 

406 hf_time_expr = sum( 

407 self.workflow.per_subscene_frames[model_name] 

408 / self.workflow.hf_frames[self.workflow.frames_per_step_idx] 

409 * self.latency_data[gpu_type][model_name, num_devices] 

410 * latency_ratio 

411 * self.workflow.num_steps[model_name] 

412 * m.work_device[dev_idx(gpu_type, model_name, instance_id, num_devices)] 

413 for num_devices in DEVICE_OPTIONS[model_name] 

414 ) 

415 # When not disaggregated, VAE runs on the same instance 

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

417 hf_vae_time_per_work = ( 

418 self.latency_data[gpu_type][Model.HF_VAE, 1] 

419 * latency_ratio 

420 / self.workflow.hf_frames[self.workflow.frames_per_step_idx] 

421 ) 

422 hf_time_expr += hf_vae_time_per_work * m.work[key] 

423 m.constraints.add(m.time[key] == hf_time_expr) 

424 # TTFF is for first chunk (can be smaller than subscene when disaggregated) 

425 ttff_frames_hf = min( 

426 self.workflow.hf_frames[0], 

427 self.workflow.per_subscene_frames[model_name]) 

428 hf_ttff_expr = sum( 

429 m.device_choice[dev_idx(gpu_type, model_name, instance_id, num_devices)] 

430 * ttff_frames_hf 

431 / self.workflow.hf_frames[self.workflow.frames_per_step_idx] 

432 * self.latency_data[gpu_type][model_name, num_devices] 

433 * latency_ratio 

434 * self.workflow.num_steps[model_name] 

435 * 1 # TTFF for first chunk 

436 for num_devices in DEVICE_OPTIONS[model_name] 

437 ) 

438 # When not disaggregated, add VAE decode time for first chunk 

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

440 hf_vae_ttff = ( 

441 ttff_frames_hf 

442 / self.workflow.hf_frames[self.workflow.frames_per_step_idx] 

443 * self.latency_data[gpu_type][Model.HF_VAE, 1] 

444 * latency_ratio 

445 ) 

446 hf_ttff_expr += hf_vae_ttff * m.is_active[key] 

447 m.constraints.add(m.ttff[key] == hf_ttff_expr) 

448 

449 # Hunyuan FramePack VAE 

450 if Model.HF_VAE in model_names and work[Model.HF_VAE] > 0: 

451 model_name = Model.HF_VAE 

452 for instance_id in instance_ids: 

453 key = idx(gpu_type, model_name, instance_id) 

454 # Makespan is the max time across all instances 

455 # Linearized: use work_device instead of device_choice * work 

456 m.constraints.add( 

457 m.time[key] == sum( 

458 self.latency_data[gpu_type][model_name, num_devices] 

459 * latency_ratio 

460 / self.workflow.hf_frames[self.workflow.frames_per_step_idx] 

461 * m.work_device[dev_idx(gpu_type, model_name, instance_id, num_devices)] 

462 for num_devices in DEVICE_OPTIONS[model_name] 

463 ) 

464 ) 

465 # TTFF is for 1 subscene 

466 m.constraints.add( 

467 m.ttff[key] == sum( 

468 m.device_choice[dev_idx(gpu_type, model_name, instance_id, num_devices)] 

469 * self.workflow.per_subscene_frames[Model.HF] 

470 * self.latency_data[gpu_type][model_name, num_devices] 

471 * latency_ratio 

472 / self.workflow.hf_frames[self.workflow.frames_per_step_idx] # frames_per_step_hf 

473 * 1 # TTFF for first subscene 

474 for num_devices in DEVICE_OPTIONS[model_name] 

475 ) 

476 ) 

477 

478 # Fantasy Talking 

479 if Model.FT in model_names and work[Model.FT] > 0: 

480 model_name = Model.FT 

481 for instance_id in instance_ids: 

482 key = idx(gpu_type, model_name, instance_id) 

483 # Makespan is the max time across all instances 

484 # Linearized: use work_device instead of device_choice * work 

485 ft_time_expr = sum( 

486 self.workflow.per_subscene_frames[model_name] 

487 / self.workflow.ft_frames[self.workflow.frames_per_step_idx] 

488 * self.latency_data[gpu_type][model_name, num_devices] 

489 * latency_ratio 

490 * self.workflow.num_steps[model_name] 

491 * m.work_device[dev_idx(gpu_type, model_name, instance_id, num_devices)] 

492 for num_devices in DEVICE_OPTIONS[model_name] 

493 ) 

494 # When not disaggregated, VAE runs on the same instance 

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

496 ft_vae_time_per_work = ( 

497 self.latency_data[gpu_type][Model.FT_VAE, 1] 

498 * latency_ratio 

499 / self.workflow.ft_frames[self.workflow.frames_per_step_idx] 

500 ) 

501 ft_time_expr += ft_vae_time_per_work * m.work[key] 

502 m.constraints.add(m.time[key] == ft_time_expr) 

503 # TTFF is for 1 work unit (e.g., subscene) 

504 ft_ttff_expr = sum( 

505 m.device_choice[dev_idx(gpu_type, model_name, instance_id, num_devices)] 

506 * self.workflow.per_subscene_frames[model_name] 

507 / self.workflow.ft_frames[self.workflow.frames_per_step_idx] 

508 * self.latency_data[gpu_type][model_name, num_devices] 

509 * latency_ratio 

510 * self.workflow.num_steps[model_name] 

511 * 1 # TTFF for first subscene 

512 for num_devices in DEVICE_OPTIONS[model_name] 

513 ) 

514 # When not disaggregated, add VAE decode time for first subscene 

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

516 ft_vae_ttff = ( 

517 self.workflow.per_subscene_frames[Model.FT] 

518 / self.workflow.ft_frames[self.workflow.frames_per_step_idx] 

519 * self.latency_data[gpu_type][Model.FT_VAE, 1] 

520 * latency_ratio 

521 ) 

522 ft_ttff_expr += ft_vae_ttff * m.is_active[key] 

523 m.constraints.add(m.ttff[key] == ft_ttff_expr) 

524 

525 # Fantasy Talking VAE 

526 if Model.FT_VAE in model_names and work[Model.FT_VAE] > 0: 

527 model_name = Model.FT_VAE 

528 for instance_id in instance_ids: 

529 key = idx(gpu_type, model_name, instance_id) 

530 # Makespan is the max time across all instances 

531 # Linearized: use work_device instead of device_choice * work 

532 m.constraints.add( 

533 m.time[key] == sum( 

534 self.latency_data[gpu_type][model_name, num_devices] 

535 * latency_ratio 

536 / self.workflow.ft_frames[self.workflow.frames_per_step_idx] 

537 * m.work_device[dev_idx(gpu_type, model_name, instance_id, num_devices)] 

538 for num_devices in DEVICE_OPTIONS[model_name] 

539 ) 

540 ) 

541 # TTFF is for 1 subscene 

542 m.constraints.add( 

543 m.ttff[key] == sum( 

544 m.device_choice[dev_idx(gpu_type, model_name, instance_id, num_devices)] 

545 * self.workflow.per_subscene_frames[Model.FT] 

546 * self.latency_data[gpu_type][model_name, num_devices] 

547 * latency_ratio 

548 / self.workflow.ft_frames[self.workflow.frames_per_step_idx] # frames_per_step_ft 

549 * 1 # TTFF for first subscene 

550 for num_devices in DEVICE_OPTIONS[model_name] 

551 ) 

552 ) 

553 

554 # Upscaler 

555 if Model.UPSCALER in model_names and work[Model.UPSCALER] > 0 and self.policy.use_upscaler: 

556 model_name = Model.UPSCALER 

557 for instance_id in instance_ids: 

558 key = idx(gpu_type, model_name, instance_id) 

559 # Linearized: use work_device instead of device_choice * work 

560 m.constraints.add( 

561 m.time[key] == sum( 

562 self.latency_data[gpu_type][model_name, num_devices] 

563 * m.work_device[dev_idx(gpu_type, model_name, instance_id, num_devices)] 

564 for num_devices in DEVICE_OPTIONS[model_name] 

565 ) 

566 ) 

567 # TTFF is for 1 work unit (e.g., subscene) 

568 m.constraints.add( 

569 m.ttff[key] == sum( 

570 m.device_choice[dev_idx(gpu_type, model_name, instance_id, num_devices)] 

571 * self.latency_data[gpu_type][model_name, num_devices] 

572 * self.workflow.per_subscene_frames[Model.FT] 

573 * 1 # TTFF is for first subscene 

574 for num_devices in DEVICE_OPTIONS[model_name] 

575 ) 

576 ) 

577 

578 # Others 

579 if Model.OTHERS in model_names and work[Model.OTHERS] > 0: 

580 model_name = Model.OTHERS 

581 for instance_id in instance_ids: 

582 key = idx(gpu_type, model_name, instance_id) 

583 # Makespan is the max time across all instances 

584 m.constraints.add( 

585 m.time[key] == sum( 

586 m.device_choice[dev_idx(gpu_type, model_name, instance_id, num_devices)] 

587 * self.latency_data[gpu_type][model_name, num_devices] 

588 * self.workflow.total_scenes 

589 for num_devices in DEVICE_OPTIONS[model_name] 

590 ) 

591 ) 

592 # TTFF is for 1 work unit 

593 m.constraints.add( 

594 m.ttff[key] == sum( 

595 m.device_choice[dev_idx(gpu_type, model_name, instance_id, num_devices)] 

596 * self.latency_data[gpu_type][model_name, num_devices] 

597 * 1 # TTFF is for first scene 

598 for num_devices in DEVICE_OPTIONS[model_name] 

599 ) 

600 ) 

601 

602 # Total work to do for each model 

603 for model_name in model_names: 

604 m.constraints.add( 

605 sum( 

606 m.work[idx(gpu_type, model_name, instance_id)] 

607 for gpu_type in gpu_types 

608 for instance_id in instance_ids 

609 ) == work[model_name] 

610 ) 

611 

612 # Number of GPUs per type 

613 # Add a variable to represent the number of servers for each GPU type 

614 m.num_servers = Var(m.GPU_TYPES, domain=NonNegativeIntegers) 

615 

616 for gpu_type in gpu_types: 

617 total_gpus = sum( 

618 m.gpus[idx(gpu_type, model_name, instance_id)] 

619 for model_name in model_names 

620 for instance_id in instance_ids 

621 ) 

622 if force_num_gpus: 

623 m.constraints.add(total_gpus == num_gpus[gpu_type]) 

624 else: 

625 m.constraints.add(total_gpus <= num_gpus[gpu_type]) 

626 

627 # GPUs used must be a multiple of NUM_GPUS_PER_SERVER 

628 if not skip_server_constraint: 

629 m.constraints.add(total_gpus == m.num_servers[gpu_type.value] * NUM_GPUS_PER_SERVER[gpu_type]) 

630 

631 # Cost calculation 

632 # running_cost=True: cost based only on active model running time 

633 if running_cost: 

634 cost_expr = sum( 

635 self._get_latency_per_work( 

636 gpu_type, 

637 model_name, 

638 num_devices, 

639 ) 

640 * num_devices 

641 * m.work_device[dev_idx(gpu_type, model_name, instance_id, num_devices)] 

642 * self.policy.gpu_cost[gpu_type] / SECONDS_IN_HOUR 

643 for gpu_type in gpu_types 

644 for model_name in model_names 

645 for instance_id in instance_ids 

646 for num_devices in DEVICE_OPTIONS[model_name] 

647 ) 

648 # running_cost=False: cost = makespan × total_GPUs_used (GPUs allocated for full job duration) 

649 else: 

650 cost_expr = m.makespan * sum( 

651 m.gpus[idx(gpu_type, model_name, instance_id)] 

652 * self.policy.gpu_cost[gpu_type] / SECONDS_IN_HOUR 

653 for gpu_type in gpu_types 

654 for model_name in model_names 

655 for instance_id in instance_ids 

656 ) 

657 m.constraints.add(m.cost == cost_expr) 

658 

659 # Energy: model-specific power * active time + idle power * (makespan - active time) 

660 if self.power_data is None: 

661 energy_expr = 0.0 

662 else: 

663 # Active energy: Use model-specific power values (not TDP) 

664 energy_expr = sum( 

665 self._get_latency_per_work( 

666 gpu_type, 

667 model_name, 

668 num_devices, 

669 ) 

670 * num_devices 

671 * m.work_device[dev_idx(gpu_type, model_name, instance_id, num_devices)] 

672 * ( 

673 self._get_power_per_work( 

674 gpu_type, 

675 model_name, 

676 num_devices, 

677 ) - self.power_data[gpu_type]["idle"] 

678 ) 

679 for gpu_type in gpu_types 

680 for model_name in model_names 

681 for instance_id in instance_ids 

682 for num_devices in DEVICE_OPTIONS[model_name] 

683 ) 

684 # Idle energy: idle power * num_gpus * makespan 

685 energy_expr += sum( 

686 self.power_data[gpu_type]["idle"] * num_gpus[gpu_type] * m.makespan 

687 for gpu_type in gpu_types 

688 ) 

689 m.constraints.add(m.energy == energy_expr) 

690 

691 # Bounds 

692 if max_cost is not None: 

693 m.constraints.add(m.cost <= max_cost) 

694 if max_ttff is not None: 

695 m.constraints.add(m.ttff_user <= max_ttff) 

696 if max_makespan is not None: 

697 m.constraints.add(m.makespan <= max_makespan) 

698 

699 # Objective functions 

700 obj = get_objective( 

701 m=m, 

702 allocator=self.policy.objective, 

703 solver_name=solver_name, 

704 ) 

705 if obj is not None: 

706 m.objective = obj 

707 

708 # Solve 

709 solver = SolverFactory(solver_name) 

710 if solver_name == "gurobi" and time_limit: 

711 solver.options["TimeLimit"] = time_limit 

712 if solver_name == "highs" and time_limit: 

713 solver.options["time_limit"] = time_limit 

714 if self.policy.objective in QUADRATIC_OBJECTIVES and solver_name == "gurobi": 

715 solver.options['NonConvex'] = 2 # Option for bilinear objectives 

716 if solver_name == "highs": 

717 solver.options["time_limit"] = 50 # seconds 

718 

719 if warm_start_path is not None: 

720 _load_warm_start(m, warm_start_path) 

721 

722 if solver_name == "gurobi": 

723 opt_result = solver.solve( 

724 m, 

725 tee=verbose, 

726 warmstart=warm_start_path is not None, 

727 ) 

728 else: 

729 opt_result = solver.solve(m, tee=verbose) 

730 

731 if opt_result.solver.status != "ok": 

732 logging.error(f"Solver failed with status: {opt_result.solver.status}") 

733 

734 if save_solution_path is not None: 

735 _save_solution(m, save_solution_path) 

736 

737 models = milp_to_models_dict( 

738 m=m, 

739 gpu_types=gpu_types, 

740 model_names=model_names, 

741 instance_ids=instance_ids, 

742 idx=idx, 

743 workflow=self.workflow, 

744 power_data=self.power_data, 

745 policy=self.policy, 

746 ) 

747 

748 if not self._is_valid_result(m): 

749 return Result() 

750 

751 tbf_s = 0.0 

752 if m.makespan.value and self.workflow.num_frames > 0: 

753 tbf_s = m.makespan.value / self.workflow.num_frames 

754 return Result( 

755 models=models, 

756 gpus_used=self._get_num_gpus(m, gpu_types, model_names, instance_ids), 

757 total_time_s=m.makespan.value, 

758 ttff_s=m.ttff_user.value, 

759 tbf_s=tbf_s, 

760 cost=m.cost.value, 

761 total_energy=m.energy.value, 

762 ) 

763 

764 def _is_valid_result(self, m: ConcreteModel) -> bool: 

765 for gpu_type in m.GPU_TYPES: 

766 for model_name in m.MODEL_NAMES: 

767 for instance_id in m.INSTANCES: 

768 if m.gpus[gpu_type, model_name, instance_id].value is None: 

769 return False 

770 return True 

771 

772 def _get_num_gpus( 

773 self, 

774 m: ConcreteModel, 

775 gpu_types: list[GPUType], 

776 model_names: list[Model], 

777 instance_ids: list[int], 

778 ) -> dict[GPUType, int]: 

779 if not self._is_valid_result(m): 

780 return {} 

781 return { 

782 gpu_type: sum( 

783 # round() snaps solver float to nearest int (e.g. 1.9999 -> 2) 

784 int(round(m.gpus[idx(gpu_type, model_name, instance_id)].value)) 

785 for model_name in model_names 

786 for instance_id in instance_ids 

787 if m.gpus[idx(gpu_type, model_name, instance_id)].value is not None 

788 ) 

789 for gpu_type in gpu_types 

790 } 

791 

792 def _get_latency_per_work( 

793 self, 

794 gpu_type: GPUType, 

795 model_name: Model, 

796 num_devices: int, 

797 ) -> float: 

798 """ 

799 Cost per unit of work for a given model and GPU type, based on latency data. 

800 Cost: Linearized - sum of (latency * work_device * num_devices * ratio) 

801 This replaces the bilinear makespan * GPUs. 

802 """ 

803 # Resolution scaling factor for HF/VAE/FT 

804 latency_ratio = self.workflow.get_resolution_scale(self.policy.use_upscaler) 

805 

806 if model_name == Model.GEMMA: 

807 return ( 

808 self.latency_data[gpu_type].gemma_first_scene[num_devices] 

809 + self.latency_data[gpu_type].gemma_per_scene[num_devices] * (self.workflow.total_scenes - 1) 

810 ) 

811 

812 if model_name == Model.FLUX: 

813 return ( 

814 self.latency_data[gpu_type][model_name, num_devices] 

815 * self.workflow.num_steps[Model.FLUX] 

816 ) 

817 

818 if model_name == Model.HF: 

819 time_per_work = ( 

820 self.workflow.per_subscene_frames[Model.HF] 

821 / self.workflow.hf_frames[self.workflow.frames_per_step_idx] 

822 * self.latency_data[gpu_type][model_name, num_devices] 

823 * latency_ratio 

824 * self.workflow.num_steps[Model.HF] 

825 ) 

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

827 time_per_work += self._get_latency_per_work( 

828 gpu_type, 

829 Model.HF_VAE, 

830 1, # VAE is single-device only in current policy 

831 ) 

832 return time_per_work 

833 

834 if model_name == Model.HF_VAE: 

835 return ( 

836 self.latency_data[gpu_type][model_name, num_devices] 

837 * latency_ratio 

838 / self.workflow.hf_frames[self.workflow.frames_per_step_idx] 

839 ) 

840 

841 if model_name == Model.FT: 

842 time_per_work = ( 

843 self.workflow.per_subscene_frames[Model.FT] 

844 / self.workflow.ft_frames[self.workflow.frames_per_step_idx] 

845 * self.latency_data[gpu_type][model_name, num_devices] 

846 * latency_ratio 

847 * self.workflow.num_steps[Model.FT] 

848 ) 

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

850 time_per_work += self._get_latency_per_work( 

851 gpu_type, 

852 Model.FT_VAE, 

853 1, # VAE is single-device only in current policy 

854 ) 

855 return time_per_work 

856 

857 if model_name == Model.FT_VAE: 

858 return ( 

859 self.latency_data[gpu_type][model_name, num_devices] 

860 * latency_ratio 

861 / self.workflow.ft_frames[self.workflow.frames_per_step_idx] 

862 ) 

863 

864 if model_name == Model.UPSCALER: 

865 return self.latency_data[gpu_type][model_name, num_devices] 

866 

867 if model_name == Model.OTHERS: 

868 return self.latency_data[gpu_type][model_name, num_devices] * self.workflow.total_scenes 

869 

870 raise ValueError(f"Unknown model_name {model_name}") 

871 

872 def _get_power_per_work( 

873 self, 

874 gpu_type: GPUType, 

875 model_name: Model, 

876 num_devices: int, 

877 ) -> float: 

878 """ 

879 Average power per unit of work for a given model and GPU type. 

880 Returns the time-weighted average power consumption in watts. 

881 For energy calculation: 

882 energy = _get_latency_per_work(...) * _get_power_per_work(...) * num_devices * work 

883 """ 

884 if self.power_data is None: 

885 return 0.0 

886 

887 if model_name == Model.GEMMA: 

888 # For Gemma, power varies between first scene and subsequent scenes 

889 # Compute energy then divide by total time to get average power 

890 power_first = self.power_data[gpu_type].gemma_first_scene[num_devices] 

891 power_per_scene = self.power_data[gpu_type].gemma_per_scene[num_devices] 

892 latency_first = self.latency_data[gpu_type].gemma_first_scene[num_devices] 

893 latency_per_scene = self.latency_data[gpu_type].gemma_per_scene[num_devices] 

894 

895 total_energy = ( 

896 power_first * latency_first 

897 + power_per_scene * latency_per_scene * (self.workflow.total_scenes - 1) 

898 ) 

899 total_time = latency_first + latency_per_scene * (self.workflow.total_scenes - 1) 

900 

901 return total_energy / total_time if total_time > 0 else power_first 

902 

903 if model_name == Model.FLUX: 

904 return self.power_data[gpu_type][model_name, num_devices] 

905 

906 if model_name == Model.HF: 

907 return self.power_data[gpu_type][model_name, num_devices] 

908 

909 if model_name == Model.HF_VAE: 

910 return self.power_data[gpu_type][model_name, num_devices] 

911 

912 if model_name == Model.FT: 

913 return self.power_data[gpu_type][model_name, num_devices] 

914 

915 if model_name == Model.FT_VAE: 

916 return self.power_data[gpu_type][model_name, num_devices] 

917 

918 if model_name == Model.UPSCALER: 

919 return self.power_data[gpu_type][model_name, num_devices] 

920 

921 if model_name == Model.OTHERS: 

922 # OTHERS model uses minimal GPU power (mostly idle) 

923 # See models.py OthersModelAllocation.calculate_energy - only uses idle power 

924 return self.power_data[gpu_type]["idle"] 

925 

926 raise ValueError(f"Unknown model_name {model_name}") 

927 

928 

929def milp_to_models_dict( 

930 m: ConcreteModel, 

931 gpu_types: list[GPUType], 

932 model_names: list[Model], 

933 instance_ids: list[int], 

934 idx: Callable[[GPUType, Model, int], tuple[str, str, int]], 

935 workflow: WorkflowConfig, 

936 power_data: Optional[PowerData], 

937 policy: Policy, 

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

939 """ 

940 MILP result to models dictionary. 

941 """ 

942 if m is None: 

943 return {} 

944 

945 models: dict[GPUType, dict[Model, list[ModelAllocation]]] = {} 

946 for gpu_type in gpu_types: 

947 models[gpu_type] = {} 

948 for model_name in model_names: 

949 models[gpu_type][model_name] = [] 

950 for instance_id in instance_ids: 

951 key = idx(gpu_type, model_name, instance_id) 

952 gpus_val = m.gpus[key].value 

953 work_val = m.work[key].value 

954 if gpus_val is None or work_val is None: 

955 continue 

956 # round() snaps solver floats to nearest int (e.g. 1.9999 -> 2); 

957 # banker's rounding is irrelevant here since MILP values can be 

958 # near-integer, like 1.999 and 2.001 

959 gpus = int(round(gpus_val)) 

960 work = int(round(work_val)) 

961 if gpus > 0 and work > 0: 

962 model_allocation = get_model_allocation( 

963 model=model_name, 

964 gpu_type=gpu_type, 

965 devices=gpus, 

966 replicas=1, 

967 ) 

968 model_allocation.work = work 

969 model_allocation.time = m.time[key].value 

970 model_allocation.time_first = m.ttff[key].value 

971 model_allocation.calculate_energy( 

972 workflow=workflow, 

973 power_data=power_data, 

974 total_time_s=m.makespan.value 

975 ) 

976 model_allocation.calculate_cost( 

977 policy, 

978 total_time_s=m.makespan.value 

979 ) 

980 models[gpu_type][model_name].append(model_allocation) 

981 merged_models = models # coalesce_models(models) 

982 return merged_models 

983 

984 

985def get_objective( 

986 m: ConcreteModel, 

987 allocator: Objective, 

988 solver_name: str, 

989) -> Optional[OptObjective]: 

990 if allocator == Objective.TIME: 

991 return OptObjective(expr=m.makespan, sense=minimize) 

992 

993 if allocator == Objective.TTFF: 

994 return OptObjective(expr=m.ttff_user, sense=minimize) 

995 

996 if allocator == Objective.TTFF_COST: 

997 # Note: This creates a bilinear (nonconvex) objective - requires Gurobi 

998 if solver_name == "gurobi": 

999 return OptObjective(expr=m.ttff_user * m.cost, sense=minimize) 

1000 logging.warning("TTFF_COST using linear utility function.") 

1001 a = 1.0 

1002 b = 1.0 

1003 return OptObjective(expr=a * m.ttff_user + b * m.cost, sense=minimize) 

1004 

1005 if allocator == Objective.COST: 

1006 return OptObjective(expr=m.cost, sense=minimize) 

1007 

1008 if allocator == Objective.ENERGY: 

1009 return OptObjective(expr=m.energy, sense=minimize) 

1010 

1011 if allocator == Objective.TIME_ENERGY: 

1012 # Note: This creates a bilinear objective - requires Gurobi 

1013 if solver_name == "gurobi": 

1014 return OptObjective(expr=m.makespan * m.energy, sense=minimize) 

1015 logging.warning("TIME_ENERGY using linear utility function.") 

1016 a = 1.0 

1017 b = 1.0 

1018 return OptObjective(expr=a * m.makespan + b * m.energy, sense=minimize) 

1019 

1020 if allocator == Objective.ENERGY_COST: 

1021 if solver_name == "gurobi": 

1022 return OptObjective(expr=m.energy * m.cost, sense=minimize) 

1023 logging.warning("ENERGY_COST using linear utility function.") 

1024 a = 1.0 

1025 b = 1.0 

1026 return OptObjective(expr=a * m.energy + b * m.cost, sense=minimize) 

1027 

1028 if allocator == Objective.FIFO: 

1029 logging.error("FIFO not implemented in MILP") 

1030 

1031 if allocator == Objective.RANDOM: 

1032 return None # No objective, just find a feasible solution 

1033 

1034 if allocator == Objective.NONE: 

1035 return None 

1036 

1037 return OptObjective(expr=m.makespan, sense=minimize) 

1038 

1039 

1040def _save_solution( 

1041 m: ConcreteModel, 

1042 save_solution_path: str, 

1043) -> None: 

1044 solution = { 

1045 var.name: var.value 

1046 for var in m.component_data_objects(Var, active=True) 

1047 if var.value is not None 

1048 } 

1049 with open(save_solution_path, "w", encoding="utf-8") as output_file: 

1050 json.dump(solution, output_file, indent=2) 

1051 

1052 

1053def _load_warm_start( 

1054 m: ConcreteModel, 

1055 warm_start_path: str, 

1056) -> None: 

1057 """Load warm start values from a JSON file and apply them to the model variables.""" 

1058 with open(warm_start_path, "r", encoding="utf-8") as input_file: 

1059 warm_start_values = json.load(input_file) 

1060 

1061 warm_start_applied = 0 

1062 for var in m.component_data_objects(Var, active=True): 

1063 if var.name in warm_start_values: 

1064 var.set_value(warm_start_values[var.name]) 

1065 warm_start_applied += 1 

1066 

1067 logging.info( 

1068 f"Warm start loaded from {warm_start_path}. " 

1069 f"Applied values to {warm_start_applied} variables." 

1070 )