Coverage for simulator/evaluator.py: 92%

193 statements  

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

1""" 

2Evaluate the performance of a given model allocation in terms of time, energy, and cost. 

3It includes some assertions (e.g., only one instance of Gemma and Flux). 

4""" 

5from __future__ import annotations 

6 

7import math 

8import logging 

9 

10from typing import Optional 

11 

12from constants import NUM_GPUS_PER_SERVER 

13from constants import TOTAL_INPUT_TOKENS 

14from constants import SECONDS_IN_HOUR 

15 

16from sim_types import Result 

17from sim_types import GPUType 

18from sim_types import WorkflowConfig 

19from sim_types import PowerData 

20from sim_types import LatencyData 

21from sim_types import Model 

22from sim_types import ModelAllocation 

23from sim_types import Policy 

24 

25from sim_types_json import models_to_json 

26from sim_types_json import workflow_to_json 

27from sim_types_json import policy_to_json 

28 

29 

30def _count_instances( 

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

32 model: Model, 

33) -> int: 

34 num_instances = 0 

35 for model_gpus in models.values(): 

36 if model in model_gpus: 

37 for model_allocation in model_gpus[model]: 

38 if model_allocation.get_num_gpus() > 0: 

39 num_instances += 1 

40 return num_instances 

41 

42 

43def _assert_single_instance( 

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

45 model: Model, 

46) -> None: 

47 num_instances = _count_instances(models, model) 

48 assert num_instances == 1, f"Expected exactly one instance of {model}, but found {num_instances}" 

49 

50 

51def _assert_at_least_one_instance( 

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

53 model: Model, 

54) -> None: 

55 num_instances = _count_instances(models, model) 

56 assert num_instances > 0, f"Expected at least one instance of {model}, but found {num_instances}" 

57 

58 

59def _assert_no_instances( 

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

61 model: Model, 

62) -> None: 

63 num_instances = _count_instances(models, model) 

64 assert num_instances == 0, f"Expected no instances of {model}, but found {num_instances}" 

65 

66 

67def evaluate_times( 

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

69 latency_data: LatencyData, 

70 workflow: WorkflowConfig, 

71 policy: Policy, 

72 include_models: Optional[list[Model]] = None, 

73) -> None: 

74 """ 

75 Compute the total time for the given model allocation and workflow, using the latency data. 

76 It only evaluates the models specified in "include_models" if provided. 

77 """ 

78 gpu_types = list(models.keys()) 

79 

80 upscaler_gpus = sum( 

81 model_alloc.get_num_gpus() 

82 for gpu_type in gpu_types 

83 for model_alloc in models.get(gpu_type, {}).get(Model.UPSCALER, []) 

84 ) 

85 if not policy.use_upscaler: 

86 assert upscaler_gpus == 0 

87 

88 for model_name in workflow.models: 

89 if include_models is not None and model_name not in include_models: 

90 continue 

91 

92 # Special conditions: models that require a policy flag 

93 if model_name == Model.HF_VAE and not policy.is_disaggregated(Model.HF): 

94 _assert_no_instances(models, Model.HF_VAE) 

95 continue 

96 if model_name == Model.FT_VAE and not policy.is_disaggregated(Model.FT): 

97 _assert_no_instances(models, Model.FT_VAE) 

98 continue 

99 if model_name == Model.UPSCALER and not policy.use_upscaler: 

100 _assert_no_instances(models, Model.UPSCALER) 

101 continue 

102 

103 _assert_at_least_one_instance(models, model_name) 

104 

105 if not workflow.is_parallelizable(model_name): 

106 # Single-instance: no work splitting 

107 for gpu_type in gpu_types: 

108 if model_name in models[gpu_type]: 

109 for model_alloc in models[gpu_type][model_name]: 

110 model_alloc.calculate_time( 

111 policy, workflow, latency_data) 

112 model_alloc.calculate_time_first( 

113 policy, workflow, latency_data) 

