From 9656a3b2357edceda65c7086e743dd7c3885ccb5 Mon Sep 17 00:00:00 2001 From: Filip Leonarski Date: Sat, 10 Oct 2026 00:00:44 +0200 Subject: [PATCH 1/2] SMV reader: convert frames straight out of the page cache (mmap) The geometry walk re-integrates the whole sweep in every round, and on an uncompressed SMV sweep that image loop is bound by memory traffic: smv::ReadInto was the top function (33% of all user cycles on 2xfw), and 16 threads read 360 frames no faster than 1 thread. On Linux/macOS the frame is now mapped and widened from the mapping, which drops the read() copy of the whole file into a scratch buffer. Windows keeps the ifstream read. Pixel values are unchanged; every pass benefits, not only the walk. Measured (RTX 5080 workstation, load 9-23 from other agents, 2 repeats, base = rc175 0457cecaf, same flags): 2xfw WALL 48.15/48.33 -> 40.49/41.98 s, Geometry check 17.2/17.4 -> 13.6/13.8 s 6zqy WALL 26.10/.. -> 23.60/23.16 s, Geometry check 8.6 -> 7.1/7.2 s 2ygz WALL 32.05 -> 26.27 s -N 8: 2xfw 50.09 -> 42.82 s, 6zqy 26.78 -> 22.97 s peak RSS -0.5 GB (no scratch copy) Microbenchmark, 360 2xfw frames, 16 threads: read 1.8-1.9 s, mmap 1.2-1.4 s. p.hkl md5 identical to the base on every set run (2xfw, 6zqy, 2ygz, -N 8, and the small SMV sets of the open arm). Tried first and dropped: keeping the decoded frames in memory across the walk's rounds. Exact, but filling 13.6 GB of fresh memory cost more than the hits saved (2xfw round 1 +2.4 s, release +0.9 s, later rounds -0.8 s each; CBF sets 5m17/7ph1 got slower), at +12.5 GB peak RSS. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01SVmAWnzCmRKAXVUCdc4iNi --- reader/SMV.cpp | 46 ++++++++++++++++++++++++++++++++++++++++++---- reader/SMV.h | 3 ++- 2 files changed, 44 insertions(+), 5 deletions(-) diff --git a/reader/SMV.cpp b/reader/SMV.cpp index c497afb36..16c30011c 100644 --- a/reader/SMV.cpp +++ b/reader/SMV.cpp @@ -15,6 +15,13 @@ #include "../common/JFJochException.h" +#ifndef _WIN32 +#include +#include +#include +#include +#endif + namespace smv { namespace { @@ -369,18 +376,49 @@ Header ReadInto(const std::string &path, int32_t *out, size_t capacity, std::vec throw JFJochException(JFJochExceptionCategory::ArrayOutOfBounds, path + ": image is larger than the buffer given for it"); + const size_t image_bytes = npixel * sizeof(uint16_t); + const auto too_short = [&] { + return JFJochException(JFJochExceptionCategory::InputParameterInvalid, + path + ": file ends before the image does"); + }; +#ifdef _WIN32 std::ifstream f(path, std::ios::binary); if (!f) throw JFJochException(JFJochExceptionCategory::MockFileOpenError, "Cannot open " + path); f.seekg(static_cast(h.data_offset), std::ios::beg); - scratch.resize(npixel * sizeof(uint16_t)); + scratch.resize(image_bytes); f.read(reinterpret_cast(scratch.data()), static_cast(scratch.size())); if (static_cast(f.gcount()) != scratch.size()) - throw JFJochException(JFJochExceptionCategory::InputParameterInvalid, - path + ": file ends before the image does"); + throw too_short(); + const uint8_t *src = scratch.data(); +#else + // Mapped rather than read: the pixels are converted straight out of the page cache, where a read + // first copies the whole frame into a buffer. The image loop that reads a sweep - again in every + // pass, and in every round of the geometry walk - is bound by memory traffic, and the copy adds the + // whole file to it: measured on a 9 Mpx sweep of 360 frames, mapping took a seventh off the run. + (void) scratch; + const int fd = open(path.c_str(), O_RDONLY); + if (fd < 0) + throw JFJochException(JFJochExceptionCategory::MockFileOpenError, "Cannot open " + path); + struct stat st{}; + if (fstat(fd, &st) != 0 || static_cast(st.st_size) < h.data_offset + image_bytes) { + close(fd); + throw too_short(); + } + const size_t mapped_bytes = h.data_offset + image_bytes; + void *mapped = mmap(nullptr, mapped_bytes, PROT_READ, MAP_PRIVATE, fd, 0); + close(fd); + if (mapped == MAP_FAILED) + throw JFJochException(JFJochExceptionCategory::MockFileOpenError, "Cannot map " + path); + struct Unmap { + void *p; + size_t bytes; + ~Unmap() { munmap(p, bytes); } + } unmap{mapped, mapped_bytes}; + const uint8_t *src = static_cast(mapped) + h.data_offset; +#endif // Stored unsigned 16-bit, handed out as the signed 32-bit the rest of the code reads. - const uint8_t *src = scratch.data(); if (h.little_endian) { for (size_t i = 0; i < npixel; i++) out[i] = static_cast(static_cast(src[2 * i] | (src[2 * i + 1] << 8))); diff --git a/reader/SMV.h b/reader/SMV.h index 9bbf3a14d..708a5d17a 100644 --- a/reader/SMV.h +++ b/reader/SMV.h @@ -85,7 +85,8 @@ Header ReadHeader(const std::string &path); Header Read(const std::string &path, std::vector &out); // The same, widening the stored pixels into memory the caller already has. scratch carries the -// raw bytes between frames so a worker walking a sweep allocates once. +// raw bytes between frames so a worker walking a sweep allocates once - on Windows only; elsewhere the +// file is mapped and scratch is not used. Header ReadInto(const std::string &path, int32_t *out, size_t capacity, std::vector &scratch); } // namespace smv From 943d3fc65568ca59e9a3322d6f393b8e8f6013b4 Mon Sep 17 00:00:00 2001 From: Filip Leonarski Date: Sat, 10 Oct 2026 00:34:18 +0200 Subject: [PATCH 2/2] rugnux: no serial CUDA context creation before the run, parallel teardown set_gpu_blocking_sync() called cudaSetDevice() on every card, and since CUDA 12 that creates the card's primary context: ~0.18 s per card on the workstation (measured, RTX 5080), one after another, before WALL_TIME starts. On the 4-card server that is most of the 1.73 s per run spent outside WALL_TIME (vs 0.37 s on one card). - The blocking-sync flag is now set with the driver's cuDevicePrimaryCtxSetFlags, taken through cudaGetDriverEntryPointByVersion (no link against libcuda, so a CUDA build still starts without a driver). It creates no context; contexts created later carry the flag (cudaGetDeviceFlags 0xc, as before; user CPU time unchanged, so no spinning). - Card 0's context is created by the existing cuFFT warm-up thread, beside the file open and pre-scan; cards 1..N-1 get theirs on a new background thread per card (create_gpu_contexts), as the pre-scan uses card 0 only. - At exit the contexts are destroyed by one thread per card (release_gpu_contexts, cudaDeviceReset) after the run has destroyed everything that used them, instead of serially by the runtime's exit handler. Microbenchmark: an explicit reset costs the same as the implicit teardown on one card (175 ms), so no change on one card. Which card does what is unchanged; only when contexts are made/destroyed. Measured on the 1-card workstation (load 7-16, 2 repeats x 2 sets plus 2 earlier pairs on myob): process start -> first log line median 0.39 -> 0.18 s; last log line -> exit 0.18 -> 0.20 s (one context either way); elapsed - WALL_TIME median 0.53 -> 0.37 s; WALL_TIME unchanged (context creation now overlaps the input read). The multi-card gain (3 serial context creations at start, 4 serial destroys at exit) cannot be measured here. p.hkl md5 identical on myob_x10sa, cytc_x10sa, 2xfw, 6zqy -N 8; all runs exit 0. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01SVmAWnzCmRKAXVUCdc4iNi --- common/CUDAWrapper.cpp | 4 ++++ common/CUDAWrapper.cu | 50 ++++++++++++++++++++++++++++++++++-------- common/CUDAWrapper.h | 12 +++++++++- rugnux/rugnux_cli.cpp | 9 +++++++- 4 files changed, 64 insertions(+), 11 deletions(-) diff --git a/common/CUDAWrapper.cpp b/common/CUDAWrapper.cpp index 4c6f53f25..19838a246 100644 --- a/common/CUDAWrapper.cpp +++ b/common/CUDAWrapper.cpp @@ -75,6 +75,10 @@ void enable_gpu_numa_binding() {} void set_gpu_blocking_sync() {} +void create_gpu_contexts(int32_t) {} + +void release_gpu_contexts() {} + void cuda_clear_error() {} void cuda_throw_if_context_lost() {} diff --git a/common/CUDAWrapper.cu b/common/CUDAWrapper.cu index 440f5eb60..f150cf101 100644 --- a/common/CUDAWrapper.cu +++ b/common/CUDAWrapper.cu @@ -2,10 +2,13 @@ // SPDX-License-Identifier: GPL-3.0-only #include +#include #include #include #include +#include + #include "CUDAWrapper.h" #include "ThreadAffinity.h" #include "JFJochException.h" @@ -116,16 +119,45 @@ void pin_gpu() { } void set_gpu_blocking_sync() { - const int32_t count = get_gpu_count(); - for (int32_t i = 0; i < count; i++) { - if (cudaSetDevice(i) != cudaSuccess) - continue; - // cudaErrorSetOnActiveProcess where a context exists already: it keeps its flags. - if (cudaSetDeviceFlags(cudaDeviceScheduleBlockingSync) != cudaSuccess) - cudaGetLastError(); + // Through the driver's primary-context flags, which a context created later takes: the runtime's + // cudaSetDevice creates the device's context, serially, and on four cards that was most of a + // second before the run had started. The function is taken from the driver at run time, so the + // program does not link against it and still starts on a machine without one. + void *fn = nullptr; + cudaDriverEntryPointQueryResult found{}; + if (get_gpu_count() == 0 + || cudaGetDriverEntryPointByVersion("cuDevicePrimaryCtxSetFlags", &fn, 12000, cudaEnableDefault, + &found) != cudaSuccess + || found != cudaDriverEntryPointSuccess) { + cudaGetLastError(); + return; } - if (count > 0) - cudaSetDevice(0); + const auto set_flags = reinterpret_cast(fn); + for (int32_t i = 0; i < get_gpu_count(); i++) + set_flags(i, CU_CTX_SCHED_BLOCKING_SYNC); +} + +void create_gpu_contexts(int32_t first_device) { + std::vector threads; + for (int32_t i = first_device; i < get_gpu_count(); i++) + threads.emplace_back([i] { + // A device that fails here fails again, with its error, where it is used. + if (cudaSetDevice(i) != cudaSuccess || cudaFree(nullptr) != cudaSuccess) + cudaGetLastError(); + }); + for (auto &t : threads) + t.join(); +} + +void release_gpu_contexts() { + std::vector threads; + for (int32_t i = 0; i < get_gpu_count(); i++) + threads.emplace_back([i] { + if (cudaSetDevice(i) != cudaSuccess || cudaDeviceReset() != cudaSuccess) + cudaGetLastError(); + }); + for (auto &t : threads) + t.join(); } void cuda_clear_error() { diff --git a/common/CUDAWrapper.h b/common/CUDAWrapper.h index d713176f3..b7405aca6 100644 --- a/common/CUDAWrapper.h +++ b/common/CUDAWrapper.h @@ -50,9 +50,19 @@ private: // Have every GPU's host threads BLOCK in a synchronisation (cudaDeviceScheduleBlockingSync) instead // of spinning on a core until the device finishes. Must be called before anything creates a CUDA -// context; a device that already has one keeps the flags it was created with. No-op without CUDA. +// context; it creates none itself. No-op without CUDA. void set_gpu_blocking_sync(); +// Create the CUDA context of every device from first_device on, one thread per device, and return +// when they all exist - for a caller that runs it beside other work, so that the first use of a card +// does not wait for its context. No-op without CUDA. +void create_gpu_contexts(int32_t first_device); + +// Destroy every device's CUDA context, one thread per device, for a program about to exit with no +// CUDA work left: at exit the runtime destroys them one after another, a fifth of a second per card. +// No-op without CUDA. +void release_gpu_contexts(); + // Drop the error CUDA has recorded for the calling thread. Call it where a CUDA failure has been // HANDLED - a device route that fell back to the host, an indexing attempt whose failure was turned // into a result - because the error otherwise stays as the thread's last error and the next diff --git a/rugnux/rugnux_cli.cpp b/rugnux/rugnux_cli.cpp index 6524fb581..8725421c1 100644 --- a/rugnux/rugnux_cli.cpp +++ b/rugnux/rugnux_cli.cpp @@ -1589,6 +1589,10 @@ static int RunRugnux(int argc, char **argv) { // Off the critical path, so cuFFT's one-time initialisation overlaps the file open, the mask read // and the beam-stop pre-scan instead of landing inside the first pass. auto cufft_warmup = std::async(std::launch::async, WarmUpCuFFT); + // The same for the CUDA contexts of the other cards (the warm-up above creates card 0's), which + // the pre-scan does not use: created one after another at start-up, they cost a fifth of a second + // per card before the run began. + auto context_warmup = std::async(std::launch::async, create_gpu_contexts, 1); // 1. Read Input File. An HDF5 master, one PILATUS miniCBF frame, or one marCCD frame - naming // any frame of a sweep reads the whole sweep, which is how these are archived and how a user @@ -3220,7 +3224,10 @@ int main(int argc, char **argv) { // run, a fifth of all CPU time went to that spinning, with no change in wall time either way. set_gpu_blocking_sync(); enable_gpu_numa_binding(); - return RunRugnux(argc, argv); + const int ret = RunRugnux(argc, argv); + // Everything that used a card has been destroyed with the run. + release_gpu_contexts(); + return ret; } catch (const std::exception &e) { Logger("rugnux").Error("{}", FailureText(e)); return EXIT_FAILURE;