diff --git a/AGENTS.md b/AGENTS.md index 52a33ff..dbe58bb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -67,7 +67,7 @@ Always keep the **compiler -> scheduler -> Verilog emission** chain coherent. - `cw_reference.py`: software reference model for CW flows (one pass over `flow.stages` linear order, mirroring the coordinator state machine); consumed by the benchmark scoreboard and tests. - `demo/de0-nano/basic-example/host/programs/run_cw_stress_benchmark.py`: live DE0-NANO scoreboard/observability harness for `CWs/example-program.cw`-class flows compiled into the board wrapper. - `cw_lang.py`: the real `.cw` front-end (lexer → AST → recursive-descent parser → host-side tree-walking interpreter). Independent of `cw_compiler.py`'s regex/template RTL-lowering path. Owns **compile-time class magic methods** (operator overloading + type-conversion hooks `__to_int__`/`__to_float__`/`__convert__`); `Interpreter.convert()` is the toolchain hook for dynamic type-format conversion. Driven by the `cw-eval` CLI subcommand and the syntax side of `cw-lint`. Must not be wired into RTL lowering or the benchmark gate. -- `arch_search.py`: synthesis-time architecture-candidate enumeration and ranking (`arch-search` CLI) plus the simulator-driven fit finder (`fit-config` CLI via `run_fit_search`/`emit_fit_config`/`FitBudget`, schema `wau_fit_search_v1`). Reuses the real `compile_project`/`build_schedule` pipeline for performance metrics; owns 2D/3D grid-shape enumeration (fixed-core-count reshapes for `arch-search`; variable-core-count sweeps bounded by a device budget for `fit-config`), the versioned estimate models (`wau_resource_model_v1`, `dram_model_v1`, `arch_search_rank_v1`), and reads device capacity metadata (`logic_cells`/`bram_kbits`/`dsp_blocks`) from `device_library.py`. Must not change scheduling/emission behavior — it only builds and evaluates candidate config payloads in memory. `run_arch_search` must stay byte-identical (guarded by `tests/python/test_arch_search.py`); `fit-config` is additive. +- `arch_search.py`: synthesis-time architecture-candidate enumeration and ranking (`arch-search` CLI) plus the simulator-driven fit finder (`fit-config` CLI via `run_fit_search`/`emit_fit_config`/`FitBudget`, schema `wau_fit_search_v1`). Reuses the real `compile_project`/`build_schedule` pipeline for performance metrics; owns 2D/3D grid-shape enumeration (fixed-core-count reshapes for `arch-search`; variable-core-count sweeps bounded by a device budget for `fit-config`), the versioned estimate models (`wau_resource_model_v1`, `dram_model_v1`, `arch_search_rank_v1`), and reads device capacity metadata (`logic_cells`/`bram_kbits`/`dsp_blocks`) from `device_library.py`. `fit-config` additionally co-sweeps `coordinator.max_in_flight` (`CandidateKnobs.max_in_flight`, `--max-in-flight`, workload-bounded via `_fit_max_in_flight_values`) so single-flow workloads reclaim the LUTs unused in-flight slots would cost; `arch-search` never sets this dimension. Must not change scheduling/emission behavior — it only builds and evaluates candidate config payloads in memory. `run_arch_search` must stay byte-identical (guarded by `tests/python/test_arch_search.py`); `fit-config` is additive. - `scripts/find_best_wau_config.py`: thin convenience wrapper over `waugen fit-config` (a `.cw` kernel or `.json` workload -> best/efficient config recommendation). Keeps all logic in `arch_search.py`. - `scripts/fetch_dataset.py` (+ `.ps1`): on-demand, git-ignored dataset download (MNIST via the CVDF mirror) for data-exchange testing; also exposes `load_mnist_images`/`load_mnist_labels` readers. `datasets/` is git-ignored and never committed. - `src/python/configs/wau_cw_fit_base.json`: minimal, demo-independent DE0-NANO base used to compile a raw `.cw` for `fit-config` and as the base for `scripts/run_cw_stress_benchmark.sh`. Carries the full `add/sub/mul/div/max` op set so the fixed ALU testbench elaborates; the board keeps its own lean `add/mul/max` base (`wau_de0_nano_cw_stress_base.json`). @@ -96,7 +96,7 @@ Always keep the **compiler -> scheduler -> Verilog emission** chain coherent. - `run_cw_example_benchmark.sh` must produce a valid `benchmarks/example_pogram_benchmark.txt` log with passing RTL tests, CW execution metrics, and `scoreboard_pass_ratio == 1.0`. - `run_cw_stress_benchmark.sh` is a thin wrapper over the same engine for `CWs/stress/mesh_stress.cw`; it must write only `benchmarks/mesh_stress_*` artifacts and must never overwrite the CI-gated `example_pogram_*` files. It must also keep `scoreboard_pass_ratio == 1.0`. - All real `.cw` programs live under `CWs/` (canonical location). Historical board reports (`benchmarks/de0_nano_*`) and dated ROADMAP entries keep their original `docs/...` paths as run-time records and must not be rewritten. -- `waugen.arch_search.run_arch_search` must stay byte-identical (guarded by `tests/python/test_arch_search.py`); `fit-config`/`run_fit_search` is additive and must not alter `arch-search` behavior. `fit-config` must only build/evaluate candidate payloads in memory (no scheduling/emission changes). +- `waugen.arch_search.run_arch_search` must stay byte-identical (guarded by `tests/python/test_arch_search.py`); `fit-config`/`run_fit_search` is additive and must not alter `arch-search` behavior. `fit-config` must only build/evaluate candidate payloads in memory (no scheduling/emission changes). The `CandidateKnobs.max_in_flight` co-sweep dimension defaults to `None` (leaves `coordinator.max_in_flight` untouched and omits the `_mif` id suffix / knobs key) precisely so `arch-search` reports stay byte-identical; only `fit-config` sets it. Swept depths must stay within the coordinator's `[1,16]` schema range. - Whenever new possible future implementations, optimizations, or architecture changes are identified, update `ROADMAP.md` in the same work cycle. - Whenever workflow steps, ownership boundaries, invariants, or scope change, update both `AGENTS.md` and `README.md` in the same work cycle. diff --git a/README.md b/README.md index b1ca84a..1b9f6db 100644 --- a/README.md +++ b/README.md @@ -180,6 +180,15 @@ ceiling. `fit-config` is additive; `arch-search` (fixed-core-count reshapes) is unchanged. Use `--quick` for a grid-only sweep, `--lut-budget` / `--max-utilization` / `--tolerance` to tune the envelope. +Alongside grid shape, op distribution, and memory split, `fit-config` also +co-sweeps `coordinator.max_in_flight`: every in-flight slot costs LUTs, but the +depth only buys makespan when independent flows overlap, so the finder tries the +workload-appropriate depths (a single-flow program collapses to `1`, reclaiming +those LUTs; multi-flow workloads try `1`, powers of two, and the flow-count +ceiling) and the ranker keeps the cheapest depth that doesn't cost makespan. +Override the depths with `--max-in-flight 1,2,4`; the swept set is reported as +`max_in_flight_swept` and encoded in each candidate id as a `_mif` suffix. + ### Datasets for data-exchange testing Real operand streams (instead of random data) make the mesh/JTAG throughput diff --git a/ROADMAP.md b/ROADMAP.md index 54459fc..978438b 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -13,6 +13,37 @@ Completed baseline: - Generated RTL for coordinator/core/station/ALU/top. - Initial verification with Python unit tests + `iverilog` testbenches. +## Progress Update (2026-07-11) +Implemented this cycle: +- **`fit-config` co-sweeps `coordinator.max_in_flight`** (2026-07-07 follow-up + "co-sweep `coordinator.max_in_flight` inside `fit-config`, currently fixed by + the compiled flow"). The fit finder now varies the coordinator in-flight depth + as a fourth candidate axis alongside grid shape / op distribution / memory + split. Every in-flight slot costs LUTs in `wau_resource_model_v1`, but the + depth only buys makespan when independent flows overlap, so the swept depths + are workload-bounded (`_fit_max_in_flight_values`): a **single-flow** workload + collapses to depth `1` (cycle-identical there, so higher depths would only + burn LUTs), while multi-flow workloads try `1`, powers of two, the base value, + and the flow-count ceiling (clamped to the `[1,16]` schema range). Because the + ranker's LUT tiebreaker follows makespan/hops, it keeps the cheapest depth + that doesn't cost makespan — e.g. on the 4-flow `wau_example_pogram_compiled` + workload the `mif1` variants outrank the equal-makespan `mif2`/`mif4` variants + by ~2% fewer estimated LUTs. `--max-in-flight 1,2,4` forces the set; the swept + depths are reported as `max_in_flight_swept` and encoded per candidate as a + `_mif` id suffix (round-tripped by `emit_fit_config`). + - Kept **additive**: `CandidateKnobs.max_in_flight` defaults to `None` + (leaves `coordinator.max_in_flight` untouched, no `_mif` suffix, no knobs + key), so `arch-search` reports stay byte-identical — verified against the + pre-change output and guarded by + `tests/python/test_arch_search.py::ArchSearchReportTests.test_arch_search_never_sweeps_max_in_flight`. + On `mesh_stress.cw` the efficient recommendation is unchanged (`2x2`, the + empirically validated board ceiling) and the candidate count is identical + (single flow ⇒ one depth). + - New coverage: `FitMaxInFlightSweepTests` (depth bounds, reported swept set, + lower-depth-never-costs-more-LUTs monotonicity, explicit override, emit + round-trip) plus two CLI tests (`--max-in-flight` override + invalid-value + rejection). Full Python suite: 112 tests pass (was 104). + ## Progress Update (2026-07-07) Implemented this cycle (DE0-NANO real-board testing support): - **Grid-scaling fit search** (`waugen fit-config` + `waugen.arch_search.run_fit_search`): @@ -68,10 +99,11 @@ Implemented this cycle (DE0-NANO real-board testing support): `CWs/samples/{nn,types}/*.cw`, `CWs/stress/mesh_stress.cw`); tests, docs, scripts, and the demo updated. Historical board reports and dated ROADMAP entries keep their original paths as run-time records. -- Follow-ups: co-sweep `lane_parallelism`/`coordinator.max_in_flight` inside - `fit-config` (currently fixed by the compiled flow); calibrate the fit - resource model against real DE0-NANO synthesis so the LUT budget maps to - measured LEs; feed board-measured stress-kernel scores back into the ranking. +- Follow-ups: co-sweep `lane_parallelism` inside `fit-config` (still fixed by + the compiled flow — `coordinator.max_in_flight` co-sweep landed 2026-07-11, + see below); calibrate the fit resource model against real DE0-NANO synthesis + so the LUT budget maps to measured LEs; feed board-measured stress-kernel + scores back into the ranking. ## Progress Update (2026-07-06) Implemented this cycle: diff --git a/src/python/waugen/arch_search.py b/src/python/waugen/arch_search.py index 48f52d8..1a905da 100644 --- a/src/python/waugen/arch_search.py +++ b/src/python/waugen/arch_search.py @@ -82,13 +82,20 @@ class CandidateKnobs: global_ram_depth: int station_cache_entries: int grid_z: int = 1 + # Coordinator in-flight depth to force on the candidate. ``None`` leaves + # the base config's ``coordinator.max_in_flight`` untouched so `arch-search` + # (which never sweeps it) stays byte-identical; the fit finder co-sweeps it. + max_in_flight: int | None = None @property def candidate_id(self) -> str: - return ( + base = ( f"g{self.grid_x}x{self.grid_y}x{self.grid_z}_" f"{self.op_distribution}_{self.memory_split}" ) + if self.max_in_flight is not None: + base += f"_mif{self.max_in_flight}" + return base @dataclass @@ -102,21 +109,25 @@ class CandidateResult: rank: int = 0 def to_json(self) -> dict[str, Any]: + knobs: dict[str, Any] = { + "grid_x": self.knobs.grid_x, + "grid_y": self.knobs.grid_y, + "grid_z": self.knobs.grid_z, + "op_distribution": self.knobs.op_distribution, + "memory_split": self.knobs.memory_split, + "local_ram_depth": self.knobs.local_ram_depth, + "global_ram_depth": self.knobs.global_ram_depth, + "station_cache_entries": self.knobs.station_cache_entries, + } + # Only surfaced when swept, so `arch-search` reports stay byte-identical. + if self.knobs.max_in_flight is not None: + knobs["max_in_flight"] = self.knobs.max_in_flight return { "candidate_id": self.knobs.candidate_id, "rank": self.rank, "feasible": self.feasible, "infeasible_reason": self.infeasible_reason, - "knobs": { - "grid_x": self.knobs.grid_x, - "grid_y": self.knobs.grid_y, - "grid_z": self.knobs.grid_z, - "op_distribution": self.knobs.op_distribution, - "memory_split": self.knobs.memory_split, - "local_ram_depth": self.knobs.local_ram_depth, - "global_ram_depth": self.knobs.global_ram_depth, - "station_cache_entries": self.knobs.station_cache_entries, - }, + "knobs": knobs, "metrics": self.metrics, "resources": self.resources, "dram": self.dram, @@ -261,6 +272,9 @@ def build_candidate_payload( station_cache = compiler.setdefault("station_cache", {}) station_cache["entries"] = knobs.station_cache_entries + if knobs.max_in_flight is not None: + payload.setdefault("coordinator", {})["max_in_flight"] = knobs.max_in_flight + _strip_placements(payload, grid_x=knobs.grid_x, grid_y=knobs.grid_y, grid_z=knobs.grid_z) return payload @@ -570,6 +584,26 @@ def _evaluate_candidate( _DEFAULT_FIT_MAX_UTILIZATION = 0.9 _DEFAULT_FIT_TOLERANCE = 0.10 +_COORD_MAX_IN_FLIGHT = 16 # schema ceiling for coordinator.max_in_flight + + +def _fit_max_in_flight_values(base_mif: int, num_flows: int) -> tuple[int, ...]: + """Coordinator in-flight depths worth trying for a workload. + + More in-flight slots only help makespan when independent flows can overlap, + but every slot costs LUTs (``wau_resource_model_v1`` charges per slot). So + the finder co-sweeps ``1`` (cheapest, cycle-identical for a single flow), + powers of two, the base config's value, and the concurrency ceiling + (bounded by the number of flows and the schema max), letting the ranker keep + the cheapest depth that does not cost makespan. + """ + cap = min(_COORD_MAX_IN_FLIGHT, max(1, num_flows)) + values = {1, cap, max(1, min(base_mif, cap))} + v = 2 + while v < cap: + values.add(v) + v *= 2 + return tuple(sorted(values)) @dataclass(frozen=True) @@ -606,6 +640,7 @@ class FitReport: best_id: str | None efficient_id: str | None tolerance: float + max_in_flight_swept: tuple[int, ...] = () def _eligible(self) -> list[CandidateResult]: return [c for c in self.candidates if _fits_budget(c, self.budget)] @@ -623,6 +658,7 @@ def to_json(self) -> dict[str, Any]: "device_part": self.device_part, "budget": self.budget.to_json(), "tolerance": self.tolerance, + "max_in_flight_swept": list(self.max_in_flight_swept), "workload": self.workload, "candidate_count": len(self.candidates), "eligible_count": len(eligible), @@ -678,6 +714,7 @@ def run_fit_search( memory_splits: tuple[str, ...] = MEMORY_SPLITS, op_distributions: tuple[str, ...] = OP_DISTRIBUTIONS, tolerance: float = _DEFAULT_FIT_TOLERANCE, + max_in_flight_values: tuple[int, ...] | None = None, max_candidates: int | None = None, ) -> FitReport: """Sweep grid shapes up to ``budget`` and rank the ones that fit. @@ -686,6 +723,12 @@ def run_fit_search( ``compile_project`` -> ``build_schedule`` pipeline (the simulator), so the makespan/hop numbers used for ranking are the generator's own predicted behaviour for that program on that architecture. + + Alongside grid shape / op distribution / memory split, the finder co-sweeps + ``coordinator.max_in_flight``: passing ``max_in_flight_values`` forces the + depths to try, otherwise they are derived from the workload's flow count via + ``_fit_max_in_flight_values`` (single-flow workloads collapse to ``1``, + reclaiming the LUTs unused in-flight slots would cost). """ try: base_payload = json.loads(base_config_path.read_text()) @@ -717,11 +760,23 @@ def run_fit_search( op_distributions if heavy_ops and light_ops else ("uniform",) ) + if max_in_flight_values is None: + mif_values = _fit_max_in_flight_values( + base_config.coordinator.max_in_flight, len(base_config.flows) + ) + else: + mif_values = tuple(sorted(set(max_in_flight_values))) + candidates: list[CandidateResult] = [] + capped = False for grid_x, grid_y, grid_z in _fit_grid_shapes(budget): + if capped: + break for op_distribution in effective_op_distributions: + if capped: + break for memory_split in memory_splits: - if max_candidates is not None and len(candidates) >= max_candidates: + if capped: break local_depth, global_depth, cache_entries = _memory_split_knobs( memory_split, @@ -729,25 +784,26 @@ def run_fit_search( base_global=base_global, base_cache_entries=base_cache_entries, ) - knobs = CandidateKnobs( - grid_x=grid_x, - grid_y=grid_y, - grid_z=grid_z, - op_distribution=op_distribution, - memory_split=memory_split, - local_ram_depth=local_depth, - global_ram_depth=global_depth, - station_cache_entries=cache_entries, - ) - candidates.append( - _evaluate_candidate( - base_payload, knobs, preset, heavy_ops=heavy_ops, light_ops=light_ops + for mif in mif_values: + if max_candidates is not None and len(candidates) >= max_candidates: + capped = True + break + knobs = CandidateKnobs( + grid_x=grid_x, + grid_y=grid_y, + grid_z=grid_z, + op_distribution=op_distribution, + memory_split=memory_split, + local_ram_depth=local_depth, + global_ram_depth=global_depth, + station_cache_entries=cache_entries, + max_in_flight=mif, + ) + candidates.append( + _evaluate_candidate( + base_payload, knobs, preset, heavy_ops=heavy_ops, light_ops=light_ops + ) ) - ) - if max_candidates is not None and len(candidates) >= max_candidates: - break - if max_candidates is not None and len(candidates) >= max_candidates: - break candidates.sort(key=_rank_key) for rank, cand in enumerate(candidates, start=1): @@ -794,6 +850,7 @@ def run_fit_search( best_id=best.knobs.candidate_id if best is not None else None, efficient_id=efficient.knobs.candidate_id if efficient is not None else None, tolerance=tolerance, + max_in_flight_swept=mif_values, ) @@ -816,10 +873,14 @@ def emit_fit_config( def _knobs_from_candidate_id(base_payload: dict[str, Any], candidate_id: str) -> CandidateKnobs: - """Parse a ``g{X}x{Y}x{Z}_{op}_{mem}`` candidate id back into knobs.""" + """Parse a ``g{X}x{Y}x{Z}_{op}_{mem}[_mif{N}]`` candidate id back into knobs.""" try: grid_part, rest = candidate_id.split("_", 1) gx, gy, gz = (int(v) for v in grid_part.lstrip("g").split("x")) + max_in_flight: int | None = None + if "_mif" in rest: + rest, mif_part = rest.rsplit("_mif", 1) + max_in_flight = int(mif_part) for op_dist in OP_DISTRIBUTIONS: if rest.startswith(op_dist + "_"): op_distribution = op_dist @@ -848,6 +909,7 @@ def _knobs_from_candidate_id(base_payload: dict[str, Any], candidate_id: str) -> local_ram_depth=local_depth, global_ram_depth=global_depth, station_cache_entries=cache_entries, + max_in_flight=max_in_flight, ) @@ -861,8 +923,9 @@ def format_fit_report_text(report: FitReport, *, top: int = 12) -> str: f"<= {budget.max_utilization:.0%} utilization", f"candidates: {len(report.candidates)} " f"(fit budget: {sum(1 for c in report.candidates if _fits_budget(c, budget))})", + f"max_in_flight swept: {', '.join(str(v) for v in report.max_in_flight_swept)}", "", - f"{'rank':>4} {'candidate':<30} {'cores':>5} {'makespan':>8} {'hops':>5} " + f"{'rank':>4} {'candidate':<34} {'cores':>5} {'makespan':>8} {'hops':>5} " f"{'util':>6} {'luts':>7} fits", ] for cand in report.candidates[:top]: @@ -873,7 +936,7 @@ def format_fit_report_text(report: FitReport, *, top: int = 12) -> str: elif cand.knobs.candidate_id == report.efficient_id: tag = " <- efficient" lines.append( - f"{cand.rank:>4} {cand.knobs.candidate_id:<30} " + f"{cand.rank:>4} {cand.knobs.candidate_id:<34} " f"{_core_count(cand):>5} " f"{cand.metrics.get('makespan_cycles', '-'):>8} " f"{cand.metrics.get('estimated_transfer_hops_total', '-'):>5} " diff --git a/src/python/waugen/cli.py b/src/python/waugen/cli.py index e05bf57..ae09fb9 100644 --- a/src/python/waugen/cli.py +++ b/src/python/waugen/cli.py @@ -342,6 +342,15 @@ def _build_parser() -> argparse.ArgumentParser: action="store_true", help="Only sweep grid shapes at balanced memory + uniform ops (faster)", ) + fit.add_argument( + "--max-in-flight", + default=None, + help=( + "Comma-separated coordinator.max_in_flight depths to co-sweep " + "(default: auto from the workload's flow count; single-flow " + "workloads collapse to 1 to reclaim unused in-flight-slot LUTs)" + ), + ) fit.add_argument( "--base-config", type=Path, @@ -737,11 +746,29 @@ def _run_fit_config(args) -> int: memory_splits = ("balanced",) if args.quick else None op_distributions = ("uniform",) if args.quick else None + max_in_flight_values: tuple[int, ...] | None = None + if args.max_in_flight is not None: + try: + max_in_flight_values = tuple( + int(v) for v in str(args.max_in_flight).split(",") if v.strip() + ) + except ValueError: + print( + f"Invalid --max-in-flight value: {args.max_in_flight!r} " + "(expected comma-separated integers)", + file=sys.stderr, + ) + return 2 + if not max_in_flight_values: + max_in_flight_values = None + kwargs: dict = {"budget": budget, "device_preset": args.device} if memory_splits is not None: kwargs["memory_splits"] = memory_splits if op_distributions is not None: kwargs["op_distributions"] = op_distributions + if max_in_flight_values is not None: + kwargs["max_in_flight_values"] = max_in_flight_values kwargs["tolerance"] = args.tolerance try: diff --git a/tests/python/test_arch_search.py b/tests/python/test_arch_search.py index 928c7e0..10f767d 100644 --- a/tests/python/test_arch_search.py +++ b/tests/python/test_arch_search.py @@ -21,7 +21,9 @@ from waugen.arch_search import ( CandidateKnobs, FitBudget, + _fit_max_in_flight_values, _fits_budget, + _knobs_from_candidate_id, _rank_key, build_candidate_payload, emit_fit_config, @@ -78,6 +80,15 @@ def test_baseline_shape_present_and_feasible(self) -> None: self.assertGreater(cand.resources["luts"], 0) self.assertGreater(cand.resources["bram_kbits"], 0) + def test_arch_search_never_sweeps_max_in_flight(self) -> None: + # arch-search reshapes a fixed core count and must stay byte-identical: + # it never co-sweeps coordinator.max_in_flight, so no candidate id may + # carry the fit-only `_mif` suffix nor expose it in the knobs JSON. + for cand in self.report.candidates: + self.assertIsNone(cand.knobs.max_in_flight) + self.assertNotIn("_mif", cand.knobs.candidate_id) + self.assertNotIn("max_in_flight", cand.to_json()["knobs"]) + def test_grid_shapes_preserve_core_count(self) -> None: core_count = self.report.base_grid_x * self.report.base_grid_y * self.report.base_grid_z for cand in self.report.candidates: @@ -280,6 +291,72 @@ def test_emit_fit_config_rebuilds_chosen_grid(self) -> None: self.assertEqual(payload["device"]["grid"]["y"], eff.knobs.grid_y) +class FitMaxInFlightSweepTests(unittest.TestCase): + """The fit finder co-sweeps coordinator.max_in_flight so it can keep the + cheapest in-flight depth that does not cost makespan (unused slots cost + LUTs), while never dragging a workload above its concurrency ceiling.""" + + def test_depth_values_bounded_by_flow_count(self) -> None: + # A single-flow workload gets a single, cheapest depth: mif=1 is + # cycle-identical there, so higher depths would only burn LUTs. + self.assertEqual(_fit_max_in_flight_values(base_mif=4, num_flows=1), (1,)) + # More flows unlock more concurrency; base value + powers of two + the + # cap are all offered, deduplicated and sorted. + self.assertEqual(_fit_max_in_flight_values(base_mif=4, num_flows=4), (1, 2, 4)) + self.assertEqual(_fit_max_in_flight_values(base_mif=2, num_flows=3), (1, 2, 3)) + # Never exceed the schema ceiling of 16. + self.assertEqual(max(_fit_max_in_flight_values(base_mif=16, num_flows=64)), 16) + + def test_report_records_swept_depths(self) -> None: + # CW_CONFIG has 4 flows, so the auto sweep spans {1, 2, 4}. + report = run_fit_search(CW_CONFIG, budget=_de0_budget()) + self.assertEqual(report.max_in_flight_swept, (1, 2, 4)) + self.assertEqual(report.to_json()["max_in_flight_swept"], [1, 2, 4]) + depths = {c.knobs.max_in_flight for c in report.candidates} + self.assertEqual(depths, {1, 2, 4}) + # Every fit candidate id carries its depth and round-trips. + for cand in report.candidates: + self.assertIn(f"_mif{cand.knobs.max_in_flight}", cand.knobs.candidate_id) + + def test_lower_depth_never_costs_more_luts(self) -> None: + # For a fixed grid/op/memory shape, a smaller in-flight depth must + # estimate no more LUTs than a larger one (the reclaim the sweep buys). + report = run_fit_search(CW_CONFIG, budget=_de0_budget()) + by_shape: dict[str, dict[int, int]] = {} + for cand in report.candidates: + k = cand.knobs + shape = f"g{k.grid_x}x{k.grid_y}x{k.grid_z}_{k.op_distribution}_{k.memory_split}" + by_shape.setdefault(shape, {})[k.max_in_flight] = cand.resources["luts"] + checked = 0 + for depths in by_shape.values(): + for depth, luts in depths.items(): + for other, other_luts in depths.items(): + if depth < other: + self.assertLessEqual(luts, other_luts) + checked += 1 + self.assertGreater(checked, 0) + + def test_explicit_override_respected(self) -> None: + report = run_fit_search( + CW_CONFIG, budget=_de0_budget(), max_in_flight_values=(1, 8) + ) + self.assertEqual(report.max_in_flight_swept, (1, 8)) + self.assertEqual({c.knobs.max_in_flight for c in report.candidates}, {1, 8}) + + def test_emit_fit_config_applies_swept_depth(self) -> None: + report = run_fit_search(CW_CONFIG, budget=_de0_budget()) + eff = _candidate_by_id(report, report.efficient_id) + payload = emit_fit_config(CW_CONFIG, report.efficient_id) + self.assertEqual( + payload["coordinator"]["max_in_flight"], eff.knobs.max_in_flight + ) + # And the id parser recovers the depth from the id alone. + parsed = _knobs_from_candidate_id( + json.loads(CW_CONFIG.read_text()), report.efficient_id + ) + self.assertEqual(parsed.max_in_flight, eff.knobs.max_in_flight) + + class FitConfigCliTests(unittest.TestCase): def test_cli_fits_cw_program_and_emits_buildable_config(self) -> None: with tempfile.TemporaryDirectory() as td: @@ -329,6 +406,42 @@ def test_cli_config_input_quick_mode(self) -> None: ) self.assertEqual(rc, 0) + def test_cli_max_in_flight_override(self) -> None: + with tempfile.TemporaryDirectory() as td: + report_path = Path(td) / "r.json" + rc = waugen_main( + [ + "fit-config", + "--config", + str(CW_CONFIG), + "--max-grid", + "2x4", + "--quick", + "--max-in-flight", + "1,3", + "--out-report", + str(report_path), + ] + ) + self.assertEqual(rc, 0) + report = json.loads(report_path.read_text()) + self.assertEqual(report["max_in_flight_swept"], [1, 3]) + + def test_cli_rejects_invalid_max_in_flight(self) -> None: + rc = waugen_main( + [ + "fit-config", + "--config", + str(CW_CONFIG), + "--max-grid", + "1x1", + "--quick", + "--max-in-flight", + "two", + ] + ) + self.assertEqual(rc, 2) + class ArchSearchCliTests(unittest.TestCase): def test_cli_writes_report_and_summary(self) -> None: