Skip to content

[RF] Architectual improvements in CUDA backend that speed up unbinned fits by up to factor two - #23263

Open
guitargeek wants to merge 8 commits into
root-project:masterfrom
guitargeek:single-stream-evaluator
Open

[RF] Architectual improvements in CUDA backend that speed up unbinned fits by up to factor two#23263
guitargeek wants to merge 8 commits into
root-project:masterfrom
guitargeek:single-stream-evaluator

Conversation

@guitargeek

@guitargeek guitargeek commented Sep 5, 2026

Copy link
Copy Markdown
Contributor

This PR overhauls the RooFit CUDA backend around a single CUDA stream per RooFit::Evaluator, replacing the per-node streams/events and the polling scheduler. On top of that, it removes all allocations and most synchronization from the evaluation hot loop and brings the CUDA NLL reduction to full semantic parity with the CPU path.

These are architectural and feature parity improvements that I always knew had to be done at some point, but the CUDA backend was not a priority in the recent years. Now that I can tell AI to implement the architecture vision, the cost function for this work has shifted and it was the time to do it.

All of the unbinned RooFit benchmarks in rootbench (1M events) drastically benefit from the new single-stream architecture. The speedup is up to a factor two:

bench_cuda

Note that the benchmarks were only ran after the PR was completed, to not risk biasing the implementation too much by benchmark overfitting.

Replace the per-node CUDA streams and events plus the polling scheduler
in the RooFit::Evaluator with a single stream per Evaluator instance on
which all GPU work is enqueued in topological order.

Rationale:

* The per-node multi-stream design never delivered actual concurrency:
  all data transfers go through synchronous cudaMemcpy calls on the
  legacy default stream, which serializes across streams. Independent
  measurements show the single-stream version is never slower and up to
  7 % faster on multi-channel simultaneous fits, the workload the
  multi-stream design targeted.

* The elementwise kernels saturate the GPU by themselves at realistic
  event counts, so overlapping them across streams cannot win anything.
  In the small-kernel regime, launch and allocation overhead dominates,
  which is better addressed with pooled allocations and CUDA graph
  capture. Both become much simpler with a fixed single-stream launch
  sequence.

* CPU/GPU overlap is preserved: kernel launches are asynchronous, so
  the topological loop keeps evaluating independent CPU nodes while
  enqueued GPU work is in flight. Host-side reads of GPU results
  synchronize in the buffer implementation.

This removes the remServers state machine, the 1 ms sleep polling loop,
and the per-node stream and event bookkeeping (net -47 lines).

Benchmarks on an RTX A4500 (Gaussian+Exponential fits, wall time,
identical minNll in all cases):

  single channel:   1M events: 0.220 s -> 0.217 s, 10M: 1.31 -> 1.30 s
  8-channel simultaneous, 125k/chan: 1.63 s -> 1.57 s
  8-channel simultaneous, 12.5k/chan: 0.90 s -> 0.84 s

test-stressroofit-cuda and test-stressroostats-cuda pass.

🤖 Done with the help of AI
After the single-stream refactoring of the RooFit::Evaluator, the
per-node CUDA event and stream-query methods on the
RooBatchComputeInterface have no users left. Remove them, together with
the CudaEvent wrapper and the event-related functions in the
CudaInterface, and the throwing stubs in the CPU implementation.

🤖 Done with the help of AI
The Bernstein compute function applied the binomial coefficients to the
extra args in-place and restored them at the end of the function. In the
CUDA case, the extra args live in global device memory shared by all
threads, so this read-modify-write pattern was a cross-block data race:
a thread block that finished early could restore the coefficients while
other blocks were still using them.

Fold the binomial coefficient recurrence into the evaluation loops
instead, so the extra args are never written to. The results are
bit-for-bit identical because the same products are computed in the
same order.

This also makes Bernstein safe for skipping the device-to-host copy-back
of the extra args after kernel launches.

🤖 Done with the help of AI
Overhaul the memory and transfer handling of the RooBatchCompute CUDA
backend:

* The typed CudaInterface copy helpers silently dropped their stream
  argument, so every copy was a synchronous cudaMemcpy on the legacy
  default stream that serialized with the compute stream. The stream is
  now forwarded, and all callers were audited for the new asynchronous
  semantics: PinnedBufferContainer::hostReadPtr() and the reduction
  results now synchronize the stream explicitly, and the buffer copies
  in the Evaluator::setInput() path keep their synchronous behavior via
  the nullptr-stream default.

