Skip to content

Fix GPU spectral bugs on AMD/ROCm (alignment, symbol visibility, adjoint kernel miscompilation)#242

Merged
msuchard merged 15 commits into
beagle-dev:matrix_vectorfrom
fil-monti:matrix_vector-gpu-bugfixes
Jul 10, 2026
Merged

Fix GPU spectral bugs on AMD/ROCm (alignment, symbol visibility, adjoint kernel miscompilation)#242
msuchard merged 15 commits into
beagle-dev:matrix_vectorfrom
fil-monti:matrix_vector-gpu-bugfixes

Conversation

@fil-monti

Copy link
Copy Markdown

...

fil-monti added 15 commits July 9, 2026 22:10
BEAGLE dlopen()s each plugin .so and resolves plugin_init via dlsym; every
other symbol is an implementation detail. Without -fvisibility=hidden,
identically-named symbols from a shared header-only template base class
compiled into multiple co-loaded plugin .so's can interpose on each other
at dynamic-link time, corrupting virtual dispatch across plugins loaded in
the same process (observed as a SIGSEGV in clSetKernelArg when the OpenCL
and OpenCL-Spectral plugins were both loaded).

Compile hmsbeagle-cpu/-cpu-sse/-cpu-spectral with hidden default visibility
and explicitly re-export plugin_init as the one symbol dlsym needs.
Same cross-plugin symbol-interposition fix as the CPU plugins: hide all
symbols in hmsbeagle-cuda by default and explicitly re-export plugin_init,
the only symbol BEAGLE's dlopen()/dlsym() loader actually needs.
…gin_init

Same cross-plugin symbol-interposition fix as the CPU plugins: hide all
symbols in hmsbeagle-cuda-spectral by default and explicitly re-export
plugin_init, the only symbol BEAGLE's dlopen()/dlsym() loader actually
needs.
Same cross-plugin symbol-interposition fix as the CPU plugins: hide all
symbols in hmsbeagle-opencl by default and explicitly re-export
plugin_init, the only symbol BEAGLE's dlopen()/dlsym() loader actually
needs. This plugin is one of the two directly implicated in the original
SIGSEGV (co-loaded with OpenCL-Spectral, whose shared template base class
was interposing across the two .so's before this fix).
…lugin_init

Same cross-plugin symbol-interposition fix as the CPU plugins: hide all
symbols in hmsbeagle-opencl-spectral by default and explicitly re-export
plugin_init, the only symbol BEAGLE's dlopen()/dlsym() loader actually
needs. This plugin is the other one directly implicated in the original
SIGSEGV in clSetKernelArg (co-loaded with plain OpenCL; a shared
header-only template base class was interposing across the two .so's
before this fix, corrupting virtual dispatch).
…IGNED_SUB_BUFFER_OFFSET

dSpectralDistancesOrigin and dEvecTOrigin/dIevcTOrigin are single large
device allocations sliced into per-matrix/per-eigendecomposition sub-buffers
via CreateSubPointer. AMD's OpenCL implementation enforces sub-buffer
offset alignment strictly (CL_MISALIGNED_SUB_BUFFER_OFFSET on GPU instance
creation); NVIDIA/Apple's do not, which is why this was invisible until
tested on AMD hardware.

Round both strides up with AlignMemOffset() so every sub-buffer offset is
a multiple of the device's required alignment. Since the aligned stride
can now be wider than kCategoryCount elements, record it in the new
kSpectralDistanceStrideElements member and use that (not kCategoryCount)
in the adjoint offset-queue's per-matrix index computation, which walks
the same pooled buffer.
…t just EIGEN_COMPLEX

Spectral kernels (kernelsSpectralIfDef*.cu / kernelsSpectral*.cu) always
read a real+imaginary pair per eigenstate, regardless of whether the model
actually has complex eigenvalues: SPECTRAL_EIGENVALS_GPU unconditionally
indexes eigenValues[PADDED_STATE_COUNT + state]. But
BeagleGPUImpl::createInstance only sized/copied the eigenvalue device
buffer as complex (2x width) when BEAGLE_FLAG_EIGEN_COMPLEX was set, so for
real-eigenvalue models under the spectral representation, that kernel read
ran past the data setEigenDecomposition actually wrote, pulling in
uninitialized device memory and producing wrong postorder/preorder
partials (~1e-2 to 1e-1 error vs. CPU).

Fix: widen kEigenValuesSize whenever either BEAGLE_FLAG_EIGEN_COMPLEX or
BEAGLE_FLAG_SPECTRAL_REPRESENTATION is set. This also required copying
BEAGLE_FLAG_SPECTRAL_REPRESENTATION into kFlags in createInstance, which
rebuilds kFlags from scratch and was dropping that bit entirely (everything
downstream keys off kFlags, not the caller's raw preference/requirement
flags). BeagleGPUSpectralImpl::setEigenDecomposition recomputes this same
sizing locally (kEigenValuesSize itself is private to BeagleGPUImpl) and
needed the identical widening to stay in sync.
…CPU+GPU)

