Files
Jungfraujoch/common/CUDAWrapper.cu
jungfrauandClaude Opus 5 088ba1cff8
Build Packages / build:viewer-tgz:cpu (push) Successful in 19m27s
Build Packages / build:windows:nocuda (push) Successful in 20m0s
Build Packages / build:viewer-tgz:cuda (push) Successful in 21m29s
Build Packages / build:rpm (rocky8) (push) Failing after 17s
Build Packages / build:rpm (ubuntu2404_nocuda) (push) Successful in 23m38s
Build Packages / build:rpm (rocky9_nocuda) (push) Successful in 24m41s
Build Packages / build:rpm (ubuntu2204_nocuda) (push) Successful in 29m28s
Build Packages / build:rpm (rocky8_nocuda) (push) Successful in 29m40s
Build Packages / build:rpm (rocky8_sls9) (push) Successful in 30m14s
Build Packages / build:rpm (rocky9_sls9) (push) Successful in 20m32s
Build Packages / XDS test (durin plugin) (push) Successful in 11m57s
Build Packages / build:windows:cuda (push) Successful in 21m56s
Build Packages / Generate python client (push) Successful in 31s
Build Packages / XDS test (JFJoch plugin) (push) Successful in 12m34s
Build Packages / Create release (push) Skipped
Build Packages / build:rpm (rocky9) (push) Successful in 21m12s
Build Packages / Build documentation (push) Successful in 57s
Build Packages / build:rpm (ubuntu2404) (push) Successful in 19m21s
Build Packages / build:rpm (ubuntu2204) (push) Successful in 22m0s
Build Packages / DIALS test (push) Successful in 17m22s
Build Packages / XDS test (neggia plugin) (push) Successful in 7m53s
Build Packages / Unit tests (push) Successful in 1h17m43s
Stop paying for workers and threads that do no work
Four changes, all from measuring why this 48-core machine was SLOWER than a
quarter of itself on a single job.

The image loop now takes a worker count of its own. It is GPU-bound, every
worker builds a private analysis engine of tens of megabytes of device and
pinned memory - and every one of those allocations implicitly synchronises the
device - so past a handful per card another worker adds setup and contention and
no throughput. Measured at about 23 ms of pure setup per extra worker, which is
why the penalty is WORSE on short runs: 200 images cost 0.86 s of loop at 12
workers and 2.25 s at 48. Capped at four per GPU, floor of eight. Every other
phase still gets the full thread count, because each one starts its own workers.

Ingest built its array with a serial push_back over every observation of every
frame - 63 million of them on the largest crystal here. Each frame's block offset
is known before anything is written, so the frames convert together, each still
written by one thread in its own order.

The pass that buckets observations by h was the single most expensive thing in a
large run - 24% of all cycles, in five instructions. It strided an array of
80-byte observations to read one 4-byte field, and its store address depended on
the loaded value, so the store buffer could not retire and the misses stopped
overlapping. The sweep that already reads every observation now copies h out as
it goes, and the bucketing walks that instead.

And the post-refine passes took the raw thread count. One of them runs 134 times
inside the rotation-scale fit, starting 48 threads each time to divide 390k terms
among them; it is gated on the work now, like everything else.

Measured on one crystal, N=48: 12.80 s -> 11.45 s, which is what 12 threads used
to cost, and on the best-matched pair the two are now level. On the heaviest
crystal ingest goes 13.5 s -> 9.9 s and the run 77.8 s -> 69.4 s. Battery 9m01s
-> 8m24s, space group 21/24, no failures.

Also restores get_gpu_numa_node() - the sysfs lookup deleted with NUMAHWPolicy -
and an opt-in CPU pin to that node behind JFJOCH_PIN_CPU_TO_GPU_NODE. It is off
because it measured neutral here: all four GPUs hang off two of the four nodes,
so pinning to them costs a worker the other half of the machine. It is kept for
boxes whose GPUs are spread over every socket. The old lookup had a latent bug -
CUDA reports the PCI id with upper-case hex and sysfs paths are lower case, so on
three of this machine's four GPUs it would have silently returned "unknown".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 10:53:47 -04:00

139 lines
4.8 KiB
Plaintext

