ModelMaskGPU: the Refmac bulk-solvent mask on the GPU

The equivalent of PutMaskOnGrid() / gemmi SolventMasker(Refmac).put_mask_on_grid() for the GPU
rigid body. Every symmetry image of every atom is masked directly (one block per image, gemmi's
box and !(d2 > r2) rule), which equals gemmi's mask-then-symmetrize-with-min because the operators
are isometries. Islands are removed by a 26-connected periodic union-find (atomicCAS, larger root
under smaller, so each component is rooted at its lowest index), a read-only root pass, a size
count capped at the island limit and a removal pass - no float atomics, bit-identical repeats.
The shrink is not implemented: SetGrid() throws when gemmi's 0.8 A stencil would be non-empty,
which it is not on any rigid-body grid (spacing d/3 >= 1.17 A).

Atoms come in as double fractional coordinates (ModelMaskAtom): with float input, 9fhc at 3.5 A
differed from gemmi at one 24-fold orbit of radius points (|d - r| ~ 2e-6 A); with double input
the mask equals gemmi's on all 8 prototype sets at 3.5 and 6 A, including 6oel, 8t7r, 9hnc, 9fhc.

Time per mask (RTX 5080, Compute incl. islands): 6oel 288^3 4.2 ms, 8t7r 18.4M 2.8 ms,
9fhc 8.0M 1.5 ms, 9hnc 1.9M 0.45 ms.

