Coverage for  / home / jenkins / .local / lib / python3.10 / site-packages / hyper_parallel / auto_parallel / sapp_nd / nd / debug.py: 92%

719 statements  

« prev     ^ index     » next       coverage.py v7.13.1, created at 2026-10-25 05:45 +0800

1# Copyright 2025-2026 Huawei Technologies Co., Ltd 

2# 

3# Licensed under the Apache License, Version 2.0 (the "License"); 

4# you may not use this file except in compliance with the License. 

5# You may obtain a copy of the License at 

6# 

7# http://www.apache.org/licenses/LICENSE-2.0 

8# 

9# Unless required by applicable law or agreed to in writing, software 

10# distributed under the License is distributed on an "AS IS" BASIS, 

11# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 

12# See the License for the specific language governing permissions and 

13# limitations under the License. 

14# ============================================================================ 

15"""Debugging utilities""" 

16 

17import os 

18import colorsys 

19import csv 

20from contextlib import contextmanager 

21from enum import Enum, auto 

22from pathlib import Path 

23from math import isnan, sqrt 

24from typing import Any, Iterator, Optional 

25 

26import pandas as pd 

27import matplotlib.pyplot as plt 

28import matplotlib.colors as mc 

29from matplotlib.font_manager import FontProperties 

30from matplotlib.backends.backend_pdf import PdfPages 

31from scipy.stats import pearsonr 

32import yaml 

33 

34from hyper_parallel.auto_parallel.sapp_nd.nd.logger import logger 

35import hyper_parallel.auto_parallel.sapp_nd.nd.dimensions as Dim 

36from hyper_parallel.auto_parallel.sapp_nd.nd.recompute_dimension import read_recompute_modes 

37 

38# The column a configuration's activation checkpoint mode goes in, written by a 

39# search with a recompute dimension and read from a measured CSV that states it. 

40RECOMPUTE_COLUMN = "recompute" 

41 

42# The results table's recompute rows: the activation checkpoint mode a 

43# configuration runs, and the plan per layer that names the exceptions to it. 

44MODE_ROW = "RMOD" 

45LAYER_ROW = "RLAYER" 

46# Rows whose cells hold text, which carries no shading and no largest value. 

47_TEXT_ROWS = (MODE_ROW, LAYER_ROW) 

48 

49 

50def _recompute_of(row: dict) -> Optional[str]: 

51 """Take a measured row's activation checkpoint mode out of it, None where the CSV states none.""" 

52 stated = (row.pop(RECOMPUTE_COLUMN, None) or "").strip() 

53 if not stated: 

54 return None 

55 modes = read_recompute_modes(stated, f"the {RECOMPUTE_COLUMN} column") 

56 if len(modes) != 1: 

57 raise ValueError(f"the {RECOMPUTE_COLUMN} column: a measured run ran one mode, not {stated!r}") 

58 return modes[0] 

59 

60 

61def _has_recompute(entries: list) -> bool: 

62 """Whether any entry's configuration states an activation checkpoint mode.""" 

63 return any(getattr(entry[0], "recompute", None) for entry in entries) 

64 

65 

66# Where the CSVs and the plots go. Defaults to a directory beside this file, 

67# which is unwritable in an installed package and mixes consecutive runs 

68# together, so ``run_nd -o`` overrides it. 

69_OUTPUT_DIR = None 

70# The CSVs Debug.write appends a row a candidate to. 

71DEBUG_CSVS = ("debug.csv", "debug_mem.csv") 

72 

73 

74def set_output_dir(path: Optional[str], fresh: bool = False) -> None: 

75 """Send the debug artifacts to *path* instead of the package directory. 

76 

77 Args: 

78 path: the run's output folder, or None for the package's own. 

79 fresh: remove the debug CSVs an earlier run left there. ``Debug.write`` appends and writes the column 

80 titles only into a new file, so a run into a folder another run wrote would add its rows under that 

81 run's. The process that starts a run passes True; the pool's workers, which append to the files 

82 the run started, do not. 

83 """ 

84 global _OUTPUT_DIR # pylint: disable=global-statement 

85 _OUTPUT_DIR = str(path) if path else None 

86 if _OUTPUT_DIR: 

87 os.makedirs(_OUTPUT_DIR, exist_ok=True) 

88 if fresh: 

89 for name in DEBUG_CSVS: 

90 stale = os.path.join(output_dir(), name) 

91 if os.path.isfile(stale): 

92 os.remove(stale) 

93 

94 

95def output_dir() -> str: 

96 """Return the directory the debug artifacts are written to.""" 

97 if _OUTPUT_DIR: 

98 return _OUTPUT_DIR 

99 return os.path.join(os.path.dirname(os.path.abspath(__file__)), "output") 

100 

101 

102class PerfParts(Enum): 

103 """decomposition of performance""" 

104 

105 FW_COMPUTE = auto() 

106 BW_COMPUTE = auto() 

107 RECOMPUTE = auto() 

108 # FSDP's gathers and reduce-scatters, and a run's other DP traffic. 

109 DP_COMM = auto() 

110 # The all-reduce among the copies of an FSDP shard: HSDP's once a step, 

111 # every gradient's where FSDP shards nothing. 

112 DP_REDUCE = auto() 

113 MP_COMM = auto() 

114 EP_COMM = auto() 

115 CP_COMM = auto() 

116 PP_COMM = auto() 

117 BUBBLE = auto() 

118 TOTAL = auto() 

119 MEMORY = auto() 

120 

121 def __str__(self): 

122 return self.name 

123 

124 def short_name(self): 

125 """Returns short component name""" 

126 name = "Perf" 

127 if self == self.FW_COMPUTE: 

128 name = "FW" 

129 elif self == self.BW_COMPUTE: 

130 name = "BW" 

131 elif self == self.RECOMPUTE: 

132 name = "Rec" 

133 elif self == self.DP_COMM: 

134 name = "DP" 

135 elif self == self.DP_REDUCE: 

136 name = "AR" 

137 elif self == self.MP_COMM: 

138 name = "MP" 

139 elif self == self.EP_COMM: 

140 name = "EP" 

141 elif self == self.CP_COMM: 

142 name = "CP" 

143 elif self == self.PP_COMM: 

144 name = "P2P" 

145 elif self == self.BUBBLE: 

146 name = "BBL" 

147 elif self == self.MEMORY: 

148 name = "MEM" 

149 return name 

150 

151 

152class RealParts(Enum): 

153 """decomposition of performance""" 

154 

155 COMP = auto() 

156 DP_WAIT = auto() 

157 MP_WAIT = auto() 

158 EP_WAIT = auto() 

159 CP_WAIT = auto() 

160 PP_WAIT = auto() 

161 IDLE = auto() 

162 TOTAL = auto() 

163 

164 def __str__(self): 

165 return self.name.lower() 

166 

167 

168# An optional column of a classified CSV, and the key it is read into: the 

169# trainer's own step time in milliseconds, measured on steps the profiler left 

170# alone. It is not a RealParts member, so every consumer that walks that enum 

171# ignores it. ``examples/training_demo/sweep_qwen3_5_moe.py`` writes the column 

172# and spells the name again rather than importing it, because that launcher 

173# runs on the cluster's control node with nothing but the standard library. 

