Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
31f98c5
Add hardware pinning (cuda_device_id/numa_node_id) for QEC decoders
kvmto Jun 26, 2026
2ca48a6
Redesign hardware pinning: base class owns CUDA/NUMA affinity
kvmto Jun 29, 2026
628c10b
feat(qec): hardware-affinity knobs + loud, cudaq-decoupled pinning fo…
kvmto Jul 1, 2026
6069dcb
feat(qec): concurrent multi-decoder pool with per-GPU pinning
kvmto Jul 1, 2026
d991724
feat(qec): non-blocking streaming submit for decoder_pool
kvmto Jul 1, 2026
7c2bdac
fix(qec): decouple plugin params from affinity knobs; add pinning sys…
kvmto Jul 2, 2026
71de8f2
Merge remote-tracking branch 'upstream/main' into decoder-hardware-pi…
kvmto Jul 2, 2026
765bc12
test(qec): skip mempolicy assertions where the syscalls are blocked
kvmto Jul 2, 2026
dca1f0e
fix(qec): harden hardware-pinning guards and drop decoder_pool
kvmto Jul 3, 2026
fd45911
bug bash
kvmto Jul 6, 2026
e25478a
fix(qec): merge-readiness hardening for decoder hardware pinning
kvmto Jul 6, 2026
5ea8f0f
test(qec): negative-path and perf-budget coverage for hardware pinning
kvmto Jul 6, 2026
f3ab07f
fix(qec): close hardware-pinning coverage gaps across all surfaces
kvmto Jul 6, 2026
cc53ef9
refactor(qec): consolidate trt guards onto the shared header; release…
kvmto Jul 6, 2026
08646c7
quick fix of include
kvmto Jul 6, 2026
73cdc8c
Merge remote-tracking branch 'upstream/main' into decoder-hardware-pi…
kvmto Jul 6, 2026
088973e
pinning test fix
kvmto Jul 7, 2026
e4760ab
CI + test fix for multi trt testing
kvmto Jul 7, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions .github/workflows/lib_qec.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -313,7 +313,8 @@ jobs:
- name: Run multi-GPU C++ tests
run: |
cd $GITHUB_WORKSPACE/build_qec
# The matching tests will be added separately; until then, this
# command should not fail solely because the filter finds no tests.
# CudaDeviceId also matches the TRT 2-GPU placement tests
# (TRTDecoderTest.CudaDeviceId_*); TwoTrtDecodersConcurrently is the
# TRT concurrent-decode test that skips on single-GPU lanes.
ctest --output-on-failure \
-R "DecoderPool|HardwarePinningGpu|PoolRunsTwoTrt|CudaDeviceId"
-R "HardwarePinningGpu|CudaDeviceId|TwoTrtDecodersConcurrently"
78 changes: 78 additions & 0 deletions libs/qec/include/cudaq/qec/decoder.h
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,16 @@
#include "cuda-qx/core/tensor.h"
#include "sparse_binary_matrix.h"
#include "cudaq/qec/detector_error_model.h"
#include "cudaq/qec/device_affinity.h"
#include <algorithm>
#include <atomic>
#include <functional>
#include <future>
#include <memory>
#include <optional>
#include <string>
#include <string_view>
#include <thread>
#include <tuple>
#include <variant>
#include <vector>
Expand Down Expand Up @@ -173,6 +176,13 @@ class decoder
/// length of the syndrome vector should be equal to `syndrome_size`.
/// @returns Vector of length `block_size` with soft probabilities of errors
/// in each index.
/// Note: this entry point has no automatic CUDA/NUMA guard. It is a pure
/// virtual that plugin implementations override directly, so the base
/// class cannot wrap it without an ABI-breaking vtable change.
/// decode_batch(), decode_async(), decode(tensor), and enqueue_syndrome()
/// all apply the guard around their own call into this function; a caller
/// invoking this overload directly on an unbound decoder is responsible
/// for its own placement (e.g. call bind_current_thread() first).
virtual decoder_result decode(const std::vector<float_t> &syndrome) = 0;

