mirror of
https://github.com/SikongJueluo/Mini-Nav.git
synced 2026-07-12 20:15:31 +08:00
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
This commit is contained in:
762
scripts/sweep_cam_perf.py
Normal file
762
scripts/sweep_cam_perf.py
Normal file
@@ -0,0 +1,762 @@
|
||||
#!/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_ROWS(CAM 行数)")
|
||||
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()
|
||||
Reference in New Issue
Block a user