Commit Graph
1311 Commits
Author SHA1 Message Date
leonarski_f f4f9157cc0 Merge branch 'gpu-spot-histclip' into rc173 (GPU adaptive spot finder: sigma clips from a per-ring histogram) 2026-09-27 21:39:12 +02:00
leonarski_fandClaude Opus 5.5 ccb38f469b GPU adaptive spot finder: sigma clips from a histogram, one image pass instead of three
The GPU finder read the whole image (int32 values + pixel_to_bin) three times per frame for its
per-ring background: a plain pass and two 3-sigma clip passes. As the CPU finder already does
(87cfc879b), the plain pass now also records every valid pixel in a per-ring value histogram
(HIST_VALUES = 1024 bins per ring, global memory, integer atomics aggregated per warp by
(ring, value)), and pixels outside [0, 1024) on an overflow list. Each clip pass is then taken from
the histogram and the list (clip_rings_from_hist, one warp per ring) - integer sums of the same
values, so the ring statistics, thresholds and spots are exactly the image passes'. If the list
overflows its capacity (npix / 32 entries), the clip passes read the image as before; the choice is
made on the device, so there is no extra host sync.

The clip bounds are now written as explicit fused multiply-adds (clip_keep). ptxas already fused
`mean -/+ clip_k * sigma` into FFMA in the image-pass kernel (checked in the sm_120 SASS); spelling it
out keeps the histogram clip and the image clip on exactly the same line.

Memory: nbins * 4 KiB histogram + 8 B * npix / 32 overflow list per engine, only on the
shared-memory path (at most ~1750 rings, so <= 7 MB of histogram); the list is 4.5 MB for a 16M EIGER2.

Exactness: a temporary side-by-side check (histogram clips vs forced image clips on every frame)
gave 0 differing ring sums/counts on myob, cytc, lyso and sparse (overflow list at most 308
entries); p.hkl, p.mtz, p_P1.mtz and p_unmerged.mtz are byte-identical to rc173 on all four.
New test AdaptiveSpotFinderGPU_HistogramClipMatchesImagePasses covers rings above/straddling the
histogram range and the overflow fallback.

Timing (RTX 5080, nsys, GPU locked; myob 16M EIGER2): reduce_rings 230 us x 3 launches/frame
(3.20 s) -> plain pass 308 us + 2 x 4 us early-exit launches + 2 x 12 us clip_rings_from_hist
(1.58 s total); summed kernel time 11.28 -> 9.18 s; image loops 5.89/5.09 -> 5.49/4.69 s. cytc:
kernels 12.86 -> 10.75 s. Small detectors gain less (lyso 0.40 -> 0.32 s, sparse 1.19 -> 1.01 s of
ring reduction), as the histogram costs relatively more in the plain pass there.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D1G8gJVAy6gp1K5Dz3NE5C
2026-09-27 21:38:45 +02:00
leonarski_f 03309032bd Merge branch 'supercell-rerun-starved' into rc173 (late passes re-run when their background ring is starved) 2026-09-27 21:27:55 +02:00
leonarski_fandClaude Opus 5.5 0aeb7edb9a rugnux: re-run the supercell-guard and header-geometry passes at the fixed radius when starved
The background-starvation check (the widened integration radius left too many
reflections without a background ring) followed only the canonical pass and the
geometry walk's write pass. The two later re-runs - the supercell guard's pass
with pass-1's lattice forced and the header-geometry redo - ran at whatever
radius was in hand, which is still the widened one whenever the canonical pass
was not starved.

A starved supercell-guard re-run marks itself superseded (quality_guard_pass1_
is still set), writes no report and no files, and nothing re-ran it: the run
exited 0 with the files of the spurious-supercell pass it was meant to replace
and a report from the re-run. A starved header-geometry redo was not superseded
but shipped the starved intensities.

The starvation check is now a lambda asked after each of the three; after the
first re-run the radius is the fixed one and it cannot fire again. The
supercell re-run keeps pass-1's lattice forced through its own starvation
re-run. Both late passes set rerun_if_starved_, so a starved one stops after
integration as the canonical pass does.

Demonstrated on myob with temporary switches forcing the supercell guard and a
starved forced pass (removed before commit): before, PASS 3 of 3 superseded and
the files came from the supercell pass; after, PASS 4 of 4 at r1=4 with the
forced lattice. Normal runs byte-identical (myob and a starved open-arm set:
p.hkl p.mtz p_P1.mtz p_unmerged.mtz). No battery run of 4 checked ever fired
the supercell guard.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D1G8gJVAy6gp1K5Dz3NE5C
2026-09-27 21:27:26 +02:00
leonarski_f 979c0ad3c0 Merge branch 'postrefine-threads' into rc173 (post-refinement: held fit beside the free fit, folds stepped together) 2026-09-27 21:23:01 +02:00
leonarski_fandClaude Opus 5.5 7e83da0722 Post-refine: run the held-distance fit beside the free one, step the six rotation-scale fits together
Exact speed-ups of the per-pass geometry post-refinement; outputs are byte-identical.

Joint fit: the distance-held solve starts from nominal and reads nothing the free solve
produces, so it now runs on its own thread (std::async) beside the free solve and its
covariance, and is used only where the free fit converged, as before. Each solve has its own
copy of the tilt block and one shared, non-owned CauchyLoss instead of one per residual. The
covariance's Jacobian evaluation is threaded (Covariance::Options::num_threads): each residual
block writes its own rows, so the values do not depend on the thread count.

Rotation scale: the all-data fit and the five leave-a-fifth-out folds differ only in which of
the five per-fifth sums they add, so their golden-section searches are stepped together, one
event sweep per step for all of them, and fits whose brackets still coincide share the point.
Each fit takes the same steps on the same sums as before: cost_grid's per-(k, fifth) sums are
bit-identical whatever k it is handed alongside. About 23 event sweeps instead of ~140.

Measured per pass (rc173 baseline -> this, GPU build, box shared with other jobs; post-refine
log timestamps, joint fit / rotation-scale fit):
  lyso   0.36 / 0.28 s -> 0.28 / 0.19 s
  cytc   0.42 / 0.35 s -> 0.36 / 0.22 s
  myob   0.29 / 0.04 s -> 0.23 / 0.04 s
  sparse 0.20 / 0.06 s -> 0.14 / 0.07 s
p.hkl, p.mtz, p_P1.mtz, p_unmerged.mtz byte-identical to the rc173 outputs on GPU myob, cytc,
lyso, sparse and CPU-only cytc, and the logged post-refinement lines (geometry, residuals, k)
identical.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D1G8gJVAy6gp1K5Dz3NE5C
2026-09-27 21:22:37 +02:00
leonarski_f bac5cf470c Merge branch 'arm64-bitshuffle' into rc173 (aarch64 bitshuffle decode: no per-block malloc, NEON port of hperf) 2026-09-27 21:22:26 +02:00
leonarski_f 5ed07eeb11 Merge branch 'tail-p1-model' into rc173 (P1 cross-check merge beside model validation) 2026-09-27 21:22:12 +02:00
leonarski_fandClaude Opus 5.5 e886189829 bitshuffle: NEON port of the hperf block decoder, used on aarch64 for 1/2/4-byte elements
bitshuffle_hperf's vector code is x86-only, so aarch64 decoded with the classic NEON path, whose
bit stage emulates x86 movemask (8 and + 8 cmeq + 2 x 9-instruction pairwise-add reductions per
16 bytes, then eight scattered 16-bit stores): ~29 instructions per 8x8 bit block plus a byte
transpose pass with scattered 8-byte stores, and a scalar path for 1-byte elements.