174TRAINER_STEP = "step_trainer" 

175 

176 

177class MemParts(Enum): 

178 """decomposition of memory""" 

179 

180 TOTAL = auto() 

181 

182 def __str__(self): 

183 return self.name 

184 

185 

186class Debug: 

187 """Debugging tools""" 

188 

189 def __init__( 

190 self, 

191 parallel_dimensions, 

192 info_type, 

193 enable=True, 

194 output_file="debug.csv", 

195 ): 

196 self.enable = enable 

197 if self.enable: 

198 self.parallel_dimensions = parallel_dimensions 

199 self.info = {p: 0 for p in info_type} 

200 self.output_file = os.path.join(output_dir(), output_file) 

201 

202 def is_enabled(self): 

203 """Check whether debugging is enabled""" 

204 return self.enable 

205 

206 def column_titles(self): 

207 """Parameters to debug""" 

208 titles = self.parallel_dimensions.keys() + list(self.info.keys()) 

209 titles = [str(t) for t in titles] 

210 return ",".join(titles) + "\n" 

211 

212 def values(self): 

213 """values debugged""" 

214 str_dims = [str(v) for v in self.parallel_dimensions.values()] 

215 str_score = [str(int(v)) for v in self.info.values()] 

216 return ",".join(str_dims + str_score) + "\n" 

217 

218 def write(self): 

219 """Append this row to the CSV, under the column titles where the file is new. 

220 

221 Inside :func:`holding_open` the file stays open for the next row. 

222 """ 

223 if self.enable: 

224 logger.info("debug written") 

225 held = _HELD.files.get(self.output_file) 

226 if held is not None: 

227 held.write(self.values()) 

228 return 

229 os.makedirs(os.path.dirname(self.output_file), exist_ok=True) 

230 is_new = not os.path.exists(self.output_file) 

231 outfile = open(self.output_file, "a", encoding="utf-8") # pylint: disable=consider-using-with 

232 if _HELD.depth: 

233 _HELD.files[self.output_file] = outfile 

234 try: 

235 if is_new: 

236 outfile.write(self.column_titles()) 

237 outfile.write(self.values()) 

238 finally: 

239 if not _HELD.depth: 

240 outfile.close() 

241 

242 

243class _HeldFiles: 

244 """The debug CSVs a search holds open while it writes them, by path.""" 

245 

246 def __init__(self) -> None: 

247 """No block open and no file held.""" 

248 self.depth = 0 

249 self.files = {} 

250 

251 

252_HELD = _HeldFiles() 

253 

254 

255@contextmanager 

256def holding_open() -> Iterator[None]: 

257 """Keep each debug CSV written inside the block open until the block ends. 

258 

259 A search writes a row a candidate, and opening and closing the file for 

260 each row cost 3.3 s of the Qwen3.5 example's 14507 rows in a temporary 

261 folder and 6.5 s inside OneDrive. The files are closed, so whole on 

262 disk, when the outermost block ends; a row written outside any block 

263 still opens and closes its file. 

264 """ 

265 _HELD.depth += 1 

266 try: 

267 yield 

268 finally: 

269 _HELD.depth -= 1 

270 if not _HELD.depth: 

271 while _HELD.files: 

272 _HELD.files.popitem()[1].close() 

273 

274 

275def pastel(color, l_delta=0.0, lbl=None, sat=None): 

276 "Pastel (lighter) color of the input" 

277 if color == "white": 

278 return (1.0, 1.0, 1.0) 

279 if color == "black": 

280 return (0.5, 0.5, 0.5) 

281 try: 

282 color = mc.cnames[color] 

283 except KeyError: 

284 pass 

285 color_hls = colorsys.rgb_to_hls(*mc.to_rgb(color)) 

286 lgt = 0.7 

287 if lbl is not None: 

288 lgt = lbl 

289 lgt = lgt + l_delta 

290 

291 if sat is None: 

292 sat = 0.6 

293 return colorsys.hls_to_rgb(color_hls[0], lgt, sat) 

294 

295 

296def near_white(color, ratio): 

297 "Very light color of the input for background" 

298 rgb = mc.to_rgb(color) 

299 if rgb is None: 

300 return "white" 

301 (red, green, blue) = rgb 

302 red += (1 - red) * ratio 

303 green += (1 - green) * ratio 

304 blue += (1 - blue) * ratio 

305 return (red, green, blue) 

306 

307 

308def dim_color(dim, default="black"): 

309 """Color of parallel dimensions for plot""" 

310 color = { 

311 Dim.DP: "orange", 

312 Dim.OP: "orange", 

313 Dim.TP: "red", 

314 Dim.EP: "blue", 

315 Dim.CP: "teal", 

316 Dim.PP: "green", 

317 Dim.VPP: "green", 

318 Dim.MBN: "green", 

319 } 

320 try: 

321 dim = Dim.get_dim(dim) 

322 if dim in color: 

323 return color[dim] 

324 return default 

325 except ValueError: 

326 return default 

327 

328 

329def gen_colors(categories): 

330 """Color of each time component""" 

331 compute_color = "purple" 

332 idle_color = "grey" 

333 col_d = { 

334 str(PerfParts.FW_COMPUTE): pastel(compute_color, -0.2), 

335 str(PerfParts.BW_COMPUTE): pastel(compute_color, -0.1), 

336 str(PerfParts.RECOMPUTE): pastel(compute_color), 

337 str(PerfParts.DP_COMM): pastel(dim_color(Dim.DP)), 

338 str(PerfParts.DP_REDUCE): pastel(dim_color(Dim.DP), -0.15), 

339 str(PerfParts.MP_COMM): pastel(dim_color(Dim.TP), -0.1), 

340 str(PerfParts.EP_COMM): pastel(dim_color(Dim.EP)), 

341 str(PerfParts.CP_COMM): pastel(dim_color(Dim.CP)), 

342 str(PerfParts.PP_COMM): pastel(dim_color(Dim.PP)), 

343 str(PerfParts.BUBBLE): pastel(dim_color(Dim.PP), -0.15), 

344 "IDLE": idle_color, 

345 "COMPUTATION": pastel(compute_color, -0.2), 

346 } 

347 return [col_d.get(cat) for cat in categories] 

348 

349 

350def set_twin_handles(ax1, data_frame, dbg_cols): 

351 """Set legend for estimation and real""" 

352 handle1, label1 = ax1.get_legend_handles_labels() 

353 ax2 = plt.twinx() 

354 data_frame[dbg_cols].plot.bar( 

355 stacked=True, 

356 sharex=True, 

357 ax=ax2, 

358 position=0, 

359 color=gen_colors(dbg_cols), 

360 width=0.4, 

361 rot=0, 

362 ) 

363 

364 handle2, label2 = ax2.get_legend_handles_labels() # type: ignore 

365 # Patches compare by identity, so testing the handle admitted every one of 

366 # them while the labels deduplicated by name: the legend then drew more 

367 # swatches than it had names and mislabelled every entry past the first 

368 # shared one. Deduplicate on the label and keep its handle in step. 

369 for handle, lbl in zip(handle2, label2): 

370 if lbl not in label1: 

371 handle1.append(handle) 

372 label1.append(lbl) 

373 handles = handle1 

374 labels = label1 