/// @brief Decode a single syndrome
Expand All @@ -187,6 +197,8 @@ class decoder
/// value is the probability that the syndrome measurement is a |1>.
/// @returns std::future of a vector of length `block_size` with soft
/// probabilities of errors in each index.
/// @note The caller must ensure the decoder outlives the returned future.
/// Destroying the decoder before calling .get() is undefined behaviour.
virtual std::future<decoder_result>
decode_async(const std::vector<float_t> &syndrome);

Expand Down Expand Up @@ -245,6 +257,51 @@ class decoder
std::size_t get_block_size() { return block_size; }
std::size_t get_syndrome_size() { return syndrome_size; }

/// @brief Store hardware affinity parameters read from the constructor params
/// map. Called by decoder::get() after the plugin constructor returns.
/// Not intended for direct use by plugin authors.
void set_hardware_params(const cudaqx::heterogeneous_map &params);

/// @brief Target NUMA node for this decoder (-1 = no binding).
int numa_node_id() const { return numa_node_id_; }

/// @brief Target CUDA device for this decoder (-1 = inherit caller's device).
int cuda_device_id() const { return cuda_device_id_; }

/// @brief NUMA memory-policy mode for this decoder (preferred = soft,
/// bind = strict).
cudaq::qec::mempolicy_mode mempolicy() const { return mempolicy_; }

/// @brief Explicit CPU list for this decoder (empty = derive from NUMA node).
const std::vector<int> &cpu_affinity() const { return cpu_affinity_; }

/// @brief Persistently bind the CALLING thread to this decoder's NUMA node
/// (and CUDA device). Call once from the thread that will own decode()/
/// enqueue for this decoder (e.g. a realtime worker). No restore.
/// @return the node bound to, or -1 if nothing was applied. Warns if a node
/// was requested (>= 0) but could not be honored.
int bind_current_thread();

/// @brief Run decode(syndrome) on a fresh worker thread bound (via
/// bind_current_thread) to this decoder's CUDA device / NUMA node, and return
/// the result. Convenience for a one-off pinned decode; for sustained
/// throughput drive decode on a long-lived bound thread instead.
decoder_result decode_on_pinned_thread(const std::vector<float_t> &syndrome);

/// @brief Single-syndrome decode with the same automatic CUDA/NUMA guard as
/// decode_batch(). Non-virtual; dispatches to the virtual decode() inside
/// the guard. Intended for host-language bindings and callers that cannot
/// use bind_current_thread(); bound threads skip the guard as usual.
decoder_result decode_guarded(const std::vector<float_t> &syndrome);

/// @brief Forget any bind_current_thread() registration on this decoder so
/// guarded entry points stop skipping their per-call guard. Call before the
/// bound thread exits (e.g. session teardown): thread ids are recycled by
/// the runtime, and a stale registration would let an unrelated new thread
/// silently skip the guard. Does not undo the OS-level placement of the
/// (exiting) bound thread.
void unbind_thread();

// -- Begin realtime decoding API --

// Note: all of the current realtime decoding API is designed to be used with
Expand Down Expand Up @@ -357,6 +414,27 @@ class decoder
/// @brief The decoder's D matrix in sparse format
std::vector<std::vector<uint32_t>> D_sparse;

/// Target CUDA device for this decoder. -1 = inherit caller's device (no-op).
int cuda_device_id_ = -1;
/// Target NUMA node for this decoder. -1 = no binding.
int numa_node_id_ = -1;
/// Thread that bind_current_thread() pinned, if any. Guarded call sites
/// (decode_batch, decode(tensor), enqueue_syndrome) skip their per-call
/// CUDA/NUMA guard only when the calling thread matches. A
/// default-constructed id means no thread is bound.
std::atomic<std::thread::id> bound_thread_{};
/// NUMA memory-policy mode for this decoder (default soft PREFERRED).
cudaq::qec::mempolicy_mode mempolicy_ = cudaq::qec::mempolicy_mode::preferred;
/// Explicit CPU cores for this decoder's owning thread (empty = use node
/// cpuset).
std::vector<int> cpu_affinity_;

/// True if bind_current_thread() was called, and the CURRENT thread is the
/// one that called it -- the only thread allowed to skip the per-call
/// CUDA/NUMA guard. Protected so plugin overrides of decode_batch() can
/// apply the same skip as the base-class entry points.
bool is_bound_here() const;

