Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
b29d219
⬆️🐍 Update dependency nanobind to v3
renovate[bot] Aug 22, 2026
96c9bf3
Adapt bindings code to v3
denialhaag Aug 22, 2026
98a7817
Provisionally try out split mode
denialhaag Aug 22, 2026
23c2b95
Add Development.SABIModule on Windows
denialhaag Aug 22, 2026
d778a77
🔥 Drop support for Python 3.10
denialhaag Aug 5, 2026
6907bd3
Remove obsolete Python version guard from PennyLane plugin
denialhaag Aug 13, 2026
d8d6b07
⬆️🐍 Adopt nanobind 3 split-mode wheels
burgholzer Aug 22, 2026
34d0398
🔒 Protect shared state for free-threaded Python
burgholzer Aug 22, 2026
953867f
🔒 Synchronize shared QDMI driver state
burgholzer Aug 22, 2026
dde5c8b
📝 Document the nanobind 3 migration
burgholzer Aug 22, 2026
1363111
🎨 Address C++ lint diagnostics
burgholzer Aug 22, 2026
422a7b8
🐛 Preserve split-mode exceptions on macOS
burgholzer Aug 22, 2026
441cd28
👷 Provision MLIR for release wheels
burgholzer Aug 22, 2026
4b44b38
🔧 Remove obsolete Python 3.13t skip
burgholzer Aug 22, 2026
be49f06
🩹 Detect Windows abi3t split builds
burgholzer Aug 22, 2026
4fa953c
🧪 Smoke-test Windows wheel artifacts
burgholzer Aug 22, 2026
033056b
🐛 Fix macOS split-module symbol exports
burgholzer Aug 22, 2026
0c7874a
♻️ Address nanobind split-mode review
burgholzer Aug 23, 2026
5142b73
Merge branch 'main' into renovate/nanobind-3.x
burgholzer Aug 23, 2026
5320b27
♻️ Simplify split-mode build configuration
burgholzer Aug 23, 2026
4b6b88c
👷 Provision CMake 4.4 for Python builds
burgholzer Aug 23, 2026
3218705
Merge branch 'main' into renovate/nanobind-3.x
burgholzer Aug 23, 2026
d6fa796
Merge branch 'main' into renovate/nanobind-3.x
burgholzer Aug 23, 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
422 changes: 422 additions & 0 deletions .agent/plans/nanobind-3-split-wheels.md

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions .github/workflows/cd.yml
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@ jobs:
uses: munich-quantum-toolkit/workflows/.github/workflows/reusable-python-packaging-wheel-cibuildwheel.yml@f33c11c5cf68957b3074fff11ae43cfce2aaf593 # v2.2.3
with:
runs-on: ${{ matrix.runs-on }}
setup-mlir: true
llvm-version: 22.1.8
permissions:
contents: read

Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -117,7 +117,7 @@ jobs:
build-project: true
files-changed-only: true
setup-python: true
install-pkgs: "nanobind==2.15.0"
install-pkgs: "nanobind==3.0.0"
cpp-linter-extra-args: "-std=c++20 -Wunused-template"
# Vendored third-party headers are checked upstream and are not maintained
# according to MQT Core's clang-tidy configuration.
Expand Down
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,18 @@ releases may include breaking changes.

### Changed

