From a1b63c3b5edd0de88547b33a6fa3ae6609f8ff0c Mon Sep 17 00:00:00 2001 From: Filip Leonarski Date: Wed, 16 Sep 2026 14:01:54 +0200 Subject: [PATCH 1/3] scale/merge: hand back the host copy of the partials once the GPU holds them With the device pipeline resident and no observation dump, nothing on the host reads the 80-byte Obs records after the upload: scaling, combine, scale-fulls and merge run on the GPU, the ASU grouping stamps a flat group_ids array, every pass restarts corr from corr_ingested, and the CPU combine only runs for the dump. The one late reader, RockingEventFrames, sees only ingest-time values on that path (the host corr is refreshed only in the dump fallback), so Ingest takes its answer up front and frees the array - the second full-size copy of the partial observations, held beside the source reflections for the whole of the canonical pass. On a finely-sliced long-axis rotation crystal with ~96 M first-pass partials that copy alone is 7.7 GB. Byte-identical by construction: every consumer that could ever read the array on this path is enumerated above, and the CPU and dump paths keep it exactly as before. Co-Authored-By: Claude Opus 5 (1M context) --- .../scale_merge/RotationScaleMerge.cpp | 18 ++++++++++++++++-- .../scale_merge/RotationScaleMerge.h | 12 ++++++++++++ 2 files changed, 28 insertions(+), 2 deletions(-) diff --git a/image_analysis/scale_merge/RotationScaleMerge.cpp b/image_analysis/scale_merge/RotationScaleMerge.cpp index f41da9dce..e5b3b917b 100644 --- a/image_analysis/scale_merge/RotationScaleMerge.cpp +++ b/image_analysis/scale_merge/RotationScaleMerge.cpp @@ -299,6 +299,7 @@ void RotationScaleMerge::MeasureIncidentFlux(const std::vector &mean_bkg void RotationScaleMerge::Ingest() { n_frames = static_cast(partials_out.size()); + partials_released = false; size_t total = 0; for (const auto &o : partials_out) total += o.reflections.size(); @@ -551,6 +552,15 @@ void RotationScaleMerge::Ingest() { gpu_->SetFrameCellOk(frame_cell_ok.data()); logger.Info("RotationScaleMerge: GPU scaling + combine + scale-fulls + merge active"); } + // The device now holds every per-obs field the resident pipeline reads, so the host copy's one + // remaining reader is RockingEventFrames - which on this path sees only ingest-time values (see + // the header). Take its answer now and hand the array back. The dump path keeps it: the CPU + // combine is what writes the dump. + if (gpu_active_ && observation_dump_path.empty()) { + rocking_event_frames_at_ingest = RockingEventFrames(); + std::vector().swap(partials); + partials_released = true; + } #endif } @@ -683,6 +693,8 @@ void RotationScaleMerge::BuildInRangeObservations(std::vector &keys) { } }); + n_partials_obs = n_keep; + if (n_keep < n_obs) logger.Info("RotationScaleMerge: dropped {} of {} observations ({} of {} distinct hkl) outside the " "requested resolution range - nothing downstream could have merged them", @@ -1087,7 +1099,7 @@ int RotationScaleMerge::ComputeAsuGroups(const HKLKeyGenerator &keygen) { #endif #ifdef JFJOCH_USE_CUDA std::vector group_ids; - if (gpu_active_) group_ids.resize(partials.size()); + if (gpu_active_) group_ids.resize(n_partials_obs); #endif // Parallel over raw-hkl runs: distinct runs own disjoint perm ranges, hence disjoint observations. ParallelChunks(n_run, nthreads, [&](int rlo, int rhi) { @@ -1111,7 +1123,7 @@ int RotationScaleMerge::ComputeAsuGroups(const HKLKeyGenerator &keygen) { // by group, parallel via per-chunk histograms over the flat group_ids; bit-identical to a serial fill // (chunk order == obs-index order, each chunk fills its groups sequentially). if (gpu_active_) { - const int n = static_cast(partials.size()); + const int n = n_partials_obs; const int nt = static_cast(std::max(1, std::min(nthreads, static_cast(std::max(1, n))))); const int chunk = (n + nt - 1) / nt; const int n_hist = chunk > 0 ? (n + chunk - 1) / chunk : 0; // chunks with work in them; <= nt @@ -1990,6 +2002,8 @@ namespace { // the finest stretch of the sweep this file is allowed to speak about: the partials of one event are // welded into a single full, so two frames closer together than this are not separable observations. int RotationScaleMerge::RockingEventFrames() const { + if (partials_released) + return rocking_event_frames_at_ingest; std::vector hist(n_frames + 2, 0); std::vector im; int n_event = 0; diff --git a/image_analysis/scale_merge/RotationScaleMerge.h b/image_analysis/scale_merge/RotationScaleMerge.h index 9e4af0f8d..0adeeea70 100644 --- a/image_analysis/scale_merge/RotationScaleMerge.h +++ b/image_analysis/scale_merge/RotationScaleMerge.h @@ -192,6 +192,18 @@ private: // Flat buffers, allocated once by Ingest() and reused across Run() calls. std::vector partials; // all per-frame partials, grouped by frame + // How many observations Ingest built (== partials.size() until any release below), so the + // sizes survive when the array itself does not. + int n_partials_obs = 0; + // With the GPU resident and no observation dump, no host stage reads the 80-byte Obs records + // after the device upload: scaling, combine, scale-fulls and merge run on the device, + // ComputeAsuGroups stamps the flat group_ids array, every pass restarts corr from + // corr_ingested, and the CPU combine only runs for the dump. The one late host reader, + // RockingEventFrames, sees only ingest-time values on that path (the host corr is refreshed + // only in the dump fallback), so Ingest takes its answer and hands the array - a second + // full-size copy of the partials, gigabytes on a fine-sliced long axis - straight back. + bool partials_released = false; + int rocking_event_frames_at_ingest = 0; std::vector frame_start, frame_count; // CSR ranges of `partials` per frame std::vector frame_cell_ok; // per-frame cell-consistency mask (1 = kept) std::vector finite_ok; // per-obs AcceptReflection finiteness (immutable; 1 = kept) From fd274d5bda666cb74677b6cd3ea3bd13815390be Mon Sep 17 00:00:00 2001 From: Filip Leonarski Date: Wed, 16 Sep 2026 14:23:48 +0200 Subject: [PATCH 2/3] scale/merge: the resident ingest never materialises the full Obs record The previous commit freed the host Obs array once the device held every field; this one stops building it. With the GPU pipeline resident (and no observation dump) the build lays down a forty-byte record - hkl, frame, rocking position, resolution, zeta, delta_phi, partiality, and the rocking-event usability bit - holding exactly what the host still touches between the build and the release: the geometry smoothing and the partiality recompute, the corr snapshot, and the rocking-event median. Every immutable per-obs field is staged to the device straight from the source reflections, in frame-aligned bounded slices, and the recomputed partiality follows after the smoothing. The smoothing loops and the rocking-event walk run on either record through the same code (templated on the record type), so the arithmetic is exactly the one the full record ran; the CPU and dump paths keep the full Obs as before. On a rotation crystal with a ~640 A axis and 95.7 M first-pass partials the full record was 7.7 GB, built and held beside the 8.4 GB of source reflections it copied from; the narrow one is 3.8 GB and is handed back at the end of Ingest. Measured there, on top of the previous commit's release: peak RSS 20.3-22.3 GB -> 18.3 GB, and the phases that used to carry the peak drop far more - the second pass's post-refine probe and space-group search run at 13-15 GB where they ran at 18-20, and the first pass's post-Ingest plateau at 12-16 where it ran at 20-21. What remains on top is no longer the scale/merge: the new peak is the first-pass ingest sitting on the image loop's own high-water mark (~17 GB), which this code never touches. Byte-identical output on that crystal and on two ordinary rotation sentinels: merged, P1 and unmerged MTZ data blocks, .hkl, .cif, per-image table, and the report modulo date/git/command line/wall time. Co-Authored-By: Claude Opus 5 (1M context) --- .../scale_merge/RotationScaleMerge.cpp | 209 +++++++++++++++--- .../scale_merge/RotationScaleMerge.h | 29 ++- 2 files changed, 200 insertions(+), 38 deletions(-) diff --git a/image_analysis/scale_merge/RotationScaleMerge.cpp b/image_analysis/scale_merge/RotationScaleMerge.cpp index e5b3b917b..13690baa6 100644 --- a/image_analysis/scale_merge/RotationScaleMerge.cpp +++ b/image_analysis/scale_merge/RotationScaleMerge.cpp @@ -300,6 +300,14 @@ void RotationScaleMerge::MeasureIncidentFlux(const std::vector &mean_bkg void RotationScaleMerge::Ingest() { n_frames = static_cast(partials_out.size()); partials_released = false; + resident_ingest = false; +#ifdef JFJOCH_USE_CUDA + // Probed before the observation array is built: which record the build lays down (the narrow + // resident one or the full Obs) depends on whether the device pipeline will be active. + gpu_ = std::make_unique(); + gpu_active_ = gpu_->Available(); + resident_ingest = gpu_active_ && observation_dump_path.empty(); +#endif size_t total = 0; for (const auto &o : partials_out) total += o.reflections.size(); @@ -491,23 +499,40 @@ void RotationScaleMerge::Ingest() { BuildInRangeObservations(keys); rawrun_group.assign(rawrun_start.size(), -1); logger.Info("RotationScaleMerge: ingested {} partial observations from {} frames ({} distinct hkl)", - partials.size(), n_frames, rawrun_start.size()); + n_partials_obs, n_frames, rawrun_start.size()); SmoothMosaicityAndPartiality(); - // The corr every pass starts from (see the header). - corr_ingested.resize(partials.size()); - ParallelChunks(static_cast(partials.size()), ThreadsForWork(partials.size(), nthreads), - [&](int lo, int hi) { - for (int i = lo; i < hi; ++i) corr_ingested[i] = partials[i].corr; - }); + // The corr every pass starts from (see the header). The resident build filled it already, + // straight from the source reflections (and the smoothing between there and here never + // touches corr). + if (!resident_ingest) { + corr_ingested.resize(partials.size()); + ParallelChunks(static_cast(partials.size()), ThreadsForWork(partials.size(), nthreads), + [&](int lo, int hi) { + for (int i = lo; i < hi; ++i) corr_ingested[i] = partials[i].corr; + }); + } #ifdef JFJOCH_USE_CUDA - // Bring the partial-scaling loop onto the GPU when one is present. Upload the immutable per-obs - // fields once (corr lives on the device, refreshed each pass); the CPU keeps the sort/keying/combine. - gpu_ = std::make_unique(); - gpu_active_ = gpu_->Available(); - if (gpu_active_) { + // Feed the device (probed at the top of Ingest). On the resident path the layout and every + // immutable field went up inside BuildInRangeObservations, straight from the source + // reflections; what is staged here is the partiality, which the recompute above rewrote. + if (resident_ingest) { + using F = RotationScaleMergeGPU::ObsField; + constexpr int STAGE_SLICE = 8 << 20; + const int n = n_partials_obs; + const int slice = std::min(std::max(n, 1), STAGE_SLICE); + const std::unique_ptr buf(new float[slice]); + for (int at = 0; at < n; at += slice) { + const int cnt = std::min(n - at, slice); + ParallelChunks(cnt, nthreads, [&](int lo, int hi) { + for (int i = lo; i < hi; ++i) buf[i] = partials_ingest[at + i].partiality; + }); + gpu_->SetObsField(F::Partiality, at, cnt, buf.get()); + } + } + if (gpu_active_ && !resident_ingest) { const int n = static_cast(partials.size()); gpu_->SetPartialsLayout(n, n_frames, frame_start.data(), frame_count.data()); // Staged in bounded slices: fourteen full-length arrays at once are more than half of the @@ -546,19 +571,21 @@ void RotationScaleMerge::Ingest() { gpu_->SetObsFrame(at, cnt, frm.get()); gpu_->SetObsOnIce(at, cnt, onice.get()); } + } + if (gpu_active_) { gpu_->SetRawRuns(static_cast(rawrun_start.size()), static_cast(perm.size()), perm.data(), rawrun_start.data(), rawrun_count.data(), rawrun_h.data(), rawrun_k.data(), rawrun_l.data()); gpu_->SetFrameCellOk(frame_cell_ok.data()); logger.Info("RotationScaleMerge: GPU scaling + combine + scale-fulls + merge active"); } - // The device now holds every per-obs field the resident pipeline reads, so the host copy's one - // remaining reader is RockingEventFrames - which on this path sees only ingest-time values (see - // the header). Take its answer now and hand the array back. The dump path keeps it: the CPU - // combine is what writes the dump. - if (gpu_active_ && observation_dump_path.empty()) { + // The device now holds every per-obs field the resident pipeline reads, so the host record's + // one remaining reader is RockingEventFrames - which on this path sees only ingest-time values + // (see the header). Take its answer now and hand the record back. The dump path keeps the full + // Obs array: the CPU combine is what writes the dump. + if (resident_ingest) { rocking_event_frames_at_ingest = RockingEventFrames(); - std::vector().swap(partials); + std::vector().swap(partials_ingest); partials_released = true; } #endif @@ -663,9 +690,99 @@ void RotationScaleMerge::BuildInRangeObservations(std::vector &keys) { // The observations themselves, one frame per thread. finite_ok is the per-obs AcceptReflection // finiteness (immutable) - it lets ComputeAsuGroups stamp the ASU-group id per obs from a flat // 1-byte array instead of re-reading the fat Obs struct for every space group. + finite_ok.assign(n_keep, 0); +#ifdef JFJOCH_USE_CUDA + if (resident_ingest) { + // The resident path: build the narrow host record, then stage every immutable per-obs field + // to the device straight from the source reflections, in frame-aligned bounded slices. The + // 80-byte Obs is never materialised here - beside the source reflections it was another + // copy of the whole payload - and the slice buffers are bounded whatever the run. + partials_ingest.clear(); + partials_ingest.resize(n_keep); + corr_ingested.resize(n_keep); + ParallelFor(n_frames, nthreads, [&](int o) { + int32_t src = src_start[o]; + int32_t at = frame_start[o]; + for (const auto &r : partials_out[o].reflections) { + if (!keep[src]) { ++src; continue; } + ++src; + ObsIngest &obs = partials_ingest[at]; + obs.h = r.h; obs.k = r.k; obs.l = r.l; + obs.frame = o; + obs.image_number = r.image_number; + obs.d = r.d; + obs.zeta = r.zeta; + obs.delta_phi = r.delta_phi_deg; + obs.partiality = r.partiality; + const float prescaling_corr = static_cast( + r.prescaling_corr * r.qe_corr * r.flight_corr / frame_flux[o]); + corr_ingested[at] = r.image_scale_corr; + finite_ok[at] = (std::isfinite(r.I) && std::isfinite(prescaling_corr) + && prescaling_corr != 0.0f + && std::isfinite(r.sigma) && r.sigma > 0.0f) ? 1 : 0; + obs.rock_ok = (std::isfinite(r.image_scale_corr) && r.image_scale_corr > 0.0f + && std::isfinite(r.I) && std::isfinite(r.sigma) && r.sigma > 0.0f) + ? 1 : 0; + ++at; + } + }); + + gpu_->SetPartialsLayout(n_keep, n_frames, frame_start.data(), frame_count.data()); + using F = RotationScaleMergeGPU::ObsField; + constexpr int STAGE_SLICE = 8 << 20; + constexpr int NFLOAT = 11; + int max_frame_obs = 0; + for (int o = 0; o < n_frames; ++o) max_frame_obs = std::max(max_frame_obs, static_cast(frame_count[o])); + const int bufsize = std::max(1, std::max(STAGE_SLICE, max_frame_obs)); + std::unique_ptr fbuf[NFLOAT]; + for (auto &b : fbuf) b.reset(new float[bufsize]); + const std::unique_ptr frm(new int32_t[bufsize]); + const std::unique_ptr onice(new uint8_t[bufsize]); + int f0 = 0; + while (f0 < n_frames) { + int f1 = f0 + 1; + while (f1 < n_frames && frame_start[f1] + frame_count[f1] - frame_start[f0] <= bufsize) + ++f1; + const int32_t base = frame_start[f0]; + const int cnt = frame_start[f1 - 1] + frame_count[f1 - 1] - base; + if (cnt > 0) { + ParallelFor(f1 - f0, nthreads, [&](int fo) { + const int o = f0 + fo; + int32_t src = src_start[o]; + int at = frame_start[o] - base; + for (const auto &r : partials_out[o].reflections) { + if (!keep[src]) { ++src; continue; } + ++src; + fbuf[0][at] = r.I; + fbuf[1][at] = r.sigma; + fbuf[2][at] = static_cast( + r.prescaling_corr * r.qe_corr * r.flight_corr / frame_flux[o]); + fbuf[3][at] = r.zeta; + fbuf[4][at] = r.image_scale_corr; + fbuf[5][at] = r.bkg; + fbuf[6][at] = r.var_bkg; + fbuf[7][at] = r.image_number; + fbuf[8][at] = r.d; + fbuf[9][at] = r.predicted_x; + fbuf[10][at] = r.predicted_y; + frm[at] = o; + onice[at] = r.on_ice_ring ? 1 : 0; + ++at; + } + }); + constexpr F fields[NFLOAT] = {F::I, F::Sigma, F::PrescalingCorr, F::Zeta, F::Corr0, + F::Bkg, F::VarBkg, F::ImageNumber, F::D, F::Px, F::Py}; + for (int k = 0; k < NFLOAT; ++k) + gpu_->SetObsField(fields[k], base, cnt, fbuf[k].get()); + gpu_->SetObsFrame(base, cnt, frm.get()); + gpu_->SetObsOnIce(base, cnt, onice.get()); + } + f0 = f1; + } + } else { +#endif partials.clear(); partials.resize(n_keep); - finite_ok.assign(n_keep, 0); ParallelFor(n_frames, nthreads, [&](int o) { int32_t src = src_start[o]; int32_t at = frame_start[o]; @@ -692,6 +809,9 @@ void RotationScaleMerge::BuildInRangeObservations(std::vector &keys) { ++at; } }); +#ifdef JFJOCH_USE_CUDA + } +#endif n_partials_obs = n_keep; @@ -761,7 +881,7 @@ namespace { // window would over-smooth a fast-slipping crystal and under-smooth a sparse one. The frame's own // value is excluded when scoring, so a window is only preferred if it PREDICTS that frame better, // which is exactly the noise-versus-drift trade. -void RotationScaleMerge::SmoothGeometry() { +template void RotationScaleMerge::SmoothGeometry(std::vector &obs) { const auto gon = x.GetGoniometer(); if (!gon || n_frames < 5) return; @@ -868,10 +988,10 @@ void RotationScaleMerge::SmoothGeometry() { } std::atomic changed{0}; - ParallelChunks(static_cast(partials.size()), nthreads, [&](int lo, int hi) { + ParallelChunks(static_cast(obs.size()), nthreads, [&](int lo, int hi) { int64_t local = 0; for (int i = lo; i < hi; ++i) { - auto &o = partials[i]; + auto &o = obs[i]; if (o.frame < 0 || o.frame >= n_frames || !ok[o.frame]) continue; const std::array &r = frame_recip[o.frame]; @@ -889,11 +1009,16 @@ void RotationScaleMerge::SmoothGeometry() { logger.Info("Smoothed per-frame geometry over +-{} frames (chosen by cross-validation); " "recomputed delta_phi for {} of {} partials", best_half, changed.load(), - partials.size()); + obs.size()); } void RotationScaleMerge::SmoothMosaicityAndPartiality() { - SmoothGeometry(); + if (resident_ingest) SmoothMosaicityAndPartialityOn(partials_ingest); + else SmoothMosaicityAndPartialityOn(partials); +} + +template void RotationScaleMerge::SmoothMosaicityAndPartialityOn(std::vector &obs) { + SmoothGeometry(obs); // One rocking event, one exact-Bragg angle. Every partial's delta_phi is solved from its OWN // frame's lattice - by the predictor, and again by SmoothGeometry above - so the per-frame @@ -919,15 +1044,15 @@ void RotationScaleMerge::SmoothMosaicityAndPartiality() { int i = lo; while (i < hi) { int j = i + 1; - while (j < hi && partials[perm[j]].image_number - - partials[perm[j - 1]].image_number <= max_frame_gap) + while (j < hi && obs[perm[j]].image_number + - obs[perm[j - 1]].image_number <= max_frame_gap) ++j; if (j - i >= 2) { - const float f0 = partials[perm[i]].image_number; + const float f0 = obs[perm[i]].image_number; double sum = 0.0; int n = 0; for (int m = i; m < j; ++m) { - const auto &o = partials[perm[m]]; + const auto &o = obs[perm[m]]; if (!std::isfinite(o.delta_phi)) continue; sum += o.delta_phi + increment_deg * (o.image_number - f0); ++n; @@ -935,7 +1060,7 @@ void RotationScaleMerge::SmoothMosaicityAndPartiality() { if (n >= 2) { const double c = sum / n; for (int m = i; m < j; ++m) { - auto &o = partials[perm[m]]; + auto &o = obs[perm[m]]; o.delta_phi = static_cast(c - increment_deg * (o.image_number - f0)); } @@ -1010,9 +1135,9 @@ void RotationScaleMerge::SmoothMosaicityAndPartiality() { // partiality recomputed here matches the one integration was based on. sin(theta_B) = lambda/(2d). const double bandwidth_sigma = x.GetBandwidthFWHM().value_or(0.0f) / 2.3548; const double half_wavelength_A = x.GetWavelength_A() / 2.0; - ParallelChunks(static_cast(partials.size()), nthreads, [&](int lo, int hi) { + ParallelChunks(static_cast(obs.size()), nthreads, [&](int lo, int hi) { for (int i = lo; i < hi; ++i) { - auto &o = partials[i]; + auto &o = obs[i]; float mos = mos_smooth[o.frame]; if (bandwidth_sigma > 0.0 && o.d > 0.0f) { const double sin_theta = half_wavelength_A / o.d; @@ -2004,6 +2129,21 @@ namespace { int RotationScaleMerge::RockingEventFrames() const { if (partials_released) return rocking_event_frames_at_ingest; + if (resident_ingest) // Ingest's own call, before the narrow record is handed back + return RockingEventFramesOver( + [&](int i) { return partials_ingest[i].rock_ok != 0; }, + [&](int i) { return partials_ingest[i].image_number; }); + return RockingEventFramesOver( + [&](int i) { + const Obs &o = partials[i]; + return std::isfinite(o.corr) && o.corr > 0.0f && std::isfinite(o.I) + && std::isfinite(o.sigma) && o.sigma > 0.0f; + }, + [&](int i) { return partials[i].image_number; }); +} + +template +int RotationScaleMerge::RockingEventFramesOver(UsableFn usable, ImgFn img) const { std::vector hist(n_frames + 2, 0); std::vector im; int n_event = 0; @@ -2011,10 +2151,9 @@ int RotationScaleMerge::RockingEventFrames() const { const int lo = rawrun_start[r], hi = lo + rawrun_count[r]; im.clear(); for (int p = lo; p < hi; ++p) { - const Obs &o = partials[perm[p]]; - if (std::isfinite(o.corr) && o.corr > 0.0f && std::isfinite(o.I) - && std::isfinite(o.sigma) && o.sigma > 0.0f) - im.push_back(o.image_number); + const int i = perm[p]; + if (usable(i)) + im.push_back(img(i)); } for (size_t i = 0; i < im.size();) { size_t k = i + 1; diff --git a/image_analysis/scale_merge/RotationScaleMerge.h b/image_analysis/scale_merge/RotationScaleMerge.h index 0adeeea70..f7cdccf40 100644 --- a/image_analysis/scale_merge/RotationScaleMerge.h +++ b/image_analysis/scale_merge/RotationScaleMerge.h @@ -128,6 +128,22 @@ private: // re-run over the samples that survive the automatic resolution cutoff. See MergeAndStats. struct Sample { double s2, I2, dev2; float d; }; + // The record the ingest keeps on the host when the device pipeline is resident (a GPU and no + // observation dump): every immutable per-obs field is uploaded straight from the source + // reflections inside BuildInRangeObservations, and the host keeps only what its own remaining + // stages touch - the geometry smoothing (delta_phi, then the recomputed partiality), plus the + // rocking-event walk's inputs. Forty bytes against the Obs's eighty, and on that path the fat + // record is never materialised at all: on a fine-sliced long axis it alone is many gigabytes, + // held beside the source reflections it was copied from. + struct ObsIngest { + int32_t h, k, l, frame; + float image_number, d, zeta, delta_phi, partiality; + // The rocking-event usability test (RockingEventFrames), evaluated on the ingest-time + // values - which is what that walk reads on the resident path, where the host corr is + // never refreshed (see partials_released). + uint8_t rock_ok; + }; + // The narrow per-observation record the ingest sort orders: the raw hkl the runs are cut on, the // frame position that breaks a tie inside one, the observation's own index (which makes the order // total - see the .cpp), and the resolution the range test reads. Twenty-four bytes against the @@ -190,8 +206,12 @@ private: double resolution_cc_target = 0.30; int report_shell_count = 9; - // Flat buffers, allocated once by Ingest() and reused across Run() calls. + // Flat buffers, allocated once by Ingest() and reused across Run() calls. Exactly one of the + // two observation arrays is built: the narrow resident record when the device pipeline is + // active and no observation dump was asked for (resident_ingest), the full Obs otherwise. std::vector partials; // all per-frame partials, grouped by frame + std::vector partials_ingest; // the resident-path record (same order) + bool resident_ingest = false; // How many observations Ingest built (== partials.size() until any release below), so the // sizes survive when the array itself does not. int n_partials_obs = 0; @@ -404,8 +424,9 @@ private: // per-frame partials of one rocking event tile the curve consistently (they sum toward 1) before the // 3D combine. Deterministic (frame order); replaces the old arrival-order mosaicity moving average // that prediction applied. SG-independent, so done once in Ingest. - void SmoothGeometry(); - void SmoothMosaicityAndPartiality(); + template void SmoothGeometry(std::vector &obs); + void SmoothMosaicityAndPartiality(); // dispatch: the resident narrow record or the full Obs + template void SmoothMosaicityAndPartialityOn(std::vector &obs); void Combine(); // partials -> fulls (CPU) @@ -470,6 +491,8 @@ private: // Walked on the partials, because the combine also runs on the GPU and a full keeps only the frame // of its peak partial. [[nodiscard]] int RockingEventFrames() const; + template + [[nodiscard]] int RockingEventFramesOver(UsableFn usable, ImgFn img) const; // Per-batch delta-CC1/2 on the corrected fulls: measure what keeping each batch of the sweep costs // the merged intensities, convict the batches that cost significantly, slide the conviction's edges // onto the frames that carry it, and turn the result into the disposition ledger. Fills From d752a78ee5532d9e660681cbefa15bdb87e110eb Mon Sep 17 00:00:00 2001 From: Filip Leonarski Date: Wed, 16 Sep 2026 14:23:54 +0200 Subject: [PATCH 3/3] docs: the rc.170 memory line reflects the further scale/merge reduction Co-Authored-By: Claude Opus 5 (1M context) --- docs/CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 4b02e4bc1..19d2af2f1 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -4,7 +4,7 @@ ### 1.0.0-rc.170 * Fixed a `jfjoch_broker` crash during indexing: a non-finite value reaching the FFT shortlist, space-group operator ranking or indexing-refinement sorts no longer causes undefined behaviour, and GPU FFT indexer kernel launches are now error-checked. -* rugnux needs about a third less peak memory to scale, merge and post-refine rotation data with tens of millions of partial observations, with identical results. +* rugnux needs about 40% less peak memory to scale, merge and post-refine rotation data with tens of millions of partial observations, with identical results. * `rugnux --model`: the placed coordinate file carries the space group its own coordinates obey, and says so when that is not the group the reflection files beside it carry. * `jfjoch_viewer`: fixes in the dataset plots (the stacked plots line up their y axes, the horizontal crosshair follows the cursor, a run in progress labels the angles of its whole sweep), in the inspector (a long dataset name wraps instead of being cut off, a live session shows its sample-head angles) and in the layout (the resize handle beside the file panel is centred).