Skip to content

Commit dcc1436

Browse files
committed
Validate generated CUDA against Cantera on a GPU
Adds test/test_cuda_validation.py, which evaluates generated CUDA kernels and compares concentrations, net production rates and the Jacobian against the same Cantera reference the C validation uses, at the same tolerances. Until now the CUDA backend was only ever checked for compiling. The build targets whatever architecture nvidia-smi reports, and the tests skip unless both nvcc and a GPU are present.
1 parent 54280aa commit dcc1436

1 file changed

Lines changed: 267 additions & 0 deletions

File tree

test/test_cuda_validation.py

Lines changed: 267 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,267 @@
1+
"""Numerical validation of generated CUDA against Cantera, on a real GPU.
2+
3+
Everything else holding the CUDA backend to account checks that it *compiles*:
4+
the golden fixtures pin the emitted source and CI builds it for several
5+
architectures. Nothing evaluates a kernel. These tests do, comparing the same
6+
quantities as the C validation against the same Cantera reference, so the two
7+
backends are held to one standard.
8+
9+
Skipped unless nvcc and a GPU are both present, so the suite is unaffected
10+
where there is neither. Run them explicitly with ``-m cuda``.
11+
12+
The CUDA build is compiled for whatever architecture the installed GPU
13+
reports. Note that a V100 is sm_70, which CUDA 13 cannot target at all, so
14+
that pairing needs a 12.x toolkit.
15+
"""
16+
17+
import json
18+
import os
19+
import pathlib
20+
import shutil
21+
import subprocess
22+
import sys
23+
import textwrap
24+
25+
import pytest
26+
27+
from conftest import GOLDEN_MECHS, MECH_DIR, read_comparison
28+
29+
#: Agreement required against Cantera, matching the C validation's bar.
30+
RATE_RTOL = 1e-8
31+
32+
#: Agreement required for the Jacobian, relative to the largest entry.
33+
JACOBIAN_RTOL = 1e-8
34+
35+
#: Agreement required for the temperature self-derivative, against a
36+
#: Richardson-extrapolated finite difference. See `test_jacobian_validation`.
37+
TEMPERATURE_RTOL = 1e-8
38+
39+
STATES = [
40+
(800.0, 1.0),
41+
(1200.0, 1.0),
42+
(1800.0, 10.0),
43+
]
44+
45+
46+
def compute_capability():
47+
"""Returns the installed GPU's architecture as an ``sm_XX`` string."""
48+
if shutil.which('nvidia-smi') is None:
49+
return None
50+
probe = subprocess.run(
51+
['nvidia-smi', '--query-gpu=compute_cap', '--format=csv,noheader'],
52+
capture_output=True,
53+
text=True,
54+
)
55+
if probe.returncode != 0 or not probe.stdout.strip():
56+
return None
57+
first = probe.stdout.strip().splitlines()[0].strip()
58+
return 'sm_' + first.replace('.', '')
59+
60+
61+
@pytest.fixture(scope='session')
62+
def cuda_arch():
63+
"""The architecture to build for, or a skip if there is nothing to build for."""
64+
if shutil.which('nvcc') is None:
65+
pytest.skip('nvcc not on PATH')
66+
arch = compute_capability()
67+
if arch is None:
68+
pytest.skip('no GPU visible to nvidia-smi')
69+
return arch
70+
71+
72+
_COMPARE = textwrap.dedent("""
73+
import json, sys
74+
import cantera as ct
75+
import numpy as np
76+
from jacobian_reference import (
77+
analytic_jacobian,
78+
temperature_derivative_by_extrapolation,
79+
)
80+
from pyjac.core.mech_interpret import read_mech, read_mech_ct
81+
from pyjac.functional_tester.test import cupyjac_evaluator
82+
83+
mech, build_dir = sys.argv[1], sys.argv[2]
84+
states, source, out_path = json.loads(sys.argv[3]), sys.argv[4], sys.argv[5]
85+
86+
gas = ct.Solution(mech)
87+
n_species = gas.n_species
88+
89+
# cupyjac_evaluator evaluates every condition up front, so the whole sweep
90+
# is handed over at construction. Column 0 is unused; the evaluator drops
91+
# it and reads temperature, pressure, then mass fractions.
92+
state_data = np.zeros((len(states), 3 + n_species))
93+
for row, (temperature, atm) in enumerate(states):
94+
gas.TPX = temperature, atm * ct.one_atm, dict.fromkeys(
95+
gas.species_names, 1.0 / n_species
96+
)
97+
state_data[row, 1] = temperature
98+
state_data[row, 2] = gas.P
99+
state_data[row, 3:] = gas.Y
100+
101+
evaluator = cupyjac_evaluator(build_dir, gas, state_data)
102+
order = np.asarray(evaluator.fwd_spec_map)
103+
104+
# Which reactions carry a separate pressure-modification factor comes from
105+
# pyJac's reading of the mechanism, not Cantera's; the two disagree for a
106+
# reaction written with an explicit collider.
107+
if source.endswith(('.yaml', '.yml')):
108+
_, _, pyjac_reacs = read_mech_ct(source)
109+
else:
110+
_, _, pyjac_reacs = read_mech(source, None)
111+
n_rev = len([r for r in pyjac_reacs if r.rev])
112+
n_pmod = len([r for r in pyjac_reacs if r.thd_body or r.pdep])
113+
114+
worst = {}
115+
116+
def record(label, got, want):
117+
got, want = np.asarray(got, float), np.asarray(want, float)
118+
nonzero = np.abs(want) > 0
119+
if not nonzero.any():
120+
return
121+
err = np.max(np.abs((got[nonzero] - want[nonzero]) / want[nonzero]))
122+
worst[label] = max(worst.get(label, 0.0), float(err))
123+
124+
worst_matrix = 0.0
125+
worst_corner = 0.0
126+
127+
for index, (temperature, atm) in enumerate(states):
128+
pressure = atm * ct.one_atm
129+
gas.TPX = temperature, pressure, dict.fromkeys(
130+
gas.species_names, 1.0 / n_species
131+
)
132+
evaluator.update(index)
133+
134+
conc = np.zeros(n_species)
135+
evaluator.eval_conc(temperature, pressure, gas.Y, conc)
136+
record('concentrations', conc, gas.concentrations)
137+
138+
spec = np.zeros(n_species)
139+
fwd = np.zeros(gas.n_reactions)
140+
rev = np.zeros(n_rev)
141+
pmod = np.zeros(n_pmod)
142+
evaluator.eval_rxn_rates(temperature, pressure, conc, fwd, rev)
143+
evaluator.get_rxn_pres_mod(temperature, pressure, conc, pmod)
144+
evaluator.eval_spec_rates(fwd, rev, pmod, spec)
145+
record('net production rates', spec, gas.net_production_rates)
146+
147+
flat = np.zeros(n_species * n_species)
148+
evaluator.eval_jacobian(0, pressure, np.hstack((temperature, gas.Y)), flat)
149+
got = flat.reshape((n_species, n_species), order='F')
150+
151+
partial = gas.Y[order[:-1]].copy()
152+
want = analytic_jacobian(gas, temperature, partial, pressure, order)
153+
extrapolated = temperature_derivative_by_extrapolation(
154+
gas, temperature, partial, pressure, order
155+
)
156+
157+
worst_corner = max(
158+
worst_corner, abs(got[0, 0] - extrapolated) / abs(extrapolated)
159+
)
160+
difference = np.abs(got - want)
161+
difference[0, 0] = 0.0
162+
worst_matrix = max(worst_matrix, difference.max() / np.abs(want).max())
163+
164+
evaluator.clean()
165+
166+
worst['jacobian'] = worst_matrix
167+
worst['temperature derivative'] = worst_corner
168+
with open(out_path, 'w') as handle:
169+
json.dump(worst, handle)
170+
""")
171+
172+
173+
def build_and_compare(source, cantera_yaml, work_dir, arch):
174+
"""Generate CUDA, build it for ``arch``, run it, and compare."""
175+
pytest.importorskip('Cython', reason='building the wrapper requires Cython')
176+
pytest.importorskip('setuptools', reason='building the wrapper requires setuptools')
177+
178+
from pyjac.core.create_jacobian import create_jacobian
179+
from pyjac.pywrap import generate_wrapper
180+
181+
build = work_dir / 'out'
182+
183+
previous = os.getcwd()
184+
os.chdir(work_dir)
185+
try:
186+
create_jacobian('cuda', mech_name=str(source), build_path=str(build))
187+
generate_wrapper('cuda', str(build), out_dir=str(work_dir), cuda_arch=arch)
188+
finally:
189+
os.chdir(previous)
190+
191+
environment = dict(os.environ)
192+
environment['PYTHONPATH'] = str(pathlib.Path(__file__).parent)
193+
194+
written = work_dir / 'comparison.json'
195+
result = subprocess.run(
196+
[
197+
sys.executable,
198+
'-c',
199+
_COMPARE,
200+
str(cantera_yaml),
201+
str(build),
202+
json.dumps(STATES),
203+
str(source),
204+
str(written),
205+
],
206+
cwd=work_dir,
207+
capture_output=True,
208+
text=True,
209+
env=environment,
210+
)
211+
return read_comparison(written, result)
212+
213+
214+
def to_yaml(chemkin_path, out_dir):
215+
"""Convert a Chemkin mechanism to Cantera YAML alongside the build."""
216+
ck2yaml = pytest.importorskip('cantera.ck2yaml')
217+
out_name = out_dir / (pathlib.Path(chemkin_path).stem + '.yaml')
218+
ck2yaml.convert(
219+
str(chemkin_path), out_name=str(out_name), permissive=True, quiet=True
220+
)
221+
return out_name
222+
223+
224+
def assert_agrees(worst):
225+
"""Hold each quantity to the same bar as the C validation."""
226+
bad = []
227+
for label, value in sorted(worst.items()):
228+
limit = {
229+
'jacobian': JACOBIAN_RTOL,
230+
'temperature derivative': TEMPERATURE_RTOL,
231+
}.get(label, RATE_RTOL)
232+
if value > limit:
233+
bad.append(f'{label} {value:.3e} (limit {limit:g})')
234+
assert not bad, 'generated CUDA disagrees with Cantera: ' + ', '.join(bad)
235+
assert worst, 'nothing was compared'
236+
237+
238+
@pytest.mark.cuda
239+
@pytest.mark.compiler
240+
@pytest.mark.slow
241+
@pytest.mark.parametrize('name', ['h2o2', 'rxn_types'])
242+
def test_cuda_matches_cantera(name, tmp_path, cuda_arch):
243+
"""Generated CUDA reproduces Cantera as closely as the C backend does.
244+
245+
rxn_types carries SRI, PLOG and Chebyshev, so between the two mechanisms
246+
every supported reaction form is evaluated on the GPU.
247+
"""
248+
chemkin = GOLDEN_MECHS['h2o2'] if name == 'h2o2' else MECH_DIR / 'rxn_types.inp'
249+
worst = build_and_compare(chemkin, to_yaml(chemkin, tmp_path), tmp_path, cuda_arch)
250+
assert_agrees(worst)
251+
252+
253+
@pytest.mark.cuda
254+
@pytest.mark.compiler
255+
@pytest.mark.slow
256+
def test_cuda_matches_cantera_at_scale(tmp_path, cuda_arch):
257+
"""A realistic mechanism, read through the Cantera reader.
258+
259+
Separate from the small mechanisms because nvcc takes considerably longer
260+
over 53 species and 325 reactions than gcc does.
261+
"""
262+
ct = pytest.importorskip('cantera')
263+
mech = pathlib.Path(ct.__file__).parent / 'data' / 'gri30.yaml'
264+
if not mech.is_file():
265+
pytest.skip('cantera does not bundle gri30.yaml')
266+
worst = build_and_compare(mech, mech, tmp_path, cuda_arch)
267+
assert_agrees(worst)

0 commit comments

Comments
 (0)