rugnux: parallelise candidate-cell refinement, and stop repeating work in the tail
Build Packages / build:viewer-tgz:cpu (push) Successful in 20m26s
Build Packages / build:viewer-tgz:cuda (push) Successful in 21m30s
Build Packages / build:rpm (ubuntu2404_nocuda) (push) Successful in 22m36s
Build Packages / build:rpm (rocky9_nocuda) (push) Successful in 24m4s
Build Packages / build:rpm (rocky8_nocuda) (push) Successful in 28m10s
Build Packages / build:rpm (ubuntu2204_nocuda) (push) Successful in 28m12s
Build Packages / build:rpm (rocky8_sls9) (push) Successful in 28m23s
Build Packages / XDS test (durin plugin) (push) Successful in 11m21s
Build Packages / build:rpm (rocky9_sls9) (push) Successful in 20m56s
Build Packages / build:rpm (rocky9) (push) Successful in 21m10s
Build Packages / Generate python client (push) Successful in 40s
Build Packages / Build documentation (push) Successful in 1m34s
Build Packages / Create release (push) Skipped
Build Packages / build:rpm (rocky8) (push) Successful in 25m28s
Build Packages / DIALS test (push) Successful in 21m15s
Build Packages / build:rpm (ubuntu2404) (push) Successful in 21m26s
Build Packages / build:rpm (ubuntu2204) (push) Successful in 25m51s
Build Packages / XDS test (JFJoch plugin) (push) Successful in 10m53s
Build Packages / XDS test (neggia plugin) (push) Successful in 9m41s
Build Packages / Unit tests (push) Successful in 2h21m29s
Build Packages / build:windows:nocuda (push) Successful in 1m15s
Build Packages / build:windows:cuda (push) Successful in 28m0s

Three independent changes to the CPU-bound parts of an offline rotation run, none
of which alters a result.

Candidate-cell refinement now splits across threads. RefineCandidateCells already
took a (block, nblocks) partition, but the only call site passed nblocks=1, so the
whole first pass of a two-pass rotation run sat on one thread per scheme - two
threads, unchanged at every -N, for a third of the run. A block touches only its
own scores(j) and cells rows and holds its own scratch, so the split is exact.
The budget is a new IndexingSettings::RefineThreads, left at 1 by default and set
only where few indexer threads exist: raising it unconditionally would
oversubscribe the paths that already run one indexer per image across all workers.

The mmCIF writer built a std::ostringstream per formatted number, twelve per
reflection. snprintf gives the same digits for 0.535 -> 0.220 s per file.

The space-group search built the same orbit mapping twice per candidate point
group - once for the merge chi^2 and once for the systematic-error b, an
apply_to_hkl and Canonicalize per observation per operator each time. Build it
once and hand it to both.

