cluster4npu/tests/unit/test_benchmarker.py
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

283 lines
10 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.

"""
PerformanceBenchmarker 的單元測試。
測試策略:
- BenchmarkConfig / BenchmarkResult 資料結構驗證
- calculate_speedup() 純計算邏輯
- run_sequential_benchmark() / run_parallel_benchmark() 透過注入的
inference_runner callable 進行 Mock不需要實際硬體
- run_full_benchmark() 整合流程
"""
import time
import pytest
from unittest.mock import MagicMock, patch
from core.performance.benchmarker import (
BenchmarkConfig,
BenchmarkResult,
PerformanceBenchmarker,
)
# ---------------------------------------------------------------------------
# 輔助:建立測試用資料結構
# ---------------------------------------------------------------------------
def make_config(**kwargs) -> BenchmarkConfig:
"""建立測試用 BenchmarkConfig提供合理的預設值。"""
defaults = dict(
pipeline_config=[],
test_duration_seconds=1.0,
warmup_frames=2,
test_input_source="test_video.mp4",
)
defaults.update(kwargs)
return BenchmarkConfig(**defaults)
def make_result(mode: str = "sequential", fps: float = 30.0) -> BenchmarkResult:
"""建立測試用 BenchmarkResult。"""
avg_latency_ms = (1000.0 / fps) if fps > 0 else 0.0
return BenchmarkResult(
mode=mode,
fps=fps,
avg_latency_ms=avg_latency_ms,
p95_latency_ms=avg_latency_ms * 1.5,
total_frames=int(fps * 30),
timestamp=time.time(),
device_config={"KL520": 1},
)
# ---------------------------------------------------------------------------
# 測試BenchmarkConfig 資料結構
# ---------------------------------------------------------------------------
class TestBenchmarkConfig:
def should_have_default_duration_30_seconds(self):
"""test_duration_seconds 預設值應為 30.0。"""
config = BenchmarkConfig(
pipeline_config=[],
test_input_source="video.mp4",
)
assert config.test_duration_seconds == 30.0
def should_have_default_warmup_50_frames(self):
"""warmup_frames 預設值應為 50。"""
config = BenchmarkConfig(
pipeline_config=[],
test_input_source="video.mp4",
)
assert config.warmup_frames == 50
def should_allow_custom_duration(self):
"""應可自訂 test_duration_seconds。"""
config = BenchmarkConfig(
pipeline_config=[],
test_input_source="video.mp4",
test_duration_seconds=10.0,
)
assert config.test_duration_seconds == 10.0
# ---------------------------------------------------------------------------
# 測試BenchmarkResult 資料結構
# ---------------------------------------------------------------------------
class TestBenchmarkResult:
def should_store_all_required_fields(self):
"""BenchmarkResult 應儲存所有規格要求的欄位。"""
ts = time.time()
result = BenchmarkResult(
mode="parallel",
fps=45.2,
avg_latency_ms=22.1,
p95_latency_ms=35.0,
total_frames=1356,
timestamp=ts,
device_config={"KL720": 2},
)
assert result.mode == "parallel"
assert result.fps == pytest.approx(45.2)
assert result.avg_latency_ms == pytest.approx(22.1)
assert result.p95_latency_ms == pytest.approx(35.0)
assert result.total_frames == 1356
assert result.timestamp == pytest.approx(ts)
assert result.device_config == {"KL720": 2}
def should_accept_sequential_mode(self):
"""mode 欄位應接受 'sequential'"""
result = make_result(mode="sequential")
assert result.mode == "sequential"
def should_accept_parallel_mode(self):
"""mode 欄位應接受 'parallel'"""
result = make_result(mode="parallel")
assert result.mode == "parallel"
# ---------------------------------------------------------------------------
# 測試calculate_speedup純計算無外部依賴
# ---------------------------------------------------------------------------
class TestCalculateSpeedup:
def should_return_ratio_of_parallel_to_sequential_fps(self):
"""calculate_speedup 應回傳 par.fps / seq.fps。"""
benchmarker = PerformanceBenchmarker()
seq = make_result(mode="sequential", fps=20.0)
par = make_result(mode="parallel", fps=60.0)
speedup = benchmarker.calculate_speedup(seq, par)
assert speedup == pytest.approx(3.0)
def should_return_one_when_same_fps(self):
"""相同 FPS 時 speedup 應為 1.0。"""
benchmarker = PerformanceBenchmarker()
result = make_result(fps=30.0)
speedup = benchmarker.calculate_speedup(result, result)
assert speedup == pytest.approx(1.0)
def should_raise_when_sequential_fps_is_zero(self):
"""seq.fps 為 0 時應引發 ValueError避免除以零。"""
benchmarker = PerformanceBenchmarker()
seq = make_result(fps=0.0)
par = make_result(fps=30.0)
with pytest.raises(ValueError):
benchmarker.calculate_speedup(seq, par)
# ---------------------------------------------------------------------------
# 測試run_sequential_benchmarkMock inference_runner
# ---------------------------------------------------------------------------
class TestRunSequentialBenchmark:
def should_return_benchmark_result_with_sequential_mode(self):
"""run_sequential_benchmark() 應回傳 mode='sequential' 的 BenchmarkResult。"""
benchmarker = PerformanceBenchmarker()
config = make_config(warmup_frames=1, test_duration_seconds=0.1)
# Mock inference_runner每次呼叫模擬 10ms 推論
def fake_runner(frame_data):
time.sleep(0.01)
return {"result": "ok"}
result = benchmarker.run_sequential_benchmark(config, inference_runner=fake_runner)
assert isinstance(result, BenchmarkResult)
assert result.mode == "sequential"
def should_report_positive_fps(self):
"""FPS 應大於 0。"""
benchmarker = PerformanceBenchmarker()
config = make_config(warmup_frames=1, test_duration_seconds=0.1)
def fake_runner(frame_data):
time.sleep(0.01)
return {}
result = benchmarker.run_sequential_benchmark(config, inference_runner=fake_runner)
assert result.fps > 0
def should_report_positive_latency(self):
"""avg_latency_ms 和 p95_latency_ms 應大於 0。"""
benchmarker = PerformanceBenchmarker()
config = make_config(warmup_frames=1, test_duration_seconds=0.1)
def fake_runner(frame_data):
time.sleep(0.01)
return {}
result = benchmarker.run_sequential_benchmark(config, inference_runner=fake_runner)
assert result.avg_latency_ms > 0
assert result.p95_latency_ms > 0
def should_count_frames_excluding_warmup(self):
"""total_frames 不應包含暖機幀數。"""
benchmarker = PerformanceBenchmarker()
call_times = []
def fake_runner(frame_data):
call_times.append(time.time())
time.sleep(0.005)
return {}
config = make_config(warmup_frames=3, test_duration_seconds=0.1)
result = benchmarker.run_sequential_benchmark(config, inference_runner=fake_runner)
# warmup 幀不計入 total_frames
assert result.total_frames < len(call_times)
assert result.total_frames > 0
def should_use_device_config_from_benchmarker(self):
"""BenchmarkResult.device_config 應由 PerformanceBenchmarker 填寫。"""
benchmarker = PerformanceBenchmarker(device_config={"KL520": 1})
config = make_config(warmup_frames=1, test_duration_seconds=0.05)
def fake_runner(frame_data):
return {}
result = benchmarker.run_sequential_benchmark(config, inference_runner=fake_runner)
assert result.device_config == {"KL520": 1}
# ---------------------------------------------------------------------------
# 測試run_parallel_benchmarkMock inference_runner
# ---------------------------------------------------------------------------
class TestRunParallelBenchmark:
def should_return_benchmark_result_with_parallel_mode(self):
"""run_parallel_benchmark() 應回傳 mode='parallel' 的 BenchmarkResult。"""
benchmarker = PerformanceBenchmarker()
config = make_config(warmup_frames=1, test_duration_seconds=0.1)
def fake_runner(frame_data):
time.sleep(0.01)
return {}
result = benchmarker.run_parallel_benchmark(config, inference_runner=fake_runner)
assert isinstance(result, BenchmarkResult)
assert result.mode == "parallel"
# ---------------------------------------------------------------------------
# 測試run_full_benchmark
# ---------------------------------------------------------------------------
class TestRunFullBenchmark:
def should_return_tuple_of_seq_par_speedup(self):
"""run_full_benchmark() 應回傳 (BenchmarkResult, BenchmarkResult, float)。"""
benchmarker = PerformanceBenchmarker()
config = make_config(warmup_frames=1, test_duration_seconds=0.05)
def fast_runner(frame_data):
time.sleep(0.005)
return {}
seq_result, par_result, speedup = benchmarker.run_full_benchmark(
config, inference_runner=fast_runner
)
assert isinstance(seq_result, BenchmarkResult)
assert isinstance(par_result, BenchmarkResult)
assert isinstance(speedup, float)
assert seq_result.mode == "sequential"
assert par_result.mode == "parallel"
def should_calculate_speedup_consistently(self):
"""speedup 應與 calculate_speedup(seq, par) 的結果一致。"""
benchmarker = PerformanceBenchmarker()
config = make_config(warmup_frames=1, test_duration_seconds=0.05)
def fake_runner(frame_data):
time.sleep(0.005)
return {}
seq_result, par_result, speedup = benchmarker.run_full_benchmark(
config, inference_runner=fake_runner
)
expected_speedup = benchmarker.calculate_speedup(seq_result, par_result)
assert speedup == pytest.approx(expected_speedup)