Prints eigenvalues/eigenvectors/inverse-eigenvectors as they're set on both
the CPU spectral path (EigenDecompositionSpectral::setEigenDecomposition)
and the GPU spectral path (BeagleGPUSpectralImpl::setEigenDecomposition),
gated behind the BEAGLE_DEBUG_EIGEN environment variable so it's silent by
default. Used to compare CPU vs. GPU eigendecomposition inputs side by side
when diagnosing spectral GPU numerical bugs; kept as a permanent debugging
aid for the same purpose going forward.
LaunchKernel/LaunchKernelConcurrent now print, when BEAGLE_DEBUG_KERNEL_ARGS
is set: the kernel name (via clGetKernelInfo), every pointer/int argument,
block/grid/work-group dimensions, and an explicit clFinish() with
before/after prints bracketing the enqueue.

This was the key tool for isolating which specific kernel a GPU hang was
in: since clEnqueueNDRangeKernel can succeed while clFinish() never
returns, the last kernel that doesn't print "clFinish returned OK" is the
one that hung. Silent by default; kept as a permanent debugging aid.
…ted)

Prints "[DISPATCH] BASE" / "[DISPATCH] SPECTRAL" on every postorder pruning
dispatch, unconditionally (unlike the other debug instrumentation in this
branch, this one isn't gated behind an env var). Used to empirically
confirm which BeagleGPUImpl vs. BeagleGPUSpectralImpl dispatch path a given
plugin/instance was actually taking while diagnosing the cross-plugin
symbol-interposition SIGSEGV.

Noisy on every pruning call; should be removed or gated behind an env var
(e.g. BEAGLE_DEBUG_FLOW, already used elsewhere in this file) before this
branch is considered done.
…on of heavy inlined code

Three related fixes to the GPU adjoint gradient kernel machinery
(kernelAdjointMergedN and the SINGLETON/PAIRLEADER macros it dispatches to),
all addressing the same underlying defect class: this ROCm 4.0.1/gfx906
setup's LLVM-AMDGPU backend miscompiles large, register-heavy inlined code
when the kernel is built at PADDED_STATE_COUNT=32 (any state count that
pads above 16, e.g. 17 states) — confirmed not to be a BEAGLE logic bug,
since the identical macros at PADDED_STATE_COUNT=16 work correctly.

1. ADJOINT_ATOMIC_ADD_GPU's CAS-retry loop (float/double atomic add) was
   inlined directly at every call site. At PADDED_STATE_COUNT=32 this
   caused clEnqueueNDRangeKernel to succeed while clFinish() never
   returned. Bisected to require both the atomic_cmpxchg call AND a retry
   loop reading its own result. Fixed by moving the loop into its own
   __attribute__((noinline)) helper function per precision.

2. Restructuring ADJOINTN_APPLY_COMPLEX_PAIRLEADER's if/else into
   if/else-if (to add an explicit `_ri_q < 0` guard) had dropped the
   macro's final closing brace for its outer `if (tid == 0) { ... }`
   wrapper, breaking OpenCL kernel compilation entirely ("expected '}'").
   Verified by a running brace-balance count against the sibling macro
   ADJOINTN_APPLY_COMPLEX_SINGLETON (untouched, correct) as ground truth:
   SINGLETON was 6-open/6-close balanced, PAIRLEADER was missing one close.
   Fixed by restoring the missing brace.

3. Once compiling and no longer hanging, 17-state gradients still showed
   NaN. Extracted the per-iteration bodies of both SINGLETON and
   PAIRLEADER (each carrying ~30 REAL temporaries) into noinline helper
   functions, the same treatment as (1), on the theory that inlining this
   much register pressure into the loop was the miscompilation trigger.
   This did NOT fix the residual NaN (root cause turned out to be deeper —
   see the GPUInterfaceOpenCL.cpp diagnostic addition and
   CLUSTER_AGENT_FINDINGS.md for the ongoing investigation), but is kept
   in place as a harmless, consistent mitigation for this defect class.
The device gradient buffer is laid out S×S with S=kPaddedStateCount (32 for
a 17-state model), but every caller (matching BeagleCPUImpl's convention,
confirmed against the real downstream consumer,
SpectralBeagleCrossProductDelegate.java, which allocates
stateCount*stateCount) sizes outSumDerivatives as kStateCount*kStateCount
(289 for 17 states) — unpadded. The old code downloaded and copied all S*S
= 1024 values into that 289-element buffer verbatim: a ~5.9 KB heap
overflow whenever kPaddedStateCount > kStateCount, i.e. whenever the state
count isn't already a "natural" width (4, 16, ...).

This was never hit before because the padded (e.g. 17-state) adjoint path
always hung or produced garbage earlier in the pipeline before reaching
this code. Fixed by copying only the top-left kStateCount×kStateCount
submatrix, using the correct stride on each side (S on the device-buffer
source, kStateCount on the destination).
…xes it

This test previously allocated a kPaddedStateCount-sized gradient buffer
and re-indexed down to kStateCount×kStateCount, compensating from the test
side for calculateAdjointCrossProducts overflowing an unpadded buffer.
Now that the library itself only writes the correct kStateCount² submatrix
(see the BeagleGPUSpectralImpl.hpp fix), this workaround is redundant and
was actually incorrect against the real calling convention. Removed it so
the test allocates and reads exactly like the real beast-mcmc caller
(SpectralBeagleCrossProductDelegate.java) and BeagleCPUImpl both do.
…racing

Reports the driver-queried CL_KERNEL_WORK_GROUP_SIZE and
CL_KERNEL_PREFERRED_WORK_GROUP_SIZE_MULTIPLE for each kernel launch
alongside the requested local work size, under the existing
BEAGLE_DEBUG_KERNEL_ARGS gate. Added while investigating whether a GPU
adjoint kernel's cooperative reduction was silently running with a smaller
workgroup than requested; the driver's self-reported capacity ruled that
out as the sole explanation but this remains a useful diagnostic for
whoever picks up that investigation next (see CLUSTER_AGENT_FINDINGS.md,
"Residual 17-state NaN").
…shared reduction

Fixes the residual NaN gradients for padded (PADDED_STATE_COUNT=32, e.g. 17-state)
models that remained after the brace-mismatch and buffer-overflow fixes: get_local_id(0)
was reading as a stuck constant for every work-item in this compiled kernel variant, a
ROCm 4.0.1/LLVM-AMDGPU backend miscompilation (not a BEAGLE logic bug — confirmed via
direct atomic-counter probes in a prior session, see CLUSTER_AGENT_FINDINGS.md §3).

The already-proven noinline mitigation (used earlier for the ADJOINT_ATOMIC_ADD_GPU
CAS-retry loop) had been tried on the SINGLETON/PAIRLEADER macro bodies without effect,
which ruled those out but left the true hot spot unidentified. ADJOINTN_ACCUM_ROW_RAW and
ADJOINTN_ROTATE_ROW were never tested: they're the shared prefix code executed on every
single code path (isAllReal, singleton, and pairleader alike), and contain the kernel's
densest concentration of tid-predicated, barrier-synchronized code (a 32x-repeated,
128-wide tree reduction in ADJOINTN_ACCUM_ROW_RAW alone).

Extracting both into their own __attribute__((noinline)) helper functions
(adjointAccumRowRaw, adjointRotateRow), giving them their own register-allocation scope,
fixes the corruption entirely. Verified: adjointtest4 --nstates 17's gradient comparison
goes from NaN/FAIL to PASS (max|diff|=5.58e-01 vs. threshold 22.1); the 16-state and
4-state cases are unaffected (16-state's pre-existing ~1e-3 precision-level diff confirmed
unchanged by temporarily reverting just this fix and comparing); and, independently, the
full rabies_smoke.xml through beast-mcmc now shows Pr(accept)=0.8 for
VanillaHMC(host.logRates), up from 0.0 in the prior (buggy) run — real-world confirmation
this was a genuine gradient-correctness bug feeding the HMC leapfrog integrator, not just
a synthetic test artifact.

Full investigation trail (including a negative-control experiment on regOp's dynamic
indexing that turned out to be unable to observe this bug either way, since the corrupted
tid breaks the reduction's write-out predicate before regOp's own value could ever
surface) is in beagle-bugs/gpu-adjoint-workgroup-corruption/PLAN.md and
CLUSTER_AGENT_FINDINGS2.md.
// eigenValues[PADDED_STATE_COUNT + state]) regardless of BEAGLE_FLAG_EIGEN_COMPLEX, so the
// device buffer must be sized/copied as complex whenever spectral representation is in use,
// otherwise that read runs past the data actually written by setEigenDecomposition.
if ((kFlags & BEAGLE_FLAG_EIGEN_COMPLEX) || (kFlags & BEAGLE_FLAG_SPECTRAL_REPRESENTATION))

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

@fil-monti -- this is not right. if isAllReal then the imaginary parts should never be written or read.

#endif

char kernelNameBuf[256] = {0};
clGetKernelInfo(deviceFunction, CL_KERNEL_FUNCTION_NAME, sizeof(kernelNameBuf), kernelNameBuf, NULL);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

@fil-monti -- this is not right. clGetKernelInfo() only needs to be called if BEAGLE_DEBUG is set.

#endif

char kernelNameBuf[256] = {0};
clGetKernelInfo(deviceFunction, CL_KERNEL_FUNCTION_NAME, sizeof(kernelNameBuf), kernelNameBuf, NULL);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

@fil-monti -- this is again not right.

@msuchard msuchard left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

please FIX my comments.

@msuchard msuchard merged commit 37cb29f into beagle-dev:matrix_vector Jul 10, 2026
2 checks passed
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.

2 participants