375 plt.legend(handles, labels, loc="upper left", bbox_to_anchor=(1, 1)) 

376 leg = ax2.get_legend() 

377 pp_color = gen_colors(["PP_COMM"])[0] 

378 leg.legend_handles[-1].set_facecolor(pp_color) # type: ignore 

379 

380 

381# The measured parts of the comparison plot, named by the ND part each is set 

382# against: FSDP waits count as DP and sequence-parallel waits as MP, as they 

383# do in the correlations. 

384MEASURED_BARS = ("COMPUTATION", "DP_COMM", "MP_COMM", "EP_COMM", "CP_COMM", "BUBBLE") 

385 

386 

387def measured_bars(waits: dict, plot_idle: bool = False) -> list: 

388 """Return one configuration's measured parts in ``MEASURED_BARS`` order. 

389 

390 Every wait column of the classified CSV lands in one bar, ``op_wait`` in 

391 DP's and ``sp_wait`` in MP's as ``real_in_parts`` counts them, so with idle 

392 the stack adds up to the measured step. A part the CSV lacks is zero. 

393 

394 Args: 

395 waits: A configuration's measured parts, as ``get_comm_classified_data`` reads them. 

396 plot_idle: Whether to append the idle remainder. 

397 """ 

398 def part(name: str) -> float: 

399 """The measured part *name*, zero when the CSV does not have it.""" 

400 return waits.get(name) or 0.0 

401 

402 bars = [ 

403 part("comp"), 

404 part("dp_wait") + part("op_wait"), 

405 part("mp_wait") + part("sp_wait"), 

406 part("ep_wait"), 

407 part("cp_wait"), 

408 part("BUBBLE"), 

409 ] 

410 if plot_idle: 

411 bars.append(part("IDLE")) 

412 return bars 

413 

414 

415def _cell_number(text): 

416 """Read one cell of the degree table as a number. 

417 

418 A boolean dimension such as SP prints as True or False, which float() 

419 refuses, so every search that varied SP failed as its plot was drawn. 

420 """ 

421 if text in ("True", "False"): 

422 return float(text == "True") 

423 return float(text) 

424 

425 

426class Plot: 

427 """plot ND top configs""" 

428 

429 title: str 

430 col_title: list[str] 

431 row_title: list[str] 

432 cell_text: list[list[str]] 

433 data: list[tuple] 

434 dbg_cols: list[str] 

435 top: int 

436 

437 def __init__( 

438 self, 

439 title: Optional[str], 

440 rows: list, 

441 debug_parts: list, 

442 top: Optional[int] = None, 

443 show_top: bool = False, 

444 ) -> None: 

445 """Initialize the plot table, optionally displaying each configuration's rank.""" 

446 self.title = title 

447 self.top = top if top is not None else 20 

448 self.row_title = rows + ["MEM"] 

449 if show_top: 

450 self.row_title.insert(0, "TOP") 

451 self.dbg_cols = list(map(str, debug_parts)) 

452 self.col_title = [] 

453 self.cell_text = [] 

454 self.data = [] 

455 

456 def make_table(self): 

457 """Make the table below the plot with each parallelism degree and the recompute rows. 

458 

459 A recompute row holds text rather than a degree, so it keeps one 

460 shade and its cells grow with the lines its plan takes. 

461 """ 

462 self.cell_text = list(map(list, zip(*self.cell_text))) # transpose 

463 max_rows = [ 

464 None if title in _TEXT_ROWS else max(map(_cell_number, cells)) 

465 for title, cells in zip(self.row_title, self.cell_text) 

466 ] 

467 the_table = plt.table( 

468 cellText=self.cell_text, 

469 rowLabels=self.row_title, 

470 colLabels=self.col_title, 

471 cellLoc="center", 

472 loc="bottom", 

473 ) 

474 row_colors = list(map(dim_color, self.row_title)) 

475 for row in range(len(self.row_title)): 

476 lines = max(str(text).count("\n") + 1 for text in self.cell_text[row]) 

477 cell = the_table[row + 1, -1] 

478 cell.set_edgecolor("none") 

479 cell.get_text().set_color(row_colors[row]) 

480 cell.set_text_props(fontproperties=FontProperties(weight="bold")) 

481 cell.set_height(cell.get_height() * lines) 

482 for col in range(len(self.cell_text[0])): 

483 self._shade_cell(the_table[row + 1, col], row, max_rows[row], lines, row_colors[row]) 

484 

485 for col in range(len(self.cell_text[0])): 

486 the_table[0, col].set_edgecolor("none") 

487 

488 the_table.scale(xscale=1, yscale=1.2) # +len(rows)/5) 

489 

490 def _shade_cell(self, cell: Any, row: int, largest: Optional[float], lines: int, color: Any) -> None: 

491 """Shade one cell of the table by its value against the row's largest. 

492 

493 Args: 

494 cell: The cell to shade. 

495 row: Its row, which names the degree or the recompute setting. 

496 largest: The largest value the row holds; ``None`` for a row of 

497 text, which keeps the row's lightest shade. 

498 lines: The lines the row's tallest cell takes. 

499 color: The row's colour. 

500 """ 

501 cell.set_height(cell.get_height() * lines) 

502 if largest is None: 

503 ratio = 1 

504 else: 

505 try: 

506 ratio = 1 - (_cell_number(str(cell.get_text().get_text())) / largest) 

507 except ZeroDivisionError: 

508 ratio = 0 

509 if self.row_title[row] == LAYER_ROW: 

510 cell.set_text_props(ha="center", fontfamily="monospace") 

511 logger.debug( 

512 "tmax = %s, ratio = %f, col=%s, newcolor=%s", 

513 str(largest), 

514 ratio, 

515 str(mc.to_rgb(color)), 

516 str(near_white(color, ratio)), 

517 ) 

518 cell.set_facecolor(near_white(pastel(color), ratio)) 

519 cell.set_edgecolor("none") 

520 

521 def close(self, output_path, filename): 

522 """Plot closing statements""" 

523 plt.gca().set_xticklabels([]) 

524 plt.gca().set_yticklabels([]) 

525 plt.xlim([-0.5, len(self.data) - 0.5]) 

526 if self.title is not None: 

527 plt.title(self.title) 

528 plt.subplots_adjust(left=0.1, bottom=0.047 * (2 + len(self.row_title))) 

529 plotfile = os.path.join(output_path, filename + ".pdf") 

530 plt.savefig(plotfile, bbox_inches="tight") 

531 plt.clf() 

532 

533 def parse_data( 

534 self, 

535 configs_estimated, 

536 **kwargs, 

537 ): 

538 """Parse test data for plot""" 

539 real_data = kwargs.get("real_data", None) 

540 plot_idle = kwargs.get("plot_idle", False) 

541 include_all = kwargs.get("include_all", False) 

542 recompute_plans = kwargs.get("recompute_plans") 

543 min_e = configs_estimated[0][2] 

544 i = 0 

545 for index, cfg_e in enumerate(configs_estimated): 

546 cells = cfg_e[0].values() + self._recompute_cells(cfg_e[0], recompute_plans) + [cfg_e[1]] 

547 if self.row_title[0] == "TOP": 

548 cells.insert(0, cfg_e[0].rank or index + 1) 

549 self.cell_text.append(cells) 

