Build Packages / Build documentation (push) Successful in 46s
Build Packages / Create release (push) Successful in 8s
Build Packages / build:portable:macos-arm64:nocuda (push) Successful in 5m25s
Build Packages / build:rugnux:linux-aarch64:cuda (push) Successful in 9m46s
Build Packages / build:portable:linux-x86_64:nocuda (push) Successful in 13m25s
Build Packages / build:portable:linux-x86_64:cuda (push) Successful in 16m41s
Build Packages / build:portable:windows-x86_64:nocuda (push) Successful in 23m46s
Build Packages / HDF5 consumer tests (DIALS, XDS) (push) Successful in 25m37s
Build Packages / build:jfjoch:rocky8:nocuda (push) Successful in 24m31s
Build Packages / build:portable:windows-x86_64:cuda (push) Successful in 28m9s
Build Packages / build:jfjoch:ubuntu2204:nocuda (push) Successful in 20m47s
Build Packages / build:jfjoch:rocky9:nocuda (push) Successful in 26m21s
Build Packages / build:jfjoch:ubuntu2404:nocuda (push) Successful in 27m36s
Build Packages / Generate python client (push) Successful in 31s
Build Packages / build:jfjoch:rocky8:cuda-sls9 (push) Successful in 29m27s
Build Packages / build:jfjoch:rocky9:cuda-sls9 (push) Successful in 31m46s
Build Packages / build:jfjoch:ubuntu2204:cuda (push) Successful in 25m22s
Build Packages / build:jfjoch:rocky8:cuda (push) Successful in 30m36s
Build Packages / build:jfjoch:rocky9:cuda (push) Successful in 31m49s
Build Packages / build:jfjoch:ubuntu2404:cuda (push) Successful in 23m57s
Build Packages / Unit tests (push) Successful in 1h47m52s
Build Packages / Validation Battery (push) Successful in 44m55s
* Rugnux: Rotation processing recovers most of the speed lost since 1.0.0-rc.180; live processing in jfjoch_broker finishes sooner. * Rugnux: More reliable rotation processing and indexing, including small molecules and supercells. * Rugnux: More robust serial data and model refinement; molecular replacement handles multi-chain models. * jfjoch_broker (experimental): Live serial processing keeps the detector geometry fixed for the whole run. * jfjoch_broker: Pixel sum without an FPGA, a mask bit for inactive pixels, and several stability fixes. * jfjoch_broker: Rotation indexing and quick integration moved to rugnux live mode; their API settings are deprecated and ignored. * jfjoch_broker, Rugnux: Azimuthal integration can use logarithmic (log10) Q bins or a given number of bins, set from the API, the web interface and the Rugnux command line. * jfjoch_viewer: Build and display fixes; the README is rewritten. Reviewed-on: #95 Co-authored-by: Filip Leonarski <filip.leonarski@psi.ch>
420 lines
18 KiB
C++
420 lines
18 KiB
C++
// SPDX-FileCopyrightText: 2026 Filip Leonarski, Paul Scherrer Institute <filip.leonarski@psi.ch>
|
|
// SPDX-License-Identifier: GPL-3.0-only
|
|
|
|
#include "StillsGPUAnalysis.h"
|
|
|
|
#include <thread>
|
|
|
|
#include "../common/CUDAWrapper.h"
|
|
#include "../common/ThreadAffinity.h"
|
|
#include "../image_analysis/MXAnalysisWithoutFPGA.h"
|
|
#include "../rugnux/RugnuxDefaults.h"
|
|
|
|
StillsGPUAnalysis::StillsGPUAnalysis(const DiffractionExperiment &in_experiment, const PixelMask &in_mask,
|
|
const StartMessage &in_start, IndexerThreadPool *in_indexing_thread_pool,
|
|
int64_t nthreads, int64_t in_max_held, Logger &in_logger, Ready in_ready,
|
|
Sink in_sink, Settings in_settings, Failed in_failed,
|
|
RugnuxLiveStatus *in_status, bool in_merge)
|
|
: experiment(in_experiment), mask(in_mask), start(in_start), indexing_thread_pool(in_indexing_thread_pool),
|
|
logger(in_logger), ready(std::move(in_ready)), sink(std::move(in_sink)), settings(std::move(in_settings)),
|
|
setup_failed(std::move(in_failed)), status(in_status), merge_frames(in_merge),
|
|
max_held(std::max<int64_t>(1, in_max_held)), queue(max_held) {
|
|
live.images_needed = std::min(PREPASS_IMAGES, experiment.GetImageNum());
|
|
live.file_beam_x_pxl = experiment.GetBeamX_pxl();
|
|
live.file_beam_y_pxl = experiment.GetBeamY_pxl();
|
|
live.file_distance_mm = experiment.GetDetectorDistance_mm();
|
|
if (status) {
|
|
status->Start(experiment.GetImageNum(),
|
|
static_cast<double>(experiment.GetImageNum())
|
|
* std::chrono::duration<double>(experiment.GetImageTime()).count(),
|
|
std::chrono::steady_clock::now());
|
|
status->Step("Pre-pass");
|
|
Publish();
|
|
}
|
|
prepass_future = std::async(std::launch::async, &StillsGPUAnalysis::PrePassThread, this);
|
|
for (int64_t i = 0; i < nthreads; i++)
|
|
analysis_futures.emplace_back(std::async(std::launch::async, &StillsGPUAnalysis::AnalysisThread, this));
|
|
logger.Info("GPU analysis: {} threads, at least the first {} images held for the pre-pass", nthreads, PREPASS_IMAGES);
|
|
}
|
|
|
|
StillsGPUAnalysis::~StillsGPUAnalysis() {
|
|
try {
|
|
if (!finished)
|
|
Finish();
|
|
} catch (const std::exception &e) {
|
|
logger.Error("GPU analysis: {}", e.what());
|
|
}
|
|
if (status && outcome)
|
|
status->Finish(outcome->first, outcome->second);
|
|
}
|
|
|
|
void StillsGPUAnalysis::AnalysisError(const std::string &text) {
|
|
logger.Error("GPU analysis: {}", text);
|
|
std::unique_lock ul(error_mutex);
|
|
if (analysis_error.empty())
|
|
analysis_error = text;
|
|
}
|
|
|
|
void StillsGPUAnalysis::Publish() {
|
|
std::unique_lock ul(live_mutex);
|
|
if (live.prepass == RugnuxLivePrePass::Done || live.prepass == RugnuxLivePrePass::Failed) {
|
|
const double t = std::chrono::duration<double>(std::chrono::steady_clock::now() - ready_time).count();
|
|
if (t > 0 && live.images_analysed > 0)
|
|
live.analysis_rate_hz = static_cast<double>(live.images_analysed) / t;
|
|
}
|
|
status->Stills(live);
|
|
}
|
|
|
|
bool StillsGPUAnalysis::Hold(const std::shared_ptr<HeldImage> &image) {
|
|
if (status)
|
|
status->ImageReceived(std::chrono::steady_clock::now());
|
|
if (!queue.Put(image)) {
|
|
// As many frames wait as the buffer is to hold: the pre-pass runs on what it has, for the last time.
|
|
StartPrePass();
|
|
return false;
|
|
}
|
|
{
|
|
std::unique_lock ul(prepass_mutex);
|
|
if (!prepass_over) {
|
|
prepass_images[image->message.number] = image;
|
|
std::unique_lock ul_live(live_mutex);
|
|
live.images_held = static_cast<int64_t>(prepass_images.size());
|
|
}
|
|
}
|
|
prepass_cv.notify_all();
|
|
if (status)
|
|
Publish();
|
|
return true;
|
|
}
|
|
|
|
void StillsGPUAnalysis::StartPrePass() {
|
|
{
|
|
std::unique_lock ul(prepass_mutex);
|
|
prepass_last = true;
|
|
}
|
|
prepass_cv.notify_all();
|
|
}
|
|
|
|
void StillsGPUAnalysis::Finish() {
|
|
finished = true;
|
|
StartPrePass();
|
|
prepass_future.get();
|
|
for (size_t i = 0; i < analysis_futures.size(); i++)
|
|
queue.PutBlocking(nullptr);
|
|
for (auto &f: analysis_futures)
|
|
f.get();
|
|
if (status) {
|
|
last_image = status->LastImage();
|
|
Publish();
|
|
}
|
|
if (merge_future.valid()) {
|
|
if (status)
|
|
status->Step("Merge");
|
|
merge_queue.Put(-1);
|
|
merge_future.get();
|
|
if (merged && status) {
|
|
PublishMerge(*merged, "the collection");
|
|
status->Headline(merge_experiment.GetSpaceGroupOrP1().xhm(), merged->merge.statistics.overall.d_min,
|
|
std::nullopt);
|
|
}
|
|
}
|
|
if (status) {
|
|
if (setup_error)
|
|
status->Finish(RugnuxLiveState::Failed, "GPU analysis setup failed");
|
|
else {
|
|
// Reported by Written, once everything is written - the end message and the writer go on meanwhile.
|
|
status->Step("Writing");
|
|
std::unique_lock ul(error_mutex);
|
|
if (analysis_error.empty())
|
|
outcome.emplace(RugnuxLiveState::Done, "");
|
|
else
|
|
outcome.emplace(RugnuxLiveState::Failed, analysis_error);
|
|
}
|
|
}
|
|
if (setup_error)
|
|
std::rethrow_exception(setup_error);
|
|
}
|
|
|
|
void StillsGPUAnalysis::Written() {
|
|
if (!status || !outcome)
|
|
return;
|
|
if (last_image)
|
|
status->Latency(std::chrono::duration<double>(std::chrono::steady_clock::now() - last_image.value()).count());
|
|
status->Finish(outcome->first, outcome->second);
|
|
outcome.reset();
|
|
}
|
|
|
|
void StillsGPUAnalysis::PrePassThread() {
|
|
const auto masked = [](const PixelMask &m) {
|
|
return std::count_if(m.GetMask().begin(), m.GetMask().end(), [](uint32_t v) { return v != 0; });
|
|
};
|
|
setup.experiment = experiment;
|
|
setup.mask = mask;
|
|
const int64_t all_images = experiment.GetImageNum();
|
|
std::optional<StillsPrePassResult> result;
|
|
std::chrono::steady_clock::time_point prepass_start = std::chrono::steady_clock::now();
|
|
// At a low hit rate the first frames may not be enough to fit the geometry on, or may not hold a crystal: with
|
|
// a cell to fit, the pre-pass runs again on all that is held once that has doubled, until the fit has its
|
|
// frames, or the collection ended, or the sample is as large as it may grow (the last try): bounded so that
|
|
// the last run is short and the other half of the queue takes the frames arriving meanwhile.
|
|
const int64_t max_sample = std::min(PREPASS_MAX_IMAGES, max_held / 2);
|
|
for (int64_t needed = std::min(PREPASS_IMAGES, all_images); ;) {
|
|
std::map<int64_t, std::shared_ptr<HeldImage>> sample;
|
|
bool last;
|
|
{
|
|
std::unique_lock ul(prepass_mutex);
|
|
prepass_cv.wait(ul, [&] {
|
|
return prepass_last || static_cast<int64_t>(prepass_images.size()) >= needed;
|
|
});
|
|
// Frames that arrived during the previous run do not make this one larger than the bound.
|
|
for (const auto &[n, image]: prepass_images) {
|
|
if (static_cast<int64_t>(sample.size()) >= std::max(needed, max_sample))
|
|
break;
|
|
sample.emplace(n, image);
|
|
}
|
|
last = prepass_last || static_cast<int64_t>(sample.size()) >= std::min(all_images, max_sample);
|
|
}
|
|
if (sample.empty())
|
|
break;
|
|
{
|
|
std::unique_lock ul(live_mutex);
|
|
live.prepass = RugnuxLivePrePass::Running;
|
|
}
|
|
if (status)
|
|
Publish();
|
|
try {
|
|
const auto t0 = std::chrono::steady_clock::now();
|
|
auto r = RunStillsPrePass(start, sample, static_cast<int>(std::thread::hardware_concurrency()));
|
|
logger.Info("Pre-pass on {} images ({:.1f} s): beam centre ({:.2f}, {:.2f}) -> ({:.2f}, {:.2f}) px, "
|
|
"distance {:.2f} -> {:.2f} mm on {} frames, masked pixels {} -> {}", r.images,
|
|
std::chrono::duration<double>(std::chrono::steady_clock::now() - t0).count(),
|
|
experiment.GetBeamX_pxl(), experiment.GetBeamY_pxl(), r.beam_x_pxl, r.beam_y_pxl,
|
|
experiment.GetDetectorDistance_mm(), r.distance_mm, r.geometry_frames,
|
|
masked(mask), masked(r.mask));
|
|
result = std::move(r);
|
|
} catch (const std::exception &e) {
|
|
logger.Warning("Pre-pass on {} images failed - {}", sample.size(), e.what());
|
|
}
|
|
if (last || !experiment.GetUnitCell()
|
|
|| (result && result->geometry_frames >= RUGNUX_REFINE_GEOMETRY_FRAMES))
|
|
break;
|
|
needed = std::min({2 * static_cast<int64_t>(sample.size()), all_images, max_sample});
|
|
std::unique_lock ul(live_mutex);
|
|
live.prepass = RugnuxLivePrePass::Holding;
|
|
live.images_needed = needed;
|
|
}
|
|
{
|
|
// The frames are analysed from here on: their slots go back to the buffer once they are sent.
|
|
std::unique_lock ul(prepass_mutex);
|
|
prepass_over = true;
|
|
prepass_images.clear();
|
|
}
|
|
const bool failed = !result;
|
|
int geometry_frames = 0;
|
|
if (result) {
|
|
const auto &r = *result;
|
|
setup.experiment.BeamX_pxl(r.beam_x_pxl).BeamY_pxl(r.beam_y_pxl).DetectorDistance_mm(r.distance_mm);
|
|
setup.mask = r.mask;
|
|
geometry_frames = r.geometry_frames;
|
|
const auto &m = r.mask.GetMask();
|
|
std::unique_lock ul(live_mutex);
|
|
live.beam_x_pxl = r.beam_x_pxl;
|
|
live.beam_y_pxl = r.beam_y_pxl;
|
|
live.distance_mm = r.distance_mm;
|
|
live.shadow_pixels = std::count_if(m.begin(), m.end(), [](uint32_t v) {
|
|
return (v & (1u << PixelMask::BeamStopPixelBit)) != 0;
|
|
});
|
|
live.bad_pixels = std::count_if(m.begin(), m.end(), [](uint32_t v) {
|
|
return (v & (1u << PixelMask::HotPixelBit)) != 0;
|
|
});
|
|
} else
|
|
logger.Warning("Pre-pass failed, the images are analysed with the stated beam centre and mask");
|
|
try {
|
|
setup.mapping = std::make_unique<AzimuthalIntegrationMapping>(setup.experiment, setup.mask);
|
|
// Retained for the merge, which takes each image's reflections as soon as it is analysed.
|
|
indexer = std::make_unique<IndexAndRefine>(setup.experiment, indexing_thread_pool, merge_frames, true);
|
|
if (geometry_frames == 0) {
|
|
const std::string text = fmt::format(
|
|
"The pre-pass could not refine the geometry (too few frames indexed in what the buffer holds, or no unit cell given): the "
|
|
"whole run is analysed at the distance in the header, {:.2f} mm, beam centre ({:.2f}, {:.2f}) px",
|
|
setup.experiment.GetDetectorDistance_mm(), setup.experiment.GetBeamX_pxl(),
|
|
setup.experiment.GetBeamY_pxl());
|
|
logger.Warning("{}", text);
|
|
if (status)
|
|
status->Note(text);
|
|
}
|
|
if (merge_frames) {
|
|
merge_experiment = setup.experiment;
|
|
merge_experiment.ImportScalingSettings(RugnuxDefaultScalingSettings(false));
|
|
merge = std::make_unique<StillsLiveMerge>(merge_experiment, experiment.GetImageNum(),
|
|
setup.experiment.GetUnitCell(), std::nullopt,
|
|
setup.experiment.IsDetectIceRings(),
|
|
SpotFindingSettings().ice_ring_width_Q_recipA, logger);
|
|
merge_future = std::async(std::launch::async, &StillsGPUAnalysis::MergeThread, this);
|
|
}
|
|
ready(setup);
|
|
} catch (const std::exception &e) {
|
|
// Nothing can be written without the start message: the frames are dropped (AnalysisThread), and
|
|
// the collection is stopped.
|
|
const auto *j = dynamic_cast<const JFJochException *>(&e);
|
|
const JFJochException error = j ? *j : JFJochException(JFJochExceptionCategory::WrongDAQState,
|
|
std::string("GPU analysis setup failed: ") + e.what());
|
|
logger.ErrorException(error);
|
|
setup_error = j ? std::current_exception() : std::make_exception_ptr(error);
|
|
if (setup_failed)
|
|
setup_failed(error);
|
|
prepass_done.count_down();
|
|
return;
|
|
}
|
|
{
|
|
std::unique_lock ul(live_mutex);
|
|
live.prepass = failed ? RugnuxLivePrePass::Failed : RugnuxLivePrePass::Done;
|
|
live.prepass_s = std::chrono::duration<double>(std::chrono::steady_clock::now() - prepass_start).count();
|
|
ready_time = std::chrono::steady_clock::now();
|
|
}
|
|
if (status) {
|
|
status->Step("GPU analysis");
|
|
Publish();
|
|
}
|
|
prepass_done.count_down();
|
|
}
|
|
|
|
void StillsGPUAnalysis::AnalysisThread() {
|
|
try {
|
|
pin_gpu();
|
|
} catch (const std::exception &e) {
|
|
logger.Warning("Error pinning GPU {}", e.what());
|
|
}
|
|
|
|
prepass_done.wait();
|
|
|
|
if (setup_error) {
|
|
while (queue.GetBlocking()) {}
|
|
return;
|
|
}
|
|
|
|
// A thread whose analysis cannot be built still passes its frames on, unanalysed.
|
|
std::unique_ptr<MXAnalysisWithoutFPGA> analysis;
|
|
try {
|
|
analysis = std::make_unique<MXAnalysisWithoutFPGA>(setup.experiment, *setup.mapping, setup.mask,
|
|
*indexer, /*enable_fused_adaptive_gpu=*/true);
|
|
} catch (const std::exception &e) {
|
|
AnalysisError(std::string("analysis thread could not start, its frames are written unanalysed - ")
|
|
+ e.what());
|
|
}
|
|
|
|
while (auto held = queue.GetBlocking()) {
|
|
DataMessage &message = held->message;
|
|
std::optional<AzimuthalIntegrationProfile> profile;
|
|
const auto start_time = std::chrono::steady_clock::now();
|
|
profile.emplace(*setup.mapping);
|
|
try {
|
|
if (analysis)
|
|
analysis->Analyze(message, *profile, settings());
|
|
} catch (const JFJochException &e) {
|
|
// The frame is still written, without its analysis.
|
|
logger.ErrorException(e);
|
|
} catch (const std::exception &e) {
|
|
AnalysisError(fmt::format("image {} written unanalysed - {}", message.number, e.what()));
|
|
}
|
|
message.processing_time_s = std::chrono::duration<float>(std::chrono::steady_clock::now()
|
|
- start_time).count();
|
|
try {
|
|
sink(message, *profile);
|
|
if (merge)
|
|
merge_queue.Put(message.number);
|
|
if (status) {
|
|
int64_t analysed;
|
|
{
|
|
std::unique_lock ul(live_mutex);
|
|
analysed = ++live.images_analysed;
|
|
if (message.indexing_result.value_or(false))
|
|
++live.images_indexed;
|
|
}
|
|
status->Progress(analysed, experiment.GetImageNum());
|
|
Publish();
|
|
}
|
|
} catch (const std::exception &e) {
|
|
// The thread goes on with the next frame, so that the queue is drained.
|
|
AnalysisError(fmt::format("image {} lost - {}", message.number, e.what()));
|
|
}
|
|
}
|
|
}
|
|
|
|
std::optional<UnitCell> StillsGPUAnalysis::GetConsensusUnitCell() const {
|
|
return indexer->GetConsensusUnitCell();
|
|
}
|
|
|
|
float StillsGPUAnalysis::GetBeamX_pxl() const {
|
|
return setup.experiment.GetBeamX_pxl();
|
|
}
|
|
|
|
float StillsGPUAnalysis::GetBeamY_pxl() const {
|
|
return setup.experiment.GetBeamY_pxl();
|
|
}
|
|
|
|
float StillsGPUAnalysis::GetDetectorDistance_mm() const {
|
|
return setup.experiment.GetDetectorDistance_mm();
|
|
}
|
|
|
|
const std::optional<StillsMergeOutput> &StillsGPUAnalysis::GetMerged() const {
|
|
return merged;
|
|
}
|
|
|
|
const DiffractionExperiment &StillsGPUAnalysis::GetMergeExperiment() const {
|
|
return merge_experiment;
|
|
}
|
|
|
|
void StillsGPUAnalysis::MergeThread() {
|
|
constexpr int64_t PREPARE_EVERY = 200; // frames between two Prepare calls after a checkpoint
|
|
const int64_t images = experiment.GetImageNum();
|
|
// The cell merged in, as rugnux merges it: the consensus of the frames indexed by the first merge (the
|
|
// collection's cell only picks the frames that are merged).
|
|
bool cell_set = false;
|
|
const auto have_cell = [&] {
|
|
if (!cell_set)
|
|
if (const auto c = indexer->GetConsensusUnitCell()) {
|
|
merge_experiment.SetUnitCell(*c);
|
|
merge->Cell(*c);
|
|
cell_set = true;
|
|
}
|
|
return cell_set;
|
|
};
|
|
size_t next = 0;
|
|
try {
|
|
const auto add = [&](int64_t n, IntegrationOutcome &&o) {
|
|
merge->Add(n, std::move(o));
|
|
if (next < CHECKPOINTS.size()
|
|
&& merge->Added() >= std::llround(CHECKPOINTS[next] * static_cast<double>(images))) {
|
|
if (have_cell())
|
|
PublishMerge(merge->Checkpoint(MERGE_THREADS),
|
|
fmt::format("{:.0f}% of the images (provisional)", 100.0 * CHECKPOINTS[next]));
|
|
++next;
|
|
} else if (next > 0 && merge->Added() % PREPARE_EVERY == 0)
|
|
merge->Prepare(MERGE_THREADS);
|
|
};
|
|
for (int64_t n = merge_queue.GetBlocking(); n >= 0; n = merge_queue.GetBlocking())
|
|
add(n, indexer->TakeIntegrationOutcome(n));
|
|
if (have_cell()) {
|
|
merged = merge->Final(static_cast<int>(AvailableCpus()));
|
|
logger.Info("Stills merge after the last image: {:.2f} s", merged->seconds);
|
|
}
|
|
} catch (const std::exception &e) {
|
|
logger.Warning("Stills merge failed: {}", e.what());
|
|
merged.reset();
|
|
}
|
|
}
|
|
|
|
void StillsGPUAnalysis::PublishMerge(const StillsMergeOutput &out, const std::string &title) {
|
|
const auto &o = out.merge.statistics.overall;
|
|
const std::string text = fmt::format("Merge of {}: {} crystals, {:.2f} A, CC1/2 {:.3f}, R_split {:.3f}, "
|
|
"completeness {:.1f}%, <I/sigma> {:.1f} ({:.1f} s)", title, out.images,
|
|
o.d_min, o.cc_half, o.r_split,
|
|
o.possible_unique_reflections > 0
|
|
? 100.0 * o.unique_reflections / o.possible_unique_reflections : 0.0,
|
|
o.mean_i_over_sigma, out.seconds);
|
|
logger.Info("{}", text);
|
|
if (status)
|
|
status->Note(text);
|
|
}
|