Coverage for  / home / jenkins / .local / lib / python3.10 / site-packages / hyper_parallel / auto_parallel / sapp_ppb / pp_modeling / pp_balancer.py: 74%

209 statements  

« prev     ^ index     » next       coverage.py v7.13.1, created at 2026-08-21 04:29 +0800

1# Copyright 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"""PP balancer — ILP-based pipeline parallelism load balancing.""" 

16 

17from __future__ import annotations 

18 

19import logging 

20from dataclasses import asdict 

21from typing import Any, Dict, List, Optional, Tuple 

22 

23from hyper_parallel.auto_parallel.sapp_ppb.pp_config_builder.layer_loader import ( 

24 SAPP_PPB_AVAILABLE, 

25 _get_pipeline_layer_class, 

26) 

27from hyper_parallel.auto_parallel.sapp_ppb.pp_config_builder.yaml_parser import ( 

28 YamlOptimizationConfig, 

29) 

30from hyper_parallel.auto_parallel.sapp_ppb.pp_modeling.pp_structs import ( 

31 PPBOutput, 

32 PPStrategyResult, 

33) 

34from hyper_parallel.auto_parallel.sapp_ppb.utils.recompute import TYPE as RecomputeType 

35 

36if SAPP_PPB_AVAILABLE: 

37 from hyper_parallel.auto_parallel.sapp_ppb.sapp.sapp_pipeline import SappPipeline # pylint: disable=C0415 

38 from hyper_parallel.auto_parallel.sapp_ppb.utils import recompute as Recompute # pylint: disable=C0415 

39 

40logger = logging.getLogger(__name__) 

41 

42 

43class PPBalancer: 

44 """ILP-based pipeline parallelism load balancer. 

45 

46 Receives a :class:`LayerBuilder` (which holds the converted 

47 sapp-ppb Layer objects and YAML/JSON configs), constructs and 

48 solves the ILP, extracts the balanced partition, and builds 

49 the strategy result. 

50 

51 Args: 

52 layer_builder: A :class:`LayerBuilder` instance with validated 

53 configuration and constructed Layer objects. 

54 

55 Example: 

56 >>> builder = LayerBuilder(yaml_config, json_path) 

57 >>> balancer = PPBalancer(builder) 

58 >>> output = balancer.balance_with_ilp(time_limit=60) 

59 """ 

60 

61 def __init__( 

62 self, 

63 layer_builder: Any, 

64 ) -> None: 

65 """Initialize PPBalancer. 

66 

67 Args: 

68 layer_builder: A :class:`LayerBuilder` instance. 

69 

70 Raises: 

71 ImportError: If sapp-ppb module is not available. 

72 """ 

73 if not SAPP_PPB_AVAILABLE: 

74 raise ImportError( 

75 "sapp-ppb module is not available. " 

76 "Please ensure sapp-ppb is installed and accessible." 

77 ) 

78 

79 self._layer_builder = layer_builder 

80 self._pipeline: Optional[Any] = None 

81 self._is_successful: bool = False 

82 

83 @property 

84 def yaml_config(self) -> YamlOptimizationConfig: 

85 """YAML configuration with pipeline topology.""" 

86 return self._layer_builder.yaml_config 

87 

88 @property 

89 def pipeline(self) -> Optional[Any]: 

90 """Solved SappPipeline instance (available after :meth:`balance_with_ilp`).""" 

91 return self._pipeline 

92 

93 def _make_infeasible_output( 

94 self, 

95 reason: str, 

96 error: str = "", 

97 solver_status: Any = None, 

98 ) -> PPBOutput: 

99 """Create a PPBOutput indicating infeasibility. 

100 

101 Args: 

102 reason: Human-readable infeasibility reason. 

103 error: Optional error message string. 

104 solver_status: Optional solver status code. 

105 

106 Returns: 

107 PPBOutput with is_feasible=False and is_successful=False. 

108 """ 

109 details: Dict[str, Any] = {"reason": reason} 

110 if error: 

111 details["error"] = error 

112 if solver_status is not None: 

113 details["solver_status"] = solver_status 

114 return PPBOutput( 

115 stage_partition=[], 

116 layer_offset={}, 

117 is_feasible=False, 

118 infeasibility_details=details, 

119 ) 

120 

