abin 55040733fe feat: implement Phase 1-4 performance visualization and device management
Phase 1 — Performance Benchmarking:
- PerformanceBenchmarker: sequential vs parallel benchmark with injectable runner
- PerformanceHistory: JSON-backed benchmark history with regression support
- PerformanceDashboard: real-time FPS/latency display widget
- BenchmarkDialog: one-click benchmark with 3-phase progress bar

Phase 2 — Device Management:
- DeviceManager: NPU dongle scan, assign/unassign, load balance recommendation
- DeviceManagementPanel: live device status cards with auto-refresh
- BottleneckAlert: dataclass for pipeline bottleneck detection

Phase 3 — Advanced Features:
- OptimizationEngine: 3 optimization rules (rebalance/adjust_queue/add_devices)
- TemplateManager: 3 built-in pipeline templates (YOLOv5, fire detection, dual-model)

Phase 4 — Report Export:
- ReportExporter: PDF (reportlab, optional) and CSV export
- ExportReportDialog: format selection + path picker UI

192 unit tests, all passing.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 19:32:05 +08:00

234 lines
7.6 KiB
Python
Raw Permalink 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.

"""
core/performance/history.py — Benchmark 歷史記錄模組。
提供 PerformanceHistory 類別,負責:
- 將 BenchmarkResult 以 JSON 格式持久化到本地磁碟。
- 依條件limit / mode查詢歷史記錄。
- 產生兩次測試間的回歸比較報告。
儲存格式範例:
{
"records": [
{
"id": "benchmark_20260405_143022",
"mode": "parallel",
"fps": 45.2,
"avg_latency_ms": 22.1,
"p95_latency_ms": 35.0,
"total_frames": 1356,
"timestamp": 1743856222.0,
"device_config": {"KL720": 2}
}
]
}
"""
import json
import logging
import os
import time
from datetime import datetime
from typing import Any, Dict, List, Optional
logger = logging.getLogger(__name__)
from .benchmarker import BenchmarkResult
class PerformanceHistory:
"""本地 Benchmark 歷史記錄管理器。
屬性:
storage_path: JSON 儲存檔案的完整路徑。
預設為 ``~/.cluster4npu/benchmark_history.json``。
"""
DEFAULT_STORAGE_PATH = os.path.join(
os.path.expanduser("~"), ".cluster4npu", "benchmark_history.json"
)
def __init__(self, storage_path: str = DEFAULT_STORAGE_PATH):
"""初始化 PerformanceHistory。
若儲存目錄不存在,會自動建立。
參數:
storage_path: JSON 儲存檔案路徑。
"""
self.storage_path = storage_path
self._ensure_storage_directory()
# ------------------------------------------------------------------
# 公開介面
# ------------------------------------------------------------------
def record(self, result: BenchmarkResult) -> None:
"""記錄一筆 BenchmarkResult 並持久化至 JSON。
此方法會:
1. 為結果產生唯一 id若尚未有 id
2. 將 id 寫回 result.id。
3. 追加到 JSON 儲存。
參數:
result: 要記錄的 BenchmarkResult。
"""
data = self._load_raw()
# 產生唯一 id
record_id = self._generate_id(result)
result.id = record_id
record_dict = self._result_to_dict(result)
data["records"].append(record_dict)
self._save_raw(data)
def get_history(
self,
limit: int = 50,
mode: Optional[str] = None,
) -> List[BenchmarkResult]:
"""查詢歷史記錄。
回傳最新優先reverse chronological的記錄列表。
參數:
limit: 最多回傳幾筆,預設 50。
mode: 若指定,只回傳符合 mode 的記錄('sequential''parallel')。
回傳:
List[BenchmarkResult],最新的記錄排在最前面。
"""
data = self._load_raw()
records = data.get("records", [])
# 過濾 mode
if mode is not None:
records = [r for r in records if r.get("mode") == mode]
# 最新優先(依 timestamp 降序)
records = sorted(records, key=lambda r: r.get("timestamp", 0), reverse=True)
# 套用 limit
records = records[:limit]
return [self._dict_to_result(r) for r in records]
def get_regression_report(
self,
baseline_id: str,
compare_id: str,
) -> Dict[str, Any]:
"""比較兩次測試的效能差異,產生回歸報告。
參數:
baseline_id: 基準測試的 id。
compare_id: 比較測試的 id。
回傳:
包含以下鍵的字典:
- baseline: BenchmarkResult基準
- compare: BenchmarkResult比較對象
- fps_change_pct: FPS 變化百分比(正值為改善)
- avg_latency_change_pct: 平均延遲變化百分比(負值為改善)
- p95_latency_change_pct: P95 延遲變化百分比(負值為改善)
引發:
ValueError: 若任一 id 不存在於歷史記錄中。
"""
data = self._load_raw()
all_records = {r["id"]: r for r in data.get("records", [])}
if baseline_id not in all_records:
raise ValueError(f"找不到基準測試 id{baseline_id}")
if compare_id not in all_records:
raise ValueError(f"找不到比較測試 id{compare_id}")
baseline = self._dict_to_result(all_records[baseline_id])
compare = self._dict_to_result(all_records[compare_id])
def pct_change(old: float, new: float) -> float:
"""計算相對變化百分比。"""
if old == 0:
return 0.0
return (new - old) / old * 100.0
return {
"baseline": baseline,
"compare": compare,
"fps_change_pct": pct_change(baseline.fps, compare.fps),
"avg_latency_change_pct": pct_change(
baseline.avg_latency_ms, compare.avg_latency_ms
),
"p95_latency_change_pct": pct_change(
baseline.p95_latency_ms, compare.p95_latency_ms
),
}
# ------------------------------------------------------------------
# 內部實作
# ------------------------------------------------------------------
def _ensure_storage_directory(self) -> None:
"""若儲存目錄不存在,自動建立。"""
parent_dir = os.path.dirname(self.storage_path)
if parent_dir:
os.makedirs(parent_dir, exist_ok=True)
def _load_raw(self) -> Dict[str, Any]:
"""從 JSON 檔案讀取原始資料。若檔案不存在或損毀,回傳空結構。"""
if not os.path.exists(self.storage_path):
return {"records": []}
try:
with open(self.storage_path, "r", encoding="utf-8") as f:
return json.load(f)
except json.JSONDecodeError as e:
logger.warning("歷史記錄 JSON 檔案損毀,降級回傳空結構:%s", e)
return {"records": []}
except (IOError, OSError) as e:
logger.warning("無法讀取歷史記錄檔案,降級回傳空結構:%s", e)
return {"records": []}
def _save_raw(self, data: Dict[str, Any]) -> None:
"""將資料寫入 JSON 檔案。"""
with open(self.storage_path, "w", encoding="utf-8") as f:
json.dump(data, f, ensure_ascii=False, indent=2)
@staticmethod
def _generate_id(result: BenchmarkResult) -> str:
"""依 timestamp 產生唯一識別碼。
格式:``benchmark_YYYYMMDD_HHMMSSffffff``
"""
dt = datetime.fromtimestamp(result.timestamp)
return dt.strftime("benchmark_%Y%m%d_%H%M%S%f")
@staticmethod
def _result_to_dict(result: BenchmarkResult) -> Dict[str, Any]:
"""將 BenchmarkResult 轉換為可序列化的字典。"""
return {
"id": result.id,
"mode": result.mode,
"fps": result.fps,
"avg_latency_ms": result.avg_latency_ms,
"p95_latency_ms": result.p95_latency_ms,
"total_frames": result.total_frames,
"timestamp": result.timestamp,
"device_config": result.device_config,
}
@staticmethod
def _dict_to_result(data: Dict[str, Any]) -> BenchmarkResult:
"""將字典轉換回 BenchmarkResult。"""
return BenchmarkResult(
id=data.get("id"),
mode=data["mode"],
fps=data["fps"],
avg_latency_ms=data["avg_latency_ms"],
p95_latency_ms=data["p95_latency_ms"],
total_frames=data["total_frames"],
timestamp=data["timestamp"],
device_config=data.get("device_config", {}),
)