* Every compute() call allocated its staging area with cudaMalloc and
  freed it with cudaFree, and staged through a pageable host vector.
  The reductions likewise allocated their output buffers per call. All
  of this is replaced by StreamScratch, a per-stream ring of pinned
  host + device staging slots guarded by CUDA events, so the hot loop
  performs no CUDA allocations at all and staging uploads are
  asynchronous.

* The extra args were unconditionally copied back after every kernel
  launch, which forced a round trip per node. Only the NormalizedPdf
  computer actually mutates its extra args (evaluation error counters),
  so only that case copies back and synchronizes.

* Evaluator::run() now synchronizes the stream before returning, which
  makes the eager buffer recycling at the start of the next evaluation
  safe and restores the completion guarantee that the old polling
  scheduler provided.

Also initialize Batches::extra and relax the debug assertion in
reduceNLL, which wrongly required scalar probability spans to be device
pointers (an observable-independent pdf legitimately provides a host
scalar).

Benchmarks on an RTX A4500 (wall time, identical minNll everywhere,
CUDA baseline before the single-stream refactoring in parentheses):

  1 channel, 1M events:                0.13 s  (was 0.22 s)
  1 channel, 10M events:               1.21 s  (was 1.31 s)
  8-channel simultaneous, 125k/chan:   1.30 s  (was 1.63 s)
  8-channel simultaneous, 12.5k/chan:  0.56 s  (was 0.90 s)

test-stressroofit-cuda and test-stressroostats-cuda pass.

🤖 Done with the help of AI
The CUDA implementation of RooBatchComputeInterface::reduceNLL()
diverged from the CPU implementation in several user-visible ways:

* Non-positive, NaN, and infinite probabilities were not counted, so
  RooNLLVarNew silently dropped all evaluation error logging in CUDA
  fits.

* The accumulated "badness" of problematic events was not packed into
  the returned NaN with RooNaNPacker, so the error recovery in the
  minimizer (RecoverFromUndefinedRegions) did not work on the GPU: the
  minimizer only saw a plain NaN without the recovery information.

* Zero-weight events were not skipped, so a zero-weight event with zero
  probability turned the NLL into NaN via 0 * inf, while the CPU
  implementation skips such events.

The nllSumKernel now mirrors the CPU getLog() semantics per event and
accumulates the badness and the three error counters with atomicAdd on
the rare path. The statistics are stored next to the Kahan sum in the
stream scratch slot, so they are read back in the same single copy, and
the host packs the badness into the NaN like the CPU implementation.

For pre-sm_60 devices, the canonical atomicCAS-based double atomicAdd
fallback is provided in RooBatchComputeTypes.h. RooNaNPacker::unpackNaN
and isNaNWithPayload are marked __roodevice__ so they can be used in
kernels.

Also fix the evaluation error counters of computeNormalizedPdf: the
thresholds for reporting type-1 and type-2 errors were "> 1" and "> 2"
instead of "> 0" (so single errors were never reported, also on CPU),
and the counter accumulation in global device memory now uses atomicAdd
to avoid losing counts to the read-modify-write race between threads.

The new RooNaNPacker.CudaEvalErrorParity test verifies that CPU and
CUDA backends produce the same packed badness payload and that
zero-weight events in undefined pdf regions are skipped identically.

🤖 Done with the help of AI
The sums of the event weights and of the squared event weights only
depend on the dataset, but they were recomputed with a full reduction
in every evaluation of the RooNLLVarNew (and for the weight sum, in
every chi2 evaluation). In CUDA fits, each of these reductions also
implied kernel launches and a stream synchronization per likelihood
component per minimizer evaluation.

The sums are now cached in the RooNLLVarNew, keyed on a new input data
generation counter in the EvalContext that the Evaluator bumps whenever
new input arrays are loaded via setInput(). This covers the dataset
swapping in RooEvaluatorWrapper::setData(), e.g. for toy studies. The
counter values are globally unique in the process, so a cached sum can
never be wrongly validated by the context of a different Evaluator.
The two sums carry separate generation stamps, because the squared
weight sum is only computed on demand when applyWeightSquared() is
enabled during the fit.

This speeds up both backends, since the CPU reduction over the weights
was equally redundant. Wall times on an RTX A4500 (before -> after,
identical minNll):

  1 channel, 10M events:               cpu 14.3 -> 13.0 s, cuda 1.21 -> 1.19 s
  8-channel simultaneous, 125k/chan:   cpu 3.35 -> 3.03 s, cuda 1.30 -> 1.22 s
  8-channel simultaneous, 12.5k/chan:  cpu 0.32 -> 0.29 s, cuda 0.56 -> 0.47 s