121 def _build_feasible_output( 

122 self, 

123 stage_partition: List[List[Tuple[int, RecomputeType]]], 

124 layer_offset: Optional[Dict[str, List[List[int]]]] = None, 

125 num_of_interleave: int = 1, 

126 ) -> PPBOutput: 

127 """Build a PPBOutput from the ILP solution. 

128 

129 Args: 

130 stage_partition: Per-(virtual-)stage list of ``(layer_id, RecomputeType)`` tuples. 

131 Length is ``vpp * pp`` when VPP > 1, otherwise ``pp``. 

132 layer_offset: Per-group layer offset (key=group_name, value shape ``[vpp][pp]``). 

133 num_of_interleave: VPP interleaving factor (1 = no VPP). 

134 

135 Returns: 

136 PPBOutput with is_feasible=True and is_successful=True. 

137 """ 

138 if layer_offset is None: 

139 layer_offset = {} 

140 

141 return PPBOutput( 

142 stage_partition=stage_partition, 

143 layer_offset=layer_offset, 

144 is_feasible=True, 

145 is_successful=True, 

146 infeasibility_details={}, 

147 num_of_interleave=num_of_interleave, 

148 ) 

149 

150 @staticmethod 

151 def _pulp_has_feasible_solution(pulp_problem: Any) -> bool: 

152 """Check whether a PuLP problem has a feasible solution despite non-optimal status. 

153 

154 When CBC times out (status=0 / Not Solved), it may have found a 

155 feasible incumbent without proving optimality. PuLP exposes this 

156 via ``sol_status``: ``LpSolutionOptimal`` (1) or 

157 ``LpSolutionIntegerFeasible`` (2) indicate a genuine feasible 

158 solution was found. ``LpSolutionNoSolutionFound`` (0) means no 

159 incumbent exists even though decision variables may have default 

160 values assigned. 

161 

162 Args: 

163 pulp_problem: The PuLP LpProblem instance. 

164 

165 Returns: 

166 True if ``sol_status`` indicates a feasible or optimal 

167 solution was found. 

168 """ 

169 from pulp import ( # pylint: disable=C0415 

170 LpSolutionOptimal, 

171 LpSolutionIntegerFeasible, 

172 ) 

173 try: 

174 return pulp_problem.sol_status in (LpSolutionOptimal, LpSolutionIntegerFeasible) 

175 except (AttributeError, TypeError): 

176 return False 

177 

178 def _check_ilp_solve_status(self) -> Optional[PPBOutput]: 

179 """Check ILP solver status and determine feasibility. 

180 

181 Distinguishes three categories: 

182 

183 1. **Optimal** (status=1) → return ``None`` (proceed to extract). 

184 2. **Not Solved** (status=0) with feasible incumbent 

185 (``sol_status`` is ``LpSolutionOptimal`` or 

186 ``LpSolutionIntegerFeasible``) → return ``None`` (proceed to 

187 extract, ``_is_successful`` set to ``True``). 

188 3. **Infeasible / Undefined / Unbounded / Not Solved without 

189 incumbent** (``sol_status`` is ``LpSolutionNoSolutionFound``) 

190 → return infeasible PPBOutput. 

191 

192 Returns: 

193 PPBOutput with ``is_feasible=False`` if no usable solution 

194 exists, otherwise ``None``. 

195 """ 

196 from pulp import ( # pylint: disable=C0415 

197 LpStatusOptimal, 

198 LpStatusInfeasible, 

199 LpStatusUndefined, 

200 LpStatusNotSolved, 

201 ) 

202 

203 if not hasattr(self._pipeline, 'problem_'): 

204 return None 

205 pulp_problem = getattr(self._pipeline.problem_, 'problem_', None) 

206 if not pulp_problem or not hasattr(pulp_problem, 'status'): 

207 return None 

208 

209 status = pulp_problem.status 

210 

211 if status == LpStatusOptimal: 

212 self._is_successful = True 

213 return None 

214 

215 if status == LpStatusInfeasible: 

216 return self._make_infeasible_output( 

217 "ILP solver returned infeasible status", 

218 solver_status=status, 

219 ) 

220 

221 if status == LpStatusUndefined: 

222 return self._make_infeasible_output( 

223 "ILP solver returned undefined status", 

224 solver_status=status, 

225 ) 

226 