bitshuffle_neon.c follows hperf's structure (per byte plane bit-untranspose, then byte interleave)
but does the 8x8 bit transpose NEON-natively: the eight rows stay in eight registers, bits are
exchanged between them with shift + bit-select in three levels, and one zip level plus two st4
write the 128 contiguous output bytes. GCC 13 -O3 gives ~75 instructions per 16 blocks (~4.7 per
block) in the bit stage; the byte interleave is one st2/st4 per 16 elements. Other element sizes
keep the classic stages with the caller's scratch. x86 is unchanged (the file compiles empty).

Byte-identical under qemu-aarch64 (GCC 13, -O3 and -O0) to the classic decoder, hperf and the
x86 hperf/AVX2 path: element sizes 1/2/3/4/8, every block of 8..1040 elements plus 1536..16384,
seven data patterns, and real EIGER2/PILATUS4 bslz4 chunks (whose decoded image hash also
matches an hdf5plugin read). Same licence and component as bitshuffle_hperf, so licenses/ and
THIRD_PARTY_NOTICES.md are unchanged.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D1G8gJVAy6gp1K5Dz3NE5C
2026-09-27 21:21:55 +02:00
leonarski_fandClaude Opus 5.5 b0b217a795 Make the P1 cross-check merge while the model validation runs
With --model, model validation and the P1 cross-check merge ran one after the other at the end of
the run. Neither reads anything the other writes, so the first ValidateAgainstModel now runs on its
own thread while the main thread makes the P1 merge (rsm->Run in P1); the merge's result is kept and
reported at the cross-check, where it used to be made, through a new `already_run` argument of
scale_and_merge.

Why it is exact: the validation reads the adopted merge, the cell, the group and the wavelength,
all captured before the thread starts, and nothing else. The scaling engine merges its own copy of
the observations under the group set on the experiment (P1 for the merge, restored after, as at the
cross-check), holds its own copy of the cell, reads the outcomes' lattices and mosaicities only at
Ingest, and writes back per-image G, CC and mosaicity - which nothing between the validation and
the cross-check reads (the unmerged MTZ build reads the mosaicity but replaces it after the merge,
as before). Every relabelling the validation decides reaches the P1 merge afterwards through
merge_to_written, exactly as when it was made later. The result fields scale_and_merge sets are
still set at the cross-check, so the report and the finalist ledger read the same values. Without
--model, or without a P1 cross-check, nothing changes.

The validation logs into a Logger::Buffered() and the lines are replayed as one block when it is
done, so the two do not interleave; the P1 merge's log lines now come before the validation's.

Checked against rc173 (rugnux_r4int_77710e1ce, output-identical to it) on 8tyy, 9hnc, 8sa8, 6oel,
7ph1 with --model and on myob: p.hkl, p.mtz, p.cif, p_P1.mtz, p_unmerged.mtz, the maps and the
placed model are byte-identical, and so is p_report.txt apart from date/command/timing lines. A
last-digit flip of one image's bkg in p_plot.txt (8sa8, one of two runs) is image-loop
nondeterminism the baseline shows too (8tyy, base vs base).

Tail wall (sweep-quality line to unmerged MTZ written), paired back-to-back runs on a loaded box:
8tyy 110.3 -> 87.3 s, 6oel 77.8 -> 63.3 s, 8sa8 171.4 -> 154.6 s; unpaired 9hnc 368.8 -> 337.0 s,
7ph1 21.1 -> 15.7 s. The saving is the P1 merge's time; the validation still sets the floor.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D1G8gJVAy6gp1K5Dz3NE5C
2026-09-27 21:21:42 +02:00
leonarski_fandClaude Opus 5.5 f8e5d0c3fa BitShuffleBlock: the aarch64 decode uses the caller's scratch instead of a malloc per block
On aarch64 JFJochBitUnshuffleBlock went through bshuf_untrans_bit_elem_NEON, which mallocs and
frees a block-sized temporary for every block, while the caller (JFJochDecompressHperfPtr) already
owns an unused scratch buffer of exactly that size. Call the two NEON stages it wraps
(bshuf_trans_byte_bitrow_NEON, bshuf_shuffle_bit_eightelem_NEON) directly with that scratch.
Same code, same output; x86 is untouched.

Checked byte-identical under qemu-aarch64 against the classic entry point, hperf and the x86
decoders: element sizes 1/2/3/4/8, every block length 8..1040 elements plus 1536..16384, seven
data patterns, and real EIGER2/PILATUS4 bslz4 chunks.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D1G8gJVAy6gp1K5Dz3NE5C
2026-09-27 21:08:41 +02:00
leonarski_f 59ea3e7348 Merge branch 'starved-skip-merge' into rc173 (a starved pass skips its merge once it is known to be re-run) 2026-09-27 20:36:54 +02:00
leonarski_fandClaude Opus 5.5 e7af8b0df5 rugnux: a pass re-run for a starved background ring stops after integration
The canonical pass at the widened integration radius (and the walk's
write pass) is thrown away when the integrator finds more than
BKG_STARVED_MAX_FRACTION of the reflections without a background ring,
and RunAllPasses re-runs it at the fixed radius. The pass knew this at
integration but still scaled, merged, searched the space group and
relabelled before marking itself superseded.

Now RunAllPasses flags the two passes the starvation check can replace
(rerun_if_starved_), and such a pass returns at the same point a probe
pass does. Readers audited: the geometry walk reads only post_refine
and validation_evidence (both in hand there); RunAllPasses reads only
bkg_starved_fraction_ (set at integration) before replacing pass2; the
space group the search leaves on experiment_ is reset at the top of
every RunPipeline; the speculative walk probe is started before the
exit point. The supercell-guard and header-geometry re-runs are not
flagged, so they merge as before. Skipped when a _process.h5 writer is
open.

