Add hardware pinning for QEC decoders#634
Conversation
Allow any registered decoder to be assigned to specific hardware through
both decoder kwargs and the realtime YAML configuration:
- cuda_device_id: GPU decoders
- numa_node_id: CPU decoders
Placement is applied generically at the decoder construction entry point
(decoder::get), so every registered decoder—including third-party plugins,
regardless of how their creator is implemented—is constructed on the
requested hardware. GPU resources are created on cuda_device_id, while
CPU decoder allocations first-touch the requested NUMA node. Negative or
out-of-range ids are rejected; omitted fields are a no-op.
Add public utilities for decoder authors:
- device_affinity.h (CUDA-free): read_cuda_device_id(),
read_numa_node_id(), and ScopedNumaNode, implemented using raw Linux
syscalls (set_mempolicy + sched_setaffinity), Linux-guarded with a
no-op fallback and no libnuma dependency.
- scoped_cuda_device.h: ScopedCudaDevice RAII helper for temporarily
setting and restoring the CUDA device.
The public decoder.h interface remains free of CUDA and NUMA
dependencies.
Support decode-time device re-assertion for decoders whose decode path
may execute on a different thread than construction (for example,
decode_async()). trt_decoder now uses ScopedCudaDevice for this purpose,
and other GPU decoders can opt in using the same public helper.
Wire both placement fields through the realtime configuration pipeline,
including the configuration structs, YAML serialization, Python
bindings, and runtime parameter injection.
Add tests covering:
- device_affinity readers and ScopedNumaNode restoration
- ScopedCudaDevice placement verified with
cudaPointerGetAttributes()
- generic decoder::get() validation for invalid hardware ids
- trt_decoder invalid-device rejection and decode_async placement
- YAML round-trip and runtime parameter injection
This change intentionally does not address nv-qldpc-decoder runtime
device handling or realtime-session/ring NUMA IPC pinning, which require
separate follow-up work.
Signed-off-by: kvmto <kmato@nvidia.com>
Move cuda_device_id and numa_node_id into the base decoder class. Private RAII guards (CudaDeviceGuard, NumaGuard) in decoder.cpp apply affinity at construction, decode_batch, and decode_async — no plugin code needed. Raw Linux syscalls, no libnuma. Remove the v1 per-plugin ScopedCudaDevice from trt_decoder; add an inline guard to its decode_batch override which bypasses the base class path. Signed-off-by: kvmto <kmato@nvidia.com>
|
I think this works, but I'm thinking we might have to do it differently for the realtime api. I think we either need to assume that the thread that creates the decoder is the thread to set affinity on (and just set it persistently during construction) or allow the user to set it outside of the decode/enqueue calls. Perhaps we could set some flags to at least notify the user that they forgot to set the affinity, instead of silently ignoring it. I'm not quite sure what all the user interfaces should be. It seems like, in a realtime system, we should launch a thread that has the decoder on it and just set the affinity once for that thread. |
…r decoders Add cuda_device_id/numa_node_id/cpu_affinity/mempolicy/pin_host_memory affinity controls to the base decoder, with safe-by-default posture (soft MPOL_PREFERRED, NUMA derived from the GPU, degrade-if-unknown). Pin via a persistent per-thread mechanism (bind_current_thread) so closed GPU decoders work without ABI changes; realtime session pins its dispatch thread and migrates ring buffers. Errors/violations are loud (throw on malformed/OOB, warn on OS-declined, info on auto-derive misses), and the affinity layer (hardware_affinity.h) is decoupled from the cudaq logger (plain-C diagnostics) so it is reusable standalone. Signed-off-by: kvmto <kmato@nvidia.com>
Add decoder_pool: runs a set of decoders concurrently, each on its own persistent worker thread pinned (bind_current_thread) to that decoder's CUDA device / NUMA node. Each decoder is constructed on its worker thread so its GPU resources land on-node. decode_all() fans a per-decoder workload out across the workers and aggregates results by id. Also add a pinned-worker decode helper and a loud, cudaq-decoupled warning when a decoder runs on a device that does not match its cuda_device_id. Tests: pool routing/aggregation; construct-time and decode-time GPU placement (eager/lazy probe decoders); two real trt decoders run concurrently across two GPUs; a gated nv-qldpc pool test that skips where the closed decoder is not built against this base. nv-qldpc-placement- derisk.md explains why the installed closed nv-qldpc cannot validate against a modified base. Signed-off-by: kvmto <kmato@nvidia.com>
Add decoder_pool::submit(id, chunk) -> future<vector<decoder_result>>: a non-blocking primitive that enqueues a chunk on that id's pinned worker and returns immediately, so callers can push syndromes over time and consume results as they resolve (a size-1 chunk streams a single syndrome). decode_all now routes through submit (all ids submitted before any get, so worker concurrency is unchanged). Tests: interleaved in-flight submits across two decoders (proves submit does not block); streamed chunks to two GPU-pinned workers stay on their assigned device across every chunk. Signed-off-by: kvmto <kmato@nvidia.com>
|
I will split the PR in two or three. |
…call gate - decoder::get(): strip the base-owned affinity keys (cuda_device_id, numa_node_id, mempolicy, cpu_affinity) from the options passed to the plugin constructor. Decoders that strictly validate their parameter keys (e.g. nv-qldpc-decoder) previously rejected them; the base consumes these keys, so plugins never need to see them. Verified with an unmodified nv-qldpc-decoder build: two instances construct and decode concurrently on distinct GPUs through decoder_pool. - realtime: fix the hardware_affinity.h include to be relative to the realtime/ subdirectory; a clean build could not resolve it. - unittests: give test_decoders_yaml the CUDA runtime include/link it needs since sample_decoder.cpp gained the GPU probe decoders. - nv-qldpc pool test: enable use_sparsity (required by its GPU batched path). - add a pinning benchmark lane: an LD_PRELOAD sched_setaffinity counter and a gate asserting a bound decode loop issues zero per-call affinity syscalls (with an unbound canary proving the counter fires), plus a loose A/B throughput report (bound path ~100x faster on a syscall-dominated micro-decode; gate is the deterministic check, timing is report-only). Signed-off-by: kvmto <kmato@nvidia.com>
…nning Signed-off-by: kvmto <kmato@nvidia.com> # Conflicts: # libs/qec/lib/decoder.cpp # libs/qec/lib/realtime/qec_realtime_session.cpp
Container runtimes commonly deny set_mempolicy/get_mempolicy (seccomp without CAP_SYS_NICE). The library already degrades gracefully there; the three tests that assert the syscalls' effects now probe first and skip instead of failing. Verified both ways: on a permissive host all 15 tests still run and pass; with the mempolicy family blocked the three skip and the rest pass. Signed-off-by: kvmto <kmato@nvidia.com>
Remove decoder_pool: out of scope for this PR. Two concurrently
constructed decoders cover the multi-GPU composition directly
(TwoTrtDecodersConcurrently).
Guard correctness:
- roll back mempolicy, CPU affinity, and CUDA device when
bind_current_thread() fails partway; never leave a half-bound thread
- decode_on_pinned_thread() restores the caller's binding after the
one-shot worker exits
- temporary guards never apply a mempolicy they cannot restore
(bind_this_thread_to_numa_node gains an apply_mempolicy switch)
- NUMA-bind failures in realtime session threads warn instead of
terminating the process
- trt_decoder::decode_batch() honors bind_current_thread() via
is_bound_here() (now protected) instead of re-binding every call
- device-restore failures in guard destructors warn instead of being
silently ignored
Tests:
- consolidate three probe decoders into one placement_probe_decoder
- LD_PRELOAD shim counts all four placement syscalls and can inject
get_mempolicy failures; test_trt_decoder now runs under it
- invariant coverage: every entry point x {bound, unbound} asserts
syscall counts and exact placement restoration; error-path rollback
and bind/pinned-thread interaction tests added
Signed-off-by: kvmto <kmato@nvidia.com>
Tests: placement-sensitive tests skip (not fail) where container seccomp blocks the mempolicy syscalls; persistent binds run on disposable worker threads so the gtest main thread stays clean; exact-core asserts probe the allowed cpuset first. Realtime session: HOST-mode ring buffers are page-aligned so the mbind(MPOL_MF_MOVE) migration actually succeeds (calloc pointers failed EINVAL with a misleading CAP_SYS_NICE warning); calloc's size-overflow protection restored; the dispatch thread registers via bind_current_thread() only when all decoders agree on cuda_device_id, numa_node_id, and cpu_affinity (disagreement keeps per-call guards active, with plain NUMA locality as fallback); new decoder::unbind_thread() clears registrations at stop_loops() so a recycled thread id can never silently skip the guards. Python: @qec.decoder-registered decoders get decode-time pinning — affinity kwargs are stripped before __init__ and stored on the C++ base (only the four affinity keys are converted; arbitrary kwargs pass through untouched); cuda_device_id()/numa_node_id() accessors exposed; read_cpu_affinity accepts Python-shaped double lists. Hygiene: license header on the affinity syscall shim; reserved identifiers _RestoreDevice/_ScopedNuma renamed. Pinning A/B (5000 decode_batch calls): bound 516.6us vs unbound 59301.4us. Signed-off-by: kvmto <kmato@nvidia.com>
Fault-injection tests for every placement syscall (blocked get/set mempolicy, sched affinity, mbind, and a fully-blocked container mode): decodes stay correct, degradation is loud, restores hold. Knob-contract negatives (typo'd key, wrong types naming the key, nonexistent NUMA node) and a silence contract for knobless users. Binding lifecycle negatives (rebind migration, cross-thread unbind, bind races). First runtime coverage of the session conflict gate (cpu_affinity/device/numa disagreement -> warn + guards stay active, corrections bit-correct). Python negatives for both get_decoder overloads. Perf-regression budgets: 7 placement syscalls and 1 sysfs read per unbound decode, enforced via the extended LD_PRELOAD shim (per-syscall fault injection, mbind and sysfs-open counters). Signed-off-by: kvmto <kmato@nvidia.com>
8443a5a to
5ea8f0f
Compare
…ne (#648) ## Why #634 adds hardware affinity (CUDA device / NUMA / CPU pinning) for QEC decoders, including a concurrent multi-decoder pool that places each decoder on its own GPU. Its test suite cannot currently run in CI for two infrastructure reasons: 1. **NUMA syscalls are blocked in the job containers.** Docker's default seccomp profile only admits `set_mempolicy` / `get_mempolicy` / `mbind` when the container holds `CAP_SYS_NICE`. Without it these syscalls return EPERM, so the hardware-affinity tests detect this and skip. 2. **No multi-GPU runner in the lane.** The QEC GPU job runs on `linux-<arch>-gpu-a100-latest-1` (single GPU), so every >= 2 GPU placement test skips. ## What One file changed (`.github/workflows/lib_qec.yaml`): - **`build-and-test`**: container gains `options: --cap-add=SYS_NICE`. This is the narrowest grant that unblocks the NUMA syscalls — note other NVIDIA repos' GPU CI (e.g. quantum-predecoder, Ising-Decoding) runs `--security-opt seccomp=unconfined`, which is strictly broader. - **New `multi-gpu-test` job**: runs the multi-GPU decoder placement tests on `linux-amd64-gpu-a100-latest-2`. Deliberately kept out of the per-PR critical path: - triggers only on merges to the default branch or manual `workflow_dispatch`; - `needs: build-and-test` and reuses its uploaded artifact (no rebuild); - `timeout-minutes: 45` so a missing runner label becomes a visible failure, not an indefinite queue; - explicit `nvidia-smi` check that >= 2 GPUs are visible; - `ctest --no-tests=error` so an empty test filter fails loudly instead of green-washing. The existing `gpu-test` job is untouched — PR CI keeps its current runner footprint and risk profile. ## What we need from DevOps 1. **Does `linux-amd64-gpu-a100-latest-2` exist in the runner pool?** The `-latest-<gpu-count>` naming pattern is in production use elsewhere (`linux-amd64-gpu-rtxpro6000-latest-2` in Ising-Decoding), but we could not confirm a 2-GPU A100 label from public config. If it does not exist, can one be provisioned — or should this job target a different 2-GPU pool? A `workflow_dispatch` run of this job is a safe empirical test. 2. **Is `--cap-add=SYS_NICE` acceptable on the fleet?** It is one capability (scheduler/affinity/NUMA-placement control inside the container), much narrower than the `seccomp=unconfined` already used by sibling repos. ## Merge ordering The test names in the `multi-gpu-test` filter land with #634. Merging this PR **after** #634 keeps the lane green from day one; merging before it means the `multi-gpu-test` job fails on main (by design — `--no-tests=error`) until #634 lands. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Signed-off-by: kvmto <kmato@nvidia.com> Signed-off-by: Angela Burton <angelab@nvidia.com> Co-authored-by: Angela Burton <angelab@nvidia.com> Co-authored-by: Ben Howe <141149032+bmhowe23@users.noreply.github.com>
YAML/config: mempolicy and cpu_affinity are now expressible in the realtime config (struct fields, YAML mapping, prepare_decoder_params, Python config bindings) — all four placement knobs exist on every surface. Core: RAII guards move to a shared lib-private header; public mempolicy() reader (trt stops reading the raw member); guards hoisted above input processing in decode(tensor) and trt's padding path; the sparse-matrix setters guard their session-lifetime allocations; new non-virtual decode_guarded() for single-syndrome callers that have not bound a thread. Realtime session: placement consensus covers all four knobs and engages on any of device/node/cpu list; conflicting mempolicy warns; DEVICE mode runs graph capture, the dispatch kernel, stats allocation, and worker streams on the decoders' agreed device and rejects conflicting devices loudly at initialize; worker streams drain before teardown frees graph buffers; ring buffers, function tables, and control words first-touch on the session node. Python: decode routes through the guarded entry; bind_current_thread/ unbind_thread and mempolicy/cpu_affinity readbacks are exposed; classes overriding decode_batch/decode_async in Python are rejected loudly when placement kwargs are passed, since attribute lookup would bypass the guarded C++ entry points. Signed-off-by: kvmto <kmato@nvidia.com>
… the GIL in decode bindings Replace trt_decoder::decode_batch's inline RestoreDevice/ScopedNuma with the shared CudaDeviceGuard/NumaGuard from hardware_guards.h — one guard definition now serves the base entry points, the realtime session, and the trt override. Unifies the error posture: an unreadable current device warns-and-skips at every entry point, and out-of-range ids throw the same count-checked error everywhere. Python decode and bind_current_thread release the GIL for the C++ call (decode_batch releases it around the decode only — its result conversion builds Python objects); nanobind's trampoline re-acquires it for Python-implemented decoders, so pinned Python workers decode concurrently across GPUs Signed-off-by: kvmto <kmato@nvidia.com>
Signed-off-by: kvmto <kmato@nvidia.com>
…nning Signed-off-by: kvmto <kmato@nvidia.com>
Signed-off-by: kvmto <kmato@nvidia.com>
Signed-off-by: kvmto <kmato@nvidia.com>
bmhowe23
left a comment
There was a problem hiding this comment.
Thanks, Kevin ... this looks really thorough. As we discussed offline, there may be some use cases that we have not fully thought through for the theading and NUMA pinning. Therefore, I think it would be best to break this down into smaller PRs.
- Decoder-specific changes to support "cuda_device_id". This one is the near-term priority and is likely the only one needed for this release. While the main goal is YAML support and C++ use cases, I think that this should likely automagically work for Python if we just implement the decoder-specific kwargs like all of our other kwargs.
- Handle NUMA memory pinning and better threading understanding/support. It would be great to capture what you have in this PR into another draft PR. But it may be next release before we work through all the use cases there.
- Python interfaces for 2
Summary
Allows any registered QEC decoder to be assigned to specific hardware via decoder kwargs and the realtime YAML configuration:
cuda_device_id— pins GPU decoders to a specific devicenuma_node_id— binds CPU decoder allocations to a NUMA nodeAffinity is owned entirely by the base
decoderclass — no plugin code required. Private RAII guards (CudaDeviceGuard,NumaGuard) indecoder.cppapply automatically at construction (decoder::get), batch decode (decode_batch), and async decode (decode_async). Raw Linux syscalls, no libnuma. Negative/absent ids are a no-op; out-of-range ids throw at construction.device_affinity.his now a CUDA-free param-reader header only (read_cuda_device_id,read_numa_node_id).scoped_cuda_device.his removed — it was in the wrong location (public include tree). The publicdecoder.hinterface remains free of CUDA and NUMA dependencies.Design notes
Why the base class owns hardware affinity
The v1 approach put
ScopedCudaDevicein the public include tree and requiredeach decoder plugin to opt in manually. That has two problems. First, a
third-party plugin author who forgets to add the guard gets silent wrong-device
behaviour with no error. Second,
scoped_cuda_device.hpulled<cuda_runtime.h>into the public API, which breaks the rule that
decoder.hmust be CUDA-free.The v2 approach puts both RAII guards (
CudaDeviceGuardandNumaGuard) in ananonymous namespace inside
decoder.cpp. They are invisible outside thattranslation unit. The factory
decoder::get()applies the guards duringconstruction and stores the ids in two protected members (
cuda_device_id_,numa_node_id_). Every decoder that goes through the factory gets correcthardware affinity for free, including third-party plugins.
Where guards are applied and why
decoder::get()decoder::decode_batch()decoder::decode_async()decoder::decode()The single-syndrome
decode()has no guard by design. Adding a CUDA API callper syndrome in a tight realtime loop would add syscall overhead on every call.
Callers that need affinity on the sync path set it themselves before entering
the loop.
The trt_decoder override
trt_decoderoverridesdecode_batch()entirely, which means the base classversion with its guard is never reached through virtual dispatch. A small inline
RAII struct at the top of
trt_decoder::decode_batch()handles this. It readscuda_device_id_from the protected base-class member and usescudaGetDevice/cudaSetDevicedirectly, since the privateCudaDeviceGuardtype is not visible outside
decoder.cpp.No libnuma
NUMA binding uses raw Linux syscalls (
SYS_set_mempolicy,sched_setaffinity)behind
#if defined(__linux__)with a one-time stderr warning on otherplatforms. This avoids a new link dependency. The CPU affinity restore is
conditional on whether
sched_getaffinitysucceeded first, which avoidspermanently pinning a thread in containers with locked cpusets.
What this does not cover
Decode-time affinity for the sync
decode()path is left to the caller.The
nv-qldpc-decoderand realtime-session NUMA IPC pinning are out of scopeand tracked separately.
The single-syndrome
decode()has no guard by design, adding a CUDA API calland a NUMA syscall per syndrome in a realtime loop would add per-call overhead.
Callers on that path are expected to set affinity once before entering the loop.
All other paths (
decode_batch,decode_async, construction) are fully covered.Tests
device_affinityparam readers (absent, int, size_t, negative-throws)decoder::get()rejects out-of-rangecuda_device_idfor any decoderdecode_asyncdoes not corrupt the calling thread's current devicetrt_decoderrejects invalid device ids and correctly runsdecode_asyncon the assigned GPU end-to-endOut of scope
Does not address
nv-qldpc-decoderruntime device handling or realtime-session NUMA IPC pinning.