18 Mpx rotation set 24.6 -> 18.7 s, 2.5 Mpx 13.0 -> 10.7 s, and the 37-crystal
battery 13m55s -> 10m47s with no failures, the same 34/37 space groups, and
statistics unchanged on 30 of 37 (the rest drift within the run-to-run spread the
binary already had, which a control build with the split disabled reproduces).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-03 07:30:33 +02:00
co-authored by Claude Opus 5
parent 13aa20a528
commit 7e47afe47f
9 changed files with 93 additions and 42 deletions
+11
View File
@@ -141,6 +141,17 @@ IndexingSettings &IndexingSettings::IndexingThreads(int64_t input) {
return *this;
}
int64_t IndexingSettings::GetRefineThreads() const {
return refine_threads;
}
IndexingSettings &IndexingSettings::RefineThreads(int64_t input) {
check_min("Candidate-cell refinement thread count", input, 1);
check_max("Candidate-cell refinement thread count", input, 64);
refine_threads = input;
return *this;
}
IndexingSettings &IndexingSettings::UnitCellDistTolerance(float input) {
check_min("Relative unit cell distance tolerance vs. reference", input, 0.0001);
check_max("Relative unit cell distance tolerance vs. reference", input, 0.2001);
+6
View File
@@ -23,6 +23,10 @@ class IndexingSettings {
float unit_cell_dist_tolerance_vs_reference = 0.05; // relative
static constexpr float unit_cell_angle_tolerance_deg = 5.0; // degree
int64_t indexing_threads = 4;
// Threads splitting the candidate-cell refinement WITHIN one indexer call. 1 (the default) is the
// right answer whenever indexers already run one per image across all workers; it is raised only
// where few indexer threads exist and cores would otherwise sit idle.
int64_t refine_threads = 1;
int64_t viable_cell_min_spots = 9;
int64_t max_extra_lattices = 2;
@@ -48,6 +52,7 @@ public:
IndexingSettings& FFT_HighResolution_A(float input);
IndexingSettings& Tolerance(float input);
IndexingSettings& IndexingThreads(int64_t input);
IndexingSettings& RefineThreads(int64_t input);
IndexingSettings& UnitCellDistTolerance(float input);
IndexingSettings& GeomRefinementAlgorithm(GeomRefinementAlgorithmEnum input);
IndexingSettings& IndexIceRings(bool input);
@@ -68,6 +73,7 @@ public:
[[nodiscard]] float GetFFT_MinAngle_deg() const;
[[nodiscard]] float GetFFT_MaxAngle_deg() const;
[[nodiscard]] int64_t GetIndexingThreads() const;
[[nodiscard]] int64_t GetRefineThreads() const;
[[nodiscard]] float GetUnitCellDistTolerance() const;
[[nodiscard]] float GetUnitCellAngleTolerance_deg() const;
[[nodiscard]] bool GetIndexIceRings() const;
+7 -3
View File
@@ -8,6 +8,7 @@
#include <algorithm>
#include <cmath>
#include <cstdio>
#include <map>
#include <tuple>
#include <fstream>
@@ -39,12 +40,15 @@ std::string CurrentDateISO() {
}
/// Format a double with given decimal places; returns "?" for non-finite.
/// snprintf rather than an ostringstream: the reflection loop below calls this twelve times per
/// reflection, and building a stream (and its locale) per call dominated the time spent writing a
/// merged file. Same digits - both go through the C locale's %.*f.
std::string Fmt(double val, int decimals = 4) {
if (!std::isfinite(val))
return "?";
std::ostringstream ss;
ss << std::fixed << std::setprecision(decimals) << val;
return ss.str();
char buf[512];
const int n = std::snprintf(buf, sizeof(buf), "%.*f", decimals, val);
return std::string(buf, n);
}
/// Quote a CIF string value; returns "?" for empty.
+3 -1
View File
@@ -12,6 +12,7 @@ FFTIndexer::FFTIndexer(const IndexingSettings &settings)
min_angle_deg(settings.GetFFT_MinAngle_deg()),
max_angle_deg(settings.GetFFT_MaxAngle_deg()),
nDirections(settings.GetFFT_NumVectors()),
refine_threads(static_cast<unsigned>(settings.GetRefineThreads())),
result_fft(nDirections) {
// Reciprocal-magnitude histogram in one_over_d = 1/d units (the internal convention -
@@ -332,7 +333,8 @@ std::vector<CrystalLattice> FFTIndexer::ReduceAndRefine(const std::vector<Coord>
.max_length_A = max_length_A,
.min_angle_deg = min_angle_deg,
.max_angle_deg = max_angle_deg,
.indexing_tolerance = indexing_tolerance
.indexing_tolerance = indexing_tolerance,
.refine_threads = refine_threads
};
return Refine(coord, nspots, oCell, scores, parameters);
+1
View File
@@ -21,6 +21,7 @@ protected:
const float max_angle_deg;
const int nDirections;
const unsigned refine_threads;
float histogram_spacing;
int64_t histogram_size;
std::vector<Coord> direction_vectors;
@@ -5,6 +5,7 @@
#include "PostIndexingRefinement.h"
#include <iostream>
#include <thread>
namespace {
struct config_ifssr final {
@@ -145,7 +146,25 @@ std::vector<CrystalLattice> Refine(const std::vector<Coord> &in_spots,
.min_spots = static_cast<uint32_t>(p.viable_cell_min_spots)
};
RefineCandidateCells(spots.topRows(nspots), oCell, scores, cifssr);
// Candidate cells refine independently - a block touches only its own scores(j) and cells rows, and
// holds its own scratch - so splitting them across threads gives the same numbers as one thread.
// Only worth it where few indexer threads run (the rotation first pass uses two, one per scheme,
// and leaves the rest of the machine idle); refine_threads stays 1 everywhere else.
const unsigned ncells = static_cast<unsigned>(scores.rows());
const unsigned nblocks = std::max(1u, std::min(p.refine_threads, ncells));
if (nblocks == 1) {
RefineCandidateCells(spots.topRows(nspots), oCell, scores, cifssr);
} else {
std::vector<std::thread> workers;
workers.reserve(nblocks - 1);
for (unsigned b = 1; b < nblocks; b++)
workers.emplace_back([&, b] {
RefineCandidateCells(spots.topRows(nspots), oCell, scores, cifssr, b, nblocks);
});
RefineCandidateCells(spots.topRows(nspots), oCell, scores, cifssr, 0, nblocks);
for (auto &w : workers)
w.join();
}
std::vector<RefinedCandidate> candidates;
@@ -30,6 +30,7 @@ struct RefineParameters {
float min_angle_deg;
float max_angle_deg;
float indexing_tolerance;
unsigned refine_threads = 1;
};
std::vector<CrystalLattice> Refine(const std::vector<Coord> &in_spots,
+37 -36
View File
@@ -375,16 +375,17 @@ SearchSpaceGroupResult SearchSpaceGroup(
holohedry = HolohedryRotationSet(opt.lattice_system.value());
const auto point_groups = EnumeratePointGroups(holohedry);
// Reduced chi^2 of the intensities merged under a point group's rotations - how well its symmetry
// equivalents agree RELATIVE TO THEIR ERRORS. A real point group gives ~1; a false operator forces
// non-equivalent reflections together, so they disagree by many sigma and chi^2 blows up. This is
// more sensitive than R-meas to a strong pseudo-symmetry (where the intensities still correlate well
// - high operator CC - but not within their errors). Inverse-variance weighted mean per orbit, over
// the present (pass_cc) reflections.
auto chi2_under = [&](const std::vector<gemmi::Op>& rotations) -> double {
struct Acc { double sw = 0.0, swI = 0.0; int n = 0; };
std::unordered_map<HKLKey, Acc, HKLKeyHash> grp;
std::vector<HKLKey> rep(n);
// Mapping every observation onto its symmetry representative under a candidate's rotations - one
// apply_to_hkl + Canonicalize per observation per operator - is the expensive half of BOTH
// quantities below, and both need exactly the same mapping. Build it once per point group.
struct Acc { double sw = 0.0, swI = 0.0; int n = 0; };
struct Orbits {
std::unordered_map<HKLKey, Acc, HKLKeyHash> grp; // representative -> inverse-variance accumulator
std::vector<HKLKey> rep; // observation -> its representative
};
auto build_orbits = [&](const std::vector<gemmi::Op>& rotations) -> Orbits {
Orbits orb;
orb.rep.resize(n);
for (size_t i = 0; i < n; ++i) {
if (!pass_cc[i] || !(Sigma[i] > 0.0))
continue;
@@ -395,17 +396,29 @@ SearchSpaceGroupResult SearchSpaceGroup(
if (std::make_tuple(k2.h, k2.k, k2.l) < std::make_tuple(best.h, best.k, best.l))
best = k2;
}
rep[i] = best;
auto& g = grp[best];
orb.rep[i] = best;
auto& g = orb.grp[best];
const double w = 1.0 / (Sigma[i] * Sigma[i]);
g.sw += w; g.swI += w * I[i]; g.n += 1;
}
return orb;
};
// Reduced chi^2 of the intensities merged under a point group's rotations - how well its symmetry
// equivalents agree RELATIVE TO THEIR ERRORS. A real point group gives ~1; a false operator forces
// non-equivalent reflections together, so they disagree by many sigma and chi^2 blows up. This is
// more sensitive than R-meas to a strong pseudo-symmetry (where the intensities still correlate well
// - high operator CC - but not within their errors). Inverse-variance weighted mean per orbit, over
// the present (pass_cc) reflections.
auto chi2_under = [&](const Orbits& orb) -> double {
const auto& grp = orb.grp;
const auto& rep = orb.rep;
double chi2 = 0.0;
long dof = 0;
for (size_t i = 0; i < n; ++i) {
if (!pass_cc[i] || !(Sigma[i] > 0.0))
continue;
const auto& g = grp[rep[i]];
const auto& g = grp.at(rep[i]);
if (g.n < 2)
continue;
const double mean = g.swI / g.sw, dev = I[i] - mean;
@@ -426,30 +439,14 @@ SearchSpaceGroupResult SearchSpaceGroup(
// the systematic part of the scatter, which the fixed-sigma chi^2 ratio cannot: a genuine but
// imperfectly-scaled high-symmetry merge and a twin can share a chi^2 ratio (~2) yet differ
// sharply here (a genuine cubic step b x1.04 vs a merohedral twin b x1.6).
auto merge_systematic_b = [&](const std::vector<gemmi::Op>& rotations) -> double {
struct Acc { double sw = 0.0, swI = 0.0; int n = 0; };
std::unordered_map<HKLKey, Acc, HKLKeyHash> grp;
std::vector<HKLKey> rep(n);
for (size_t i = 0; i < n; ++i) {
if (!pass_cc[i] || !(Sigma[i] > 0.0))
continue;
HKLKey best = key[i];
for (const auto& op : rotations) {
const auto m = op.apply_to_hkl(gemmi::Op::Miller{{H[i], K[i], L[i]}});
const HKLKey k2 = Canonicalize(m[0], m[1], m[2], opt.merge_friedel);
if (std::make_tuple(k2.h, k2.k, k2.l) < std::make_tuple(best.h, best.k, best.l))
best = k2;
}
rep[i] = best;
auto& g = grp[best];
const double w = 1.0 / (Sigma[i] * Sigma[i]);
g.sw += w; g.swI += w * I[i]; g.n += 1;
}
auto merge_systematic_b = [&](const Orbits& orb) -> double {
const auto& grp = orb.grp;
const auto& rep = orb.rep;
std::vector<std::array<double, 3>> obs; // I, sigma, deviation-from-orbit-mean
for (size_t i = 0; i < n; ++i) {
if (!pass_cc[i] || !(Sigma[i] > 0.0))
continue;
const auto& g = grp[rep[i]];
const auto& g = grp.at(rep[i]);
if (g.n < 2)
continue;
obs.push_back({I[i], Sigma[i], I[i] - g.swI / g.sw});
@@ -483,9 +480,13 @@ SearchSpaceGroupResult SearchSpaceGroup(
const auto [present, min_class_cc] = point_group_present(pg.rotations);
if (!present)
continue;
const double ch = pg.rotations.empty() ? std::numeric_limits<double>::quiet_NaN()
: chi2_under(pg.rotations);
const double be = pg.rotations.empty() ? 0.0 : merge_systematic_b(pg.rotations);
double ch = std::numeric_limits<double>::quiet_NaN();
double be = 0.0;
if (!pg.rotations.empty()) {
const Orbits orb = build_orbits(pg.rotations);
ch = chi2_under(orb);
be = merge_systematic_b(orb);
}
pg_cands.push_back({&pg, static_cast<int>(pg.rotations.size()) + 1, min_class_cc, ch, be});
if (!pg.rotations.empty() && std::isfinite(ch))
chi2_ref = std::min(chi2_ref, ch);
+7 -1
View File
@@ -523,8 +523,13 @@ ProcessResult Rugnux::RunPipeline(RugnuxObserver *observer, bool write_output, b
// parallel (the main per-image loop then uses the forced lattice, no FFT). Size the pool to
// those two so a rotation run doesn't pay to initialise (cuFFT plan + GPU alloc) indexers it
// never uses. Stills fire the FFT once per image across all workers, so keep the default there.
if (config_.rotation_indexing && config_.two_pass_rotation && experiment_.IsRotationIndexing())
if (config_.rotation_indexing && config_.two_pass_rotation && experiment_.IsRotationIndexing()) {
indexing_settings.IndexingThreads(2);
// Two indexer threads leave the rest of the machine idle for the whole first pass, which is
// a third of the run. Split each scheme's candidate-cell refinement over -N/2 threads so the
// two together use -N. The split is by candidate cell, so the result does not depend on it.
indexing_settings.RefineThreads(std::max(1, config_.nthreads / 2));
}
indexer_pool = std::make_unique<IndexerThreadPool>(indexing_settings, IndexerConstruction::OnFirstUse);
indexer = std::make_unique<IndexAndRefine>(experiment_, indexer_pool.get());
if (!config_.reference_data.empty())
@@ -764,6 +769,7 @@ ProcessResult Rugnux::RunPipeline(RugnuxObserver *observer, bool write_output, b
auto coarse_settings = experiment_.GetIndexingSettings();
coarse_settings.FFT_HighResolution_A(3.5f); // low-order reflections only -> robust long axis
coarse_settings.IndexingThreads(2);
coarse_settings.RefineThreads(std::max(1, config_.nthreads / 2));
IndexerThreadPool coarse_pool(coarse_settings, IndexerConstruction::OnFirstUse);
// Coarse first pass: keep the recovered cell with the LONGEST axis directly. Its full-
// resolution per-frame validation would be low (the coarse cell is metrically right but