Model validation: scale fits on the GPU, change-of-basis probe gridded once per frame on all threads

The scale fit of every model-validation fit (fit_model: the real model, the indexing probe's relabellings
and every null replicate) was FitModelScale on the host - a long-double Levenberg-Marquardt per solvent grid
point, ~80% of the validation's CPU on a 400 A cell. It now runs on the GPU through ModelScaleGPU::FitSolvent,
the engine the rigid body and molecular replacement already use, from a small pool (ModelScaleGPUPool: up to
four engines with their own streams, sized on the card's total memory, at most an eighth of it). The grid,
the fits and the winner rule are FitModelScale's; the sums are taken in double on the device, so R-factors
agree with the host's to rounding. The CPU build, and a GPU build without a card, keep FitModelScale.

The change-of-basis probe gridded each candidate frame with gemmi's serial put_model_density_on_grid, once
for the data as merged and once more for each alternative indexing. It now grids each frame once, with
ModelGrid's parallel PutModelDensityOnGrid (the same grid, bit for bit), from threads outside the pool so the
gridding spreads over it, and scores every labelling on that F_calc. Exact.

4nwv (122k atoms, I222, 400 A cell): model validation 33.2 s -> 14.0 s; every logged R, sigma and decision
the same.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EizBKhTqrAYago9KJG3dA3
This commit is contained in:
2026-10-12 21:40:54 +02:00
co-authored by Claude Opus 5.5
parent f567771d56
commit 1fe73bab41
4 changed files with 282 additions and 35 deletions
+35
View File
@@ -15,6 +15,7 @@
#include <filesystem>
#include <fstream>
#include <future>
#include "gemmi/asumask.hpp"
#include "gemmi/mmread_gz.hpp"
@@ -347,4 +348,38 @@ TEST_CASE("ModelScaleGPU_Deterministic", "[ModelValidation][gpu]") {
}
}
// What model validation's fits take: FitModelScale's answer, to rounding, from a pool shared by more threads
// than it has engines - and the same answer, to the bit, whichever engine a fit got.
TEST_CASE("ModelScaleGPUPool_MatchesFitModelScale", "[ModelValidation][gpu]") {
if (get_gpu_count() == 0)
SKIP("No GPU");
for (const Case &c : CASES) {
INFO(c.name);
gemmi::UnitCell cell(c.a, c.b, c.c, c.alpha, c.beta, c.gamma);
const gemmi::SpaceGroup &sg = *gemmi::find_spacegroup_by_name(c.hm);
const gemmi::Scaling<float> points = MakeScaling(cell, sg, 2.8);
gemmi::Scaling<float> cpu = points;
const ModelScaleReport report = FitModelScale(cpu, {}, 8);
ModelScaleGPUPool pool(0, points.points.size(), 2);
std::vector<gemmi::Scaling<float>> gpu(6, points);
std::vector<ModelScaleReport> gpu_report(gpu.size());
std::vector<std::future<void>> fits;
for (size_t i = 0; i < gpu.size(); i++)
fits.push_back(std::async(std::launch::async, [&, i] { gpu_report[i] = pool.Fit(gpu[i], 80.0, 2); }));
for (auto &f : fits)
f.get();
CHECK(gpu_report[0].n_grid == report.n_grid);
CHECK(std::fabs(gpu_report[0].r_work_fit - report.r_work_fit) < R_TOLERANCE);
CHECK(std::fabs(gpu[0].calculate_r_factor() - cpu.calculate_r_factor()) < R_TOLERANCE);
for (size_t i = 1; i < gpu.size(); i++) {
CHECK(gpu[i].k_sol == gpu[0].k_sol);
CHECK(gpu[i].b_sol == gpu[0].b_sol);
CHECK(gpu[i].k_overall == gpu[0].k_overall);
CHECK(std::memcmp(&gpu[i].b_star, &gpu[0].b_star, sizeof(gpu[0].b_star)) == 0);
}
}
}
#endif