Measured on 5 starved open-arm sets (8agq, 8sqq, 9h0q, 6moj, 7qij, no
--model) against rc173 and the r4-integration binary: p.hkl, p.mtz,
p_P1.mtz, p_unmerged.mtz and p.cif byte-identical on all five, and on
myob (not starved). Integration-to-re-run-decision time summed over the
five: 82.8 s -> 19.0 s (8sqq 21.2 -> 3.9 s, 6moj 19.6 -> 5.3 s);
wall totals were dominated by GPU-lock contention on a shared box.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D1G8gJVAy6gp1K5Dz3NE5C
2026-09-27 20:36:24 +02:00
leonarski_f e3093af60e Merge branch 'dcc-incremental' into rc173 (delta-CC1/2 scan re-measures only after a removal)
Build Packages / Create release (push) Successful in 17s
Build Packages / build:viewer:macos-arm64:nocuda (push) Successful in 3m7s
Build Packages / build:rugnux:macos-arm64:nocuda (push) Successful in 2m22s
Build Packages / build:rugnux:linux-aarch64:cuda (push) Successful in 8m38s
Build Packages / build:rugnux:linux-x86_64:cuda (push) Successful in 8m53s
Build Packages / build:viewer:linux-x86_64:nocuda (push) Successful in 10m0s
Build Packages / build:viewer:linux-x86_64:cuda (push) Successful in 12m13s
Build Packages / build:jfjoch:rocky8:nocuda (push) Successful in 14m33s
Build Packages / build:viewer:windows-x86_64:nocuda (push) Successful in 17m52s
Build Packages / build:jfjoch:rocky9:nocuda (push) Successful in 16m26s
Build Packages / build:viewer:windows-x86_64:cuda (push) Successful in 20m31s
Build Packages / HDF5 consumer tests (DIALS, XDS) (push) Successful in 23m36s
Build Packages / build:jfjoch:ubuntu2204:nocuda (push) Successful in 18m15s
Build Packages / build:jfjoch:ubuntu2404:nocuda (push) Successful in 19m22s
Build Packages / Generate python client (push) Successful in 55s
Build Packages / build:rugnux:windows-x86_64:cuda (push) Successful in 11m29s
Build Packages / build:jfjoch:rocky8:cuda-sls9 (push) Successful in 20m7s
Build Packages / Build documentation (push) Successful in 1m25s
Build Packages / build:jfjoch:rocky9:cuda-sls9 (push) Successful in 19m48s
Build Packages / build:jfjoch:rocky8:cuda (push) Successful in 17m31s
Build Packages / build:jfjoch:rocky9:cuda (push) Successful in 16m31s
Build Packages / build:jfjoch:ubuntu2204:cuda (push) Successful in 15m10s
Build Packages / build:jfjoch:ubuntu2404:cuda (push) Successful in 12m22s
Build Packages / Unit tests (push) Successful in 1h17m18s
2026-09-27 20:13:42 +02:00
leonarski_fandClaude Opus 5.5 1fba1a19cd delta-CC1/2: measure each candidate once per reference, share prefixes, stream frame-ordered data
MeasureBatchDeltaCCHalf re-formed the reference and re-measured every open
candidate on every round of its removal loop - including the rounds where the
picked candidate was only marked spent (not corroborated / no longer
convicted), which change nothing. On a 7200-frame sweep that is most of its
cost. All changes below keep every sum in its original order, so every
delta-CC1/2 is bit-identical:

 - candidates are re-measured only after frames are actually removed; a
   spent candidate leaves the others' measurements standing. The curve is
   read off the first round of candidates (the width-1 ones are the batches),
   and the redundant build_totals after the loop is gone.
 - measure_all measures ranges that start on the same frame as one chain,
   shortest first, carrying on the per-reflection sums - the same additions
   in the same order, but shared frames are walked once.
 - the usable observations are laid out once in frame order as
   {group, I, info}, and the per-reflection sums are one struct per
   reflection, so a measurement streams contiguous memory instead of three
   indirect loads and four arrays per observation.
 - locate measures the (up to) four edge moves of a step side by side and
   takes them in the original order; the candidate's own measurement and the
   conviction's are reused rather than repeated.

Verified with a temporary in-process check that re-ran every measurement
through the old code (sparse 324+825, cytc 68+68 measurements, all
bit-identical), and p.hkl/p.mtz/p_P1.mtz/p_unmerged.mtz plus the logged
delta-CC1/2 lines byte-identical to rc173 on myob/cytc/lyso (GPU), cytc and
sparse (CPU, sparse removes 288 and 72 frames) and 8a1a (vs the r4int
baseline binary).

Measured (perf, loaded box): 8a1a delta-CC1/2 20.1+7.4 s -> 2.2+1.7 s wall
(123 -> 13 CPU-s), run 129.9 -> 107.1 s; lyso (CPU build) 0.76+0.30 ->
0.16+0.11 s; cytc (CPU build) 0.15+0.25 -> 0.10+0.16 s.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D1G8gJVAy6gp1K5Dz3NE5C
2026-09-27 20:13:10 +02:00
leonarski_fandClaude Opus 5.5 420750e0aa Reader: remove the background read-ahead
Build Packages / Create release (push) Successful in 21s
Build Packages / build:viewer:macos-arm64:nocuda (push) Successful in 3m1s
Build Packages / build:rugnux:macos-arm64:nocuda (push) Successful in 2m18s
Build Packages / build:rugnux:linux-aarch64:cuda (push) Successful in 8m34s
Build Packages / build:rugnux:linux-x86_64:cuda (push) Successful in 9m22s
Build Packages / build:viewer:linux-x86_64:nocuda (push) Successful in 10m35s
Build Packages / build:viewer:linux-x86_64:cuda (push) Successful in 11m40s
Build Packages / build:jfjoch:rocky8:nocuda (push) Successful in 16m1s
Build Packages / build:jfjoch:rocky9:nocuda (push) Successful in 16m31s
Build Packages / build:viewer:windows-x86_64:nocuda (push) Successful in 17m47s
Build Packages / build:viewer:windows-x86_64:cuda (push) Successful in 20m21s
Build Packages / HDF5 consumer tests (DIALS, XDS) (push) Successful in 24m42s
Build Packages / build:rugnux:windows-x86_64:cuda (push) Successful in 11m12s
Build Packages / build:jfjoch:ubuntu2204:nocuda (push) Successful in 20m31s
Build Packages / build:jfjoch:ubuntu2404:nocuda (push) Successful in 20m10s
Build Packages / Generate python client (push) Successful in 35s
Build Packages / build:jfjoch:rocky8:cuda-sls9 (push) Successful in 20m59s
Build Packages / Build documentation (push) Successful in 1m27s
Build Packages / build:jfjoch:rocky9:cuda-sls9 (push) Successful in 20m49s
Build Packages / build:jfjoch:rocky8:cuda (push) Successful in 18m10s
Build Packages / build:jfjoch:rocky9:cuda (push) Successful in 18m20s
Build Packages / build:jfjoch:ubuntu2204:cuda (push) Successful in 14m31s
Build Packages / build:jfjoch:ubuntu2404:cuda (push) Successful in 11m3s
Build Packages / Unit tests (push) Successful in 1h20m31s
rugnux will mostly run on GPFS/Lustre-type storage. GPFS caches in its own
fixed-size pagepool, often smaller than a dataset, so streaming the files
ahead of the consumer can evict data before it is used; and choosing the
behaviour by the underlying filesystem is not wanted. This removes the
ReadAhead streamer of aa3fa6b9c: reader/ReadAhead.{h,cpp},
JFJochReader::StartReadAhead/NoteImageRead, the DataFiles() lists that only
it used (HDF5 reader, HDF5ImageSource, HDF5ImageLocator, CBF/marCCD/SMV),
and its start in rugnux_cli.cpp.

Kept from the same commit: the in-order CBF/marCCD/SMV header scan
(sweep::ForEachInOrder) and the 16 kB -> 256 kB CBF header probe, with its
test. They only change the order and size of reads the program makes
anyway, hold no memory and read less (cold 2400-frame CBF header scan
18 s -> 6 s on the measured HDD).