227 if status == LpStatusNotSolved: 

228 if self._pulp_has_feasible_solution(pulp_problem): 

229 self._is_successful = True 

230 return None 

231 return self._make_infeasible_output( 

232 "ILP solver timed out with no feasible solution found", 

233 solver_status=status, 

234 ) 

235 

236 return self._make_infeasible_output( 

237 f"ILP solver returned unbounded status {status}", 

238 solver_status=status, 

239 ) 

240 

241 def balance_with_ilp( 

242 self, 

243 time_limit: int = 90, 

244 solver: str = "pulp", 

245 ) -> PPStrategyResult: 

246 """Run ILP-based load balancing using sapp-ppb. 

247 

248 Solves the ILP and builds the :class:`PPStrategyResult`. 

249 Simulation is *not* run here — that responsibility belongs to 

250 :class:`PPOptimizer`. 

251 

252 Pipeline topology parameters (``num_of_interleave``, 

253 ``vpp_less_memory``, ``optimization_level``) are read from 

254 the ``yaml_config`` stored in the :class:`LayerBuilder` passed 

255 at construction. 

256 

257 Args: 

258 time_limit: Solver time limit in seconds. 

259 solver: Solver backend ("pulp" or "gurobi"). 

260 

261 Returns: 

262 PP strategy result with balanced partition (without 

263 simulation metrics; simulation is the caller's 

264 responsibility). 

265 

266 Example: 

267 >>> builder = LayerBuilder(yaml_config, json_path) 

268 >>> balancer = PPBalancer(builder) 

269 >>> result = balancer.balance_with_ilp(time_limit=60) 

270 """ 

271 if not SAPP_PPB_AVAILABLE: 

272 raise ImportError("sapp-ppb module is not available") 

273 

274 if not self._layer_builder.memory_limit: 

275 raise ValueError( 

276 "memory_limit is required for ILP load balancing. " 

277 "Please specify memory_limit in the JSON profile." 

278 ) 

279 

280 num_of_interleave = self.yaml_config.num_of_interleave 

281 vpp_less_memory = self.yaml_config.vpp_less_memory 

282 optimization_level = self.yaml_config.optimization_level 

283 

284 self._pipeline = SappPipeline( # pylint: disable=E0606 

285 model_name="sapp_nd_model", 

286 num_of_stage=self.yaml_config.pp_degree, 

287 num_of_micro_batch=self.yaml_config.micro_batch_num, 

288 max_memory=self._layer_builder.memory_limit, 

289 layers=self._layer_builder.layers_sapp_ppb, 

290 num_of_interleave=num_of_interleave, 

291 vpp_less_memory=vpp_less_memory, 

292 constant_memory=self._layer_builder.constant_memory, 

293 optimization_level=optimization_level, 

294 use_backward_time=self._layer_builder.use_backward_time, 

295 ) 

296 

297 self._pipeline.construct_problem(solver=solver) 

298 

299 self._pipeline.solve_problem(time_limit=time_limit) 

300 

301 ppb_output = self._build_ilp_result() 

302 

303 return self._build_strategy_result(ppb_output) 

304 

305 def _build_strategy_result( 

306 self, 

307 ppb_output: PPBOutput, 

308 ) -> PPStrategyResult: 

309 """Build a :class:`PPStrategyResult` from the ILP output. 

310 

311 Translates the raw ILP result into the strategy result that 

312 downstream consumers use. Simulation metrics are left at 

313 their defaults (``simulation_status="not_run"``, etc.); 

314 :class:`PPOptimizer` is responsible for running the simulator 

315 and merging those fields. 

316 

317 Args: 

318 ppb_output: Output from :meth:`_build_ilp_result`. 

319 

320 Returns: 

321 Assembled strategy result (without simulation metrics). 

322 """ 

323 return PPStrategyResult( 

324 pp_degree=self.yaml_config.pp_degree, 

325 micro_batch_num=self.yaml_config.micro_batch_num, 

326 vpp_less_memory=self.yaml_config.vpp_less_memory, 

327 pipeline_bubble=None, 

328 **asdict(ppb_output), 

329 ) 

330 

331 def _build_ilp_result( 

332 self, 

333 ) -> PPBOutput: 

334 """Extract results from solved ILP and build PPBOutput. 

335 

336 Returns: 

337 PPB output with balanced partition. 

338 """ 