550 self.col_title.append("") 

551 try: 

552 self.data.append( 

553 tuple([cfg_e[0], cfg_e[2], cfg_e[3]] + cfg_e[4]) 

554 ) 

555 if real_data is not None: 

556 logger.info(cfg_e[5]) 

557 real_data.append(tuple(measured_bars(cfg_e[5], plot_idle))) 

558 except IndexError: 

559 score = cfg_e[2] 

560 if not include_all and ( 

561 i >= self.top or (min_e is not None and score > min_e * 20) 

562 ): 

563 self.cell_text.pop() 

564 break 

565 self.data.append(tuple([cfg_e[0], score] + cfg_e[3])) 

566 i += 1 

567 

568 def _recompute_cells(self, config: Any, recompute_plans: Optional[dict]) -> list: 

569 """The recompute cells of one configuration's column, empty where the table has no such row. 

570 

571 Args: 

572 config: The configuration the column states. 

573 recompute_plans: ``{configuration: (mode, layers)}`` from a 

574 choice among named modes per layer; ``None`` where the search 

575 made none. 

576 

577 Returns: 

578 The mode alone, the mode and its plan per layer, or nothing. 

579 """ 

580 if MODE_ROW not in self.row_title: 

581 return [] 

582 mode, layers = (recompute_plans or {}).get(config, (getattr(config, "recompute", None), None)) 

583 cells = [mode.upper() if mode else "-"] 

584 if LAYER_ROW in self.row_title: 

585 cells.append( 

586 yaml.safe_dump(layers, default_flow_style=False, sort_keys=False).strip() 

587 if layers is not None else "-" 

588 ) 

589 return cells 

590 

591 

592def top_plot_configs(configs_estimated: list, max_num: Optional[int] = None) -> list: 

593 """Return the configurations the standard ND plot would include.""" 

594 if not configs_estimated: 

595 return [] 

596 top = max_num if max_num is not None else 20 

597 if top <= 0: 

598 return [] 

599 best_score = configs_estimated[0][2] 

600 selected = [] 

601 for config in configs_estimated: 

602 if len(selected) >= top or config[2] > best_score * 20: 

603 break 

604 selected.append(config) 

605 return selected 

606 

607 

608def plot_nd( 

609 configs_estimated: list, 

610 output_path: str, 

611 debug_parts: list, 

612 title: Optional[str] = None, 

613 max_num: Optional[int] = None, 

614 include_all: bool = False, 

615 top_result_count: Optional[int] = None, 

616 recompute_plans: Optional[dict] = None, 

617) -> None: 

618 """Plot estimation with optional recompute rows and a divider before additions. 

619 

620 Args: 

621 configs_estimated: Ranked configurations with memory, score and its parts. 

622 TOP uses the rank stored on each configuration, falling back to its 

623 position in the supplied list when it has not been ranked. 

624 output_path: Directory in which to save results.pdf. 

625 debug_parts: Performance components to plot. 

626 title: Optional plot title. 

627 max_num: Maximum number of configurations in the normal plot. 

628 include_all: Include every supplied configuration without normal plot limits. 

629 top_result_count: Number of normal results preceding the additions. 

630 recompute_plans: ``{configuration: (mode, layers)}``, the activation 

631 checkpoint mode and the plan per layer a choice among named modes 

632 made. ``None`` omits the plan row; a configuration that states a 

633 mode of its own still shows it. 

634 """ 

635 plot = Plot( 

636 title, configs_estimated[0][0].keys(), debug_parts, 

637 top=max_num, show_top=True, 

638 ) 

639 if _has_recompute(configs_estimated) or recompute_plans is not None: 

640 plot.row_title.insert(-1, MODE_ROW) 

641 if recompute_plans is not None: 

642 plot.row_title.insert(-1, LAYER_ROW) 

643 plot.parse_data(configs_estimated, include_all=include_all, recompute_plans=recompute_plans) 

644 

645 data_frame = pd.DataFrame( 

646 plot.data, columns=(["config", "estim"] + plot.dbg_cols) 

647 ) 

648 axis = data_frame[plot.dbg_cols].plot.bar( 

649 stacked=True, color=gen_colors(plot.dbg_cols), width=0.4, rot=0 

650 ) 

651 axis.set_ylim(ymin=1) 

652 axis.legend(loc="upper left", bbox_to_anchor=(1, 1)) 

653 if top_result_count is not None and 0 < top_result_count < len(plot.data): 

654 axis.axvline(top_result_count - 0.5, color="black", linewidth=1.5) 

655 

656 plot.make_table() 

657 plot.close(output_path, "results") 

658 

659 

660def plot_recompute( 

661 groups: list, 

662 output_path: str, 

663 debug_parts: list, 

664 filename: str, 

665 title: Optional[str] = None, 

666 columns_per_page: int = 12, 

667) -> None: 

668 """Write a summary of every selected recompute plan, then a detail page per strategy. 

669 

670 Each bar is the cost model's estimate of the step under that plan, split 

671 into its parts, so the step between two bars is what the cost model 

672 expects the extra recomputed layer to cost. 

673 

674 Args: 

675 groups: The ranked parallel configurations and the records of their 

676 selected plans, as ``recompute_plot_data`` prepares them. 

677 output_path: Directory in which to save the PDF. 

678 debug_parts: The performance components in each record's parts. 

679 filename: The PDF's basename, without its extension. 

680 title: Optional description of the model and the selection. 

681 columns_per_page: The most plans on one detail page. The summary 

682 holds every selected plan, with a divider between strategies. 

683 

684 Raises: 

685 ValueError: The page size is not positive, or a record's score parts 

686 do not cover every component. 

687 """ 

688 if columns_per_page <= 0: 

689 raise ValueError("columns_per_page must be positive") 

690 if not groups: 

691 return 

692 os.makedirs(output_path, exist_ok=True) 

693 heading = title or "ND recompute" 

694 with PdfPages(os.path.join(output_path, filename + ".pdf")) as document: 

695 _save_page(document, _recompute_summary(groups, debug_parts, heading)) 

696 for config, records in groups: 

697 _save_detail_pages(document, config, records, debug_parts, heading, columns_per_page) 

698 

699 

700def _save_page(document: Any, figure: Any) -> None: 

701 """Add one figure to the PDF and release it.""" 

702 document.savefig(figure, bbox_inches="tight") 

703 plt.close(figure) 

704 

705 

706def _recompute_summary(groups: list, debug_parts: list, heading: str) -> Any: 

707 """The page that holds every selected plan, the strategies it covers named where it has none.""" 

708 plans = [(config, record) for config, records in groups for record in records] 

709 labels = [f"{config.rank}.{index + 1}" for config, records in groups for index in range(len(records))] 

710 page_title = f"{heading}\nSummary | {len(plans)} plans across {len(groups)} configurations" 

711 if not plans: 

712 return _empty_recompute_page([config for config, _ in groups], page_title) 

713 figure = _recompute_page(plans, debug_parts, page_title, labels) 

714 empty = [str(config.rank) for config, records in groups if not records] 

715 if empty: 

716 figure.text(0.5, 0.01, "No qualifying plans for TOP " + ", ".join(empty), ha="center", fontsize=8) 

717 return figure 

718 

719 