- 💥 Require Python 3.11 or newer ([#2209]) ([**@denialhaag**],
[**@burgholzer**])
- ⬆️ Update `nanobind` to version 3.0.0 ([#2209]) ([**@denialhaag**],
[**@burgholzer**])
- 📦 Publish one split-mode `cp311-abi3` wheel for GIL-enabled CPython 3.11 and
newer ([#2209]) ([**@denialhaag**], [**@burgholzer**])
- 📦 Publish one `cp315-abi3t` wheel for free-threaded CPython 3.15 and newer
([#2209]) ([**@denialhaag**], [**@burgholzer**])
- ⚡ Remove an extra dense copy from `VectorDD.get_vector` ([#2209])
([**@burgholzer**])
- 🐛 Protect process-wide DD, IR, and QDMI state for free-threaded Python
([#2209]) ([**@burgholzer**])
- 💥 Prune dead and misleading CoreIR APIs and remove random-number generator
state from `QuantumComputation` ([#2111], [#2112]) ([**@simon1hofmann**])
- 💥 Update QIR execution for QIR 2.1, isolated runtimes, reproducible
Expand Down Expand Up @@ -792,6 +804,7 @@ for previous changelogs._

<!-- PR links -->

[#2209]: https://github.com/munich-quantum-toolkit/core/pull/2209
[#2211]: https://github.com/munich-quantum-toolkit/core/pull/2211
[#2210]: https://github.com/munich-quantum-toolkit/core/pull/2210
[#2216]: https://github.com/munich-quantum-toolkit/core/pull/2216
Expand Down
7 changes: 1 addition & 6 deletions CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -48,13 +48,8 @@ if(BUILD_MQT_CORE_BINDINGS)
ON
CACHE BOOL "Prevent multiple searches for Python and instead cache the results.")

if(DISABLE_GIL)
message(STATUS "Disabling Python GIL")
add_compile_definitions(Py_GIL_DISABLED)
endif()

# top-level call to find Python
find_package(Python 3.10 REQUIRED COMPONENTS Interpreter Development.Module
find_package(Python 3.11 REQUIRED COMPONENTS Interpreter Development.Module
${SKBUILD_SABI_COMPONENT})
endif()

Expand Down
18 changes: 18 additions & 0 deletions UPGRADING.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,24 @@ factories. Move required implementations to the package that uses them. The
`BUILD_MQT_CORE_BENCHMARKS` option and its legacy DD evaluation target were also
removed.

### Python 3.11 and split-mode wheels

MQT Core now requires Python 3.11 or newer. Upgrade the Python environment
before installing this release.

MQT Core now uses nanobind 3 split mode. One `cp311-abi3` wheel supports
GIL-enabled CPython 3.11 and newer. Free-threaded support starts with CPython
3.15 and uses a separate `cp315-abi3t` wheel. MQT Core no longer publishes
free-threaded CPython 3.13 or 3.14 wheels.

nanobind 3 changes the nanobind ABI. Rebuild downstream native Python extensions
that use MQT Core's nanobind-bound C++ types. Pure Python consumers do not need
to recompile anything.

The Python bindings depend on `nanobind-backend`, which supplies the
interpreter-specific nanobind runtime. This dependency does not change the C++
API or the Python import paths.

### Program serialization for QDMI Qiskit backends

The Qiskit backend no longer decides in its own code how to turn a circuit into
Expand Down
8 changes: 4 additions & 4 deletions bindings/dd/register_dd_package.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -323,7 +323,7 @@ Specifically, it
[](dd::Package& p, const dd::vEdge& v, const qc::NonUnitaryOperation& op,
const std::vector<bool>& measurements,
const qc::Permutation& perm = {}) {
static std::mt19937_64 rng(std::random_device{}());
static thread_local std::mt19937_64 rng(std::random_device{}());
auto measurementsCopy = measurements;
return std::pair{
applyMeasurement(op, v, p, rng, measurementsCopy, perm),
Expand Down Expand Up @@ -351,7 +351,7 @@ Specifically, it
"apply_reset",
[](dd::Package& p, const dd::vEdge& v, const qc::NonUnitaryOperation& op,
const qc::Permutation& perm = {}) {
static std::mt19937_64 rng(std::random_device{}());
static thread_local std::mt19937_64 rng(std::random_device{}());
return applyReset(op, v, p, rng, perm);
},
"vec"_a, "operation"_a, "permutation"_a = qc::Permutation{},
Expand Down Expand Up @@ -399,7 +399,7 @@ Specifically, it
dd.def(
"measure_collapsing",
[](dd::Package& p, dd::vEdge& v, const dd::Qubit q) {
static std::mt19937_64 rng(std::random_device{}());
static thread_local std::mt19937_64 rng(std::random_device{}());
return p.measureOneCollapsing(v, q, rng);
},
"vec"_a, "qubit"_a, R"pb(Measure a qubit and collapse the DD.
Expand All @@ -418,7 +418,7 @@ Specifically, it
dd.def(
"measure_all",
[](dd::Package& p, dd::vEdge& v, const bool collapse = false) {
static std::mt19937_64 rng(std::random_device{}());
static thread_local std::mt19937_64 rng(std::random_device{}());
return p.measureAll(v, collapse, rng);
},
"vec"_a, "collapse"_a = false, R"pb(Measure all qubits.
Expand Down
6 changes: 4 additions & 2 deletions bindings/dd/register_matrix_dds.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -44,10 +44,11 @@ Matrix getMatrix(const dd::mEdge& m, const size_t numQubits,

if (numQubits == 0U) {
auto dataPtr = std::make_unique<std::complex<dd::fp>>(m.w);
auto* data = dataPtr.release();
auto* const data = dataPtr.get();
const nb::capsule owner(data, [](void* ptr) noexcept {
delete static_cast<std::complex<dd::fp>*>(ptr);
});
[[maybe_unused]] const auto* const releasedDataPtr = dataPtr.release();
return Matrix(data, {1, 1}, owner);
}

Expand All @@ -60,10 +61,11 @@ Matrix getMatrix(const dd::mEdge& m, const size_t numQubits,
dataPtr[(i * dim) + j] = c;
},
numQubits, threshold);
auto* data = dataPtr.release();
auto* const data = dataPtr.get();
const nb::capsule owner(data, [](void* ptr) noexcept {
delete[] static_cast<std::complex<dd::fp>*>(ptr);
});
[[maybe_unused]] const auto* const releasedDataPtr = dataPtr.release();
return Matrix(data, {dim, dim}, owner);
}

Expand Down
15 changes: 7 additions & 8 deletions bindings/dd/register_vector_dds.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,6 @@
#include <nanobind/stl/string.h> // NOLINT(misc-include-cleaner)
#include <nanobind/stl/vector.h> // NOLINT(misc-include-cleaner)

#include <algorithm>
#include <cmath>
#include <complex>
#include <cstddef>
Expand All @@ -38,14 +37,14 @@ using Vector = nb::ndarray<nb::numpy, std::complex<dd::fp>, nb::ndim<1>>;

// NOLINTNEXTLINE(misc-use-internal-linkage)
Vector getVector(const dd::vEdge& v, const dd::fp threshold) {
auto vec = v.getVector(threshold);
auto dataPtr = std::make_unique<std::complex<dd::fp>[]>(vec.size());
std::ranges::copy(vec, dataPtr.get());
auto* data = dataPtr.release();
const nb::capsule owner(data, [](void* ptr) noexcept {
delete[] static_cast<std::complex<dd::fp>*>(ptr);
auto dataPtr = std::make_unique<dd::CVec>(v.getVector(threshold));
auto* const data = dataPtr->data();
const auto size = dataPtr->size();
const nb::capsule owner(dataPtr.get(), [](void* ptr) noexcept {
delete static_cast<dd::CVec*>(ptr);
});
return Vector(data, {vec.size()}, owner);
[[maybe_unused]] const auto* const releasedDataPtr = dataPtr.release();
return Vector(data, {size}, owner);
}

// NOLINTNEXTLINE(misc-use-internal-linkage)
Expand Down
4 changes: 2 additions & 2 deletions bindings/ir/operations/register_if_else_operation.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ Otherwise, the else operation is executed.
new (self) qc::IfElseOperation(std::move(thenPtr), std::move(elsePtr),
controlReg, expectedVal, kind);
},
"then_operation"_a, nb::arg("else_operation").none(true),
"then_operation"_a, nb::arg("else_operation").none(),
"control_register"_a, "expected_value"_a = 1U,
"comparison_kind"_a = qc::ComparisonKind::Eq);
ifElse.def(
Expand All @@ -81,7 +81,7 @@ Otherwise, the else operation is executed.
new (self) qc::IfElseOperation(std::move(thenPtr), std::move(elsePtr),
controlBit, expectedVal, kind);
},
"then_operation"_a, nb::arg("else_operation").none(true), "control_bit"_a,
"then_operation"_a, nb::arg("else_operation").none(), "control_bit"_a,
"expected_value"_a = true, "comparison_kind"_a = qc::ComparisonKind::Eq);

ifElse.def_prop_ro("then_operation", &qc::IfElseOperation::getThenOp,
Expand Down
2 changes: 1 addition & 1 deletion bindings/patterns.txt
Original file line number Diff line number Diff line change
Expand Up @@ -135,7 +135,7 @@ mqt\.core\.mlir\.CompilerTarget\.from_device$:
\doc

mqt\.core\.mlir\.CompilerTarget\.from_device_id$:
\from mqt.core._compat.typing import Unpack
\from typing import Unpack
\from mqt.core.typing import QDMISessionParameters
@staticmethod
def from_device_id(device_id: str, **session_parameters: Unpack[QDMISessionParameters]) -> CompilerTarget:
Expand Down
32 changes: 28 additions & 4 deletions cmake/AddMQTPythonBinding.cmake
Original file line number Diff line number Diff line change
Expand Up @@ -10,15 +10,22 @@ function(add_mqt_python_binding package_name target_name)
cmake_parse_arguments(ARG "" "MODULE_NAME;INSTALL_DIR" "LINK_LIBS" ${ARGN})
set(SOURCES ${ARG_UNPARSED_ARGUMENTS})

# nanobind 3.0 ignores scikit-build-core's abi3t signal on Windows. NB_ABI configures the nested
# nanobind_add_module call.
if(WIN32 AND Py_TARGET_ABI3T)
set(NB_ABI "${Python_VERSION_MAJOR}${Python_VERSION_MINOR}t")
endif()

nanobind_add_module(
# Name of the extension
${target_name}
# Target the stable ABI for Python 3.12+, which reduces the number of binary wheels
STABLE_ABI
# Enable free-threaded support
FREE_THREADED
# Suppress compiler warnings from the nanobind library
NB_SUPPRESS_WARNINGS
# Use nanobind's shared runtime. Split mode enables the Stable ABI internally.
BACKEND_MODULE
nanobind_backend
# Source files
${SOURCES})

Expand All @@ -38,12 +45,29 @@ function(add_mqt_python_binding package_name target_name)
set(module_name ${target_name})
endif()

# A Python extension's only public native symbol is its module initializer. Keep symbols from
# statically linked dependencies local to avoid collisions with other extension modules.
# Keep statically linked dependencies local.
if(APPLE)
target_link_options(${target_name} PRIVATE "LINKER:-exported_symbol,_PyInit_${module_name}")

# Apple's x86-64 ABI requires unique RTTI. Its arm64 ABI compares non-unique RTTI names.
if(CMAKE_SYSTEM_PROCESSOR STREQUAL "x86_64")
target_link_options(
${target_name}
PRIVATE
"LINKER:-exported_symbol,__ZTIN8nanobind4abi112python_errorE"
"LINKER:-exported_symbol,__ZTSN8nanobind4abi112python_errorE"
"LINKER:-exported_symbol,__ZTIN8nanobind4abi117builtin_exceptionE"
"LINKER:-exported_symbol,__ZTSN8nanobind4abi117builtin_exceptionE")
endif()
elseif(UNIX)
target_link_options(${target_name} PRIVATE "LINKER:--exclude-libs,ALL")

# nanobind 3.0 omits section garbage collection from split-mode targets.
Comment thread
burgholzer marked this conversation as resolved.
target_link_options(
${target_name}
PRIVATE
"$<$<OR:$<CONFIG:Release>,$<CONFIG:MinSizeRel>,$<CONFIG:RelWithDebInfo>>:LINKER:--gc-sections>"
)
elseif(WIN32)
set_target_properties(${target_name} PROPERTIES WINDOWS_EXPORT_ALL_SYMBOLS OFF)
endif()
Expand Down
1 change: 0 additions & 1 deletion docs/conf.py
Original file line number Diff line number Diff line change
Expand Up @@ -159,7 +159,6 @@ def format_url(self, _e: Entry) -> HRef: # ruff:ignore[no-self-use]
nitpick_ignore_regex = [
("py:class", r"Annotated\[numpy\.typing\.NDArray\[numpy\.complex128\], \{'shape': \(.*\)\}\]"),
("py:class", r"Ellipsis"),
("py:class", r"mqt\.core\._compat\.typing\.Unpack"),
("py:class", r"ParametersType"),
("py:class", r"pennylane\.tape\.QuantumScriptOrBatch"),
("py:class", r"pennylane\.transforms\.core\.CompilePipeline"),
Expand Down
11 changes: 7 additions & 4 deletions include/mqt-core/ir/Register.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@

#include "Definitions.hpp"

#include <atomic>
#include <cstddef>
#include <functional>
#include <stdexcept>
Expand Down Expand Up @@ -89,8 +90,9 @@ class QuantumRegister final : public Register<Qubit> {

protected:
static std::string generateName() {
static std::size_t counter = 0;
return "q" + std::to_string(counter++);
static std::atomic_size_t counter = 0;
return "q" +
std::to_string(counter.fetch_add(1, std::memory_order_relaxed));
}
};

Expand All @@ -106,8 +108,9 @@ class ClassicalRegister final : public Register<Bit> {

protected:
static std::string generateName() {
static std::size_t counter = 0;
return "c" + std::to_string(counter++);
static std::atomic_size_t counter = 0;
return "c" +
std::to_string(counter.fetch_add(1, std::memory_order_relaxed));
}
};

Expand Down
6 changes: 4 additions & 2 deletions include/mqt-core/ir/operations/Expression.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
#include <cstddef>
#include <cstdint>
#include <functional>
#include <mutex>
#include <numeric>
#include <ostream>
#include <stdexcept>
Expand Down Expand Up @@ -57,6 +58,7 @@ struct Variable {
static inline std::unordered_map<std::string, std::size_t> registered{};
static inline std::unordered_map<std::size_t, std::string> names{};
static inline std::size_t nextId{};
static inline std::mutex registryMutex{};
// NOLINTEND(cppcoreguidelines-avoid-non-const-global-variables)

/**
Expand All @@ -69,7 +71,7 @@ struct Variable {
* @brief Get the name of the variable.
* @return Name of the variable.
*/
[[nodiscard]] std::string getName() const noexcept;
[[nodiscard]] std::string getName() const;

/**
* @brief Check whether this variable's id is equal to another variable's id.
Expand Down Expand Up @@ -113,7 +115,7 @@ struct Variable {
* @brief Hash function for the Variable struct.
*/
template <> struct std::hash<sym::Variable> {
std::size_t operator()(const sym::Variable& var) const noexcept {
std::size_t operator()(const sym::Variable& var) const {
return std::hash<std::string>()(var.getName());
}
};
Expand Down
19 changes: 16 additions & 3 deletions include/mqt-core/qdmi/driver/Driver.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,12 @@
#include <qdmi/client.h>
#include <qdmi/device.h>

#include <condition_variable>
#include <cstddef>
#include <cstdint>
#include <filesystem>
#include <memory>
#include <mutex>
#include <optional>
#include <string>
#include <string_view>
Expand Down Expand Up @@ -217,6 +219,8 @@ struct QDMI_Device_impl_d {
QDMI_Device_Session deviceSession_ = nullptr;
/// Client-facing wrappers for direct child devices.
std::vector<std::unique_ptr<QDMI_Device_impl_d>> childDevices_;
/// Guards the owned job wrappers.
std::mutex jobsMutex_;
/**
* @brief Map of jobs to their corresponding unique pointers of
* QDMI_Job_impl_d objects.
Expand Down Expand Up @@ -446,6 +450,15 @@ class Driver final : public Singleton<Driver> {
/// @brief Private constructor to enforce the singleton pattern.
Driver();

/// Guards all mutable driver state below.
mutable std::mutex stateMutex_;

/// Wakes callers waiting for a persistent device to finish opening.
std::condition_variable stateChanged_;

/// Ensures that the configured client catalog is materialized once.
std::once_flag clientCatalogOnce_;

/**
* @brief Vector of unique pointers to QDMI_Device_impl_d objects.
*/
Expand All @@ -463,12 +476,12 @@ class Driver final : public Singleton<Driver> {
/// @brief Materialized devices exposed through the client API.
std::vector<QDMI_Device> clientDevices_;

/// @brief Whether the configured client device catalog has been opened.
bool clientCatalogMaterialized_ = false;

/// @brief Opened devices indexed by their stable registration ID.
std::unordered_map<std::string, QDMI_Device> openedDevices_;

/// @brief Device IDs whose persistent sessions are being opened.
std::unordered_set<std::string> openingDeviceIds_;

/**
* @brief Map of sessions to their corresponding unique pointers to
* QDMI_Session_impl_d objects.
Expand Down
2 changes: 1 addition & 1 deletion noxfile.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@
if os.environ.get("CI", None):
nox.options.error_on_missing_interpreters = True

PYTHON_ALL_VERSIONS = ["3.10", "3.11", "3.12", "3.13", "3.14"]
PYTHON_ALL_VERSIONS = ["3.11", "3.12", "3.13", "3.14"]


@contextlib.contextmanager
Expand Down
Loading
Loading