Files
Jungfraujoch/image_analysis/indexing/IndexerThreadPool.cpp
T
leonarski_fandClaude Opus 5.5 6936b04d5f jfjoch_broker and rugnux check the CUDA JIT cache at start and warn when cuFFT plans are slow
cuFFT ships its transforms as PTX for every card (cuFFT 11 and 12 alike), so each plan has the driver
compile kernels, and a second plan of the same shape compiles them again. From the driver's JIT cache
that costs ~0.5 ms a plan; without it 45-130 ms, under cuFFT's process-wide lock - which, over the
~100 plans of molecular replacement, matches the stage pattern of the slow MR in the production broker.

The cache is unusable in more ways than CUDA_CACHE_DISABLE: with no HOME at all (a systemd system
service without User= gets none, and the driver does not fall back to the passwd entry), on a read-only
or unwritable directory, on a filesystem whose fcntl locks fail (NFS without locking) - each measured
as slow as no cache - or on a slow network filesystem (each plan opens and locks the cache ~10 times).

- common/CudaJitCache: the path the driver uses (CUDA_CACHE_PATH, else $HOME/.nv/ComputeCache), its
  filesystem type, whether it is writable, whether its index opens read-write and takes the driver's
  fcntl read lock, CUDA_CACHE_DISABLE and a CUDA_CACHE_MAXSIZE below 64 MiB. One line: info when fine,
  a warning with the cause and the remedy (CUDA_CACHE_PATH on a local disk) when not, or when a plan
  takes over 10 ms.
- WarmUpCuFFT returns the fastest of three 64^3 R2C plan makes after a first one that pays the
  initialisation (and a cold-cache compile): ~2 ms in all with a cache, ~0.3 s without.
- jfjoch_broker runs it after its version line (0.15 s at start, cuFFT init and card 0's context
  included); rugnux on its existing background warm-up thread.
- CUDAMemHelpers.h: the modules are PTX on every platform, not only on Windows.

No result changes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PXc1zD1yxHqaR1DGdCTHL4
2026-10-20 01:37:10 +02:00

496 lines
20 KiB
C++

