Build Packages / build:windows:nocuda (push) Successful in 16m31s
Build Packages / build:windows:cuda (push) Successful in 19m15s
Build Packages / build:viewer-tgz:cpu (push) Successful in 19m58s
Build Packages / build:viewer-tgz:cuda (push) Successful in 22m51s
Build Packages / build:rpm (ubuntu2404_nocuda) (push) Successful in 24m5s
Build Packages / build:rpm (rocky9_nocuda) (push) Successful in 25m3s
Build Packages / build:rpm (ubuntu2204_nocuda) (push) Successful in 28m51s
Build Packages / build:rpm (rocky8_nocuda) (push) Successful in 28m55s
Build Packages / build:rpm (rocky8_sls9) (push) Successful in 28m35s
Build Packages / build:rpm (rocky9_sls9) (push) Successful in 21m19s
Build Packages / XDS test (durin plugin) (push) Successful in 11m17s
Build Packages / build:rpm (rocky9) (push) Successful in 23m30s
Build Packages / Generate python client (push) Successful in 33s
Build Packages / build:rpm (rocky8) (push) Successful in 25m35s
Build Packages / Create release (push) Skipped
Build Packages / Build documentation (push) Successful in 1m37s
Build Packages / build:rpm (ubuntu2404) (push) Successful in 21m40s
Build Packages / XDS test (neggia plugin) (push) Successful in 10m17s
Build Packages / build:rpm (ubuntu2204) (push) Successful in 26m44s
Build Packages / XDS test (JFJoch plugin) (push) Successful in 11m10s
Build Packages / DIALS test (push) Successful in 23m27s
Build Packages / Unit tests (push) Successful in 1h19m59s
Two changes to how the per-image loop is set up, neither of which touches what it computes. Device buffers were taken with cudaMalloc and returned with cudaFree, both of which are on CUDA's implicit-synchronisation list: each one synchronises the device across every stream. One analysis engine per worker, each making a few dozen of them, means the workers still constructing stall the workers already processing images, and the cost grows with the worker count. They are now stream-ordered allocations from the device's memory pool, with the synchronous pair kept as the fallback where no pool is available. Two deliberate limits on that. The pool's release threshold is one gibibyte rather than unbounded: holding the small per-worker buffers is the whole point, but the card also has to fit the merge afterwards, which asks for several gigabytes of its own. And the shared geometry tables keep the synchronous allocator, because their deleter runs on whichever thread drops the last reference, so an asynchronous free there would be ordered on a stream that says nothing about the engine streams whose kernels read the table; they are allocated once per card, so the pool bought them nothing. The loop's worker cap per card goes from eight to sixteen. The comment beside it already recorded where the measurement put the minimum - the loop's time falls to sixteen workers and then rises - and a later measurement on a single card agrees: at eight the loop waits on the queue rather than on the card. An explicit -N is still obeyed as given. Reflection files are byte-identical on four crystals with the worker count doubled, which is the property the frame-ordered mosaicity smoothing and the deterministic prediction order were built to give. Thirty consecutive runs of one crystal on the pooled allocator: no failure, every file identical to the first. Peak device memory over the whole rotation test set is 4.9 of 16 gibibytes. Four minutes seventeen to four minutes one over thirty-eight crystals, each binary repeating itself to within half a per cent; nineteen crystals faster, nineteen level, none slower, and every column of the comparison table identical. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EGpGdgmJ8MyY9pCGWjktyi
117 lines
5.9 KiB
C++
117 lines
5.9 KiB
C++
// SPDX-FileCopyrightText: 2026 Filip Leonarski, Paul Scherrer Institute <filip.leonarski@psi.ch>
|
|
// SPDX-License-Identifier: GPL-3.0-only
|
|
|
|
#pragma once
|
|
|
|
#include <map>
|
|
#include <memory>
|
|
#include <mutex>
|
|
#include <tuple>
|
|
#include <utility>
|
|
|
|
#include "CUDAMemHelpers.h"
|
|
#include "../../common/TableChecksum.h"
|
|
|
|
// Read-only lookup tables that depend only on the detector geometry (pixel -> azimuthal bin, the
|
|
// per-pixel correction factors, the pixel mask). One analysis engine is built per worker thread, so
|
|
// each of those used to upload its own copy: on a 18 Mpx detector that is ~220 MB per thread, and
|
|
// 32 threads spent ~7 GB of device memory on identical data.
|
|
//
|
|
// Upload once per GPU instead and hand every engine on that GPU a shared pointer to the same table.
|
|
// The cache is keyed by (device, key) because a worker thread is pinned round-robin to a device
|
|
// (pin_gpu()), so on a multi-GPU node each device keeps its own copy - a kernel may only read memory
|
|
// resident on the device it runs on. `key` identifies the table's source data; use the address of the
|
|
// host vector that produced it, which lives in the experiment / integration mapping and therefore
|
|
// outlives every engine.
|
|
//
|
|
// A bare address is not enough on its own to say "same table", though: a host buffer can be mutated
|
|
// in place, or freed and a new one allocated at the same address, and either would hand the caller a
|
|
// device copy of something else - silently, since the data is only ever read. So the byte length and
|
|
// a checksum of the bytes actually uploaded are part of the key too.
|
|
//
|
|
// The checksum is part of the KEY, so it is computed before the lookup and a cache hit pays for it as
|
|
// well: the tables are tens of megabytes and one engine is built per worker per pass, which put the
|
|
// hashing alone at several percent of all CPU samples. The overload below therefore takes a checksum
|
|
// the caller already has. That does not weaken anything, because it is the buffer's OWNER that
|
|
// computes it - AzimuthalIntegrationMapping and PixelMask hash their tables whenever they write them
|
|
// and hand out the result - so the checksum still describes the bytes as they are now. What must not
|
|
// be done instead is to memoise the checksum on the address here: that is precisely the reused-address
|
|
// hazard the checksum exists to catch.
|
|
//
|
|
// Entries are held weakly, so the tables are released once the last engine using them is gone.
|
|
|
|
namespace jfjoch_cuda_shared_tables {
|
|
// (device, source address, byte length, checksum of the bytes)
|
|
using TableKey = std::tuple<int, const void *, size_t, uint64_t>;
|
|
|
|
struct Registry {
|
|
std::mutex m;
|
|
std::map<TableKey, std::weak_ptr<void>> tables;
|
|
};
|
|
|
|
inline Registry ®istry() {
|
|
static Registry r;
|
|
return r;
|
|
}
|
|
|
|
// Not called cuda_err: the .cu files that include this header define their own such helper in an
|
|
// anonymous namespace, and a second one at global scope would make every call ambiguous.
|
|
inline void check(cudaError_t val) {
|
|
if (val != cudaSuccess)
|
|
throw JFJochException(JFJochExceptionCategory::GPUCUDAError, cudaGetErrorString(val));
|
|
}
|
|
}
|
|
|
|
// Return the device-resident copy of `host` (`count` elements) for the calling thread's GPU,
|
|
// uploading it on `stream` the first time it is asked for. `host_checksum` is TableChecksum() of
|
|
// those `count * sizeof(T)` bytes, computed by whoever owns the buffer.
|
|
template <typename T>
|
|
std::shared_ptr<CudaDevicePtr<T>> SharedDeviceTable(const void *key, size_t count, const T *host,
|
|
uint64_t host_checksum, cudaStream_t stream) {
|
|
int device = 0;
|
|
jfjoch_cuda_shared_tables::check(cudaGetDevice(&device));
|
|
|
|
const size_t bytes = count * sizeof(T);
|
|
const jfjoch_cuda_shared_tables::TableKey table_key{device, key, bytes, host_checksum};
|
|
|
|
auto ® = jfjoch_cuda_shared_tables::registry();
|
|
// The upload happens while the lock is held: another worker must not obtain the pointer before
|
|
// its content is on the device.
|
|
std::lock_guard lock(reg.m);
|
|
if (auto it = reg.tables.find(table_key); it != reg.tables.end()) {
|
|
if (auto cached = it->second.lock())
|
|
return std::static_pointer_cast<CudaDevicePtr<T>>(cached);
|
|
}
|
|
// Drop entries whose table is gone before adding one. Without this a long session that reloads
|
|
// masks or remaps geometry accumulates a dead entry per distinct content, for ever.
|
|
for (auto it = reg.tables.begin(); it != reg.tables.end();)
|
|
it = it->second.expired() ? reg.tables.erase(it) : std::next(it);
|
|
|
|
// Free on the device that allocated it - the last engine to drop the table may well be a worker
|
|
// pinned to a different GPU. And synchronously, for the same reason: the thread that drops the
|
|
// last reference is not the one whose kernels read the table, so a stream-ordered free would be
|
|
// ordered against the wrong work. The table is allocated once per GPU, so it has nothing to gain
|
|
// from the pool anyway.
|
|
std::shared_ptr<CudaDevicePtr<T>> table(new CudaDevicePtr<T>(count, CudaAlloc::Synchronous),
|
|
[device](CudaDevicePtr<T> *p) {
|
|
int current = 0;
|
|
cudaGetDevice(¤t);
|
|
cudaSetDevice(device);
|
|
delete p;
|
|
cudaSetDevice(current);
|
|
});
|
|
jfjoch_cuda_shared_tables::check(
|
|
cudaMemcpyAsync(table->get(), host, bytes, cudaMemcpyHostToDevice, stream));
|
|
jfjoch_cuda_shared_tables::check(cudaStreamSynchronize(stream));
|
|
|
|
reg.tables[table_key] = std::shared_ptr<void>(table);
|
|
return table;
|
|
}
|
|
|
|
// Same, for a caller with no checksum of its own to hand over.
|
|
template <typename T>
|
|
std::shared_ptr<CudaDevicePtr<T>> SharedDeviceTable(const void *key, size_t count, const T *host,
|
|
cudaStream_t stream) {
|
|
return SharedDeviceTable(key, count, host, TableChecksum(host, count * sizeof(T)), stream);
|
|
}
|