Coverage for simulator/plot_utils.py: 99%

168 statements  

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

1""" 

2Utilities for plotting. 

3""" 

4from __future__ import annotations 

5 

6import numpy as np 

7 

8from matplotlib import pyplot as plt 

9from matplotlib.markers import MarkerStyle 

10 

11from typing import Optional 

12 

13from utils import get_pareto_frontier 

14 

15from sim_types import ProvisioningResult 

16from sim_types import GPUType 

17from sim_types import Model 

18from sim_types import QualityLevel 

19 

20 

21FIG_SIZE = (7, 5) 

22 

23 

24def get_color_map() -> list[tuple[float, float, float]]: 

25 return plt.get_cmap('tab10').colors # type: ignore[attr-defined] 

26 

27 

28def _get_time_ticklabels( 

29 xmin: Optional[float] = None, 

30 xmax: Optional[float] = None, 

31 exclude_x: list[int] = [], 

32) -> tuple[list[int], list[str]]: 

33 ticks_seconds = [1, 2, 5, 10, 15, 30] 

34 

35 # Define desired minute values (in seconds) to match the log scale 

36 minute_ticks_minutes = [1, 2, 5, 10, 20, 40] # in minutes 

37 minute_ticks_seconds = [mins * 60 for mins in minute_ticks_minutes] # convert to seconds 

38 

39 hour_ticks_hours = [1, 3, 5, 8, 12] # in hours 

40 hour_ticks_seconds = [hours * 60 * 60 for hours in hour_ticks_hours] # convert to seconds 

41 

42 day_ticks_days: list[int] = [1] # in days 

43 day_ticks_seconds = [days * 24 * 60 * 60 for days in day_ticks_days] # convert to seconds 

44 tick_labels = \ 

45 [f"{s:g}s" for s in ticks_seconds] + \ 

46 [f"{m:g}m" for m in minute_ticks_minutes] + \ 

47 [f"{h:g}h" for h in hour_ticks_hours] + \ 

48 [f"{d:g}d" for d in day_ticks_days] 

49 

50 ticks: list[int] = ticks_seconds + minute_ticks_seconds + hour_ticks_seconds + day_ticks_seconds 

51 

52 if xmin is not None or xmax is not None or exclude_x: 

53 filtered_ticks: list[int] = [] 

54 filtered_labels: list[str] = [] 

55 for tick, label in zip(ticks, tick_labels): 

56 if tick in exclude_x: 

57 continue 

58 if xmin is not None and tick < xmin: 

59 continue 

60 if xmax is not None and tick > xmax: 

61 continue 

62 filtered_ticks.append(tick) 

63 filtered_labels.append(label) 

64 ticks = filtered_ticks 

65 tick_labels = filtered_labels 

66 

67 return ticks, tick_labels 

68 

69 

70def plot_x_vs_y( 

71 x_data: list[float], 

72 y_data: list[float], 

73 provisions: list[dict[GPUType, int]], 

74 verbose: bool = False, 

75 # Plot 

76 figsize: tuple[int, int] = FIG_SIZE, 

77 # X 

78 xlabel: str = "Latency (sec)", 

79 xmin: Optional[float] = 20, 

80 xmax: Optional[float] = None, 

81 # Y 

82 ylabel: str = "Cost ($)", 

83 ymin: Optional[float] = 0, 

84 ymax: Optional[float] = None, 

85 # Marker 

86 marker_size: int = 80, 

87) -> None: 

88 # Create the figure 

89 plt.figure(figsize=figsize) 

90 

91 # Change the font size to 12 

92 plt.rcParams.update({'font.size': 12}) 

93 

94 # Define color map 

95 tab10 = plt.get_cmap("tab10").colors # type: ignore[attr-defined] 

96 colors = tab10 

97 markers = ["o", "^", "s", "D", "P", "X"] # circle, triangle_up, square, diamond, plus, x 

98 

99 # scatter plot for mixed provisioning 

100 idx_list_mixed: list[int] = [] 

101 

102 # Data for each GPU type 

103 idx_lists_single: dict[GPUType, list[int]] = {} 

104 for gpu_idx, gpu_type in enumerate(GPUType): 

105 idx_lists_single[gpu_type] = [] 

106 for idx, provision in enumerate(provisions): 

107 if provision.get(gpu_type, 0) > 0 and len(provision) == 1: 

108 idx_lists_single[gpu_type].append(idx) 

109 else: 

110 idx_list_mixed.append(idx) 

111 

112 # Scatter plot for mixed provisioning 

113 idx_list_mixed = list(set(idx_list_mixed)) # Remove duplicates 

114 idx_list_single = [ 

115 idx 

116 for gpu_type in GPUType 

117 for idx in idx_lists_single[gpu_type] 

118 ] 

119 idx_list_mixed = [ 

120 idx 

121 for idx in idx_list_mixed 

122 if idx not in idx_list_single 

123 ] 

124 x_data_mixed = [x_data[i] for i in idx_list_mixed] 

125 y_data_mixed = [y_data[i] for i in idx_list_mixed] 

126 if x_data_mixed and y_data_mixed: 

127 plt.scatter( 

128 x_data_mixed, 

129 y_data_mixed, 

130 s=marker_size, 

131 color=tab10[0], 

132 label="Mixed", 

133 marker=MarkerStyle('x'), 

134 alpha=0.5, 

135 ) 

136 

137 # Scatter plot for single GPU type provisioning 

138 for gpu_idx, gpu_type in enumerate(GPUType): 

139 x_data_gpu_type = [x_data[i] for i in idx_lists_single[gpu_type]] 

140 y_data_gpu_type = [y_data[i] for i in idx_lists_single[gpu_type]] 

141 if x_data_gpu_type and y_data_gpu_type: 

142 plt.scatter( 

143 x_data_gpu_type, 

144 y_data_gpu_type, 

145 s=marker_size, 

146 color=colors[gpu_idx + 1], 

147 marker=MarkerStyle(markers[gpu_idx]), 

148 label=gpu_type.name, 

149 alpha=0.7, 

150 ) 

151 

152 # Pareto frontier for all points 

153 pareto_front = get_pareto_frontier(x_data, y_data) 

154 

155 # Find the provisioning options that correspond to the Pareto front points 

156 pareto_provision = [] 

157 for point in pareto_front.tolist(): 

158 x_val = point[0] 

159 y_val = point[1] 

160 try: 

161 idx = np.where((np.array(x_data) == x_val) & (np.array(y_data) == y_val))[0][0] 

162 pareto_provision.append(provisions[idx]) 

163 except IndexError: 

164 pass # Ignore artificial points 

165 

166 if verbose: 

167 print("Pareto Front Provisioning Options:") 

168 for point in pareto_front.tolist(): 

169 x_val = point[0] 

170 y_val = point[1] 

171 try: 

172 idx = np.where((np.array(x_data) == x_val) & (np.array(y_data) == y_val))[0][0] 

173 num_gpus = provisions[idx] 

174 print( 

175 f"{num_gpus} -> " 

176 f"TTFF: {point[0]:.2f} seconds, Cost: ${point[1]:.2f}") 

177 except IndexError: 

178 pass # Ignore artificial points 

179 

180 # plot the parento curve 

181 plt.plot( 

182 pareto_front[:, 0], 

183 pareto_front[:, 1], 

184 color=tab10[0], 

185 linewidth=3, 

186 # linestyle='--', 

187 label="Frontier", 

188 zorder=0 

189 ) 

190 

191 # add arrow point to the left bottom corner with 'Better' inside the arrow 

192 # plt.text(30, 20, "Better", ha="center", va="center", 

193 # rotation=45, size=12, 

194 # bbox=dict(boxstyle="larrow,pad=0.2", fc="white", ec="black", lw=1)) 

195 

196 if xmin is None or xmin > 0: 

197 plt.xscale("log") 

198 plt.xlim(xmin, xmax) 

199 plt.ylim(ymin, ymax) 

200 

201 # add a vertical line at x=600 seconds 

202 # plt.axvline(x=600, color='red', linestyle='--', label='Realtime') 

203 

204 ticks, tick_labels = _get_time_ticklabels( 

205 xmin=xmin, 

206 xmax=xmax, 

207 ) 

208 plt.xticks( 

209 ticks, 

210 tick_labels 

211 ) 

212 

213 plt.xlabel(xlabel, fontsize=12) 

214 plt.ylabel(ylabel, fontsize=12) 

215 

216 plt.grid(True, linestyle='--', alpha=0.7) 

217 

218 plt.legend() 

219 

220 # Improve formatting 

221 plt.tight_layout(pad=0) 

222 

223 plt.show() 

224 # plt.savefig('ttff_vs_cost.pdf', dpi=300, bbox_inches='tight') 

225 

226 

227def plot_ttff_vs_cost( 

228 ttffs: list[float], 

229 costs: list[float], 

230 provisions: list[dict[GPUType, int]], 

231 verbose: bool = False, 

232 # Plot 

233 figsize: tuple[int, int] = FIG_SIZE, 

234 # X 

235 xlabel: str = "Time to First Frame (TTFF)", 

236 xmin: Optional[float] = 20, 

237 xmax: Optional[float] = None, 

238 # Y 

239 ylabel: str = "Cost ($)", 

240 ymin: Optional[float] = 0, 

241 ymax: Optional[float] = None, 

242) -> None: 

