增加可选性能埋点并完成物性效率评估

This commit is contained in:
ljz committed 2026-08-16 17:46:04 +08:00
1 parent 4e0b9fd8cc
commit 57b459bc72
21 files changed
+2029 -179

No files matched your search

+58
View File
@@ -0,0 +1,58 @@
from __future__ import annotations
import unittest
from app.simulation.benchmark_performance import (
_duration_summary,
_load_factory_xml,
_named_value,
_serialize_result_event,
)
def sample_xml_factory() -> bytes:
return b"<System/>"
class PerformanceBenchmarkToolTests(unittest.TestCase):
def test_named_value_requires_an_explicit_name(self) -> None:
self.assertEqual(
_named_value("chain=tests.example:project", option="--factory"),
("chain", "tests.example:project"),
)
with self.assertRaisesRegex(ValueError, "NAME=VALUE"):
_named_value("tests.example:project", option="--factory")
def test_duration_summary_reports_repeatable_order_statistics(self) -> None:
summary = _duration_summary([5.0, 1.0, 3.0, 2.0, 4.0])
self.assertEqual(summary["minimumMs"], 1.0)
self.assertEqual(summary["medianMs"], 3.0)
self.assertEqual(summary["p95Ms"], 5.0)
self.assertEqual(summary["maximumMs"], 5.0)
def test_factory_loader_accepts_a_bytes_factory(self) -> None:
self.assertEqual(
_load_factory_xml(
"tests.test_performance_benchmark:sample_xml_factory"
),
b"<System/>",
)
def test_result_event_serialization_uses_ndjson_shape(self) -> None:
payload = _serialize_result_event(
{
"success": True,
"status": "completed",
"simulatedUntil": 1.0,
"requestedStopTime": 1.0,
}
)
self.assertTrue(payload.endswith(b"\n"))
self.assertIn(b'"event":"result"', payload)
self.assertIn(b'"result":{"success":true', payload)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,141 @@
from __future__ import annotations
import unittest
from unittest.mock import Mock, patch
from app.simulation.components.amesim.media.mediums import (
AmesimHeliumPengRobinsonMedium,
)
class PropertyPerformanceInstrumentationTests(unittest.TestCase):
def setUp(self) -> None:
self.medium = AmesimHeliumPengRobinsonMedium()
self.medium.temperature_from_pressure_enthalpy.cache_clear()
self.medium.properties_from_mU.cache_clear()
def tearDown(self) -> None:
self.medium.temperature_from_pressure_enthalpy.cache_clear()
self.medium.properties_from_mU.cache_clear()
def test_temperature_iterations_are_recorded_only_on_cache_miss(self) -> None:
pressure = 15.3e6
temperature = 293.15
enthalpy = self.medium.specific_enthalpy_at_pressure(
pressure,
temperature,
)
with patch(
"app.simulation.components.amesim.media.mediums."
"record_property_iterations"
) as record_iterations:
first = self.medium.temperature_from_pressure_enthalpy(
pressure,
enthalpy,
)
second = self.medium.temperature_from_pressure_enthalpy(
pressure,
enthalpy,
)
self.assertEqual(second, first)
record_iterations.assert_called_once()
operation, iterations, converged = record_iterations.call_args.args
self.assertEqual(operation, "temperature_from_pressure_enthalpy")
self.assertEqual(iterations, 5)
self.assertTrue(converged)
def test_state_recovery_iterations_are_recorded_only_on_cache_miss(self) -> None:
pressure = 15.3e6
temperature = 293.15
volume = 0.057
mass = self.medium.density(pressure, temperature) * volume
internal_energy = mass * self.medium.specific_internal_energy_at_pressure(
pressure,
temperature,
)
with patch(
"app.simulation.components.amesim.media.mediums."
"record_property_iterations"
) as record_iterations:
first = self.medium.properties_from_mU(mass, internal_energy, volume)
second = self.medium.properties_from_mU(mass, internal_energy, volume)
self.assertIs(second, first)
record_iterations.assert_called_once()
operation, iterations, converged = record_iterations.call_args.args
self.assertEqual(operation, "properties_from_mU")
self.assertEqual(iterations, 5)
self.assertTrue(converged)
def test_temperature_iteration_limit_is_reported_as_not_converged(self) -> None:
pressure = 123_456.0
base_temperature = 300.0
enthalpy = self.medium.specific_enthalpy(base_temperature)
fake_fluid = Mock()
fake_fluid.residual_specific_enthalpy.side_effect = (
lambda _pressure, temperature: (
self.medium.cp_ref * 10.0
if temperature >= base_temperature
else -self.medium.cp_ref * 10.0
)
)
with (
patch.object(AmesimHeliumPengRobinsonMedium, "fluid", fake_fluid),
patch(
"app.simulation.components.amesim.media.mediums."
"record_property_iterations"
) as record_iterations,
):
self.medium.temperature_from_pressure_enthalpy(
pressure,
enthalpy,
)
record_iterations.assert_called_once_with(
"temperature_from_pressure_enthalpy",
16,
False,
)
def test_state_recovery_iteration_limit_is_reported_as_not_converged(self) -> None:
base_temperature = 300.0
target_internal_energy = self.medium.specific_internal_energy(
base_temperature
)
fake_fluid = Mock()
fake_fluid.residual_specific_internal_energy_at_density.side_effect = (
lambda temperature, _density: (
self.medium.cv * 10.0
if temperature >= base_temperature
else -self.medium.cv * 10.0
)
)
fake_fluid.pressure_from_density.return_value = 101_325.0
fake_fluid.residual_specific_enthalpy.return_value = 0.0
with (
patch.object(AmesimHeliumPengRobinsonMedium, "fluid", fake_fluid),
patch(
"app.simulation.components.amesim.media.mediums."
"record_property_iterations"
) as record_iterations,
):
self.medium.properties_from_mU(
1.0,
target_internal_energy,
1.0,
)
record_iterations.assert_called_once_with(
"properties_from_mU",
16,
False,
)
if __name__ == "__main__":
unittest.main()
+248
View File
@@ -0,0 +1,248 @@
from __future__ import annotations
import asyncio
from contextlib import contextmanager
from functools import lru_cache
import importlib
import os
import unittest
from unittest.mock import patch
import app.simulation.performance as performance
@contextmanager
def profiling_mode(mode: str):
previous = os.environ.get("SIMULATIONAPP_PROFILE")
os.environ["SIMULATIONAPP_PROFILE"] = mode
module = importlib.reload(performance)
try:
yield module
finally:
if previous is None:
os.environ.pop("SIMULATIONAPP_PROFILE", None)
else:
os.environ["SIMULATIONAPP_PROFILE"] = previous
importlib.reload(performance)
class SimulationPerformanceTests(unittest.TestCase):
def test_off_mode_decorators_return_original_callables(self) -> None:
with profiling_mode("off") as module:
def phase_function(value: int) -> int:
return value + 1
def property_function(value: int) -> int:
return value * 2
self.assertIs(
module.profile_phase("phase")(phase_function),
phase_function,
)
self.assertIs(
module.profile_phase("phase", minimum_mode="off")(phase_function),
phase_function,
)
self.assertIs(
module.profile_property("property")(property_function),
property_function,
)
self.assertIs(
module.profile_property(
"property",
minimum_mode="off",
)(property_function),
property_function,
)
with module.profile_run() as trace:
self.assertEqual(phase_function(2), 3)
self.assertEqual(
trace.snapshot(),
{
"mode": "off",
"phases": {},
"properties": {},
"propertyOutermostNs": 0,
},
)
def test_standard_mode_records_nested_inclusive_and_self_time(self) -> None:
with profiling_mode("standard") as module:
@module.profile_phase("inner")
def inner() -> None:
return None
@module.profile_phase("outer")
def outer() -> None:
inner()
clock = iter((0, 10, 20, 30, 50, 80))
with patch.object(module, "perf_counter_ns", side_effect=clock):
with module.profile_run() as trace:
outer()
snapshot = trace.snapshot()
self.assertEqual(
snapshot["phases"]["inner"],
{
"calls": 1,
"inclusiveNs": 10,
"selfNs": 10,
"maxNs": 10,
"errors": 0,
},
)
self.assertEqual(snapshot["phases"]["outer"]["inclusiveNs"], 40)
self.assertEqual(snapshot["phases"]["outer"]["selfNs"], 30)
self.assertEqual(
snapshot["phases"]["simulation.total"]["inclusiveNs"],
80,
)
self.assertEqual(
snapshot["phases"]["simulation.total"]["selfNs"],
40,
)
def test_errors_are_recorded_and_propagated(self) -> None:
with profiling_mode("standard") as module:
@module.profile_phase("explode")
def explode() -> None:
raise RuntimeError("expected")
with self.assertRaisesRegex(RuntimeError, "expected"):
with module.profile_run() as trace:
explode()
snapshot = trace.snapshot()
self.assertEqual(snapshot["phases"]["explode"]["errors"], 1)
self.assertEqual(
snapshot["phases"]["simulation.total"]["errors"],
1,
)
def test_manual_performance_span_uses_the_current_trace(self) -> None:
with profiling_mode("standard") as module:
clock = iter((0, 10, 20, 30))
with patch.object(module, "perf_counter_ns", side_effect=clock):
with module.profile_run() as trace:
with module.performance_span("manual"):
pass
snapshot = trace.snapshot()
self.assertEqual(snapshot["phases"]["manual"]["inclusiveNs"], 10)
self.assertEqual(snapshot["phases"]["simulation.total"]["selfNs"], 20)
def test_nested_properties_only_add_outermost_time_once(self) -> None:
with profiling_mode("standard") as module:
class TestMedium:
name = "TestMedium"
@module.profile_property("inner", minimum_mode="standard")
def inner(self) -> float:
return 1.0
@module.profile_property("outer", minimum_mode="standard")
def outer(self) -> float:
return self.inner()
medium = TestMedium()
clock = iter((0, 10, 20, 30, 50, 80))
with patch.object(module, "perf_counter_ns", side_effect=clock):
with module.profile_run() as trace:
self.assertEqual(medium.outer(), 1.0)
snapshot = trace.snapshot()
outer = snapshot["properties"]["semantic.TestMedium.outer"]
inner = snapshot["properties"]["semantic.TestMedium.inner"]
self.assertEqual(outer["inclusiveNs"], 40)
self.assertEqual(outer["selfNs"], 30)
self.assertEqual(inner["inclusiveNs"], 10)
self.assertEqual(snapshot["propertyOutermostNs"], 40)
def test_audit_resets_exact_input_shadow_and_records_iterations_and_cache(self) -> None:
with profiling_mode("audit") as module:
class TestMedium:
name = "AuditMedium"
@module.profile_property("inverse", track_cache=True)
@lru_cache(maxsize=4)
def inverse(self, value: float) -> float:
module.record_property_iterations(
"inverse",
3 if value == 1.0 else 4,
value == 1.0,
)
return value * 2.0
medium = TestMedium()
@module.profile_phase("closure", reset_property_shadow=True)
def closure() -> None:
medium.inverse(1.0)
medium.inverse(1.0)
medium.inverse(2.0)
with module.profile_run() as trace:
closure()
closure()
metric = trace.snapshot()["properties"][
"semantic.AuditMedium.inverse"
]
self.assertEqual(metric["calls"], 6)
self.assertEqual(metric["exactInputUnique"], 4)
self.assertEqual(metric["exactInputRepeats"], 2)
self.assertEqual(metric["iterationCalls"], 2)
self.assertEqual(metric["iterationTotal"], 7)
self.assertEqual(metric["iterationMax"], 4)
self.assertEqual(metric["iterationConverged"], 1)
self.assertEqual(metric["iterationNonconverged"], 1)
self.assertEqual(metric["cacheLookups"], 6)
self.assertEqual(metric["cacheHits"], 4)
self.assertEqual(metric["cacheMisses"], 2)
self.assertTrue(callable(medium.inverse.cache_clear))
self.assertTrue(callable(medium.inverse.cache_info))
self.assertTrue(callable(medium.inverse.cache_parameters))
medium.inverse.cache_clear()
self.assertEqual(medium.inverse.cache_info().currsize, 0)
def test_audit_minimum_decorator_is_absent_in_standard_mode(self) -> None:
with profiling_mode("standard") as module:
def kernel(value: float) -> float:
return value
self.assertIs(
module.profile_property(
"kernel",
layer="kernel",
minimum_mode="audit",
)(kernel),
kernel,
)
def test_async_profile_runs_are_context_isolated(self) -> None:
with profiling_mode("standard") as module:
@module.profile_phase("work")
async def work() -> None:
await asyncio.sleep(0)
async def one_run() -> dict[str, object]:
with module.profile_run() as trace:
await work()
return trace.snapshot()
async def exercise() -> list[dict[str, object]]:
return await asyncio.gather(one_run(), one_run())
snapshots = asyncio.run(exercise())
self.assertEqual(
[snapshot["phases"]["work"]["calls"] for snapshot in snapshots],
[1, 1],
)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,171 @@
from __future__ import annotations
import json
import os
from pathlib import Path
import subprocess
import sys
import textwrap
import unittest
PROJECT_ROOT = Path(__file__).resolve().parent.parent
class SimulationPerformancePipelineTests(unittest.TestCase):
def test_off_mode_does_not_change_result_diagnostics(self) -> None:
script = textwrap.dedent(
"""
from app.main import (
build_reactflow_system_xml,
run_system_xml_simulation,
)
from tests.test_amesim_pnl00r_xml import amesim_pnl00r_project
result = run_system_xml_simulation(
build_reactflow_system_xml(amesim_pnl00r_project())
)
assert result["success"], result["message"]
print("performance" in result["diagnostics"])
"""
)
environment = os.environ.copy()
environment["SIMULATIONAPP_PROFILE"] = "off"
completed = subprocess.run(
[sys.executable, "-c", script],
cwd=PROJECT_ROOT,
env=environment,
check=True,
capture_output=True,
text=True,
timeout=30,
)
self.assertEqual(completed.stdout.strip(), "False")
def test_standard_mode_reports_coarse_pipeline_phases_per_run(self) -> None:
script = textwrap.dedent(
"""
import json
from app.main import (
build_reactflow_system_xml,
run_system_xml_simulation,
)
from tests.test_amesim_pnl00r_xml import amesim_pnl00r_project
xml = build_reactflow_system_xml(amesim_pnl00r_project())
snapshots = []
for _ in range(2):
result = run_system_xml_simulation(xml)
assert result["success"], result["message"]
performance = result["diagnostics"]["performance"]
snapshots.append(
{
"mode": performance["mode"],
"calls": {
name: metrics["calls"]
for name, metrics in performance["phases"].items()
},
"propertyCount": len(performance["properties"]),
}
)
print(json.dumps(snapshots))
"""
)
environment = os.environ.copy()
environment["SIMULATIONAPP_PROFILE"] = "standard"
completed = subprocess.run(
[sys.executable, "-c", script],
cwd=PROJECT_ROOT,
env=environment,
check=True,
capture_output=True,
text=True,
timeout=30,
)
snapshots = json.loads(completed.stdout)
required_phases = {
"simulation.total",
"simulation.xml_validation",
"simulation.network_compilation",
"simulation.system_construction",
"simulation.sample_initialization",
"simulation.integration",
"simulation.postprocessing",
"simulation.result_assembly",
"simulation.response_assembly",
}
audit_only_phases = {
"simulation.rhs",
"simulation.closure",
"simulation.refresh",
"simulation.pressure_flow",
"simulation.pneumatic_volume",
"simulation.stream",
"simulation.signal",
"simulation.derivatives",
}
self.assertEqual(len(snapshots), 2)
for snapshot in snapshots:
self.assertEqual(snapshot["mode"], "standard")
self.assertTrue(required_phases.issubset(snapshot["calls"]))
self.assertEqual(snapshot["calls"]["simulation.total"], 1)
self.assertEqual(snapshot["calls"]["simulation.xml_validation"], 1)
self.assertEqual(snapshot["calls"]["simulation.network_compilation"], 1)
self.assertEqual(snapshot["calls"]["simulation.integration"], 1)
self.assertEqual(snapshot["calls"]["simulation.postprocessing"], 1)
self.assertEqual(snapshot["calls"]["simulation.result_assembly"], 1)
self.assertEqual(snapshot["calls"]["simulation.response_assembly"], 1)
self.assertTrue(audit_only_phases.isdisjoint(snapshot["calls"]))
self.assertEqual(snapshot["propertyCount"], 0)
def test_audit_mode_reports_hot_phases_and_property_metrics(self) -> None:
script = textwrap.dedent(
"""
import json
from app.main import (
build_reactflow_system_xml,
run_system_xml_simulation,
)
from tests.test_amesim_pnl00r_xml import amesim_pnl00r_project
result = run_system_xml_simulation(
build_reactflow_system_xml(amesim_pnl00r_project())
)
assert result["success"], result["message"]
print(json.dumps(result["diagnostics"]["performance"]))
"""
)
environment = os.environ.copy()
environment["SIMULATIONAPP_PROFILE"] = "audit"
completed = subprocess.run(
[sys.executable, "-c", script],
cwd=PROJECT_ROOT,
env=environment,
check=True,
capture_output=True,
text=True,
timeout=30,
)
snapshot = json.loads(completed.stdout)
for phase in (
"simulation.rhs",
"simulation.closure",
"simulation.refresh",
"simulation.pressure_flow",
"simulation.pneumatic_volume",
"simulation.stream",
"simulation.signal",
"simulation.derivatives",
):
self.assertGreater(snapshot["phases"][phase]["calls"], 0)
self.assertGreater(len(snapshot["properties"]), 0)
self.assertGreater(snapshot["propertyOutermostNs"], 0)
if __name__ == "__main__":
unittest.main()