339 infeasible_output = self._check_ilp_solve_status() 

340 if infeasible_output is not None: 

341 return infeasible_output 

342 

343 result = self._pipeline.get_result() 

344 

345 try: 

346 stage_partition = self._extract_stage_partition(result) 

347 except RuntimeError as e: 

348 return self._make_infeasible_output( 

349 "Failed to extract partition from ILP solution", 

350 error=str(e), 

351 ) 

352 

353 try: 

354 layer_offset = self._extract_layer_offset_from_ilp(stage_partition) 

355 except RuntimeError as e: 

356 return self._make_infeasible_output( 

357 "Failed to extract layer offset from ILP solution", 

358 error=str(e), 

359 ) 

360 

361 vpp = self._pipeline.num_of_interleave_ 

362 

363 return self._build_feasible_output( 

364 stage_partition, 

365 layer_offset=layer_offset, 

366 num_of_interleave=vpp, 

367 ) 

368 

369 def _extract_stage_partition(self, result: Dict[str, List[List[str]]]) -> List[List[Tuple[int, RecomputeType]]]: # pylint: disable=unused-argument 

370 """Extract stage partition from sapp-ppb result. 

371 

372 Each BODY group has its own entry in the ILP variables (keyed by 

373 ``layer.name_``). This method extracts per-group 

374 per-interleave-per-stage per-recompute layer counts, maps them 

375 to contiguous layer-ID ranges (HEAD=0, then BODY groups in 

376 order, then TAIL=total_body+1), and attaches the per-layer 

377 ``RecomputeType`` from the ILP decision variables. 

378 

379 When ``num_of_interleave == 1`` (no VPP), the output has 

380 ``pp_degree`` entries indexed by physical stage. 

381 

382 When ``num_of_interleave > 1`` (VPP enabled), the output has 

383 ``vpp * pp_degree`` entries treated as **virtual stages**: 

384 virtual stage ``v * pp_degree + s`` corresponds to VPP chunk 

385 ``v`` on physical stage ``s``. Layer IDs are assigned 

386 per-VPP-chunk: each chunk gets a contiguous range of body 

387 layer IDs (chunk 0 gets the first range, chunk 1 the next, 

388 etc.), preserving the true layer-to-chunk mapping. 

389 

390 HEAD (layer_id 0) is placed in virtual stage 0 (chunk 0, 

391 stage 0). TAIL (layer_id ``total_body + 1``) is placed in 

392 the last virtual stage (chunk ``vpp-1``, stage 

393 ``pp_degree-1``). 

394 

395 HEAD and TAIL layers are always annotated as 

396 ``RecomputeType.NONE``. 

397 

398 Args: 

399 result: sapp-ppb result dictionary (unused, kept for API compatibility). 

400 

401 Returns: 

402 List of ``(layer_id, RecomputeType)`` tuples per virtual 

403 stage. Length is ``vpp * pp_degree`` when VPP > 1, 

404 otherwise ``pp_degree``. 

405 """ 

406 if self._pipeline is None or self._pipeline.problem_ is None: 

407 raise RuntimeError("Pipeline not constructed or solved yet") 

408 

409 solver = self._pipeline.problem_ 

410 pp = self.yaml_config.pp_degree 

411 vpp = self._pipeline.num_of_interleave_ 

412 num_virtual_stages = vpp * pp 

413 

414 body_group_names = self._get_body_group_names() 

415 total_body = self._total_body_layers() 

416 

417 stage_partition: List[List[Tuple[int, RecomputeType]]] = [ 

418 [] for _ in range(num_virtual_stages) 

419 ] 

420 

421 if vpp <= 1: 

422 self._assign_layers_no_vpp( 

423 solver, body_group_names, pp, stage_partition, 

424 total_body, 

425 ) 

426 else: 

427 self._assign_layers_with_vpp( 

428 solver, body_group_names, pp, vpp, stage_partition, 

429 total_body, 

430 ) 

431 

432 for vstage in stage_partition: 

433 vstage.sort(key=lambda entry: entry[0]) 

434 

435 return stage_partition 

436 

437 def _assign_layers_no_vpp( 

438 self, 

439 solver: Any, 

440 body_group_names: List[str], 

441 pp: int, # pylint: disable=unused-argument 

442 stage_partition: List[List[Tuple[int, RecomputeType]]], 

443 total_body: int, 

444 ) -> None: 