720def _save_detail_pages( 

721 document: Any, config: Any, records: list, debug_parts: list, heading: str, columns_per_page: int 

722) -> None: 

723 """Add one strategy's plans to the PDF, paginated, or a page saying it has none.""" 

724 if not records: 

725 _save_page(document, _empty_recompute_page([config], f"{heading}\nTOP {config.rank}")) 

726 return 

727 for start in range(0, len(records), columns_per_page): 

728 page = records[start:start + columns_per_page] 

729 page_title = ( 

730 f"{heading}\n" 

731 f"TOP {config.rank} | plans {start + 1}-{start + len(page)} of {len(records)}" 

732 ) 

733 labels = [str(start + index + 1) for index in range(len(page))] 

734 _save_page(document, _recompute_page([(config, record) for record in page], debug_parts, page_title, labels)) 

735 

736 

737def _empty_recompute_page(configs: list, title: str) -> Any: 

738 """Say that a selection is empty, and name the strategies it covers.""" 

739 figure = plt.figure(figsize=(12, max(5, 3 + 0.3 * len(configs)))) 

740 figure.text(0.5, 0.8, title, ha="center", fontsize=12) 

741 figure.text(0.5, 0.6, "No recompute plan satisfies the selection for these configurations.", ha="center") 

742 descriptions = [ 

743 f"TOP {config.rank}: " + " ".join(f"{dim}={value}" for dim, value in config.dims_val.items()) 

744 for config in configs 

745 ] 

746 figure.text(0.5, 0.45, "\n".join(descriptions), ha="center", va="top") 

747 return figure 

748 

749 

750def _recompute_page(plans: list, debug_parts: list, title: str, labels: list) -> Any: 

751 """Draw one page of plans, with a layer table under the chart and a divider between strategies.""" 

752 if any(len(record["parts"]) != len(debug_parts) for _, record in plans): 

753 raise ValueError("recompute plot records must include every performance component") 

754 rows = ["TOP"] + plans[0][0].keys() + ["RFULL", MODE_ROW, LAYER_ROW, "MEM", "SCORE"] 

755 cells = list(map(list, zip(*[_recompute_column(config, record) for config, record in plans]))) 

756 lines = [max(str(text).count("\n") + 1 for text in row) for row in cells] 

757 heights = [0.20] + [0.18 * count + 0.06 for count in lines] 

758 table_height = sum(heights) 

759 figure = plt.figure(figsize=(max(12, 1.3 * len(plans) + 3), 4.5 + table_height)) 

760 grid = figure.add_gridspec(2, 1, height_ratios=[3.5, table_height], hspace=0.10) 

761 axis = _recompute_chart(figure.add_subplot(grid[0]), plans, debug_parts, title, labels) 

762 table_axis = figure.add_subplot(grid[1]) 

763 _recompute_table(table_axis, rows, cells, labels, heights) 

764 for index in range(1, len(plans)): 

765 if plans[index][0] != plans[index - 1][0]: 

766 axis.axvline(index - 0.5, color="black", linewidth=1.5) 

767 table_axis.axvline(index / len(plans), color="black", linewidth=1.5, zorder=10) 

768 figure.subplots_adjust(left=0.09, right=0.84, top=0.88, bottom=0.035) 

769 return figure 

770 

771 

772def _recompute_column(config: Any, record: dict) -> list: 

773 """One plan's column: its strategy, how many layers it fully recomputes, its plan, memory and estimate.""" 

774 layers = record["layers"] 

775 return [str(config.rank)] + config.values() + [ 

776 str(record["full_layers"]) if record["full_layers"] is not None else "-", 

777 record["mode"].upper() if record["mode"] else "-", 

778 yaml.safe_dump(layers, default_flow_style=False, sort_keys=False).strip() if layers is not None else "-", 

779 f"{record['memory']:.0f} MB", 

780 f"{record['score']:.6g}", 

781 ] 

782 

783 

784def _recompute_chart(axis: Any, plans: list, debug_parts: list, title: str, labels: list) -> Any: 

785 """Stack each plan's estimated parts on one bar, and return the axis.""" 

786 parts = list(map(str, debug_parts)) 

787 frame = pd.DataFrame([record["parts"] for _, record in plans], columns=parts, index=labels) 

788 frame.plot.bar(ax=axis, stacked=True, color=gen_colors(parts), width=0.65, rot=0) 

789 axis.set_xlim(-0.5, len(plans) - 0.5) 

790 axis.set_ylim( 

791 min(0, float(frame.clip(upper=0).sum(axis=1).min()) * 1.05), 

792 max(1, float(frame.clip(lower=0).sum(axis=1).max()) * 1.05), 

793 ) 

794 axis.set_ylabel("Estimated step (lower is better)") 

795 axis.set_xlabel("") 

796 axis.legend(loc="upper left", bbox_to_anchor=(1, 1), fontsize=8) 

797 axis.set_title(title, fontsize=11) 

798 return axis 

799 

800 

801def _recompute_table(table_axis: Any, rows: list, cells: list, labels: list, heights: list) -> None: 

802 """Draw the table of plans under the chart, each row shaded like its dimension.""" 

803 table_height = sum(heights) 

804 table_axis.axis("off") 

805 table = table_axis.table(cellText=cells, rowLabels=list(map(str, rows)), colLabels=labels, 

806 cellLoc="center", loc="center", bbox=[0, 0, 1, 1]) 

807 table.auto_set_font_size(False) 

808 table.set_fontsize(8) 

809 for (row, column), cell in table.get_celld().items(): 

810 cell.set_height(heights[row] / table_height) 

811 cell.set_edgecolor("white") 

812 if row == 0: 

813 cell.set_facecolor("#eeeeee") 

814 continue 

815 row_title = rows[row - 1] 

816 if column == -1: 

817 cell.get_text().set_color(dim_color(row_title)) 

818 cell.get_text().set_fontweight("bold") 

819 else: 

820 cell.set_facecolor(near_white(pastel(dim_color(row_title)), 0.9)) 

821 if row_title == LAYER_ROW: 

822 cell.get_text().set_fontfamily("monospace") 

823 cell.get_text().set_ha("center" if cells[row - 1][column] == "{}" else "left") 

824 

825 

826def _score_parts() -> list: 

827 """The parts a score splits into, in the order the debugger fills them.""" 

828 return [part for part in PerfParts if part not in {PerfParts.TOTAL, PerfParts.MEMORY}] 

829 

830 

831def _make_parent(path: str) -> None: 

832 """Create the directory *path* is to be written in, when it is missing.""" 

833 directory = os.path.dirname(path) 

834 if directory: 

835 os.makedirs(directory, exist_ok=True) 

836 

837 

838def write_ranking_csv(scored_space: list, path: str) -> None: 

839 """Write a search's configurations in ND's order, best first. 

840 

841 One row per configuration that fits memory: its rank, its degrees, the 

842 activation checkpoint mode where the search had a recompute dimension, 

843 the peak memory in MB, the score and the parts the score splits into, 

844 which are blank when the search ran without debug output. Scores keep 

845 full precision so that a reader can tell a tie from a near miss. 

846 

847 Args: 

848 scored_space: ``(config, memory, score, parts)`` entries, as 

849 ``ParallelizeLayer.order_search_space`` sorts them. Stored ranks 

850 are preserved; unranked configurations use their input positions. 

851 path: CSV file to write; its directory is created when missing. 

852 """ 

