Files
Mini-Nav/scripts/sweep_cam_perf.py
SikongJueluo 2e0e36eea5 feat(sim): add CAM performance sweep test infrastructure
- hw/sim/tests/test_cam_perf.py: new cocotb perf test with bounded wait helpers
- scripts/sweep_cam_perf.py: sweep data model, matrix, and make command builders  
- tests/test_sweep_cam_perf.py: unit tests for sweep helpers
- tests/conftest.py: pytest path configuration for scripts package
- hw/sim/Makefile: deterministic noise params override for perf test compatibility
2026-05-16 15:42:29 +08:00

763 lines
27 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""CAM performance simulation sweep — data model, sweep matrix, and command builders."""
from __future__ import annotations
import csv
import os
import re
import subprocess
import time
from dataclasses import dataclass
from datetime import date as date_type
from datetime import datetime
from pathlib import Path
from typing import Annotated
import typer
# Timeout for each external simulation invocation (30 minutes).
DEFAULT_COMMAND_TIMEOUT_SEC = 1800
# Cocotb test module that records PERF_RESULT metrics.
PERF_MODULE = "tests.test_cam_perf"
# CSV column names for sweep result output — must match exactly.
CSV_FIELDNAMES = [
"sweep_name",
"num_rows",
"hash_bits",
"lanes",
"latency_cycles",
"total_cycles",
"accepted_queries",
"completed_queries",
"queries_per_cycle",
"sim_time_sec",
"status",
"log_path",
]
# ---------------------------------------------------------------------------
# Dense sweep-matrix value sets for paper-quality figures.
NUM_ROWS_SWEEP_VALUES = (64, 128, 192, 256, 384, 512, 768, 1024)
HASH_BITS_SWEEP_VALUES = (64, 128, 192, 256, 320, 384, 448, 512)
LANES_SWEEP_VALUES = (2, 4, 8, 16, 32)
# ---------------------------------------------------------------------------
# Data model
# ---------------------------------------------------------------------------
@dataclass(frozen=True)
class SweepConfig:
"""A single point in the CAM performance sweep matrix."""
sweep_name: str
num_rows: int
hash_bits: int
lanes: int
@dataclass(frozen=True)
class PerfRun:
"""Result of executing a single sweep configuration."""
config: SweepConfig
status: str
returncode: int
duration_sec: float
log_path: Path
metrics: dict[str, str]
error: str = ""
# ---------------------------------------------------------------------------
# Default sweep matrix
# ---------------------------------------------------------------------------
def default_sweep_configs() -> list[SweepConfig]:
"""Return the paper-supporting dense sweep matrix (19 unique configs).
The matrix sweeps one dimension at a time:
* NUM_ROWS: ``(nr, 512, 16)`` for ``nr`` in ``NUM_ROWS_SWEEP_VALUES``
* HASH_BITS: ``(512, hb, 16)`` for ``hb`` in ``HASH_BITS_SWEEP_VALUES``
* LANES: ``(512, 512, la)`` for ``la`` in ``LANES_SWEEP_VALUES``
Overlapping triples are deduplicated preserving first occurrence.
"""
candidates = (
[SweepConfig("num_rows", nr, 512, 16) for nr in NUM_ROWS_SWEEP_VALUES]
+ [SweepConfig("hash_bits", 512, hb, 16) for hb in HASH_BITS_SWEEP_VALUES]
+ [SweepConfig("lanes", 512, 512, la) for la in LANES_SWEEP_VALUES]
)
seen: set[tuple[int, int, int]] = set()
result: list[SweepConfig] = []
for cfg in candidates:
key = (cfg.num_rows, cfg.hash_bits, cfg.lanes)
if key not in seen:
seen.add(key)
result.append(cfg)
return result
# ---------------------------------------------------------------------------
# Sweep membership helper
# ---------------------------------------------------------------------------
def logical_sweeps_for_config(config: SweepConfig) -> tuple[str, ...]:
"""Return the logical sweep-group memberships for *config*.
A single config may participate in multiple sweep dimensions. This is
especially important for the baseline config ``(512, 512, 16)`` whose
singleton CSV row has ``sweep_name='num_rows'`` (first occurrence wins
during dedup), yet logically belongs to **all three** sweep groups.
The predicates reference the module-level dense-value constants:
* ``'num_rows'`` ``HASH_BITS=512, LANES=16, NUM_ROWS ∈ NUM_ROWS_SWEEP_VALUES``
* ``'hash_bits'`` ``NUM_ROWS=512, LANES=16, HASH_BITS ∈ HASH_BITS_SWEEP_VALUES``
* ``'lanes'`` ``NUM_ROWS=512, HASH_BITS=512, LANES ∈ LANES_SWEEP_VALUES``
Downstream plot / analysis code **must** use this helper to derive sweep
groups instead of filtering solely by ``SweepConfig.sweep_name``; otherwise
the baseline ``(512,512,16)`` would be missing from ``hash_bits`` and
``lanes`` group queries.
"""
memberships: list[str] = []
nr, hb, la = config.num_rows, config.hash_bits, config.lanes
if hb == 512 and la == 16 and nr in NUM_ROWS_SWEEP_VALUES:
memberships.append("num_rows")
if nr == 512 and la == 16 and hb in HASH_BITS_SWEEP_VALUES:
memberships.append("hash_bits")
if nr == 512 and hb == 512 and la in LANES_SWEEP_VALUES:
memberships.append("lanes")
return tuple(memberships)
# ---------------------------------------------------------------------------
# Log parsing
# ---------------------------------------------------------------------------
_PERF_EXPECTED_KEYS = frozenset({
"latency_cycles",
"total_cycles",
"accepted_queries",
"completed_queries",
"queries_per_cycle",
"status",
})
def parse_perf_result(log_text: str) -> dict[str, str]:
"""Parse the first *complete* ``PERF_RESULT`` marker line from a simulation log.
Scans all lines for a ``PERF_RESULT`` marker (standalone word). For each
such line, extracts ``key=value`` tokens and checks whether all expected
keys (``latency_cycles``, ``total_cycles``, ``accepted_queries``,
``completed_queries``, ``queries_per_cycle``, ``status``) are present.
Returns the dict from the **first** line with a complete set, or ``{}``
if no complete marker exists in the entire log.
"""
_marker_re = re.compile(r"\bPERF_RESULT\b")
for line in log_text.splitlines():
if _marker_re.search(line):
tokens = line.split()
parsed: dict[str, str] = {}
for token in tokens:
if "=" in token:
key, _, value = token.partition("=")
parsed[key] = value
if _PERF_EXPECTED_KEYS.issubset(parsed.keys()):
return parsed
return {}
# ---------------------------------------------------------------------------
# Command builders
# ---------------------------------------------------------------------------
def build_make_command(config: SweepConfig) -> list[str]:
"""Build a ``make`` command to run the perf test module with *config*."""
return [
"make",
"-C",
"hw/sim",
f"NUM_ROWS={config.num_rows}",
f"HASH_BITS={config.hash_bits}",
f"LANES={config.lanes}",
f"COCOTB_TEST_MODULES={PERF_MODULE}",
]
# ---------------------------------------------------------------------------
# Execution helpers
# ---------------------------------------------------------------------------
def _run_subprocess(
cmd: list[str],
*,
cwd: Path,
env: dict[str, str],
timeout: int,
) -> tuple[subprocess.CompletedProcess[str] | None, float, str | None]:
"""Execute *cmd*, capture output, enforce *timeout*.
Returns ``(result, duration_sec, error_msg)``.
On success *result* is a ``CompletedProcess`` and *error_msg* is ``None``.
On failure (timeout / OSError / SubprocessError) *result* is ``None``
and *error_msg* describes what happened.
"""
start = time.monotonic()
try:
result = subprocess.run(
cmd,
cwd=cwd,
env=env,
text=True,
capture_output=True,
timeout=timeout,
)
except subprocess.TimeoutExpired as exc:
end = time.monotonic()
partial = ""
if exc.stdout:
partial += f"\n# [partial stdout before timeout]\n{exc.stdout}"
if exc.stderr:
partial += f"\n# [partial stderr before timeout]\n{exc.stderr}"
return None, round(end - start, 3), f"timed out after {timeout}s{partial}"
except (OSError, subprocess.SubprocessError) as exc:
end = time.monotonic()
return None, round(end - start, 3), str(exc)
end = time.monotonic()
return result, round(end - start, 3), None
def run_one_config(
config: SweepConfig,
*,
output_dir: Path,
repo_root: Path,
timeout: int = DEFAULT_COMMAND_TIMEOUT_SEC,
) -> PerfRun:
"""Execute a single sweep configuration and return the result.
Steps:
1. Create logs directory.
2. Validate ``NUM_ROWS % LANES == 0``; return fail immediately if invalid.
3. Run ``make -C hw/sim clean``.
4. Build and run the simulation command via ``build_make_command(config)``.
5. Capture combined stdout/stderr into a log file named
``<sweep_name>_<num_rows>r_<hash_bits>b_<lanes>l.log``.
6. Parse ``PERF_RESULT`` from the combined log.
7. Determine pass/fail: pass only if returncode == 0 and metrics status == 'pass'.
"""
logs_dir = output_dir / "logs"
logs_dir.mkdir(parents=True, exist_ok=True)
log_filename = (
f"{config.sweep_name}_{config.num_rows}r_{config.hash_bits}b_{config.lanes}l.log"
)
log_path = logs_dir / log_filename
relative_log = Path("logs") / log_filename
# ── Validation: NUM_ROWS must be divisible by LANES ──────────────────
if config.num_rows % config.lanes != 0:
log_path.write_text(
f"# ERROR: NUM_ROWS ({config.num_rows}) not divisible by LANES ({config.lanes})\n"
)
return PerfRun(
config=config,
status="fail",
returncode=-1,
duration_sec=0.0,
log_path=relative_log,
metrics={},
error=f"NUM_ROWS ({config.num_rows}) not divisible by LANES ({config.lanes})",
)
env = os.environ.copy()
log_chunks: list[str] = []
# ── Clean step ───────────────────────────────────────────────────────
clean_cmd = ["make", "-C", "hw/sim", "clean"]
log_chunks.append(f"# Command: {' '.join(clean_cmd)}\n")
clean_result, clean_duration, clean_err = _run_subprocess(
clean_cmd, cwd=repo_root, env=env, timeout=timeout,
)
if clean_result is not None:
log_chunks.append(clean_result.stdout)
log_chunks.append(clean_result.stderr)
else:
log_chunks.append(f"# ERROR: {clean_err}\n")
if clean_err is not None or (
clean_result is not None and clean_result.returncode != 0
):
returncode = clean_result.returncode if clean_result is not None else -1
log_path.write_text("".join(log_chunks))
return PerfRun(
config=config,
status="fail",
returncode=returncode,
duration_sec=clean_duration,
log_path=relative_log,
metrics={},
error=clean_err or f"make clean failed with returncode {returncode}",
)
# ── Main simulation command ──────────────────────────────────────────
command = build_make_command(config)
log_chunks.append(f"# Command: {' '.join(command)}\n")
main_result, duration, main_err = _run_subprocess(
command, cwd=repo_root, env=env, timeout=timeout,
)
if main_result is not None:
log_chunks.append(main_result.stdout)
log_chunks.append(main_result.stderr)
else:
log_chunks.append(f"# ERROR: {main_err}\n")
combined_log = "".join(log_chunks)
log_path.write_text(combined_log)
# ── Parse PERF_RESULT ────────────────────────────────────────────────
metrics = parse_perf_result(combined_log)
if main_err is not None:
status = "fail"
returncode = -1
error = main_err
else:
returncode = main_result.returncode
metrics_status = metrics.get("status", "")
if returncode == 0 and metrics_status == "pass":
status = "pass"
error = ""
else:
status = "fail"
if returncode != 0:
error = f"simulation returned non-zero exit code {returncode}"
elif metrics_status != "pass":
error = f"PERF_RESULT status is '{metrics_status}'"
else:
error = "unknown failure"
return PerfRun(
config=config,
status=status,
returncode=returncode,
duration_sec=duration,
log_path=relative_log,
metrics=metrics,
error=error,
)
# ---------------------------------------------------------------------------
# CSV helpers
# ---------------------------------------------------------------------------
def run_to_row(run: PerfRun) -> dict[str, str]:
"""Convert a ``PerfRun`` to a CSV row dict matching ``CSV_FIELDNAMES``."""
return {
"sweep_name": run.config.sweep_name,
"num_rows": str(run.config.num_rows),
"hash_bits": str(run.config.hash_bits),
"lanes": str(run.config.lanes),
"latency_cycles": run.metrics.get("latency_cycles", ""),
"total_cycles": run.metrics.get("total_cycles", ""),
"accepted_queries": run.metrics.get("accepted_queries", ""),
"completed_queries": run.metrics.get("completed_queries", ""),
"queries_per_cycle": run.metrics.get("queries_per_cycle", ""),
"sim_time_sec": str(run.duration_sec),
"status": run.status,
"log_path": str(run.log_path),
}
def write_csv(output_dir: Path, runs: list[PerfRun]) -> None:
"""Write ``sweep.csv`` with ``CSV_FIELDNAMES`` header and one row per run."""
path = output_dir / "sweep.csv"
with open(path, "w", newline="") as f:
writer = csv.DictWriter(f, fieldnames=CSV_FIELDNAMES)
writer.writeheader()
for run in runs:
writer.writerow(run_to_row(run))
# ---------------------------------------------------------------------------
# Output helpers
# ---------------------------------------------------------------------------
def _float_or_none(value: str) -> float | None:
"""Parse *value* as ``float``, returning ``None`` on failure."""
try:
return float(value)
except (ValueError, TypeError):
return None
# ---------------------------------------------------------------------------
# Nature-like plot style constants
# ---------------------------------------------------------------------------
PLOT_NAVY = "#243B53"
PLOT_SLATE = "#7A8793"
PLOT_TEAL = "#4FA7A3"
PLOT_GRID = "#D7DEE5"
PLOT_TEXT = "#1F2933"
def _style_perf_axis(ax, *, title: str, xlabel: str, ylabel: str) -> None:
"""Apply Nature-like styling to a performance figure axis."""
ax.set_title(title, fontsize=10, color=PLOT_TEXT, pad=8)
ax.set_xlabel(xlabel, fontsize=9, color=PLOT_TEXT)
ax.set_ylabel(ylabel, fontsize=9, color=PLOT_TEXT)
ax.grid(True, color=PLOT_GRID, linewidth=0.6, alpha=0.8)
ax.spines["top"].set_visible(False)
ax.spines["right"].set_visible(False)
ax.spines["left"].set_color(PLOT_SLATE)
ax.spines["bottom"].set_color(PLOT_SLATE)
ax.tick_params(axis="both", colors=PLOT_TEXT, labelsize=8, width=0.6)
def _plot_points(
runs: list[PerfRun], group: str, x_getter, metric: str,
) -> list[tuple[int, float]]:
"""Filter *runs* to pass runs in *group* and return sorted ``(x, y)`` points."""
points: list[tuple[int, float]] = []
for run in runs:
if run.status != "pass" or group not in logical_sweeps_for_config(run.config):
continue
value = _float_or_none(run.metrics.get(metric, ""))
if value is None:
continue
points.append((int(x_getter(run.config)), value))
return sorted(points, key=lambda item: item[0])
def write_summary(output_dir: Path, runs: list[PerfRun]) -> None:
"""Write ``sweep_summary.md`` in Chinese with summary table and disclaimer."""
total = len(runs)
passed = sum(1 for r in runs if r.status == "pass")
failed = total - passed
failed_runs = [r for r in runs if r.status == "fail"]
lines: list[str] = []
lines.append("# CAM 性能扫描摘要")
lines.append("")
lines.append(f"**输出目录:** {output_dir}")
lines.append(f"**总运行数:** {total}")
lines.append(f"**通过数:** {passed}")
lines.append(f"**失败数:** {failed}")
lines.append("")
lines.append("## 性能结果")
lines.append("")
lines.append("| 扫描分组 | NUM_ROWS | HASH_BITS | LANES | 延迟(周期数) | 查询量/周期 | 状态 | 日志 |")
lines.append("|----------|----------|-----------|-------|-------------|-------------|------|------|")
for r in runs:
lines.append(
f"| {r.config.sweep_name} | {r.config.num_rows} | {r.config.hash_bits} "
f"| {r.config.lanes} | {r.metrics.get('latency_cycles', '')} "
f"| {r.metrics.get('queries_per_cycle', '')} | {r.status} "
f"| {r.log_path} |"
)
lines.append("")
lines.append("## 失败列表")
lines.append("")
if failed_runs:
for r in failed_runs:
err = r.error or ""
lines.append(f"- **{r.config.sweep_name}** (`{r.log_path}`): {err}")
else:
lines.append("- 无")
lines.append("")
lines.append("## 免责声明")
lines.append("")
lines.append(
"本报告为 **Verilator/Cocotb 仿真** 结果,非 FPGA 板级实测性能、"
"资源利用率或 Fmax。所有数据仅供架构评估参考不代表最终 FPGA 实现指标。"
)
lines.append("")
path = output_dir / "sweep_summary.md"
path.write_text("\n".join(lines))
def write_notes(output_dir: Path, runs: list[PerfRun]) -> None:
"""Write ``ch6_3_cam_perf_notes.md`` in Chinese with trend analysis."""
lines: list[str] = []
lines.append("# 第 6.3 节 CAM 性能仿真笔记")
lines.append("")
lines.append("## 说明")
lines.append("")
lines.append(
"以下结果基于 **Verilator/Cocotb 仿真** 环境,旨在评估 CAM 设计的查询延迟"
"latency_cycles和吞吐量queries_per_cycle趋势。所有数据均为仿真"
"结果,**非 FPGA 板级实测性能**。本文档不提供 LUT、FF、BRAM 或 Fmax 等"
"资源利用率或时序结论。"
)
lines.append("")
lines.append("## 参数趋势分析")
lines.append("")
lines.append("### NUM_ROWSCAM 行数)")
lines.append("")
nr_runs = [
r for r in runs
if "num_rows" in logical_sweeps_for_config(r.config) and r.status == "pass"
]
if nr_runs:
for r in sorted(nr_runs, key=lambda x: x.config.num_rows):
lat = r.metrics.get("latency_cycles", "N/A")
qpc = r.metrics.get("queries_per_cycle", "N/A")
lines.append(f"- NUM_ROWS={r.config.num_rows}: 延迟={lat} 周期, 吞吐量={qpc} 查询/周期")
else:
lines.append("- 无通过数据")
lines.append("")
lines.append("### HASH_BITS哈希位宽")
lines.append("")
hb_runs = [
r for r in runs
if "hash_bits" in logical_sweeps_for_config(r.config) and r.status == "pass"
]
if hb_runs:
for r in sorted(hb_runs, key=lambda x: x.config.hash_bits):
lat = r.metrics.get("latency_cycles", "N/A")
qpc = r.metrics.get("queries_per_cycle", "N/A")
lines.append(f"- HASH_BITS={r.config.hash_bits}: 延迟={lat} 周期, 吞吐量={qpc} 查询/周期")
else:
lines.append("- 无通过数据")
lines.append("")
lines.append("### LANES流水线通路数")
lines.append("")
la_runs = [
r for r in runs
if "lanes" in logical_sweeps_for_config(r.config) and r.status == "pass"
]
if la_runs:
for r in sorted(la_runs, key=lambda x: x.config.lanes):
lat = r.metrics.get("latency_cycles", "N/A")
qpc = r.metrics.get("queries_per_cycle", "N/A")
lines.append(f"- LANES={r.config.lanes}: 延迟={lat} 周期, 吞吐量={qpc} 查询/周期")
else:
lines.append("- 无通过数据")
lines.append("")
lines.append("## 免责声明")
lines.append("")
lines.append(
"本文档中的数据来源于 Verilator/Cocotb 仿真,用于观察性能趋势。"
"实际 FPGA 实现的性能、资源利用率和最大工作频率Fmax可能因综合选项、"
"布局布线、芯片型号等因素而显著不同。请勿将本文档中的数据直接用作 FPGA "
"实现指标。"
)
lines.append("")
path = output_dir / "ch6_3_cam_perf_notes.md"
path.write_text("\n".join(lines))
def plot_figures(output_dir: Path, runs: list[PerfRun]) -> None:
"""Generate performance trend figures under ``figures/``.
Three single-panel PNGs and one 1×3 multi-panel PNG are produced
when at least 2 pass rows exist in a logical sweep group:
- ``cam_num_rows_vs_latency.png``
- ``cam_hash_bits_vs_latency.png``
- ``cam_lanes_vs_throughput.png``
- ``cam_perf_multipanel.png``
Uses ``logical_sweeps_for_config`` so the baseline ``(512,512,16)``
appears in all three groups.
"""
pass_runs = [r for r in runs if r.status == "pass"]
if not pass_runs:
return
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
# White / Nature-like rcParams defaults.
plt.rcParams.update({
"figure.facecolor": "white",
"axes.facecolor": "white",
"savefig.facecolor": "white",
"font.family": "DejaVu Sans",
"axes.linewidth": 0.8,
})
fig_dir = output_dir / "figures"
fig_dir.mkdir(parents=True, exist_ok=True)
# ── Define the three logical groups ──────────────────────────────────
groups: list[dict] = [
{
"group": "num_rows",
"x_getter": lambda c: c.num_rows,
"metric": "latency_cycles",
"xlabel": "NUM_ROWS",
"ylabel": "Latency (cycles)",
"title": "CAM Query Latency vs NUM_ROWS\n(HASH_BITS=512, LANES=16)",
"filename": "cam_num_rows_vs_latency.png",
"marker": "o",
},
{
"group": "hash_bits",
"x_getter": lambda c: c.hash_bits,
"metric": "latency_cycles",
"xlabel": "HASH_BITS",
"ylabel": "Latency (cycles)",
"title": "CAM Query Latency vs HASH_BITS\n(NUM_ROWS=512, LANES=16)",
"filename": "cam_hash_bits_vs_latency.png",
"marker": "s",
},
{
"group": "lanes",
"x_getter": lambda c: c.lanes,
"metric": "queries_per_cycle",
"xlabel": "LANES",
"ylabel": "Queries / Cycle",
"title": "CAM Throughput vs LANES\n(NUM_ROWS=512, HASH_BITS=512)",
"filename": "cam_lanes_vs_throughput.png",
"marker": "^",
},
]
# ── Individual figures ───────────────────────────────────────────────
for g in groups:
points = _plot_points(pass_runs, g["group"], g["x_getter"], g["metric"])
if len(points) < 2:
continue
xs, ys = zip(*points)
fig, ax = plt.subplots(figsize=(4.2, 3.0))
ax.plot(
xs, ys,
color=PLOT_NAVY, linewidth=1.6,
marker=g["marker"], markersize=4.5,
markerfacecolor=PLOT_TEAL, markeredgecolor="white", markeredgewidth=0.7,
)
_style_perf_axis(ax, title=g["title"], xlabel=g["xlabel"], ylabel=g["ylabel"])
fig.tight_layout(pad=0.7)
fig.savefig(fig_dir / g["filename"], dpi=300, bbox_inches="tight")
plt.close(fig)
# ── Multi-panel figure (1 × 3) ───────────────────────────────────────
fig, axes = plt.subplots(1, 3, figsize=(10.8, 3.0), constrained_layout=True)
for ax, g in zip(axes, groups):
points = _plot_points(pass_runs, g["group"], g["x_getter"], g["metric"])
if len(points) < 2:
ax.text(0.5, 0.5, "insufficient data", ha="center", va="center",
transform=ax.transAxes, fontsize=8, color=PLOT_SLATE)
continue
xs, ys = zip(*points)
ax.plot(
xs, ys,
color=PLOT_NAVY, linewidth=1.6,
marker=g["marker"], markersize=4.5,
markerfacecolor=PLOT_TEAL, markeredgecolor="white", markeredgewidth=0.7,
)
_style_perf_axis(ax, title=g["title"], xlabel=g["xlabel"], ylabel=g["ylabel"])
fig.savefig(fig_dir / "cam_perf_multipanel.png", dpi=300, bbox_inches="tight")
plt.close(fig)
# ---------------------------------------------------------------------------
# Orchestrator
# ---------------------------------------------------------------------------
def run_all(
configs: list[SweepConfig],
output_root: Path = Path("outputs/cam_perf"),
date: str | None = None,
repo_root: Path | None = None,
) -> int:
"""Run all sweep configurations sequentially, write CSV, return 0 if all pass.
Output is written under ``*output_root* / *date*``.
"""
if repo_root is None:
repo_root = Path(__file__).resolve().parent.parent
output_root = Path(output_root)
if not output_root.is_absolute():
output_root = repo_root / output_root
if date is None:
date = datetime.now().strftime("%Y-%m-%d")
output_dir = output_root / date
output_dir.mkdir(parents=True, exist_ok=True)
runs: list[PerfRun] = []
for config in configs:
run = run_one_config(config, output_dir=output_dir, repo_root=repo_root)
runs.append(run)
write_csv(output_dir, runs)
write_summary(output_dir, runs)
write_notes(output_dir, runs)
plot_figures(output_dir, runs)
all_pass = all(run.status == "pass" for run in runs)
return 0 if all_pass else 1
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def script_repo_root() -> Path:
"""Return the repository root (two levels up from ``scripts/sweep_cam_perf.py``)."""
return Path(__file__).resolve().parents[1]
# ---------------------------------------------------------------------------
# CLI
# ---------------------------------------------------------------------------
app = typer.Typer(
add_completion=False,
help=(
"Run CAM performance simulation sweep "
"and collect paper-ready outputs."
),
)
@app.command()
def main(
date: Annotated[str | None, typer.Option("--date", help="Output date directory. Defaults to today.")] = None,
single: Annotated[bool, typer.Option("--single", help="Run a single config instead of the full sweep.")] = False,
num_rows: Annotated[int, typer.Option("--num-rows", help="CAM row count.", min=1)] = 512,
hash_bits: Annotated[int, typer.Option("--hash-bits", help="Hash width.", min=1)] = 512,
lanes: Annotated[int, typer.Option("--lanes", help="CAM lane count.", min=1)] = 4,
) -> None:
"""Run the CAM performance simulation sweep."""
if single:
configs = [SweepConfig("single", num_rows, hash_bits, lanes)]
else:
configs = default_sweep_configs()
run_date = date or date_type.today().isoformat()
exit_code = run_all(
configs=configs,
date=run_date,
repo_root=script_repo_root(),
)
raise typer.Exit(exit_code)
if __name__ == "__main__":
app()