243 """ 

244 Plots Time to First Frame (TTFF) against Cost for different provisioning options. 

245 Args: 

246 ttff_list (list): List of TTFF values corresponding to each provisioning option. 

247 costs (list): List of cost values corresponding to each provisioning option. 

248 provision (list): List of tuples representing the provisioning options (num_a100s, num_h100s, num_h200s). 

249 """ 

250 plot_x_vs_y( 

251 x_data=ttffs, 

252 y_data=costs, 

253 provisions=provisions, 

254 xlabel=xlabel, 

255 ylabel=ylabel, 

256 verbose=verbose, 

257 figsize=figsize, 

258 xmin=xmin, 

259 xmax=xmax, 

260 ymin=ymin, 

261 ymax=ymax, 

262 ) 

263 

264 

265def plot_ttff_vs_energy( 

266 ttff_list: list[float], 

267 energy_list: list[float], 

268 actual_provision: list[dict[GPUType, int]], 

269 verbose: bool = False, 

270 # Plot 

271 figsize: tuple[int, int] = FIG_SIZE, 

272 # X 

273 xlabel: str = "Time to First Frame (TTFF)", 

274 xmin: Optional[float] = 20, 

275 xmax: Optional[float] = None, 

276 # Y 

277 ylabel: str = "Energy (kWh)", 

278 ymin: Optional[float] = 0, 

279 ymax: Optional[float] = None, 

280) -> None: 

281 # convert energy from Ws to kWh 

282 energy_list_copy = [ 

283 energy / (60 * 60 * 1000) # convert from Ws to kWh 

284 for energy in energy_list 

285 ] 

286 plot_x_vs_y( 

287 x_data=ttff_list, 

288 y_data=energy_list_copy, 

289 provisions=actual_provision, 

290 xlabel=xlabel, 

291 ylabel=ylabel, 

292 verbose=verbose, 

293 figsize=figsize, 

294 xmin=xmin, 

295 xmax=xmax, 

296 ymin=ymin, 

297 ymax=ymax, 

298 ) 

299 

300 

301def plot_adaptive_quality( 

302 provisioning_result_adaptive: ProvisioningResult, 

303 provisioning_qualities: dict[QualityLevel, ProvisioningResult], 

304 slide_seconds: float = 2.2, # Value in seconds 

305 # Plot 

306 figsize: tuple[int, int] = FIG_SIZE, 

307 # X: 1 second to 10 minutes 

308 xmin: Optional[float] = 1, 

309 xmax: Optional[float] = 10 * 60, 

310 # Y: Cost ($) 

311 ymin: Optional[float] = 0, 

312 ymax: Optional[float] = 85, 

313) -> None: 

314 # Plot quality pareto frontiers 

315 pareto_front_adaptive = get_pareto_frontier( 

316 provisioning_result_adaptive.ttffs, 

317 provisioning_result_adaptive.costs, 

318 max_x=ymax, 

319 max_y=xmax, 

320 ) 

321 

322 pareto_fronts: dict[QualityLevel, np.ndarray] = {} 

323 for quality in [QualityLevel.HIGH, QualityLevel.MEDIUM, QualityLevel.LOW]: 

324 pareto_fronts[quality] = get_pareto_frontier( 

325 provisioning_qualities[quality].ttffs, 

326 provisioning_qualities[quality].costs, 

327 max_x=ymax, 

328 max_y=xmax, 

329 ) 

330 

331 _, ax = plt.subplots(figsize=figsize) 

332 for quality in [QualityLevel.HIGH, QualityLevel.MEDIUM, QualityLevel.LOW]: 

333 ax.plot( 

334 pareto_fronts[quality][:, 0], 

335 pareto_fronts[quality][:, 1], 

336 linewidth=3, 

337 label=quality.name.lower().capitalize(), 

338 ) 

339 

340 # Adaptive quality 

341 ax.plot( 

342 pareto_front_adaptive[:, 0], 

343 pareto_front_adaptive[:, 1], 

344 linewidth=3, 

345 linestyle='--', 

346 label="Adaptive", 

347 ) 

348 

349 # Adaptive + Slide 

350 plt.plot( 

351 pareto_front_adaptive[:, 0] - slide_seconds, 

352 pareto_front_adaptive[:, 1], 

353 linewidth=3, 

354 linestyle='-.', 

355 label="+Slide") 

356 

357 plt.legend(loc="upper right") 

358 plt.grid() 

359 

360 ax.grid(True, which='major', linestyle='--', alpha=0.7) 