Output unchanged: myob p.hkl, p.mtz, p_P1.mtz and p_unmerged.mtz
byte-identical to the rc173 reference (17.1 s wall warm, 3.8 GB peak RSS).
Reader tests: [HDF5] 108 cases, CBF/marCCD/SMV/sweep/VDS/GetRawImage cases
all pass.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D1G8gJVAy6gp1K5Dz3NE5C
2026-09-27 18:48:10 +02:00
leonarski_f 1479af2984 Merge branch 'gpu-rsm-streams' into rc173 (CUDA stream-ordered frees, stop on a lost context)
Build Packages / Create release (push) Successful in 15s
Build Packages / build:viewer:macos-arm64:nocuda (push) Successful in 3m11s
Build Packages / build:rugnux:macos-arm64:nocuda (push) Successful in 2m20s
Build Packages / build:rugnux:linux-aarch64:cuda (push) Successful in 7m51s
Build Packages / build:rugnux:linux-x86_64:cuda (push) Successful in 8m52s
Build Packages / build:viewer:linux-x86_64:nocuda (push) Successful in 10m48s
Build Packages / build:viewer:linux-x86_64:cuda (push) Successful in 11m49s
Build Packages / build:jfjoch:rocky8:nocuda (push) Successful in 15m10s
Build Packages / build:jfjoch:rocky9:nocuda (push) Successful in 16m20s
Build Packages / build:viewer:windows-x86_64:nocuda (push) Successful in 18m12s
Build Packages / build:viewer:windows-x86_64:cuda (push) Successful in 20m37s
Build Packages / HDF5 consumer tests (DIALS, XDS) (push) Successful in 24m27s
Build Packages / build:jfjoch:ubuntu2204:nocuda (push) Successful in 19m2s
Build Packages / build:rugnux:windows-x86_64:cuda (push) Successful in 11m7s
Build Packages / build:jfjoch:ubuntu2404:nocuda (push) Successful in 21m0s
Build Packages / Generate python client (push) Successful in 1m0s
Build Packages / build:jfjoch:rocky8:cuda-sls9 (push) Successful in 20m41s
Build Packages / Build documentation (push) Successful in 1m38s
Build Packages / build:jfjoch:rocky9:cuda-sls9 (push) Successful in 21m17s
Build Packages / build:jfjoch:rocky8:cuda (push) Successful in 18m45s
Build Packages / build:jfjoch:rocky9:cuda (push) Successful in 18m25s
Build Packages / build:jfjoch:ubuntu2204:cuda (push) Successful in 15m3s
Build Packages / build:jfjoch:ubuntu2404:cuda (push) Successful in 12m58s
Build Packages / Unit tests (push) Successful in 1h19m26s
2026-09-27 17:12:17 +02:00
leonarski_f d7e7163f11 Merge branch 'r4-integration' into rc173 (rotation latency round 4, beam-stop arm fixes) 2026-09-27 17:11:49 +02:00
leonarski_fandClaude Opus 5.5 ea9d0adcde Stop on a lost CUDA context instead of falling back to the host decoder
A sticky error (an illegal address, say) is reported by whichever worker synchronises next - usually
the bslz4 device decode - and MXAnalysisWithoutFPGA::Analyze and Rugnux::MaskDefectivePixels then
logged "falling back to host" and carried on, although the context is gone and the run fails anyway,
later and less clearly. cuda_throw_if_context_lost() now throws a GPUCUDAError ("CUDA device
unusable after an unrecoverable error: ...") there first, which the image loops already treat as
fatal (IsFatalResourceError), like a CUDA out-of-memory.

The last error cannot tell: once cudaGetLastError() has returned a sticky error, it and
cudaPeekAtLastError() both report success. cudaFree(nullptr) frees nothing and does not
synchronise, but returns the sticky error (checked: after an illegal-address kernel it returns
cudaErrorIllegalAddress; with only a pending out-of-memory it returns success), so a handled,
non-sticky decode failure still falls back - MXAnalysis_HandledDeviceDecodeFailureLeavesNoError
passes.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D1G8gJVAy6gp1K5Dz3NE5C
2026-09-27 17:11:38 +02:00
leonarski_fandClaude Opus 5.5 cb97832fb3 BraggIntegrationEngineGPU, FFTIndexerGPU: upload on the engine's stream, not the NULL stream
The engines' kernels run on their own non-blocking stream, which is not ordered after the legacy
NULL stream. The counter reset (cudaMemset, asynchronous to the host) and the radial-background
kernel upload in the Bragg engine constructor, and the direction-grid upload in FFTIndexerGPU (a
pageable cudaMemcpy returns once staged, before the DMA completes) were issued on the NULL stream,
so the first kernel reading them was not ordered after them. Now cudaMemsetAsync/cudaMemcpyAsync on
the engine's stream. In the Bragg constructor that is this->stream: the parameter of the same name
has been moved from.

compute-sanitizer --track-stream-ordered-races all flagged the Bragg ones (16 + 16 reports on
rugnux -e 150 myob; 1 in the [CUDAMemHelpers] tests); 0 after. Outputs byte-identical on myob,
cytc, lyso.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D1G8gJVAy6gp1K5Dz3NE5C
2026-09-27 17:11:38 +02:00
leonarski_fandClaude Opus 5.5 ca1baffef4 RotationScaleMergeGPU, HotPixelFinderGPU: allocate synchronously, like BeamCenterFFTGPU
Both queue their work (kernels, cudaMemcpy) on the legacy NULL stream - HotPixelFinderGPU for its
download, its sums otherwise on the workers' streams - while their buffers came from the
stream-ordered pool, whose cudaFreeAsync is ordered on the thread's non-blocking allocation stream
and so after none of that work. Unlike BeamCenterFFTGPU no free has overtaken a read: every entry
point waits for its work on the host (cudaDeviceSynchronize, or a blocking device-to-host copy)
before it returns. But that holds only by convention, not for a free while unwinding from a failed
call, and compute-sanitizer --track-stream-ordered-races cannot see host synchronisation, so it
reported every reassigned merge buffer as a use-after-free - noise that buries a real race like the
BeamCenterFFTGPU one.

compute-sanitizer --tool memcheck --track-stream-ordered-races all:
- rugnux --mode scale on a myob _process.h5: 22 use-after-free reports before, 0 after
  (MergeAccum/MergeAccumRange/MergeRmeas buffers freed by MergeAccum's reassignment or ~Impl).
- rugnux -e 150 -N 8 on myob: 92 before, 0 after (together with the next commit).

myob, cytc, lyso: p.hkl, p.mtz, p_P1.mtz, p_unmerged.mtz byte-identical to r4-integration. Full-run
wall time unchanged within noise (17.4/25.9/18.6 s before, 17.6/25.8/18.7 s after); the scale/merge
phase alone (--mode scale, myob) 2.23 -> 2.31 s.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D1G8gJVAy6gp1K5Dz3NE5C
2026-09-27 17:11:38 +02:00
leonarski_fandClaude Fable 5.1 10ef6fa643 Merge branch 'viewer-auth' into rc173 (SECURITY.md update)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-27 13:24:45 +02:00
leonarski_fandClaude Fable 5.1 7922b0a7fa SECURITY.md: describe the token swap semantics; drop the single-sign-on paragraph
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-27 13:24:40 +02:00
leonarski_fandClaude Opus 5.5 77710e1cec ShadowFinder: the arm search's dim plane as a zero-filled Plane, for the merged helpers
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D1G8gJVAy6gp1K5Dz3NE5C
2026-09-27 11:48:23 +02:00
leonarski_f 065f1f9794 Merge branch 'perf-prescan-acd' (A, C) into r4-integration
# Conflicts:
#	image_analysis/beam_stop/ShadowFinder.cpp
#	image_analysis/beam_stop/ShadowFinder.h
#	rugnux/Rugnux.cpp
2026-09-27 11:47:58 +02:00
leonarski_f 3fd2b6dab2 Merge branch 'gpu-decode-fault' into r4-integration 2026-09-27 11:41:33 +02:00
leonarski_fandClaude Opus 5.5 65805cc3b2 BeamCenterFFTGPU: allocate synchronously, the work is on the NULL stream
Every kernel, cuFFT plan and copy in the beam-centre FFT engine is queued on the legacy NULL
stream, but its buffers came from the stream-ordered pool, whose cudaFreeAsync is ordered on the
thread's non-blocking allocation stream - not after the NULL stream. The free therefore completed
at once while PointShortlist's last Suppress (and, depending on what the plan teardown syncs, the
last inverse transform) was still writing, and the pool was free to hand those bytes to another
worker or return them to the driver.

The capture runs in the background into the first pass of the main run, exactly when the spot
engines are allocating their buffers, and on a loaded shared card its NULL-stream work lags. That
is the "illegal memory access" seen 0.3-0.5 s after "Processing ... images" in four runs across
three binaries and two datasets: a sticky error, reported by whichever worker synchronised next -
the bslz4 device decode, whose host fallback cannot recover from it.

compute-sanitizer --track-stream-ordered-races all over the [BeamCenterFFTGPU] tests: 16502
use-after-free reports before, 0 after; on a rugnux run it flagged the 294 MB transform buffers
freed under a cuFFT kernel. myob output (hkl, mtz, P1 mtz, unmerged mtz) byte-identical.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D1G8gJVAy6gp1K5Dz3NE5C
2026-09-27 11:40:55 +02:00
leonarski_f 15340b7b2c Merge branch 'perf-tail-merge' into r4-integration 2026-09-27 10:57:40 +02:00
leonarski_fandClaude Opus 5.5 8fac362a06 RotationScaleMerge: per-observation arrays without a serial zero-fill; rocking-event histogram on all threads
Ingest and every Run allocated per-observation arrays as sized vectors (the ingest sort keys, the
remap, the ingest-time delta_phi / partiality / image number / usability arrays, and per Run the
group ids and the group permutation), each zeroed on the main thread and faulted in page by page
before the parallel loop that writes every element of it: 0.6 s of main-thread memset in a loaded
16M profile of ComputeAsuGroups alone. They are plain arrays now, first touched by the threads that
fill them - the idiom the ingest staging buffers already use. RockingEventFrames' walk over every
raw-hkl run (0.3 s alone on the main thread at ingest) counts per block of runs on all threads;
integer histograms, so the sum is the serial one.

Item 2 of this round - building part of the ingest incrementally while the last image loop runs -
is not done: the per-frame pieces (background mean, h range, h histogram) are one of the five sweeps
over the reflections, and the sort and the grouping need the whole sweep.

Median of 3 paired runs (GPU, no other rugnux running), with the other two commits of this series:
ingest segment 0.94 -> 0.67 s on cytochrome C (17M partials), merge segments down with it. Peak RSS
unchanged (4.74 vs 4.77 GB cytochrome C GPU). Outputs byte-identical on myoglobin, cytochrome C and
lysozyme (GPU) and cytochrome C (CPU-only); logs and reports identical up to line order.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D1G8gJVAy6gp1K5Dz3NE5C
2026-09-27 10:56:38 +02:00
leonarski_fandClaude Opus 5.5 cb29dea7a1 Merge tail: the serial walks of the written merges on all threads, in the same order
What the main thread did alone in each written merge (adopted group, pinned pair, P1 cross-check),
each rewritten so that every sum is still taken over the same terms in the same order:
- WilsonOutliers: the observations are gathered once in resolution order and each shell is one task
  (the shells are contiguous runs of that order); the reflection sort is on a total order, so it is
  a ParallelSort.
- MergeAndStats: the per-group sums (Bijvoet excess, ISa asymptote, the CPU error-model means) and the
  per-pair anomalous sums split the GROUPS over the threads, each walking every full and taking only
  its own, so each group is summed in index order; the error-model samples, the Wilson records and
  the anomalous cells are filled per observation after an in-order compaction.
- FitErrorModel: the per-bin rounds on up to 16 threads instead of 5 (one bin per task already).
- MeasureBatchDeltaCCHalf: the three fields it reads are taken out of the 80-byte fulls once, on all
  threads.
- ScaledObservations (anisotropy): the rocking events walked in blocks cut at hkl boundaries and
  joined in block order. Its sort stays serial: parts can tie on (hkl, image), and the event sums
  depend on introsort's order of the tied ones.

Median of 3 paired runs (GPU, no other rugnux running), with the other two commits of this series:
cytochrome C tail 11.1 -> 8.5 s (P1 cross-check segment 4.0 -> 2.9 s, reports 0.76 -> 0.70),
lysozyme 9.2 -> 8.2 s, myoglobin 2.57 -> 2.40 s. Outputs byte-identical on myoglobin, cytochrome C
and lysozyme (GPU) and cytochrome C (CPU-only); logs and reports identical up to line order.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D1G8gJVAy6gp1K5Dz3NE5C
2026-09-27 10:56:26 +02:00
leonarski_fandClaude Opus 5.5 bfe853288e Rugnux: the pre-promotion tNCS and twinning measured beside the adopted group's merge
They read only the search merge, which nothing writes until the adopted group's merge replaces it,
and nothing reads them before that merge returns (the refused-promotion arm after it is their first
reader). So they run on their own thread while the engine re-scales in the adopted group, and are
joined before `sm` is replaced. The cell is captured at launch, since a committed change of basis
replaces it. The merges share one engine (one set of device buffers, corr, groups and memos), so the
candidate merges themselves stay serial; this is the analysis that could overlap one of them.

The gap from the all-observation search to "Adopting" went 0.81 -> 0.20 s on a 16M cytochrome C
sweep (median of 3 paired runs, GPU; the adopted merge beside it 1.89 -> 1.71 s with the later
commits). Outputs byte-identical on myoglobin, cytochrome C and lysozyme (GPU) and cytochrome C
(CPU-only); logs and reports identical up to line order.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D1G8gJVAy6gp1K5Dz3NE5C
2026-09-27 10:56:14 +02:00
leonarski_f c270c435d6 Merge branch 'perf-readahead' into r4-integration 2026-09-27 10:33:27 +02:00
leonarski_fandClaude Opus 5.5 aa3fa6b9c7 Reader: stream the frame data into the page cache ahead of the image loops
A cold run on a spinning disk waited on the disk twice over. The CBF header
scan read 256 kB from every frame on eight threads that each strode through
their own share of the sweep, so they drifted apart and the scan became a
seek storm (34 s for 2400 frames here); and after it, the pre-scan and the
first-pass indexing touch a few hundred frames and leave the disk idle until
the first image loop reads everything at seek-bound rates.

- ReadAhead (reader/): once the dataset is open, rugnux starts eight threads
  that read the data files - HDF5 data files (legacy, VDS or the integrated
  master) or the per-frame CBF/marCCD/SMV files - in 4 MB pieces taken
  strictly in order, into a throwaway buffer. One stream reads this disk at
  125 MB/s, eight in-order streams at 190 MB/s, 32 at 157 MB/s. It never gets
  more than a quarter of MemAvailable (GlobalMemoryStatusEx on Windows, 4 GiB
  where there is no figure) ahead of what ReadRawImage has handed out, so a
  dataset bigger than the cache does not evict its own start, and it stops
  with the reader. Plain ifstream reads: portable, no POSIX calls.
- Header scans (CBF, marCCD, SMV) hand the files out in order from an atomic
  counter (sweep::ForEachInOrder) instead of striding: 18 s -> 12 s for 2400
  cold CBF headers. The CBF header is first read with a 16 kB probe and again
  with the old 256 kB one only when the separator is not in it, so the parsed
  header is exactly what it was: 12 s -> 6 s.

Output unchanged: p.hkl, p.mtz and p_unmerged.mtz md5-identical to the
rc173 baseline on 6toc (CBF, 2400 frames, 6.0 GB) and 9q41 (HDF5 VDS, 900
frames, 5.1 GB), and on 6z9g (HDF5, 12.8 GB) to the unmodified branch; myob
(p.hkl p.mtz p_P1.mtz p_unmerged.mtz) md5-identical to the reference.

Measured cold (files evicted with POSIX_FADV_DONTNEED before every run),
same code without this commit vs with it, on a shared box (load 20-70, other
agents reading the same disk, so single runs scatter by +-20 s):
  6toc  wall 61.7/62.7 -> 49.3/49.8 s (clean pairs); all data resident
        after 62/51/50 -> 45/41/42 s
  9q41  wall 67.3 -> 57.8 s (clean pair); resident after 58/46/43 -> 48/37/35 s
  6z9g  resident after 81 -> 69 s
The first image loop can look slower with this in CBF runs: the old 256 kB
header probes pulled ~70% of the data in as kernel readahead, so the old
loop started warm - after a 34 s header scan instead of 10 s.
Warm (myob, NVMe, cached): 19.35/20.00 s without, 19.76-20.16 s with; the
read-ahead then only copies 9.3 GB out of the page cache, 0.44 s wall and
3.4 CPU-s measured standalone.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D1G8gJVAy6gp1K5Dz3NE5C
2026-09-27 10:32:58 +02:00
leonarski_fandClaude Fable 5.1 b221c1f1a9 Merge branch 'viewer-auth' into rc173 (per-dataset bearer tokens, viewer token/model/grid-plot/dark theme, tutorial)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-27 10:30:50 +02:00
leonarski_fandClaude Fable 5.1 7f884f67c7 Per-dataset bearer tokens in the broker; viewer token, model option, grid-scan plot, dark theme, tutorial
jfjoch_broker: /start takes an optional `tokens` list (any number, all equivalent; writeOnly in the
API). While the current dataset has any, the endpoints that expose it - /statistics/data_collection,
/result/scan, /image_buffer/{start.cbor,image.cbor,image.jpeg,image.tiff}, /preview/plot{,.bin} -
answer 401 unless the request carries `Authorization: Bearer <token>`; /statistics keeps serving
the instrument view and only omits its `measurement` block. Enforcement is one pre-routing hook
over a named path set (the same set carries `bearerAuth` in jfjoch_api.yaml); tokens are compared
in constant time and never read back or logged. The tokens are replaced only by an accepted start,
and atomically with clearing the previous run's status, plots and image buffer, in this order:
clear, swap, import the new settings - so no moment serves the old run under the new tokens or the
new run's name under the old ones. Settings are validated on a copy first so a refused start
changes nothing.

jfjoch_viewer: a Token field (password echo) next to the http/https scheme in Open HTTP Connection,
JUNGFRAUJOCH_HTTP_TOKEN, and D-Bus LoadFile(..., token) / SetHttpToken; the dialog overrides the
others, nothing is persisted. A 401 clears the display, stops following and puts a line on the
status bar - no dialog, since a dataset changing hands is the normal cause.

Web frontend: key button in the top bar (token in sessionStorage, applied to the bearerAuth
operations by the generated client, and to the raw preview fetch), a tokens field in the start
form, and a "token required" hint on the plots. Python client: Configuration(access_token=...)
after regeneration.

Viewer: reference dataset accepts a structure-factor mmCIF (already read by content; the dialog
now offers it) and a model file can be chosen next to it (ProcessConfig::model_path, as
rugnux --model); the job also carries the reference's free-R flags, cell and setting, and the
copied command line states -z / --reference-column / --model. A grid scan keeps its own preferred
dataset-info plot and "Spots + background" means the spot count there. Dark theme: the navy hero
buttons, checked segments, warning texts and chart guide lines follow the theme instead of their
light-theme colours.

Docs: SECURITY.md section 3 is now the implemented scheme; a guided tour with four screenshots
in JFJOCH_VIEWER.md; broker, OpenAPI, Python client and frontend pages mention the token.

Tests: BearerTokens unit test and an HTTP round trip over the real broker HTTP layer (jfjoch_test
now compiles JFJochBrokerHttp.cpp and links httplib).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-27 10:30:06 +02:00
leonarski_f 6f509d9ae5 Merge branch 'perf-hotpixel-gpu' into r4-integration 2026-09-27 10:20:56 +02:00
leonarski_fandClaude Opus 5.5 6fb4379d35 Rugnux: a defective-pixel frame the device cannot decode goes to the host decoder
As in the image loops: AnalyzeCompressed throwing no longer drops the frame from the defective-pixel
statistics; it is logged, the CUDA error is cleared, and the frame is decompressed on the host and
preprocessed as any format without a device decoder already is.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D1G8gJVAy6gp1K5Dz3NE5C
2026-09-27 10:20:56 +02:00
leonarski_f 8495cebd7d Merge branch 'perf-hotpixel-gpu' into r4-integration 2026-09-27 10:17:39 +02:00
leonarski_f cff5fa0fdb Merge branch 'shadow-6jgh-dimfield' into r4-integration
# Conflicts:
#	image_analysis/beam_stop/ShadowFinder.cpp
2026-09-27 10:10:07 +02:00
leonarski_f 0dab24241f Merge branch 'atg-arm-centre' into r4-integration
# Conflicts:
#	image_analysis/beam_stop/ShadowFinder.cpp
#	rugnux/Rugnux.cpp
2026-09-27 10:09:54 +02:00
leonarski_f 0ad6334155 Merge branch 'perf-walk-probes' into r4-integration 2026-09-27 10:09:17 +02:00
leonarski_f d7052afc53 Merge branch 'perf-sparse-ladder' into r4-integration 2026-09-27 10:09:17 +02:00
leonarski_fandClaude Opus 5.5 3a4fcd2dd4 Rugnux: geometry-walk rounds run as probe passes, one full pass writes the kept round
Every round of the rotation geometry walk was a full canonical pass - image loop, scale/merge,
space-group search, model validation, P1 cross-check and every output file - while the walk reads
only the round's post-refinement and validation-spot evidence, both in hand before the scaling
engine is built. Rounds now stop there (postrefine_probe_only_, as the lattice arms already do), and
the round the walk keeps is run once more to write the canonical files.

Exact: a round's probe and its full pass are the same code up to the probe's return, and past that
point a pass changes nothing on the run but the space group, which the next pass resets. A pass
does change the run before that point (spot budget, axis sign, beam-centre search), so the kept
LAST round is re-run from the experiment and beam_center_searched_ it started from, not from where
its probe left them; a kept earlier round is gone back to exactly as before. The final pass runs
without the post-refinement measurement the last round carried, which only reads the integrated
reflections.

p.hkl, p.mtz, p_P1.mtz, p_unmerged.mtz and p.cif byte-identical against the parent on the three
open-arm sets whose walk takes rounds here (8pqd 1 round, 5m17 2, 7ph1 3; without --model, and 7ph1
also with it, where the report's model section matches too), and on myob and cytc (no rounds). The
walk's log lines (distances, probe percentages, stop reason) are unchanged; the report's PASS count
rises by one, the probe that now runs beside the full pass.

Cost: a walk of n rounds runs n probes and one full pass instead of n full passes. Measured on a
shared, loaded box (load 30-60): a round's full pass took 32-47 s on 7ph1 (56-88 s with --model)
and 44-77 s on 5m17, its probe 6-11 s (16-18 s with --model); 7ph1 run back to back 182.7 -> 113.5 s.
A one-round walk pays one probe more (8pqd: 24 s).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D1G8gJVAy6gp1K5Dz3NE5C
2026-09-27 10:02:32 +02:00
leonarski_fandClaude Opus 5.5 2158b8e306 Rugnux: index the spot-budget ladder's rungs, and a beam-centre ring's centres, at once
On a sweep that indexes poorly the rotation first pass runs every rung of the spot-budget ladder (up to
12 rungs, two schemes each) and then the beam-centre walk (four centres a ring), each a RotationIndexer
run of a few parallel candidate solves and a serial Ceres chain, one after the other on a mostly idle
machine. The rungs are independent - the ladder decides over all of them together - and a ring's
centres are read in a fixed order, so pick_best is split into feed / index / score: every rung (every
centre of a ring) is fed in turn under its own settings, all of them are indexed at once, and they are
then scored and decided in the original order, each under its settings again. A centre is fed on its
own copy of the experiment, since try_beam_center moves experiment_. The indexers read only what they
were fed and their own experiment, so nothing but the log order changes; a centre after the adopted
one is indexed for nothing.

Measured on the weak sweep that nothing indexes well (GPU, load ~5): 62 -> 40 s; each ladder 9.8 ->
4.8 s, the two beam-centre rings 11.6 -> 5 s. Under load 40-65, three interleaved pairs: 442 -> 126,
289 -> 64, 114 -> 85 s. p.hkl, p.mtz, p_P1.mtz and p_unmerged.mtz byte-identical on the four reference
sweeps (GPU) and on two of them (CPU-only); RUGNUX_VERIFY_FIRST_PASS_MEMO passes on two.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D1G8gJVAy6gp1K5Dz3NE5C
2026-09-27 10:00:39 +02:00
leonarski_fandClaude Opus 5.5 7b55a31a3a ShadowFinder: judge the arm search's dim pixels against the ring's azimuthal harmonic
The transmitting-piece step added in a1816e905 called a pixel dim when it sat below 0.75 of its
ring's median. When the polarization factor the background is divided by is not the beam's, every
outer ring is left with a cos 2phi modulation: on one open-arm marCCD sweep (0.75 A, 90 mm, default
p = 0.99) the corrected background runs 1.5-1.8x the ring median along x and 0.6-0.75x along y at
r = 1600-2200 px. Joined, the y lobes made two pieces of 1.01 M and 0.39 M dim pixels holding only
0.17 % deep pixels (noise and a real ~20 % partial shadow) - past the absolute gate of 200 - and the
mask grew from 16,584 to 1,428,328 pixels, covering the top and bottom of the detector.

The step now fits, per 64 px radial band, the 24 sector medians with m + p cos 2phi + q sin 2phi
(sectors below 0.75 of the fit left out, three times over) and judges dim against min(1, model).
Capped at 1 the model only removes dim pixels, so a sweep with no piece keeps its mask bit for bit
and a piece can only shrink or go.

Shadow pixels (b90b377b8 -> this), same binary otherwise:
  that sweep 1,428,328 -> 29,821 (the stop's stick extension kept); arm sets 7atg 724,032 -> 723,708,
  6oel 223,087 -> 216,126, 7rji 311,951 -> 309,816, 9fhc 173,597 -> 171,372, 5ebi 793,217 -> 785,477,
  6zqr 289,324 -> 251,764, 5j23 530,541 -> 526,115, metformin 341,368 -> 231,123 (two speckled
  pieces in the high-angle lobe dropped, the arm below the beam kept).
Merges: 6oel, 7rji, 6zqr, metformin unchanged in space group and resolution, R_meas/ISa within
0.1 %/0.2; 7atg fails either way on this base (the beam-centre defect fixed on atg-arm-centre).
The sweep above: same space group, resolution, CC1/2 and ISa (6.72 -> 6.73); R_meas 21.7 -> 22.7 %,
because the spurious mask also hid part of a real ~20 % partial shadow at the top right that neither
version masks (it is above penumbra_ratio).

New test ShadowFinder_AWrongPolarizationFactorIsNotAnArm (unpolarized background, 0.99 stated,
small absorbing speck in the dim lobe) masks 4547 px before and 0 after; all *Shadow* tests pass.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D1G8gJVAy6gp1K5Dz3NE5C
2026-09-27 09:59:17 +02:00
leonarski_fandClaude Opus 5.5 a804cf61b7 HotPixelFinder: the defective-pixel pass on the GPU, bit-identical
MaskDefectivePixels re-read its 60 sample frames with 8 CPU workers - host
bitshuffle/LZ4 decode, CPU preprocessing, then per frame a scatter by ring-sector,
nth_element medians and per-pixel sums over ~22 B/px of state - and was bound by
memory bandwidth: 2.9 s wall on an EIGER2 16M sweep, the largest single step left in
the pre-scan.

With a GPU each worker now decodes and preprocesses the frame on the device
(ImagePreprocessorGPU::AnalyzeCompressed, as the image loops do), and
HotPixelFinderGPU takes the order statistics there: one block per ring-sector and
per ring, radix selection eight bits at a time, which is exact for any values, so
the histogram-plus-fallback of the host path is not needed. Only the per-key
statistics (count, sector median, ring median and MAD) come back; levels and
thresholds are computed on the host by the same function the CPU path now calls
(AddLevels), uploaded, and a per-pixel kernel does AddImage's loop line for line,
including the float comparison. The per-pixel sums stay on the device and are
downloaded once when the mask is read. The CPU path is unchanged. The constructor's
~470 MB of per-pixel arrays are no longer zeroed on the calling thread: they are
allocated unwritten and first written by the existing parallel key pass.

Exactness: a temporary check that ran both finders side by side on the same
frames (CPU decode + preprocess against GPU decode + preprocess) found every
per-pixel and per-key sum identical on myob, cytc, lyso and sparse; the new test
HotPixelFinder_DeviceMatchesHost compares the masks on synthetic frames that take
every branch (histogram fallback, negatives, saturated/error/masked pixels,
134 masked). p.hkl, p.mtz, p_P1.mtz and p_unmerged.mtz are byte-identical to the
previous GPU build on myob and cytc; the masked counts (3/2/0) are unchanged.

myob GPU, quiet box: MaskDefectivePixels 2.90 s -> 0.42 s, total 20.4 -> 19.4 s,
peak RSS 3.52 -> 3.60 GB.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D1G8gJVAy6gp1K5Dz3NE5C
2026-09-27 09:32:26 +02:00
leonarski_fandClaude Opus 5.5 4616921087 AzimuthalIntegrationMapping: reuse the per-pixel geometry of the last mapping; pre-scan's off the critical path
Building a mapping evaluates the geometry of every pixel (resolution, azimuth, solid-angle and
polarization corrections, bin) - about 2 CPU-s and 0.1-0.25 s of wall on a 16 Mpx detector - and a
rotation run built seven, several for a geometry it had already evaluated: the first pass repeats the
pre-scan's, and the canonical pass and the second of two concurrent probes repeat the probes'. None of
it depends on the mask beyond skipping masked pixels, so AzimuthalIntegrationGeometryCache keeps the
unmasked tables of the last geometry (keyed bit for bit on everything SetupPixel reads), and a
mapping for the same geometry copies them and blanks its own masked pixels to what SetupPixel leaves
there. One entry, shared by the run's copies; on myob four of the seven builds now evaluate, three
copy (~40-120 ms against ~240+ ms on a loaded box).

The pre-scan's mapping is only read by the spot measurement, so it is built at the start of that
background task rather than on the main thread ahead of the projection read. It also fills the cache
for the first pass.

Output identical on the three GPU reference sweeps; a new test checks cached against fresh mappings
under different masks and centres.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D1G8gJVAy6gp1K5Dz3NE5C
2026-09-27 09:12:25 +02:00
leonarski_fandClaude Opus 5.5 3e68ed8c4a ShadowFinder: first-touch GetMask's arrays on the workers, reduce the projection once
On a 16 Mpx detector the pre-scan's main thread spent most of its kernel time faulting in memory.
GetMask made some thirty per-pixel arrays (1.6 GB) as value-initialised std::vectors, so the calling
thread zeroed - and took the page faults of - every one of them before the parallel passes ran: about
0.75 s on a 16M sweep. They are now Planes (NoInitAllocator vectors) that the ParallelChunks writing
them first touches; each pass writes every element it used to leave at the initial value, and the
three that are filled piecemeal start from a parallel fill.

The projection was also reduced three times - for the ring-centre fit, for GetMask and for the
beam-centre capture - each a 360 MB Download into fresh memory. It is reduced once on the first read
and kept until the caller releases it after GetMask, and PreScan shares the one mean projection
read-only (shared_ptr<const>) with the ring-centre fit, the diagnostic picture and the capture
instead of copying it into each.

Output identical (p.hkl, p.mtz, p_P1.mtz, p_unmerged.mtz) on the three GPU reference sweeps;
ShadowFinder and BeamCenterFromBackground tests pass.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D1G8gJVAy6gp1K5Dz3NE5C
2026-09-27 08:55:36 +02:00
leonarski_fandClaude Opus 5.5 a42b73ccfb Rugnux: fit the beam centre without the transmitting beam-stop pieces
a1816e905 adds the partly transmitting holder-arm pieces to the beam-stop mask, and the
background beam-centre fit read that mask too. A thin arm running into the beam, masked, takes
cells out of the rings it crosses; which radial bins stay above the sector-coverage gate then
changes as the trial centre moves, and a walk seeded beside the arm (the FFT capture sat 48 px off)
limit-cycled on that edge and stopped 44 px short (sigma 0.34 -> 2.27 px). On an open-arm set whose
header centre is 171 px out, the measured centre no longer indexed (60/60 -> 0/60), the run kept the
file's centre and merged P1 at CC1/2 0.004 instead of P212121 at 0.995.

The arm's dimming is multiplicative in azimuth, which the fit's per-sector amplitude already
absorbs, so the fit now uses the mask without those pieces (ShadowFinder marks them TRANSMITTING);
integration still masks them. The fitted centre is back to its a0518abe6 value on every set checked
(the failing one plus four arm sets); the failing set merges P212121, CC1/2 0.995, ISa 22.6.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D1G8gJVAy6gp1K5Dz3NE5C
2026-09-27 08:34:36 +02:00
leonarski_f 82ae0eb64a Merge branch 'perf-rotation' into rc173 (rotation latency, round 3)
Build Packages / build:viewer:linux-x86_64:nocuda (push) Successful in 10m38s
Build Packages / build:viewer:linux-x86_64:cuda (push) Successful in 12m16s
Build Packages / build:viewer:windows-x86_64:nocuda (push) Successful in 17m51s
Build Packages / build:viewer:windows-x86_64:cuda (push) Successful in 20m19s
Build Packages / build:viewer:macos-arm64:nocuda (push) Successful in 3m23s
Build Packages / build:rugnux:linux-x86_64:cuda (push) Successful in 9m10s
Build Packages / build:rugnux:linux-aarch64:cuda (push) Successful in 8m20s
Build Packages / build:rugnux:windows-x86_64:cuda (push) Successful in 11m13s
Build Packages / build:rugnux:macos-arm64:nocuda (push) Successful in 2m27s
Build Packages / Unit tests (push) Successful in 1h13m52s
Build Packages / build:jfjoch:rocky8:nocuda (push) Successful in 15m28s
Build Packages / build:jfjoch:rocky9:nocuda (push) Successful in 15m51s
Build Packages / build:jfjoch:ubuntu2204:nocuda (push) Successful in 18m58s
Build Packages / build:jfjoch:ubuntu2404:nocuda (push) Successful in 19m37s
Build Packages / build:jfjoch:rocky8:cuda-sls9 (push) Successful in 20m10s
Build Packages / build:jfjoch:rocky9:cuda-sls9 (push) Successful in 19m45s
Build Packages / build:jfjoch:rocky8:cuda (push) Successful in 18m2s
Build Packages / build:jfjoch:rocky9:cuda (push) Successful in 18m42s
Build Packages / build:jfjoch:ubuntu2204:cuda (push) Successful in 14m8s
Build Packages / build:jfjoch:ubuntu2404:cuda (push) Successful in 12m6s
Build Packages / HDF5 consumer tests (DIALS, XDS) (push) Successful in 24m43s
Build Packages / Build documentation (push) Successful in 1m15s
Build Packages / Create release (push) Successful in 16s
Build Packages / Generate python client (push) Successful in 10s
2026-09-27 07:31:48 +02:00