853 parts = _score_parts() 

854 _make_parent(path) 

855 dims = [str(dim) for dim in scored_space[0][0].keys()] if scored_space else [] 

856 moded = _has_recompute(scored_space) 

857 with open(path, "w", newline="", encoding="utf-8") as handle: 

858 writer = csv.writer(handle) 

859 writer.writerow(["rank"] + dims + ([RECOMPUTE_COLUMN] if moded else []) + ["memory_mb", "score"] 

860 + [str(part) for part in parts]) 

861 for rank, (config, memory, score, values) in enumerate(scored_space, start=1): 

862 split = [repr(float(value)) for value in values] if values else [""] * len(parts) 

863 writer.writerow([config.rank or rank] + config.values() + ([config.recompute] if moded else []) 

864 + [memory, repr(float(score))] + split) 

865 

866 

867def write_estimates_csv(configs_estimated: list, path: str) -> None: 

868 """Write ND's estimate of every configuration of a classified comparison. 

869 

870 One row per measured configuration, in the comparison's order: its 

871 degrees, the activation checkpoint mode where the measured CSV states 

872 one, the measured step, ND's peak memory in MB, its score and the parts 

873 of the score. The plots show these only as bars; a sweep needs ND's 

874 memory as a number, to set it beside the peak the trainer logged. 

875 

876 Args: 

877 configs_estimated: ``(config, peak_mem, real_time, score, parts, 

878 real_parts)`` entries, as ``ParallelizeLayer.compare_with_csv`` 

879 returns them. 

880 path: CSV file to write; its directory is created when missing. 

881 """ 

882 parts = _score_parts() 

883 _make_parent(path) 

884 dims = [str(dim) for dim in configs_estimated[0][0].keys()] if configs_estimated else [] 

885 moded = _has_recompute(configs_estimated) 

886 with open(path, "w", newline="", encoding="utf-8") as handle: 

887 writer = csv.writer(handle) 

888 writer.writerow(dims + ([RECOMPUTE_COLUMN] if moded else []) + ["time", "memory_mb", "score"] 

889 + [str(part) for part in parts]) 

890 for config, memory, step, score, values, _ in configs_estimated: 

891 split = [repr(float(value)) for value in values[:len(parts)]] 

892 writer.writerow(config.values() + ([config.recompute] if moded else []) 

893 + [step, memory, repr(float(score))] + split) 

894 

895 

896def busy_time(entry: tuple) -> float: 

897 """The measured step of a comparison entry less its idle remainder.""" 

898 return entry[2] - (entry[5].get("IDLE") or 0.0) 

899 

900 

901def plot_vs_real( 

902 configs_estimated, csv_f, output_path, debug_parts, title=None 

903): 

904 """Plot estimation vs real global time""" 

905 plot = Plot(title, configs_estimated[0][0].keys(), debug_parts) 

906 plot.parse_data(configs_estimated) 

907 

908 data_frame = pd.DataFrame( 

909 plot.data, columns=(["config", "Real", "estim"] + plot.dbg_cols) 

910 ) 

911 ax1 = data_frame["Real"].plot.bar( 

912 position=1.1, width=0.4, secondary_y="real", color="grey", rot=0 

913 ) 

914 

915 set_twin_handles(ax1, data_frame, plot.dbg_cols) 

916 plot.make_table() 

917 plot.close(output_path, Path(os.path.basename(csv_f)).stem) 

918 

919 

920def plot_vs_real_comm_classified( 

921 configs_estimated, 

922 csv_f, 

923 output_path, 

924 debug_parts, 

925 **kwargs, 

926): 

927 """Plot estimation vs real detailed time. 

928 

929 Written to ``<csv stem><suffix>.pdf`` in *output_path*: ``plot_idle`` adds 

930 the measured idle remainder to the measured bars, and ``suffix`` (empty 

931 by default) tells apart several plots of one CSV. 

932 """ 

933 plot_idle = kwargs.get("plot_idle", False) 

934 title = kwargs.get("title", None) 

935 suffix = kwargs.get("suffix", "") 

936 real_data = [] 

937 

938 plot = Plot(title, configs_estimated[0][0].keys(), debug_parts) 

939 plot.parse_data( 

940 configs_estimated, 

941 real_data=real_data, 

942 plot_idle=plot_idle, 

943 ) 

944 

945 data_frame = pd.DataFrame( 

946 plot.data, columns=(["config", "real", "estim"] + plot.dbg_cols) 

947 ) 

948 real_cols = list(MEASURED_BARS) 

949 if plot_idle: 

950 real_cols.append("IDLE") 

951 real_df = pd.DataFrame(real_data, columns=real_cols) 

952 

953 ax1 = real_df[real_cols].plot.bar( 

954 stacked=True, 

955 sharex=True, 

956 position=1, 

957 secondary_y="real", 

958 color=gen_colors(real_cols), 

959 width=0.4, 

960 rot=0, 

961 legend=False, 

962 ) 

963 

964 set_twin_handles(ax1, data_frame, plot.dbg_cols) 

965 plot.make_table() 

966 plot.close( 

967 output_path, 

968 Path(os.path.basename(csv_f)).stem + suffix, 

969 ) 

970 

971 

972def correlation_topk(configs_estimated, csv_f): 

973 """Computes correlation & top-k between real & estimation""" 

974 times = [] 

975 estims = [] 

976 for _, _, time, score, _ in configs_estimated: 

977 times.append(time) 

978 estims.append(score) 

979 correl = pearsonr(times, estims).statistic # type: ignore 

980 if isnan(correl): 

981 logger.critical( 

982 "An input array is constant: %s or %s", str(times), str(estims) 

983 ) 

984 topk = 0 

985 for i, score in enumerate(estims): 

986 if not score == min(estims[i:]): 

987 break 

988 topk += 1 

989 if topk == 0: 

990 for i, score in enumerate(estims): 

991 if score == min(estims[i:]): 

992 break 

993 topk -= 1 

994 

995 logger.info("Correlation for file %s is: %.3f", csv_f, correl * 100) 

996 return correl, topk 

997 

998 

999def get_real_data(csv_f): 

1000 """Read execution time of different configurations on a given csv file""" 

1001 configs = [] 

1002 row_num = 0 

1003 with open(csv_f, newline="", encoding="utf-8") as csv_file: 

1004 rows = csv.DictReader(csv_file) 

1005 for row in rows: 

1006 row_num += 1 

1007 logger.info(row) 

1008 real_time = float(row.pop("time")) 

1009 recompute = _recompute_of(row) 

1010 config = [] 

1011 for dim_str, value in row.items(): 

1012 try: 

1013 dim = Dim.get_dim(dim_str) 

1014 logger.debug("%s : %s", str(dim), str(dim.from_str(value))) 

1015 config.append((dim, dim.from_str(value))) 

1016 except ValueError: 

1017 pass 

1018 configs.append((Dim.Dimensions(config, recompute=recompute), real_time)) 

1019 return configs, row_num 

1020 

1021 

1022def get_diff_dims(csv_f): 

1023 """Read execution time of different configurations on a given csv file""" 

