Skip to content

Address PR #242 review comments (clGetKernelInfo gating, imaginary-eigenvalue read gating)#243

Open
fil-monti wants to merge 9 commits into
beagle-dev:matrix_vectorfrom
fil-monti:matrix_vector-gpu-bugfixes-followup
Open

Address PR #242 review comments (clGetKernelInfo gating, imaginary-eigenvalue read gating)#243
fil-monti wants to merge 9 commits into
beagle-dev:matrix_vectorfrom
fil-monti:matrix_vector-gpu-bugfixes-followup

Conversation

@fil-monti

Copy link
Copy Markdown

Summary

Follow-up to #242, addressing all three review comments from @msuchard.

GPUInterfaceOpenCL.cpp:548,659 — "clGetKernelInfo() only needs to be called if
BEAGLE_DEBUG is set":

  • Moved the clGetKernelInfo() call inside the existing BEAGLE_DEBUG_KERNEL_ARGS gate in both
    LaunchKernel and LaunchKernelConcurrent, so it no longer runs on every kernel launch.

BeagleGPUImpl.hpp:513 — "this is not right. if isAllReal then the imaginary parts
should never be written or read":

  • The GPU spectral pruning kernels (SPECTRAL_EIGENVALS_GPU/SPECTRAL_EIGENVALS_SIB_ONLY_GPU)
    now take isAllReal1/isAllReal2 kernel arguments and skip the imaginary-eigenvalue read
    entirely when a decomposition is real — mirroring the pattern the adjoint kernel
    (kernelAdjointMergedN) already used. Applied to both kernelsSpectralIfDef.cu and
    kernelsSpectralIfDef4.cu (byte-identical macros in both files).
  • Threaded the new flags through KernelLauncher's eight pruning/growing wrapper functions and
    BeagleGPUSpectralImpl's dispatch call sites, computed from the existing per-decomposition
    hEigenDecompIsAllReal array.
  • With the kernel side properly gated, narrowed the device eigenvalue buffer sizing back to
    EIGEN_COMPLEX-only (dropping the SPECTRAL_REPRESENTATION clause added in Fix GPU spectral bugs on AMD/ROCm (alignment, symbol visibility, adjoint kernel miscompilation) #242), exactly
    matching the CPU implementation (EigenDecompositionSpectral.hpp).
  • Added two new adjointtest4 test cases that the existing suite didn't cover: a genuinely
    real-only model that never requests EIGEN_COMPLEX (confirms the buffer is truly narrow, not
    just coincidentally safe), and a dynamic real→complex→real transition across sequential
    setEigenDecomposition calls on the same instance (since realness is recomputed per call, not
    fixed per instance).

Full design rationale, alternatives considered, and verification protocol are written up in
beagle-bugs/gpu-spectral-imaginary-eigenvalue-gating/PLAN.md.

Verified

  • adjointtest4 --gpu 1 (4/16/17-state): unchanged from Fix GPU spectral bugs on AMD/ROCm (alignment, symbol visibility, adjoint kernel miscompilation) #242's baseline (16-state's pre-existing
    ~1e-3 precision-level diff confirmed unrelated to this change).
  • New --realonly test: PASS, confirmed via BEAGLE_DEBUG_EIGEN=1 that the device buffer is
    genuinely narrow.
  • New --dynamic test: PASS across the real→complex→real transition.
  • Full rabies_smoke.xml through beast-mcmc with GPU forced: unaffected (that model already
    requests EIGEN_COMPLEX, so it was never relying on the over-broad widening being removed here).

Known gap: the CUDA-side spectral kernels (kernelsSpectral.cu/kernelsSpectral4.cu) have the
identical unconditional imaginary-read issue but aren't touched here — BUILD_CUDA is off on the
AMD hardware this was developed/tested on, so this couldn't be built or verified for CUDA.

Test plan

  • adjointtest4 --gpu 1 --all on AMD/ROCm hardware (gfx906)
  • rabies_smoke.xml through beast-mcmc, GPU forced
  • CUDA build/test (not possible in this environment — flagging for reviewer or a follow-up)

In both LaunchKernel and LaunchKernelConcurrent, the clGetKernelInfo() call that
fetches the kernel name for debug tracing was unconditional, paying for an
OpenCL driver round-trip on every single kernel launch even when
BEAGLE_DEBUG_KERNEL_ARGS isn't set and the name is never used. Move it inside
the existing getenv() gate, matching how every other use of kernelNameBuf in
both functions is already gated.

Addresses PR beagle-dev#242 review comments (msuchard) on GPUInterfaceOpenCL.cpp:548,659.
…sAllReal2