114 continue 

115 

116 # Parallel: capacity-based work splitting (throughput-weighted) 

117 capacities: dict[GPUType, list[float]] = {} 

118 for gpu_type in gpu_types: 

119 capacities[gpu_type] = [] 

120 if model_name not in models[gpu_type]: 

121 continue 

122 for model_alloc in models[gpu_type][model_name]: 

123 if model_alloc.get_num_gpus() > 0: 

124 latency = latency_data[gpu_type][model_name, model_alloc.devices] 

125 # When not disaggregated, include VAE overhead in capacity 

126 if model_name == Model.FT and not policy.is_disaggregated(Model.FT): 

127 latency += latency_data[gpu_type][Model.FT_VAE, 1] / workflow.num_steps[Model.FT] 

128 if model_name == Model.HF and not policy.is_disaggregated(Model.HF): 

129 latency += latency_data[gpu_type][Model.HF_VAE, 1] / workflow.num_steps[Model.HF] 

130 if model_name in (Model.HF, Model.HF_VAE, Model.FT, Model.FT_VAE): 

131 latency *= workflow.get_resolution_scale(policy.use_upscaler) 

132 if model_name == Model.GEMMA: 

133 latency *= workflow.total_input_tokens / TOTAL_INPUT_TOKENS 

134 if latency == 0: 

135 capacities[gpu_type].append(0.0) 

136 else: 

137 capacities[gpu_type].append(model_alloc.replicas / latency) 

138 

139 total_capacity = sum(sum(c) for c in capacities.values()) 

140 for gpu_type in gpu_types: 

141 if model_name not in models[gpu_type]: 

142 continue 

143 cap_idx = 0 

144 for model_alloc in models[gpu_type][model_name]: 

145 if model_alloc.get_num_gpus() > 0: 

146 work_pct = capacities[gpu_type][cap_idx] / total_capacity if total_capacity > 0 else 0.0 

147 model_alloc.calculate_time( 

148 policy, workflow, latency_data, 

149 work_pct=work_pct) 

150 model_alloc.calculate_time_first( 

151 policy, workflow, latency_data) 

152 cap_idx += 1 

153 

154 

155def evaluate_energy( 

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

157 power_data: PowerData, 

158 workflow: WorkflowConfig, 

159 total_time_s: float = 0.0, 

160) -> None: 

161 """ 

162 Calculate total energy (power * time * replicas for each model). 

163 Need to run after evaluate_times since energy calculation depends on time. 

164 """ 

165 for gpu_type_allocs in models.values(): 

166 for model_allocation_list in gpu_type_allocs.values(): 

167 for model_allocation in model_allocation_list: 

168 model_allocation.calculate_energy( 

169 workflow, 

170 power_data, 

171 total_time_s) 

172 

173 

174def evaluate_cost( 

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

176 total_time_s: float, 

177 policy: Policy, 

178) -> None: 

179 """ 

180 Calculate total cost based on GPU hours used. 

181 Need to run after evaluate_times since cost calculation depends on time. 

182 """ 

183 for gpu_type_allocs in models.values(): 

184 for model_allocation_list in gpu_type_allocs.values(): 

185 for model in model_allocation_list: 

186 model.calculate_cost(policy, total_time_s) 

187 

188 

189_EVALUATOR_CACHE: dict[str, Result] = {} 

190 

191 

192def evaluate_model_allocation( 

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

194 num_gpus: dict[GPUType, int], 

195 workflow: WorkflowConfig, 

196 latency_data: LatencyData, 

197 power_data: Optional[PowerData], 

198 policy: Policy, 

199 include_models: Optional[list[Model]] = None, 

200 cache_results: bool = False, 

201 round_up_cost_to_server: bool = False, 

202) -> Result: 

203 """ 

204 Evaluate the metrics for a given allocation of models to GPUs. 

205 It only evaluates the models in "include_models" if specified. 

206 """ 

