From 52d618f3acb711e1bbd32c6a884b2b8669963f0e Mon Sep 17 00:00:00 2001 From: Oscar Mattia Date: Sat, 4 Apr 2026 08:39:34 -0700 Subject: [PATCH 1/3] Placement improvements, onboarding doc, optuna tuning, test updates Made-with: Cursor --- ONBOARDING.md | 275 +++++++++++++++++++++++++++ lab_notebook.md | 39 ++++ placement.py | 486 +++++++++++++++++++++++++++++++++++------------- test.py | 4 +- tune_optuna.py | 203 ++++++++++++++++++++ 5 files changed, 876 insertions(+), 131 deletions(-) create mode 100644 ONBOARDING.md create mode 100644 lab_notebook.md create mode 100644 tune_optuna.py diff --git a/ONBOARDING.md b/ONBOARDING.md new file mode 100644 index 0000000..630d018 --- /dev/null +++ b/ONBOARDING.md @@ -0,0 +1,275 @@ +# Onboarding: `placement.py` library + +This document orients new contributors to the VLSI-style **cell placement** code in [`placement.py`](placement.py): what it does, how data is laid out, the public API (inputs, outputs, purpose), and where performance matters. + +--- + +## Problem and geometry + +The library optimizes **2D positions** of rectangular **cells** (macros and standard cells) so that: + +1. **Overlap is minimized** (primary objective in the challenge). +2. **Wirelength** between connected **pins** is reduced (secondary). + +**Convention:** Each cell is an axis-aligned rectangle **centered** at `(x, y)` with given `width` and `height`. Overlap between two cells is computed from center-to-center separation versus half-widths and half-heights (same criterion everywhere: strict separation when `|dx| == (w_i + w_j)/2` is treated as non-overlapping in the vectorized checks via `<`). + +Pins have positions **relative** to the cell corner in the stored features, but **wirelength loss** recomputes absolute pin coordinates as `cell_center + relative_offset` each forward pass, so moving `cell_features[:, 2:4]` is sufficient for optimization. + +--- + +## Quick mental model + +```mermaid +flowchart LR + subgraph inputs [Inputs] + CF[cell_features N x 6] + PF[pin_features P x 7] + EL[edge_list E x 2] + end + subgraph train [train_placement] + WL[wirelength_attraction_loss] + OL[overlap_repulsion_loss] + ADAM[Adam on positions] + CF --> WL + CF --> OL + PF --> WL + EL --> WL + WL --> ADAM + OL --> ADAM + end + subgraph eval [Evaluation] + MET[calculate_normalized_metrics] + CF2[final cell_features] + CF2 --> MET + end + ADAM --> CF2 +``` + +--- + +## Main modules of responsibility + +| Section | Role | +|--------|------| +| **Setup** | Synthetic netlist generation (`generate_placement_input`). | +| **Optimization** | Differentiable losses and `train_placement` (the part you typically edit). | +| **Evaluation** | Non-differentiable metrics for reporting and tests (`calculate_*`). | +| **Visualization** | Optional Matplotlib plots (`plot_*`). | +| **Demo** | `main()` end-to-end script. | + +The test harness [`test.py`](test.py) imports `generate_placement_input`, `train_placement`, and `calculate_normalized_metrics`. + +--- + +## Data structures + +### `CellFeatureIdx` / `PinFeatureIdx` + +`IntEnum` types indexing columns of feature tensors. Prefer these over magic numbers. + +### `cell_features` — shape `[N, 6]` + +| Index | Name (enum) | Meaning | +|------|----------------|---------| +| 0 | `AREA` | Cell area (scalar used in normalization and generation). | +| 1 | `NUM_PINS` | Pin count for that cell (informational / generation). | +| 2 | `X` | Cell center **x** (optimized in training). | +| 3 | `Y` | Cell center **y** (optimized in training). | +| 4 | `WIDTH` | Full width of the rectangle. | +| 5 | `HEIGHT` | Full height of the rectangle. | + +Only columns **2–3** receive gradients during `train_placement`; other columns are fixed physical parameters. + +### `pin_features` — shape `[P, 7]` + +| Index | Name (enum) | Meaning | +|------|----------------|---------| +| 0 | `CELL_IDX` | Index of owning cell in `[0, N)`. | +| 1 | `PIN_X` | Pin offset **x** relative to cell (used in loss). | +| 2 | `PIN_Y` | Pin offset **y** relative to cell (used in loss). | +| 3 | `X` | Absolute **x** at init / legacy column (loss does not rely on staying updated). | +| 4 | `Y` | Absolute **y** at init / legacy column. | +| 5 | `WIDTH` | Pin width (e.g. 0.1). | +| 6 | `HEIGHT` | Pin height. | + +**Note:** `wirelength_attraction_loss` builds absolute positions from **cell centers + columns 1–2**, not from columns 3–4. + +### `edge_list` — shape `[E, 2]`, `dtype` long + +Each row is `[src_pin_idx, tgt_pin_idx]` into `pin_features`. Undirected connectivity is represented as one row per edge (order may follow generation logic). + +--- + +## Public API reference + +### `generate_placement_input(num_macros, num_std_cells)` + +| | | +|--|--| +| **Input** | Two nonnegative integers: macro count, standard-cell count. | +| **Output** | `(cell_features, pin_features, edge_list)` as described above. | +| **Utility** | Builds a random synthetic design: macro areas in `[MIN_MACRO_AREA, MAX_MACRO_AREA]`, standard cells from `STANDARD_CELL_AREAS`, random pins per cell, random edges with deduplication. Prints a short summary. | + +Implementation mixes vectorized tensor ops with Python loops over cells/pins for pin placement and edge wiring. + +--- + +### `wirelength_attraction_loss(cell_features, pin_features, edge_list)` + +| | | +|--|--| +| **Input** | Full feature tensors and edge list. | +| **Output** | Scalar `torch.Tensor` (mean smooth Manhattan distance per edge). | +| **Utility** | Differentiable **wirelength proxy**: gathers pin absolutes via `cell_positions[cell_indices] + offsets`, then per-edge smooth L1 / log-sum-exp Manhattan with smoothing parameter `alpha = 0.1`. Returns **0** with `requires_grad=True` if `E == 0`. | + +**Vectorization:** Indexing `pin_absolute_*` by `src_pins` and `tgt_pins` avoids Python loops over edges. + +--- + +### `overlap_repulsion_loss(cell_features, pin_features, edge_list, mode="fast")` + +| | | +|--|--| +| **Input** | `cell_features`; `pin_features` and `edge_list` are **ignored** (deleted) but kept for a uniform call signature with wirelength loss. | +| **Output** | Scalar differentiable penalty. | +| **Utility** | Penalizes axis-aligned overlap between all **unordered** pairs (upper triangle only). Modes: **`fast`** — sum of overlap areas divided by `(count_overlapping_pairs + 1)`; **`area`** / **`squared`** / **`both`** — mean overlap area and/or mean squared overlap area over `N(N-1)/2` pairs. | + +**Vectorization:** `N×N` tensors via `unsqueeze` broadcast for `dx`, `dy`, `min_sep_*`, `relu` penetration, and `torch.triu` mask. + +--- + +### `_fast_overlap_ratio(cell_features)` (private) + +| | | +|--|--| +| **Input** | `cell_features` `[N, 6]`. | +| **Output** | Python `float`: fraction of cells that participate in **at least one** overlap. | +| **Utility** | Fast, **vectorized** estimate aligned with the overlap definition used in `calculate_cells_with_overlaps` (strict `<` on separations). Used during training for optional plot annotations. | + +--- + +### `_lr_cosine_anneal(progress, lr_max, lr_min_frac=0.1)` (private) + +| | | +|--|--| +| **Input** | `progress` in `[0, 1]`, peak LR, minimum fraction of peak. | +| **Output** | Scalar learning rate. | +| **Utility** | Cosine decay from `lr_max` down to `lr_max * lr_min_frac`. | + +--- + +### `train_placement(cell_features, pin_features, edge_list, ...)` + +| | | +|--|--| +| **Input** | Initial features and graph; hyperparameters: `num_epochs`, `lr`, `lambda_wirelength`, `lambda_overlap`, `overlap_loss_mode`, `verbose`, `log_interval`, optional `loss_plot_path`, `overlap_ratio_tag_interval`, `per_cell_grad_clip_norm`. | +| **Output** | `dict` with `final_cell_features`, `initial_cell_features`, `loss_history` (lists per epoch + optional overlap ratio tags), `lambda_wirelength`, `lambda_overlap`, `num_epochs`. | +| **Utility** | Runs **Adam** only on `cell_positions` clone. Each step: cosine LR; forward = weighted sum `λ_wl * L_wl + λ_ol * L_ol`; backward; optional **per-cell** L2 grad clip (row-wise norm, scale capped at 1); optimizer step. Writes loss plot if path given. | + +**Training graph:** `cell_features` is cloned; each epoch builds `cell_features_current` by copying and injecting `cell_positions` into columns 2–3, so the backward path flows into `cell_positions` only. + +--- + +### `calculate_overlap_metrics(cell_features)` + +| | | +|--|--| +| **Input** | `cell_features` `[N, 6]`. | +| **Output** | `dict`: `overlap_count` (pair count), `total_overlap_area`, `max_overlap_area`, `overlap_percentage` (implemented as `(overlap_count / N) * 100` when `total_area > 0`). | +| **Utility** | **Ground-truth** reporting using NumPy loops over pairs; **not** differentiable. | + +--- + +### `calculate_cells_with_overlaps(cell_features)` + +| | | +|--|--| +| **Input** | `cell_features` `[N, 6]`. | +| **Output** | Python `set` of cell indices that appear in at least one overlapping pair. | +| **Utility** | Defines the **official overlap_ratio** used in tests: `len(set) / N`. | + +--- + +### `calculate_normalized_metrics(cell_features, pin_features, edge_list)` + +| | | +|--|--| +| **Input** | Final placement tensors. | +| **Output** | `dict`: `overlap_ratio`, `normalized_wl`, `num_cells_with_overlaps`, `total_cells`, `num_nets`. | +| **Utility** | Single entry point for leaderboard-style metrics: overlap from `calculate_cells_with_overlaps`; wirelength from `wirelength_attraction_loss` × `E` then `(total_wirelength / num_nets) / sqrt(total_area)`. | + +--- + +### `plot_placement(initial_cell_features, final_cell_features, pin_features, edge_list, filename=...)` + +| | | +|--|--| +| **Input** | Initial/final cells, pins, edges; output basename. | +| **Output** | Writes PNG under `OUTPUT_DIR` (script directory) unless `filename` is absolute. | +| **Utility** | Side-by-side rectangles + overlap summary text; requires Matplotlib. | + +--- + +### `plot_training_loss_curves(loss_history, lambda_wirelength, lambda_overlap, filename=...)` + +| | | +|--|--| +| **Input** | History dict from `train_placement`; lambdas for title. | +| **Output** | Saves a 2×2 figure (log-scaled curves + overlap share + annotations from `overlap_ratio_tags`). | +| **Utility** | Debugging convergence; optional dependency on Matplotlib/NumPy. | + +Nested helper `_positive_log_y` masks non-positive/non-finite values for log axes. + +--- + +### `main()` + +| | | +|--|--| +| **Input** | None (uses fixed demo sizes and seed). | +| **Output** | Console logs, optional plots, success/fail message. | +| **Utility** | Demonstrates full pipeline: generate → random radial spread → train → metrics → `plot_placement`. | + +--- + +## Runtime and memory notes + +### Where work scales as **O(N²)** + +- **`overlap_repulsion_loss`:** Builds `N×N` pairwise tensors. Dominates cost for large `N` on GPU/CPU. Fully **vectorized** (no pair loop in Python). +- **`_fast_overlap_ratio`:** Same pairwise structure; **vectorized**; `any` over rows for “cell has any overlap.” +- **`calculate_overlap_metrics` / `calculate_cells_with_overlaps`:** **Double Python loops** over pairs — simpler but slower for large `N`. Evaluation is usually run once per test, not per epoch. + +### Where work scales as **O(E)** or **O(P)** + +- **`wirelength_attraction_loss`:** O(P) indexing for pin absolutes, O(E) edge reduction. **Vectorized** along edges. + +### Training loop overhead + +- Each epoch: **`cell_features.clone()`** plus assignment of positions — allocates a full `[N, 6]` tensor every step. For huge `N`, reducing clones (e.g. only swapping in position columns without full tensor duplicate) would be a possible optimization; current code favors clarity and correct autograd wiring into `cell_positions`. + +### Differentiable overlap + +- **`relu(min_sep - |delta|)`** gives zero gradient when pairs are separated; gradients flow only through overlapping pairs. **`fast`** mode changes magnitude via divide-by-overlap-count (+1), which can affect gradient scaling when few pairs overlap. + +### Device + +- Code assumes a single default tensor device (typically CPU in the reference harness). For CUDA, ensure all tensors (`mask`, constants) live on the same device as `cell_features` — `overlap_repulsion_loss` already uses `device=x.device` for the upper-triangular mask. + +### Optional plotting + +- Matplotlib is **lazy-imported** inside plotting functions so headless / CI runs without the dependency until you plot. + +--- + +## Related files + +| File | Role | +|------|------| +| [`test.py`](test.py) | Batch runs `TEST_CASES` with `train_placement(..., verbose=False)` and aggregates timing and normalized metrics. | +| [`tune_optuna.py`](tune_optuna.py) | Hyperparameter search (external to core library API). | +| [`README.md`](README.md) | Challenge statement and leaderboard. | + +--- + +Welcome aboard — when changing losses or training, keep **tensor shapes**, **centered-rectangle geometry**, and **test metrics** (`calculate_cells_with_overlaps` + normalized wirelength) consistent with this document. diff --git a/lab_notebook.md b/lab_notebook.md new file mode 100644 index 0000000..c9fe649 --- /dev/null +++ b/lab_notebook.md @@ -0,0 +1,39 @@ +# Lab notebook — placement overlap / training + +Concise log of what was tried and measured. Re-run `python test.py` for full leaderboard-style numbers. + +## Approaches tried + +| Approach | Notes | +|----------|--------| +| **Baseline relu overlap** | Pairwise `relu(min_sep − \|Δ\|)` overlap area, mean over all pairs; overlap term often ~100% of objective vs wirelength. | +| **Legacy modes** | `area`, `squared`, `both` (mean over all pairs); selectable via `overlap_loss_mode`. | +| **Fast loss (`mode="fast"`, default)** | `relu` overlap area sum ÷ (number of overlapping pairs + 1). Stronger gradients than mean over all pairs when few pairs overlap. | +| **Per-cell grad clip** | Clip L2 norm **per cell** on position grads (`per_cell_grad_clip_norm`), instead of global `clip_grad_norm_`. | +| **Loss diagnostics** | `loss_history`: weighted WL / OL, overlap share, scheduled λ, `lr`. Plots: overlap-ratio tags. Overlap checks use `_fast_overlap_ratio` (torch, no Python pair loops). | +| **Overlap sweep script** | `plot_overlap_loss_vs_cells.py`: overlap loss vs N (2…50) for heights 1,2,3 (`overlap_loss_vs_num_cells.png`). | + +## Hyperparameters (see `train_placement` defaults in `placement.py`) + +## Test results (harness `test.run_placement_test`, seeds from `TEST_CASES`) + +Recorded on one local run (macOS / project env). **Overlap target:** `num_cells_with_overlaps == 0`. + +| Test id | Macros × std cells | Overlap ratio | Cells w/ overlap | Time (s) | Result | +|--------:|---------------------|---------------|------------------|----------|--------| +| 1 | 2 × 20 | 0.0 | 0 / 22 | ~5.6 | PASS | +| 2 | 3 × 25 | 0.0 | 0 / 28 | ~6.5 | PASS | +| 3 | 2 × 30 | 0.0 | 0 / 32 | ~6.0 | PASS | +| 4–10 | — | — | — | — | *Re-run `python test.py` (test 10: 2010 cells × 10k epochs, long)* | + +## Failed / partial experiments (historical) + +- **λ_ol=10, legacy loss, short epochs:** overlap ratio often remained > 0; needed more epochs and/or higher λ_ol. + +## Commands + +```bash +python placement.py # demo + placement_result.png + training_loss_curves.png if enabled +python test.py # full suite (12 cases; 11–12 extra credit / very large) +python plot_overlap_loss_vs_cells.py +``` diff --git a/placement.py b/placement.py index d70412d..f1692af 100644 --- a/placement.py +++ b/placement.py @@ -1,43 +1,17 @@ """ -VLSI Cell Placement Optimization Challenge -========================================== - -CHALLENGE OVERVIEW: -You are tasked with implementing a critical component of a chip placement optimizer. -Given a set of cells (circuit components) with fixed sizes and connectivity requirements, -you need to find positions for these cells that: -1. Minimize total wirelength (wiring cost between connected pins) -2. Eliminate all overlaps between cells - -YOUR TASK: -Implement the `overlap_repulsion_loss()` function to prevent cells from overlapping. -The function must: -- Be differentiable (uses PyTorch operations for gradient descent) -- Detect when cells overlap in 2D space -- Apply increasing penalties for larger overlaps -- Work efficiently with vectorized operations - -SUCCESS CRITERIA: -After running the optimizer with your implementation: -- overlap_count should be 0 (no overlapping cell pairs) -- total_overlap_area should be 0.0 (no overlap) -- wirelength should be minimized -- Visualization should show clean, non-overlapping placement - -GETTING STARTED: -1. Read through the existing code to understand the data structures -2. Look at wirelength_attraction_loss() as a reference implementation -3. Implement overlap_repulsion_loss() following the TODO instructions -4. Run main() and check the overlap metrics in the output -5. Tune hyperparameters (lambda_overlap, lambda_wirelength) if needed -6. Generate visualization to verify your solution - -BONUS CHALLENGES: -- Improve convergence speed by tuning learning rate or adding momentum -- Implement better initial placement strategy -- Add visualization of optimization progress over time +VLSI-style cell placement via gradient descent on synthetic netlists. + +Generates random macros and standard cells with pins and nets, then optimizes +cell (x, y) with Adam. The objective combines ``wirelength_attraction_loss`` +(smooth Manhattan wirelength) and ``overlap_repulsion_loss`` (pairwise +axis-aligned overlap). ``train_placement`` applies cosine learning-rate decay; +optional loss and placement figures are saved next to this file (``OUTPUT_DIR``). + +``calculate_overlap_metrics`` and ``calculate_normalized_metrics`` report exact +overlap statistics and normalized wirelength for tests and benchmarks. """ +import math import os from enum import IntEnum @@ -244,15 +218,13 @@ def generate_placement_input(num_macros, num_std_cells): return cell_features, pin_features, edge_list -# ======= OPTIMIZATION CODE (edit this part) ======= +# ======= PLACEMENT OPTIMIZATION ======= def wirelength_attraction_loss(cell_features, pin_features, edge_list): - """Calculate loss based on total wirelength to minimize routing. + """Differentiable wirelength loss: smooth Manhattan distance per net, mean over edges. - This is a REFERENCE IMPLEMENTATION showing how to write a differentiable loss function. - - The loss computes the Manhattan distance between connected pins and minimizes - the total wirelength across all edges. + Pin absolutes are derived from cell centers plus relative pin offsets; edge endpoints + are then compared with a log-sum-exp smoothed L1 (see ``alpha`` in the body). Args: cell_features: [N, 6] tensor with [area, num_pins, x, y, width, height] @@ -299,77 +271,122 @@ def wirelength_attraction_loss(cell_features, pin_features, edge_list): return total_wirelength / edge_list.shape[0] # Normalize by number of edges -def overlap_repulsion_loss(cell_features, pin_features, edge_list): - """Calculate loss to prevent cell overlaps. - - TODO: IMPLEMENT THIS FUNCTION - - This is the main challenge. You need to implement a differentiable loss function - that penalizes overlapping cells. The loss should: - - 1. Be zero when no cells overlap - 2. Increase as overlap area increases - 3. Use only differentiable PyTorch operations (no if statements on tensors) - 4. Work efficiently with vectorized operations - - HINTS: - - Two axis-aligned rectangles overlap if they overlap in BOTH x and y dimensions - - For rectangles centered at (x1, y1) and (x2, y2) with widths (w1, w2) and heights (h1, h2): - * x-overlap occurs when |x1 - x2| < (w1 + w2) / 2 - * y-overlap occurs when |y1 - y2| < (h1 + h2) / 2 - - Use torch.relu() to compute positive overlaps: overlap_x = relu((w1+w2)/2 - |x1-x2|) - - Overlap area = overlap_x * overlap_y - - Consider all pairs of cells: use broadcasting with unsqueeze - - Use torch.triu() to avoid counting each pair twice (only consider i < j) - - Normalize the loss appropriately (by number of pairs or total area) - - RECOMMENDED APPROACH: - 1. Extract positions, widths, heights from cell_features - 2. Compute all pairwise distances using broadcasting: - positions_i = positions.unsqueeze(1) # [N, 1, 2] - positions_j = positions.unsqueeze(0) # [1, N, 2] - distances = positions_i - positions_j # [N, N, 2] - 3. Calculate minimum separation distances for each pair - 4. Use relu to get positive overlap amounts - 5. Multiply overlaps in x and y to get overlap areas - 6. Mask to only consider upper triangle (i < j) - 7. Sum and normalize +def _fast_overlap_ratio(cell_features): + """Fraction of cells involved in any axis-aligned overlap (vectorized, matches eval). - Args: - cell_features: [N, 6] tensor with [area, num_pins, x, y, width, height] - pin_features: [P, 7] tensor with pin information (not used here) - edge_list: [E, 2] tensor with edges (not used here) + Uses the same separation test as ``calculate_cells_with_overlaps`` (strict + non-overlap when center distance equals half-sum of extents). + """ + N = cell_features.shape[0] + if N <= 1: + return 0.0 + x = cell_features[:, CellFeatureIdx.X] + y = cell_features[:, CellFeatureIdx.Y] + w = cell_features[:, CellFeatureIdx.WIDTH] + h = cell_features[:, CellFeatureIdx.HEIGHT] + dx = torch.abs(x.unsqueeze(1) - x.unsqueeze(0)) + dy = torch.abs(y.unsqueeze(1) - y.unsqueeze(0)) + sep_x = (w.unsqueeze(1) + w.unsqueeze(0)) * 0.5 + sep_y = (h.unsqueeze(1) + h.unsqueeze(0)) * 0.5 + overlaps = (dx < sep_x) & (dy < sep_y) + overlaps.fill_diagonal_(False) + has_any = overlaps.any(dim=1) + return (has_any.sum().float() / N).item() + + +def overlap_repulsion_loss( + cell_features, + pin_features, + edge_list, + mode="fast", +): + """Penalty for axis-aligned cell overlap (differentiable). - Returns: - Scalar loss value (should be 0 when no overlaps exist) + ``pin_features`` and ``edge_list`` are accepted for the same call pattern as + ``wirelength_attraction_loss`` but are not used; overlap is purely geometric. + + Modes: + ``fast``: sum of overlap areas over upper-triangle pairs, divided by the number + of overlapping pairs (stable when few pairs overlap). + ``area`` / ``squared`` / ``both``: mean overlap area or mean squared overlap + area over all unordered pairs (upper triangle only). """ + del pin_features, edge_list + N = cell_features.shape[0] if N <= 1: - return torch.tensor(0.0, requires_grad=True) + z = cell_features.sum() * 0.0 + return z + + x = cell_features[:, CellFeatureIdx.X] + y = cell_features[:, CellFeatureIdx.Y] + w = cell_features[:, CellFeatureIdx.WIDTH] + h = cell_features[:, CellFeatureIdx.HEIGHT] + + xi, xj = x.unsqueeze(1), x.unsqueeze(0) + yi, yj = y.unsqueeze(1), y.unsqueeze(0) + wi, wj = w.unsqueeze(1), w.unsqueeze(0) + hi, hj = h.unsqueeze(1), h.unsqueeze(0) + + min_sep_x = (wi + wj) * 0.5 + min_sep_y = (hi + hj) * 0.5 + dx = torch.abs(xi - xj) + dy = torch.abs(yi - yj) + + mask = torch.triu( + torch.ones((N, N), device=x.device, dtype=x.dtype), + diagonal=1, + ) + + if mode == "fast": + ox = torch.relu(min_sep_x - dx) + oy = torch.relu(min_sep_y - dy) + overlap_area = ox * oy * mask + has_overlap = (overlap_area > 0).to(overlap_area.dtype) + denom = has_overlap.sum() + 1.0 + return overlap_area.sum() / denom + + ox = torch.relu(min_sep_x - dx) + oy = torch.relu(min_sep_y - dy) + overlap_area = ox * oy + pair_areas = overlap_area * mask + num_pairs = N * (N - 1) / 2 + + mean_area = pair_areas.sum() / num_pairs + mean_sq = (pair_areas * pair_areas).sum() / num_pairs + + if mode == "area": + return mean_area + if mode == "squared": + return mean_sq + if mode == "both": + return mean_area + mean_sq + raise ValueError( + f"mode must be 'fast', 'area', 'squared', or 'both', got {mode!r}" + ) - # TODO: Implement overlap detection and loss calculation here - # - # Your implementation should: - # 1. Extract cell positions, widths, and heights - # 2. Compute pairwise overlaps using vectorized operations - # 3. Return a scalar loss that is zero when no overlaps exist - # - # Delete this placeholder and add your implementation: - # Placeholder - returns a constant loss (REPLACE THIS!) - return torch.tensor(1.0, requires_grad=True) +def _lr_cosine_anneal(progress, lr_max, lr_min_frac=0.1): + """Cosine schedule: lr_max at progress=0 → lr_max*lr_min_frac at progress=1.""" + lr_min = lr_max * lr_min_frac + c = math.cos(math.pi * progress) + return lr_min + (lr_max - lr_min) * 0.5 * (1.0 + c) def train_placement( cell_features, pin_features, edge_list, - num_epochs=1000, - lr=0.01, - lambda_wirelength=1.0, - lambda_overlap=10.0, + num_epochs=10000, + lr=0.05, + lambda_wirelength=0.1, + lambda_overlap=50, + overlap_loss_mode="fast", verbose=True, log_interval=100, + loss_plot_path=None, + overlap_ratio_tag_interval=2000, + per_cell_grad_clip_norm=2.44343, ): """Train the placement optimization using gradient descent. @@ -378,17 +395,18 @@ def train_placement( pin_features: [P, 7] tensor with pin properties edge_list: [E, 2] tensor with edge connectivity num_epochs: Number of optimization iterations - lr: Learning rate for Adam optimizer - lambda_wirelength: Weight for wirelength loss - lambda_overlap: Weight for overlap loss + lr: Peak learning rate (Adam); cosine annealing to 0.1×lr over ``num_epochs`` + lambda_wirelength: Weight for wirelength loss term + lambda_overlap: Weight for overlap loss term + overlap_loss_mode: One of ``fast``, ``area``, ``squared``, ``both`` verbose: Whether to print progress log_interval: How often to print progress + loss_plot_path: If set, save loss-vs-epoch figure (requires matplotlib) + overlap_ratio_tag_interval: Tag overlap ratio on plot every N epochs (0=off) + per_cell_grad_clip_norm: Max L2 norm per cell for position gradients Returns: - Dictionary with: - - final_cell_features: Optimized cell positions - - initial_cell_features: Original cell positions (for comparison) - - loss_history: Loss values over time + Dictionary with final/initial features, loss_history, lambdas, num_epochs. """ # Clone features and create learnable positions cell_features = cell_features.clone() @@ -401,15 +419,28 @@ def train_placement( # Create optimizer optimizer = optim.Adam([cell_positions], lr=lr) - # Track loss history + lambda_wl_t = lambda_wirelength + lambda_ol_t = lambda_overlap + span = max(num_epochs - 1, 1) + + # Track loss history (raw losses + weighted objective terms for debugging) loss_history = { "total_loss": [], "wirelength_loss": [], "overlap_loss": [], + "weighted_wirelength": [], + "weighted_overlap": [], + "overlap_share_of_total": [], + "overlap_ratio_tags": [], + "scheduled_lambda_wl": [], + "scheduled_lambda_ol": [], + "learning_rate": [], } - # Training loop for epoch in range(num_epochs): + lr_t = _lr_cosine_anneal(epoch / span, lr) + optimizer.param_groups[0]["lr"] = lr_t + optimizer.zero_grad() # Create cell_features with current positions @@ -421,45 +452,101 @@ def train_placement( cell_features_current, pin_features, edge_list ) overlap_loss = overlap_repulsion_loss( - cell_features_current, pin_features, edge_list + cell_features_current, + pin_features, + edge_list, + mode=overlap_loss_mode, ) - # Combined loss - total_loss = lambda_wirelength * wl_loss + lambda_overlap * overlap_loss + # Combined loss: total = λ_wl * L_wl + λ_ol * L_ol + weighted_wl = lambda_wl_t * wl_loss + weighted_ol = lambda_ol_t * overlap_loss + total_loss = weighted_wl + weighted_ol # Backward pass total_loss.backward() - # Gradient clipping to prevent extreme updates - torch.nn.utils.clip_grad_norm_([cell_positions], max_norm=5.0) + # Per-cell gradient clipping (L2 norm per row) + if cell_positions.grad is not None and per_cell_grad_clip_norm is not None: + g = cell_positions.grad + gn = g.norm(dim=1, keepdim=True).clamp(min=1e-8) + scale = torch.clamp( + per_cell_grad_clip_norm / gn, max=1.0 + ) + g.mul_(scale) + + is_last_epoch = epoch == num_epochs - 1 + + # Overlap ratio tags for plots (same layout as loss this epoch) + if overlap_ratio_tag_interval and ( + epoch % overlap_ratio_tag_interval == 0 or is_last_epoch + ): + with torch.no_grad(): + cf_tag = cell_features.clone() + cf_tag[:, 2:4] = cell_positions.detach() + o_ratio = _fast_overlap_ratio(cf_tag) + loss_history["overlap_ratio_tags"].append( + {"epoch": epoch, "overlap_ratio": o_ratio} + ) # Update positions optimizer.step() # Record losses - loss_history["total_loss"].append(total_loss.item()) + t_val = total_loss.item() + w_wl = weighted_wl.item() + w_ol = weighted_ol.item() + loss_history["total_loss"].append(t_val) loss_history["wirelength_loss"].append(wl_loss.item()) loss_history["overlap_loss"].append(overlap_loss.item()) + loss_history["weighted_wirelength"].append(w_wl) + loss_history["weighted_overlap"].append(w_ol) + loss_history["scheduled_lambda_wl"].append(lambda_wl_t) + loss_history["scheduled_lambda_ol"].append(lambda_ol_t) + loss_history["learning_rate"].append(lr_t) + if t_val > 1e-12: + loss_history["overlap_share_of_total"].append(w_ol / t_val) + else: + loss_history["overlap_share_of_total"].append(float("nan")) # Log progress - if verbose and (epoch % log_interval == 0 or epoch == num_epochs - 1): - print(f"Epoch {epoch}/{num_epochs}:") - print(f" Total Loss: {total_loss.item():.6f}") - print(f" Wirelength Loss: {wl_loss.item():.6f}") - print(f" Overlap Loss: {overlap_loss.item():.6f}") + if verbose and (epoch % log_interval == 0 or is_last_epoch): + ol_pct = ( + 100.0 * w_ol / t_val if t_val > 1e-12 else float("nan") + ) + print(f"Epoch {epoch}/{num_epochs} lr={lr_t:.6g}") + print(f" Total objective: {t_val:.6f} (= λ_wl·L_wl + λ_ol·L_ol)") + print( + f" λ_wl·L_wl (wirelength term): {w_wl:.6f} | raw L_wl: {wl_loss.item():.6f} (λ_wl={lambda_wl_t:.6g})" + ) + print( + f" λ_ol·L_ol (overlap term): {w_ol:.6f} | raw L_ol: {overlap_loss.item():.6f} (λ_ol={lambda_ol_t:.6g})" + ) + print(f" Overlap share of objective: {ol_pct:.1f}%") # Create final cell features final_cell_features = cell_features.clone() final_cell_features[:, 2:4] = cell_positions.detach() - return { + result = { "final_cell_features": final_cell_features, "initial_cell_features": initial_cell_features, "loss_history": loss_history, + "lambda_wirelength": lambda_wirelength, + "lambda_overlap": lambda_overlap, + "num_epochs": num_epochs, } + if loss_plot_path: + plot_training_loss_curves( + loss_history, + lambda_wirelength, + lambda_overlap, + loss_plot_path, + ) + return result -# ======= FINAL EVALUATION CODE (Don't edit this part) ======= +# ======= EVALUATION & VISUALIZATION ======= def calculate_overlap_metrics(cell_features): """Calculate ground truth overlap statistics (non-differentiable). @@ -705,15 +792,155 @@ def plot_placement( print(f"Could not create visualization: {e}") print("Install matplotlib to enable visualization: pip install matplotlib") + +def plot_training_loss_curves( + loss_history, + lambda_wirelength, + lambda_overlap, + filename="training_loss_curves.png", +): + """Plot objective and per-term losses vs epoch (uses matplotlib if available). + + ``loss_history`` is ``result["loss_history"]`` from ``train_placement``. The + figure uses ``total_loss``, raw and weighted wirelength/overlap terms, + ``overlap_share_of_total``, and optional ``overlap_ratio_tags`` for point + annotations. Fields like ``learning_rate`` are recorded in the same dict but + not plotted. + """ + try: + import matplotlib.pyplot as plt + import numpy as np + + def _positive_log_y(values): + a = np.asarray(values, dtype=np.float64) + return np.where(np.isfinite(a) & (a > 0), a, np.nan) + + epochs = range(len(loss_history["total_loss"])) + if not epochs: + return + + fig, axes = plt.subplots(2, 2, figsize=(12, 8)) + fig.suptitle( + f"Training curves (λ_wl={lambda_wirelength}, λ_ol={lambda_overlap})", + fontsize=12, + ) + + ax = axes[0, 0] + total_curve = loss_history["total_loss"] + total_curve_p = _positive_log_y(total_curve) + ax.plot(epochs, total_curve_p, color="black", linewidth=1.2) + ax.set_xlabel("Epoch") + ax.set_ylabel("Total objective (log scale)") + ax.set_title("Total loss = λ_wl·L_wl + λ_ol·L_ol") + ax.set_yscale("log") + ax.grid(True, which="major", alpha=0.3) + ax.grid(True, which="minor", alpha=0.15, linestyle=":") + + for tag in loss_history.get("overlap_ratio_tags") or []: + e = tag["epoch"] + if e < 0 or e >= len(total_curve): + continue + y = total_curve[e] + if not (np.isfinite(y) and y > 0): + continue + r = tag["overlap_ratio"] + ax.annotate( + f"ol={r:.2f}", + xy=(e, y), + xytext=(6, 10), + textcoords="offset points", + fontsize=7, + ha="left", + bbox=dict(boxstyle="round,pad=0.25", facecolor="wheat", alpha=0.85), + arrowprops=dict( + arrowstyle="-", + color="saddlebrown", + lw=0.8, + shrinkA=0, + shrinkB=0, + ), + ) + + ax = axes[0, 1] + ax.plot( + epochs, + _positive_log_y(loss_history["weighted_wirelength"]), + label="λ_wl·L_wl (wirelength)", + color="tab:blue", + ) + ax.plot( + epochs, + _positive_log_y(loss_history["weighted_overlap"]), + label="λ_ol·L_ol (overlap)", + color="tab:orange", + ) + ax.set_xlabel("Epoch") + ax.set_ylabel("Weighted term value (log scale)") + ax.set_title("Contribution to objective (same units as total)") + ax.set_yscale("log") + ax.legend(loc="upper right", fontsize=8) + ax.grid(True, which="major", alpha=0.3) + ax.grid(True, which="minor", alpha=0.15, linestyle=":") + + ax = axes[1, 0] + ax.plot( + epochs, + _positive_log_y(loss_history["wirelength_loss"]), + label="L_wl (raw)", + color="tab:cyan", + ) + ax.plot( + epochs, + _positive_log_y(loss_history["overlap_loss"]), + label="L_ol (raw)", + color="tab:red", + ) + ax.set_xlabel("Epoch") + ax.set_ylabel("Unweighted loss (log scale)") + ax.set_title("Raw losses (before λ scaling)") + ax.set_yscale("log") + ax.legend(loc="upper right", fontsize=8) + ax.grid(True, which="major", alpha=0.3) + ax.grid(True, which="minor", alpha=0.15, linestyle=":") + + ax = axes[1, 1] + ax.plot( + epochs, + _positive_log_y(loss_history["overlap_share_of_total"]), + color="tab:purple", + linewidth=1.2, + ) + ax.set_xlabel("Epoch") + ax.set_ylabel("Fraction of total (log scale)") + ax.set_title("Overlap term share: (λ_ol·L_ol) / total") + ax.set_yscale("log") + ax.set_ylim(bottom=1e-4, top=1.2) + ax.grid(True, which="major", alpha=0.3) + ax.grid(True, which="minor", alpha=0.15, linestyle=":") + + plt.tight_layout() + output_path = ( + filename + if os.path.isabs(filename) + else os.path.join(OUTPUT_DIR, filename) + ) + plt.savefig(output_path, dpi=150, bbox_inches="tight") + plt.close() + print(f"Saved training loss curves to {output_path}") + + except ImportError as e: + print(f"Could not plot training curves: {e}") + print("Install matplotlib: pip install matplotlib") + + # ======= MAIN FUNCTION ======= def main(): - """Main function demonstrating the placement optimization challenge.""" + """Run a sample placement: random netlist, optimize, print metrics, save figures.""" print("=" * 70) - print("VLSI CELL PLACEMENT OPTIMIZATION CHALLENGE") + print("VLSI CELL PLACEMENT OPTIMIZATION") print("=" * 70) - print("\nObjective: Implement overlap_repulsion_loss() to eliminate cell overlaps") - print("while minimizing wirelength.\n") + print("\nObjective: minimize overlap and wirelength via gradient descent.\n") # Set random seed for reproducibility torch.manual_seed(42) @@ -760,6 +987,7 @@ def main(): edge_list, verbose=True, log_interval=200, + loss_plot_path="training_loss_curves.png", ) # Calculate final metrics (both detailed and normalized) @@ -793,15 +1021,15 @@ def main(): if normalized_metrics["num_cells_with_overlaps"] == 0: print("✓ PASS: No overlapping cells!") print("✓ PASS: Overlap ratio is 0.0") - print("\nCongratulations! Your implementation successfully eliminated all overlaps.") + print("\nPlacement reached zero overlapping cells.") print(f"Your normalized wirelength: {normalized_metrics['normalized_wl']:.4f}") else: print("✗ FAIL: Overlaps still exist") print(f" Need to eliminate overlaps in {normalized_metrics['num_cells_with_overlaps']} cells") print("\nSuggestions:") - print(" 1. Check your overlap_repulsion_loss() implementation") - print(" 2. Change lambdas (try increasing lambda_overlap)") - print(" 3. Change learning rate or number of epochs") + print(" 1. Increase lambda_overlap or try overlap_loss_mode (e.g. 'area', 'both')") + print(" 2. Adjust learning rate, num_epochs, or per_cell_grad_clip_norm") + print(" 3. Change initial spread or netlist size for the demo run") # Generate visualization plot_placement( diff --git a/test.py b/test.py index f22ff21..09a1206 100644 --- a/test.py +++ b/test.py @@ -46,8 +46,8 @@ (9, 8, 200, 1009), (10, 10, 2000, 1010), # Realistic designs - (11, 10, 10000, 1011), - (12, 10, 100000, 1012), + #(11, 10, 10000, 1011), + #(12, 10, 100000, 1012), ] diff --git a/tune_optuna.py b/tune_optuna.py new file mode 100644 index 0000000..4359b67 --- /dev/null +++ b/tune_optuna.py @@ -0,0 +1,203 @@ +#!/usr/bin/env python3 +""" +Hyperparameter search for ``train_placement`` using Optuna (optional dependency). + +See ``--help`` and the epilog for usage. Requires: pip install optuna +""" + +from __future__ import annotations + +import argparse +import sys + +import torch + +from placement import ( + calculate_normalized_metrics, + generate_placement_input, + train_placement, +) + + +def build_fixed_problem(num_macros: int, num_std_cells: int, seed: int): + """Same netlist + radial spread as ``test.py`` (deterministic for ``seed``).""" + torch.manual_seed(seed) + cell_features, pin_features, edge_list = generate_placement_input( + num_macros, num_std_cells + ) + cell_features = cell_features.clone() + total_cells = cell_features.shape[0] + total_area = cell_features[:, 0].sum().item() + spread_radius = (total_area**0.5) * 0.6 + angles = torch.rand(total_cells) * 2 * 3.14159 + radii = torch.rand(total_cells) * spread_radius + cell_features[:, 2] = radii * torch.cos(angles) + cell_features[:, 3] = radii * torch.sin(angles) + return cell_features, pin_features, edge_list + + +def parse_args(): + p = argparse.ArgumentParser( + description=( + "Search hyperparameters for train_placement. Minimizes normalized " + "wirelength on one fixed netlist + initial spread; trials with overlaps " + "get a configurable penalty." + ), + epilog=( + "Install Optuna: pip install optuna\n\n" + "Examples:\n" + " python tune_optuna.py --n-trials 30\n" + " python tune_optuna.py --n-trials 100 --num-macros 3 " + "--num-std-cells 50 --seed 1004\n" + " python tune_optuna.py --n-trials 50 --storage sqlite:///optuna.db " + "--study-name placement" + ), + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + p.add_argument("--n-trials", type=int, default=30, help="Number of Optuna trials") + p.add_argument( + "--num-macros", + type=int, + default=3, + help="Macros for the fixed validation instance", + ) + p.add_argument( + "--num-std-cells", + type=int, + default=50, + help="Standard cells for the fixed validation instance", + ) + p.add_argument( + "--seed", + type=int, + default=1004, + help="Torch seed for netlist + initial placement (match a test.py case)", + ) + p.add_argument( + "--study-name", + type=str, + default="placement_tune", + help="Optuna study name (used with --storage)", + ) + p.add_argument( + "--storage", + type=str, + default=None, + help="Optuna storage URL, e.g. sqlite:///optuna.db (default: in-memory)", + ) + p.add_argument( + "--epochs-low", + type=int, + default=2000, + help="Minimum num_epochs to search", + ) + p.add_argument( + "--epochs-high", + type=int, + default=10000, + help="Maximum num_epochs to search", + ) + p.add_argument( + "--epochs-step", + type=int, + default=1000, + help="Step for num_epochs integer suggestions", + ) + p.add_argument( + "--overlap-penalty", + type=float, + default=1e6, + help="Base penalty when any overlap remains (adds overlap_ratio on top)", + ) + p.add_argument( + "--quiet-optuna", + action="store_true", + help="Turn off Optuna info logs", + ) + return p.parse_args() + + +def main(): + args = parse_args() + if args.epochs_low > args.epochs_high or args.epochs_step < 1: + print("Invalid epoch bounds or step.", file=sys.stderr) + sys.exit(2) + + try: + import optuna + from optuna.trial import TrialState + except ImportError: # pragma: no cover - optional dependency + print( + "Optuna is not installed. Install with:\n pip install optuna", + file=sys.stderr, + ) + sys.exit(1) + + if args.quiet_optuna: + optuna.logging.set_verbosity(optuna.logging.WARNING) + + cell_features, pin_features, edge_list = build_fixed_problem( + args.num_macros, args.num_std_cells, args.seed + ) + + def objective(trial: optuna.Trial) -> float: + lr = trial.suggest_float("lr", 1e-4, 1e-1, log=True) + lambda_wl = trial.suggest_float("lambda_wirelength", 1e-3, 1.0, log=True) + lambda_ol = trial.suggest_float("lambda_overlap", 1.0, 200.0, log=True) + num_epochs = trial.suggest_int( + "num_epochs", + args.epochs_low, + args.epochs_high, + step=args.epochs_step, + ) + overlap_mode = trial.suggest_categorical( + "overlap_loss_mode", + ["area", "fast"], + ) + clip = trial.suggest_float("per_cell_grad_clip_norm", 1.0, 20.0, log=True) + + result = train_placement( + cell_features, + pin_features, + edge_list, + num_epochs=num_epochs, + lr=lr, + lambda_wirelength=lambda_wl, + lambda_overlap=lambda_ol, + overlap_loss_mode=overlap_mode, + per_cell_grad_clip_norm=clip, + verbose=False, + ) + metrics = calculate_normalized_metrics( + result["final_cell_features"], + pin_features, + edge_list, + ) + if metrics["num_cells_with_overlaps"] > 0: + return args.overlap_penalty + metrics["overlap_ratio"] + return metrics["normalized_wl"] + + study = optuna.create_study( + study_name=args.study_name, + storage=args.storage, + direction="minimize", + load_if_exists=bool(args.storage), + ) + study.optimize(objective, n_trials=args.n_trials, show_progress_bar=True) + + completed = study.get_trials(deepcopy=False, states=(TrialState.COMPLETE,)) + print() + print("=" * 60) + print(f"Study: {args.study_name} | completed trials: {len(completed)}") + if study.best_trial is not None: + print(f"Best value (normalized WL or penalty): {study.best_value:.6g}") + print("Best params:") + for k, v in sorted(study.best_params.items()): + print(f" {k}: {v}") + else: + print("No completed trials.") + print("=" * 60) + + +if __name__ == "__main__": + main() From c478a41159c470667008972537cda18d10aaaae3 Mon Sep 17 00:00:00 2001 From: Oscar Mattia Date: Sat, 4 Apr 2026 09:21:18 -0700 Subject: [PATCH 2/3] Leaderboard entry, ONBOARDING sync, test aggregate report, remove lab_notebook Made-with: Cursor --- ONBOARDING.md | 16 ++++++++-------- README.md | 35 +++++++++++++++++++---------------- lab_notebook.md | 39 --------------------------------------- test.py | 30 ++++++++++++++++++++++-------- 4 files changed, 49 insertions(+), 71 deletions(-) delete mode 100644 lab_notebook.md diff --git a/ONBOARDING.md b/ONBOARDING.md index 630d018..284a31a 100644 --- a/ONBOARDING.md +++ b/ONBOARDING.md @@ -13,7 +13,7 @@ The library optimizes **2D positions** of rectangular **cells** (macros and stan **Convention:** Each cell is an axis-aligned rectangle **centered** at `(x, y)` with given `width` and `height`. Overlap between two cells is computed from center-to-center separation versus half-widths and half-heights (same criterion everywhere: strict separation when `|dx| == (w_i + w_j)/2` is treated as non-overlapping in the vectorized checks via `<`). -Pins have positions **relative** to the cell corner in the stored features, but **wirelength loss** recomputes absolute pin coordinates as `cell_center + relative_offset` each forward pass, so moving `cell_features[:, 2:4]` is sufficient for optimization. +In **`generate_placement_input`**, pin `PIN_X` / `PIN_Y` are sampled in **cell-local** coordinates from the cell’s **lower-left** (between margins and the cell width/height). **`wirelength_attraction_loss`** forms world coordinates as **`cell_center + (PIN_X, PIN_Y)`** (same columns each step), so only `cell_features[:, 2:4]` is optimized; be aware this mixes “center + offset” with offsets that were generated from the lower-left frame. --- @@ -92,7 +92,7 @@ Only columns **2–3** receive gradients during `train_placement`; other columns | 5 | `WIDTH` | Pin width (e.g. 0.1). | | 6 | `HEIGHT` | Pin height. | -**Note:** `wirelength_attraction_loss` builds absolute positions from **cell centers + columns 1–2**, not from columns 3–4. +**Note:** `wirelength_attraction_loss` uses **cell centers + columns 1–2** (not columns 3–4, which are not kept in sync during training). ### `edge_list` — shape `[E, 2]`, `dtype` long @@ -119,8 +119,8 @@ Implementation mixes vectorized tensor ops with Python loops over cells/pins for | | | |--|--| | **Input** | Full feature tensors and edge list. | -| **Output** | Scalar `torch.Tensor` (mean smooth Manhattan distance per edge). | -| **Utility** | Differentiable **wirelength proxy**: gathers pin absolutes via `cell_positions[cell_indices] + offsets`, then per-edge smooth L1 / log-sum-exp Manhattan with smoothing parameter `alpha = 0.1`. Returns **0** with `requires_grad=True` if `E == 0`. | +| **Output** | Scalar `torch.Tensor`: mean per-edge cost (sum of edge terms divided by `E`). | +| **Utility** | Differentiable **wirelength proxy**: absolute pins = `cell_positions[cell_indices] + pin_features[:, 1:3]`; for each edge, nonnegative `dx, dy` from `abs` differences; **`alpha * logsumexp([dx/α, dy/α])`** on the two axes (a smooth **maximum**-like blend of the separations, not `dx + dy`). `alpha = 0.1`. Returns **0** with `requires_grad=True` if `E == 0`. | **Vectorization:** Indexing `pin_absolute_*` by `src_pins` and `tgt_pins` avoids Python loops over edges. @@ -162,9 +162,9 @@ Implementation mixes vectorized tensor ops with Python loops over cells/pins for | | | |--|--| -| **Input** | Initial features and graph; hyperparameters: `num_epochs`, `lr`, `lambda_wirelength`, `lambda_overlap`, `overlap_loss_mode`, `verbose`, `log_interval`, optional `loss_plot_path`, `overlap_ratio_tag_interval`, `per_cell_grad_clip_norm`. | -| **Output** | `dict` with `final_cell_features`, `initial_cell_features`, `loss_history` (lists per epoch + optional overlap ratio tags), `lambda_wirelength`, `lambda_overlap`, `num_epochs`. | -| **Utility** | Runs **Adam** only on `cell_positions` clone. Each step: cosine LR; forward = weighted sum `λ_wl * L_wl + λ_ol * L_ol`; backward; optional **per-cell** L2 grad clip (row-wise norm, scale capped at 1); optimizer step. Writes loss plot if path given. | +| **Input** | Initial features and graph. Defaults (overridable): `num_epochs=10000`, `lr=0.05`, `lambda_wirelength=0.1`, `lambda_overlap=50`, `overlap_loss_mode="fast"`, `verbose=True`, `log_interval=100`, optional `loss_plot_path`, `overlap_ratio_tag_interval=2000`, `per_cell_grad_clip_norm=2.44343` (or `None` to disable clipping). | +| **Output** | `dict` with `final_cell_features`, `initial_cell_features`, `loss_history`, `lambda_wirelength`, `lambda_overlap`, `num_epochs`. | +| **Utility** | Runs **Adam** on a detached `cell_positions` tensor (columns 2–3 only). Each epoch: **`_lr_cosine_anneal(epoch/span, lr)`** sets the optimizer LR (`span = max(num_epochs-1, 1)`). **`λ_wl` and `λ_ol` are fixed** for the whole run (`lambda_wirelength`, `lambda_overlap`). Forward: `total_loss = λ_wl * L_wl + λ_ol * L_ol`; backward; optional **per-cell** L2 grad clip; `optimizer.step()`. `loss_history` records raw/weighted losses, **constant** `scheduled_lambda_wl` / `scheduled_lambda_ol`, `learning_rate`, and optional `overlap_ratio_tags` when `overlap_ratio_tag_interval` is nonzero. Optional loss figure via `plot_training_loss_curves`. | **Training graph:** `cell_features` is cloned; each epoch builds `cell_features_current` by copying and injecting `cell_positions` into columns 2–3, so the backward path flows into `cell_positions` only. @@ -266,7 +266,7 @@ Nested helper `_positive_log_y` masks non-positive/non-finite values for log axe | File | Role | |------|------| -| [`test.py`](test.py) | Batch runs `TEST_CASES` with `train_placement(..., verbose=False)` and aggregates timing and normalized metrics. | +| [`test.py`](test.py) | Batch runs `TEST_CASES` with `train_placement(..., verbose=False)`; prints per-test metrics and an **aggregate** block (average overlap, average normalized wirelength, total runtime) plus a one-line summary at the end. | | [`tune_optuna.py`](tune_optuna.py) | Hyperparameter search (external to core library API). | | [`README.md`](README.md) | Challenge statement and leaderboard. | diff --git a/README.md b/README.md index cf27bfb..f6006e0 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,8 @@ We will review submissions on a rolling basis. ## Leaderboard (sorted by overlap) + + | Rank | Name | Overlap | Wirelength (um) | Runtime (s) | Notes | |------|-----------------|-------------|-----------------|-------------|----------------------| | 1 | Brayden Rudisill | 0.0000 | 0.2611 | 50.51 | Timed on a mac air | @@ -38,23 +40,24 @@ We will review submissions on a rolling basis. | 5 | William Pan | 0.0000 | 0.2848 | 155.33s | | | 6 | Ashmit Dutta | 0.0000 | 0.2870 | 995.58 | Spent my entire morning (12 am - 6 am) doing this :P | | 7 | Pawan Paleja | 0.0000 | 0.3311 | 1.74s | Implemented hint for loss func, cosine annealing on learning rate with warmup, std annealing on lambda weight. Used optuna to tune hyperparam. Tested on gh codespaces 2-core. | - 8 | Shashank Shriram | 0.0000 | 0.3312 | 11.32 | 🏎️💥 | +| 8 | Shashank Shriram | 0.0000 | 0.3312 | 11.32 | 🏎️💥 | | 9 | Gabriel Del Monte | 0.0000 | 0.3427 | 606.07 | | -| 10 | Aleksey Valouev| 0.0000 | 0.3577 | 118.98 | | -| 11 | Mohul Shukla | 0.0000 | 0.5048 | 54.60s | | -| 12 | Ryan Hulke | 0.0000 | 0.5226 | 166.24 | | -| 13 | Neel Shah | 0.0000 | 0.5445 | 45.40 | Zero overlaps on all tests, adaptive schedule + early stop | -| 14 | Nawel Asgar | 0.0000 | 0.5675 | 81.49 | Adaptive penalty scaling with cubic gradients and design-size optimization -| 15 | Shiva Baghel | 0.0000 | 0.5885 | 491.00 | Stable zero-overlap with balanced optimization | -| 16 | Vansh Jain | 0.0000 | 0.9352 | 86.36 | | -| 17 | Akash Pai | 0.0006 | 0.4933 | 326.25s | | -| 18 | Zade Mahayni | 0.00665 | 0.5157 | 127.4 | Will try again tomorrow | -| 19 | Nithin Yanna | 0.0148 | 0.5034 | 247.30s | aggressive overlap penalty with quadratic scaling | -| 20 | Sean Ko | 0.0271 | .5138 | 31.83s | lr increase, decrease epoch, increase lambda overlap and decreased lambda wire_length + log penalty loss | -| 21 | Keya Gohil | 0.0155 | 0.4678 | 1513.07 | Still working | -| 22 | Prithvi Seran | 0.0499 | 0.4890 | 398.58 | | -| 23 | partcl example | 0.8 | 0.4 | 5 | example | -| 24 | Add Yours! | | | | | +| 10 | Aleksey Valouev| 0.0000 | 0.3577 | 118.98 | | +| 11 | Oscar Mattia | 0.0000 | 0.4933 | 580.66 | ran on macbookair m1 base model, developed on an early morning flight :) | +| 12 | Mohul Shukla | 0.0000 | 0.5048 | 54.60s | | +| 13 | Ryan Hulke | 0.0000 | 0.5226 | 166.24 | | +| 14 | Neel Shah | 0.0000 | 0.5445 | 45.40 | Zero overlaps on all tests, adaptive schedule + early stop | +| 15 | Nawel Asgar | 0.0000 | 0.5675 | 81.49 | Adaptive penalty scaling with cubic gradients and design-size optimization | +| 16 | Shiva Baghel | 0.0000 | 0.5885 | 491.00 | Stable zero-overlap with balanced optimization | +| 17 | Vansh Jain | 0.0000 | 0.9352 | 86.36 | | +| 18 | Akash Pai | 0.0006 | 0.4933 | 326.25s | | +| 19 | Zade Mahayni | 0.00665 | 0.5157 | 127.4 | Will try again tomorrow | +| 20 | Nithin Yanna | 0.0148 | 0.5034 | 247.30s | aggressive overlap penalty with quadratic scaling | +| 21 | Sean Ko | 0.0271 | .5138 | 31.83s | lr increase, decrease epoch, increase lambda overlap and decreased lambda wire_length + log penalty loss | +| 22 | Keya Gohil | 0.0155 | 0.4678 | 1513.07 | Still working | +| 23 | Prithvi Seran | 0.0499 | 0.4890 | 398.58 | | +| 24 | partcl example | 0.8 | 0.4 | 5 | example | +| 25 | Add Yours! | | | | | > **To add your results:** > Insert a new row in the table above with your name, overlap, wirelength, and any notes. Ensure you sort by overlap. diff --git a/lab_notebook.md b/lab_notebook.md deleted file mode 100644 index c9fe649..0000000 --- a/lab_notebook.md +++ /dev/null @@ -1,39 +0,0 @@ -# Lab notebook — placement overlap / training - -Concise log of what was tried and measured. Re-run `python test.py` for full leaderboard-style numbers. - -## Approaches tried - -| Approach | Notes | -|----------|--------| -| **Baseline relu overlap** | Pairwise `relu(min_sep − \|Δ\|)` overlap area, mean over all pairs; overlap term often ~100% of objective vs wirelength. | -| **Legacy modes** | `area`, `squared`, `both` (mean over all pairs); selectable via `overlap_loss_mode`. | -| **Fast loss (`mode="fast"`, default)** | `relu` overlap area sum ÷ (number of overlapping pairs + 1). Stronger gradients than mean over all pairs when few pairs overlap. | -| **Per-cell grad clip** | Clip L2 norm **per cell** on position grads (`per_cell_grad_clip_norm`), instead of global `clip_grad_norm_`. | -| **Loss diagnostics** | `loss_history`: weighted WL / OL, overlap share, scheduled λ, `lr`. Plots: overlap-ratio tags. Overlap checks use `_fast_overlap_ratio` (torch, no Python pair loops). | -| **Overlap sweep script** | `plot_overlap_loss_vs_cells.py`: overlap loss vs N (2…50) for heights 1,2,3 (`overlap_loss_vs_num_cells.png`). | - -## Hyperparameters (see `train_placement` defaults in `placement.py`) - -## Test results (harness `test.run_placement_test`, seeds from `TEST_CASES`) - -Recorded on one local run (macOS / project env). **Overlap target:** `num_cells_with_overlaps == 0`. - -| Test id | Macros × std cells | Overlap ratio | Cells w/ overlap | Time (s) | Result | -|--------:|---------------------|---------------|------------------|----------|--------| -| 1 | 2 × 20 | 0.0 | 0 / 22 | ~5.6 | PASS | -| 2 | 3 × 25 | 0.0 | 0 / 28 | ~6.5 | PASS | -| 3 | 2 × 30 | 0.0 | 0 / 32 | ~6.0 | PASS | -| 4–10 | — | — | — | — | *Re-run `python test.py` (test 10: 2010 cells × 10k epochs, long)* | - -## Failed / partial experiments (historical) - -- **λ_ol=10, legacy loss, short epochs:** overlap ratio often remained > 0; needed more epochs and/or higher λ_ol. - -## Commands - -```bash -python placement.py # demo + placement_result.png + training_loss_curves.png if enabled -python test.py # full suite (12 cases; 11–12 extra credit / very large) -python plot_overlap_loss_vs_cells.py -``` diff --git a/test.py b/test.py index 09a1206..ac620aa 100644 --- a/test.py +++ b/test.py @@ -6,15 +6,15 @@ of various sizes and reports metrics for leaderboard submission. Usage: - python test_placement.py + python test.py Metrics Reported: - Average Overlap: (num cells with overlaps / total num cells) - Average Wirelength: (total wirelength / num nets) / sqrt(total area) This normalization allows fair comparison across different design sizes. -Note: This test uses the default hyperparameters from train_placement() in -vb_playground.py. The challenge is to implement the overlap loss function, +Note: This test uses the default hyperparameters from ``train_placement()`` in +``placement.py``. The challenge is to implement the overlap loss function, not to tune hyperparameters. """ @@ -169,13 +169,27 @@ def run_all_tests(): avg_normalized_wl = sum(r["normalized_wl"] for r in all_results) / len(all_results) total_time = sum(r["elapsed_time"] for r in all_results) - # Print aggregate results + # Print aggregate results (leaderboard submission metrics) print("=" * 70) - print("FINAL RESULTS") + print("AGGREGATE RESULTS (all tests)") + print("=" * 70) + print() + print(" Average Overlap: ", f"{avg_overlap_ratio:.4f}") + print(" (mean overlap ratio = cells with any overlap / total cells)") + print() + print(" Average Wirelength: ", f"{avg_normalized_wl:.4f}") + print(" (mean normalized wirelength: (WL / #nets) / sqrt(total area))") + print() + print(" Total Runtime: ", f"{total_time:.2f}s") + print(" (sum of per-test optimization time)") + print() + print("-" * 70) + print( + "Summary — Average Overlap: " + f"{avg_overlap_ratio:.4f} | Average Wirelength: " + f"{avg_normalized_wl:.4f} | Total Runtime: {total_time:.2f}s" + ) print("=" * 70) - print(f"Average Overlap: {avg_overlap_ratio:.4f}") - print(f"Average Wirelength: {avg_normalized_wl:.4f}") - print(f"Total Runtime: {total_time:.2f}s") print() return { From 8f4306f1f85c16c0624bf17de20ab09b13c708e4 Mon Sep 17 00:00:00 2001 From: Oscar Mattia Date: Sat, 4 Apr 2026 09:22:52 -0700 Subject: [PATCH 3/3] delete optuna tuning script, unused --- tune_optuna.py | 203 ------------------------------------------------- 1 file changed, 203 deletions(-) delete mode 100644 tune_optuna.py diff --git a/tune_optuna.py b/tune_optuna.py deleted file mode 100644 index 4359b67..0000000 --- a/tune_optuna.py +++ /dev/null @@ -1,203 +0,0 @@ -#!/usr/bin/env python3 -""" -Hyperparameter search for ``train_placement`` using Optuna (optional dependency). - -See ``--help`` and the epilog for usage. Requires: pip install optuna -""" - -from __future__ import annotations - -import argparse -import sys - -import torch - -from placement import ( - calculate_normalized_metrics, - generate_placement_input, - train_placement, -) - - -def build_fixed_problem(num_macros: int, num_std_cells: int, seed: int): - """Same netlist + radial spread as ``test.py`` (deterministic for ``seed``).""" - torch.manual_seed(seed) - cell_features, pin_features, edge_list = generate_placement_input( - num_macros, num_std_cells - ) - cell_features = cell_features.clone() - total_cells = cell_features.shape[0] - total_area = cell_features[:, 0].sum().item() - spread_radius = (total_area**0.5) * 0.6 - angles = torch.rand(total_cells) * 2 * 3.14159 - radii = torch.rand(total_cells) * spread_radius - cell_features[:, 2] = radii * torch.cos(angles) - cell_features[:, 3] = radii * torch.sin(angles) - return cell_features, pin_features, edge_list - - -def parse_args(): - p = argparse.ArgumentParser( - description=( - "Search hyperparameters for train_placement. Minimizes normalized " - "wirelength on one fixed netlist + initial spread; trials with overlaps " - "get a configurable penalty." - ), - epilog=( - "Install Optuna: pip install optuna\n\n" - "Examples:\n" - " python tune_optuna.py --n-trials 30\n" - " python tune_optuna.py --n-trials 100 --num-macros 3 " - "--num-std-cells 50 --seed 1004\n" - " python tune_optuna.py --n-trials 50 --storage sqlite:///optuna.db " - "--study-name placement" - ), - formatter_class=argparse.RawDescriptionHelpFormatter, - ) - p.add_argument("--n-trials", type=int, default=30, help="Number of Optuna trials") - p.add_argument( - "--num-macros", - type=int, - default=3, - help="Macros for the fixed validation instance", - ) - p.add_argument( - "--num-std-cells", - type=int, - default=50, - help="Standard cells for the fixed validation instance", - ) - p.add_argument( - "--seed", - type=int, - default=1004, - help="Torch seed for netlist + initial placement (match a test.py case)", - ) - p.add_argument( - "--study-name", - type=str, - default="placement_tune", - help="Optuna study name (used with --storage)", - ) - p.add_argument( - "--storage", - type=str, - default=None, - help="Optuna storage URL, e.g. sqlite:///optuna.db (default: in-memory)", - ) - p.add_argument( - "--epochs-low", - type=int, - default=2000, - help="Minimum num_epochs to search", - ) - p.add_argument( - "--epochs-high", - type=int, - default=10000, - help="Maximum num_epochs to search", - ) - p.add_argument( - "--epochs-step", - type=int, - default=1000, - help="Step for num_epochs integer suggestions", - ) - p.add_argument( - "--overlap-penalty", - type=float, - default=1e6, - help="Base penalty when any overlap remains (adds overlap_ratio on top)", - ) - p.add_argument( - "--quiet-optuna", - action="store_true", - help="Turn off Optuna info logs", - ) - return p.parse_args() - - -def main(): - args = parse_args() - if args.epochs_low > args.epochs_high or args.epochs_step < 1: - print("Invalid epoch bounds or step.", file=sys.stderr) - sys.exit(2) - - try: - import optuna - from optuna.trial import TrialState - except ImportError: # pragma: no cover - optional dependency - print( - "Optuna is not installed. Install with:\n pip install optuna", - file=sys.stderr, - ) - sys.exit(1) - - if args.quiet_optuna: - optuna.logging.set_verbosity(optuna.logging.WARNING) - - cell_features, pin_features, edge_list = build_fixed_problem( - args.num_macros, args.num_std_cells, args.seed - ) - - def objective(trial: optuna.Trial) -> float: - lr = trial.suggest_float("lr", 1e-4, 1e-1, log=True) - lambda_wl = trial.suggest_float("lambda_wirelength", 1e-3, 1.0, log=True) - lambda_ol = trial.suggest_float("lambda_overlap", 1.0, 200.0, log=True) - num_epochs = trial.suggest_int( - "num_epochs", - args.epochs_low, - args.epochs_high, - step=args.epochs_step, - ) - overlap_mode = trial.suggest_categorical( - "overlap_loss_mode", - ["area", "fast"], - ) - clip = trial.suggest_float("per_cell_grad_clip_norm", 1.0, 20.0, log=True) - - result = train_placement( - cell_features, - pin_features, - edge_list, - num_epochs=num_epochs, - lr=lr, - lambda_wirelength=lambda_wl, - lambda_overlap=lambda_ol, - overlap_loss_mode=overlap_mode, - per_cell_grad_clip_norm=clip, - verbose=False, - ) - metrics = calculate_normalized_metrics( - result["final_cell_features"], - pin_features, - edge_list, - ) - if metrics["num_cells_with_overlaps"] > 0: - return args.overlap_penalty + metrics["overlap_ratio"] - return metrics["normalized_wl"] - - study = optuna.create_study( - study_name=args.study_name, - storage=args.storage, - direction="minimize", - load_if_exists=bool(args.storage), - ) - study.optimize(objective, n_trials=args.n_trials, show_progress_bar=True) - - completed = study.get_trials(deepcopy=False, states=(TrialState.COMPLETE,)) - print() - print("=" * 60) - print(f"Study: {args.study_name} | completed trials: {len(completed)}") - if study.best_trial is not None: - print(f"Best value (normalized WL or penalty): {study.best_value:.6g}") - print("Best params:") - for k, v in sorted(study.best_params.items()): - print(f" {k}: {v}") - else: - print("No completed trials.") - print("=" * 60) - - -if __name__ == "__main__": - main()