1024 dims = [] 

1025 data_frame = pd.read_csv(csv_f) 

1026 for dim_str, degrees in data_frame.items(): 

1027 try: 

1028 dim = Dim.get_dim(dim_str) 

1029 diff_values = len(set(degrees)) 

1030 if diff_values > 1: 

1031 dims.append(dim) 

1032 except ValueError: 

1033 pass 

1034 return dims 

1035 

1036 

1037def get_comm_classified_data(csv_f, plot_idle=False): 

1038 """Read time components of different configurations on a given csv file. 

1039 

1040 ``time`` is the mean of the PROFILED steps, which is what the instrument 

1041 read and not what the model costs: on Ascend the profiler adds 0.33 to 

1042 0.84 s a step at EP 1 and 0.01 to 0.10 at EP 8, which is enough to reverse 

1043 a ranking. A CSV may therefore carry ``step_trainer`` as well, the 

1044 trainer's own step time over steps it did not profile, and this reads it 

1045 into the parts under the same name for whoever ranks the round. 

1046 

1047 It is deliberately NOT substituted for ``time`` here. The parts carry the 

1048 profiler's cost too, so the step less its parts goes negative against the 

1049 honest total on 4 of the 15 points of the 2 October round, to -499 ms at 

1050 EP 1, where against the profiled step the same residual is +96 to +454. 

1051 Idle stays a profiled quantity until the parts have a correction of their 

1052 own; only the total is honest enough to rank on. 

1053 """ 

1054 configs = [] 

1055 with open(csv_f, newline="", encoding="utf-8") as csv_file: 

1056 rows = csv.DictReader(csv_file) 

1057 for row in rows: 

1058 logger.info(row) 

1059 time = float(row.pop("time")) 

1060 trainer_step = (row.pop(TRAINER_STEP, "") or "").strip() 

1061 recompute = _recompute_of(row) 

1062 config = [] 

1063 comm_wait_time_classified = {} 

1064 total_wait = 0 

1065 

1066 for component, value_str in row.items(): 

1067 if "wait" in component: 

1068 value_float = float(value_str) 

1069 logger.info( 

1070 "Comm_wait = %s, v = %f", component, value_float 

1071 ) 

1072 comm_wait_time_classified[component] = value_float 

1073 total_wait += value_float 

1074 elif "comp" in component: 

1075 value_float = float(value_str) 

1076 logger.info("Computation = %f", value_float) 

1077 comm_wait_time_classified["comp"] = value_float 

1078 total_wait += value_float 

1079 else: 

1080 logger.info("d = %s, v = %s", component, value_str) 

1081 dim = Dim.get_dim(component) 

1082 config.append((dim, dim.from_str(value_str))) 

1083 comm_wait_time_classified["BUBBLE"] = comm_wait_time_classified.get(str(RealParts.PP_WAIT)) 

1084 if trainer_step: 

1085 comm_wait_time_classified[TRAINER_STEP] = float(trainer_step) 

1086 if plot_idle: 

1087 comm_wait_time_classified["IDLE"] = time - total_wait 

1088 logger.info( 

1089 "idle = total time - total waits = %.3f - %.3f", 

1090 time, 

1091 total_wait, 

1092 ) 

1093 configs.append( 

1094 (Dim.Dimensions(config, recompute=recompute), time, comm_wait_time_classified) 

1095 ) 

1096 return configs 

1097 

1098 

1099def estimation_in_real_parts( 

1100 estimations_in_real_components, estimations, score 

1101): 

1102 """Transform the estimation components into the RealParts components for comparison with real time""" 

1103 estimations_in_real_components[RealParts.TOTAL].append(score) 

1104 estimations_in_real_components[RealParts.COMP].append( 

1105 estimations[PerfParts.FW_COMPUTE.value - 1] 

1106 + estimations[PerfParts.BW_COMPUTE.value - 1] 

1107 + estimations[PerfParts.RECOMPUTE.value - 1] 

1108 ) 

1109 estimations_in_real_components[RealParts.DP_WAIT].append( 

1110 estimations[PerfParts.DP_COMM.value - 1] 

1111 + estimations[PerfParts.DP_REDUCE.value - 1] 

1112 ) 

1113 estimations_in_real_components[RealParts.MP_WAIT].append( 

1114 estimations[PerfParts.MP_COMM.value - 1] 

1115 ) 

1116 estimations_in_real_components[RealParts.CP_WAIT].append( 

1117 estimations[PerfParts.CP_COMM.value - 1] 

1118 ) 

1119 estimations_in_real_components[RealParts.EP_WAIT].append( 

1120 estimations[PerfParts.EP_COMM.value - 1] 

1121 ) 

1122 estimations_in_real_components[RealParts.PP_WAIT].append( 

1123 estimations[PerfParts.BUBBLE.value - 1] 

1124 + estimations[PerfParts.PP_COMM.value - 1] 

1125 ) 

1126 return estimations_in_real_components 

1127 

1128 

1129def real_in_parts(parts, real, time): 

1130 """Transform the real time components into the RealParts components for comparison with estimation""" 

1131 parts[RealParts.TOTAL].append(time) 

1132 for part in RealParts: 

1133 if part not in {RealParts.TOTAL, RealParts.IDLE}: 

1134 if str(part) in real.keys(): 

1135 parts[part].append(real[str(part)]) 

1136 else: 

1137 logger.warning( 

1138 "part = %s not in real keys = %s", part, real.keys() 

1139 ) 

1140 parts[part].append(0) 

1141 

1142 op = "op_wait" 

1143 if op in real.keys(): 

1144 parts[RealParts.DP_WAIT][-1] += real["op_wait"] 

1145 

1146 sp = "sp_wait" 

1147 if sp in real.keys(): 

1148 parts[RealParts.MP_WAIT][-1] += real["sp_wait"] 

1149 

1150 return parts 

1151 

1152 

1153def correlation_with_classified_comms(configs_estimated: list) -> tuple: 

1154 """Computes correlation and distance between components time & estimation.""" 

1155 score_classified = {} 

1156 time_classified = {} 

1157 distances = {} 

1158 

1159 for wait in RealParts: 

1160 if wait not in {RealParts.IDLE}: 

1161 score_classified[wait] = [] 

1162 time_classified[wait] = [] 

1163 distances[wait] = [] 

1164 

1165 topk = 0 

1166 still_top_k = True 

1167 

1168 for i, (_, _, time, score, values, real_values) in enumerate( 

1169 configs_estimated 

1170 ): 

1171 if ( 

1172 still_top_k 

1173 and score == (min(configs_estimated[i:], key=lambda t: t[3]))[3] 

1174 ): 

1175 topk += 1 

1176 else: 

1177 still_top_k = False 

1178 

1179 score_classified = estimation_in_real_parts( 

1180 score_classified, values, score 

1181 ) 

1182 

1183 time_classified = real_in_parts(time_classified, real_values, time) 

1184 

1185 square_distances_sum = 0 

1186 for wait in RealParts: 

1187 if wait not in {RealParts.TOTAL, RealParts.IDLE}: 

1188 distance = ( 

1189 time_classified[wait][-1] 

1190 / time_classified[RealParts.TOTAL][-1] 

1191 - score_classified[wait][-1] 

1192 / score_classified[RealParts.TOTAL][-1] 

1193 ) 

