Repository navigation
codegen: write C for what ExprTk means, or decline before compiling (#1212) - #1348
Conversation
…anl#1212) The C emitter copied parts of a rate law into the generated C that C has no meaning for: ExprTk's frac, inrange, iclamp, roundn, root, logn, sinc, equal, not_equal, cot, sec, csc, ncdf, deg2rad, rad2deg, deg2grad, grad2deg, mul, mand and mor; a table read as `tfun_kf`, `tfun_kf ()` or `2tfun_kf()`; a name with a dot (`k.f`); and the physical constants _R, _NA, _kB, _h and _F. cc refused the source after automatic codegen had paid for the compile; the MIR JIT refused it, so automatic codegen fell over where cc fell back. - Each function is C transcribed from its *_impl in exprtk.hpp (helpers in the prelude where an argument is read twice or a table is needed), bit for bit against the interpreter. hypot and log2 are transcribed too: C's differ from ExprTk's (hypot(1e200, 1e200) is inf to the engine; log2 differs in the last place at 82 of 300 random values). - ExprTk's implicit products get their `*`; names may hold a dot; a table is read through the lookup by any spelling, and the function order reads the same names, so a reader of a table is emitted after its index (it could come first and read it unset). - Any name still without C declines with a named reason while the expression is in hand, before any compiler sees it; prepare_* record it as a decline, not a failure. Forced MIR (BNGSIM_CODEGEN_THRESHOLD=1): 97 failures on main, 54 here, none new; the issue's five files go from 38 failures to 0. The corpus (1,468 models, plain and sensitivity sources) emits byte-identical C outside the prelude.
…kip (lanl#1212) MSVC reports __cplusplus as 199711L, so ExprTk built there takes its pre-C++11 trunc, (long long)v, which is undefined past 2**63: the Windows interpreter gave frac(1e300) = 1e300. The rows stay where both builds agree. The MIR whole-model tests become one test, so a build without MIR skips once rather than 27 times (the suite's skip ceiling). Rename a local mypy read as the later names string.
|
Thanks. We reviewed this independently, Opus 5.5 and Fable 5.1, each on our own build of 00a4321 merged onto main c0877fa (clean), on macOS arm64 with MIR on. We found no correctness defect in what it translates:
Two cases of #1212's own class still reach the compiler, both on main already. Three low items follow. What we checked comes first, so it needn't be redone. What we checked
1. Implicit products the engine reads beyond the inserter's tableThe interpreter also multiplies three shapes that
The rewrite leaves those shapes as written. No name is unresolved, so the new decline doesn't fire, and the compiler gets them. A
These are on main too, so they aren't a regression. But they're #1212's failure class left standing. Under automatic codegen, cc pays a failed compile before falling back, and the MIR JIT refuses outright. We'd suggest:
Or at least decline those shapes. 37 other adversarial spellings we tried agree bit for bit, and 7 the engine itself rejects. 2. A model name inside a number literalThe new
The controls come out right:
Low
Not checked: Windows/MSVC (ExprTk's pre-C++11 branch there, which the body notes) and Linux beyond CI; the sensitivity half of the body's 2,936 sources (we compared the 1,688 plain ones); perf_ab (the change is emit-time Python only). |
…nt's letter in its number (lanl#1212) Review items on lanl#1348: - A variable, or a bracketed group, before an opening bracket is a product to ExprTk's parser (post_variable_process and post_bracket_process in exprtk.hpp): `k(x + 1)`, `g()(x)`, `(a)(b)`. _insert_implicit_products now writes the `*` there, knowing from the lookup which names are variables, and writes square and curly brackets as round ones. After a call, a bracket opens a second statement, whose value the engine gives for the whole expression (`k*exp(-x)(y)` is y). There is no C for that, and it declines. The hint now lets `2.A` and `2.(A)` reach the tokenizer. - The number guard runs before the lookup, so a parameter named `e` is no longer written into the exponent of `2e-3`. - The pair table's comment says the lexer multiplies those pairs and the parser two more. - The trajectory comparisons give the reason for rtol 1e-9.
…iteral (lanl#1212) The comment over the exponent-letter rows said only a .net reaches the translator with a literal such as 2e-3. The SBML loader writes a number as repr(v), which is e-notation below 1e-4 and from 1e16, and BIOMD0000001042 has <cn type="e-notation">1<sep/>-10</cn> beside a parameter e: main wrote `1p[7]-10`, and its codegen build failed. A model of the same law through Model.from_sbml_string now runs compiled as it runs interpreted.
…ndows' sin(2.5) is an ulp apart (lanl#1212)
|
Following up on our review above: we pushed items 1, 2, 4 and 5 as two commits on top, aefcd28 and c091905. You then merged main and added a0fc972. We merged main again (98224d2) to bring in #1413, whose macos-15 checks are now required, and we're merging now that CI is green on it. #1413 changes no code under test, only runners and two comment lines in the tests, so the runs below on main 49c2931 + a0fc972 stand for it. The transcriptions, the decline and its plumbing stand as you wrote them. Item 3 is below. What the engine does, measuredItem 1 rests on what ExprTk's parser does with a bracket, so we measured it with
What the commits change
a0fc972: a libm call held to 4 ulpOur c091905 failed both Windows legs (MIR codegen and the non-NFsim tail) on one row, the control What we checked
BIOMD0000001042, and a sentence of oursOur review said only BIOMD0000001042 has We ran the harness's own checks on the nightly's window. Four of its baseline rows move once this merges:
These are expected, not alerts. Item 3: the body's countsAt 00a4321 the file collects 127 tests. Dropped into main's tree, 93 fail and 34 pass. Our review's 92 came from a copy outside Not changed here: the engine's own reading of a bracket after a call drops the call without a word. That's #1415. |
…nal (#1217) Run with every model forced through the MIR JIT (BNGSIM_CODEGEN_JIT=mir BNGSIM_CODEGEN_THRESHOLD=1), 53 tests fail on main with #1348's codegen fix in. 39 test the cc backend's own artifact or name. 12 more fail under a forced threshold alone, because they need a small model to run interpreted. Each now carries a skipif from _codegen_backend.py, with a reason conftest declares. Three expose engine-dependent answers, filed separately. They get strict xfails under a lowered threshold, not skips.
Closes #1212.
What was wrong
The C emitter (
python/bngsim/_codegen.py) copied into the generated C parts of a rate law that C has no meaning for:frac,inrange,iclamp,roundn,root,logn,sinc,equal,cot,sec,csc,ncdf,deg2rad,rad2deg;exprtk.hpp'sbase_function_list:not_equal,deg2grad,grad2deg,mul,mand,mor.tfun_kf,tfun_kf ()ortfun_kf\t(). The old pass matched onlytfun_kf().k.f), which ExprTk's lexer reads as one name.2tfun_kf()/2and2k.f/2, emitted as2.0tfun_kf._R,_NA,_kB,_h,_F). Only_piand_ewere mapped, sok*_Rwas emitted as_R.With cc, the compile failed. Automatic codegen fell back, but only after paying for the compile.
codegen=Truerefused with the compiler's error. The MIR JIT refused by name, so under automatic codegen the run failed where cc fell back.Two functions compiled but gave a different answer. C has
hypotandlog2, but not ExprTk's:hypotis the unscaledsqrt(a*a + b*b). The interpreter giveshypot(1e200, 1e200)=inf; C's gives1.4e200.log2islog(v)/ln 2. It differed from C'slog2in the last place at 82 of 300 random values.One more problem was found while testing.
_topological_function_orderfound a table read only astfun_<table>(. A function reading the table by any other spelling could therefore be emitted before the function its index is, and would readfunc[]unset. The cc source didn't compile in those cases, so this never ran, but it would have once the spelling compiled.The fix
C for each function. Each is transcribed from its
*_implinexprtk.hpp, or from the trinary node forinrange/iclamp. Both sides build with-ffp-contract=off, so the C computes the interpreter's double bit for bit.bngsim_helper in a new prelude block,_EXPRTK_FUNCTION_C_LINES:frac,inrange,iclamp,roundn,root,sinc,equal,not_equal,hypot._c_engine_call:logn,log2,cot,sec,csc,ncdf, the four angle conversions, and the variadicmul/mand/mor.log2,sqrt2,pi_180,_180_pi) are spelled with the same digits as inexprtk.hpp, so both compilers round them to the same double.The names.
_insert_implicit_productswrites out the*that ExprTk'scommutative_inserterreads: number–name, number–(, name–number,)–number,)–name, and name–name. ExprTk's reserved words (and,or,if…) are never operands.tfun_<table>goes into the lookup, so every spelling resolves todata->tfun_eval(...). This replaces_embedded_tfun_resolver, which rewrotetfun_X()in the translated text afterwards._topological_function_orderreads the same names: dotted names, and every spelling of a table read._R,_NA,_kB,_hand_Fare written as theirrepr(), the same double.test_builtin_constants.pyalready pins those values to the C++ ones.The decline. A name the translator leaves unresolved raises
CodegenDeclined, naming it, while the expression is still in hand. Allowed through are:<math.h>call whose ExprTk function of the same name is the same call (_C_CALLS_AS_WRITTEN);_PRELUDE_HELPERS, read off the prelude);prepare_model_codegenandprepare_model_codegen_sourcerecord this as a decline, not a failure. No compiler ever sees the source, and both backends fall back the same way. What still declines is ExprTk's word operators (xor,nand, …), which nothing translates.I chose the allowlist from measurement. I logged every unknown name the translator passed through, across a full suite run with forced codegen (16,536 tests) and the 1,500-model corpus. Every call left on the list is a
<math.h>name of the same meaning, or one the fix now translates.Tests
python/tests/test_codegen_exprtk_functions.py(153 tests). The oracle is the interpreter._expr_to_c, compiled with the shipped prelude, againstcompute_derivs, compared as doubles (NaN = NaN, sign of zero counts). The rows hit each branch: both sides oftrunc, range edges,iclamp's midpoint, half-wayroundnwith out-of-range digit counts,rootof a negative with odd, even and fractional n,sincaround DBL_EPSILON,equalon both sides of its 1e-10 scale, NaN where comparisons decide.hypotandlog2need transcribing: C's own versions disagree with ExprTk's at one of those rows.exprtk.hpp'sbase_function_listis handled, exceptlikeandswap, which aren't numeric..netrun undercodegen=True(cc), and under MIR when built, againstcodegen=False.hfirst, so only the dependency edge puts it after.xor/nanddecline beforecompile_rhsis reached (it is monkeypatched to raise). They are recorded as a decline with no error,codegen=Truerefuses by name, and the interpreter runs the model.test_codegen_jit_prelude.py::TestACallThePreludeDoesNotDeclareIsRefusedusedequal/fracas its bare-call fixtures, as the issue anticipated. It now pins MirJit's refusal on a hand-built source: the source generated for a law callingexp, with the call renamed. The source is handed to the JIT on the model. A new test asserts the emitter itself now writesbngsim_equal/bngsim_frac.The new file is added to the
mir.ymlandwindows-tail.ymlrun lists.Against main (9a26f5b), 116 of the 153 new tests fail, each for the reason above: the compiler's error, MIR's refusal by name, or the missing helper.
Mutations: 17 of 18 are caught. Each was a one-line change to the fix:
hypot/log2libm swap;mandtoor;The one survivor,
>=to>insinc's guard, is equivalent: at v = DBL_EPSILON,sin(v)/vis exactly 1.0. Dropping the guard altogether is caught.Measured
BNGSIM_CODEGEN_JIT=mir BNGSIM_CODEGEN_THRESHOLD=1test_a_relative_interpreter_stays_in_its_venv, the known/tmpvenv artifact on macOSCorpus. Of 1,500 corpus files (1,323 BioModels SBML, 177
.net), 1,468 load. Their generated sources number 2,936, plain and sensitivity. Each is byte-identical to main's outside the prelude, and no model newly declines.Noticed, not changed
Interpreter reads a table one evaluation late. In a model built through
ModelBuilder, a function that reads a table-backed function by its name, declared before that function or its index, gets the table one evaluation late:compute_derivsat A = 1.5, 0.5, 1.5 returns 0, 7, 3 for 7, 3, 7. A.netholding the same functions in the same order is evaluated right. The new table test compares against the canonical spelling for that reason. I can file it separately.Sensitivity source depends on earlier models. Main's sensitivity-RHS source for BIOMD0000000469, 472 and 473 depends on which models were emitted earlier in the same process. Fresh processes agree, on main and here. This is unrelated to this change.
MSVC's interpreter uses ExprTk's pre-C++11 branch. MSVC reports
__cplusplusas 199711L, and the build has no/Zc:__cplusplus, so ExprTk built there takes its pre-C++11 branch:truncis(long long)v, undefined past 2^63; the Windows interpreter givesfrac(1e300)= 1e300;asinh,acosh,atanh,expm1andlog1pare log formulas, not thestd::functions.The bit-for-bit rows stay inside
long longrange, where both builds agree. Codegen's pass-through of those five to C was already as it is on Windows before this PR.