445 """Assign layer IDs when VPP is disabled (vpp <= 1). 

446 

447 Iterates over BODY groups in order and assigns contiguous 

448 layer IDs to each physical stage based on the summed 

449 (across recompute types) ILP variable values. 

450 

451 Args: 

452 solver: ILP solver object with ``variables_`` attribute. 

453 body_group_names: Ordered list of BODY group names. 

454 _pp: Pipeline parallel degree (unused, kept for API 

455 consistency with :meth:`_assign_layers_with_vpp`). 

456 stage_partition: ``[pp]`` list to populate. 

457 total_body: Total number of BODY layers. 

458 """ 

459 current_layer_id = 1 

460 for group_name in body_group_names: 

461 if group_name not in solver.variables_: 

462 raise RuntimeError( 

463 f"Group '{group_name}' not found in solver variables. " 

464 f"Available groups: {list(solver.variables_.keys())}" 

465 ) 

466 body_lay = self._get_body_layer_by_name(group_name) 

467 stage_rec_counts = self._extract_group_stage_recompute( 

468 solver, group_name, body_lay.recompute_considered_, 

469 ) 

470 for stage_id, rec_counts in enumerate(stage_rec_counts): 

471 for rec_type, count in rec_counts: 

472 for lid in range(current_layer_id, current_layer_id + count): 

473 stage_partition[stage_id].append((lid, rec_type)) 

474 current_layer_id += count 

475 

476 if current_layer_id != total_body + 1: 

477 raise RuntimeError( 

478 f"ILP layer count mismatch: extracted {current_layer_id - 1} " 

479 f"body layers, expected {total_body}" 

480 ) 

481 

482 stage_partition[0].insert(0, (0, RecomputeType.NONE)) 

483 stage_partition[-1].append((total_body + 1, RecomputeType.NONE)) 

484 

485 def _assign_layers_with_vpp( 

486 self, 

487 solver: Any, 

488 body_group_names: List[str], 

489 pp: int, 

490 vpp: int, 

491 stage_partition: List[List[Tuple[int, RecomputeType]]], 

492 total_body: int, 

493 ) -> None: 

494 """Assign layer IDs when VPP is enabled (vpp > 1). 

495 

496 Each VPP chunk receives a contiguous block of body layer IDs. 

497 Chunk 0 gets the first block, chunk 1 the next, and so on. 

498 Within each chunk, BODY groups are iterated in order; for each 

499 group the per-stage per-recompute counts are read from the ILP 

500 variables for that specific interleave index. 

501 

502 HEAD (layer_id 0) is placed in virtual stage 0 (chunk 0, 

503 stage 0). TAIL (layer_id ``total_body + 1``) is placed in 

504 the last virtual stage (chunk ``vpp-1``, stage ``pp-1``). 

505 

506 Args: 

507 solver: ILP solver object with ``variables_`` attribute. 

508 body_group_names: Ordered list of BODY group names. 

509 pp: Pipeline parallel degree. 

510 vpp: VPP interleaving factor. 

511 stage_partition: ``[vpp * pp]`` list to populate. 

512 total_body: Total number of BODY layers. 

513 """ 

514 current_layer_id = 1 

515 for inter in range(vpp): 

516 for group_name in body_group_names: 

517 if group_name not in solver.variables_: 

518 raise RuntimeError( 

519 f"Group '{group_name}' not found in solver variables. " 

520 f"Available groups: {list(solver.variables_.keys())}" 

521 ) 

522 body_lay = self._get_body_layer_by_name(group_name) 

523 chunk_stage_rec = self._extract_chunk_stage_recompute( 

524 solver, group_name, inter, body_lay.recompute_considered_, 

525 ) 

526 for stage_id, rec_counts in enumerate(chunk_stage_rec): 

527 for rec_type, count in rec_counts: 

528 vstage = inter * pp + stage_id 

529 for lid in range(current_layer_id, current_layer_id + count): 

530 stage_partition[vstage].append((lid, rec_type)) 

531 current_layer_id += count 

532 

533 if current_layer_id != total_body + 1: 

534 raise RuntimeError( 

535 f"ILP layer count mismatch: extracted {current_layer_id - 1} " 

536 f"body layers, expected {total_body}" 

537 ) 

