Skip to content

Add hardware pinning for QEC decoders#634

Open
kvmto wants to merge 18 commits into
NVIDIA:mainfrom
kvmto:decoder-hardware-pinning
Open

Add hardware pinning for QEC decoders#634
kvmto wants to merge 18 commits into
NVIDIA:mainfrom
kvmto:decoder-hardware-pinning

Conversation

@kvmto

@kvmto kvmto commented Jun 26, 2026

Copy link
Copy Markdown
Collaborator

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 device
  • numa_node_id — binds CPU decoder allocations to a NUMA node

Affinity is owned entirely by the base decoder class — no plugin code required. Private RAII guards (CudaDeviceGuard, NumaGuard) in decoder.cpp apply 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.h is now a CUDA-free param-reader header only (read_cuda_device_id, read_numa_node_id). scoped_cuda_device.h is removed — it was in the wrong location (public include tree). The public decoder.h interface remains free of CUDA and NUMA dependencies.

Design notes

Why the base class owns hardware affinity

The v1 approach put ScopedCudaDevice in the public include tree and required
each 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.h pulled <cuda_runtime.h>
into the public API, which breaks the rule that decoder.h must be CUDA-free.

The v2 approach puts both RAII guards (CudaDeviceGuard and NumaGuard) in an
anonymous namespace inside decoder.cpp. They are invisible outside that
translation unit. The factory decoder::get() applies the guards during
construction and stores the ids in two protected members (cuda_device_id_,
numa_node_id_). Every decoder that goes through the factory gets correct
hardware affinity for free, including third-party plugins.

Where guards are applied and why

Call site Guard applied Reason
decoder::get() Both guards GPU allocations and NUMA first-touch happen here
decoder::decode_batch() Both guards One syscall for the whole batch, not per syndrome
decoder::decode_async() Both guards, value-captured Async thread starts with no inherited affinity
decoder::decode() None Sync callers in realtime loops manage their own thread state

The single-syndrome decode() has no guard by design. Adding a CUDA API call
per 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_decoder overrides decode_batch() entirely, which means the base class
version 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 reads
cuda_device_id_ from the protected base-class member and uses
cudaGetDevice/cudaSetDevice directly, since the private CudaDeviceGuard
type 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 other
platforms. This avoids a new link dependency. The CPU affinity restore is
conditional on whether sched_getaffinity succeeded first, which avoids
permanently 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-decoder and realtime-session NUMA IPC pinning are out of scope
and tracked separately.

The single-syndrome decode() has no guard by design, adding a CUDA API call
and 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_affinity param readers (absent, int, size_t, negative-throws)
  • decoder::get() rejects out-of-range cuda_device_id for any decoder
  • decode_async does not corrupt the calling thread's current device
  • trt_decoder rejects invalid device ids and correctly runs decode_async on the assigned GPU end-to-end

Out of scope

Does not address nv-qldpc-decoder runtime device handling or realtime-session NUMA IPC pinning.

kvmto added 2 commits June 26, 2026 15:58
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>
@kvmto kvmto changed the title [DRAFT] Add hardware pinning for QEC decoders Add hardware pinning for QEC decoders Jun 29, 2026
@tlshannon

Copy link
Copy Markdown
Collaborator

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.

Comment thread libs/qec/lib/decoder.cpp Outdated
kvmto added 3 commits July 1, 2026 14:38
…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>
@kvmto

kvmto commented Jul 1, 2026

Copy link
Copy Markdown
Collaborator Author

I will split the PR in two or three.
Still finishing some things, but I should have tackled the majority of the use cases and comments now.
@bmhowe23 @tlshannon

kvmto added 3 commits July 2, 2026 11:43
…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>
Comment thread libs/qec/lib/decoder.cpp Outdated
Comment thread libs/qec/lib/decoder_pool.cpp Outdated
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>
@kvmto

kvmto commented Jul 3, 2026

Copy link
Copy Markdown
Collaborator Author

We will need to wait for new CI :) @bmhowe23 @anjbur

kvmto added 3 commits July 6, 2026 12:54
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>
@kvmto kvmto force-pushed the decoder-hardware-pinning branch from 8443a5a to 5ea8f0f Compare July 6, 2026 17:12
bmhowe23 added a commit that referenced this pull request Jul 6, 2026
…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>
Comment thread libs/qec/lib/decoders/plugins/trt_decoder/trt_decoder.cpp Outdated
kvmto added 2 commits July 6, 2026 22:07
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>
kvmto added 4 commits July 6, 2026 23:43
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>

@tlshannon tlshannon left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

@bmhowe23 bmhowe23 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

  1. 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.
  2. 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.
  3. Python interfaces for 2

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants