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_definition/supertile.py b/fabulous/fabric_definition/supertile.py index f679f32b1..cf515f8d1 100644 --- a/fabulous/fabric_definition/supertile.py +++ b/fabulous/fabric_definition/supertile.py @@ -128,44 +128,35 @@ 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. + 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. 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 +164,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..7f1117a62 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 @@ -313,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 ---------- @@ -325,87 +324,74 @@ 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 len( - list( - itertools.chain.from_iterable( - [ - list(itertools.chain.from_iterable(p.expandPortInfo("all"))) - for p in ports - ] - ) - ) - ) + 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, 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/fabric_macro_flow.py b/fabulous/fabric_generator/gds_generator/flows/fabric_macro_flow.py index fb46e448c..fb5d49323 100644 --- a/fabulous/fabric_generator/gds_generator/flows/fabric_macro_flow.py +++ b/fabulous/fabric_generator/gds_generator/flows/fabric_macro_flow.py @@ -146,7 +146,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/fabric_optimisation_flow.py similarity index 55% 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 afc2146a2..684c0c7db 100644 --- a/fabulous/fabric_generator/gds_generator/flows/full_fabric_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 @@ -9,16 +9,18 @@ """ 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.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 @@ -40,11 +42,11 @@ from fabulous.fabric_generator.gds_generator.steps.extract_pdk_info import ( ExtractPDKInfo, ) -from fabulous.fabric_generator.gds_generator.steps.global_tile_opitmisation 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.fabulous_settings import init_context +from fabulous.fabric_generator.gds_generator.steps.tile_area_opt import OptMode +from fabulous.fabulous_settings import get_context from fabulous.processpool import DillProcessPoolExecutor if TYPE_CHECKING: @@ -56,19 +58,40 @@ Classic.config_vars + Floorplan.config_vars + flow_common_variables - + GlobalTileSizeOptimization.config_vars + + FabricAreaOptimisation.config_vars + + [ + Variable( + "FABULOUS_NLP_ONLY", + bool, + description="Stop after NLP optimisation, skip recompilation and stitching", + default=False, + ), + ] ) +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, - proj_dir: Path, io_pin_config: Path, optimisation: OptMode, base_config_path: Path, override_config_path: Path, + pdk: str, + pdk_root: Path, + models_pack: Path | None, + design_dir: Path | None = None, **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 @@ -78,61 +101,137 @@ 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 - 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 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. + 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. 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, - 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 or {}, + design_dir=design_dir, + **custom_config_overrides, ) state: State = flow.start() - except Exception: # noqa: BLE001 - return None, traceback.format_exc() + 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: + 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() -class FABulousFabricMacroFullFlow(Flow): +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 + @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}") + + @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...") @@ -203,22 +302,20 @@ 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, OptMode.FIND_MIN_WIDTH, ] - handlers: list[ - tuple[Future[tuple[State | None, str | None]], OptMode, Tile | SuperTile] - ] = [] - with DillProcessPoolExecutor(max_workers=None) as executor: + handlers: list[tuple[Future[WorkerResult], OptMode, Tile | SuperTile]] = [] + with DillProcessPoolExecutor(max_workers=get_context().max_worker) as executor: for opt_mode, tile_type in product( 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" ) @@ -226,14 +323,16 @@ 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, io_config_path, 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)) @@ -246,28 +345,30 @@ 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: + metric_keys = ( + "design__die__bbox", + "design__core__bbox", + "design__instance__area__stdcell", + "design__instance__utilization__stdcell", + "fabulous__clean_probes", + ) metrics_dict = { - k: state.metrics.get(k) - for k in [ - "design__die__bbox", - "design__core__bbox", - "design__instance__area__stdcell", - "design__instance__utilization__stdcell", - ] + 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 # Add error info if present if error is not None: @@ -275,8 +376,10 @@ 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: + """Convert Decimal values to float for JSON serialisation.""" if isinstance(obj, Decimal): return float(obj) return obj @@ -291,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 @@ -311,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"]) @@ -328,39 +431,47 @@ 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() + 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 ===") + # 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(tile_type, io_config_path, fabric=fabric) + # Compile tiles with optimal dimensions in parallel - handlers: list[ - tuple[Future[tuple[State | None, str | None]], Tile | SuperTile] - ] = [] - with DillProcessPoolExecutor(max_workers=None) as executor: + handlers: list[tuple[Future[WorkerResult], Tile | SuperTile]] = [] + with DillProcessPoolExecutor(max_workers=get_context().max_worker) 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 = tile_type.tileDir.parent / "io_pin_order.yaml" base_config_path: Path = ( proj_dir / "Tile" / "include" / "gds_config.yaml" ) @@ -371,15 +482,21 @@ 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_optimised" + ) # 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, io_config_path, OptMode.NO_OPT, base_config_path, override_config_path, + 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)) @@ -388,13 +505,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,53 +530,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 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" + for parent in Path(str(gds_path)).parents + if (parent / "final").is_dir() + ), + None, ) + 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"Collected {len(macros)} tile macros") - - # 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) + info(f"Created final_views symlinks for {len(tile_type_states)} tiles") # Step 5: Run fabric stitching self.progress_bar.start_stage("Fabric Stitching") @@ -469,10 +576,15 @@ 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, ) final_state: State = stitching_flow.start() self.progress_bar.end_stage() - info("\n✓ Fabric flow completed successfully!") + # 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/fabulous/fabric_generator/gds_generator/flows/flow_define.py b/fabulous/fabric_generator/gds_generator/flows/flow_define.py index 8eedd0bf6..f22f7d5f1 100644 --- a/fabulous/fabric_generator/gds_generator/flows/flow_define.py +++ b/fabulous/fabric_generator/gds_generator/flows/flow_define.py @@ -20,6 +20,12 @@ from fabulous.fabric_generator.gds_generator.steps.extract_pdk_info import ( ExtractPDKInfo, ) +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, @@ -85,8 +91,16 @@ OpenROAD.IRDropReport, ] +tile_optimisation_physical_steps: list[type[Step]] = [ + TileAreaOptimisation, + OpenROAD.FillInsertion, + Odb.CellFrequencyTables, + OpenROAD.RCX, + OpenROAD.IRDropReport, +] + write_out_steps: list[type[Step]] = [ - Magic.StreamOut, + FABulousMagicStreamOut, KLayout.StreamOut, Magic.WriteLEF, ] @@ -125,7 +139,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/flows/tile_macro_flow.py b/fabulous/fabric_generator/gds_generator/flows/tile_macro_flow.py index 3c2b8b763..fbcf0398c 100644 --- a/fabulous/fabric_generator/gds_generator/flows/tile_macro_flow.py +++ b/fabulous/fabric_generator/gds_generator/flows/tile_macro_flow.py @@ -9,10 +9,8 @@ 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 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 @@ -94,6 +83,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, @@ -105,8 +95,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): @@ -132,13 +127,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 @@ -155,13 +150,43 @@ 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, 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: + directional = final_opt_mode in ( + OptMode.FIND_MIN_WIDTH, + OptMode.FIND_MIN_HEIGHT, + ) + # 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: + 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: + 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 @@ -181,12 +206,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: @@ -204,6 +227,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 " @@ -213,6 +244,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/script/tile_io_place.py b/fabulous/fabric_generator/gds_generator/script/tile_io_place.py index b8b2f50b4..42f16b3e5 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 @@ -39,6 +40,131 @@ 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. + + 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] = [] + + 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]]: @@ -903,6 +1029,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() @@ -952,74 +1089,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/fabulous/fabric_generator/gds_generator/steps/fabric_area_opt.py b/fabulous/fabric_generator/gds_generator/steps/fabric_area_opt.py new file mode 100644 index 000000000..70bc383bd --- /dev/null +++ b/fabulous/fabric_generator/gds_generator/steps/fabric_area_opt.py @@ -0,0 +1,846 @@ +"""FABulous GDS Generator - NLP optimisation Step using pymoo.""" + +import json +from collections import defaultdict +from decimal import Decimal +from pathlib import Path +from typing import Any, Optional + +import numpy as np +from librelane.config.variable import Variable +from librelane.flows.flow import FlowException +from librelane.logging.logger import info, warn +from librelane.state.design_format import DesignFormat +from librelane.state.state import State +from librelane.steps.step import MetricsUpdate, Step, ViewsUpdate +from pymoo.algorithms.soo.nonconvex.isres import ISRES +from pymoo.core.problem import ElementwiseProblem +from pymoo.core.repair import Repair +from pymoo.optimize import minimize +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_area_opt import OptMode + + +class NLPTileProblem(ElementwiseProblem): + """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 + 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 + 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 %). + + 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__( + self, + fabric: Fabric, + 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 + self.area_margin = area_margin + self._all_tile_metrics = all_tile_metrics or tile_metrics + + 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 + + # 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) + + self.col_groups: dict[int, int] = {} + self._compute_equivalence_classes(self.tile_column_set, self.col_groups) + + 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" + ) + + # 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] = _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 = _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( + 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: + for component_tile in row: + if component_tile is None: + continue + 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)) + + # Update width bounds from column neighbors + for col_idx in range(supertile.max_width): + for row in supertile.tileMap: + 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) + + xl = np.zeros(n_vars) + + 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) + + # 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] = [] + raw_samples: list[tuple[float, float]] = [] + for mode_metrics in self.tile_metrics.values(): + m = mode_metrics.get(name) + if m is None: + continue + # 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)) + + self.min_areas[name] = min(areas) if areas else float("inf") + self.tile_samples[name] = self._pareto_frontier(raw_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 + + for tile in fabric.get_all_unique_tiles(): + samples = self.tile_samples.get(tile.name, []) + 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] + 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 + else: + 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: + 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 + } + 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." + ) + + 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))) + + 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[name] * 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[name] * 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: {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, + n_obj=1, + n_ieq_constr=n_constr, + xl=xl, + xu=xu, + ) + + @staticmethod + def _compute_equivalence_classes( + tile_positions: dict[str, set[int]], + groups: dict[int, int], + ) -> 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. + """ + 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 + + all_indices: set[int] = set() + for indices in tile_positions.values(): + all_indices |= indices + + 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. + """ + 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, + ) + + 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). + """ + 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) + + @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. + + 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. + """ + 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( + 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 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 _evaluate(self, x: np.ndarray, out: dict) -> None: + """Pymoo evaluation: compute objective (total fabric area) and constraints. + + 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. + """ + total_area = 0.0 + for cv, rv in self._position_var_pairs: + total_area += x[cv] * x[rv] + out["F"] = total_area + + result: list[float] = [] + 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() +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.FabricAreaOptimisation" + name = "FABulous Fabric Area optimisation" + + config_vars = [ + Variable( + "TILE_OPT_INFO", + Optional[Path], # noqa: UP045 librelane issue + description="Tile optimisation information dictionary or path to JSON file", + default=None, + ), + Variable( + "FABULOUS_FABRIC", + Fabric, + description="Fabric configuration object", + ), + Variable( + "FABULOUS_PROJ_DIR", + 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 = [] + outputs = [ + DesignFormat.GDS, + DesignFormat.LEF, + DesignFormat.LIB, + DesignFormat.DEF, + ] + + @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. + + Parameters + ---------- + data : dict + Raw metric fields for a tile from the JSON file, including required + bbox fields and optional pin minimums. + + Returns + ------- + 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 + ------ + 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." + ) + + result: dict[str, Any] = { + "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", + "fabulous__pin_min_height", + "design__instance__area__stdcell", + ): + 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 + 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)``. 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] = {} + 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 "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"): + continue + + parsed = cls._parse_tile_fields(data) + valid_dict[tile_name] = parsed + all_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." + ) + fabric: Fabric = self.config["FABULOUS_FABRIC"] + + if isinstance(self.config["TILE_OPT_INFO"], Path): + valid_metrics, all_metrics = self._load_tile_metrics_from_json( + self.config["TILE_OPT_INFO"] + ) + else: + valid_metrics = self.config["TILE_OPT_INFO"] + all_metrics = valid_metrics + + 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 + ) + + x_pitch = Decimal(state_in.metrics.get("pdk__site_width", 0.5)) + y_pitch = Decimal(state_in.metrics.get("pdk__site_height", 0.5)) + + 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 + """Round variables to nearest grid pitch.""" + for j in range(X.shape[0]): + for i in range(X.shape[1]): + if i < n_row_vars: # row height variables + X[j][i] = float(round_up_decimal(Decimal(X[j][i]), y_pitch)) + else: # column width variables + X[j][i] = float(round_up_decimal(Decimal(X[j][i]), x_pitch)) + return X + + algorithm = ISRES(repair=RoundRepair()) + + n_gen = 500 + info(f"Running optimisation for {n_gen} generations") + termination = MaximumGenerationTermination(n_gen) + + res = minimize(problem, algorithm, termination, verbose=True) + + if res.X is None: + 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") + pop_sorted = sorted( + res.pop, + key=lambda ind: ( + ind.CV[0] + if hasattr(ind, "CV") and ind.CV is not None + else float("inf"), + ind.F[0], + ), + ) + best_ind = pop_sorted[0] + res.X = best_ind.X + res.F = best_ind.F + res.CV = best_ind.CV if hasattr(best_ind, "CV") else None + else: + 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: + warn(f"Solution has constraint violation of {res.CV[0]}") + else: + info(f"Found feasible solution with CV={res.CV[0]}") + else: + info("Found solution (constraint violation not available)") + + info(f"optimisation terminated with objective={res.F[0]}") + + quant = Decimal(".01") + zero = Decimal(0) + 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) + + for tile in fabric.tileDic.values(): + if tile.partOfSuperTile: + continue + result_dict[tile.name] = ( + zero, + zero, + quantized_width(tile.name), + quantized_height(tile.name), + ) + + for supertile in fabric.superTileDic.values(): + total_w = zero + if supertile.tileMap: + for tile in supertile.tileMap[0]: + if tile is not None: + total_w += quantized_width(tile.name) + + 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) + + result_dict[supertile.name] = (zero, zero, total_w, total_h) + + total_area = int(res.F[0]) + info(f" Total fabric area: {total_area}") + info(f" Optimal tile dimensions: {result_dict}") + + 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, + } diff --git a/fabulous/fabric_generator/gds_generator/steps/global_tile_opitmisation.py b/fabulous/fabric_generator/gds_generator/steps/global_tile_opitmisation.py deleted file mode 100644 index b22cb7bf7..000000000 --- a/fabulous/fabric_generator/gds_generator/steps/global_tile_opitmisation.py +++ /dev/null @@ -1,535 +0,0 @@ -"""FABulous GDS Generator - NLP Optimization Step using pymoo.""" - -import json -from collections import Counter, defaultdict -from decimal import Decimal -from pathlib import Path -from typing import TYPE_CHECKING, Any, NamedTuple, Optional - -import numpy as np -from librelane.config.variable import Variable -from librelane.flows.flow import FlowException -from librelane.logging.logger import info, warn -from librelane.state.design_format import DesignFormat -from librelane.state.state import State -from librelane.steps.step import MetricsUpdate, Step, ViewsUpdate -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. - - This class defines the optimization problem with bilinear constraints for minimizing - total fabric area subject to minimum area requirements. - - 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 - """ - - 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]] - ) -> None: - self.fabric = fabric - self.tile_metrics = ( - tile_metrics # Keep nested format: {opt_mode: {tile_name: {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) - - 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) - - 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 - - tile_min: dict[str, tuple[float, float]] = {} - - for i in unique_tiles: - if i.partOfSuperTile: - # Skip component tiles of supertiles - 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 - for supertile in fabric.superTileDic.values(): - row_min_heights: dict[str, float] = {} - 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] - ) - - # Compute min_width for each column - col_min_widths: dict[str, float] = {} - 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] - ) - - # 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(len(self.tile_to_solution_index) * 2) - xu = np.zeros(len(self.tile_to_solution_index) * 2) - - 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.copy() * 4 # Arbitrary upper bound: 4x min size - - # Count constraints by simulating what will be generated - x = np.zeros(len(self.tile_to_solution_index) * 2) - - super().__init__( - n_var=len(self.tile_to_solution_index) * 2, - n_obj=1, - n_ieq_constr=len(self._add_mode_constraints(x)) - + len(self._add_equality_constraints(x)), - 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) - - eq_constraints = self._add_equality_constraints(x) - mode_constraints = self._add_mode_constraints(x) - - # Concatenate all constraints - all_constraints = eq_constraints + mode_constraints - out["G"] = np.array(all_constraints) - - 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 _add_equality_constraints(self, x: np.ndarray) -> list: - """Add equality constraints on tile dimensions. - - For pymoo: g(x) <= 0, so we use (h1 - h2)^2 - tolerance <= 0 - This is better than abs(h1-h2) for differentiability. - """ - result = [] - tolerance = 0.5 # Allow tiles to differ by ~1 unit - - # 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] - - 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) - - return result - - def _add_mode_constraints(self, x: np.ndarray) -> list: - """Add mode constraints on tile dimensions. - - 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 - """ - result = [] - # Regular tiles - for tile in self.fabric.tileDic.values(): - if tile.partOfSuperTile: - continue - - 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 - 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 - 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)) - - 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. - - After optimization, it automatically recompiles all tiles with the optimal - dimensions and stores the recompiled states in metrics for downstream processing. - """ - - id = "FABulous.GlobalTileSizeOptimization" - name = "FABulous Global Tile Size Optimization" - - config_vars = [ - Variable( - "TILE_OPT_INFO", - Optional[Path], # noqa: UP045 librelane issue - description="Tile optimization information dictionary or path to JSON file", - default=None, - ), - Variable( - "FABULOUS_FABRIC", - Fabric, - description="Fabric configuration object", - ), - Variable( - "FABULOUS_PROJ_DIR", - 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 = [] - outputs = [ - DesignFormat.GDS, - DesignFormat.LEF, - DesignFormat.LIB, - 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 - """ - 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() - ) - 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 - problem = NLPTileProblem( - fabric, - tile_opt_data, - ) - - 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 - 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.""" - for j in range(X.shape[0]): - for i in range(X.shape[1]): - if i % 2 == 0: - X[j][i] = float(round_up_decimal(Decimal(X[j][i]), y_pitch)) - else: - 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) - - 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: ( - ind.CV[0] - if hasattr(ind, "CV") and ind.CV is not None - else float("inf"), - ind.F[0], - ), - ) - best_ind = pop_sorted[0] - res.X = best_ind.X - 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") - - # 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]}") - else: - info(f"Found feasible solution with CV={res.CV[0]}") - else: - info("Found solution (constraint violation not available)") - - info(f"Optimization terminated with objective={res.F[0]}") - - # Extract results - - 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 - continue - result_dict[tile_name] = ( - Decimal(0), - Decimal(0), - Decimal(w).quantize(Decimal(".01")), - Decimal(h).quantize(Decimal(".01")), - ) - - 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 - if tile is not None: - sub_idx = problem.tile_to_solution_index[tile.name] - total_w += res.X[sub_idx.width_idx] - - # 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")), - ) - - # Calculate total area - total_area = int(res.F[0]) - - # Report results - info(f" Total fabric area: {total_area}") - info(f" Optimal tile dimensions: {result_dict}") - - metrics_updates = { - "nlp__tile__area": result_dict, - "nlp__total__area": total_area, - } - - return {}, metrics_updates 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..6b19fb35a --- /dev/null +++ b/fabulous/fabric_generator/gds_generator/steps/magic_streamout.py @@ -0,0 +1,38 @@ +"""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 typing import Any + +from librelane.state.state import State +from librelane.steps.magic import StreamOut +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`.""" + + 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: 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()) + self.config = self.config.copy(DIE_AREA=die_area) + return super().run(state_in, **kwargs) diff --git a/fabulous/fabric_generator/gds_generator/steps/tile_area_opt.py b/fabulous/fabric_generator/gds_generator/steps/tile_area_opt.py new file mode 100644 index 000000000..c5be03fbb --- /dev/null +++ b/fabulous/fabric_generator/gds_generator/steps/tile_area_opt.py @@ -0,0 +1,673 @@ +"""Tile size optimisation step for FABulous fabric generator.""" + +from decimal import Decimal +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 +from librelane.steps import checker as Checker +from librelane.steps import odb as Odb +from librelane.steps import openroad as OpenROAD +from librelane.steps.step import MetricsUpdate, Step, ViewsUpdate + +from fabulous.fabric_generator.gds_generator.helper import ( + get_pitch, + get_routing_obstructions, + round_up_decimal, +) +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.diodes_on_ports import ( + FABulousDiodesOnPorts, +) +from fabulous.fabric_generator.gds_generator.steps.tile_IO_placement import ( + FABulousTileIOPlacement, +) +from fabulous.fabric_generator.gds_generator.steps.timed_detailed_routing import ( + FABulousDetailedRoutingTimed, +) +from fabulous.fabric_generator.gds_generator.steps.while_step import WhileStep + + +class OptMode(StrEnum): + """Optimisation modes for tile size finding.""" + + FIND_MIN_WIDTH = "find_min_width" + FIND_MIN_HEIGHT = "find_min_height" + BALANCE = "balance" + LARGE = "large" + NO_OPT = "no_opt" + + @classmethod + def _missing_(cls, value: object) -> "OptMode": + """Look up an OptMode member case-insensitively.""" + if isinstance(value, str): + value_lower = value.lower() + for member in cls: + if member.value == value_lower: + return member + + if value is None: + return cls.NO_OPT + + raise ValueError(f"{value!r} is not a valid {cls.__name__}") + + +var = [ + Variable( + "FABULOUS_OPTIMISATION_WIDTH_STEP_COUNT", + int, + "The number of placement sites by which the tile size reduces in each " + "iteration. The actual reduction in DBU is this count multiplied by the PDK " + "site dimensions.", + default=4, + ), + Variable( + "FABULOUS_OPTIMISATION_HEIGHT_STEP_COUNT", + int, + "The number of placement sites by which the tile size reduces in each " + "iteration. The actual reduction in DBU is this count multiplied by the PDK " + "site dimensions.", + default=1, + ), + Variable( + "FABULOUS_OPT_MODE", + OptMode, + "Optimisation mode to use. Options are: " + " - 'find_min_width': default, finds minimal width by increasing from " + "initial guess. " + " - 'find_min_height': finds minimal height by increasing from initial guess. " + " - 'balance': finds minimal area by starting from square bounding box and " + "increasing alternatingly. " + " - 'no-opt': Disable optimisation.", + default=OptMode.BALANCE, + ), + Variable( + "IGNORE_ANTENNA_VIOLATIONS", + bool, + "If True, antenna violations are ignored during tile optimisation. " + "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), + ), + 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, + ), + Variable( + "FABULOUS_BASE_OPTIMISATION_ITERATION_START", + int, + "The base iteration number to start from for optimisations.", + default=15, + ), +] + + +@Step.factory.register() +class TileAreaOptimisation(WhileStep): + """Tile size optimisation step.""" + + id = "FABulous.TileAreaOptimisation" + name = "Tile Area Optimisation" + + inputs = [DesignFormat.NETLIST] + + Steps = [ + OpenROAD.Floorplan, + OpenROAD.DumpRCValues, + Odb.CheckMacroAntennaProperties, + Odb.SetPowerConnections, + Odb.ManualMacroPlacement, + OpenROAD.CutRows, + OpenROAD.TapEndcapInsertion, + Odb.AddPDNObstructions, + CustomGeneratePDN, # Custom PDN default pdn_cfg.tcl + Odb.RemovePDNObstructions, + Odb.AddRoutingObstructions, + FABulousTileIOPlacement, # Replace with FABulous IO Placement + Odb.ApplyDEFTemplate, + FABulousDiodesOnPorts, + OpenROAD.GlobalPlacement, + AddBuffers, # Add Buffers after Global Placement + Odb.WriteVerilogHeader, + Checker.PowerGridViolations, + Odb.ManualGlobalPlacement, + OpenROAD.DetailedPlacement, + OpenROAD.CTS, + OpenROAD.GlobalRouting, + OpenROAD.CheckAntennas, + OpenROAD.RepairAntennas, + FABulousDetailedRoutingTimed, + Odb.RemoveRoutingObstructions, + OpenROAD.CheckAntennas, + Checker.TrDRC, + Odb.ReportDisconnectedPins, + Checker.DisconnectedPins, + Odb.ReportWireLength, + Checker.WireLength, + ] + + config_vars = var + + max_iterations = 20 + + last_working_state: State | None = None + + clean_probes: list[list[float]] = [] + + raise_on_failure: bool = False + + iter_count: int = 0 + + 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 + + bracket_high: Decimal | None = None + + bracket_cap: Decimal | None = None + + 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 ( + 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 + + metrics_to_check = ["route__drc_errors"] + if not self.config["IGNORE_ANTENNA_VIOLATIONS"]: + metrics_to_check.extend( + ["antenna__violating__pins", "antenna__violating__nets"] + ) + + 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) + + 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") + if die_bbox is not None: + self.clean_probes.append([float(v) for v in die_bbox.split()]) + + if self._is_directional(): + 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: + 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() + return post_iteration + + 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 + ) + if die_area_raw is None: + raise ValueError("DIE_AREA metric not found in state.") + + _, _, width, height = die_area_raw + + 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) + + width_step = site_width * self.config["FABULOUS_OPTIMISATION_WIDTH_STEP_COUNT"] + height_step = ( + site_height * self.config["FABULOUS_OPTIMISATION_HEIGHT_STEP_COUNT"] + ) + + 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: + # 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 + margin_y = Decimal(2) * site_height + core_area = (width - margin_x) * (height - margin_y) + + 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), + Decimal(0), + round_up_decimal(new_width, x_pitch), + round_up_decimal(new_height, y_pitch), + ) + self.config = self.config.copy( + DRT_OPT_ITERS=self.config["FABULOUS_BASE_OPTIMISATION_ITERATION_START"] + + 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 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"] + + # 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 + + 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 is_supertile: + 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: + height += height_step + case OptMode.LARGE: + width += width_step + height += height_step + case _: + raise ValueError(f"Unknown FABULOUS_OPT_MODE: {opt_mode}") + + 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 + + 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) + pitch = x_pitch if target_is_width else 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: + 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.") + + # State.metrics is an immutable dict; rebuild the state with an added + # clean_probes entry rather than mutating in place. + 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. + 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: Step) -> bool: + """Mid iteration callback.""" + if not isinstance(step, Checker.TrDRC): + return False + + metrics_to_check = ["route__drc_errors"] + if not self.config["IGNORE_ANTENNA_VIOLATIONS"]: + metrics_to_check.extend( + [ + "antenna__violating__nets", + "antenna__violating__pins", + ] + ) + + return any(cast("int", state.metrics.get(m, 0)) > 0 for m in metrics_to_check) + + def run( + self, + state_in: State, + **_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) + 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)) + 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 + # 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 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. + 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 = 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() + 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) + init_h = round_up_decimal(init_h, y_pitch) + + # 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: + 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/fabulous/fabric_generator/gds_generator/steps/tile_optimisation.py b/fabulous/fabric_generator/gds_generator/steps/tile_optimisation.py deleted file mode 100644 index edec67b4f..000000000 --- a/fabulous/fabric_generator/gds_generator/steps/tile_optimisation.py +++ /dev/null @@ -1,308 +0,0 @@ -"""Tile size optimisation step for FABulous fabric generator.""" - -from decimal import Decimal -from enum import StrEnum -from typing import cast - -from librelane.config.variable import Variable -from librelane.logging.logger import info -from librelane.state.design_format import DesignFormat -from librelane.state.state import State -from librelane.steps import checker as Checker -from librelane.steps import odb as Odb -from librelane.steps import openroad as OpenROAD -from librelane.steps.step import MetricsUpdate, Step, ViewsUpdate - -from fabulous.fabric_generator.gds_generator.helper import ( - get_pitch, - get_routing_obstructions, - round_up_decimal, -) -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.diodes_on_ports import ( - FABulousDiodesOnPorts, -) -from fabulous.fabric_generator.gds_generator.steps.tile_IO_placement import ( - FABulousTileIOPlacement, -) -from fabulous.fabric_generator.gds_generator.steps.while_step import WhileStep - - -class OptMode(StrEnum): - """Optimisation modes for tile size finding.""" - - FIND_MIN_WIDTH = "find_min_width" - FIND_MIN_HEIGHT = "find_min_height" - BALANCE = "balance" - LARGE = "large" - NO_OPT = "no_opt" - - @classmethod - def _missing_(cls, value: object) -> "OptMode": - """Look up an OptMode member case-insensitively.""" - if isinstance(value, str): - value_lower = value.lower() - for member in cls: - if member.value == value_lower: - return member - - if value is None: - return cls.NO_OPT - - raise ValueError(f"{value!r} is not a valid {cls.__name__}") - - -var = [ - Variable( - "FABULOUS_OPTIMISATION_WIDTH_STEP_COUNT", - int, - "The number of placement sites by which the tile size reduces in each " - "iteration. The actual reduction in DBU is this count multiplied by the PDK " - "site dimensions.", - default=4, - ), - Variable( - "FABULOUS_OPTIMISATION_HEIGHT_STEP_COUNT", - int, - "The number of placement sites by which the tile size reduces in each " - "iteration. The actual reduction in DBU is this count multiplied by the PDK " - "site dimensions.", - default=1, - ), - Variable( - "FABULOUS_OPT_MODE", - OptMode, - "Optimisation mode to use. Options are: " - " - 'find_min_width': default, finds minimal width by increasing from " - "initial guess. " - " - 'find_min_height': finds minimal height by increasing from initial guess. " - " - 'balance': finds minimal area by starting from square bounding box and " - "increasing alternatingly. " - " - 'no-opt': Disable optimisation.", - default=OptMode.BALANCE, - ), - Variable( - "IGNORE_ANTENNA_VIOLATIONS", - bool, - "If True, antenna violations are ignored during tile optimisation. " - "Default is False.", - default=False, - ), -] - - -@Step.factory.register() -class TileOptimisation(WhileStep): - """Tile size optimisation step.""" - - id = "FABulous.TileOptimisation" - name = "Tile Optimisation" - - inputs = [DesignFormat.NETLIST] - - Steps = [ - OpenROAD.Floorplan, - OpenROAD.DumpRCValues, - Odb.CheckMacroAntennaProperties, - Odb.SetPowerConnections, - Odb.ManualMacroPlacement, - OpenROAD.CutRows, - OpenROAD.TapEndcapInsertion, - Odb.AddPDNObstructions, - CustomGeneratePDN, # Custom PDN default pdn_cfg.tcl - Odb.RemovePDNObstructions, - Odb.AddRoutingObstructions, - FABulousTileIOPlacement, # Replace with FABulous IO Placement - Odb.ApplyDEFTemplate, - FABulousDiodesOnPorts, - OpenROAD.GlobalPlacement, - AddBuffers, # Add Buffers after Global Placement - Odb.WriteVerilogHeader, - Checker.PowerGridViolations, - Odb.ManualGlobalPlacement, - OpenROAD.DetailedPlacement, - OpenROAD.CTS, - OpenROAD.GlobalRouting, - # AutoEcoDiodeInsertion, - OpenROAD.CheckAntennas, - OpenROAD.RepairAntennas, - OpenROAD.DetailedRouting, - Odb.RemoveRoutingObstructions, - OpenROAD.CheckAntennas, - Checker.TrDRC, - Odb.ReportDisconnectedPins, - Checker.DisconnectedPins, - Odb.ReportWireLength, - Checker.WireLength, - ] - - config_vars = var - - max_iterations = 20 - - last_working_state: State | None = None - - raise_on_failure: bool = False - - break_next_iteration: bool = False - - to_change_width: bool = False - - iter_count: int = 0 - - def condition(self, state: State) -> bool: - """Loop condition.""" - if state.metrics.get("route__drc_errors") is None: - return True - - checklist = [] - 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 - - return False - - def post_iteration_callback( - self, post_iteration: State, full_iter_completed: bool - ) -> State: - """Save state if iteration completed successfully.""" - if full_iter_completed: - 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 - - 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) - return pre_iteration - die_area_raw: tuple[Decimal, Decimal, Decimal, Decimal] = self.config.get( - "DIE_AREA", None - ) - if die_area_raw is None: - raise ValueError("DIE_AREA metric not found in 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() - - if width == 0: - width = instance_area.sqrt() - - 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']}" - ) - - die_area = ( - Decimal(0), - Decimal(0), - 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) - ) - if p := self.get_current_iteration_dir(): - (p / "config.json").write_text(self.config.dumps()) - - return pre_iteration - - 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.") - - 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 - - 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 - ) - - return False - - def run( - self, - state_in: State, - **_kwargs: dict, - ) -> tuple[ViewsUpdate, MetricsUpdate]: - """Run the tile optimisation step.""" - if self.config["IGNORE_ANTENNA_VIOLATIONS"]: - info("Ignoring antenna violations during tile optimisation.") - 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) 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..2c0bb88d0 --- /dev/null +++ b/fabulous/fabric_generator/gds_generator/steps/timed_detailed_routing.py @@ -0,0 +1,108 @@ +"""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() + timers: list[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() + timers.append(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: + 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 e5c305710..55fcf8956 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 @@ -66,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 @@ -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: @@ -148,6 +154,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..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 @@ -37,8 +38,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, @@ -46,7 +47,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 @@ -684,6 +685,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.""" @@ -692,8 +695,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) @@ -707,7 +712,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()), @@ -715,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/fabulous/fabulous_cli/fabulous_cli.py b/fabulous/fabulous_cli/fabulous_cli.py index c84a383cf..d0093ebca 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, @@ -59,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, ) @@ -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""" ______ ____ __ | ____/\ | _ \ | | @@ -217,6 +297,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 @@ -1398,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 ) @@ -1465,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" @@ -1490,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() @@ -1568,8 +1682,28 @@ def do_gen_fabric_macro(self, *_args: str) -> None: base_config_path=self.projectDir / "Fabric" / "gds_config.yaml", ) + eFPGA_macro_parser: Cmd2ArgumentParser = 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_category(CMD_FABRIC_FLOW) - def do_run_FABulous_eFPGA_macro(self, *_arg: str) -> None: + @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(): logger.error( @@ -1579,12 +1713,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/fabulous/fabulous_settings.py b/fabulous/fabulous_settings.py index 0f5e85fee..4ccf19540 100644 --- a/fabulous/fabulous_settings.py +++ b/fabulous/fabulous_settings.py @@ -79,6 +79,13 @@ class FABulousSettings(BaseSettings): deprecated=True, description="Deprecated, use proj_version instead", ) + max_worker: int | None = Field( + default=2, + 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 editor: str | None = None diff --git a/fabulous/processpool.py b/fabulous/processpool.py index 34228a726..5a68b1b9d 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.""" @@ -31,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, + max_workers=workers or None, mp_context=multiprocessing.get_context("spawn"), initializer=_init_worker, initargs=initargs, diff --git a/tests/cli_test/test_cli.py b/tests/cli_test/test_cli.py index df1ea65ba..fa19d101c 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_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 from tests.cli_test.conftest import MOCK_COMPLETED_PROCESS, TILE, find_task_calls @@ -451,3 +453,164 @@ 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" + ) + + +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/conftest.py b/tests/fabric_definition/conftest.py index 7d3178a93..3750cdeb8 100644 --- a/tests/fabric_definition/conftest.py +++ b/tests/fabric_definition/conftest.py @@ -5,7 +5,10 @@ import pytest +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 @pytest.fixture @@ -30,3 +33,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: Side, 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, + ) 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/fabric_definition/test_tile.py b/tests/fabric_definition/test_tile.py new file mode 100644 index 000000000..50eb3fdc5 --- /dev/null +++ b/tests/fabric_definition/test_tile.py @@ -0,0 +1,125 @@ +"""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 +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=Path(), + matrixDir=Path(), + 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/flow_test/test_fabric_optimisation_flow.py b/tests/gds_flow_test/flow_test/test_fabric_optimisation_flow.py new file mode 100644 index 000000000..2e08c7325 --- /dev/null +++ b/tests/gds_flow_test/flow_test/test_fabric_optimisation_flow.py @@ -0,0 +1,493 @@ +"""Tests for FABulousFabricOptimisationFlow - fabric optimisation flow. + +Tests focus on: +- Flow initialization and configuration +- Project directory validation +- Worker function behavior +- Flow steps and configuration variables +""" + +# ruff: noqa: SLF001 + +from decimal import Decimal +from pathlib import Path +from unittest.mock import MagicMock + +import pytest +from pytest_mock import MockerFixture + +from fabulous.fabric_generator.gds_generator.flows.fabric_optimisation_flow import ( + FABulousFabricOptimisationFlow, + WorkerResult, + _run_tile_flow_worker, +) +from fabulous.fabric_generator.gds_generator.steps.tile_area_opt import OptMode + + +# Shared fixtures +@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=FABulousFabricOptimisationFlow) + flow._validate_project_dir = FABulousFabricOptimisationFlow._validate_project_dir + return flow + + +@pytest.fixture +def mock_fabric(mocker: MockerFixture) -> MagicMock: + """Create a mock fabric for testing.""" + fabric: MagicMock = mocker.MagicMock() + fabric.tileDic = {"tile1": mocker.MagicMock(), "tile2": mocker.MagicMock()} + fabric.superTileDic = {} + return fabric + + +class TestValidateProjectDir: + """Tests for _validate_project_dir method.""" + + def test_validate_project_dir_success( + self, + mock_flow_with_validate_project_dir: MagicMock, + mock_fabric: MagicMock, + tmp_path: Path, + ) -> None: + """Test validation passes for valid directory structure.""" + flow: MagicMock = mock_flow_with_validate_project_dir + # Create required directories + tile_dir: Path = tmp_path / "Tile" + tile_dir.mkdir() + (tile_dir / "tile1").mkdir() + (tile_dir / "tile2").mkdir() + + # Should not raise + flow._validate_project_dir(flow, tmp_path, mock_fabric) + + def test_validate_project_dir_missing_proj_dir( + self, + mock_flow_with_validate_project_dir: MagicMock, + mock_fabric: MagicMock, + ) -> None: + """Test validation fails when project directory doesn't exist.""" + flow: MagicMock = mock_flow_with_validate_project_dir + nonexistent: Path = Path("/nonexistent/path") + + with pytest.raises(FileNotFoundError, match="Project directory not found"): + flow._validate_project_dir(flow, nonexistent, mock_fabric) + + def test_validate_project_dir_not_a_directory( + self, + mock_flow_with_validate_project_dir: MagicMock, + mock_fabric: MagicMock, + tmp_path: Path, + ) -> None: + """Test validation fails when path is not a directory.""" + flow: MagicMock = mock_flow_with_validate_project_dir + file_path: Path = tmp_path / "file.txt" + file_path.touch() + + with pytest.raises(NotADirectoryError, match="not a directory"): + flow._validate_project_dir(flow, file_path, mock_fabric) + + def test_validate_project_dir_missing_tile_dir( + self, + mock_flow_with_validate_project_dir: MagicMock, + mock_fabric: MagicMock, + tmp_path: Path, + ) -> None: + """Test validation fails when Tile directory is missing.""" + flow: MagicMock = mock_flow_with_validate_project_dir + with pytest.raises(FileNotFoundError, match="Tile directory not found"): + flow._validate_project_dir(flow, tmp_path, mock_fabric) + + def test_validate_project_dir_missing_tiles( + self, + mock_flow_with_validate_project_dir: MagicMock, + mock_fabric: MagicMock, + tmp_path: Path, + ) -> None: + """Test validation fails when tile directories are missing.""" + flow: MagicMock = mock_flow_with_validate_project_dir + tile_dir: Path = tmp_path / "Tile" + tile_dir.mkdir() + # Only create tile1, not tile2 + (tile_dir / "tile1").mkdir() + + with pytest.raises(FileNotFoundError, match="Missing tile directories"): + flow._validate_project_dir(flow, tmp_path, mock_fabric) + + def test_validate_project_dir_with_supertiles( + self, + mock_flow_with_validate_project_dir: MagicMock, + mocker: MockerFixture, + tmp_path: Path, + ) -> None: + """Test validation with SuperTiles.""" + flow: MagicMock = mock_flow_with_validate_project_dir + fabric: MagicMock = mocker.MagicMock() + fabric.tileDic = {"subtile1": mocker.MagicMock()} + + # SuperTile containing subtile1 + supertile: MagicMock = mocker.MagicMock() + supertile.tiles = [mocker.MagicMock(name="subtile1")] + supertile.tiles[0].name = "subtile1" + fabric.superTileDic = {"SuperTile1": supertile} + + tile_dir: Path = tmp_path / "Tile" + tile_dir.mkdir() + # SubTiles don't need directories, but SuperTiles do + (tile_dir / "SuperTile1").mkdir() + + flow._validate_project_dir(flow, tmp_path, fabric) + + def test_validate_project_dir_missing_supertile_dir( + self, + mock_flow_with_validate_project_dir: MagicMock, + mocker: MockerFixture, + tmp_path: Path, + ) -> None: + """Test validation fails when SuperTile directory is missing.""" + flow: MagicMock = mock_flow_with_validate_project_dir + fabric: MagicMock = mocker.MagicMock() + fabric.tileDic = {} + + supertile: MagicMock = mocker.MagicMock() + supertile.tiles = [] + fabric.superTileDic = {"SuperTile1": supertile} + + tile_dir: Path = tmp_path / "Tile" + tile_dir.mkdir() + + with pytest.raises(FileNotFoundError, match="SuperTile"): + flow._validate_project_dir(flow, tmp_path, fabric) + + +class TestRunTileFlowWorker: + """Tests for _run_tile_flow_worker function.""" + + def test_worker_propagates_unexpected_exceptions( + self, mocker: MockerFixture, tmp_path: Path + ) -> None: + """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.fabric_optimisation_flow.FABulousTileVerilogMacroFlow", + side_effect=ValueError("Test error"), + ) + + tile: MagicMock = mocker.MagicMock() + 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.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.fabric_optimisation_flow.get_latest_file", + return_value=state_file, + ) + mocker.patch( + "fabulous.fabric_generator.gds_generator.flows.fabric_optimisation_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, + tmp_path / "base.yaml", + tmp_path / "override.yaml", + "test_pdk", + tmp_path, + tmp_path / "models_pack.v", + ) + + assert state is recovered_state + assert error_trace is not 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 + ) -> None: + """Test that worker returns state on successful execution.""" + 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.fabric_optimisation_flow.FABulousTileVerilogMacroFlow", + return_value=mock_flow, + ) + + tile: MagicMock = mocker.MagicMock() + result: WorkerResult = _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", + ) + + state, error_trace, pin_min = result + assert state is mock_state + assert error_trace is None + assert pin_min is not None + + +class TestWorkerCustomOverrides: + """Tests for custom config overrides in worker function.""" + + def test_worker_passes_custom_overrides( + self, mocker: MockerFixture, tmp_path: Path + ) -> None: + """Test that worker passes custom config overrides to flow.""" + 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.fabric_optimisation_flow.FABulousTileVerilogMacroFlow", + return_value=mock_flow, + ) + + tile: MagicMock = mocker.MagicMock() + _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", + CUSTOM_KEY="custom_value", + ) + + # Check that custom override was passed + call_kwargs = mock_flow_class.call_args + 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.fabric_optimisation_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, + } + + 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 + 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.fabric_optimisation_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, + } + + 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 + 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=FABulousFabricOptimisationFlow) + + 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." + "fabric_optimisation_flow.FabricAreaOptimisation" + ) + stitching = mocker.patch( + "fabulous.fabric_generator.gds_generator.flows." + "fabric_optimisation_flow.FABulousFabricMacroFlow" + ) + pool = mocker.patch( + "fabulous.fabric_generator.gds_generator.flows." + "fabric_optimisation_flow.DillProcessPoolExecutor" + ) + + initial_state: MagicMock = mocker.MagicMock() + result_state, result_steps = FABulousFabricOptimisationFlow.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() + + +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"): + FABulousFabricOptimisationFlow._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.fabric_optimisation_flow.info" + ) + + FABulousFabricOptimisationFlow._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 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 deleted file mode 100644 index 64230d433..000000000 --- a/tests/gds_flow_test/flow_test/test_full_fabric_flow.py +++ /dev/null @@ -1,281 +0,0 @@ -"""Tests for FABulousFabricMacroFullFlow - Full automatic fabric flow. - -Tests focus on: -- Flow initialization and configuration -- Project directory validation -- Worker function behavior -- Flow steps and configuration variables -""" - -# ruff: noqa: SLF001 - -from pathlib import Path -from typing import TYPE_CHECKING -from unittest.mock import MagicMock - -import pytest -from pytest_mock import MockerFixture - -from fabulous.fabric_generator.gds_generator.flows.full_fabric_flow import ( - FABulousFabricMacroFullFlow, - _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 -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 - return flow - - -@pytest.fixture -def mock_fabric(mocker: MockerFixture) -> MagicMock: - """Create a mock fabric for testing.""" - fabric: MagicMock = mocker.MagicMock() - fabric.tileDic = {"tile1": mocker.MagicMock(), "tile2": mocker.MagicMock()} - fabric.superTileDic = {} - return fabric - - -class TestValidateProjectDir: - """Tests for _validate_project_dir method.""" - - def test_validate_project_dir_success( - self, - mock_flow_with_validate_project_dir: MagicMock, - mock_fabric: MagicMock, - tmp_path: Path, - ) -> None: - """Test validation passes for valid directory structure.""" - flow: MagicMock = mock_flow_with_validate_project_dir - # Create required directories - tile_dir: Path = tmp_path / "Tile" - tile_dir.mkdir() - (tile_dir / "tile1").mkdir() - (tile_dir / "tile2").mkdir() - - # Should not raise - flow._validate_project_dir(flow, tmp_path, mock_fabric) - - def test_validate_project_dir_missing_proj_dir( - self, - mock_flow_with_validate_project_dir: MagicMock, - mock_fabric: MagicMock, - ) -> None: - """Test validation fails when project directory doesn't exist.""" - flow: MagicMock = mock_flow_with_validate_project_dir - nonexistent: Path = Path("/nonexistent/path") - - with pytest.raises(FileNotFoundError, match="Project directory not found"): - flow._validate_project_dir(flow, nonexistent, mock_fabric) - - def test_validate_project_dir_not_a_directory( - self, - mock_flow_with_validate_project_dir: MagicMock, - mock_fabric: MagicMock, - tmp_path: Path, - ) -> None: - """Test validation fails when path is not a directory.""" - flow: MagicMock = mock_flow_with_validate_project_dir - file_path: Path = tmp_path / "file.txt" - file_path.touch() - - with pytest.raises(NotADirectoryError, match="not a directory"): - flow._validate_project_dir(flow, file_path, mock_fabric) - - def test_validate_project_dir_missing_tile_dir( - self, - mock_flow_with_validate_project_dir: MagicMock, - mock_fabric: MagicMock, - tmp_path: Path, - ) -> None: - """Test validation fails when Tile directory is missing.""" - flow: MagicMock = mock_flow_with_validate_project_dir - with pytest.raises(FileNotFoundError, match="Tile directory not found"): - flow._validate_project_dir(flow, tmp_path, mock_fabric) - - def test_validate_project_dir_missing_tiles( - self, - mock_flow_with_validate_project_dir: MagicMock, - mock_fabric: MagicMock, - tmp_path: Path, - ) -> None: - """Test validation fails when tile directories are missing.""" - flow: MagicMock = mock_flow_with_validate_project_dir - tile_dir: Path = tmp_path / "Tile" - tile_dir.mkdir() - # Only create tile1, not tile2 - (tile_dir / "tile1").mkdir() - - with pytest.raises(FileNotFoundError, match="Missing tile directories"): - flow._validate_project_dir(flow, tmp_path, mock_fabric) - - def test_validate_project_dir_with_supertiles( - self, - mock_flow_with_validate_project_dir: MagicMock, - mocker: MockerFixture, - tmp_path: Path, - ) -> None: - """Test validation with SuperTiles.""" - flow: MagicMock = mock_flow_with_validate_project_dir - fabric: MagicMock = mocker.MagicMock() - fabric.tileDic = {"subtile1": mocker.MagicMock()} - - # SuperTile containing subtile1 - supertile: MagicMock = mocker.MagicMock() - supertile.tiles = [mocker.MagicMock(name="subtile1")] - supertile.tiles[0].name = "subtile1" - fabric.superTileDic = {"SuperTile1": supertile} - - tile_dir: Path = tmp_path / "Tile" - tile_dir.mkdir() - # SubTiles don't need directories, but SuperTiles do - (tile_dir / "SuperTile1").mkdir() - - flow._validate_project_dir(flow, tmp_path, fabric) - - def test_validate_project_dir_missing_supertile_dir( - self, - mock_flow_with_validate_project_dir: MagicMock, - mocker: MockerFixture, - tmp_path: Path, - ) -> None: - """Test validation fails when SuperTile directory is missing.""" - flow: MagicMock = mock_flow_with_validate_project_dir - fabric: MagicMock = mocker.MagicMock() - fabric.tileDic = {} - - supertile: MagicMock = mocker.MagicMock() - supertile.tiles = [] - fabric.superTileDic = {"SuperTile1": supertile} - - tile_dir: Path = tmp_path / "Tile" - tile_dir.mkdir() - - with pytest.raises(FileNotFoundError, match="SuperTile"): - flow._validate_project_dir(flow, tmp_path, fabric) - - -class TestRunTileFlowWorker: - """Tests for _run_tile_flow_worker function.""" - - 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", - side_effect=ValueError("Test error"), - ) - - tile: MagicMock = mocker.MagicMock() - result: tuple[State | None, str | None] = _run_tile_flow_worker( - tile, - tmp_path, - tmp_path / "io.yaml", - OptMode.BALANCE, - tmp_path / "base.yaml", - tmp_path / "override.yaml", - ) - - # Should return (None, error_trace) - state: State | None - error_trace: str | None - state, error_trace = result - assert state is None - assert error_trace is not None - assert "Test error" in error_trace - - 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 - 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( - tile, - tmp_path, - tmp_path / "io.yaml", - OptMode.BALANCE, - tmp_path / "base.yaml", - tmp_path / "override.yaml", - ) - - state: State | None - error_trace: str | None - state, error_trace = result - assert state is mock_state - assert error_trace is None - - -class TestWorkerCustomOverrides: - """Tests for custom config overrides in worker function.""" - - 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 - mock_flow_class: MagicMock = mocker.patch( - "fabulous.fabric_generator.gds_generator.flows.full_fabric_flow.FABulousTileVerilogMacroFlow", - return_value=mock_flow, - ) - - tile: MagicMock = mocker.MagicMock() - _run_tile_flow_worker( - tile, - tmp_path, - tmp_path / "io.yaml", - OptMode.BALANCE, - tmp_path / "base.yaml", - tmp_path / "override.yaml", - CUSTOM_KEY="custom_value", - ) - - # Check that custom override was passed - call_kwargs = mock_flow_class.call_args - assert "CUSTOM_KEY" in call_kwargs.kwargs or ( - len(call_kwargs.args) > 0 and hasattr(call_kwargs, "kwargs") - ) 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..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 @@ -24,13 +24,35 @@ from fabulous.fabric_generator.gds_generator.flows.tile_macro_flow import ( FABulousTileVerilogMacroFlow, ) -from fabulous.fabric_generator.gds_generator.steps.tile_optimisation import OptMode +from fabulous.fabric_generator.gds_generator.helper import round_up_decimal +from fabulous.fabric_generator.gds_generator.steps.tile_area_opt import OptMode @pytest.mark.usefixtures("mock_config_load") 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 +60,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 +79,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 +99,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,18 +121,56 @@ 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", ) 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.""" + self._create_flow( + tile_type=mock_tile, + io_pin_config=io_pin_config, + mock_pdk_root=mock_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 = self._create_flow( + tile_type=mock_tile, + io_pin_config=io_pin_config, + mock_pdk_root=mock_pdk_root, + models_pack_path=Path("/fake/models/pack.v"), + ) + + assert flow.config["PAD_GDS"] == ["/definitely/missing/pad.gds"] + def test_die_area_set_with_ignore_default( self, mock_tile: MagicMock, @@ -123,12 +178,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, ) @@ -144,14 +197,16 @@ 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"): - FABulousTileVerilogMacroFlow( + """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, - 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")), ) @@ -162,12 +217,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")), ) @@ -176,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, @@ -184,12 +355,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( @@ -199,12 +369,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")), ) @@ -220,12 +389,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) @@ -239,12 +406,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, ) @@ -260,12 +425,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, ) @@ -278,12 +441,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" @@ -300,12 +461,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, ) @@ -318,12 +477,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"] @@ -341,12 +499,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, ) @@ -361,12 +517,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")), ) 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() # ============================================================================ 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..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 @@ -16,6 +16,7 @@ PinPlacementPlan, SegmentInfo, equally_spaced_sequence, + filter_pin_tracks_by_stride_and_distance, grid_to_tracks, ) @@ -27,6 +28,7 @@ def mock_modules(mocker: MockerFixture) -> None: sys.modules["odb"] = mocker.MagicMock() sys.modules["openroad"] = mocker.MagicMock() + sys.modules["utl"] = mocker.MagicMock() class TestGridToTracks: @@ -1285,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, @@ -1312,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, @@ -1327,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, ( @@ -1356,6 +1384,106 @@ 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 diff --git a/tests/gds_flow_test/step_test/conftest.py b/tests/gds_flow_test/step_test/conftest.py index 64d24d42d..80af5a190 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 @@ -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) @@ -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_fabric_area_opt.py b/tests/gds_flow_test/step_test/test_fabric_area_opt.py new file mode 100644 index 000000000..f39c9acc3 --- /dev/null +++ b/tests/gds_flow_test/step_test/test_fabric_area_opt.py @@ -0,0 +1,458 @@ +"""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 +sample, producing 1:8 rectangles for square-ish tiles. These tests pin the +correct algorithm so that regression cannot reappear unnoticed. +""" + +# for testing private methods +# ruff: noqa: SLF001 + +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.fabric_area_opt import ( + FabricAreaOptimisation, + NLPTileProblem, +) +from fabulous.fabric_generator.gds_generator.steps.tile_area_opt import OptMode + + +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]}" + ) + + +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 = FabricAreaOptimisation._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 = FabricAreaOptimisation._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 = FabricAreaOptimisation._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 = FabricAreaOptimisation._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"): + 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"): + FabricAreaOptimisation._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. 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_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": { + "design__die__bbox": "0 0 100 100", + "design__core__bbox": "0 0 100 100", + }, + "broken": { + "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" + path.write_text(json.dumps(payload)) + + valid, all_ = FabricAreaOptimisation._load_tile_metrics_from_json(path) + + 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, no error -> nothing to constrain; entry is dropped. + payload = { + OptMode.BALANCE.value: { + "no_bbox": {}, + } + } + path = tmp_path / "metrics.json" + path.write_text(json.dumps(payload)) + + valid, all_ = FabricAreaOptimisation._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_area_opt.py b/tests/gds_flow_test/step_test/test_tile_area_opt.py new file mode 100644 index 000000000..7e166ad33 --- /dev/null +++ b/tests/gds_flow_test/step_test/test_tile_area_opt.py @@ -0,0 +1,525 @@ +"""Tests for TileAreaOptimisation step.""" + +# for testing private methods +# ruff: noqa: SLF001 + +from decimal import Decimal +from pathlib import Path + +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 + +from fabulous.fabric_generator.gds_generator.steps.tile_area_opt import ( + OptMode, + TileAreaOptimisation, +) + + +class TestTileOptimisation: + """Test suite for TileOptimisation step.""" + + def test_condition_returns_true_on_drc_errors( + self, mock_config: Config, mock_state: State + ) -> None: + """Test condition returns True when DRC errors exist.""" + mock_state.metrics["route__drc_errors"] = 5 + + step = TileAreaOptimisation(mock_config) + step.config = mock_config + assert step.condition(mock_state) is True + + def test_condition_returns_true_on_antenna_violations( + self, mock_config: Config, mock_state: State + ) -> None: + """Test condition returns True when antenna violations exist.""" + mock_state.metrics["route__drc_errors"] = 0 + mock_state.metrics["antenna__violating__nets"] = 2 + + step = TileAreaOptimisation(mock_config) + step.config = mock_config + assert step.condition(mock_state) is True + + def test_condition_returns_false_when_no_errors( + self, mock_config: Config, mock_state: State + ) -> None: + """Test condition returns False when no errors exist.""" + mock_state.metrics["route__drc_errors"] = 0 + 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 = TileAreaOptimisation(mock_config) + step.config = mock_config + assert step.condition(mock_state) is False + + def test_pre_iteration_callback_find_min_width_mode( + self, + mocker: MockerFixture, + mock_config: Config, + mock_state: State, + tmp_path: Path, + ) -> None: + """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_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_area_opt.get_routing_obstructions", + return_value=[], + ) + + mock_config = mock_config.copy(FABULOUS_OPT_MODE=OptMode.FIND_MIN_WIDTH) + mock_config = mock_config.copy( + DIE_AREA=(Decimal(0), Decimal(0), Decimal(100), Decimal(100)) + ) + mock_config = mock_config.copy(LEFT_MARGIN_MULT=Decimal(0)) + mock_config = mock_config.copy(RIGHT_MARGIN_MULT=Decimal(0)) + mock_config = mock_config.copy(BOTTOM_MARGIN_MULT=Decimal(0)) + mock_config = mock_config.copy(TOP_MARGIN_MULT=Decimal(0)) + + step = TileAreaOptimisation(mock_config) + step.step_dir = str(tmp_path) + step.config = mock_config + step.iter_count = 0 + step.pre_iteration_callback(mock_state) + + # DIE_AREA should be updated + new_die_area = step.config["DIE_AREA"] + assert new_die_area is not None + assert new_die_area[2] >= Decimal(100) + + def test_post_loop_callback_returns_working_state( + self, mock_config: Config, mock_state: State + ) -> None: + """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 = TileAreaOptimisation(mock_config) + 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.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 + ) -> None: + """Test post_loop_callback raises error if no working state found.""" + step = TileAreaOptimisation(mock_config) + step.config = mock_config + step.last_working_state = None + + with pytest.raises(RuntimeError, match="No working state found"): + step.post_loop_callback(mock_state) + + def test_run_ignores_antenna_violations_when_configured( + self, mocker: MockerFixture, mock_config: Config, mock_state: State + ) -> None: + """Test run method with IGNORE_ANTENNA_VIOLATIONS enabled.""" + mock_config = mock_config.copy(IGNORE_ANTENNA_VIOLATIONS=True) + + step = TileAreaOptimisation(mock_config) + step.config = mock_config + _mock_run = mocker.patch( + "fabulous.fabric_generator.gds_generator.steps.tile_area_opt.WhileStep.run", + return_value=({}, {}), + ) + + step.run(mock_state) + + # ERROR_ON_TR_DRC should be set to False + assert step.config["ERROR_ON_TR_DRC"] is False + + 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_area_opt import ( + Checker, + ) + + mock_state.metrics["route__drc_errors"] = 5 + mock_config = mock_config.copy(IGNORE_ANTENNA_VIOLATIONS=True) + + step = TileAreaOptimisation(mock_config) + step.config = mock_config + + result = step.mid_iteration_break(mock_state, Checker.TrDRC()) + + 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], + ) -> TileAreaOptimisation: + mocker.patch( + "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_area_opt.WhileStep.run", + return_value=({}, {}), + ) + cfg = config.copy(FABULOUS_IGNORE_DEFAULT_DIE_AREA=False, DIE_AREA=die_area) + step = TileAreaOptimisation(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) + + 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. + + 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 = TileAreaOptimisation(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 = TileAreaOptimisation(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 = TileAreaOptimisation(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 = TileAreaOptimisation(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 = TileAreaOptimisation(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 = TileAreaOptimisation(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 = TileAreaOptimisation(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 = TileAreaOptimisation(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 = TileAreaOptimisation(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 + ) -> TileAreaOptimisation: + # get_pitch is read inside the helper to compute pitch on the target axis. + mocker.patch( + "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) + cfg = cfg.copy(FABULOUS_PIN_MIN_WIDTH=Decimal(1)) + cfg = cfg.copy(FABULOUS_PIN_MIN_HEIGHT=Decimal(1)) + step = TileAreaOptimisation(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) diff --git a/tests/gds_flow_test/step_test/test_tile_optimisation.py b/tests/gds_flow_test/step_test/test_tile_optimisation.py deleted file mode 100644 index 78dc28b34..000000000 --- a/tests/gds_flow_test/step_test/test_tile_optimisation.py +++ /dev/null @@ -1,148 +0,0 @@ -"""Tests for TileOptimisation step.""" - -from decimal import Decimal -from pathlib import Path - -import pytest -from librelane.config.config import Config -from librelane.state.state import State -from pytest_mock import MockerFixture - -from fabulous.fabric_generator.gds_generator.steps.tile_optimisation import ( - OptMode, - TileOptimisation, -) - - -class TestTileOptimisation: - """Test suite for TileOptimisation step.""" - - def test_condition_returns_true_on_drc_errors( - self, mock_config: Config, mock_state: State - ) -> None: - """Test condition returns True when DRC errors exist.""" - mock_state.metrics["route__drc_errors"] = 5 - - step = TileOptimisation(mock_config) - step.config = mock_config - assert step.condition(mock_state) is True - - def test_condition_returns_true_on_antenna_violations( - self, mock_config: Config, mock_state: State - ) -> None: - """Test condition returns True when antenna violations exist.""" - mock_state.metrics["route__drc_errors"] = 0 - mock_state.metrics["antenna__violating__nets"] = 2 - - step = TileOptimisation(mock_config) - step.config = mock_config - assert step.condition(mock_state) is True - - def test_condition_returns_false_when_no_errors( - self, mock_config: Config, mock_state: State - ) -> None: - """Test condition returns False when no errors exist.""" - mock_state.metrics["route__drc_errors"] = 0 - mock_state.metrics["antenna__violating__nets"] = 0 - mock_state.metrics["antenna__violating__pins"] = 0 - - step = TileOptimisation(mock_config) - step.config = mock_config - assert step.condition(mock_state) is False - - def test_pre_iteration_callback_find_min_width_mode( - self, - mocker: MockerFixture, - mock_config: Config, - mock_state: State, - tmp_path: Path, - ) -> None: - """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", - 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", - return_value=[], - ) - - mock_config = mock_config.copy(FABULOUS_OPT_MODE=OptMode.FIND_MIN_WIDTH) - mock_config = mock_config.copy( - DIE_AREA=(Decimal(0), Decimal(0), Decimal(100), Decimal(100)) - ) - mock_config = mock_config.copy(LEFT_MARGIN_MULT=Decimal(0)) - mock_config = mock_config.copy(RIGHT_MARGIN_MULT=Decimal(0)) - 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.step_dir = str(tmp_path) - step.config = mock_config - step.iter_count = 0 - step.pre_iteration_callback(mock_state) - - # DIE_AREA should be updated - new_die_area = step.config["DIE_AREA"] - assert new_die_area is not None - assert new_die_area[2] >= Decimal(100) - - 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.""" - step = TileOptimisation(mock_config) - step.last_working_state = mock_state - - result = step.post_loop_callback(mock_state) - - assert result == mock_state - - 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.config = mock_config - step.last_working_state = None - - with pytest.raises(RuntimeError, match="No working state found"): - step.post_loop_callback(mock_state) - - def test_run_ignores_antenna_violations_when_configured( - self, mocker: MockerFixture, mock_config: Config, mock_state: State - ) -> None: - """Test run method with IGNORE_ANTENNA_VIOLATIONS enabled.""" - mock_config = mock_config.copy(IGNORE_ANTENNA_VIOLATIONS=True) - - step = TileOptimisation(mock_config) - step.config = mock_config - _mock_run = mocker.patch( - "fabulous.fabric_generator.gds_generator.steps.tile_optimisation.WhileStep.run", - return_value=({}, {}), - ) - - step.run(mock_state) - - # ERROR_ON_TR_DRC should be set to False - assert step.config["ERROR_ON_TR_DRC"] is False - - 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 ( - Checker, - ) - - mock_state.metrics["route__drc_errors"] = 5 - mock_config = mock_config.copy(IGNORE_ANTENNA_VIOLATIONS=True) - - step = TileOptimisation(mock_config) - step.config = mock_config - - result = step.mid_iteration_break(mock_state, Checker.TrDRC()) - - assert result is True 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()