Skip to content

[RF] Implement RooFormulaVar and RooGenericPdf evalutation path without TFormula - #23209

Draft
guitargeek wants to merge 5 commits into
root-project:masterfrom
guitargeek:roofit-jit-free-formula
Draft

[RF] Implement RooFormulaVar and RooGenericPdf evalutation path without TFormula#23209
guitargeek wants to merge 5 commits into
root-project:masterfrom
guitargeek:roofit-jit-free-formula

Conversation

@guitargeek

Copy link
Copy Markdown
Contributor

Closes #21052

🤖 Done with the help of AI

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
Var, ///< push vars[arg]
Add, ///< a + b
Sub, ///< a - b
Mul, ///< a * b

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@github-actions

github-actions Bot commented Sep 1, 2026

Copy link
Copy Markdown

Test Results

    19 files      19 suites   3d 0h 51m 23s ⏱️
 3 839 tests  3 838 ✅ 0 💤  1 ❌
65 028 runs  65 013 ✅ 0 💤 15 ❌

For more details on these failures, see this check.

Results for commit 2107291.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Cling JIT out-of-memory error when using large number of expression-based NormFactors

2 participants