258 lines
13 KiB
Python
258 lines
13 KiB
Python
"""Compiler structure and fixed-state native probes; no integration is run."""
|
|
from collections import defaultdict
|
|
import ctypes
|
|
import hashlib
|
|
import json
|
|
import math
|
|
import os
|
|
from pathlib import Path
|
|
import re
|
|
import shlex
|
|
import shutil
|
|
import struct
|
|
import subprocess
|
|
import tempfile
|
|
import unittest
|
|
|
|
from app.main import compile_system_xml_network
|
|
from app.simulation.native_codegen.compiler import compile_native_program
|
|
from app.simulation.native_codegen.extended import compile_extended_program
|
|
from app.simulation.native_codegen.input import load_input
|
|
from app.simulation.native_codegen.jacobian import StateDependencies
|
|
from tests.native_reference import reference_data, reference_network
|
|
|
|
ROOT = Path(__file__).resolve().parents[1]
|
|
|
|
|
|
def compile_input(path):
|
|
_, document = load_input(ROOT / path)
|
|
return compile_native_program(compile_system_xml_network(document))
|
|
|
|
|
|
def array(program, name):
|
|
match = re.search(r'const int '+name+r'\[\d+\] = \{([^}]*)\};', program.source)
|
|
if not match:
|
|
raise AssertionError(f'Missing generated {name}')
|
|
return list(map(int, match[1].split(',')))
|
|
|
|
|
|
def rows_and_colors(program):
|
|
pointers = array(program, 'model_jacobian_col_ptr')
|
|
indices = array(program, 'model_jacobian_row_index')
|
|
colors = array(program, 'model_jacobian_column_color')
|
|
rows = [set() for _ in program.state_keys]
|
|
for column in range(len(colors)):
|
|
for row in indices[pointers[column]:pointers[column+1]]:
|
|
rows[row].add(column)
|
|
return rows, colors
|
|
|
|
|
|
class StructuralDependencyTests(unittest.TestCase):
|
|
def test_unknown_reachable_values_and_calls_disable_coloring(self):
|
|
for expression in ('mystery', 'q[900]', 'unreviewed_kernel(y[0])'):
|
|
deps = StateDependencies(2)
|
|
deps.expression('dy[0]', expression)
|
|
deps.expression('dy[1]', '0')
|
|
result = deps.build()
|
|
self.assertFalse(result.enabled)
|
|
self.assertIn('Unresolved', result.reason)
|
|
deps = StateDependencies(1)
|
|
deps.expression('w[9]', 'unused_diagnostic[0]')
|
|
deps.expression('dy[0]', '0')
|
|
self.assertTrue(deps.build().enabled)
|
|
self.assertEqual(deps.build().rows, ((0,),))
|
|
|
|
def test_scc_closure_keeps_independent_regions_separate(self):
|
|
deps = StateDependencies(6)
|
|
for target, inputs in {'h[0]': ('h[1]', 'y[0]'), 'h[1]': ('h[0]', 'y[1]'),
|
|
'h[2]': ('h[3]', 'y[3]'), 'h[3]': ('h[2]', 'y[4]')}.items():
|
|
deps.assign(target, inputs)
|
|
for i in range(6):
|
|
deps.expression(f'dy[{i}]', '0')
|
|
deps.assign('dy[2]', ('h[0]',))
|
|
deps.assign('dy[5]', ('h[2]',))
|
|
result = deps.build()
|
|
self.assertTrue(result.enabled)
|
|
self.assertEqual(result.rows[2], (0, 1, 2))
|
|
self.assertEqual(result.rows[5], (3, 4, 5))
|
|
self.assertEqual(result.color_count, 3)
|
|
|
|
def test_projection_and_stop_control_dependencies_keep_original_sources(self):
|
|
deps = StateDependencies(6)
|
|
deps.project_states([0, 2])
|
|
deps.project_states([1, 3])
|
|
for i in range(6):
|
|
deps.expression(f'dy[{i}]', '0')
|
|
deps.expression('g[0].p', 'y[0]+y[1]')
|
|
deps.expression('dy[4]', 'g[0].p')
|
|
deps.expression('dy[5]', 'y[4]')
|
|
deps.stop_motion(4, 5)
|
|
result = deps.build()
|
|
self.assertEqual(result.rows[4], tuple(range(6)))
|
|
self.assertEqual(result.rows[5], tuple(range(6)))
|
|
|
|
def test_catalog_patterns_have_valid_csc_and_colorings(self):
|
|
for index, case in enumerate(reference_data()['cases']):
|
|
with self.subTest(case=index):
|
|
program = compile_extended_program(reference_network(case))
|
|
info = program.jacobian_structure
|
|
self.assertTrue(info['enabled'], info['reason'])
|
|
self.assertTrue(info['canonicalRhs'])
|
|
self.assertEqual(info['defaultRuntimePolicy'], info['policy'])
|
|
self.assertEqual(info['policyScope'], 'default-runtime')
|
|
self.assertEqual(info['verification'], '--verify-jacobian')
|
|
self.assertNotIn('activation', info)
|
|
rows, colors = rows_and_colors(program)
|
|
for row, entries in enumerate(rows):
|
|
self.assertIn(row, entries)
|
|
self.assertEqual(len(entries), len({colors[col] for col in entries}))
|
|
self.assertEqual(sum(map(len, rows)), info['nonzeros'])
|
|
self.assertEqual(max(colors)+1, info['colorCount'])
|
|
eligible = info['colorCount'] < len(program.state_keys)
|
|
self.assertEqual(info['runtimeEligible'], eligible)
|
|
self.assertEqual(info['runtimeFallbackReason'] is None, eligible)
|
|
if eligible:
|
|
self.assertEqual(info['policy'], 'CVODE colored forward differences; canonical-property-cache RHS')
|
|
self.assertEqual(info['rhsPolicy'], 'canonical-property-cache finite differences')
|
|
else:
|
|
self.assertEqual(info['policy'], 'CVODE default dense differences')
|
|
self.assertEqual(info['rhsPolicy'], 'ordinary model_eval')
|
|
|
|
def test_eight_branch_pattern_covers_mechanical_volume_and_uses_fewer_groups(self):
|
|
program = compile_input('tests/data/test-mql-8-corrected.json')
|
|
rows, colors = rows_and_colors(program)
|
|
self.assertLess(max(colors)+1, len(program.state_keys)//2)
|
|
self.assertTrue(program.jacobian_structure['runtimeEligible'])
|
|
self.assertEqual(program.jacobian_structure['defaultRuntimePolicy'],
|
|
'CVODE colored forward differences; canonical-property-cache RHS')
|
|
self.assertIn('#define MODEL_JACOBIAN_CANONICAL_RHS 1', program.header)
|
|
self.assertEqual(len(colors), len(program.state_keys))
|
|
index = {key: i for i, key in enumerate(program.state_keys)}
|
|
# PNCH012 pressure/energy includes piston volume from two moving masses;
|
|
# a gas-only origin label would miss these position dependencies.
|
|
target = index['amesim_pnch012_11.U']
|
|
self.assertIn(index['amesim_mecmas21_5.x'], rows[target])
|
|
self.assertIn(index['amesim_mecmas21_9.x'], rows[target])
|
|
|
|
def test_compact_path_retains_dense_fallback(self):
|
|
program = compile_input('tests/fixtures/native-skill-test.xml')
|
|
self.assertFalse(program.jacobian_structure['enabled'])
|
|
self.assertFalse(program.jacobian_structure['runtimeEligible'])
|
|
self.assertTrue(program.jacobian_structure['runtimeFallbackReason'])
|
|
self.assertEqual(program.jacobian_structure['defaultRuntimePolicy'], 'CVODE default dense differences')
|
|
self.assertEqual(program.jacobian_structure['rhsPolicy'], 'ordinary model_eval')
|
|
self.assertIn('#define MODEL_JACOBIAN_COLORED 0', program.header)
|
|
self.assertIn('#define MODEL_JACOBIAN_CANONICAL_RHS 0', program.header)
|
|
self.assertNotIn('model_eval_jacobian', program.header)
|
|
|
|
|
|
class NativeJacobianProbeTests(unittest.TestCase):
|
|
@classmethod
|
|
def setUpClass(cls):
|
|
command = shlex.split(os.environ.get('CC', ''))
|
|
if not command:
|
|
compiler = shutil.which('gcc') or shutil.which('clang')
|
|
if not compiler:
|
|
raise unittest.SkipTest('A C compiler is required for fixed-state probes')
|
|
command = [compiler]
|
|
cls.compiler = command
|
|
cls.directory = tempfile.TemporaryDirectory(prefix='native-jacobian-probe-')
|
|
cls.addClassCleanup(cls.directory.cleanup)
|
|
cls.root = Path(cls.directory.name)
|
|
cls.fixture = json.loads((ROOT/'tests/fixtures/native-jacobian-cache-state.json').read_text())
|
|
if hashlib.sha256((ROOT/cls.fixture['input']).read_bytes()).hexdigest() != cls.fixture['inputSha256']:
|
|
raise AssertionError('Update the fixed-state fixture deliberately when changing the physical input')
|
|
cls.program = compile_input(cls.fixture['input'])
|
|
cls.n = len(cls.program.state_keys)
|
|
cls.noutputs = len(cls.program.variables)
|
|
cls.library = cls.build_library('current', cls.program.source)
|
|
# Restore the pre-canonical gas-seeding policy for BOTH entrypoints in
|
|
# an isolated TU. This checks ordinary dispatch/cache behavior on each
|
|
# platform without requiring identical libm rounding across platforms.
|
|
old = 'gas_properties=canonical?NULL:properties;'
|
|
if cls.program.source.count(old) != 1:
|
|
raise AssertionError('Update the explicitly restored baseline policy when changing the generator')
|
|
baseline = cls.program.source.replace(old, 'gas_properties=properties;(void)canonical;')
|
|
cls.baseline = cls.build_library('seeded-baseline', baseline)
|
|
|
|
@classmethod
|
|
def build_library(cls, name, source):
|
|
directory = cls.root/name
|
|
directory.mkdir()
|
|
(directory/'model.c').write_text(source)
|
|
(directory/'model.h').write_text(cls.program.header)
|
|
output = directory/('probe.dll' if os.name == 'nt' else 'probe.so')
|
|
command = cls.compiler + ['-std=c11', '-O3', '-shared', '-Wall', '-Wextra', '-Werror',
|
|
'-ffp-contract=off', '-fno-fast-math']
|
|
if os.name != 'nt':
|
|
command.append('-fPIC')
|
|
command += ['-I', str(directory), '-I', str(ROOT/'native/include'),
|
|
str(directory/'model.c'), str(ROOT/'native/components/kernels.c'), '-lm', '-o', str(output)]
|
|
result = subprocess.run(command, capture_output=True, text=True, timeout=60)
|
|
if result.returncode:
|
|
raise AssertionError(result.stderr)
|
|
library = ctypes.CDLL(str(output))
|
|
if os.name == 'nt':
|
|
import _ctypes
|
|
cls.addClassCleanup(_ctypes.FreeLibrary, library._handle)
|
|
for name in ('model_eval', 'model_eval_jacobian'):
|
|
function = getattr(library, name)
|
|
function.argtypes = [ctypes.c_double, *([ctypes.POINTER(ctypes.c_double)]*3)]
|
|
function.restype = ctypes.c_int
|
|
return library
|
|
|
|
def evaluate(self, values, *, canonical=True, library=None):
|
|
state = (ctypes.c_double*self.n)(*values)
|
|
derivative = (ctypes.c_double*self.n)()
|
|
outputs = (ctypes.c_double*self.noutputs)()
|
|
function = getattr(library or self.library, 'model_eval_jacobian' if canonical else 'model_eval')
|
|
self.assertEqual(function(self.fixture['time'], state, derivative, outputs), 1)
|
|
self.assertEqual(list(state), values, 'RHS evaluation mutated its input state')
|
|
return list(derivative), list(outputs)
|
|
|
|
def test_normal_rhs_preserves_seeded_baseline_bits(self):
|
|
for column in (None, 71, 80, 81, 84):
|
|
state = self.fixture['state'][:]
|
|
if column is not None:
|
|
state[column] += math.sqrt(2**-52)*abs(state[column])
|
|
actual = self.evaluate(state, canonical=False)
|
|
expected = self.evaluate(state, canonical=False, library=self.baseline)
|
|
for got, want in zip(actual, expected):
|
|
self.assertEqual(struct.pack(f'={len(got)}d', *got), struct.pack(f'={len(want)}d', *want))
|
|
|
|
def test_canonical_full_dense_and_colored_differences_agree(self):
|
|
rows, colors = rows_and_colors(self.program)
|
|
state = self.fixture['state'][:]
|
|
base, _ = self.evaluate(state)
|
|
increments = [max(math.sqrt(2**-52)*abs(value), 1e-14) for value in state]
|
|
dense = []
|
|
for column in range(self.n):
|
|
trial = state[:]
|
|
trial[column] += increments[column]
|
|
value, _ = self.evaluate(trial)
|
|
differences = [value[row]-base[row] for row in range(self.n)]
|
|
for row in range(self.n):
|
|
if column not in rows[row]:
|
|
self.assertEqual(differences[row], 0, (row, column))
|
|
dense.append(differences)
|
|
groups = defaultdict(list)
|
|
for column, color in enumerate(colors):
|
|
groups[color].append(column)
|
|
for columns in groups.values():
|
|
trial = state[:]
|
|
for column in columns:
|
|
trial[column] += increments[column]
|
|
value, _ = self.evaluate(trial)
|
|
for column in columns:
|
|
for row in range(self.n):
|
|
if column in rows[row]:
|
|
self.assertEqual(value[row]-base[row], dense[column][row], (row, column))
|
|
# The historically offending cross-branch entries must be exact zeros.
|
|
for column in (80, 81):
|
|
for row in (68, 69, 70, 86):
|
|
self.assertEqual(dense[column][row], 0)
|
|
|
|
|
|
if __name__ == '__main__':
|
|
unittest.main()
|