207 cache_key = None 

208 if cache_results: 

209 cache_key = models_to_json(models) + \ 

210 workflow_to_json(workflow) + \ 

211 str(latency_data) + \ 

212 str(power_data) + \ 

213 policy_to_json(policy) + \ 

214 str(include_models) 

215 if cache_key in _EVALUATOR_CACHE: 

216 return _EVALUATOR_CACHE[cache_key] 

217 

218 # Check if setup is possible 

219 gpus_used = {} 

220 for gpu_type, model_gpu in models.items(): 

221 gpus_used[gpu_type] = calc_used_gpus({gpu_type: model_gpu}) 

222 assert num_gpus[gpu_type] % NUM_GPUS_PER_SERVER[gpu_type] == 0, \ 

223 f"{gpu_type.value}: {num_gpus[gpu_type]} % {NUM_GPUS_PER_SERVER[gpu_type]}" 

224 assert gpus_used[gpu_type] <= num_gpus[gpu_type], \ 

225 f"{gpu_type.value}: {gpus_used[gpu_type]} > {num_gpus[gpu_type]}" 

226 

227 # Assert input models are built correctly 

228 for gpu_type in models.keys(): 

229 for model_name in models[gpu_type].keys(): 

230 for instance_id in range(len(models[gpu_type][model_name])): 

231 assert models[gpu_type][model_name][instance_id].model == model_name 

232 assert models[gpu_type][model_name][instance_id].gpu_type == gpu_type 

233 

234 # Actual evaluation 

235 evaluate_times( 

236 models, latency_data, workflow, policy, 

237 include_models=include_models, 

238 ) 

239 time_s = calc_total_time(models) 

240 

241 first_chunk_time = calc_ttff(models) 

242 ttff_s = max( 

243 first_chunk_time, 

244 time_s - workflow.total_video_seconds 

245 ) 

246 

247 num_frames = (workflow.total_frames[Model.FT] - workflow.per_subscene_frames[Model.FT]) 

248 tbf_s = (time_s - first_chunk_time) / num_frames 

249 if tbf_s < 0: 

250 logging.debug( 

251 f"Negative TBF: " 

252 F"{tbf_s:.2f} = ({time_s:.2f} - {first_chunk_time:.2f}) / {num_frames}") 

253 tbf_s = 0.0 

254 

255 # Calculate total energy (power * time * replicas for each model) 

256 energy = 0.0 

257 if power_data is not None: 

258 evaluate_energy(models, power_data, workflow, time_s) 

259 energy = calc_energy(models=models) 

260 

261 evaluate_cost(models, time_s, policy) 

262 cost = calc_cost( 

263 models, time_s, policy, 

264 round_up_to_server=round_up_cost_to_server) 

265 

266 ret = Result( 

267 models=models, 

268 gpus_used=gpus_used, 

269 gpus_total=num_gpus, 

270 total_time_s=time_s, 

271 first_chunk_time=first_chunk_time, 

272 ttff_s=ttff_s, 

273 tbf_s=tbf_s, 

274 total_energy=energy if power_data else 0.0, 

275 cost=cost, 

276 ) 

277 

278 if cache_key is not None: 

279 _EVALUATOR_CACHE[cache_key] = ret 

280 

281 return ret 

282 

283 

284def calc_energy( 

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

286) -> float: 

287 """ 

288 Calculate total energy (power * time * replicas for each model). 

289 Energy in Watt x seconds (Joules). 

290 This assumes that evaluate_energy() has been called already. 

291 """ 

292 energy = 0.0 # Total energy in Watt-seconds (Joules = Watt x second) 

293 for model_dict in models.values(): 

294 for model_allocations in model_dict.values(): 

295 for model_allocation in model_allocations: 

296 energy += model_allocation.energy 

297 return energy 

298 

299 

300def calc_model_cost( 

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

302) -> float: 

