The offline worker threads built MXAnalysisWithoutFPGA without selecting a CUDA device, so all per-image preprocessing/spot-finding/azimuthal integration ran on GPU 0 (only the indexer pool was distributed). Add pin_gpu() to CUDAWrapper - a process-wide round-robin counter (counter++ % get_gpu_count(), no thread id, no-op without a GPU, honours CUDA_VISIBLE_DEVICES) - and call it once per worker before building the analysis resources so their CUDA streams/engines land on distinct devices. Also add NUMA_GPU_REVIEW.md: a working note mapping ImageBuffer/NUMAHWPolicy/GPU dispatch with goals and a staged plan (multi-broker GPU isolation via CUDA_VISIBLE_DEVICES, dropping libnuma, reassessing NUMA pinning for the FPGA path). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
7.4 KiB
NUMA / GPU usage — current state, goals, proposed changes (for review)
Working note to agree on direction before modifying ImageBuffer / NUMAHWPolicy and the
GPU-dispatch logic. Nothing here is implemented yet. File:line anchors are from branch
2606-pixel-refine.
1. Current state (the mind map)
1a. ImageBuffer — the big RAM ring buffer
- One instance, a member of the receiver:
JFJochReceiverService::image_buffer(receiver/JFJochReceiverService.h:21), sizedimage_buffer_MiBfrom broker config (broker/jfjoch_broker.cpp:104→JFJochReceiverServicectorreceiver/JFJochReceiverService.cpp:15,image_buffer(send_buffer_size_MiB*1024*1024)). This is the 150–200 GB allocation. - Allocated + zeroed in the ctor (
common/ImageBuffer.cpp):numa_alloc_interleavedif libnuma, elsestd::malloc; then a single-threadedmemsetto pre-fault every page (deliberate — kills first-use page-fault latency in the hot path). Happens once at broker startup. - Producers/consumers: receiver/decompression threads write frames into slots; consumers are
preview/TIFF/JPEG/HTTP retrieval (
GetImage) and the ZMQ/file sender. Access is random and unpinned (any thread → any slot). - Not used by
jfjoch_processorjfjoch_viewer— they read HDF5 through the reader, never instantiateImageBuffer. So this buffer is broker/receiver-only (it only needs to compile for the viewer).
1b. NUMAHWPolicy — bundles three concerns per worker thread
Built from the broker config numa_policy string (e.g. n2g2, n8g4, n8g4_hbm) into a table of
NUMABinding{cpu_node, mem_node, gpu}; GetBinding(thread) = bindings[thread % nbindings]
(round-robin, common/NUMAHWPolicy.cpp:54). Bind(thread) does all three at once
(common/NUMAHWPolicy.cpp:61):
- CPU pin —
RunOnNode/numa_run_on_node - Memory bind —
MemOnNode/numa_set_membind(onn8g4_hbm,mem_node = i+8→ binds to HBM nodes) - GPU select —
SelectGPU/set_gpu(=cudaSetDevice, not libnuma)
Call sites:
receiver/JFJochReceiverFPGA.cpp:180/217/264— data-stream threadsRunOnNode(FPGA's NUMA node)(device-locality pin, the NIC-era idea applied to the FPGA card).receiver/JFJochReceiverFPGA.cpp:299,receiver/JFJochReceiverLite.cpp:234— analysis worker threadsnuma_policy.Bind(threadid)→ cpu + mem + GPU per the policy table.image_analysis/indexing/IndexerThreadPool.cpp:34— each indexer threadSelectGPUAndItsNUMA(threadid % gpu_count)(GPU round-robin + that GPU's own NUMA node; independent of thenuma_policytable).
libnuma is used in exactly three files: NUMAHWPolicy.cpp (the above), ImageBuffer.cpp
(numa_alloc_interleaved/numa_free), and CUDAWrapper.cpp (one numa_node lookup).
1c. GPU dispatch — and why jfjoch_process underuses GPUs
get_gpu_count()=cudaGetDeviceCount()(common/CUDAWrapper.*), so it already honoursCUDA_VISIBLE_DEVICES. All dispatch is% gpu_count.- Broker/receiver: worker threads spread over GPUs via
numa_policy.Bind(thegpufield) + the indexer pool viathreadid % gpu_count. → uses all visible GPUs. jfjoch_process: its worker lambda (tools/jfjoch_process.cpp:849, launchednthreadstimes) constructsMXAnalysisWithoutFPGAbut never callsBind/SelectGPU, andMXAnalysisWithoutFPGAitself does not select a device (image_analysis/MXAnalysisWithoutFPGA.cpp:38just builds GPU engines on the current device). → all per-image preprocessing / spot-finding / azimuthal integration run on GPU 0. Only the indexer pool spreads. This is the root cause of "jfjoch_processdoesn't use all GPUs."
2. Goals
- G1 — multiple brokers, disjoint GPUs. Run >1
jfjoch_brokeron one machine, each confined to a subset of GPUs, with the code transparently using "all it can see" (no hard-coded indices). Pure workload control, no security requirement. - G2 —
jfjoch_processshould use all visible GPUs, not just GPU 0. - G3 — drop the libnuma dependency if it doesn't cost real performance (annoying dep; also a blocker for the long-term Windows/MSVC viewer).
- G4 — reassess whether NUMA CPU/mem pinning is still worth it given the FPGA pipeline (DMA into kernel-mmap'd buffers, negligible IRQ traffic) rather than the old network-RX model.
3. Proposed changes
-
G1 (zero code): launch each broker under
CUDA_DEVICE_ORDER=PCI_BUS_ID CUDA_VISIBLE_DEVICES=<subset> jfjoch_broker ….get_gpu_count()/% gpu_countalready do the rest. Action item: document this (deployment note) and setCUDA_DEVICE_ORDER=PCI_BUS_IDso indices are stable across boots. -
G2 (DONE): added
pin_gpu()toCUDAWrapper— a process-wide round-robin counter (counter++ % get_gpu_count(), no thread id needed, no-op when no GPU). Thejfjoch_processworker calls it once before buildingMXAnalysisWithoutFPGA, so each worker's CUDA streams/engines land on a distinct device. Caller-agnostic and reusable by other thread pools later. -
G3 /
ImageBuffer: replacenuma_alloc_interleaved+ single-threadedmemsetwith plainmalloc+ a parallel first-touchmemset(N threads, unpinned). Threads spread by the scheduler → balanced placement ≈ interleave for random access, and faster startup, and no libnuma. Safe here because the buffer is 30–40 % of RAM (first-touch spills to the other node if one fills; no OOM; just checkvm.zone_reclaim_mode == 0). Deterministic per-page interleave (if ever needed under tight RAM) is a rawmbind(MPOL_INTERLEAVE)syscall — still libnuma-free. -
G3 / G4 /
NUMAHWPolicy: split the bundled concerns:- CPU pin (
RunOnNode) — likely drop for the FPGA path (G4). If ever wanted back, usesched_setaffinity(no libnuma). - GPU select (
SelectGPU/SelectGPUAndItsNUMA) — keep; alreadycudaSetDevice, no libnuma. - Memory bind (
MemOnNode) — drop. (HBM is out of scope: the only Xeon MAX box didn't pay off and isn't worth special-casing, so nombindpath is needed — treat every host as plain multi-socket.) CUDAWrappernuma_node— read the GPU PCIe device's/sys/.../numa_nodeinstead of libnuma.- Result:
NUMA_LIBRARYleaves the CMake entirely.
- CPU pin (
4. Open questions / to validate before deleting anything
- G4 is empirical. A/B at production frame rate (pinning on vs off): sustained throughput, dropped frames, latency jitter. The reasoning predicts "no regression," but measure on one real box first — production systems are currently tuned around this.
- Confirm
vm.zone_reclaim_modeis0on the broker hosts (else first-touch reclaims locally before spilling → latency stalls). - Parallel first-touch placement is approximate (depends on the scheduler spreading the zeroing
threads); fine with RAM headroom, but note it's not the guaranteed 50/50 of
mbindinterleave.
5. Suggested order (low-risk first)
- G1 — document
CUDA_VISIBLE_DEVICESlaunch (no code). G2 — per-worker GPU pin inDONE (jfjoch_process.pin_gpu()).- ImageBuffer parallel first-touch (drops one libnuma user, helps startup; stands alone).
- G4 A/B on a real broker; if clean, drop CPU pinning.
- NUMAHWPolicy simplify (keep GPU select, drop CPU pin + mem bind) +
CUDAWrappersysfs → removeNUMA_LIBRARYfrom CMake.