1194 square_distances_sum += distance * distance 

1195 distances[wait].append(abs(distance)) 

1196 distances[RealParts.TOTAL].append(sqrt(square_distances_sum)) 

1197 

1198 correls = {} 

1199 for wait in RealParts: 

1200 pearson_wait(correls, time_classified, score_classified, wait) 

1201 return correls, distances, topk, len(configs_estimated) 

1202 

1203 

1204def _share(value, total): 

1205 """Fraction of a total, 0 when the total is 0""" 

1206 return value / total if total else 0.0 

1207 

1208 

1209def format_classified_comparison(configs_estimated: list) -> str: 

1210 """Side-by-side measured and estimated parts of every configuration. 

1211 

1212 Shares are over each side's own total. ND has no idle term, so the measured 

1213 idle share is the part of the step the estimate does not account for. 

1214 

1215 Args: 

1216 configs_estimated: Entries of ``ParallelizeLayer.order_space_test_comm_classified``. 

1217 

1218 Returns: 

1219 One table per configuration: measured value and share, ND share, and their difference. 

1220 """ 

1221 parts = [part for part in RealParts if part not in {RealParts.IDLE, RealParts.TOTAL}] 

1222 lines = [] 

1223 for config, _, time, score, values, real_values in configs_estimated: 

1224 estim = estimation_in_real_parts({part: [] for part in RealParts}, values, score) 

1225 real = real_in_parts({part: [] for part in RealParts}, real_values, time) 

1226 lines.append(f"{config}: measured {time:.6g}, ND score {score:.6g}") 

1227 lines.append(f" {'part':8s} {'measured':>12s} {'share':>8s} {'ND share':>9s} {'diff':>8s}") 

1228 for part in parts: 

1229 real_share = _share(real[part][-1], time) 

1230 estim_share = _share(estim[part][-1], score) 

1231 lines.append( 

1232 f" {str(part):8s} {real[part][-1]:12.6g} {real_share:8.1%} {estim_share:9.1%} " 

1233 f"{real_share - estim_share:+8.1%}" 

1234 ) 

1235 idle = time - sum(real[part][-1] for part in parts) 

1236 lines.append(f" {str(RealParts.IDLE):8s} {idle:12.6g} {_share(idle, time):8.1%} {'-':>9s}") 

1237 return "\n".join(lines) 

1238 

1239 

1240def color_diff(diff): 

1241 """Color difference""" 

1242 if diff > 0: 

1243 return f"\033[92m improved by {diff:.3f}%\033[00m" 

1244 return f"\033[91m worsened by {-diff:.3f}%\033[00m" 

1245 

1246 

1247def color_correl(correlation): 

1248 """Color correlation""" 

1249 res = f"{correlation*100:.3f}%" 

1250 if correlation > 0.9: 

1251 res = f" \033[92m{res}\033[00m " 

1252 elif correlation < 0: 

1253 res = f"\033[91m{res}\033[00m " 

1254 elif correlation < 0.5: 

1255 res = f" \033[91m{res}\033[00m " 

1256 else: 

1257 res = f" \033[00m{res}\033[00m " 

1258 return res 

1259 

1260 

1261def print_diff(case, prev, new, **kwargs): 

1262 """Print difference of correlation""" 

1263 topk = kwargs.get("topk", None) 

1264 total = kwargs.get("total", None) 

1265 tabsize = kwargs.get("tabsize", 40) 

1266 diff = (new - prev) * 100 

1267 msg = "" 

1268 if -0.1 < diff < 0.1: 

1269 msg = f"{case} \tcorrelation :{color_correl(new)} \033[00m\033[00m" 

1270 else: 

1271 msg = ( 

1272 f"{case} \tcorrelation ({color_correl(new)}) is{color_diff(diff)}" 

1273 ) 

1274 if topk is not None and total is not None: 

1275 msg += f" topk = {topk}/{total}" 

1276 logger.output(msg.expandtabs(tabsize)) 

1277 

1278 

1279def get_distance_i(part: RealParts, data_i: tuple) -> float: 

1280 """get the average distance of a given part""" 

1281 _, distance, _, _ = data_i 

1282 return sum(distance[part]) / len(distance[part]) 

1283 

1284 

1285def get_correl_i(part, data_i): 

1286 """get the correlation of a given part""" 

1287 f_correl, _, _, _ = data_i 

1288 return f_correl[part] 

1289 

1290 

1291def print_part_x_file(data, fun): 

1292 """Prints a metric computed by fun for each couple (part, file)""" 

1293 msg = "" 

1294 for part in RealParts: 

1295 if part is not RealParts.IDLE: 

1296 msg += "\n" + str(part) + "\t" 

1297 col_sum = 0 

1298 col_num = 0 

1299 for data_i in data: 

1300 try: 

1301 info = fun(part, data_i) 

1302 msg += f"\t{(info*100):.1f}%" 

1303 col_sum += info 

1304 col_num += 1 

1305 except KeyError: 

1306 msg += "\t -" 

1307 if col_num > 0: 

1308 msg += f"\t\t{(col_sum/col_num)*100:.1f}%" 

1309 return msg 

1310 

1311 

1312def print_correlations_classified(data): 

1313 """Printer for estimation vs detailed profiling""" 

1314 msg = "\n\t" 

1315 for i, _ in enumerate(data): 

1316 msg += "\tFile " + str(i + 1) 

1317 msg += "\t\tavg" 

1318 

1319 msg += "\nCorrelation (higher is better)" 

1320 msg += print_part_x_file(data, get_correl_i) 

1321 

1322 msg += "\ntop_k\t" 

1323 for _, _, top_k, total in data: 

1324 msg += "\t" + str(top_k) + "/" + str(total) 

1325 

1326 msg += "\n\nEuclidean Distance (lower is better)" 

1327 msg += print_part_x_file(data, get_distance_i) 

1328 

1329 logger.output(msg) 

1330 

1331 

1332def is_constant(array): 

1333 """Whether the given array only has the same elements""" 

1334 if len(array) == 0: 

1335 return True 

1336 value = array[0] 

1337 return all(vi == value for vi in array) 

1338 

1339 

1340def pearson_wait(correls, real, estim, wait): 

1341 """Compute Pearson correlation if inputs are not empty""" 

1342 if wait not in {RealParts.IDLE}: 

1343 logger.debug( 

1344 "correlation for %s between real = %s && estim = %s", 

1345 str(wait), 

1346 str(real[wait]), 

1347 str(estim[wait]), 

1348 ) 

1349 if not is_constant(real[wait]) and not is_constant(estim[wait]): 

1350 pearson = pearsonr( 

1351 real[wait], estim[wait] 

1352 ).statistic # type: ignore 

1353 logger.info( 

1354 "correlation[%s] of real %s vs estim %s = %f", 

1355 wait, 

1356 real[wait], 

1357 estim[wait], 

1358 pearson, 

1359 ) 

1360 correls[wait] = pearson 

1361 else: 

1362 logger.warning( 

1363 "either estim[%s] = %s is constant", wait, str(estim[wait]) 

1364 ) 

1365 logger.warning( 

1366 "or real[%s] = %s is constant", wait, str(real[wait]) 

1367 )