// SPDX-FileCopyrightText: 2025 Filip Leonarski, Paul Scherrer Institute <filip.leonarski@psi.ch>
// SPDX-License-Identifier: GPL-3.0-only
#include "IndexerThreadPool.h"
#include <algorithm>
#include <chrono>
#include <cmath>
#include <map>
#include <tuple>
#include "../common/CUDAWrapper.h"
#include "../common/Logger.h"
#ifdef JFJOCH_USE_CUDA
#include "FFBIDXIndexer.h"
#include "FFTIndexerGPU.h"
#endif
#ifdef JFJOCH_USE_FFTW
#include "FFTIndexerCPU.h"
#endif
double WarmUpCuFFT() {
#ifdef JFJOCH_USE_CUDA
if (get_gpu_count() == 0)
return NAN;
// The first plan pays the initialisation, and compiles its kernels if the JIT cache does not have them
// yet; the next ones show what a plan costs from then on. The fastest of three, so that a stall
// beside (another card's context being made) is not taken for a slow plan.
double fastest_ms = INFINITY;
for (int i = 0; i < 4; i++) {
const auto t0 = std::chrono::steady_clock::now();
cufftHandle plan = 0;
if (cufftPlan3d(&plan, 64, 64, 64, CUFFT_R2C) != CUFFT_SUCCESS)
return NAN;
cufftDestroy(plan);
const double ms = std::chrono::duration<double, std::milli>(std::chrono::steady_clock::now() - t0).count();
if (i > 0)
fastest_ms = std::min(fastest_ms, ms);
}
return fastest_ms;
#else
return NAN;
#endif
}
#ifdef JFJOCH_USE_CUDA
FFTPlanReuse::FFTPlanReuse() : scope(std::make_unique<CudaFFTPlanScope>()) {}
#else
class CudaFFTPlanScope {};
FFTPlanReuse::FFTPlanReuse() = default;
#endif
FFTPlanReuse::~FFTPlanReuse() = default;
#ifndef JFJOCH_USE_CUDA
FFTIndexerReuse::FFTIndexerReuse() = default;
FFTIndexerReuse::~FFTIndexerReuse() = default;
void KeepFFTIndexers(const IndexingSettings &, int) {}
#endif
#ifdef JFJOCH_USE_CUDA
namespace {
// The card and what FFTIndexer's constructor reads from the settings.
using FFTIndexerKey = std::tuple<int, float, float, float, float, int64_t, float, int64_t>;
struct KeptFFTIndexers {
std::mutex m;
int scopes = 0;
std::map<FFTIndexerKey, std::vector<std::unique_ptr<Indexer>>> idle;
};
KeptFFTIndexers &kept_fft_indexers() {
static auto *k = new KeptFFTIndexers;
return *k;
}
FFTIndexerKey KeyOnCurrentCard(const IndexingSettings &s) {
int device = 0;
if (cudaGetDevice(&device) != cudaSuccess)
throw JFJochException(JFJochExceptionCategory::GPUCUDAError, "Failed to read the CUDA device");
return {device, s.GetFFT_MaxUnitCell_A(), s.GetFFT_MinUnitCell_A(), s.GetFFT_MinAngle_deg(),
s.GetFFT_MaxAngle_deg(), s.GetFFT_NumVectors(), s.GetFFT_HighResolution_A(), s.GetRefineThreads()};
}
std::unique_ptr<Indexer> BorrowFFTIndexer(const IndexingSettings &settings) {
auto &k = kept_fft_indexers();
std::lock_guard lock(k.m);
auto it = k.idle.find(KeyOnCurrentCard(settings));
if (it == k.idle.end() || it->second.empty())
return nullptr;
auto ret = std::move(it->second.back());
it->second.pop_back();
return ret;
}
// On the card the indexer was built on, which is the calling worker's.
void GiveBackFFTIndexer(const IndexingSettings &settings, std::unique_ptr<Indexer> &indexer) {
if (!indexer)
return;
auto &k = kept_fft_indexers();
std::lock_guard lock(k.m);
if (k.scopes > 0)
k.idle[KeyOnCurrentCard(settings)].push_back(std::move(indexer));
}
}
FFTIndexerReuse::FFTIndexerReuse() {
std::lock_guard lock(kept_fft_indexers().m);
kept_fft_indexers().scopes++;
}
FFTIndexerReuse::~FFTIndexerReuse() {
std::map<FFTIndexerKey, std::vector<std::unique_ptr<Indexer>>> idle;
{
std::lock_guard lock(kept_fft_indexers().m);
if (--kept_fft_indexers().scopes == 0)
idle.swap(kept_fft_indexers().idle);
}
for (auto &[key, list] : idle) {
ScopedGpuPin pin(std::get<0>(key));
list.clear();
}
}
// An FFT indexer is a cuFFT plan and ~0.6-0.9 GB of device buffers, and several pipeline copies of one
// process run on the card at the same time: probe passes (GPUWorkBeside) beside the main line, for a
// few seconds each. So one that does not fit hands back what the allocation pool holds unused and waits
// for the work beside it to end, then is built again - as the merge's allocations do. The main line
// waits for the probes; a probe waits for the main line's image pass (GPUImagePass), never for a probe,
// itself included. Neither of those waits for the other, so nothing waits in a circle. Only when the
// indexer is built depends on this, never what it computes; one that does not fit the card fails as
// before.
static std::unique_ptr<Indexer> MakeFFTIndexerGPU(const IndexingSettings &settings, bool beside) {
if (auto kept = BorrowFFTIndexer(settings))
return kept;
try {
return std::make_unique<FFTIndexerGPU>(settings);
} catch (const JFJochException &) {
cuda_throw_if_context_lost(); // not memory: waiting would only hide the real error
cuda_clear_error();
}
if (beside)
wait_for_gpu_image_passes();
else
// That work is a probe pass of seconds, so this long means it is stuck: the indexer fails as
// it would have without it.
wait_for_gpu_work_beside(std::chrono::minutes(10));
cuda_trim_allocation_pool();
return std::make_unique<FFTIndexerGPU>(settings);
}
void KeepFFTIndexers(const IndexingSettings &settings, int count) {
if (get_gpu_count() == 0)
return;
std::vector<std::unique_ptr<Indexer>> indexers;
for (int i = 0; i < count; i++)
indexers.push_back(MakeFFTIndexerGPU(settings, false));
for (auto &i : indexers)
GiveBackFFTIndexer(settings, i);
}
#endif
// The indexer for one RESOLVED algorithm, or nullptr if this build/host cannot serve it.
static std::unique_ptr<Indexer> MakeIndexer(IndexingAlgorithmEnum algorithm, const IndexingSettings &settings,
bool beside) {
#ifdef JFJOCH_USE_CUDA
if (get_gpu_count() > 0) {
if (algorithm == IndexingAlgorithmEnum::FFT)
return MakeFFTIndexerGPU(settings, beside);
if (algorithm == IndexingAlgorithmEnum::FFBIDX)
return std::make_unique<FFBIDXIndexer>();
}
#endif
#ifdef JFJOCH_USE_FFTW
if (algorithm == IndexingAlgorithmEnum::FFTW)
return std::make_unique<FFTIndexerCPU>(settings);
#endif
return nullptr;
}
IndexerThread::IndexerThread(const IndexingSettings &settings, int threadid, IndexerConstruction construction,
bool beside)
: settings_(settings), construction_(construction), beside_(beside) {
std::unique_lock<std::mutex> lock(m);
state = TaskState::STARTING;
worker_thread = std::thread(&IndexerThread::Worker, this, threadid);
c_running.wait(lock, [this] { return state != TaskState::STARTING; });
if (state == TaskState::ERROR) {
worker_thread.join();
throw JFJochException(JFJochExceptionCategory::InputParameterInvalid,
"Indexer thread initialization failed");
}
}
void IndexerThread::Worker(int threadid) {
try {
pin_gpu();
} catch (const std::exception &e) {
spdlog::error("Failed to pin to GPU {}", e.what());
} catch (...) {
// GPU pinning errors are not critical and should be ignored for the time being.
}
std::unique_ptr<Indexer> fft_indexer, ffbidx_indexer, fftw_indexer;
// Preconstruct: build every indexer the requested algorithm could resolve to before the pool
// reports ready, so no cuFFT planning happens once frames are flowing, and a failure is fatal
// for the pool instead of being met frame by frame. OnFirstUse skips this and builds in the
// dispatch below.
if (construction_ == IndexerConstruction::Preconstruct) {
try {
const auto requested = settings_.GetAlgorithm();
if (requested == IndexingAlgorithmEnum::Auto || requested == IndexingAlgorithmEnum::FFT)
fft_indexer = MakeIndexer(IndexingAlgorithmEnum::FFT, settings_, beside_);
if (requested == IndexingAlgorithmEnum::Auto || requested == IndexingAlgorithmEnum::FFBIDX)
ffbidx_indexer = MakeIndexer(IndexingAlgorithmEnum::FFBIDX, settings_, beside_);
if ((requested == IndexingAlgorithmEnum::Auto && get_gpu_count() == 0)
|| requested == IndexingAlgorithmEnum::FFTW)
fftw_indexer = MakeIndexer(IndexingAlgorithmEnum::FFTW, settings_, beside_);
} catch (const std::exception &e) {
spdlog::error("Failed to initialize indexer: {}", e.what());
{
std::unique_lock<std::mutex> lock(m);
state = TaskState::ERROR;
}
c_running.notify_all();
return;
} catch (...) {
spdlog::error("Failed to initialize indexer");
{
std::unique_lock<std::mutex> lock(m);
state = TaskState::ERROR;
}
c_running.notify_all();
return;
}
}
{
std::unique_lock<std::mutex> lock(m);
state = TaskState::IDLE;
}
c_running.notify_all();
while (true) {
std::unique_ptr<TaskInput> input;
bool drop = false;
// Look for task + handle stop
{
std::unique_lock<std::mutex> lock(m);
c_start.wait(lock, [this] { return stop || drop_indexers || state == TaskState::READY; });
if (stop && (state != TaskState::READY)) {
#ifdef JFJOCH_USE_CUDA
GiveBackFFTIndexer(settings_, fft_indexer);
#endif
return;
}
if (state == TaskState::READY) {
state = TaskState::RUNNING;
input = std::move(task_input);
} else
drop = true;
}
if (drop) {
#ifdef JFJOCH_USE_CUDA
GiveBackFFTIndexer(settings_, fft_indexer);
#endif
fft_indexer.reset();
ffbidx_indexer.reset();
fftw_indexer.reset();
{
std::unique_lock<std::mutex> lock(m);
drop_indexers = false;
}
c_done.notify_all();
}
if (input) {
std::unique_ptr<IndexerResult> tmp_result;
try {
auto algorithm = input->experiment.GetIndexingAlgorithm();
std::unique_ptr<Indexer> *slot = nullptr;
switch (algorithm) {
case IndexingAlgorithmEnum::FFT: slot = &fft_indexer; break;
case IndexingAlgorithmEnum::FFBIDX: slot = &ffbidx_indexer; break;
case IndexingAlgorithmEnum::FFTW: slot = &fftw_indexer; break;
default: break;
}
// A preconstructing worker already holds it; an OnFirstUse worker builds it here,
// on the first frame that resolves to this algorithm.
if (slot && !*slot)
*slot = MakeIndexer(algorithm, settings_, beside_);
if (!slot || !*slot) {
// Algorithm is already resolved here (never Auto/None - see
// IndexerThreadPool::Run, which also checked this host can serve it). Reaching
// this means the resolved algorithm has no matching indexer in this build -
// fail loudly instead of silently not indexing.
throw JFJochException(JFJochExceptionCategory::InputParameterInvalid,
"Internal error: no indexer available for the resolved "
"indexing algorithm");
}
Indexer &indexer = **slot;
indexer.Setup(input->experiment);
tmp_result = std::make_unique<IndexerResult>(indexer.Run(input->recip, input->severity_only));
} catch (std::exception &e) {
// Hand the failure back as a result carrying the reason. A nullptr here was
// indistinguishable from a worker that was never dispatched, and both then read
// downstream as "this frame did not index".
spdlog::error("Indexer thread {} failed: {}", threadid, e.what());
tmp_result = std::make_unique<IndexerResult>(IndexerResult{
.lattice = {}, .indexing_time_s = 0, .executed = false,
.error = std::current_exception()});
// This thread goes on to the next frame, so a CUDA error left behind here would be
// reported by the first cudaGetLastError() of every frame that follows - one failed
// attempt would read as an indexer that never works again.
cuda_clear_error();
}
{
std::unique_lock<std::mutex> lock(m);
state = TaskState::COMPLETED;
result = std::move(tmp_result);
}
c_done.notify_all();
}
}
}
void IndexerThread::Release() {
std::unique_lock<std::mutex> lock(m);
if (stop)
return;
drop_indexers = true;
c_start.notify_all();
c_done.wait(lock, [this] { return !drop_indexers; });
}
void IndexerThread::Finalize() {
{
std::unique_lock<std::mutex> lock(m);
stop = true;
}
c_start.notify_all();
if (worker_thread.joinable())
worker_thread.join();
}
std::unique_ptr<IndexerResult> IndexerThread::Run(const DiffractionExperiment &experiment,
const std::vector<Coord> &recip, bool severity_only) {
std::unique_ptr<IndexerResult> tmp_result;
{
std::unique_lock<std::mutex> lock(m);
if (stop)
return nullptr;
if (state != TaskState::IDLE)
return nullptr;
task_input = std::make_unique<TaskInput>(std::cref(experiment), std::cref(recip), severity_only);
state = TaskState::READY;
}
c_start.notify_one();
{
std::unique_lock<std::mutex> lock(m);
c_done.wait(lock, [this] { return state == TaskState::COMPLETED; });
tmp_result = std::move(result);
state = TaskState::IDLE;
}
return tmp_result;
}
IndexerThread::~IndexerThread() {
Finalize();
}
IndexerThreadPool::IndexerThreadPool(const IndexingSettings &settings, IndexerConstruction construction,
bool beside)
: worker_busy(settings.GetIndexingThreads(), 0),
worker_free_count(settings.GetIndexingThreads()),
viable_cell_min_spots(settings.GetViableCellMinSpots()),
blocking(settings.GetBlockingBehavior()),
settings_(settings) {
for (size_t i = 0; i < settings.GetIndexingThreads(); ++i)
tasks.emplace_back(std::make_unique<IndexerThread>(std::cref(settings), i, construction, beside));
}
void IndexerThreadPool::ReleaseIndexers() {
for (auto &t : tasks)
t->Release();
#ifdef JFJOCH_USE_CUDA
// Their buffers went back to the allocation pool, which would keep them reserved for the next
// buffer of whoever asks; hand them to the card.
cuda_trim_allocation_pools();
#endif
}
int IndexerThreadPool::GetFreeWorker() {
std::unique_lock<std::mutex> lock(m);
if (tasks.size() == 0)
return -1;
if (blocking)
c.wait(lock, [this] { return worker_free_count > 0; });
for (int i = 0; i < tasks.size(); i++) {
if (worker_busy[i] == 0) {
worker_busy[i] = 1;
worker_free_count--;
return i;
}
}
return -1;
}
IndexerResult IndexerThreadPool::Run(const DiffractionExperiment &experiment, const std::vector<Coord> &recip,
bool severity_only) {
const auto algorithm = experiment.GetIndexingAlgorithm();
if (algorithm == IndexingAlgorithmEnum::None)
return IndexerResult{.lattice = {}, .indexing_time_s = 0, .executed = false};
// GetIndexingAlgorithm() must already have resolved Auto to a concrete algorithm;
// the pool has no policy to resolve it, so Auto here is an upstream contract bug.
if (algorithm == IndexingAlgorithmEnum::Auto)
throw JFJochException(JFJochExceptionCategory::InputParameterInvalid,
"Internal error: indexing algorithm must be resolved (not Auto) "
"before reaching the indexer pool");
// The workers built their indexers from the raw requested algorithm, but the algorithm actually
// dispatched is the RESOLVED one (rotation, for instance, always resolves to the GPU FFT indexer
// when a GPU is present, ignoring the request). If the resolution lands on an algorithm this host
// did not build an indexer for, fail here with an explanation instead of the opaque "no indexer
// available for the resolved algorithm" from deep inside a worker.
const auto requested = experiment.GetIndexingSettings().GetAlgorithm();
const bool have_gpu = get_gpu_count() > 0;
#ifdef JFJOCH_USE_FFTW
constexpr bool fftw_built = true;
#else
constexpr bool fftw_built = false;
#endif
const bool servable =
(algorithm == IndexingAlgorithmEnum::FFT && have_gpu &&
(requested == IndexingAlgorithmEnum::Auto || requested == IndexingAlgorithmEnum::FFT)) ||
(algorithm == IndexingAlgorithmEnum::FFBIDX && have_gpu &&
(requested == IndexingAlgorithmEnum::Auto || requested == IndexingAlgorithmEnum::FFBIDX)) ||
(algorithm == IndexingAlgorithmEnum::FFTW && fftw_built &&
((requested == IndexingAlgorithmEnum::Auto && !have_gpu) || requested == IndexingAlgorithmEnum::FFTW));
if (!servable) {
std::string msg;
if (requested == IndexingAlgorithmEnum::FFTW && have_gpu)
msg = "FFTW is the CPU indexer and is not available on a node with a GPU. Rotation indexing "
"always uses the GPU FFT indexer here; select FFT or Auto, or run FFTW on a CPU-only node.";
else if (algorithm == IndexingAlgorithmEnum::FFT && !have_gpu)
msg = "FFT is the GPU indexer but no GPU is available. Select FFTW or Auto for CPU indexing.";
else if (algorithm == IndexingAlgorithmEnum::FFBIDX && !have_gpu)
msg = "FFBIDX is a GPU indexer but no GPU is available. Select FFTW or Auto for CPU indexing.";
else if (algorithm == IndexingAlgorithmEnum::FFTW)
msg = "FFTW (CPU) indexing was requested but this build has no FFTW indexer.";
else
msg = "the requested indexing algorithm resolved to one with no indexer available on this host.";
throw JFJochException(JFJochExceptionCategory::InputParameterInvalid,
"Cannot index: " + msg);
}
// Check if there is available worker
const int task = GetFreeWorker();
std::unique_ptr<IndexerResult> result;
if (task >= 0) {
try {
result = tasks[task]->Run(experiment, recip, severity_only);
} catch (const std::exception &e) {
spdlog::error("Indexer thread failed: {}", e.what());
result = std::make_unique<IndexerResult>(IndexerResult{
.lattice = {}, .indexing_time_s = 0, .executed = false,
.error = std::current_exception()});
}
{
std::unique_lock<std::mutex> lock(m);
worker_busy[task] = 0;
worker_free_count++;
}
c.notify_one();
}
if (result)
return *result;
// No free worker, or the pool is stopping: indexing was not attempted. Distinct from both a
// frame that did not index and an indexer that failed, and left without an error for that reason.
return IndexerResult{.lattice = {}, .indexing_time_s = 0};
}