The model scale searched b_sol only over 10-80 A^2. That is the range
Fokine & Urzhumtsev report for models at ordinary B, but on low-resolution
entries whose deposited B is 200-400 A^2 the fit sat on the 80 A^2 ceiling
(and k_sol on its 0.1 floor): a solvent edge far sharper than the atoms it
lies against, reaching too deep in resolution. R-free came out 0.05-0.10
above REFMAC (0 cycles) and phenix.model_vs_data on the same MTZ, model
and flags.
The ceiling is now max(80, 2 x the model's mean atomic B) (SolventBMax),
for the reported fit and for the rigid-body placement's per-zone solvent
(CPU, GPU and the host fallback alike). The grid keeps eight b_sol values
across a wider box, so it costs no more points; the default box, and so
every model with mean B <= 40 A^2, is bit-identical to before.
Measured (10 open-arm sets with --model, GPU build, vs the same base):
high-B set, 7.0 A: R-free 0.462 -> 0.364, b_sol 80 -> 379 (REFMAC 0.366,
REFMAC's own b_sol ~371; model_vs_data 0.291);
the rigid-body step now commits
high-B set, 3.9 A: 0.410 -> 0.331, b_sol 80 -> 388, k_sol 0.10 -> 0.225
(REFMAC 0.328, model_vs_data 0.314); null 10.1 -> 14.2 sigma
B~200 set, 3.2 A: 0.408 -> 0.402, b_sol 80 -> 120; enantiomorph label
decision unchanged (27.2 -> 25.8 sigma)
7 other sets (mean B 16-57): bit-identical R, k_sol, b_sol, placement.
p.hkl md5 and the MTZ reflection data are identical to the base on all 10.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
In a stress run (two rugnux on one 16 GB card) the indexer's cufftPlanMany failed three times with
CUFFT_INTERNAL_ERROR (5), not CUFFT_ALLOC_FAILED, so the message carried neither the free memory
nor the remedy. Every plan failure is now reported like a failed allocation: free memory on the
card, the two usual causes and the CPU remedy.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A CUDA out-of-memory in an indexer worker was turned into an IndexerResult carrying only the
message string. The rotation indexer then returned no lattice, the first pass treated that as a
genuine negative and the rescues acted on it: in a stress run (two rugnux on one card) a run had
both first-pass schemes fail with out-of-memory, the axis-sign rescue compared 0 frames at the
flipped sign with the -1 "no lattice" sentinel, adopted the flipped sign, and the beam-centre rescue
then moved the centre - a failure of the machine used as evidence about the crystal. The CUDA
category was lost on the way, so the fail-fast check (IsFatalResourceError) never saw it.
- IndexerResult::error is now the std::exception_ptr the worker caught. RotationIndexer::RunIndexing
rethrows it before touching any state, so nothing is memoised for a failed run and the exception
keeps its category: rugnux stops with the CUDA message at once (verified with a forced indexer
failure: exit 1 after 1.9 s, "CUDA (GPU) error (...)"). FirstPass::indexer_error,
RotationIndexer::GetIndexerError and RequireLattice's machine-failure branch are gone.
- The beam-centre arm in RunAllPasses rethrows fatal resource errors like its sibling catches; it
had caught the out-of-memory and carried on at the file's centre.
- IndexAndRefine keeps per-frame indexer failures apart from frames that did not index (count +
first exception). The frame stays "not attempted" as before; the receiver logs the count and the
first error at the end of a collection (no cancel), rugnux's image pass and stills geometry
refinement rethrow it and stop. A rotation-indexer failure inside IndexAndRefine (online rotation
indexing, FinalizeRotationIndexing) is recorded the same way, so a live collection is not
cancelled by it.
- A failed device allocation or cuFFT plan now says how much was asked, how much of the card is
free, and - when the card is merely full - that another program is using the GPU or the data set
needs more than the card has, with the CPU remedy; previously "Failed to allocate device memory
(out of memory)" / "Failed to create CUFFT plan with cufftPlanMany".
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Narrow, guarded: the doubled pass runs only where the added class grows with resolution and is resolved; the doubled
cell is adopted only where one pseudo-translation explains the added class in every shell and the point group does not
rise. Fixes the one remaining lattice failure (half cell -> deposited cell, R-free 0.574 -> 0.275); p.hkl identical on
the ci tier, the supercell gate set, 78 further supercell-candidate sets and all private sets.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EizBKhTqrAYago9KJG3dA3
Exact: p.hkl md5 and MTZ reflection data identical on 18 sets. 4nwv R-free 0.545 -> 0.297 and cell in the depositor's
frame; written cells corrected on 9rcs, 9b22, 9qw8, myob_x06da_powder_1.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EizBKhTqrAYago9KJG3dA3
- ModelValidation reads the model and, where it carries non-given NCS operators
(_struct_ncs_oper 'generate' / MTRIX without the given flag), expands them
(gemmi expand_ncs, vendored assembly.cpp from GEMMI 0.7.5, MPL-2.0 as the rest
of gemmi_gph). Same in the battery's model_check.py and depdata_check.py.
An icosahedral-virus entry deposited as 3 of the 45 chains of its ASU was
scored against a fifteenth of its atoms: R-free 0.545 -> 0.297.
- The indexing the model chooses (AdoptModelFrame) now moves the cell with the
indices (reseat_integrated in the main path; CellInBasis in the forecast,
twin-file and stills/scale paths). A near-symmetry law (b within 2% of c, beta
a degree off 90, a~b in P1) relabelled the reflections only, so the files were
written with two axes swapped or beta flipped relative to the indices. Where
the law moved the cell, the validation is remade on the moved cell.
Measured on 18 open/in-house sets with models: p.hkl and MTZ reflection data
bit-identical on all; cell and model R unchanged where the law is exact; the
cell now matches the deposited one on the 6 pseudo-merohedral cases (orthorhombic
a/c swap, three monoclinic beta flips, a P1 alpha/beta swap), R-free within
+-0.001 there.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EizBKhTqrAYago9KJG3dA3
Doubled-cell hypothesis: the run now always merges the doubled cell under <prefix>_doubled first and
decides with both merges in hand. Adopted only where (1) the probe's class grows significantly with
resolution (three standard errors between the 20-8 A shell and the finest measured one) and (2) in
the doubled merge one pseudo-translation - half a cell plus eps, fitted over the class - modulates at
least half of the added class in every shell (FitDoubledClassTranslation: A sin^2(pi h.eps) + B per
shell, share 1 - B/mean). Where adopted, every <prefix>_doubled file takes the plain name and the
probed cell's file of that name moves to <prefix>_halfcell. Replaces the HALF_CELL_BREAKS_DOWN bound.
Measured on the forced doubled merges: modulated share per shell 0.80-1.0 out to 1.9 A on the crystal
deposited in the doubled cell; on the ones deposited in the half cell it falls to 0.01-0.34 by
2.1-2.8 A (the rising class with twinning), or stays at -0.3-0.48 at every resolution (three others);
the one class it fully explains does not rise (eps 0.1 of a cell, flat at 0.1 of the lattice).
Space-group search: the trim of a screw zone's largest absence exempted a violation "at its row's
mean" on the de-modulated intensity, so under a deep pseudo-translation a reflection at 0.015 of its
row (de-modulated by 1/0.002) could never be trimmed and alone decided the row against the screw.
The exemption now reads the measured intensity, at both sites.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EizBKhTqrAYago9KJG3dA3
Exact: p.hkl, MTZ and map data, p_model.pdb identical at -N 1/default/-N 8 on 9 sets (CPU build and GPU build on
the CPU path); GPU path unchanged. Indicative: 8xtg CPU validation 32-41 -> 23-25 s.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Where the first pass recognises two twin domains on their coincidence lattice (reticular
merohedry, index n = 2 or 3) and adopts the stronger domain's cell, the 1/n of that domain's
reflections that lie on the other domain's lattice too carry both domains' intensity. The step
now records the overlap as a lattice vector t of the standing cell (h . t divisible by n), the
rotation result carries it, and the per-image integration drops those reflections. t is a
real-space vector, so it holds through every later change of basis; it is read in sixths so a
centred conventional cell (F, I, C, R) is exact. Each unique reflection keeps its equivalents
that the other domain does not overlap; only those whose every equivalent is overlapped are lost.
The two-pass quality guard compared signal counts between a header pass that integrated
everything and a refined pass that left the overlap out, and sent the run back to the
composite lattice. A pass that left an overlap of index n out now has its counts taken up by
n/(n-1), and its axial rows are not compared with a pass that did not.
Nothing changes where the step does not fire: the field stays empty, the factor is 1.
Measured (merging the existing outputs with and without the class H.(111) = 0 mod 3, against
removing a random third): spinel-twinned F-cubic private set R_meas 25.5 -> 14.3% (random
26.7%), insu_I_x06da_low_isa 24.1 -> 16.6% (random 24.2%). With this change, end to end:
- insu_I_x06da_low_isa: I23, R_meas 24.0 -> 12.7%, ISa 5.2 -> 28.8, CC1/2 0.997 -> 1.000,
completeness 99.8 -> 95.7%, d 1.45 -> 1.47 A, model R-free 0.260 -> 0.246.
- private F-cubic twin: F432, R_meas 25.5 -> 6.3%, ISa 3.9 -> 20.4, completeness 100 -> 93%,
d 3.23 -> 3.77 A (XDS reference 4.0). 432 now passes the added-operator gate on the
all-observation merge (contrast 0.75, bound 0.72) instead of being refused there (0.62) and
rescued by the twin-immune zone.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SVmAWnzCmRKAXVUCdc4iNi
LevMarFit takes compute_wssr() in a copy (ComputeWSSR) that keeps each
reflection's exp(-0.25 * b_star.r_u_r(h)); when the step is accepted,
ComputeLMMatrices is evaluated at exactly those parameters and takes the same
values from there instead of calling exp again. Same expressions, same
parameters, same numbers.
Bench against the base FitModelScale (single thread, 48k reflections, P2_1):
1.65 -> 1.10 s for this and the previous commit together; P1, P2_12_12_1,
P6_122, I23 and the dependent n<=5 path identical to the bit and 30-40% faster.
Validation outputs (p.hkl md5, p.mtz / p_maps.mtz data, maps, p_model.pdb, log)
identical to the base: CPU build --mode scale on 5reo, myob_x10sa, 8sa8, 8xtg,
5ebi at -N 1, default and -N 8; full CPU runs of 9ea5, 6iu5, 7dkp; the GPU build
with no visible device on the five --mode scale sets.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SVmAWnzCmRKAXVUCdc4iNi
A pooled CudaDevicePtr frees on the thread's allocation stream, not on the engine's stream, and the
pool may hand the memory to another engine - or, past its release threshold, unmap it - as soon as
that free is reached, which on an idle allocation stream is at once. An engine destroyed with work
still queued (FFTIndexerGPU after SearchCap's last DirectionsChanged upload, a spot finder between
DetectAt and Extract, a shadow accumulator after a pending fold, any engine on an exception path)
thus had kernels or copies writing memory that was someone else's or no longer mapped. Now:
- CudaStream synchronises before cudaStreamDestroy (destructor and move-assignment), which covers
engines whose own stream is declared after their buffers (FFTIndexerGPU, the gather buffer);
- every engine holding pooled buffers and a stream (shared or own, declared before the buffers)
synchronises it in its destructor; BraggIntegrationEngineGPU also before EnsureCapacity
reallocates, where a Run that threw leaves work queued.
A GPU failure that is handled no longer hides a lost context: ShadowFinder, BeamCenterFFT, the
rigid-body pool and model validation call cuda_throw_if_context_lost() before cuda_clear_error(),
as the device-decode fallbacks already did; RotationScaleMergeGPU's Alloc does so before waiting up
to ten minutes for GPU work beside it and then reporting a lost device as out of memory; and a
failed cudaMalloc says why. BeamCenterFFT logs the failure it used to drop silently, the
speculative geometry probe logs the exception it swallowed (its GPU fault was otherwise reported by
the merge beside it, under the merge's name), and RotationScaleMergeGPU's DeviceGuard no longer
throws from its destructor.
Only synchronisation and error paths change: p.hkl md5 and the MTZ data (gemmi) are identical to
the b530c2d full battery on myob_x10sa, cytc_x10sa, 8a1a, 9gdj, 11if, kdp_x10sa_20keV and 6z9g.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SVmAWnzCmRKAXVUCdc4iNi
Supercell probe, log-only part: the class it reads over 20-3 A is followed out to the run's own
limit (a second prediction of the same supercell on the same 60 frames, so the 20-3 A band and the
SUPERCELL_* keys are unchanged). Half-integer reflections are counted only where no lattice
reflection predicted on the same frame lies within r3, and each resolution shell is read against the
median of the six other parity classes - which a single doubling leaves empty - as an excess in units
of the lattice's intensity and as a z in the class's own standard errors, the strongest sibling's z
beside it ("Supercell probe to the limit" in the log).
Doubled-cell hypothesis: where that class stands clear of the six empty ones (measured above its
noise and at more than twice the strongest of them, in at least three shells), the run integrates and
merges the doubled cell too, forcing the last pass's indexing result with its lattice doubled along
the class (orientation, geometry and axis kept). One of the two is written as the answer, the other's
merged .mtz/.cif/.hkl beside it as <prefix>_doubled or <prefix>_halfcell; the report names both
(DOUBLED_CELL_*). Which one is the answer is a policy behind one constant: ADDED_CLASS_SIGNIFICANT
(the doubled cell wherever the class stands clear) or HALF_CELL_BREAKS_DOWN (default: only where the
class reaches a quarter of the lattice's intensity in the finest shell both are measured in).
The doubled lattice is classed with a near-zero tolerance: the default lattice search promoted a
triclinic doubled cell with an 88.2 deg angle to orthorhombic C, and no frame indexed.
Measured on forced doubled-cell merges (rc175 b530c2d base): the open-arm crystal deposited in the
doubled cell merges at R-free 0.275 against its deposited model, against 0.574 in the half cell
(odd/even 0.02 at >8 A rising to 0.65 at 1.95 A, odd half-set CC1/2 0.93 at 8-5 A). Five crystals
deposited in the half cell read classes the doubled merge holds as well (odd CC1/2 0.73-0.98 at
8-4 A): two rise with resolution like the first (to 0.4-0.5 at 2.5 A), one is strong from low
resolution (0.3-0.5), two stay flat at 0.1-0.2. Nothing measured on intensities - the trend, the
neighbour correlation, the separation from the nearest lattice reflection after the r3 filter -
separated the doubled deposition from the rising half-cell ones, so the policy is the owner's call.
Also: the SUPERCELL_POSSIBLE prose no longer says correct cells "read the same", and the warning no
longer advises -C with the doubled cell, which the indexer reduces back to the half cell.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SVmAWnzCmRKAXVUCdc4iNi
compute_model_factors' CPU path (no GPU, or the grid does not fit the card)
gridded the density with gemmi's serial put_model_density_on_grid and the mask
with SolventMasker::put_mask_on_grid. It now uses ModelGrid's
PutModelDensityOnGrid / PutMaskOnGrid with -N threads, sharing one set of orbit
leaders (the two grids are the same size) - gemmi's grids bit for bit at any
thread count, as the rigid body already uses them. The GPU path is untouched.
Exactness: p.hkl md5, p.mtz/p_maps.mtz reflection data, the three maps and
p_model.pdb identical to the base and the validation log lines identical (up to
the order of concurrent replicate lines) - CPU build, --mode scale --model on 5reo,
myob_x10sa, 8sa8, 8xtg, 5ebi at -N 1, default and -N 8; full CPU runs of 9ea5,
7dkp, 6iu5, 8tyy; the GPU build with no visible device on the five --mode scale
sets.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SVmAWnzCmRKAXVUCdc4iNi
All exact to the bit (bench against the previous FitModelScale on P1, P2_1,
P2_12_12_1, P4_32_12, P6_122, P2_13, I23 and the dependent n<=5 path; the
ModelScaling cases; validation outputs identical on real data):
- the start of a grid point (fit_isotropic_b_approximately) is computed from the
same |Fcalc| the fit takes, once instead of twice, and without copying the
Scaling's points per chunk;
- gemmi's Levenberg-Marquardt is followed in a copy (LevMarFit) whose
compute_lm_matrices is templated on the parameter count (2..7), so alpha/beta
stay in registers; a zero derivative row adds +0 instead of being skipped
(alpha/beta are never -0), and the full square is summed (the lower half is
gemmi's, the upper is its mirror) - branch-free and vectorised;
- grid points are scheduled one per task (ParallelFor) instead of fixed chunks;
- a fine-pass pair bit-identical to a coarse-pass pair reuses that fit.
Bench (single thread, 48k reflections, P2_1): 1.58 -> 1.33 s; ~15-25% on the
other groups.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SVmAWnzCmRKAXVUCdc4iNi
UseAtomBoxesByPlane handed its functor one grid point at a time, so the density
(gemmi's ExpSum/ExpAnisoSum with unsafe_expapprox) ran scalar - on the CPU path of
model validation the rigid body's density copies were 48% of all validation
CPU time (8sa8, perf). The walk over an atom's box on a plane is unchanged (same
order, same delta.x stepping) but now only collects the points within the radius
(index, d2, delta); the density functor then evaluates them a Gaussian at a time
over all points - each point still sums its Gaussians in gemmi's order from 0 -
in loops GCC vectorises (AVX2, no intrinsics), and adds them to the grid in walk
order. The mask functor zeroes the gathered points. Same grid, bit for bit, at
any thread count (ModelValidation_ParallelGriddingMatchesGemmi: iso + aniso,
five groups, 1 and 4 threads).
Measured (CPU build, --mode scale --model, shared machine, indicative): 8xtg
validation 28.1 -> 13.6 s wall, run total 665 -> 366 user-s (with the FitModelScale
commit); outputs identical.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SVmAWnzCmRKAXVUCdc4iNi
The de-novo FFT indexers implement the 1D-projection method of Steller,
Bolotovsky & Rossmann (1997), which was already credited; the 3D Fourier
transform indexing idea it descends from (Bricogne 1986, EEC PSD software
workshop, Phase III, LURE, p. 28 - no DOI) was not. Add it to
ACKNOWLEDGEMENT.md, the CPU_DATA_ANALYSIS references, the §5 text and the
in-source credit, and add the DOI of Powell (1999) to the MOSFLM paragraph.
3D-FFT papers (Campbell 1998, Gildea et al. 2014) are not cited: the code
does not compute a 3D transform.
Add a section acknowledging Global Phasing (Bricogne and colleagues) and
autoPROC (Vonrhein et al. 2011) as the expert-system model rugnux aims at.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SVmAWnzCmRKAXVUCdc4iNi
Exact: P4 p.hkl identical to the base on 39 noise-subset sets; concurrent arms p.hkl md5 and MTZ data identical
to P4 on 12 sets at default -N and -N 8 (incl. 5 sets where the arms disagree). Side arm on card 2 with 3+ cards,
else on the last card when 2 x observations x 256 B fits its total memory (decided up front).
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SVmAWnzCmRKAXVUCdc4iNi
The projected histogram is sampled every 1/(2*max_length) of 1/d, so a row
of length L between max_length and 2*max_length folds to 2*max_length - L and
peaks there as sharply as a real row. At the default 500 A bound a 602 A axis
came back as ~400 A and a 701 A axis as ~299 A; the folded cell still indexed
59/60 and 37/60 validation frames (only the reflections whose index lands on
an integer of the folded period), so the long-axis rescue, gated on a poor
pass, never ran. The same fold also turned real >500 A lattice combinations
of shorter-axis cells into spurious shortlist rows.
Each shortlisted row is now asked once, by a direct Fourier sum over the spot
positions (no sampling, so no fold), whether the spots carry its own length or
its partner 2*max_length - L more strongly, and keeps the stronger. Partners
within 0.1 of an integer multiple are left alone (a harmonic, settled
elsewhere). Refinement accepts rows up to 2*max_length when one was unfolded.
Measured: a hexagonal P6122 c=602 A EIGER set and a P6522 c=701 A CCD set,
both indexed de novo on the folded cell before, now index the deposited cell
60/60 and merge to R-free 0.31 / 0.22 against the models (the same numbers as
-C with the deposited cell). Of 13 long-axis battery sets, 7 are bit-identical
(p.hkl md5) and 2 never unfold a row, 8v4o and 9yl4 have the same intensities (CC 1.0) without needing
the long-axis rescue, 4mtk refines a cell closer to the deposit at half the
wall time, 4nwv merges better (R_meas 0.58 -> 0.47, ISa 6.7 -> 8.4).
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SVmAWnzCmRKAXVUCdc4iNi
Stage A offered the b-unique two-fold (reference setting) on the lattice's holohedry alone, but held
its a- and c-unique siblings (pass-1 settings) to CellHostsRotations at 2e-3. On a cell refined free
~0.2 deg off orthogonal only the b-unique rung existed, so when the holohedral 222 was refused the
setting, not the data, chose the two-fold axis. Stage B already offers non-reference settings
without asking the cell, for the same reason; Stage A now does likewise where a holohedry is given.
With siblings of one order eligible together, ties were broken on the class CC alone, which a twin
law at fraction ~0.4 can win. A twin law cannot extinguish an axial row (every principal row maps to
itself or its Friedel mate), so ties now go first to the group whose pure two-fold axes carry the
more screw evidence on their own odd axial row - judged exactly as Stage B judges that zone - and
only then to the CC. Rows on 3-, 4- or 6-fold axes are not read.
Open arm: 6p8j (deposited P2_1, b = 152 A) was P 1 1 2 on the 88 A axis, now P 1 21 1 on the 152 A
axis (152 A odd row 158 nats, 46 A row 36, 88 A row refused; R-free 0.214). 9hnc and 5cc8 p.mtz
md5-identical (siblings offered but refused by added-operator R). On five passing P2_1 sets in an
orthorhombic metric the true axis carries all the evidence (offline replay), so no change there.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SVmAWnzCmRKAXVUCdc4iNi
The space-group search made its two P1 merges one after the other on one
engine: the Lorentz-filtered one, its search, then the all-observation one and
its search. Since every merge now starts from the same state, the
all-observation merge is made on a copy of the engine (CloneIngest: the host
half of the ingest copied, the device half staged on the copy's card), started
right after the ingest and read where the search needs it, so it runs beside the
P1 merge, the re-scaled search merge and the first arm. Its search then runs
beside the first arm's.
Placement: from three cards up the copy gets card 2 to itself (card 0 has the
main engine, card 1 the quality guard's). With one or two cards it shares the
last card, and only where two engines of this size fit the card's total memory
(DEVICE_BYTES_PER_OBSERVATION = 256; two engines measured at ~150 B/observation
each), so the choice never depends on what is free at the moment. On a 16 GB
card that admits sets up to ~32M observations; larger ones keep the serial
order. Nothing of the copy reaches the run before the first arm says the arm is
wanted: its log, its result fields and its per-frame write-back onto the
outcomes are taken at the point where the serial merge would have been made. A
copy that failed is merged again in series.
Results are bit-identical to the serial order (p.hkl md5 and MTZ data), at
default -N and -N 8.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SVmAWnzCmRKAXVUCdc4iNi
The background ring's high-side clip, mean + 4*sqrt(mean), assumes counted photons. A CCD reports
ADU: on a Rigaku Saturn a background pixel scatters 4-5x its mean, so the clip sat at ~2 sigma, cut
the upper tail of every ring and added a positive offset to every reflection, compressing the
intensity statistics (a false 422 on one twinned I41 crystal).
No header carries a usable gain (SMV states none, every marCCD file the writer default of 1 ADU per
photon), so it is measured: background_gain::MeasureFrame takes the variance per unit mean of the
difference of pixels two columns apart, per level, clipped, on every fourth row of up to 16 of the
pre-scan's frames; Gain is the pair-weighted median over the levels. The clip becomes
mean + n*sqrt(max(g,1)*mean) (BraggIntegrationSettings::bkg_pixel_gain, CPU and GPU engines).
Only for SMV/marCCD input (ProcessConfig::counts_in_adu, set from the file format); HDF5 and CBF
keep g = 1, the identity. One log line reports the value. No new option.
Measured g: Saturn 944+ 4.7-5.2, Saturn 944 2.7, A200 0.68, ADSC ~0.3, marCCD ~0.3 - so the
ADSC/marCCD/A200 sets keep the photon clip exactly.
Checks (reflection data via gemmi and p.hkl, against the rc175 battery outputs / base build):
identical on 11if, 7qis, 8a1a, myob_x10sa, cytc_x10sa (photon counting), 4bwl, 5epe (ADSC,
marCCD) and 6cee (A200). Saturn sets, battery baseline -> this change:
3r6o I4122 R_meas 19.3% ISa 4.4 <|L|> .321 -> I41 (correct) 12.1% 8.1 .384
3mc4 R_meas 10.2 -> 9.8 %, ISa 12.1 -> 12.7, d_min 1.79 -> 1.75 A, <|L|> .360 -> .391
5uth 12.6 -> 11.8 %, ISa 10.4 -> 10.1, <|L|> .426 -> .483, offset vs |Fc|^2 0.21 -> 0.00
5vml 10.2 -> 9.6 %, ISa 11.0 -> 11.6, <|L|> .426 -> .475, offset 0.15 -> 0.05
5cc8 8.1 -> 7.9 %, ISa 10.8 -> 11.2, <|L|> .469 -> .494
3meb 14.5 -> 14.6 %, ISa 13.2 -> 13.2, <|L|> .442 -> .457
Space groups unchanged except 3r6o. Pre-scan cost: +0.5 s on a 9 Mpx ADSC sweep, within noise on
a 15 Mpx marCCD sweep, nothing on HDF5/CBF.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SVmAWnzCmRKAXVUCdc4iNi
Each merge restored corr to the ingest state but kept g_partial from the merge
before it. A frame the new merge did not fit kept the G an earlier merge had
fitted it with, and the fulls' CC1/2 weight (frame_scale_fulls) read it, so a
merge depended on which merges ran before it on the same engine. RunArm now
resets g_partial to 1 at its start, as the first merge on an ingest already had
it. That is what lets two merges run side by side on two engines and give what
they give in series.
The fits themselves never warm-started (FitPerFrameG and the device kernel both
solve each frame afresh), so the change only reaches frames that a merge leaves
unfitted after an earlier one fitted them.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SVmAWnzCmRKAXVUCdc4iNi
Where a twin hypothesis (<prefix>_twin_<SG>) was written, the model is also
validated against that merge: as merged, and as twinned by its law -
|F_twin(h)|^2 = (1-a)|F_model(h)|^2 + a|F_model(Th)|^2 from the placed, scaled
model, one overall scale refitted per a, a scanned 0..0.5 on the working set
and R-free read at it (ValidateAgainstModel's new twin_law argument; GPU
structure factors as before). The maps and the placed model are then written
from whichever merge has the lower R-free; the report gives MAPS_FROM and the
subgroup's plain and twinned R-work/R-free and fraction. The subgroup file
follows its own validation's indexing, as the answer follows its own.
The model chooses the maps only: the space group, <prefix>.mtz and R_FREE stay
the answer's. Documented in RUGNUX_REPORT.md, RUGNUX_ADVANCED.md and
CPU_DATA_ANALYSIS_DECISIONS.md (14.9); Yeates (1997) credited.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SVmAWnzCmRKAXVUCdc4iNi
Near-perfect twins of a subgroup are undecidable from intensities, and a merge
in the higher group cannot be twin-refined. Where the adopted group may have
absorbed a twin law - a twin gate refused it and was overruled, or a group above
P1 was adopted on a merge that read twin-like before the search - the same
observations are also merged in a subgroup and written beside the answer, with
the twin law and its fraction in _pdbx_reflns_twin.
The subgroup is the one a decision named (the group a refused promotion was
adopted over, the indexed lattice's group a metric re-ask promoted from), else
the index-2 subgroup the search confirmed as a finalist whose lost two-fold
agrees worst with the intensities. It is merged from the same engine, to the
answer's resolution, without writing per-frame scales back; it carries the
answer's free set (flags read off the answer's merge by the adopted group's
asymmetric unit) and is written in the subgroup's reference and standard
setting (a C 1 1 2_1 description is reduced to P 1 2_1 1). The fraction is
Yeates' H on the pairs the law relates (1/2 - median H, a lower bound).
At most one file; other qualifying subgroups are named in the report
(TWIN_SUBGROUP_* keys). The answer's files are byte-identical.
IndexTwoTwinSubgroups and EstimateTwinLawFraction are new and tested; the
point-group ledger now carries each hypothesis's representative group.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SVmAWnzCmRKAXVUCdc4iNi
Whether a twin law can exist was read off a hard-coded list of Laue classes
(4/mmm, 6/mmm, m-3m and rhombohedral -3m "holohedral", everything else
eligible). That is a property of the lattice, not of the class: a C-centred
orthorhombic or a monoclinic lattice with no special metric admits no twin law
for mmm or 2/m either, and a pseudo-merohedral one admits a law the class says
nothing about. On five pseudo-merohedral sets the report said "Twinning
INDICATED - refinement needs a twin law" where the lattice admits none.
TwinLawPossible asks gemmi's find_twin_laws (Le Page two-folds within 3 deg,
as RfreeFlags and ReindexAmbiguity already do), and AnalyzeTwinning takes the
cell to decide it; in P1 or without a cell a law stays possible, so the
pre-promotion analysis and the space-group search are unchanged. Twin-like
statistics (<|L|> below 0.44) above the false-operator bar where no law is
possible are now reported as NO_TWIN_LAW: not a twin in this group, so the
symmetry may be too high or something else narrowed the distribution.
Report-only: no reflection file changes.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SVmAWnzCmRKAXVUCdc4iNi
The commit is already re-stamped on every build (JFJochGitInfo runs
GitInfoStamp.cmake with cmake -P; configure_file rewrites GitInfo.cpp
only when the value changed). What was missing is the case with no git:
a downloaded tarball left the SHA empty, and one unpacked inside some
other repository would have reported that repository's HEAD. The stamp
now asks git only when the source directory is itself the top of a work
tree, and otherwise records commit "unknown" beside the version from
VERSION. The reflection files' git description drops its trailing date
when there is none.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SVmAWnzCmRKAXVUCdc4iNi
The unmerged observations already leave a run as the unmerged MTZ (and
the scaled fulls as the .hkl), and deposition does not come straight
from rugnux, so the _diffrn_refln block made the .cif up to ten times
larger for no consumer. The .cif is one data block again; the keys,
software rows, metadata, R_merge/R_pim/CC* and provenance stay.
ScaledFull loses the scale and frame it carried only for that block.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SVmAWnzCmRKAXVUCdc4iNi
Overloads: the integrated reflections whose signal disk held a saturated
pixel are counted on the integration outcome before the merge, over the
whole range and over the reflections coarser than 4 A, where they
concentrate (log line "Overloads:", report OVERLOADED_REFLECTIONS*). An
OVERLOADS warning fires from 0.05% of all, or 0.25% of the low-resolution
reflections, and recommends attenuation / a shorter exposure. Measured: a
strongly diffracting in-house sweep reads 0.017% / 0.42% (warned), an
ordinary sweep with a few hundred overloaded partials 0.001% / 0.012%.
Overlaps: both integration engines count the signal disks a nearer
neighbour's prediction takes pixels from (the overlap ownership the default
--overlap exclude already builds), and those left under --overlap-minpk of
their own - BraggIntegrationCounts::overlapped / overlapped_major (log line
"Overlapping spots:", report OVERLAPPED_REFLECTIONS*). An OVERLAPS warning
fires from 1% in the second and recommends a longer distance, finer slicing
or a smaller beam; on 10 battery sets it fires on the two long-axis ones
(8.3% and 13.4%), the rest read 0.0-0.44%. Summary lines for both;
documented in RUGNUX_REPORT.md.
Counters only; nothing reads them. p.mtz md5-identical to the rc175
battery on 10 sets.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SVmAWnzCmRKAXVUCdc4iNi
The merged .mtz and the unmerged .mtz carry, after the existing "From
Rugnux <version>" record, HISTORY records with the git commit and date,
the command line, the input file (absolute path) and a cheap identity of
it - size and modification time, the master file's for HDF5, nothing
hashed - and the collection date. MTZ allows 30 records of 80
characters; a longer value runs on over records starting with '+', cut
so that no record ends in a blank a reader would strip.
The mmCIF names the commit in _software.description and the command
line, input file and identity under _jfjoch_provenance (the local prefix
the other private items use; no PDBx item holds them), the first two as
text fields so any character survives.
OutputProvenance is filled once in the CLI and travels in ProcessConfig
to every writer of a reflection file. Reflection data are unchanged.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SVmAWnzCmRKAXVUCdc4iNi
The .cif now follows the PDBx/mmCIF dictionary's keys: _entry.id, the
entry_id of _cell/_symmetry/_reflns, pdbx_ordinal and pdbx_diffrn_id on
_reflns and _reflns_shell, diffrn_id on _diffrn_source/_diffrn_detector/
_diffrn_radiation, and _diffrn_radiation_wavelength.id. _software is a
loop naming Rugnux for 'data reduction' and for 'data scaling'.
The experiment as the input states it: _diffrn.ambient_temp from the
sample temperature, the source's general class (from NXmx
/entry/source/type, which the HDF5 reader now reads), facility and
beamline, the detector make/model in _diffrn_detector.type (where
.detector used to carry it) and its class in .detector where the name
settles it, and the collection date from /entry/start_time.
R_merge and R_pim are formed beside R_meas from the same per-reflection
sums (stills and rotation merges) and written with CC* overall and per
shell (_reflns.pdbx_Rmerge_I_obs/pdbx_Rpim_I_all/pdbx_CC_star and the
_reflns_shell counterparts), as report keys R_MERGE/R_PIM/CC_STAR and as
three new columns of the shell tables. R_pim following Weiss (2001),
CC* following Karplus & Diederichs (2012).
A second data block, data_sample_unmerged, carries the scaled unmerged
observations of a rotation merge as _diffrn_refln (measured index,
frame, scaled I and sigma, pdbx_scale_value) - the same records as the
.hkl, which the wwPDB accepts as scaled unmerged data. ScaledFull now
carries the scale and the frame. The unmerged MTZ stays unscaled.
Reflection data are unchanged: the merged _refln rows, the .mtz columns
and the .hkl are written as before.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SVmAWnzCmRKAXVUCdc4iNi
Structure factors, maps and the null on the GPU (with gemmi's mask shrink step), the second
validation started on a forecast beside the first, change-of-basis candidates scored at the same
time, validation work spread over the visible cards, the merge's GPU engine freed before the
validation, a CUDA error failing the validation instead of restarting it on the CPU, and no
first-validation maps where the forecast one is kept. Output files md5-identical to the CPU path's
reflection files and placed model on 8sa8, 8xtg, 9ea5, 8tyy, 7qis and myob; maps within float
tolerance of the CPU path and bit-identical run to run and at -N 8. Validation time 8sa8 24.0 ->
7.4 s, 8xtg 18.4 -> 5.0 s, 9ea5 19.1 -> 3.0 s, 8tyy 47.7 -> 3.0 s.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SVmAWnzCmRKAXVUCdc4iNi
The multi-GPU placement (replicate threads pinned with pin_gpu) and the
second-validation memory rule read RigidBodyGPUPool and get_gpu_count(),
which a build without CUDA does not have; both are now inside
JFJOCH_USE_CUDA, where the pool can exist at all. Nothing changes in a
CUDA build. Non-CUDA rugnux and jfjoch_test build; [ModelValidation] 22
cases pass there.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SVmAWnzCmRKAXVUCdc4iNi
Where the validation in the model's setting, started beside the first on
the forecast (b5980c9ba), is going to be kept, it writes the maps, the map
coefficients and the placed model on the written axes, and the first
validation's p_2fofc/p_fofc/p_anom.ccp4 and p_maps.mtz were overwritten
unread. ModelValidationSchedule::maps_superseded is asked once the
verdict, the setting and the indexing are decided, before any map is
made; the first validation then makes none (the map coefficients and the
MTZ are not built, the three map transforms not run, nothing written).
The answer is the same rule that keeps the forecast validation
afterwards (forecast_kept, now one lambda for both). The kept validation
also brings back the reflections it relabelled, free set included, so
relabel_output does not reindex and redraw them a second time. Where the
forecast is discarded or was never started, everything runs as before,
and modelpar's delete-before-rewrite stays for those cases. If the kept
validation then fails, a warning says no maps were written.
Log lines that disappear (first validation only, kept-forecast runs):
"mean 2mFo-DFc density at atom centres = ...", "anomalous difference map
from ... Bijvoet pairs; strongest density ...", "wrote p_2fofc.ccp4, ...";
in their place: "no maps made here - the validation in the model's
setting writes them". (The "anomalous density ... inverted" warning would
also go; it cannot be raised where a forecast is kept.)
Verified on 8sa8, 8xtg, 9ea5 (forecast kept), 8tyy, 7qis, myob_x10sa (no
forecast): p.mtz, p.hkl, p.cif, the three maps, p_maps.mtz and
p_model.cif/pdb md5-identical to the merge commit, repeated and at -N 8;
the discard path (8sa8 with a rotated model) identical files and log.
Time saved is small with the maps already on the GPU: 8sa8 7.6 -> 7.4 s,
8xtg 5.1 -> 5.0 s, 9ea5 within noise.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SVmAWnzCmRKAXVUCdc4iNi
Both sides kept: modelpar's parallel basis scoring, the second validation
started on a forecast beside the first (write gate, schedule parameter),
the multi-GPU placement and delete-before-rewrite; gpusf's GPU structure
factors, maps and null engine, failure-instead-of-restart, and the merge
engine released before the validation (now just before modelpar's
ValidateAgainstModel call, after the forecast lambda is set up).
Placement: each validation's structure-factor engines (d_min and the
null's) are made on the card of the thread that runs it - the main
thread's for the first validation, card 1 % count for the speculative
second, which pins itself there - so two validations on two cards use
both, as the rigid-body pools do.
One memory rule for both, per card, from total memory, up front: a
validation plans at most half of its card - its structure-factor engines
a quarter together (was half for the d_min engine alone), its rigid-body
engines a quarter (RigidBodyGPUPool) - and the second validation runs
beside the first only where twice the first's plan (rigid-body planned
bytes + the d_min engine, twice it where a null is coming, the null's
engine being no larger) fits half of all cards' memory together (was:
twice the rigid-body plan within a quarter). The card's total is read
once when the engine is made; a CUDA error there fails the validation
like any other.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SVmAWnzCmRKAXVUCdc4iNi
The null - nine random placements of the model, and the real model's side
of the comparison - is scored to a coarser limit (3.5 A, or where the free
set allows), and those structure factors still ran on the CPU, one model
at a time on each replicate thread. Where the d_min structure factors are
on the GPU, a second ModelStructureFactorsGPU engine at the null's limit
is now made, on the same card, before any replicate starts; every
replicate and the real side go through it, so all of them are computed
the same way. It is decided like the first engine, from the card's total
memory: the two together must stay within half of it, else the null stays
on the CPU (logged). The replicates' threads may sit on other cards
(modelpar's 8d6e9d3a4); each call works on the engine's own device. Where
the null is scored to d_min it shares the d_min engine, as before.
Measured (validation time, before -> after; CPU path for reference):
P3_2 1.55 A: 11.2 -> 9.0 s (CPU 17.7)
P2_1 oblique: 5.1 -> 4.5 s (CPU 7.9)
C2 1.11 A: 12.9 -> 13.2 s (CPU 23.3) - its null time is in the
scale fits, which stay on the CPU
p.mtz md5-identical in all four variants per set (before, after, CPU,
-N 8); maps and map MTZ bit-identical after vs -N 8; the null's sigma
moves only in the second decimal (+113.39 -> +113.38, +86.13 -> +86.14).
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SVmAWnzCmRKAXVUCdc4iNi
The image-loop workers are kept on the CPUs of their card's NUMA node (pin_gpu), but the two
threads that drive the merging tail were not: the quality guard took its card with set_gpu(1),
and the main engine runs on the pipeline's own thread, which goes on to everything else. On a
multi-socket server their pageable copies to the card went through a remote socket.
RotationScaleMerge now holds a ScopedGpuPin (new, CUDAWrapper) for each IngestHost,
IngestDevice and Run: pin_gpu(gpu_device) for the call, and afterwards the thread's previous
device and CPU mask back (GetThreadCpus/SetThreadCpus, new in ThreadAffinity). So the binding
covers whichever thread drives an engine, and does not stay on the pipeline thread. The threads
the engine starts itself (ComputeAsuGroups, Combine, FitErrorModel, ApplyFrenchWilson use
std::async) call RestoreThreadAffinity first, as the pool's workers do, so they are not
squeezed onto one node. The guard thread, which does nothing but the guard's merge, uses
pin_gpu(1) instead of set_gpu(1). Pool workers are untouched: they restore the start mask
themselves; the new test checks both that and that a pinned caller keeps its own mask.
No flag, no environment variable. On one NUMA node (this workstation) only the device setting
happens, so nothing can move. Measured on the 5080 workstation (one card, one node), base =
rc175-next 41fab6156, --model dropped, under the GPU lock: p.mtz md5 identical on myob_x10sa,
cytc_x10sa, thau_x10sa_16keV, 8a1a, 11if, 7qis, 6h5t, kdp_x10sa_20keV at default -N (2 runs),
-N 8 and CUDA_VISIBLE_DEVICES=0. Tail ("scale, merge and symmetry") unchanged within noise,
e.g. 8a1a 30.06 -> 29.37 s (base runs 29.75/30.36), cytc 5.33 -> 5.35 s. The gain is for
multi-socket boxes (T4 server: guard copies 1.7-2.1 vs 3.5-4.3 GB/s) and is not measured here.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SVmAWnzCmRKAXVUCdc4iNi
A CUDA error on the validation's GPU path (rigid body, structure factors,
maps) used to start the whole validation again on the CPU - a migration
part way through, which makes how long a run takes, and in principle what
its rigid bodies converge to, depend on a device fault. It now ends the
validation with ok = false and a logged reason ("the GPU failed during the
validation (...)"), which the run reports as MODEL_NOT_VALIDATED. A
validation that did not finish decides nothing, so the reflection files
are those of a run without a model. Where there is no GPU at all, or the
up-front rule sends the work to the CPU, nothing changes.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SVmAWnzCmRKAXVUCdc4iNi
ModelStructureFactorsGPU computes what compute_model_factors() and
map_from_coefficients() compute on the CPU - F_calc from the model's
density (IT92, Refmac-compatible blur, unblurred as prepare_asu_data()
does) and F_mask from the Refmac bulk-solvent mask, both on the
reflections prepare_asu_data(d_min) lists, in its order; and a map from
ASU coefficients on the grid get_size_for_hkl(coef, 0, 3.0) sizes - on a
device. Made once per cell, group, resolution and model, then evaluated
as often as the coordinates change, so refinement or MR can call it in a
loop. The device is an explicit parameter; every call leaves the calling
thread's current device as it found it.
Pieces:
- ModelDensityGPU: the rigid body's deterministic brick gather, moved
out of RigidBodyGPU.cu into a component of its own (ModelMaskGPU's
pattern); the rigid body uses it unchanged. MAX_BRICKS_PER_AXIS 8 ->
16, so fine grids with high-B atoms (lysozyme at 1.2 A, a 0.9 A P1
cell) are no longer refused; existing zones are gridded identically.
- One copy of the content is gridded and the symmetry composed in
reciprocal space (SymmetryComposition), operators applied on the fly;
the mask is ModelMaskGPU (every image of every atom, islands, shrink).
- Maps: gemmi's get_f_phi_on_grid() in ZYX order on the host (the
coefficients written are the same), in-place cuFFT c2r, transposed back
to XYZ on the device. One map at a time, in the engine's buffers.
Decided once, up front, per card, from its TOTAL memory: the engine's
bytes (16 N + cuFFT work + reflections, N the larger of the structure-
factor and map grids) must be at most half the card - the rigid body's
engines take at most a quarter beside it. Otherwise, or where the gather
cannot grid the cell, the CPU path runs, logged with needed vs total.
Anything to a resolution other than d_min (the null's 3.5 A fits) stays
on the CPU, so all replicates and the real model's side of the null are
computed the same way. A CUDA failure takes the existing path: the
validation restarts on the CPU.
Measured, model validation total per run (CPU path -> GPU), 16 GB card:
F432 215 A cubic, 1.30 A, 500^3 grid: 47.7 -> 15.6 s (two validations;
14.3 -> 2.4 and 33.4 -> 13.2, the rest of the second is writing the
three 0.5 GB maps); F_calc + F_mask 5.7 s -> 0.05 s
C2 1.11 A: 23.9 -> 13.6 s; P3_2 1.55 A: 18.2 -> 10.2 s;
P2_1 1.25 A: 13.4 -> 6.5 s; F4_132 328 A: 13.0 -> 5.5 s;
P6_5: 8.8 -> 4.2 s; P4_3 0.97 A: 4.6 -> 2.5 s; P1 0.92 A: 4.2 -> 2.2 s;
small P1: 3.2 -> 1.3 s; P6_1: 8.8 -> 6.2 s; lysozyme: 1.8 -> 1.4 s.
p.mtz md5-identical on all 13 sets. Against the CPU path: FC within
1e-4 of mean |F|, phases of the strong half within 0.003 deg, maps within
1e-4 (2mFo-DFc) and 7e-4 (mFo-DFc) of their rms; every logged R, CC,
FOM, k_sol and anomalous site list identical at the printed precision,
except where a rigid-body commit sat on an exact R-free tie (0.2155 ->
0.2155) and fell the other way (R-work 0.2127 vs 0.2129). The GPU result
is bit-identical run to run and with -N 8 (maps, map MTZ, placed model).
Peak device memory of the engine: 2.5 GB at 500^3 (process total peaked
at 14.4 GB with what the merge still holds).
Tests: ModelStructureFactorsGPU_MatchesCPU (five groups, 3.5 and 1.5 A:
same reflections, F_calc <= 1e-5 of mean |F|, F_mask 2e-7 rms, repeat
bit-identical), ModelStructureFactorsGPU_MapMatchesCPU (<= 5e-6 of rms).
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SVmAWnzCmRKAXVUCdc4iNi
The GPU bulk-solvent mask had no shrink step (SolventMasker::shrink(),
set_margin_around() with Refmac's r_shrink = 0.8 A), so every rigid-body
zone whose grid has an offset within 0.8 A was sent to the CPU. That is
not only fine grids: in an oblique setting (P2_1, beta ~141 deg) the
3.5 A zone's lattice-plane spacing is 0.69 A, and on such a set the real
fit and all nine null replicates ran their rigid bodies on the CPU.
The shrink is now two passes over the grid: mark the solvent points with
a macromolecule point among gemmi's near offsets, then turn into solvent
every macromolecule point with such an edge point at any stencil offset
(near or far, gemmi's split at the coarsest grid step). That is gemmi's
rule in both of its branches, and the stencil is built on the host in
double exactly as gemmi builds it. ModelMaskGPU::ShrinkIsNoOp() and
RigidBodyGPUEngine::MaskSupports() are gone; the rigid body no longer
refuses such zones.
Verified (ModelMaskGPU_ShrinkMatchesGemmi): the shrink alone on gemmi's
post-island mask is bit-identical, and the whole GPU mask equals gemmi's
put_mask_on_grid() bit for bit (0 differing points) on the five test
groups at 1.5 A and an oblique P2_1 cell at 3.5 A; repeats are identical.
Measured on the oblique-setting set of the open arm (P2_1, 14k atoms,
1.66 A): model validation 17.2 s -> 5.2 s with the GPU structure factors
of the next commit in place (the first validation's ten rigid bodies,
~12 s on the CPU, now take well under a second). p.mtz md5-identical;
the null moves from +73.1 to +86.1 sigma (the replicates are refined on
the GPU, which agrees with the CPU to rounding), verdict unchanged.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SVmAWnzCmRKAXVUCdc4iNi
The validation in the model's setting writes its three maps and the map
MTZ over the ones the first validation wrote seconds before, and a
two-pass run writes over the first pass's. XFS (like ext4) flushes a file
that was truncated and rewritten when it is closed, so each overwrite
forced ~155 MB out to the disk on the spot. Measured on /data (XFS on a
hard disk): three 155 MB files rewritten 3.16 s, written fresh 0.62 s,
removed and written again 0.66 s. The contents are the same bytes either
way.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SVmAWnzCmRKAXVUCdc4iNi
The rigid-body pool put all of its engines on the calling thread's current
card, so a validation used one GPU whatever the machine had. Now, by fixed
rules decided up front and never by momentary free memory:
- RigidBodyGPUPool::Create puts engine i on card (d + i) % count, d being
the calling thread's card; the pool restores that card afterwards
(an engine's constructor sets its own) and an engine is released on
its own card.
- The threads that run the null's replicates are pinned with pin_gpu
(which also binds them to the card's NUMA node where that is enabled),
replicate thread t to card (d + 1 + t) % count, and Acquire() hands a
thread an idle engine on its own card where there is one, any other
otherwise. With one card this is exactly the previous back-of-the-list
choice.
- The validation started on the forecast runs on card 1 % count, so with
two cards or more it is on the other card from the first; the up-front
memory rule becomes: twice the planned engine bytes within a quarter of
the cards' total memory taken together.
The engines' kernels are deterministic (no floating-point atomics) and an
engine's result does not depend on which engine it is, so on cards of one
model the numbers are those of one card; what changes is only where the
work runs. On this one-card workstation the multi-card path cannot be
exercised: md5 of p.mtz and the validation outputs are identical to the
base, and the card-count arithmetic was checked by reading only.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SVmAWnzCmRKAXVUCdc4iNi
Where a model that fits is written on other axes than the data, the files
take its setting and the validation is made a second time on the
relabelled data. That second validation depends on the first only through
the setting and the indexing the first settles, and both are known long
before the first has finished: the change of basis right after the frame
scoring, the indexing the probe prefers before the null. So ValidateAgainstModel
now reports them (ModelFrameForecast, through ModelValidationSchedule::
on_forecast), and the second validation is started there, on the
relabelling AdoptModelFrame and relabel_output would make, applied to a
copy of the merge - beside the first one's null, real fit and maps.
It is kept only where the first decides exactly what was forecast (the
model fits, same change of basis, same indexing; a model asserting the
other enantiomorph is not forecast, as the label is decided last, on the
anomalous map). Until then its log is held (Logger::Buffered, replayed
where the serial run logged it) and its files wait on a gate
(ModelValidationSchedule::write_gate) placed before the first map is
written; otherwise it is released with false and returns unwritten, and
the serial validation runs as before.
GPU memory: two validations at once take twice the rigid-body engines.
The parallel start is decided up front from sizes, never from what is
free: allowed where twice what the first pool asked for (bytes per engine
times the engines wanted) fits a quarter of the card's TOTAL memory, the
share one validation may take. Threads: both validations submit to the
one ParallelFor pool from threads outside it (the second runs on a
std::async thread, as the null's replicates do), so no pass runs inline
on a pool worker and the pool's size bounds the workers.
Measured on the loaded 16-core workstation, TIMING model validation:
8sa8 30.6 -> 19.7 s, 8xtg 19.9 -> 13.9 s, 9ea5 22.1 -> 16.4 s (this and
the previous commit together). p.mtz, p.hkl, p.cif, the three maps,
p_maps.mtz and p_model.cif/pdb md5-identical to the base on 8sa8, 8xtg,
9ea5, 7qis and myob_x10sa; the validation's log lines identical as a set
(the null's replicate lines were already in completion order).
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SVmAWnzCmRKAXVUCdc4iNi
Each candidate setting of the model was scored one after another on the
coarse shell (a density, an FFT and an isotropic scale fit, 0.2-0.5 s
each), and a run with a model in another setting scores them in both of
its validations. They share nothing but what they read, so they now run
on ParallelFor, each on its own copy of the model, and are read back in
their own order: the log lines, the ranking and the tie-break (first of
equal R wins) are those of the serial loop.
Measured (16-core workstation, loaded): 8sa8 3 candidates 0.64 s ->
0.22 s; 9ea5 3 candidates 1.47 s -> 0.50 s; 8xtg 1.45 s -> 0.40 s.
p.mtz, the maps, the map MTZ and the placed model md5-identical.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SVmAWnzCmRKAXVUCdc4iNi
This reverts commit a825fb6cb.
The per-frame cell-scale retry restored the space group of its one
target (a P2 set whose cell grows with dose) but not its data: R_meas
went 26 -> 37 % and d_min 1.54 -> 1.62 A against the pre-part-B run. It
patches the early, low-dose cell of pass 2 frame by frame with a
+-2 % range, 0.1 % step and sqrt(n) margin chosen on that set, and runs
inside AnalyzeIndexing on every set for one difficult one. A late cell
belongs to rc176's far-end check of the early decisions instead.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SVmAWnzCmRKAXVUCdc4iNi