538 

539 stage_partition[0].insert(0, (0, RecomputeType.NONE)) 

540 stage_partition[vpp * pp - 1].append((total_body + 1, RecomputeType.NONE)) 

541 

542 def _extract_chunk_stage_recompute( 

543 self, 

544 solver: Any, 

545 group_name: str, 

546 interleave: int, 

547 layer_recompute_considered: Optional[Dict[Any, bool]] = None, 

548 ) -> List[List[Tuple[Any, int]]]: 

549 """Extract per-stage per-recompute layer counts for a single VPP chunk. 

550 

551 Unlike :meth:`_extract_group_stage_recompute` which sums across 

552 all interleaves, this method reads only the specified interleave 

553 index from the ILP variables. 

554 

555 Args: 

556 solver: ILP solver object with ``variables_`` attribute. 

557 group_name: Name of the layer group. 

558 interleave: The VPP chunk (interleave) index to extract. 

559 layer_recompute_considered: Optional per-layer recompute 

560 considered dict. 

561 

562 Returns: 

563 Per-stage list of ``(recompute_type, count)`` pairs for the 

564 specified interleave. 

565 """ 

566 recompute_considered = layer_recompute_considered 

567 pp = self.yaml_config.pp_degree 

568 stage_rec: List[List[Tuple[Any, int]]] = [ 

569 [] for _ in range(pp) 

570 ] 

571 

572 for stage_id in range(pp): 

573 for rec in Recompute.TYPE: # pylint: disable=E0606 

574 if recompute_considered and not recompute_considered.get(rec, False): 

575 continue 

576 try: 

577 var_value = solver.variables_[group_name][rec][interleave][stage_id].varValue 

578 if var_value is not None: 

579 count = round(var_value) 

580 if count > 0: 

581 stage_rec[stage_id].append((rec, count)) 

582 except (KeyError, AttributeError): 

583 continue 

584 

585 return stage_rec 

586 

587 def _get_body_group_names(self) -> List[str]: 

588 """Return ordered list of BODY group names from layers. 

589 

590 Returns: 

591 List of group name strings, in the order the BODY layers were 

592 appended to the layer list. 

593 """ 

594 pipeline_layer = _get_pipeline_layer_class() 

595 return [ 

596 lay.name_ for lay in self._layer_builder.layers_sapp_ppb 

597 if lay.type_ == pipeline_layer.type_enum.BODY 

598 ] 

599 

600 def _total_body_layers(self) -> int: 

601 """Return total number of BODY layers across all groups. 

602 

603 Returns: 

604 Sum of ``nb_layer_`` for all BODY layer objects. 

605 """ 

606 pipeline_layer = _get_pipeline_layer_class() 

607 return sum( 

608 lay.nb_layer_ for lay in self._layer_builder.layers_sapp_ppb 

609 if lay.type_ == pipeline_layer.type_enum.BODY 

610 ) 

611 

612 def _get_body_layer_by_name(self, name: str) -> Any: 

613 """Find a BODY layer by name from layers. 

614 

615 Args: 

616 name: The ``name_`` of the BODY layer to find. 

617 

618 Returns: 

619 The matching :class:`Layer` object. 

620 

621 Raises: 

622 RuntimeError: If no BODY layer with the given name exists. 

623 """ 

624 pipeline_layer = _get_pipeline_layer_class() 

625 for lay in self._layer_builder.layers_sapp_ppb: 

626 if lay.type_ == pipeline_layer.type_enum.BODY and lay.name_ == name: 

627 return lay 

628 raise RuntimeError(f"BODY layer '{name}' not found in layers") 

629 

630 def _extract_group_stage_recompute( 

631 self, 

632 solver: Any, 

633 group_name: str, 

634 layer_recompute_considered: Optional[Dict[Any, bool]] = None, 

635 ) -> List[List[Tuple[Any, int]]]: 

636 """Extract per-stage per-recompute layer counts for a group. 

637 

638 For each stage, returns a list of ``(Recompute.TYPE, count)`` 

639 pairs for recompute types that have non-zero layer counts in 

640 that stage (summed across all interleaves). 

641 

642 Args: 

643 solver: ILP solver object with ``variables_`` attribute. 

644 group_name: Name of the layer group. 

645 layer_recompute_considered: Optional per-layer recompute 

646 considered dict. When provided, used instead of the 

647 solver's global ``recompute_considered_`` so that each 

648 BODY group is filtered by its own supported types. 

649 

650 Returns: 

651 Per-stage list of ``(recompute_type, count)`` pairs. 

652 """ 

