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>
283 lines
10 KiB
Python
283 lines
10 KiB
Python
"""
|
||
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_benchmark(Mock 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_benchmark(Mock 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)
|