303 """ 

304 Calculate total cost based on GPU hours used. 

305 This assumes that evaluate_cost() has been called already. 

306 """ 

307 costs = {} 

308 for gpu_type, model_dict in models.items(): 

309 costs[gpu_type] = 0.0 

310 for model_allocations in model_dict.values(): 

311 for model_allocation in model_allocations: 

312 costs[gpu_type] += model_allocation.cost 

313 return sum(costs.values()) 

314 

315 

316def calc_cost( 

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

318 time_s: float, 

319 policy: Policy, 

320 round_up_to_server: bool = True, 

321) -> float: 

322 """ 

323 Calculate total cost based on GPU hours used. 

324 """ 

325 used_gpus = calc_used_gpus_per_type(models) 

326 

327 # Round up to the nearest server (pack of GPUs) since we pay for whole servers 

328 if round_up_to_server: 

329 for gpu_type, used in used_gpus.items(): 

330 used_pack = math.ceil(used / NUM_GPUS_PER_SERVER[gpu_type]) * NUM_GPUS_PER_SERVER[gpu_type] 

331 used_gpus[gpu_type] = used_pack 

332 

333 return calc_cost_total(used_gpus, time_s, policy) 

334 

335 

336def calc_cost_total( 

337 num_gpus: dict[GPUType, int], 

338 time_s: float, 

339 policy: Policy, 

340) -> float: 

341 """ 

342 Calculate total cost based on GPU hours used. 

343 It includes the idle GPUs not assigned to a model. 

344 """ 

345 cost = 0.0 

346 for gpu_type, num in num_gpus.items(): 

347 cost += num * (time_s / SECONDS_IN_HOUR) * policy.gpu_cost[gpu_type] 

348 return cost 

349 

350 

351def calc_used_gpus_per_type( 

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

353) -> dict[GPUType, int]: 

354 """ 

355 Calculate number of GPUs used per GPU type across all models. 

356 """ 

357 gpus_used = {} 

358 for gpu_type, model_gpu in models.items(): 

359 gpus_used[gpu_type] = 0 

360 for model_allocations in model_gpu.values(): 

361 for model_allocation in model_allocations: 

362 gpus_used[gpu_type] += model_allocation.get_num_gpus() 

363 return gpus_used 

364 

365 

366def calc_used_gpus( 

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

368) -> int: 

369 """ 

370 Calculate total number of GPUs used across all models and GPU types. 

371 """ 

372 gpus_used = calc_used_gpus_per_type(models) 

373 return sum(gpus_used.values()) 

374 

375 

376def calc_total_time( 

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

378) -> float: 

379 """ 

380 Calculate total time considering all stages and dependencies. 

381 This assumes that evaluate_time() has been called already. 

382 """ 

383 total_time_secs = 0.0 

384 for model_name in Model: 

385 model_alloc_times = [ 

386 model_alloc.time 

387 for gpu_type in GPUType 

388 if gpu_type in models and model_name in models[gpu_type] 

389 for model_alloc in models[gpu_type][model_name] 

390 ] 

391 model_time = max(model_alloc_times) if model_alloc_times else 0.0 

392 total_time_secs += model_time 

393 return total_time_secs 

394 

395 

396def calc_ttff( 

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

398) -> float: 

399 """ 

400 Calculate time to first frame (chunk). 

401 It takes the time to first frame (TTFF) for each model. 

402 This assumes that evaluate_time() has been called already. 

403 """ 

404 models_time_first: dict[Model, float] = {} 

405 for model_name in Model: 

406 times_first = [] 

407 for gpu_type in models.keys(): 

408 if model_name in models[gpu_type]: 

409 for model_alloc in models[gpu_type][model_name]: 

410 if model_alloc.get_num_gpus() > 0: 

411 times_first.append(model_alloc.time_first) 

412 if len(times_first) > 0: 

413 models_time_first[model_name] = min(times_first) # The fastest model determines TTFF 

414 return sum(models_time_first.values())