From 9d4cd25caa2342579bc668ab729c0051c99c02c0 Mon Sep 17 00:00:00 2001 From: Kelvin Chung Date: Thu, 19 Feb 2026 22:36:42 +0000 Subject: [PATCH 01/48] fix: fix full auto gds flow chore: minor clean up chore: update final view path chore: fabric marco out path fix fix: more minor fix chore: more clean up chore: another compile fix chore: remove uselss script chore: more clean up --- fabulous/fabric_definition/supertile.py | 66 +- fabulous/fabric_definition/tile.py | 79 +- .../gds_generator/flows/full_fabric_flow.py | 199 +++-- .../gds_generator/flows/tile_macro_flow.py | 1 - .../steps/global_tile_opitmisation.py | 773 ++++++++++-------- .../gds_generator/steps/tile_optimisation.py | 263 ++++-- .../gds_generator/steps/while_step.py | 4 + fabulous/fabulous_api.py | 2 + fabulous/fabulous_cli/fabulous_cli.py | 26 +- .../flow_test/test_full_fabric_flow.py | 29 +- tests/gds_flow_test/step_test/conftest.py | 2 +- 11 files changed, 840 insertions(+), 604 deletions(-) diff --git a/fabulous/fabric_definition/supertile.py b/fabulous/fabric_definition/supertile.py index f679f32b1..50b1a1435 100644 --- a/fabulous/fabric_definition/supertile.py +++ b/fabulous/fabric_definition/supertile.py @@ -128,44 +128,34 @@ def get_min_die_area( self, x_pitch: Decimal, y_pitch: Decimal, - x_pin_thickness_mult: Decimal, - y_pin_thickness_mult: Decimal, - x_spacing: Decimal, - y_spacing: Decimal, + x_pin_thickness_mult: Decimal = Decimal(1), + y_pin_thickness_mult: Decimal = Decimal(1), + edge_offset: int = 2, ) -> tuple[Decimal, Decimal]: - """Calculate minimum SuperTile dimensions based on IO pin density. + """Calculate minimum SuperTile dimensions based on IO pin track requirements. - For this supertile, aggregates IO pins from all constituent tiles - that appear on the outer edges and calculates the minimum physical - width and height required. + Aggregates IO pins from all constituent tiles on the outer edges + and calculates the minimum physical width and height required. + + See ``Tile.get_min_die_area`` for the track-based derivation. Parameters ---------- x_pitch : Decimal - Horizontal pitch between tracks (DBU). + Vertical-layer track pitch (for north/south pins). y_pitch : Decimal - Vertical pitch between tracks (DBU). + Horizontal-layer track pitch (for east/west pins). x_pin_thickness_mult : Decimal - Pin thickness multiplier in the horizontal direction. + Number of tracks each north/south pin spans, by default 1. y_pin_thickness_mult : Decimal - Pin thickness multiplier in the vertical direction. - x_spacing : Decimal - Pin spacing in the horizontal direction (DBU). - y_spacing : Decimal - Pin spacing in the vertical direction (DBU). + Number of tracks each east/west pin spans, by default 1. + edge_offset : int, optional + Reserved tracks at tile edge, by default 2. Returns ------- tuple[Decimal, Decimal] - (min_width, min_height) where: - - min_width: minimum width needed for north/south edge IO pins - - min_height: minimum height needed for west/east edge IO pins - - Notes - ----- - For supertiles, we aggregate IO pins from all constituent tiles - that appear on the outer edges of the supertile to get conservative - estimates for minimum dimensions. + (min_width, min_height) """ max_north = 0 max_south = 0 @@ -173,21 +163,15 @@ def get_min_die_area( max_east = 0 for subtile in self.tiles: - north_ports = subtile.get_port_count(Side.NORTH) - south_ports = subtile.get_port_count(Side.SOUTH) - west_ports = subtile.get_port_count(Side.WEST) - east_ports = subtile.get_port_count(Side.EAST) - - max_north = max(max_north, north_ports) - max_south = max(max_south, south_ports) - max_west = max(max_west, west_ports) - max_east = max(max_east, east_ports) - - min_width_io = Decimal(max(max_north, max_south)) * ( - x_pitch * x_pin_thickness_mult + x_spacing - ) - min_height_io = Decimal(max(max_west, max_east)) * ( - y_pitch * y_pin_thickness_mult + y_spacing - ) + max_north = max(max_north, subtile.get_port_count(Side.NORTH)) + max_south = max(max_south, subtile.get_port_count(Side.SOUTH)) + max_west = max(max_west, subtile.get_port_count(Side.WEST)) + max_east = max(max_east, subtile.get_port_count(Side.EAST)) + + x_io_count = Decimal(max(max_north, max_south)) + min_width_io = (x_io_count * x_pin_thickness_mult + edge_offset) * x_pitch + + y_io_count = Decimal(max(max_west, max_east)) + min_height_io = (y_io_count * y_pin_thickness_mult + edge_offset) * y_pitch return min_width_io, min_height_io diff --git a/fabulous/fabric_definition/tile.py b/fabulous/fabric_definition/tile.py index 602ac8c45..3c6df32e2 100644 --- a/fabulous/fabric_definition/tile.py +++ b/fabulous/fabric_definition/tile.py @@ -1,6 +1,5 @@ """Tile class definition for FPGA fabric representation.""" -import itertools from dataclasses import dataclass, field from decimal import Decimal from pathlib import Path @@ -326,86 +325,64 @@ def get_port_count(self, side: Side) -> int: Total number of expanded ports on the given side. """ ports = [p for p in self.portsInfo if p.sideOfTile == side and p.name != "NULL"] - return len( - list( - itertools.chain.from_iterable( - [ - list(itertools.chain.from_iterable(p.expandPortInfo("all"))) - for p in ports - ] - ) - ) - ) + return sum(len(group) for p in ports for group in p.expandPortInfo("all")) def get_min_die_area( self, x_pitch: Decimal, y_pitch: Decimal, - x_pin_thickness_mult: Decimal, - y_pin_thickness_mult: Decimal, - x_spacing: Decimal, - y_spacing: Decimal, + x_pin_thickness_mult: Decimal = Decimal(1), + y_pin_thickness_mult: Decimal = Decimal(1), frame_data_width: int = 32, frame_strobe_width: int = 20, + edge_offset: int = 2, ) -> tuple[Decimal, Decimal]: - """Calculate minimum tile dimensions based on IO pin density. + """Calculate minimum tile dimensions based on IO pin track requirements. + + The IO pin placer distributes pins across available tracks on each + tile edge. Each pin occupies ``thickness_mult`` consecutive tracks, + and ``edge_offset`` tracks are reserved at the start of the tile + (see ``tile_io_place.allocate_tracks``). + + The minimum number of tracks on a side is therefore:: + + required_tracks = pin_count * thickness_mult + edge_offset - For this tile, calculates the minimum physical width and height - required to accommodate all IO pins at the PDK's track pitch. + And the minimum physical dimension is:: + + min_dim = required_tracks * pitch Parameters ---------- x_pitch : Decimal - Horizontal pitch between tracks (DBU). + Vertical-layer track pitch (for north/south pins). y_pitch : Decimal - Vertical pitch between tracks (DBU). + Horizontal-layer track pitch (for east/west pins). x_pin_thickness_mult : Decimal - Pin thickness multiplier in the horizontal direction. + Number of tracks each north/south pin spans, by default 1. y_pin_thickness_mult : Decimal - Pin thickness multiplier in the vertical direction. - x_spacing : Decimal - Pin spacing in the horizontal direction (DBU). - y_spacing : Decimal - Pin spacing in the vertical direction (DBU). + Number of tracks each east/west pin spans, by default 1. frame_data_width : int, optional Frame data width, by default 32. frame_strobe_width : int, optional Frame strobe width, by default 20. + edge_offset : int, optional + Reserved tracks at tile edge, by default 2. Returns ------- tuple[Decimal, Decimal] - (min_width, min_height) where: - - min_width: minimum width needed for north/south edge IO pins - - min_height: minimum height needed for west/east edge IO pins - - Notes - ----- - The minimum dimensions are calculated as: - - min_width = max(north_pins, south_pins) * x_pitch - - min_height = max(west_pins, east_pins) * y_pitch - - These constraints prevent the NLP solver from suggesting dimensions - that are physically impossible due to IO pin spacing requirements. + (min_width, min_height) """ - # Count ports on each physical side north_ports = self.get_port_count(Side.NORTH) south_ports = self.get_port_count(Side.SOUTH) west_ports = self.get_port_count(Side.WEST) east_ports = self.get_port_count(Side.EAST) - # Min width constrained by north/south edges x_io_count = Decimal(max(north_ports, south_ports) + frame_strobe_width) - min_width_io = ( - x_io_count * (x_pitch * x_pin_thickness_mult) - + x_spacing * x_io_count - + 2 * x_spacing - ) - # Min height constrained by west/east edges + min_width_io = (x_io_count * x_pin_thickness_mult + edge_offset) * x_pitch + y_io_count = Decimal(max(west_ports, east_ports) + frame_data_width) - min_height_io = ( - y_io_count * (y_pitch * y_pin_thickness_mult) - + y_spacing * y_io_count - + 2 * y_spacing - ) + min_height_io = (y_io_count * y_pin_thickness_mult + edge_offset) * y_pitch + return min_width_io, min_height_io diff --git a/fabulous/fabric_generator/gds_generator/flows/full_fabric_flow.py b/fabulous/fabric_generator/gds_generator/flows/full_fabric_flow.py index afc2146a2..6b9cebe77 100644 --- a/fabulous/fabric_generator/gds_generator/flows/full_fabric_flow.py +++ b/fabulous/fabric_generator/gds_generator/flows/full_fabric_flow.py @@ -9,14 +9,16 @@ """ import json +import shutil import traceback from decimal import Decimal from itertools import product from pathlib import Path from typing import TYPE_CHECKING +from librelane.common.misc import get_latest_file from librelane.config.flow import flow_common_variables -from librelane.config.variable import Macro +from librelane.config.variable import Variable from librelane.flows.classic import Classic from librelane.flows.flow import Flow, FlowException from librelane.logging.logger import err, info @@ -57,8 +59,32 @@ + Floorplan.config_vars + flow_common_variables + GlobalTileSizeOptimization.config_vars + + [ + Variable( + "FABULOUS_NLP_ONLY", + bool, + description="Stop after NLP optimisation, skip recompilation and stitching", + default=False, + ), + Variable( + "FABULOUS_NLP_AREA_MARGIN", + float, + description="Area margin for NLP constraint (0.05 = 5% slack)", + default=0.05, + ), + ] ) +WorkerResult = tuple[State | None, str | None, dict[str, float] | None] + + +def _extract_pin_min(flow: FABulousTileVerilogMacroFlow) -> dict[str, float]: + """Extract pin minimum dimensions from flow config.""" + return { + "fabulous__pin_min_width": float(flow.config["FABULOUS_PIN_MIN_WIDTH"]), + "fabulous__pin_min_height": float(flow.config["FABULOUS_PIN_MIN_HEIGHT"]), + } + def _run_tile_flow_worker( tile_type: Tile | SuperTile, @@ -68,7 +94,7 @@ def _run_tile_flow_worker( base_config_path: Path, override_config_path: Path, **custom_config_overrides: dict, -) -> tuple[State | None, str | None]: +) -> WorkerResult: """Worker function to run a tile flow in a separate process. This function is called by ProcessPoolExecutor to compile tiles in parallel @@ -93,15 +119,16 @@ def _run_tile_flow_worker( Returns ------- - tuple[State | None, str | None] - (compiled_state, error_trace) for result processing. + WorkerResult + (compiled_state, error_trace, pin_min) for result processing. """ + flow: FABulousTileVerilogMacroFlow | None = None try: from fabulous.fabulous_settings import FABulousSettings context: FABulousSettings = init_context(project_dir=proj_dir) # Reconstruct the flow in the worker process with serializable data - flow: FABulousTileVerilogMacroFlow = FABulousTileVerilogMacroFlow( + flow = FABulousTileVerilogMacroFlow( tile_type, io_pin_config, optimisation, @@ -109,13 +136,20 @@ def _run_tile_flow_worker( pdk_root=context.pdk_root, base_config_path=base_config_path, override_config_path=override_config_path, - **custom_config_overrides or {}, + **custom_config_overrides, ) state: State = flow.start() except Exception: # noqa: BLE001 - return None, traceback.format_exc() + # Try to recover the state from disk - deferred errors (e.g. XOR + # differences) raise after the state has already been saved. + if flow is not None and flow.run_dir is not None: + latest_state = get_latest_file(flow.run_dir, "state_out.json") + if latest_state is not None: + recovered = State.loads(Path(latest_state).read_text(encoding="utf-8")) + return recovered, traceback.format_exc(), _extract_pin_min(flow) + return None, traceback.format_exc(), None else: - return state, None + return state, None, _extract_pin_min(flow) @Flow.factory.register() @@ -133,6 +167,24 @@ class FABulousFabricMacroFullFlow(Flow): config_vars = configs + @staticmethod + def _log_nlp_summary(nlp_state: State) -> None: + """Log a summary table of NLP tile dimensions and utilization.""" + tile_areas = nlp_state.metrics.get("nlp__tile__area", {}) + stdcell_areas = nlp_state.metrics.get("nlp__tile__stdcell_area", {}) + total_area = nlp_state.metrics.get("nlp__total__area", 0) + hdr = f"{'Tile':<20} {'Width':>10} {'Height':>10} {'Area':>12} {'Util':>8}" + info(hdr) + info("-" * len(hdr)) + for name, dims in tile_areas.items(): + w, h = float(dims[2]), float(dims[3]) + alloc_area = w * h + sc = stdcell_areas.get(name, 0.0) + util = sc / alloc_area * 100 if alloc_area > 0 else 0.0 + info(f"{name:<20} {w:>10.2f} {h:>10.2f} {alloc_area:>12.2f} {util:>7.1f}%") + info("-" * len(hdr)) + info(f"{'Total fabric area':<20} {'':>10} {'':>10} {total_area:>12} {'':>8}") + def _validate_project_dir(self, proj_dir: Path, fabric: Fabric) -> None: """Validate the project directory structure for required tile directories.""" info("Validating project directory structure...") @@ -210,9 +262,7 @@ def _init_compile(self, fabric: Fabric, proj_dir: Path) -> None: OptMode.FIND_MIN_WIDTH, ] - handlers: list[ - tuple[Future[tuple[State | None, str | None]], OptMode, Tile | SuperTile] - ] = [] + handlers: list[tuple[Future[WorkerResult], OptMode, Tile | SuperTile]] = [] with DillProcessPoolExecutor(max_workers=None) as executor: for opt_mode, tile_type in product( opt_modes, fabric.get_all_unique_tiles() @@ -226,7 +276,7 @@ def _init_compile(self, fabric: Fabric, proj_dir: Path) -> None: tile_type.tileDir.parent / "gds_config.yaml" ) - result: Future[tuple[State | None, str | None]] = executor.submit( + result: Future[WorkerResult] = executor.submit( _run_tile_flow_worker, tile_type, proj_dir, @@ -246,28 +296,27 @@ def _init_compile(self, fabric: Fabric, proj_dir: Path) -> None: error: str | None = None error_trace: str | None = None state: State | None = None - + pin_min: dict[str, float] | None = None try: - state, error_trace_worker = state_future.result() + state, error_trace_worker, pin_min = state_future.result() if error_trace_worker: error = "Worker execution failed" error_trace = error_trace_worker except Exception as e: # noqa: BLE001 error = str(e) error_trace = traceback.format_exc() - # Try to save snapshot if state exists - # Always build the metrics dict + # Build metrics dict from state if available metrics_dict: dict[str, object] = {} if state is not None: - metrics_dict = { - k: state.metrics.get(k) - for k in [ - "design__die__bbox", - "design__core__bbox", - "design__instance__area__stdcell", - "design__instance__utilization__stdcell", - ] - } + metric_keys = ( + "design__die__bbox", + "design__core__bbox", + "design__instance__area__stdcell", + "design__instance__utilization__stdcell", + ) + metrics_dict = {k: state.metrics.get(k) for k in metric_keys} + if pin_min is not None: + metrics_dict |= pin_min # Add error info if present if error is not None: @@ -275,6 +324,7 @@ def _init_compile(self, fabric: Fabric, proj_dir: Path) -> None: metrics_dict["error_traceback"] = error_trace info(f"opt_mode={opt_mode.value}, tile={tile_name}, metrics={metrics_dict}") + result_summary[opt_mode.value][tile_name] = metrics_dict def custom_serializer(obj: object) -> float | object: if isinstance(obj, Decimal): @@ -348,19 +398,20 @@ def run(self, initial_state: State, **_kwargs: dict) -> tuple[State, list[Step]] self.progress_bar.end_stage() + if self.config.get("FABULOUS_NLP_ONLY"): + info("\n=== NLP-only mode: skipping recompilation and stitching ===") + self._log_nlp_summary(nlp_state) + return nlp_state, [] + # Step 3: Recompile tiles with optimal dimensions self.progress_bar.start_stage("Tile Recompilation") info("\n=== Step 3: Recompiling tiles with optimal dimensions ===") # Compile tiles with optimal dimensions in parallel - handlers: list[ - tuple[Future[tuple[State | None, str | None]], Tile | SuperTile] - ] = [] + handlers: list[tuple[Future[WorkerResult], Tile | SuperTile]] = [] with DillProcessPoolExecutor(max_workers=None) as executor: for tile_type in fabric.get_all_unique_tiles(): - io_config_path: Path = ( - tile_type.tileDir.parent / f"{tile_type.name}_io_pin_order.yaml" - ) + io_config_path: Path = tile_type.tileDir.parent / "io_pin_order.yaml" base_config_path: Path = ( proj_dir / "Tile" / "include" / "gds_config.yaml" ) @@ -372,7 +423,7 @@ def run(self, initial_state: State, **_kwargs: dict) -> tuple[State, list[Step]] "nlp__tile__area" ][tile_type.name] # Submit tile compilation with optimal dimensions - result: Future[tuple[State | None, str | None]] = executor.submit( + result: Future[WorkerResult] = executor.submit( _run_tile_flow_worker, tile_type, proj_dir, @@ -388,13 +439,16 @@ def run(self, initial_state: State, **_kwargs: dict) -> tuple[State, list[Step]] tile_type_states: dict[str, State] = {} for state_future, tile_type in handlers: tile_name: str = tile_type.name - state: State | None - error_trace: str | None - state, error_trace = state_future.result() - if error_trace or state is None: + state, error_trace, _ = state_future.result() + if state is None: raise RuntimeError( f"Tile {tile_name} compilation failed:\n{error_trace}" ) + if error_trace: + err( + f"Tile {tile_name} had errors but state was recovered:\n" + f"{error_trace}" + ) # Verify compilation succeeded if not state.get(DesignFormat.GDS) or not state.get(DesignFormat.LEF): @@ -410,49 +464,40 @@ def run(self, initial_state: State, **_kwargs: dict) -> tuple[State, list[Step]] self.progress_bar.end_stage() - # Step 4: Collect tile macros for fabric stitching - macros: dict[str, Macro] = {} - tile_sizes: dict[str, tuple[Decimal, Decimal]] = {} - - for tile_type_name, tile_state in tile_type_states.items(): - width: Decimal = Decimal( - tile_type_states[tile_type_name] - .metrics["design__die__bbox"] - .split(" ")[2] - ) - height: Decimal = Decimal( - tile_type_states[tile_type_name] - .metrics["design__die__bbox"] - .split(" ")[3] - ) - tile_sizes[tile_type_name] = (width, height) - - # Get tile output files - gds_file: Path | None = tile_state.get(DesignFormat.GDS) - lef_file: Path | None = tile_state.get(DesignFormat.LEF) - lib_files: dict[str, list[Path]] | list[Path] | Path | None = ( - tile_state.get(DesignFormat.LIB) - ) + # Step 4: Create final_views symlinks for each tile so the + # fabric stitching flow can find them at the standard path. + for tile_name, tile_state in tile_type_states.items(): + gds_path: Path | None = tile_state.get(DesignFormat.GDS) + if gds_path is None: + raise RuntimeError( + f"Tile {tile_name} has no GDS output after recompilation" + ) - # Build lib dict - lib_dict: dict[str, list[Path]] = {} - if lib_files: - if isinstance(lib_files, dict): - for corner, paths in lib_files.items(): - lib_dict[corner] = [Path(str(p)) for p in paths] - elif isinstance(lib_files, list): - lib_dict["default"] = [Path(str(p)) for p in lib_files] - else: - lib_dict["default"] = [Path(str(lib_files))] - - macros[tile_type_name] = Macro( - gds=[Path(str(gds_file))] if gds_file else [], - lef=[Path(str(lef_file))] if lef_file else [], - lib=lib_dict, - instances={}, + # Walk up from the GDS path to find the run directory that + # contains the final/ snapshot. This is robust to varying step + # nesting depth (e.g. write-out steps inside a WhileStep wrapper). + # Note: librelane's Path is a UserString, so we wrap with pathlib. + final_dir: Path | None = next( + ( + parent / "final" + for parent in Path(str(gds_path)).parents + if (parent / "final").is_dir() + ), + None, ) - - info(f"Collected {len(macros)} tile macros") + if final_dir is None: + raise RuntimeError( + f"Could not locate final/ directory for tile {tile_name} " + f"from GDS path {gds_path}" + ) + final_views: Path = proj_dir / "Tile" / tile_name / "macro" / "final_views" + if final_views.is_symlink(): + final_views.unlink() + elif final_views.is_dir(): + shutil.rmtree(final_views) + final_views.symlink_to(final_dir) + + info(f"Created final_views symlinks for {len(tile_type_states)} tiles") # Generate fabric-level IO pin configuration fabric_io_config_path: Path = proj_dir / "Fabric" / "fabric_io_pin_order.yaml" diff --git a/fabulous/fabric_generator/gds_generator/flows/tile_macro_flow.py b/fabulous/fabric_generator/gds_generator/flows/tile_macro_flow.py index 3c2b8b763..82a48d012 100644 --- a/fabulous/fabric_generator/gds_generator/flows/tile_macro_flow.py +++ b/fabulous/fabric_generator/gds_generator/flows/tile_macro_flow.py @@ -25,7 +25,6 @@ write_out_steps, ) from fabulous.fabric_generator.gds_generator.helper import ( - get_offset, get_pitch, get_routing_obstructions, round_die_area, diff --git a/fabulous/fabric_generator/gds_generator/steps/global_tile_opitmisation.py b/fabulous/fabric_generator/gds_generator/steps/global_tile_opitmisation.py index b22cb7bf7..6c7aa2c4f 100644 --- a/fabulous/fabric_generator/gds_generator/steps/global_tile_opitmisation.py +++ b/fabulous/fabric_generator/gds_generator/steps/global_tile_opitmisation.py @@ -1,10 +1,10 @@ """FABulous GDS Generator - NLP Optimization Step using pymoo.""" import json -from collections import Counter, defaultdict +from collections import defaultdict from decimal import Decimal from pathlib import Path -from typing import TYPE_CHECKING, Any, NamedTuple, Optional +from typing import Any, Optional import numpy as np from librelane.config.variable import Variable @@ -16,299 +16,398 @@ from pymoo.algorithms.soo.nonconvex.isres import ISRES from pymoo.core.problem import ElementwiseProblem from pymoo.core.repair import Repair -from pymoo.core.termination import TerminateIfAny from pymoo.optimize import minimize -from pymoo.termination.ftol import SingleObjectiveSpaceTermination from pymoo.termination.max_gen import MaximumGenerationTermination from fabulous.fabric_definition.fabric import Fabric from fabulous.fabric_generator.gds_generator.helper import round_up_decimal from fabulous.fabric_generator.gds_generator.steps.tile_optimisation import OptMode -if TYPE_CHECKING: - from fabulous.fabric_definition.tile import Tile - class NLPTileProblem(ElementwiseProblem): - """NLP problem class for tile size optimization using pymoo. + """NLP problem for tile size optimization using row/column variables. - This class defines the optimization problem with bilinear constraints for minimizing - total fabric area subject to minimum area requirements. + Variables are row heights h[r] and column widths w[c], so that uniformity + within each row and column is inherent in the formulation rather than + enforced through soft equality constraints. - Parameters - ---------- - fabric : Fabric - the fabric object that contains the tile layout and structure - tile_metrics : dict[OptMode, dict] - Dictionary of tile metrics per optimization mode, containing die bounding boxes + Rows that share a tile type are linked into equivalence classes so they + get a single shared height variable. Columns are linked similarly. """ - class PositionIndex(NamedTuple): - """Helper class for named values.""" - - width_idx: int - height_idx: int - def __init__( self, fabric: Fabric, - tile_metrics: dict[ - OptMode, dict - ], # dict[tile_name, dict] OR dict[opt_mode, dict[tile_name, dict]] + tile_metrics: dict[OptMode, dict], + all_tile_metrics: dict[OptMode, dict] | None = None, + area_margin: float = 0.05, ) -> None: self.fabric = fabric - self.tile_metrics = ( - tile_metrics # Keep nested format: {opt_mode: {tile_name: {metrics}}} - ) + self.tile_metrics = tile_metrics + self.area_margin = area_margin + # all_tile_metrics includes failed explorations for lower bounds + self._all_tile_metrics = all_tile_metrics or tile_metrics - self.tile_count = Counter( - [t.name for (_, _), t in self.fabric if t is not None] - ) - # Get unique tile names to process - unique_tiles: list[Tile] = list(fabric.tileDic.values()) self.tile_row_set: dict[str, set[int]] = defaultdict(set) self.tile_column_set: dict[str, set[int]] = defaultdict(set) + self.position_map: dict[tuple[int, int], str] = {} for (x, y), tile in fabric: if tile is None: continue self.tile_row_set[tile.name].add(y) self.tile_column_set[tile.name].add(x) + self.position_map[(x, y)] = tile.name - indices: int = 0 - self.tile_to_solution_index: dict[str, NLPTileProblem.PositionIndex] = {} - for t in unique_tiles: - self.tile_to_solution_index[t.name] = NLPTileProblem.PositionIndex( - width_idx=indices, height_idx=indices + 1 - ) - indices += 2 + # Rows sharing a tile type must have the same height, and likewise + # for columns. Union-find groups enforce this. + self.row_groups: dict[int, int] = {} + self._compute_equivalence_classes(self.tile_row_set, self.row_groups) - tile_min: dict[str, tuple[float, float]] = {} + self.col_groups: dict[int, int] = {} + self._compute_equivalence_classes(self.tile_column_set, self.col_groups) - for i in unique_tiles: - if i.partOfSuperTile: - # Skip component tiles of supertiles + unique_row_groups = sorted(set(self.row_groups.values())) + unique_col_groups = sorted(set(self.col_groups.values())) + self.row_group_to_var: dict[int, int] = { + g: i for i, g in enumerate(unique_row_groups) + } + n_row_vars = len(unique_row_groups) + self.col_group_to_var: dict[int, int] = { + g: n_row_vars + i for i, g in enumerate(unique_col_groups) + } + n_vars = n_row_vars + len(unique_col_groups) + + info( + f"NLP variables: {n_row_vars} row groups, " + f"{len(unique_col_groups)} column groups = {n_vars} total" + ) + + tile_min: dict[str, tuple[float, float]] = {} + for tile in fabric.tileDic.values(): + if tile.partOfSuperTile: continue - tmp_min_width: float = 1.0 - tmp_min_height: float = 1.0 - for j in self.tile_metrics.values(): - if i.name not in j: - continue - x0, y0, x1, y1 = j[i.name]["design__die__bbox"] - w = x1 - x0 - h = y1 - y0 - tmp_min_width = max(tmp_min_width, w) - tmp_min_height = max(tmp_min_height, h) - tile_min[i.name] = (tmp_min_width, tmp_min_height) - - # For each supertile, compute min/max dimensions for its component tiles + tile_min[tile.name] = self._pin_min_from_metrics(tile.name) + + # SuperTile components derive bounds from the SuperTile minimum + # and from row/column neighbors for supertile in fabric.superTileDic.values(): - row_min_heights: dict[str, float] = {} + st_min_w, st_min_h = self._pin_min_from_metrics(supertile.name) + n_cols = sum( + 1 for t in (supertile.tileMap[0] if supertile.tileMap else []) if t is not None + ) + n_rows = sum( + 1 + for row in supertile.tileMap + if row and any(t is not None for t in row) + ) + if n_cols == 0 or n_rows == 0: + continue + + # Ensure all component tiles have an entry before updating + for row in supertile.tileMap: + for component_tile in row: + if component_tile is not None: + tile_min.setdefault(component_tile.name, (1.0, 1.0)) + + # Update height bounds from row neighbors for row in supertile.tileMap: - # Compute min_height for this row from max of component tile - # min_heights in that row for component_tile in row: if component_tile is None: continue - component_name = component_tile.name - on_rows = self.tile_row_set[component_tile.name] - target_tile: set[str] = set() - for t, s in self.tile_row_set.items(): - if t == component_name: - continue - if len(on_rows & s) > 0: - target_tile.add(t) - - row_min_heights[component_name] = max( - [tile_min[i][1] for i in target_tile if i in tile_min] + name = component_tile.name + neighbors = self._find_sharing_tiles(name, self.tile_row_set) + neighbor_h = max( + (tile_min[n][1] for n in neighbors if n in tile_min), + default=1.0, ) + cur_w, _ = tile_min[name] + tile_min[name] = (cur_w, max(neighbor_h, st_min_h / n_rows)) - # Compute min_width for each column - col_min_widths: dict[str, float] = {} + # Update width bounds from column neighbors for col_idx in range(supertile.max_width): for row in supertile.tileMap: - if col_idx < len(row): - component_tile = row[col_idx] - if component_tile is None: - continue - component_name = component_tile.name - on_cols = self.tile_column_set[component_tile.name] - target_tile: set[str] = set() - for t, s in self.tile_column_set.items(): - if t == component_name: - continue - if len(on_cols & s) > 0: - target_tile.add(t) - - col_min_widths[component_name] = max( - [tile_min[i][0] for i in target_tile if i in tile_min] - ) + if col_idx >= len(row) or row[col_idx] is None: + continue + name = row[col_idx].name + neighbors = self._find_sharing_tiles(name, self.tile_column_set) + neighbor_w = max( + (tile_min[n][0] for n in neighbors if n in tile_min), + default=1.0, + ) + _, cur_h = tile_min[name] + tile_min[name] = (max(neighbor_w, st_min_w / n_cols), cur_h) - # Update tile_min with computed column widths - for (_, _), sub_tile in supertile: - tile_min[sub_tile.name] = ( - col_min_widths[sub_tile.name], - row_min_heights[sub_tile.name], - ) + xl = np.zeros(n_vars) - xl = np.zeros(len(self.tile_to_solution_index) * 2) - xu = np.zeros(len(self.tile_to_solution_index) * 2) + for tile_name, (min_w, min_h) in tile_min.items(): + for row_idx in self.tile_row_set[tile_name]: + var_idx = self.row_group_to_var[self.row_groups[row_idx]] + xl[var_idx] = max(xl[var_idx], min_h) + for col_idx in self.tile_column_set[tile_name]: + var_idx = self.col_group_to_var[self.col_groups[col_idx]] + xl[var_idx] = max(xl[var_idx], min_w) - for k, v in tile_min.items(): - indices = self.tile_to_solution_index[k] - xl[indices.width_idx] = v[0] - xl[indices.height_idx] = v[1] + xu = xl * 4 + + # Compute minimum compilable area per tile from exploration results + self.min_areas: dict[str, float] = { + t.name: self._compute_min_area(t.name) + for t in fabric.get_all_unique_tiles() + } + # Stdcell area per tile (for utilization reporting) + self.stdcell_areas: dict[str, float] = { + t.name: self._compute_stdcell_area(t.name) + for t in fabric.get_all_unique_tiles() + } - xu = xl.copy() * 4 # Arbitrary upper bound: 4x min size + # Verify every tile/supertile has at least one successful compilation. + self._verify_all_tiles_have_metrics() - # Count constraints by simulating what will be generated - x = np.zeros(len(self.tile_to_solution_index) * 2) + self._tile_constraints = self._build_tile_constraints() + self._supertile_constraints = self._build_supertile_constraints() + n_constr = len(self._tile_constraints) + len(self._supertile_constraints) + + info(f"NLP constraints: {n_constr} area constraints") super().__init__( - n_var=len(self.tile_to_solution_index) * 2, + n_var=n_vars, n_obj=1, - n_ieq_constr=len(self._add_mode_constraints(x)) - + len(self._add_equality_constraints(x)), + n_ieq_constr=n_constr, xl=xl, xu=xu, ) - def _evaluate(self, x: np.ndarray, out: dict) -> None: - """Pymoo evaluation function for objective and constraints.""" - # x shape: (n_var,) - single solution vector for ElementwiseProblem - out["F"] = self._compute_objective(x) + @staticmethod + def _compute_equivalence_classes( + tile_positions: dict[str, set[int]], + groups: dict[int, int], + ) -> None: + """Compute equivalence classes for rows or columns. - eq_constraints = self._add_equality_constraints(x) - mode_constraints = self._add_mode_constraints(x) + Two indices must be in the same group if any tile type appears in both. + Uses union-find logic to compute transitive closure. + """ + parent: dict[int, int] = {} - # Concatenate all constraints - all_constraints = eq_constraints + mode_constraints - out["G"] = np.array(all_constraints) + def find(x: int) -> int: + while parent.get(x, x) != x: + parent[x] = parent.get(parent[x], parent[x]) + x = parent[x] + return x - def _compute_objective(self, x: np.ndarray) -> float: - """Compute the total area objective for a single solution.""" - total_area = 0.0 - for tile_name, indices in self.tile_to_solution_index.items(): - w = x[indices.width_idx] - h = x[indices.height_idx] - total_area += w * h * self.tile_count[tile_name] - return total_area + def union(a: int, b: int) -> None: + ra, rb = find(a), find(b) + if ra != rb: + parent[ra] = rb - def _add_equality_constraints(self, x: np.ndarray) -> list: - """Add equality constraints on tile dimensions. + all_indices: set[int] = set() + for indices in tile_positions.values(): + all_indices |= indices - For pymoo: g(x) <= 0, so we use (h1 - h2)^2 - tolerance <= 0 - This is better than abs(h1-h2) for differentiability. + for idx in all_indices: + parent[idx] = idx + + for indices in tile_positions.values(): + idx_list = sorted(indices) + for i in range(1, len(idx_list)): + union(idx_list[0], idx_list[i]) + + for idx in all_indices: + groups[idx] = find(idx) + + def _min_dimensions_from_metrics(self, name: str) -> tuple[float, float]: + """Return (min_width, min_height) across all compilation modes. + + The minimum width and height may come from different modes. Falls back + to 1.0 if no metrics exist for the given tile. """ - result = [] - tolerance = 0.5 # Allow tiles to differ by ~1 unit + widths: list[float] = [] + heights: list[float] = [] + for mode_metrics in self._all_tile_metrics.values(): + if name not in mode_metrics: + continue + x0, y0, x1, y1 = mode_metrics[name]["design__die__bbox"] + widths.append(x1 - x0) + heights.append(y1 - y0) + return ( + min(widths) if widths else 1.0, + min(heights) if heights else 1.0, + ) - # Height equality: tiles on same row - for tile_name in self.tile_row_set: - on_rows = self.tile_row_set[tile_name] - tile_idx = self.tile_to_solution_index[tile_name] - tile_h = x[tile_idx.height_idx] + def _pin_min_from_metrics(self, name: str) -> tuple[float, float]: + """Return (pin_min_width, pin_min_height) from any mode's metrics. - for other_name in self.tile_row_set: - if other_name == tile_name: # Skip self-comparison only - continue - other_tile = self.fabric.tileDic[other_name] - other_rows = self.tile_row_set[other_name] - if len(on_rows & other_rows) > 0: - other_idx = self.tile_to_solution_index[other_name] - other_h = x[other_idx.height_idx] - # Use squared difference for differentiability - result.append((tile_h - other_h) ** 2 - tolerance) - - # Width equality: tiles on same column - for tile_name in self.tile_column_set: - on_cols = self.tile_column_set[tile_name] - tile_idx = self.tile_to_solution_index[tile_name] - tile_w = x[tile_idx.width_idx] - - for other_name in self.tile_column_set: - if other_name == tile_name: # Skip self-comparison only - continue - other_tile = self.fabric.tileDic[other_name] - if other_tile.partOfSuperTile: - continue - other_cols = self.tile_column_set[other_name] - if len(on_cols & other_cols) > 0: - other_idx = self.tile_to_solution_index[other_name] - other_w = x[other_idx.width_idx] - result.append((tile_w - other_w) ** 2 - tolerance) + Pin minimums are IO-density-based dimension floors and are identical + across exploration modes. Falls back to exploration bbox minimums if + pin_min fields are absent (pre-patch JSON). + """ + for mode_metrics in self._all_tile_metrics.values(): + if name not in mode_metrics: + continue + m = mode_metrics[name] + w = m.get("fabulous__pin_min_width") + h = m.get("fabulous__pin_min_height") + if w is not None and h is not None: + return (w, h) + return self._min_dimensions_from_metrics(name) + + def _compute_min_area(self, name: str) -> float: + """Return minimum die area across successful exploration modes.""" + areas: list[float] = [] + for mode_metrics in self.tile_metrics.values(): + if name not in mode_metrics: + continue + x0, y0, x1, y1 = mode_metrics[name]["design__die__bbox"] + areas.append((x1 - x0) * (y1 - y0)) + return min(areas) if areas else float("inf") - return result + def _compute_stdcell_area(self, name: str) -> float: + """Return max standard-cell area across exploration modes. + + The stdcell area is essentially fixed by the design but varies + slightly across modes due to buffer insertion. Using max gives + the most conservative (worst-case) estimate of what must fit. + """ + areas: list[float] = [] + for mode_metrics in self._all_tile_metrics.values(): + if name not in mode_metrics: + continue + a = mode_metrics[name].get("design__instance__area__stdcell") + if a is not None: + areas.append(a) + return max(areas) if areas else 0.0 + + @staticmethod + def _find_sharing_tiles( + tile_name: str, + tile_positions: dict[str, set[int]], + ) -> set[str]: + """Find tiles sharing at least one row or column with the given tile.""" + positions = tile_positions[tile_name] + return { + other + for other, other_pos in tile_positions.items() + if other != tile_name and positions & other_pos + } - def _add_mode_constraints(self, x: np.ndarray) -> list: - """Add mode constraints on tile dimensions. + def _verify_all_tiles_have_metrics(self) -> None: + """Verify every tile and supertile has at least one successful compilation. - For regular tiles: w * h >= mode_die_area for at least one mode - For supertiles: sum(component_widths) * sum(component_heights) >= mode_die_area - for at least one mode + Raises ``RuntimeError`` listing the names of any tiles that failed + all exploration modes. This catches configuration or sizing issues + early rather than letting the NLP produce unconstrained results. """ - result = [] - # Regular tiles + all_valid_names = { + name for mode_metrics in self.tile_metrics.values() for name in mode_metrics + } + missing: list[str] = [ + t.name + for t in self.fabric.get_all_unique_tiles() + if t.name not in all_valid_names + ] + + if missing: + raise RuntimeError( + f"Tile(s) {missing} failed all exploration modes and have no " + f"successful compilation. The NLP cannot determine feasible " + f"dimensions without at least one working compilation per tile." + ) + + def get_row_height(self, x: np.ndarray, row_idx: int) -> float: + """Get the height variable for a given row index.""" + return x[self.row_group_to_var[self.row_groups[row_idx]]] + + def get_col_width(self, x: np.ndarray, col_idx: int) -> float: + """Get the width variable for a given column index.""" + return x[self.col_group_to_var[self.col_groups[col_idx]]] + + def _build_tile_constraints(self) -> list[tuple[str, int, int]]: + """Build (tile_name, col, row) tuples for regular tile constraints. + + One representative position per tile type suffices because all + positions in the same row/col group share identical dimensions. + """ + constraints: list[tuple[str, int, int]] = [] for tile in self.fabric.tileDic.values(): if tile.partOfSuperTile: continue + rows = self.tile_row_set[tile.name] + cols = self.tile_column_set[tile.name] + if rows and cols: + constraints.append((tile.name, min(cols), min(rows))) + return constraints - tile_idx = self.tile_to_solution_index[tile.name] - tile_w = x[tile_idx.width_idx] - tile_h = x[tile_idx.height_idx] - - # Collect all modes for this tile - mode_constraints = [] - for mode_metrics in self.tile_metrics.values(): - if tile.name in mode_metrics: - x0, y0, x1, y1 = mode_metrics[tile.name]["design__die__bbox"] - mode_die_area = (x1 - x0) * (y1 - y0) - # Constraint: mode_die_area - tile_w * tile_h <= 0 # noqa: ERA001 - mode_constraints.append(mode_die_area - tile_w * tile_h) - result.append(min(mode_constraints)) - - # Supertiles + def _build_supertile_constraints( + self, + ) -> list[tuple[str, list[int], list[int]]]: + """Build (st_name, col_indices, row_indices) for SuperTile constraints.""" + constraints: list[tuple[str, list[int], list[int]]] = [] for supertile in self.fabric.superTileDic.values(): - # Sum component tile dimensions from first row (width) and - # first column (height) - # Width is sum of widths in the first row - total_w = 0.0 - if supertile.tileMap and len(supertile.tileMap) > 0: - for tile in supertile.tileMap[0]: # First row - if tile is not None: - sub_idx = self.tile_to_solution_index[tile.name] - total_w += x[sub_idx.width_idx] - - # Height is sum of heights in the first column - total_h = 0.0 + st_cols: list[int] = [ + min(self.tile_column_set[tile.name]) + for tile in (supertile.tileMap[0] if supertile.tileMap else []) + if tile is not None + ] + st_rows: list[int] = [] for row in supertile.tileMap: - if row and len(row) > 0 and row[0] is not None: # First column - sub_idx = self.tile_to_solution_index[row[0].name] - total_h += x[sub_idx.height_idx] - - # Collect all modes for this supertile - mode_constraints = [] - for mode_metrics in self.tile_metrics.values(): - if supertile.name in mode_metrics: - x0, y0, x1, y1 = mode_metrics[supertile.name]["design__die__bbox"] - mode_die_area = (x1 - x0) * (y1 - y0) - # Constraint: mode_die_area - total_w * total_h <= 0 # noqa: ERA001 - mode_constraints.append(mode_die_area - total_w * total_h) - result.append(min(mode_constraints)) + first_tile = next((t for t in row if t is not None), None) if row else None + if first_tile is not None: + st_rows.append(min(self.tile_row_set[first_tile.name])) + constraints.append((supertile.name, st_cols, st_rows)) + return constraints + + def _evaluate(self, x: np.ndarray, out: dict) -> None: + """Pymoo evaluation: compute objective and constraints.""" + out["F"] = self._compute_objective(x) + out["G"] = np.array(self._eval_constraints(x), dtype=float) + + def _compute_objective(self, x: np.ndarray) -> float: + """Minimize total fabric area = sum over all grid positions of w*h.""" + total_area = 0.0 + for col, row in self.position_map: + total_area += self.get_col_width(x, col) * self.get_row_height(x, row) + return total_area + + def _area_violation(self, name: str, alloc_w: float, alloc_h: float) -> float: + """Return area-based feasibility violation. + + Non-positive means the allocated area meets or exceeds the minimum + compilable area (with margin) observed during exploration: + min_area * (1 + margin) - alloc_w * alloc_h <= 0 + """ + min_area = self.min_areas.get(name, float("inf")) + return min_area * (1.0 + self.area_margin) - alloc_w * alloc_h + + def _eval_constraints(self, x: np.ndarray) -> list[float]: + """Evaluate area-based feasibility constraints. + + For each tile/supertile the allocated area (w * h) must meet or + exceed the minimum compilable area (with margin) from exploration: + min_area * (1 + margin) - alloc_w * alloc_h <= 0 + """ + result: list[float] = [] + + for tile_name, col, row in self._tile_constraints: + alloc_w = self.get_col_width(x, col) + alloc_h = self.get_row_height(x, row) + result.append(self._area_violation(tile_name, alloc_w, alloc_h)) + + for st_name, st_cols, st_rows in self._supertile_constraints: + total_w = sum(self.get_col_width(x, c) for c in st_cols) + total_h = sum(self.get_row_height(x, r) for r in st_rows) + result.append(self._area_violation(st_name, total_w, total_h)) return result @Step.factory.register() class GlobalTileSizeOptimization(Step): - """LibreLane step for solving NLP optimization to find optimal tile dimensions. - - This step formulates and solves a Non-Linear Program using pymoo to minimize total - fabric area subject to minimum area constraints (bilinear w*h >= A_min), row/column - grid constraints, and SuperTile boundary constraints. + """LibreLane step for NLP optimization of tile dimensions. - After optimization, it automatically recompiles all tiles with the optimal - dimensions and stores the recompiled states in metrics for downstream processing. + Formulates and solves a Non-Linear Program using pymoo to minimize total + fabric area. Variables are row heights and column widths, ensuring + uniformity within each row/column by construction. """ id = "FABulous.GlobalTileSizeOptimization" @@ -331,13 +430,6 @@ class GlobalTileSizeOptimization(Step): Path, description="Path to the FABulous project directory", ), - Variable( - "FABULOUS_NLP_FTOL_TOLERANCE", - float, - description="Function tolerance for NLP optimizer - " - "stops when objective change is below this value", - default=1e-6, - ), ] inputs = [] @@ -348,111 +440,125 @@ class GlobalTileSizeOptimization(Step): DesignFormat.DEF, ] - def run(self, state_in: State, **_kwargs: str) -> tuple[ViewsUpdate, MetricsUpdate]: - """Solve NLP problem and recompile tiles with optimal dimensions. - - The NLP formulation minimizes total fabric area sum(w_i*h_i) for all tiles, - subject to minimum area constraints w_i*h_i >= A_min,i (bilinear terms), - row/column grid consistency, and supertile spanning constraints. - - Variables: row_heights[r], col_widths[c] for each row/col with tiles - Objective: Minimize sum over all positions: row_height[r] * col_width[c] - Constraints: - - Regular tiles: row_height[r] * col_width[c] >= A_min,i - - Supertiles: sum_spanned_row_h * sum_spanned_col_w >= A_min,i - - Bounds: from min tile dimensions to max available modes - - After solving, recompiles all tiles with optimal dimensions. - - Parameters - ---------- - state_in : State - Input state with fabric structure and tile dimension options - **_kwargs: str - Additional keyword arguments (not used) - - Returns - ------- - tuple[ViewsUpdate, MetricsUpdate] - Updated views (design files) and metrics with optimal dimensions - and recompiled states - - - Raises - ------ - FlowException - TILE_OPT_INFO not set in configuration - RuntimeError - No NLP solution found + @staticmethod + def _parse_tile_fields(data: dict) -> dict[str, Any]: + """Parse tile metric fields from JSON data. + + Parses bbox strings into float lists and extracts optional scalar + fields (pin minimums) when present. + """ + + def parse_bbox(key: str) -> list[float]: + return [float(v) for v in data[key].split()] + + result: dict[str, Any] = { + "design__die__bbox": parse_bbox("design__die__bbox"), + "design__core__bbox": parse_bbox("design__core__bbox"), + } + for key in ( + "fabulous__pin_min_width", + "fabulous__pin_min_height", + "design__instance__area__stdcell", + ): + if data.get(key) is not None: + result[key] = float(data[key]) + return result + + @classmethod + def _load_tile_metrics_from_json( + cls, + path: Path, + ) -> tuple[dict[OptMode, dict], dict[OptMode, dict]]: + """Load tile metrics from a JSON file. + + Returns two dicts: (valid_metrics, all_metrics). + valid_metrics excludes tiles whose exploration never found a + working state (used for feasibility constraints). + all_metrics includes everything with a bbox (used for lower bounds). """ + tile_data_raw = json.loads(path.resolve().read_text()) + valid_data: dict[OptMode, dict] = {} + all_data: dict[OptMode, dict] = {} + + for mode, tile_info in tile_data_raw.items(): + valid_dict: dict[str, dict[str, Any]] = {} + all_dict: dict[str, dict[str, Any]] = {} + + for tile_name, data in tile_info.items(): + if not data.get("design__die__bbox"): + if "error" in data: + warn( + f"Tile {tile_name} in mode {mode} has error " + f"and no bbox: {data['error']}" + ) + continue + + parsed = cls._parse_tile_fields(data) + all_dict[tile_name] = parsed + + if "No working state found" in data.get("error_traceback", ""): + warn( + f"Tile {tile_name} in mode {mode} never compiled " + f"successfully, excluding from constraints" + ) + else: + valid_dict[tile_name] = parsed + + valid_data[OptMode(mode)] = valid_dict + all_data[OptMode(mode)] = all_dict + + return valid_data, all_data + + def run(self, state_in: State, **_kwargs: str) -> tuple[ViewsUpdate, MetricsUpdate]: + """Solve NLP problem for optimal tile dimensions.""" info("Formulating NLP problem using pymoo...") if self.config["TILE_OPT_INFO"] is None: raise FlowException( "Values of TILE_OPT_INFO should have been set when calling this step." ) - # Get fabric configuration fabric: Fabric = self.config["FABULOUS_FABRIC"] - tolerance = self.config.get("FABULOUS_NLP_FTOL_TOLERANCE", 10.0) + if isinstance(self.config["TILE_OPT_INFO"], Path): - tile_data: dict[OptMode, dict] = {} - tile_data_raw = json.load( - Path(self.config["TILE_OPT_INFO"]).resolve().open() + valid_metrics, all_metrics = self._load_tile_metrics_from_json( + self.config["TILE_OPT_INFO"] ) - for mode, tile_info in tile_data_raw.items(): - tile_data[OptMode(mode)] = {} - for tile_name, data in tile_info.items(): - if "error" in data: - continue - tile_data[OptMode(mode)][tile_name] = {} - tile_data[OptMode(mode)][tile_name]["design__die__bbox"] = [ - float(i) for i in data["design__die__bbox"].split() - ] - tile_data[OptMode(mode)][tile_name]["design__core__bbox"] = [ - float(i) for i in data["design__core__bbox"].split() - ] - tile_opt_data = tile_data else: - tile_opt_data = self.config["TILE_OPT_INFO"] - # Create pymoo problem - constructor handles all the formatting + valid_metrics = self.config["TILE_OPT_INFO"] + all_metrics = valid_metrics + + area_margin = self.config.get("FABULOUS_NLP_AREA_MARGIN", 0.05) + info(f"Using area margin: {area_margin:.1%}") problem = NLPTileProblem( - fabric, - tile_opt_data, + fabric, valid_metrics, all_metrics, area_margin=area_margin ) x_pitch = Decimal(state_in.metrics.get("pdk__site_width", 0.5)) y_pitch = Decimal(state_in.metrics.get("pdk__site_height", 0.5)) - # Solve with ISRES - specifically designed for constrained optimization + n_row_vars = len(set(problem.row_groups.values())) + class RoundRepair(Repair): def _do(self, _problem: Any, X: np.ndarray, **_kwargs: Any) -> np.ndarray: # noqa: ANN401 - """Solution repair to round to nearest grid pitch.""" + """Round variables to nearest grid pitch.""" for j in range(X.shape[0]): for i in range(X.shape[1]): - if i % 2 == 0: + if i < n_row_vars: # row height variables X[j][i] = float(round_up_decimal(Decimal(X[j][i]), y_pitch)) - else: + else: # column width variables X[j][i] = float(round_up_decimal(Decimal(X[j][i]), x_pitch)) return X algorithm = ISRES(repair=RoundRepair()) - info("Running optimization with function tolerance termination") - - # Combine: stop when objective stops changing (ftol) OR feasible solution found - # OR max 50000 generations - ftol_termination = SingleObjectiveSpaceTermination(tol=tolerance) - max_gen_termination = MaximumGenerationTermination(50000) - termination = TerminateIfAny(ftol_termination, max_gen_termination) + n_gen = 500 + info(f"Running optimization for {n_gen} generations") + termination = MaximumGenerationTermination(n_gen) res = minimize(problem, algorithm, termination, verbose=True) - # Check if we have a valid solution - # Try to get best solution even if infeasible if res.X is None: - # Check if there's a population with solutions if hasattr(res, "pop") and res.pop is not None and len(res.pop) > 0: info("No single best solution found, using best from population") - # Sort population by constraint violation, then by objective pop_sorted = sorted( res.pop, key=lambda ind: ( @@ -469,7 +575,6 @@ def _do(self, _problem: Any, X: np.ndarray, **_kwargs: Any) -> np.ndarray: # no else: raise RuntimeError("NLP optimization failed to find any solution") - # Check constraint violation if hasattr(res, "CV") and res.CV is not None: if res.CV[0] > 1e-6: warn(f"Solution has constraint violation of {res.CV[0]}") @@ -480,56 +585,50 @@ def _do(self, _problem: Any, X: np.ndarray, **_kwargs: Any) -> np.ndarray: # no info(f"Optimization terminated with objective={res.F[0]}") - # Extract results + quant = Decimal(".01") + zero = Decimal(0) + result_dict: dict[str, tuple[Decimal, ...]] = {} + + def quantized_width(tile_name: str) -> Decimal: + col = min(problem.tile_column_set[tile_name]) + return Decimal(problem.get_col_width(res.X, col)).quantize(quant) - result_dict = {} - for tile_name, indices in problem.tile_to_solution_index.items(): - w = res.X[indices.width_idx] - h = res.X[indices.height_idx] - if fabric.tileDic[tile_name].partOfSuperTile: - # Skip component tiles of supertiles + def quantized_height(tile_name: str) -> Decimal: + row = min(problem.tile_row_set[tile_name]) + return Decimal(problem.get_row_height(res.X, row)).quantize(quant) + + for tile in fabric.tileDic.values(): + if tile.partOfSuperTile: continue - result_dict[tile_name] = ( - Decimal(0), - Decimal(0), - Decimal(w).quantize(Decimal(".01")), - Decimal(h).quantize(Decimal(".01")), + result_dict[tile.name] = ( + zero, + zero, + quantized_width(tile.name), + quantized_height(tile.name), ) for supertile in fabric.superTileDic.values(): - # Sum component tile dimensions from first row (width) and - # first column (height) Width is sum of widths in the first row - total_w = 0.0 - if supertile.tileMap and len(supertile.tileMap) > 0: - for tile in supertile.tileMap[0]: # First row + total_w = zero + if supertile.tileMap: + for tile in supertile.tileMap[0]: if tile is not None: - sub_idx = problem.tile_to_solution_index[tile.name] - total_w += res.X[sub_idx.width_idx] + total_w += quantized_width(tile.name) - # Height is sum of heights in the first column - total_h = 0.0 - for row in supertile.tileMap: - if row and len(row) > 0 and row[0] is not None: # First column - sub_idx = problem.tile_to_solution_index[row[0].name] - total_h += res.X[sub_idx.height_idx] - - result_dict[supertile.name] = ( - Decimal(0), - Decimal(0), - Decimal(total_w).quantize(Decimal(".01")), - Decimal(total_h).quantize(Decimal(".01")), - ) + total_h = zero + for row_tiles in supertile.tileMap: + first_tile = next((t for t in row_tiles if t is not None), None) if row_tiles else None + if first_tile is not None: + total_h += quantized_height(first_tile.name) - # Calculate total area - total_area = int(res.F[0]) + result_dict[supertile.name] = (zero, zero, total_w, total_h) - # Report results + total_area = int(res.F[0]) info(f" Total fabric area: {total_area}") info(f" Optimal tile dimensions: {result_dict}") - metrics_updates = { + return {}, { "nlp__tile__area": result_dict, "nlp__total__area": total_area, + "nlp__tile__min_area": problem.min_areas, + "nlp__tile__stdcell_area": problem.stdcell_areas, } - - return {}, metrics_updates diff --git a/fabulous/fabric_generator/gds_generator/steps/tile_optimisation.py b/fabulous/fabric_generator/gds_generator/steps/tile_optimisation.py index edec67b4f..182081574 100644 --- a/fabulous/fabric_generator/gds_generator/steps/tile_optimisation.py +++ b/fabulous/fabric_generator/gds_generator/steps/tile_optimisation.py @@ -89,6 +89,18 @@ def _missing_(cls, value: object) -> "OptMode": "Default is False.", default=False, ), + Variable( + "FABULOUS_PIN_MIN_WIDTH", + Decimal, + "Minimum tile width based on pin requirements.", + default=Decimal(0), + ), + Variable( + "FABULOUS_PIN_MIN_HEIGHT", + Decimal, + "Minimum tile height based on pin requirements.", + default=Decimal(0), + ), ] @@ -124,7 +136,6 @@ class TileOptimisation(WhileStep): OpenROAD.DetailedPlacement, OpenROAD.CTS, OpenROAD.GlobalRouting, - # AutoEcoDiodeInsertion, OpenROAD.CheckAntennas, OpenROAD.RepairAntennas, OpenROAD.DetailedRouting, @@ -151,27 +162,37 @@ class TileOptimisation(WhileStep): iter_count: int = 0 + last_core_area: Decimal | None = None + + last_drc_errors: int = 0 + def condition(self, state: State) -> bool: """Loop condition.""" if state.metrics.get("route__drc_errors") is None: return True - checklist = [] + metrics_to_check = ["route__drc_errors"] if not self.config["IGNORE_ANTENNA_VIOLATIONS"]: - checklist.append("antenna__violating__pins") - checklist.append("antenna__violating__nets") - - checklist.append("route__drc_errors") - for i in checklist: - if (v := state.metrics.get(i)) and cast("int", v) > 0: - return True + metrics_to_check.extend( + ["antenna__violating__pins", "antenna__violating__nets"] + ) - return False + return any(cast("int", state.metrics.get(m, 0)) > 0 for m in metrics_to_check) def post_iteration_callback( self, post_iteration: State, full_iter_completed: bool ) -> State: """Save state if iteration completed successfully.""" + # Capture core area for the next iteration's scaling check. + # The WhileStep resets state each iteration, so we persist this + # on the instance to carry it across resets. + if (ca := post_iteration.metrics.get("design__core__area")) is not None: + self.last_core_area = Decimal(ca) + + self.last_drc_errors = cast( + "int", post_iteration.metrics.get("route__drc_errors", 0) + ) + if full_iter_completed: self.last_working_state = post_iteration.copy() return post_iteration @@ -180,11 +201,25 @@ def post_iteration_callback( self.iter_count += 1 return post_iteration + def _refresh_routing_obstructions(self) -> None: + """Clear and recompute routing obstructions from current config. + + The two-step process is required because get_routing_obstructions reads + ROUTING_OBSTRUCTIONS from config and appends edge obstructions. Clearing + first prevents stale obstructions from accumulating across iterations. + """ + self.config = self.config.copy(ROUTING_OBSTRUCTIONS=None) + self.config = self.config.copy( + ROUTING_OBSTRUCTIONS=get_routing_obstructions(self.config) + ) + def pre_iteration_callback(self, pre_iteration: State) -> State: """Pre iteration callback.""" if self.config["FABULOUS_OPT_MODE"] == OptMode.NO_OPT: self.config = self.config.copy(DRT_OPT_ITERS=64) + self._refresh_routing_obstructions() return pre_iteration + die_area_raw: tuple[Decimal, Decimal, Decimal, Decimal] = self.config.get( "DIE_AREA", None ) @@ -193,66 +228,46 @@ def pre_iteration_callback(self, pre_iteration: State) -> State: _, _, width, height = die_area_raw - # Get PDK site dimensions from metrics (if available) site_width = Decimal(pre_iteration.metrics.get("pdk__site_width", Decimal(1))) site_height = Decimal(pre_iteration.metrics.get("pdk__site_height", Decimal(1))) x_pitch, y_pitch = get_pitch(self.config) - # Calculate step size based on PDK site dimensions - width_step_count = self.config["FABULOUS_OPTIMISATION_WIDTH_STEP_COUNT"] - height_step_count = self.config["FABULOUS_OPTIMISATION_HEIGHT_STEP_COUNT"] - width_step = site_width * width_step_count - height_step = site_height * height_step_count - - instance_area = Decimal(pre_iteration.metrics.get("design__instance__area", 0)) - new_height: Decimal - new_width: Decimal - - if height == 0: - height = instance_area.sqrt() + width_step = site_width * self.config["FABULOUS_OPTIMISATION_WIDTH_STEP_COUNT"] + height_step = ( + site_height * self.config["FABULOUS_OPTIMISATION_HEIGHT_STEP_COUNT"] + ) - if width == 0: - width = instance_area.sqrt() + # Diode insertion on ports adds cells that need extra area. + # Scale both step sizes so the optimiser grows the tile faster + # to accommodate the additional diode cells. + diode_on_ports: str = self.config.get("DIODE_ON_PORTS", "none") + if diode_on_ports == "both": + width_step += site_width * 8 + height_step += site_height * 8 + elif diode_on_ports in ("in", "out"): + width_step += site_width * 4 + height_step += site_height * 4 - match self.config["FABULOUS_OPT_MODE"]: - case OptMode.FIND_MIN_WIDTH: - if width == 0: - new_width, new_height = (instance_area / height, height) - else: - new_width, new_height = (width + width_step, height) - case OptMode.FIND_MIN_HEIGHT: - # Initialize height based on instance area if not yet set properly - if height == 0: - new_width, new_height = (width, instance_area / width) - else: - new_width, new_height = (width, height + height_step) - case OptMode.BALANCE: - # Initialize to square bounding box if not yet set properly - if width == 0 or height == 0: - if width == 0 and height == 0: - side = instance_area.sqrt() - new_width, new_height = side, side - elif width > height: - new_width, new_height = width, instance_area / width - else: - new_width, new_height = instance_area / height, height - else: - if self.to_change_width: - new_width, new_height = (width + width_step, height) - else: - new_width, new_height = (width, height + height_step) - case OptMode.LARGE: - # Initialize to square bounding box if not yet set properly - if width == 0 or height == 0: - initial_side = instance_area.sqrt() - new_width, new_height = (initial_side, initial_side) - else: - new_width, new_height = (width + width_step, height + height_step) - - case _: - raise ValueError( - f"Unknown FABULOUS_OPT_MODE: {self.config['FABULOUS_OPT_MODE']}" - ) + instance_area = Decimal(pre_iteration.metrics.get("design__instance__area", 0)) + if self.last_core_area is not None: + core_area = self.last_core_area + else: + # First iteration: no actual core area yet. Estimate core area + # from die area minus floorplan margins (site insets on each side) + # so the overshoot scaling triggers when cells barely fit. + sites_per_side_x = 6 + margin_x = Decimal(2) * site_width * sites_per_side_x + margin_y = Decimal(2) * site_height + core_area = (width - margin_x) * (height - margin_y) + + new_width, new_height = self._compute_new_dimensions( + width, + height, + width_step, + height_step, + instance_area, + core_area, + ) die_area = ( Decimal(0), @@ -260,39 +275,123 @@ def pre_iteration_callback(self, pre_iteration: State) -> State: round_up_decimal(new_width, x_pitch), round_up_decimal(new_height, y_pitch), ) - self.config = self.config.copy(DRT_OPT_ITERS=5 + self.iter_count) - self.config = self.config.copy(DIE_AREA=die_area) - self.config = self.config.copy(ROUTING_OBSTRUCTIONS=None) self.config = self.config.copy( - ROUTING_OBSTRUCTIONS=get_routing_obstructions(self.config) + DRT_OPT_ITERS=5 + self.iter_count, + DIE_AREA=die_area, ) + self._refresh_routing_obstructions() + if p := self.get_current_iteration_dir(): (p / "config.json").write_text(self.config.dumps()) return pre_iteration + def _compute_new_dimensions( + self, + width: Decimal, + height: Decimal, + width_step: Decimal, + height_step: Decimal, + instance_area: Decimal, + core_area: Decimal, + ) -> tuple[Decimal, Decimal]: + """Compute the next tile dimensions based on the optimisation mode. + + First ensures the die can accommodate the instance area by scaling + the non-optimised dimension (directional modes) or both dimensions + proportionally (balanced/large modes). Then applies the iterative + growth step. Finally, for directional modes, if the previous + iteration had DRC violations the non-optimised dimension is boosted + proportionally to the violation count so that extreme aspect ratios + self-correct without a hard cap. + """ + opt_mode = self.config["FABULOUS_OPT_MODE"] + + # Scale up proportionally if instance area exceeds the placeable + # core area. Using sqrt on both dimensions keeps aspect ratios + # reasonable regardless of optimisation mode. Directional + # exploration is handled by the iterative step below. + if core_area > 0 and instance_area > core_area: + scale = (instance_area / core_area).sqrt() + width *= scale + height *= scale + + # Apply iterative step + match opt_mode: + case OptMode.FIND_MIN_WIDTH: + width += width_step + case OptMode.FIND_MIN_HEIGHT: + height += height_step + case OptMode.BALANCE: + if self.to_change_width: + width += width_step + else: + height += height_step + case OptMode.LARGE: + width += width_step + height += height_step + case _: + raise ValueError(f"Unknown FABULOUS_OPT_MODE: {opt_mode}") + + # Adaptive scaling: if the previous iteration had DRC violations, + # also grow the non-optimised dimension. The boost is proportional + # to the violation count (violations / 1000), so a tile with 17k + # violations gets a ~17x step boost while a tile with 50 violations + # gets almost nothing. This lets extreme aspect ratios self-correct + # without imposing a hard cap. + if self.last_drc_errors > 0: + boost = Decimal(self.last_drc_errors) / Decimal(1000) + if opt_mode == OptMode.FIND_MIN_WIDTH: + height += height_step * boost + elif opt_mode == OptMode.FIND_MIN_HEIGHT: + width += width_step * boost + + return width, height + def post_loop_callback(self, state: State) -> State: # noqa: ARG002 """Post loop callback.""" - if self.last_working_state is not None: - return self.last_working_state - if self.config["FABULOUS_OPT_MODE"] == OptMode.NO_OPT: - raise RuntimeError( - "Fail to find a clean state after the physical implementation" - ) - raise RuntimeError("No working state found after tile optimisation.") + if self.last_working_state is None: + if self.config["FABULOUS_OPT_MODE"] == OptMode.NO_OPT: + raise RuntimeError( + "Fail to find a clean state after the physical implementation" + ) + raise RuntimeError("No working state found after tile optimisation.") + + result = self.last_working_state + + # Update config with actual die area so downstream steps + # (e.g. Magic/KLayout stream-out) use the correct boundary. + die_bbox_str = result.metrics.get("design__die__bbox") + if die_bbox_str is not None: + new_die_area = tuple(Decimal(x) for x in die_bbox_str.split()) + old_die_area = self.config.get("DIE_AREA") + if ( + old_die_area is None + or tuple(Decimal(x) for x in old_die_area) != new_die_area + ): + info( + f"Updating DIE_AREA from {old_die_area} to {new_die_area} " + "based on TileOptimisation output." + ) + self.config = self.config.copy(DIE_AREA=new_die_area) + + return result def mid_iteration_break(self, state: State, step: type[Step]) -> bool: """Mid iteration callback.""" - if isinstance(step, Checker.TrDRC): - if self.config["IGNORE_ANTENNA_VIOLATIONS"]: - return cast("int", state.metrics.get("route__drc_errors")) > 0 + if not isinstance(step, Checker.TrDRC): + return False - return (cast("int", state.metrics.get("antenna__violating__nets")) > 0) or ( - cast("int", state.metrics.get("antenna__violating__pins")) > 0 - or cast("int", state.metrics.get("route__drc_errors")) > 0 + metrics_to_check = ["route__drc_errors"] + if not self.config["IGNORE_ANTENNA_VIOLATIONS"]: + metrics_to_check.extend( + [ + "antenna__violating__nets", + "antenna__violating__pins", + ] ) - return False + return any(cast("int", state.metrics.get(m, 0)) > 0 for m in metrics_to_check) def run( self, diff --git a/fabulous/fabric_generator/gds_generator/steps/while_step.py b/fabulous/fabric_generator/gds_generator/steps/while_step.py index e5c305710..3431c6002 100644 --- a/fabulous/fabric_generator/gds_generator/steps/while_step.py +++ b/fabulous/fabric_generator/gds_generator/steps/while_step.py @@ -148,6 +148,10 @@ def run( ) progress_bar.end_stage() current_state = self.post_loop_callback(current_state) + + # Persist final config — post_loop_callback may have updated it + (Path(self.step_dir) / "config.json").write_text(self.config.dumps()) + for key in current_state: if ( state_in.get(key) != current_state.get(key) diff --git a/fabulous/fabulous_api.py b/fabulous/fabulous_api.py index e2d1ed2c2..96bb7091e 100644 --- a/fabulous/fabulous_api.py +++ b/fabulous/fabulous_api.py @@ -684,6 +684,8 @@ def full_fabric_automation( base_config_path: Path | None = None, config_override_path: Path | None = None, tile_opt_config: Path | None = None, + nlp_only: bool = False, + nlp_area_margin: float = 0.05, **config_overrides: dict, ) -> None: """Run the stitching flow to assemble tile macros into a fabric-level GDS.""" diff --git a/fabulous/fabulous_cli/fabulous_cli.py b/fabulous/fabulous_cli/fabulous_cli.py index c84a383cf..06addeacd 100644 --- a/fabulous/fabulous_cli/fabulous_cli.py +++ b/fabulous/fabulous_cli/fabulous_cli.py @@ -1568,8 +1568,28 @@ def do_gen_fabric_macro(self, *_args: str) -> None: base_config_path=self.projectDir / "Fabric" / "gds_config.yaml", ) + eFPGA_macro_parser = Cmd2ArgumentParser() + eFPGA_macro_parser.add_argument( + "--tile-opt-info", + type=str, + default=None, + help="Path to tile optimisation summary JSON to skip Step 1", + ) + eFPGA_macro_parser.add_argument( + "--nlp-only", + action="store_true", + help="Run exploration and NLP only, skip recompilation", + ) + eFPGA_macro_parser.add_argument( + "--nlp-area-margin", + type=float, + default=0.05, + help="Area margin for NLP constraint (default: 0.05 = 5%%)", + ) + + @with_argparser(eFPGA_macro_parser) @with_category(CMD_FABRIC_FLOW) - def do_run_FABulous_eFPGA_macro(self, *_arg: str) -> None: + def do_run_FABulous_eFPGA_macro(self, args: argparse.Namespace) -> None: """Run the full FABulous eFPGA macro generation flow.""" if not is_pdk_config_set(): logger.error( @@ -1579,12 +1599,16 @@ def do_run_FABulous_eFPGA_macro(self, *_arg: str) -> None: return (self.projectDir / "Fabric" / "macro").mkdir(exist_ok=True) + tile_opt_config = Path(args.tile_opt_info) if args.tile_opt_info else None self.fabulousAPI.full_fabric_automation( self.projectDir, self.projectDir / "Fabric" / "macro", cast("str", get_context().pdk), cast("Path", get_context().pdk_root), base_config_path=self.projectDir / "Fabric" / "gds_config.yaml", + tile_opt_config=tile_opt_config, + nlp_only=args.nlp_only, + nlp_area_margin=args.nlp_area_margin, ) gui_parser: Cmd2ArgumentParser = Cmd2ArgumentParser() diff --git a/tests/gds_flow_test/flow_test/test_full_fabric_flow.py b/tests/gds_flow_test/flow_test/test_full_fabric_flow.py index 64230d433..8c5a1358c 100644 --- a/tests/gds_flow_test/flow_test/test_full_fabric_flow.py +++ b/tests/gds_flow_test/flow_test/test_full_fabric_flow.py @@ -9,8 +9,8 @@ # ruff: noqa: SLF001 +from decimal import Decimal from pathlib import Path -from typing import TYPE_CHECKING from unittest.mock import MagicMock import pytest @@ -18,13 +18,11 @@ from fabulous.fabric_generator.gds_generator.flows.full_fabric_flow import ( FABulousFabricMacroFullFlow, + WorkerResult, _run_tile_flow_worker, ) from fabulous.fabric_generator.gds_generator.steps.tile_optimisation import OptMode -if TYPE_CHECKING: - from librelane.state.state import State - # Shared fixtures @pytest.fixture @@ -186,7 +184,7 @@ def test_worker_catches_exceptions( ) tile: MagicMock = mocker.MagicMock() - result: tuple[State | None, str | None] = _run_tile_flow_worker( + result: WorkerResult = _run_tile_flow_worker( tile, tmp_path, tmp_path / "io.yaml", @@ -195,13 +193,11 @@ def test_worker_catches_exceptions( tmp_path / "override.yaml", ) - # Should return (None, error_trace) - state: State | None - error_trace: str | None - state, error_trace = result + state, error_trace, pin_min = result assert state is None assert error_trace is not None assert "Test error" in error_trace + assert pin_min is None def test_worker_returns_state_on_success( self, mocker: MockerFixture, tmp_path: Path @@ -218,13 +214,17 @@ def test_worker_returns_state_on_success( mock_state: MagicMock = mocker.MagicMock() mock_flow: MagicMock = mocker.MagicMock() mock_flow.start.return_value = mock_state + mock_flow.config = { + "FABULOUS_PIN_MIN_WIDTH": Decimal("10.0"), + "FABULOUS_PIN_MIN_HEIGHT": Decimal("10.0"), + } mocker.patch( "fabulous.fabric_generator.gds_generator.flows.full_fabric_flow.FABulousTileVerilogMacroFlow", return_value=mock_flow, ) tile: MagicMock = mocker.MagicMock() - result: tuple[State | None, str | None] = _run_tile_flow_worker( + result: WorkerResult = _run_tile_flow_worker( tile, tmp_path, tmp_path / "io.yaml", @@ -233,11 +233,10 @@ def test_worker_returns_state_on_success( tmp_path / "override.yaml", ) - state: State | None - error_trace: str | None - state, error_trace = result + state, error_trace, pin_min = result assert state is mock_state assert error_trace is None + assert pin_min is not None class TestWorkerCustomOverrides: @@ -258,6 +257,10 @@ def test_worker_passes_custom_overrides( mock_state: MagicMock = mocker.MagicMock() mock_flow: MagicMock = mocker.MagicMock() mock_flow.start.return_value = mock_state + mock_flow.config = { + "FABULOUS_PIN_MIN_WIDTH": Decimal("10.0"), + "FABULOUS_PIN_MIN_HEIGHT": Decimal("10.0"), + } mock_flow_class: MagicMock = mocker.patch( "fabulous.fabric_generator.gds_generator.flows.full_fabric_flow.FABulousTileVerilogMacroFlow", return_value=mock_flow, diff --git a/tests/gds_flow_test/step_test/conftest.py b/tests/gds_flow_test/step_test/conftest.py index 64d24d42d..3d7af80f6 100644 --- a/tests/gds_flow_test/step_test/conftest.py +++ b/tests/gds_flow_test/step_test/conftest.py @@ -1,4 +1,4 @@ -"""Fixtures fceor gds_generator_test tests.""" +"""Fixtures for gds_generator_test tests.""" from decimal import Decimal From fd6230ab2e9abe9ba08b51b4089b693344fded68 Mon Sep 17 00:00:00 2001 From: Kelvin Chung Date: Tue, 17 Mar 2026 16:27:33 +0000 Subject: [PATCH 02/48] chore: add docs --- .../steps/global_tile_opitmisation.py | 35 ++++++++++++++++--- 1 file changed, 30 insertions(+), 5 deletions(-) diff --git a/fabulous/fabric_generator/gds_generator/steps/global_tile_opitmisation.py b/fabulous/fabric_generator/gds_generator/steps/global_tile_opitmisation.py index 6c7aa2c4f..112849b2b 100644 --- a/fabulous/fabric_generator/gds_generator/steps/global_tile_opitmisation.py +++ b/fabulous/fabric_generator/gds_generator/steps/global_tile_opitmisation.py @@ -42,6 +42,21 @@ def __init__( all_tile_metrics: dict[OptMode, dict] | None = None, area_margin: float = 0.05, ) -> None: + """Initialise the NLP tile-sizing problem. + + Parameters + ---------- + fabric : Fabric + The fabric whose tiles are being sized. + tile_metrics : dict[OptMode, dict] + Per-mode compilation metrics for tiles that compiled successfully. + all_tile_metrics : dict[OptMode, dict] | None, optional + Metrics including failed explorations, used for lower-bound + estimates. Falls back to *tile_metrics* when ``None``. + area_margin : float, optional + Fractional margin added to standard-cell area constraints, + by default 0.05 (5 %). + """ self.fabric = fabric self.tile_metrics = tile_metrics self.area_margin = area_margin @@ -93,9 +108,8 @@ def __init__( # and from row/column neighbors for supertile in fabric.superTileDic.values(): st_min_w, st_min_h = self._pin_min_from_metrics(supertile.name) - n_cols = sum( - 1 for t in (supertile.tileMap[0] if supertile.tileMap else []) if t is not None - ) + first_row = supertile.tileMap[0] if supertile.tileMap else [] + n_cols = sum(1 for t in first_row if t is not None) n_rows = sum( 1 for row in supertile.tileMap @@ -191,12 +205,14 @@ def _compute_equivalence_classes( parent: dict[int, int] = {} def find(x: int) -> int: + """Return the root representative of *x* with path compression.""" while parent.get(x, x) != x: parent[x] = parent.get(parent[x], parent[x]) x = parent[x] return x def union(a: int, b: int) -> None: + """Merge the sets containing *a* and *b*.""" ra, rb = find(a), find(b) if ra != rb: parent[ra] = rb @@ -351,7 +367,9 @@ def _build_supertile_constraints( ] st_rows: list[int] = [] for row in supertile.tileMap: - first_tile = next((t for t in row if t is not None), None) if row else None + first_tile = ( + next((t for t in row if t is not None), None) if row else None + ) if first_tile is not None: st_rows.append(min(self.tile_row_set[first_tile.name])) constraints.append((supertile.name, st_cols, st_rows)) @@ -449,6 +467,7 @@ def _parse_tile_fields(data: dict) -> dict[str, Any]: """ def parse_bbox(key: str) -> list[float]: + """Parse a whitespace-separated bbox string into a list of floats.""" return [float(v) for v in data[key].split()] result: dict[str, Any] = { @@ -590,10 +609,12 @@ def _do(self, _problem: Any, X: np.ndarray, **_kwargs: Any) -> np.ndarray: # no result_dict: dict[str, tuple[Decimal, ...]] = {} def quantized_width(tile_name: str) -> Decimal: + """Return the NLP-optimal width for *tile_name*, quantized to 0.01.""" col = min(problem.tile_column_set[tile_name]) return Decimal(problem.get_col_width(res.X, col)).quantize(quant) def quantized_height(tile_name: str) -> Decimal: + """Return the NLP-optimal height for *tile_name*, quantized to 0.01.""" row = min(problem.tile_row_set[tile_name]) return Decimal(problem.get_row_height(res.X, row)).quantize(quant) @@ -616,7 +637,11 @@ def quantized_height(tile_name: str) -> Decimal: total_h = zero for row_tiles in supertile.tileMap: - first_tile = next((t for t in row_tiles if t is not None), None) if row_tiles else None + first_tile = ( + next((t for t in row_tiles if t is not None), None) + if row_tiles + else None + ) if first_tile is not None: total_h += quantized_height(first_tile.name) From ec22fb6710ada23059c959f32242ebf4513c7eda Mon Sep 17 00:00:00 2001 From: Kelvin Chung Date: Tue, 17 Mar 2026 16:27:44 +0000 Subject: [PATCH 03/48] chore: more docs --- .../fabric_generator/gds_generator/flows/full_fabric_flow.py | 1 + 1 file changed, 1 insertion(+) diff --git a/fabulous/fabric_generator/gds_generator/flows/full_fabric_flow.py b/fabulous/fabric_generator/gds_generator/flows/full_fabric_flow.py index 6b9cebe77..7ce48dc18 100644 --- a/fabulous/fabric_generator/gds_generator/flows/full_fabric_flow.py +++ b/fabulous/fabric_generator/gds_generator/flows/full_fabric_flow.py @@ -327,6 +327,7 @@ def _init_compile(self, fabric: Fabric, proj_dir: Path) -> None: result_summary[opt_mode.value][tile_name] = metrics_dict def custom_serializer(obj: object) -> float | object: + """Convert Decimal values to float for JSON serialisation.""" if isinstance(obj, Decimal): return float(obj) return obj From c128489caaede139e424451a932e214753f12682 Mon Sep 17 00:00:00 2001 From: Kelvin Chung Date: Tue, 17 Mar 2026 16:41:06 +0000 Subject: [PATCH 04/48] chore: ci --- .github/workflows/nightly_full_auto_flow.yml | 212 +++++++++++++++++++ 1 file changed, 212 insertions(+) create mode 100644 .github/workflows/nightly_full_auto_flow.yml diff --git a/.github/workflows/nightly_full_auto_flow.yml b/.github/workflows/nightly_full_auto_flow.yml new file mode 100644 index 000000000..a3ae2b546 --- /dev/null +++ b/.github/workflows/nightly_full_auto_flow.yml @@ -0,0 +1,212 @@ +name: Nightly Full Auto Flow Test + +on: + schedule: + # Run every night at 02:00 UTC (offset from dependency test at 00:00) + - cron: '0 2 * * *' + workflow_dispatch: # Allow manual triggering + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + full_auto_flow: + name: Full auto GDS flow (end-to-end, ihp-sg13g2) + runs-on: ubuntu-latest + permissions: + issues: write + contents: read + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + steps: + - name: Checkout + uses: actions/checkout@v5 + + - name: Install Nix + uses: nixbuild/nix-quick-install-action@v34 + with: + nix_version: "2.31.2" + nix_conf: | + substituters = https://cache.nixos.org https://nix-cache.fossi-foundation.org + extra-trusted-public-keys = nix-cache.fossi-foundation.org:3+K59iFwXqKsL7BNu6Guy0v+uTlwsxYQxjspXzqLYQs= + + - name: Free up disk space + run: | + sudo rm -rf /usr/share/dotnet + sudo rm -rf /usr/local/lib/android + sudo rm -rf /opt/ghc + sudo rm -rf /opt/hostedtoolcache + sudo rm -rf /usr/local/share/boost + sudo rm -rf /usr/lib/jvm + sudo rm -rf /usr/share/swift + + - name: Build Nix devshell + run: nix build + + - name: Create demo project and run full auto flow + id: run_flow + continue-on-error: true + run: | + nix develop --command bash -c ' + set -euo pipefail + + echo "=== Tool versions ===" + FABulous --version + openroad -version + yosys --version + + echo "=== Creating demo Verilog project ===" + FABulous -c /tmp/fab_demo -w verilog + + echo "=== Running full auto flow (exploration → NLP → recompilation → stitching) ===" + cd /tmp/fab_demo + FABulous /tmp/fab_demo <&1 | tee /tmp/flow_output.txt + + echo "exit_code=${PIPESTATUS[0]}" >> $GITHUB_OUTPUT + + - name: Check flow results + id: check_results + run: | + if [ "${{ steps.run_flow.outputs.exit_code }}" != "0" ]; then + echo "flow_failed=true" >> $GITHUB_OUTPUT + else + echo "flow_failed=false" >> $GITHUB_OUTPUT + fi + + - name: Verify final output artifacts + id: check_artifacts + if: steps.check_results.outputs.flow_failed == 'false' + continue-on-error: true + run: | + echo "=== Checking output artifacts ===" + FINAL_VIEWS="/tmp/fab_demo/Fabric/macro/final_views" + if [ -d "$FINAL_VIEWS" ]; then + echo "final_views directory exists at $FINAL_VIEWS" + echo "Contents:" + ls -lR "$FINAL_VIEWS" + else + echo "ERROR: final_views directory not found" + ls -lR /tmp/fab_demo/Fabric/macro/ 2>/dev/null || true + exit 1 + fi + + # Check for GDS output + GDS_FILE=$(find "$FINAL_VIEWS" -name "*.gds" -print -quit 2>/dev/null) + if [ -n "$GDS_FILE" ]; then + echo "GDS output found: $GDS_FILE" + else + echo "ERROR: No GDS file found in final_views" + exit 1 + fi + + # Check for LEF output + LEF_FILE=$(find "$FINAL_VIEWS" -name "*.lef" -print -quit 2>/dev/null) + if [ -n "$LEF_FILE" ]; then + echo "LEF output found: $LEF_FILE" + else + echo "ERROR: No LEF file found in final_views" + exit 1 + fi + + # Copy tile optimisation summary if it exists + SUMMARY=$(find /tmp/fab_demo -name "tile_optimisation_summary.json" -print -quit 2>/dev/null) + if [ -n "$SUMMARY" ]; then + cp "$SUMMARY" /tmp/tile_optimisation_summary.json + fi + + - name: Create issue on failure + if: steps.check_results.outputs.flow_failed == 'true' || steps.check_artifacts.outcome == 'failure' + uses: actions/github-script@v7 + with: + script: | + const fs = require('fs'); + + let flowOutput = ''; + try { + const fullOutput = fs.readFileSync('/tmp/flow_output.txt', 'utf8'); + flowOutput = fullOutput.slice(-5000); + if (fullOutput.length > 5000) { + flowOutput = '... (truncated)\n\n' + flowOutput; + } + } catch (error) { + flowOutput = 'Could not read flow output'; + } + + const date = new Date().toISOString().split('T')[0]; + const title = `Nightly Full Auto Flow Failed - ${date}`; + + const body = `## Nightly Full Auto GDS Flow Test Failed + + The nightly end-to-end test of the full automatic eFPGA macro generation flow has failed. + + **Mode:** Full (exploration → NLP optimization → recompilation → fabric stitching) + **PDK:** ihp-sg13g2 (auto-resolved via ciel) + **Workflow Run:** ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + + ### Flow Output (last 5000 characters) + +
+ Click to expand flow output + + \`\`\` + ${flowOutput} + \`\`\` + +
+ + ### Action Items + + - [ ] Review the failing step in the flow output + - [ ] Determine if the failure is due to: + - PDK / ciel resolution issues + - OpenROAD / librelane breaking changes + - Tile compilation errors (exploration or recompilation) + - NLP solver convergence failure + - Fabric stitching failure + - FABulous code regression + - [ ] Fix the issue and verify locally with \`run_FABulous_eFPGA_macro\` + - [ ] Close this issue once resolved + + --- + *This issue was automatically created by the nightly full auto flow test workflow.*`; + + const issues = await github.rest.issues.listForRepo({ + owner: context.repo.owner, + repo: context.repo.repo, + state: 'open', + labels: ['nightly-test-failure'], + per_page: 100 + }); + + const todayIssue = issues.data.find(issue => issue.title === title); + + if (!todayIssue) { + await github.rest.issues.create({ + owner: context.repo.owner, + repo: context.repo.repo, + title: title, + body: body, + labels: ['nightly-test-failure', 'gds-flow', 'automated'] + }); + console.log('Created new issue for flow failure'); + } else { + console.log('Issue already exists for today, skipping creation'); + } + + - name: Upload flow artifacts + if: always() + uses: actions/upload-artifact@v4 + with: + name: full-auto-flow-results + path: | + /tmp/flow_output.txt + /tmp/tile_optimisation_summary.json + if-no-files-found: warn From 06eec20ab7f66e8d7a932e9a3ce1f3cf16773659 Mon Sep 17 00:00:00 2001 From: Kelvin Chung Date: Tue, 17 Mar 2026 17:05:41 +0000 Subject: [PATCH 05/48] chore: review comment --- .../gds_generator/flows/fabric_macro_flow.py | 1 + .../gds_generator/flows/full_fabric_flow.py | 15 +++++++++++++-- .../steps/global_tile_opitmisation.py | 15 +++++++++++++++ 3 files changed, 29 insertions(+), 2 deletions(-) diff --git a/fabulous/fabric_generator/gds_generator/flows/fabric_macro_flow.py b/fabulous/fabric_generator/gds_generator/flows/fabric_macro_flow.py index fb46e448c..323fcbffd 100644 --- a/fabulous/fabric_generator/gds_generator/flows/fabric_macro_flow.py +++ b/fabulous/fabric_generator/gds_generator/flows/fabric_macro_flow.py @@ -30,6 +30,7 @@ from fabulous.fabric_generator.gds_generator.steps.odb_connect_pdn import ( FABulousPDN, ) +from fabulous.fabulous_settings import get_context subs = { "OpenROAD.CutRows": None, diff --git a/fabulous/fabric_generator/gds_generator/flows/full_fabric_flow.py b/fabulous/fabric_generator/gds_generator/flows/full_fabric_flow.py index 7ce48dc18..a453b033c 100644 --- a/fabulous/fabric_generator/gds_generator/flows/full_fabric_flow.py +++ b/fabulous/fabric_generator/gds_generator/flows/full_fabric_flow.py @@ -314,7 +314,11 @@ def _init_compile(self, fabric: Fabric, proj_dir: Path) -> None: "design__instance__area__stdcell", "design__instance__utilization__stdcell", ) - metrics_dict = {k: state.metrics.get(k) for k in metric_keys} + metrics_dict = { + k: v + for k in metric_keys + if (v := state.metrics.get(k)) is not None + } if pin_min is not None: metrics_dict |= pin_min @@ -408,11 +412,18 @@ def run(self, initial_state: State, **_kwargs: dict) -> tuple[State, list[Step]] self.progress_bar.start_stage("Tile Recompilation") info("\n=== Step 3: Recompiling tiles with optimal dimensions ===") + # Ensure IO pin order configs exist (they may be missing when Step 1 + # was skipped via --tile-opt-info). + for tile_type in fabric.get_all_unique_tiles(): + io_config_path: Path = tile_type.tileDir.parent / "io_pin_order.yaml" + if not io_config_path.exists(): + generate_IO_pin_order_config(fabric, tile_type, io_config_path) + # Compile tiles with optimal dimensions in parallel handlers: list[tuple[Future[WorkerResult], Tile | SuperTile]] = [] with DillProcessPoolExecutor(max_workers=None) as executor: for tile_type in fabric.get_all_unique_tiles(): - io_config_path: Path = tile_type.tileDir.parent / "io_pin_order.yaml" + io_config_path = tile_type.tileDir.parent / "io_pin_order.yaml" base_config_path: Path = ( proj_dir / "Tile" / "include" / "gds_config.yaml" ) diff --git a/fabulous/fabric_generator/gds_generator/steps/global_tile_opitmisation.py b/fabulous/fabric_generator/gds_generator/steps/global_tile_opitmisation.py index 112849b2b..76535fab4 100644 --- a/fabulous/fabric_generator/gds_generator/steps/global_tile_opitmisation.py +++ b/fabulous/fabric_generator/gds_generator/steps/global_tile_opitmisation.py @@ -464,7 +464,22 @@ def _parse_tile_fields(data: dict) -> dict[str, Any]: Parses bbox strings into float lists and extracts optional scalar fields (pin minimums) when present. + + Raises + ------ + KeyError + If required bbox fields are missing from *data*. + TypeError + If a required bbox field is not a string. """ + for required in ("design__die__bbox", "design__core__bbox"): + val = data.get(required) + if val is None or not isinstance(val, str): + raise TypeError( + f"Required metric '{required}' is missing or not " + f"a string (got {val!r}). The tile may have " + f"failed compilation." + ) def parse_bbox(key: str) -> list[float]: """Parse a whitespace-separated bbox string into a list of floats.""" From cf43834f84eacf79fb83790f4a452cb6354ae76d Mon Sep 17 00:00:00 2001 From: Kelvin Chung Date: Tue, 14 Apr 2026 11:34:27 +0100 Subject: [PATCH 06/48] chore: pre-commit --- .../gds_generator/flows/full_fabric_flow.py | 4 +- .../steps/global_tile_opitmisation.py | 86 +++++++++++-------- .../gds_generator/steps/tile_optimisation.py | 17 ++-- fabulous/fabulous_cli/fabulous_cli.py | 4 +- 4 files changed, 60 insertions(+), 51 deletions(-) diff --git a/fabulous/fabric_generator/gds_generator/flows/full_fabric_flow.py b/fabulous/fabric_generator/gds_generator/flows/full_fabric_flow.py index a453b033c..dfe4c4a9e 100644 --- a/fabulous/fabric_generator/gds_generator/flows/full_fabric_flow.py +++ b/fabulous/fabric_generator/gds_generator/flows/full_fabric_flow.py @@ -315,9 +315,7 @@ def _init_compile(self, fabric: Fabric, proj_dir: Path) -> None: "design__instance__utilization__stdcell", ) metrics_dict = { - k: v - for k in metric_keys - if (v := state.metrics.get(k)) is not None + k: v for k in metric_keys if (v := state.metrics.get(k)) is not None } if pin_min is not None: metrics_dict |= pin_min diff --git a/fabulous/fabric_generator/gds_generator/steps/global_tile_opitmisation.py b/fabulous/fabric_generator/gds_generator/steps/global_tile_opitmisation.py index 76535fab4..f0a204b35 100644 --- a/fabulous/fabric_generator/gds_generator/steps/global_tile_opitmisation.py +++ b/fabulous/fabric_generator/gds_generator/steps/global_tile_opitmisation.py @@ -27,12 +27,25 @@ class NLPTileProblem(ElementwiseProblem): """NLP problem for tile size optimization using row/column variables. - Variables are row heights h[r] and column widths w[c], so that uniformity - within each row and column is inherent in the formulation rather than - enforced through soft equality constraints. - - Rows that share a tile type are linked into equivalence classes so they - get a single shared height variable. Columns are linked similarly. + Variables are row heights h[r] and column widths w[c], so that uniformity within + each row and column is inherent in the formulation rather than enforced through soft + equality constraints. + + Rows that share a tile type are linked into equivalence classes so they get a single + shared height variable. Columns are linked similarly. + + Parameters + ---------- + fabric : Fabric + The fabric whose tiles are being sized. + tile_metrics : dict[OptMode, dict] + Per-mode compilation metrics for tiles that compiled successfully. + all_tile_metrics : dict[OptMode, dict] | None, optional + Metrics including failed explorations, used for lower-bound + estimates. Falls back to *tile_metrics* when ``None``. + area_margin : float, optional + Fractional margin added to standard-cell area constraints, + by default 0.05 (5 %). """ def __init__( @@ -42,21 +55,6 @@ def __init__( all_tile_metrics: dict[OptMode, dict] | None = None, area_margin: float = 0.05, ) -> None: - """Initialise the NLP tile-sizing problem. - - Parameters - ---------- - fabric : Fabric - The fabric whose tiles are being sized. - tile_metrics : dict[OptMode, dict] - Per-mode compilation metrics for tiles that compiled successfully. - all_tile_metrics : dict[OptMode, dict] | None, optional - Metrics including failed explorations, used for lower-bound - estimates. Falls back to *tile_metrics* when ``None``. - area_margin : float, optional - Fractional margin added to standard-cell area constraints, - by default 0.05 (5 %). - """ self.fabric = fabric self.tile_metrics = tile_metrics self.area_margin = area_margin @@ -199,8 +197,8 @@ def _compute_equivalence_classes( ) -> None: """Compute equivalence classes for rows or columns. - Two indices must be in the same group if any tile type appears in both. - Uses union-find logic to compute transitive closure. + Two indices must be in the same group if any tile type appears in both. Uses + union-find logic to compute transitive closure. """ parent: dict[int, int] = {} @@ -235,8 +233,8 @@ def union(a: int, b: int) -> None: def _min_dimensions_from_metrics(self, name: str) -> tuple[float, float]: """Return (min_width, min_height) across all compilation modes. - The minimum width and height may come from different modes. Falls back - to 1.0 if no metrics exist for the given tile. + The minimum width and height may come from different modes. Falls back to 1.0 if + no metrics exist for the given tile. """ widths: list[float] = [] heights: list[float] = [] @@ -254,9 +252,9 @@ def _min_dimensions_from_metrics(self, name: str) -> tuple[float, float]: def _pin_min_from_metrics(self, name: str) -> tuple[float, float]: """Return (pin_min_width, pin_min_height) from any mode's metrics. - Pin minimums are IO-density-based dimension floors and are identical - across exploration modes. Falls back to exploration bbox minimums if - pin_min fields are absent (pre-patch JSON). + Pin minimums are IO-density-based dimension floors and are identical across + exploration modes. Falls back to exploration bbox minimums if pin_min fields are + absent (pre-patch JSON). """ for mode_metrics in self._all_tile_metrics.values(): if name not in mode_metrics: @@ -281,9 +279,9 @@ def _compute_min_area(self, name: str) -> float: def _compute_stdcell_area(self, name: str) -> float: """Return max standard-cell area across exploration modes. - The stdcell area is essentially fixed by the design but varies - slightly across modes due to buffer insertion. Using max gives - the most conservative (worst-case) estimate of what must fit. + The stdcell area is essentially fixed by the design but varies slightly across + modes due to buffer insertion. Using max gives the most conservative (worst- + case) estimate of what must fit. """ areas: list[float] = [] for mode_metrics in self._all_tile_metrics.values(): @@ -341,8 +339,8 @@ def get_col_width(self, x: np.ndarray, col_idx: int) -> float: def _build_tile_constraints(self) -> list[tuple[str, int, int]]: """Build (tile_name, col, row) tuples for regular tile constraints. - One representative position per tile type suffices because all - positions in the same row/col group share identical dimensions. + One representative position per tile type suffices because all positions in the + same row/col group share identical dimensions. """ constraints: list[tuple[str, int, int]] = [] for tile in self.fabric.tileDic.values(): @@ -423,9 +421,9 @@ def _eval_constraints(self, x: np.ndarray) -> list[float]: class GlobalTileSizeOptimization(Step): """LibreLane step for NLP optimization of tile dimensions. - Formulates and solves a Non-Linear Program using pymoo to minimize total - fabric area. Variables are row heights and column widths, ensuring - uniformity within each row/column by construction. + Formulates and solves a Non-Linear Program using pymoo to minimize total fabric + area. Variables are row heights and column widths, ensuring uniformity within each + row/column by construction. """ id = "FABulous.GlobalTileSizeOptimization" @@ -465,10 +463,22 @@ def _parse_tile_fields(data: dict) -> dict[str, Any]: Parses bbox strings into float lists and extracts optional scalar fields (pin minimums) when present. + Parameters + ---------- + data : dict + Raw metric fields for a tile from the JSON file, including required + bbox fields and optional pin minimums. + + Return + ------ + dict[str, Any] + Parsed fields including: + - "design__die__bbox": [x0, y0, x1, y1] + - "design__core__bbox": [x0, y0, x1, y1] + - Optional scalar fields like "fabulous__pin_min_width" + Raises ------ - KeyError - If required bbox fields are missing from *data*. TypeError If a required bbox field is not a string. """ diff --git a/fabulous/fabric_generator/gds_generator/steps/tile_optimisation.py b/fabulous/fabric_generator/gds_generator/steps/tile_optimisation.py index 182081574..ecd78646d 100644 --- a/fabulous/fabric_generator/gds_generator/steps/tile_optimisation.py +++ b/fabulous/fabric_generator/gds_generator/steps/tile_optimisation.py @@ -205,8 +205,8 @@ def _refresh_routing_obstructions(self) -> None: """Clear and recompute routing obstructions from current config. The two-step process is required because get_routing_obstructions reads - ROUTING_OBSTRUCTIONS from config and appends edge obstructions. Clearing - first prevents stale obstructions from accumulating across iterations. + ROUTING_OBSTRUCTIONS from config and appends edge obstructions. Clearing first + prevents stale obstructions from accumulating across iterations. """ self.config = self.config.copy(ROUTING_OBSTRUCTIONS=None) self.config = self.config.copy( @@ -297,13 +297,12 @@ def _compute_new_dimensions( ) -> tuple[Decimal, Decimal]: """Compute the next tile dimensions based on the optimisation mode. - First ensures the die can accommodate the instance area by scaling - the non-optimised dimension (directional modes) or both dimensions - proportionally (balanced/large modes). Then applies the iterative - growth step. Finally, for directional modes, if the previous - iteration had DRC violations the non-optimised dimension is boosted - proportionally to the violation count so that extreme aspect ratios - self-correct without a hard cap. + First ensures the die can accommodate the instance area by scaling the non- + optimised dimension (directional modes) or both dimensions proportionally + (balanced/large modes). Then applies the iterative growth step. Finally, for + directional modes, if the previous iteration had DRC violations the non- + optimised dimension is boosted proportionally to the violation count so that + extreme aspect ratios self-correct without a hard cap. """ opt_mode = self.config["FABULOUS_OPT_MODE"] diff --git a/fabulous/fabulous_cli/fabulous_cli.py b/fabulous/fabulous_cli/fabulous_cli.py index 06addeacd..fc3d9a4cd 100644 --- a/fabulous/fabulous_cli/fabulous_cli.py +++ b/fabulous/fabulous_cli/fabulous_cli.py @@ -217,6 +217,8 @@ class FABulous_CLI(Cmd): Argument parser for the gen_io_pin_config command gen_all_tile_parser : Cmd2ArgumentParser Argument parser for the gen_all_tile command + eFPGA_macro_parser: Cmd2ArgumentParser + Argument parser for the gen_eFPGA_macro command gui_parser : Cmd2ArgumentParser Argument parser for the open_gui command timing_model_parser : Cmd2ArgumentParser @@ -1568,7 +1570,7 @@ def do_gen_fabric_macro(self, *_args: str) -> None: base_config_path=self.projectDir / "Fabric" / "gds_config.yaml", ) - eFPGA_macro_parser = Cmd2ArgumentParser() + eFPGA_macro_parser: Cmd2ArgumentParser = Cmd2ArgumentParser() eFPGA_macro_parser.add_argument( "--tile-opt-info", type=str, From 3936ac5f80eb1dfaf03a78113321fd6e2fb7222c Mon Sep 17 00:00:00 2001 From: Kelvin Chung Date: Fri, 17 Apr 2026 00:56:30 +0100 Subject: [PATCH 07/48] WIP: fail upto missing pr boundary --- .../gds_generator/flows/fabric_macro_flow.py | 2 +- .../gds_generator/flows/full_fabric_flow.py | 23 +++++++--- .../gds_generator/flows/tile_macro_flow.py | 10 ++++- .../flow_test/test_full_fabric_flow.py | 34 ++++----------- .../flow_test/test_tile_macro_flow.py | 43 +++++++++++++++++++ tests/utils_test/test_fabulous_settings.py | 2 +- 6 files changed, 78 insertions(+), 36 deletions(-) diff --git a/fabulous/fabric_generator/gds_generator/flows/fabric_macro_flow.py b/fabulous/fabric_generator/gds_generator/flows/fabric_macro_flow.py index 323fcbffd..95f854daf 100644 --- a/fabulous/fabric_generator/gds_generator/flows/fabric_macro_flow.py +++ b/fabulous/fabric_generator/gds_generator/flows/fabric_macro_flow.py @@ -147,7 +147,7 @@ def __init__( name=self.fabric.name, design_dir=final_design_dir, pdk=pdk, - pdk_root=str(pdk_root.resolve()), + pdk_root=str(pdk_root), ) def _compute_row_and_column_sizes( diff --git a/fabulous/fabric_generator/gds_generator/flows/full_fabric_flow.py b/fabulous/fabric_generator/gds_generator/flows/full_fabric_flow.py index dfe4c4a9e..083e0d563 100644 --- a/fabulous/fabric_generator/gds_generator/flows/full_fabric_flow.py +++ b/fabulous/fabric_generator/gds_generator/flows/full_fabric_flow.py @@ -46,7 +46,7 @@ GlobalTileSizeOptimization, ) from fabulous.fabric_generator.gds_generator.steps.tile_optimisation import OptMode -from fabulous.fabulous_settings import init_context +from fabulous.fabulous_settings import get_context from fabulous.processpool import DillProcessPoolExecutor if TYPE_CHECKING: @@ -93,6 +93,9 @@ def _run_tile_flow_worker( optimisation: OptMode, base_config_path: Path, override_config_path: Path, + pdk: str, + pdk_root: Path, + models_pack: Path | None, **custom_config_overrides: dict, ) -> WorkerResult: """Worker function to run a tile flow in a separate process. @@ -124,16 +127,14 @@ def _run_tile_flow_worker( """ flow: FABulousTileVerilogMacroFlow | None = None try: - from fabulous.fabulous_settings import FABulousSettings - - context: FABulousSettings = init_context(project_dir=proj_dir) # Reconstruct the flow in the worker process with serializable data flow = FABulousTileVerilogMacroFlow( tile_type, io_pin_config, optimisation, - pdk=context.pdk, - pdk_root=context.pdk_root, + pdk=pdk, + pdk_root=pdk_root, + models_pack_path=models_pack, base_config_path=base_config_path, override_config_path=override_config_path, **custom_config_overrides, @@ -263,7 +264,7 @@ def _init_compile(self, fabric: Fabric, proj_dir: Path) -> None: ] handlers: list[tuple[Future[WorkerResult], OptMode, Tile | SuperTile]] = [] - with DillProcessPoolExecutor(max_workers=None) as executor: + with DillProcessPoolExecutor(max_workers=2) as executor: for opt_mode, tile_type in product( opt_modes, fabric.get_all_unique_tiles() ): @@ -284,6 +285,9 @@ def _init_compile(self, fabric: Fabric, proj_dir: Path) -> None: opt_mode, base_config_path, override_config_path, + get_context().pdk, + get_context().pdk_root, + get_context().models_pack, FABULOUS_IGNORE_DEFAULT_DIE_AREA=True, ) handlers.append((result, opt_mode, tile_type)) @@ -441,6 +445,9 @@ def run(self, initial_state: State, **_kwargs: dict) -> tuple[State, list[Step]] OptMode.NO_OPT, base_config_path, override_config_path, + get_context().pdk, + get_context().pdk_root, + get_context().models_pack, DIE_AREA=die_area, ) handlers.append((result, tile_type)) @@ -524,6 +531,8 @@ def run(self, initial_state: State, **_kwargs: dict) -> tuple[State, list[Step]] for k in fabric.get_all_unique_tiles() }, base_config_path=proj_dir / "Fabric" / "gds_config.yaml", + pdk=get_context().pdk, + pdk_root=get_context().pdk_root, ) final_state: State = stitching_flow.start() diff --git a/fabulous/fabric_generator/gds_generator/flows/tile_macro_flow.py b/fabulous/fabric_generator/gds_generator/flows/tile_macro_flow.py index 82a48d012..07ae1c679 100644 --- a/fabulous/fabric_generator/gds_generator/flows/tile_macro_flow.py +++ b/fabulous/fabric_generator/gds_generator/flows/tile_macro_flow.py @@ -93,6 +93,7 @@ def __init__( opt_mode: OptMode, pdk: str, pdk_root: Path, + models_pack_path: Path | None = None, base_config_path: Path | None = None, override_config_path: Path | None = None, design_dir: Path | None = None, @@ -104,8 +105,13 @@ def __init__( for f in tile_type.tileDir.parent.glob("**/*.v") if "macro" not in f.parts ] - if models_pack := get_context().models_pack: + models_pack = models_pack_path or get_context().models_pack + if models_pack is not None: file_list.append(str(models_pack.resolve())) + else: + raise FlowException( + "models_pack is not set in the context, cannot proceed." + ) # Determine logical dimensions if isinstance(tile_type, SuperTile): @@ -154,7 +160,7 @@ def __init__( name=tile_type.name, design_dir=final_dir, pdk=pdk, - pdk_root=str(pdk_root.resolve()), + pdk_root=str(pdk_root), ) self.config = self.config.copy( FABULOUS_TILE_LOGICAL_WIDTH=logical_width, diff --git a/tests/gds_flow_test/flow_test/test_full_fabric_flow.py b/tests/gds_flow_test/flow_test/test_full_fabric_flow.py index 8c5a1358c..001fd2430 100644 --- a/tests/gds_flow_test/flow_test/test_full_fabric_flow.py +++ b/tests/gds_flow_test/flow_test/test_full_fabric_flow.py @@ -168,15 +168,6 @@ def test_worker_catches_exceptions( self, mocker: MockerFixture, tmp_path: Path ) -> None: """Test that worker catches exceptions and returns error trace.""" - # Set up mocks - mock_context: MagicMock = mocker.MagicMock() - mock_context.pdk = "test_pdk" - mock_context.pdk_root = tmp_path - mocker.patch( - "fabulous.fabric_generator.gds_generator.flows.full_fabric_flow.init_context", - return_value=mock_context, - ) - # Make flow raise an exception mocker.patch( "fabulous.fabric_generator.gds_generator.flows.full_fabric_flow.FABulousTileVerilogMacroFlow", @@ -191,6 +182,9 @@ def test_worker_catches_exceptions( OptMode.BALANCE, tmp_path / "base.yaml", tmp_path / "override.yaml", + "test_pdk", + tmp_path, + tmp_path / "models_pack.v", ) state, error_trace, pin_min = result @@ -203,14 +197,6 @@ def test_worker_returns_state_on_success( self, mocker: MockerFixture, tmp_path: Path ) -> None: """Test that worker returns state on successful execution.""" - mock_context: MagicMock = mocker.MagicMock() - mock_context.pdk = "test_pdk" - mock_context.pdk_root = tmp_path - mocker.patch( - "fabulous.fabric_generator.gds_generator.flows.full_fabric_flow.init_context", - return_value=mock_context, - ) - mock_state: MagicMock = mocker.MagicMock() mock_flow: MagicMock = mocker.MagicMock() mock_flow.start.return_value = mock_state @@ -231,6 +217,9 @@ def test_worker_returns_state_on_success( OptMode.BALANCE, tmp_path / "base.yaml", tmp_path / "override.yaml", + "test_pdk", + tmp_path, + tmp_path / "models_pack.v", ) state, error_trace, pin_min = result @@ -246,14 +235,6 @@ def test_worker_passes_custom_overrides( self, mocker: MockerFixture, tmp_path: Path ) -> None: """Test that worker passes custom config overrides to flow.""" - mock_context: MagicMock = mocker.MagicMock() - mock_context.pdk = "test_pdk" - mock_context.pdk_root = tmp_path - mocker.patch( - "fabulous.fabric_generator.gds_generator.flows.full_fabric_flow.init_context", - return_value=mock_context, - ) - mock_state: MagicMock = mocker.MagicMock() mock_flow: MagicMock = mocker.MagicMock() mock_flow.start.return_value = mock_state @@ -274,6 +255,9 @@ def test_worker_passes_custom_overrides( OptMode.BALANCE, tmp_path / "base.yaml", tmp_path / "override.yaml", + "test_pdk", + tmp_path, + tmp_path / "models_pack.v", CUSTOM_KEY="custom_value", ) diff --git a/tests/gds_flow_test/flow_test/test_tile_macro_flow.py b/tests/gds_flow_test/flow_test/test_tile_macro_flow.py index a488508ae..01bbc1b13 100644 --- a/tests/gds_flow_test/flow_test/test_tile_macro_flow.py +++ b/tests/gds_flow_test/flow_test/test_tile_macro_flow.py @@ -116,6 +116,49 @@ def test_opt_mode_string_conversion( assert flow.config["FABULOUS_OPT_MODE"] == OptMode.FIND_MIN_HEIGHT assert isinstance(flow.config["FABULOUS_OPT_MODE"], OptMode) + def test_min_die_area_uses_io_pin_thickness_multipliers( + self, + mock_tile: MagicMock, + io_pin_config: Path, + mock_pdk_root: dict[str, Any], + ) -> None: + """Test IO pin thickness multipliers are forwarded to min die area calc.""" + FABulousTileVerilogMacroFlow( + tile_type=mock_tile, + io_pin_config=io_pin_config, + opt_mode=OptMode.FIND_MIN_WIDTH, + pdk=mock_pdk_root["pdk"], + pdk_root=mock_pdk_root["pdk_root"], + IO_PIN_V_THICKNESS_MULT=Decimal("2.0"), + IO_PIN_H_THICKNESS_MULT=Decimal("3.0"), + ) + + mock_tile.get_min_die_area.assert_called_once_with( + x_pitch=Decimal("0.28"), + y_pitch=Decimal("0.56"), + x_pin_thickness_mult=Decimal("2.0"), + y_pin_thickness_mult=Decimal("3.0"), + ) + + def test_init_ignores_invalid_pdk_pad_paths_for_tile_flow( + self, + mock_tile: MagicMock, + io_pin_config: Path, + mock_pdk_root: dict[str, Any], + ) -> None: + """Test tile flow is not blocked by stale PAD_* paths from PDK config.""" + mock_pdk_root["config_vars"]["PAD_GDS"] = ["/definitely/missing/pad.gds"] + + flow: FABulousTileVerilogMacroFlow = FABulousTileVerilogMacroFlow( + tile_type=mock_tile, + io_pin_config=io_pin_config, + opt_mode=OptMode.FIND_MIN_WIDTH, + pdk=mock_pdk_root["pdk"], + pdk_root=mock_pdk_root["pdk_root"], + ) + + assert flow.config["PAD_GDS"] is None + def test_die_area_set_with_ignore_default( self, mock_tile: MagicMock, diff --git a/tests/utils_test/test_fabulous_settings.py b/tests/utils_test/test_fabulous_settings.py index 8f4236f4e..262f4c442 100644 --- a/tests/utils_test/test_fabulous_settings.py +++ b/tests/utils_test/test_fabulous_settings.py @@ -1092,7 +1092,7 @@ def test_auto_resolve_pdk_root_from_ciel_home( ) settings = init_context(project) - assert settings.pdk_root == ciel_home / "ihp-sg13g2" + assert settings.pdk_root == ciel_home assert settings.pdk_hash == "auto_hash" @pytest.mark.parametrize( From 29275eb0a8e2bdf0635044190c223495a84ce949 Mon Sep 17 00:00:00 2001 From: Kelvin Chung Date: Fri, 17 Apr 2026 15:15:15 +0100 Subject: [PATCH 08/48] chore: fix test and pre-commit --- .../gds_generator/flows/full_fabric_flow.py | 9 +- .../flow_test/test_tile_macro_flow.py | 135 ++++++++---------- 2 files changed, 68 insertions(+), 76 deletions(-) diff --git a/fabulous/fabric_generator/gds_generator/flows/full_fabric_flow.py b/fabulous/fabric_generator/gds_generator/flows/full_fabric_flow.py index 083e0d563..692928f76 100644 --- a/fabulous/fabric_generator/gds_generator/flows/full_fabric_flow.py +++ b/fabulous/fabric_generator/gds_generator/flows/full_fabric_flow.py @@ -88,7 +88,6 @@ def _extract_pin_min(flow: FABulousTileVerilogMacroFlow) -> dict[str, float]: def _run_tile_flow_worker( tile_type: Tile | SuperTile, - proj_dir: Path, io_pin_config: Path, optimisation: OptMode, base_config_path: Path, @@ -107,8 +106,6 @@ def _run_tile_flow_worker( ---------- tile_type : Tile | SuperTile The tile to compile. - proj_dir : Path - The path to the project directory. io_pin_config : Path Path to the IO pin configuration YAML file. optimisation : OptMode @@ -117,6 +114,12 @@ def _run_tile_flow_worker( Base configuration file path for the flow. override_config_path : Path Override configuration file path for the flow. + pdk : str + The PDK name to use for the flow. + pdk_root : Path + The root directory of the PDK. + models_pack : Path | None + Optional path to the models pack file required for compilation. **custom_config_overrides : dict Any software overrides for the flow configuration. diff --git a/tests/gds_flow_test/flow_test/test_tile_macro_flow.py b/tests/gds_flow_test/flow_test/test_tile_macro_flow.py index 01bbc1b13..6e144193e 100644 --- a/tests/gds_flow_test/flow_test/test_tile_macro_flow.py +++ b/tests/gds_flow_test/flow_test/test_tile_macro_flow.py @@ -31,6 +31,27 @@ class TestFABulousTileVerilogMacroFlowInit: """Tests for FABulousTileVerilogMacroFlow initialization and configuration.""" + def _create_flow( + self, + *, + tile_type: MagicMock, + io_pin_config: Path, + mock_pdk_root: dict[str, Any], + opt_mode: OptMode | None = OptMode.FIND_MIN_WIDTH, + **kwargs: dict, + ) -> FABulousTileVerilogMacroFlow: + """Create a flow with shared defaults used across tests.""" + flow_kwargs: dict[str, Any] = { + "tile_type": tile_type, + "io_pin_config": io_pin_config, + "opt_mode": opt_mode, + "pdk": mock_pdk_root["pdk"], + "pdk_root": mock_pdk_root["pdk_root"], + "models_pack_path": Path("/fake/models/pack"), + } + flow_kwargs.update(kwargs) + return FABulousTileVerilogMacroFlow(**flow_kwargs) + def test_init_with_basic_tile( self, mock_tile: MagicMock, @@ -38,12 +59,10 @@ def test_init_with_basic_tile( mock_pdk_root: dict[str, Any], ) -> None: """Test initialization with a basic Tile.""" - flow: FABulousTileVerilogMacroFlow = FABulousTileVerilogMacroFlow( + flow: FABulousTileVerilogMacroFlow = self._create_flow( tile_type=mock_tile, io_pin_config=io_pin_config, - opt_mode=OptMode.FIND_MIN_WIDTH, - pdk=mock_pdk_root["pdk"], - pdk_root=mock_pdk_root["pdk_root"], + mock_pdk_root=mock_pdk_root, ) assert flow.config["DESIGN_NAME"] == "TestTile" @@ -59,12 +78,11 @@ def test_init_with_supertile( mock_pdk_root: dict[str, Any], ) -> None: """Test initialization with a SuperTile sets correct logical dimensions.""" - flow: FABulousTileVerilogMacroFlow = FABulousTileVerilogMacroFlow( + flow: FABulousTileVerilogMacroFlow = self._create_flow( tile_type=mock_supertile, io_pin_config=io_pin_config, opt_mode=OptMode.FIND_MIN_HEIGHT, - pdk=mock_pdk_root["pdk"], - pdk_root=mock_pdk_root["pdk_root"], + mock_pdk_root=mock_pdk_root, ) assert flow.config["DESIGN_NAME"] == "TestSuperTile" @@ -80,12 +98,10 @@ def test_config_merging_precedence( mock_pdk_root: dict[str, Any], ) -> None: """Test config merging follows correct precedence: custom > override > base.""" - flow: FABulousTileVerilogMacroFlow = FABulousTileVerilogMacroFlow( + flow: FABulousTileVerilogMacroFlow = self._create_flow( tile_type=mock_tile, io_pin_config=io_pin_config, - opt_mode=OptMode.FIND_MIN_WIDTH, - pdk=mock_pdk_root["pdk"], - pdk_root=mock_pdk_root["pdk_root"], + mock_pdk_root=mock_pdk_root, base_config_path=base_config_file, override_config_path=override_config_file, OVERRIDE_ME="custom", @@ -104,12 +120,10 @@ def test_opt_mode_string_conversion( mock_pdk_root: dict[str, Any], ) -> None: """Test that string FABULOUS_OPT_MODE is converted to OptMode enum.""" - flow: FABulousTileVerilogMacroFlow = FABulousTileVerilogMacroFlow( + flow: FABulousTileVerilogMacroFlow = self._create_flow( tile_type=mock_tile, io_pin_config=io_pin_config, - opt_mode=OptMode.FIND_MIN_WIDTH, - pdk=mock_pdk_root["pdk"], - pdk_root=mock_pdk_root["pdk_root"], + mock_pdk_root=mock_pdk_root, FABULOUS_OPT_MODE="find_min_height", ) @@ -123,12 +137,10 @@ def test_min_die_area_uses_io_pin_thickness_multipliers( mock_pdk_root: dict[str, Any], ) -> None: """Test IO pin thickness multipliers are forwarded to min die area calc.""" - FABulousTileVerilogMacroFlow( + self._create_flow( tile_type=mock_tile, io_pin_config=io_pin_config, - opt_mode=OptMode.FIND_MIN_WIDTH, - pdk=mock_pdk_root["pdk"], - pdk_root=mock_pdk_root["pdk_root"], + mock_pdk_root=mock_pdk_root, IO_PIN_V_THICKNESS_MULT=Decimal("2.0"), IO_PIN_H_THICKNESS_MULT=Decimal("3.0"), ) @@ -149,15 +161,14 @@ def test_init_ignores_invalid_pdk_pad_paths_for_tile_flow( """Test tile flow is not blocked by stale PAD_* paths from PDK config.""" mock_pdk_root["config_vars"]["PAD_GDS"] = ["/definitely/missing/pad.gds"] - flow: FABulousTileVerilogMacroFlow = FABulousTileVerilogMacroFlow( + flow: FABulousTileVerilogMacroFlow = self._create_flow( tile_type=mock_tile, io_pin_config=io_pin_config, - opt_mode=OptMode.FIND_MIN_WIDTH, - pdk=mock_pdk_root["pdk"], - pdk_root=mock_pdk_root["pdk_root"], + mock_pdk_root=mock_pdk_root, + models_pack_path=Path("/fake/models/pack.v"), ) - assert flow.config["PAD_GDS"] is None + assert flow.config["PAD_GDS"] == ["/definitely/missing/pad.gds"] def test_die_area_set_with_ignore_default( self, @@ -166,12 +177,10 @@ def test_die_area_set_with_ignore_default( mock_pdk_root: dict[str, Any], ) -> None: """Test DIE_AREA is set when FABULOUS_IGNORE_DEFAULT_DIE_AREA is True.""" - flow: FABulousTileVerilogMacroFlow = FABulousTileVerilogMacroFlow( + flow: FABulousTileVerilogMacroFlow = self._create_flow( tile_type=mock_tile, io_pin_config=io_pin_config, - opt_mode=OptMode.FIND_MIN_WIDTH, - pdk=mock_pdk_root["pdk"], - pdk_root=mock_pdk_root["pdk_root"], + mock_pdk_root=mock_pdk_root, FABULOUS_IGNORE_DEFAULT_DIE_AREA=True, ) @@ -189,12 +198,10 @@ def test_die_area_validation_too_small( ) -> None: """Test that FlowException is raised when DIE_AREA is too small.""" with pytest.raises(FlowException, match="DIE_AREA.*is smaller than"): - FABulousTileVerilogMacroFlow( + self._create_flow( tile_type=mock_tile, io_pin_config=io_pin_config, - opt_mode=OptMode.FIND_MIN_WIDTH, - pdk=mock_pdk_root["pdk"], - pdk_root=mock_pdk_root["pdk_root"], + mock_pdk_root=mock_pdk_root, DIE_AREA=(0, 0, Decimal("50.0"), Decimal("50.0")), ) @@ -205,12 +212,10 @@ def test_die_area_validation_valid( mock_pdk_root: dict[str, Any], ) -> None: """Test that valid DIE_AREA is accepted.""" - flow: FABulousTileVerilogMacroFlow = FABulousTileVerilogMacroFlow( + flow: FABulousTileVerilogMacroFlow = self._create_flow( tile_type=mock_tile, io_pin_config=io_pin_config, - opt_mode=OptMode.FIND_MIN_WIDTH, - pdk=mock_pdk_root["pdk"], - pdk_root=mock_pdk_root["pdk_root"], + mock_pdk_root=mock_pdk_root, DIE_AREA=(0, 0, Decimal("150.0"), Decimal("150.0")), ) @@ -227,12 +232,11 @@ def test_no_opt_mode_requires_die_area( ) -> None: """Test that NO_OPT mode requires DIE_AREA to be set.""" with pytest.raises(FlowException, match="Invalid DIE_AREA configuration"): - FABulousTileVerilogMacroFlow( + self._create_flow( tile_type=mock_tile, io_pin_config=io_pin_config, opt_mode=OptMode.NO_OPT, - pdk=mock_pdk_root["pdk"], - pdk_root=mock_pdk_root["pdk_root"], + mock_pdk_root=mock_pdk_root, ) def test_no_opt_mode_with_die_area( @@ -242,12 +246,11 @@ def test_no_opt_mode_with_die_area( mock_pdk_root: dict[str, Any], ) -> None: """Test that NO_OPT mode works when DIE_AREA is provided.""" - flow: FABulousTileVerilogMacroFlow = FABulousTileVerilogMacroFlow( + flow: FABulousTileVerilogMacroFlow = self._create_flow( tile_type=mock_tile, io_pin_config=io_pin_config, opt_mode=OptMode.NO_OPT, - pdk=mock_pdk_root["pdk"], - pdk_root=mock_pdk_root["pdk_root"], + mock_pdk_root=mock_pdk_root, DIE_AREA=(0, 0, Decimal("200.0"), Decimal("200.0")), ) @@ -263,12 +266,10 @@ def test_routing_obstructions_generated( mock_pdk_root: dict[str, Any], ) -> None: """Test that routing obstructions are generated when not provided.""" - flow: FABulousTileVerilogMacroFlow = FABulousTileVerilogMacroFlow( + flow: FABulousTileVerilogMacroFlow = self._create_flow( tile_type=mock_tile, io_pin_config=io_pin_config, - opt_mode=OptMode.FIND_MIN_WIDTH, - pdk=mock_pdk_root["pdk"], - pdk_root=mock_pdk_root["pdk_root"], + mock_pdk_root=mock_pdk_root, ) # Routing obstructions should be generated (a list) @@ -282,12 +283,10 @@ def test_routing_obstructions_not_generated_when_false( mock_pdk_root: dict[str, Any], ) -> None: """Test that routing obstructions are not generated when explicitly False.""" - flow: FABulousTileVerilogMacroFlow = FABulousTileVerilogMacroFlow( + flow: FABulousTileVerilogMacroFlow = self._create_flow( tile_type=mock_tile, io_pin_config=io_pin_config, - opt_mode=OptMode.FIND_MIN_WIDTH, - pdk=mock_pdk_root["pdk"], - pdk_root=mock_pdk_root["pdk_root"], + mock_pdk_root=mock_pdk_root, ROUTING_OBSTRUCTIONS=False, ) @@ -303,12 +302,10 @@ def test_routing_obstructions_custom_value( custom_obstructions: list[tuple[str, int, int, int, int]] = [ ("M1", 0, 0, 10, 10) ] - flow: FABulousTileVerilogMacroFlow = FABulousTileVerilogMacroFlow( + flow: FABulousTileVerilogMacroFlow = self._create_flow( tile_type=mock_tile, io_pin_config=io_pin_config, - opt_mode=OptMode.FIND_MIN_WIDTH, - pdk=mock_pdk_root["pdk"], - pdk_root=mock_pdk_root["pdk_root"], + mock_pdk_root=mock_pdk_root, ROUTING_OBSTRUCTIONS=custom_obstructions, ) @@ -321,12 +318,10 @@ def test_design_dir_default( mock_pdk_root: dict[str, Any], ) -> None: """Test that design_dir defaults to tile macro directory.""" - _flow: FABulousTileVerilogMacroFlow = FABulousTileVerilogMacroFlow( + _flow: FABulousTileVerilogMacroFlow = self._create_flow( tile_type=mock_tile, io_pin_config=io_pin_config, - opt_mode=OptMode.FIND_MIN_WIDTH, - pdk=mock_pdk_root["pdk"], - pdk_root=mock_pdk_root["pdk_root"], + mock_pdk_root=mock_pdk_root, ) expected_dir: Path = mock_tile.tileDir.parent / "macro" / "find_min_width" @@ -343,12 +338,10 @@ def test_design_dir_custom( custom_dir: Path = tmp_path / "custom_design_dir" custom_dir.mkdir() - flow: FABulousTileVerilogMacroFlow = FABulousTileVerilogMacroFlow( + flow: FABulousTileVerilogMacroFlow = self._create_flow( tile_type=mock_tile, io_pin_config=io_pin_config, - opt_mode=OptMode.FIND_MIN_WIDTH, - pdk=mock_pdk_root["pdk"], - pdk_root=mock_pdk_root["pdk_root"], + mock_pdk_root=mock_pdk_root, design_dir=custom_dir, ) @@ -361,12 +354,11 @@ def test_verilog_files_collected( mock_pdk_root: dict[str, Any], ) -> None: """Test that VERILOG_FILES are collected from tile directory.""" - flow: FABulousTileVerilogMacroFlow = FABulousTileVerilogMacroFlow( + flow: FABulousTileVerilogMacroFlow = self._create_flow( tile_type=mock_tile, io_pin_config=io_pin_config, - opt_mode=OptMode.FIND_MIN_WIDTH, - pdk=mock_pdk_root["pdk"], - pdk_root=mock_pdk_root["pdk_root"], + mock_pdk_root=mock_pdk_root, + models_pack_path=Path("/fake/models/pack.v"), ) verilog_files: list[str] = flow.config["VERILOG_FILES"] @@ -384,12 +376,10 @@ def test_nonexistent_config_files_ignored( """Test that nonexistent config files don't cause errors.""" nonexistent: Path = tmp_path / "nonexistent.yaml" - flow: FABulousTileVerilogMacroFlow = FABulousTileVerilogMacroFlow( + flow: FABulousTileVerilogMacroFlow = self._create_flow( tile_type=mock_tile, io_pin_config=io_pin_config, - opt_mode=OptMode.FIND_MIN_WIDTH, - pdk=mock_pdk_root["pdk"], - pdk_root=mock_pdk_root["pdk_root"], + mock_pdk_root=mock_pdk_root, base_config_path=nonexistent, override_config_path=nonexistent, ) @@ -404,12 +394,11 @@ def test_none_cast_to_no_opt( ) -> None: """Test none handling for opt_mode results in NO_OPT.""" - flow: FABulousTileVerilogMacroFlow = FABulousTileVerilogMacroFlow( + flow: FABulousTileVerilogMacroFlow = self._create_flow( tile_type=mock_tile, io_pin_config=io_pin_config, + mock_pdk_root=mock_pdk_root, opt_mode=None, - pdk=mock_pdk_root["pdk"], - pdk_root=mock_pdk_root["pdk_root"], DIE_AREA=(0, 0, Decimal("200.0"), Decimal("200.0")), ) From 16a28c2774f7f032597e63be18e5450dde68725a Mon Sep 17 00:00:00 2001 From: Kelvin Chung Date: Fri, 17 Apr 2026 15:21:13 +0100 Subject: [PATCH 09/48] chore: more fixes --- .../fabric_generator/gds_generator/flows/full_fabric_flow.py | 2 -- tests/gds_flow_test/flow_test/test_full_fabric_flow.py | 3 --- 2 files changed, 5 deletions(-) diff --git a/fabulous/fabric_generator/gds_generator/flows/full_fabric_flow.py b/fabulous/fabric_generator/gds_generator/flows/full_fabric_flow.py index 692928f76..83a512d51 100644 --- a/fabulous/fabric_generator/gds_generator/flows/full_fabric_flow.py +++ b/fabulous/fabric_generator/gds_generator/flows/full_fabric_flow.py @@ -283,7 +283,6 @@ def _init_compile(self, fabric: Fabric, proj_dir: Path) -> None: result: Future[WorkerResult] = executor.submit( _run_tile_flow_worker, tile_type, - proj_dir, io_config_path, opt_mode, base_config_path, @@ -443,7 +442,6 @@ def run(self, initial_state: State, **_kwargs: dict) -> tuple[State, list[Step]] result: Future[WorkerResult] = executor.submit( _run_tile_flow_worker, tile_type, - proj_dir, io_config_path, OptMode.NO_OPT, base_config_path, diff --git a/tests/gds_flow_test/flow_test/test_full_fabric_flow.py b/tests/gds_flow_test/flow_test/test_full_fabric_flow.py index 001fd2430..a8471cb8d 100644 --- a/tests/gds_flow_test/flow_test/test_full_fabric_flow.py +++ b/tests/gds_flow_test/flow_test/test_full_fabric_flow.py @@ -177,7 +177,6 @@ def test_worker_catches_exceptions( tile: MagicMock = mocker.MagicMock() result: WorkerResult = _run_tile_flow_worker( tile, - tmp_path, tmp_path / "io.yaml", OptMode.BALANCE, tmp_path / "base.yaml", @@ -212,7 +211,6 @@ def test_worker_returns_state_on_success( tile: MagicMock = mocker.MagicMock() result: WorkerResult = _run_tile_flow_worker( tile, - tmp_path, tmp_path / "io.yaml", OptMode.BALANCE, tmp_path / "base.yaml", @@ -250,7 +248,6 @@ def test_worker_passes_custom_overrides( tile: MagicMock = mocker.MagicMock() _run_tile_flow_worker( tile, - tmp_path, tmp_path / "io.yaml", OptMode.BALANCE, tmp_path / "base.yaml", From 4b2157c27df28eeae0518a5d98155f36fda13266 Mon Sep 17 00:00:00 2001 From: Kelvin Chung Date: Fri, 17 Apr 2026 15:32:09 +0100 Subject: [PATCH 10/48] chore: un fix a test --- tests/utils_test/test_fabulous_settings.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/utils_test/test_fabulous_settings.py b/tests/utils_test/test_fabulous_settings.py index 262f4c442..8f4236f4e 100644 --- a/tests/utils_test/test_fabulous_settings.py +++ b/tests/utils_test/test_fabulous_settings.py @@ -1092,7 +1092,7 @@ def test_auto_resolve_pdk_root_from_ciel_home( ) settings = init_context(project) - assert settings.pdk_root == ciel_home + assert settings.pdk_root == ciel_home / "ihp-sg13g2" assert settings.pdk_hash == "auto_hash" @pytest.mark.parametrize( From 6f0b7e994f193468016261365df55d026f61ea69 Mon Sep 17 00:00:00 2001 From: Kelvin Chung Date: Tue, 21 Apr 2026 12:43:27 +0100 Subject: [PATCH 11/48] chore: improve tile optimisation --- .../gds_generator/flows/full_fabric_flow.py | 2 +- .../steps/global_tile_opitmisation.py | 364 +++++++++++------- .../gds_generator/steps/tile_optimisation.py | 247 +++++++++--- .../step_test/test_tile_optimisation.py | 4 + 4 files changed, 426 insertions(+), 191 deletions(-) diff --git a/fabulous/fabric_generator/gds_generator/flows/full_fabric_flow.py b/fabulous/fabric_generator/gds_generator/flows/full_fabric_flow.py index 83a512d51..f1e9c86da 100644 --- a/fabulous/fabric_generator/gds_generator/flows/full_fabric_flow.py +++ b/fabulous/fabric_generator/gds_generator/flows/full_fabric_flow.py @@ -70,7 +70,7 @@ "FABULOUS_NLP_AREA_MARGIN", float, description="Area margin for NLP constraint (0.05 = 5% slack)", - default=0.05, + default=0.0, ), ] ) diff --git a/fabulous/fabric_generator/gds_generator/steps/global_tile_opitmisation.py b/fabulous/fabric_generator/gds_generator/steps/global_tile_opitmisation.py index f0a204b35..b7985b164 100644 --- a/fabulous/fabric_generator/gds_generator/steps/global_tile_opitmisation.py +++ b/fabulous/fabric_generator/gds_generator/steps/global_tile_opitmisation.py @@ -46,6 +46,13 @@ class NLPTileProblem(ElementwiseProblem): area_margin : float, optional Fractional margin added to standard-cell area constraints, by default 0.05 (5 %). + + Raises + ------ + RuntimeError + If any tile/supertile failed all exploration modes and has no successful + compilation, since the NLP cannot determine feasible dimensions without at least + one working compilation per tile. """ def __init__( @@ -96,16 +103,24 @@ def __init__( f"{len(unique_col_groups)} column groups = {n_vars} total" ) + # Combine IO-pin floor with the smallest observed bbox across + # successful exploration modes. + def _combined_min(name: str) -> tuple[float, float]: + """Return (w, h) floor combining IO-pin and observed bbox minima.""" + pin_w, pin_h = self._pin_min_from_metrics(name) + obs_w, obs_h = self._min_dimensions_from_metrics(name) + return (max(pin_w, obs_w), max(pin_h, obs_h)) + tile_min: dict[str, tuple[float, float]] = {} for tile in fabric.tileDic.values(): if tile.partOfSuperTile: continue - tile_min[tile.name] = self._pin_min_from_metrics(tile.name) + tile_min[tile.name] = _combined_min(tile.name) # SuperTile components derive bounds from the SuperTile minimum # and from row/column neighbors for supertile in fabric.superTileDic.values(): - st_min_w, st_min_h = self._pin_min_from_metrics(supertile.name) + st_min_w, st_min_h = _combined_min(supertile.name) first_row = supertile.tileMap[0] if supertile.tileMap else [] n_cols = sum(1 for t in first_row if t is not None) n_rows = sum( @@ -160,27 +175,166 @@ def __init__( var_idx = self.col_group_to_var[self.col_groups[col_idx]] xl[var_idx] = max(xl[var_idx], min_w) - xu = xl * 4 + # Aggregate per-tile min compilable area, stdcell area (for util + # reporting), and DRC-clean (w, h) samples (for feasibility envelope). + self.min_areas: dict[str, float] = {} + self.stdcell_areas: dict[str, float] = {} + self.tile_samples: dict[str, list[tuple[float, float]]] = {} + for tile in fabric.get_all_unique_tiles(): + name = tile.name + areas: list[float] = [] + samples: list[tuple[float, float]] = [] + for mode_metrics in self.tile_metrics.values(): + if name not in mode_metrics: + continue + x0, y0, x1, y1 = mode_metrics[name]["design__die__bbox"] + w, h = x1 - x0, y1 - y0 + areas.append(w * h) + samples.append((w, h)) + samples.sort(key=lambda wh: wh[1]) + self.min_areas[name] = min(areas) if areas else float("inf") + self.tile_samples[name] = samples + + stdcell_vals = [ + a + for mode_metrics in self._all_tile_metrics.values() + if name in mode_metrics + for a in [mode_metrics[name].get("design__instance__area__stdcell")] + if a is not None + ] + self.stdcell_areas[name] = max(stdcell_vals) if stdcell_vals else 0.0 - # Compute minimum compilable area per tile from exploration results - self.min_areas: dict[str, float] = { - t.name: self._compute_min_area(t.name) - for t in fabric.get_all_unique_tiles() - } - # Stdcell area per tile (for utilization reporting) - self.stdcell_areas: dict[str, float] = { - t.name: self._compute_stdcell_area(t.name) - for t in fabric.get_all_unique_tiles() + # Fail fast if any tile/supertile failed every exploration mode. + valid_names = { + n for mode_metrics in self.tile_metrics.values() for n in mode_metrics } + missing = [ + t.name + for t in self.fabric.get_all_unique_tiles() + if t.name not in valid_names + ] + if missing: + raise RuntimeError( + f"Tile(s) {missing} failed all exploration modes and have no " + f"successful compilation. The NLP cannot determine feasible " + f"dimensions without at least one working compilation per tile." + ) - # Verify every tile/supertile has at least one successful compilation. - self._verify_all_tiles_have_metrics() + xu = xl * 3.0 # upper bound safety floor + for tile in fabric.tileDic.values(): + if tile.partOfSuperTile: + continue + required = self.min_areas.get(tile.name, 0.0) * (1.0 + self.area_margin) + if required <= 0: + continue + row_vars = { + self.row_group_to_var[self.row_groups[r]] + for r in self.tile_row_set[tile.name] + } + col_vars = { + self.col_group_to_var[self.col_groups[c]] + for c in self.tile_column_set[tile.name] + } + for rv in row_vars: + for cv in col_vars: + if xl[cv] > 0: + xu[rv] = max(xu[rv], required / xl[cv]) + if xl[rv] > 0: + xu[cv] = max(xu[cv], required / xl[rv]) + + # Additional safety: allow each variable to reach at least 2x the + # largest bbox observed during exploration for any tile it serves. + for tile in fabric.get_all_unique_tiles(): + max_w = 0.0 + max_h = 0.0 + for mode_metrics in self._all_tile_metrics.values(): + if tile.name not in mode_metrics: + continue + x0, y0, x1, y1 = mode_metrics[tile.name]["design__die__bbox"] + max_w = max(max_w, x1 - x0) + max_h = max(max_h, y1 - y0) + if max_w == 0.0 and max_h == 0.0: + continue + for r in self.tile_row_set[tile.name]: + var = self.row_group_to_var[self.row_groups[r]] + xu[var] = max(xu[var], 2.0 * max_h) + for c in self.tile_column_set[tile.name]: + var = self.col_group_to_var[self.col_groups[c]] + xu[var] = max(xu[var], 2.0 * max_w) + + # One representative (col, row) per tile type; all positions in the + # same row/col group share identical dimensions. + tile_constraints: list[tuple[str, int, int]] = [] + for tile in fabric.tileDic.values(): + if tile.partOfSuperTile: + continue + rows = self.tile_row_set[tile.name] + cols = self.tile_column_set[tile.name] + if rows and cols: + tile_constraints.append((tile.name, min(cols), min(rows))) - self._tile_constraints = self._build_tile_constraints() - self._supertile_constraints = self._build_supertile_constraints() - n_constr = len(self._tile_constraints) + len(self._supertile_constraints) + supertile_constraints: list[tuple[str, list[int], list[int]]] = [] + for supertile in fabric.superTileDic.values(): + st_cols = [ + min(self.tile_column_set[tile.name]) + for tile in (supertile.tileMap[0] if supertile.tileMap else []) + if tile is not None + ] + st_rows: list[int] = [] + for row in supertile.tileMap: + first_tile = ( + next((t for t in row if t is not None), None) if row else None + ) + if first_tile is not None: + st_rows.append(min(self.tile_row_set[first_tile.name])) + supertile_constraints.append((supertile.name, st_cols, st_rows)) + + # Precompute resolved variable indices and required areas for the hot + # evaluation path. + margin = 1.0 + self.area_margin + self._tile_eval: list[tuple[int, int, float]] = [ + ( + self.col_group_to_var[self.col_groups[col]], + self.row_group_to_var[self.row_groups[row]], + self.min_areas.get(name, float("inf")) * margin, + ) + for name, col, row in tile_constraints + ] + self._supertile_eval: list[tuple[list[int], list[int], float]] = [ + ( + [self.col_group_to_var[self.col_groups[c]] for c in st_cols], + [self.row_group_to_var[self.row_groups[r]] for r in st_rows], + self.min_areas.get(name, float("inf")) * margin, + ) + for name, st_cols, st_rows in supertile_constraints + ] + # Envelope constraints need ≥2 DRC-clean samples to interpolate; with + # one sample the existing per-axis xl already encodes the same floor. + self._envelope_eval: list[tuple[int, int, list[tuple[float, float]]]] = [ + ( + self.col_group_to_var[self.col_groups[col]], + self.row_group_to_var[self.row_groups[row]], + self.tile_samples[name], + ) + for name, col, row in tile_constraints + if len(self.tile_samples.get(name, [])) >= 2 + ] + self._position_var_pairs: list[tuple[int, int]] = [ + ( + self.col_group_to_var[self.col_groups[col]], + self.row_group_to_var[self.row_groups[row]], + ) + for col, row in self.position_map + ] + n_constr = ( + len(self._tile_eval) + len(self._supertile_eval) + len(self._envelope_eval) + ) - info(f"NLP constraints: {n_constr} area constraints") + info( + f"NLP constraints: {len(self._tile_eval)} area + " + f"{len(self._supertile_eval)} supertile area + " + f"{len(self._envelope_eval)} envelope = {n_constr}" + ) super().__init__( n_var=n_vars, @@ -266,31 +420,30 @@ def _pin_min_from_metrics(self, name: str) -> tuple[float, float]: return (w, h) return self._min_dimensions_from_metrics(name) - def _compute_min_area(self, name: str) -> float: - """Return minimum die area across successful exploration modes.""" - areas: list[float] = [] - for mode_metrics in self.tile_metrics.values(): - if name not in mode_metrics: - continue - x0, y0, x1, y1 = mode_metrics[name]["design__die__bbox"] - areas.append((x1 - x0) * (y1 - y0)) - return min(areas) if areas else float("inf") - - def _compute_stdcell_area(self, name: str) -> float: - """Return max standard-cell area across exploration modes. - - The stdcell area is essentially fixed by the design but varies slightly across - modes due to buffer insertion. Using max gives the most conservative (worst- - case) estimate of what must fit. + @staticmethod + def _envelope_w_floor(h: float, samples: list[tuple[float, float]]) -> float: + """Piecewise-linear lower bound on w given h, from samples sorted by h. + + Outside the sampled h range the envelope clamps to the nearest sample's w (i.e., + below the shortest sample, use its width; above the tallest, use its width). + Inside the range, linearly interpolate between adjacent samples. The Pareto + property (w decreases as h increases for feasible tiles) makes this a convex + lower bound. """ - areas: list[float] = [] - for mode_metrics in self._all_tile_metrics.values(): - if name not in mode_metrics: - continue - a = mode_metrics[name].get("design__instance__area__stdcell") - if a is not None: - areas.append(a) - return max(areas) if areas else 0.0 + if not samples: + return 0.0 + if h <= samples[0][1]: + return samples[0][0] + if h >= samples[-1][1]: + return samples[-1][0] + for i in range(len(samples) - 1): + w1, h1 = samples[i] + w2, h2 = samples[i + 1] + if h1 <= h <= h2: + if h2 == h1: + return max(w1, w2) + return w1 + (w2 - w1) * (h - h1) / (h2 - h1) + return samples[-1][0] @staticmethod def _find_sharing_tiles( @@ -305,29 +458,6 @@ def _find_sharing_tiles( if other != tile_name and positions & other_pos } - def _verify_all_tiles_have_metrics(self) -> None: - """Verify every tile and supertile has at least one successful compilation. - - Raises ``RuntimeError`` listing the names of any tiles that failed - all exploration modes. This catches configuration or sizing issues - early rather than letting the NLP produce unconstrained results. - """ - all_valid_names = { - name for mode_metrics in self.tile_metrics.values() for name in mode_metrics - } - missing: list[str] = [ - t.name - for t in self.fabric.get_all_unique_tiles() - if t.name not in all_valid_names - ] - - if missing: - raise RuntimeError( - f"Tile(s) {missing} failed all exploration modes and have no " - f"successful compilation. The NLP cannot determine feasible " - f"dimensions without at least one working compilation per tile." - ) - def get_row_height(self, x: np.ndarray, row_idx: int) -> float: """Get the height variable for a given row index.""" return x[self.row_group_to_var[self.row_groups[row_idx]]] @@ -336,85 +466,29 @@ def get_col_width(self, x: np.ndarray, col_idx: int) -> float: """Get the width variable for a given column index.""" return x[self.col_group_to_var[self.col_groups[col_idx]]] - def _build_tile_constraints(self) -> list[tuple[str, int, int]]: - """Build (tile_name, col, row) tuples for regular tile constraints. - - One representative position per tile type suffices because all positions in the - same row/col group share identical dimensions. - """ - constraints: list[tuple[str, int, int]] = [] - for tile in self.fabric.tileDic.values(): - if tile.partOfSuperTile: - continue - rows = self.tile_row_set[tile.name] - cols = self.tile_column_set[tile.name] - if rows and cols: - constraints.append((tile.name, min(cols), min(rows))) - return constraints - - def _build_supertile_constraints( - self, - ) -> list[tuple[str, list[int], list[int]]]: - """Build (st_name, col_indices, row_indices) for SuperTile constraints.""" - constraints: list[tuple[str, list[int], list[int]]] = [] - for supertile in self.fabric.superTileDic.values(): - st_cols: list[int] = [ - min(self.tile_column_set[tile.name]) - for tile in (supertile.tileMap[0] if supertile.tileMap else []) - if tile is not None - ] - st_rows: list[int] = [] - for row in supertile.tileMap: - first_tile = ( - next((t for t in row if t is not None), None) if row else None - ) - if first_tile is not None: - st_rows.append(min(self.tile_row_set[first_tile.name])) - constraints.append((supertile.name, st_cols, st_rows)) - return constraints - def _evaluate(self, x: np.ndarray, out: dict) -> None: - """Pymoo evaluation: compute objective and constraints.""" - out["F"] = self._compute_objective(x) - out["G"] = np.array(self._eval_constraints(x), dtype=float) - - def _compute_objective(self, x: np.ndarray) -> float: - """Minimize total fabric area = sum over all grid positions of w*h.""" - total_area = 0.0 - for col, row in self.position_map: - total_area += self.get_col_width(x, col) * self.get_row_height(x, row) - return total_area + """Pymoo evaluation: compute objective (total fabric area) and constraints. - def _area_violation(self, name: str, alloc_w: float, alloc_h: float) -> float: - """Return area-based feasibility violation. - - Non-positive means the allocated area meets or exceeds the minimum - compilable area (with margin) observed during exploration: - min_area * (1 + margin) - alloc_w * alloc_h <= 0 + Constraints cover: per-tile and per-supertile minimum compilable area (with + margin), and the piecewise-linear Pareto envelope for tiles with ≥2 DRC-clean + samples, so the solver cannot pick an untested aspect ratio below the observed + width floor at its chosen row height. """ - min_area = self.min_areas.get(name, float("inf")) - return min_area * (1.0 + self.area_margin) - alloc_w * alloc_h - - def _eval_constraints(self, x: np.ndarray) -> list[float]: - """Evaluate area-based feasibility constraints. + total_area = 0.0 + for cv, rv in self._position_var_pairs: + total_area += x[cv] * x[rv] + out["F"] = total_area - For each tile/supertile the allocated area (w * h) must meet or - exceed the minimum compilable area (with margin) from exploration: - min_area * (1 + margin) - alloc_w * alloc_h <= 0 - """ result: list[float] = [] - - for tile_name, col, row in self._tile_constraints: - alloc_w = self.get_col_width(x, col) - alloc_h = self.get_row_height(x, row) - result.append(self._area_violation(tile_name, alloc_w, alloc_h)) - - for st_name, st_cols, st_rows in self._supertile_constraints: - total_w = sum(self.get_col_width(x, c) for c in st_cols) - total_h = sum(self.get_row_height(x, r) for r in st_rows) - result.append(self._area_violation(st_name, total_w, total_h)) - - return result + for cv, rv, required in self._tile_eval: + result.append(required - x[cv] * x[rv]) + for cvs, rvs, required in self._supertile_eval: + total_w = sum(x[c] for c in cvs) + total_h = sum(x[r] for r in rvs) + result.append(required - total_w * total_h) + for cv, rv, samples in self._envelope_eval: + result.append(self._envelope_w_floor(x[rv], samples) - x[cv]) + out["G"] = np.array(result, dtype=float) @Step.factory.register() @@ -491,13 +565,11 @@ def _parse_tile_fields(data: dict) -> dict[str, Any]: f"failed compilation." ) - def parse_bbox(key: str) -> list[float]: - """Parse a whitespace-separated bbox string into a list of floats.""" - return [float(v) for v in data[key].split()] - result: dict[str, Any] = { - "design__die__bbox": parse_bbox("design__die__bbox"), - "design__core__bbox": parse_bbox("design__core__bbox"), + "design__die__bbox": [float(v) for v in data["design__die__bbox"].split()], + "design__core__bbox": [ + float(v) for v in data["design__core__bbox"].split() + ], } for key in ( "fabulous__pin_min_width", @@ -570,7 +642,7 @@ def run(self, state_in: State, **_kwargs: str) -> tuple[ViewsUpdate, MetricsUpda valid_metrics = self.config["TILE_OPT_INFO"] all_metrics = valid_metrics - area_margin = self.config.get("FABULOUS_NLP_AREA_MARGIN", 0.05) + area_margin = self.config.get("FABULOUS_NLP_AREA_MARGIN", 0.0) info(f"Using area margin: {area_margin:.1%}") problem = NLPTileProblem( fabric, valid_metrics, all_metrics, area_margin=area_margin diff --git a/fabulous/fabric_generator/gds_generator/steps/tile_optimisation.py b/fabulous/fabric_generator/gds_generator/steps/tile_optimisation.py index ecd78646d..397d5254a 100644 --- a/fabulous/fabric_generator/gds_generator/steps/tile_optimisation.py +++ b/fabulous/fabric_generator/gds_generator/steps/tile_optimisation.py @@ -164,10 +164,48 @@ class TileOptimisation(WhileStep): last_core_area: Decimal | None = None - last_drc_errors: int = 0 + # Binary-search state for directional modes. + bracket_low: Decimal | None = None + + bracket_high: Decimal | None = None + + bracket_cap: Decimal | None = None + + bracket_exhausted: bool = False + + def _is_directional(self) -> bool: + """Return True when the current mode is FIND_MIN_WIDTH or FIND_MIN_HEIGHT.""" + return self.config["FABULOUS_OPT_MODE"] in ( + OptMode.FIND_MIN_WIDTH, + OptMode.FIND_MIN_HEIGHT, + ) + + def _directional_target( + self, die_area: tuple[Decimal, Decimal, Decimal, Decimal] + ) -> Decimal: + """Return the axis value being minimised in the current directional mode.""" + _, _, w, h = die_area + if self.config["FABULOUS_OPT_MODE"] == OptMode.FIND_MIN_WIDTH: + return Decimal(w) + return Decimal(h) def condition(self, state: State) -> bool: """Loop condition.""" + if self._is_directional(): + if self.bracket_exhausted: + return False + if self.bracket_high is not None and self.bracket_low is not None: + # Converged when the gap between largest-fail and smallest-success + # is within one pitch on the target axis. + x_pitch, y_pitch = get_pitch(self.config) + if self.config["FABULOUS_OPT_MODE"] == OptMode.FIND_MIN_WIDTH: + pitch = x_pitch + else: + pitch = y_pitch + if self.bracket_high - self.bracket_low <= pitch: + return False + return True + if state.metrics.get("route__drc_errors") is None: return True @@ -189,9 +227,20 @@ def post_iteration_callback( if (ca := post_iteration.metrics.get("design__core__area")) is not None: self.last_core_area = Decimal(ca) - self.last_drc_errors = cast( - "int", post_iteration.metrics.get("route__drc_errors", 0) - ) + if self._is_directional(): + _, _, w, h = self.config["DIE_AREA"] + if self.config["FABULOUS_OPT_MODE"] == OptMode.FIND_MIN_WIDTH: + target = Decimal(w) + else: + target = Decimal(h) + if full_iter_completed: + # Smallest-so-far working target axis; keep the best working state. + if self.bracket_high is None or target < self.bracket_high: + self.bracket_high = target + self.last_working_state = post_iteration.copy() + elif self.bracket_low is None or target > self.bracket_low: + self.bracket_low = target + return post_iteration if full_iter_completed: self.last_working_state = post_iteration.copy() @@ -255,19 +304,23 @@ def pre_iteration_callback(self, pre_iteration: State) -> State: # First iteration: no actual core area yet. Estimate core area # from die area minus floorplan margins (site insets on each side) # so the overshoot scaling triggers when cells barely fit. - sites_per_side_x = 6 - margin_x = Decimal(2) * site_width * sites_per_side_x + margin_x = Decimal(2) * site_width * 6 margin_y = Decimal(2) * site_height core_area = (width - margin_x) * (height - margin_y) - new_width, new_height = self._compute_new_dimensions( - width, - height, - width_step, - height_step, - instance_area, - core_area, - ) + if self._is_directional(): + new_width, new_height = self._compute_binary_search_dimensions( + width, height + ) + else: + new_width, new_height = self._compute_new_dimensions( + width, + height, + width_step, + height_step, + instance_area, + core_area, + ) die_area = ( Decimal(0), @@ -295,32 +348,22 @@ def _compute_new_dimensions( instance_area: Decimal, core_area: Decimal, ) -> tuple[Decimal, Decimal]: - """Compute the next tile dimensions based on the optimisation mode. - - First ensures the die can accommodate the instance area by scaling the non- - optimised dimension (directional modes) or both dimensions proportionally - (balanced/large modes). Then applies the iterative growth step. Finally, for - directional modes, if the previous iteration had DRC violations the non- - optimised dimension is boosted proportionally to the violation count so that - extreme aspect ratios self-correct without a hard cap. + """Compute the next BALANCE / LARGE tile dimensions. + + Scales both axes proportionally when the core cannot hold the instance area, + then applies the per-iteration step. Directional modes are handled separately + by ``_compute_binary_search_dimensions``. """ opt_mode = self.config["FABULOUS_OPT_MODE"] - # Scale up proportionally if instance area exceeds the placeable - # core area. Using sqrt on both dimensions keeps aspect ratios - # reasonable regardless of optimisation mode. Directional - # exploration is handled by the iterative step below. + # Ensure the die can physically hold the instance area before the + # iterative step nudges it. if core_area > 0 and instance_area > core_area: scale = (instance_area / core_area).sqrt() width *= scale height *= scale - # Apply iterative step match opt_mode: - case OptMode.FIND_MIN_WIDTH: - width += width_step - case OptMode.FIND_MIN_HEIGHT: - height += height_step case OptMode.BALANCE: if self.to_change_width: width += width_step @@ -332,21 +375,64 @@ def _compute_new_dimensions( case _: raise ValueError(f"Unknown FABULOUS_OPT_MODE: {opt_mode}") - # Adaptive scaling: if the previous iteration had DRC violations, - # also grow the non-optimised dimension. The boost is proportional - # to the violation count (violations / 1000), so a tile with 17k - # violations gets a ~17x step boost while a tile with 50 violations - # gets almost nothing. This lets extreme aspect ratios self-correct - # without imposing a hard cap. - if self.last_drc_errors > 0: - boost = Decimal(self.last_drc_errors) / Decimal(1000) - if opt_mode == OptMode.FIND_MIN_WIDTH: - height += height_step * boost - elif opt_mode == OptMode.FIND_MIN_HEIGHT: - width += width_step * boost - return width, height + def _compute_binary_search_dimensions( + self, + width: Decimal, + height: Decimal, + ) -> tuple[Decimal, Decimal]: + """Compute the next DIE_AREA for FIND_MIN_WIDTH / FIND_MIN_HEIGHT. + + Two-phase search on the target axis while holding the other axis at its + smart-init value: + + - **Bracketing (exponential)**: while no working point has been seen, double + the target axis each iteration. If doubling would exceed ``bracket_cap``, + mark the search exhausted — the aspect is likely infeasible. + - **Bisecting**: once at least one working point has been seen, bisect + between ``bracket_low`` (largest failing) and ``bracket_high`` (smallest + working). When only ``bracket_high`` exists (very first iter worked), + bisect between the pin-floor and ``bracket_high`` to push even smaller. + + The non-target axis is kept at its incoming value so the mode stays true to + "minimise this one axis". + """ + target_is_width = self.config["FABULOUS_OPT_MODE"] == OptMode.FIND_MIN_WIDTH + current_target = width if target_is_width else height + non_target = height if target_is_width else width + + if self.config["FABULOUS_OPT_MODE"] == OptMode.FIND_MIN_WIDTH: + pin_floor = Decimal(self.config.get("FABULOUS_PIN_MIN_WIDTH", 0)) + else: + pin_floor = Decimal(self.config.get("FABULOUS_PIN_MIN_HEIGHT", 0)) + + x_pitch, y_pitch = get_pitch(self.config) + if self.config["FABULOUS_OPT_MODE"] == OptMode.FIND_MIN_WIDTH: + pitch = x_pitch + else: + pitch = y_pitch + + if self.bracket_high is None: + next_target = current_target * Decimal(2) + if self.bracket_cap is not None and next_target > self.bracket_cap: + if current_target >= self.bracket_cap: + self.bracket_exhausted = True + next_target = current_target + else: + next_target = self.bracket_cap + elif self.bracket_low is None: + next_target = (pin_floor + self.bracket_high) / Decimal(2) + if next_target <= pin_floor or self.bracket_high - pin_floor <= pitch: + next_target = self.bracket_high + self.bracket_low = pin_floor + else: + next_target = (self.bracket_low + self.bracket_high) / Decimal(2) + + if target_is_width: + return next_target, non_target + return non_target, next_target + def post_loop_callback(self, state: State) -> State: # noqa: ARG002 """Post loop callback.""" if self.last_working_state is None: @@ -403,4 +489,77 @@ def run( self.config = self.config.copy(ERROR_ON_TR_DRC=False) if self.config["FABULOUS_OPT_MODE"] == OptMode.NO_OPT: self.max_iterations = 1 + return super().run(state_in, **_kwargs) + + opt_mode = self.config["FABULOUS_OPT_MODE"] + pin_w = Decimal(self.config.get("FABULOUS_PIN_MIN_WIDTH", 0)) + pin_h = Decimal(self.config.get("FABULOUS_PIN_MIN_HEIGHT", 0)) + instance_area = Decimal(state_in.metrics.get("design__instance__area", 0)) + + # Short-circuit directional modes whose pin floor already comfortably + # covers the instance area - iterating would only produce a tall/narrow + # bbox at the pin floor anyway. + if self._is_directional() and ( + pin_w > 0 and pin_h > 0 and pin_w * pin_h >= instance_area * Decimal("1.3") + ): + info( + f"Pin-min area ({pin_w}x{pin_h}) already comfortably covers " + f"instance area ({instance_area}); short-circuiting " + f"{opt_mode.value} to 1 iter." + ) + self.max_iterations = 1 + return super().run(state_in, **_kwargs) + + # Size the first iteration's die so it can hold the synth-reported + # stdcell area at 100% utilisation. Buffer/CTS/routing slack is earned + # by subsequent while-loop iterations. Per mode: + # FIND_MIN_WIDTH: keep h = pin_min_h, widen to fit the cells. + # FIND_MIN_HEIGHT: keep w = pin_min_w, grow h to fit the cells. + # BALANCE / LARGE: square bbox sized to hold the cells. + if instance_area > 0 and pin_w > 0 and pin_h > 0: + match opt_mode: + case OptMode.FIND_MIN_WIDTH: + init_h = pin_h + init_w = max(pin_w, instance_area / init_h) + case OptMode.FIND_MIN_HEIGHT: + init_w = pin_w + init_h = max(pin_h, instance_area / init_w) + case _: # BALANCE, LARGE + side = instance_area.sqrt() + init_w = max(pin_w, side) + init_h = max(pin_h, side) + + x_pitch, y_pitch = get_pitch(self.config) + init_w = round_up_decimal(init_w, x_pitch) + init_h = round_up_decimal(init_h, y_pitch) + + die_area = self.config.get("DIE_AREA") + current_w = Decimal(die_area[2]) if die_area else Decimal(0) + current_h = Decimal(die_area[3]) if die_area else Decimal(0) + + # Grow per-axis only so a user-supplied DIE_AREA override on the + # non-target axis is preserved. + new_w = max(current_w, init_w) + new_h = max(current_h, init_h) + if new_w > current_w or new_h > current_h: + info( + f"Smart init DIE_AREA for {opt_mode.value}: " + f"{current_w}x{current_h} -> {new_w}x{new_h} " + f"(instance_area={instance_area})" + ) + self.config = self.config.copy( + DIE_AREA=(Decimal(0), Decimal(0), new_w, new_h) + ) + + # Cap exponential growth at 4x the init target: beyond that the + # aspect is effectively infeasible at the pin floor on the other axis. + if self._is_directional(): + die_area = self.config.get("DIE_AREA") + if die_area is not None: + _, _, w, h = die_area + if self.config["FABULOUS_OPT_MODE"] == OptMode.FIND_MIN_WIDTH: + self.bracket_cap = Decimal(w) * Decimal(4) + else: + self.bracket_cap = Decimal(h) * Decimal(4) + return super().run(state_in, **_kwargs) diff --git a/tests/gds_flow_test/step_test/test_tile_optimisation.py b/tests/gds_flow_test/step_test/test_tile_optimisation.py index 78dc28b34..973fe06c4 100644 --- a/tests/gds_flow_test/step_test/test_tile_optimisation.py +++ b/tests/gds_flow_test/step_test/test_tile_optimisation.py @@ -46,6 +46,10 @@ def test_condition_returns_false_when_no_errors( mock_state.metrics["antenna__violating__nets"] = 0 mock_state.metrics["antenna__violating__pins"] = 0 + # Non-directional modes terminate on clean DRC. Directional modes use a + # bracket-based termination and ignore DRC when no bracket is set, so + # pin the mode here. + mock_config = mock_config.copy(FABULOUS_OPT_MODE=OptMode.BALANCE) step = TileOptimisation(mock_config) step.config = mock_config assert step.condition(mock_state) is False From 6a83ecbe63e1f7baabcf2419eab8fa84073792a3 Mon Sep 17 00:00:00 2001 From: Kelvin Chung Date: Tue, 21 Apr 2026 16:02:51 +0100 Subject: [PATCH 12/48] feat: fixes and performance improvement --- fabulous/fabric_definition/tile.py | 15 ++- .../gds_generator/flows/full_fabric_flow.py | 1 + .../steps/global_tile_opitmisation.py | 33 +++-- .../gds_generator/steps/tile_optimisation.py | 26 +++- tests/fabric_definition/test_tile.py | 124 ++++++++++++++++++ .../step_test/test_tile_optimisation.py | 15 ++- 6 files changed, 197 insertions(+), 17 deletions(-) create mode 100644 tests/fabric_definition/test_tile.py diff --git a/fabulous/fabric_definition/tile.py b/fabulous/fabric_definition/tile.py index 3c6df32e2..7f1117a62 100644 --- a/fabulous/fabric_definition/tile.py +++ b/fabulous/fabric_definition/tile.py @@ -312,7 +312,7 @@ def globalConfigBits(self) -> int: return ret def get_port_count(self, side: Side) -> int: - """Count total number of expanded ports on a given side of the tile. + """Count total number of expanded physical pins on a given side of the tile. Parameters ---------- @@ -324,8 +324,17 @@ def get_port_count(self, side: Side) -> int: int Total number of expanded ports on the given side. """ - ports = [p for p in self.portsInfo if p.sideOfTile == side and p.name != "NULL"] - return sum(len(group) for p in ports for group in p.expandPortInfo("all")) + total = 0 + for p in self.portsInfo: + if p.sideOfTile != side or p.name == "NULL": + continue + inputs, outputs = p.expandPortInfo("all") + if p.name == p.sourceName: + total += len(inputs) + elif p.name == p.destinationName: + total += len(outputs) + + return total def get_min_die_area( self, diff --git a/fabulous/fabric_generator/gds_generator/flows/full_fabric_flow.py b/fabulous/fabric_generator/gds_generator/flows/full_fabric_flow.py index f1e9c86da..7f30acd14 100644 --- a/fabulous/fabric_generator/gds_generator/flows/full_fabric_flow.py +++ b/fabulous/fabric_generator/gds_generator/flows/full_fabric_flow.py @@ -319,6 +319,7 @@ def _init_compile(self, fabric: Fabric, proj_dir: Path) -> None: "design__core__bbox", "design__instance__area__stdcell", "design__instance__utilization__stdcell", + "fabulous__clean_probes", ) metrics_dict = { k: v for k in metric_keys if (v := state.metrics.get(k)) is not None diff --git a/fabulous/fabric_generator/gds_generator/steps/global_tile_opitmisation.py b/fabulous/fabric_generator/gds_generator/steps/global_tile_opitmisation.py index b7985b164..4fe2a6595 100644 --- a/fabulous/fabric_generator/gds_generator/steps/global_tile_opitmisation.py +++ b/fabulous/fabric_generator/gds_generator/steps/global_tile_opitmisation.py @@ -183,17 +183,29 @@ def _combined_min(name: str) -> tuple[float, float]: for tile in fabric.get_all_unique_tiles(): name = tile.name areas: list[float] = [] - samples: list[tuple[float, float]] = [] + raw_samples: list[tuple[float, float]] = [] for mode_metrics in self.tile_metrics.values(): - if name not in mode_metrics: + m = mode_metrics.get(name) + if m is None: continue - x0, y0, x1, y1 = mode_metrics[name]["design__die__bbox"] - w, h = x1 - x0, y1 - y0 - areas.append(w * h) - samples.append((w, h)) - samples.sort(key=lambda wh: wh[1]) + # Terminal bbox plus every mid-run DRC-clean sample + bboxes = [m["design__die__bbox"], *m.get("fabulous__clean_probes", [])] + for x0, y0, x1, y1 in bboxes: + w, h = x1 - x0, y1 - y0 + areas.append(w * h) + raw_samples.append((w, h)) + + raw_samples.sort(key=lambda wh: (wh[1], wh[0])) + frontier: list[tuple[float, float]] = [] + min_w_so_far = float("inf") + for w, h in reversed(raw_samples): + if w < min_w_so_far: + frontier.append((w, h)) + min_w_so_far = w + frontier.reverse() + self.min_areas[name] = min(areas) if areas else float("inf") - self.tile_samples[name] = samples + self.tile_samples[name] = frontier stdcell_vals = [ a @@ -578,6 +590,11 @@ def _parse_tile_fields(data: dict) -> dict[str, Any]: ): if data.get(key) is not None: result[key] = float(data[key]) + probes_raw = data.get("fabulous__clean_probes") + if probes_raw: + result["fabulous__clean_probes"] = [ + [float(v) for v in bbox] for bbox in probes_raw + ] return result @classmethod diff --git a/fabulous/fabric_generator/gds_generator/steps/tile_optimisation.py b/fabulous/fabric_generator/gds_generator/steps/tile_optimisation.py index 397d5254a..57e46b79c 100644 --- a/fabulous/fabric_generator/gds_generator/steps/tile_optimisation.py +++ b/fabulous/fabric_generator/gds_generator/steps/tile_optimisation.py @@ -154,6 +154,8 @@ class TileOptimisation(WhileStep): last_working_state: State | None = None + clean_probes: list[list[float]] = [] + raise_on_failure: bool = False break_next_iteration: bool = False @@ -227,6 +229,12 @@ def post_iteration_callback( if (ca := post_iteration.metrics.get("design__core__area")) is not None: self.last_core_area = Decimal(ca) + # DRC and antenna clean design as sample for the later optimisation. + if full_iter_completed: + die_bbox = post_iteration.metrics.get("design__die__bbox") + if die_bbox is not None: + self.clean_probes.append([float(v) for v in die_bbox.split()]) + if self._is_directional(): _, _, w, h = self.config["DIE_AREA"] if self.config["FABULOUS_OPT_MODE"] == OptMode.FIND_MIN_WIDTH: @@ -304,7 +312,7 @@ def pre_iteration_callback(self, pre_iteration: State) -> State: # First iteration: no actual core area yet. Estimate core area # from die area minus floorplan margins (site insets on each side) # so the overshoot scaling triggers when cells barely fit. - margin_x = Decimal(2) * site_width * 6 + margin_x = Decimal(2) * site_width margin_y = Decimal(2) * site_height core_area = (width - margin_x) * (height - margin_y) @@ -365,7 +373,7 @@ def _compute_new_dimensions( match opt_mode: case OptMode.BALANCE: - if self.to_change_width: + if width <= height: width += width_step else: height += height_step @@ -442,7 +450,18 @@ def post_loop_callback(self, state: State) -> State: # noqa: ARG002 ) raise RuntimeError("No working state found after tile optimisation.") - result = self.last_working_state + # State.metrics is an immutable dict; rebuild the state with an added + # clean_probes entry rather than mutating in place. + from librelane.common import GenericImmutableDict + + last = self.last_working_state + result = State( + last, + metrics=GenericImmutableDict( + last.metrics, + overrides={"fabulous__clean_probes": list(self.clean_probes)}, + ), + ) # Update config with actual die area so downstream steps # (e.g. Magic/KLayout stream-out) use the correct boundary. @@ -484,6 +503,7 @@ def run( **_kwargs: dict, ) -> tuple[ViewsUpdate, MetricsUpdate]: """Run the tile optimisation step.""" + self.clean_probes = [] if self.config["IGNORE_ANTENNA_VIOLATIONS"]: info("Ignoring antenna violations during tile optimisation.") self.config = self.config.copy(ERROR_ON_TR_DRC=False) diff --git a/tests/fabric_definition/test_tile.py b/tests/fabric_definition/test_tile.py new file mode 100644 index 000000000..1fef893c4 --- /dev/null +++ b/tests/fabric_definition/test_tile.py @@ -0,0 +1,124 @@ +"""Tests for Tile methods — notably pin-count and min-die-area computation.""" + +from decimal import Decimal + +from fabulous.fabric_definition.define import IO, Direction, Side +from fabulous.fabric_definition.port import Port +from fabulous.fabric_definition.tile import Tile + + +def _mk_tile(ports: list[Port]) -> Tile: + """Construct a Tile with only portsInfo set — enough for get_port_count tests.""" + return Tile( + name="T", + ports=ports, + bels=[], + tileDir=None, + matrixDir=None, + gen_ios=[], + userCLK=False, + ) + + +def _directional_ports( + direction: str, + src: str, + dst: str, + wires: int, + x_offset: int = 0, + y_offset: int = -1, +) -> list[Port]: + """Mirror parsePortLine: one OUTPUT port on ``side``, one INPUT on opposite.""" + side = Side[direction] + return [ + Port( + Direction[direction], + src, + x_offset, + y_offset, + dst, + wires, + src, + IO.OUTPUT, + side, + ), + Port( + Direction[direction], + src, + x_offset, + y_offset, + dst, + wires, + dst, + IO.INPUT, + side.opposite, + ), + ] + + +class TestGetPortCount: + """``get_port_count`` must return physical pin count, not 2× the wire count.""" + + def test_single_direction_counts_once_per_wire(self) -> None: + # One NORTH wire produces N1BEG on N edge and N1END on S edge. + # Each edge should report exactly wireCount pins, not 2× wireCount. + tile = _mk_tile(_directional_ports("NORTH", "N1BEG", "N1END", 4)) + assert tile.get_port_count(Side.NORTH) == 4 + assert tile.get_port_count(Side.SOUTH) == 4 + + def test_distance_multiplies_wire_count(self) -> None: + # N4 wire with distance 4 and wireCount=4 expands to 16 physical pins + # per edge in "all" mode (4 tiles of passthrough × 4 wires). + tile = _mk_tile(_directional_ports("NORTH", "N4BEG", "N4END", 4, y_offset=-4)) + assert tile.get_port_count(Side.NORTH) == 16 + assert tile.get_port_count(Side.SOUTH) == 16 + + def test_opposite_directions_sum_on_shared_edge(self) -> None: + # A north-going wire (N1BEG on N edge) and a south-going wire + # (S1END on N edge, received from north neighbor) both contribute + # physical pins to the N edge. + ports = _directional_ports("NORTH", "N1BEG", "N1END", 4) + _directional_ports( + "SOUTH", "S1BEG", "S1END", 4, y_offset=1 + ) + tile = _mk_tile(ports) + assert tile.get_port_count(Side.NORTH) == 8 # 4 N1BEG + 4 S1END + assert tile.get_port_count(Side.SOUTH) == 8 # 4 N1END + 4 S1BEG + + def test_null_ports_excluded(self) -> None: + # GND/VCC-like ports with NULL source count only the non-NULL side. + port = Port( + Direction.JUMP, + "NULL", + 0, + 0, + "VCC", + 1, + "VCC", + IO.INPUT, + Side.ANY, + ) + tile = _mk_tile([port]) + assert tile.get_port_count(Side.ANY) == 1 + + +class TestGetMinDieArea: + """``get_min_die_area`` derives pin-limited min dimensions from get_port_count.""" + + def test_pin_min_reflects_physical_pins_only(self) -> None: + # Without the double-count bug, 4 wires on N should produce pin_min_w + # of roughly (4 + frame_strobe_width)·thickness_mult·pitch + offset·pitch. + ports = _directional_ports("NORTH", "N1BEG", "N1END", 4) + tile = _mk_tile(ports) + pitch = Decimal("0.5") + thickness = Decimal(2) + mw, _ = tile.get_min_die_area( + x_pitch=pitch, + y_pitch=pitch, + x_pin_thickness_mult=thickness, + y_pin_thickness_mult=thickness, + frame_data_width=0, + frame_strobe_width=0, + edge_offset=2, + ) + # 4 pins × 2 thickness + 2 offset = 10 tracks × 0.5 pitch = 5.0 um + assert mw == Decimal("5.0") diff --git a/tests/gds_flow_test/step_test/test_tile_optimisation.py b/tests/gds_flow_test/step_test/test_tile_optimisation.py index 973fe06c4..4dfe995ac 100644 --- a/tests/gds_flow_test/step_test/test_tile_optimisation.py +++ b/tests/gds_flow_test/step_test/test_tile_optimisation.py @@ -96,13 +96,22 @@ def test_pre_iteration_callback_find_min_width_mode( def test_post_loop_callback_returns_working_state( self, mock_config: Config, mock_state: State ) -> None: - """Test post_loop_callback returns the last working state.""" + """Test post_loop_callback returns a state derived from the last working one. + + The result is a freshly constructed ``State`` so the ``fabulous__clean_probes`` + metric can be added onto an immutable metrics dict — identity comparison + against ``mock_state`` no longer holds, but the original metrics must still be + visible on the returned state. + """ step = TileOptimisation(mock_config) - step.last_working_state = mock_state + step.config = mock_config + step.last_working_state = State(metrics=mock_state.metrics) + step.clean_probes = [] result = step.post_loop_callback(mock_state) - assert result == mock_state + assert result.metrics["route__drc_errors"] == 0 + assert result.metrics["fabulous__clean_probes"] == [] def test_post_loop_callback_raises_error_without_working_state( self, mock_config: Config, mock_state: State From 00636adb8fe4e63630bcf14ea3c3ed4ee81cac4a Mon Sep 17 00:00:00 2001 From: Kelvin Chung Date: Wed, 22 Apr 2026 17:16:13 +0100 Subject: [PATCH 13/48] chore: another run complete --- .../steps/global_tile_opitmisation.py | 37 +++++++++++++++ .../gds_generator/steps/tile_optimisation.py | 47 +++++++++++++++++-- 2 files changed, 79 insertions(+), 5 deletions(-) diff --git a/fabulous/fabric_generator/gds_generator/steps/global_tile_opitmisation.py b/fabulous/fabric_generator/gds_generator/steps/global_tile_opitmisation.py index 4fe2a6595..a3b1d6e8c 100644 --- a/fabulous/fabric_generator/gds_generator/steps/global_tile_opitmisation.py +++ b/fabulous/fabric_generator/gds_generator/steps/global_tile_opitmisation.py @@ -216,6 +216,43 @@ def _combined_min(name: str) -> tuple[float, float]: ] self.stdcell_areas[name] = max(stdcell_vals) if stdcell_vals else 0.0 + for tile in fabric.get_all_unique_tiles(): + samples = self.tile_samples.get(tile.name, []) + if len(samples) != 1: + continue + sample_w, sample_h = samples[0] + + if tile.name in self.fabric.superTileDic: + supertile = self.fabric.superTileDic[tile.name] + first_row = supertile.tileMap[0] if supertile.tileMap else [] + n_cols = sum(1 for t in first_row if t is not None) + n_rows = sum( + 1 + for row in supertile.tileMap + if row and any(t is not None for t in row) + ) + if n_cols == 0 or n_rows == 0: + continue + per_row_h = sample_h / n_rows + per_col_w = sample_w / n_cols + for row in supertile.tileMap: + for component in row: + if component is None: + continue + for row_idx in self.tile_row_set[component.name]: + vi = self.row_group_to_var[self.row_groups[row_idx]] + xl[vi] = max(xl[vi], per_row_h) + for col_idx in self.tile_column_set[component.name]: + vi = self.col_group_to_var[self.col_groups[col_idx]] + xl[vi] = max(xl[vi], per_col_w) + else: + for row_idx in self.tile_row_set[tile.name]: + var_idx = self.row_group_to_var[self.row_groups[row_idx]] + xl[var_idx] = max(xl[var_idx], sample_h) + for col_idx in self.tile_column_set[tile.name]: + var_idx = self.col_group_to_var[self.col_groups[col_idx]] + xl[var_idx] = max(xl[var_idx], sample_w) + # Fail fast if any tile/supertile failed every exploration mode. valid_names = { n for mode_metrics in self.tile_metrics.values() for n in mode_metrics diff --git a/fabulous/fabric_generator/gds_generator/steps/tile_optimisation.py b/fabulous/fabric_generator/gds_generator/steps/tile_optimisation.py index 57e46b79c..2b21b3e32 100644 --- a/fabulous/fabric_generator/gds_generator/steps/tile_optimisation.py +++ b/fabulous/fabric_generator/gds_generator/steps/tile_optimisation.py @@ -101,6 +101,20 @@ def _missing_(cls, value: object) -> "OptMode": "Minimum tile height based on pin requirements.", default=Decimal(0), ), + Variable( + "FABULOUS_TILE_LOGICAL_WIDTH", + int, + "Supertile logical column count; 1 for regular tiles. Used to lock the " + "balance-mode aspect ratio to logical_w:logical_h (square cells).", + default=1, + ), + Variable( + "FABULOUS_TILE_LOGICAL_HEIGHT", + int, + "Supertile logical row count; 1 for regular tiles. Paired with " + "FABULOUS_TILE_LOGICAL_WIDTH for aspect locking.", + default=1, + ), ] @@ -371,9 +385,22 @@ def _compute_new_dimensions( width *= scale height *= scale + logical_w = Decimal(self.config.get("FABULOUS_TILE_LOGICAL_WIDTH", 1)) + logical_h = Decimal(self.config.get("FABULOUS_TILE_LOGICAL_HEIGHT", 1)) + is_supertile = logical_w * logical_h > Decimal(1) + match opt_mode: case OptMode.BALANCE: - if width <= height: + if is_supertile: + # Keep total aspect = logical_w : logical_h (square cells). + # Grow area by ~one step worth on each axis, then rebalance. + new_area = ( + width * height + width * height_step + height * width_step + ) + ratio = logical_w / logical_h + width = (new_area * ratio).sqrt() + height = width / ratio + elif width <= height: width += width_step else: height += height_step @@ -537,6 +564,8 @@ def run( # FIND_MIN_HEIGHT: keep w = pin_min_w, grow h to fit the cells. # BALANCE / LARGE: square bbox sized to hold the cells. if instance_area > 0 and pin_w > 0 and pin_h > 0: + logical_w = Decimal(self.config.get("FABULOUS_TILE_LOGICAL_WIDTH", 1)) + logical_h = Decimal(self.config.get("FABULOUS_TILE_LOGICAL_HEIGHT", 1)) match opt_mode: case OptMode.FIND_MIN_WIDTH: init_h = pin_h @@ -544,10 +573,18 @@ def run( case OptMode.FIND_MIN_HEIGHT: init_w = pin_w init_h = max(pin_h, instance_area / init_w) - case _: # BALANCE, LARGE - side = instance_area.sqrt() - init_w = max(pin_w, side) - init_h = max(pin_h, side) + case _: # BALANCE, LARGE — target square cells, aspect = W:H. + cell_side = (instance_area / (logical_w * logical_h)).sqrt() + init_w = logical_w * cell_side + init_h = logical_h * cell_side + # Respect pin floor; if forced up on one axis, scale the + # other to keep logical aspect. + if pin_w > init_w: + init_w = pin_w + init_h = max(init_h, init_w * logical_h / logical_w) + if pin_h > init_h: + init_h = pin_h + init_w = max(init_w, init_h * logical_w / logical_h) x_pitch, y_pitch = get_pitch(self.config) init_w = round_up_decimal(init_w, x_pitch) From fee838a27e7aef59e63f42fe4631536715df79ee Mon Sep 17 00:00:00 2001 From: Kelvin Chung Date: Thu, 23 Apr 2026 00:02:07 +0100 Subject: [PATCH 14/48] chore: probably will run --- .../steps/global_tile_opitmisation.py | 23 ++++++++++++++++--- .../gds_generator/steps/tile_optimisation.py | 11 +++------ 2 files changed, 23 insertions(+), 11 deletions(-) diff --git a/fabulous/fabric_generator/gds_generator/steps/global_tile_opitmisation.py b/fabulous/fabric_generator/gds_generator/steps/global_tile_opitmisation.py index a3b1d6e8c..e7fb48826 100644 --- a/fabulous/fabric_generator/gds_generator/steps/global_tile_opitmisation.py +++ b/fabulous/fabric_generator/gds_generator/steps/global_tile_opitmisation.py @@ -216,13 +216,14 @@ def _combined_min(name: str) -> tuple[float, float]: ] self.stdcell_areas[name] = max(stdcell_vals) if stdcell_vals else 0.0 + self._supertile_target_sample: dict[str, tuple[float, float]] = {} for tile in fabric.get_all_unique_tiles(): samples = self.tile_samples.get(tile.name, []) - if len(samples) != 1: + if not samples: continue - sample_w, sample_h = samples[0] - if tile.name in self.fabric.superTileDic: + is_supertile = tile.name in self.fabric.superTileDic + if is_supertile: supertile = self.fabric.superTileDic[tile.name] first_row = supertile.tileMap[0] if supertile.tileMap else [] n_cols = sum(1 for t in first_row if t is not None) @@ -233,6 +234,22 @@ def _combined_min(name: str) -> tuple[float, float]: ) if n_cols == 0 or n_rows == 0: continue + else: + if len(samples) != 1: + continue + n_cols, n_rows = 1, 1 + + target_aspect = n_cols / n_rows # w/h for square cells + best = min( + samples, + key=lambda s: ( + abs((s[0] / s[1]) - target_aspect) if s[1] > 0 else float("inf") + ), + ) + sample_w, sample_h = best + + if is_supertile: + self._supertile_target_sample[tile.name] = (sample_w, sample_h) per_row_h = sample_h / n_rows per_col_w = sample_w / n_cols for row in supertile.tileMap: diff --git a/fabulous/fabric_generator/gds_generator/steps/tile_optimisation.py b/fabulous/fabric_generator/gds_generator/steps/tile_optimisation.py index 2b21b3e32..c6abca7b0 100644 --- a/fabulous/fabric_generator/gds_generator/steps/tile_optimisation.py +++ b/fabulous/fabric_generator/gds_generator/steps/tile_optimisation.py @@ -392,14 +392,9 @@ def _compute_new_dimensions( match opt_mode: case OptMode.BALANCE: if is_supertile: - # Keep total aspect = logical_w : logical_h (square cells). - # Grow area by ~one step worth on each axis, then rebalance. - new_area = ( - width * height + width * height_step + height * width_step - ) - ratio = logical_w / logical_h - width = (new_area * ratio).sqrt() - height = width / ratio + cell_step = max(width_step / logical_w, height_step / logical_h) + width += cell_step * logical_w + height += cell_step * logical_h elif width <= height: width += width_step else: From 6b61eb8b1a8e9714cb48d74883538347f208aadc Mon Sep 17 00:00:00 2001 From: Kelvin Chung Date: Tue, 28 Apr 2026 21:34:51 +0100 Subject: [PATCH 15/48] chore: test --- .../test_global_tile_optimisation.py | 120 ++++++++++++++++++ 1 file changed, 120 insertions(+) create mode 100644 tests/gds_flow_test/step_test/test_global_tile_optimisation.py diff --git a/tests/gds_flow_test/step_test/test_global_tile_optimisation.py b/tests/gds_flow_test/step_test/test_global_tile_optimisation.py new file mode 100644 index 000000000..341150d3d --- /dev/null +++ b/tests/gds_flow_test/step_test/test_global_tile_optimisation.py @@ -0,0 +1,120 @@ +"""Tests for GlobalTileSizeOptimization NLP problem helpers. + +The Pareto frontier helper is the most failure-prone part of the NLP setup: a +flipped iteration direction silently locks the body row to the worst-aspect +sample, producing 1:8 rectangles for square-ish tiles. These tests pin the +correct algorithm so that regression cannot reappear unnoticed. +""" + +# ruff: noqa: SLF001 + +import pytest + +from fabulous.fabric_generator.gds_generator.steps.global_tile_opitmisation import ( + NLPTileProblem, +) + + +class TestParetoFrontier: + """Unit tests for NLPTileProblem._pareto_frontier.""" + + def test_empty_input_returns_empty(self) -> None: + assert NLPTileProblem._pareto_frontier([]) == [] + + def test_single_sample_round_trips(self) -> None: + assert NLPTileProblem._pareto_frontier([(100.0, 50.0)]) == [(100.0, 50.0)] + + def test_keeps_wide_short_alongside_narrow_tall(self) -> None: + """Two samples with opposite trade-offs are both Pareto-optimal.""" + # (236.16, 470.4) and (127.68, 1034.88) are mutually non-dominated: + # neither has both smaller w AND smaller h. This is the DSP supertile + # case from the demo project that the buggy frontier discarded. + samples = [(127.68, 1034.88), (236.16, 470.4)] + frontier = NLPTileProblem._pareto_frontier(samples) + + assert (236.16, 470.4) in frontier + assert (127.68, 1034.88) in frontier + assert len(frontier) == 2 + + def test_drops_dominated_samples(self) -> None: + """A sample that is wider AND taller than another is dominated and dropped.""" + # (200, 200) dominates (300, 300) — keep the former, drop the latter. + samples = [(300.0, 300.0), (200.0, 200.0), (250.0, 250.0)] + frontier = NLPTileProblem._pareto_frontier(samples) + assert frontier == [(200.0, 200.0)] + + def test_dedupes_equal_height_samples_keeping_smallest_w(self) -> None: + """At the same h, only the smallest-w sample survives.""" + samples = [(150.0, 100.0), (100.0, 100.0), (200.0, 100.0)] + frontier = NLPTileProblem._pareto_frontier(samples) + assert frontier == [(100.0, 100.0)] + + def test_output_sorted_by_h_ascending(self) -> None: + """Frontier is sorted by h ascending, w strictly decreasing along it.""" + # Real LUT4AB samples from the demo project's exploration: + samples = [ + (582.72, 108.36), # FIND_MIN_WIDTH probe + (238.08, 271.74), # BALANCE final + (120.96, 1310.4), # FIND_MIN_HEIGHT final + (731.52, 108.36), # dominated by (582.72, 108.36) at same h + ] + frontier = NLPTileProblem._pareto_frontier(samples) + assert frontier == [ + (582.72, 108.36), + (238.08, 271.74), + (120.96, 1310.4), + ] + # Heights strictly increase, widths strictly decrease. + for prev, curr in zip(frontier, frontier[1:], strict=False): + assert curr[1] > prev[1] + assert curr[0] < prev[0] + + @pytest.mark.parametrize( + ("samples", "expected_first_w_h", "expected_last_w_h"), + [ + pytest.param( + # DSP supertile: balance (236, 470), find_min_height probes + # at w=127.68 with various h. Frontier should include both + # the wide+short balance sample AND the narrow+tall extremes. + [ + (236.16, 470.4), + (127.68, 828.24), + (127.68, 1034.88), + (127.68, 916.02), + ], + (236.16, 470.4), + (127.68, 828.24), + id="dsp-supertile-frontier", + ), + pytest.param( + # W_IO real samples — has 3 frontier points. + [ + (108.48, 142.38), + (20.16, 1338.96), + (125.76, 108.36), + (249.6, 108.36), # dominated by (125.76, 108.36) + ], + (125.76, 108.36), + (20.16, 1338.96), + id="w-io-frontier", + ), + ], + ) + def test_real_demo_project_frontiers( + self, + samples: list[tuple[float, float]], + expected_first_w_h: tuple[float, float], + expected_last_w_h: tuple[float, float], + ) -> None: + """Lock in the frontier shape for representative tiles from the demo.""" + frontier = NLPTileProblem._pareto_frontier(samples) + assert frontier[0] == expected_first_w_h + assert frontier[-1] == expected_last_w_h + # Every kept sample is non-dominated by every other kept sample. + for i, (w_i, h_i) in enumerate(frontier): + for j, (w_j, h_j) in enumerate(frontier): + if i == j: + continue + assert not (w_j <= w_i and h_j <= h_i and (w_j, h_j) != (w_i, h_i)), ( + f"{frontier[j]} dominates {frontier[i]}" + ) From 125293723ac2a476588c75779d90a76ac481df65 Mon Sep 17 00:00:00 2001 From: Kelvin Chung Date: Tue, 28 Apr 2026 21:55:09 +0100 Subject: [PATCH 16/48] chore: more fixes Co-authored-by: Copilot --- .../steps/global_tile_opitmisation.py | 34 +++++++++++++------ fabulous/fabulous_api.py | 4 ++- 2 files changed, 27 insertions(+), 11 deletions(-) diff --git a/fabulous/fabric_generator/gds_generator/steps/global_tile_opitmisation.py b/fabulous/fabric_generator/gds_generator/steps/global_tile_opitmisation.py index e7fb48826..3bc40dd6f 100644 --- a/fabulous/fabric_generator/gds_generator/steps/global_tile_opitmisation.py +++ b/fabulous/fabric_generator/gds_generator/steps/global_tile_opitmisation.py @@ -195,17 +195,8 @@ def _combined_min(name: str) -> tuple[float, float]: areas.append(w * h) raw_samples.append((w, h)) - raw_samples.sort(key=lambda wh: (wh[1], wh[0])) - frontier: list[tuple[float, float]] = [] - min_w_so_far = float("inf") - for w, h in reversed(raw_samples): - if w < min_w_so_far: - frontier.append((w, h)) - min_w_so_far = w - frontier.reverse() - self.min_areas[name] = min(areas) if areas else float("inf") - self.tile_samples[name] = frontier + self.tile_samples[name] = self._pareto_frontier(raw_samples) stdcell_vals = [ a @@ -486,6 +477,29 @@ def _pin_min_from_metrics(self, name: str) -> tuple[float, float]: return (w, h) return self._min_dimensions_from_metrics(name) + @staticmethod + def _pareto_frontier( + samples: list[tuple[float, float]], + ) -> list[tuple[float, float]]: + """Return the (w, h) Pareto frontier where smaller is better on both axes. + + A sample is kept iff no other sample has both smaller-or-equal w AND smaller-or- + equal h with at least one strictly smaller. The result is sorted by h ascending + (and by construction, w descending). + + This preserves wider+shorter samples alongside narrower+taller ones so the + supertile aspect-target selector and the piecewise envelope constraint see the + full feasible front rather than a single extreme. + """ + sorted_samples = sorted(samples, key=lambda wh: (wh[1], wh[0])) + frontier: list[tuple[float, float]] = [] + min_w_so_far = float("inf") + for w, h in sorted_samples: + if w < min_w_so_far: + frontier.append((w, h)) + min_w_so_far = w + return frontier + @staticmethod def _envelope_w_floor(h: float, samples: list[tuple[float, float]]) -> float: """Piecewise-linear lower bound on w given h, from samples sorted by h. diff --git a/fabulous/fabulous_api.py b/fabulous/fabulous_api.py index 96bb7091e..a1fb4dda7 100644 --- a/fabulous/fabulous_api.py +++ b/fabulous/fabulous_api.py @@ -694,8 +694,10 @@ def full_fabric_automation( logger.info(f"Output folder: {out_folder.resolve()}") config_args = { "FABULOUS_PROJ_DIR": str(project_dir.resolve()), - "FABULOUS_FABRIC": self.fabric.name, + "FABULOUS_FABRIC": self.fabric, "DESIGN_NAME": self.fabric.name, + "FABULOUS_NLP_ONLY": nlp_only, + "FABULOUS_NLP_AREA_MARGIN": nlp_area_margin, } if tile_opt_config is not None: config_args["TILE_OPT_INFO"] = str(tile_opt_config) From 752c1cc0f5960c24132348ab559880fc7b844438 Mon Sep 17 00:00:00 2001 From: Kelvin Chung Date: Tue, 28 Apr 2026 22:48:03 +0100 Subject: [PATCH 17/48] chore: test Co-authored-by: Copilot --- tests/fabric_definition/conftest.py | 32 +++ tests/fabric_definition/test_supertile.py | 203 +++++++++++++ .../test_global_tile_optimisation.py | 224 +++++++++++++++ .../step_test/test_tile_optimisation.py | 267 ++++++++++++++++++ 4 files changed, 726 insertions(+) create mode 100644 tests/fabric_definition/test_supertile.py diff --git a/tests/fabric_definition/conftest.py b/tests/fabric_definition/conftest.py index 7d3178a93..e503ed010 100644 --- a/tests/fabric_definition/conftest.py +++ b/tests/fabric_definition/conftest.py @@ -4,8 +4,12 @@ from pathlib import Path import pytest +from PIL.ImageCms import Direction +from fabulous.fabric_definition.define import IO, Side from fabulous.fabric_definition.fabric import Fabric +from fabulous.fabric_definition.port import Port +from fabulous.fabric_definition.tile import Tile @pytest.fixture @@ -30,3 +34,31 @@ def _make(**overrides: int) -> Fabric: return Fabric(**defaults) return _make + + +def make_empty_tile(name: str, ports: list[Port] | None = None) -> Tile: + """Build a minimal Tile usable inside a SuperTile.tileMap.""" + return Tile( + name=name, + ports=ports or [], + bels=[], + tileDir=Path(), + matrixDir=Path(), + gen_ios=[], + userCLK=False, + ) + + +def make_side_port(side: str, name: str = "P") -> Port: + """Construct a Port physically located on the given side.""" + return Port( + Direction.JUMP, + name, + 0, + 0, + name, + 1, + name, + IO.INPUT, + Side[side], + ) diff --git a/tests/fabric_definition/test_supertile.py b/tests/fabric_definition/test_supertile.py new file mode 100644 index 000000000..b1b8c48cb --- /dev/null +++ b/tests/fabric_definition/test_supertile.py @@ -0,0 +1,203 @@ +"""Tests for SuperTile methods. + +The supertile aggregates Tile objects into a 2D layout. The methods under test are +pure functions of the ``tileMap`` shape and the constituent tiles' port counts: + +- ``getPortsAroundTile``: emits side-of-tile port lists for *outer* edges only. +- ``getInternalConnections``: emits side-of-tile port lists for *inner* edges only. +- ``__iter__``: yields ``((x, y), tile)`` for every non-None cell. +- ``max_width`` / ``max_height``: dimensions of the layout grid. +- ``get_min_die_area``: pin-density-driven physical minimum. + +These were not previously covered and are entry points to the global tile size +optimisation pipeline, so any drift in their semantics propagates silently. +""" + +from decimal import Decimal +from pathlib import Path + +from pytest_mock import MockerFixture + +from fabulous.fabric_definition.define import Side +from fabulous.fabric_definition.supertile import SuperTile +from fabulous.fabric_definition.tile import Tile +from tests.fabric_definition.conftest import make_empty_tile, make_side_port + + +class TestSuperTileLayout: + """Geometric properties — independent of the constituent tiles' ports.""" + + def test_iter_yields_only_non_none_tiles_with_xy(self) -> None: + # Layout: + # row0: T00, None + # row1: None, T11 + # __iter__ uses (row_index, col_index) as (x, y) per the implementation. + t00 = make_empty_tile("T00") + t11 = make_empty_tile("T11") + st = SuperTile( + name="ST", + tileDir=Path(), + tiles=[t00, t11], + tileMap=[[t00, None], [None, t11]], + ) + assert list(st) == [((0, 0), t00), ((1, 1), t11)] + + def test_max_width_uses_widest_row(self) -> None: + t = make_empty_tile("T") + # Ragged layout: top row is wider. + st = SuperTile( + name="ST", + tileDir=Path(), + tiles=[t], + tileMap=[[t, t, t], [t, None]], + ) + assert st.max_width == 3 + + def test_max_height_is_row_count(self) -> None: + t = make_empty_tile("T") + st = SuperTile( + name="ST", + tileDir=Path(), + tiles=[t], + tileMap=[[t], [t], [t]], + ) + assert st.max_height == 3 + + +class TestSuperTilePortQueries: + """``getPortsAroundTile`` / ``getInternalConnections`` partition the four edges of + every cell into "outer" (boundary or facing a hole) and "inner" (facing another + tile). + + The implementation calls the side-getter for outer-edges only + in ``getPortsAroundTile`` and inner-edges only in ``getInternalConnections``. + """ + + def test_single_tile_supertile_has_all_outer_edges( + self, mocker: MockerFixture + ) -> None: + # A 1x1 supertile: every edge is outer, none are internal. + tile = mocker.MagicMock(spec=Tile) + tile.getNorthSidePorts.return_value = ["N"] + tile.getEastSidePorts.return_value = ["E"] + tile.getSouthSidePorts.return_value = ["S"] + tile.getWestSidePorts.return_value = ["W"] + + st = SuperTile( + name="ST", + tileDir=Path(), + tiles=[tile], + tileMap=[[tile]], + ) + + ports = st.getPortsAroundTile() + # The cell coordinate is "x,y" for col x, row y. + assert list(ports.keys()) == ["0,0"] + # Every direction should appear exactly once on the only cell. + assert ports["0,0"] == [["N"], ["E"], ["S"], ["W"]] + + # No internal connections. + assert st.getInternalConnections() == [] + + def test_two_tile_horizontal_splits_outer_and_inner( + self, mocker: MockerFixture + ) -> None: + # Two side-by-side tiles. The left tile's outer edges are N, S, W and + # its east edge is internal (faces the right tile); mirror for the + # right tile. + left = mocker.MagicMock(spec=Tile) + left.getNorthSidePorts.return_value = ["LN"] + left.getEastSidePorts.return_value = ["LE"] + left.getSouthSidePorts.return_value = ["LS"] + left.getWestSidePorts.return_value = ["LW"] + + right = mocker.MagicMock(spec=Tile) + right.getNorthSidePorts.return_value = ["RN"] + right.getEastSidePorts.return_value = ["RE"] + right.getSouthSidePorts.return_value = ["RS"] + right.getWestSidePorts.return_value = ["RW"] + + st = SuperTile( + name="ST", + tileDir=Path(), + tiles=[left, right], + tileMap=[[left, right]], + ) + + ports = st.getPortsAroundTile() + # Outer edges: left has N, S, W (no E, since right is east neighbor). + assert ports["0,0"] == [["LN"], ["LS"], ["LW"]] + # Outer edges: right has N, E, S (no W). + assert ports["1,0"] == [["RN"], ["RE"], ["RS"]] + + # Inner connections: left's E side and right's W side. + internal = st.getInternalConnections() + # Each entry is (ports, x, y); order follows the loop. + assert (["LE"], 0, 0) in internal + assert (["RW"], 1, 0) in internal + assert len(internal) == 2 + + +class TestSuperTileMinDieArea: + """``get_min_die_area`` aggregates the maximum per-side port count across + constituent tiles, then derives a physical floor from the pitch. + + Formula per side: ``min_dim = (max_count * thickness_mult + edge_offset) * pitch``. + """ + + def test_picks_max_side_count_across_tiles(self) -> None: + # Tile A: 3 north, 1 east. Tile B: 1 north, 2 east. + # Aggregate max: 3 north (=> width axis), 2 east (=> height axis). + a = make_empty_tile( + "A", + ports=[make_side_port(Side.NORTH, f"AN{i}") for i in range(3)] + + [make_side_port(Side.EAST, "AE")], + ) + b = make_empty_tile( + "B", + ports=[make_side_port(Side.NORTH, "BN")] + + [make_side_port(Side.EAST, f"BE{i}") for i in range(2)], + ) + + st = SuperTile( + name="ST", + tileDir=Path(), + tiles=[a, b], + tileMap=[[a, b]], + ) + + # pitch=1, thickness_mult=1, edge_offset=2. + # width = (3*1 + 2)*1 = 5 ; height = (2*1 + 2)*1 = 4. + w, h = st.get_min_die_area( + x_pitch=Decimal(1), + y_pitch=Decimal(1), + x_pin_thickness_mult=Decimal(1), + y_pin_thickness_mult=Decimal(1), + edge_offset=2, + ) + assert w == Decimal(5) + assert h == Decimal(4) + + def test_thickness_mult_and_pitch_scale_dimensions(self) -> None: + # 2 ports on south (covers x_io_count via max(north, south)). + a = make_empty_tile( + "A", + ports=[make_side_port(Side.SOUTH, f"AS{i}") for i in range(2)] + + [make_side_port(Side.WEST, "AW")], + ) + st = SuperTile( + name="ST", + tileDir=Path(), + tiles=[a], + tileMap=[[a]], + ) + # width = (2 * 3 + 2) * 0.5 = 4.0 ; height = (1 * 2 + 2) * 0.25 = 1.0 + w, h = st.get_min_die_area( + x_pitch=Decimal("0.5"), + y_pitch=Decimal("0.25"), + x_pin_thickness_mult=Decimal(3), + y_pin_thickness_mult=Decimal(2), + edge_offset=2, + ) + assert w == Decimal("4.0") + assert h == Decimal("1.0") diff --git a/tests/gds_flow_test/step_test/test_global_tile_optimisation.py b/tests/gds_flow_test/step_test/test_global_tile_optimisation.py index 341150d3d..e7f99377d 100644 --- a/tests/gds_flow_test/step_test/test_global_tile_optimisation.py +++ b/tests/gds_flow_test/step_test/test_global_tile_optimisation.py @@ -6,13 +6,19 @@ correct algorithm so that regression cannot reappear unnoticed. """ +# for testing private methods # ruff: noqa: SLF001 +import json +from pathlib import Path + import pytest from fabulous.fabric_generator.gds_generator.steps.global_tile_opitmisation import ( + GlobalTileSizeOptimization, NLPTileProblem, ) +from fabulous.fabric_generator.gds_generator.steps.tile_optimisation import OptMode class TestParetoFrontier: @@ -118,3 +124,221 @@ def test_real_demo_project_frontiers( assert not (w_j <= w_i and h_j <= h_i and (w_j, h_j) != (w_i, h_i)), ( f"{frontier[j]} dominates {frontier[i]}" ) + + +class TestEnvelopeWFloor: + """Piecewise-linear lower bound on width given a row height. + + The Pareto frontier delivered by ``_pareto_frontier`` is sorted by ``h`` + ascending and ``w`` descending — that monotone shape is the precondition + of the linear interpolation used here. + """ + + def test_no_samples_returns_zero(self) -> None: + # No exploration data -> no constraint, the floor is 0. + assert NLPTileProblem._envelope_w_floor(100.0, []) == 0.0 + + def test_below_sample_range_clamps_to_first(self) -> None: + # The frontier is sorted by h ascending; below the smallest h we clamp + # to the widest sample. This is the correct convex lower bound. + samples = [(200.0, 50.0), (100.0, 100.0)] + assert NLPTileProblem._envelope_w_floor(10.0, samples) == 200.0 + # And exactly at the smallest h. + assert NLPTileProblem._envelope_w_floor(50.0, samples) == 200.0 + + def test_above_sample_range_clamps_to_last(self) -> None: + samples = [(200.0, 50.0), (100.0, 100.0)] + assert NLPTileProblem._envelope_w_floor(999.0, samples) == 100.0 + # And exactly at the largest h. + assert NLPTileProblem._envelope_w_floor(100.0, samples) == 100.0 + + def test_inside_range_linearly_interpolates(self) -> None: + # Between (200, 50) and (100, 100): at h=75 (midpoint) -> w=150. + samples = [(200.0, 50.0), (100.0, 100.0)] + assert NLPTileProblem._envelope_w_floor(75.0, samples) == 150.0 + + def test_three_segment_envelope_picks_correct_segment(self) -> None: + # Three Pareto points -> two interpolation segments. + samples = [(300.0, 10.0), (200.0, 20.0), (100.0, 40.0)] + # h=15 is in [10, 20]: w = 300 + (200-300)*(15-10)/(20-10) = 250. + assert NLPTileProblem._envelope_w_floor(15.0, samples) == 250.0 + # h=30 is in [20, 40]: w = 200 + (100-200)*(30-20)/(40-20) = 150. + assert NLPTileProblem._envelope_w_floor(30.0, samples) == 150.0 + + +class TestComputeEquivalenceClasses: + """Union-find groupings: tiles that share rows pull those rows together.""" + + def test_disjoint_rows_form_separate_groups(self) -> None: + # Tile A occupies rows {0, 1}; tile B occupies rows {3}. No overlap, + # so {0, 1} merge into one group and {3} stays alone. + positions = {"A": {0, 1}, "B": {3}} + groups: dict[int, int] = {} + NLPTileProblem._compute_equivalence_classes(positions, groups) + + # Indices in the same set should hash to the same group. + assert groups[0] == groups[1] + # Different sets should hash to different groups. + assert groups[0] != groups[3] + # Every input index must appear in the result. + assert set(groups.keys()) == {0, 1, 3} + + def test_shared_row_merges_two_tile_groups(self) -> None: + # Tile A on rows {0, 1}, tile B on rows {1, 2}: the shared row 1 + # forces all three indices into one equivalence class. + positions = {"A": {0, 1}, "B": {1, 2}} + groups: dict[int, int] = {} + NLPTileProblem._compute_equivalence_classes(positions, groups) + + assert groups[0] == groups[1] == groups[2] + + def test_empty_input_yields_empty_groups(self) -> None: + groups: dict[int, int] = {} + NLPTileProblem._compute_equivalence_classes({}, groups) + assert groups == {} + + +class TestFindSharingTiles: + """Sets of tiles that share at least one row or column with the target.""" + + def test_returns_only_overlapping_neighbours(self) -> None: + positions = { + "A": {0, 1}, + "B": {1, 2}, # shares row 1 with A + "C": {3}, # disjoint from A + } + # The implementation excludes the target itself. + assert NLPTileProblem._find_sharing_tiles("A", positions) == {"B"} + + def test_returns_empty_when_no_overlap(self) -> None: + positions = {"A": {0}, "B": {1}, "C": {2}} + assert NLPTileProblem._find_sharing_tiles("A", positions) == set() + + def test_target_itself_never_in_result(self) -> None: + # Tile A only occupies rows it shares with itself; result must skip A. + positions = {"A": {0, 1, 2}} + assert NLPTileProblem._find_sharing_tiles("A", positions) == set() + + +class TestParseTileFields: + """``_parse_tile_fields`` converts JSON strings/lists into typed metric values.""" + + def test_parses_required_bbox_strings_to_floats(self) -> None: + # Bbox fields arrive as space-separated strings and must come out as + # 4-tuples of floats. + out = GlobalTileSizeOptimization._parse_tile_fields( + { + "design__die__bbox": "0 0 100 200", + "design__core__bbox": "1 2 99 198", + } + ) + assert out["design__die__bbox"] == [0.0, 0.0, 100.0, 200.0] + assert out["design__core__bbox"] == [1.0, 2.0, 99.0, 198.0] + + def test_optional_scalar_fields_passed_through_as_floats(self) -> None: + out = GlobalTileSizeOptimization._parse_tile_fields( + { + "design__die__bbox": "0 0 100 100", + "design__core__bbox": "0 0 100 100", + "fabulous__pin_min_width": "12.5", + "fabulous__pin_min_height": 7.0, + "design__instance__area__stdcell": "999", + } + ) + assert out["fabulous__pin_min_width"] == 12.5 + assert out["fabulous__pin_min_height"] == 7.0 + assert out["design__instance__area__stdcell"] == 999.0 + + def test_optional_fields_omitted_when_none(self) -> None: + # Explicitly None scalars must not appear in the output. + out = GlobalTileSizeOptimization._parse_tile_fields( + { + "design__die__bbox": "0 0 100 100", + "design__core__bbox": "0 0 100 100", + "fabulous__pin_min_width": None, + } + ) + assert "fabulous__pin_min_width" not in out + + def test_clean_probes_parsed_into_nested_floats(self) -> None: + # Each probe is a list of stringly-typed numerics; output is float-of-float. + out = GlobalTileSizeOptimization._parse_tile_fields( + { + "design__die__bbox": "0 0 100 100", + "design__core__bbox": "0 0 100 100", + "fabulous__clean_probes": [["0", "0", "50", "60"], [0, 0, 70, 80]], + } + ) + assert out["fabulous__clean_probes"] == [ + [0.0, 0.0, 50.0, 60.0], + [0.0, 0.0, 70.0, 80.0], + ] + + def test_missing_bbox_raises_typeerror(self) -> None: + with pytest.raises(TypeError, match="design__die__bbox"): + GlobalTileSizeOptimization._parse_tile_fields( + {"design__core__bbox": "0 0 1 1"} + ) + + def test_non_string_bbox_raises_typeerror(self) -> None: + # Lists / numbers are not accepted — bbox must be a string. + with pytest.raises(TypeError, match="design__die__bbox"): + GlobalTileSizeOptimization._parse_tile_fields( + { + "design__die__bbox": [0, 0, 1, 1], + "design__core__bbox": "0 0 1 1", + } + ) + + +class TestLoadTileMetricsFromJson: + """End-to-end deserialisation of the per-mode metric file produced by the + exploration phase. The function partitions tiles into: + + - ``valid_data``: tiles whose exploration found a working state + (used for feasibility constraints). + - ``all_data``: every tile that has a bbox, including those that never + compiled (used for lower-bound estimates only). + """ + + def test_partitions_valid_and_all_metrics(self, tmp_path: Path) -> None: + # "good" tile compiled cleanly; "broken" tile has a bbox but + # exploration eventually gave up with "No working state found". + # The latter is included in all_data but excluded from valid_data. + payload = { + OptMode.BALANCE.value: { + "good": { + "design__die__bbox": "0 0 100 100", + "design__core__bbox": "0 0 100 100", + }, + "broken": { + "design__die__bbox": "0 0 50 50", + "design__core__bbox": "0 0 50 50", + "error_traceback": "RuntimeError: No working state found", + }, + } + } + path = tmp_path / "metrics.json" + path.write_text(json.dumps(payload)) + + valid, all_ = GlobalTileSizeOptimization._load_tile_metrics_from_json(path) + + assert OptMode.BALANCE in valid + assert "good" in valid[OptMode.BALANCE] + assert "broken" not in valid[OptMode.BALANCE] + assert "good" in all_[OptMode.BALANCE] + assert "broken" in all_[OptMode.BALANCE] + + def test_skips_entries_without_bbox(self, tmp_path: Path) -> None: + # No bbox -> nothing to constrain; the entry is dropped from both dicts. + payload = { + OptMode.BALANCE.value: { + "no_bbox": {"error": "compilation failed"}, + } + } + path = tmp_path / "metrics.json" + path.write_text(json.dumps(payload)) + + valid, all_ = GlobalTileSizeOptimization._load_tile_metrics_from_json(path) + assert valid[OptMode.BALANCE] == {} + assert all_[OptMode.BALANCE] == {} diff --git a/tests/gds_flow_test/step_test/test_tile_optimisation.py b/tests/gds_flow_test/step_test/test_tile_optimisation.py index 4dfe995ac..f4f69b4ee 100644 --- a/tests/gds_flow_test/step_test/test_tile_optimisation.py +++ b/tests/gds_flow_test/step_test/test_tile_optimisation.py @@ -1,5 +1,8 @@ """Tests for TileOptimisation step.""" +# for testing private methods +# ruff: noqa: SLF001 + from decimal import Decimal from pathlib import Path @@ -159,3 +162,267 @@ def test_mid_iteration_break_on_drc_errors( result = step.mid_iteration_break(mock_state, Checker.TrDRC()) assert result is True + + +class TestOptModeMissing: + """``OptMode._missing_`` is the entry point for tolerant string lookups. + + The CSV / config layer hands us strings with arbitrary case and the explicit + sentinel ``None``; this method maps both onto canonical members. + """ + + def test_uppercase_string_matches_lowercase_member(self) -> None: + # The enum values are lowercase but config files commonly upper-case. + assert OptMode("BALANCE") is OptMode.BALANCE + assert OptMode("Find_Min_Width") is OptMode.FIND_MIN_WIDTH + + def test_none_maps_to_no_opt(self) -> None: + # A missing config key surfaces as None; treat it as "do not optimise" + # rather than raising, so the flow can be opted out cleanly. + assert OptMode(None) is OptMode.NO_OPT + + def test_unknown_string_raises(self) -> None: + with pytest.raises(ValueError, match="not a valid OptMode"): + OptMode("not_a_real_mode") + + +class TestDirectionalHelpers: + """``_is_directional`` and ``_directional_target`` drive bracket-based search.""" + + def test_is_directional_true_for_min_width_and_min_height( + self, mock_config: Config + ) -> None: + for mode in (OptMode.FIND_MIN_WIDTH, OptMode.FIND_MIN_HEIGHT): + cfg = mock_config.copy(FABULOUS_OPT_MODE=mode) + step = TileOptimisation(cfg) + step.config = cfg + assert step._is_directional() is True + + def test_is_directional_false_for_balance_and_no_opt( + self, mock_config: Config + ) -> None: + for mode in (OptMode.BALANCE, OptMode.LARGE, OptMode.NO_OPT): + cfg = mock_config.copy(FABULOUS_OPT_MODE=mode) + step = TileOptimisation(cfg) + step.config = cfg + assert step._is_directional() is False + + def test_directional_target_returns_w_for_find_min_width( + self, mock_config: Config + ) -> None: + cfg = mock_config.copy(FABULOUS_OPT_MODE=OptMode.FIND_MIN_WIDTH) + step = TileOptimisation(cfg) + step.config = cfg + # die_area is (x0, y0, w, h); FIND_MIN_WIDTH targets w. + assert step._directional_target( + (Decimal(0), Decimal(0), Decimal("12.5"), Decimal("99.9")) + ) == Decimal("12.5") + + def test_directional_target_returns_h_for_find_min_height( + self, mock_config: Config + ) -> None: + cfg = mock_config.copy(FABULOUS_OPT_MODE=OptMode.FIND_MIN_HEIGHT) + step = TileOptimisation(cfg) + step.config = cfg + assert step._directional_target( + (Decimal(0), Decimal(0), Decimal("12.5"), Decimal("99.9")) + ) == Decimal("99.9") + + +class TestComputeNewDimensions: + """``_compute_new_dimensions`` covers BALANCE / LARGE / supertile-aspect logic.""" + + def test_balance_grows_smaller_axis(self, mock_config: Config) -> None: + # BALANCE on a non-supertile (logical=1x1) grows the smaller axis only. + cfg = mock_config.copy(FABULOUS_OPT_MODE=OptMode.BALANCE) + cfg = cfg.copy(FABULOUS_TILE_LOGICAL_WIDTH=1, FABULOUS_TILE_LOGICAL_HEIGHT=1) + step = TileOptimisation(cfg) + step.config = cfg + + # width (5) <= height (10) -> width grows by width_step. + new_w, new_h = step._compute_new_dimensions( + width=Decimal(5), + height=Decimal(10), + width_step=Decimal(2), + height_step=Decimal(3), + instance_area=Decimal(0), + core_area=Decimal(100), + ) + assert new_w == Decimal(7) + assert new_h == Decimal(10) + + # When height < width, height grows. + new_w, new_h = step._compute_new_dimensions( + width=Decimal(20), + height=Decimal(10), + width_step=Decimal(2), + height_step=Decimal(3), + instance_area=Decimal(0), + core_area=Decimal(100), + ) + assert new_w == Decimal(20) + assert new_h == Decimal(13) + + def test_large_grows_both_axes(self, mock_config: Config) -> None: + cfg = mock_config.copy(FABULOUS_OPT_MODE=OptMode.LARGE) + step = TileOptimisation(cfg) + step.config = cfg + + new_w, new_h = step._compute_new_dimensions( + width=Decimal(5), + height=Decimal(10), + width_step=Decimal(2), + height_step=Decimal(3), + instance_area=Decimal(0), + core_area=Decimal(100), + ) + assert new_w == Decimal(7) + assert new_h == Decimal(13) + + def test_instance_area_overflow_scales_both_axes(self, mock_config: Config) -> None: + # When instance area > core area, both axes scale by sqrt(ratio) + # *before* the per-axis step is applied. + cfg = mock_config.copy(FABULOUS_OPT_MODE=OptMode.LARGE) + step = TileOptimisation(cfg) + step.config = cfg + + # ratio = 400/100 = 4 -> scale = 2. + new_w, new_h = step._compute_new_dimensions( + width=Decimal(10), + height=Decimal(10), + width_step=Decimal(0), + height_step=Decimal(0), + instance_area=Decimal(400), + core_area=Decimal(100), + ) + # 10 * 2 + 0 = 20 on both axes. + assert new_w == Decimal(20) + assert new_h == Decimal(20) + + def test_supertile_balance_locks_aspect_to_logical( + self, mock_config: Config + ) -> None: + # 2x1 supertile: logical aspect 2:1 must be preserved when growing. + cfg = mock_config.copy(FABULOUS_OPT_MODE=OptMode.BALANCE) + cfg = cfg.copy(FABULOUS_TILE_LOGICAL_WIDTH=2, FABULOUS_TILE_LOGICAL_HEIGHT=1) + step = TileOptimisation(cfg) + step.config = cfg + + # cell_step = max(width_step/2, height_step/1) = max(2, 3) = 3 + # width += 3 * 2 = 6 -> 16 + # height += 3 * 1 = 3 -> 13 + new_w, new_h = step._compute_new_dimensions( + width=Decimal(10), + height=Decimal(10), + width_step=Decimal(4), + height_step=Decimal(3), + instance_area=Decimal(0), + core_area=Decimal(100), + ) + assert new_w == Decimal(16) + assert new_h == Decimal(13) + + def test_unknown_mode_raises(self, mock_config: Config) -> None: + cfg = mock_config.copy(FABULOUS_OPT_MODE=OptMode.NO_OPT) + step = TileOptimisation(cfg) + step.config = cfg + with pytest.raises(ValueError, match="Unknown FABULOUS_OPT_MODE"): + step._compute_new_dimensions( + width=Decimal(1), + height=Decimal(1), + width_step=Decimal(0), + height_step=Decimal(0), + instance_area=Decimal(0), + core_area=Decimal(1), + ) + + +class TestComputeBinarySearchDimensions: + """``_compute_binary_search_dimensions`` is the bracket-based axis search. + + Phase 1 (bracketing): no working point yet — double the target axis until + we hit ``bracket_cap``. + Phase 2 (bisecting): once a working point exists, bisect between the + largest failure and the smallest success. + """ + + def _setup( + self, mocker: MockerFixture, mock_config: Config, mode: OptMode + ) -> TileOptimisation: + # get_pitch is read inside the helper to compute pitch on the target axis. + mocker.patch( + "fabulous.fabric_generator.gds_generator.steps.tile_optimisation.get_pitch", + return_value=(Decimal("0.5"), Decimal("0.5")), + ) + cfg = mock_config.copy(FABULOUS_OPT_MODE=mode) + cfg = cfg.copy(FABULOUS_PIN_MIN_WIDTH=Decimal(1)) + cfg = cfg.copy(FABULOUS_PIN_MIN_HEIGHT=Decimal(1)) + step = TileOptimisation(cfg) + step.config = cfg + return step + + def test_doubles_target_when_no_bracket_set( + self, mocker: MockerFixture, mock_config: Config + ) -> None: + step = self._setup(mocker, mock_config, OptMode.FIND_MIN_WIDTH) + # No bracket_high yet -> target axis (width) doubles. + new_w, new_h = step._compute_binary_search_dimensions( + width=Decimal(10), height=Decimal(50) + ) + assert new_w == Decimal(20) + # Non-target axis is preserved. + assert new_h == Decimal(50) + + def test_caps_target_at_bracket_cap( + self, mocker: MockerFixture, mock_config: Config + ) -> None: + step = self._setup(mocker, mock_config, OptMode.FIND_MIN_HEIGHT) + step.bracket_cap = Decimal(15) + # Doubling 10 -> 20 would exceed cap=15, so clamp to 15. Width preserved. + new_w, new_h = step._compute_binary_search_dimensions( + width=Decimal(7), height=Decimal(10) + ) + assert new_w == Decimal(7) + assert new_h == Decimal(15) + + def test_marks_exhausted_when_at_cap( + self, mocker: MockerFixture, mock_config: Config + ) -> None: + step = self._setup(mocker, mock_config, OptMode.FIND_MIN_WIDTH) + step.bracket_cap = Decimal(10) + # Already at cap -> doubling would exceed AND current >= cap, so + # mark exhausted and keep current. + new_w, new_h = step._compute_binary_search_dimensions( + width=Decimal(10), height=Decimal(20) + ) + assert step.bracket_exhausted is True + assert new_w == Decimal(10) + assert new_h == Decimal(20) + + def test_bisects_between_low_and_high( + self, mocker: MockerFixture, mock_config: Config + ) -> None: + step = self._setup(mocker, mock_config, OptMode.FIND_MIN_WIDTH) + step.bracket_low = Decimal(10) + step.bracket_high = Decimal(20) + # Bisecting between low=10 and high=20 should land on the midpoint 15. + new_w, new_h = step._compute_binary_search_dimensions( + width=Decimal(99), height=Decimal(7) + ) + assert new_w == Decimal(15) + assert new_h == Decimal(7) + + def test_bisects_between_pin_floor_and_high( + self, mocker: MockerFixture, mock_config: Config + ) -> None: + # Only bracket_high is set (first iter worked) -> bisect between pin + # floor and bracket_high. + step = self._setup(mocker, mock_config, OptMode.FIND_MIN_WIDTH) + step.config = step.config.copy(FABULOUS_PIN_MIN_WIDTH=Decimal(2)) + step.bracket_high = Decimal(10) + step.bracket_low = None + new_w, _ = step._compute_binary_search_dimensions( + width=Decimal(50), height=Decimal(50) + ) + # (2 + 10) / 2 = 6. + assert new_w == Decimal(6) From 62fa2b6774120e5905acd99671f095fd5c35a9fe Mon Sep 17 00:00:00 2001 From: Kelvin Chung Date: Tue, 28 Apr 2026 23:00:54 +0100 Subject: [PATCH 18/48] chore: more clean up --- .../gds_generator/flows/full_fabric_flow.py | 14 ++++------ .../steps/global_tile_opitmisation.py | 2 -- .../gds_generator/steps/tile_optimisation.py | 28 +++++++------------ 3 files changed, 15 insertions(+), 29 deletions(-) diff --git a/fabulous/fabric_generator/gds_generator/flows/full_fabric_flow.py b/fabulous/fabric_generator/gds_generator/flows/full_fabric_flow.py index 7f30acd14..391662af0 100644 --- a/fabulous/fabric_generator/gds_generator/flows/full_fabric_flow.py +++ b/fabulous/fabric_generator/gds_generator/flows/full_fabric_flow.py @@ -492,10 +492,10 @@ def run(self, initial_state: State, **_kwargs: dict) -> tuple[State, list[Step]] f"Tile {tile_name} has no GDS output after recompilation" ) - # Walk up from the GDS path to find the run directory that - # contains the final/ snapshot. This is robust to varying step - # nesting depth (e.g. write-out steps inside a WhileStep wrapper). - # Note: librelane's Path is a UserString, so we wrap with pathlib. + # Walk up from the GDS path to find the run directory containing the + # final/ snapshot. Robust to varying step nesting (e.g. write-out + # steps inside a WhileStep wrapper). librelane's Path is a + # UserString, so wrap with pathlib. final_dir: Path | None = next( ( parent / "final" @@ -518,10 +518,6 @@ def run(self, initial_state: State, **_kwargs: dict) -> tuple[State, list[Step]] info(f"Created final_views symlinks for {len(tile_type_states)} tiles") - # Generate fabric-level IO pin configuration - fabric_io_config_path: Path = proj_dir / "Fabric" / "fabric_io_pin_order.yaml" - fabric_io_config_path.parent.mkdir(parents=True, exist_ok=True) - # Step 5: Run fabric stitching self.progress_bar.start_stage("Fabric Stitching") @@ -540,5 +536,5 @@ def run(self, initial_state: State, **_kwargs: dict) -> tuple[State, list[Step]] final_state: State = stitching_flow.start() self.progress_bar.end_stage() - info("\n✓ Fabric flow completed successfully!") + info("\nFabric flow completed successfully!") return final_state, [] diff --git a/fabulous/fabric_generator/gds_generator/steps/global_tile_opitmisation.py b/fabulous/fabric_generator/gds_generator/steps/global_tile_opitmisation.py index 3bc40dd6f..1dfb0703c 100644 --- a/fabulous/fabric_generator/gds_generator/steps/global_tile_opitmisation.py +++ b/fabulous/fabric_generator/gds_generator/steps/global_tile_opitmisation.py @@ -207,7 +207,6 @@ def _combined_min(name: str) -> tuple[float, float]: ] self.stdcell_areas[name] = max(stdcell_vals) if stdcell_vals else 0.0 - self._supertile_target_sample: dict[str, tuple[float, float]] = {} for tile in fabric.get_all_unique_tiles(): samples = self.tile_samples.get(tile.name, []) if not samples: @@ -240,7 +239,6 @@ def _combined_min(name: str) -> tuple[float, float]: sample_w, sample_h = best if is_supertile: - self._supertile_target_sample[tile.name] = (sample_w, sample_h) per_row_h = sample_h / n_rows per_col_w = sample_w / n_cols for row in supertile.tileMap: diff --git a/fabulous/fabric_generator/gds_generator/steps/tile_optimisation.py b/fabulous/fabric_generator/gds_generator/steps/tile_optimisation.py index c6abca7b0..f2cc85ec4 100644 --- a/fabulous/fabric_generator/gds_generator/steps/tile_optimisation.py +++ b/fabulous/fabric_generator/gds_generator/steps/tile_optimisation.py @@ -172,10 +172,6 @@ class TileOptimisation(WhileStep): raise_on_failure: bool = False - break_next_iteration: bool = False - - to_change_width: bool = False - iter_count: int = 0 last_core_area: Decimal | None = None @@ -250,11 +246,7 @@ def post_iteration_callback( self.clean_probes.append([float(v) for v in die_bbox.split()]) if self._is_directional(): - _, _, w, h = self.config["DIE_AREA"] - if self.config["FABULOUS_OPT_MODE"] == OptMode.FIND_MIN_WIDTH: - target = Decimal(w) - else: - target = Decimal(h) + target = self._directional_target(self.config["DIE_AREA"]) if full_iter_completed: # Smallest-so-far working target axis; keep the best working state. if self.bracket_high is None or target < self.bracket_high: @@ -268,7 +260,6 @@ def post_iteration_callback( self.last_working_state = post_iteration.copy() return post_iteration - self.to_change_width = not self.to_change_width self.iter_count += 1 return post_iteration @@ -432,16 +423,17 @@ def _compute_binary_search_dimensions( current_target = width if target_is_width else height non_target = height if target_is_width else width - if self.config["FABULOUS_OPT_MODE"] == OptMode.FIND_MIN_WIDTH: - pin_floor = Decimal(self.config.get("FABULOUS_PIN_MIN_WIDTH", 0)) - else: - pin_floor = Decimal(self.config.get("FABULOUS_PIN_MIN_HEIGHT", 0)) + pin_floor = Decimal( + self.config.get( + "FABULOUS_PIN_MIN_WIDTH" + if target_is_width + else "FABULOUS_PIN_MIN_HEIGHT", + 0, + ) + ) x_pitch, y_pitch = get_pitch(self.config) - if self.config["FABULOUS_OPT_MODE"] == OptMode.FIND_MIN_WIDTH: - pitch = x_pitch - else: - pitch = y_pitch + pitch = x_pitch if target_is_width else y_pitch if self.bracket_high is None: next_target = current_target * Decimal(2) From cd78932df3e9ee47dd24a91f53b0db5b4d7b15f1 Mon Sep 17 00:00:00 2001 From: Kelvin Chung Date: Tue, 28 Apr 2026 23:04:40 +0100 Subject: [PATCH 19/48] chore: more fix --- tests/fabric_definition/conftest.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/tests/fabric_definition/conftest.py b/tests/fabric_definition/conftest.py index e503ed010..96d9316ba 100644 --- a/tests/fabric_definition/conftest.py +++ b/tests/fabric_definition/conftest.py @@ -4,9 +4,8 @@ from pathlib import Path import pytest -from PIL.ImageCms import Direction -from fabulous.fabric_definition.define import IO, Side +from fabulous.fabric_definition.define import IO, Direction, Side from fabulous.fabric_definition.fabric import Fabric from fabulous.fabric_definition.port import Port from fabulous.fabric_definition.tile import Tile From 095f319c4f5c248f536922921640f7f90b3233f4 Mon Sep 17 00:00:00 2001 From: Kelvin Chung Date: Tue, 28 Apr 2026 23:15:38 +0100 Subject: [PATCH 20/48] chore: more test fix --- tests/fabric_definition/conftest.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/fabric_definition/conftest.py b/tests/fabric_definition/conftest.py index 96d9316ba..3750cdeb8 100644 --- a/tests/fabric_definition/conftest.py +++ b/tests/fabric_definition/conftest.py @@ -48,7 +48,7 @@ def make_empty_tile(name: str, ports: list[Port] | None = None) -> Tile: ) -def make_side_port(side: str, name: str = "P") -> Port: +def make_side_port(side: Side, name: str = "P") -> Port: """Construct a Port physically located on the given side.""" return Port( Direction.JUMP, @@ -59,5 +59,5 @@ def make_side_port(side: str, name: str = "P") -> Port: 1, name, IO.INPUT, - Side[side], + side, ) From 81e06ad594c3369eddaecb15ed920d2e62b75977 Mon Sep 17 00:00:00 2001 From: Kelvin Chung Date: Sun, 10 May 2026 03:31:00 +0100 Subject: [PATCH 21/48] chore: some fixes --- .../gds_generator/flows/full_fabric_flow.py | 4 ++-- .../gds_generator/flows/tile_macro_flow.py | 11 +++++------ 2 files changed, 7 insertions(+), 8 deletions(-) diff --git a/fabulous/fabric_generator/gds_generator/flows/full_fabric_flow.py b/fabulous/fabric_generator/gds_generator/flows/full_fabric_flow.py index 391662af0..759fab56e 100644 --- a/fabulous/fabric_generator/gds_generator/flows/full_fabric_flow.py +++ b/fabulous/fabric_generator/gds_generator/flows/full_fabric_flow.py @@ -272,7 +272,7 @@ def _init_compile(self, fabric: Fabric, proj_dir: Path) -> None: opt_modes, fabric.get_all_unique_tiles() ): io_config_path: Path = tile_type.tileDir.parent / "io_pin_order.yaml" - generate_IO_pin_order_config(fabric, tile_type, io_config_path) + generate_IO_pin_order_config(tile_type, io_config_path, fabric=fabric) base_config_path: Path = ( proj_dir / "Tile" / "include" / "gds_config.yaml" ) @@ -422,7 +422,7 @@ def run(self, initial_state: State, **_kwargs: dict) -> tuple[State, list[Step]] for tile_type in fabric.get_all_unique_tiles(): io_config_path: Path = tile_type.tileDir.parent / "io_pin_order.yaml" if not io_config_path.exists(): - generate_IO_pin_order_config(fabric, tile_type, io_config_path) + generate_IO_pin_order_config(tile_type, io_config_path, fabric=fabric) # Compile tiles with optimal dimensions in parallel handlers: list[tuple[Future[WorkerResult], Tile | SuperTile]] = [] diff --git a/fabulous/fabric_generator/gds_generator/flows/tile_macro_flow.py b/fabulous/fabric_generator/gds_generator/flows/tile_macro_flow.py index 07ae1c679..0c6555b64 100644 --- a/fabulous/fabric_generator/gds_generator/flows/tile_macro_flow.py +++ b/fabulous/fabric_generator/gds_generator/flows/tile_macro_flow.py @@ -25,6 +25,7 @@ write_out_steps, ) from fabulous.fabric_generator.gds_generator.helper import ( + get_offset, get_pitch, get_routing_obstructions, round_die_area, @@ -186,12 +187,10 @@ def _apply_tile_die_area_config( x_pitch, y_pitch = get_pitch(config) get_offset(config) min_x, min_y = tile_type.get_min_die_area( - x_pitch, - y_pitch, - config.get("IO_PIN_V_THINKNESS_MULT", Decimal(1)), - config.get("IO_PIN_H_THINKNESS_MULT", Decimal(1)), - x_pitch, - y_pitch, + x_pitch=x_pitch, + y_pitch=y_pitch, + x_pin_thickness_mult=config.get("IO_PIN_V_THICKNESS_MULT", Decimal(1)), + y_pin_thickness_mult=config.get("IO_PIN_H_THICKNESS_MULT", Decimal(1)), ) if opt_mode == OptMode.NO_OPT: From e985c91fce056824882fa8295d0c48a82fd3f159 Mon Sep 17 00:00:00 2001 From: Kelvin Chung Date: Tue, 12 May 2026 13:11:44 +0100 Subject: [PATCH 22/48] chore: update file path naming --- .../gds_generator/flows/full_fabric_flow.py | 9 +++++++++ .../gds_generator/flows/tile_macro_flow.py | 14 +++++++------- 2 files changed, 16 insertions(+), 7 deletions(-) diff --git a/fabulous/fabric_generator/gds_generator/flows/full_fabric_flow.py b/fabulous/fabric_generator/gds_generator/flows/full_fabric_flow.py index 759fab56e..0aa2d9040 100644 --- a/fabulous/fabric_generator/gds_generator/flows/full_fabric_flow.py +++ b/fabulous/fabric_generator/gds_generator/flows/full_fabric_flow.py @@ -95,6 +95,7 @@ def _run_tile_flow_worker( pdk: str, pdk_root: Path, models_pack: Path | None, + design_dir: Path | None = None, **custom_config_overrides: dict, ) -> WorkerResult: """Worker function to run a tile flow in a separate process. @@ -120,6 +121,9 @@ def _run_tile_flow_worker( The root directory of the PDK. models_pack : Path | None Optional path to the models pack file required for compilation. + design_dir : Path | None + Override the flow's design directory. When ``None``, the default + ``/macro/`` location is used. **custom_config_overrides : dict Any software overrides for the flow configuration. @@ -140,6 +144,7 @@ def _run_tile_flow_worker( models_pack_path=models_pack, base_config_path=base_config_path, override_config_path=override_config_path, + design_dir=design_dir, **custom_config_overrides, ) state: State = flow.start() @@ -439,6 +444,9 @@ def run(self, initial_state: State, **_kwargs: dict) -> tuple[State, list[Step]] die_area: tuple[int, int, Decimal, Decimal] = nlp_state.metrics[ "nlp__tile__area" ][tile_type.name] + optimised_design_dir: Path = ( + tile_type.tileDir.parent / "macro" / "fabric_optmised" + ) # Submit tile compilation with optimal dimensions result: Future[WorkerResult] = executor.submit( _run_tile_flow_worker, @@ -450,6 +458,7 @@ def run(self, initial_state: State, **_kwargs: dict) -> tuple[State, list[Step]] get_context().pdk, get_context().pdk_root, get_context().models_pack, + design_dir=optimised_design_dir, DIE_AREA=die_area, ) handlers.append((result, tile_type)) diff --git a/fabulous/fabric_generator/gds_generator/flows/tile_macro_flow.py b/fabulous/fabric_generator/gds_generator/flows/tile_macro_flow.py index 0c6555b64..eecf78d76 100644 --- a/fabulous/fabric_generator/gds_generator/flows/tile_macro_flow.py +++ b/fabulous/fabric_generator/gds_generator/flows/tile_macro_flow.py @@ -138,13 +138,13 @@ def __init__( custom_config_overrides["FABULOUS_OPT_MODE"] ) - default_design_dir = tile_type.tileDir.parent / "macro" / opt_mode.value - default_design_dir.mkdir(parents=True, exist_ok=True) - final_dir: str - if design_dir is None: - final_dir = str(default_design_dir.resolve()) - else: - final_dir = str(design_dir) + final_dir_path = ( + Path(str(design_dir)) + if design_dir is not None + else tile_type.tileDir.parent / "macro" / opt_mode.value + ) + final_dir_path.mkdir(parents=True, exist_ok=True) + final_dir = str(final_dir_path.resolve()) configs = [ i From 74fc58e83a237cd1e624c20bfe86dfa0bab2cfa9 Mon Sep 17 00:00:00 2001 From: Kelvin Chung Date: Tue, 12 May 2026 21:10:35 +0100 Subject: [PATCH 23/48] chore: make max worker settable --- .../fabric_generator/gds_generator/flows/full_fabric_flow.py | 4 ++-- fabulous/fabulous_settings.py | 5 +++++ fabulous/processpool.py | 4 +++- 3 files changed, 10 insertions(+), 3 deletions(-) diff --git a/fabulous/fabric_generator/gds_generator/flows/full_fabric_flow.py b/fabulous/fabric_generator/gds_generator/flows/full_fabric_flow.py index 0aa2d9040..5e6987c8e 100644 --- a/fabulous/fabric_generator/gds_generator/flows/full_fabric_flow.py +++ b/fabulous/fabric_generator/gds_generator/flows/full_fabric_flow.py @@ -272,7 +272,7 @@ def _init_compile(self, fabric: Fabric, proj_dir: Path) -> None: ] handlers: list[tuple[Future[WorkerResult], OptMode, Tile | SuperTile]] = [] - with DillProcessPoolExecutor(max_workers=2) as executor: + with DillProcessPoolExecutor(max_workers=get_context().max_worker) as executor: for opt_mode, tile_type in product( opt_modes, fabric.get_all_unique_tiles() ): @@ -431,7 +431,7 @@ def run(self, initial_state: State, **_kwargs: dict) -> tuple[State, list[Step]] # Compile tiles with optimal dimensions in parallel handlers: list[tuple[Future[WorkerResult], Tile | SuperTile]] = [] - with DillProcessPoolExecutor(max_workers=None) as executor: + with DillProcessPoolExecutor(max_workers=get_context().max_worker) as executor: for tile_type in fabric.get_all_unique_tiles(): io_config_path = tile_type.tileDir.parent / "io_pin_order.yaml" base_config_path: Path = ( diff --git a/fabulous/fabulous_settings.py b/fabulous/fabulous_settings.py index 0f5e85fee..5ce27bc24 100644 --- a/fabulous/fabulous_settings.py +++ b/fabulous/fabulous_settings.py @@ -79,6 +79,11 @@ class FABulousSettings(BaseSettings): deprecated=True, description="Deprecated, use proj_version instead", ) + max_worker: int | None = Field( + default=2, + description="Maximum number of worker processes for parallel tasks " + "(Only for full fabric flow will use multiple workers for now)", + ) # CLI variable editor: str | None = None diff --git a/fabulous/processpool.py b/fabulous/processpool.py index 34228a726..989a8da28 100644 --- a/fabulous/processpool.py +++ b/fabulous/processpool.py @@ -7,6 +7,8 @@ import dill +from fabulous.fabulous_settings import get_context + def _init_worker() -> None: """Initialize worker process to use dill for pickling.""" @@ -32,7 +34,7 @@ def __init__( ForkingPickler.dumps = dill.dumps ForkingPickler.loads = dill.loads super().__init__( - max_workers=max_workers, + max_workers=max_workers or get_context().max_worker, mp_context=multiprocessing.get_context("spawn"), initializer=_init_worker, initargs=initargs, From ba7e8172b2e921e70edb91360b83782e6449f749 Mon Sep 17 00:00:00 2001 From: Kelvin Chung <67872121+KelvinChung2000@users.noreply.github.com> Date: Wed, 13 May 2026 11:09:24 +0100 Subject: [PATCH 24/48] Update fabulous/fabulous_settings.py Co-authored-by: Marcel Jung <132259776+IAmMarcelJung@users.noreply.github.com> --- fabulous/fabulous_settings.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/fabulous/fabulous_settings.py b/fabulous/fabulous_settings.py index 5ce27bc24..b24e13347 100644 --- a/fabulous/fabulous_settings.py +++ b/fabulous/fabulous_settings.py @@ -82,7 +82,7 @@ class FABulousSettings(BaseSettings): max_worker: int | None = Field( default=2, description="Maximum number of worker processes for parallel tasks " - "(Only for full fabric flow will use multiple workers for now)", + "(Multiple workers are only used for the full fabric flow for now)", ) # CLI variable From e4ba17d2f1ef7f169dbb19d85f7b1ceaaec6f855 Mon Sep 17 00:00:00 2001 From: Kelvin Chung Date: Fri, 15 May 2026 00:08:11 +0100 Subject: [PATCH 25/48] chore: fix xor miss match --- .../gds_generator/flows/flow_define.py | 7 ++-- .../gds_generator/steps/magic_streamout.py | 32 +++++++++++++++++++ 2 files changed, 37 insertions(+), 2 deletions(-) create mode 100644 fabulous/fabric_generator/gds_generator/steps/magic_streamout.py diff --git a/fabulous/fabric_generator/gds_generator/flows/flow_define.py b/fabulous/fabric_generator/gds_generator/flows/flow_define.py index 8eedd0bf6..8de0bb054 100644 --- a/fabulous/fabric_generator/gds_generator/flows/flow_define.py +++ b/fabulous/fabric_generator/gds_generator/flows/flow_define.py @@ -20,6 +20,9 @@ from fabulous.fabric_generator.gds_generator.steps.extract_pdk_info import ( ExtractPDKInfo, ) +from fabulous.fabric_generator.gds_generator.steps.magic_streamout import ( + FABulousMagicStreamOut, +) prep_steps: list[type[Step]] = [ Verilator.Lint, @@ -86,7 +89,7 @@ ] write_out_steps: list[type[Step]] = [ - Magic.StreamOut, + FABulousMagicStreamOut, KLayout.StreamOut, Magic.WriteLEF, ] @@ -125,7 +128,7 @@ "OpenROAD.FillInsertion": ["RUN_FILL_INSERTION"], "OpenROAD.STAPostPNR": ["RUN_MCSTA"], "OpenROAD.IRDropReport": ["RUN_IRDROP_REPORT"], - "Magic.StreamOut": ["RUN_MAGIC_STREAMOUT"], + "Magic.FABulousStreamOut": ["RUN_MAGIC_STREAMOUT"], "KLayout.StreamOut": ["RUN_KLAYOUT_STREAMOUT"], "Magic.WriteLEF": ["RUN_MAGIC_WRITE_LEF"], "Magic.DRC": ["RUN_MAGIC_DRC"], diff --git a/fabulous/fabric_generator/gds_generator/steps/magic_streamout.py b/fabulous/fabric_generator/gds_generator/steps/magic_streamout.py new file mode 100644 index 000000000..675a82cce --- /dev/null +++ b/fabulous/fabric_generator/gds_generator/steps/magic_streamout.py @@ -0,0 +1,32 @@ +"""FABulous Magic StreamOut - syncs DIE_AREA env with state's design__die__bbox. + +Magic.StreamOut tries to honour ``state_in.metrics["design__die__bbox"]`` +(librelane/steps/magic.py:329-330) but TclStep.run re-calls ``prepare_env``, +which iterates ``self.config`` and overwrites ``env["DIE_AREA"]`` from config. +When TileOptimisation evolves DIE_AREA across iterations, the new value lands +in state metrics rather than Flow config, so the stock streamout renders the +stale smart-init rectangle on layer 189/4. Rebinding ``self.config["DIE_AREA"]`` +from the state metric makes ``prepare_env`` emit the right value. +""" + +from decimal import Decimal + +from librelane.state.state import State +from librelane.steps.magic import StreamOut +from librelane.steps.step import MetricsUpdate, ViewsUpdate + + +class FABulousMagicStreamOut(StreamOut): + """Magic.StreamOut variant that pulls DIE_AREA from ``design__die__bbox``.""" + + id = "Magic.FABulousStreamOut" + name = "GDSII Stream Out (Magic, FABulous)" + long_name = "Magic GDSII stream-out with DIE_AREA sync from design__die__bbox" + + def run(self, state_in: State, **kwargs: dict) -> tuple[ViewsUpdate, MetricsUpdate]: + """Rebind DIE_AREA from the state metric, then delegate to ``StreamOut``.""" + die_bbox = state_in.metrics.get("design__die__bbox") + if die_bbox is not None: + die_area = tuple(Decimal(c) for c in die_bbox.split()) + self.config = self.config.copy(DIE_AREA=die_area) + return super().run(state_in, **kwargs) From dfd4c39f99f41d2a17ce9acff00f16079d0b59f8 Mon Sep 17 00:00:00 2001 From: kelvin Date: Mon, 18 May 2026 09:24:36 +0100 Subject: [PATCH 26/48] feat: allow starting point to be set --- .../gds_generator/steps/tile_optimisation.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/fabulous/fabric_generator/gds_generator/steps/tile_optimisation.py b/fabulous/fabric_generator/gds_generator/steps/tile_optimisation.py index f2cc85ec4..d3761f2f7 100644 --- a/fabulous/fabric_generator/gds_generator/steps/tile_optimisation.py +++ b/fabulous/fabric_generator/gds_generator/steps/tile_optimisation.py @@ -115,6 +115,12 @@ def _missing_(cls, value: object) -> "OptMode": "FABULOUS_TILE_LOGICAL_WIDTH for aspect locking.", default=1, ), + Variable( + "FABULOUS_BASE_OPTIMISATION_ITERATION_START", + int, + "The base iteration number to start from for optimisations.", + default=15, + ), ] @@ -342,7 +348,8 @@ def pre_iteration_callback(self, pre_iteration: State) -> State: round_up_decimal(new_height, y_pitch), ) self.config = self.config.copy( - DRT_OPT_ITERS=5 + self.iter_count, + DRT_OPT_ITERS=self.config["FABULOUS_BASE_OPTIMISATION_ITERATION_START"] + + self.iter_count, DIE_AREA=die_area, ) self._refresh_routing_obstructions() From 3d33a23a68d0986f32a775ab3733b0b184d5ee3f Mon Sep 17 00:00:00 2001 From: kelvin Date: Mon, 18 May 2026 09:49:10 +0100 Subject: [PATCH 27/48] chore: fix test --- tests/gds_flow_test/step_test/conftest.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/gds_flow_test/step_test/conftest.py b/tests/gds_flow_test/step_test/conftest.py index 3d7af80f6..64d24d42d 100644 --- a/tests/gds_flow_test/step_test/conftest.py +++ b/tests/gds_flow_test/step_test/conftest.py @@ -1,4 +1,4 @@ -"""Fixtures for gds_generator_test tests.""" +"""Fixtures fceor gds_generator_test tests.""" from decimal import Decimal From c21796c2bc1e8dfabb4a642992c030006258f1b6 Mon Sep 17 00:00:00 2001 From: Kelvin Chung <67872121+KelvinChung2000@users.noreply.github.com> Date: Mon, 18 May 2026 10:01:05 +0100 Subject: [PATCH 28/48] chore: Fix typo in docstring for conftest.py --- tests/gds_flow_test/step_test/conftest.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/gds_flow_test/step_test/conftest.py b/tests/gds_flow_test/step_test/conftest.py index 64d24d42d..3d7af80f6 100644 --- a/tests/gds_flow_test/step_test/conftest.py +++ b/tests/gds_flow_test/step_test/conftest.py @@ -1,4 +1,4 @@ -"""Fixtures fceor gds_generator_test tests.""" +"""Fixtures for gds_generator_test tests.""" from decimal import Decimal From b43a79f0fba90907ec8c51cc7821db2bd3706b38 Mon Sep 17 00:00:00 2001 From: kelvin Date: Tue, 19 May 2026 12:33:25 +0100 Subject: [PATCH 29/48] chore: make the opt ignore default area default --- .../gds_generator/flows/tile_macro_flow.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/fabulous/fabric_generator/gds_generator/flows/tile_macro_flow.py b/fabulous/fabric_generator/gds_generator/flows/tile_macro_flow.py index eecf78d76..0cab4590a 100644 --- a/fabulous/fabric_generator/gds_generator/flows/tile_macro_flow.py +++ b/fabulous/fabric_generator/gds_generator/flows/tile_macro_flow.py @@ -4,6 +4,7 @@ from pathlib import Path from typing import Any +from dill.logger import logger from librelane.common import GenericDict from librelane.config.variable import Variable from librelane.flows.classic import Classic @@ -133,6 +134,13 @@ def __init__( "FABULOUS_OPT_MODE": OptMode(opt_mode), } + if opt_mode != OptMode.NO_OPT: + logger.info( + "Tile optimisation is enabled. " + "Setting FABULOUS_IGNORE_DEFAULT_DIE_AREA to True." + ) + tile_config_dict["FABULOUS_IGNORE_DEFAULT_DIE_AREA"] = True + if "FABULOUS_OPT_MODE" in custom_config_overrides: custom_config_overrides["FABULOUS_OPT_MODE"] = OptMode( custom_config_overrides["FABULOUS_OPT_MODE"] From de591ce7a8fba4d0dbd9bcd0a9c824d1028673fb Mon Sep 17 00:00:00 2001 From: kelvin Date: Tue, 19 May 2026 12:43:24 +0100 Subject: [PATCH 30/48] chore: improve opt mode setting --- .../gds_generator/flows/tile_macro_flow.py | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/fabulous/fabric_generator/gds_generator/flows/tile_macro_flow.py b/fabulous/fabric_generator/gds_generator/flows/tile_macro_flow.py index 0cab4590a..c6b67b614 100644 --- a/fabulous/fabric_generator/gds_generator/flows/tile_macro_flow.py +++ b/fabulous/fabric_generator/gds_generator/flows/tile_macro_flow.py @@ -134,13 +134,6 @@ def __init__( "FABULOUS_OPT_MODE": OptMode(opt_mode), } - if opt_mode != OptMode.NO_OPT: - logger.info( - "Tile optimisation is enabled. " - "Setting FABULOUS_IGNORE_DEFAULT_DIE_AREA to True." - ) - tile_config_dict["FABULOUS_IGNORE_DEFAULT_DIE_AREA"] = True - if "FABULOUS_OPT_MODE" in custom_config_overrides: custom_config_overrides["FABULOUS_OPT_MODE"] = OptMode( custom_config_overrides["FABULOUS_OPT_MODE"] @@ -175,7 +168,17 @@ def __init__( FABULOUS_TILE_LOGICAL_WIDTH=logical_width, FABULOUS_TILE_LOGICAL_HEIGHT=logical_height, ) - self.config = _apply_tile_die_area_config(self.config, tile_type, opt_mode) + final_opt_mode = self.config.get("FABULOUS_OPT_MODE", None) + if final_opt_mode and final_opt_mode != OptMode.NO_OPT: + logger.info( + f"FABulous optimisation is set to {final_opt_mode}, " + "default die area is ignored." + ) + self.config = self.config.copy(FABULOUS_IGNORE_DEFAULT_DIE_AREA=True) + + self.config = _apply_tile_die_area_config( + self.config, tile_type, final_opt_mode + ) self.config = round_die_area(self.config) if ( "ROUTING_OBSTRUCTIONS" not in self.config From 01035fa6e2c7bddae54a2be1df758800dc4e1598 Mon Sep 17 00:00:00 2001 From: Kelvin Chung Date: Tue, 19 May 2026 23:27:20 +0100 Subject: [PATCH 31/48] feat: IO count based area offset --- .../gds_generator/script/tile_io_place.py | 12 ++++ .../gds_generator/steps/tile_optimisation.py | 60 +++++++++++++++---- 2 files changed, 59 insertions(+), 13 deletions(-) diff --git a/fabulous/fabric_generator/gds_generator/script/tile_io_place.py b/fabulous/fabric_generator/gds_generator/script/tile_io_place.py index b8b2f50b4..e33d1573b 100644 --- a/fabulous/fabric_generator/gds_generator/script/tile_io_place.py +++ b/fabulous/fabric_generator/gds_generator/script/tile_io_place.py @@ -12,6 +12,7 @@ import click import odb # type: ignore[import] +import utl # type: ignore[import] import yaml from librelane.logging.logger import debug, err, info, warn from librelane.scripts.odbpy.reader import click_odb @@ -903,6 +904,17 @@ def io_place( if bterm.getSigType() not in ["POWER", "GROUND"] ] + # Expose I/O bit counts so downstream steps (e.g. TileOptimisation) can + # size the die to absorb DiodesOnPorts cells without parsing the netlist. + utl.metric_integer( + "design__io__count__input", + sum(1 for b in bterms if b.getIoType() == "INPUT"), + ) + utl.metric_integer( + "design__io__count__output", + sum(1 for b in bterms if b.getIoType() == "OUTPUT"), + ) + # generate slots DIE_AREA = reader.block.getDieArea() BLOCK_LL_X = DIE_AREA.xMin() diff --git a/fabulous/fabric_generator/gds_generator/steps/tile_optimisation.py b/fabulous/fabric_generator/gds_generator/steps/tile_optimisation.py index d3761f2f7..12b92af1a 100644 --- a/fabulous/fabric_generator/gds_generator/steps/tile_optimisation.py +++ b/fabulous/fabric_generator/gds_generator/steps/tile_optimisation.py @@ -182,6 +182,12 @@ class TileOptimisation(WhileStep): last_core_area: Decimal | None = None + # (input_bits, output_bits) captured from the design__io__count__* + # metrics that FABulousTileIOPlacement emits. Cached on the instance + # because WhileStep resets state between iterations, so the metric + # produced by the previous iteration's IO placement is otherwise lost. + diode_port_bits: tuple[int, int] | None = None + # Binary-search state for directional modes. bracket_low: Decimal | None = None @@ -191,6 +197,34 @@ class TileOptimisation(WhileStep): bracket_exhausted: bool = False + def _diode_port_area(self, site_width: Decimal, site_height: Decimal) -> Decimal: + """Estimate the flat instance-area contribution of port diodes. + + ``DiodesOnPorts`` inserts one diode per protected port bit. Each + diode cell is approximated as one placement-site footprint. The + result is added to the design's instance area so downstream sizing + absorbs the diodes in one shot rather than growing iteratively. + + Returns zero until the first iteration's ``FABulousTileIOPlacement`` + has populated ``diode_port_bits`` via ``post_iteration_callback``. + """ + mode = self.config.get("DIODE_ON_PORTS", "none") + if mode == "none" or self.diode_port_bits is None: + return Decimal(0) + + input_bits, output_bits = self.diode_port_bits + match mode: + case "in": + bits = input_bits + case "out": + bits = output_bits + case "both": + bits = input_bits + output_bits + case _: + return Decimal(0) + + return Decimal(bits) * site_width * site_height + def _is_directional(self) -> bool: """Return True when the current mode is FIND_MIN_WIDTH or FIND_MIN_HEIGHT.""" return self.config["FABULOUS_OPT_MODE"] in ( @@ -245,6 +279,11 @@ def post_iteration_callback( if (ca := post_iteration.metrics.get("design__core__area")) is not None: self.last_core_area = Decimal(ca) + io_in = post_iteration.metrics.get("design__io__count__input") + io_out = post_iteration.metrics.get("design__io__count__output") + if io_in is not None and io_out is not None: + self.diode_port_bits = (int(io_in), int(io_out)) + # DRC and antenna clean design as sample for the later optimisation. if full_iter_completed: die_bbox = post_iteration.metrics.get("design__die__bbox") @@ -305,18 +344,9 @@ def pre_iteration_callback(self, pre_iteration: State) -> State: site_height * self.config["FABULOUS_OPTIMISATION_HEIGHT_STEP_COUNT"] ) - # Diode insertion on ports adds cells that need extra area. - # Scale both step sizes so the optimiser grows the tile faster - # to accommodate the additional diode cells. - diode_on_ports: str = self.config.get("DIODE_ON_PORTS", "none") - if diode_on_ports == "both": - width_step += site_width * 8 - height_step += site_height * 8 - elif diode_on_ports in ("in", "out"): - width_step += site_width * 4 - height_step += site_height * 4 - - instance_area = Decimal(pre_iteration.metrics.get("design__instance__area", 0)) + instance_area = Decimal( + pre_iteration.metrics.get("design__instance__area", 0) + ) + self._diode_port_area(site_width, site_height) if self.last_core_area is not None: core_area = self.last_core_area else: @@ -535,7 +565,11 @@ def run( opt_mode = self.config["FABULOUS_OPT_MODE"] pin_w = Decimal(self.config.get("FABULOUS_PIN_MIN_WIDTH", 0)) pin_h = Decimal(self.config.get("FABULOUS_PIN_MIN_HEIGHT", 0)) - instance_area = Decimal(state_in.metrics.get("design__instance__area", 0)) + site_width = Decimal(state_in.metrics.get("pdk__site_width", Decimal(1))) + site_height = Decimal(state_in.metrics.get("pdk__site_height", Decimal(1))) + instance_area = Decimal( + state_in.metrics.get("design__instance__area", 0) + ) + self._diode_port_area(site_width, site_height) # Short-circuit directional modes whose pin floor already comfortably # covers the instance area - iterating would only produce a tall/narrow From 499615e8d09c69c9a2c38237d8391349e0439e85 Mon Sep 17 00:00:00 2001 From: Kelvin Chung Date: Wed, 20 May 2026 17:07:07 +0100 Subject: [PATCH 32/48] chore: fix test and pre-commit --- .../fabric_generator/gds_generator/flows/fabric_macro_flow.py | 1 - tests/gds_flow_test/script_test/test_tile_io_place.py | 1 + 2 files changed, 1 insertion(+), 1 deletion(-) diff --git a/fabulous/fabric_generator/gds_generator/flows/fabric_macro_flow.py b/fabulous/fabric_generator/gds_generator/flows/fabric_macro_flow.py index 95f854daf..fb5d49323 100644 --- a/fabulous/fabric_generator/gds_generator/flows/fabric_macro_flow.py +++ b/fabulous/fabric_generator/gds_generator/flows/fabric_macro_flow.py @@ -30,7 +30,6 @@ from fabulous.fabric_generator.gds_generator.steps.odb_connect_pdn import ( FABulousPDN, ) -from fabulous.fabulous_settings import get_context subs = { "OpenROAD.CutRows": None, diff --git a/tests/gds_flow_test/script_test/test_tile_io_place.py b/tests/gds_flow_test/script_test/test_tile_io_place.py index 24686254c..46ba2f2aa 100644 --- a/tests/gds_flow_test/script_test/test_tile_io_place.py +++ b/tests/gds_flow_test/script_test/test_tile_io_place.py @@ -27,6 +27,7 @@ def mock_modules(mocker: MockerFixture) -> None: sys.modules["odb"] = mocker.MagicMock() sys.modules["openroad"] = mocker.MagicMock() + sys.modules["utl"] = mocker.MagicMock() class TestGridToTracks: From f30479ce18bf6700a5ef8b7ccb8be9b9e91cdd7d Mon Sep 17 00:00:00 2001 From: Kelvin Chung Date: Wed, 20 May 2026 17:51:55 +0100 Subject: [PATCH 33/48] chore: another fix --- tests/gds_flow_test/script_test/conftest.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/gds_flow_test/script_test/conftest.py b/tests/gds_flow_test/script_test/conftest.py index 6b9a2427f..c3d9049ab 100644 --- a/tests/gds_flow_test/script_test/conftest.py +++ b/tests/gds_flow_test/script_test/conftest.py @@ -9,6 +9,7 @@ # Mock external dependencies BEFORE any test imports sys.modules["odb"] = MagicMock() sys.modules["openroad"] = MagicMock() +sys.modules["utl"] = MagicMock() # ============================================================================ From e03d5ca1441c512f6efe20fa44d8f35b59bc0ece Mon Sep 17 00:00:00 2001 From: Kelvin Chung Date: Wed, 20 May 2026 23:27:31 +0100 Subject: [PATCH 34/48] chore: add step timeout --- .../gds_generator/steps/tile_optimisation.py | 11 +- .../steps/timed_detailed_routing.py | 110 ++++++++++++++++++ .../gds_generator/steps/while_step.py | 6 + 3 files changed, 126 insertions(+), 1 deletion(-) create mode 100644 fabulous/fabric_generator/gds_generator/steps/timed_detailed_routing.py diff --git a/fabulous/fabric_generator/gds_generator/steps/tile_optimisation.py b/fabulous/fabric_generator/gds_generator/steps/tile_optimisation.py index 12b92af1a..7672b342e 100644 --- a/fabulous/fabric_generator/gds_generator/steps/tile_optimisation.py +++ b/fabulous/fabric_generator/gds_generator/steps/tile_optimisation.py @@ -26,6 +26,10 @@ from fabulous.fabric_generator.gds_generator.steps.tile_IO_placement import ( FABulousTileIOPlacement, ) +from fabulous.fabric_generator.gds_generator.steps.timed_detailed_routing import ( + DRTTimedOutError, + FABulousDetailedRoutingTimed, +) from fabulous.fabric_generator.gds_generator.steps.while_step import WhileStep @@ -158,7 +162,7 @@ class TileOptimisation(WhileStep): OpenROAD.GlobalRouting, OpenROAD.CheckAntennas, OpenROAD.RepairAntennas, - OpenROAD.DetailedRouting, + FABulousDetailedRoutingTimed, Odb.RemoveRoutingObstructions, OpenROAD.CheckAntennas, Checker.TrDRC, @@ -178,6 +182,11 @@ class TileOptimisation(WhileStep): raise_on_failure: bool = False + # A DRT timeout means TritonRoute couldn't route this die size within the + # budget; another iteration would only grow the die and waste another + # budget on the same problem. Let the timeout abort the whole loop. + propagate_exceptions: tuple[type[BaseException], ...] = (DRTTimedOutError,) + iter_count: int = 0 last_core_area: Decimal | None = None diff --git a/fabulous/fabric_generator/gds_generator/steps/timed_detailed_routing.py b/fabulous/fabric_generator/gds_generator/steps/timed_detailed_routing.py new file mode 100644 index 000000000..2806ac091 --- /dev/null +++ b/fabulous/fabric_generator/gds_generator/steps/timed_detailed_routing.py @@ -0,0 +1,110 @@ +"""Detailed Routing step with a hard wall-clock timeout. + +During tile-size exploration TritonRoute can spin for hours on pathological +dies. This step wraps :class:`librelane.steps.openroad.DetailedRouting` so that +if routing has not finished within ``FABULOUS_DRT_TIMEOUT`` seconds the entire +OpenROAD process group is killed and a :class:`DRTTimedOutError` is raised, +which the surrounding optimisation loop treats as a hard stop. +""" + +import contextlib +import os +import signal +import subprocess +import threading +from typing import Any + +from librelane.config.variable import Variable +from librelane.logging.logger import warn +from librelane.state.state import State +from librelane.steps import openroad as OpenROAD +from librelane.steps.step import MetricsUpdate, Step, StepError, ViewsUpdate + + +class DRTTimedOutError(StepError): + """Raised when Detailed Routing exceeds its configured wall-clock timeout.""" + + +class _GroupLeaderPopen(subprocess.Popen): + """Minimal ``subprocess.Popen`` shim accepted by librelane's stats thread. + + librelane's ``ProcessStatsThread`` checks + ``status() in {STATUS_ZOMBIE, STATUS_DEAD}`` at entry to its sampling + loop; returning ``"dead"`` (psutil's ``STATUS_DEAD`` literal) makes it + exit immediately, so no psutil-specific methods are ever called and + stats reporting is skipped for the timed DRT step. Everything else + (line streaming, ``wait()``, ``returncode``, etc.) is plain + ``subprocess.Popen`` behaviour. + """ + + def status(self) -> str: # noqa: D401, D102 + return "dead" + + +@Step.factory.register() +class FABulousDetailedRoutingTimed(OpenROAD.DetailedRouting): + """`OpenROAD.DetailedRouting` with a hard wall-clock timeout.""" + + id = "FABulous.DetailedRoutingTimed" + name = "Detailed Routing (Timed)" + + config_vars = OpenROAD.DetailedRouting.config_vars + [ + Variable( + "FABULOUS_DRT_TIMEOUT", + int, + "Maximum wall-clock seconds for Detailed Routing. The OpenROAD " + "process group is killed when this elapses and the surrounding " + "tile optimisation loop is aborted.", + default=600, + ), + ] + + def run( + self, + state_in: State, + **kwargs: Any, # noqa: ANN401 + ) -> tuple[ViewsUpdate, MetricsUpdate]: + """Run detailed routing, killing the OpenROAD process tree on timeout.""" + timeout_s = int(self.config["FABULOUS_DRT_TIMEOUT"]) + timed_out = threading.Event() + # Keep a ref to the timer so the finally-clause can cancel it. + _timer_ref: dict[str, threading.Timer] = {} + + def _spawn( + *args: Any, # noqa: ANN401 + **inner_kwargs: Any, # noqa: ANN401 + ) -> _GroupLeaderPopen: + # Put OpenROAD (and any descendants it spawns, e.g. TritonRoute + # workers) into a fresh process group so we can SIGKILL the whole + # tree with a single os.killpg call. + inner_kwargs["start_new_session"] = True + proc = _GroupLeaderPopen(*args, **inner_kwargs) + + def _kill() -> None: + timed_out.set() + warn( + f"{self.id}: Detailed Routing exceeded {timeout_s}s; " + "killing OpenROAD process group." + ) + with contextlib.suppress(ProcessLookupError, PermissionError): + os.killpg(os.getpgid(proc.pid), signal.SIGKILL) + + timer = threading.Timer(timeout_s, _kill) + timer.daemon = True + timer.start() + _timer_ref["t"] = timer + return proc + + kwargs["_popen_callable"] = _spawn + try: + return super().run(state_in, **kwargs) + except Exception as exc: + if timed_out.is_set(): + raise DRTTimedOutError( + f"Detailed Routing exceeded {timeout_s}s timeout and was killed." + ) from exc + raise + finally: + timer = _timer_ref.get("t") + if timer is not None: + timer.cancel() diff --git a/fabulous/fabric_generator/gds_generator/steps/while_step.py b/fabulous/fabric_generator/gds_generator/steps/while_step.py index 3431c6002..aee092f2c 100644 --- a/fabulous/fabric_generator/gds_generator/steps/while_step.py +++ b/fabulous/fabric_generator/gds_generator/steps/while_step.py @@ -28,6 +28,8 @@ class WhileStep(Step): break_on_failure: bool = True + propagate_exceptions: tuple[type[BaseException], ...] = () + _current_iter_dir: Path | None = None def __init_subclass__(Self): # noqa: ANN204, D105 @@ -130,6 +132,10 @@ def run( if self.mid_iteration_break(current_state, step): break except Exception as e: + if self.propagate_exceptions and isinstance( + e, self.propagate_exceptions + ): + raise if self.raise_on_failure: raise e from None if self.break_on_failure: From d4e1cc8f10b64a6eaec51ff607f50feb1aa19d76 Mon Sep 17 00:00:00 2001 From: Kelvin Chung Date: Thu, 21 May 2026 14:16:55 +0100 Subject: [PATCH 35/48] fix: fix opt mode regression --- .../gds_generator/flows/tile_macro_flow.py | 48 +++++++- .../gds_generator/steps/tile_optimisation.py | 31 +++-- fabulous/fabulous_cli/fabulous_cli.py | 114 +++++++++++++++++- 3 files changed, 177 insertions(+), 16 deletions(-) diff --git a/fabulous/fabric_generator/gds_generator/flows/tile_macro_flow.py b/fabulous/fabric_generator/gds_generator/flows/tile_macro_flow.py index c6b67b614..83331061c 100644 --- a/fabulous/fabric_generator/gds_generator/flows/tile_macro_flow.py +++ b/fabulous/fabric_generator/gds_generator/flows/tile_macro_flow.py @@ -170,11 +170,31 @@ def __init__( ) final_opt_mode = self.config.get("FABULOUS_OPT_MODE", None) if final_opt_mode and final_opt_mode != OptMode.NO_OPT: - logger.info( - f"FABulous optimisation is set to {final_opt_mode}, " - "default die area is ignored." + directional = final_opt_mode in ( + OptMode.FIND_MIN_WIDTH, + OptMode.FIND_MIN_HEIGHT, ) - self.config = self.config.copy(FABULOUS_IGNORE_DEFAULT_DIE_AREA=True) + # Directional modes minimise one axis. When the user supplies a + # DIE_AREA they are fixing the other axis, so keep that value instead + # of forcing the computed minimum. BALANCE/LARGE have no fixed axis, + # so they always fall back to full-auto sizing. + honour_user_die_area = ( + directional + and self.config.get("DIE_AREA") is not None + and not self.config["FABULOUS_IGNORE_DEFAULT_DIE_AREA"] + ) + if honour_user_die_area: + logger.info( + f"FABulous optimisation is set to {final_opt_mode}, honouring " + "the user DIE_AREA: the fixed axis is locked and the other " + "axis is minimised." + ) + else: + logger.info( + f"FABulous optimisation is set to {final_opt_mode}, " + "default die area is ignored." + ) + self.config = self.config.copy(FABULOUS_IGNORE_DEFAULT_DIE_AREA=True) self.config = _apply_tile_die_area_config( self.config, tile_type, final_opt_mode @@ -219,6 +239,14 @@ def _apply_tile_die_area_config( _, _, width, height = die_area width = Decimal(width) height = Decimal(height) + + if opt_mode == OptMode.FIND_MIN_WIDTH: + _validate_fixed_axis("height", height, min_y, tile_type.name) + return config + if opt_mode == OptMode.FIND_MIN_HEIGHT: + _validate_fixed_axis("width", width, min_x, tile_type.name) + return config + if width < min_x or height < min_y: raise FlowException( f"DIE_AREA ({width}, {height}) is smaller than the " @@ -228,6 +256,18 @@ def _apply_tile_die_area_config( return config +def _validate_fixed_axis( + axis: str, value: Decimal, minimum: Decimal, tile_name: str +) -> None: + """Reject a user-fixed directional axis below its physical IO-pin minimum.""" + if value < minimum: + raise FlowException( + f"Fixed {axis} ({value}) is smaller than the minimum required " + f"{axis} ({minimum}) to fit the IO pins of tile {tile_name}. " + f"Increase the DIE_AREA {axis} or pick a different optimisation mode." + ) + + @Flow.factory.register() class FABulousTileVHDLMacroFlowClassic(SequentialFlow): """Classic LibreLane flow for FABulous fabric generation from VHDL.""" diff --git a/fabulous/fabric_generator/gds_generator/steps/tile_optimisation.py b/fabulous/fabric_generator/gds_generator/steps/tile_optimisation.py index 7672b342e..c46d49a1e 100644 --- a/fabulous/fabric_generator/gds_generator/steps/tile_optimisation.py +++ b/fabulous/fabric_generator/gds_generator/steps/tile_optimisation.py @@ -597,18 +597,32 @@ def run( # Size the first iteration's die so it can hold the synth-reported # stdcell area at 100% utilisation. Buffer/CTS/routing slack is earned # by subsequent while-loop iterations. Per mode: - # FIND_MIN_WIDTH: keep h = pin_min_h, widen to fit the cells. - # FIND_MIN_HEIGHT: keep w = pin_min_w, grow h to fit the cells. + # FIND_MIN_WIDTH: keep h fixed (user DIE_AREA, else pin_min_h), widen. + # FIND_MIN_HEIGHT: keep w fixed (user DIE_AREA, else pin_min_w), grow h. # BALANCE / LARGE: square bbox sized to hold the cells. - if instance_area > 0 and pin_w > 0 and pin_h > 0: + die_area = self.config.get("DIE_AREA") + current_w = Decimal(die_area[2]) if die_area else Decimal(0) + current_h = Decimal(die_area[3]) if die_area else Decimal(0) + + # In directional modes the user locks the non-minimised axis via DIE_AREA + # (FABULOUS_IGNORE_DEFAULT_DIE_AREA stays False). When they do, hold that + # axis at the user value and seed only the minimised axis from the + # instance area, even if no pin floor is configured. + user_fixed = ( + die_area is not None + and self._is_directional() + and not self.config.get("FABULOUS_IGNORE_DEFAULT_DIE_AREA", False) + ) + + if instance_area > 0 and (pin_w > 0 and pin_h > 0 or user_fixed): logical_w = Decimal(self.config.get("FABULOUS_TILE_LOGICAL_WIDTH", 1)) logical_h = Decimal(self.config.get("FABULOUS_TILE_LOGICAL_HEIGHT", 1)) match opt_mode: case OptMode.FIND_MIN_WIDTH: - init_h = pin_h + init_h = current_h if user_fixed else pin_h init_w = max(pin_w, instance_area / init_h) case OptMode.FIND_MIN_HEIGHT: - init_w = pin_w + init_w = current_w if user_fixed else pin_w init_h = max(pin_h, instance_area / init_w) case _: # BALANCE, LARGE — target square cells, aspect = W:H. cell_side = (instance_area / (logical_w * logical_h)).sqrt() @@ -627,12 +641,7 @@ def run( init_w = round_up_decimal(init_w, x_pitch) init_h = round_up_decimal(init_h, y_pitch) - die_area = self.config.get("DIE_AREA") - current_w = Decimal(die_area[2]) if die_area else Decimal(0) - current_h = Decimal(die_area[3]) if die_area else Decimal(0) - - # Grow per-axis only so a user-supplied DIE_AREA override on the - # non-target axis is preserved. + # Grow per-axis only so a user-locked axis is preserved. new_w = max(current_w, init_w) new_h = max(current_h, init_h) if new_w > current_w or new_h > current_h: diff --git a/fabulous/fabulous_cli/fabulous_cli.py b/fabulous/fabulous_cli/fabulous_cli.py index fc3d9a4cd..848eeed4a 100644 --- a/fabulous/fabulous_cli/fabulous_cli.py +++ b/fabulous/fabulous_cli/fabulous_cli.py @@ -33,9 +33,11 @@ import tkinter as tk import traceback from collections.abc import Callable +from decimal import Decimal from pathlib import Path from typing import cast +import yaml from cmd2 import ( Cmd, Cmd2ArgumentParser, @@ -106,6 +108,84 @@ } +def _require_directional_mode( + opt_mode: OptMode, implied: OptMode, flag: str +) -> OptMode: + """Return the directional mode a ``--fix-*`` flag implies, or raise on conflict. + + Parameters + ---------- + opt_mode : OptMode + The mode requested via ``--optimise`` (``NO_OPT`` when unset). + implied : OptMode + The directional mode the fix flag requires. + flag : str + The flag name, used for the error message. + + Returns + ------- + OptMode + ``implied`` when compatible with ``opt_mode``. + + Raises + ------ + ValueError + If ``opt_mode`` is an explicit mode other than ``implied``. + """ + if opt_mode in (OptMode.NO_OPT, implied): + return implied + raise ValueError( + f"{flag} is only valid with --optimise {implied.value}, not {opt_mode.value}." + ) + + +def _resolve_directional_fix( + opt_mode: OptMode, + fix_width: Decimal | None, + fix_height: Decimal | None, +) -> tuple[OptMode, list[int | Decimal] | None]: + """Resolve ``--optimise`` plus ``--fix-*`` into a mode and DIE_AREA override. + + A fixed axis pins one side and minimises the other: ``--fix-width`` pairs with + ``find_min_height`` and ``--fix-height`` with ``find_min_width``. The minimised + axis starts square; ``TileOptimisation`` re-seeds it from the synthesised cell + area, so only the fixed value needs to be supplied. + + Parameters + ---------- + opt_mode : OptMode + The mode requested via ``--optimise``. + fix_width : Decimal | None + Locked tile width, if ``--fix-width`` was given. + fix_height : Decimal | None + Locked tile height, if ``--fix-height`` was given. + + Returns + ------- + tuple[OptMode, list[int | Decimal] | None] + The resolved optimisation mode and the DIE_AREA override, or ``None`` when + neither fix flag is set. + + Raises + ------ + ValueError + If both fix flags are given, or a fix flag contradicts ``--optimise``. + """ + if fix_width is not None and fix_height is not None: + raise ValueError("Specify only one of --fix-width / --fix-height.") + if fix_width is not None: + mode = _require_directional_mode( + opt_mode, OptMode.FIND_MIN_HEIGHT, "--fix-width" + ) + return mode, [0, 0, fix_width, fix_width] + if fix_height is not None: + mode = _require_directional_mode( + opt_mode, OptMode.FIND_MIN_WIDTH, "--fix-height" + ) + return mode, [0, 0, fix_height, fix_height] + return opt_mode, None + + INTO_STRING = rf""" ______ ____ __ | ____/\ | _ \ | | @@ -1400,6 +1480,22 @@ def do_gen_io_fabric(self, _args: str) -> None: help="Override config with a custom YAML config file", type=Path, ) + gds_parser.add_argument( + "--fix-width", + type=Decimal, + default=None, + metavar="WIDTH", + help="Lock the tile width to WIDTH and minimise the height " + "(implies --optimise find_min_height).", + ) + gds_parser.add_argument( + "--fix-height", + type=Decimal, + default=None, + metavar="HEIGHT", + help="Lock the tile height to HEIGHT and minimise the width " + "(implies --optimise find_min_width).", + ) gds_parser.add_argument( "--io-pin-config", help="Path to a custom IO pin config YAML file", type=Path ) @@ -1467,6 +1563,21 @@ def do_gen_tile_macro(self, args: argparse.Namespace) -> None: ) return + try: + opt_mode, die_area_override = _resolve_directional_fix( + args.optimise, args.fix_width, args.fix_height + ) + except ValueError as exc: + logger.error(str(exc)) + return + + custom_overrides: dict = {} + if args.override: + custom_overrides.update(yaml.safe_load(args.override.read_text()) or {}) + if die_area_override is not None: + custom_overrides["FABULOUS_OPT_MODE"] = opt_mode + custom_overrides["DIE_AREA"] = die_area_override + tile_dir = self.projectDir / "Tile" / args.tile pin_order_file = tile_dir / f"{args.tile}_io_pin_order.yaml" @@ -1492,9 +1603,10 @@ def do_gen_tile_macro(self, args: argparse.Namespace) -> None: tile_dir / "macro", cast("str", get_context().pdk), cast("Path", get_context().pdk_root), - optimisation=args.optimise, + optimisation=opt_mode, base_config_path=self.projectDir / "Tile" / "include" / "gds_config.yaml", config_override_path=tile_dir / "gds_config.yaml", + custom_config_overrides=custom_overrides or None, ) gen_all_tile_parser: Cmd2ArgumentParser = Cmd2ArgumentParser() From a5701fb21cbc114bdd19262ac8280c2d9df108d8 Mon Sep 17 00:00:00 2001 From: Kelvin Chung Date: Thu, 21 May 2026 14:17:03 +0100 Subject: [PATCH 36/48] chore: add test --- tests/cli_test/test_cli.py | 110 ++++++++++++++- .../flow_test/test_tile_macro_flow.py | 127 +++++++++++++++++- tests/gds_flow_test/step_test/conftest.py | 1 + .../step_test/test_tile_optimisation.py | 81 +++++++++++ 4 files changed, 316 insertions(+), 3 deletions(-) diff --git a/tests/cli_test/test_cli.py b/tests/cli_test/test_cli.py index df1ea65ba..94af69e83 100644 --- a/tests/cli_test/test_cli.py +++ b/tests/cli_test/test_cli.py @@ -5,12 +5,14 @@ """ import os +from decimal import Decimal from pathlib import Path import pytest from pytest_mock import MockerFixture -from fabulous.fabulous_cli.fabulous_cli import FABulous_CLI +from fabulous.fabric_generator.gds_generator.steps.tile_optimisation import OptMode +from fabulous.fabulous_cli.fabulous_cli import FABulous_CLI, _resolve_directional_fix from fabulous.fabulous_cli.helper import create_project, setup_logger from fabulous.fabulous_settings import init_context, reset_context from tests.cli_test.conftest import MOCK_COMPLETED_PROCESS, TILE, find_task_calls @@ -451,3 +453,109 @@ def test_start_klayout_gui_layer_file( cmd: list[str] = run_mock.call_args.args[0] assert "-l" in cmd, f"klayout invocation missing -l: {cmd}" assert Path(cmd[cmd.index("-l") + 1]) == expected_layer_file + + +class TestResolveDirectionalFix: + """``_resolve_directional_fix`` maps a fix flag onto a directional mode.""" + + def test_fix_height_implies_find_min_width(self) -> None: + mode, die_area = _resolve_directional_fix(OptMode.NO_OPT, None, Decimal(245)) + assert mode == OptMode.FIND_MIN_WIDTH + assert die_area == [0, 0, Decimal(245), Decimal(245)] + + def test_fix_width_implies_find_min_height(self) -> None: + mode, die_area = _resolve_directional_fix(OptMode.NO_OPT, Decimal(246), None) + assert mode == OptMode.FIND_MIN_HEIGHT + assert die_area == [0, 0, Decimal(246), Decimal(246)] + + def test_fix_height_consistent_with_explicit_mode(self) -> None: + mode, _ = _resolve_directional_fix(OptMode.FIND_MIN_WIDTH, None, Decimal(245)) + assert mode == OptMode.FIND_MIN_WIDTH + + def test_fix_height_conflicts_with_find_min_height(self) -> None: + with pytest.raises( + ValueError, match="only valid with --optimise find_min_width" + ): + _resolve_directional_fix(OptMode.FIND_MIN_HEIGHT, None, Decimal(245)) + + def test_fix_width_conflicts_with_balance(self) -> None: + with pytest.raises( + ValueError, match="only valid with --optimise find_min_height" + ): + _resolve_directional_fix(OptMode.BALANCE, Decimal(246), None) + + def test_both_fix_flags_raise(self) -> None: + with pytest.raises(ValueError, match="only one of"): + _resolve_directional_fix(OptMode.NO_OPT, Decimal(246), Decimal(245)) + + def test_no_fix_flags_passthrough(self) -> None: + mode, die_area = _resolve_directional_fix(OptMode.BALANCE, None, None) + assert mode == OptMode.BALANCE + assert die_area is None + + +class TestGenTileMacroFlags: + """End-to-end CLI wiring for the explicit size flags.""" + + def _patch(self, cli: FABulous_CLI, mocker: MockerFixture) -> MockerFixture: + mocker.patch( + "fabulous.fabulous_cli.fabulous_cli.is_pdk_config_set", return_value=True + ) + mocker.patch.object(cli.fabulousAPI, "gen_io_pin_order_config") + return mocker.patch.object(cli.fabulousAPI, "genTileMacro") + + def test_fix_height_sets_mode_and_die_area( + self, cli: FABulous_CLI, mocker: MockerFixture + ) -> None: + gen_macro = self._patch(cli, mocker) + + run_cmd(cli, f"gen_tile_macro {TILE} --fix-height 245") + + kwargs = gen_macro.call_args.kwargs + assert kwargs["optimisation"] == OptMode.FIND_MIN_WIDTH + overrides = kwargs["custom_config_overrides"] + assert overrides["DIE_AREA"] == [0, 0, Decimal(245), Decimal(245)] + assert overrides["FABULOUS_OPT_MODE"] == OptMode.FIND_MIN_WIDTH + + def test_fix_width_sets_mode_and_die_area( + self, cli: FABulous_CLI, mocker: MockerFixture + ) -> None: + gen_macro = self._patch(cli, mocker) + + run_cmd(cli, f"gen_tile_macro {TILE} --fix-width 246") + + kwargs = gen_macro.call_args.kwargs + assert kwargs["optimisation"] == OptMode.FIND_MIN_HEIGHT + assert kwargs["custom_config_overrides"]["DIE_AREA"] == [ + 0, + 0, + Decimal(246), + Decimal(246), + ] + + def test_fix_height_conflicting_mode_aborts( + self, cli: FABulous_CLI, mocker: MockerFixture, caplog: pytest.LogCaptureFixture + ) -> None: + gen_macro = self._patch(cli, mocker) + + run_cmd( + cli, + f"gen_tile_macro {TILE} --optimise find_min_height --fix-height 245", + ) + + gen_macro.assert_not_called() + assert "only valid with --optimise find_min_width" in caplog.text + + def test_override_merges_custom_yaml( + self, cli: FABulous_CLI, mocker: MockerFixture, tmp_path: Path + ) -> None: + gen_macro = self._patch(cli, mocker) + override = tmp_path / "ov.yaml" + override.write_text("DIODE_ON_PORTS: both\n") + + run_cmd(cli, f"gen_tile_macro {TILE} --override {override}") + + assert ( + gen_macro.call_args.kwargs["custom_config_overrides"]["DIODE_ON_PORTS"] + == "both" + ) diff --git a/tests/gds_flow_test/flow_test/test_tile_macro_flow.py b/tests/gds_flow_test/flow_test/test_tile_macro_flow.py index 6e144193e..8af7d2a2c 100644 --- a/tests/gds_flow_test/flow_test/test_tile_macro_flow.py +++ b/tests/gds_flow_test/flow_test/test_tile_macro_flow.py @@ -24,6 +24,7 @@ from fabulous.fabric_generator.gds_generator.flows.tile_macro_flow import ( FABulousTileVerilogMacroFlow, ) +from fabulous.fabric_generator.gds_generator.helper import round_up_decimal from fabulous.fabric_generator.gds_generator.steps.tile_optimisation import OptMode @@ -196,8 +197,12 @@ def test_die_area_validation_too_small( io_pin_config: Path, mock_pdk_root: dict[str, Any], ) -> None: - """Test that FlowException is raised when DIE_AREA is too small.""" - with pytest.raises(FlowException, match="DIE_AREA.*is smaller than"): + """Test that FlowException is raised when DIE_AREA is too small. + + Default opt mode is ``find_min_width``, so the fixed axis is the height; + a height below the physical minimum must be rejected. + """ + with pytest.raises(FlowException, match="smaller than the minimum"): self._create_flow( tile_type=mock_tile, io_pin_config=io_pin_config, @@ -224,6 +229,124 @@ def test_die_area_validation_valid( # 150.0 / 0.56 = 267.85... -> 268 * 0.56 = 150.08 assert flow.config["DIE_AREA"] == (0, 0, Decimal("150.08"), Decimal("150.08")) + def test_find_min_width_honors_user_fixed_height( + self, + mock_tile: MagicMock, + io_pin_config: Path, + mock_pdk_root: dict[str, Any], + ) -> None: + """find_min_width keeps the user height (fixed axis) and tolerates a + below-minimum width, since width is the axis being minimised.""" + # Physical minimum: width >= 200, height >= 100. + mock_tile.get_min_die_area.return_value = (Decimal("200.0"), Decimal("100.0")) + + flow: FABulousTileVerilogMacroFlow = self._create_flow( + tile_type=mock_tile, + io_pin_config=io_pin_config, + mock_pdk_root=mock_pdk_root, + opt_mode=OptMode.FIND_MIN_WIDTH, + DIE_AREA=(0, 0, Decimal("50.0"), Decimal("150.0")), + ) + + # User die area is honoured (not replaced by the computed minimum). + assert flow.config["FABULOUS_IGNORE_DEFAULT_DIE_AREA"] is False + assert flow.config["DIE_AREA"] == ( + 0, + 0, + round_up_decimal(Decimal("50.0"), Decimal("0.28")), + round_up_decimal(Decimal("150.0"), Decimal("0.56")), + ) + + def test_find_min_width_rejects_height_below_physical_min( + self, + mock_tile: MagicMock, + io_pin_config: Path, + mock_pdk_root: dict[str, Any], + ) -> None: + """find_min_width must reject a fixed height below the physical minimum, + even when the (minimised) width is comfortably above its minimum.""" + mock_tile.get_min_die_area.return_value = (Decimal("200.0"), Decimal("100.0")) + + with pytest.raises(FlowException, match="smaller than the minimum"): + self._create_flow( + tile_type=mock_tile, + io_pin_config=io_pin_config, + mock_pdk_root=mock_pdk_root, + opt_mode=OptMode.FIND_MIN_WIDTH, + DIE_AREA=(0, 0, Decimal("500.0"), Decimal("50.0")), + ) + + def test_find_min_height_honors_user_fixed_width( + self, + mock_tile: MagicMock, + io_pin_config: Path, + mock_pdk_root: dict[str, Any], + ) -> None: + """find_min_height keeps the user width (fixed axis) and tolerates a + below-minimum height, since height is the axis being minimised.""" + # Physical minimum: width >= 100, height >= 200. + mock_tile.get_min_die_area.return_value = (Decimal("100.0"), Decimal("200.0")) + + flow: FABulousTileVerilogMacroFlow = self._create_flow( + tile_type=mock_tile, + io_pin_config=io_pin_config, + mock_pdk_root=mock_pdk_root, + opt_mode=OptMode.FIND_MIN_HEIGHT, + DIE_AREA=(0, 0, Decimal("150.0"), Decimal("50.0")), + ) + + assert flow.config["FABULOUS_IGNORE_DEFAULT_DIE_AREA"] is False + assert flow.config["DIE_AREA"] == ( + 0, + 0, + round_up_decimal(Decimal("150.0"), Decimal("0.28")), + round_up_decimal(Decimal("50.0"), Decimal("0.56")), + ) + + def test_find_min_height_rejects_width_below_physical_min( + self, + mock_tile: MagicMock, + io_pin_config: Path, + mock_pdk_root: dict[str, Any], + ) -> None: + """find_min_height must reject a fixed width below the physical minimum.""" + mock_tile.get_min_die_area.return_value = (Decimal("100.0"), Decimal("200.0")) + + with pytest.raises(FlowException, match="smaller than the minimum"): + self._create_flow( + tile_type=mock_tile, + io_pin_config=io_pin_config, + mock_pdk_root=mock_pdk_root, + opt_mode=OptMode.FIND_MIN_HEIGHT, + DIE_AREA=(0, 0, Decimal("50.0"), Decimal("500.0")), + ) + + def test_balance_ignores_user_die_area( + self, + mock_tile: MagicMock, + io_pin_config: Path, + mock_pdk_root: dict[str, Any], + ) -> None: + """balance has no fixed axis, so a user DIE_AREA is ignored in favour of + the computed minimum (full-auto sizing).""" + mock_tile.get_min_die_area.return_value = (Decimal("100.0"), Decimal("100.0")) + + flow: FABulousTileVerilogMacroFlow = self._create_flow( + tile_type=mock_tile, + io_pin_config=io_pin_config, + mock_pdk_root=mock_pdk_root, + opt_mode=OptMode.BALANCE, + DIE_AREA=(0, 0, Decimal("300.0"), Decimal("300.0")), + ) + + assert flow.config["FABULOUS_IGNORE_DEFAULT_DIE_AREA"] is True + assert flow.config["DIE_AREA"] == ( + 0, + 0, + round_up_decimal(Decimal("100.0"), Decimal("0.28")), + round_up_decimal(Decimal("100.0"), Decimal("0.56")), + ) + def test_no_opt_mode_requires_die_area( self, mock_tile: MagicMock, diff --git a/tests/gds_flow_test/step_test/conftest.py b/tests/gds_flow_test/step_test/conftest.py index 3d7af80f6..f4ecb43e5 100644 --- a/tests/gds_flow_test/step_test/conftest.py +++ b/tests/gds_flow_test/step_test/conftest.py @@ -35,6 +35,7 @@ def mock_config() -> Config: # type: ignore[name-defined] "IGNORE_DEFAULT_DIE_AREA": False, "FABULOUS_OPTIMISATION_WIDTH_STEP_COUNT": 5, "FABULOUS_OPTIMISATION_HEIGHT_STEP_COUNT": 5, + "FABULOUS_BASE_OPTIMISATION_ITERATION_START": 15, "FABULOUS_IO_MIN_WIDTH": 1, "FABULOUS_IO_MIN_HEIGHT": 1, "FABULOUS_OPT_MODE": OptMode.FIND_MIN_WIDTH, diff --git a/tests/gds_flow_test/step_test/test_tile_optimisation.py b/tests/gds_flow_test/step_test/test_tile_optimisation.py index f4f69b4ee..fba30dbf7 100644 --- a/tests/gds_flow_test/step_test/test_tile_optimisation.py +++ b/tests/gds_flow_test/step_test/test_tile_optimisation.py @@ -164,6 +164,87 @@ def test_mid_iteration_break_on_drc_errors( assert result is True +class TestRunUserFixedSmartInit: + """``run`` smart-init for directional modes with a user-locked axis. + + When ``FABULOUS_IGNORE_DEFAULT_DIE_AREA`` is False the user has supplied a + DIE_AREA whose non-minimised axis must be held constant, while the minimised + axis is seeded from the synthesised instance area so the bracket search + starts somewhere feasible. + """ + + def _prepare( + self, + mocker: MockerFixture, + config: Config, + die_area: tuple[Decimal, Decimal, Decimal, Decimal], + ) -> TileOptimisation: + mocker.patch( + "fabulous.fabric_generator.gds_generator.steps.tile_optimisation.get_pitch", + return_value=(Decimal("0.5"), Decimal("0.5")), + ) + mocker.patch( + "fabulous.fabric_generator.gds_generator.steps.tile_optimisation.WhileStep.run", + return_value=({}, {}), + ) + cfg = config.copy(FABULOUS_IGNORE_DEFAULT_DIE_AREA=False, DIE_AREA=die_area) + step = TileOptimisation(cfg) + step.config = cfg + return step + + def test_find_min_width_locks_height_and_seeds_width( + self, mocker: MockerFixture, mock_config: Config, mock_state: State + ) -> None: + # Tiny start width, fixed height 100; instance area 5000 needs ~50 width. + step = self._prepare( + mocker, + mock_config.copy(FABULOUS_OPT_MODE=OptMode.FIND_MIN_WIDTH), + (Decimal(0), Decimal(0), Decimal(1), Decimal(100)), + ) + mock_state.metrics["design__instance__area"] = 5000 + + step.run(mock_state) + + die = step.config["DIE_AREA"] + assert die[3] == Decimal(100) # height locked to the user value + assert die[2] >= Decimal(50) # width seeded to hold the cells + + def test_find_min_height_locks_width_and_seeds_height( + self, mocker: MockerFixture, mock_config: Config, mock_state: State + ) -> None: + step = self._prepare( + mocker, + mock_config.copy(FABULOUS_OPT_MODE=OptMode.FIND_MIN_HEIGHT), + (Decimal(0), Decimal(0), Decimal(100), Decimal(1)), + ) + mock_state.metrics["design__instance__area"] = 5000 + + step.run(mock_state) + + die = step.config["DIE_AREA"] + assert die[2] == Decimal(100) # width locked to the user value + assert die[3] >= Decimal(50) # height seeded to hold the cells + + def test_user_fixed_height_not_grown_by_pin_floor( + self, mocker: MockerFixture, mock_config: Config, mock_state: State + ) -> None: + # A larger pin floor must not override the user-locked fixed axis. + step = self._prepare( + mocker, + mock_config.copy( + FABULOUS_OPT_MODE=OptMode.FIND_MIN_WIDTH, + FABULOUS_PIN_MIN_WIDTH=Decimal(1), + FABULOUS_PIN_MIN_HEIGHT=Decimal(500), + ), + (Decimal(0), Decimal(0), Decimal(10), Decimal(100)), + ) + mock_state.metrics["design__instance__area"] = 5000 + + step.run(mock_state) + + assert step.config["DIE_AREA"][3] == Decimal(100) + + class TestOptModeMissing: """``OptMode._missing_`` is the entry point for tolerant string lookups. From cae572b8d551ca92352d3cc73d2af6f194f56e35 Mon Sep 17 00:00:00 2001 From: Kelvin Chung Date: Thu, 21 May 2026 22:38:45 +0100 Subject: [PATCH 37/48] chore: clean up round 1 --- .github/workflows/nightly_full_auto_flow.yml | 212 ------------------ fabulous/fabric_definition/supertile.py | 5 +- .../gds_generator/flows/full_fabric_flow.py | 14 +- .../gds_generator/flows/tile_macro_flow.py | 7 +- .../steps/global_tile_opitmisation.py | 16 +- .../gds_generator/steps/magic_streamout.py | 24 +- .../gds_generator/steps/tile_optimisation.py | 16 +- .../steps/timed_detailed_routing.py | 8 +- .../gds_generator/steps/while_step.py | 2 +- fabulous/fabulous_cli/fabulous_cli.py | 2 +- fabulous/fabulous_settings.py | 6 +- fabulous/processpool.py | 3 +- 12 files changed, 59 insertions(+), 256 deletions(-) delete mode 100644 .github/workflows/nightly_full_auto_flow.yml diff --git a/.github/workflows/nightly_full_auto_flow.yml b/.github/workflows/nightly_full_auto_flow.yml deleted file mode 100644 index a3ae2b546..000000000 --- a/.github/workflows/nightly_full_auto_flow.yml +++ /dev/null @@ -1,212 +0,0 @@ -name: Nightly Full Auto Flow Test - -on: - schedule: - # Run every night at 02:00 UTC (offset from dependency test at 00:00) - - cron: '0 2 * * *' - workflow_dispatch: # Allow manual triggering - -concurrency: - group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true - -jobs: - full_auto_flow: - name: Full auto GDS flow (end-to-end, ihp-sg13g2) - runs-on: ubuntu-latest - permissions: - issues: write - contents: read - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - steps: - - name: Checkout - uses: actions/checkout@v5 - - - name: Install Nix - uses: nixbuild/nix-quick-install-action@v34 - with: - nix_version: "2.31.2" - nix_conf: | - substituters = https://cache.nixos.org https://nix-cache.fossi-foundation.org - extra-trusted-public-keys = nix-cache.fossi-foundation.org:3+K59iFwXqKsL7BNu6Guy0v+uTlwsxYQxjspXzqLYQs= - - - name: Free up disk space - run: | - sudo rm -rf /usr/share/dotnet - sudo rm -rf /usr/local/lib/android - sudo rm -rf /opt/ghc - sudo rm -rf /opt/hostedtoolcache - sudo rm -rf /usr/local/share/boost - sudo rm -rf /usr/lib/jvm - sudo rm -rf /usr/share/swift - - - name: Build Nix devshell - run: nix build - - - name: Create demo project and run full auto flow - id: run_flow - continue-on-error: true - run: | - nix develop --command bash -c ' - set -euo pipefail - - echo "=== Tool versions ===" - FABulous --version - openroad -version - yosys --version - - echo "=== Creating demo Verilog project ===" - FABulous -c /tmp/fab_demo -w verilog - - echo "=== Running full auto flow (exploration → NLP → recompilation → stitching) ===" - cd /tmp/fab_demo - FABulous /tmp/fab_demo <&1 | tee /tmp/flow_output.txt - - echo "exit_code=${PIPESTATUS[0]}" >> $GITHUB_OUTPUT - - - name: Check flow results - id: check_results - run: | - if [ "${{ steps.run_flow.outputs.exit_code }}" != "0" ]; then - echo "flow_failed=true" >> $GITHUB_OUTPUT - else - echo "flow_failed=false" >> $GITHUB_OUTPUT - fi - - - name: Verify final output artifacts - id: check_artifacts - if: steps.check_results.outputs.flow_failed == 'false' - continue-on-error: true - run: | - echo "=== Checking output artifacts ===" - FINAL_VIEWS="/tmp/fab_demo/Fabric/macro/final_views" - if [ -d "$FINAL_VIEWS" ]; then - echo "final_views directory exists at $FINAL_VIEWS" - echo "Contents:" - ls -lR "$FINAL_VIEWS" - else - echo "ERROR: final_views directory not found" - ls -lR /tmp/fab_demo/Fabric/macro/ 2>/dev/null || true - exit 1 - fi - - # Check for GDS output - GDS_FILE=$(find "$FINAL_VIEWS" -name "*.gds" -print -quit 2>/dev/null) - if [ -n "$GDS_FILE" ]; then - echo "GDS output found: $GDS_FILE" - else - echo "ERROR: No GDS file found in final_views" - exit 1 - fi - - # Check for LEF output - LEF_FILE=$(find "$FINAL_VIEWS" -name "*.lef" -print -quit 2>/dev/null) - if [ -n "$LEF_FILE" ]; then - echo "LEF output found: $LEF_FILE" - else - echo "ERROR: No LEF file found in final_views" - exit 1 - fi - - # Copy tile optimisation summary if it exists - SUMMARY=$(find /tmp/fab_demo -name "tile_optimisation_summary.json" -print -quit 2>/dev/null) - if [ -n "$SUMMARY" ]; then - cp "$SUMMARY" /tmp/tile_optimisation_summary.json - fi - - - name: Create issue on failure - if: steps.check_results.outputs.flow_failed == 'true' || steps.check_artifacts.outcome == 'failure' - uses: actions/github-script@v7 - with: - script: | - const fs = require('fs'); - - let flowOutput = ''; - try { - const fullOutput = fs.readFileSync('/tmp/flow_output.txt', 'utf8'); - flowOutput = fullOutput.slice(-5000); - if (fullOutput.length > 5000) { - flowOutput = '... (truncated)\n\n' + flowOutput; - } - } catch (error) { - flowOutput = 'Could not read flow output'; - } - - const date = new Date().toISOString().split('T')[0]; - const title = `Nightly Full Auto Flow Failed - ${date}`; - - const body = `## Nightly Full Auto GDS Flow Test Failed - - The nightly end-to-end test of the full automatic eFPGA macro generation flow has failed. - - **Mode:** Full (exploration → NLP optimization → recompilation → fabric stitching) - **PDK:** ihp-sg13g2 (auto-resolved via ciel) - **Workflow Run:** ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} - - ### Flow Output (last 5000 characters) - -
- Click to expand flow output - - \`\`\` - ${flowOutput} - \`\`\` - -
- - ### Action Items - - - [ ] Review the failing step in the flow output - - [ ] Determine if the failure is due to: - - PDK / ciel resolution issues - - OpenROAD / librelane breaking changes - - Tile compilation errors (exploration or recompilation) - - NLP solver convergence failure - - Fabric stitching failure - - FABulous code regression - - [ ] Fix the issue and verify locally with \`run_FABulous_eFPGA_macro\` - - [ ] Close this issue once resolved - - --- - *This issue was automatically created by the nightly full auto flow test workflow.*`; - - const issues = await github.rest.issues.listForRepo({ - owner: context.repo.owner, - repo: context.repo.repo, - state: 'open', - labels: ['nightly-test-failure'], - per_page: 100 - }); - - const todayIssue = issues.data.find(issue => issue.title === title); - - if (!todayIssue) { - await github.rest.issues.create({ - owner: context.repo.owner, - repo: context.repo.repo, - title: title, - body: body, - labels: ['nightly-test-failure', 'gds-flow', 'automated'] - }); - console.log('Created new issue for flow failure'); - } else { - console.log('Issue already exists for today, skipping creation'); - } - - - name: Upload flow artifacts - if: always() - uses: actions/upload-artifact@v4 - with: - name: full-auto-flow-results - path: | - /tmp/flow_output.txt - /tmp/tile_optimisation_summary.json - if-no-files-found: warn diff --git a/fabulous/fabric_definition/supertile.py b/fabulous/fabric_definition/supertile.py index 50b1a1435..cf515f8d1 100644 --- a/fabulous/fabric_definition/supertile.py +++ b/fabulous/fabric_definition/supertile.py @@ -134,8 +134,9 @@ def get_min_die_area( ) -> tuple[Decimal, Decimal]: """Calculate minimum SuperTile dimensions based on IO pin track requirements. - Aggregates IO pins from all constituent tiles on the outer edges - and calculates the minimum physical width and height required. + Takes the maximum per-side IO pin count across all constituent subtiles + as a conservative upper bound, then derives the minimum physical width + and height required. See ``Tile.get_min_die_area`` for the track-based derivation. diff --git a/fabulous/fabric_generator/gds_generator/flows/full_fabric_flow.py b/fabulous/fabric_generator/gds_generator/flows/full_fabric_flow.py index 5e6987c8e..7075e9133 100644 --- a/fabulous/fabric_generator/gds_generator/flows/full_fabric_flow.py +++ b/fabulous/fabric_generator/gds_generator/flows/full_fabric_flow.py @@ -20,7 +20,7 @@ from librelane.config.flow import flow_common_variables from librelane.config.variable import Variable from librelane.flows.classic import Classic -from librelane.flows.flow import Flow, FlowException +from librelane.flows.flow import Flow, FlowError, FlowException from librelane.logging.logger import err, info from librelane.state.design_format import DesignFormat from librelane.state.state import State @@ -66,12 +66,6 @@ description="Stop after NLP optimisation, skip recompilation and stitching", default=False, ), - Variable( - "FABULOUS_NLP_AREA_MARGIN", - float, - description="Area margin for NLP constraint (0.05 = 5% slack)", - default=0.0, - ), ] ) @@ -148,9 +142,7 @@ def _run_tile_flow_worker( **custom_config_overrides, ) state: State = flow.start() - except Exception: # noqa: BLE001 - # Try to recover the state from disk - deferred errors (e.g. XOR - # differences) raise after the state has already been saved. + except FlowError: if flow is not None and flow.run_dir is not None: latest_state = get_latest_file(flow.run_dir, "state_out.json") if latest_state is not None: @@ -445,7 +437,7 @@ def run(self, initial_state: State, **_kwargs: dict) -> tuple[State, list[Step]] "nlp__tile__area" ][tile_type.name] optimised_design_dir: Path = ( - tile_type.tileDir.parent / "macro" / "fabric_optmised" + tile_type.tileDir.parent / "macro" / "fabric_optimised" ) # Submit tile compilation with optimal dimensions result: Future[WorkerResult] = executor.submit( diff --git a/fabulous/fabric_generator/gds_generator/flows/tile_macro_flow.py b/fabulous/fabric_generator/gds_generator/flows/tile_macro_flow.py index 83331061c..4d67865a5 100644 --- a/fabulous/fabric_generator/gds_generator/flows/tile_macro_flow.py +++ b/fabulous/fabric_generator/gds_generator/flows/tile_macro_flow.py @@ -4,13 +4,12 @@ from pathlib import Path from typing import Any -from dill.logger import logger from librelane.common import GenericDict from librelane.config.variable import Variable from librelane.flows.classic import Classic from librelane.flows.flow import Flow, FlowException from librelane.flows.sequential import SequentialFlow -from librelane.logging.logger import err, warn +from librelane.logging.logger import err, info, warn from librelane.state.state import State from librelane.steps import odb as Odb from librelane.steps import openroad as OpenROAD @@ -184,13 +183,13 @@ def __init__( and not self.config["FABULOUS_IGNORE_DEFAULT_DIE_AREA"] ) if honour_user_die_area: - logger.info( + info( f"FABulous optimisation is set to {final_opt_mode}, honouring " "the user DIE_AREA: the fixed axis is locked and the other " "axis is minimised." ) else: - logger.info( + info( f"FABulous optimisation is set to {final_opt_mode}, " "default die area is ignored." ) diff --git a/fabulous/fabric_generator/gds_generator/steps/global_tile_opitmisation.py b/fabulous/fabric_generator/gds_generator/steps/global_tile_opitmisation.py index 1dfb0703c..ca92bf73a 100644 --- a/fabulous/fabric_generator/gds_generator/steps/global_tile_opitmisation.py +++ b/fabulous/fabric_generator/gds_generator/steps/global_tile_opitmisation.py @@ -351,7 +351,7 @@ def _combined_min(name: str) -> tuple[float, float]: ( self.col_group_to_var[self.col_groups[col]], self.row_group_to_var[self.row_groups[row]], - self.min_areas.get(name, float("inf")) * margin, + self.min_areas[name] * margin, ) for name, col, row in tile_constraints ] @@ -359,7 +359,7 @@ def _combined_min(name: str) -> tuple[float, float]: ( [self.col_group_to_var[self.col_groups[c]] for c in st_cols], [self.row_group_to_var[self.row_groups[r]] for r in st_rows], - self.min_areas.get(name, float("inf")) * margin, + self.min_areas[name] * margin, ) for name, st_cols, st_rows in supertile_constraints ] @@ -598,6 +598,12 @@ class GlobalTileSizeOptimization(Step): Path, description="Path to the FABulous project directory", ), + Variable( + "FABULOUS_NLP_AREA_MARGIN", + float, + description="Area margin for NLP constraint (0.05 = 5% slack)", + default=0.05, + ), ] inputs = [] @@ -621,8 +627,8 @@ def _parse_tile_fields(data: dict) -> dict[str, Any]: Raw metric fields for a tile from the JSON file, including required bbox fields and optional pin minimums. - Return - ------ + Returns + ------- dict[str, Any] Parsed fields including: - "design__die__bbox": [x0, y0, x1, y1] @@ -725,7 +731,7 @@ def run(self, state_in: State, **_kwargs: str) -> tuple[ViewsUpdate, MetricsUpda valid_metrics = self.config["TILE_OPT_INFO"] all_metrics = valid_metrics - area_margin = self.config.get("FABULOUS_NLP_AREA_MARGIN", 0.0) + area_margin = self.config["FABULOUS_NLP_AREA_MARGIN"] info(f"Using area margin: {area_margin:.1%}") problem = NLPTileProblem( fabric, valid_metrics, all_metrics, area_margin=area_margin diff --git a/fabulous/fabric_generator/gds_generator/steps/magic_streamout.py b/fabulous/fabric_generator/gds_generator/steps/magic_streamout.py index 675a82cce..6b19fb35a 100644 --- a/fabulous/fabric_generator/gds_generator/steps/magic_streamout.py +++ b/fabulous/fabric_generator/gds_generator/steps/magic_streamout.py @@ -1,30 +1,36 @@ """FABulous Magic StreamOut - syncs DIE_AREA env with state's design__die__bbox. -Magic.StreamOut tries to honour ``state_in.metrics["design__die__bbox"]`` -(librelane/steps/magic.py:329-330) but TclStep.run re-calls ``prepare_env``, -which iterates ``self.config`` and overwrites ``env["DIE_AREA"]`` from config. +Magic.StreamOut tries to honour `state_in.metrics["design__die__bbox"]` +(librelane/steps/magic.py:329-330) but TclStep.run re-calls `prepare_env`, +which iterates `self.config` and overwrites `env["DIE_AREA"]` from config. When TileOptimisation evolves DIE_AREA across iterations, the new value lands in state metrics rather than Flow config, so the stock streamout renders the -stale smart-init rectangle on layer 189/4. Rebinding ``self.config["DIE_AREA"]`` -from the state metric makes ``prepare_env`` emit the right value. +stale smart-init rectangle on layer 189/4. Rebinding `self.config["DIE_AREA"]` +from the state metric makes `prepare_env` emit the right value. """ from decimal import Decimal +from typing import Any from librelane.state.state import State from librelane.steps.magic import StreamOut -from librelane.steps.step import MetricsUpdate, ViewsUpdate +from librelane.steps.step import MetricsUpdate, Step, ViewsUpdate +@Step.factory.register() class FABulousMagicStreamOut(StreamOut): - """Magic.StreamOut variant that pulls DIE_AREA from ``design__die__bbox``.""" + """Magic.StreamOut variant that pulls DIE_AREA from `design__die__bbox`.""" id = "Magic.FABulousStreamOut" name = "GDSII Stream Out (Magic, FABulous)" long_name = "Magic GDSII stream-out with DIE_AREA sync from design__die__bbox" - def run(self, state_in: State, **kwargs: dict) -> tuple[ViewsUpdate, MetricsUpdate]: - """Rebind DIE_AREA from the state metric, then delegate to ``StreamOut``.""" + def run( + self, + state_in: State, + **kwargs: Any, # noqa: ANN401 + ) -> tuple[ViewsUpdate, MetricsUpdate]: + """Rebind DIE_AREA from the state metric, then delegate to `StreamOut`.""" die_bbox = state_in.metrics.get("design__die__bbox") if die_bbox is not None: die_area = tuple(Decimal(c) for c in die_bbox.split()) diff --git a/fabulous/fabric_generator/gds_generator/steps/tile_optimisation.py b/fabulous/fabric_generator/gds_generator/steps/tile_optimisation.py index c46d49a1e..59c3619b1 100644 --- a/fabulous/fabric_generator/gds_generator/steps/tile_optimisation.py +++ b/fabulous/fabric_generator/gds_generator/steps/tile_optimisation.py @@ -4,7 +4,9 @@ from enum import StrEnum from typing import cast +from librelane.common import GenericImmutableDict from librelane.config.variable import Variable +from librelane.flows.flow import FlowException from librelane.logging.logger import info from librelane.state.design_format import DesignFormat from librelane.state.state import State @@ -512,8 +514,6 @@ def post_loop_callback(self, state: State) -> State: # noqa: ARG002 # State.metrics is an immutable dict; rebuild the state with an added # clean_probes entry rather than mutating in place. - from librelane.common import GenericImmutableDict - last = self.last_working_state result = State( last, @@ -541,7 +541,7 @@ def post_loop_callback(self, state: State) -> State: # noqa: ARG002 return result - def mid_iteration_break(self, state: State, step: type[Step]) -> bool: + def mid_iteration_break(self, state: State, step: Step) -> bool: """Mid iteration callback.""" if not isinstance(step, Checker.TrDRC): return False @@ -620,9 +620,19 @@ def run( match opt_mode: case OptMode.FIND_MIN_WIDTH: init_h = current_h if user_fixed else pin_h + if init_h <= 0: + raise FlowException( + "FIND_MIN_WIDTH needs a positive locked height: set a " + "non-zero DIE_AREA height or FABULOUS_PIN_MIN_HEIGHT." + ) init_w = max(pin_w, instance_area / init_h) case OptMode.FIND_MIN_HEIGHT: init_w = current_w if user_fixed else pin_w + if init_w <= 0: + raise FlowException( + "FIND_MIN_HEIGHT needs a positive locked width: set a " + "non-zero DIE_AREA width or FABULOUS_PIN_MIN_WIDTH." + ) init_h = max(pin_h, instance_area / init_w) case _: # BALANCE, LARGE — target square cells, aspect = W:H. cell_side = (instance_area / (logical_w * logical_h)).sqrt() diff --git a/fabulous/fabric_generator/gds_generator/steps/timed_detailed_routing.py b/fabulous/fabric_generator/gds_generator/steps/timed_detailed_routing.py index 2806ac091..2c0bb88d0 100644 --- a/fabulous/fabric_generator/gds_generator/steps/timed_detailed_routing.py +++ b/fabulous/fabric_generator/gds_generator/steps/timed_detailed_routing.py @@ -67,8 +67,7 @@ def run( """Run detailed routing, killing the OpenROAD process tree on timeout.""" timeout_s = int(self.config["FABULOUS_DRT_TIMEOUT"]) timed_out = threading.Event() - # Keep a ref to the timer so the finally-clause can cancel it. - _timer_ref: dict[str, threading.Timer] = {} + timers: list[threading.Timer] = [] def _spawn( *args: Any, # noqa: ANN401 @@ -92,7 +91,7 @@ def _kill() -> None: timer = threading.Timer(timeout_s, _kill) timer.daemon = True timer.start() - _timer_ref["t"] = timer + timers.append(timer) return proc kwargs["_popen_callable"] = _spawn @@ -105,6 +104,5 @@ def _kill() -> None: ) from exc raise finally: - timer = _timer_ref.get("t") - if timer is not None: + for timer in timers: timer.cancel() diff --git a/fabulous/fabric_generator/gds_generator/steps/while_step.py b/fabulous/fabric_generator/gds_generator/steps/while_step.py index aee092f2c..55fcf8956 100644 --- a/fabulous/fabric_generator/gds_generator/steps/while_step.py +++ b/fabulous/fabric_generator/gds_generator/steps/while_step.py @@ -68,7 +68,7 @@ def condition(self, _state: State) -> bool: """Return true if the condition is met and keep the loop going.""" return True - def mid_iteration_break(self, _state: State, _step: type[Step]) -> bool: + def mid_iteration_break(self, _state: State, _step: Step) -> bool: """Return True to break the current iteration and start the next iteration. If True, breaks the current iteration and starts the next iteration. Breaking diff --git a/fabulous/fabulous_cli/fabulous_cli.py b/fabulous/fabulous_cli/fabulous_cli.py index 848eeed4a..25bdeb5fa 100644 --- a/fabulous/fabulous_cli/fabulous_cli.py +++ b/fabulous/fabulous_cli/fabulous_cli.py @@ -1701,8 +1701,8 @@ def do_gen_fabric_macro(self, *_args: str) -> None: help="Area margin for NLP constraint (default: 0.05 = 5%%)", ) - @with_argparser(eFPGA_macro_parser) @with_category(CMD_FABRIC_FLOW) + @with_argparser(eFPGA_macro_parser) def do_run_FABulous_eFPGA_macro(self, args: argparse.Namespace) -> None: """Run the full FABulous eFPGA macro generation flow.""" if not is_pdk_config_set(): diff --git a/fabulous/fabulous_settings.py b/fabulous/fabulous_settings.py index b24e13347..4ccf19540 100644 --- a/fabulous/fabulous_settings.py +++ b/fabulous/fabulous_settings.py @@ -81,8 +81,10 @@ class FABulousSettings(BaseSettings): ) max_worker: int | None = Field( default=2, - description="Maximum number of worker processes for parallel tasks " - "(Multiple workers are only used for the full fabric flow for now)", + ge=0, + description="Maximum number of worker processes for parallel tasks. " + "0 or None means use the system default. (Multiple workers are only " + "used for the full fabric flow for now)", ) # CLI variable diff --git a/fabulous/processpool.py b/fabulous/processpool.py index 989a8da28..5a68b1b9d 100644 --- a/fabulous/processpool.py +++ b/fabulous/processpool.py @@ -33,8 +33,9 @@ def __init__( ) -> None: ForkingPickler.dumps = dill.dumps ForkingPickler.loads = dill.loads + workers = max_workers if max_workers is not None else get_context().max_worker super().__init__( - max_workers=max_workers or get_context().max_worker, + max_workers=workers or None, mp_context=multiprocessing.get_context("spawn"), initializer=_init_worker, initargs=initargs, From 62e8233c6d51351302fd25b27d7661441596050b Mon Sep 17 00:00:00 2001 From: Kelvin Chung Date: Thu, 21 May 2026 22:40:21 +0100 Subject: [PATCH 38/48] chore: a lot of test --- tests/cli_test/test_cli.py | 55 +++++ tests/fabric_definition/test_tile.py | 5 +- .../flow_test/test_full_fabric_flow.py | 188 +++++++++++++++++- .../test_global_tile_optimisation.py | 111 +++++++++++ .../step_test/test_tile_optimisation.py | 16 ++ .../step_test/test_timed_detailed_routing.py | 76 +++++++ .../step_test/test_while_step.py | 81 ++++++++ tests/utils_test/test_fabulous_settings.py | 35 ++++ tests/utils_test/test_processpool.py | 69 +++++++ 9 files changed, 626 insertions(+), 10 deletions(-) create mode 100644 tests/gds_flow_test/step_test/test_timed_detailed_routing.py create mode 100644 tests/utils_test/test_processpool.py diff --git a/tests/cli_test/test_cli.py b/tests/cli_test/test_cli.py index 94af69e83..291850b62 100644 --- a/tests/cli_test/test_cli.py +++ b/tests/cli_test/test_cli.py @@ -559,3 +559,58 @@ def test_override_merges_custom_yaml( gen_macro.call_args.kwargs["custom_config_overrides"]["DIODE_ON_PORTS"] == "both" ) + + +class TestRunEFPGAMacroForwarding: + """End-to-end CLI wiring: flags forwarded to the API entrypoint.""" + + def _patch(self, cli: FABulous_CLI, mocker: MockerFixture) -> MockerFixture: + mocker.patch( + "fabulous.fabulous_cli.fabulous_cli.is_pdk_config_set", return_value=True + ) + return mocker.patch.object(cli.fabulousAPI, "full_fabric_automation") + + def test_forwards_nlp_flags(self, cli: FABulous_CLI, mocker: MockerFixture) -> None: + full_auto = self._patch(cli, mocker) + + run_cmd(cli, "run_FABulous_eFPGA_macro --nlp-only --nlp-area-margin 0.1") + + full_auto.assert_called_once() + kwargs = full_auto.call_args.kwargs + assert kwargs["nlp_only"] is True + assert kwargs["nlp_area_margin"] == pytest.approx(0.1) + assert kwargs["tile_opt_config"] is None + + def test_forwards_defaults(self, cli: FABulous_CLI, mocker: MockerFixture) -> None: + full_auto = self._patch(cli, mocker) + + run_cmd(cli, "run_FABulous_eFPGA_macro") + + kwargs = full_auto.call_args.kwargs + assert kwargs["nlp_only"] is False + assert kwargs["nlp_area_margin"] == pytest.approx(0.05) + assert kwargs["tile_opt_config"] is None + + def test_forwards_tile_opt_info_as_path( + self, cli: FABulous_CLI, mocker: MockerFixture, tmp_path: Path + ) -> None: + full_auto = self._patch(cli, mocker) + summary = tmp_path / "tile_optimisation_summary.json" + summary.touch() + + run_cmd(cli, f"run_FABulous_eFPGA_macro --tile-opt-info {summary}") + + tile_opt_config = full_auto.call_args.kwargs["tile_opt_config"] + assert tile_opt_config == Path(summary) + + def test_skips_when_pdk_not_set( + self, cli: FABulous_CLI, mocker: MockerFixture + ) -> None: + mocker.patch( + "fabulous.fabulous_cli.fabulous_cli.is_pdk_config_set", return_value=False + ) + full_auto = mocker.patch.object(cli.fabulousAPI, "full_fabric_automation") + + run_cmd(cli, "run_FABulous_eFPGA_macro") + + full_auto.assert_not_called() diff --git a/tests/fabric_definition/test_tile.py b/tests/fabric_definition/test_tile.py index 1fef893c4..50eb3fdc5 100644 --- a/tests/fabric_definition/test_tile.py +++ b/tests/fabric_definition/test_tile.py @@ -1,6 +1,7 @@ """Tests for Tile methods — notably pin-count and min-die-area computation.""" from decimal import Decimal +from pathlib import Path from fabulous.fabric_definition.define import IO, Direction, Side from fabulous.fabric_definition.port import Port @@ -13,8 +14,8 @@ def _mk_tile(ports: list[Port]) -> Tile: name="T", ports=ports, bels=[], - tileDir=None, - matrixDir=None, + tileDir=Path(), + matrixDir=Path(), gen_ios=[], userCLK=False, ) diff --git a/tests/gds_flow_test/flow_test/test_full_fabric_flow.py b/tests/gds_flow_test/flow_test/test_full_fabric_flow.py index a8471cb8d..7ae465bdf 100644 --- a/tests/gds_flow_test/flow_test/test_full_fabric_flow.py +++ b/tests/gds_flow_test/flow_test/test_full_fabric_flow.py @@ -164,18 +164,64 @@ def test_validate_project_dir_missing_supertile_dir( class TestRunTileFlowWorker: """Tests for _run_tile_flow_worker function.""" - def test_worker_catches_exceptions( + def test_worker_propagates_unexpected_exceptions( self, mocker: MockerFixture, tmp_path: Path ) -> None: - """Test that worker catches exceptions and returns error trace.""" - # Make flow raise an exception + """Unexpected (non-flow) exceptions propagate instead of being masked. + + Only librelane `FlowError` (which includes deferred errors raised after + the GDS is written) triggers the disk-recovery path. A genuine bug must + surface with its stack trace. + """ mocker.patch( "fabulous.fabric_generator.gds_generator.flows.full_fabric_flow.FABulousTileVerilogMacroFlow", side_effect=ValueError("Test error"), ) tile: MagicMock = mocker.MagicMock() - result: WorkerResult = _run_tile_flow_worker( + with pytest.raises(ValueError, match="Test error"): + _run_tile_flow_worker( + tile, + tmp_path / "io.yaml", + OptMode.BALANCE, + tmp_path / "base.yaml", + tmp_path / "override.yaml", + "test_pdk", + tmp_path, + tmp_path / "models_pack.v", + ) + + def test_worker_recovers_state_on_deferred_flow_error( + self, mocker: MockerFixture, tmp_path: Path + ) -> None: + """A `FlowError` after the state was saved recovers the on-disk state.""" + from librelane.flows.flow import FlowError + + recovered_state: MagicMock = mocker.MagicMock() + mock_flow: MagicMock = mocker.MagicMock() + mock_flow.start.side_effect = FlowError("deferred errors were encountered") + mock_flow.run_dir = str(tmp_path) + mock_flow.config = { + "FABULOUS_PIN_MIN_WIDTH": Decimal("10.0"), + "FABULOUS_PIN_MIN_HEIGHT": Decimal("10.0"), + } + mocker.patch( + "fabulous.fabric_generator.gds_generator.flows.full_fabric_flow.FABulousTileVerilogMacroFlow", + return_value=mock_flow, + ) + state_file: Path = tmp_path / "state_out.json" + state_file.write_text("{}", encoding="utf-8") + mocker.patch( + "fabulous.fabric_generator.gds_generator.flows.full_fabric_flow.get_latest_file", + return_value=state_file, + ) + mocker.patch( + "fabulous.fabric_generator.gds_generator.flows.full_fabric_flow.State.loads", + return_value=recovered_state, + ) + + tile: MagicMock = mocker.MagicMock() + state, error_trace, pin_min = _run_tile_flow_worker( tile, tmp_path / "io.yaml", OptMode.BALANCE, @@ -186,11 +232,10 @@ def test_worker_catches_exceptions( tmp_path / "models_pack.v", ) - state, error_trace, pin_min = result - assert state is None + assert state is recovered_state assert error_trace is not None - assert "Test error" in error_trace - assert pin_min is None + assert "deferred errors" in error_trace + assert pin_min is not None def test_worker_returns_state_on_success( self, mocker: MockerFixture, tmp_path: Path @@ -263,3 +308,130 @@ def test_worker_passes_custom_overrides( assert "CUSTOM_KEY" in call_kwargs.kwargs or ( len(call_kwargs.args) > 0 and hasattr(call_kwargs, "kwargs") ) + + +class TestLogNlpSummary: + """Tests for the _log_nlp_summary static method.""" + + def test_logs_table_with_tile_rows_and_utilisation( + self, mocker: MockerFixture + ) -> None: + """Each tile produces a row containing its name and a utilisation %.""" + info_mock = mocker.patch( + "fabulous.fabric_generator.gds_generator.flows.full_fabric_flow.info" + ) + + nlp_state: MagicMock = mocker.MagicMock() + # nlp__tile__area maps name -> (x0, y0, width, height); util uses w*h. + nlp_state.metrics = { + "nlp__tile__area": { + "tile1": (0, 0, 10.0, 20.0), # alloc area 200 + "tile2": (0, 0, 5.0, 4.0), # alloc area 20 + }, + "nlp__tile__stdcell_area": { + "tile1": 100.0, # 50% util + "tile2": 5.0, # 25% util + }, + "nlp__total__area": 220.0, + } + + FABulousFabricMacroFullFlow._log_nlp_summary(nlp_state) + + logged = "\n".join(str(call.args[0]) for call in info_mock.call_args_list) + assert "tile1" in logged + assert "tile2" in logged + # tile1: 100/200 -> 50.0%, tile2: 5/20 -> 25.0% + assert "50.0%" in logged + assert "25.0%" in logged + # The width/height columns are derived from dims[2]/dims[3]. + assert "10.00" in logged + assert "20.00" in logged + + def test_handles_zero_allocated_area(self, mocker: MockerFixture) -> None: + """A zero-area tile reports 0% utilisation instead of dividing by zero.""" + info_mock = mocker.patch( + "fabulous.fabric_generator.gds_generator.flows.full_fabric_flow.info" + ) + + nlp_state: MagicMock = mocker.MagicMock() + nlp_state.metrics = { + "nlp__tile__area": {"empty": (0, 0, 0.0, 0.0)}, + "nlp__tile__stdcell_area": {"empty": 0.0}, + "nlp__total__area": 0, + } + + FABulousFabricMacroFullFlow._log_nlp_summary(nlp_state) + + logged = "\n".join(str(call.args[0]) for call in info_mock.call_args_list) + assert "empty" in logged + assert "0.0%" in logged + + +class TestRunNlpOnlyEarlyReturn: + """Tests for the FABULOUS_NLP_ONLY early-return path in run().""" + + def test_returns_after_nlp_without_stitching( + self, mocker: MockerFixture, tmp_path: Path + ) -> None: + """With FABULOUS_NLP_ONLY set, run() returns the NLP state and stops. + + The heavy recompilation/stitching collaborators must not be invoked: + no process pool, no stitching flow. + """ + flow: MagicMock = mocker.MagicMock(spec=FABulousFabricMacroFullFlow) + + fabric: MagicMock = mocker.MagicMock() + + # Drive config lookups from a real dict so behaviour is explicit. + # TILE_OPT_INFO present -> initial compilation is skipped. + config_data: dict[str, object] = { + "FABULOUS_FABRIC": fabric, + "FABULOUS_PROJ_DIR": str(tmp_path), + "TILE_OPT_INFO": str(tmp_path / "summary.json"), + "FABULOUS_NLP_ONLY": True, + } + config: MagicMock = mocker.MagicMock() + config.__getitem__.side_effect = config_data.__getitem__ + config.get.side_effect = config_data.get + config.copy.return_value = config + flow.config = config + + # progress_bar is an instance attribute on Flow, not a class attribute, + # so the spec'd mock won't auto-create it. + flow.progress_bar = mocker.MagicMock() + + nlp_state: MagicMock = mocker.MagicMock() + flow.start_step.return_value = nlp_state + flow._validate_project_dir = mocker.MagicMock() + flow._init_compile = mocker.MagicMock() + flow._log_nlp_summary = mocker.MagicMock() + + # Patch the collaborators constructed inside run(). + mocker.patch( + "fabulous.fabric_generator.gds_generator.flows." + "full_fabric_flow.GlobalTileSizeOptimization" + ) + stitching = mocker.patch( + "fabulous.fabric_generator.gds_generator.flows." + "full_fabric_flow.FABulousFabricMacroFlow" + ) + pool = mocker.patch( + "fabulous.fabric_generator.gds_generator.flows." + "full_fabric_flow.DillProcessPoolExecutor" + ) + + initial_state: MagicMock = mocker.MagicMock() + result_state, result_steps = FABulousFabricMacroFullFlow.run( + flow, initial_state + ) + + # NLP-only contract: returns the NLP state with no executed steps. + assert result_state is nlp_state + assert result_steps == [] + # NLP summary is logged on the early-return path. + flow._log_nlp_summary.assert_called_once_with(nlp_state) + # Step 1 skipped because TILE_OPT_INFO was provided. + flow._init_compile.assert_not_called() + # No recompilation pool, no stitching flow. + pool.assert_not_called() + stitching.assert_not_called() diff --git a/tests/gds_flow_test/step_test/test_global_tile_optimisation.py b/tests/gds_flow_test/step_test/test_global_tile_optimisation.py index e7f99377d..3ba5b9019 100644 --- a/tests/gds_flow_test/step_test/test_global_tile_optimisation.py +++ b/tests/gds_flow_test/step_test/test_global_tile_optimisation.py @@ -12,8 +12,11 @@ import json from pathlib import Path +import numpy as np import pytest +from fabulous.fabric_definition.fabric import Fabric +from fabulous.fabric_definition.tile import Tile from fabulous.fabric_generator.gds_generator.steps.global_tile_opitmisation import ( GlobalTileSizeOptimization, NLPTileProblem, @@ -342,3 +345,111 @@ def test_skips_entries_without_bbox(self, tmp_path: Path) -> None: valid, all_ = GlobalTileSizeOptimization._load_tile_metrics_from_json(path) assert valid[OptMode.BALANCE] == {} assert all_[OptMode.BALANCE] == {} + + +def _make_tile(name: str) -> Tile: + """Build a minimal real Tile (no BELs, ports, or supertile membership).""" + return Tile( + name=name, + ports=[], + bels=[], + tileDir=Path(), + matrixDir=Path(), + gen_ios=[], + userCLK=False, + ) + + +def _make_fabric(grid: list[list[Tile | None]]) -> Fabric: + """Build a real Fabric from a row-major grid of Tile/None positions. + + `numberOfRows`/`numberOfColumns` are derived from the grid shape, and + `tileDic`` is populated with one entry per distinct tile name so that + `get_all_unique_tiles` and the row/column index helpers behave exactly as + they do for a CSV-parsed fabric. + """ + tile_dic: dict[str, Tile] = {} + for row in grid: + for tile in row: + if tile is not None: + tile_dic.setdefault(tile.name, tile) + return Fabric( + fabric_dir=Path("/tmp"), + tile=grid, + numberOfRows=len(grid), + numberOfColumns=len(grid[0]), + tileDic=tile_dic, + ) + + +def _metric(width: float, height: float) -> dict: + """A successful single-mode metric entry with the given die bbox.""" + return { + "design__die__bbox": [0.0, 0.0, float(width), float(height)], + "design__instance__area__stdcell": 100.0, + } + + +class TestNLPTileProblemInit: + """Constructor coverage for the NLP problem setup. + + These exercise the equivalence-class -> variable mapping, the xl/xu bound + derivation, and the fail-fast that rejects a fabric whose tile never + compiled. They build real `Fabric`/`Tile` objects rather than mocks so + the iteration, `tileDic`, and `get_all_unique_tiles` paths are real. + """ + + def test_missing_tile_raises_runtimeerror(self) -> None: + # Tile "C" sits in the fabric grid but appears in no mode's metrics, so + # the NLP cannot bound its dimensions and must fail fast. + a = _make_tile("A") + c = _make_tile("C") + fabric = _make_fabric([[a], [c]]) + tile_metrics = {OptMode.BALANCE: {"A": _metric(100.0, 200.0)}} + + with pytest.raises(RuntimeError, match="failed all exploration modes") as exc: + NLPTileProblem(fabric, tile_metrics) + assert "C" in str(exc.value) + + def test_happy_path_variable_count_and_bounds(self) -> None: + # A in column 0, B in column 1; both span rows {0, 1}. Sharing both rows + # collapses the rows into a single height group, while the two columns + # stay distinct: 1 row var + 2 column vars = 3 variables. + a = _make_tile("A") + b = _make_tile("B") + fabric = _make_fabric([[a, b], [a, b]]) + tile_metrics = { + OptMode.BALANCE: {"A": _metric(100.0, 200.0), "B": _metric(120.0, 200.0)} + } + + problem = NLPTileProblem(fabric, tile_metrics) + + n_row_groups = len(set(problem.row_groups.values())) + n_col_groups = len(set(problem.col_groups.values())) + assert n_row_groups == 1 + assert n_col_groups == 2 + assert problem.n_var == n_row_groups + n_col_groups + + # Every dimension has a strictly positive floor and a non-collapsing + # upper bound. + assert np.all(problem.xl > 0) + assert np.all(problem.xu >= problem.xl) + + def test_disjoint_rows_get_distinct_row_groups(self) -> None: + # A occupies only row 0 and B only row 1 (single shared column). Because + # no tile type spans both rows, the two rows must land in different + # height groups. + a = _make_tile("A") + b = _make_tile("B") + fabric = _make_fabric([[a], [b]]) + tile_metrics = { + OptMode.BALANCE: {"A": _metric(100.0, 200.0), "B": _metric(100.0, 250.0)} + } + + problem = NLPTileProblem(fabric, tile_metrics) + + (row_a,) = problem.tile_row_set["A"] + (row_b,) = problem.tile_row_set["B"] + assert problem.row_groups[row_a] != problem.row_groups[row_b] + # The shared single column keeps both column indices in one group. + assert len(set(problem.col_groups.values())) == 1 diff --git a/tests/gds_flow_test/step_test/test_tile_optimisation.py b/tests/gds_flow_test/step_test/test_tile_optimisation.py index fba30dbf7..1cca443ac 100644 --- a/tests/gds_flow_test/step_test/test_tile_optimisation.py +++ b/tests/gds_flow_test/step_test/test_tile_optimisation.py @@ -8,6 +8,7 @@ import pytest from librelane.config.config import Config +from librelane.flows.flow import FlowException from librelane.state.state import State from pytest_mock import MockerFixture @@ -244,6 +245,21 @@ def test_user_fixed_height_not_grown_by_pin_floor( assert step.config["DIE_AREA"][3] == Decimal(100) + def test_zero_locked_axis_raises_clear_error( + self, mocker: MockerFixture, mock_config: Config, mock_state: State + ) -> None: + # A user DIE_AREA whose locked axis is zero must raise a clear error + # instead of a decimal.DivisionByZero from the smart-init seeding. + step = self._prepare( + mocker, + mock_config.copy(FABULOUS_OPT_MODE=OptMode.FIND_MIN_WIDTH), + (Decimal(0), Decimal(0), Decimal(10), Decimal(0)), + ) + mock_state.metrics["design__instance__area"] = 5000 + + with pytest.raises(FlowException): + step.run(mock_state) + class TestOptModeMissing: """``OptMode._missing_`` is the entry point for tolerant string lookups. diff --git a/tests/gds_flow_test/step_test/test_timed_detailed_routing.py b/tests/gds_flow_test/step_test/test_timed_detailed_routing.py new file mode 100644 index 000000000..93c505c3e --- /dev/null +++ b/tests/gds_flow_test/step_test/test_timed_detailed_routing.py @@ -0,0 +1,76 @@ +"""Tests for FABulousDetailedRoutingTimed step. + +Covers the wall-clock timeout wrapper around OpenROAD detailed routing without +relying on real timers or subprocesses. +""" + +import pytest +from librelane.steps import openroad as OpenROAD +from pytest_mock import MockerFixture + +from fabulous.fabric_generator.gds_generator.steps import timed_detailed_routing +from fabulous.fabric_generator.gds_generator.steps.timed_detailed_routing import ( + DRTTimedOutError, + FABulousDetailedRoutingTimed, +) + + +class TestFABulousDetailedRoutingTimed: + """Test suite for the timed detailed routing step.""" + + def test_timeout_config_var_default(self) -> None: + """FABULOUS_DRT_TIMEOUT is declared in config_vars with default 600.""" + names = {var.name: var for var in FABulousDetailedRoutingTimed.config_vars} + assert "FABULOUS_DRT_TIMEOUT" in names + assert names["FABULOUS_DRT_TIMEOUT"].default == 600 + + def test_timeout_raises_drt_timed_out_error(self, mocker: MockerFixture) -> None: + """When the timer fired, a failure is wrapped as DRTTimedOutError.""" + event = mocker.MagicMock() + event.is_set.return_value = True + mocker.patch.object( + timed_detailed_routing.threading, "Event", return_value=event + ) + mocker.patch.object( + OpenROAD.DetailedRouting, "run", side_effect=RuntimeError("boom") + ) + + step = FABulousDetailedRoutingTimed.__new__(FABulousDetailedRoutingTimed) + step.config = {"FABULOUS_DRT_TIMEOUT": 600} + state = mocker.MagicMock() + + with pytest.raises(DRTTimedOutError): + step.run(state) + + def test_non_timeout_reraises_original(self, mocker: MockerFixture) -> None: + """When the timer never fired, the original exception propagates as-is.""" + event = mocker.MagicMock() + event.is_set.return_value = False + mocker.patch.object( + timed_detailed_routing.threading, "Event", return_value=event + ) + mocker.patch.object( + OpenROAD.DetailedRouting, "run", side_effect=RuntimeError("boom") + ) + + step = FABulousDetailedRoutingTimed.__new__(FABulousDetailedRoutingTimed) + step.config = {"FABULOUS_DRT_TIMEOUT": 600} + state = mocker.MagicMock() + + with pytest.raises(RuntimeError, match="boom"): + step.run(state) + + def test_success_passes_popen_callable(self, mocker: MockerFixture) -> None: + """On success the result is returned and a _popen_callable kwarg is injected.""" + super_run = mocker.patch.object( + OpenROAD.DetailedRouting, "run", return_value=({}, {}) + ) + + step = FABulousDetailedRoutingTimed.__new__(FABulousDetailedRoutingTimed) + step.config = {"FABULOUS_DRT_TIMEOUT": 600} + state = mocker.MagicMock() + + result = step.run(state) + + assert result == ({}, {}) + assert "_popen_callable" in super_run.call_args.kwargs diff --git a/tests/gds_flow_test/step_test/test_while_step.py b/tests/gds_flow_test/step_test/test_while_step.py index e3b8910b8..77154e049 100644 --- a/tests/gds_flow_test/step_test/test_while_step.py +++ b/tests/gds_flow_test/step_test/test_while_step.py @@ -1,12 +1,31 @@ """Tests for WhileStep base class.""" +import pytest from librelane.config.config import Config from librelane.state.state import State +from librelane.steps.step import Step from pytest_mock import MockerFixture from fabulous.fabric_generator.gds_generator.steps.while_step import WhileStep +class CustomError(Exception): + """Marker exception used to exercise propagate_exceptions handling.""" + + +class _InnerStep(Step): + """Minimal sub-step whose ``start`` is patched per-test.""" + + id = "Test.Inner" + name = "Inner" + inputs = [] # noqa: RUF012 + outputs = [] # noqa: RUF012 + config_vars = [] # noqa: RUF012 + + def run(self, state_in: State, **kwargs: dict) -> tuple[dict, dict]: # noqa: D102, ARG002 + return {}, {} + + class TestWhileStep: """Test suite for WhileStep base class.""" @@ -68,3 +87,65 @@ def test_get_current_iteration_dir_none_initially( """Test that current iteration directory is None initially.""" step = WhileStep(mock_config) assert step.get_current_iteration_dir() is None + + def test_propagate_exceptions_reraises_over_break( + self, + mock_config: Config, + mock_state: State, + mocker: MockerFixture, + tmp_path, # noqa: ANN001 + ) -> None: + """An exception in propagate_exceptions re-raises even when break_on_failure. + + propagate_exceptions must win over break_on_failure (and raise_on_failure + being False), which would otherwise swallow the error via ``break``. + """ + + class PropagatingWhileStep(WhileStep): + Steps = [_InnerStep] # noqa: RUF012 + outputs = [] # noqa: RUF012 + propagate_exceptions = (CustomError,) + raise_on_failure = False + break_on_failure = True + max_iterations = 1 + + mocker.patch.object(_InnerStep, "start", side_effect=CustomError("boom")) + + step = PropagatingWhileStep(mock_config) + step.config = mock_config + step.step_dir = str(tmp_path) + step.toolbox = mocker.MagicMock() + step.name = "PropagatingWhileStep" + + with pytest.raises(CustomError): + step.run(mock_state) + + def test_break_on_failure_swallows_when_not_propagated( + self, + mock_config: Config, + mock_state: State, + mocker: MockerFixture, + tmp_path, # noqa: ANN001 + ) -> None: + """With empty propagate_exceptions, break_on_failure swallows the error.""" + + class SwallowingWhileStep(WhileStep): + Steps = [_InnerStep] # noqa: RUF012 + outputs = [] # noqa: RUF012 + propagate_exceptions = () + raise_on_failure = False + break_on_failure = True + max_iterations = 1 + + mocker.patch.object(_InnerStep, "start", side_effect=CustomError("boom")) + + step = SwallowingWhileStep(mock_config) + step.config = mock_config + step.step_dir = str(tmp_path) + step.toolbox = mocker.MagicMock() + step.name = "SwallowingWhileStep" + + # Should complete without raising because break_on_failure breaks the loop. + views_update, metrics_update = step.run(mock_state) + assert views_update == {} + assert metrics_update == {} diff --git a/tests/utils_test/test_fabulous_settings.py b/tests/utils_test/test_fabulous_settings.py index 8f4236f4e..e4dc0f70e 100644 --- a/tests/utils_test/test_fabulous_settings.py +++ b/tests/utils_test/test_fabulous_settings.py @@ -102,6 +102,41 @@ def test_initialization_with_environment_variables( assert settings.switch_matrix_debug_signal is True assert settings.proj_version_created == Version("1.2.3") + def test_max_worker_zero_accepted( + self, project: Path, monkeypatch: pytest.MonkeyPatch, mocker: MockerFixture + ) -> None: + """FAB_MAX_WORKER=0 is accepted (the 0->default mapping happens in the pool).""" + monkeypatch.setenv("PATH", "/bin:/usr/bin") + monkeypatch.setenv("FAB_MAX_WORKER", "0") + mocker.patch("fabulous.fabulous_settings.which", return_value=None) + + settings = init_context(project) + + assert settings.max_worker == 0 + + def test_max_worker_positive_preserved( + self, project: Path, monkeypatch: pytest.MonkeyPatch, mocker: MockerFixture + ) -> None: + """A positive FAB_MAX_WORKER is kept as the requested worker count.""" + monkeypatch.setenv("PATH", "/bin:/usr/bin") + monkeypatch.setenv("FAB_MAX_WORKER", "4") + mocker.patch("fabulous.fabulous_settings.which", return_value=None) + + settings = init_context(project) + + assert settings.max_worker == 4 + + def test_max_worker_negative_rejected( + self, project: Path, monkeypatch: pytest.MonkeyPatch, mocker: MockerFixture + ) -> None: + """A negative FAB_MAX_WORKER fails validation rather than being normalised.""" + monkeypatch.setenv("PATH", "/bin:/usr/bin") + monkeypatch.setenv("FAB_MAX_WORKER", "-1") + mocker.patch("fabulous.fabulous_settings.which", return_value=None) + + with pytest.raises(ValidationError): + init_context(project) + def test_initialization_with_tool_paths_found( self, project: Path, monkeypatch: pytest.MonkeyPatch, mocker: MockerFixture ) -> None: diff --git a/tests/utils_test/test_processpool.py b/tests/utils_test/test_processpool.py new file mode 100644 index 000000000..0289e2f60 --- /dev/null +++ b/tests/utils_test/test_processpool.py @@ -0,0 +1,69 @@ +"""Tests for DillProcessPoolExecutor worker-count resolution. + +A worker count of 0 (from either the explicit argument or the context setting) +means "use the system default"; ProcessPoolExecutor only accepts None for that, +so the executor must coerce 0 -> None rather than crash. +""" + +# accessing the private _max_workers is the cleanest observable here +# ruff: noqa: SLF001 + +import multiprocessing +from concurrent.futures import ProcessPoolExecutor + +import pytest +from pytest_mock import MockerFixture + +from fabulous import processpool + + +@pytest.fixture +def default_worker_count() -> int: + """The worker count ProcessPoolExecutor picks when given None.""" + executor = ProcessPoolExecutor( + max_workers=None, mp_context=multiprocessing.get_context("spawn") + ) + try: + return executor._max_workers + finally: + executor.shutdown() + + +class TestDillProcessPoolWorkers: + """Worker-count resolution for DillProcessPoolExecutor.""" + + def test_zero_arg_maps_to_default( + self, mocker: MockerFixture, default_worker_count: int + ) -> None: + mocker.patch.object(processpool, "get_context") + executor = processpool.DillProcessPoolExecutor(max_workers=0) + try: + assert executor._max_workers == default_worker_count + finally: + executor.shutdown() + + def test_zero_context_maps_to_default( + self, mocker: MockerFixture, default_worker_count: int + ) -> None: + mocker.patch.object(processpool, "get_context").return_value.max_worker = 0 + executor = processpool.DillProcessPoolExecutor(max_workers=None) + try: + assert executor._max_workers == default_worker_count + finally: + executor.shutdown() + + def test_positive_arg_preserved(self, mocker: MockerFixture) -> None: + mocker.patch.object(processpool, "get_context") + executor = processpool.DillProcessPoolExecutor(max_workers=3) + try: + assert executor._max_workers == 3 + finally: + executor.shutdown() + + def test_positive_context_preserved(self, mocker: MockerFixture) -> None: + mocker.patch.object(processpool, "get_context").return_value.max_worker = 5 + executor = processpool.DillProcessPoolExecutor(max_workers=None) + try: + assert executor._max_workers == 5 + finally: + executor.shutdown() From 55143962256c3a898968803d5a90b98284bd91e7 Mon Sep 17 00:00:00 2001 From: Kelvin Chung Date: Thu, 21 May 2026 23:17:51 +0100 Subject: [PATCH 39/48] chore: comment and some stat --- .../gds_generator/flows/full_fabric_flow.py | 50 ++++++++++++++++- .../flow_test/test_full_fabric_flow.py | 55 +++++++++++++++++++ 2 files changed, 104 insertions(+), 1 deletion(-) diff --git a/fabulous/fabric_generator/gds_generator/flows/full_fabric_flow.py b/fabulous/fabric_generator/gds_generator/flows/full_fabric_flow.py index 7075e9133..216f245d5 100644 --- a/fabulous/fabric_generator/gds_generator/flows/full_fabric_flow.py +++ b/fabulous/fabric_generator/gds_generator/flows/full_fabric_flow.py @@ -186,6 +186,52 @@ def _log_nlp_summary(nlp_state: State) -> None: info("-" * len(hdr)) info(f"{'Total fabric area':<20} {'':>10} {'':>10} {total_area:>12} {'':>8}") + @staticmethod + def _finalise( + fabric: Fabric, + final_state: State, + tile_states: dict[str, State], + ) -> None: + """Verify the stitched fabric is complete and log a compact summary. + + Parameters + ---------- + fabric : Fabric + The fabric that was stitched. + final_state : State + The state returned by the stitching flow. + tile_states : dict[str, State] + The recompiled per-tile macro states, keyed by tile name, used to + report each macro's final size. + + Raises + ------ + RuntimeError + If the stitching flow returned without producing a final GDS, i.e. + the fabric is incomplete despite the flow finishing. + """ + gds = final_state.get(DesignFormat.GDS) + if not gds: + raise RuntimeError( + "Fabric stitching finished but produced no GDS; the final " + "fabric is incomplete." + ) + + info("\n=== Fabric summary ===") + info(f" Fabric : {fabric.name}") + info(f" Unique tile types : {len(fabric.get_all_unique_tiles())}") + die_bbox = final_state.metrics.get("design__die__bbox") + if die_bbox is not None: + x0, y0, x1, y1 = (float(c) for c in str(die_bbox).split()) + info(f" Die area : {x1 - x0:.2f} x {y1 - y0:.2f} um") + info(" Tile macro sizes:") + for name in sorted(tile_states): + tile_bbox = tile_states[name].metrics.get("design__die__bbox") + if tile_bbox is None: + continue + x0, y0, x1, y1 = (float(c) for c in str(tile_bbox).split()) + info(f" {name:<18} {x1 - x0:>9.2f} x {y1 - y0:>9.2f} um") + def _validate_project_dir(self, proj_dir: Path, fabric: Fabric) -> None: """Validate the project directory structure for required tile directories.""" info("Validating project directory structure...") @@ -494,7 +540,7 @@ def run(self, initial_state: State, **_kwargs: dict) -> tuple[State, list[Step]] ) # Walk up from the GDS path to find the run directory containing the - # final/ snapshot. Robust to varying step nesting (e.g. write-out + # `final/`` snapshot. Robust to varying step nesting (e.g. write-out # steps inside a WhileStep wrapper). librelane's Path is a # UserString, so wrap with pathlib. final_dir: Path | None = next( @@ -537,5 +583,7 @@ def run(self, initial_state: State, **_kwargs: dict) -> tuple[State, list[Step]] final_state: State = stitching_flow.start() self.progress_bar.end_stage() + # Confirm the stitch actually produced a fabric before declaring success. + self._finalise(fabric, final_state, tile_type_states) info("\nFabric flow completed successfully!") return final_state, [] diff --git a/tests/gds_flow_test/flow_test/test_full_fabric_flow.py b/tests/gds_flow_test/flow_test/test_full_fabric_flow.py index 7ae465bdf..138988ede 100644 --- a/tests/gds_flow_test/flow_test/test_full_fabric_flow.py +++ b/tests/gds_flow_test/flow_test/test_full_fabric_flow.py @@ -435,3 +435,58 @@ def test_returns_after_nlp_without_stitching( # No recompilation pool, no stitching flow. pool.assert_not_called() stitching.assert_not_called() + + +class TestFinaliseFabric: + """Tests for the post-stitching completeness check and summary.""" + + @staticmethod + def _fabric(mocker: MockerFixture) -> MagicMock: + fabric: MagicMock = mocker.MagicMock() + fabric.name = "myfab" + fabric.get_all_unique_tiles.return_value = [object(), object()] + return fabric + + @staticmethod + def _tile_state(mocker: MockerFixture, bbox: str) -> MagicMock: + state: MagicMock = mocker.MagicMock() + state.metrics = {"design__die__bbox": bbox} + return state + + def test_raises_when_no_gds(self, mocker: MockerFixture) -> None: + """An incomplete stitch (no GDS) raises rather than reporting success.""" + final_state: MagicMock = mocker.MagicMock() + final_state.get.return_value = None # no GDS produced + final_state.metrics = {} + + with pytest.raises(RuntimeError, match="no GDS"): + FABulousFabricMacroFullFlow._finalise(self._fabric(mocker), final_state, {}) + + def test_logs_summary_with_per_tile_macro_sizes( + self, mocker: MockerFixture + ) -> None: + """A complete stitch logs the die area and each tile macro's size.""" + final_state: MagicMock = mocker.MagicMock() + final_state.get.return_value = "/runs/final/gds/myfab.gds" + final_state.metrics = {"design__die__bbox": "0 0 100 200"} + tile_states = { + "LUT": self._tile_state(mocker, "0 0 30 40"), + "DSP": self._tile_state(mocker, "0 0 50 60"), + } + info_mock = mocker.patch( + "fabulous.fabric_generator.gds_generator.flows.full_fabric_flow.info" + ) + + FABulousFabricMacroFullFlow._finalise( + self._fabric(mocker), final_state, tile_states + ) + + # Collapse the column-alignment padding so the assertions aren't brittle. + logged = " ".join( + " ".join(str(call.args[0]).split()) for call in info_mock.call_args_list + ) + assert "myfab" in logged + assert "100.00 x 200.00" in logged # overall die area w x h + assert "LUT 30.00 x 40.00" in logged # per-macro tile size + assert "DSP 50.00 x 60.00" in logged + assert "myfab.gds" in logged From 67d9afc943defffac7fb98ce36a9616bf27928a4 Mon Sep 17 00:00:00 2001 From: Kelvin Chung Date: Fri, 22 May 2026 11:10:51 +0100 Subject: [PATCH 40/48] chore: update flow name --- ...ic_flow.py => fabric_optimisation_flow.py} | 2 +- fabulous/fabulous_api.py | 6 ++--- .../flow_test/test_full_fabric_flow.py | 22 ++++++++++--------- 3 files changed, 16 insertions(+), 14 deletions(-) rename fabulous/fabric_generator/gds_generator/flows/{full_fabric_flow.py => fabric_optimisation_flow.py} (99%) diff --git a/fabulous/fabric_generator/gds_generator/flows/full_fabric_flow.py b/fabulous/fabric_generator/gds_generator/flows/fabric_optimisation_flow.py similarity index 99% rename from fabulous/fabric_generator/gds_generator/flows/full_fabric_flow.py rename to fabulous/fabric_generator/gds_generator/flows/fabric_optimisation_flow.py index 216f245d5..024c2637d 100644 --- a/fabulous/fabric_generator/gds_generator/flows/full_fabric_flow.py +++ b/fabulous/fabric_generator/gds_generator/flows/fabric_optimisation_flow.py @@ -154,7 +154,7 @@ def _run_tile_flow_worker( @Flow.factory.register() -class FABulousFabricMacroFullFlow(Flow): +class FABulousFabricOptimisationFlow(Flow): """Full automatic fabric flow with LP-optimized tile dimensions. This flow automatically: diff --git a/fabulous/fabulous_api.py b/fabulous/fabulous_api.py index a1fb4dda7..780f34068 100644 --- a/fabulous/fabulous_api.py +++ b/fabulous/fabulous_api.py @@ -37,8 +37,8 @@ from fabulous.fabric_generator.gds_generator.flows.fabric_macro_flow import ( FABulousFabricMacroFlow, ) -from fabulous.fabric_generator.gds_generator.flows.full_fabric_flow import ( - FABulousFabricMacroFullFlow, +from fabulous.fabric_generator.gds_generator.flows.fabric_optimisation_flow import ( + FABulousFabricOptimisationFlow, ) from fabulous.fabric_generator.gds_generator.flows.tile_macro_flow import ( FABulousTileVerilogMacroFlow, @@ -711,7 +711,7 @@ def full_fabric_automation( ] if i is not None ] - flow = FABulousFabricMacroFullFlow( + flow = FABulousFabricOptimisationFlow( configs, name=self.fabric.name, design_dir=str(out_folder.resolve()), diff --git a/tests/gds_flow_test/flow_test/test_full_fabric_flow.py b/tests/gds_flow_test/flow_test/test_full_fabric_flow.py index 138988ede..b72219272 100644 --- a/tests/gds_flow_test/flow_test/test_full_fabric_flow.py +++ b/tests/gds_flow_test/flow_test/test_full_fabric_flow.py @@ -16,8 +16,8 @@ import pytest from pytest_mock import MockerFixture -from fabulous.fabric_generator.gds_generator.flows.full_fabric_flow import ( - FABulousFabricMacroFullFlow, +from fabulous.fabric_generator.gds_generator.flows.fabric_optimisation_flow import ( + FABulousFabricOptimisationFlow, WorkerResult, _run_tile_flow_worker, ) @@ -28,8 +28,8 @@ @pytest.fixture def mock_flow_with_validate_project_dir(mocker: MockerFixture) -> MagicMock: """Create a mock flow with _validate_project_dir method bound.""" - flow: MagicMock = mocker.MagicMock(spec=FABulousFabricMacroFullFlow) - flow._validate_project_dir = FABulousFabricMacroFullFlow._validate_project_dir + flow: MagicMock = mocker.MagicMock(spec=FABulousFabricOptimisationFlow) + flow._validate_project_dir = FABulousFabricOptimisationFlow._validate_project_dir return flow @@ -335,7 +335,7 @@ def test_logs_table_with_tile_rows_and_utilisation( "nlp__total__area": 220.0, } - FABulousFabricMacroFullFlow._log_nlp_summary(nlp_state) + FABulousFabricOptimisationFlow._log_nlp_summary(nlp_state) logged = "\n".join(str(call.args[0]) for call in info_mock.call_args_list) assert "tile1" in logged @@ -360,7 +360,7 @@ def test_handles_zero_allocated_area(self, mocker: MockerFixture) -> None: "nlp__total__area": 0, } - FABulousFabricMacroFullFlow._log_nlp_summary(nlp_state) + FABulousFabricOptimisationFlow._log_nlp_summary(nlp_state) logged = "\n".join(str(call.args[0]) for call in info_mock.call_args_list) assert "empty" in logged @@ -378,7 +378,7 @@ def test_returns_after_nlp_without_stitching( The heavy recompilation/stitching collaborators must not be invoked: no process pool, no stitching flow. """ - flow: MagicMock = mocker.MagicMock(spec=FABulousFabricMacroFullFlow) + flow: MagicMock = mocker.MagicMock(spec=FABulousFabricOptimisationFlow) fabric: MagicMock = mocker.MagicMock() @@ -421,7 +421,7 @@ def test_returns_after_nlp_without_stitching( ) initial_state: MagicMock = mocker.MagicMock() - result_state, result_steps = FABulousFabricMacroFullFlow.run( + result_state, result_steps = FABulousFabricOptimisationFlow.run( flow, initial_state ) @@ -460,7 +460,9 @@ def test_raises_when_no_gds(self, mocker: MockerFixture) -> None: final_state.metrics = {} with pytest.raises(RuntimeError, match="no GDS"): - FABulousFabricMacroFullFlow._finalise(self._fabric(mocker), final_state, {}) + FABulousFabricOptimisationFlow._finalise( + self._fabric(mocker), final_state, {} + ) def test_logs_summary_with_per_tile_macro_sizes( self, mocker: MockerFixture @@ -477,7 +479,7 @@ def test_logs_summary_with_per_tile_macro_sizes( "fabulous.fabric_generator.gds_generator.flows.full_fabric_flow.info" ) - FABulousFabricMacroFullFlow._finalise( + FABulousFabricOptimisationFlow._finalise( self._fabric(mocker), final_state, tile_states ) From 56b9711cf1593eb86b9b3367744a3c486c137b50 Mon Sep 17 00:00:00 2001 From: Kelvin Chung Date: Fri, 22 May 2026 11:14:40 +0100 Subject: [PATCH 41/48] chore: file name typo --- .../gds_generator/flows/fabric_optimisation_flow.py | 2 +- ...{global_tile_opitmisation.py => global_tile_optimisation.py} | 0 tests/gds_flow_test/step_test/test_global_tile_optimisation.py | 2 +- 3 files changed, 2 insertions(+), 2 deletions(-) rename fabulous/fabric_generator/gds_generator/steps/{global_tile_opitmisation.py => global_tile_optimisation.py} (100%) diff --git a/fabulous/fabric_generator/gds_generator/flows/fabric_optimisation_flow.py b/fabulous/fabric_generator/gds_generator/flows/fabric_optimisation_flow.py index 024c2637d..f48a6249a 100644 --- a/fabulous/fabric_generator/gds_generator/flows/fabric_optimisation_flow.py +++ b/fabulous/fabric_generator/gds_generator/flows/fabric_optimisation_flow.py @@ -42,7 +42,7 @@ from fabulous.fabric_generator.gds_generator.steps.extract_pdk_info import ( ExtractPDKInfo, ) -from fabulous.fabric_generator.gds_generator.steps.global_tile_opitmisation import ( +from fabulous.fabric_generator.gds_generator.steps.global_tile_optimisation import ( GlobalTileSizeOptimization, ) from fabulous.fabric_generator.gds_generator.steps.tile_optimisation import OptMode diff --git a/fabulous/fabric_generator/gds_generator/steps/global_tile_opitmisation.py b/fabulous/fabric_generator/gds_generator/steps/global_tile_optimisation.py similarity index 100% rename from fabulous/fabric_generator/gds_generator/steps/global_tile_opitmisation.py rename to fabulous/fabric_generator/gds_generator/steps/global_tile_optimisation.py diff --git a/tests/gds_flow_test/step_test/test_global_tile_optimisation.py b/tests/gds_flow_test/step_test/test_global_tile_optimisation.py index 3ba5b9019..bd6077ef4 100644 --- a/tests/gds_flow_test/step_test/test_global_tile_optimisation.py +++ b/tests/gds_flow_test/step_test/test_global_tile_optimisation.py @@ -17,7 +17,7 @@ from fabulous.fabric_definition.fabric import Fabric from fabulous.fabric_definition.tile import Tile -from fabulous.fabric_generator.gds_generator.steps.global_tile_opitmisation import ( +from fabulous.fabric_generator.gds_generator.steps.global_tile_optimisation import ( GlobalTileSizeOptimization, NLPTileProblem, ) From 14e600779793f669dcd52918e81984b51d0db24a Mon Sep 17 00:00:00 2001 From: Kelvin Chung Date: Fri, 22 May 2026 11:44:54 +0100 Subject: [PATCH 42/48] chore: rename and again and fix "optimization" --- .../building_doc/fabric_definition.md | 2 +- .../user_guide/building_doc/fabric_gds.md | 34 +++++++------- .../flows/fabric_optimisation_flow.py | 40 ++++++++-------- .../gds_generator/flows/flow_define.py | 11 +++++ .../gds_generator/flows/tile_macro_flow.py | 21 ++------- ...ile_optimisation.py => fabric_area_opt.py} | 22 ++++----- ...{tile_optimisation.py => tile_area_opt.py} | 6 +-- fabulous/fabulous_api.py | 2 +- fabulous/fabulous_cli/fabulous_cli.py | 2 +- tests/cli_test/test_cli.py | 2 +- .../flow_test/test_full_fabric_flow.py | 4 +- .../flow_test/test_tile_macro_flow.py | 2 +- tests/gds_flow_test/step_test/conftest.py | 2 +- .../test_global_tile_optimisation.py | 26 +++++------ .../step_test/test_tile_optimisation.py | 46 +++++++++---------- 15 files changed, 110 insertions(+), 112 deletions(-) rename fabulous/fabric_generator/gds_generator/steps/{global_tile_optimisation.py => fabric_area_opt.py} (98%) rename fabulous/fabric_generator/gds_generator/steps/{tile_optimisation.py => tile_area_opt.py} (99%) diff --git a/docs/source/user_guide/building_doc/fabric_definition.md b/docs/source/user_guide/building_doc/fabric_definition.md index 622a782e4..d96e4eccf 100644 --- a/docs/source/user_guide/building_doc/fabric_definition.md +++ b/docs/source/user_guide/building_doc/fabric_definition.md @@ -1035,7 +1035,7 @@ entity LUT4 is Exporting configuration bits is a requirement for any primitive or switch matrix that uses configuration bits. The tile configuration bitstream is formed by concatenating first the primitive configuration bits (if primitives are available and use configuration bits) and then the switch matrix configuration bits (again, only if the switch matrix uses configuration bits) into one long tile configuration word. This is done in the order that the primitives are declared by `BEL` entries in the tile definition. Configuration bitstream vectors are defined in the *downto* direction and the first BEL primitive configuration bits will be placed at the LSB side of the tile bitstream and the configuration switch matrix at the MSB side. -Using the **shift-register configuration mode** will form a tile configuration chain. FABulous only supports one long bit-serial configuration chain. While configuration speed could possibly be boosted by using multiple parallel (and correspondingly shorter) chains, we have not added further optimizations, because shift register configuration is inferior to frame-based configuration mode. +Using the **shift-register configuration mode** will form a tile configuration chain. FABulous only supports one long bit-serial configuration chain. While configuration speed could possibly be boosted by using multiple parallel (and correspondingly shorter) chains, we have not added further optimisations, because shift register configuration is inferior to frame-based configuration mode. For **frame-based configuration mode**, FABulous will pack those configuration bits into frames. By default, FABulous will start with frame 0 and pack the first `FrameBitsPerRow` bits from the tile configuration bitstream starting with the MSBs of the tile bitstream frame-by-frame until all configuration bits are packed. This may leave some of the `FrameBitsPerRow` x `MaxFramesPerCol` possible configuration bits unused. diff --git a/docs/source/user_guide/building_doc/fabric_gds.md b/docs/source/user_guide/building_doc/fabric_gds.md index 06c5fbca0..5d3dbd7eb 100644 --- a/docs/source/user_guide/building_doc/fabric_gds.md +++ b/docs/source/user_guide/building_doc/fabric_gds.md @@ -68,20 +68,20 @@ This will generate the tile GDS for you under the tile macro folder (`/ ### Command Options -The `gen_tile_macro` command supports an optimization flag: +The `gen_tile_macro` command supports an optimisation flag: ```bash fabulous> gen_tile_macro --optimise [mode] ``` -Where `[mode]` is one of the optimization modes described in the [Tile Size Optimization](#tile-size-optimization) section. If `--optimise` is provided without a mode, `balance` is used by default. +Where `[mode]` is one of the optimisation modes described in the [Tile Size optimisation](#tile-size-optimisation) section. If `--optimise` is provided without a mode, `balance` is used by default. To generate all tiles at once: ```bash fabulous> gen_all_tile_macros fabulous> gen_all_tile_macros --parallel # Run in parallel for faster compilation -fabulous> gen_all_tile_macros --optimise # With optimization (balance mode) +fabulous> gen_all_tile_macros --optimise # With optimisation (balance mode) ``` ### Tile Config @@ -92,7 +92,7 @@ The per tile `gds_config.yaml` is particularly useful and important as you can s ### Pin Config -During the generation process there will be an extra file generated under the `macro` folder, which is the `io_pin_order.yaml`. This file controls the placement of the IO pins along the tile. This is auto-populated to make sure all the pins of a tile align with the adjacent tiles. But one can modify it for whatever means, such as optimization. The following is an example of the IO config file: +During the generation process there will be an extra file generated under the `macro` folder, which is the `io_pin_order.yaml`. This file controls the placement of the IO pins along the tile. This is auto-populated to make sure all the pins of a tile align with the adjacent tiles. But one can modify it for whatever means, such as optimisation. The following is an example of the IO config file: ```yaml X0Y0: @@ -173,21 +173,21 @@ Same as tile implementation, there is a `gds_config.yaml` file under the `Fabric ## Full Automated Flow -For a fully automated flow that handles tile size optimization and fabric stitching, use: +For a fully automated flow that handles tile size optimisation and fabric stitching, use: ```bash fabulous> run_FABulous_eFPGA_macro ``` :::{note} -The fully automated flow can take significantly longer than manual tile compilation, as it performs design space exploration by compiling all tiles with multiple optimization modes in parallel before running NLP optimization. For large fabrics with many unique tiles, expect longer runtimes. +The fully automated flow can take significantly longer than manual tile compilation, as it performs design space exploration by compiling all tiles with multiple optimisation modes in parallel before running NLP optimisation. For large fabrics with many unique tiles, expect longer runtimes. ::: This command performs the following steps automatically: -1. **Design Space Exploration**: Compiles all tiles with three optimization modes (`balance`, `find_min_width`, `find_min_height`) in parallel to explore possible tile dimensions. +1. **Design Space Exploration**: Compiles all tiles with three optimisation modes (`balance`, `find_min_width`, `find_min_height`) in parallel to explore possible tile dimensions. -2. **NLP Optimization**: Uses Non-Linear Programming (via pymoo) to find optimal tile dimensions that minimize total fabric area while satisfying: +2. **NLP optimisation**: Uses Non-Linear Programming (via pymoo) to find optimal tile dimensions that minimize total fabric area while satisfying: - Minimum area constraints for each tile - Row height consistency (all tiles in a row must have the same height) - Column width consistency (all tiles in a column must have the same width) @@ -197,13 +197,13 @@ This command performs the following steps automatically: 4. **Fabric Stitching**: Assembles all tiles into the final fabric layout. -(tile-size-optimization)= +(tile-size-optimisation)= -## Tile Size Optimization +## Tile Size Optimisation -The GDS flow includes an iterative optimization process to find the minimum viable tile dimensions. This is controlled by the `FABULOUS_OPT_MODE` variable. +The GDS flow includes an iterative optimisation process to find the minimum viable tile dimensions. This is controlled by the `FABULOUS_OPT_MODE` variable. -### Optimization Modes +### Optimisation Modes | Mode | Description | Use Case | |------|-------------|----------| @@ -211,9 +211,9 @@ The GDS flow includes an iterative optimization process to find the minimum viab | `find_min_width` | Increases width iteratively while keeping height fixed | When height is constrained | | `find_min_height` | Increases height iteratively while keeping width fixed | When width is constrained | | `large` | Increases both dimensions together | Quick compilation, larger area | -| `no_opt` | No optimization, uses provided `DIE_AREA` directly | Manual control, requires `DIE_AREA` to be set | +| `no_opt` | No optimisation, uses provided `DIE_AREA` directly | Manual control, requires `DIE_AREA` to be set | -### How Optimization Works +### How Optimisation Works 1. The flow starts with an initial die area (either provided or calculated from instance area) 2. It runs through placement and routing @@ -239,9 +239,9 @@ After successful compilation, the output is organized as follows: ├── Tile/ │ └── / │ └── macro/ -│ ├── balance/ # Output from balance optimization -│ ├── find_min_width/ # Output from width optimization -│ ├── find_min_height/ # Output from height optimization +│ ├── balance/ # Output from balance optimisation +│ ├── find_min_width/ # Output from width optimisation +│ ├── find_min_height/ # Output from height optimisation │ └── final_views/ # Final compiled output │ ├── gds/ # GDSII files │ ├── lef/ # LEF macro files diff --git a/fabulous/fabric_generator/gds_generator/flows/fabric_optimisation_flow.py b/fabulous/fabric_generator/gds_generator/flows/fabric_optimisation_flow.py index f48a6249a..0695166b6 100644 --- a/fabulous/fabric_generator/gds_generator/flows/fabric_optimisation_flow.py +++ b/fabulous/fabric_generator/gds_generator/flows/fabric_optimisation_flow.py @@ -1,4 +1,4 @@ -"""Full automatic fabric flow with LP-based tile optimization. +"""Full automatic fabric flow with LP-based tile optimisation. This flow uses non-Linear Programming (NLP) to optimize tile dimensions: 1. Compiles all tiles with 3 modes (balance, min-width, min-height) in parallel @@ -42,10 +42,10 @@ from fabulous.fabric_generator.gds_generator.steps.extract_pdk_info import ( ExtractPDKInfo, ) -from fabulous.fabric_generator.gds_generator.steps.global_tile_optimisation import ( - GlobalTileSizeOptimization, +from fabulous.fabric_generator.gds_generator.steps.fabric_area_opt import ( + FabricAreaOptimisation, ) -from fabulous.fabric_generator.gds_generator.steps.tile_optimisation import OptMode +from fabulous.fabric_generator.gds_generator.steps.tile_area_opt import OptMode from fabulous.fabulous_settings import get_context from fabulous.processpool import DillProcessPoolExecutor @@ -58,7 +58,7 @@ Classic.config_vars + Floorplan.config_vars + flow_common_variables - + GlobalTileSizeOptimization.config_vars + + FabricAreaOptimisation.config_vars + [ Variable( "FABULOUS_NLP_ONLY", @@ -104,7 +104,7 @@ def _run_tile_flow_worker( io_pin_config : Path Path to the IO pin configuration YAML file. optimisation : OptMode - The optimization mode for tile compilation. + The optimisation mode for tile compilation. base_config_path : Path Base configuration file path for the flow. override_config_path : Path @@ -158,13 +158,13 @@ class FABulousFabricOptimisationFlow(Flow): """Full automatic fabric flow with LP-optimized tile dimensions. This flow automatically: - 1. Compiles all tiles with 3 optimization modes to explore dimension space + 1. Compiles all tiles with 3 optimisation modes to explore dimension space 2. Solves LP problem to find optimal dimensions minimizing fabric perimeter 3. Recompiles tiles with optimal dimensions from LP solution 4. Stitches all tiles into final fabric with minimal area """ - Steps = [ExtractPDKInfo, GlobalTileSizeOptimization] + Steps = [ExtractPDKInfo, FabricAreaOptimisation] config_vars = configs @@ -302,7 +302,7 @@ def _validate_project_dir(self, proj_dir: Path, fabric: Fabric) -> None: def _init_compile(self, fabric: Fabric, proj_dir: Path) -> None: """Compile all tiles for design space exploration.""" - # Optimization modes to try for each tile + # optimisation modes to try for each tile opt_modes: list[OptMode] = [ OptMode.BALANCE, OptMode.FIND_MIN_HEIGHT, @@ -394,7 +394,7 @@ def run(self, initial_state: State, **_kwargs: dict) -> tuple[State, list[Step]] """Execute the NLP-based fabric flow. Flow steps: - 1. Compile all tiles with optimization mode in parallel + 1. Compile all tiles with optimisation mode in parallel 2. Formulate Non-linear Programming (NLP) problem to minimize total fabric area 3. Recompile tiles with optimal dimensions in parallel 4. Stitch all tiles into final fabric @@ -414,9 +414,9 @@ def run(self, initial_state: State, **_kwargs: dict) -> tuple[State, list[Step]] Raises ------ RuntimeError - If tile compilation or NLP optimization fails. + If tile compilation or NLP optimisation fails. FlowException - When NLP optimization step fails. + When NLP optimisation step fails. """ fabric: Fabric = self.config["FABULOUS_FABRIC"] proj_dir: Path = Path(self.config["FABULOUS_PROJ_DIR"]) @@ -431,23 +431,23 @@ def run(self, initial_state: State, **_kwargs: dict) -> tuple[State, list[Step]] self._init_compile(fabric, proj_dir) else: info( - "Tile optimization info already present, skipping initial compilation." + "Tile optimisation info already present, skipping initial compilation." ) - self.progress_bar.start_stage("NLP Optimization") - info("\n=== Step 2: Solving NLP optimization ===") - # Create and run NLP optimization step + self.progress_bar.start_stage("NLP optimisation") + info("\n=== Step 2: Solving NLP optimisation ===") + # Create and run NLP optimisation step nlp_config: Config = self.config.copy(FABULOUS_PROJ_DIR=proj_dir) - nlp_step: GlobalTileSizeOptimization = GlobalTileSizeOptimization( - nlp_config, id="SolveNLPOptimization", state_in=initial_state + nlp_step: FabricAreaOptimisation = FabricAreaOptimisation( + nlp_config, id="SolveNLPoptimisation", state_in=initial_state ) try: nlp_state: State = self.start_step(nlp_step) except Exception as e: - err(f"NLP optimization step failed to start/execute: {e}") + err(f"NLP optimisation step failed to start/execute: {e}") err(traceback.format_exc()) - raise FlowException("NLP optimization step failed") from e + raise FlowException("NLP optimisation step failed") from e self.progress_bar.end_stage() diff --git a/fabulous/fabric_generator/gds_generator/flows/flow_define.py b/fabulous/fabric_generator/gds_generator/flows/flow_define.py index 8de0bb054..f22f7d5f1 100644 --- a/fabulous/fabric_generator/gds_generator/flows/flow_define.py +++ b/fabulous/fabric_generator/gds_generator/flows/flow_define.py @@ -23,6 +23,9 @@ from fabulous.fabric_generator.gds_generator.steps.magic_streamout import ( FABulousMagicStreamOut, ) +from fabulous.fabric_generator.gds_generator.steps.tile_area_opt import ( + TileAreaOptimisation, +) prep_steps: list[type[Step]] = [ Verilator.Lint, @@ -88,6 +91,14 @@ OpenROAD.IRDropReport, ] +tile_optimisation_physical_steps: list[type[Step]] = [ + TileAreaOptimisation, + OpenROAD.FillInsertion, + Odb.CellFrequencyTables, + OpenROAD.RCX, + OpenROAD.IRDropReport, +] + write_out_steps: list[type[Step]] = [ FABulousMagicStreamOut, KLayout.StreamOut, diff --git a/fabulous/fabric_generator/gds_generator/flows/tile_macro_flow.py b/fabulous/fabric_generator/gds_generator/flows/tile_macro_flow.py index 4d67865a5..fbcf0398c 100644 --- a/fabulous/fabric_generator/gds_generator/flows/tile_macro_flow.py +++ b/fabulous/fabric_generator/gds_generator/flows/tile_macro_flow.py @@ -11,8 +11,6 @@ from librelane.flows.sequential import SequentialFlow from librelane.logging.logger import err, info, warn from librelane.state.state import State -from librelane.steps import odb as Odb -from librelane.steps import openroad as OpenROAD from librelane.steps.step import Step from fabulous.fabric_definition.supertile import SuperTile @@ -22,6 +20,7 @@ classic_gating_config_vars, physical_steps, prep_steps, + tile_optimisation_physical_steps, write_out_steps, ) from fabulous.fabric_generator.gds_generator.helper import ( @@ -32,13 +31,12 @@ ) from fabulous.fabric_generator.gds_generator.steps.add_buffer import AddBuffers from fabulous.fabric_generator.gds_generator.steps.custom_pdn import CustomGeneratePDN +from fabulous.fabric_generator.gds_generator.steps.tile_area_opt import ( + OptMode, +) from fabulous.fabric_generator.gds_generator.steps.tile_IO_placement import ( FABulousTileIOPlacement, ) -from fabulous.fabric_generator.gds_generator.steps.tile_optimisation import ( - OptMode, - TileOptimisation, -) from fabulous.fabulous_settings import get_context subs = { @@ -71,16 +69,7 @@ class FABulousTileVerilogMacroFlow(SequentialFlow): """A tile optimisation flow for FABulous fabric generation from Verilog.""" Steps = ( - prep_steps - + [ - TileOptimisation, - OpenROAD.FillInsertion, - Odb.CellFrequencyTables, - OpenROAD.RCX, - OpenROAD.IRDropReport, - ] - + write_out_steps - + check_steps + prep_steps + tile_optimisation_physical_steps + write_out_steps + check_steps ) config_vars = configs diff --git a/fabulous/fabric_generator/gds_generator/steps/global_tile_optimisation.py b/fabulous/fabric_generator/gds_generator/steps/fabric_area_opt.py similarity index 98% rename from fabulous/fabric_generator/gds_generator/steps/global_tile_optimisation.py rename to fabulous/fabric_generator/gds_generator/steps/fabric_area_opt.py index ca92bf73a..b4c6a91a4 100644 --- a/fabulous/fabric_generator/gds_generator/steps/global_tile_optimisation.py +++ b/fabulous/fabric_generator/gds_generator/steps/fabric_area_opt.py @@ -1,4 +1,4 @@ -"""FABulous GDS Generator - NLP Optimization Step using pymoo.""" +"""FABulous GDS Generator - NLP optimisation Step using pymoo.""" import json from collections import defaultdict @@ -21,11 +21,11 @@ from fabulous.fabric_definition.fabric import Fabric from fabulous.fabric_generator.gds_generator.helper import round_up_decimal -from fabulous.fabric_generator.gds_generator.steps.tile_optimisation import OptMode +from fabulous.fabric_generator.gds_generator.steps.tile_area_opt import OptMode class NLPTileProblem(ElementwiseProblem): - """NLP problem for tile size optimization using row/column variables. + """NLP problem for tile size optimisation using row/column variables. Variables are row heights h[r] and column widths w[c], so that uniformity within each row and column is inherent in the formulation rather than enforced through soft @@ -570,22 +570,22 @@ def _evaluate(self, x: np.ndarray, out: dict) -> None: @Step.factory.register() -class GlobalTileSizeOptimization(Step): - """LibreLane step for NLP optimization of tile dimensions. +class FabricAreaOptimisation(Step): + """LibreLane step for NLP optimisation of tile dimensions. Formulates and solves a Non-Linear Program using pymoo to minimize total fabric area. Variables are row heights and column widths, ensuring uniformity within each row/column by construction. """ - id = "FABulous.GlobalTileSizeOptimization" - name = "FABulous Global Tile Size Optimization" + id = "FABulous.FabricAreaOptimisation" + name = "FABulous Fabric Area optimisation" config_vars = [ Variable( "TILE_OPT_INFO", Optional[Path], # noqa: UP045 librelane issue - description="Tile optimization information dictionary or path to JSON file", + description="Tile optimisation information dictionary or path to JSON file", default=None, ), Variable( @@ -756,7 +756,7 @@ def _do(self, _problem: Any, X: np.ndarray, **_kwargs: Any) -> np.ndarray: # no algorithm = ISRES(repair=RoundRepair()) n_gen = 500 - info(f"Running optimization for {n_gen} generations") + info(f"Running optimisation for {n_gen} generations") termination = MaximumGenerationTermination(n_gen) res = minimize(problem, algorithm, termination, verbose=True) @@ -778,7 +778,7 @@ def _do(self, _problem: Any, X: np.ndarray, **_kwargs: Any) -> np.ndarray: # no res.F = best_ind.F res.CV = best_ind.CV if hasattr(best_ind, "CV") else None else: - raise RuntimeError("NLP optimization failed to find any solution") + raise RuntimeError("NLP optimisation failed to find any solution") if hasattr(res, "CV") and res.CV is not None: if res.CV[0] > 1e-6: @@ -788,7 +788,7 @@ def _do(self, _problem: Any, X: np.ndarray, **_kwargs: Any) -> np.ndarray: # no else: info("Found solution (constraint violation not available)") - info(f"Optimization terminated with objective={res.F[0]}") + info(f"optimisation terminated with objective={res.F[0]}") quant = Decimal(".01") zero = Decimal(0) diff --git a/fabulous/fabric_generator/gds_generator/steps/tile_optimisation.py b/fabulous/fabric_generator/gds_generator/steps/tile_area_opt.py similarity index 99% rename from fabulous/fabric_generator/gds_generator/steps/tile_optimisation.py rename to fabulous/fabric_generator/gds_generator/steps/tile_area_opt.py index 59c3619b1..3eaa2bc0b 100644 --- a/fabulous/fabric_generator/gds_generator/steps/tile_optimisation.py +++ b/fabulous/fabric_generator/gds_generator/steps/tile_area_opt.py @@ -131,11 +131,11 @@ def _missing_(cls, value: object) -> "OptMode": @Step.factory.register() -class TileOptimisation(WhileStep): +class TileAreaOptimisation(WhileStep): """Tile size optimisation step.""" - id = "FABulous.TileOptimisation" - name = "Tile Optimisation" + id = "FABulous.TileAreaOptimisation" + name = "Tile Area Optimisation" inputs = [DesignFormat.NETLIST] diff --git a/fabulous/fabulous_api.py b/fabulous/fabulous_api.py index 780f34068..30f9ab1b0 100644 --- a/fabulous/fabulous_api.py +++ b/fabulous/fabulous_api.py @@ -46,7 +46,7 @@ from fabulous.fabric_generator.gds_generator.gen_io_pin_config_yaml import ( generate_IO_pin_order_config, ) -from fabulous.fabric_generator.gds_generator.steps.tile_optimisation import OptMode +from fabulous.fabric_generator.gds_generator.steps.tile_area_opt import OptMode from fabulous.fabric_generator.gen_fabric.fabric_automation import genIOBel from fabulous.fabric_generator.gen_fabric.gen_configmem import generateConfigMem from fabulous.fabric_generator.gen_fabric.gen_fabric import generateFabric diff --git a/fabulous/fabulous_cli/fabulous_cli.py b/fabulous/fabulous_cli/fabulous_cli.py index 25bdeb5fa..d0093ebca 100644 --- a/fabulous/fabulous_cli/fabulous_cli.py +++ b/fabulous/fabulous_cli/fabulous_cli.py @@ -61,7 +61,7 @@ from fabulous.fabric_generator.code_generator.code_generator_VHDL import ( VHDLCodeGenerator, ) -from fabulous.fabric_generator.gds_generator.steps.tile_optimisation import OptMode +from fabulous.fabric_generator.gds_generator.steps.tile_area_opt import OptMode from fabulous.fabric_generator.gen_fabric.fabric_automation import ( generateCustomTileConfig, ) diff --git a/tests/cli_test/test_cli.py b/tests/cli_test/test_cli.py index 291850b62..fa19d101c 100644 --- a/tests/cli_test/test_cli.py +++ b/tests/cli_test/test_cli.py @@ -11,7 +11,7 @@ import pytest from pytest_mock import MockerFixture -from fabulous.fabric_generator.gds_generator.steps.tile_optimisation import OptMode +from fabulous.fabric_generator.gds_generator.steps.tile_area_opt import OptMode from fabulous.fabulous_cli.fabulous_cli import FABulous_CLI, _resolve_directional_fix from fabulous.fabulous_cli.helper import create_project, setup_logger from fabulous.fabulous_settings import init_context, reset_context diff --git a/tests/gds_flow_test/flow_test/test_full_fabric_flow.py b/tests/gds_flow_test/flow_test/test_full_fabric_flow.py index b72219272..3c7e25e69 100644 --- a/tests/gds_flow_test/flow_test/test_full_fabric_flow.py +++ b/tests/gds_flow_test/flow_test/test_full_fabric_flow.py @@ -21,7 +21,7 @@ WorkerResult, _run_tile_flow_worker, ) -from fabulous.fabric_generator.gds_generator.steps.tile_optimisation import OptMode +from fabulous.fabric_generator.gds_generator.steps.tile_area_opt import OptMode # Shared fixtures @@ -409,7 +409,7 @@ def test_returns_after_nlp_without_stitching( # Patch the collaborators constructed inside run(). mocker.patch( "fabulous.fabric_generator.gds_generator.flows." - "full_fabric_flow.GlobalTileSizeOptimization" + "full_fabric_flow.GlobalTileSizeoptimisation" ) stitching = mocker.patch( "fabulous.fabric_generator.gds_generator.flows." diff --git a/tests/gds_flow_test/flow_test/test_tile_macro_flow.py b/tests/gds_flow_test/flow_test/test_tile_macro_flow.py index 8af7d2a2c..43b787409 100644 --- a/tests/gds_flow_test/flow_test/test_tile_macro_flow.py +++ b/tests/gds_flow_test/flow_test/test_tile_macro_flow.py @@ -25,7 +25,7 @@ FABulousTileVerilogMacroFlow, ) from fabulous.fabric_generator.gds_generator.helper import round_up_decimal -from fabulous.fabric_generator.gds_generator.steps.tile_optimisation import OptMode +from fabulous.fabric_generator.gds_generator.steps.tile_area_opt import OptMode @pytest.mark.usefixtures("mock_config_load") diff --git a/tests/gds_flow_test/step_test/conftest.py b/tests/gds_flow_test/step_test/conftest.py index f4ecb43e5..80af5a190 100644 --- a/tests/gds_flow_test/step_test/conftest.py +++ b/tests/gds_flow_test/step_test/conftest.py @@ -6,7 +6,7 @@ from librelane.config.config import Config from pytest_mock import MockerFixture -from fabulous.fabric_generator.gds_generator.steps.tile_optimisation import OptMode +from fabulous.fabric_generator.gds_generator.steps.tile_area_opt import OptMode @pytest.fixture(autouse=True) diff --git a/tests/gds_flow_test/step_test/test_global_tile_optimisation.py b/tests/gds_flow_test/step_test/test_global_tile_optimisation.py index bd6077ef4..50054ede0 100644 --- a/tests/gds_flow_test/step_test/test_global_tile_optimisation.py +++ b/tests/gds_flow_test/step_test/test_global_tile_optimisation.py @@ -1,4 +1,4 @@ -"""Tests for GlobalTileSizeOptimization NLP problem helpers. +"""Tests for GlobalTileSizeoptimisation NLP problem helpers. The Pareto frontier helper is the most failure-prone part of the NLP setup: a flipped iteration direction silently locks the body row to the worst-aspect @@ -17,11 +17,11 @@ from fabulous.fabric_definition.fabric import Fabric from fabulous.fabric_definition.tile import Tile -from fabulous.fabric_generator.gds_generator.steps.global_tile_optimisation import ( - GlobalTileSizeOptimization, +from fabulous.fabric_generator.gds_generator.steps.fabric_area_opt import ( + FabricAreaOptimisation, NLPTileProblem, ) -from fabulous.fabric_generator.gds_generator.steps.tile_optimisation import OptMode +from fabulous.fabric_generator.gds_generator.steps.tile_area_opt import OptMode class TestParetoFrontier: @@ -229,7 +229,7 @@ class TestParseTileFields: def test_parses_required_bbox_strings_to_floats(self) -> None: # Bbox fields arrive as space-separated strings and must come out as # 4-tuples of floats. - out = GlobalTileSizeOptimization._parse_tile_fields( + out = FabricAreaOptimisation._parse_tile_fields( { "design__die__bbox": "0 0 100 200", "design__core__bbox": "1 2 99 198", @@ -239,7 +239,7 @@ def test_parses_required_bbox_strings_to_floats(self) -> None: assert out["design__core__bbox"] == [1.0, 2.0, 99.0, 198.0] def test_optional_scalar_fields_passed_through_as_floats(self) -> None: - out = GlobalTileSizeOptimization._parse_tile_fields( + out = FabricAreaOptimisation._parse_tile_fields( { "design__die__bbox": "0 0 100 100", "design__core__bbox": "0 0 100 100", @@ -254,7 +254,7 @@ def test_optional_scalar_fields_passed_through_as_floats(self) -> None: def test_optional_fields_omitted_when_none(self) -> None: # Explicitly None scalars must not appear in the output. - out = GlobalTileSizeOptimization._parse_tile_fields( + out = FabricAreaOptimisation._parse_tile_fields( { "design__die__bbox": "0 0 100 100", "design__core__bbox": "0 0 100 100", @@ -265,7 +265,7 @@ def test_optional_fields_omitted_when_none(self) -> None: def test_clean_probes_parsed_into_nested_floats(self) -> None: # Each probe is a list of stringly-typed numerics; output is float-of-float. - out = GlobalTileSizeOptimization._parse_tile_fields( + out = FabricAreaOptimisation._parse_tile_fields( { "design__die__bbox": "0 0 100 100", "design__core__bbox": "0 0 100 100", @@ -279,14 +279,12 @@ def test_clean_probes_parsed_into_nested_floats(self) -> None: def test_missing_bbox_raises_typeerror(self) -> None: with pytest.raises(TypeError, match="design__die__bbox"): - GlobalTileSizeOptimization._parse_tile_fields( - {"design__core__bbox": "0 0 1 1"} - ) + FabricAreaOptimisation._parse_tile_fields({"design__core__bbox": "0 0 1 1"}) def test_non_string_bbox_raises_typeerror(self) -> None: # Lists / numbers are not accepted — bbox must be a string. with pytest.raises(TypeError, match="design__die__bbox"): - GlobalTileSizeOptimization._parse_tile_fields( + FabricAreaOptimisation._parse_tile_fields( { "design__die__bbox": [0, 0, 1, 1], "design__core__bbox": "0 0 1 1", @@ -324,7 +322,7 @@ def test_partitions_valid_and_all_metrics(self, tmp_path: Path) -> None: path = tmp_path / "metrics.json" path.write_text(json.dumps(payload)) - valid, all_ = GlobalTileSizeOptimization._load_tile_metrics_from_json(path) + valid, all_ = FabricAreaOptimisation._load_tile_metrics_from_json(path) assert OptMode.BALANCE in valid assert "good" in valid[OptMode.BALANCE] @@ -342,7 +340,7 @@ def test_skips_entries_without_bbox(self, tmp_path: Path) -> None: path = tmp_path / "metrics.json" path.write_text(json.dumps(payload)) - valid, all_ = GlobalTileSizeOptimization._load_tile_metrics_from_json(path) + valid, all_ = FabricAreaOptimisation._load_tile_metrics_from_json(path) assert valid[OptMode.BALANCE] == {} assert all_[OptMode.BALANCE] == {} diff --git a/tests/gds_flow_test/step_test/test_tile_optimisation.py b/tests/gds_flow_test/step_test/test_tile_optimisation.py index 1cca443ac..3e9144386 100644 --- a/tests/gds_flow_test/step_test/test_tile_optimisation.py +++ b/tests/gds_flow_test/step_test/test_tile_optimisation.py @@ -12,9 +12,9 @@ from librelane.state.state import State from pytest_mock import MockerFixture -from fabulous.fabric_generator.gds_generator.steps.tile_optimisation import ( +from fabulous.fabric_generator.gds_generator.steps.tile_area_opt import ( OptMode, - TileOptimisation, + TileAreaOptimisation, ) @@ -27,7 +27,7 @@ def test_condition_returns_true_on_drc_errors( """Test condition returns True when DRC errors exist.""" mock_state.metrics["route__drc_errors"] = 5 - step = TileOptimisation(mock_config) + step = TileAreaOptimisation(mock_config) step.config = mock_config assert step.condition(mock_state) is True @@ -38,7 +38,7 @@ def test_condition_returns_true_on_antenna_violations( mock_state.metrics["route__drc_errors"] = 0 mock_state.metrics["antenna__violating__nets"] = 2 - step = TileOptimisation(mock_config) + step = TileAreaOptimisation(mock_config) step.config = mock_config assert step.condition(mock_state) is True @@ -54,7 +54,7 @@ def test_condition_returns_false_when_no_errors( # bracket-based termination and ignore DRC when no bracket is set, so # pin the mode here. mock_config = mock_config.copy(FABULOUS_OPT_MODE=OptMode.BALANCE) - step = TileOptimisation(mock_config) + step = TileAreaOptimisation(mock_config) step.config = mock_config assert step.condition(mock_state) is False @@ -86,7 +86,7 @@ def test_pre_iteration_callback_find_min_width_mode( mock_config = mock_config.copy(BOTTOM_MARGIN_MULT=Decimal(0)) mock_config = mock_config.copy(TOP_MARGIN_MULT=Decimal(0)) - step = TileOptimisation(mock_config) + step = TileAreaOptimisation(mock_config) step.step_dir = str(tmp_path) step.config = mock_config step.iter_count = 0 @@ -107,7 +107,7 @@ def test_post_loop_callback_returns_working_state( against ``mock_state`` no longer holds, but the original metrics must still be visible on the returned state. """ - step = TileOptimisation(mock_config) + step = TileAreaOptimisation(mock_config) step.config = mock_config step.last_working_state = State(metrics=mock_state.metrics) step.clean_probes = [] @@ -121,7 +121,7 @@ def test_post_loop_callback_raises_error_without_working_state( self, mock_config: Config, mock_state: State ) -> None: """Test post_loop_callback raises error if no working state found.""" - step = TileOptimisation(mock_config) + step = TileAreaOptimisation(mock_config) step.config = mock_config step.last_working_state = None @@ -134,7 +134,7 @@ def test_run_ignores_antenna_violations_when_configured( """Test run method with IGNORE_ANTENNA_VIOLATIONS enabled.""" mock_config = mock_config.copy(IGNORE_ANTENNA_VIOLATIONS=True) - step = TileOptimisation(mock_config) + step = TileAreaOptimisation(mock_config) step.config = mock_config _mock_run = mocker.patch( "fabulous.fabric_generator.gds_generator.steps.tile_optimisation.WhileStep.run", @@ -157,7 +157,7 @@ def test_mid_iteration_break_on_drc_errors( mock_state.metrics["route__drc_errors"] = 5 mock_config = mock_config.copy(IGNORE_ANTENNA_VIOLATIONS=True) - step = TileOptimisation(mock_config) + step = TileAreaOptimisation(mock_config) step.config = mock_config result = step.mid_iteration_break(mock_state, Checker.TrDRC()) @@ -179,7 +179,7 @@ def _prepare( mocker: MockerFixture, config: Config, die_area: tuple[Decimal, Decimal, Decimal, Decimal], - ) -> TileOptimisation: + ) -> TileAreaOptimisation: mocker.patch( "fabulous.fabric_generator.gds_generator.steps.tile_optimisation.get_pitch", return_value=(Decimal("0.5"), Decimal("0.5")), @@ -189,7 +189,7 @@ def _prepare( return_value=({}, {}), ) cfg = config.copy(FABULOUS_IGNORE_DEFAULT_DIE_AREA=False, DIE_AREA=die_area) - step = TileOptimisation(cfg) + step = TileAreaOptimisation(cfg) step.config = cfg return step @@ -291,7 +291,7 @@ def test_is_directional_true_for_min_width_and_min_height( ) -> None: for mode in (OptMode.FIND_MIN_WIDTH, OptMode.FIND_MIN_HEIGHT): cfg = mock_config.copy(FABULOUS_OPT_MODE=mode) - step = TileOptimisation(cfg) + step = TileAreaOptimisation(cfg) step.config = cfg assert step._is_directional() is True @@ -300,7 +300,7 @@ def test_is_directional_false_for_balance_and_no_opt( ) -> None: for mode in (OptMode.BALANCE, OptMode.LARGE, OptMode.NO_OPT): cfg = mock_config.copy(FABULOUS_OPT_MODE=mode) - step = TileOptimisation(cfg) + step = TileAreaOptimisation(cfg) step.config = cfg assert step._is_directional() is False @@ -308,7 +308,7 @@ def test_directional_target_returns_w_for_find_min_width( self, mock_config: Config ) -> None: cfg = mock_config.copy(FABULOUS_OPT_MODE=OptMode.FIND_MIN_WIDTH) - step = TileOptimisation(cfg) + step = TileAreaOptimisation(cfg) step.config = cfg # die_area is (x0, y0, w, h); FIND_MIN_WIDTH targets w. assert step._directional_target( @@ -319,7 +319,7 @@ def test_directional_target_returns_h_for_find_min_height( self, mock_config: Config ) -> None: cfg = mock_config.copy(FABULOUS_OPT_MODE=OptMode.FIND_MIN_HEIGHT) - step = TileOptimisation(cfg) + step = TileAreaOptimisation(cfg) step.config = cfg assert step._directional_target( (Decimal(0), Decimal(0), Decimal("12.5"), Decimal("99.9")) @@ -333,7 +333,7 @@ def test_balance_grows_smaller_axis(self, mock_config: Config) -> None: # BALANCE on a non-supertile (logical=1x1) grows the smaller axis only. cfg = mock_config.copy(FABULOUS_OPT_MODE=OptMode.BALANCE) cfg = cfg.copy(FABULOUS_TILE_LOGICAL_WIDTH=1, FABULOUS_TILE_LOGICAL_HEIGHT=1) - step = TileOptimisation(cfg) + step = TileAreaOptimisation(cfg) step.config = cfg # width (5) <= height (10) -> width grows by width_step. @@ -362,7 +362,7 @@ def test_balance_grows_smaller_axis(self, mock_config: Config) -> None: def test_large_grows_both_axes(self, mock_config: Config) -> None: cfg = mock_config.copy(FABULOUS_OPT_MODE=OptMode.LARGE) - step = TileOptimisation(cfg) + step = TileAreaOptimisation(cfg) step.config = cfg new_w, new_h = step._compute_new_dimensions( @@ -380,7 +380,7 @@ def test_instance_area_overflow_scales_both_axes(self, mock_config: Config) -> N # When instance area > core area, both axes scale by sqrt(ratio) # *before* the per-axis step is applied. cfg = mock_config.copy(FABULOUS_OPT_MODE=OptMode.LARGE) - step = TileOptimisation(cfg) + step = TileAreaOptimisation(cfg) step.config = cfg # ratio = 400/100 = 4 -> scale = 2. @@ -402,7 +402,7 @@ def test_supertile_balance_locks_aspect_to_logical( # 2x1 supertile: logical aspect 2:1 must be preserved when growing. cfg = mock_config.copy(FABULOUS_OPT_MODE=OptMode.BALANCE) cfg = cfg.copy(FABULOUS_TILE_LOGICAL_WIDTH=2, FABULOUS_TILE_LOGICAL_HEIGHT=1) - step = TileOptimisation(cfg) + step = TileAreaOptimisation(cfg) step.config = cfg # cell_step = max(width_step/2, height_step/1) = max(2, 3) = 3 @@ -421,7 +421,7 @@ def test_supertile_balance_locks_aspect_to_logical( def test_unknown_mode_raises(self, mock_config: Config) -> None: cfg = mock_config.copy(FABULOUS_OPT_MODE=OptMode.NO_OPT) - step = TileOptimisation(cfg) + step = TileAreaOptimisation(cfg) step.config = cfg with pytest.raises(ValueError, match="Unknown FABULOUS_OPT_MODE"): step._compute_new_dimensions( @@ -445,7 +445,7 @@ class TestComputeBinarySearchDimensions: def _setup( self, mocker: MockerFixture, mock_config: Config, mode: OptMode - ) -> TileOptimisation: + ) -> TileAreaOptimisation: # get_pitch is read inside the helper to compute pitch on the target axis. mocker.patch( "fabulous.fabric_generator.gds_generator.steps.tile_optimisation.get_pitch", @@ -454,7 +454,7 @@ def _setup( cfg = mock_config.copy(FABULOUS_OPT_MODE=mode) cfg = cfg.copy(FABULOUS_PIN_MIN_WIDTH=Decimal(1)) cfg = cfg.copy(FABULOUS_PIN_MIN_HEIGHT=Decimal(1)) - step = TileOptimisation(cfg) + step = TileAreaOptimisation(cfg) step.config = cfg return step From 4766a5c7d5837939f90efe1a7c5f819a5213fd43 Mon Sep 17 00:00:00 2001 From: Kelvin Chung Date: Fri, 22 May 2026 12:03:16 +0100 Subject: [PATCH 43/48] chore: rename test --- ...ow.py => test_fabric_optimisation_flow.py} | 27 +++++++++---------- ...ptimisation.py => test_fabric_area_opt.py} | 2 +- ..._optimisation.py => test_tile_area_opt.py} | 16 +++++------ 3 files changed, 22 insertions(+), 23 deletions(-) rename tests/gds_flow_test/flow_test/{test_full_fabric_flow.py => test_fabric_optimisation_flow.py} (93%) rename tests/gds_flow_test/step_test/{test_global_tile_optimisation.py => test_fabric_area_opt.py} (99%) rename tests/gds_flow_test/step_test/{test_tile_optimisation.py => test_tile_area_opt.py} (98%) diff --git a/tests/gds_flow_test/flow_test/test_full_fabric_flow.py b/tests/gds_flow_test/flow_test/test_fabric_optimisation_flow.py similarity index 93% rename from tests/gds_flow_test/flow_test/test_full_fabric_flow.py rename to tests/gds_flow_test/flow_test/test_fabric_optimisation_flow.py index 3c7e25e69..2e08c7325 100644 --- a/tests/gds_flow_test/flow_test/test_full_fabric_flow.py +++ b/tests/gds_flow_test/flow_test/test_fabric_optimisation_flow.py @@ -1,4 +1,4 @@ -"""Tests for FABulousFabricMacroFullFlow - Full automatic fabric flow. +"""Tests for FABulousFabricOptimisationFlow - fabric optimisation flow. Tests focus on: - Flow initialization and configuration @@ -174,7 +174,7 @@ def test_worker_propagates_unexpected_exceptions( surface with its stack trace. """ mocker.patch( - "fabulous.fabric_generator.gds_generator.flows.full_fabric_flow.FABulousTileVerilogMacroFlow", + "fabulous.fabric_generator.gds_generator.flows.fabric_optimisation_flow.FABulousTileVerilogMacroFlow", side_effect=ValueError("Test error"), ) @@ -206,17 +206,17 @@ def test_worker_recovers_state_on_deferred_flow_error( "FABULOUS_PIN_MIN_HEIGHT": Decimal("10.0"), } mocker.patch( - "fabulous.fabric_generator.gds_generator.flows.full_fabric_flow.FABulousTileVerilogMacroFlow", + "fabulous.fabric_generator.gds_generator.flows.fabric_optimisation_flow.FABulousTileVerilogMacroFlow", return_value=mock_flow, ) state_file: Path = tmp_path / "state_out.json" state_file.write_text("{}", encoding="utf-8") mocker.patch( - "fabulous.fabric_generator.gds_generator.flows.full_fabric_flow.get_latest_file", + "fabulous.fabric_generator.gds_generator.flows.fabric_optimisation_flow.get_latest_file", return_value=state_file, ) mocker.patch( - "fabulous.fabric_generator.gds_generator.flows.full_fabric_flow.State.loads", + "fabulous.fabric_generator.gds_generator.flows.fabric_optimisation_flow.State.loads", return_value=recovered_state, ) @@ -249,7 +249,7 @@ def test_worker_returns_state_on_success( "FABULOUS_PIN_MIN_HEIGHT": Decimal("10.0"), } mocker.patch( - "fabulous.fabric_generator.gds_generator.flows.full_fabric_flow.FABulousTileVerilogMacroFlow", + "fabulous.fabric_generator.gds_generator.flows.fabric_optimisation_flow.FABulousTileVerilogMacroFlow", return_value=mock_flow, ) @@ -286,7 +286,7 @@ def test_worker_passes_custom_overrides( "FABULOUS_PIN_MIN_HEIGHT": Decimal("10.0"), } mock_flow_class: MagicMock = mocker.patch( - "fabulous.fabric_generator.gds_generator.flows.full_fabric_flow.FABulousTileVerilogMacroFlow", + "fabulous.fabric_generator.gds_generator.flows.fabric_optimisation_flow.FABulousTileVerilogMacroFlow", return_value=mock_flow, ) @@ -318,7 +318,7 @@ def test_logs_table_with_tile_rows_and_utilisation( ) -> None: """Each tile produces a row containing its name and a utilisation %.""" info_mock = mocker.patch( - "fabulous.fabric_generator.gds_generator.flows.full_fabric_flow.info" + "fabulous.fabric_generator.gds_generator.flows.fabric_optimisation_flow.info" ) nlp_state: MagicMock = mocker.MagicMock() @@ -350,7 +350,7 @@ def test_logs_table_with_tile_rows_and_utilisation( def test_handles_zero_allocated_area(self, mocker: MockerFixture) -> None: """A zero-area tile reports 0% utilisation instead of dividing by zero.""" info_mock = mocker.patch( - "fabulous.fabric_generator.gds_generator.flows.full_fabric_flow.info" + "fabulous.fabric_generator.gds_generator.flows.fabric_optimisation_flow.info" ) nlp_state: MagicMock = mocker.MagicMock() @@ -409,15 +409,15 @@ def test_returns_after_nlp_without_stitching( # Patch the collaborators constructed inside run(). mocker.patch( "fabulous.fabric_generator.gds_generator.flows." - "full_fabric_flow.GlobalTileSizeoptimisation" + "fabric_optimisation_flow.FabricAreaOptimisation" ) stitching = mocker.patch( "fabulous.fabric_generator.gds_generator.flows." - "full_fabric_flow.FABulousFabricMacroFlow" + "fabric_optimisation_flow.FABulousFabricMacroFlow" ) pool = mocker.patch( "fabulous.fabric_generator.gds_generator.flows." - "full_fabric_flow.DillProcessPoolExecutor" + "fabric_optimisation_flow.DillProcessPoolExecutor" ) initial_state: MagicMock = mocker.MagicMock() @@ -476,7 +476,7 @@ def test_logs_summary_with_per_tile_macro_sizes( "DSP": self._tile_state(mocker, "0 0 50 60"), } info_mock = mocker.patch( - "fabulous.fabric_generator.gds_generator.flows.full_fabric_flow.info" + "fabulous.fabric_generator.gds_generator.flows.fabric_optimisation_flow.info" ) FABulousFabricOptimisationFlow._finalise( @@ -491,4 +491,3 @@ def test_logs_summary_with_per_tile_macro_sizes( assert "100.00 x 200.00" in logged # overall die area w x h assert "LUT 30.00 x 40.00" in logged # per-macro tile size assert "DSP 50.00 x 60.00" in logged - assert "myfab.gds" in logged diff --git a/tests/gds_flow_test/step_test/test_global_tile_optimisation.py b/tests/gds_flow_test/step_test/test_fabric_area_opt.py similarity index 99% rename from tests/gds_flow_test/step_test/test_global_tile_optimisation.py rename to tests/gds_flow_test/step_test/test_fabric_area_opt.py index 50054ede0..90370259f 100644 --- a/tests/gds_flow_test/step_test/test_global_tile_optimisation.py +++ b/tests/gds_flow_test/step_test/test_fabric_area_opt.py @@ -1,4 +1,4 @@ -"""Tests for GlobalTileSizeoptimisation NLP problem helpers. +"""Tests for FabricAreaOptimisation NLP problem helpers. The Pareto frontier helper is the most failure-prone part of the NLP setup: a flipped iteration direction silently locks the body row to the worst-aspect diff --git a/tests/gds_flow_test/step_test/test_tile_optimisation.py b/tests/gds_flow_test/step_test/test_tile_area_opt.py similarity index 98% rename from tests/gds_flow_test/step_test/test_tile_optimisation.py rename to tests/gds_flow_test/step_test/test_tile_area_opt.py index 3e9144386..7e166ad33 100644 --- a/tests/gds_flow_test/step_test/test_tile_optimisation.py +++ b/tests/gds_flow_test/step_test/test_tile_area_opt.py @@ -1,4 +1,4 @@ -"""Tests for TileOptimisation step.""" +"""Tests for TileAreaOptimisation step.""" # for testing private methods # ruff: noqa: SLF001 @@ -68,12 +68,12 @@ def test_pre_iteration_callback_find_min_width_mode( """Test pre_iteration_callback in find_min_width mode.""" # Mock get_pitch to return reasonable pitch values mocker.patch( - "fabulous.fabric_generator.gds_generator.steps.tile_optimisation.get_pitch", + "fabulous.fabric_generator.gds_generator.steps.tile_area_opt.get_pitch", return_value=(Decimal("0.46"), Decimal("2.72")), ) # Mock get_routing_obstructions to avoid config key errors mocker.patch( - "fabulous.fabric_generator.gds_generator.steps.tile_optimisation.get_routing_obstructions", + "fabulous.fabric_generator.gds_generator.steps.tile_area_opt.get_routing_obstructions", return_value=[], ) @@ -137,7 +137,7 @@ def test_run_ignores_antenna_violations_when_configured( step = TileAreaOptimisation(mock_config) step.config = mock_config _mock_run = mocker.patch( - "fabulous.fabric_generator.gds_generator.steps.tile_optimisation.WhileStep.run", + "fabulous.fabric_generator.gds_generator.steps.tile_area_opt.WhileStep.run", return_value=({}, {}), ) @@ -150,7 +150,7 @@ def test_mid_iteration_break_on_drc_errors( self, mock_config: Config, mock_state: State ) -> None: """Test mid_iteration_break returns True on DRC errors.""" - from fabulous.fabric_generator.gds_generator.steps.tile_optimisation import ( + from fabulous.fabric_generator.gds_generator.steps.tile_area_opt import ( Checker, ) @@ -181,11 +181,11 @@ def _prepare( die_area: tuple[Decimal, Decimal, Decimal, Decimal], ) -> TileAreaOptimisation: mocker.patch( - "fabulous.fabric_generator.gds_generator.steps.tile_optimisation.get_pitch", + "fabulous.fabric_generator.gds_generator.steps.tile_area_opt.get_pitch", return_value=(Decimal("0.5"), Decimal("0.5")), ) mocker.patch( - "fabulous.fabric_generator.gds_generator.steps.tile_optimisation.WhileStep.run", + "fabulous.fabric_generator.gds_generator.steps.tile_area_opt.WhileStep.run", return_value=({}, {}), ) cfg = config.copy(FABULOUS_IGNORE_DEFAULT_DIE_AREA=False, DIE_AREA=die_area) @@ -448,7 +448,7 @@ def _setup( ) -> TileAreaOptimisation: # get_pitch is read inside the helper to compute pitch on the target axis. mocker.patch( - "fabulous.fabric_generator.gds_generator.steps.tile_optimisation.get_pitch", + "fabulous.fabric_generator.gds_generator.steps.tile_area_opt.get_pitch", return_value=(Decimal("0.5"), Decimal("0.5")), ) cfg = mock_config.copy(FABULOUS_OPT_MODE=mode) From c93c29317274b087af01b1279bda5a6376f12d74 Mon Sep 17 00:00:00 2001 From: Kelvin Chung Date: Tue, 26 May 2026 16:47:02 +0100 Subject: [PATCH 44/48] fix: fix fail result leaking --- .../gds_generator/steps/fabric_area_opt.py | 40 ++++++++--------- .../gds_generator/steps/tile_area_opt.py | 6 --- .../step_test/test_fabric_area_opt.py | 43 +++++++++++-------- 3 files changed, 44 insertions(+), 45 deletions(-) diff --git a/fabulous/fabric_generator/gds_generator/steps/fabric_area_opt.py b/fabulous/fabric_generator/gds_generator/steps/fabric_area_opt.py index b4c6a91a4..44052a9a6 100644 --- a/fabulous/fabric_generator/gds_generator/steps/fabric_area_opt.py +++ b/fabulous/fabric_generator/gds_generator/steps/fabric_area_opt.py @@ -41,8 +41,9 @@ class NLPTileProblem(ElementwiseProblem): tile_metrics : dict[OptMode, dict] Per-mode compilation metrics for tiles that compiled successfully. all_tile_metrics : dict[OptMode, dict] | None, optional - Metrics including failed explorations, used for lower-bound - estimates. Falls back to *tile_metrics* when ``None``. + Retained for backwards compatibility; should contain the same + successful-compilation entries as *tile_metrics*. Falls back to + *tile_metrics* when ``None``. area_margin : float, optional Fractional margin added to standard-cell area constraints, by default 0.05 (5 %). @@ -65,7 +66,6 @@ def __init__( self.fabric = fabric self.tile_metrics = tile_metrics self.area_margin = area_margin - # all_tile_metrics includes failed explorations for lower bounds self._all_tile_metrics = all_tile_metrics or tile_metrics self.tile_row_set: dict[str, set[int]] = defaultdict(set) @@ -676,10 +676,15 @@ def _load_tile_metrics_from_json( ) -> tuple[dict[OptMode, dict], dict[OptMode, dict]]: """Load tile metrics from a JSON file. - Returns two dicts: (valid_metrics, all_metrics). - valid_metrics excludes tiles whose exploration never found a - working state (used for feasibility constraints). - all_metrics includes everything with a bbox (used for lower bounds). + Returns two dicts: ``(valid_metrics, all_metrics)``. Both contain only + entries that compiled successfully — any entry with an ``error`` field + is dropped. A failed run may have left behind a recovered intermediate + bbox (for example, when detailed routing timed out at an attempted die + size), but that bbox does not represent a buildable geometry and would + mislead both the NLP lower bounds and the feasibility envelope. + + The two dicts are returned identical for now; the second slot remains + for backwards compatibility with ``NLPTileProblem.all_tile_metrics``. """ tile_data_raw = json.loads(path.resolve().read_text()) valid_data: dict[OptMode, dict] = {} @@ -690,25 +695,20 @@ def _load_tile_metrics_from_json( all_dict: dict[str, dict[str, Any]] = {} for tile_name, data in tile_info.items(): + if "error" in data: + warn( + f"Tile {tile_name} in mode {mode} failed " + f"({data['error']!r}), excluding from constraints" + ) + continue + if not data.get("design__die__bbox"): - if "error" in data: - warn( - f"Tile {tile_name} in mode {mode} has error " - f"and no bbox: {data['error']}" - ) continue parsed = cls._parse_tile_fields(data) + valid_dict[tile_name] = parsed all_dict[tile_name] = parsed - if "No working state found" in data.get("error_traceback", ""): - warn( - f"Tile {tile_name} in mode {mode} never compiled " - f"successfully, excluding from constraints" - ) - else: - valid_dict[tile_name] = parsed - valid_data[OptMode(mode)] = valid_dict all_data[OptMode(mode)] = all_dict diff --git a/fabulous/fabric_generator/gds_generator/steps/tile_area_opt.py b/fabulous/fabric_generator/gds_generator/steps/tile_area_opt.py index 3eaa2bc0b..9ce43e097 100644 --- a/fabulous/fabric_generator/gds_generator/steps/tile_area_opt.py +++ b/fabulous/fabric_generator/gds_generator/steps/tile_area_opt.py @@ -29,7 +29,6 @@ FABulousTileIOPlacement, ) from fabulous.fabric_generator.gds_generator.steps.timed_detailed_routing import ( - DRTTimedOutError, FABulousDetailedRoutingTimed, ) from fabulous.fabric_generator.gds_generator.steps.while_step import WhileStep @@ -184,11 +183,6 @@ class TileAreaOptimisation(WhileStep): raise_on_failure: bool = False - # A DRT timeout means TritonRoute couldn't route this die size within the - # budget; another iteration would only grow the die and waste another - # budget on the same problem. Let the timeout abort the whole loop. - propagate_exceptions: tuple[type[BaseException], ...] = (DRTTimedOutError,) - iter_count: int = 0 last_core_area: Decimal | None = None diff --git a/tests/gds_flow_test/step_test/test_fabric_area_opt.py b/tests/gds_flow_test/step_test/test_fabric_area_opt.py index 90370259f..f39c9acc3 100644 --- a/tests/gds_flow_test/step_test/test_fabric_area_opt.py +++ b/tests/gds_flow_test/step_test/test_fabric_area_opt.py @@ -294,18 +294,19 @@ def test_non_string_bbox_raises_typeerror(self) -> None: class TestLoadTileMetricsFromJson: """End-to-end deserialisation of the per-mode metric file produced by the - exploration phase. The function partitions tiles into: - - - ``valid_data``: tiles whose exploration found a working state - (used for feasibility constraints). - - ``all_data``: every tile that has a bbox, including those that never - compiled (used for lower-bound estimates only). + exploration phase. Any entry with an ``error`` field is excluded from both + returned dicts — a partial recovered bbox from a failed run does not + represent a buildable geometry, so it must not enter either the NLP + feasibility samples or the lower-bound floor. """ - def test_partitions_valid_and_all_metrics(self, tmp_path: Path) -> None: - # "good" tile compiled cleanly; "broken" tile has a bbox but - # exploration eventually gave up with "No working state found". - # The latter is included in all_data but excluded from valid_data. + def test_excludes_failed_entries_from_both_dicts(self, tmp_path: Path) -> None: + # "good" compiled cleanly. "broken" gave up with "No working state + # found" and carries no bbox. "recovered_failure" timed out in + # detailed routing but left an intermediate bbox + error -- this + # is the case that motivated tightening the filter, since the bbox + # was previously slipping into all_metrics and shrinking the NLP + # lower bound below the only proven-buildable width. payload = { OptMode.BALANCE.value: { "good": { @@ -313,10 +314,17 @@ def test_partitions_valid_and_all_metrics(self, tmp_path: Path) -> None: "design__core__bbox": "0 0 100 100", }, "broken": { - "design__die__bbox": "0 0 50 50", - "design__core__bbox": "0 0 50 50", + "error": "No working state found", "error_traceback": "RuntimeError: No working state found", }, + "recovered_failure": { + "design__die__bbox": "0 0 50 200", + "design__core__bbox": "0 0 50 200", + "error": "Worker execution failed", + "error_traceback": ( + "FlowError: Detailed Routing exceeded 600s timeout" + ), + }, } } path = tmp_path / "metrics.json" @@ -324,17 +332,14 @@ def test_partitions_valid_and_all_metrics(self, tmp_path: Path) -> None: valid, all_ = FabricAreaOptimisation._load_tile_metrics_from_json(path) - assert OptMode.BALANCE in valid - assert "good" in valid[OptMode.BALANCE] - assert "broken" not in valid[OptMode.BALANCE] - assert "good" in all_[OptMode.BALANCE] - assert "broken" in all_[OptMode.BALANCE] + assert valid[OptMode.BALANCE].keys() == {"good"} + assert all_[OptMode.BALANCE].keys() == {"good"} def test_skips_entries_without_bbox(self, tmp_path: Path) -> None: - # No bbox -> nothing to constrain; the entry is dropped from both dicts. + # No bbox, no error -> nothing to constrain; entry is dropped. payload = { OptMode.BALANCE.value: { - "no_bbox": {"error": "compilation failed"}, + "no_bbox": {}, } } path = tmp_path / "metrics.json" From 81e9ebb7d6136446f2f7d9e822ef0908aba2fdb2 Mon Sep 17 00:00:00 2001 From: Kelvin Chung Date: Thu, 28 May 2026 14:24:30 +0100 Subject: [PATCH 45/48] fix: fix an edge case bug --- .../gds_generator/script/tile_io_place.py | 197 ++++++++++++------ .../script_test/test_tile_io_place.py | 181 +++++++++++++--- 2 files changed, 285 insertions(+), 93 deletions(-) diff --git a/fabulous/fabric_generator/gds_generator/script/tile_io_place.py b/fabulous/fabric_generator/gds_generator/script/tile_io_place.py index e33d1573b..cdccfd2f3 100644 --- a/fabulous/fabric_generator/gds_generator/script/tile_io_place.py +++ b/fabulous/fabric_generator/gds_generator/script/tile_io_place.py @@ -40,6 +40,126 @@ def grid_to_tracks(origin: float, count: int, step: float) -> list[float]: return tracks +def filter_pin_tracks_by_stride_and_distance( + plan: "PinPlacementPlan", + step_by_side: dict[Side, float], + origin_by_side: dict[Side, float], + micron_in_units: float, +) -> tuple[dict[Side, list[list[float]]], list[dict]]: + """Filter the per-segment raw tracks to satisfy min/max distance. + + For each side and each segment in ``plan``, keep every ``stride``-th raw + track to enforce the segment's ``min_distance``, then insert extra + tracks where consecutive filtered tracks fall further apart than the + segment's ``max_distance``. Segments whose filtered track count is + smaller than ``actual_pin_count`` are flagged in the returned + ``track_errors`` list for the caller to surface. + + Parameters + ---------- + plan : PinPlacementPlan + Plan whose ``track_coordinates`` and ``segments_by_side`` have been + populated by ``allocate_tracks`` and ``ensure_min_distances``. + step_by_side : dict[Side, float] + Track pitch for each side, in DBU. + origin_by_side : dict[Side, float] + Track origin for each side, in DBU. + micron_in_units : float + DBU per micron, used to convert segment distances to DBU. + + Returns + ------- + pin_tracks : dict[Side, list[list[float]]] + Filtered track coordinates per side, in the same order as + ``plan.segments_by_side[side]``. + track_errors : list[dict] + One entry per segment that did not have enough filtered tracks to + hold its pins, with ``side``, ``shortage``, ``step`` and + ``min_distance`` keys. + """ + pin_tracks: dict[Side, list[list[float]]] = {side: [] for side in Side} + track_errors: list[dict] = [] + + for side, segments in plan.segments_by_side.items(): + if not segments or side not in origin_by_side: + continue + global_origin = origin_by_side[side] + step = step_by_side[side] + + # Stride cadence is carried across segments that share a physical + # tile; it resets when ``tile_index`` changes so each super-tile + # division still produces the same pin layout as a standalone tile. + side_last_filtered_idx: int | None = None + side_current_tile_index: int | None = None + first_segment_seen = False + + for segment_index, segment in enumerate(segments): + if segment.min_distance is None: + raise AssertionError("min_distance must be defined before placement") + min_distance = segment.min_distance * micron_in_units + max_distance = segment.max_distance + if max_distance is not None: + max_distance = max_distance * micron_in_units + + raw_tracks = plan.track_coordinates[side][segment_index] + + stride = max(1, math.ceil(min_distance / step)) + + # Reset the cadence when entering a new physical tile so each + # super-tile division produces the same pin positions as a + # standalone build. The first segment on the side always + # anchors a fresh cadence on its own first raw track. + if not first_segment_seen or segment.tile_index != side_current_tile_index: + side_last_filtered_idx = None + side_current_tile_index = segment.tile_index + first_segment_seen = True + + filtered: list[float] = [] + for track_coord in raw_tracks: + track_idx = round((track_coord - global_origin) / step) + if ( + side_last_filtered_idx is None + or (track_idx - side_last_filtered_idx) >= stride + ): + filtered.append(track_coord) + side_last_filtered_idx = track_idx + + if max_distance is not None: + max_stride = max(1, math.floor(max_distance / step)) + enforced: list[float] = [] + last_global_idx: int | None = None + for track_coord in filtered: + global_track_idx = round((track_coord - global_origin) / step) + if last_global_idx is None: + enforced.append(track_coord) + last_global_idx = global_track_idx + else: + if global_track_idx - last_global_idx > max_stride: + interim_idx = last_global_idx + max_stride + while interim_idx < global_track_idx: + interim_coord = global_origin + interim_idx * step + enforced.append(interim_coord) + interim_idx += max_stride + enforced.append(track_coord) + last_global_idx = global_track_idx + filtered = enforced + + needed = segment.actual_pin_count + if needed > len(filtered): + track_errors.append( + { + "side": side, + "shortage": needed - len(filtered), + "step": step, + "min_distance": min_distance, + } + ) + + pin_tracks[side].append(filtered) + + return pin_tracks, track_errors + + def equally_spaced_sequence( side_pin_placement: list[int | odbBTermLike], possible_locations: list[float] ) -> list[tuple[float, odbBTermLike]]: @@ -964,74 +1084,15 @@ def io_place( Side.SOUTH: v_step, } - pin_tracks: dict[Side, list[list[float]]] = {side: [] for side in Side} - track_errors: list[dict] = [] - - for side in Side: - # Get origin for this side to calculate global alignment - global_origin = origin_v if side in {Side.NORTH, Side.SOUTH} else origin_h - - for segment_index, segment in enumerate(plan.segments_by_side[side]): - if segment.min_distance is None: - raise AssertionError("min_distance must be defined before placement") - min_distance = segment.min_distance * micron_in_units - max_distance = segment.max_distance - if max_distance is not None: - max_distance = max_distance * micron_in_units - - raw_tracks = plan.track_coordinates[side][segment_index] - step = step_by_side[side] - - stride = max(1, math.ceil(min_distance / step)) - - # Filter tracks by stride. Use the first raw track as the - # reference so the stride pattern restarts at each division - # boundary. This ensures that super-tile divisions produce - # the same pin positions as a standalone tile, regardless of - # whether the division boundary falls on an even or odd - # global track index. - filtered = [] - ref_idx: int | None = None - for track_coord in raw_tracks: - track_idx = round((track_coord - global_origin) / step) - if ref_idx is None: - ref_idx = track_idx - if (track_idx - ref_idx) % stride == 0: - filtered.append(track_coord) - - if max_distance is not None: - max_stride = max(1, math.floor(max_distance / step)) - enforced = [] - last_global_idx = None - for track_coord in filtered: - global_track_idx = round((track_coord - global_origin) / step) - if last_global_idx is None: - enforced.append(track_coord) - last_global_idx = global_track_idx - else: - if global_track_idx - last_global_idx > max_stride: - # Need to add intermediate tracks - interim_idx = last_global_idx + max_stride - while interim_idx < global_track_idx: - interim_coord = global_origin + interim_idx * step - enforced.append(interim_coord) - interim_idx += max_stride - enforced.append(track_coord) - last_global_idx = global_track_idx - filtered = enforced - - needed = segment.actual_pin_count - if needed > len(filtered): - track_errors.append( - { - "side": side, - "shortage": needed - len(filtered), - "step": step, - "min_distance": min_distance, - } - ) - - pin_tracks[side].append(filtered) + origin_by_side = { + Side.NORTH: origin_v, + Side.SOUTH: origin_v, + Side.EAST: origin_h, + Side.WEST: origin_h, + } + pin_tracks, track_errors = filter_pin_tracks_by_stride_and_distance( + plan, step_by_side, origin_by_side, micron_in_units + ) if track_errors: err("Insufficient tracks for pin allocation. Minimum die size increase needed:") diff --git a/tests/gds_flow_test/script_test/test_tile_io_place.py b/tests/gds_flow_test/script_test/test_tile_io_place.py index 46ba2f2aa..57ae258c6 100644 --- a/tests/gds_flow_test/script_test/test_tile_io_place.py +++ b/tests/gds_flow_test/script_test/test_tile_io_place.py @@ -16,6 +16,7 @@ PinPlacementPlan, SegmentInfo, equally_spaced_sequence, + filter_pin_tracks_by_stride_and_distance, grid_to_tracks, ) @@ -1286,24 +1287,34 @@ def test_stride_filter_does_not_shift_divisions( stride: int, label: str, ) -> None: - """Stride filtering must not shift pins between divisions. + """Stride filtering must not shift pins between super-tile divisions. When the division boundary falls on an odd global track index and stride=2, a global-index-based filter would skip the first track in upper divisions, - shifting all pins by 1 track. The stride must be relative to each division's - start instead. + shifting all pins by 1 track. The cadence must reset at every ``tile_index`` + boundary so each super-tile division reproduces the standalone tile layout. """ import math as m origin_f = float(origin) step_f = float(step) tile_h = float(tile_height) + min_distance = stride * step_f + micron_in_units = 1.0 normal_count = m.floor((tile_h - origin_f) / step_f) + 1 super_count = m.floor((2 * tile_h - origin_f) / step_f) + 1 # Normal tile normal_config = { - "X0Y0": {"EAST": [{"pins": ["n0", "n1"], "sort_mode": "bus_major"}]}, + "X0Y0": { + "EAST": [ + { + "pins": ["n0", "n1"], + "sort_mode": "bus_major", + "min_distance": min_distance, + } + ] + }, } plan_normal = PinPlacementPlan( normal_config, @@ -1313,12 +1324,28 @@ def test_stride_filter_does_not_shift_divisions( plan_normal.allocate_tracks( {Side.EAST: (normal_count, step_f, origin_f, tile_h)} ) - normal_raw = plan_normal.track_coordinates[Side.EAST][0] + plan_normal.ensure_min_distances({Side.EAST: min_distance}) # Super tile super_config = { - "X0Y0": {"WEST": [{"pins": ["s0", "s1"], "sort_mode": "bus_major"}]}, - "X0Y1": {"WEST": [{"pins": ["s2", "s3"], "sort_mode": "bus_major"}]}, + "X0Y0": { + "WEST": [ + { + "pins": ["s0", "s1"], + "sort_mode": "bus_major", + "min_distance": min_distance, + } + ] + }, + "X0Y1": { + "WEST": [ + { + "pins": ["s2", "s3"], + "sort_mode": "bus_major", + "min_distance": min_distance, + } + ] + }, } plan_super = PinPlacementPlan( super_config, @@ -1328,24 +1355,24 @@ def test_stride_filter_does_not_shift_divisions( plan_super.allocate_tracks( {Side.WEST: (super_count, step_f, origin_f, 2 * tile_h)} ) - super_raw_top = plan_super.track_coordinates[Side.WEST][0] # Y0 = top - super_raw_bot = plan_super.track_coordinates[Side.WEST][1] # Y1 = bottom - - # Apply stride filter (same logic as io_place) - def stride_filter(tracks: list[float]) -> list[float]: - result = [] - ref_idx = None - for t in tracks: - idx = round((t - origin_f) / step_f) - if ref_idx is None: - ref_idx = idx - if (idx - ref_idx) % stride == 0: - result.append(t) - return result - - normal_filtered = stride_filter(normal_raw) - super_top_filtered = stride_filter(super_raw_top) - super_bot_filtered = stride_filter(super_raw_bot) + plan_super.ensure_min_distances({Side.WEST: min_distance}) + + step_by_side = {side: step_f for side in Side} + origin_by_side = {side: origin_f for side in Side} + + normal_pin_tracks, normal_errors = filter_pin_tracks_by_stride_and_distance( + plan_normal, step_by_side, origin_by_side, micron_in_units + ) + super_pin_tracks, super_errors = filter_pin_tracks_by_stride_and_distance( + plan_super, step_by_side, origin_by_side, micron_in_units + ) + + assert normal_errors == [], f"[{label}] normal filter errors: {normal_errors}" + assert super_errors == [], f"[{label}] super filter errors: {super_errors}" + + normal_filtered = normal_pin_tracks[Side.EAST][0] + super_top_filtered = super_pin_tracks[Side.WEST][0] # Y0 = top + super_bot_filtered = super_pin_tracks[Side.WEST][1] # Y1 = bottom # Bottom division must match normal tile exactly assert normal_filtered == super_bot_filtered, ( @@ -1357,6 +1384,110 @@ def stride_filter(tracks: list[float]) -> list[float]: f"stride filter, normal has {len(normal_filtered)}" ) + def test_stride_filter_respects_distance_across_segments_in_tile( + self, mocker: MockerFixture + ) -> None: + """Stride filter must hold ``stride * step`` across segment boundaries. + + Real tiles split one side across multiple YAML segments (e.g. + ``N_term_single`` SOUTH allocates pins to ``N1END``, ``N2MID``, + ``N2END``). The stride filter that enforces ``min_distance`` should + treat the whole side of a physical tile as one stride cadence. + When the cadence restarts at every segment boundary the last + filtered pin of segment A and the first filtered pin of segment B + can land at ``step`` apart instead of ``stride * step``, which + TritonRoute then reports as Metal Spacing DRCs on the routed nets. + + Reproduction observed on ``N_term_single`` SOUTH at W=120.96 um. + """ + # SG13G2 Metal2 routing parameters: origin on-grid, step is the + # native pitch, min_distance = 2 pitches → stride = 2. + origin, step, tile_width = 0.0, 0.48, 60.0 + min_distance = 2 * step + expected_spacing = min_distance + micron_in_units = 1.0 + + # Three segments on SOUTH with odd-sized pin sets so the buggy + # per-segment cadence reset puts the boundaries off-parity. + config = { + "X0Y0": { + "SOUTH": [ + { + "pins": ["a0", "a1", "a2", "a3", "a4"], + "sort_mode": "bus_major", + "min_distance": min_distance, + }, + { + "pins": ["b0", "b1", "b2", "b3", "b4"], + "sort_mode": "bus_major", + "min_distance": min_distance, + }, + { + "pins": ["c0", "c1", "c2", "c3", "c4"], + "sort_mode": "bus_major", + "min_distance": min_distance, + }, + ], + }, + } + pin_names = [f"{letter}{i}" for letter in "abc" for i in range(5)] + plan = PinPlacementPlan( + config, self._make_bterms(mocker, pin_names), "none" + ) + + track_count = int((tile_width - origin) / step) + 1 + plan.allocate_tracks( + {Side.SOUTH: (track_count, step, origin, tile_width)} + ) + plan.ensure_min_distances({Side.SOUTH: min_distance}) + + assert len(plan.segments_by_side[Side.SOUTH]) == 3 + assert len(plan.track_coordinates[Side.SOUTH]) == 3 + + pin_tracks, track_errors = filter_pin_tracks_by_stride_and_distance( + plan, + step_by_side={ + Side.NORTH: step, + Side.SOUTH: step, + Side.EAST: step, + Side.WEST: step, + }, + origin_by_side={ + Side.NORTH: origin, + Side.SOUTH: origin, + Side.EAST: origin, + Side.WEST: origin, + }, + micron_in_units=micron_in_units, + ) + + assert track_errors == [], ( + f"filter reported track shortfalls instead of producing tracks: " + f"{track_errors}" + ) + + south_segments = pin_tracks[Side.SOUTH] + assert len(south_segments) == 3 + + # Each segment in isolation must satisfy its own stride spacing. + for seg_idx, filtered in enumerate(south_segments): + for i in range(len(filtered) - 1): + delta = filtered[i + 1] - filtered[i] + assert delta == pytest.approx(expected_spacing), ( + f"segment {seg_idx} internal spacing {delta} " + f"!= expected {expected_spacing}" + ) + + # Spacing must also hold across segment boundaries on the same side. + side_tracks = [t for seg in south_segments for t in seg] + for i in range(len(side_tracks) - 1): + delta = side_tracks[i + 1] - side_tracks[i] + assert delta == pytest.approx(expected_spacing), ( + f"cross-segment spacing violation between filtered " + f"track {i}={side_tracks[i]} and {i + 1}={side_tracks[i + 1]}: " + f"Δ={delta} != expected {expected_spacing}" + ) + @pytest.mark.parametrize("num_divisions", [2, 3, 4]) def test_division_index_symmetry_east_west(self, num_divisions: int) -> None: """EAST and WEST sides must produce the same division index for the same From cc0fe4932d708493a5ea20a89f29a61f1576ccf5 Mon Sep 17 00:00:00 2001 From: Kelvin Chung Date: Thu, 28 May 2026 14:24:48 +0100 Subject: [PATCH 46/48] chore: performance improvement --- .../flows/fabric_optimisation_flow.py | 1 + .../gds_generator/steps/fabric_area_opt.py | 5 +- .../gds_generator/steps/tile_area_opt.py | 46 +++++++++++++++++++ fabulous/fabulous_api.py | 12 ++++- .../step_test/test_fabric_area_opt.py | 46 +++++++++++-------- 5 files changed, 87 insertions(+), 23 deletions(-) diff --git a/fabulous/fabric_generator/gds_generator/flows/fabric_optimisation_flow.py b/fabulous/fabric_generator/gds_generator/flows/fabric_optimisation_flow.py index 0695166b6..684c0c7db 100644 --- a/fabulous/fabric_generator/gds_generator/flows/fabric_optimisation_flow.py +++ b/fabulous/fabric_generator/gds_generator/flows/fabric_optimisation_flow.py @@ -576,6 +576,7 @@ def run(self, initial_state: State, **_kwargs: dict) -> tuple[State, list[Step]] for k in fabric.get_all_unique_tiles() }, base_config_path=proj_dir / "Fabric" / "gds_config.yaml", + design_dir=proj_dir / "Fabric" / "macro", pdk=get_context().pdk, pdk_root=get_context().pdk_root, ) diff --git a/fabulous/fabric_generator/gds_generator/steps/fabric_area_opt.py b/fabulous/fabric_generator/gds_generator/steps/fabric_area_opt.py index 44052a9a6..70bc383bd 100644 --- a/fabulous/fabric_generator/gds_generator/steps/fabric_area_opt.py +++ b/fabulous/fabric_generator/gds_generator/steps/fabric_area_opt.py @@ -212,6 +212,9 @@ def _combined_min(name: str) -> tuple[float, float]: if not samples: continue + if len(samples) >= 2: + continue + is_supertile = tile.name in self.fabric.superTileDic if is_supertile: supertile = self.fabric.superTileDic[tile.name] @@ -225,8 +228,6 @@ def _combined_min(name: str) -> tuple[float, float]: if n_cols == 0 or n_rows == 0: continue else: - if len(samples) != 1: - continue n_cols, n_rows = 1, 1 target_aspect = n_cols / n_rows # w/h for square cells diff --git a/fabulous/fabric_generator/gds_generator/steps/tile_area_opt.py b/fabulous/fabric_generator/gds_generator/steps/tile_area_opt.py index 9ce43e097..00595d2fb 100644 --- a/fabulous/fabric_generator/gds_generator/steps/tile_area_opt.py +++ b/fabulous/fabric_generator/gds_generator/steps/tile_area_opt.py @@ -202,6 +202,10 @@ class TileAreaOptimisation(WhileStep): bracket_exhausted: bool = False + pending_locked_axis_bump: Decimal = Decimal(0) + + LOW_UTIL_THRESH: Decimal = Decimal("0.25") + def _diode_port_area(self, site_width: Decimal, site_height: Decimal) -> Decimal: """Estimate the flat instance-area contribution of port diodes. @@ -304,6 +308,12 @@ def post_iteration_callback( self.last_working_state = post_iteration.copy() elif self.bracket_low is None or target > self.bracket_low: self.bracket_low = target + + # Adaptive widen: when the seed picked by IGNORE_DEFAULT_DIE_AREA + # mode left the locked axis too narrow (cells sparse against + # walls), nudge the locked axis up by one step so the next iter + # has more room. Only fires when we control the seed. + self._maybe_request_locked_axis_widen(post_iteration) return post_iteration if full_iter_completed: @@ -313,6 +323,33 @@ def post_iteration_callback( self.iter_count += 1 return post_iteration + def _maybe_request_locked_axis_widen(self, state: State) -> None: + """Queue a locked-axis bump when the iter ran with utilisation < threshold. + + Only meaningful when FABULOUS_IGNORE_DEFAULT_DIE_AREA is on (we control + the seed): a sparse placement means the locked axis is over-sized + relative to the cells, but in directional modes we can only fix that + on the *locked* axis (the target axis is being optimised on its own). + The bump is consumed on the next call to + _compute_binary_search_dimensions. + """ + if not self.config.get("FABULOUS_IGNORE_DEFAULT_DIE_AREA", False): + return + util_raw = state.metrics.get("design__instance__utilization") + if util_raw is None: + return + if Decimal(util_raw) >= self.LOW_UTIL_THRESH: + return + + target_is_width = self.config["FABULOUS_OPT_MODE"] == OptMode.FIND_MIN_WIDTH + site_w = Decimal(state.metrics.get("pdk__site_width", Decimal(1))) + site_h = Decimal(state.metrics.get("pdk__site_height", Decimal(1))) + if target_is_width: + step = site_h * self.config["FABULOUS_OPTIMISATION_HEIGHT_STEP_COUNT"] + else: + step = site_w * self.config["FABULOUS_OPTIMISATION_WIDTH_STEP_COUNT"] + self.pending_locked_axis_bump = step + def _refresh_routing_obstructions(self) -> None: """Clear and recompute routing obstructions from current config. @@ -493,6 +530,15 @@ def _compute_binary_search_dimensions( else: next_target = (self.bracket_low + self.bracket_high) / Decimal(2) + # Consume any pending locked-axis bump requested by the previous + # iter's util check. Widening the locked axis makes the tile + # strictly easier, so prior failing targets may now succeed — + # drop bracket_low so the bisection re-explores below it. + if self.pending_locked_axis_bump > 0: + non_target = non_target + self.pending_locked_axis_bump + self.pending_locked_axis_bump = Decimal(0) + self.bracket_low = None + if target_is_width: return next_target, non_target return non_target, next_target diff --git a/fabulous/fabulous_api.py b/fabulous/fabulous_api.py index 30f9ab1b0..cb6b8cd79 100644 --- a/fabulous/fabulous_api.py +++ b/fabulous/fabulous_api.py @@ -5,6 +5,7 @@ various fabric-related operations. """ +import shutil from collections.abc import Iterable from pathlib import Path @@ -719,8 +720,15 @@ def full_fabric_automation( pdk_root=str(pdk_root.resolve()), ) result = flow.start() - logger.info(f"Saving final views for FABulous to {out_folder / 'final_views'}") - result.save_snapshot(out_folder / "final_views") + final_views = out_folder / "final_views" + logger.info(f"Saving final views for FABulous to {final_views}") + result.save_snapshot(final_views) + tile_opt_summary = flow.config.get("TILE_OPT_INFO") + if tile_opt_summary is not None: + summary_src = Path(tile_opt_summary) + summary_dst = final_views / summary_src.name + logger.info(f"Copying tile optimisation summary to {summary_dst}") + shutil.copyfile(summary_src, summary_dst) logger.info("Stitching flow completed.") def timing_model_interface( diff --git a/tests/gds_flow_test/step_test/test_fabric_area_opt.py b/tests/gds_flow_test/step_test/test_fabric_area_opt.py index f39c9acc3..0ff3da69e 100644 --- a/tests/gds_flow_test/step_test/test_fabric_area_opt.py +++ b/tests/gds_flow_test/step_test/test_fabric_area_opt.py @@ -130,43 +130,51 @@ def test_real_demo_project_frontiers( class TestEnvelopeWFloor: - """Piecewise-linear lower bound on width given a row height. + """Hyperbolic (constant-area) lower bound on width given a row height. - The Pareto frontier delivered by ``_pareto_frontier`` is sorted by ``h`` - ascending and ``w`` descending — that monotone shape is the precondition - of the linear interpolation used here. + The envelope assumes density-limited feasibility: any (w, h) with + w * h >= min(observed die area) should compile. Floor clamped from below + by the narrowest observed width so we never extrapolate past the sampled + w range. """ def test_no_samples_returns_zero(self) -> None: # No exploration data -> no constraint, the floor is 0. assert NLPTileProblem._envelope_w_floor(100.0, []) == 0.0 - def test_below_sample_range_clamps_to_first(self) -> None: - # The frontier is sorted by h ascending; below the smallest h we clamp - # to the widest sample. This is the correct convex lower bound. + def test_below_sample_range_grows_hyperbolic(self) -> None: + # Both samples have die area 10,000; at h=10 hyperbolic says w>=1000. samples = [(200.0, 50.0), (100.0, 100.0)] - assert NLPTileProblem._envelope_w_floor(10.0, samples) == 200.0 - # And exactly at the smallest h. + assert NLPTileProblem._envelope_w_floor(10.0, samples) == 1000.0 + # At smallest sampled h: hyperbolic recovers the sample width. assert NLPTileProblem._envelope_w_floor(50.0, samples) == 200.0 - def test_above_sample_range_clamps_to_last(self) -> None: + def test_above_sample_range_clamps_to_narrowest(self) -> None: + # Both samples have area 10,000. At h=999 hyperbolic w would be ~10, + # but narrowest observed w is 100; the floor never drops below it. samples = [(200.0, 50.0), (100.0, 100.0)] assert NLPTileProblem._envelope_w_floor(999.0, samples) == 100.0 # And exactly at the largest h. assert NLPTileProblem._envelope_w_floor(100.0, samples) == 100.0 - def test_inside_range_linearly_interpolates(self) -> None: - # Between (200, 50) and (100, 100): at h=75 (midpoint) -> w=150. + def test_inside_range_uses_hyperbolic(self) -> None: + # min die area is 10,000; at h=75 -> w_floor = 10000/75 = 133.33. samples = [(200.0, 50.0), (100.0, 100.0)] - assert NLPTileProblem._envelope_w_floor(75.0, samples) == 150.0 + assert NLPTileProblem._envelope_w_floor(75.0, samples) == pytest.approx( + 10000.0 / 75.0 + ) - def test_three_segment_envelope_picks_correct_segment(self) -> None: - # Three Pareto points -> two interpolation segments. + def test_uses_tightest_observed_area(self) -> None: + # Three points; tightest die area is 100*40 = 4000 (NOT 300*10 = 3000 + # is actually tighter — let's be explicit). Use a clear case: + # (300, 10) -> area 3000; (200, 20) -> 4000; (100, 40) -> 4000. + # min_die_area = 3000. At h=15: w_floor = 3000/15 = 200. samples = [(300.0, 10.0), (200.0, 20.0), (100.0, 40.0)] - # h=15 is in [10, 20]: w = 300 + (200-300)*(15-10)/(20-10) = 250. - assert NLPTileProblem._envelope_w_floor(15.0, samples) == 250.0 - # h=30 is in [20, 40]: w = 200 + (100-200)*(30-20)/(40-20) = 150. - assert NLPTileProblem._envelope_w_floor(30.0, samples) == 150.0 + assert NLPTileProblem._envelope_w_floor(15.0, samples) == pytest.approx( + 3000.0 / 15.0 + ) + # At h=30: hyperbolic gives 3000/30 = 100, which equals narrowest w. + assert NLPTileProblem._envelope_w_floor(30.0, samples) == 100.0 class TestComputeEquivalenceClasses: From c2af61393186630336cb9478ade288dda21b4f9b Mon Sep 17 00:00:00 2001 From: Kelvin Chung Date: Thu, 28 May 2026 14:41:59 +0100 Subject: [PATCH 47/48] chore: remove useless feature --- .../gds_generator/steps/tile_area_opt.py | 45 ------------------- 1 file changed, 45 deletions(-) diff --git a/fabulous/fabric_generator/gds_generator/steps/tile_area_opt.py b/fabulous/fabric_generator/gds_generator/steps/tile_area_opt.py index 00595d2fb..c5be03fbb 100644 --- a/fabulous/fabric_generator/gds_generator/steps/tile_area_opt.py +++ b/fabulous/fabric_generator/gds_generator/steps/tile_area_opt.py @@ -202,10 +202,6 @@ class TileAreaOptimisation(WhileStep): bracket_exhausted: bool = False - pending_locked_axis_bump: Decimal = Decimal(0) - - LOW_UTIL_THRESH: Decimal = Decimal("0.25") - def _diode_port_area(self, site_width: Decimal, site_height: Decimal) -> Decimal: """Estimate the flat instance-area contribution of port diodes. @@ -309,11 +305,6 @@ def post_iteration_callback( elif self.bracket_low is None or target > self.bracket_low: self.bracket_low = target - # Adaptive widen: when the seed picked by IGNORE_DEFAULT_DIE_AREA - # mode left the locked axis too narrow (cells sparse against - # walls), nudge the locked axis up by one step so the next iter - # has more room. Only fires when we control the seed. - self._maybe_request_locked_axis_widen(post_iteration) return post_iteration if full_iter_completed: @@ -323,33 +314,6 @@ def post_iteration_callback( self.iter_count += 1 return post_iteration - def _maybe_request_locked_axis_widen(self, state: State) -> None: - """Queue a locked-axis bump when the iter ran with utilisation < threshold. - - Only meaningful when FABULOUS_IGNORE_DEFAULT_DIE_AREA is on (we control - the seed): a sparse placement means the locked axis is over-sized - relative to the cells, but in directional modes we can only fix that - on the *locked* axis (the target axis is being optimised on its own). - The bump is consumed on the next call to - _compute_binary_search_dimensions. - """ - if not self.config.get("FABULOUS_IGNORE_DEFAULT_DIE_AREA", False): - return - util_raw = state.metrics.get("design__instance__utilization") - if util_raw is None: - return - if Decimal(util_raw) >= self.LOW_UTIL_THRESH: - return - - target_is_width = self.config["FABULOUS_OPT_MODE"] == OptMode.FIND_MIN_WIDTH - site_w = Decimal(state.metrics.get("pdk__site_width", Decimal(1))) - site_h = Decimal(state.metrics.get("pdk__site_height", Decimal(1))) - if target_is_width: - step = site_h * self.config["FABULOUS_OPTIMISATION_HEIGHT_STEP_COUNT"] - else: - step = site_w * self.config["FABULOUS_OPTIMISATION_WIDTH_STEP_COUNT"] - self.pending_locked_axis_bump = step - def _refresh_routing_obstructions(self) -> None: """Clear and recompute routing obstructions from current config. @@ -530,15 +494,6 @@ def _compute_binary_search_dimensions( else: next_target = (self.bracket_low + self.bracket_high) / Decimal(2) - # Consume any pending locked-axis bump requested by the previous - # iter's util check. Widening the locked axis makes the tile - # strictly easier, so prior failing targets may now succeed — - # drop bracket_low so the bisection re-explores below it. - if self.pending_locked_axis_bump > 0: - non_target = non_target + self.pending_locked_axis_bump - self.pending_locked_axis_bump = Decimal(0) - self.bracket_low = None - if target_is_width: return next_target, non_target return non_target, next_target From abef31ddbf9a39cf5843b3cd61afc7fb8ad77b21 Mon Sep 17 00:00:00 2001 From: Kelvin Chung Date: Thu, 28 May 2026 21:02:31 +0100 Subject: [PATCH 48/48] chore: fix test and pre-commit --- .../gds_generator/script/tile_io_place.py | 5 ++ .../script_test/test_tile_io_place.py | 8 +--- .../step_test/test_fabric_area_opt.py | 46 ++++++++----------- 3 files changed, 26 insertions(+), 33 deletions(-) diff --git a/fabulous/fabric_generator/gds_generator/script/tile_io_place.py b/fabulous/fabric_generator/gds_generator/script/tile_io_place.py index cdccfd2f3..42f16b3e5 100644 --- a/fabulous/fabric_generator/gds_generator/script/tile_io_place.py +++ b/fabulous/fabric_generator/gds_generator/script/tile_io_place.py @@ -76,6 +76,11 @@ def filter_pin_tracks_by_stride_and_distance( One entry per segment that did not have enough filtered tracks to hold its pins, with ``side``, ``shortage``, ``step`` and ``min_distance`` keys. + + Raises + ------ + AssertionError + If any segment is missing a ``min_distance`` value, which should have """ pin_tracks: dict[Side, list[list[float]]] = {side: [] for side in Side} track_errors: list[dict] = [] diff --git a/tests/gds_flow_test/script_test/test_tile_io_place.py b/tests/gds_flow_test/script_test/test_tile_io_place.py index 57ae258c6..702e9cdf0 100644 --- a/tests/gds_flow_test/script_test/test_tile_io_place.py +++ b/tests/gds_flow_test/script_test/test_tile_io_place.py @@ -1431,14 +1431,10 @@ def test_stride_filter_respects_distance_across_segments_in_tile( }, } pin_names = [f"{letter}{i}" for letter in "abc" for i in range(5)] - plan = PinPlacementPlan( - config, self._make_bterms(mocker, pin_names), "none" - ) + plan = PinPlacementPlan(config, self._make_bterms(mocker, pin_names), "none") track_count = int((tile_width - origin) / step) + 1 - plan.allocate_tracks( - {Side.SOUTH: (track_count, step, origin, tile_width)} - ) + plan.allocate_tracks({Side.SOUTH: (track_count, step, origin, tile_width)}) plan.ensure_min_distances({Side.SOUTH: min_distance}) assert len(plan.segments_by_side[Side.SOUTH]) == 3 diff --git a/tests/gds_flow_test/step_test/test_fabric_area_opt.py b/tests/gds_flow_test/step_test/test_fabric_area_opt.py index 0ff3da69e..f39c9acc3 100644 --- a/tests/gds_flow_test/step_test/test_fabric_area_opt.py +++ b/tests/gds_flow_test/step_test/test_fabric_area_opt.py @@ -130,51 +130,43 @@ def test_real_demo_project_frontiers( class TestEnvelopeWFloor: - """Hyperbolic (constant-area) lower bound on width given a row height. + """Piecewise-linear lower bound on width given a row height. - The envelope assumes density-limited feasibility: any (w, h) with - w * h >= min(observed die area) should compile. Floor clamped from below - by the narrowest observed width so we never extrapolate past the sampled - w range. + The Pareto frontier delivered by ``_pareto_frontier`` is sorted by ``h`` + ascending and ``w`` descending — that monotone shape is the precondition + of the linear interpolation used here. """ def test_no_samples_returns_zero(self) -> None: # No exploration data -> no constraint, the floor is 0. assert NLPTileProblem._envelope_w_floor(100.0, []) == 0.0 - def test_below_sample_range_grows_hyperbolic(self) -> None: - # Both samples have die area 10,000; at h=10 hyperbolic says w>=1000. + def test_below_sample_range_clamps_to_first(self) -> None: + # The frontier is sorted by h ascending; below the smallest h we clamp + # to the widest sample. This is the correct convex lower bound. samples = [(200.0, 50.0), (100.0, 100.0)] - assert NLPTileProblem._envelope_w_floor(10.0, samples) == 1000.0 - # At smallest sampled h: hyperbolic recovers the sample width. + assert NLPTileProblem._envelope_w_floor(10.0, samples) == 200.0 + # And exactly at the smallest h. assert NLPTileProblem._envelope_w_floor(50.0, samples) == 200.0 - def test_above_sample_range_clamps_to_narrowest(self) -> None: - # Both samples have area 10,000. At h=999 hyperbolic w would be ~10, - # but narrowest observed w is 100; the floor never drops below it. + def test_above_sample_range_clamps_to_last(self) -> None: samples = [(200.0, 50.0), (100.0, 100.0)] assert NLPTileProblem._envelope_w_floor(999.0, samples) == 100.0 # And exactly at the largest h. assert NLPTileProblem._envelope_w_floor(100.0, samples) == 100.0 - def test_inside_range_uses_hyperbolic(self) -> None: - # min die area is 10,000; at h=75 -> w_floor = 10000/75 = 133.33. + def test_inside_range_linearly_interpolates(self) -> None: + # Between (200, 50) and (100, 100): at h=75 (midpoint) -> w=150. samples = [(200.0, 50.0), (100.0, 100.0)] - assert NLPTileProblem._envelope_w_floor(75.0, samples) == pytest.approx( - 10000.0 / 75.0 - ) + assert NLPTileProblem._envelope_w_floor(75.0, samples) == 150.0 - def test_uses_tightest_observed_area(self) -> None: - # Three points; tightest die area is 100*40 = 4000 (NOT 300*10 = 3000 - # is actually tighter — let's be explicit). Use a clear case: - # (300, 10) -> area 3000; (200, 20) -> 4000; (100, 40) -> 4000. - # min_die_area = 3000. At h=15: w_floor = 3000/15 = 200. + def test_three_segment_envelope_picks_correct_segment(self) -> None: + # Three Pareto points -> two interpolation segments. samples = [(300.0, 10.0), (200.0, 20.0), (100.0, 40.0)] - assert NLPTileProblem._envelope_w_floor(15.0, samples) == pytest.approx( - 3000.0 / 15.0 - ) - # At h=30: hyperbolic gives 3000/30 = 100, which equals narrowest w. - assert NLPTileProblem._envelope_w_floor(30.0, samples) == 100.0 + # h=15 is in [10, 20]: w = 300 + (200-300)*(15-10)/(20-10) = 250. + assert NLPTileProblem._envelope_w_floor(15.0, samples) == 250.0 + # h=30 is in [20, 40]: w = 200 + (100-200)*(30-20)/(40-20) = 150. + assert NLPTileProblem._envelope_w_floor(30.0, samples) == 150.0 class TestComputeEquivalenceClasses: