200 lines
9.1 KiB
Python
200 lines
9.1 KiB
Python
"""Real native cache integration on both Windows and Linux.
|
|
|
|
The fixture uses the configured compiler and SUNDIALS installation directly.
|
|
CI sets SIMULATION_NATIVE_REQUIRE_TOOLCHAIN=1 so missing native dependencies
|
|
fail this suite instead of silently skipping platform acceptance.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
from dataclasses import replace
|
|
from hashlib import sha256
|
|
import json
|
|
import math
|
|
import os
|
|
from pathlib import Path
|
|
import subprocess
|
|
import tempfile
|
|
import threading
|
|
import unittest
|
|
from unittest.mock import patch
|
|
import xml.etree.ElementTree as ET
|
|
|
|
from app.main import compile_system_xml_network
|
|
from app.simulation.backends import simulation_config
|
|
from app.simulation.config import SolverActivityTracker
|
|
from app.simulation.native_codegen import build as native_build
|
|
from app.simulation.native_codegen.cache_storage import prune_cache
|
|
from app.simulation.native_codegen.compiler import compile_native_program
|
|
from app.simulation.native_codegen.runner import execute_native
|
|
from app.system_xml import validate_system_xml_document
|
|
|
|
|
|
ROOT = Path(__file__).resolve().parents[1]
|
|
FIXTURE = ROOT / "tests/fixtures/native-skill-test.xml"
|
|
|
|
|
|
class NativeCachePlatformTests(unittest.TestCase):
|
|
@classmethod
|
|
def setUpClass(cls):
|
|
try:
|
|
cls.compiler, cls.sundials, cls.compiler_version = native_build.toolchain()
|
|
except (OSError, RuntimeError, subprocess.SubprocessError) as exc:
|
|
message = f"Native cache platform test requires a working configured toolchain: {exc}"
|
|
if os.environ.get("SIMULATION_NATIVE_REQUIRE_TOOLCHAIN") == "1":
|
|
raise RuntimeError(message) from exc
|
|
raise unittest.SkipTest(message) from exc
|
|
|
|
def setUp(self):
|
|
self.temporary = tempfile.TemporaryDirectory(prefix="native-platform-")
|
|
self.addCleanup(self.temporary.cleanup)
|
|
# Exercise argument handling with spaces without assuming shell quoting.
|
|
self.root = Path(self.temporary.name) / "cache integration"
|
|
self.root.mkdir()
|
|
self.cache = self.root / "cache"
|
|
self.real_run = subprocess.run
|
|
self._builds = []
|
|
self.addCleanup(self._close_builds)
|
|
self.document = self._document(FIXTURE.read_bytes())
|
|
self.program = compile_native_program(compile_system_xml_network(self.document))
|
|
self.config = replace(simulation_config(self.document.simulation),
|
|
method="RK45", t_stop=0.1)
|
|
|
|
def _close_builds(self):
|
|
for build in reversed(self._builds):
|
|
build.close()
|
|
|
|
def _document(self, xml):
|
|
report = validate_system_xml_document(xml)
|
|
self.assertTrue(report.valid, report.as_dict())
|
|
self.assertIsNotNone(report.document)
|
|
return report.document
|
|
|
|
def _build(self, program):
|
|
commands = []
|
|
lock = threading.Lock()
|
|
|
|
def record(command, *args, **kwargs):
|
|
with lock:
|
|
commands.append(tuple(map(str, command)))
|
|
return self.real_run(command, *args, **kwargs)
|
|
|
|
with patch.object(native_build.subprocess, "run", side_effect=record):
|
|
build = native_build.build_native(program, cache_dir=self.cache)
|
|
self._builds.append(build)
|
|
return build, commands
|
|
|
|
@staticmethod
|
|
def _compile_count(commands):
|
|
return sum("-c" in command for command in commands)
|
|
|
|
@staticmethod
|
|
def _link_count(commands):
|
|
return sum("-o" in command and "-c" not in command and "-E" not in command
|
|
for command in commands)
|
|
|
|
def _execute(self, build, name, **kwargs):
|
|
payload = execute_native(build, self.config, 0.02,
|
|
run_dir=self.root / name, timeout=30, **kwargs)
|
|
self.assertTrue(payload["success"], payload.get("message"))
|
|
self.assertEqual(payload["simulatedUntil"], self.config.t_stop)
|
|
self.assertEqual(payload["series"]["time"][-1], self.config.t_stop)
|
|
self.assertEqual(set(payload["series"]), {"time", *(v.key for v in self.program.variables)})
|
|
self.assertTrue(all(math.isfinite(value)
|
|
for series in payload["series"].values() for value in series))
|
|
return payload
|
|
|
|
def _assert_windows_package(self, build):
|
|
if os.name != "nt":
|
|
return
|
|
self.assertEqual(build.executable.name, "model.exe")
|
|
names = [f"sundials_{name}.dll" for name in native_build.LIBRARIES]
|
|
if (self.sundials / "bin/vcruntime140.dll").is_file():
|
|
names.append("vcruntime140.dll")
|
|
for name in names:
|
|
with self.subTest(dll=name):
|
|
original = self.sundials / "bin" / name
|
|
packaged = build.executable.parent / name
|
|
self.assertTrue(packaged.is_file())
|
|
digest = sha256(packaged.read_bytes()).hexdigest()
|
|
self.assertEqual(digest, sha256(original.read_bytes()).hexdigest())
|
|
self.assertEqual(digest, build.manifest["artifacts"][name])
|
|
self.assertEqual(digest, build.manifest["buildIdentity"]["dependencies"][name])
|
|
# The EXE must load its copied DLLs without the compiler/Conda directories.
|
|
system32 = Path(os.environ.get("SystemRoot", r"C:\Windows")) / "System32"
|
|
completed = self.real_run(
|
|
[str(build.executable), "--init"], cwd=self.root,
|
|
env={**os.environ, "PATH": str(system32)},
|
|
capture_output=True, text=True, check=True, timeout=15,
|
|
)
|
|
self.assertEqual(len(json.loads(completed.stdout)), len(self.program.state_keys))
|
|
|
|
def test_real_build_execution_reuse_and_in_use_eviction(self):
|
|
self.assertFalse(self.cache.exists())
|
|
cold, cold_commands = self._build(self.program)
|
|
self.assertFalse(cold.cache_hit)
|
|
self.assertGreater(self._compile_count(cold_commands), 1)
|
|
self.assertEqual(self._link_count(cold_commands), 1)
|
|
self.assertEqual((cold.executable.parent / "model.c").read_bytes(), self.program.source.encode())
|
|
self.assertEqual((cold.executable.parent / "model.h").read_bytes(), self.program.header.encode())
|
|
self._assert_windows_package(cold)
|
|
cold_result = self._execute(cold, "cold run")
|
|
|
|
warm, warm_commands = self._build(self.program)
|
|
self.assertTrue(warm.cache_hit)
|
|
self.assertEqual(cold.manifest["buildKey"], warm.manifest["buildKey"])
|
|
self.assertEqual(self._compile_count(warm_commands), 0)
|
|
self.assertEqual(self._link_count(warm_commands), 0)
|
|
self.assertEqual(warm.details["objectCompilations"], 0)
|
|
self.assertEqual(warm.details["linkSeconds"], 0)
|
|
warm_result = self._execute(warm, "warm run")
|
|
self.assertEqual(cold_result["series"], warm_result["series"])
|
|
self.assertEqual(cold_result["finalState"], warm_result["finalState"])
|
|
|
|
xml = ET.fromstring(FIXTURE.read_bytes())
|
|
pressure = xml.find("./Components/Component[@id='amesim_pnch023_1']/Parameter[@name='p0']")
|
|
self.assertIsNotNone(pressure)
|
|
pressure.set("value", "16000000")
|
|
modified = self._document(ET.tostring(xml, encoding="utf-8"))
|
|
changed_program = compile_native_program(compile_system_xml_network(modified))
|
|
self.assertEqual(changed_program.header, self.program.header)
|
|
changed, changed_commands = self._build(changed_program)
|
|
self.assertFalse(changed.cache_hit)
|
|
self.assertNotEqual(changed.manifest["buildKey"], cold.manifest["buildKey"])
|
|
self.assertEqual(self._compile_count(changed_commands), 1)
|
|
self.assertEqual(self._link_count(changed_commands), 1)
|
|
self.assertEqual(changed.details["objectCompilations"], 1)
|
|
self.assertGreater(changed.details["objectCacheHits"], 0)
|
|
self._assert_windows_package(changed)
|
|
|
|
test = self
|
|
sweeps = []
|
|
|
|
class PruningTracker(SolverActivityTracker):
|
|
def start_integration(self, time):
|
|
super().start_integration(time)
|
|
# execute_native calls this after starting the real C child,
|
|
# before reading its result. Both model entries are still in use.
|
|
report = prune_cache(test.cache, model_limit_bytes=0)
|
|
sweeps.append(report)
|
|
test.assertGreaterEqual(report["models"]["skippedInUse"], 2)
|
|
test.assertTrue(cold.executable.is_file())
|
|
test.assertTrue(changed.executable.is_file())
|
|
|
|
changed_result = self._execute(changed, "changed run", activity_tracker=PruningTracker())
|
|
self.assertEqual(len(sweeps), 1)
|
|
column = "amesim_pnch023_1.p"
|
|
self.assertNotEqual(changed_result["series"][column][0], cold_result["series"][column][0])
|
|
self.assertEqual(changed_result["buildKey"], changed.manifest["buildKey"])
|
|
|
|
# Once use ends, a zero budget may evict the older model while retaining
|
|
# the final oversized entry. Release every shared handle explicitly.
|
|
self._close_builds()
|
|
report = prune_cache(self.cache, model_limit_bytes=0)
|
|
self.assertEqual(report["models"]["removedEntries"], 1)
|
|
self.assertFalse(cold.executable.exists())
|
|
self.assertTrue(changed.executable.is_file())
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|