653 recompute_considered = layer_recompute_considered 

654 stage_rec: List[List[Tuple[Any, int]]] = [ 

655 [] for _ in range(self.yaml_config.pp_degree) 

656 ] 

657 

658 for stage_id in range(self.yaml_config.pp_degree): 

659 for rec in Recompute.TYPE: 

660 if recompute_considered and not recompute_considered.get(rec, False): 

661 continue 

662 total_count = 0 

663 for inter in range(self._pipeline.num_of_interleave_): 

664 try: 

665 var_value = solver.variables_[group_name][rec][inter][stage_id].varValue 

666 if var_value is not None: 

667 total_count += round(var_value) 

668 except (KeyError, AttributeError): 

669 continue 

670 if total_count > 0: 

671 stage_rec[stage_id].append((rec, total_count)) 

672 

673 return stage_rec 

674 

675 def _extract_layer_offset_from_ilp( 

676 self, 

677 stage_partition: List[List[Tuple[int, RecomputeType]]], # pylint: disable=unused-argument 

678 ) -> Dict[str, List[List[int]]]: 

679 """Extract per-group layer offset from ILP solution using sapp-ppb native semantics. 

680 

681 For each BODY group, delegates to 

682 :func:`Recompute.yaml_from_internal` which computes the offset 

683 following the sapp-ppb convention:: 

684 

685 offset[group_name][i][s] = actual_group[i][s] - nass[i][s] 

686 

687 where ``nass`` is the naive layer assignment (pure integer division 

688 ``nb_layer_ // (pp * vpp)``) per ``(interleave, stage)`` cell for 

689 that group, and ``actual_group[i][s]`` is the total body layers the 

690 ILP assigned to that cell for that group (summed across recompute 

691 types). 

692 

693 Using naive (uncorrected) nass ensures round-trip consistency with 

694 :func:`Recompute.internal_from_yaml` and 

695 :meth:`SappPipeline.print_yaml_results`, which both use the same 

696 naive nass baseline. 

697 

698 **Edge case —** ``nb_layer_ < pp * vpp``: the naive nass is 0 for 

699 every cell, so the offset equals the total ILP assignment for that 

700 cell. This is consistent with the round-trip. 

701 

702 Args: 

703 stage_partition: The extracted stage partition from ILP 

704 (unused; offset is computed directly from solver 

705 variables to preserve the VPP dimension). 

706 

707 Returns: 

708 Per-group offset dict. Key is the BODY group name; value 

709 is a list of shape ``[vpp][pp]``. Values may be negative 

710 when the ILP deviates from the naive uniform baseline (see 

711 edge-case note above). 

712 """ 

713 if self._pipeline is None or self._pipeline.problem_ is None: 

714 return {} 

715 

716 solver = self._pipeline.problem_ 

717 pp = self.yaml_config.pp_degree 

718 vpp = self._pipeline.num_of_interleave_ 

719 

720 pipeline_layer = _get_pipeline_layer_class() 

721 

722 result: Dict[str, List[List[int]]] = {} 

723 

724 for lay in self._layer_builder.layers_sapp_ppb: 

725 if lay.type_ != pipeline_layer.type_enum.BODY: 

726 continue 

727 

728 group_name = lay.name_ 

729 if group_name not in solver.variables_: 

730 raise RuntimeError( 

731 f"Cannot extract layer offset from ILP: '{group_name}' not in solver variables" 

732 ) 

733 

734 raw_nass = ( 

735 [[lay.nb_layer_ // (pp * vpp)] * pp for _ in range(vpp)] 

736 if (pp * vpp) > 0 

737 else [[0] * pp for _ in range(vpp)] 

738 ) 

739 

740 yaml_out = Recompute.yaml_from_internal( # pylint: disable=E0606 

741 vpp, pp, solver.variables_[group_name], raw_nass, 

742 ) 

743 result[group_name] = yaml_out[Recompute.OFFSET] # pylint: disable=E0606 

744 

745 return result