private:
decode_result_type result_type_ = decode_result_type::decode_to_errs;
};
Expand Down
88 changes: 88 additions & 0 deletions libs/qec/include/cudaq/qec/device_affinity.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
/*******************************************************************************
* Copyright (c) 2025 - 2026 NVIDIA Corporation & Affiliates. *
* All rights reserved. *
* *
* This source code and the accompanying materials are made available under *
* the terms of the Apache License 2.0 which accompanies this distribution. *
******************************************************************************/

#pragma once

#include "cuda-qx/core/heterogeneous_map.h"
#include <stdexcept>
#include <string>
#include <vector>

namespace cudaq::qec {

namespace detail {
/// Returns -1 if key is absent; throws if stored value is negative.
/// Handles both int (YAML path) and std::size_t (Python kwargs path) storage.
inline int read_pin_key(const cudaqx::heterogeneous_map &params,
const std::string &key) {
if (!params.contains(key))
return -1;
int value = params.get<int>(key);
if (value < 0)
throw std::runtime_error(key + " must be >= 0 (got " +
std::to_string(value) + ")");
return value;
}
} // namespace detail

/// @brief GPU device id for a decoder. -1 = inherit current device (no-op).
inline int read_cuda_device_id(const cudaqx::heterogeneous_map &params) {
return detail::read_pin_key(params, "cuda_device_id");
}

/// @brief NUMA node id for a decoder. -1 = no binding.
inline int read_numa_node_id(const cudaqx::heterogeneous_map &params) {
return detail::read_pin_key(params, "numa_node_id");
}

/// @brief Public NUMA memory-policy selector. preferred = soft; bind = strict.
enum class mempolicy_mode { preferred, bind };

/// @brief Read "mempolicy": "bind"->bind, "preferred"/absent->preferred, else
/// throw.
inline mempolicy_mode read_mempolicy(const cudaqx::heterogeneous_map &params) {
if (!params.contains("mempolicy"))
return mempolicy_mode::preferred;
const std::string v = params.get<std::string>("mempolicy");
if (v == "bind")
return mempolicy_mode::bind;
if (v == "preferred")
return mempolicy_mode::preferred;
throw std::runtime_error(
"mempolicy must be \"preferred\" or \"bind\" (got \"" + v + "\")");
}

/// @brief Read "cpu_affinity": a list of CPU core ids. Absent -> empty (no
/// override). Accepts vector<int> (C++/YAML) and vector<double> with integral
/// values (Python kwargs path — lists arrive as doubles).
inline std::vector<int>
read_cpu_affinity(const cudaqx::heterogeneous_map &params) {
if (!params.contains("cpu_affinity"))
return {};
try {
return params.get<std::vector<int>>("cpu_affinity");
} catch (...) {
}
const auto vals = params.get<std::vector<double>>("cpu_affinity");
std::vector<int> cores;
cores.reserve(vals.size());
for (double v : vals) {
if (v != static_cast<double>(static_cast<int>(v)))
throw std::runtime_error("cpu_affinity core ids must be integers (got " +
std::to_string(v) + ")");
cores.push_back(static_cast<int>(v));
}
return cores;
}

/// @brief NUMA node local to a CUDA device (via PCIe locality), or -1 if the
/// device id is negative or the topology can't be resolved. Defined in
/// decoder.cpp.
int numa_node_for_cuda_device(int cuda_device_id);

} // namespace cudaq::qec
4 changes: 4 additions & 0 deletions libs/qec/include/cudaq/qec/realtime/decoding_config.h
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,10 @@ struct decoder_config {
std::vector<std::int64_t> H_sparse;
std::vector<std::int64_t> O_sparse;
std::vector<std::int64_t> D_sparse;
std::optional<int> cuda_device_id;
std::optional<int> numa_node_id;
std::optional<std::string> mempolicy; // "preferred" | "bind"
std::optional<std::vector<int>> cpu_affinity; // explicit core ids
std::variant<single_error_lut_config, multi_error_lut_config,
nv_qldpc_decoder_config, sliding_window_config,
trt_decoder_config, pymatching_config>
Expand Down
Loading
Loading