Union-find credited to Playne & Hawick (2018) at the kernel and in ACKNOWLEDGEMENT.md.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D1G8gJVAy6gp1K5Dz3NE5C
This commit is contained in:
2026-09-28 17:13:09 +02:00
co-authored by Claude Opus 5.5
parent 9f00fd1f4d
commit 63fde9246a
6 changed files with 574 additions and 0 deletions
+214
View File
@@ -0,0 +1,214 @@
// SPDX-FileCopyrightText: 2026 Filip Leonarski, Paul Scherrer Institute <filip.leonarski@psi.ch>
// SPDX-License-Identifier: GPL-3.0-only
#include <catch2/catch_all.hpp>
#include "../common/CUDAWrapper.h"
#ifdef JFJOCH_USE_CUDA
#include <cmath>
#include <cstring>
#include <random>
#include <string>
#include <vector>
#include <gemmi/pdb.hpp>
#include <gemmi/solmask.hpp>
#include <gemmi/symmetry.hpp>
#include "../rugnux/ModelMaskGPU.h"
namespace {
// The five groups of ModelValidation_ParallelGriddingMatchesGemmi: none, a centring, screws, a cubic
// body centring and a cubic face centring with a diamond glide.
const char *kCrysts[] = {
"CRYST1 40.000 50.000 60.000 90.00 90.00 90.00 P 1 1\n",
"CRYST1 40.000 50.000 60.000 90.00 100.00 90.00 C 1 2 1 4\n",
"CRYST1 40.000 50.000 60.000 90.00 90.00 90.00 P 21 21 21 4\n",
"CRYST1 60.000 60.000 60.000 90.00 90.00 90.00 I 2 3 24\n",
"CRYST1 80.000 80.000 80.000 90.00 90.00 90.00 F 41 3 2 96\n",
};
// ModelValidationTest.cpp's ClusterPdb(): random atoms in one corner of the cell, from a fixed seed, with
// every fifth atom a nitrogen and every seventh an oxygen so that more than one radius is in play. A
// cluster this loose leaves many small pockets of solvent between its atoms: the islands.
gemmi::Structure ClusterModel(const char *cryst) {
std::string pdb = cryst;
std::mt19937 rng(20260902);
std::uniform_real_distribution<double> x(2, 14), y(2, 16), z(2, 18);
char line[96];
for (int i = 1; i <= 150; i++) {
const char *el = i % 5 == 0 ? "N" : i % 7 == 0 ? "O" : "C";
std::snprintf(line, sizeof line,
"ATOM %5d %s UNK A 1 %8.3f%8.3f%8.3f 1.00 20.00 %s\n",
i, el, x(rng), y(rng), z(rng), el);
pdb += line;
}
pdb += "END\n";
gemmi::Structure st = gemmi::read_pdb_string(pdb, "cluster");
return st;
}
gemmi::Grid<float> MaskGrid(const gemmi::Structure &st, double d_min) {
gemmi::Grid<float> grid;
grid.unit_cell = st.cell;
grid.spacegroup = st.find_spacegroup();
grid.set_size_from_spacing(d_min / (2 * 1.5), gemmi::GridSizeRounding::Up);
return grid;
}
// What the rigid body hands ModelMaskGPU: the atoms gemmi masks, fractional and wrapped, with gemmi's
// Refmac radius; every symmetry operator with every centring vector.
std::vector<ModelMaskAtom> MaskAtoms(const gemmi::Structure &st) {
const gemmi::SolventMasker masker(gemmi::AtomicRadiiSet::Refmac);
std::vector<ModelMaskAtom> atoms;
for (const gemmi::Chain &ch : st.models[0].chains)
for (const gemmi::Residue &res : ch.residues)
for (const gemmi::Atom &a : res.atoms) {
if (a.is_hydrogen() || a.occ <= 0)
continue;
const gemmi::Fractional f = st.cell.fractionalize(a.pos).wrap_to_unit();
const double r = masker.constant_r + masker.rprobe
+ gemmi::refmac_radius_for_bulk_solvent(a.element.elem);
atoms.push_back(ModelMaskAtom{f.x, f.y, f.z, r});
}
return atoms;
}
std::vector<ModelMaskOp> MaskOps(const gemmi::SpaceGroup &sg) {
const gemmi::GroupOps gops = sg.operations();
std::vector<ModelMaskOp> ops;
for (const auto &cen : gops.cen_ops)
for (const gemmi::Op &op : gops.sym_ops) {
ModelMaskOp m{};
for (int i = 0; i < 3; i++) {
for (int j = 0; j < 3; j++)
m.rot[3 * i + j] = static_cast<double>(op.rot[i][j]) / gemmi::Op::DEN;
m.tran[i] = static_cast<double>(op.tran[i] + cen[i]) / gemmi::Op::DEN;
}
ops.push_back(m);
}
return ops;
}
ModelMaskGrid MaskGridDescription(const gemmi::Grid<float> &grid) {
ModelMaskGrid g;
g.nu = grid.nu;
g.nv = grid.nv;
g.nw = grid.nw;
for (int i = 0; i < 3; i++)
for (int j = 0; j < 3; j++)
g.orth[3 * i + j] = grid.unit_cell.orth.mat[i][j];
g.volume = grid.unit_cell.volume;
return g;
}
// |distance - radius| to the nearest image of any atom that reaches point (u, v, w), in A: how close
// the point lies to the edge of the mask.
double DistanceToMaskEdge(const gemmi::Grid<float> &grid, const std::vector<ModelMaskAtom> &atoms,
const std::vector<ModelMaskOp> &ops, int u, int v, int w) {
const double p[3] = {static_cast<double>(u) / grid.nu, static_cast<double>(v) / grid.nv,
static_cast<double>(w) / grid.nw};
double best = INFINITY;
for (const ModelMaskAtom &a : atoms)
for (const ModelMaskOp &op : ops) {
const double f[3] = {a.x, a.y, a.z};
gemmi::Fractional d;
for (int k = 0; k < 3; k++) {
const double x = op.rot[3 * k] * f[0] + op.rot[3 * k + 1] * f[1] + op.rot[3 * k + 2] * f[2]
+ op.tran[k];
d.at(k) = p[k] - x - std::round(p[k] - x);
}
best = std::min(best, std::fabs(grid.unit_cell.orthogonalize_difference(d).length() - a.radius));
}
return best;
}
} // namespace
// The whole mask against gemmi's put_mask_on_grid(), the island step alone against gemmi's on gemmi's
// own pre-island mask (exact), and three repeats bit for bit. The masking itself may differ from gemmi
// only at points that sit at an atom's radius, where the float distance rounds the other way.
TEST_CASE("ModelMaskGPU_MatchesGemmi", "[ModelValidation][gpu]") {
if (get_gpu_count() == 0) {
SKIP("No GPU");
}
CudaStream stream;
bool any_islands = false, any_odd_size = false;
for (const char *cryst : kCrysts) {
const gemmi::Structure st = ClusterModel(cryst);
const gemmi::SpaceGroup *sg = st.find_spacegroup();
REQUIRE(sg != nullptr);
const std::vector<ModelMaskAtom> atoms = MaskAtoms(st);
const std::vector<ModelMaskOp> ops = MaskOps(*sg);
CudaDevicePtr<ModelMaskAtom> atoms_d(atoms.size());
REQUIRE(cudaMemcpy(atoms_d, atoms.data(), atoms.size() * sizeof(ModelMaskAtom), cudaMemcpyHostToDevice) == cudaSuccess);
for (double d_min : {6.0, 3.5}) {
const gemmi::SolventMasker masker(gemmi::AtomicRadiiSet::Refmac);
gemmi::Grid<float> pre = MaskGrid(st, d_min);
masker.clear(pre);
masker.mask_points(pre, st.models[0]);
masker.symmetrize(pre);
gemmi::Grid<float> ref = pre;
const int islands = masker.remove_islands(ref);
masker.shrink(ref);
const size_t n = ref.data.size();
any_islands |= islands > 0;
any_odd_size |= ref.nu % 8 != 0 || ref.nv % 8 != 0 || ref.nw % 8 != 0;
ModelMaskGPU mask(stream, n);
mask.SetGrid(MaskGridDescription(ref), ops);
CudaDevicePtr<float> mask_d(n);
std::vector<float> out(n), first;
// The island step alone, on gemmi's pre-island mask: exact.
REQUIRE(cudaMemcpy(mask_d, pre.data.data(), n * sizeof(float), cudaMemcpyHostToDevice) == cudaSuccess);
mask.RemoveIslands(mask_d);
REQUIRE(cudaMemcpyAsync(out.data(), mask_d, n * sizeof(float), cudaMemcpyDeviceToHost, stream) == cudaSuccess);
REQUIRE(cudaStreamSynchronize(stream) == cudaSuccess);
CHECK(std::memcmp(out.data(), ref.data.data(), n * sizeof(float)) == 0);
for (int repeat = 0; repeat < 3; repeat++) {
mask.Compute(atoms_d, static_cast<int>(atoms.size()), mask_d);
REQUIRE(cudaMemcpyAsync(out.data(), mask_d, n * sizeof(float), cudaMemcpyDeviceToHost, stream) == cudaSuccess);
REQUIRE(cudaStreamSynchronize(stream) == cudaSuccess);
if (repeat == 0) {
first = out;
size_t differ = 0;
for (size_t i = 0; i < n; i++)
if (out[i] != ref.data[i]) {
differ++;
const int u = static_cast<int>(i % ref.nu), v = static_cast<int>(i / ref.nu % ref.nv),
w = static_cast<int>(i / (static_cast<size_t>(ref.nu) * ref.nv));
CHECK(DistanceToMaskEdge(ref, atoms, ops, u, v, w) < 1e-4);
}
INFO(sg->hm << " d " << d_min << " grid " << ref.nu << "x" << ref.nv << "x" << ref.nw
<< " islands " << islands << ": " << differ << " points differ");
CHECK(differ <= 15 * n / 10000000 + 1);
} else
CHECK(std::memcmp(out.data(), first.data(), n * sizeof(float)) == 0);
}
}
}
CHECK(any_islands);
CHECK(any_odd_size);
}
// The shrink is not implemented: a grid fine enough for it to change anything is refused.
TEST_CASE("ModelMaskGPU_RefusesAGridTheShrinkWouldChange", "[ModelValidation][gpu]") {
if (get_gpu_count() == 0) {
SKIP("No GPU");
}
CudaStream stream;
const gemmi::Structure st = ClusterModel(kCrysts[0]);
const std::vector<ModelMaskOp> ops = MaskOps(*st.find_spacegroup());
const gemmi::Grid<float> coarse = MaskGrid(st, 3.5);
const gemmi::Grid<float> fine = MaskGrid(st, 2.0);
ModelMaskGPU mask(stream, fine.point_count());
CHECK_NOTHROW(mask.SetGrid(MaskGridDescription(coarse), ops));
CHECK_THROWS(mask.SetGrid(MaskGridDescription(fine), ops));
}
#endif