forked from masonhuang/cluster4npu
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>
234 lines
7.6 KiB
Python
234 lines
7.6 KiB
Python
"""
|
||
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", {}),
|
||
)
|