Verified that NLL values after setData() are bit-identical to freshly
created NLL objects on both backends, that SumW2Error fits agree
between CPU and CUDA, and that the full RooFit test suite including the
CUDA stress tests passes.

🤖 Done with the help of AI
The NormalizedPdf compute function uses its extra args as output
parameters for the evaluation error counters, and the caller inspected
them right after the compute() call. In CUDA mode, this forced a full
stream synchronization after every NormalizedPdf kernel launch: one
pipeline stall per likelihood component per minimizer evaluation.

The readback is now deferred: compute() enqueues an asynchronous copy
of the counters into a persistent pinned slot of the per-stream scratch
memory, and synchronizeCudaStream() forwards them to the caller's
memory after the synchronization at the end of the evaluation of the
computation graph. Correspondingly, RooNormalizedPdf keeps its counters
in a member instead of on the stack, and defers the logging of the
evaluation errors with the new EvalContext::deferAction() mechanism:
the RooFit::Evaluator runs the deferred actions right after the final
stream synchronization of each evaluation.

The deferral stays within one evaluation, so the evaluation errors are
still logged at the same point in the fit and with the same parameter
value snapshots as before, and the number of logged errors is identical
between the CPU and CUDA backends (verified: 1802 errors on both for a
pdf that goes negative in part of its range). The CPU code path is
unchanged apart from the counters living in the member.

Wall times on an RTX A4500 (identical minNll):

  1 channel, 1M events:                cuda 0.13 -> 0.12 s
  8-channel simultaneous, 125k/chan:   cuda 1.22 -> 1.18 s
  8-channel simultaneous, 12.5k/chan:  cuda 0.47 -> 0.42 s

The full RooFit test suite including the CUDA stress tests passes.

🤖 Done with the help of AI
Address the confirmed findings of a code review of the CUDA evaluation
refactoring:

* Don't recycle pinned host buffers eagerly during an evaluation. Since
  the host-to-device uploads are genuinely asynchronous now, a released
  pinned buffer could be handed to another CPU-evaluated node that
  overwrites the pinned host array with a plain CPU write - which is
  not ordered by the CUDA stream - while the previous owner's upload is
  still pending, silently corrupting GPU inputs. Buffers of nodes with
  copyAfterEvaluation are now released only at the beginning of the
  next evaluation, after the stream synchronization at the end of the
  current one.

* If an evaluation is aborted by an exception, synchronize the stream
  and deliver the deferred readbacks while the destination memory in
  the computation graph is guaranteed to be alive, so that no armed
  readback survives into a later evaluation where it would flush stale
  data (or, in the worst case, write to freed memory).

* Make the reallocation paths of the stream scratch memory exception
  safe: reset the slot state before reallocating, so a throwing
  cudaMalloc can't leave dangling pointers with a stale capacity behind
  that would later lead to a double free.

* In the reductions, record the slot guard event right after the last
  enqueued use instead of after the (throwing) stream synchronization,
  so the slot stays protected on the exception path.

* Drain the deferred actions of both evaluation contexts in the
  heterogeneous evaluation, so actions registered by CPU-evaluated
  nodes can not be silently discarded.

* Document the narrowed extra-args contract of compute() in the
  interface header: only NormalizedPdf receives output through the
  extra args, and in CUDA mode the caller's memory must stay valid
  until the next stream synchronization.

Verified with a heterogeneous model (two CUDA-unsupported pdfs in a
RooAddPdf, exercising multiple same-size pinned boundary buffers per
evaluation), all previous parity checks, and the full RooFit test
suite including the CUDA stress tests. Benchmark timings are unchanged.

🤖 Done with the help of AI
@guitargeek guitargeek self-assigned this Sep 5, 2026
@guitargeek
guitargeek requested a review from dpiparo September 5, 2026 09:57
@guitargeek guitargeek changed the title [RF] Architectual improvements in CUDA backend [RF] Architectual improvements in CUDA backend that speed up unbinned fits by up to factor two Sep 5, 2026
@github-actions

github-actions Bot commented Sep 5, 2026

Copy link
Copy Markdown

Test Results

    23 files      23 suites   3d 18h 24m 7s ⏱️
 3 869 tests  3 868 ✅ 0 💤 1 ❌
79 725 runs  79 724 ✅ 0 💤 1 ❌

For more details on these failures, see this check.

Results for commit 9faad73.

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.

1 participant