[RF] Implement RooFormulaVar and RooGenericPdf evalutation path without TFormula - #23209
Draft
guitargeek wants to merge 5 commits into
Draft
[RF] Implement RooFormulaVar and RooGenericPdf evalutation path without TFormula#23209guitargeek wants to merge 5 commits into
guitargeek wants to merge 5 commits into
Conversation
Add a small, self-contained expression compiler and evaluator to RooFit (RooFormulaParser + RooExprEvaluator) that handles the realistic subset of RooFormula expressions without any use of the interpreter/JIT. This is the main building block for fixing cling out-of-memory problems with many expression-based NormFactors (see root-project#21052; not fully closed yet, since the codegen path still JIT-compiles on demand). The contract is strict: either the compiled program evaluates bitwise identically to what TFormula/cling computes for the same processed formula string, or the parser refuses and RooFormula silently falls back to the TFormula backend, so every expression that worked before keeps working, with identical values and identical error messages for invalid formulas. In particular: - Operator precedence matches C++; `^`/`**` reproduce TFormula's HandleExponentiation rewrite (right-associative exponentiation binding tighter than `*` and unary minus, `expr^2` -> TMath::Sq). - cling's int-vs-double expression typing is tracked: truncating integer division (`1/2`, `(x>0)/2`) and min/max with mixed int/double arguments (invalid in cling) fall back. - Function calls evaluate exactly what the JIT'd code called for each spelling: bare/std:: spellings call libm/std, TMath:: spellings call the TMath functions (which are not always identical, e.g. TMath::Erf, TMath::ATan2, TMath::Min). - Comparisons yield exactly 0.0/1.0; `&&`/`||` do not short-circuit and `?:` evaluates both branches (keeping the scalar semantics consistent with a future vectorized path). Identical formula strings share one immutable instruction vector through a mutex-protected process-wide registry (mirroring what TFormula's gClingFunctions cache does for JIT'd code); evaluation is const, lock-free and touches no globals, so it is safe under RooFit's concurrent evaluation. The environment variable ROOFIT_FORMULA_BACKEND overrides the backend choice: `tformula` never uses the new evaluator, `ast` fails loudly instead of falling back (for testing), unset means AST with silent fallback. The first fallback in a process is reported once at INFO level, so it is not completely invisible; the individual fallbacks stay on the InputArguments debug stream. Since the codegen path calls the cling-JIT-compiled TFormula function by name (getUniqueFuncName()), RooFormula::getTFormula() now lazily creates a TFormula on demand when the formula is evaluated JIT-free, keeping codegen and AD working exactly as before. This stopgap goes away when codegen emits C++ from the parsed expression directly. On the real-world formula corpus (all RooFormulaVar/RooGenericPdf strings in the RooFit tests and tutorials, RooFit-generated formulas, and the RooFit-realistic subset of the TFormula parsing tests), 152 of 156 entries take the JIT-free path (97%) and evaluate bitwise identically to TFormula on random inputs; the remainder is the ROOT::Math::*_pdf family, which falls back. 🤖 Done with the help of AI
Make the JIT-free formula evaluator emit the expression as C++ source, and use that in the codegen backend: the two codegenImpl overloads for RooFormulaVar and RooGenericPdf now inline the emitted expression into the generated code (with the dependents' result names substituted for the formula variables) instead of calling a cling-JIT-compiled TFormula function by name. This removes the per-formula JIT compilation from the codegen path, and Clad now sees plain arithmetic instead of a call across a JIT boundary. The C++ is emitted by walking the same instruction vector that eval() interprets, with explicit parenthesization and with numeric literals formatted at max_digits10 precision, so the emitted code evaluates bitwise identically to the AST evaluator (verified by a new differential test that compiles the emitted code for the corpus expressions and compares against AST evaluation). Formulas that fall back to the TFormula backend keep the previous codegen behavior; the JIT'd function name now flows through a narrow uniqueFuncName() accessor on the evaluator interface, and RooFormulaVar and RooGenericPdf expose which backend handled the formula through a new formulaUsesAstBackend() query (their public getUniqueFuncName() returns an empty string exactly when the expression is inlined instead). getTFormula() is deleted from RooFormula and from the evaluator interface entirely, so no future caller can reintroduce the TFormula coupling, and the temporary lazily-created TFormula stopgap for codegen is gone with it. 🤖 Done with the help of AI
…uator
Phase 3 (differential testing) and phase 3.5 (workspace I/O) of the
JIT-free RooFormula evaluation work:
* A deterministic random-expression generator over the full supported
grammar (all operators including `^`/`**`, comparisons, logical
operators, ternary, every allow-listed function spelling sampled from
the actual table, and edge-case inputs including 0, +-1, 1e300,
1e-300 and negative arguments to sqrt/log). 500 expressions x 8 input
vectors must agree bitwise with TFormula/cling, and a 150-expression
sample additionally goes through emitCpp() + interpreter compilation
and must agree bitwise with AST evaluation. No tolerances anywhere.
* The generator found four real divergences between the AST dialect and
what TFormula/cling actually compiles; the parser now falls back to
the TFormula backend for all of them, keeping today's behavior:
- a textually adjacent `++` is TFormula's linear-combination
separator, not an addition (and runs of three or more `-` survive
TFormula's double-negation rewrite as a pre-decrement that does
not compile);
- bare chained comparisons (`a < b < c`) are invalid in TFormula
because cling compiles with -Wparentheses promoted to an error;
- `^` with an explicit sign on a parenthesized exponent is broken in
TFormula (`x^-(a+b)` compiles as pow(x,-(a)+b));
- sign()/TMath::Sign with a bool-typed first argument resolves to
the generic TMath::Sign template returning bool, which is not
copysign. Expression typing is now tracked as double/int/bool to
detect this (and bool/int min/max mixes, which do not compile).
* The same typing information also folds int-typed constant
subexpressions in int64 at parse time, because cling computes them in
wrapping int32 arithmetic ("100000*100000" is 1410065408 there, not
1e10). Any int-typed constant intermediate that leaves the int32
range falls back, as do integer literals too large for int (which
have type long or unsigned int in C++, not int).
* testRooFuncWrapper gains a gradient-agreement test: for a Gaussian
with RooFormulaVar parameters and for a numerically-integrated
RooGenericPdf, the Clad gradient from the new inlined-expression
codegen path must match the gradient from the old
call-the-JIT'd-TFormula path to 1e-9 and numerical differentiation
to 1e-4.
* New testRooFormulaEvaluatorIO.cxx covers workspace persistence:
- round-trip through the AST path (the direct regression test for
the formulaString()/_formExpr persistence landmine), asserting
non-empty persisted strings and bitwise-identical evaluation after
reading back;
- reading a legacy workspace fixture written by a pre-AST build
(testRooFormulaEvaluator_legacy_ws.root), asserting bitwise
agreement with the recorded 17-digit reference values on both
backends;
- on-disk fidelity: writing the same content through the AST and the
TFormula backends produces identical persisted formula strings and
identical streamed class versions, both matching the legacy file.
The real-world corpus coverage of the AST path is unchanged at 152/156
(97.4%), reported via GTest properties with a hard floor of 90%.
🤖 Done with the help of AI
…ct#21052) The out-of-memory failure in issue root-project#21052 comes from formulas whose bodies differ only in their numeric literals (TRExFitter-style expression NormFactors like `(1-(SF_b*0.035)-(SF_c*0.138))/0.518` with per-region constants): each distinct-literal formula misses TFormula's JIT'd-function cache and costs two cling JIT compilations (the formula plus its validation clone) at roughly 130 kB and several milliseconds each, i.e. about 8 GB and minutes at the reported scale of 30000 formulas. The issue's attached reproducer does not show this, because its formula bodies all normalize to the same string and the cache absorbs them, so the new test bakes distinct literals into every formula. With the JIT-free expression backend, constructing and evaluating 30000 such RooFormulaVars takes about a second and tens of MB. The test asserts that every formula stays on the JIT-free backend (an empty uniqueFuncName(), which on the TFormula backend is always the name of the JIT-compiled function) plus generous order-of-magnitude wall-time and RSS-growth bounds. The codegen variant covers the second half of the issue: codegen used to force one TFormula JIT compilation per formula just to obtain a function name for the generated code to call. Now the expressions are inlined, so building and evaluating the likelihood of a model with 1000 distinct-literal formulas invokes cling once, for the whole squashed function: O(1) compilations per model instead of O(N) per formula. 🤖 Done with the help of AI
Replace the scalar per-event loop in RooFormula::doEval() with chunked, vectorized evaluation of the JIT-free expression programs, closing the performance gap of the AST backend to the old cling-JIT path on long formulas and widening its lead on short ones. The instruction set of the postfix expression programs moves into RooBatchCompute (RooExprProgram.h); the same instruction vector still drives the scalar RooExprEvaluator::eval(). A new interface entry point, RooBatchCompute::computeExprProgram(), interprets a program with the loops interchanged: one instruction is applied across a chunk of RooBatchCompute::bufferSize (64) events at a time, with the value stack held as stack-allocated 64-double chunk buffers, so all intermediates stay L1-resident (the same chunking as the existing pdf kernels). The per-instruction loops are trivial elementwise operations that auto-vectorize in each of the GENERIC/SSE4.1/AVX/AVX2/AVX512 libraries, the interpreter dispatch amortizes over 64 events, per-event indirect calls disappear, and the scalar-vs-vector input broadcast decision hoists out of the per-event loop. Call instructions now carry their resolved function pointer; the exp/log/sin/cos/sqrt spelling families get dedicated opcodes so the batch interpreter can use the fast vectorizable VDT implementations (fast_exp etc.) where ROOT is built with VDT, exactly like the pdf kernels. RooFormula::doEval() now short-circuits the all-scalar case (every input span of size 1, the HistFactory expression-NormFactor shape of issue root-project#21052) to a single scalar evaluation that is broadcast, routes expression programs through computeExprProgram(), and keeps the scalar per-event loop for the TFormula fallback backend and for programs deeper than the vector interpreter's fixed-size chunk stack. The evaluator remains free of mutable state, so concurrent doEval() calls stay safe. Numerical contract: without VDT every vectorized operation is the exact same double-precision operation per event as scalar evaluation, so batch results are bitwise identical (asserted in the new differential tests on such builds). With VDT, batch results can differ from scalar evaluation within RooBatchCompute's usual batch-vs-scalar tolerance (relative ~5e-14, see _toleranceCompareBatches in the vectorisedPDFs tests); the same class of difference already applies to every built-in pdf kernel. New differential tests compare the vectorized doEval() per event against scalar evaluation of the same program with mixed span sizes (vector observable, scalar parameters, an unused dependent with an empty span) across the real-world formula corpus, generated random expressions including ternary/comparison/logical operators, batch sizes around the 64-event chunking boundaries, a 10^6-event batch, NaN/Inf propagation, the all-scalar short-circuit, and the TFormula fallback. Benchmarks (AVX2 host, vdt=OFF build, RooFit::Evaluator driver, vs the scalar-AST doEval before this commit and the cling-JIT backend): a long ~30-instruction formula over 1M events drops from 166 ns/event to 64 ns/event, now faster than the JIT path (69.5); the short HistFactory shape x*p drops from 8.2 to 0.9 ns/event (JIT: 12.2), reaching 0.4-0.6 ns/event on cache-resident batches. An NLL over 1M events with a RooGenericPdf improves from 42 ms to 12.7 ms per evaluation (JIT backend: 23.5 ms). The vectorized path wins at every batch size in the sweep; identical output checksums against the JIT backend confirm the bitwise contract on VDT-less builds. 🤖 Done with the help of AI
ferdymercury
reviewed
Sep 1, 2026
| Var, ///< push vars[arg] | ||
| Add, ///< a + b | ||
| Sub, ///< a - b | ||
| Mul, ///< a * b |
Collaborator
There was a problem hiding this comment.
side note: https://www.partow.net/programming/exprtk/index.html
is being used by tree/dataframe and is maybe helpful for some of this stuff.
Test Results 19 files 19 suites 3d 0h 51m 23s ⏱️ For more details on these failures, see this check. Results for commit 2107291. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #21052
🤖 Done with the help of AI