diff --git a/.agent/plans/qiskit-reusable-gates.md b/.agent/plans/qiskit-reusable-gates.md new file mode 100644 index 0000000000..b18c2a945f --- /dev/null +++ b/.agent/plans/qiskit-reusable-gates.md @@ -0,0 +1,77 @@ +# Preserve reusable Qiskit gates in QC + +Status: complete. + +## Goal and scope + +Import nonstandard Qiskit `Gate` definitions as private QC unitary functions and +preserve their applications as `qc.call`. Export supported private unitary +functions and calls as Qiskit Gates. Nested and repeated definitions must stay +linear in the size of the definition graph and preserve parameters, public +names, global phases, qubit order, and supported modifiers. + +The normalized boundary is `bindings/mlir/qiskit/QiskitTranslation.h`. The +Qiskit 2.5 implementation is in `bindings/mlir/qiskit/Qiskit2_5.cpp`; +format-independent import and export are in `QiskitImport.cpp` and +`QiskitExport.cpp`. Behavioral coverage is in +`test/python/test_mlir_qiskit_translation.py`. + +Generic Qiskit `Instruction` definitions remain flattened. They can contain +classical bits, measurement, reset, and control flow, for which the unitary QC +function ABI is not valid. Gate functions cannot contain classical bits, +standalone classical variables, barriers, or nonunitary operations. + +## Decisions + +- Use Qiskit's public `Gate` type as the reusable-function boundary. Determine + built-in gates from Qiskit's standard-gate identity, not from user-controlled + names. +- Build a function from the bound definition circuit's remaining free + parameters. Qiskit specializes copied definitions during parameter binding, so + recovering an erased generic template would require private provenance. +- Intern definitions by source name and parameter hash, then use object identity + and Qiskit circuit equality within that bucket. Qiskit deep-copies a Gate and + its definition when appending it, so pointer identity alone duplicates equal + functions. Signature buckets avoid a global quadratic equality scan. +- Unique colliding MLIR symbols deterministically and retain the Qiskit name in + `mqt.source_name`. The first available symbol keeps the source name. +- Preserve inverse, closed-control, and finite numeric power modifiers around + custom Gate calls. Reject open controls and symbolic powers that Qiskit 2.5 + cannot bind reliably. +- Use MLIR `CallGraph` and LLVM SCC traversal for callee-first export order and + recursion rejection. Reject unreachable functions because a Qiskit circuit + cannot retain unused Gate declarations. +- Retain the 64-level definition and exported call-depth limits. Qiskit exposes + mutable Python definition graphs with arbitrary cycles, and parameterized + `QuantumCircuit.to_gate(parameter_map=...)` recursively copies nested + definitions. The export limit also keeps emitted circuits within the + importer's supported range. +- Construct custom Gates in the version-specific bridge through the existing + deferred-placeholder pattern. The Qiskit C API does not append arbitrary + Python Gates or `AnnotatedOperation` values. +- Do not add a classical callable ABI. Standalone variables and control-flow + captures remain entry- or block-owned and do not enter unitary functions. + +## Validation + +From the repository root, build and test with: + + cmake --build --preset release --target mqt-core-mlir-bindings -j2 + pytest -n0 -q test/python/test_mlir_qiskit_translation.py + uvx nox -s stubs + uvx nox -s cpp-lint + uvx nox -s lint + +The focused file passes all 294 tests. Stub generation, repository lint, and C++ +lint pass; the C++ lint session performs a clean build before running +clang-tidy. Hosted CI is separate evidence and must run on the final published +commit. + +## Outcome + +The implementation uses the existing QC function model, Qiskit public Gate +model, MLIR call graph, and normalized translation boundary. It adds no dialect +operation, external dependency, generic call-graph framework, or classical +function ABI. A parameterized QC helper can split into specialized helpers on a +Qiskit round trip because Qiskit's public circuit retains the specialized +definitions rather than their erased template. diff --git a/CHANGELOG.md b/CHANGELOG.md index 697a3154da..7839ac87b6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -45,9 +45,9 @@ releases may include breaking changes. #### Import and export -- ✨ Add Qiskit circuit import and target-aware export to the compiler - collection ([#2031], [#2133], [#2140], [#2150], [#2175], [#2176], [#2178]) - ([**@burgholzer**], [**@simon1hofmann**]) +- ✨ Add Qiskit circuit import, target-aware export, and reusable custom Gate + round trips to the compiler collection ([#2031], [#2133], [#2140], [#2150], + [#2175], [#2176], [#2178], [#2342]) ([**@burgholzer**], [**@simon1hofmann**]) - ✨ Add conversions between `jeff` and QCO ([#1479], [#1548], [#1565], [#1637], [#1676], [#1706], [#1776], [#1836], [#1934], [#2000], [#2018], [#2105], [#2339]) ([**@denialhaag**], [**@burgholzer**]) @@ -928,6 +928,7 @@ for previous changelogs._ [#2368]: https://github.com/munich-quantum-toolkit/core/pull/2368 [#2358]: https://github.com/munich-quantum-toolkit/core/pull/2358 [#2349]: https://github.com/munich-quantum-toolkit/core/pull/2349 +[#2342]: https://github.com/munich-quantum-toolkit/core/pull/2342 [#2340]: https://github.com/munich-quantum-toolkit/core/pull/2340 [#2339]: https://github.com/munich-quantum-toolkit/core/pull/2339 [#2338]: https://github.com/munich-quantum-toolkit/core/pull/2338 diff --git a/bindings/mlir/CMakeLists.txt b/bindings/mlir/CMakeLists.txt index 5b79b97c43..f261c7ee20 100644 --- a/bindings/mlir/CMakeLists.txt +++ b/bindings/mlir/CMakeLists.txt @@ -83,6 +83,7 @@ if(NOT TARGET ${TARGET_NAME}) MQT::CoreBenchGenerate MQTCompilerQDMIAdapter MQTCompilerPipeline + MLIRAnalysis MLIRMQTDialect MLIRMQTUtils MLIRQCTranslationSupport diff --git a/bindings/mlir/qiskit/Qiskit2_5.cpp b/bindings/mlir/qiskit/Qiskit2_5.cpp index 3a8088f953..76ba6fa872 100644 --- a/bindings/mlir/qiskit/Qiskit2_5.cpp +++ b/bindings/mlir/qiskit/Qiskit2_5.cpp @@ -40,6 +40,7 @@ #include #include #include +#include #include #include @@ -597,6 +598,21 @@ static void appendControlModifier(const nb::handle object, nb::module_::import_("qiskit.circuit").attr("Store")); } +[[nodiscard]] static bool isPythonGate(nb::handle operation) { + const auto terminal = terminalPythonGate(operation); + return nb::isinstance(terminal, + nb::module_::import_("qiskit.circuit").attr("Gate")); +} + +[[nodiscard]] static bool isPythonStandardGate(nb::handle operation) { + const auto terminal = terminalPythonGate(operation); + const auto baseClass = pythonAttribute( + terminal, "base_class", "Qiskit Gate does not expose its base class"); + return !pythonAttribute(baseClass, "_standard_gate", + "Qiskit Gate base class has no standard identity") + .is_none(); +} + static void normalizePythonModifier(const nb::handle modifier, std::vector& modifiers) { const auto type = pythonAttribute(modifier, "__class__", @@ -811,14 +827,56 @@ standardGateMapping(const std::string_view name) { namespace { class NativeControlFlowReader; +class DefinitionRegistry final { +public: + [[nodiscard]] uintptr_t identify(nb::handle definition, + const std::string_view name, + nb::handle parameters) { + const nb::tuple parameterTuple(parameters); + const auto parameterHash = PyObject_Hash(parameterTuple.ptr()); + if (parameterHash == -1) { + throwPythonError("Qiskit Gate parameters are not hashable"); + } + // ponytail: use a structural circuit hash if many same-signature Gate + // definitions become common. + auto& bucket = + definitions_[llvm::StringRef(name.data(), name.size())][parameterHash]; + for (const auto& entry : bucket) { + if (entry.definition.is(definition) || + entry.definition.equal(definition)) { + return entry.identity; + } + } + const auto identity = nextIdentity_++; + bucket.push_back({ + .definition = nb::borrow(definition), + .identity = identity, + }); + return identity; + } + +private: + struct Definition { + nb::object definition; + uintptr_t identity; + }; + + using ParameterBuckets = + std::unordered_map>; + llvm::StringMap definitions_; + uintptr_t nextIdentity_ = 1U; +}; + class NativeCircuitReader final : public CircuitReader { public: - explicit NativeCircuitReader(const nb::handle circuit) + NativeCircuitReader(nb::handle circuit, + std::shared_ptr definitions) : pythonCircuit_(nb::borrow(circuit)), data_(pythonAttribute( circuit, "_data", "expected a Qiskit QuantumCircuit with native CircuitData")), - circuit_(qk_circuit_borrow_from_python(data_.ptr())) { + circuit_(qk_circuit_borrow_from_python(data_.ptr())), + definitions_(std::move(definitions)) { if (circuit_ == nullptr) { throwPythonError("Qiskit rejected QuantumCircuit._data"); } @@ -827,12 +885,14 @@ class NativeCircuitReader final : public CircuitReader { NativeCircuitReader(nb::object pythonCircuit, const QkCircuit* circuit, const QkCircuit* rootCircuit, - const QkControlFlowInstruction* parent) + const QkControlFlowInstruction* parent, + std::shared_ptr definitions) : pythonCircuit_(std::move(pythonCircuit)), data_(pythonAttribute( pythonCircuit_, "_data", "Qiskit control-flow block has no native CircuitData")), - circuit_(circuit), rootCircuit_(rootCircuit), parent_(parent) {} + circuit_(circuit), rootCircuit_(rootCircuit), parent_(parent), + definitions_(std::move(definitions)) {} [[nodiscard]] uint32_t numQubits() const override { return qk_circuit_num_qubits(circuit_); @@ -966,6 +1026,9 @@ class NativeCircuitReader final : public CircuitReader { } normalizedUnknown.emplace(); normalizePythonGate(operation, *normalizedUnknown); + if (isPythonGate(operation)) { + normalizedUnknown->kind = OperationKind::Gate; + } } QkCircuitInstruction native{}; qk_circuit_get_instruction(circuit_, index, &native); @@ -1008,14 +1071,14 @@ class NativeCircuitReader final : public CircuitReader { result.parameters.emplace_back(normalizeParameter(parameter)); } } - if (result.kind == OperationKind::Unknown) { + if (kind == OperationKind::Unknown) { result.name = std::move(normalizedUnknown->name); result.modifiers = std::move(normalizedUnknown->modifiers); - if (!result.modifiers.empty()) { - result.kind = OperationKind::Gate; - } + result.kind = normalizedUnknown->kind; + } + if (kind != OperationKind::Unknown || isPythonStandardGate(operation)) { + result.standardGate = standardGateMapping(result.name); } - result.standardGate = standardGateMapping(result.name); return result; } @@ -1101,7 +1164,7 @@ class NativeCircuitReader final : public CircuitReader { [[nodiscard]] std::unique_ptr definition(const size_t index) const override { - const auto operation = pythonOperation(index); + const auto operation = terminalPythonGate(pythonOperation(index)); const auto definition = pythonAttribute( operation, "definition", "Qiskit instruction does not expose a circuit definition"); @@ -1110,18 +1173,23 @@ class NativeCircuitReader final : public CircuitReader { instruction(index).name + "' has no circuit definition"); } - return std::make_unique(definition); + return std::make_unique(definition, definitions_); } [[nodiscard]] uintptr_t definitionIdentity(const size_t index) const override { + const auto operation = terminalPythonGate(pythonOperation(index)); const auto definition = pythonAttribute( - pythonOperation(index), "definition", + operation, "definition", "Qiskit instruction does not expose a circuit definition"); if (definition.is_none()) { return 0U; } - return reinterpret_cast(definition.ptr()); + const auto name = pythonStringAttribute( + operation, "name", "Qiskit instruction has no valid name"); + const auto parameters = pythonAttribute( + operation, "params", "Qiskit instruction has no parameter list"); + return definitions_->identify(definition, name, parameters); } private: @@ -1164,6 +1232,7 @@ class NativeCircuitReader final : public CircuitReader { const QkCircuit* circuit_ = nullptr; const QkCircuit* rootCircuit_ = circuit_; const QkControlFlowInstruction* parent_ = nullptr; + std::shared_ptr definitions_; }; } // namespace @@ -1437,13 +1506,15 @@ class NativeControlFlowReader final : public ControlFlowReader { const QkCircuit* circuit, const size_t index, const QkControlFlowInstruction* parent, nb::object instruction, - nb::object containingPythonCircuit) + nb::object containingPythonCircuit, + std::shared_ptr definitions) : rootCircuit_(rootCircuit), circuit_(circuit), parent_(parent), instruction_(std::move(instruction)), operation_(pythonAttribute( instruction_, "operation", "Qiskit circuit instruction has no control-flow operation")), containingPythonCircuit_(std::move(containingPythonCircuit)), + definitions_(std::move(definitions)), controlFlow_( qk_circuit_get_control_flow_instruction(circuit, index, parent)) { if (controlFlow_ == nullptr) { @@ -1490,7 +1561,7 @@ class NativeControlFlowReader final : public ControlFlowReader { const auto block = nb::borrow(blocks[index]); return std::make_unique( block, qk_control_flow_block_circuit(controlFlow_, index), rootCircuit_, - controlFlow_); + controlFlow_, definitions_); } [[nodiscard]] std::vector qubitMap() const override { @@ -1726,6 +1797,7 @@ class NativeControlFlowReader final : public ControlFlowReader { nb::object instruction_; nb::object operation_; nb::object containingPythonCircuit_; + std::shared_ptr definitions_; QkControlFlowInstruction* controlFlow_ = nullptr; }; } // namespace @@ -1811,7 +1883,7 @@ std::unique_ptr NativeCircuitReader::controlFlow(const size_t index) const { return std::make_unique( rootCircuit_, circuit_, index, parent_, - nb::borrow(data_[index]), pythonCircuit_); + nb::borrow(data_[index]), pythonCircuit_, definitions_); } namespace { @@ -2091,13 +2163,17 @@ struct NativeSymbol { using NativeSymbolTable = llvm::StringMap; using PythonParameterGroups = llvm::StringMap; +using PythonSymbols = llvm::StringMap; + +using NativeGateRegistry = llvm::StringMap; class NativeCircuitWriter final : public CircuitWriter { public: NativeCircuitWriter(const uint32_t looseQubits, const uint32_t looseClbits, - std::shared_ptr symbols) + std::shared_ptr symbols, + std::shared_ptr gates) : circuit_(qk_circuit_new(looseQubits, looseClbits)), - symbols_(std::move(symbols)) { + symbols_(std::move(symbols)), gates_(std::move(gates)) { if (circuit_ == nullptr) { throwPythonError("Qiskit failed to allocate a circuit"); } @@ -2172,6 +2248,21 @@ class NativeCircuitWriter final : public CircuitWriter { "adding parameterized gate"); } + void addCustomGate(std::string_view name, const std::vector& qubits, + const std::vector& parameters, + const std::vector& modifiers) override { + const auto instructionIndex = qk_circuit_num_instructions(circuit_); + checkExitCode(qk_circuit_barrier(circuit_, nullptr, 0U), + "adding custom-gate placeholder"); + pendingCustomGates_.push_back({ + .instructionIndex = instructionIndex, + .name = std::string(name), + .qubits = qubits, + .parameters = parameters, + .modifiers = modifiers, + }); + } + void addMeasure(const uint32_t qubit, const uint32_t clbit) override { checkExitCode(qk_circuit_measure(circuit_, qubit, clbit), "adding measurement"); @@ -2290,8 +2381,9 @@ class NativeCircuitWriter final : public CircuitWriter { [[nodiscard]] nb::object finish() override { PythonParameterGroups groups; + PythonSymbols symbols; return finishImpl(false, nb::none(), nb::none(), nb::none(), nb::none(), - groups, {}); + groups, symbols, {}); } private: @@ -2299,7 +2391,7 @@ class NativeCircuitWriter final : public CircuitWriter { finishImpl(const bool rebase, const nb::handle exactQubits, const nb::handle exactClbits, const nb::handle exactQregs, const nb::handle exactCregs, PythonParameterGroups& groups, - PythonVariables variables) { + PythonSymbols& symbols, PythonVariables variables) { if (circuit_ == nullptr) { throw std::runtime_error( "Qiskit circuit writer has already been finalized"); @@ -2316,6 +2408,8 @@ class NativeCircuitWriter final : public CircuitWriter { exactQregs, exactCregs); } replacePendingControlledUnitaries(pythonCircuit); + synchronizePythonSymbols(pythonCircuit, symbols); + replacePendingCustomGates(pythonCircuit, symbols); restoreParameterGroups(pythonCircuit, *symbols_, groups); const PythonClassicalBuilder classical(pythonCircuit, variables); for (const auto& variable : variables_) { @@ -2331,7 +2425,7 @@ class NativeCircuitWriter final : public CircuitWriter { } } replacePendingStores(pythonCircuit, variables); - replacePendingControlFlow(pythonCircuit, groups, variables); + replacePendingControlFlow(pythonCircuit, groups, symbols, variables); } catch (const nb::python_error& error) { throwPythonError("Qiskit failed to construct deferred instructions", error); @@ -2351,6 +2445,14 @@ class NativeCircuitWriter final : public CircuitWriter { std::unique_ptr value; }; + struct PendingCustomGate { + size_t instructionIndex = 0U; + std::string name; + std::vector qubits; + std::vector parameters; + std::vector modifiers; + }; + struct PendingControlFlow { size_t instructionIndex = 0U; ControlFlowKind kind = ControlFlowKind::IfElse; @@ -2434,6 +2536,223 @@ class NativeCircuitWriter final : public CircuitWriter { } } + static void synchronizePythonSymbols(nb::handle circuit, + PythonSymbols& symbols) { + nb::dict replacements; + const auto parameters = pythonAttribute( + circuit, "parameters", "Qiskit circuit has no parameter collection"); + for (const nb::handle parameter : nb::iter(parameters)) { + const auto name = pythonStringAttribute( + parameter, "name", "Qiskit circuit parameter has no name"); + const auto [symbol, inserted] = + symbols.try_emplace(name, nb::borrow(parameter)); + if (!inserted && !symbol->second.equal(parameter)) { + replacements[parameter] = symbol->second; + } + } + if (nb::len(replacements) != 0U) { + pythonAttribute(circuit, "assign_parameters", + "Qiskit circuit cannot unify output parameters")( + replacements, nb::arg("inplace") = true, + nb::arg("flat_input") = true); + } + } + + [[nodiscard]] nb::object pythonParameter(const Parameter& parameter, + PythonSymbols& symbols, + size_t& nodeCount, size_t depth) { + countParameterExpressionNode(nodeCount); + if (depth > MAX_PARAMETER_EXPRESSION_DEPTH) { + throwParameterExpressionDepthError(); + } + if (const auto* number = parameter.getNumber()) { + return nb::float_(number->value); + } + if (const auto* symbol = parameter.getSymbol()) { + symbols_->try_emplace(symbol->name, symbol->name, symbol->group); + auto pythonSymbol = symbols.find(symbol->name); + if (pythonSymbol == symbols.end()) { + pythonSymbol = symbols + .try_emplace(symbol->name, + nb::module_::import_("qiskit.circuit") + .attr("Parameter")(symbol->name)) + .first; + } + return nb::borrow(pythonSymbol->second); + } + if (const auto* unary = parameter.getUnary()) { + auto operand = + pythonParameter(*unary->operand, symbols, nodeCount, depth + 1U); + if (nb::isinstance(operand)) { + auto numeric = nb::cast(operand); + switch (unary->operation) { + case UnaryParameterKind::Negate: + numeric = -numeric; + break; + case UnaryParameterKind::Sin: + numeric = std::sin(numeric); + break; + case UnaryParameterKind::Cos: + numeric = std::cos(numeric); + break; + case UnaryParameterKind::Tan: + numeric = std::tan(numeric); + break; + case UnaryParameterKind::ArcSin: + numeric = std::asin(numeric); + break; + case UnaryParameterKind::ArcCos: + numeric = std::acos(numeric); + break; + case UnaryParameterKind::ArcTan: + numeric = std::atan(numeric); + break; + case UnaryParameterKind::Exp: + numeric = std::exp(numeric); + break; + case UnaryParameterKind::Log: + numeric = std::log(numeric); + break; + case UnaryParameterKind::Abs: + numeric = std::abs(numeric); + break; + case UnaryParameterKind::Conjugate: + break; + } + if (!std::isfinite(numeric)) { + throw std::runtime_error( + "cannot construct a non-finite Qiskit parameter"); + } + return nb::float_(numeric); + } + switch (unary->operation) { + case UnaryParameterKind::Negate: + return -operand; + case UnaryParameterKind::Sin: + return operand.attr("sin")(); + case UnaryParameterKind::Cos: + return operand.attr("cos")(); + case UnaryParameterKind::Tan: + return operand.attr("tan")(); + case UnaryParameterKind::ArcSin: + return operand.attr("arcsin")(); + case UnaryParameterKind::ArcCos: + return operand.attr("arccos")(); + case UnaryParameterKind::ArcTan: + return operand.attr("arctan")(); + case UnaryParameterKind::Exp: + return operand.attr("exp")(); + case UnaryParameterKind::Log: + return operand.attr("log")(); + case UnaryParameterKind::Abs: + return operand.attr("abs")(); + case UnaryParameterKind::Conjugate: + return operand.attr("conjugate")(); + } + } + if (const auto* binary = parameter.getBinary()) { + auto left = + pythonParameter(*binary->left, symbols, nodeCount, depth + 1U); + auto right = + pythonParameter(*binary->right, symbols, nodeCount, depth + 1U); + switch (binary->operation) { + case BinaryParameterKind::Add: + return left + right; + case BinaryParameterKind::Subtract: + return left - right; + case BinaryParameterKind::Multiply: + return left * right; + case BinaryParameterKind::Divide: + return left / right; + case BinaryParameterKind::Power: + return nb::module_::import_("builtins").attr("pow")(left, right); + } + } + throw std::runtime_error("unknown normalized parameter expression"); + } + + [[nodiscard]] nb::object pythonParameter(const Parameter& parameter, + PythonSymbols& symbols) { + size_t nodeCount = 0U; + return pythonParameter(parameter, symbols, nodeCount, 1U); + } + + void replacePendingCustomGates(nb::handle pythonCircuit, + PythonSymbols& symbols) { + auto data = pythonAttribute(pythonCircuit, "data", + "Qiskit circuit has no instruction data"); + const auto circuitQubits = pythonAttribute(pythonCircuit, "qubits", + "Qiskit circuit has no qubits"); + const auto circuitModule = nb::module_::import_("qiskit.circuit"); + for (const auto& pending : pendingCustomGates_) { + if (pending.instructionIndex >= nb::len(data)) { + throw std::runtime_error("Qiskit custom-gate placeholder is missing"); + } + const auto gate = gates_->find(pending.name); + if (gate == gates_->end()) { + throw std::runtime_error("Qiskit custom Gate '" + pending.name + + "' has no registered definition"); + } + const nb::object formalParameters = gate->second.attr("params"); + if (nb::len(formalParameters) != pending.parameters.size()) { + throw std::runtime_error("Qiskit custom Gate '" + pending.name + + "' has incompatible parameters"); + } + nb::dict parameterMap; + for (size_t index = 0U; index < pending.parameters.size(); ++index) { + parameterMap[formalParameters[index]] = + pythonParameter(pending.parameters[index], symbols); + } + nb::object operation = gate->second; + if (!pending.parameters.empty()) { + operation = + pythonAttribute(operation.attr("definition"), "to_gate", + "Qiskit custom definition cannot become a Gate")( + nb::arg("parameter_map") = parameterMap); + } + if (!pending.modifiers.empty()) { + nb::list modifiers; + for (const auto& modifier : pending.modifiers) { + switch (modifier.kind) { + case GateModifierKind::Inverse: + modifiers.append(circuitModule.attr("InverseModifier")()); + break; + case GateModifierKind::Control: + modifiers.append( + circuitModule.attr("ControlModifier")(modifier.numControls)); + break; + case GateModifierKind::Power: { + const auto* exponent = modifier.exponent.getNumber(); + if (exponent == nullptr) { + throw std::runtime_error( + "Qiskit custom Gate power must be numeric"); + } + modifiers.append( + circuitModule.attr("PowerModifier")(exponent->value)); + break; + } + } + } + operation = + circuitModule.attr("AnnotatedOperation")(operation, modifiers); + } + nb::list qargs; + for (const auto qubit : pending.qubits) { + if (qubit >= nb::len(circuitQubits)) { + throw std::runtime_error( + "Qiskit custom Gate references an invalid qubit"); + } + qargs.append(circuitQubits[qubit]); + } + const auto placeholder = + nb::borrow(data[pending.instructionIndex]); + data[pending.instructionIndex] = + pythonAttribute(placeholder, "replace", + "Qiskit custom-gate placeholder cannot be replaced")( + nb::arg("operation") = operation, nb::arg("qubits") = qargs); + } + } + [[nodiscard]] static nb::object rebaseCircuit(const nb::handle circuit, const nb::handle exactQubits, const nb::handle exactClbits, @@ -2555,6 +2874,7 @@ class NativeCircuitWriter final : public CircuitWriter { void replacePendingControlFlow(const nb::handle pythonCircuit, PythonParameterGroups& groups, + PythonSymbols& symbols, const PythonVariables& variables) { auto data = pythonAttribute(pythonCircuit, "data", "Qiskit circuit has no instruction data"); @@ -2584,7 +2904,7 @@ class NativeCircuitWriter final : public CircuitWriter { } blocks.emplace_back( writer->finishImpl(true, circuitQubits, circuitClbits, circuitQregs, - circuitCregs, groups, variables)); + circuitCregs, groups, symbols, variables)); for (auto captured : nb::iter(blocks.back().attr("iter_captured_vars")())) { if (!nb::cast(pythonCircuit.attr("has_var")(captured))) { @@ -2716,15 +3036,18 @@ class NativeCircuitWriter final : public CircuitWriter { std::vector pendingControlledUnitaries_; std::vector pendingStores_; std::vector variables_; + std::vector pendingCustomGates_; std::vector pendingControlFlow_; std::shared_ptr symbols_; + std::shared_ptr gates_; }; class NativeTranslation final : public VersionedTranslation { public: [[nodiscard]] std::unique_ptr openCircuit(const nb::handle circuit) const override { - return std::make_unique(circuit); + return std::make_unique( + circuit, std::make_shared()); } [[nodiscard]] bool supportsGate(const StandardGateMapping gate) const override { @@ -2735,12 +3058,42 @@ class NativeTranslation final : public VersionedTranslation { createCircuit(const uint32_t looseQubits, const uint32_t looseClbits) const override { return std::make_unique(looseQubits, looseClbits, - symbols_); + symbols_, gates_); + } + + void registerCustomGate(std::string_view symbol, std::string_view name, + const std::vector& formalParameters, + std::unique_ptr definition) override { + if (gates_->contains(symbol)) { + throw std::runtime_error("Qiskit custom Gate '" + std::string(symbol) + + "' is already registered"); + } + auto circuit = definition->finish(); + circuit.attr("name") = nb::str(name.data(), name.size()); + nb::list parameters; + try { + for (const auto& parameter : formalParameters) { + parameters.append(pythonAttribute( + circuit, "get_parameter", + "Qiskit custom definition cannot resolve a formal parameter")( + parameter)); + } + } catch (const nb::python_error& error) { + throwPythonError( + "Qiskit custom definition cannot resolve a formal parameter", error); + } + auto gate = nb::module_::import_("qiskit.circuit") + .attr("Gate")(nb::str(name.data(), name.size()), + circuit.attr("num_qubits"), parameters); + gate.attr("definition") = circuit; + gates_->try_emplace(symbol, std::move(gate)); } private: std::shared_ptr symbols_ = std::make_shared(); + std::shared_ptr gates_ = + std::make_shared(); }; } // namespace diff --git a/bindings/mlir/qiskit/QiskitExport.cpp b/bindings/mlir/qiskit/QiskitExport.cpp index 0bcc8ff176..b8508c14c8 100644 --- a/bindings/mlir/qiskit/QiskitExport.cpp +++ b/bindings/mlir/qiskit/QiskitExport.cpp @@ -29,11 +29,13 @@ #include #include #include +#include #include #include #include #include #include +#include #include #include #include @@ -72,8 +74,9 @@ namespace mqt::bindings::qiskit { constexpr size_t MAX_EXPORT_CONTROL_FLOW_DEPTH = 64U; constexpr size_t MAX_EXPORT_EXPRESSION_DEPTH = 64U; -/// Bounded bit-based casts between jeff native widths expand expression trees. +// Bounded bit-based casts between jeff native widths expand expression trees. constexpr size_t MAX_EXPORT_EXPRESSION_NODES = 16384U; +constexpr size_t MAX_EXPORT_GATE_CALL_DEPTH = 64U; namespace { struct ExportedControlFlow; @@ -81,6 +84,7 @@ struct ExportedControlFlow; struct ExportedInstruction { enum class Kind : uint8_t { Gate, + CustomGate, Measure, Reset, Barrier, @@ -90,10 +94,12 @@ struct ExportedInstruction { }; Kind kind = Kind::Gate; StandardGateMapping gate; + std::string customGate; std::vector qubits; std::vector clbits; std::vector parameters; std::vector> matrix; + std::vector modifiers; uint32_t unitaryControls = 0; ClassicalTarget target; std::unique_ptr value; @@ -108,6 +114,14 @@ struct ExportedCircuit { std::vector instructions; }; +struct ExportedGateDefinition { + std::string symbol; + std::string name; + std::vector formalParameters; + uint32_t numQubits = 0U; + ExportedCircuit circuit; +}; + struct ExportedControlFlow { ControlFlowKind kind = ControlFlowKind::IfElse; ClassicalTarget target; @@ -470,9 +484,10 @@ static void validateExportParameters(const ExportedCircuit& circuit, } } -static void collectParameters(mlir::func::FuncOp function, ExportState& state) { - for (const auto [index, argument] : - llvm::enumerate(function.getArguments())) { +static void collectParameters(mlir::func::FuncOp function, ExportState& state, + size_t count) { + for (size_t index = 0U; index < count; ++index) { + auto argument = function.getArgument(index); const auto name = function.getArgAttrOfType( index, mlir::mqt::MQTDialect::InputNameAttrHelper::getNameStr()); if (!argument.getType().isF64() || !name) { @@ -675,6 +690,17 @@ static void invertGate(ExportedInstruction& instruction) { collectUnitaryInstruction(mlir::Operation& operation, const llvm::DenseMap& qubits, ExportedParameters& parameters) { + if (auto call = llvm::dyn_cast(operation)) { + ExportedInstruction result{ + .kind = ExportedInstruction::Kind::CustomGate, + .customGate = call.getCallee().str(), + .qubits = mapQubits(call.getQubits(), qubits), + }; + for (auto parameter : call.getParameters()) { + result.parameters.push_back(exportParameter(parameter, parameters)); + } + return result; + } if (auto control = llvm::dyn_cast(operation)) { auto bodyOperations = modifierBodyOperations(control.getRegion()); const auto controls = mapQubits(control.getControls(), qubits); @@ -703,10 +729,20 @@ collectUnitaryInstruction(mlir::Operation& operation, } if (bodyOperations.size() != 1U) { throw std::runtime_error( - "QC control export requires one standard gate in the modifier body"); + "QC control export requires one gate in the modifier body"); } auto result = collectUnitaryInstruction(*bodyOperations.front(), nestedMap, parameters); + if (result.kind == ExportedInstruction::Kind::CustomGate) { + result.modifiers.push_back({ + .kind = GateModifierKind::Control, + .numControls = + checkedIndex(static_cast(controls.size()), "control"), + }); + result.qubits.insert(result.qubits.begin(), controls.begin(), + controls.end()); + return result; + } auto& numControls = result.kind == ExportedInstruction::Kind::Unitary ? result.unitaryControls : result.gate.controls; @@ -723,31 +759,45 @@ collectUnitaryInstruction(mlir::Operation& operation, auto bodyOperations = modifierBodyOperations(inverse.getRegion()); if (bodyOperations.size() != 1U) { throw std::runtime_error( - "QC inverse export requires one standard gate in the modifier body"); + "QC inverse export requires one gate in the modifier body"); } auto nestedMap = modifierQubitMap(qubits, inverse.getRegion().front(), inverse.getQubits()); auto result = collectUnitaryInstruction(*bodyOperations.front(), nestedMap, parameters); - invertGate(result); + if (result.kind == ExportedInstruction::Kind::CustomGate) { + result.modifiers.push_back({.kind = GateModifierKind::Inverse}); + } else { + invertGate(result); + } return result; } if (auto power = llvm::dyn_cast(operation)) { auto bodyOperations = modifierBodyOperations(power.getRegion()); if (bodyOperations.size() != 1U) { throw std::runtime_error( - "QC power export requires one standard gate in the modifier body"); + "QC power export requires one gate in the modifier body"); } const auto exponent = exportParameter(power.getExponent(), parameters); const auto* number = exponent.getNumber(); - if (number == nullptr || (number->value != 1.0 && number->value != -1.0)) { - throw std::runtime_error( - "QC power export supports only constant exponents 1 and -1"); - } auto nestedMap = modifierQubitMap(qubits, power.getRegion().front(), power.getQubits()); auto result = collectUnitaryInstruction(*bodyOperations.front(), nestedMap, parameters); + if (result.kind == ExportedInstruction::Kind::CustomGate) { + if (number == nullptr || !std::isfinite(number->value)) { + throw std::runtime_error( + "Qiskit custom Gate power must have a finite numeric exponent"); + } + result.modifiers.push_back( + {.kind = GateModifierKind::Power, .exponent = exponent}); + return result; + } + if (number == nullptr || (number->value != 1.0 && number->value != -1.0)) { + throw std::runtime_error( + "QC power export supports only constant exponents 1 and -1 for " + "standard gates and dense unitaries"); + } if (number->value == -1.0) { invertGate(result); } @@ -915,8 +965,8 @@ static void collectResources(mlir::func::FuncOp function, ExportState& state, } } llvm::append_range(registers, returnedValues); - /// Qiskit exposes all register storage; put observable outputs last in count - /// order. + // Qiskit exposes all register storage. Put observable outputs last in count + // order. for (auto result : registers) { if (auto alloc = result.getDefiningOp()) { if (const auto name = alloc->getAttrOfType( @@ -1305,7 +1355,7 @@ exportExpressionImpl(mlir::Value value, ExportState& state, return operand; } if (operand->kind == ExpressionKind::ClassicalRegister) { - /// Qiskit's OpenQASM exporter needs a matching-width register cast first. + // Qiskit's OpenQASM exporter needs a matching-width register cast first. countExpressionNode(nodeCount); auto exact = std::make_unique(); exact->kind = ExpressionKind::Cast; @@ -2631,6 +2681,19 @@ validateConstructibleGates(const ExportedCircuit& circuit, descriptor.operationSymbol.str() + "' with " + std::to_string(instruction.gate.controls) + " controls"); } + if (instruction.kind == ExportedInstruction::Kind::CustomGate) { + llvm::DenseSet qubits; + if (instruction.customGate.empty()) { + throw std::runtime_error("Qiskit custom Gate has an empty name"); + } + for (const auto qubit : instruction.qubits) { + if (!qubits.insert(qubit).second) { + throw std::runtime_error("Qiskit custom Gate '" + + instruction.customGate + + "' uses the same qubit more than once"); + } + } + } if (instruction.kind != ExportedInstruction::Kind::ControlFlow) { continue; } @@ -2653,6 +2716,10 @@ static void emitCircuit(ExportedCircuit& circuit, CircuitWriter& writer, writer.addGate(instruction.gate, instruction.qubits, instruction.parameters); break; + case ExportedInstruction::Kind::CustomGate: + writer.addCustomGate(instruction.customGate, instruction.qubits, + instruction.parameters, instruction.modifiers); + break; case ExportedInstruction::Kind::Measure: writer.addMeasure(instruction.qubits.at(0), instruction.clbits.at(0)); break; @@ -2688,32 +2755,138 @@ static void emitCircuit(ExportedCircuit& circuit, CircuitWriter& writer, } } +[[nodiscard]] static size_t gateParameterCount(mlir::func::FuncOp function) { + size_t numParameters = 0U; + while (numParameters < function.getNumArguments() && + function.getArgument(numParameters).getType().isF64()) { + ++numParameters; + } + return numParameters; +} + +static void validateGateFunction(mlir::func::FuncOp function) { + if (!mlir::mqt::isUnitaryFunction(function)) { + throw std::runtime_error( + "Qiskit custom Gates require private, defined, single-block unitary " + "functions with no results"); + } + if (function + .walk( + [](mlir::qc::BarrierOp) { return mlir::WalkResult::interrupt(); }) + .wasInterrupted()) { + throw std::runtime_error( + "Qiskit custom Gate definitions cannot contain barriers"); + } +} + +[[nodiscard]] static std::vector +collectGateFunctions(mlir::ModuleOp moduleOp, mlir::func::FuncOp entryPoint) { + const mlir::CallGraph graph(moduleOp); + llvm::DenseMap gateCallDepths; + std::vector ordered; + for (auto component = + llvm::scc_iterator>:: + begin(graph.lookupNode(&entryPoint.getBody())); + !component.isAtEnd(); ++component) { + auto* node = component->front(); + if (node->isExternal() || component.hasCycle()) { + throw std::runtime_error( + "Qiskit custom Gates require defined, non-recursive functions"); + } + auto function = mlir::dyn_cast( + node->getCallableRegion()->getParentOp()); + if (!function) { + throw std::runtime_error( + "Qiskit custom Gates require func.func definitions"); + } + const auto ownDepth = function == entryPoint ? 0U : 1U; + size_t depth = ownDepth; + for (const auto& edge : *node) { + depth = + std::max(depth, gateCallDepths.lookup(edge.getTarget()) + ownDepth); + } + if (depth > MAX_EXPORT_GATE_CALL_DEPTH) { + throw std::runtime_error( + "Qiskit custom Gate calls exceed the nesting limit of 64"); + } + gateCallDepths[node] = depth; + if (function != entryPoint) { + validateGateFunction(function); + ordered.push_back(function); + } + } + for (auto function : moduleOp.getOps()) { + if (!gateCallDepths.contains(graph.lookupNode(&function.getBody()))) { + throw std::runtime_error("Qiskit circuit export cannot preserve " + "function '" + + function.getName().str() + "'"); + } + } + return ordered; +} + +[[nodiscard]] static ExportedGateDefinition +collectGateDefinition(mlir::func::FuncOp function) { + const auto numParameters = gateParameterCount(function); + ExportState state; + collectParameters(function, state, numParameters); + const auto numQubits = function.getNumArguments() - numParameters; + state.numQubits = checkedIndex(static_cast(numQubits), "qubit"); + for (auto [index, argument] : + llvm::enumerate(function.getArguments().drop_front(numParameters))) { + state.qubits[argument] = + checkedIndex(static_cast(index), "qubit"); + } + auto circuit = collectBlock(function.getBody().front(), state, 0U); + validateExportParameters(circuit, state.inputParameters); + std::vector formalParameters; + formalParameters.reserve(state.inputParameters.size()); + for (const auto& parameter : state.inputParameters) { + formalParameters.push_back(parameter.getSymbol()->name); + } + auto sourceName = function->getAttrOfType( + mlir::mqt::MQTDialect::SourceNameAttrHelper::getNameStr()); + return { + .symbol = function.getName().str(), + .name = sourceName ? sourceName.str() : function.getName().str(), + .formalParameters = std::move(formalParameters), + .numQubits = state.numQubits, + .circuit = std::move(circuit), + }; +} + nb::object exportCircuit(const mlir::QCProgram& program, const mlir::CompilerTarget* const target) { mlir::OwningOpRef expanded = program.module().clone(); auto moduleOp = *expanded; mlir::RewritePatternSet patterns(moduleOp.getContext()); mlir::mqt::populateIntegerExpansionPatterns(patterns); - /// Expand missing operations and eliminate dead expressions, without folding - /// unrelated control flow or changing the source program. + // Expand missing operations and eliminate dead expressions without folding + // unrelated control flow or changing the source program. if (mlir::failed(mlir::applyPatternsGreedily( moduleOp, std::move(patterns), mlir::GreedyRewriteConfig().enableFolding(false)))) { throw std::runtime_error("failed to expand integer operations for Qiskit"); } - const auto functions = moduleOp.getOps(); - if (functions.empty() || !llvm::hasSingleElement(functions)) { + auto function = mlir::mqt::getEntryPoint(moduleOp); + if (!function) { throw std::runtime_error( - "QC to Qiskit export requires exactly one entry function"); + "QC to Qiskit export requires an mqt.entry_point function"); } - auto function = *functions.begin(); if (function.getBody().empty() || !llvm::hasSingleElement(function.getBody())) { throw std::runtime_error( "QC to Qiskit export requires a single-block entry function"); } + const auto gateFunctions = collectGateFunctions(moduleOp, function); + std::vector gateDefinitions; + gateDefinitions.reserve(gateFunctions.size()); + for (auto gateFunction : gateFunctions) { + gateDefinitions.push_back(collectGateDefinition(gateFunction)); + } ExportState state; - collectParameters(function, state); + collectParameters(function, state, function.getNumArguments()); if (target != nullptr) { state.numQubits = checkedIndex(static_cast(target->numSites()), "target qubit count"); @@ -2756,7 +2929,19 @@ nb::object exportCircuit(const mlir::QCProgram& program, state.numClbits, "classical"); auto translation = selectTranslation(); + for (const auto& definition : gateDefinitions) { + validateConstructibleGates(definition.circuit, *translation); + } validateConstructibleGates(circuit, *translation); + for (auto& definition : gateDefinitions) { + auto definitionWriter = + translation->createCircuit(definition.numQubits, 0U); + emitCircuit(definition.circuit, *definitionWriter, *translation, + definition.numQubits, 0U); + translation->registerCustomGate(definition.symbol, definition.name, + definition.formalParameters, + std::move(definitionWriter)); + } auto writer = translation->createCircuit(looseQubits, looseClbits); for (const auto& reg : state.quantumRegisters) { writer->addQuantumRegister(reg.name, diff --git a/bindings/mlir/qiskit/QiskitImport.cpp b/bindings/mlir/qiskit/QiskitImport.cpp index d90b10892e..4a87cdda2f 100644 --- a/bindings/mlir/qiskit/QiskitImport.cpp +++ b/bindings/mlir/qiskit/QiskitImport.cpp @@ -12,7 +12,6 @@ #include "Qiskit.h" // IWYU pragma: keep #include "QiskitTranslation.h" #include "QiskitVersion.h" -#include "jeff/IR/JeffDialect.h" #include "mlir/Compiler/Programs.h" #include "mlir/Dialect/CBit/IR/CBitDialect.h" #include "mlir/Dialect/CBit/IR/CBitOps.h" @@ -21,8 +20,6 @@ #include "mlir/Dialect/QC/Builder/QCProgramBuilder.h" #include "mlir/Dialect/QC/IR/QCDialect.h" #include "mlir/Dialect/QC/Translation/StandardGate.h" -#include "mlir/Dialect/QCO/IR/QCODialect.h" -#include "mlir/Dialect/QTensor/IR/QTensorDialect.h" #include "mlir/Support/IntegerExpressions.h" #include @@ -42,7 +39,6 @@ #include #include #include -#include #include #include #include @@ -51,15 +47,11 @@ #include #include #include -#include -#include #include #include #include #include #include -#include -#include #include #include @@ -89,6 +81,18 @@ using LocalParameters = llvm::StringMap; using GlobalParameters = llvm::StringMap; using ValidationParameters = llvm::StringMap; +namespace { +struct GateImportState { + explicit GateImportState(ParameterGroupRegistry& groups) + : parameterGroups(groups) {} + + llvm::DenseMap gates; + llvm::StringSet<> functionNames; + llvm::StringMap nextFunctionSuffix; + ParameterGroupRegistry& parameterGroups; +}; +} // namespace + constexpr size_t MAX_DEFINITION_DEPTH = 64U; constexpr size_t MAX_CONTROL_FLOW_DEPTH = 64U; constexpr size_t MAX_EXPANDED_OPERATIONS = 10'000'000U; @@ -126,22 +130,6 @@ parameterGroupAttribute(mlir::Builder& builder, const ParameterGroup& group) { std::to_string(MAX_PARAMETER_EXPRESSION_DEPTH) + "-level nesting depth"); } -[[nodiscard]] static std::shared_ptr createContext() { - mlir::DialectRegistry registry; - registry.insert(); - mlir::registerBuiltinDialectTranslation(registry); - mlir::registerLLVMDialectTranslation(registry); - auto context = std::make_shared(registry); - context->loadAllAvailableDialects(); - return context; -} - static void validateParameterImpl(const Parameter& parameter, const ValidationParameters& localParameters, const ValidationParameters& freeParameters, @@ -242,7 +230,7 @@ parameterValueImpl(mlir::qc::QCProgramBuilder& builder, if (const auto* unary = parameter.getUnary()) { if (unary->operation == UnaryParameterKind::Conjugate) { - /// QC scalar parameters are real-valued, so conjugation is the identity. + // QC scalar parameters are real-valued, so conjugation is the identity. return parameterValueImpl(builder, *unary->operand, localParameters, globalParameters, depth + 1U, nodes); } @@ -1292,7 +1280,8 @@ static void translateCircuit(mlir::qc::QCProgramBuilder& builder, llvm::ArrayRef classicalBits, const LocalParameters& localParameters, const GlobalParameters& globalParameters, - size_t definitionDepth, size_t controlFlowDepth, + GateImportState& gateState, size_t definitionDepth, + size_t controlFlowDepth, ImportedVariables& variables); [[nodiscard]] static int64_t rangeLength(const Loop& loop) { @@ -1375,6 +1364,7 @@ static void translateControlFlow(mlir::qc::QCProgramBuilder& builder, llvm::ArrayRef rootClbitMap, const LocalParameters& localParameters, const GlobalParameters& globalParameters, + GateImportState& gateState, const size_t definitionDepth, const size_t controlFlowDepth, ImportedVariables& variables) { @@ -1404,8 +1394,8 @@ static void translateControlFlow(mlir::qc::QCProgramBuilder& builder, const LocalParameters& parameters) { translateCircuit(builder, block, qubitMap, clbitMap, rootQubitMap, rootClbitMap, allQubits, classicalBits, parameters, - globalParameters, definitionDepth, controlFlowDepth + 1U, - variables); + globalParameters, gateState, definitionDepth, + controlFlowDepth + 1U, variables); }; const auto translateLoopWithJumps = @@ -1782,7 +1772,7 @@ void translateCircuit(mlir::qc::QCProgramBuilder& builder, const llvm::ArrayRef classicalBits, const LocalParameters& localParameters, const GlobalParameters& globalParameters, - const size_t definitionDepth, + GateImportState& gateState, const size_t definitionDepth, const size_t controlFlowDepth, ImportedVariables& variables) { std::vector declared; @@ -1805,8 +1795,8 @@ void translateCircuit(mlir::qc::QCProgramBuilder& builder, throw std::runtime_error( "Qiskit variable is declared more than once in an enclosing scope"); } - /// Definite initialization rejects reads until a store; the initial loop - /// state still needs a representable, unobservable scalar placeholder. + // Definite initialization rejects reads until a store. The initial loop + // state still needs a representable, unobservable scalar placeholder. variables.variables.emplace( variable.identity, ImportedVariables::Entry{ .value = mlir::arith::ConstantOp::create( @@ -1864,7 +1854,96 @@ void translateCircuit(mlir::qc::QCProgramBuilder& builder, translateCircuit(builder, *definition, definitionQubits, definitionClbits, definitionQubits, definitionClbits, allQubits, classicalBits, localParameters, globalParameters, - definitionDepth + 1U, controlFlowDepth, variables); + gateState, definitionDepth + 1U, controlFlowDepth, + variables); + }; + const auto translateGate = [&](size_t index, const Instruction& instruction) { + auto definition = circuit.definition(index); + const auto definitionParameters = definition->parameters(); + const auto identity = circuit.definitionIdentity(index); + auto known = gateState.gates.find(identity); + auto function = + known == gateState.gates.end() ? mlir::func::FuncOp{} : known->second; + if (!function) { + llvm::SmallVector argumentTypes(definitionParameters.size(), + builder.getF64Type()); + argumentTypes.append(definition->numQubits(), + mlir::qc::QubitType::get(builder.getContext())); + std::string symbol = instruction.name; + if (!gateState.functionNames.insert(symbol).second) { + auto& suffix = gateState.nextFunctionSuffix[instruction.name]; + do { + symbol = instruction.name + "_" + std::to_string(suffix++); + } while (!gateState.functionNames.insert(symbol).second); + } + function = builder.createUnitaryFunction( + symbol, argumentTypes, [&](mlir::ValueRange arguments) { + LocalParameters formalParameters; + for (const auto [parameterIndex, parameter] : + llvm::enumerate(definitionParameters)) { + const auto* symbol = parameter.getSymbol(); + if (symbol == nullptr || symbol->name.empty()) { + throw std::runtime_error( + "Qiskit Gate definition has an invalid formal parameter"); + } + formalParameters[symbol->name] = arguments[parameterIndex]; + } + llvm::SmallVector definitionQubits( + arguments.drop_front(definitionParameters.size())); + std::vector definitionQubitMap(definitionQubits.size()); + std::iota(definitionQubitMap.begin(), definitionQubitMap.end(), 0U); + ImportedVariables gateVariables; + translateCircuit(builder, *definition, definitionQubitMap, {}, + definitionQubitMap, {}, definitionQubits, {}, + formalParameters, {}, gateState, + definitionDepth + 1U, controlFlowDepth, + gateVariables); + }); + if (symbol != instruction.name) { + function->setAttr( + mlir::mqt::MQTDialect::SourceNameAttrHelper::getNameStr(), + builder.getStringAttr(instruction.name)); + } + for (const auto [parameterIndex, parameter] : + llvm::enumerate(definitionParameters)) { + const auto* symbol = parameter.getSymbol(); + function.setArgAttr( + parameterIndex, + mlir::mqt::MQTDialect::InputNameAttrHelper::getNameStr(), + builder.getStringAttr(symbol->name)); + if (symbol->group) { + gateState.parameterGroups.add(*symbol->group); + function.setArgAttr( + parameterIndex, + mlir::mqt::MQTDialect::ParameterGroupAttrHelper::getNameStr(), + parameterGroupAttribute(builder, *symbol->group)); + } + } + gateState.gates.insert({identity, function}); + } + + llvm::SmallVector qubits; + qubits.reserve(instruction.qubits.size()); + for (const auto qubit : instruction.qubits) { + qubits.push_back(getQubit(qubit)); + } + llvm::SmallVector parameters; + for (const auto& parameter : definitionParameters) { + auto value = + parameterValue(builder, parameter, localParameters, globalParameters); + parameters.push_back(std::holds_alternative(value) + ? floatConstant(builder, std::get(value)) + : std::get(value)); + } + emitModifiedOperation( + builder, instruction, qubits, + modifiedQubitArity(instruction, definition->numQubits()), + localParameters, globalParameters, + [&](mlir::ValueRange targetArguments) { + llvm::SmallVector operands(parameters); + llvm::append_range(operands, targetArguments); + builder.call(function, operands); + }); }; for (size_t index = 0; index < circuit.numInstructions(); ++index) { @@ -1878,7 +1957,7 @@ void translateCircuit(mlir::qc::QCProgramBuilder& builder, emitGate(builder, instruction, allQubits, qubitMap, localParameters, globalParameters); } else { - translateDefinition(index, instruction); + translateGate(index, instruction); } break; case OperationKind::Barrier: { @@ -1933,8 +2012,8 @@ void translateCircuit(mlir::qc::QCProgramBuilder& builder, const auto controlFlow = circuit.controlFlow(index); translateControlFlow(builder, *controlFlow, allQubits, classicalBits, rootQubitMap, rootClbitMap, localParameters, - globalParameters, definitionDepth, controlFlowDepth, - variables); + globalParameters, gateState, definitionDepth, + controlFlowDepth, variables); break; } case OperationKind::Delay: @@ -1956,6 +2035,8 @@ struct ExpansionSummary { struct ExpansionCountState { llvm::DenseMap definitions; llvm::DenseSet activeDefinitions; + llvm::DenseSet materializedGateDefinitions; + size_t materializedDefinitionOperations = 0U; }; } // namespace @@ -1984,10 +2065,11 @@ expansionSummary(const CircuitReader& circuit, ExpansionCountState& state, for (size_t index = 0; index < circuit.numInstructions(); ++index) { addExpandedOperations(result.operations, 1U); const auto instruction = circuit.instruction(index); - if ((instruction.kind == OperationKind::Gate && - !instruction.standardGate) || - instruction.kind == OperationKind::Unknown) { - if (!instruction.modifiers.empty()) { + const bool customGate = + instruction.kind == OperationKind::Gate && !instruction.standardGate; + if (customGate || instruction.kind == OperationKind::Unknown) { + if (instruction.kind == OperationKind::Unknown && + !instruction.modifiers.empty()) { throw std::runtime_error( "Qiskit circuit import does not support modifiers on custom " "instructions"); @@ -2002,17 +2084,27 @@ expansionSummary(const CircuitReader& circuit, ExpansionCountState& state, "Qiskit instruction definitions contain a cycle"); } ExpansionSummary definitionSummary; + bool materializeGate = false; try { if (definitionDepth >= MAX_DEFINITION_DEPTH) { throw std::runtime_error( "Qiskit instruction definitions exceed the nesting limit of 64"); } const auto definition = circuit.definition(index); - if (definition->numQubits() != instruction.qubits.size() || + const auto controls = + customGate ? modifierControlCount(instruction) : 0U; + if (definition->numQubits() + controls != instruction.qubits.size() || definition->numClbits() != instruction.clbits.size()) { throw std::runtime_error("Qiskit instruction '" + instruction.name + "' does not match its definition arity"); } + if (customGate) { + if (instruction.name.empty()) { + throw std::runtime_error("Qiskit Gate has an empty name"); + } + materializeGate = + state.materializedGateDefinitions.insert(identity).second; + } if (const auto cached = state.definitions.find(identity); cached != state.definitions.end()) { definitionSummary = cached->second; @@ -2040,7 +2132,14 @@ expansionSummary(const CircuitReader& circuit, ExpansionCountState& state, } result.controlFlowDepth = std::max(result.controlFlowDepth, definitionSummary.controlFlowDepth); - addExpandedOperations(result.operations, definitionSummary.operations); + if (customGate) { + if (materializeGate) { + addExpandedOperations(state.materializedDefinitionOperations, + definitionSummary.operations); + } + } else { + addExpandedOperations(result.operations, definitionSummary.operations); + } continue; } if (instruction.kind != OperationKind::ControlFlow) { @@ -2079,8 +2178,10 @@ static void validateCircuit(const CircuitReader& circuit, const ValidationParameters& localParameters, const ValidationParameters& freeParameters, llvm::StringSet<>& parameterNames, + llvm::DenseSet& validatedGateDefinitions, uint32_t rootQubits, uint32_t rootClbits, - size_t definitionDepth, size_t controlFlowDepth); + size_t definitionDepth, size_t controlFlowDepth, + bool gateDefinition); static void validateExpression(const Expression& expression, const uint32_t rootClbits, @@ -2267,14 +2368,12 @@ static void validateTarget(const ClassicalTarget& target, } } -static void validateControlFlow(const ControlFlowReader& controlFlow, - ValidationParameters localParameters, - const ValidationParameters& freeParameters, - llvm::StringSet<>& parameterNames, - const uint32_t rootQubits, - const uint32_t rootClbits, - const size_t definitionDepth, - const size_t controlFlowDepth) { +static void validateControlFlow( + const ControlFlowReader& controlFlow, ValidationParameters localParameters, + const ValidationParameters& freeParameters, + llvm::StringSet<>& parameterNames, + llvm::DenseSet& validatedGateDefinitions, uint32_t rootQubits, + uint32_t rootClbits, size_t definitionDepth, size_t controlFlowDepth) { if (controlFlowDepth >= MAX_CONTROL_FLOW_DEPTH) { throw std::runtime_error( "Qiskit control flow exceeds the nesting limit of 64"); @@ -2399,35 +2498,68 @@ static void validateControlFlow(const ControlFlowReader& controlFlow, "Qiskit control-flow block operands do not match its bit mapping"); } validateCircuit(*block, localParameters, freeParameters, parameterNames, - rootQubits, rootClbits, definitionDepth, - controlFlowDepth + 1U); + validatedGateDefinitions, rootQubits, rootClbits, + definitionDepth, controlFlowDepth + 1U, false); } } -static void validateDefinition(const CircuitReader& circuit, const size_t index, - const ValidationParameters& localParameters, - const ValidationParameters& freeParameters, - llvm::StringSet<>& parameterNames, - const size_t definitionDepth, - const size_t controlFlowDepth) { +static void +validateDefinition(const CircuitReader& circuit, size_t index, + const ValidationParameters& localParameters, + const ValidationParameters& freeParameters, + llvm::StringSet<>& parameterNames, + llvm::DenseSet& validatedGateDefinitions, + size_t definitionDepth, size_t controlFlowDepth, + bool gateDefinition) { if (definitionDepth >= MAX_DEFINITION_DEPTH) { throw std::runtime_error( "Qiskit instruction definitions exceed the nesting limit of 64"); } const auto definition = circuit.definition(index); - validateCircuit(*definition, localParameters, freeParameters, parameterNames, - definition->numQubits(), definition->numClbits(), - definitionDepth + 1U, controlFlowDepth); + if (!gateDefinition) { + validateCircuit(*definition, localParameters, freeParameters, + parameterNames, validatedGateDefinitions, + definition->numQubits(), definition->numClbits(), + definitionDepth + 1U, controlFlowDepth, false); + return; + } + if (definition->numClbits() != 0U) { + throw std::runtime_error( + "Qiskit Gate definitions must not contain classical bits"); + } + ValidationParameters formalParameters; + for (const auto& parameter : definition->parameters()) { + validateParameter(parameter, localParameters, freeParameters); + const auto* symbol = parameter.getSymbol(); + if (symbol == nullptr || symbol->name.empty() || + !formalParameters.try_emplace(symbol->name, parameter).second) { + throw std::runtime_error( + "Qiskit Gate definition has invalid formal parameters"); + } + } + if (!validatedGateDefinitions.insert(circuit.definitionIdentity(index)) + .second) { + return; + } + validateCircuit(*definition, formalParameters, {}, parameterNames, + validatedGateDefinitions, definition->numQubits(), 0U, + definitionDepth + 1U, controlFlowDepth, true); } void validateCircuit(const CircuitReader& circuit, const ValidationParameters& localParameters, const ValidationParameters& freeParameters, llvm::StringSet<>& parameterNames, + llvm::DenseSet& validatedGateDefinitions, const uint32_t rootQubits, const uint32_t rootClbits, - const size_t definitionDepth, - const size_t controlFlowDepth) { - for (const auto& variable : circuit.variables()) { + size_t definitionDepth, size_t controlFlowDepth, + bool gateDefinition) { + const auto variables = circuit.variables(); + if (gateDefinition && !variables.empty()) { + throw std::runtime_error( + "Qiskit Gate definitions must not contain classical variables"); + } + for (const auto& variable : variables) { if (variable.type == ClassicalType::Uint && variable.width > 64U) { throw std::runtime_error("Qiskit local variables support unsigned " "integers of at most 64 bits"); @@ -2463,9 +2595,19 @@ void validateCircuit(const CircuitReader& circuit, } for (const auto& modifier : instruction.modifiers) { if (modifier.kind == GateModifierKind::Power) { - validateParameter(modifier.exponent, localParameters, freeParameters); + const auto* number = modifier.exponent.getNumber(); + if (number == nullptr || !std::isfinite(number->value)) { + throw std::runtime_error( + "Qiskit Gate power must have a finite numeric exponent"); + } } } + if (gateDefinition && instruction.kind != OperationKind::Gate && + instruction.kind != OperationKind::Unitary) { + throw std::runtime_error( + "Qiskit Gate definitions may contain only Gate and unitary " + "operations"); + } switch (instruction.kind) { case OperationKind::Gate: @@ -2487,13 +2629,9 @@ void validateCircuit(const CircuitReader& circuit, } break; } - if (!instruction.modifiers.empty()) { - throw std::runtime_error( - "Qiskit circuit import does not support modifiers on custom " - "instructions"); - } validateDefinition(circuit, index, localParameters, freeParameters, - parameterNames, definitionDepth, controlFlowDepth); + parameterNames, validatedGateDefinitions, + definitionDepth, controlFlowDepth, true); break; case OperationKind::Unknown: if (!instruction.modifiers.empty()) { @@ -2502,7 +2640,8 @@ void validateCircuit(const CircuitReader& circuit, "instructions"); } validateDefinition(circuit, index, localParameters, freeParameters, - parameterNames, definitionDepth, controlFlowDepth); + parameterNames, validatedGateDefinitions, + definitionDepth, controlFlowDepth, false); break; case OperationKind::Barrier: if (!instruction.parameters.empty() || !instruction.clbits.empty()) { @@ -2578,8 +2717,8 @@ void validateCircuit(const CircuitReader& circuit, case OperationKind::ControlFlow: { const auto controlFlow = circuit.controlFlow(index); validateControlFlow(*controlFlow, localParameters, freeParameters, - parameterNames, rootQubits, rootClbits, - definitionDepth, controlFlowDepth); + parameterNames, validatedGateDefinitions, rootQubits, + rootClbits, definitionDepth, controlFlowDepth); break; } case OperationKind::Delay: @@ -2618,9 +2757,13 @@ mlir::QCProgram importCircuit(const nb::handle circuit) { } ExpansionCountState expansion; - static_cast(expansionSummary(*view, expansion)); + auto expanded = expansionSummary(*view, expansion); + addExpandedOperations(expanded.operations, + expansion.materializedDefinitionOperations); + llvm::DenseSet validatedGateDefinitions; validateCircuit(*view, {}, freeParameterSymbols, parameterNames, - view->numQubits(), view->numClbits(), 0U, 0U); + validatedGateDefinitions, view->numQubits(), + view->numClbits(), 0U, 0U, false); const auto quantumRegisters = circuitRegisters(*view, true); const auto classicalRegisters = circuitRegisters(*view, false); for (const auto& reg : @@ -2635,7 +2778,7 @@ mlir::QCProgram importCircuit(const nb::handle circuit) { const auto looseClbits = validateRegisterLayout( classicalRegisters, view->numClbits(), "classical"); - auto context = createContext(); + auto context = mlir::createCompilerContext(); mlir::qc::QCProgramBuilder builder(context.get()); llvm::SmallVector resultTypes; if (view->numClbits() == 0U) { @@ -2667,9 +2810,7 @@ mlir::QCProgram importCircuit(const nb::handle circuit) { parameterGroupAttribute(builder, *symbol->group))); } const auto index = function.getNumArguments(); - // MLIR types are handles. Converting FloatType to Type keeps the same - // storage and does not slice object state. - const mlir::Type parameterType = builder.getF64Type(); + mlir::Type parameterType = builder.getF64Type(); if (failed(function.insertArgument( index, parameterType, builder.getDictionaryAttr(argumentAttributes), builder.getLoc()))) { @@ -2717,9 +2858,11 @@ mlir::QCProgram importCircuit(const nb::handle circuit) { std::iota(qubitMap.begin(), qubitMap.end(), 0U); std::iota(clbitMap.begin(), clbitMap.end(), 0U); ImportedVariables variables; + GateImportState gateState(parameterGroups); + gateState.functionNames.insert(function.getName()); translateCircuit(builder, *view, qubitMap, clbitMap, qubitMap, clbitMap, - qubits, classicalBits, {}, globalParameters, 0U, 0U, - variables); + qubits, classicalBits, {}, globalParameters, gateState, 0U, + 0U, variables); auto moduleOp = classicalStorage.empty() ? builder.finalize() : builder.finalize(classicalStorage); diff --git a/bindings/mlir/qiskit/QiskitTranslation.h b/bindings/mlir/qiskit/QiskitTranslation.h index e69c5cf986..24ea4af613 100644 --- a/bindings/mlir/qiskit/QiskitTranslation.h +++ b/bindings/mlir/qiskit/QiskitTranslation.h @@ -48,7 +48,7 @@ struct Register { std::vector bits; }; -/** Validate canonical register membership and return the leading loose bits. */ +/// Validate canonical register membership and return the leading loose bits. [[nodiscard]] uint32_t validateRegisterLayout(const std::vector& registers, uint32_t total, std::string_view kind); @@ -57,7 +57,7 @@ inline constexpr size_t MAX_PARAMETER_EXPRESSION_DEPTH = 64U; inline constexpr size_t MAX_PARAMETER_EXPRESSION_NODES = 4096U; inline constexpr uint64_t MAX_PARAMETER_GROUP_SIZE = 65'536U; -/** Source-level vector metadata for one scalar parameter. */ +/// Source-level vector metadata for one scalar parameter. struct ParameterGroup { std::string identity; std::string name; @@ -98,7 +98,7 @@ enum class BinaryParameterKind : uint8_t { Power, }; -/** One normalized scalar parameter-expression tree. */ +/// One normalized scalar parameter-expression tree. class Parameter { public: struct Number { @@ -151,27 +151,27 @@ class Parameter { } [[nodiscard]] const Number* getNumber() const { - return std::get_if(&storage); + return std::get_if(&storage_); } [[nodiscard]] const Symbol* getSymbol() const { - return std::get_if(&storage); + return std::get_if(&storage_); } [[nodiscard]] const Unary* getUnary() const { - return std::get_if(&storage); + return std::get_if(&storage_); } [[nodiscard]] const Binary* getBinary() const { - return std::get_if(&storage); + return std::get_if(&storage_); } private: using Value = std::variant; - explicit Parameter(Value value) : storage(std::move(value)) {} + explicit Parameter(Value value) : storage_(std::move(value)) {} - Value storage = Number{0.0}; + Value storage_ = Number{0.0}; }; enum class GateModifierKind : uint8_t { @@ -257,7 +257,7 @@ struct ClassicalVariable { bool input = false; }; -/** One normalized Qiskit classical-expression tree. */ +/// One normalized Qiskit classical-expression tree. struct Expression { ExpressionKind kind = ExpressionKind::Value; ClassicalType type = ClassicalType::Bool; @@ -335,7 +335,7 @@ class CircuitReader { [[nodiscard]] virtual std::vector variables() const = 0; [[nodiscard]] virtual Register quantumRegister(size_t index) const = 0; [[nodiscard]] virtual Register classicalRegister(size_t index) const = 0; - /** Return the circuit's free scalar parameters in a stable order. */ + /// Return the circuit's free scalar parameters in a stable order. [[nodiscard]] virtual std::vector parameters() const = 0; [[nodiscard]] virtual Parameter globalPhase() const = 0; [[nodiscard]] virtual Instruction instruction(size_t index) const = 0; @@ -386,6 +386,10 @@ class CircuitWriter { virtual void addGate(StandardGateMapping gate, const std::vector& qubits, const std::vector& parameters) = 0; + virtual void addCustomGate(std::string_view name, + const std::vector& qubits, + const std::vector& parameters, + const std::vector& modifiers) = 0; virtual void addMeasure(uint32_t qubit, uint32_t clbit) = 0; virtual void addReset(uint32_t qubit) = 0; virtual void addBarrier(const std::vector& qubits) = 0; @@ -398,7 +402,7 @@ class CircuitWriter { addControlFlow(ControlFlowKind kind, ClassicalTarget target, Loop loop, std::vector switchCases, std::vector> blocks) = 0; - /** Transfer the native circuit to a new owned Python QuantumCircuit. */ + /// Transfer the native circuit to a new owned Python QuantumCircuit. [[nodiscard]] virtual nb::object finish() = 0; }; @@ -416,6 +420,10 @@ class VersionedTranslation { [[nodiscard]] virtual bool supportsGate(StandardGateMapping gate) const = 0; [[nodiscard]] virtual std::unique_ptr createCircuit(uint32_t looseQubits, uint32_t looseClbits) const = 0; + virtual void + registerCustomGate(std::string_view symbol, std::string_view name, + const std::vector& formalParameters, + std::unique_ptr definition) = 0; }; #define MQT_QISKIT_DECLARE_VERSION_IMPL(suffix) \ diff --git a/test/python/test_mlir_qiskit_translation.py b/test/python/test_mlir_qiskit_translation.py index e6ed7e0d5d..596fb7bb68 100644 --- a/test/python/test_mlir_qiskit_translation.py +++ b/test/python/test_mlir_qiskit_translation.py @@ -27,6 +27,7 @@ Clbit, ControlModifier, Gate, + Instruction, InverseModifier, Parameter, ParameterExpression, @@ -46,6 +47,8 @@ if TYPE_CHECKING: from collections.abc import Callable + from qiskit.circuit.annotated_operation import Modifier + installed_qiskit = Version(qiskit.__version__) candidate_version = os.environ.get("MQT_QISKIT_TEST_CANDIDATE_VERSION") if not (Version("2.5.0") <= installed_qiskit < Version("2.6.0") or qiskit.__version__ == candidate_version): @@ -1264,24 +1267,127 @@ def test_layout_is_accepted_and_ignored() -> None: assert np.allclose(Operator(restored).data, Operator(laid_out).data) -def test_nested_numeric_custom_definitions_are_inlined() -> None: - """Bind numeric call parameters and recursively inline definitions.""" +def test_nested_numeric_custom_definitions_are_preserved() -> None: + """Keep nested custom Gates as reusable functions after binding.""" theta = Parameter("theta") - definition = QuantumCircuit(1) + definition = QuantumCircuit(1, name="inner") definition.rx(theta, 0) - inner = definition.to_gate(label="inner") - middle_definition = QuantumCircuit(1) + inner = definition.to_gate() + middle_definition = QuantumCircuit(1, name="outer") middle_definition.append(inner, [0]) - outer = middle_definition.to_gate(label="outer") + outer = middle_definition.to_gate() circuit = QuantumCircuit(1) circuit.append(outer, [0]) circuit.assign_parameters({theta: 0.25}, inplace=True) program = QCProgram.from_qiskit(circuit) + restored = program.to_qiskit() + assert program.ir.count("mqt.unitary") == 2 + assert "qc.call @inner" in program.ir + assert "qc.call @outer" in program.ir assert "qc.rx" in program.ir assert "2.500000e-01" in program.ir assert circuit.parameters == set() + assert restored.data[0].operation.name == "outer" + assert np.allclose(Operator(restored).data, Operator(circuit).data) + + +def test_custom_gate_definitions_are_interned_by_name_and_body() -> None: + """Reuse copied definitions and unique distinct same-named bodies.""" + definition = QuantumCircuit(1, name="shared") + definition.h(0) + gate = definition.to_gate() + circuit = QuantumCircuit(1) + circuit.append(gate, [0]) + circuit.append(gate, [0]) + + assert QCProgram.from_qiskit(circuit).ir.count("mqt.unitary") == 1 + + conflicting_definition = QuantumCircuit(1, name="shared") + conflicting_definition.x(0) + circuit.append(conflicting_definition.to_gate(), [0]) + program = QCProgram.from_qiskit(circuit) + restored = program.to_qiskit() + + assert program.ir.count("mqt.unitary") == 2 + assert 'mqt.source_name = "shared"' in program.ir + assert [item.operation.name for item in restored.data] == ["shared", "shared", "shared"] + assert np.allclose(Operator(restored).data, Operator(circuit).data) + + +def test_custom_gate_with_standard_name_is_not_mistranslated() -> None: + """Classify standard gates by Qiskit identity rather than by name.""" + definition = QuantumCircuit(1) + definition.h(0) + custom = Gate("x", 1, []) + custom.definition = definition + circuit = QuantumCircuit(1) + circuit.append(custom, [0]) + + program = QCProgram.from_qiskit(circuit) + restored = program.to_qiskit() + + assert "func.func private @x" in program.ir + assert "qc.h" in program.ir + assert np.allclose(Operator(restored).data, Operator(circuit).data) + + +def test_custom_gate_export_rejects_duplicate_qargs() -> None: + """Reject a call Qiskit cannot apply to distinct Gate inputs.""" + program = QCProgram.from_mlir_str( + """module { + func.func private @pair(%a: !qc.qubit, %b: !qc.qubit) attributes {mqt.unitary} { + return + } + func.func @main() attributes {mqt.entry_point} { + %q = qc.alloc : !qc.qubit + qc.call @pair(%q, %q) : !qc.qubit, !qc.qubit + qc.dealloc %q : !qc.qubit + return + } +} +""" + ) + + with pytest.raises(RuntimeError, match="uses the same qubit more than once"): + program.to_qiskit() + + +def test_custom_gate_export_rejects_unreferenced_functions() -> None: + """Reject helper definitions that a Qiskit circuit cannot retain.""" + program = QCProgram.from_mlir_str( + """module { + func.func private @unused(%q: !qc.qubit) attributes {mqt.unitary} { + qc.x %q : !qc.qubit + return + } + func.func @main() attributes {mqt.entry_point} { + return + } +} +""" + ) + + with pytest.raises(RuntimeError, match="cannot preserve function 'unused'"): + program.to_qiskit() + + +def test_generic_instruction_with_clbits_remains_flattened() -> None: + """Keep classical Instruction definitions outside the unitary call ABI.""" + definition = QuantumCircuit(1, 1) + definition.measure(0, 0) + instruction = Instruction("observe", 1, 1, []) + instruction.definition = definition + circuit = QuantumCircuit(1, 1) + circuit.append(instruction, [0], [0]) + + program = QCProgram.from_qiskit(circuit) + restored = program.to_qiskit() + + assert "mqt.unitary" not in program.ir + assert "qc.measure" in program.ir + assert restored.data[0].operation.name == "measure" def test_ambiguous_custom_parameter_binding_is_rejected() -> None: @@ -1296,7 +1402,7 @@ def test_ambiguous_custom_parameter_binding_is_rejected() -> None: circuit = QuantumCircuit(1) circuit.append(gate, [0]) - with pytest.raises(RuntimeError, match="parameter symbol 'z' is not defined"): + with pytest.raises(RuntimeError, match=r"parameter symbol '[az]' is not defined"): QCProgram.from_qiskit(circuit) @@ -1319,18 +1425,56 @@ def test_custom_definition_uses_call_parameter_order_after_binding() -> None: @pytest.mark.parametrize("modifier", [InverseModifier(), PowerModifier(0.5), ControlModifier(1)]) -def test_modified_custom_definitions_are_rejected( +def test_modified_custom_definitions_round_trip( modifier: InverseModifier | PowerModifier | ControlModifier, ) -> None: - """Reject modifiers whose semantics cannot be preserved while inlining.""" - definition = QuantumCircuit(1) + """Preserve supported modifiers around reusable custom Gates.""" + definition = QuantumCircuit(1, name="custom") definition.h(0) - custom = definition.to_gate(label="custom") + custom = definition.to_gate() operation = AnnotatedOperation(custom, modifier) circuit = QuantumCircuit(operation.num_qubits) circuit.append(operation, circuit.qubits) - with pytest.raises(RuntimeError, match="does not support modifiers on custom instructions"): + program = QCProgram.from_qiskit(circuit) + restored = program.to_qiskit() + + assert "qc.call @custom" in program.ir + assert isinstance(restored.data[0].operation, AnnotatedOperation) + assert restored.data[0].operation.modifiers == [modifier] + assert np.allclose(Operator(restored).data, Operator(circuit).data) + + +def test_mixed_custom_gate_modifiers_are_canonicalized() -> None: + """Preserve semantics while combining commuting closed controls.""" + definition = QuantumCircuit(1, name="custom") + definition.h(0) + source_modifiers: list[Modifier] = [ + ControlModifier(1), + InverseModifier(), + PowerModifier(0.5), + ControlModifier(2), + ] + operation = AnnotatedOperation(definition.to_gate(), source_modifiers) + circuit = QuantumCircuit(operation.num_qubits) + circuit.append(operation, circuit.qubits) + + restored = QCProgram.from_qiskit(circuit).to_qiskit() + restored_operation = restored.data[0].operation + + assert isinstance(restored_operation, AnnotatedOperation) + assert restored_operation.modifiers == [InverseModifier(), PowerModifier(0.5), ControlModifier(3)] + assert np.allclose(Operator(restored).data, Operator(circuit).data) + + +def test_symbolic_power_modifier_is_rejected() -> None: + """Reject symbolic modifier state that Qiskit does not track or bind.""" + theta = Parameter("theta") + operation = AnnotatedOperation(library.XGate(), PowerModifier(theta)) + circuit = QuantumCircuit(1) + circuit.append(operation, [0]) + + with pytest.raises(RuntimeError, match="power must have a finite numeric exponent"): QCProgram.from_qiskit(circuit) @@ -1373,13 +1517,14 @@ def test_cyclic_and_excessively_nested_definitions_are_rejected() -> None: QCProgram.from_qiskit(too_deep) -def test_exponential_definition_expansion_is_rejected_by_budget() -> None: - """Count repeated definitions without materializing their full expansion.""" +def test_repeated_definition_graph_remains_compact() -> None: + """Keep a branching Gate graph compact through import and export.""" leaf_definition = QuantumCircuit(1) leaf_definition.h(0) nested = Gate("leaf", 1, []) nested.definition = leaf_definition - for level in range(22): + levels = 25 + for level in range(levels): definition = QuantumCircuit(1) definition.append(nested, [0]) definition.append(nested, [0]) @@ -1388,28 +1533,203 @@ def test_exponential_definition_expansion_is_rejected_by_budget() -> None: circuit = QuantumCircuit(1) circuit.append(nested, [0]) - with pytest.raises(RuntimeError, match="expansion exceeds 10000000 operations"): - QCProgram.from_qiskit(circuit) + program = QCProgram.from_qiskit(circuit) + + assert program.ir.count("mqt.unitary") == levels + 1 + assert program.ir.count("qc.call") == (2 * levels) + 1 + restored = QCProgram.from_qiskit(program.to_qiskit()) + assert restored.ir.count("mqt.unitary") == levels + 1 + assert restored.ir.count("qc.call") == (2 * levels) + 1 + + +def test_custom_gate_export_reuses_a_long_call_chain() -> None: + """Export repeated calls after visiting enough helpers to grow the graph.""" + functions = [ + """ func.func private @leaf(%q: !qc.qubit) attributes {mqt.unitary} { + qc.h %q : !qc.qubit + return + }""" + ] + callee = "leaf" + for level in range(59): + name = f"level_{level}" + functions.append( + f""" func.func private @{name}(%q: !qc.qubit) attributes {{mqt.unitary}} {{ + qc.call @{callee}(%q) : !qc.qubit + return + }}""" + ) + callee = name + functions.append( + f""" func.func @main() attributes {{mqt.entry_point}} {{ + %q = qc.alloc : !qc.qubit + qc.call @{callee}(%q) : !qc.qubit + qc.call @{callee}(%q) : !qc.qubit + qc.dealloc %q : !qc.qubit + return + }}""" + ) + program = QCProgram.from_mlir_str("module {\n" + "\n".join(functions) + "\n}\n") + + restored = program.to_qiskit() + + assert np.allclose(Operator(restored).data, np.eye(2)) + assert QCProgram.from_qiskit(restored).ir.count("mqt.unitary") == 60 + + +def test_custom_gate_export_checks_longest_shared_call_path() -> None: + """Reject a deep path even when its shared leaf was already visited.""" + functions = [ + """ func.func private @leaf(%q: !qc.qubit) attributes {mqt.unitary} { + qc.h %q : !qc.qubit + return + }""" + ] + callee = "leaf" + for level in range(64): + name = f"level_{level}" + functions.append( + f""" func.func private @{name}(%q: !qc.qubit) attributes {{mqt.unitary}} {{ + qc.call @{callee}(%q) : !qc.qubit + return + }}""" + ) + callee = name + functions.append( + f""" func.func @main() attributes {{mqt.entry_point}} {{ + %q = qc.alloc : !qc.qubit + qc.call @leaf(%q) : !qc.qubit + qc.call @{callee}(%q) : !qc.qubit + qc.dealloc %q : !qc.qubit + return + }}""" + ) + program = QCProgram.from_mlir_str("module {\n" + "\n".join(functions) + "\n}\n") + + with pytest.raises(RuntimeError, match="custom Gate calls exceed the nesting limit of 64"): + program.to_qiskit() + + +def test_constant_unary_custom_gate_argument_is_folded() -> None: + """Fold constant expressions before reconstructing Python parameters.""" + program = QCProgram.from_mlir_str( + """module { + func.func private @custom(%theta: f64 {mqt.input_name = "theta"}, %q: !qc.qubit) attributes {mqt.unitary} { + qc.rx(%theta) %q : !qc.qubit + return + } + func.func @main() attributes {mqt.entry_point} { + %q = qc.alloc : !qc.qubit + %constant = arith.constant 5.000000e-01 : f64 + %angle = math.sin %constant : f64 + qc.call @custom(%angle, %q) : f64, !qc.qubit + qc.dealloc %q : !qc.qubit + return + } +} +""" + ) + + restored = program.to_qiskit() + + assert restored.data[0].operation.params == [pytest.approx(np.sin(0.5))] -def test_value_list_loop_expansion_counts_each_iteration() -> None: - """Apply the expansion budget to every statically unrolled loop value.""" +def test_float_castable_custom_gate_argument_keeps_its_symbol() -> None: + """Do not fold a unary expression whose operand still tracks a symbol.""" + program = QCProgram.from_mlir_str( + """module { + func.func private @custom(%angle: f64 {mqt.input_name = "angle"}, %q: !qc.qubit) attributes {mqt.unitary} { + qc.rx(%angle) %q : !qc.qubit + return + } + func.func @main(%theta: f64 {mqt.input_name = "theta"}) attributes {mqt.entry_point} { + %q = qc.alloc : !qc.qubit + %zero = arith.subf %theta, %theta : f64 + %angle = math.sin %zero : f64 + qc.call @custom(%angle, %q) : f64, !qc.qubit + qc.dealloc %q : !qc.qubit + return + } +} +""" + ) + + restored = program.to_qiskit() + + assert {parameter.name for parameter in restored.parameters} == {"theta"} + assert restored.data[0].operation.params[0].parameters == restored.parameters + + +def test_distinct_custom_gate_specializations_round_trip() -> None: + """Preserve same-named numeric and symbolic specializations.""" + theta = Parameter("theta") + x = Parameter("x") + y = Parameter("y") + definition = QuantumCircuit(1, name="custom") + definition.rx(theta, 0) + circuit = QuantumCircuit(1) + for actual in (0.1, 0.2, x + 0.25, y * 2): + circuit.append(definition.to_gate(parameter_map={theta: actual}), [0]) + + program = QCProgram.from_qiskit(circuit) + restored = program.to_qiskit() + + assert program.ir.count("mqt.unitary") == 4 + assert 'mqt.source_name = "custom"' in program.ir + assert [item.operation.name for item in restored.data] == ["custom"] * 4 + values = {"x": 0.3, "y": -0.2} + assert np.allclose( + Operator(_assign_parameter_values(restored, values)).data, + Operator(_assign_parameter_values(circuit, values)).data, + ) + + +def test_parameterized_helper_specializes_across_qiskit_round_trip() -> None: + """Retain semantics and source names when Qiskit erases a shared ABI.""" + program = QCProgram.from_mlir_str( + """module { + func.func private @custom(%theta: f64 {mqt.input_name = "theta"}, %q: !qc.qubit) attributes {mqt.unitary} { + qc.rx(%theta) %q : !qc.qubit + return + } + func.func @main() attributes {mqt.entry_point} { + %q = qc.alloc : !qc.qubit + %first = arith.constant 1.000000e-01 : f64 + %second = arith.constant 2.000000e-01 : f64 + qc.call @custom(%first, %q) : f64, !qc.qubit + qc.call @custom(%second, %q) : f64, !qc.qubit + qc.dealloc %q : !qc.qubit + return + } +} +""" + ) + + qiskit_circuit = program.to_qiskit() + restored_program = QCProgram.from_qiskit(qiskit_circuit) + restored_circuit = restored_program.to_qiskit() + + assert restored_program.ir.count("mqt.unitary") == 2 + assert 'mqt.source_name = "custom"' in restored_program.ir + assert [item.operation.name for item in restored_circuit.data] == ["custom", "custom"] + assert np.allclose(Operator(restored_circuit).data, Operator(qiskit_circuit).data) + + +def test_custom_gate_in_value_list_loop_is_reused() -> None: + """Reuse one custom Gate across a statically unrolled value-list loop.""" leaf_definition = QuantumCircuit(1) leaf_definition.h(0) nested = Gate("leaf", 1, []) nested.definition = leaf_definition - for level in range(20): - definition = QuantumCircuit(1) - definition.append(nested, [0]) - definition.append(nested, [0]) - nested = Gate(f"branch_{level}", 1, []) - nested.definition = definition circuit = QuantumCircuit(1) with circuit.for_loop([0, 2, 5, 9], None, None, None, None, label=None): circuit.append(nested, [0]) - with pytest.raises(RuntimeError, match="expansion exceeds 10000000 operations"): - QCProgram.from_qiskit(circuit) + program = QCProgram.from_qiskit(circuit) + + assert program.ir.count("mqt.unitary") == 1 + assert program.ir.count("qc.call @leaf") == 4 def test_rejections_do_not_modify_source_circuits() -> None: @@ -2942,7 +3262,7 @@ def test_float_castable_symbolic_expression_keeps_parameter_identity() -> None: def test_parameterized_custom_definition_round_trip() -> None: - """Substitute symbolic call parameters while recursively inlining a definition.""" + """Substitute symbolic call parameters while preserving its definition.""" formal = Parameter("formal") definition = QuantumCircuit(1) definition.rx(formal + 1, 0)