// SPDX-FileCopyrightText: 2024 Filip Leonarski, Paul Scherrer Institute <filip.leonarski@psi.ch>
// SPDX-License-Identifier: GPL-3.0-only
#include <algorithm>
#include <atomic>
#include <cctype>
#include <cstdlib>
#include <fstream>
#include <string>
#ifdef __linux__
#include <pthread.h>
#include <sched.h>
#endif
#include "CUDAWrapper.h"
#include "JFJochException.h"
inline void cuda_err(cudaError_t val) {
if (val != cudaSuccess)
throw JFJochException(JFJochExceptionCategory::GPUCUDAError, cudaGetErrorString(val));
}
int32_t get_gpu_count() {
int device_count;
cudaError_t val = cudaGetDeviceCount(&device_count);
switch (val) {
case cudaSuccess:
return device_count;
case cudaErrorNoDevice:
case cudaErrorInsufficientDriver:
return 0;
default:
throw JFJochException(JFJochExceptionCategory::GPUCUDAError, cudaGetErrorString(val));
}
}
void set_gpu(int32_t dev_id) {
auto dev_count = get_gpu_count();
// Ignore if no GPU present
if (dev_count > 0) {
if ((dev_id < 0) || (dev_id >= dev_count))
throw JFJochException(JFJochExceptionCategory::InputParameterInvalid, "Device ID cannot be negative");
cuda_err(cudaSetDevice(dev_id));
}
}
// The NUMA node a GPU is attached to, from sysfs; -1 when unknown or not applicable. No libnuma:
// this is a file read. (This lookup existed before cc925b26 and is restored unchanged apart from
// lower-casing the bus id - CUDA reports hex digits in upper case on some drivers and sysfs paths
// are lower case.)
int get_gpu_numa_node(int32_t dev_id) {
#ifdef __linux__
if (dev_id < 0 || dev_id >= get_gpu_count())
return -1;
char buf[64] = {};
if (cudaDeviceGetPCIBusId(buf, static_cast<int>(sizeof(buf)), dev_id) != cudaSuccess)
return -1;
std::string bus_id(buf);
std::transform(bus_id.begin(), bus_id.end(), bus_id.begin(),
[](unsigned char c) { return static_cast<char>(std::tolower(c)); });
std::ifstream f("/sys/bus/pci/devices/" + bus_id + "/numa_node");
int node = -1;
if (!(f >> node))
return -1;
return node;
#else
(void) dev_id;
return -1;
#endif
}
#ifdef __linux__
namespace {
// Confine the calling thread to the cores of one NUMA node, read from sysfs. Its memory then
// follows by first touch, which is what actually matters - the placement of what the thread
// allocates, not the affinity itself.
void run_on_numa_node(int node) {
std::ifstream f("/sys/devices/system/node/node" + std::to_string(node) + "/cpulist");
std::string list;
if (!std::getline(f, list) || list.empty())
return;
cpu_set_t set;
CPU_ZERO(&set);
// cpulist is comma-separated singles and a-b ranges, e.g. "24-35" or "0,2,4-7".
size_t pos = 0;
while (pos < list.size()) {
size_t comma = list.find(',', pos);
const std::string item = list.substr(pos, comma == std::string::npos ? comma : comma - pos);
const size_t dash = item.find('-');
const int lo = std::atoi(item.c_str());
const int hi = dash == std::string::npos ? lo : std::atoi(item.c_str() + dash + 1);
for (int c = lo; c <= hi && c < CPU_SETSIZE; ++c)
CPU_SET(c, &set);
if (comma == std::string::npos) break;
pos = comma + 1;
}
if (CPU_COUNT(&set) > 0)
pthread_setaffinity_np(pthread_self(), sizeof(set), &set);
}
int numa_node_count() {
int n = 0;
while (std::ifstream("/sys/devices/system/node/node" + std::to_string(n) + "/cpulist").good())
++n;
return n;
}
}
#endif
void pin_gpu() {
static std::atomic<uint32_t> counter{0};
auto dev_count = get_gpu_count();
if (dev_count <= 0)
return;
const int32_t dev = static_cast<int32_t>(counter.fetch_add(1) % dev_count);
set_gpu(dev);
#ifdef __linux__
// Optionally also confine the thread to the cores local to that GPU. On a machine whose GPUs all
// hang off some of the sockets, a worker can otherwise sit on a socket with no GPU of its own and
// cross the interconnect for every transfer and every page it allocates. Off by default: it is a
// real trade - it also denies the thread the cores of the other sockets - and only measurement on
// a given box can say which way it goes. No-op unless there is more than one node and the GPU's
// node is known.
static const bool pin_cpu = [] {
const char *e = std::getenv("JFJOCH_PIN_CPU_TO_GPU_NODE");
return e && *e && *e != '0';
}();
if (!pin_cpu || numa_node_count() < 2)
return;
const int node = get_gpu_numa_node(dev);
if (node >= 0)
run_on_numa_node(node);
#endif
}