SPECTRAL_EIGENVALS_GPU() and SPECTRAL_EIGENVALS_SIB_ONLY_GPU() (in both
kernelsSpectralIfDef.cu, the general N-state file, and kernelsSpectralIfDef4.cu,
the 4-state specialization -- these carry byte-identical copies of both
macros) unconditionally read eigenValues[PADDED_STATE_COUNT + state],
regardless of whether that eigendecomposition is actually complex, unlike
the adjoint kernel (kernelAdjointMergedN), which already gates this read
correctly behind a per-branch isAllReal flag.

Add isAllReal1/isAllReal2 parameters (one per child) to both macros and
every one of the twelve kernel functions that call them, and skip the
imaginary read + SPECTRAL_SINCOS call entirely when real, writing sDs=e,
sCs=0 directly (the same real-eigenvalue convention already documented
elsewhere in this file) rather than relying on cos(0)=1/sin(0)=0 falling
out of an angle that would otherwise never need to be read.

This is a prerequisite for narrowing the host-side eigenvalue buffer
sizing back to EIGEN_COMPLEX-only (next commit) -- without this gating,
that narrowing would reopen the original out-of-bounds read. See
beagle-bugs/gpu-spectral-imaginary-eigenvalue-gating/PLAN.md for the full
analysis (PR beagle-dev#242 review comment from msuchard on BeagleGPUImpl.hpp:513).

Mirrors the adjoint kernel's already-proven isAllReal pattern rather than
a compile-time kernel-variant split, since real/complex status is a
per-eigendecomposition runtime property (recomputed on every
setEigenDecomposition call, can change between MCMC iterations) rather
than a fixed per-instance one.

Note: the CUDA-side kernels (kernelsSpectral.cu/kernelsSpectral4.cu) have
the identical unconditional-read issue but are not touched here --
BUILD_CUDA is off on the hardware available for this work, so this
couldn't be built or verified there.
Adds isAllReal1/isAllReal2 int parameters to the eight KernelLauncher
wrapper functions covering the pruning and growing/pre-order spectral
kernels (PartialsPartialsPruningSpectral, StatesPartialsPruningSpectral,
StatesStatesPruningSpectral, PartialsPartialsGrowingSpectral(Top),
PartialsStatesGrowingSpectral(Top), and the two GrowingSpectralTopRoot
variants which need only isAllReal2, one child), threading them into the
new kernel arguments added in the previous commit and bumping
parameterCountV/totalParameterCount at each LaunchKernel/LaunchKernelConcurrent
call site accordingly.

BeagleGPUSpectralImpl.hpp's dispatchPrunePP/SP/SS and dispatchGrowingSpectral
compute the actual flags from hEigenDecompIsAllReal[ei1]/[ei2] -- already
populated per-eigendecomposition from the real eigenvalue data in
setEigenDecomposition -- and pass them through at every call site.

A plain scalar kernel argument (rather than a device offset-queue record,
as the adjoint kernel's isAllReal uses) is the right mechanism here: the
adjoint kernel is a single merged launch covering many branches at once,
while these pruning/growing kernels launch one call per edge/pair, so a
plain argument looked up host-side immediately before each launch is
simpler and sufficient.
…tching CPU

Now that the pruning kernels gate their imaginary-eigenvalue reads on a
per-decomposition isAllReal flag (previous two commits), the device
eigenvalue buffer no longer needs to be widened for every
BEAGLE_FLAG_SPECTRAL_REPRESENTATION instance regardless of whether it's
actually complex -- only EIGEN_COMPLEX requires the extra width, exactly
matching EigenDecompositionSpectral.hpp's CPU implementation
(`kEigenValuesSize(isComplex ? kStateCount * 2 : kStateCount)`).

Addresses PR beagle-dev#242 review comment (msuchard) on BeagleGPUImpl.hpp:513: "this
is not right. if isAllReal then the imaginary parts should never be
written or read." Widening on SPECTRAL_REPRESENTATION alone had no CPU
analog and wasted memory/copy work for spectral-mode instances that are
structurally guaranteed real. This change alone would reopen the original
out-of-bounds read bug without the kernel-side gating from the previous
commits landing first -- see
beagle-bugs/gpu-spectral-imaginary-eigenvalue-gating/PLAN.md for the full
analysis of why the two changes are coupled.

Verified: adjointtest4 --gpu 1 (4/16/17-state) unchanged from baseline; new
--realonly test confirms (via BEAGLE_DEBUG_EIGEN=1) the device buffer is
now genuinely narrow, not just coincidentally safe; full rabies_smoke.xml
through beast-mcmc unaffected (that model already requests EIGEN_COMPLEX,
so it was never relying on the removed over-widening).
Two new regression tests for the isAllReal gating/buffer-narrowing change:

- runTestRealOnly (--realonly): a symmetric/reversible 17-state circulant
  (r_fwd == r_bkd, so every eigenvalue is exactly real, not just
  numerically close to zero) that never requests BEAGLE_FLAG_EIGEN_COMPLEX,
  at a padded state count (17 -> kPaddedStateCount=32). The device
  eigenvalue buffer is allocated at exactly kPaddedStateCount width, no
  imaginary slack at all -- if the kernel's imaginary-eigenvalue read
  weren't properly gated, this configuration reads past the end of that
  buffer. This is the one existing-test gap the PR beagle-dev#242 review comment
  identified: every previous test instance requested EIGEN_COMPLEX, so
  none of them exercised the narrow-buffer code path this change enables.

- runTestDynamicTransition (--dynamic): a single BEAGLE_FLAG_EIGEN_COMPLEX
  16-state instance with three sequential setEigenDecomposition calls on
  the same eigenIndex -- real, then complex, then real again -- exercising
  hEigenDecompIsAllReal's per-call (not per-instance) nature: it's
  recomputed from the actual eigenvalues on every call, since a model's
  eigendecomposition can genuinely flip between real and complex across
  MCMC iterations. Includes a step0-vs-step2 consistency check (identical
  real input data, with a complex decomposition processed on the same
  instance in between) to catch stale kernel/device state from the
  transition; the small residual diff this surfaced was tracked down to
  pre-existing GPU atomic-add reduction non-associativity noise between
  separate kernel launches, unrelated to this change, and documented
  inline.

buildCirculantN() gained optional r_fwd/r_bkd parameters (defaulting to the
existing asymmetric 1.0/0.5 values, so all prior callers are unaffected)
to support constructing the symmetric/real-only case above without
duplicating the eigendecomposition-construction logic.

Both new modes are included in --all alongside the existing 4/16/17-state
tests.
…t getenv()

Investigating why GPU BEAGLE was ~16-17x slower than CPU on a real analysis
(see beagle-bugs/gpu-spectral-per-branch-distance-batching/PLAN.md), no ROCm
profiler was available on the target machine, so this adds targeted
accumulating-counter instrumentation instead: call counts and elapsed
host-side wall time for LaunchKernel, LaunchKernelConcurrent, SynchronizeHost
(clFinish), and MemcpyHostToDevice, gated behind a new BEAGLE_PROFILE_SUMMARY
env var and printed once via atexit() (not per-call, to avoid repeating an
earlier session's mistake of an unconditional per-call print flooding a
benchmark log with millions of lines). A warmup window discards the first
300 SynchronizeHost calls before any counter starts accumulating, so the
summary reflects steady-state behavior rather than one-time startup costs.

Also fixes a real, if minor, inefficiency found while reading this code:
LaunchKernel/LaunchKernelConcurrent were calling
getenv("BEAGLE_DEBUG_KERNEL_ARGS") repeatedly (11-15 times per single kernel
launch) instead of once; hoisted to a single check per call.

This instrumentation is what identified the root cause fixed in the next
commit (6.5 million individually-blocking MemcpyHostToDevice calls
dominating GPU wall-clock time) and remains available as a standing,
default-off profiling knob.
These were added as temporary diagnostics while tracking down a cross-plugin
symbol-interposition bug (see the "Add unconditional dispatchPruneSS trace
prints (temporary, not env-gated)" commit) and flagged there as needing
cleanup before this branch was done -- they print on every single postorder
pruning dispatch, unconditionally (no env-var gate, unlike every other debug
aid in this codebase), and were confirmed to be actively drowning out real
signal in a benchmark log during later performance work. No longer needed;
whatever they were diagnosing has long since been fixed and verified.
… per branch

Profiling found GPU BEAGLE spending 82.6% of measured GPU-interface time
(100.4s of 121.4s accounted, on a 3000-iteration real analysis) in 6.5
million individually-blocking ~100-byte MemcpyHostToDevice calls -- one per
tree branch per HMC leapfrog step, updating that branch's distance
(effective branch length x category rate) in BeagleGPUSpectralImpl::
updateTransitionMatrices. Actual GPU compute (clFinish wait) was only 3.5%
of that time; the bottleneck was round-trip latency paid millions of times
over for tiny transfers, not bandwidth.

Fixes it by mirroring a pattern already proven in this same codebase:
BeagleGPUImpl::updateTransitionMatrices (the base class, called directly
above the code this changes) already solves the identical class of problem
for its dense-matrix update via gather-batch-scatter -- build one flat
host-side queue of (destination-offset, value) pairs, upload it with
exactly two MemcpyHostToDevice calls regardless of branch count, then
dispatch one GPU kernel that scatters each value into its device-memory
destination. Applies that same pattern here:

- New dedicated queue buffers (dSpectralPtrQueue/dSpectralDistanceQueue +
  host mirrors), sized kMatrixCount * kCategoryCount. Not reusing the base
  class's own queue buffers: they're declared `private:` in BeagleGPUImpl,
  unreachable from this subclass, so dedicated buffers were required, not
  just safer.
- New trivial kernelScatterSpectralDistances kernel (one thread per queue
  entry, no PADDED_STATE_COUNT/STATE_COUNT dependence), added identically
  to both kernelsSpectralIfDef.cu and kernelsSpectralIfDef4.cu (matching
  this codebase's existing precedent of duplicating spectral-only kernel
  logic into both files rather than the shared dense-kernel file).
- Destination offsets computed via the existing kSpectralDistanceStrideElements
  member (probabilityIndices[i] * kSpectralDistanceStrideElements + j) --
  no new addressing arithmetic needed.
- New KernelLauncher::ScatterSpectralDistances wrapper, deliberately without
  a synchronizing call after the launch (matching GetTransitionProbabilitiesSquare's
  own precedent) -- adding one would reintroduce exactly the kind of
  round-trip stall this fix removes.

Verified correctness (adjointtest4 --gpu 1 --all unchanged from baseline,
including a controlled A/B confirming a pre-existing marginal 16-state
precision-tolerance FAIL is unrelated to this change) and performance
(same 3000-iteration benchmark, BEAGLE_PROFILE_SUMMARY-instrumented):
MemcpyHostToDevice calls dropped 6,546,621 -> 105,772 (61.9x fewer),
MemcpyHostToDevice time 100.4s -> 2.1s (47.7x less), total BEAST wall time
267.9s -> 173.3s (1.55x faster). CPU-vs-GPU ratio on this analysis improved
from ~15.9x GPU-slower to ~10.3x GPU-slower -- GPU still slower overall for
a problem this small, but the specific round-trip-latency bottleneck this
session targeted is resolved; most of the remaining gap is JVM/Java-side
work outside BEAGLE's GPU interface entirely (only ~15% of BEAST's wall
time is now spent in the instrumented GPU-interface layer at all).

See beagle-bugs/gpu-spectral-per-branch-distance-batching/PLAN.md for the
full design rationale.
Follow-up to the distance-batching fix: after that fix, only ~25.3s of a
171.4s real-analysis BEAST run was accounted for by the four originally
profiled metrics (LaunchKernel, LaunchKernelConcurrent, SynchronizeHost,
MemcpyHostToDevice) -- an unexplained ~148s (85%) gap that couldn't be
generic JVM overhead, since the CPU-backed run of the identical analysis
takes only ~16.8s total.

Adding MemcpyDeviceToHost instrumentation (same accumulating-counter,
warmup-gated, atexit-printed style as the existing metrics) found it
accounts for 91.9s (53.6% of wall time) on its own -- bigger than the
entire MemcpyHostToDevice bottleneck was before its fix. Adding MemsetZero
instrumentation found a further 35-37s, previously assumed "likely small"
and never actually measured. Together these bring total accounted time to
~90% of wall time, closing the mystery gap from ~53s down to ~16s.

Both new metrics were then diagnosed (via a temporary, since-reverted
SynchronizeHost() probe inserted at each call site -- not part of this
commit) to be genuine, unavoidable GPU-kernel-completion wait time, not
fixable memcpy/write-level overhead: OpenCL kernel launches are
asynchronous, so the real compute time doesn't show up at the kernel
enqueue call, it surfaces at whatever blocking call happens next. No
software fix exists at this level; the remaining GPU-vs-CPU gap for this
problem size is now understood to be substantially explained by real
compute time, not a bug. Full investigation and conclusion in
beagle-bugs/gpu-spectral-device-to-host-readback/PLAN.md and
PROJECT_STATUS.md.

MemsetZero's existing blocking clEnqueueWriteBuffer mechanism (deliberately
not clEnqueueFillBuffer, per the pre-existing comment documenting a real,
previously-observed intermittent data race with that alternative) is left
completely unchanged -- only a timing wrapper was added around it.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant