Skip to content

[RF] Make multiprocess gradient parallelization work with new vectorizing CPU backend - #23321

Merged
guitargeek merged 3 commits into
root-project:masterfrom
guitargeek:parallel-gradient
Sep 14, 2026
Merged

guitargeek merged 3 commits into
root-project:masterfrom
guitargeek:parallel-gradient

Conversation

@guitargeek

@guitargeek guitargeek commented Sep 9, 2026

Copy link
Copy Markdown
Contributor

The parallel gradient was still silently evaluating the likelihood with the RooFit legacy scalar backend on the workers, which heavily penalized it compared to the new default vectorizing CPU backend. This had to be fixed to give the gradient parallelization a chance at proving its worth.

And indeed, it works quite well!

  1. createNLL/FitHelpers now forward the EvalBackend into the modular likelihood, so workers use the vectorized CPU backend instead of the legacy scalar one (this was a one-line wiring bug that cost ~7x per evaluation; the legacy-vs-modular gtests passed because of it, and now pin EvalBackend::Legacy explicitly).
  2. RooUnbinnedL replaces its scalar std::log reduction loop with the same RooBatchCompute::reduceNLL/reduceSum used by RooNLLVarNew, with identical NaN-packing error semantics.
  3. The workers pre-seed the numerical derivator with the function value that Minuit's line search already knows (FCNBase::GradientWithPrevResult), skipping one full likelihood evaluation per worker per gradient call.

A toy model with 96 parameters over 8 channels nicely illustrates the performance gains (median of 3 interleaved repeats, 12-core machine, Minuit strategy 0, Migrad only):

config time vs serial
serial cpu fitTo 9.8 s 1x
Parallelize(8), unpatched master 21.6 s 0.46x
Parallelize(1) 10.4 s 0.94x
Parallelize(2) 6.2 s 1.6x
Parallelize(4) 4.8 s 2.0x
Parallelize(8) 3.3 s 3.0x

So for this toy model, you get a solid 2x already with four processes.

This PR therefore brings the gradient parallelization back into the discussion space when deciding how to make RooFit faster.

@github-actions

github-actions Bot commented Sep 10, 2026

Copy link
Copy Markdown

Test Results

    23 files      23 suites   3d 17h 26m 12s ⏱️
 3 873 tests  3 872 ✅ 0 💤 1 ❌
80 000 runs  79 999 ✅ 0 💤 1 ❌

For more details on these failures, see this check.

Results for commit d34268c.

♻️ This comment has been updated with latest results.

The evaluator-backed RooUnbinnedL computed the negative log-likelihood
from the batch of probabilities with a scalar std::log loop, costing
several times more than one vectorized likelihood pass now that the rest
of the evaluation is SIMD. Use the same RooBatchCompute::reduceNLL()
reduction as RooNLLVarNew, which also reproduces the RooNaNPacker-based
error propagation of the loop (badness packed into the returned NaN).

🤖 Done with the help of AI
The ModularL branch of createNLL never passed the parsed EvalBackend on
to the NLLFactory, whose default is the legacy backend. As a result,
every parallel fit (fitTo with Parallelize(), which implies ModularL)
silently evaluated the deprecated legacy scalar likelihood on the
workers, costing about 7x per likelihood evaluation compared to the
vectorized CPU backend and making the parallel gradient lose against any
serial fit (benchmark: 8-channel unbinned simultaneous fit with 64
correlated constrained systematics, 96 free parameters, 200k events:
serial fitTo 9.9s, Parallelize(8) 23.7s before, 4.7s after this and the
accompanying gradient-job commits).

The bitwise legacy-vs-modular comparisons in testLikelihoodGradientJob
now request EvalBackend(Legacy) explicitly on the modular side: they
compare against a legacy reference fit, so both likelihoods must use the
same arithmetic (previously that happened by virtue of this bug).

🤖 Done with the help of AI
Every gradient calculation in LikelihoodGradientJob started with each
worker evaluating the full likelihood once at the central point, only to
obtain the scalar function value that NumericalDerivator needs for its
step-size tolerances (SetupDifferentiate). Minuit already knows this
exact value: the line search that precedes each gradient request stores
it in MinimumParameters::Fval().

Hand that value through a new fifth argument of
FCNBase::GradientWithPrevResult() (a backward-compatible overload that
falls back to the old virtual), broadcast it to the workers along with
the rest of the minimizer state, and pre-seed the derivator's existing
central-value cache (fVxFValCache) with it, so SetupDifferentiate skips
its function call. When the likelihood offsets changed in the same state
update, NaN is broadcast instead and the workers evaluate as before,
since the known value corresponds to the previous offsets.

This is bitwise-transparent: the exact-equality comparisons in
testLikelihoodGradientJob pass unchanged with the pre-seeding active.

🤖 Done with the help of AI
@guitargeek
guitargeek merged commit 4f9479e into root-project:master Sep 14, 2026
33 of 35 checks passed
@guitargeek
guitargeek deleted the parallel-gradient branch September 14, 2026 07:04
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.

2 participants