361 ax.grid(True, which='minor', linestyle=':', alpha=0.4) 

362 

363 ax.set_xlabel("TTFF (s)", labelpad=-8) 

364 ax.set_ylabel("Cost ($)") 

365 

366 if xmin is None or xmin > 0: 

367 plt.xscale("log") 

368 plt.xlim(xmin, xmax) 

369 plt.ylim(ymin, ymax) 

370 

371 ticks, tick_labels = _get_time_ticklabels( 

372 xmin=xmin, 

373 xmax=xmax, 

374 ) 

375 plt.xticks( 

376 ticks, 

377 tick_labels 

378 ) 

379 

380 ax2 = ax.twinx() 

381 if ymax is not None: 

382 ax2.set_ylim(0, ymax / 10) # Scale by 1/10 cost/10 minutes 

383 ax2.set_ylabel("Cost ($/minute)", rotation=-90, labelpad=15) 

384 

385 plt.tight_layout(pad=0) 

386 plt.show() 

387 

388 

389def plot_cost_vs_qpm( 

390 costs: dict[GPUType, dict[Model, list[float]]], 

391 qpms: list[float] = [1, 2], 

392 # Plot 

393 figsize: tuple[int, int] = FIG_SIZE, 

394 xlabel: str = "Requests per Minute (QPM)", 

395 ylabel: str = "Cost ($)", 

396) -> None: 

397 """Plot cost vs QPM for each component.""" 

398 plt.figure(figsize=figsize) 

399 

400 for gpu_type in costs.keys(): 

401 for model in costs[gpu_type].keys(): 

402 plt.plot( 

403 qpms, 

404 costs[gpu_type][model], 

405 marker='o', 

406 label=f"{model.name} ({gpu_type.name})") 

407 

408 # plt.plot(QPM_LIST, total_costs, marker='o', label='Total Cost', color='black', linewidth=2) 

409 

410 plt.xscale('log') 

411 

412 plt.xlabel(xlabel, fontsize=12) 

413 plt.ylabel(ylabel, fontsize=12) 

414 plt.title('Cost vs. Requests per Minute (QPM) for Each Component', fontsize=14) 

415 

416 plt.xticks(qpms, rotation=45) 

417 plt.grid(True, linestyle='--', alpha=0.7) 

418 if len(costs) > 0: 

419 plt.legend() 

420 plt.tight_layout(pad=0) 

421 plt.show() 

422 

423 

424def plot_policies_ttff_vs_cost( 

425 provision_results: dict[str, ProvisioningResult], 

426 points: bool = True, 

427 front: bool = True, 

428 # Plot 

429 figsize: tuple[int, int] = FIG_SIZE, 

430 # X 

431 xlabel: str = "Time to first frame (TTFF)", 

432 xmin: Optional[int] = 20, 

433 xmax: Optional[int] = 3 * 60 * 60, # 3 hours 

434 # Y 

435 ylabel: str = "Cost ($)", 

436 ymin: Optional[int] = 0, 

437 ymax: Optional[int] = 1500, 

438) -> None: 

439 assert front or points, "At least one of 'front' or 'points' must be True" 

440 

441 _, ax = plt.subplots(figsize=figsize) 

442 

443 # Plot results 

444 for policy_name, provision_result in provision_results.items(): 

445 # Plot points 

446 if points: 

447 ax.scatter( 

448 provision_result.ttffs, 

449 provision_result.costs, 

450 marker=MarkerStyle("o"), 

451 alpha=0.5, 

452 label=policy_name 

453 ) 

454 # Plot Pareto frontier 

455 if front: 

456 pareto_frontier = get_pareto_frontier( 

457 provision_result.ttffs, 

458 provision_result.costs, 

459 max_x=ymax, 

460 max_y=xmax, 

461 ) 

462 ax.plot( 

463 pareto_frontier[:, 0], 

464 pareto_frontier[:, 1], 

465 linewidth=3, 

466 label=f"{policy_name}" if not points else None 

467 ) 

468 

469 if xmin is None or xmin > 0: 

470 plt.xscale("log") 

471 plt.xlim(xmin, xmax) 

472 plt.ylim(ymin, ymax) 

473 

474 ticks, tick_labels = _get_time_ticklabels( 

475 xmin=xmin, 

476 xmax=xmax, 

477 ) 

478 plt.xticks( 

479 ticks, 

480 tick_labels 

481 ) 

482 

483 plt.xlabel(xlabel) 

484 plt.ylabel(ylabel) 

485 

486 plt.grid() 

487 if len(provision_results) > 0: 

488 plt.legend() 

489 plt.tight_layout(pad=0) 

490 plt.show()