From e99d917b1bd605abac5cec22505883c827a491a5 Mon Sep 17 00:00:00 2001 From: Simon Hofmann Date: Tue, 8 Sep 2026 08:24:02 +0200 Subject: [PATCH 1/4] =?UTF-8?q?=E2=9C=A8=20Synthesize=20multi-controlled?= =?UTF-8?q?=20Pauli=20rotations?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reuse exact borrowed-helper MCX plans for numeric and symbolic RX, RY, and RZ synthesis without extra qubits. Preserve conditional phase and the existing native-target and minimum-width policies. Assisted-by: GPT via Codex --- .agent/plans/controlled-rotations.md | 65 +++++ bindings/mlir/register_mlir.cpp | 7 +- mlir/include/mlir/Compiler/Programs.h | 6 +- .../mlir/Dialect/QCO/Transforms/Passes.td | 23 +- .../DecomposeMultiControlled.cpp | 82 ++++++ .../test_multi_controlled_decomposition.cpp | 257 +++++++++++++++++- python/mqt/core/mlir.pyi | 2 +- test/python/test_mlir_qiskit_translation.py | 52 ++++ 8 files changed, 478 insertions(+), 16 deletions(-) create mode 100644 .agent/plans/controlled-rotations.md diff --git a/.agent/plans/controlled-rotations.md b/.agent/plans/controlled-rotations.md new file mode 100644 index 0000000000..b6cd3c4dc7 --- /dev/null +++ b/.agent/plans/controlled-rotations.md @@ -0,0 +1,65 @@ +# Multi-controlled Pauli rotations + +Status: complete. + +## Goal and scope + +`decompose-multi-controlled` supports RX, RY, and RZ with numeric and symbolic +angles. It preserves phase, wire order, native target operations, and the +existing `min-qubits` policy. Synthesis uses no additional qubits and scales +linearly in entangling gates before routing. + +The owning implementation is +`mlir/lib/Dialect/QCO/Transforms/Decomposition/DecomposeMultiControlled.cpp`. +Pass tests reside in the existing decomposition unit-test file. No new public +API or dependency was added. + +## Decisions + +- Derive the implementation from Pauli rotation identities and Core's existing + decomposition helpers. Do not consult or adapt Qiskit source. Use Qiskit's + public APIs only as an external performance comparator. +- For RY and RZ, split controls into two balanced groups and alternate their MCX + operations with quarter-angle rotations. Borrow controls from the other group + through Core's exact dirty-helper MCX decomposition. Helpers must be restored + coherently, including relative phases. +- Obtain RX by Hadamard conjugation of RZ. Controlled rotations through `2*pi` + retain their conditional phase; the phase-gate normalization rules do not + apply. +- Reuse the existing fixed-angle MCX plans. Keep symbolic rotation angles as SSA + values, including computations defined within a control region. + +## Validation + +The complete `mqt-core-mlir-unittest-decomposition` binary passes 263 tests, +including 26 rotation tests. These check phase-exact full operators for 2–8 +controls, runtime and region-local angles, native target and threshold policy, +and linear resources through 64 controls. + +`pytest test/python/test_mlir_qiskit_translation.py -k multi_controlled_rotations` +passes all 24 cases. Numeric target compilation requests native `gphase` to +retain overall phase, as required by the existing target contract. Symbolic +synthesis is exported and bound before exact matrix comparison. + +All 384 Python translation and typed-program checks pass, including the two QDMI +device cases with the built native device configured. General lint, stub +generation, and whole-changed-file C++ lint pass with no remaining findings. + +## Performance and limits + +Compare Qiskit 2.5 public `mcrx`/`mcry`/`mcrz` synthesis with Core's +decomposition pass, using no extra qubits and the `u,cx` basis. At 2 and 3 +controls, RX/RY use 4 and 14 CX gates versus Qiskit's 8 and 20. All other +sampled widths through 64 controls match Qiskit's CX count, for numeric and +symbolic angles. + +With identical level-3 post-optimization, larger Core outputs are 15 layers +deeper: RY has depth 186 versus 171 at 8 controls and 1978 versus 1963 at 64. +Local nine-sample median synthesis times were lower for all sampled cases in a +MinSizeRel build. These timings exclude frontend import, basis normalization, +routing, and full target compilation; they are not an end-to-end speed claim. + +Symbolic decomposition and export work. Full symbolic target compilation can +still produce `math.atan2`, which the existing Qiskit exporter does not support. +Bind parameters before target compilation when using that export path. A general +symbolic exporter change is outside this synthesis implementation. diff --git a/bindings/mlir/register_mlir.cpp b/bindings/mlir/register_mlir.cpp index 9be16696e2..ba79377f2b 100644 --- a/bindings/mlir/register_mlir.cpp +++ b/bindings/mlir/register_mlir.cpp @@ -1232,9 +1232,10 @@ operations.)pb"); &BooleanMemberAdapter< &mlir::QCOProgram::decomposeMultiControlled>::call, nb::kw_only(), "min_qubits"_a = 3, - "Decompose controlled X/Z/SWAP gates, qco.rccx, and constant-angle " - "phase gates that act on at least min_qubits qubits (min_qubits " - "must be at least 3; default 3 means wider than two-qubit).") + "Decompose controlled X/Z/SWAP and RX/RY/RZ gates, qco.rccx, and " + "constant-angle phase gates that act on at least min_qubits qubits " + "(min_qubits must be at least 3; default 3 means wider than " + "two-qubit).") .def("compile_for_target", &BooleanMemberAdapter<&mlir::QCOProgram::compileForTarget>::call, "target_environment"_a, nb::kw_only(), "enable_timing"_a = false, diff --git a/mlir/include/mlir/Compiler/Programs.h b/mlir/include/mlir/Compiler/Programs.h index d4c3bdd59b..2939be9dec 100644 --- a/mlir/include/mlir/Compiler/Programs.h +++ b/mlir/include/mlir/Compiler/Programs.h @@ -277,9 +277,9 @@ class QCOProgram final : public Program { /// Prepare the program for qubit reuse and reuse eligible qubits. [[nodiscard]] bool runQubitReusePipeline(); - /// Decompose controlled X/Z/SWAP gates, `qco.rccx`, and constant-angle phase - /// gates that act on at least @p minQubits qubits (@p minQubits must be at - /// least 3; default 3 means wider than two-qubit). + /// Decompose controlled X/Z/SWAP and RX/RY/RZ gates, `qco.rccx`, and + /// constant-angle phase gates that act on at least @p minQubits qubits + /// (@p minQubits must be at least 3; default 3 means wider than two-qubit). [[nodiscard]] bool decomposeMultiControlled(uint64_t minQubits = 3); /// Compile this program for a target in place. diff --git a/mlir/include/mlir/Dialect/QCO/Transforms/Passes.td b/mlir/include/mlir/Dialect/QCO/Transforms/Passes.td index 85e88d0cb6..cc0e284669 100644 --- a/mlir/include/mlir/Dialect/QCO/Transforms/Passes.td +++ b/mlir/include/mlir/Dialect/QCO/Transforms/Passes.td @@ -390,13 +390,15 @@ def DecomposeMultiControlled : Pass<"decompose-multi-controlled", "mlir::ModuleOp"> { let dependentDialects = ["mlir::qco::QCODialect", "::mlir::arith::ArithDialect"]; - let summary = "Decompose controlled X/Z/phase/SWAP gates and qco.rccx that " - "act on at least min-qubits qubits"; + let summary = "Decompose controlled X/Z/rotation/phase/SWAP gates and " + "qco.rccx that act on at least min-qubits qubits"; let description = [{ Decomposes multi-qubit controlled operations that act on at least `min-qubits` qubits (default 3: everything wider than a two-qubit gate). - Supported shapes: `qco.ctrl` with a `qco.x`, `qco.z`, `qco.swap`, or - constant-angle `qco.p` body, and `qco.rccx`. + Supported shapes: `qco.ctrl` with a sole `qco.x`, `qco.z`, `qco.rx`, + `qco.ry`, `qco.rz`, `qco.swap`, or constant-angle `qco.p` body, and + `qco.rccx`. Rotation angles may be constants or runtime SSA values, + including classical expressions inside the control region. | Family | Width (qubits) | Decomposition | | ------ | -------------- | ------------- | @@ -405,6 +407,7 @@ def DecomposeMultiControlled | X/Z | 5 | Specialized ancilla-free relative-phase `C^4(Z)` | | X/Z | 6–33 | da Silva-Park SP22 MCP(π) core (`H · MCP(π) · H` for X) | | X/Z | ≥34 | Huang-Palsberg (HP24) borrowed-helper synthesis with a compile-time CX policy table | + | RX/RY/RZ | ≥3 | Balanced control halves with exact MCX and quarter-angle rotations; RX uses H-conjugated RZ | | Phase | 3 | Optimized `C^2(P)` | | Phase | 4–5 | Vale (Barenco-relative residual) | | Phase | ≥6 | da Silva-Park SP22 linear-depth | @@ -414,6 +417,11 @@ def DecomposeMultiControlled Width is the total number of qubits the gate acts on (controls plus targets). + Rotation synthesis uses a linear number of gates and no additional + qubits. Each half-MCX may borrow controls from the opposite half and + restores them coherently. The decomposition preserves phase, including + the conditional phase of a rotation by `2π`. + For controlled SWAP, `C ∪ {b}` is the original control set together with SWAP target `b`, and the MCX target is the other SWAP qubit `a`. The emitted MCX is then lowered by the X path under the same `min-qubits` @@ -425,9 +433,10 @@ def DecomposeMultiControlled }]; let options = [Option< "minQubits", "min-qubits", "uint64_t", "3", - "Decompose controlled X/Z/phase/SWAP gates and qco.rccx that act on at " - "least this many qubits (must be at least 3; default 3 means wider than " - "two-qubit).">]; + "Decompose controlled X/Z/rotation/phase/SWAP gates and qco.rccx that " + "act " + "on at least this many qubits (must be at least 3; default 3 means wider " + "than two-qubit).">]; } #endif // MLIR_DIALECT_QCO_TRANSFORMS_PASSES_TD diff --git a/mlir/lib/Dialect/QCO/Transforms/Decomposition/DecomposeMultiControlled.cpp b/mlir/lib/Dialect/QCO/Transforms/Decomposition/DecomposeMultiControlled.cpp index 4643f25613..38b487ee16 100644 --- a/mlir/lib/Dialect/QCO/Transforms/Decomposition/DecomposeMultiControlled.cpp +++ b/mlir/lib/Dialect/QCO/Transforms/Decomposition/DecomposeMultiControlled.cpp @@ -82,6 +82,14 @@ class GateEmitter { setWire(q, POp::create(*builder_, loc_, wire(q), theta).getOutputQubit(0)); } + void ry(size_t q, Value theta) { + setWire(q, RYOp::create(*builder_, loc_, wire(q), theta).getOutputQubit(0)); + } + + void rz(size_t q, Value theta) { + setWire(q, RZOp::create(*builder_, loc_, wire(q), theta).getOutputQubit(0)); + } + void t(size_t q) { setWire(q, TOp::create(*builder_, loc_, wire(q)).getOutputQubit(0)); } @@ -1074,6 +1082,69 @@ static BorrowedControlPartition partitionControls(size_t numControls) { return {.k1 = (numControls + 1) / 2, .k2 = numControls / 2}; } +/// Synthesize a controlled Pauli rotation using X R(a) X = R(-a) for Y/Z. +static SmallVector +synthesizeMultiControlledRotation(OpBuilder& builder, Location loc, + ValueRange controls, Value target, + UnitaryOpInterface rotation) { + const size_t numControls = controls.size(); + const auto [k1, k2] = partitionControls(numControls); + SmallVector wires(controls); + wires.push_back(target); + GateEmitter emitter(builder, loc, wires); + + const auto halfMcx = [&](size_t begin, size_t count) { + SmallVector map; + map.reserve(numControls + 1); + for (size_t control = begin; control < begin + count; ++control) { + map.push_back(control); + } + map.push_back(numControls); + // The balanced split provides at least count - 2 dirty helpers. Each + // exact MCX restores these opposite controls before the next rotation. + for (size_t control = 0; control < numControls; ++control) { + if (control < begin || control >= begin + count) { + map.push_back(control); + } + } + CircuitPlan plan; + appendRemapped(plan, planBorrowedHelperMcx(count), map); + return plan; + }; + const CircuitPlan firstHalf = halfMcx(0, k1); + const CircuitPlan secondHalf = halfMcx(k1, k2); + + auto quarter = + arith::MulFOp::create(builder, loc, rotation.getParameters()[0], + mqt::constantFromScalar(builder, loc, 0.25)); + auto negativeQuarter = arith::NegFOp::create(builder, loc, quarter); + const bool isY = isa(rotation.getOperation()); + const bool isX = isa(rotation.getOperation()); + const auto rotate = [&](Value angle) { + if (isY) { + emitter.ry(numControls, angle); + } else { + emitter.rz(numControls, angle); + } + }; + + // RX(theta) = H RZ(theta) H. In either remaining axis, the four rotations + // sum to theta exactly when both control halves are all ones, else to zero. + if (isX) { + emitter.h(numControls); + } + for (size_t repeat = 0; repeat < 2; ++repeat) { + lowerPlan(emitter, firstHalf); + rotate(negativeQuarter); + lowerPlan(emitter, secondHalf); + rotate(quarter); + } + if (isX) { + emitter.h(numControls); + } + return wires; +} + // Vale + Barenco-relative residual at this MCP width. static constexpr size_t K_MCP_VALE_RELATIVE_RESIDUAL_CONTROLS = 4; @@ -1390,6 +1461,17 @@ struct DecomposeControlledGatePattern final : OpRewritePattern { if (op.getNumTargets() != 1) { return failure(); } + if (isa(inner.getOperation())) { + // Verified support operations cannot depend on the body's qubits. + // Hoist them so region-local symbolic angles survive the replacement. + mqt::hoistSupportingOpsBefore(*op.getBody(), inner.getOperation(), op, + rewriter); + rewriter.setInsertionPoint(op); + rewriter.replaceOp(op, synthesizeMultiControlledRotation( + rewriter, op.getLoc(), op.getControlsIn(), + op.getInputTarget(0), inner)); + return success(); + } const auto spec = matchControlledTarget(inner); if (!spec) { return failure(); diff --git a/mlir/unittests/Dialect/QCO/Transforms/Decomposition/test_multi_controlled_decomposition.cpp b/mlir/unittests/Dialect/QCO/Transforms/Decomposition/test_multi_controlled_decomposition.cpp index ec516326dc..7d9ced5e30 100644 --- a/mlir/unittests/Dialect/QCO/Transforms/Decomposition/test_multi_controlled_decomposition.cpp +++ b/mlir/unittests/Dialect/QCO/Transforms/Decomposition/test_multi_controlled_decomposition.cpp @@ -17,6 +17,7 @@ #include "mlir/Dialect/QCO/IR/QCODialect.h" #include "mlir/Dialect/QCO/IR/QCOInterfaces.h" #include "mlir/Dialect/QCO/IR/QCOOps.h" +#include "mlir/Dialect/QCO/QCOUtils.h" #include "mlir/Dialect/QCO/Transforms/Passes.h" #include "mlir/Dialect/QCO/Utils/DDAdapter.h" #include "mlir/Dialect/QCO/Utils/DDFunctionality.h" @@ -25,17 +26,22 @@ #include #include #include +#include #include +#include #include #include #include #include +#include #include #include #include #include #include +#include +#include #include #include #include @@ -123,6 +129,7 @@ static constexpr std::array K_EXPECTED_MCX_CX = { namespace { enum class ControlledPauli : uint8_t { X, Z }; +enum class RotationAxis : uint8_t { X, Y, Z }; } // namespace [[nodiscard]] static dd::Controls makeControls(size_t numControls) { @@ -182,6 +189,9 @@ class MczSmokeTest : public MultiControlledDecompositionTest, public testing::WithParamInterface {}; class McpSmokeTest : public MultiControlledDecompositionTest, public testing::WithParamInterface {}; +class McrDdTest + : public MultiControlledDecompositionTest, + public testing::WithParamInterface> {}; } // namespace @@ -230,6 +240,74 @@ buildMcpModule(MLIRContext* context, size_t numControls, double theta) { }); } +[[nodiscard]] static Value applyRotation(QCOProgramBuilder& builder, + RotationAxis axis, Value theta, + Value target) { + if (axis == RotationAxis::X) { + return builder.rx(theta, target); + } + if (axis == RotationAxis::Y) { + return builder.ry(theta, target); + } + return builder.rz(theta, target); +} + +static void buildControlledRotation(QCOProgramBuilder& builder, + size_t numControls, RotationAxis axis, + Value theta, bool regionLocal = false) { + SmallVector wires; + for (size_t i = 0; i <= numControls; ++i) { + wires.push_back(builder.staticQubit(i)); + } + const size_t target = numControls / 2; + SmallVector controls; + for (size_t i = numControls + 1; i-- > 0;) { + if (i != target) { + controls.push_back(wires[i]); + } + } + builder.ctrl(controls, wires[target], [&](Value targetArg) { + auto angle = + regionLocal ? arith::NegFOp::create(builder, theta).getResult() : theta; + return applyRotation(builder, axis, angle, targetArg); + }); +} + +[[nodiscard]] static OwningOpRef buildMcrModule(MLIRContext* context, + size_t numControls, + RotationAxis axis, + double theta) { + return QCOProgramBuilder::build(context, [&](QCOProgramBuilder& builder) { + buildControlledRotation(builder, numControls, axis, + builder.floatConstant(theta)); + return SmallVector{}; + }); +} + +// R_a(theta) = cos(theta/2) I - i sin(theta/2) sigma_a. +[[nodiscard]] static dd::GateMatrix rotationMatrix(RotationAxis axis, + double theta) { + const double cosine = std::cos(theta / 2); + const double sine = std::sin(theta / 2); + if (axis == RotationAxis::X) { + return { + cosine, + std::complex{0, -sine}, + std::complex{0, -sine}, + cosine, + }; + } + if (axis == RotationAxis::Y) { + return {cosine, -sine, sine, cosine}; + } + return { + std::complex{cosine, -sine}, + 0, + 0, + std::complex{cosine, sine}, + }; +} + [[nodiscard]] static OwningOpRef buildRCCXModule(MLIRContext* context) { return QCOProgramBuilder::build(context, [](QCOProgramBuilder& b) { @@ -247,6 +325,28 @@ buildRCCXModule(MLIRContext* context) { return numQubits; } +static void expectImplementsControlledRotation( + func::FuncOp funcOp, size_t numControls, RotationAxis axis, double theta, + const DDArgumentBindings& bindings = DDArgumentBindings()) { + ASSERT_EQ(countStaticQubits(funcOp), numControls + 1); + const auto package = std::make_unique(numControls + 1); + const auto actual = buildFunctionality(funcOp, *package, bindings); + ASSERT_TRUE(succeeded(actual)); + const auto target = static_cast(numControls / 2); + dd::Controls controls; + for (size_t i = 0; i <= numControls; ++i) { + if (i != static_cast(target)) { + controls.emplace(static_cast(i)); + } + } + const auto expected = + package->makeGateDD(rotationMatrix(axis, theta), controls, target); + // Full operator equality preserves phase and restores every borrowed control, + // including when controls are entangled with other qubits. + EXPECT_EQ(*actual, expected); + package->decRef(*actual); +} + static void expectFullyDecomposed(func::FuncOp funcOp) { funcOp.walk([](CtrlOp op) { EXPECT_EQ(op.getNumControls(), 1U); @@ -444,9 +544,159 @@ static void expectFullyLowered(ModuleOp moduleOp) { static LogicalResult runDecomposeMultiControlled( ModuleOp moduleOp, const DecomposeMultiControlledOptions& options = {}) { + if (failed(verify(moduleOp)) || failed(verifyLinearity(moduleOp))) { + return failure(); + } PassManager pm(moduleOp.getContext()); pm.addPass(createDecomposeMultiControlled(options)); - return pm.run(moduleOp); + if (failed(pm.run(moduleOp))) { + return failure(); + } + return success(succeeded(verify(moduleOp)) && + succeeded(verifyLinearity(moduleOp))); +} + +//===----------------------------------------------------------------------===// +// Multi-controlled rotations +//===----------------------------------------------------------------------===// + +TEST_P(McrDdTest, PreservesFullOperatorAndBorrowedControls) { + const auto [axis, numControls] = GetParam(); + for (const double theta : {0.0, 0.73, -1.21, 2 * std::numbers::pi}) { + SCOPED_TRACE(testing::Message() << "theta=" << theta); + auto moduleOp = buildMcrModule(context(), numControls, axis, theta); + ASSERT_TRUE(moduleOp); + ASSERT_TRUE(succeeded(runDecomposeMultiControlled(moduleOp.get()))); + expectFullyLowered(moduleOp.get()); + auto funcOp = *moduleOp->getBody()->getOps().begin(); + expectFullyDecomposed(funcOp); + expectImplementsControlledRotation(funcOp, numControls, axis, theta); + } +} + +INSTANTIATE_TEST_SUITE_P( + DdRange, McrDdTest, + testing::Combine(testing::Values(RotationAxis::X, RotationAxis::Y, + RotationAxis::Z), + testing::Values(2U, 3U, 4U, 5U, 6U, 7U, 8U)), + ([](const testing::TestParamInfo>& info) { + const auto [axis, numControls] = info.param; + return std::string(axis == RotationAxis::X ? "Rx" + : axis == RotationAxis::Y ? "Ry" + : "Rz") + + "k" + std::to_string(numControls); + })); + +TEST_F(MultiControlledDecompositionTest, RotationsPreserveRuntimeAngles) { + constexpr size_t numControls = 8; + for (const auto axis : {RotationAxis::X, RotationAxis::Y, RotationAxis::Z}) { + for (const bool regionLocal : {false, true}) { + SCOPED_TRACE(testing::Message() << "axis=" << static_cast(axis) + << " regionLocal=" << regionLocal); + Value parameter; + auto moduleOp = + QCOProgramBuilder::build(context(), [&](QCOProgramBuilder& builder) { + parameter = builder.floatConstant(0.73); + buildControlledRotation(builder, numControls, axis, parameter, + regionLocal); + return SmallVector{}; + }); + ASSERT_TRUE(moduleOp); + auto funcOp = *moduleOp->getBody()->getOps().begin(); + funcOp.insertArgument(0, Float64Type::get(context()), {}, + funcOp.getLoc()); + parameter.replaceAllUsesWith(funcOp.getArgument(0)); + ASSERT_TRUE(succeeded(runDecomposeMultiControlled(moduleOp.get()))); + expectFullyLowered(moduleOp.get()); + expectFullyDecomposed(funcOp); + for (const double theta : {-0.91, 2 * std::numbers::pi}) { + const DDArgumentBindings bindings{ + { + funcOp.getArgument(0), + FloatAttr::get(Float64Type::get(context()), theta), + }, + }; + expectImplementsControlledRotation( + funcOp, numControls, axis, regionLocal ? -theta : theta, bindings); + } + } + } +} + +TEST_F(MultiControlledDecompositionTest, + RotationsUseLinearResourcesWithoutExtraQubits) { + for (const auto axis : {RotationAxis::X, RotationAxis::Y, RotationAxis::Z}) { + for (const size_t numControls : {16U, 32U, 64U}) { + SCOPED_TRACE(testing::Message() << "axis=" << static_cast(axis) + << " controls=" << numControls); + auto moduleOp = buildMcrModule(context(), numControls, axis, 0.73); + ASSERT_TRUE(moduleOp); + ASSERT_TRUE(succeeded(runDecomposeMultiControlled(moduleOp.get()))); + expectFullyLowered(moduleOp.get()); + auto funcOp = *moduleOp->getBody()->getOps().begin(); + expectFullyDecomposed(funcOp); + EXPECT_EQ(countStaticQubits(funcOp), numControls + 1); + funcOp.walk([](AllocOp) { ADD_FAILURE() << "unexpected helper qubit"; }); + // The linear synthesis bound permits cancellation and gate substitutions. + EXPECT_LE(countElementaryCxOps(moduleOp.get()), 16 * numControls); + size_t elementaryGates = 0; + funcOp.walk([&](UnitaryOpInterface op) { + if (op->getNumRegions() == 0) { + ++elementaryGates; + } + }); + EXPECT_LE(elementaryGates, 60 * numControls); + } + } +} + +TEST_F(MultiControlledDecompositionTest, RotationsRespectMinQubits) { + for (const auto axis : {RotationAxis::X, RotationAxis::Y, RotationAxis::Z}) { + auto moduleOp = buildMcrModule(context(), 3, axis, 0.73); + ASSERT_TRUE(moduleOp); + DecomposeMultiControlledOptions options; + options.minQubits = 5; + ASSERT_TRUE( + succeeded(runDecomposeMultiControlled(moduleOp.get(), options))); + EXPECT_EQ(countMultiControlledOps(moduleOp.get()), 1U); + auto funcOp = *moduleOp->getBody()->getOps().begin(); + expectImplementsControlledRotation(funcOp, 3, axis, 0.73); + + options.minQubits = 4; + ASSERT_TRUE( + succeeded(runDecomposeMultiControlled(moduleOp.get(), options))); + EXPECT_EQ(countMultiControlledOps(moduleOp.get(), 3), 0U); + expectImplementsControlledRotation(funcOp, 3, axis, 0.73); + } +} + +TEST_F(MultiControlledDecompositionTest, PreservesTargetNativeRotations) { + using TargetOperation = CompilerTarget::Operation; + const std::vector operations{ + llvm::cantFail(TargetOperation::create( + "rx", TargetOperation::Arity::variadic(4), 1)), + llvm::cantFail(TargetOperation::create( + "ry", TargetOperation::Arity::variadic(4), 1)), + llvm::cantFail(TargetOperation::create( + "rz", TargetOperation::Arity::variadic(4), 1)), + }; + const auto target = llvm::cantFail(CompilerTarget::create( + 4, CompilerTarget::Connectivity::allToAll(), + CompilerTarget::NativeOperations::fromOperations(operations))); + for (const auto axis : {RotationAxis::X, RotationAxis::Y, RotationAxis::Z}) { + auto moduleOp = buildMcrModule(context(), 3, axis, 0.73); + ASSERT_TRUE(moduleOp); + ASSERT_TRUE(succeeded(verify(moduleOp.get()))); + ASSERT_TRUE(succeeded(verifyLinearity(moduleOp.get()))); + PassManager pm(context()); + pm.addPass(createDecomposeMultiControlled(target)); + ASSERT_TRUE(succeeded(pm.run(moduleOp.get()))); + ASSERT_TRUE(succeeded(verify(moduleOp.get()))); + ASSERT_TRUE(succeeded(verifyLinearity(moduleOp.get()))); + EXPECT_EQ(countMultiControlledOps(moduleOp.get()), 1U); + auto funcOp = *moduleOp->getBody()->getOps().begin(); + expectImplementsControlledRotation(funcOp, 3, axis, 0.73); + } } //===----------------------------------------------------------------------===// @@ -607,6 +857,9 @@ TEST_F(MultiControlledDecompositionTest, LeavesSingleControlledUntouched) { QCOProgramBuilder::build(context(), [](QCOProgramBuilder& builder) { builder.cx(builder.staticQubit(0), builder.staticQubit(1)); builder.cz(builder.staticQubit(2), builder.staticQubit(3)); + builder.crx(0.73, builder.staticQubit(4), builder.staticQubit(5)); + builder.cry(0.73, builder.staticQubit(6), builder.staticQubit(7)); + builder.crz(0.73, builder.staticQubit(8), builder.staticQubit(9)); return SmallVector{}; }); ASSERT_TRUE(moduleOp); @@ -618,7 +871,7 @@ TEST_F(MultiControlledDecompositionTest, LeavesSingleControlledUntouched) { ++singleControlled; } }); - EXPECT_EQ(singleControlled, 2U); + EXPECT_EQ(singleControlled, 5U); } TEST_F(MultiControlledDecompositionTest, DecomposesRCCX) { diff --git a/python/mqt/core/mlir.pyi b/python/mqt/core/mlir.pyi index e608caaa80..63b58e63a8 100644 --- a/python/mqt/core/mlir.pyi +++ b/python/mqt/core/mlir.pyi @@ -631,7 +631,7 @@ class QCOProgram(Program): """Prepare the program for qubit reuse and reuse eligible qubits.""" def decompose_multi_controlled(self, *, min_qubits: int = 3) -> None: - """Decompose controlled X/Z/SWAP gates, qco.rccx, and constant-angle phase gates that act on at least min_qubits qubits (min_qubits must be at least 3; default 3 means wider than two-qubit).""" + """Decompose controlled X/Z/SWAP and RX/RY/RZ gates, qco.rccx, and constant-angle phase gates that act on at least min_qubits qubits (min_qubits must be at least 3; default 3 means wider than two-qubit).""" def compile_for_target( self, target_environment: TargetEnvironment, *, enable_timing: bool = False, enable_statistics: bool = False diff --git a/test/python/test_mlir_qiskit_translation.py b/test/python/test_mlir_qiskit_translation.py index 2184fd1bf1..2b72cd21b0 100644 --- a/test/python/test_mlir_qiskit_translation.py +++ b/test/python/test_mlir_qiskit_translation.py @@ -262,6 +262,58 @@ def test_two_qubit_dense_unitary_compiles_to_target_basis() -> None: assert set(restored.count_ops()) <= {"u", "cx"} +@pytest.mark.parametrize("gate_type", [library.RXGate, library.RYGate, library.RZGate]) +@pytest.mark.parametrize("num_controls", [2, 5, 8]) +@pytest.mark.parametrize("angle", [0.73, 2 * np.pi]) +def test_multi_controlled_rotations_compile_to_target_basis( + gate_type: Callable[[float], Gate], num_controls: int, angle: float +) -> None: + """Preserve the exact controlled rotation, including conditional phase.""" + circuit = QuantumCircuit(num_controls + 1) + circuit.append(AnnotatedOperation(gate_type(angle), ControlModifier(num_controls)), circuit.qubits) + target = CompilerTarget( + circuit.num_qubits, + connectivity=CompilerTarget.Connectivity.all_to_all(), + native_operations=CompilerTarget.NativeOperations([ + CompilerTarget.Operation("u", 1, 3), + CompilerTarget.Operation("cx", 2, 0), + CompilerTarget.Operation("gphase", 0, 1), + ]), + ) + program = QCProgram.from_qiskit(circuit).to_qco() + + program.compile_for_target(target) + restored = program.to_qc().to_qiskit(target=target) + + assert restored.num_qubits == circuit.num_qubits + assert set(restored.count_ops()) <= {"u", "cx"} + assert np.allclose(Operator(restored).data, Operator(circuit).data, atol=1e-10, rtol=0) + + +@pytest.mark.parametrize("gate_type", [library.RXGate, library.RYGate, library.RZGate]) +@pytest.mark.parametrize("num_controls", [3, 8]) +def test_symbolic_multi_controlled_rotations_decompose_and_bind( + gate_type: Callable[[Parameter], Gate], num_controls: int +) -> None: + """Export symbolic synthesis and retain its phase after parameter binding.""" + theta = Parameter("theta") + circuit = QuantumCircuit(num_controls + 1) + circuit.append(AnnotatedOperation(gate_type(theta), ControlModifier(num_controls)), circuit.qubits) + program = QCProgram.from_qiskit(circuit).to_qco() + + program.decompose_multi_controlled() + restored = program.to_qc().to_qiskit() + + assert restored.num_qubits == circuit.num_qubits + assert {parameter.name for parameter in restored.parameters} == {theta.name} + restored_theta = next(iter(restored.parameters)) + assert all(item.operation.num_qubits <= 2 for item in restored.data) + for angle in (-0.61, 2 * np.pi): + expected = circuit.assign_parameters({theta: angle}) + actual = restored.assign_parameters({restored_theta: angle}) + assert np.allclose(Operator(actual).data, Operator(expected).data, atol=1e-10, rtol=0) + + def test_controlled_dense_unitary_export_preserves_operation_order() -> None: """Export a controlled dense matrix with a Qiskit control annotation.""" program = QCProgram.from_mlir_str( From ce44c229172b1876372e180654fe0a8850692ef2 Mon Sep 17 00:00:00 2001 From: Simon Hofmann Date: Tue, 8 Sep 2026 10:37:55 +0200 Subject: [PATCH 2/4] =?UTF-8?q?=E2=9C=A8=20Add=20controlled=20Y=20synthesi?= =?UTF-8?q?s=20and=20refine=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reuse MCX lowering for Y and check Pauli and rotation CX budgets. Keep Python boundary tests small and avoid duplicate IR verification. Align pass descriptions and update the v4 changelog reference. Assisted-by: GPT via Codex --- .../controlled-synthesis-test-runtime.md | 48 +++ .agent/plans/controlled-rotations.md | 39 +-- CHANGELOG.md | 3 +- bindings/mlir/register_mlir.cpp | 2 +- mlir/include/mlir/Compiler/Programs.h | 2 +- .../mlir/Dialect/QCO/Transforms/Passes.td | 14 +- .../DecomposeMultiControlled.cpp | 16 + .../test_multi_controlled_decomposition.cpp | 299 ++++++++++-------- python/mqt/core/mlir.pyi | 2 +- test/python/test_mlir.py | 5 +- test/python/test_mlir_qiskit_translation.py | 49 +-- 11 files changed, 279 insertions(+), 200 deletions(-) create mode 100644 .agent/audits/controlled-synthesis-test-runtime.md diff --git a/.agent/audits/controlled-synthesis-test-runtime.md b/.agent/audits/controlled-synthesis-test-runtime.md new file mode 100644 index 0000000000..c0631ab6d3 --- /dev/null +++ b/.agent/audits/controlled-synthesis-test-runtime.md @@ -0,0 +1,48 @@ +# Controlled synthesis test runtime + +Status: implemented and validated. Baseline: `1f25f40667079f909685992653549b12ce0e0be6` +with the pending MCY support, shared Pauli tests, and rotation CX budgets. +The open PR is [#2467](https://github.com/munich-quantum-toolkit/core/pull/2467). + +## Scope and contract + +Speed up `test_multi_controlled_decomposition.cpp` without removing control +widths, numeric or runtime angles, gate-count bounds, exact-phase comparisons, +borrowed-control restoration, or input/output IR and linearity verification. +Production synthesis and the Python test matrix are unchanged. + +## Findings + +The single-pass helper verified its output twice: once in `PassManager::run` +and again in an explicit `verify` call. Sampling the rotation resource test +attributed 658 samples to the first check and 677 to the second, compared with +712 in greedy rewriting. The helper now explicitly enables the pass manager's +verifier and retains the separate output linearity check. Input verification +is unchanged. Both output checks covered the same module after the same pass. + +The large Pauli full-operator tests spend most of their time in DD matrix +multiplication. Reusing packages solely to avoid allocations is not justified +by this profile. Switching the three matrix-only helpers to the existing +unitary-simulation DD configuration showed no benefit: the three-run median +for the 25 Pauli-at-eight-controls and numeric/runtime rotation tests changed +from 5.276 to 5.294 seconds. This experiment was reverted. + +## Validation + +The baseline Debug binary passed all 297 decomposition tests in 42.4 seconds +on macOS ARM64 with AppleClang 21 and LLVM/MLIR 23.1. The run included a short +sampling interval, so it is diagnostic rather than a controlled speed ratio. + +A subsequent unprofiled three-run comparison of +`MultiControlledDecompositionTest.RotationsUseLinearResourcesWithoutExtraQubits` +used the original binary and the rebuilt single-verifier binary serially, with +no concurrent build. The median fell from 2.035 to 1.477 seconds (27%). All six +runs passed with all 96 axis, width, and angle-kind combinations retained. +The 34-test MCY and rotation-resource filter also passed three times per +binary. Its median fell from 10.528 to 8.910 seconds (15%). +Compilation and profiling time are excluded from test durations. + +The final rebuilt binary passes all 297 tests. The full changed-file +`uvx nox -s cpp-lint -- ec799daa09f855bd0edcbc5592a5fedd90836516` check reports +zero findings, and `uvx nox -s lint` passes. The final full-suite run overlapped +with C++ lint and is not used for a whole-suite speedup claim. diff --git a/.agent/plans/controlled-rotations.md b/.agent/plans/controlled-rotations.md index b6cd3c4dc7..6289864f4c 100644 --- a/.agent/plans/controlled-rotations.md +++ b/.agent/plans/controlled-rotations.md @@ -16,9 +16,8 @@ API or dependency was added. ## Decisions -- Derive the implementation from Pauli rotation identities and Core's existing - decomposition helpers. Do not consult or adapt Qiskit source. Use Qiskit's - public APIs only as an external performance comparator. +- Lower controlled Y as `S†`, MCX, then `S` on the target, reusing the existing + MCX decomposition and its width and native-target policies. - For RY and RZ, split controls into two balanced groups and alternate their MCX operations with quarter-angle rotations. Borrow controls from the other group through Core's exact dirty-helper MCX decomposition. Helpers must be restored @@ -31,19 +30,26 @@ API or dependency was added. ## Validation -The complete `mqt-core-mlir-unittest-decomposition` binary passes 263 tests, -including 26 rotation tests. These check phase-exact full operators for 2–8 -controls, runtime and region-local angles, native target and threshold policy, -and linear resources through 64 controls. +The complete `mqt-core-mlir-unittest-decomposition` binary passes 297 tests. +These check phase-exact operators, runtime and region-local angles, native +target and threshold policy, and numeric and symbolic rotation CX budgets +through 64 controls. Shared Pauli tests cover X, Y, and Z with the same CX +counts, including coherent states at synthesis boundaries. -`pytest test/python/test_mlir_qiskit_translation.py -k multi_controlled_rotations` -passes all 24 cases. Numeric target compilation requests native `gphase` to -retain overall phase, as required by the existing target contract. Symbolic -synthesis is exported and bound before exact matrix comparison. +The Python `test_qco_program_decomposes_multi_controlled` API test covers X, Y, +RX, RY, and RZ, including the minimum-width argument and its error handling. One +symbolic RY round trip checks export and binding of generated angle expressions. +These six cases pass; synthesis matrices and resource bounds remain in the +native tests. -All 384 Python translation and typed-program checks pass, including the two QDMI -device cases with the built native device configured. General lint, stub -generation, and whole-changed-file C++ lint pass with no remaining findings. +The test pass manager verifies each output once; the helper retains separate +input/output linearity checks. The runtime comparison and rejected cache +experiment are recorded in +[`controlled-synthesis-test-runtime.md`](../audits/controlled-synthesis-test-runtime.md). + +MCY import, decomposition, and export preserve the exact operator at 2, 3, and 5 +controls. General lint, stub generation, and whole-changed-file C++ lint pass +with no remaining findings. ## Performance and limits @@ -58,8 +64,3 @@ deeper: RY has depth 186 versus 171 at 8 controls and 1978 versus 1963 at 64. Local nine-sample median synthesis times were lower for all sampled cases in a MinSizeRel build. These timings exclude frontend import, basis normalization, routing, and full target compilation; they are not an end-to-end speed claim. - -Symbolic decomposition and export work. Full symbolic target compilation can -still produce `math.atan2`, which the existing Qiskit exporter does not support. -Bind parameters before target compilation when using that export path. A general -symbolic exporter change is outside this synthesis implementation. diff --git a/CHANGELOG.md b/CHANGELOG.md index 97ff540113..b327b87917 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -86,7 +86,7 @@ releases may include breaking changes. [**@denialhaag**], [**@MatthiasReumann**], [**@simon1hofmann**]) - ✨ Add multi-qubit decomposition, fusion, and target-native synthesis passes ([#1774], [#1802], [#1803], [#1809], [#1810], [#1814], [#1832], [#1850], - [#1865], [#1961], [#1996], [#1998], [#2001]) ([**@simon1hofmann**], + [#1865], [#1961], [#1996], [#1998], [#2001], [#2467]) ([**@simon1hofmann**], [**@burgholzer**]) #### Other additions @@ -927,6 +927,7 @@ for previous changelogs._ +[#2467]: https://github.com/munich-quantum-toolkit/core/pull/2467 [#2457]: https://github.com/munich-quantum-toolkit/core/pull/2457 [#2436]: https://github.com/munich-quantum-toolkit/core/pull/2436 [#2421]: https://github.com/munich-quantum-toolkit/core/pull/2421 diff --git a/bindings/mlir/register_mlir.cpp b/bindings/mlir/register_mlir.cpp index ba79377f2b..c2940f405a 100644 --- a/bindings/mlir/register_mlir.cpp +++ b/bindings/mlir/register_mlir.cpp @@ -1232,7 +1232,7 @@ operations.)pb"); &BooleanMemberAdapter< &mlir::QCOProgram::decomposeMultiControlled>::call, nb::kw_only(), "min_qubits"_a = 3, - "Decompose controlled X/Z/SWAP and RX/RY/RZ gates, qco.rccx, and " + "Decompose controlled X/Y/Z/SWAP and RX/RY/RZ gates, qco.rccx, and " "constant-angle phase gates that act on at least min_qubits qubits " "(min_qubits must be at least 3; default 3 means wider than " "two-qubit).") diff --git a/mlir/include/mlir/Compiler/Programs.h b/mlir/include/mlir/Compiler/Programs.h index 2939be9dec..9660279ace 100644 --- a/mlir/include/mlir/Compiler/Programs.h +++ b/mlir/include/mlir/Compiler/Programs.h @@ -277,7 +277,7 @@ class QCOProgram final : public Program { /// Prepare the program for qubit reuse and reuse eligible qubits. [[nodiscard]] bool runQubitReusePipeline(); - /// Decompose controlled X/Z/SWAP and RX/RY/RZ gates, `qco.rccx`, and + /// Decompose controlled X/Y/Z/SWAP and RX/RY/RZ gates, `qco.rccx`, and /// constant-angle phase gates that act on at least @p minQubits qubits /// (@p minQubits must be at least 3; default 3 means wider than two-qubit). [[nodiscard]] bool decomposeMultiControlled(uint64_t minQubits = 3); diff --git a/mlir/include/mlir/Dialect/QCO/Transforms/Passes.td b/mlir/include/mlir/Dialect/QCO/Transforms/Passes.td index cc0e284669..c29b2eba5d 100644 --- a/mlir/include/mlir/Dialect/QCO/Transforms/Passes.td +++ b/mlir/include/mlir/Dialect/QCO/Transforms/Passes.td @@ -390,13 +390,13 @@ def DecomposeMultiControlled : Pass<"decompose-multi-controlled", "mlir::ModuleOp"> { let dependentDialects = ["mlir::qco::QCODialect", "::mlir::arith::ArithDialect"]; - let summary = "Decompose controlled X/Z/rotation/phase/SWAP gates and " + let summary = "Decompose controlled X/Y/Z/rotation/phase/SWAP gates and " "qco.rccx that act on at least min-qubits qubits"; let description = [{ Decomposes multi-qubit controlled operations that act on at least `min-qubits` qubits (default 3: everything wider than a two-qubit gate). - Supported shapes: `qco.ctrl` with a sole `qco.x`, `qco.z`, `qco.rx`, - `qco.ry`, `qco.rz`, `qco.swap`, or constant-angle `qco.p` body, and + Supported shapes: `qco.ctrl` with a sole `qco.x`, `qco.y`, `qco.z`, + `qco.rx`, `qco.ry`, `qco.rz`, `qco.swap`, or constant-angle `qco.p` body, and `qco.rccx`. Rotation angles may be constants or runtime SSA values, including classical expressions inside the control region. @@ -407,6 +407,7 @@ def DecomposeMultiControlled | X/Z | 5 | Specialized ancilla-free relative-phase `C^4(Z)` | | X/Z | 6–33 | da Silva-Park SP22 MCP(π) core (`H · MCP(π) · H` for X) | | X/Z | ≥34 | Huang-Palsberg (HP24) borrowed-helper synthesis with a compile-time CX policy table | + | Y | ≥3 | X decomposition with `S†` before and `S` after on the target | | RX/RY/RZ | ≥3 | Balanced control halves with exact MCX and quarter-angle rotations; RX uses H-conjugated RZ | | Phase | 3 | Optimized `C^2(P)` | | Phase | 4–5 | Vale (Barenco-relative residual) | @@ -433,10 +434,9 @@ def DecomposeMultiControlled }]; let options = [Option< "minQubits", "min-qubits", "uint64_t", "3", - "Decompose controlled X/Z/rotation/phase/SWAP gates and qco.rccx that " - "act " - "on at least this many qubits (must be at least 3; default 3 means wider " - "than two-qubit).">]; + "Decompose controlled X/Y/Z/rotation/phase/SWAP gates and qco.rccx " + "that act on at least this many qubits (must be at least 3; default 3 " + "means wider than two-qubit).">]; } #endif // MLIR_DIALECT_QCO_TRANSFORMS_PASSES_TD diff --git a/mlir/lib/Dialect/QCO/Transforms/Decomposition/DecomposeMultiControlled.cpp b/mlir/lib/Dialect/QCO/Transforms/Decomposition/DecomposeMultiControlled.cpp index 38b487ee16..c731904828 100644 --- a/mlir/lib/Dialect/QCO/Transforms/Decomposition/DecomposeMultiControlled.cpp +++ b/mlir/lib/Dialect/QCO/Transforms/Decomposition/DecomposeMultiControlled.cpp @@ -1461,6 +1461,22 @@ struct DecomposeControlledGatePattern final : OpRewritePattern { if (op.getNumTargets() != 1) { return failure(); } + if (isa(inner.getOperation())) { + // Y = S X S†; the new MCX reuses this pass's width selection. + rewriter.setInsertionPoint(op); + auto loc = op.getLoc(); + auto target = + SdgOp::create(rewriter, loc, op.getInputTarget(0)).getOutputQubit(0); + auto mcx = CtrlOp::create( + rewriter, loc, op.getControlsIn(), target, [&](Value targetArg) { + return XOp::create(rewriter, loc, targetArg).getOutputQubit(0); + }); + SmallVector results(mcx.getOutputControls()); + results.push_back( + SOp::create(rewriter, loc, mcx.getOutputTarget(0)).getOutputQubit(0)); + rewriter.replaceOp(op, results); + return success(); + } if (isa(inner.getOperation())) { // Verified support operations cannot depend on the body's qubits. // Hoist them so region-local symbolic angles survive the replacement. diff --git a/mlir/unittests/Dialect/QCO/Transforms/Decomposition/test_multi_controlled_decomposition.cpp b/mlir/unittests/Dialect/QCO/Transforms/Decomposition/test_multi_controlled_decomposition.cpp index 7d9ced5e30..b44fb0761f 100644 --- a/mlir/unittests/Dialect/QCO/Transforms/Decomposition/test_multi_controlled_decomposition.cpp +++ b/mlir/unittests/Dialect/QCO/Transforms/Decomposition/test_multi_controlled_decomposition.cpp @@ -56,7 +56,7 @@ using namespace mlir; using namespace mlir::qco; /// DD for k=2…20 plus the first HP24 width (k=33): full matrix DD through k=8 -/// (MCX/MCZ) or k=6 (MCP); basis-state DD for larger MCX/MCZ widths; +/// (MCX/MCY/MCZ) or k=6 (MCP); basis-state DD for larger Pauli widths; /// coherent-state DD at selected policy boundaries and representative larger /// MCP widths. static constexpr std::array K_DD_CONTROL_COUNTS = { @@ -64,8 +64,8 @@ static constexpr std::array K_DD_CONTROL_COUNTS = { }; static constexpr size_t K_MATRIX_DD_MAX_PAULI = 8; static constexpr size_t K_MATRIX_DD_MAX_MCP = 6; -static constexpr std::array K_COHERENT_HP24_CONTROL_COUNTS = { - 10, 11, 21, 22, 23, +static constexpr std::array K_COHERENT_HP24_CONTROL_COUNTS = { + 10, 11, 21, 22, 23, 33, }; static constexpr std::array K_COHERENT_MCP_CONTROL_COUNTS = {7, 12}; /// Additional fully-lowered/CX smoke checks for k > 20 through the SP22 MCX @@ -127,8 +127,20 @@ static constexpr std::array K_EXPECTED_MCX_CX = { return small[k]; } +/// CX regression budget for the borrowed-helper rotation construction. +/// Each half-MCX occurs twice and costs 1/6/14 CX at 1/2/3 controls. +/// Above that, its two passes each use a 6-CX CCX, a 3-CX RCCX, +/// and 2(n-3) two-CX gadgets: 8n-6 CX. Both halves reach this case at k=8. +[[nodiscard]] static constexpr size_t expectedMcrCxBudget(size_t k) { + if (k >= 8) { + return (16 * k) - 24; + } + constexpr std::array small = {0, 0, 4, 14, 24, 40, 56, 80}; + return small[k]; +} + namespace { -enum class ControlledPauli : uint8_t { X, Z }; +enum class ControlledPauli : uint8_t { X, Y, Z }; enum class RotationAxis : uint8_t { X, Y, Z }; } // namespace @@ -141,8 +153,9 @@ enum class RotationAxis : uint8_t { X, Y, Z }; } [[nodiscard]] static dd::GateMatrix pauliMatrix(ControlledPauli pauli) { - const auto matrix = pauli == ControlledPauli::X ? XOp::getUnitaryMatrix() - : ZOp::getUnitaryMatrix(); + const auto matrix = pauli == ControlledPauli::X ? XOp::getUnitaryMatrix() + : pauli == ControlledPauli::Y ? YOp::getUnitaryMatrix() + : ZOp::getUnitaryMatrix(); return {matrix(0, 0), matrix(0, 1), matrix(1, 0), matrix(1, 1)}; } @@ -177,16 +190,16 @@ class MultiControlledDecompositionTest : public testing::Test { std::unique_ptr context_; }; -class McxDdTest : public MultiControlledDecompositionTest, - public testing::WithParamInterface {}; -class MczDdTest : public MultiControlledDecompositionTest, - public testing::WithParamInterface {}; +class McPauliDdTest + : public MultiControlledDecompositionTest, + public testing::WithParamInterface> { +}; class McpDdTest : public MultiControlledDecompositionTest, public testing::WithParamInterface {}; -class McxSmokeTest : public MultiControlledDecompositionTest, - public testing::WithParamInterface {}; -class MczSmokeTest : public MultiControlledDecompositionTest, - public testing::WithParamInterface {}; +class McPauliSmokeTest + : public MultiControlledDecompositionTest, + public testing::WithParamInterface> { +}; class McpSmokeTest : public MultiControlledDecompositionTest, public testing::WithParamInterface {}; class McrDdTest @@ -209,6 +222,8 @@ buildControlledPauliModule(MLIRContext* context, size_t numControls, auto target = wires.back(); if (pauli == ControlledPauli::X) { b.mcx(controls, target); + } else if (pauli == ControlledPauli::Y) { + b.mcy(controls, target); } else { b.mcz(controls, target); } @@ -216,16 +231,6 @@ buildControlledPauliModule(MLIRContext* context, size_t numControls, }); } -[[nodiscard]] static OwningOpRef buildMcxModule(MLIRContext* context, - size_t numControls) { - return buildControlledPauliModule(context, numControls, ControlledPauli::X); -} - -[[nodiscard]] static OwningOpRef buildMczModule(MLIRContext* context, - size_t numControls) { - return buildControlledPauliModule(context, numControls, ControlledPauli::Z); -} - [[nodiscard]] static OwningOpRef buildMcpModule(MLIRContext* context, size_t numControls, double theta) { return QCOProgramBuilder::build( @@ -273,15 +278,22 @@ static void buildControlledRotation(QCOProgramBuilder& builder, }); } -[[nodiscard]] static OwningOpRef buildMcrModule(MLIRContext* context, - size_t numControls, - RotationAxis axis, - double theta) { - return QCOProgramBuilder::build(context, [&](QCOProgramBuilder& builder) { - buildControlledRotation(builder, numControls, axis, - builder.floatConstant(theta)); - return SmallVector{}; - }); +[[nodiscard]] static OwningOpRef +buildMcrModule(MLIRContext* context, size_t numControls, RotationAxis axis, + double theta, bool runtimeAngle = false) { + Value parameter; + auto moduleOp = + QCOProgramBuilder::build(context, [&](QCOProgramBuilder& builder) { + parameter = builder.floatConstant(theta); + buildControlledRotation(builder, numControls, axis, parameter); + return SmallVector{}; + }); + if (moduleOp && runtimeAngle) { + auto funcOp = *moduleOp->getBody()->getOps().begin(); + funcOp.insertArgument(0, Float64Type::get(context), {}, funcOp.getLoc()); + parameter.replaceAllUsesWith(funcOp.getArgument(0)); + } + return moduleOp; } // R_a(theta) = cos(theta/2) I - i sin(theta/2) sigma_a. @@ -548,12 +560,13 @@ static LogicalResult runDecomposeMultiControlled( return failure(); } PassManager pm(moduleOp.getContext()); + pm.enableVerifier(); pm.addPass(createDecomposeMultiControlled(options)); if (failed(pm.run(moduleOp))) { return failure(); } - return success(succeeded(verify(moduleOp)) && - succeeded(verifyLinearity(moduleOp))); + // The pass manager already verifies the output IR. + return verifyLinearity(moduleOp); } //===----------------------------------------------------------------------===// @@ -626,26 +639,49 @@ TEST_F(MultiControlledDecompositionTest, RotationsPreserveRuntimeAngles) { TEST_F(MultiControlledDecompositionTest, RotationsUseLinearResourcesWithoutExtraQubits) { for (const auto axis : {RotationAxis::X, RotationAxis::Y, RotationAxis::Z}) { - for (const size_t numControls : {16U, 32U, 64U}) { - SCOPED_TRACE(testing::Message() << "axis=" << static_cast(axis) - << " controls=" << numControls); - auto moduleOp = buildMcrModule(context(), numControls, axis, 0.73); - ASSERT_TRUE(moduleOp); - ASSERT_TRUE(succeeded(runDecomposeMultiControlled(moduleOp.get()))); - expectFullyLowered(moduleOp.get()); - auto funcOp = *moduleOp->getBody()->getOps().begin(); - expectFullyDecomposed(funcOp); - EXPECT_EQ(countStaticQubits(funcOp), numControls + 1); - funcOp.walk([](AllocOp) { ADD_FAILURE() << "unexpected helper qubit"; }); - // The linear synthesis bound permits cancellation and gate substitutions. - EXPECT_LE(countElementaryCxOps(moduleOp.get()), 16 * numControls); - size_t elementaryGates = 0; - funcOp.walk([&](UnitaryOpInterface op) { - if (op->getNumRegions() == 0) { - ++elementaryGates; - } - }); - EXPECT_LE(elementaryGates, 60 * numControls); + for (const size_t numControls : { + 2U, + 3U, + 4U, + 5U, + 6U, + 7U, + 8U, + 9U, + 15U, + 16U, + 17U, + 31U, + 32U, + 33U, + 63U, + 64U, + }) { + for (const bool runtimeAngle : {false, true}) { + SCOPED_TRACE(testing::Message() + << "axis=" << static_cast(axis) << " controls=" + << numControls << " runtimeAngle=" << runtimeAngle); + auto moduleOp = + buildMcrModule(context(), numControls, axis, 0.73, runtimeAngle); + ASSERT_TRUE(moduleOp); + ASSERT_TRUE(succeeded(runDecomposeMultiControlled(moduleOp.get()))); + expectFullyLowered(moduleOp.get()); + auto funcOp = *moduleOp->getBody()->getOps().begin(); + expectFullyDecomposed(funcOp); + EXPECT_EQ(countStaticQubits(funcOp), numControls + 1); + funcOp.walk( + [](AllocOp) { ADD_FAILURE() << "unexpected helper qubit"; }); + // The same bound covers every axis and permits future cancellation. + EXPECT_LE(countElementaryCxOps(moduleOp.get()), + expectedMcrCxBudget(numControls)); + size_t elementaryGates = 0; + funcOp.walk([&](UnitaryOpInterface op) { + if (op->getNumRegions() == 0) { + ++elementaryGates; + } + }); + EXPECT_LE(elementaryGates, 60 * numControls); + } } } } @@ -700,42 +736,24 @@ TEST_F(MultiControlledDecompositionTest, PreservesTargetNativeRotations) { } //===----------------------------------------------------------------------===// -// MCX / MCZ / MCP: DD + CX for k = 2..20 +// MCX / MCY / MCZ / MCP: DD + CX for k = 2..20 and k = 33 //===----------------------------------------------------------------------===// -TEST_P(McxDdTest, EquivalenceAndCxCount) { - const size_t k = GetParam(); - auto moduleOp = buildMcxModule(context(), k); +TEST_P(McPauliDdTest, EquivalenceAndCxCount) { + const auto [pauli, k] = GetParam(); + auto moduleOp = buildControlledPauliModule(context(), k, pauli); ASSERT_TRUE(moduleOp); ASSERT_TRUE(runDecomposeMultiControlled(moduleOp.get()).succeeded()); expectFullyLowered(moduleOp.get()); + // MCY and MCZ share MCX's CX budget; their basis changes add no CX. EXPECT_EQ(countElementaryCxOps(moduleOp.get()), K_EXPECTED_MCX_CX[k]) << "k=" << k; auto funcOp = *moduleOp->getBody()->getOps().begin(); if (k <= K_MATRIX_DD_MAX_PAULI) { - expectImplementsControlledPauli(funcOp, k, ControlledPauli::X); + expectImplementsControlledPauli(funcOp, k, pauli); } else { - expectMatchesReferenceOnBasisStates(funcOp, k, ControlledPauli::X); - } -} - -TEST_P(MczDdTest, EquivalenceAndCxCount) { - const size_t k = GetParam(); - auto moduleOp = buildMczModule(context(), k); - ASSERT_TRUE(moduleOp); - ASSERT_TRUE(runDecomposeMultiControlled(moduleOp.get()).succeeded()); - expectFullyLowered(moduleOp.get()); - // MCZ shares the MCX elementary sequences / cores (no extra CX from the - // outer H sandwich on X). - EXPECT_EQ(countElementaryCxOps(moduleOp.get()), K_EXPECTED_MCX_CX[k]) - << "k=" << k; - - auto funcOp = *moduleOp->getBody()->getOps().begin(); - if (k <= K_MATRIX_DD_MAX_PAULI) { - expectImplementsControlledPauli(funcOp, k, ControlledPauli::Z); - } else { - expectMatchesReferenceOnBasisStates(funcOp, k, ControlledPauli::Z); + expectMatchesReferenceOnBasisStates(funcOp, k, pauli); } } @@ -754,16 +772,21 @@ TEST_P(McpDdTest, EquivalenceAndCxCount) { } } -INSTANTIATE_TEST_SUITE_P(DdRange, McxDdTest, - testing::ValuesIn(K_DD_CONTROL_COUNTS), - [](const testing::TestParamInfo& info) { - return "k" + std::to_string(info.param); - }); -INSTANTIATE_TEST_SUITE_P(DdRange, MczDdTest, - testing::ValuesIn(K_DD_CONTROL_COUNTS), - [](const testing::TestParamInfo& info) { - return "k" + std::to_string(info.param); - }); +static std::string pauliTestName( + const testing::TestParamInfo>& info) { + const auto [pauli, k] = info.param; + return std::string(pauli == ControlledPauli::X ? "X" + : pauli == ControlledPauli::Y ? "Y" + : "Z") + + "k" + std::to_string(k); +} + +INSTANTIATE_TEST_SUITE_P( + DdRange, McPauliDdTest, + testing::Combine(testing::Values(ControlledPauli::X, ControlledPauli::Y, + ControlledPauli::Z), + testing::ValuesIn(K_DD_CONTROL_COUNTS)), + pauliTestName); INSTANTIATE_TEST_SUITE_P(DdRange, McpDdTest, testing::ValuesIn(K_DD_CONTROL_COUNTS), [](const testing::TestParamInfo& info) { @@ -773,10 +796,10 @@ INSTANTIATE_TEST_SUITE_P(DdRange, McpDdTest, TEST_F(MultiControlledDecompositionTest, CoherentStatesMatchAcrossHp24PolicyBoundaries) { for (const auto k : K_COHERENT_HP24_CONTROL_COUNTS) { - for (const auto pauli : {ControlledPauli::X, ControlledPauli::Z}) { + for (const auto pauli : + {ControlledPauli::X, ControlledPauli::Y, ControlledPauli::Z}) { SCOPED_TRACE(testing::Message() - << "k=" << k - << " pauli=" << (pauli == ControlledPauli::X ? "X" : "Z")); + << "k=" << k << " pauli=" << static_cast(pauli)); auto moduleOp = buildControlledPauliModule(context(), k, pauli); ASSERT_TRUE(moduleOp); ASSERT_TRUE(runDecomposeMultiControlled(moduleOp.get()).succeeded()); @@ -802,19 +825,9 @@ TEST_F(MultiControlledDecompositionTest, CoherentStatesMatchForLargerSp22Mcp) { // Additional smoke checks for k > 20 — fully lowered, pinned CX //===----------------------------------------------------------------------===// -TEST_P(McxSmokeTest, FullyLowersWithExpectedCx) { - const size_t k = GetParam(); - auto moduleOp = buildMcxModule(context(), k); - ASSERT_TRUE(moduleOp); - ASSERT_TRUE(runDecomposeMultiControlled(moduleOp.get()).succeeded()); - expectFullyLowered(moduleOp.get()); - EXPECT_EQ(countElementaryCxOps(moduleOp.get()), K_EXPECTED_MCX_CX[k]) - << "k=" << k; -} - -TEST_P(MczSmokeTest, FullyLowersWithExpectedCx) { - const size_t k = GetParam(); - auto moduleOp = buildMczModule(context(), k); +TEST_P(McPauliSmokeTest, FullyLowersWithExpectedCx) { + const auto [pauli, k] = GetParam(); + auto moduleOp = buildControlledPauliModule(context(), k, pauli); ASSERT_TRUE(moduleOp); ASSERT_TRUE(runDecomposeMultiControlled(moduleOp.get()).succeeded()); expectFullyLowered(moduleOp.get()); @@ -832,16 +845,12 @@ TEST_P(McpSmokeTest, FullyLowersWithExpectedCx) { EXPECT_EQ(countEffectiveCxOps(moduleOp.get()), expectedMcpCx(k)) << "k=" << k; } -INSTANTIATE_TEST_SUITE_P(SmokeRange, McxSmokeTest, - testing::ValuesIn(K_SMOKE_CONTROL_COUNTS), - [](const testing::TestParamInfo& info) { - return "k" + std::to_string(info.param); - }); -INSTANTIATE_TEST_SUITE_P(SmokeRange, MczSmokeTest, - testing::ValuesIn(K_SMOKE_CONTROL_COUNTS), - [](const testing::TestParamInfo& info) { - return "k" + std::to_string(info.param); - }); +INSTANTIATE_TEST_SUITE_P( + SmokeRange, McPauliSmokeTest, + testing::Combine(testing::Values(ControlledPauli::X, ControlledPauli::Y, + ControlledPauli::Z), + testing::ValuesIn(K_SMOKE_CONTROL_COUNTS)), + pauliTestName); INSTANTIATE_TEST_SUITE_P(SmokeRange, McpSmokeTest, testing::ValuesIn(K_SMOKE_CONTROL_COUNTS), [](const testing::TestParamInfo& info) { @@ -860,6 +869,7 @@ TEST_F(MultiControlledDecompositionTest, LeavesSingleControlledUntouched) { builder.crx(0.73, builder.staticQubit(4), builder.staticQubit(5)); builder.cry(0.73, builder.staticQubit(6), builder.staticQubit(7)); builder.crz(0.73, builder.staticQubit(8), builder.staticQubit(9)); + builder.cy(builder.staticQubit(10), builder.staticQubit(11)); return SmallVector{}; }); ASSERT_TRUE(moduleOp); @@ -871,7 +881,7 @@ TEST_F(MultiControlledDecompositionTest, LeavesSingleControlledUntouched) { ++singleControlled; } }); - EXPECT_EQ(singleControlled, 5U); + EXPECT_EQ(singleControlled, 6U); } TEST_F(MultiControlledDecompositionTest, DecomposesRCCX) { @@ -925,16 +935,53 @@ TEST_F(MultiControlledDecompositionTest, } TEST_F(MultiControlledDecompositionTest, MinQubitsThreshold) { - auto moduleOp = buildMcxModule(context(), 2); - ASSERT_TRUE(moduleOp); - DecomposeMultiControlledOptions options; - options.minQubits = 4; - ASSERT_TRUE(runDecomposeMultiControlled(moduleOp.get(), options).succeeded()); - EXPECT_EQ(countMultiControlledOps(moduleOp.get(), 2), 1U); + for (const auto pauli : + {ControlledPauli::X, ControlledPauli::Y, ControlledPauli::Z}) { + SCOPED_TRACE(testing::Message() + << "pauli=" << static_cast(pauli)); + auto moduleOp = buildControlledPauliModule(context(), 2, pauli); + ASSERT_TRUE(moduleOp); + DecomposeMultiControlledOptions options; + options.minQubits = 4; + ASSERT_TRUE( + runDecomposeMultiControlled(moduleOp.get(), options).succeeded()); + EXPECT_EQ(countMultiControlledOps(moduleOp.get(), 2), 1U); + + options.minQubits = 3; + ASSERT_TRUE( + runDecomposeMultiControlled(moduleOp.get(), options).succeeded()); + auto funcOp = *moduleOp->getBody()->getOps().begin(); + expectImplementsControlledPauli(funcOp, 2, pauli); - options.minQubits = 2; - EXPECT_FALSE( - runDecomposeMultiControlled(moduleOp.get(), options).succeeded()); + options.minQubits = 2; + EXPECT_FALSE( + runDecomposeMultiControlled(moduleOp.get(), options).succeeded()); + } +} + +TEST_F(MultiControlledDecompositionTest, PreservesTargetNativeMcy) { + auto moduleOp = buildControlledPauliModule(context(), 3, ControlledPauli::Y); + ASSERT_TRUE(moduleOp); + ASSERT_TRUE(succeeded(verify(moduleOp.get()))); + ASSERT_TRUE(succeeded(verifyLinearity(moduleOp.get()))); + using TargetOperation = CompilerTarget::Operation; + const std::vector operations{ + llvm::cantFail( + TargetOperation::create("y", TargetOperation::Arity::variadic(4), 0)), + }; + const auto target = llvm::cantFail(CompilerTarget::create( + 4, CompilerTarget::Connectivity::allToAll(), + CompilerTarget::NativeOperations::fromOperations(operations))); + PassManager pm(context()); + pm.addPass(createDecomposeMultiControlled(target)); + ASSERT_TRUE(succeeded(pm.run(moduleOp.get()))); + EXPECT_TRUE(succeeded(verify(moduleOp.get()))); + EXPECT_TRUE(succeeded(verifyLinearity(moduleOp.get()))); + EXPECT_EQ(countMultiControlledOps(moduleOp.get(), 3), 1U); + moduleOp->walk([](CtrlOp op) { + ASSERT_EQ(op.getNumBodyUnitaries(), 1U); + EXPECT_TRUE(isa(op.getBodyUnitary(0).getOperation())); + }); } TEST_F(MultiControlledDecompositionTest, DecomposesSingleControlledSwap) { diff --git a/python/mqt/core/mlir.pyi b/python/mqt/core/mlir.pyi index 63b58e63a8..33cce08f6e 100644 --- a/python/mqt/core/mlir.pyi +++ b/python/mqt/core/mlir.pyi @@ -631,7 +631,7 @@ class QCOProgram(Program): """Prepare the program for qubit reuse and reuse eligible qubits.""" def decompose_multi_controlled(self, *, min_qubits: int = 3) -> None: - """Decompose controlled X/Z/SWAP and RX/RY/RZ gates, qco.rccx, and constant-angle phase gates that act on at least min_qubits qubits (min_qubits must be at least 3; default 3 means wider than two-qubit).""" + """Decompose controlled X/Y/Z/SWAP and RX/RY/RZ gates, qco.rccx, and constant-angle phase gates that act on at least min_qubits qubits (min_qubits must be at least 3; default 3 means wider than two-qubit).""" def compile_for_target( self, target_environment: TargetEnvironment, *, enable_timing: bool = False, enable_statistics: bool = False diff --git a/test/python/test_mlir.py b/test/python/test_mlir.py index 1f5720d471..f96434634f 100644 --- a/test/python/test_mlir.py +++ b/test/python/test_mlir.py @@ -895,10 +895,11 @@ def test_typed_programs_normalize_global_phases() -> None: assert qco.ir == once -def test_qco_program_decomposes_multi_controlled() -> None: +@pytest.mark.parametrize("gate", ["x", "y", "rx(0.73)", "ry(0.73)", "rz(0.73)"]) +def test_qco_program_decomposes_multi_controlled(gate: str) -> None: """Decompose multi-controlled gates through the typed QCOProgram API.""" qco = compile_program( - 'OPENQASM 3.0; include "stdgates.inc"; qubit[3] q; ctrl(2) @ x q[0], q[1], q[2];', + f'OPENQASM 3.0; include "stdgates.inc"; qubit[3] q; ctrl(2) @ {gate} q[0], q[1], q[2];', output=OutputFormat.QCO, ) assert isinstance(qco, QCOProgram) diff --git a/test/python/test_mlir_qiskit_translation.py b/test/python/test_mlir_qiskit_translation.py index 2b72cd21b0..c1c41acbae 100644 --- a/test/python/test_mlir_qiskit_translation.py +++ b/test/python/test_mlir_qiskit_translation.py @@ -262,56 +262,21 @@ def test_two_qubit_dense_unitary_compiles_to_target_basis() -> None: assert set(restored.count_ops()) <= {"u", "cx"} -@pytest.mark.parametrize("gate_type", [library.RXGate, library.RYGate, library.RZGate]) -@pytest.mark.parametrize("num_controls", [2, 5, 8]) -@pytest.mark.parametrize("angle", [0.73, 2 * np.pi]) -def test_multi_controlled_rotations_compile_to_target_basis( - gate_type: Callable[[float], Gate], num_controls: int, angle: float -) -> None: - """Preserve the exact controlled rotation, including conditional phase.""" - circuit = QuantumCircuit(num_controls + 1) - circuit.append(AnnotatedOperation(gate_type(angle), ControlModifier(num_controls)), circuit.qubits) - target = CompilerTarget( - circuit.num_qubits, - connectivity=CompilerTarget.Connectivity.all_to_all(), - native_operations=CompilerTarget.NativeOperations([ - CompilerTarget.Operation("u", 1, 3), - CompilerTarget.Operation("cx", 2, 0), - CompilerTarget.Operation("gphase", 0, 1), - ]), - ) - program = QCProgram.from_qiskit(circuit).to_qco() - - program.compile_for_target(target) - restored = program.to_qc().to_qiskit(target=target) - - assert restored.num_qubits == circuit.num_qubits - assert set(restored.count_ops()) <= {"u", "cx"} - assert np.allclose(Operator(restored).data, Operator(circuit).data, atol=1e-10, rtol=0) - - -@pytest.mark.parametrize("gate_type", [library.RXGate, library.RYGate, library.RZGate]) -@pytest.mark.parametrize("num_controls", [3, 8]) -def test_symbolic_multi_controlled_rotations_decompose_and_bind( - gate_type: Callable[[Parameter], Gate], num_controls: int -) -> None: - """Export symbolic synthesis and retain its phase after parameter binding.""" +def test_symbolic_multi_controlled_rotations_decompose_and_bind() -> None: + """Export and bind angle expressions produced by decomposition.""" theta = Parameter("theta") - circuit = QuantumCircuit(num_controls + 1) - circuit.append(AnnotatedOperation(gate_type(theta), ControlModifier(num_controls)), circuit.qubits) + circuit = QuantumCircuit(3) + circuit.append(AnnotatedOperation(library.RYGate(theta), ControlModifier(2)), circuit.qubits) program = QCProgram.from_qiskit(circuit).to_qco() program.decompose_multi_controlled() restored = program.to_qc().to_qiskit() - assert restored.num_qubits == circuit.num_qubits assert {parameter.name for parameter in restored.parameters} == {theta.name} - restored_theta = next(iter(restored.parameters)) assert all(item.operation.num_qubits <= 2 for item in restored.data) - for angle in (-0.61, 2 * np.pi): - expected = circuit.assign_parameters({theta: angle}) - actual = restored.assign_parameters({restored_theta: angle}) - assert np.allclose(Operator(actual).data, Operator(expected).data, atol=1e-10, rtol=0) + expected = circuit.assign_parameters({theta: -0.61}) + actual = _assign_parameter_values(restored, {theta.name: -0.61}) + assert np.allclose(Operator(actual).data, Operator(expected).data, atol=1e-10, rtol=0) def test_controlled_dense_unitary_export_preserves_operation_order() -> None: From 6e819825b56ae79c106b714d33596ee49c430cd5 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Tue, 8 Sep 2026 08:38:54 +0000 Subject: [PATCH 3/4] =?UTF-8?q?=F0=9F=8E=A8=20pre-commit=20fixes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controlled-synthesis-test-runtime.md | 37 ++++++++++--------- 1 file changed, 19 insertions(+), 18 deletions(-) diff --git a/.agent/audits/controlled-synthesis-test-runtime.md b/.agent/audits/controlled-synthesis-test-runtime.md index c0631ab6d3..ff505b28e0 100644 --- a/.agent/audits/controlled-synthesis-test-runtime.md +++ b/.agent/audits/controlled-synthesis-test-runtime.md @@ -1,8 +1,9 @@ # Controlled synthesis test runtime -Status: implemented and validated. Baseline: `1f25f40667079f909685992653549b12ce0e0be6` -with the pending MCY support, shared Pauli tests, and rotation CX budgets. -The open PR is [#2467](https://github.com/munich-quantum-toolkit/core/pull/2467). +Status: implemented and validated. Baseline: +`1f25f40667079f909685992653549b12ce0e0be6` with the pending MCY support, shared +Pauli tests, and rotation CX budgets. The open PR is +[#2467](https://github.com/munich-quantum-toolkit/core/pull/2467). ## Scope and contract @@ -13,34 +14,34 @@ Production synthesis and the Python test matrix are unchanged. ## Findings -The single-pass helper verified its output twice: once in `PassManager::run` -and again in an explicit `verify` call. Sampling the rotation resource test +The single-pass helper verified its output twice: once in `PassManager::run` and +again in an explicit `verify` call. Sampling the rotation resource test attributed 658 samples to the first check and 677 to the second, compared with 712 in greedy rewriting. The helper now explicitly enables the pass manager's -verifier and retains the separate output linearity check. Input verification -is unchanged. Both output checks covered the same module after the same pass. +verifier and retains the separate output linearity check. Input verification is +unchanged. Both output checks covered the same module after the same pass. The large Pauli full-operator tests spend most of their time in DD matrix -multiplication. Reusing packages solely to avoid allocations is not justified -by this profile. Switching the three matrix-only helpers to the existing -unitary-simulation DD configuration showed no benefit: the three-run median -for the 25 Pauli-at-eight-controls and numeric/runtime rotation tests changed -from 5.276 to 5.294 seconds. This experiment was reverted. +multiplication. Reusing packages solely to avoid allocations is not justified by +this profile. Switching the three matrix-only helpers to the existing +unitary-simulation DD configuration showed no benefit: the three-run median for +the 25 Pauli-at-eight-controls and numeric/runtime rotation tests changed from +5.276 to 5.294 seconds. This experiment was reverted. ## Validation -The baseline Debug binary passed all 297 decomposition tests in 42.4 seconds -on macOS ARM64 with AppleClang 21 and LLVM/MLIR 23.1. The run included a short +The baseline Debug binary passed all 297 decomposition tests in 42.4 seconds on +macOS ARM64 with AppleClang 21 and LLVM/MLIR 23.1. The run included a short sampling interval, so it is diagnostic rather than a controlled speed ratio. A subsequent unprofiled three-run comparison of `MultiControlledDecompositionTest.RotationsUseLinearResourcesWithoutExtraQubits` used the original binary and the rebuilt single-verifier binary serially, with no concurrent build. The median fell from 2.035 to 1.477 seconds (27%). All six -runs passed with all 96 axis, width, and angle-kind combinations retained. -The 34-test MCY and rotation-resource filter also passed three times per -binary. Its median fell from 10.528 to 8.910 seconds (15%). -Compilation and profiling time are excluded from test durations. +runs passed with all 96 axis, width, and angle-kind combinations retained. The +34-test MCY and rotation-resource filter also passed three times per binary. Its +median fell from 10.528 to 8.910 seconds (15%). Compilation and profiling time +are excluded from test durations. The final rebuilt binary passes all 297 tests. The full changed-file `uvx nox -s cpp-lint -- ec799daa09f855bd0edcbc5592a5fedd90836516` check reports From 4182df95d7567898a76323698b2a000d2a15ccdb Mon Sep 17 00:00:00 2001 From: Lukas Burgholzer Date: Tue, 8 Sep 2026 21:00:43 +0000 Subject: [PATCH 4/4] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20Simplify=20controlled?= =?UTF-8?q?=20synthesis=20and=20track=20quality?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove unreachable HP24 policies and duplicate wire remapping. Cover active odd/even modes with phase-sensitive numerical state comparisons and extend rotation matrix checks through ten controls. Record a reproducible Qiskit comparison and helper-order candidates; correct the unequal-work timing claim. The rebase includes main's shared identity-modifier DD fix. Assisted-by: GPT-6 via Codex --- .agent/audits/controlled-rotation-quality.csv | 145 ++++++++++ .../pr2467-controlled-synthesis-review.md | 150 ++++++++++ .agent/plans/controlled-rotations.md | 29 +- .../mlir/Dialect/QCO/Transforms/Passes.td | 2 +- .../DecomposeMultiControlled.cpp | 266 +++--------------- mlir/tools/mqt-cc/mqt-cc.cpp | 6 +- .../test_multi_controlled_decomposition.cpp | 46 ++- test/bench/compare_controlled_rotations.py | 160 +++++++++++ 8 files changed, 547 insertions(+), 257 deletions(-) create mode 100644 .agent/audits/controlled-rotation-quality.csv create mode 100644 .agent/audits/pr2467-controlled-synthesis-review.md create mode 100644 test/bench/compare_controlled_rotations.py diff --git a/.agent/audits/controlled-rotation-quality.csv b/.agent/audits/controlled-rotation-quality.csv new file mode 100644 index 0000000000..a18a4ee8b3 --- /dev/null +++ b/.agent/audits/controlled-rotation-quality.csv @@ -0,0 +1,145 @@ +axis,controls,angle,backend,synthesis_ms,raw_cx,raw_one_qubit,raw_depth,raw_cx_depth,optimized_cx,optimized_one_qubit,optimized_depth,optimized_cx_depth,max_operator_error +rx,2,numeric,core,0.272258,4,6,10,4,4,5,9,4,2.1153413308660057e-16 +rx,2,numeric,qiskit,0.033504,8,15,18,8,8,7,13,8,3.298440587052649e-16 +rx,2,symbolic,core,0.365394,4,6,10,4,4,6,10,4,3.1401849173675503e-16 +rx,2,symbolic,qiskit,0.043104,8,15,18,8,8,9,15,8,3.3306690738754696e-16 +rx,3,numeric,core,0.382035,14,24,28,12,14,16,20,12,1.5331320705493272e-15 +rx,3,numeric,qiskit,0.069664,20,35,42,20,20,15,29,20,7.610516769633753e-16 +rx,3,symbolic,core,0.384211,14,24,28,12,14,20,24,12,1.2780539954372932e-15 +rx,3,symbolic,qiskit,0.086401,20,35,42,20,20,21,35,20,4.440892098500626e-16 +rx,4,numeric,core,0.433683,24,42,46,18,24,28,34,18,2.3361959382837703e-15 +rx,4,numeric,qiskit,0.189521,24,42,46,18,24,28,34,18,2.3361959382837703e-15 +rx,4,symbolic,core,0.448979,24,42,46,18,24,36,40,18,2.558937633260452e-15 +rx,4,symbolic,qiskit,0.087009,24,42,46,18,24,36,40,18,2.558937633260452e-15 +rx,5,numeric,core,0.26325,40,58,70,34,40,48,64,34,2.6801557172297703e-15 +rx,5,numeric,qiskit,0.074576,40,58,70,34,40,48,64,34,2.6801557172297703e-15 +rx,5,symbolic,core,0.265666,40,58,70,34,40,56,68,34,3.9563492527737815e-15 +rx,5,symbolic,qiskit,0.095857,40,58,70,34,40,56,68,34,3.9563492527737815e-15 +rx,6,numeric,core,0.350434,56,74,86,38,56,69,76,38, +rx,6,numeric,qiskit,0.089969,56,74,86,38,56,69,76,38, +rx,6,symbolic,core,0.342834,56,74,86,38,56,75,84,38, +rx,6,symbolic,qiskit,0.101105,56,74,86,38,56,75,84,38, +rx,7,numeric,core,0.426562,80,124,176,80,80,88,152,80, +rx,7,numeric,qiskit,0.250914,80,124,176,80,80,88,152,80, +rx,7,symbolic,core,0.934085,80,124,176,80,80,96,156,80, +rx,7,symbolic,qiskit,0.306498,80,124,176,80,80,96,156,80, +rx,8,numeric,core,1.081863,104,174,213,98,104,120,185,98, +rx,8,numeric,qiskit,0.24325,104,174,200,89,104,120,170,89, +rx,8,symbolic,core,1.085719,104,174,213,98,104,128,185,98, +rx,8,symbolic,qiskit,0.315538,104,174,200,89,104,128,170,89, +rx,9,numeric,core,1.210728,120,198,248,112,120,136,213,112, +rx,9,numeric,qiskit,0.253954,120,198,248,108,120,136,209,108, +rx,9,symbolic,core,1.215672,120,198,248,112,120,144,213,112, +rx,9,symbolic,qiskit,0.306066,120,198,248,108,120,144,209,108, +rx,10,numeric,core,1.361704,136,222,293,130,136,152,249,130, +rx,10,numeric,qiskit,0.277426,136,222,280,121,136,152,234,121, +rx,10,symbolic,core,1.386809,136,222,293,130,136,160,249,130, +rx,10,symbolic,qiskit,0.329666,136,222,280,121,136,160,234,121, +rx,16,numeric,core,2.290686,232,366,533,226,232,248,441,226, +rx,16,numeric,qiskit,0.335074,232,366,520,217,232,248,426,217, +rx,16,symbolic,core,2.301854,232,366,533,226,232,256,441,226, +rx,16,symbolic,qiskit,0.378083,232,366,520,217,232,256,426,217, +rx,32,numeric,core,4.60406,488,750,1173,482,488,504,953,482, +rx,32,numeric,qiskit,0.472531,488,750,1160,473,488,504,938,473, +rx,32,symbolic,core,4.589452,488,750,1173,482,488,512,953,482, +rx,32,symbolic,qiskit,0.519923,488,750,1160,473,488,512,938,473, +rx,64,numeric,core,9.109,1000,1518,2453,994,1000,1016,1977,994, +rx,64,numeric,qiskit,0.707028,1000,1518,2440,985,1000,1016,1962,985, +rx,64,symbolic,core,9.080984,1000,1518,2453,994,1000,1024,1977,994, +rx,64,symbolic,qiskit,0.842309,1000,1518,2440,985,1000,1024,1962,985, +ry,2,numeric,core,0.258562,4,4,8,4,4,4,8,4,2.220446049250313e-16 +ry,2,numeric,qiskit,0.036944,8,15,18,8,8,6,12,8,2.220446049250313e-16 +ry,2,symbolic,core,0.270545,4,4,8,4,4,4,8,4,2.220446049250313e-16 +ry,2,symbolic,qiskit,0.042288,8,15,18,8,8,6,12,8,3.3306690738754696e-16 +ry,3,numeric,core,0.350882,14,22,26,12,14,17,21,12,1.024360521199179e-15 +ry,3,numeric,qiskit,0.075584,20,35,42,20,20,14,28,20,2.220446049250313e-16 +ry,3,symbolic,core,0.355074,14,22,26,12,14,20,24,12,8.892273665708786e-16 +ry,3,symbolic,qiskit,0.095025,20,35,42,20,20,14,28,20,4.440892098500626e-16 +ry,4,numeric,core,0.406435,24,40,44,18,24,29,35,18,1.520397016336855e-15 +ry,4,numeric,qiskit,0.177777,24,40,44,18,24,29,35,18,1.520397016336855e-15 +ry,4,symbolic,core,0.408035,24,40,44,18,24,36,41,18,1.686470170725321e-15 +ry,4,symbolic,qiskit,0.207521,24,40,44,18,24,36,41,18,1.686470170725321e-15 +ry,5,numeric,core,0.562084,40,56,69,34,40,50,64,34,5.0035533349649525e-15 +ry,5,numeric,qiskit,0.197794,40,56,69,34,40,50,64,34,5.0035533349649525e-15 +ry,5,symbolic,core,0.558788,40,56,69,34,40,55,68,34,4.0974976367522364e-15 +ry,5,symbolic,qiskit,0.25357,40,56,69,34,40,55,68,34,4.0974976367522364e-15 +ry,6,numeric,core,0.719669,56,72,85,38,56,67,76,38, +ry,6,numeric,qiskit,0.200482,56,72,85,38,56,67,76,38, +ry,6,symbolic,core,0.345506,56,72,85,38,56,74,83,38, +ry,6,symbolic,qiskit,0.095505,56,72,85,38,56,74,83,38, +ry,7,numeric,core,0.440963,80,122,174,80,80,89,153,80, +ry,7,numeric,qiskit,0.095392,80,122,174,80,80,89,153,80, +ry,7,symbolic,core,0.42237,80,122,174,80,80,96,156,80, +ry,7,symbolic,qiskit,0.108529,80,122,174,80,80,96,156,80, +ry,8,numeric,core,0.506563,104,172,212,98,104,121,186,98, +ry,8,numeric,qiskit,0.237873,104,172,199,89,104,121,171,89, +ry,8,symbolic,core,1.086358,104,172,212,98,104,128,186,98, +ry,8,symbolic,qiskit,0.274962,104,172,199,89,104,128,171,89, +ry,9,numeric,core,1.203495,120,196,247,112,120,137,214,112, +ry,9,numeric,qiskit,0.243874,120,196,247,108,120,137,210,108, +ry,9,symbolic,core,1.210119,120,196,247,112,120,144,214,112, +ry,9,symbolic,qiskit,0.301426,120,196,247,108,120,144,210,108, +ry,10,numeric,core,1.335944,136,220,292,130,136,153,250,130, +ry,10,numeric,qiskit,0.25957,136,220,279,121,136,153,235,121, +ry,10,symbolic,core,1.356264,136,220,292,130,136,160,250,130, +ry,10,symbolic,qiskit,0.319554,136,220,279,121,136,160,235,121, +ry,16,numeric,core,2.213934,232,364,532,226,232,249,442,226, +ry,16,numeric,qiskit,0.346178,232,364,519,217,232,249,427,217, +ry,16,symbolic,core,2.235966,232,364,532,226,232,256,442,226, +ry,16,symbolic,qiskit,0.369299,232,364,519,217,232,256,427,217, +ry,32,numeric,core,2.109981,488,748,1172,482,488,505,954,482, +ry,32,numeric,qiskit,0.205905,488,748,1159,473,488,505,939,473, +ry,32,symbolic,core,2.101613,488,748,1172,482,488,512,954,482, +ry,32,symbolic,qiskit,0.233938,488,748,1159,473,488,512,939,473, +ry,64,numeric,core,4.250746,1000,1516,2452,994,1000,1017,1978,994, +ry,64,numeric,qiskit,0.349938,1000,1516,2439,985,1000,1017,1963,985, +ry,64,symbolic,core,4.418747,1000,1516,2452,994,1000,1024,1978,994, +ry,64,symbolic,qiskit,0.359011,1000,1516,2439,985,1000,1024,1963,985, +rz,2,numeric,core,0.094,4,4,8,4,4,4,8,4,0.0 +rz,2,numeric,qiskit,0.065872,4,4,8,4,4,4,8,4,0.0 +rz,2,symbolic,core,0.107504,4,4,8,4,4,4,8,4,0.0 +rz,2,symbolic,qiskit,0.074577,4,4,8,4,4,4,8,4,0.0 +rz,3,numeric,core,0.146417,14,22,26,12,14,17,21,12,9.71445146547012e-16 +rz,3,numeric,qiskit,0.070896,14,22,26,12,14,17,21,12,9.71445146547012e-16 +rz,3,symbolic,core,0.146593,14,22,26,12,14,20,24,12,8.473409486550037e-16 +rz,3,symbolic,qiskit,0.081153,14,22,26,12,14,20,24,12,8.473409486550037e-16 +rz,4,numeric,core,0.183121,24,40,44,18,24,29,35,18,1.3092278833360677e-15 +rz,4,numeric,qiskit,0.075681,24,40,44,18,24,29,35,18,1.3092278833360677e-15 +rz,4,symbolic,core,0.438195,24,40,44,18,24,36,41,18,1.6136471996107273e-15 +rz,4,symbolic,qiskit,0.213825,24,40,44,18,24,36,41,18,1.6136471996107273e-15 +rz,5,numeric,core,0.553923,40,56,69,34,40,48,64,34,2.538614803067886e-15 +rz,5,numeric,qiskit,0.199377,40,56,69,34,40,48,64,34,2.538614803067886e-15 +rz,5,symbolic,core,0.270114,40,56,69,34,40,55,68,34,2.871672278914067e-15 +rz,5,symbolic,qiskit,0.099201,40,56,69,34,40,55,68,34,2.871672278914067e-15 +rz,6,numeric,core,0.355427,56,72,85,38,56,69,76,38, +rz,6,numeric,qiskit,0.095633,56,72,85,38,56,69,76,38, +rz,6,symbolic,core,0.36133,56,72,85,38,56,74,83,38, +rz,6,symbolic,qiskit,0.102721,56,72,85,38,56,74,83,38, +rz,7,numeric,core,0.447603,80,122,174,80,80,89,153,80, +rz,7,numeric,qiskit,0.101792,80,122,174,80,80,89,153,80, +rz,7,symbolic,core,0.453491,80,122,174,80,80,96,156,80, +rz,7,symbolic,qiskit,0.112609,80,122,174,80,80,96,156,80, +rz,8,numeric,core,0.528067,104,172,212,98,104,121,186,98, +rz,8,numeric,qiskit,0.108993,104,172,199,89,104,121,171,89, +rz,8,symbolic,core,0.53146,104,172,212,98,104,128,186,98, +rz,8,symbolic,qiskit,0.119873,104,172,199,89,104,128,171,89, +rz,9,numeric,core,0.60346,120,196,247,112,120,137,214,112, +rz,9,numeric,qiskit,0.275169,120,196,247,108,120,137,210,108, +rz,9,symbolic,core,1.363753,120,196,247,112,120,144,214,112, +rz,9,symbolic,qiskit,0.341538,120,196,247,108,120,144,210,108, +rz,10,numeric,core,1.525946,136,220,292,130,136,153,250,130, +rz,10,numeric,qiskit,0.288994,136,220,279,121,136,153,235,121, +rz,10,symbolic,core,1.479721,136,220,292,130,136,160,250,130, +rz,10,symbolic,qiskit,0.145633,136,220,279,121,136,160,235,121, +rz,16,numeric,core,1.075383,232,364,532,226,232,249,442,226, +rz,16,numeric,qiskit,0.145457,232,364,519,217,232,249,427,217, +rz,16,symbolic,core,1.07559,232,364,532,226,232,256,442,226, +rz,16,symbolic,qiskit,0.163921,232,364,519,217,232,256,427,217, +rz,32,numeric,core,2.147598,488,748,1172,482,488,505,954,482, +rz,32,numeric,qiskit,0.482483,488,748,1159,473,488,505,939,473, +rz,32,symbolic,core,4.725454,488,748,1172,482,488,512,954,482, +rz,32,symbolic,qiskit,0.505475,488,748,1159,473,488,512,939,473, +rz,64,numeric,core,9.260025,1000,1516,2452,994,1000,1017,1978,994, +rz,64,numeric,qiskit,0.746389,1000,1516,2439,985,1000,1017,1963,985, +rz,64,symbolic,core,9.402778,1000,1516,2452,994,1000,1024,1978,994, +rz,64,symbolic,qiskit,0.842181,1000,1516,2439,985,1000,1024,1963,985, diff --git a/.agent/audits/pr2467-controlled-synthesis-review.md b/.agent/audits/pr2467-controlled-synthesis-review.md new file mode 100644 index 0000000000..3f2e5b9113 --- /dev/null +++ b/.agent/audits/pr2467-controlled-synthesis-review.md @@ -0,0 +1,150 @@ +# Controlled-synthesis audit and quality comparison + +Status: applied. Date: 2026-09-08. Original baseline: +`00a214ec3f49aab0eeee0352a05ef817100e8c55`. Changes rebased onto main +`4c5e45855e5bb50c42b68ddbf4f9a4dababb8737`. + +## Findings and disposition + +- **Identity modifier evaluation:** wide X/Y/Z synthesis can leave empty + `qco.ctrl` bodies after angle folding. The original head's DD consumer + rejected these valid identity regions. Main's shared `composeBodyMatrix` fix + in #2464 resolves this on rebase. No duplicate or Y-specific workaround is + needed. The 63-control coherent-state regression now passes without + canonicalization. +- **Unreachable HP24 machinery:** `mczCoreForWidth` uses specialized synthesis + below five controls and SP22 through 32. HP24 now asserts its actual minimum + of 33 controls and directly selects one dirty helper for odd widths, two for + even widths. Removed the inactive small-width table, ripple incrementer, + recursive relative-phase planner, thread-local cache, and estimate branches. + Active half-MCX widths exceed 11 and incrementer widths exceed 10, so those + removed alternatives had no production caller. Changing the crossover must + revisit this limit. This is a maintenance improvement, not a speed claim. +- **Wide-state oracle and coverage:** retained existing SP22 samples and added + 32/33/34, 47/48, and 63/64 controls for X/Y/Z. Compare the phase-sensitive + norm of `actual - expected` at `1e-11`, rather than DD node identity. The + coherent helper scopes DD arithmetic tolerance to `1e-15` and restores it + afterward; the default merging tolerance accumulated about `1e-9` error at 32 + controls. These selected states are not a full-operator bound. Independent + numeric and runtime rotation matrices now cover 2 through 10 controls. +- **Duplicate remapping:** remap each generated rotation half-plan in place; + remove unused `GateEmitter` remapping. This removes a second plan allocation + and move loop. Plans remain the sole owner of wire remapping. +- **Stale CLI descriptions:** both compiler options now name Y and rotations; + the generated pass description names the active HP24 dirty-helper choice. + +Source: +`mlir/lib/Dialect/QCO/Transforms/Decomposition/DecomposeMultiControlled.cpp`. +Regression tests: the corresponding +`mlir/unittests/Dialect/QCO/Transforms/Decomposition/test_multi_controlled_decomposition.cpp`. + +## Contracts retained + +The modifier verifier owns restrictions on classical support operations. No +second dependency walker is needed. Balanced halves borrow opposite controls and +restore them coherently. Arbitrary relative-phase MCX replacements have not been +proved safe in the four-MCX rotation shell. RX uses Hadamard conjugation of RZ; +Y uses target S conjugation of X. Controlled `2*pi` rotations retain their +conditional minus sign. Native-target and minimum-width policies still apply. No +new cache, public API, or dependency is justified. + +## Reproducible Qiskit comparison + +Run `uv run --no-sync python test/bench/compare_controlled_rotations.py` with +the locally built package and Qiskit 2.5.2. The script records numeric and +symbolic RX/RY/RZ at 2, 3, 4, 5, 6, 7, 8, 9, 10, 16, 32, and 64 controls. Raw +rows from this run are in +[controlled-rotation-quality.csv](controlled-rotation-quality.csv). Core was +built with Clang/LLVM 23.1, Release, ThinLTO, and mold on ARM64. + +Both methods use exactly the input qubits. Nine-sample median synthesis times +exclude one warmup, input creation, import/export, basis conversion, and +routing. Both circuits receive the same `u,cx` normalization, then level-3 +optimization with seed zero and `qubits_initially_zero=False`. The CSV includes +CX count, one-qubit count, total depth, CX depth, and synthesis time. Numeric +and symbolic operators through five controls pass phase-sensitive checks, +including symbolic binding at `2*pi`. Timings are local sequential measurements, +not an end-to-end compilation comparison or stable performance guarantee. + +Numeric post-optimization examples (Core / Qiskit): + +| Gate | Controls | CX | Depth | Synthesis ms | +| ---- | -------: | ----------: | ----------: | ------------: | +| RX | 2 | 4 / 8 | 9 / 13 | 0.272 / 0.034 | +| RY | 3 | 14 / 20 | 21 / 28 | 0.351 / 0.076 | +| RY | 8 | 104 / 104 | 186 / 171 | 0.507 / 0.238 | +| RY | 16 | 232 / 232 | 442 / 427 | 2.214 / 0.346 | +| RY | 64 | 1000 / 1000 | 1978 / 1963 | 4.251 / 0.350 | + +Core saves CX gates for RX/RY at two and three controls. Other sampled CX counts +match, including all RZ cases and symbolic angles. Core's larger circuits are up +to 15 layers deeper. Core synthesis is slower in this measurement. Earlier +claims of uniformly faster Core synthesis included basis lowering in Qiskit's +timed work and are superseded by this comparison. + +## Improvement beyond Qiskit: measured candidate, deferred implementation + +Helper order affects scheduling even when CX count stays fixed. In an isolated +prototype, reverse the selected dirty-helper wires in both balanced half-MCXs, +keeping controls in order. Use Qiskit's exact `synth_mcx_n_dirty_i15` to +construct those halves, and the same four quarter-angle rotations and +normalization as above. This produces these numeric RY depths: + +| Controls | Core | Qiskit | Reversed-helper prototype | CX (all three) | +| -------- | ---: | -----: | ------------------------: | -------------: | +| 9 | 214 | 210 | 204 | 120 | +| 10 | 250 | 235 | 232 | 136 | +| 16 | 442 | 427 | 388 | 232 | +| 32 | 954 | 939 | 804 | 488 | +| 64 | 1978 | 1963 | 1636 | 1000 | + +At eight controls, balanced reversed helpers give depth 180, worse than Qiskit's +171. A 5+3 split instead reduces CX from 104 to 96 but has depth 185. Neither +candidate dominates at every width. The reversed balanced prototype at nine +controls and both eight-control splits pass full phase-sensitive operator +comparisons with maximum element error below `1.2e-14`. + +Minimal reproduction, after importing `QuantumCircuit`, `transpile`, and +`qiskit.synthesis.synth_mcx_n_dirty_i15`: + +```python +k = 64 +first = (k + 1) // 2 +halves = [] +for start, count in ((0, first), (first, k - first)): + plan = synth_mcx_n_dirty_i15(count) + spare = [i for i in range(k) if i < start or i >= start + count] + spare = spare[: plan.num_qubits - count - 1] + halves.append((plan, list(range(start, start + count)) + [k] + spare[::-1])) +circuit = QuantumCircuit(k + 1) +for _ in range(2): + circuit.compose(*halves[0], inplace=True) + circuit.ry(-0.73 / 4, k) + circuit.compose(*halves[1], inplace=True) + circuit.ry(0.73 / 4, k) +output = transpile( + circuit, + basis_gates=["u", "cx"], + optimization_level=3, + seed_transpiler=0, + qubits_initially_zero=False, +) +assert output.count_ops()["cx"] == 1000 +assert output.depth() == 1636 +``` + +This establishes room beyond Qiskit's current public synthesis output, not +optimality or a ready Core patch. Before implementation, measure Core's own +helper ordering across all axes, symbolic expressions, and routing targets; +retain the exact-restoration tests. A width-specific split policy needs an +explicit objective because CX count and depth disagree at eight controls. The +existing construction has linear CX count; results requiring additional +clean/dirty qubits do not establish an improvement under this no-extra-qubit +contract. + +## Validation + +The native decomposition binary passes all 303 tests, including both active HP24 +dirty-helper modes and numeric/runtime full operators through ten controls. The +benchmark completes all 144 backend rows. Final lint and Python validation are +recorded in the implementation plan. diff --git a/.agent/plans/controlled-rotations.md b/.agent/plans/controlled-rotations.md index 6289864f4c..dc62386fa9 100644 --- a/.agent/plans/controlled-rotations.md +++ b/.agent/plans/controlled-rotations.md @@ -1,6 +1,7 @@ # Multi-controlled Pauli rotations -Status: complete. +Status: implemented. Rebased onto main +`4c5e45855e5bb50c42b68ddbf4f9a4dababb8737`. ## Goal and scope @@ -30,7 +31,7 @@ API or dependency was added. ## Validation -The complete `mqt-core-mlir-unittest-decomposition` binary passes 297 tests. +The complete `mqt-core-mlir-unittest-decomposition` binary passes 303 tests. These check phase-exact operators, runtime and region-local angles, native target and threshold policy, and numeric and symbolic rotation CX budgets through 64 controls. Shared Pauli tests cover X, Y, and Z with the same CX @@ -49,18 +50,20 @@ experiment are recorded in MCY import, decomposition, and export preserve the exact operator at 2, 3, and 5 controls. General lint, stub generation, and whole-changed-file C++ lint pass -with no remaining findings. +with no remaining findings. Stub generation leaves no diff. The complete +`uvx nox --non-interactive -s docs` HTML build passes, as does the native +`mlir-doc` target. The compiler help lists the supported gate families. ## Performance and limits -Compare Qiskit 2.5 public `mcrx`/`mcry`/`mcrz` synthesis with Core's -decomposition pass, using no extra qubits and the `u,cx` basis. At 2 and 3 -controls, RX/RY use 4 and 14 CX gates versus Qiskit's 8 and 20. All other -sampled widths through 64 controls match Qiskit's CX count, for numeric and -symbolic angles. +The reproducible benchmark and applied audit findings are recorded in +[`pr2467-controlled-synthesis-review.md`](../audits/pr2467-controlled-synthesis-review.md). +Core uses fewer CX gates than Qiskit 2.5.2 for two- and three-control RX/RY and +matches other sampled counts through 64 controls. Larger outputs are up to 15 +layers deeper. Fair synthesis-only timings are slower for Core in this run; +previous timing claims are superseded. -With identical level-3 post-optimization, larger Core outputs are 15 layers -deeper: RY has depth 186 versus 171 at 8 controls and 1978 versus 1963 at 64. -Local nine-sample median synthesis times were lower for all sampled cases in a -MinSizeRel build. These timings exclude frontend import, basis normalization, -routing, and full target compilation; they are not an end-to-end speed claim. +A reversed-helper prototype beats Qiskit's depth at sampled widths from nine +through 64 controls, with unchanged CX counts. An unbalanced eight-control split +saves eight CX gates but increases depth. These are measured follow-up +candidates, not production changes or an optimality claim. diff --git a/mlir/include/mlir/Dialect/QCO/Transforms/Passes.td b/mlir/include/mlir/Dialect/QCO/Transforms/Passes.td index c29b2eba5d..10e9f66175 100644 --- a/mlir/include/mlir/Dialect/QCO/Transforms/Passes.td +++ b/mlir/include/mlir/Dialect/QCO/Transforms/Passes.td @@ -406,7 +406,7 @@ def DecomposeMultiControlled | X/Z | 4 | Elementary CCCX/CCCZ | | X/Z | 5 | Specialized ancilla-free relative-phase `C^4(Z)` | | X/Z | 6–33 | da Silva-Park SP22 MCP(π) core (`H · MCP(π) · H` for X) | - | X/Z | ≥34 | Huang-Palsberg (HP24) borrowed-helper synthesis with a compile-time CX policy table | + | X/Z | ≥34 | Huang-Palsberg (HP24) borrowed-helper synthesis with one dirty helper for odd control counts and two for even counts | | Y | ≥3 | X decomposition with `S†` before and `S` after on the target | | RX/RY/RZ | ≥3 | Balanced control halves with exact MCX and quarter-angle rotations; RX uses H-conjugated RZ | | Phase | 3 | Optimized `C^2(P)` | diff --git a/mlir/lib/Dialect/QCO/Transforms/Decomposition/DecomposeMultiControlled.cpp b/mlir/lib/Dialect/QCO/Transforms/Decomposition/DecomposeMultiControlled.cpp index c731904828..9c71f2ec9f 100644 --- a/mlir/lib/Dialect/QCO/Transforms/Decomposition/DecomposeMultiControlled.cpp +++ b/mlir/lib/Dialect/QCO/Transforms/Decomposition/DecomposeMultiControlled.cpp @@ -27,7 +27,6 @@ #include #include -#include #include #include #include @@ -48,16 +47,6 @@ namespace { // the publications cited at the respective algorithms. enum class Hp24DirtyMode : uint8_t { OneDirty, TwoDirty }; -enum class Hp24IncrementerKind : uint8_t { Ripple, Partitioned }; -enum class Hp24HalfMcxKind : uint8_t { RelativePhaseTernary, BorrowedHelper }; -struct Hp24Policy { - Hp24DirtyMode dirtyMode = Hp24DirtyMode::TwoDirty; - Hp24IncrementerKind incrementerKind = Hp24IncrementerKind::Ripple; - size_t incrementerRippleMaxWidth = 10; - Hp24HalfMcxKind halfMcxKind = Hp24HalfMcxKind::RelativePhaseTernary; - size_t halfMcxBorrowedHelperMinControls = 11; -}; - enum class ControlledTarget : uint8_t { X, Z, Phase }; constexpr double K_PI = std::numbers::pi; @@ -65,9 +54,8 @@ constexpr double K_PI8 = K_PI / 8.0; class GateEmitter { public: - GateEmitter(OpBuilder& builder, Location loc, SmallVector& wires, - ArrayRef remap = {}) - : builder_(&builder), loc_(loc), wires_(&wires), remap_(remap) {} + GateEmitter(OpBuilder& builder, Location loc, SmallVector& wires) + : builder_(&builder), loc_(loc), wires_(&wires) {} // Single- and two-qubit primitives void h(size_t q) { @@ -256,22 +244,13 @@ class GateEmitter { setWire(target, ctrlOp.getTargetsOut()[0]); } - [[nodiscard]] size_t wireIndex(size_t local) const { - return remap_.empty() ? local : remap_[local]; - } + [[nodiscard]] Value wire(size_t local) const { return (*wires_)[local]; } - [[nodiscard]] Value wire(size_t local) const { - return (*wires_)[wireIndex(local)]; - } - - void setWire(size_t local, Value value) { - (*wires_)[wireIndex(local)] = value; - } + void setWire(size_t local, Value value) { (*wires_)[local] = value; } OpBuilder* builder_; Location loc_; SmallVector* wires_; - ArrayRef remap_; }; //===----------------------------------------------------------------------===// @@ -331,53 +310,20 @@ struct BorrowedControlPartition { return 2 * (2 + (10 * (numControls - 3))); } -[[nodiscard]] static size_t estimateRelativePhaseMcxOps(size_t numControls) { - if (numControls <= 2) { - return 1; - } - const size_t num3 = numControls / 3; - const size_t num2 = (numControls - num3) / 2; - const size_t num1 = numControls - num3 - num2; - return 9 + (4 * estimateRelativePhaseMcxOps(num3)) + - (2 * estimateRelativePhaseMcxOps(num2)) + - (2 * estimateRelativePhaseMcxOps(num1)); -} - [[nodiscard]] static size_t estimateIncrementerPartitionedOps(size_t n) { return (16 * n) + 4; } -[[nodiscard]] static size_t estimateIncrementerRippleOps(size_t n) { - size_t total = 1; - for (size_t width = 1; width < n; ++width) { - total += estimateBorrowedHelperMcxOps(width); - } - return total; -} - -[[nodiscard]] static size_t estimateIncrementerOps(size_t n, - const Hp24Policy& policy) { - if (policy.incrementerKind == Hp24IncrementerKind::Ripple && - n <= policy.incrementerRippleMaxWidth) { - return estimateIncrementerRippleOps(n); - } - return estimateIncrementerPartitionedOps(n); -} - [[nodiscard]] static size_t -estimateBorrowedDirtyIncrementerOps(size_t n, const Hp24Policy& policy, +estimateBorrowedDirtyIncrementerOps(size_t n, Hp24DirtyMode dirtyMode, bool flagAdd) { - const bool oneDirty = policy.dirtyMode == Hp24DirtyMode::OneDirty; + const bool oneDirty = dirtyMode == Hp24DirtyMode::OneDirty; const size_t k = oneDirty ? (n + 1) / 2 : (n + 2) / 2; const size_t lowIncrementWidth = oneDirty ? k : (1 + n - k); const size_t incrementerOps = - estimateIncrementerOps(lowIncrementWidth, policy); - const size_t halfMcxOps = - policy.halfMcxKind == Hp24HalfMcxKind::RelativePhaseTernary && - k < policy.halfMcxBorrowedHelperMinControls - ? estimateRelativePhaseMcxOps(k) - : estimateBorrowedHelperMcxOps(k); - const size_t highIncrementOps = estimateIncrementerOps(k, policy); + estimateIncrementerPartitionedOps(lowIncrementWidth); + const size_t halfMcxOps = estimateBorrowedHelperMcxOps(k); + const size_t highIncrementOps = estimateIncrementerPartitionedOps(k); return (2 * incrementerOps) + (2 * halfMcxOps) + highIncrementOps + (2 * (n - k)) + 4 + (flagAdd ? 0 : (2 * n)); } @@ -460,39 +406,6 @@ static void appendRemapped(CircuitPlan& dest, CircuitPlan src, // Phase-π core on all-ones; no clean helpers (borrow target / a control as // dirty). Callers use `MCZ = core` and `MCX = H . core . H` on the target. -static constexpr size_t K_ONE_DIRTY_MIN_CONTROLS = 23; -static constexpr size_t K_HP24_POLICY_TABLE_MIN = 4; -static constexpr size_t K_HP24_POLICY_TABLE_MAX = 24; - -[[nodiscard]] static constexpr Hp24Policy -defaultHp24Policy(size_t numControls) { - Hp24Policy policy; - if (numControls >= K_ONE_DIRTY_MIN_CONTROLS && (numControls % 2 == 1)) { - policy.dirtyMode = Hp24DirtyMode::OneDirty; - } - return policy; -} - -// HP24 policies for k=4…24 (`selectHp24Policy`). -static constexpr auto K_HP24_POLICY_TABLE = [] { - std::array table{}; - for (size_t k = 0; k <= K_HP24_POLICY_TABLE_MAX; ++k) { - table[k] = defaultHp24Policy(k); - } - table[7].dirtyMode = Hp24DirtyMode::OneDirty; - table[21].halfMcxBorrowedHelperMinControls = 13; - table[22].halfMcxBorrowedHelperMinControls = 13; - return table; -}(); - -[[nodiscard]] static Hp24Policy selectHp24Policy(size_t numControls) { - if (numControls >= K_HP24_POLICY_TABLE_MIN && - numControls <= K_HP24_POLICY_TABLE_MAX) { - return K_HP24_POLICY_TABLE[numControls]; - } - return defaultHp24Policy(numControls); -} - // HP24 §4.3 relative-phase Toffoli gadget (and its reverse-order adjoint). static void appendGadget(CircuitPlan& plan, size_t q0, size_t q1, size_t q2, bool invert) { @@ -606,122 +519,19 @@ static CircuitPlan planIncrementerPartitioned(size_t n) { return plan; } -// HP24 Fig. 10 ripple incrementer `U^n_{+1}` (narrow registers). -static CircuitPlan planIncrementerRipple(size_t n) { - CircuitPlan plan; - plan.ops.reserve(estimateIncrementerRippleOps(n)); - SmallVector wires; - for (size_t width = n - 1; width >= 1; --width) { - wires.clear(); - for (size_t q = 0; q <= width; ++q) { - wires.push_back(q); - } - for (size_t q = n + 1; q < 2 * n; ++q) { - wires.push_back(q); - } - appendRemapped(plan, planBorrowedHelperMcx(width), wires); - } - plan.append({.kind = PlanOpKind::X, .wires = {0}}); - return plan; -} - -// Leaf `U^n_{+1}`: Fig. 10 ripple when narrow, partitioned carry ladder when -// wide (crossover via policy; Fig. 6 recursion is in -// `planBorrowedDirtyIncrementer`). -static CircuitPlan planIncrementer(size_t n, const Hp24Policy& policy) { - if (policy.incrementerKind == Hp24IncrementerKind::Ripple && - n <= policy.incrementerRippleMaxWidth) { - return planIncrementerRipple(n); - } - return planIncrementerPartitioned(n); -} - -// HP24 §4.3 relative-phase MCX (ternary ladder); phases cancel in pairs. -static CircuitPlan planRelativePhaseMcx(size_t numControls) { - // Memoize by width: the recursive ladder rebuilds the same sub-widths many - // times, and half-MCX widths stay well below this bound in practice. - constexpr size_t kCacheMax = 32; - thread_local std::array, kCacheMax + 1> cache{}; - if (numControls <= kCacheMax && cache[numControls].has_value()) { - return *cache[numControls]; - } - - CircuitPlan plan; - const size_t target = numControls; - if (numControls == 1) { - plan.append({.kind = PlanOpKind::CX, .wires = {0, 1}}); - } else if (numControls == 2) { - plan.append({.kind = PlanOpKind::RCCX, .wires = {0, 1, 2}}); - } else if (numControls >= 3) { - plan.ops.reserve(estimateRelativePhaseMcxOps(numControls)); - - // Balanced three-way split of the controls into blocks of sizes num1, - // num2, num3 (num3 = floor(k/3) is the largest split that keeps the ladder - // balanced across the recursion). - const size_t num3 = numControls / 3; - const size_t num2 = (numControls - num3) / 2; - const size_t num1 = numControls - num3 - num2; - const size_t block2Begin = num1; - const size_t block3Begin = num1 + num2; - const size_t controlsEnd = numControls; - - SmallVector wires; - const auto ladderStep = [&](size_t begin, size_t end, size_t width, - bool positive) { - plan.append({ - .kind = PlanOpKind::P, - .wires = {target}, - .angle = positive ? K_PI8 : -K_PI8, - }); - wires.clear(); - for (size_t q = begin; q < end; ++q) { - wires.push_back(q); - } - wires.push_back(target); - appendRemapped(plan, planRelativePhaseMcx(width), wires); - }; - - plan.append({.kind = PlanOpKind::H, .wires = {target}}); - ladderStep(block3Begin, controlsEnd, num3, true); - ladderStep(block2Begin, block3Begin, num2, false); - ladderStep(block3Begin, controlsEnd, num3, true); - ladderStep(0, block2Begin, num1, false); - ladderStep(block3Begin, controlsEnd, num3, true); - ladderStep(block2Begin, block3Begin, num2, false); - ladderStep(block3Begin, controlsEnd, num3, true); - ladderStep(0, block2Begin, num1, false); - plan.append({.kind = PlanOpKind::H, .wires = {target}}); - } - - if (numControls <= kCacheMax) { - cache[numControls] = plan; - } - return plan; -} - -// Ternary relative-phase MCX below helperMin; else borrowed-helper MCX. -static CircuitPlan planRelativePhaseMcxWide(size_t numControls, - const Hp24Policy& policy) { - if (policy.halfMcxKind == Hp24HalfMcxKind::RelativePhaseTernary && - numControls < policy.halfMcxBorrowedHelperMinControls) { - return planRelativePhaseMcx(numControls); - } - return planBorrowedHelperMcx(numControls); -} - // HP24 Fig. 6/8 partitioned incrementer. One-dirty borrows the target; // two-dirty also borrows the top control. `flagAdd == false` yields `U_{-1}` // (Eq. (7)). static CircuitPlan planBorrowedDirtyIncrementer(size_t n, bool flagAdd, - const Hp24Policy& policy) { + Hp24DirtyMode dirtyMode) { CircuitPlan plan; - const bool oneDirty = policy.dirtyMode == Hp24DirtyMode::OneDirty; + const bool oneDirty = dirtyMode == Hp24DirtyMode::OneDirty; const size_t numDirty = oneDirty ? 1 : 2; const size_t k = oneDirty ? (n + 1) / 2 : (n + 2) / 2; const size_t helper = n; const size_t helper2 = n + 1; const size_t lowIncrementWidth = oneDirty ? k : (1 + n - k); - plan.ops.reserve(estimateBorrowedDirtyIncrementerOps(n, policy, flagAdd)); + plan.ops.reserve(estimateBorrowedDirtyIncrementerOps(n, dirtyMode, flagAdd)); const auto flipRegister = [&] { for (size_t q = 0; q < n; ++q) { @@ -772,14 +582,13 @@ static CircuitPlan planBorrowedDirtyIncrementer(size_t n, bool flagAdd, } const auto incrementLow = [&] { - appendRemapped(plan, planIncrementer(lowIncrementWidth, policy), + appendRemapped(plan, planIncrementerPartitioned(lowIncrementWidth), lowIncrementWires); }; const auto halfMcx = [&] { - // Relative-phase / borrowed-helper MCX: the high half (and optional - // helper2) on `halfMcxWires` are dirty workspace and must stay in the - // remap map — a bare NestedMCX would drop them. - appendRemapped(plan, planRelativePhaseMcxWide(k, policy), halfMcxWires); + /// The high half (and optional helper2) supplies dirty workspace. + /// Keep those wires in the map; a bare NestedMCX would omit them. + appendRemapped(plan, planBorrowedHelperMcx(k), halfMcxWires); }; const auto fanOutHelper = [&] { for (size_t q = k; q < n; ++q) { @@ -799,7 +608,7 @@ static CircuitPlan planBorrowedDirtyIncrementer(size_t n, bool flagAdd, plan.append({.kind = PlanOpKind::X, .wires = {helper}}); halfMcx(); fanOutHelper(); - appendRemapped(plan, planIncrementer(k, policy), highIncrementWires); + appendRemapped(plan, planIncrementerPartitioned(k), highIncrementWires); if (!flagAdd) { flipRegister(); @@ -808,26 +617,29 @@ static CircuitPlan planBorrowedDirtyIncrementer(size_t n, bool flagAdd, } // HP24 Theorem 4.4: `C^{n-1}(p(π))` via dirty incrementer + phase ladder. -static CircuitPlan planHp24Core(size_t n, const Hp24Policy& policy) { +static CircuitPlan planHp24Core(size_t numControls) { + /// Narrower widths use the specialized or SP22 constructions. + assert(numControls >= 33 && "HP24 requires at least 33 controls"); + const size_t n = numControls + 1; + const auto dirtyMode = + numControls % 2 == 1 ? Hp24DirtyMode::OneDirty : Hp24DirtyMode::TwoDirty; CircuitPlan plan; - const size_t numControls = n - 1; const size_t target = n - 1; const size_t topControl = n - 2; - const size_t registerWidth = policy.dirtyMode == Hp24DirtyMode::OneDirty - ? numControls - : numControls - 1; + const size_t registerWidth = + dirtyMode == Hp24DirtyMode::OneDirty ? numControls : numControls - 1; plan.ops.reserve( - estimateBorrowedDirtyIncrementerOps(registerWidth, policy, true) + - estimateBorrowedDirtyIncrementerOps(registerWidth, policy, false) + + estimateBorrowedDirtyIncrementerOps(registerWidth, dirtyMode, true) + + estimateBorrowedDirtyIncrementerOps(registerWidth, dirtyMode, false) + (2 * (numControls - 1)) + 1); SmallVector registerWires(n); std::iota(registerWires.begin(), registerWires.end(), 0U); - if (policy.dirtyMode == Hp24DirtyMode::OneDirty) { + if (dirtyMode == Hp24DirtyMode::OneDirty) { const auto increment = [&](bool add) { appendRemapped(plan, - planBorrowedDirtyIncrementer(numControls, add, policy), + planBorrowedDirtyIncrementer(numControls, add, dirtyMode), registerWires); }; increment(true); @@ -847,9 +659,9 @@ static CircuitPlan planHp24Core(size_t n, const Hp24Policy& policy) { } const auto increment = [&](bool add) { - appendRemapped(plan, - planBorrowedDirtyIncrementer(numControls - 1, add, policy), - registerWires); + appendRemapped( + plan, planBorrowedDirtyIncrementer(numControls - 1, add, dirtyMode), + registerWires); }; increment(true); double phi = -K_PI; @@ -1041,7 +853,7 @@ static CircuitPlan planMczRelativePhaseK4() { return plan; } -static CircuitPlan mczCoreForWidth(size_t numControls, size_t numWires); +static CircuitPlan mczCoreForWidth(size_t numControls); static SmallVector synthesizeMultiControlled(OpBuilder& builder, Location loc, ValueRange controls, @@ -1051,7 +863,7 @@ synthesizeMultiControlled(OpBuilder& builder, Location loc, ValueRange controls, const size_t targetIdx = controls.size(); GateEmitter emitter(builder, loc, wires); - const CircuitPlan plan = mczCoreForWidth(controls.size(), wires.size()); + const CircuitPlan plan = mczCoreForWidth(controls.size()); if (gate == ControlledTarget::X) { emitter.h(targetIdx); lowerPlan(emitter, plan); @@ -1107,8 +919,10 @@ synthesizeMultiControlledRotation(OpBuilder& builder, Location loc, map.push_back(control); } } - CircuitPlan plan; - appendRemapped(plan, planBorrowedHelperMcx(count), map); + auto plan = planBorrowedHelperMcx(count); + for (auto& op : plan.ops) { + remapPlanOpInPlace(op, map); + } return plan; }; const CircuitPlan firstHalf = halfMcx(0, k1); @@ -1317,7 +1131,7 @@ static CircuitPlan planMcpSp22(double theta, size_t numControls) { } // MCZ core: k=4 relative-phase C^4(Z); SP22 MCP(π) for 5..32; else HP24. -static CircuitPlan mczCoreForWidth(size_t numControls, size_t numWires) { +static CircuitPlan mczCoreForWidth(size_t numControls) { if (numControls == 4) { return planMczRelativePhaseK4(); } @@ -1325,7 +1139,7 @@ static CircuitPlan mczCoreForWidth(size_t numControls, size_t numWires) { numControls <= K_MCX_SP22_MAX_CONTROLS) { return planMcpSp22(K_PI, numControls); } - return planHp24Core(numWires, selectHp24Policy(numControls)); + return planHp24Core(numControls); } // General-angle MCP: SP22 at k >= 5, else C²P / Vale (relative residual at 4). diff --git a/mlir/tools/mqt-cc/mqt-cc.cpp b/mlir/tools/mqt-cc/mqt-cc.cpp index 68d9871060..49a2e21425 100644 --- a/mlir/tools/mqt-cc/mqt-cc.cpp +++ b/mlir/tools/mqt-cc/mqt-cc.cpp @@ -226,7 +226,8 @@ parseOutputFormat(const StringRef format) { static llvm::cl::opt enableDecomposeMultiControlled( "decompose-multi-controlled", llvm::cl::desc( - "Decompose controlled X/Z/phase/SWAP gates and qco.rccx that act on at " + "Decompose controlled X/Y/Z/rotation/phase/SWAP gates and qco.rccx " + "that act on at " "least --decompose-multi-controlled-min-qubits qubits (default 3)."), llvm::cl::init(false)); @@ -234,7 +235,8 @@ static llvm::cl::opt decomposeMultiControlledMinQubits( "decompose-multi-controlled-min-qubits", llvm::cl::desc( "Minimum qubit count for --decompose-multi-controlled: decompose " - "controlled X/Z/phase/SWAP gates and qco.rccx that act on at least " + "controlled X/Y/Z/rotation/phase/SWAP gates and qco.rccx that act on " + "at least " "this many qubits (default 3; must be at least 3). Higher values leave " "narrower gates undecomposed."), llvm::cl::init(3)); diff --git a/mlir/unittests/Dialect/QCO/Transforms/Decomposition/test_multi_controlled_decomposition.cpp b/mlir/unittests/Dialect/QCO/Transforms/Decomposition/test_multi_controlled_decomposition.cpp index b44fb0761f..cf51df6cd0 100644 --- a/mlir/unittests/Dialect/QCO/Transforms/Decomposition/test_multi_controlled_decomposition.cpp +++ b/mlir/unittests/Dialect/QCO/Transforms/Decomposition/test_multi_controlled_decomposition.cpp @@ -23,6 +23,7 @@ #include "mlir/Dialect/QCO/Utils/DDFunctionality.h" #include +#include #include #include #include @@ -64,8 +65,10 @@ static constexpr std::array K_DD_CONTROL_COUNTS = { }; static constexpr size_t K_MATRIX_DD_MAX_PAULI = 8; static constexpr size_t K_MATRIX_DD_MAX_MCP = 6; -static constexpr std::array K_COHERENT_HP24_CONTROL_COUNTS = { - 10, 11, 21, 22, 23, 33, +/// Retain representative SP22 widths and cover the HP24 crossover, both +/// dirty-helper modes, and wide phase ladders that can fold to identity. +static constexpr std::array K_COHERENT_PAULI_CONTROL_COUNTS = { + 10, 11, 21, 22, 23, 32, 33, 34, 47, 48, 63, 64, }; static constexpr std::array K_COHERENT_MCP_CONTROL_COUNTS = {7, 12}; /// Additional fully-lowered/CX smoke checks for k > 20 through the SP22 MCX @@ -384,6 +387,21 @@ static void expectImplementsControlledPauli(func::FuncOp funcOp, dd->decRef(*decomposedDD); } +/// Compare the complete states, including phase, without requiring identical +/// DD nodes after floating-point synthesis. +static void expectStatesNear(dd::Package& package, const dd::VectorDD& actual, + const dd::VectorDD& expected) { + auto negativeExpected = expected; + negativeExpected.w = package.cn.lookup(-dd::RealNumber::val(expected.w.r), + -dd::RealNumber::val(expected.w.i)); + const auto difference = package.add(actual, negativeExpected); + package.incRef(difference); + constexpr double tolerance = 1e-11; + EXPECT_LE(package.innerProduct(difference, difference).r, + tolerance * tolerance); + package.decRef(difference); +} + static void expectMatchesReferenceOnBasisStates(func::FuncOp funcOp, size_t numControls, ControlledPauli pauli) { @@ -413,11 +431,7 @@ static void expectMatchesReferenceOnBasisStates(func::FuncOp funcOp, ASSERT_TRUE(succeeded(decomposedOutput)); const auto referenceOutput = dd->applyOperation( referenceGate, dd::makeBasisState(numQubits, basisState, *dd)); - EXPECT_EQ(decomposedOutput->p, referenceOutput.p); - EXPECT_NEAR(dd::RealNumber::val(decomposedOutput->w.r), - dd::RealNumber::val(referenceOutput.w.r), 1e-11); - EXPECT_NEAR(dd::RealNumber::val(decomposedOutput->w.i), - dd::RealNumber::val(referenceOutput.w.i), 1e-11); + expectStatesNear(*dd, *decomposedOutput, referenceOutput); dd->decRef(*decomposedOutput); dd->decRef(referenceOutput); } @@ -439,6 +453,12 @@ static void expectMatchesReferenceOnCoherentState(func::FuncOp funcOp, size_t numControls, bool targetOne, const dd::GateMatrix& referenceMatrix) { + /// Resolve small SP22 ladder phases before comparing the whole-state error. + const auto previousTolerance = dd::RealNumber::eps; + const auto restoreTolerance = llvm::make_scope_exit([previousTolerance] { + dd::ComplexNumbers::setTolerance(previousTolerance); + }); + dd::ComplexNumbers::setTolerance(1e-15); const auto numQubits = countStaticQubits(funcOp); ASSERT_EQ(numQubits, numControls + 1); expectFullyDecomposed(funcOp); @@ -452,11 +472,7 @@ expectMatchesReferenceOnCoherentState(func::FuncOp funcOp, size_t numControls, makeControlledGateDD(*dd, numControls, referenceMatrix), makeCoherentControlInput(numControls, targetOne, *dd)); - EXPECT_EQ(decomposedOutput->p, referenceOutput.p); - EXPECT_NEAR(dd::RealNumber::val(decomposedOutput->w.r), - dd::RealNumber::val(referenceOutput.w.r), 1e-11); - EXPECT_NEAR(dd::RealNumber::val(decomposedOutput->w.i), - dd::RealNumber::val(referenceOutput.w.i), 1e-11); + expectStatesNear(*dd, *decomposedOutput, referenceOutput); dd->decRef(*decomposedOutput); dd->decRef(referenceOutput); @@ -591,7 +607,7 @@ INSTANTIATE_TEST_SUITE_P( DdRange, McrDdTest, testing::Combine(testing::Values(RotationAxis::X, RotationAxis::Y, RotationAxis::Z), - testing::Values(2U, 3U, 4U, 5U, 6U, 7U, 8U)), + testing::Values(2U, 3U, 4U, 5U, 6U, 7U, 8U, 9U, 10U)), ([](const testing::TestParamInfo>& info) { const auto [axis, numControls] = info.param; return std::string(axis == RotationAxis::X ? "Rx" @@ -794,8 +810,8 @@ INSTANTIATE_TEST_SUITE_P(DdRange, McpDdTest, }); TEST_F(MultiControlledDecompositionTest, - CoherentStatesMatchAcrossHp24PolicyBoundaries) { - for (const auto k : K_COHERENT_HP24_CONTROL_COUNTS) { + CoherentStatesMatchAcrossSynthesisBoundaries) { + for (const auto k : K_COHERENT_PAULI_CONTROL_COUNTS) { for (const auto pauli : {ControlledPauli::X, ControlledPauli::Y, ControlledPauli::Z}) { SCOPED_TRACE(testing::Message() diff --git a/test/bench/compare_controlled_rotations.py b/test/bench/compare_controlled_rotations.py new file mode 100644 index 0000000000..3a7c49b35c --- /dev/null +++ b/test/bench/compare_controlled_rotations.py @@ -0,0 +1,160 @@ +# Copyright (c) 2023 - 2026 Chair for Design Automation, TUM +# Copyright (c) 2025 - 2026 Munich Quantum Software Company GmbH +# All rights reserved. +# +# SPDX-License-Identifier: MIT +# +# Licensed under the MIT License + +"""Compare ancilla-free Core/Qiskit rotation synthesis in a common u,cx basis. + +Run with the locally built package, for example:: + + uv run --no-sync python test/bench/compare_controlled_rotations.py \ + --output build/bench/controlled-rotations.csv + +Timing excludes input preparation, import/export, basis conversion, and routing. +Both outputs receive the same basis conversion and level-3 optimization, with +no assumption that input qubits start in zero. Small cases also check the +phase-sensitive operator, including symbolic binding at 2*pi. +""" + +# Standalone benchmark executable; this directory is not a Python package. +# ruff: file-ignore[implicit-namespace-package] + +from __future__ import annotations + +import argparse +import csv +import hashlib +import logging +from importlib.metadata import version +from math import pi +from pathlib import Path +from statistics import median +from time import perf_counter_ns + +import numpy as np +from qiskit import QuantumCircuit, transpile +from qiskit.circuit import AnnotatedOperation, ControlModifier, Parameter +from qiskit.circuit.library import RXGate, RYGate, RZGate +from qiskit.quantum_info import Operator + +from mqt.core.mlir import QCOProgram, QCProgram + +LOGGER = logging.getLogger(__name__) + + +def synthesize_core(source: str, samples: int) -> tuple[QuantumCircuit, float]: + """Measure decomposition of fresh copies, excluding one warmup. + + Returns: + The synthesized circuit and median synthesis time in milliseconds. + """ + timings = [] + for _ in range(samples + 1): + program = QCOProgram.from_mlir_str(source) + start = perf_counter_ns() + program.decompose_multi_controlled() + timings.append((perf_counter_ns() - start) / 1e6) + return program.to_qc().to_qiskit(), median(timings[1:]) + + +def synthesize_qiskit(axis: str, controls: int, angle: float | Parameter, samples: int) -> tuple[QuantumCircuit, float]: + """Measure the public no-ancilla synthesis methods, excluding one warmup. + + Returns: + The synthesized circuit and median synthesis time in milliseconds. + """ + timings = [] + for _ in range(samples + 1): + circuit = QuantumCircuit(controls + 1) + start = perf_counter_ns() + if axis == "ry": + circuit.mcry(angle, list(range(controls)), controls, mode="noancilla") + else: + getattr(circuit, f"mc{axis}")(angle, list(range(controls)), controls) + timings.append((perf_counter_ns() - start) / 1e6) + return circuit, median(timings[1:]) + + +def metrics(circuit: QuantumCircuit, reference: QuantumCircuit) -> dict[str, int | float | str]: + """Measure common-basis circuits and verify phase on small operators. + + Returns: + Gate counts, depths, and the phase-sensitive error for small operators. + """ + assert circuit.num_qubits == reference.num_qubits + normalized = transpile( + circuit, basis_gates=["u", "cx"], optimization_level=0, seed_transpiler=0, qubits_initially_zero=False + ) + optimized = transpile( + normalized, basis_gates=["u", "cx"], optimization_level=3, seed_transpiler=0, qubits_initially_zero=False + ) + result: dict[str, int | float | str] = {} + for prefix, output in (("raw", normalized), ("optimized", optimized)): + assert set(output.count_ops()) <= {"u", "cx"} + result[f"{prefix}_cx"] = output.count_ops().get("cx", 0) + result[f"{prefix}_one_qubit"] = output.count_ops().get("u", 0) + result[f"{prefix}_depth"] = output.depth() + result[f"{prefix}_cx_depth"] = output.depth(lambda instruction: instruction.operation.num_qubits == 2) + result["max_operator_error"] = "" + if reference.num_qubits <= 6: + error = 0.0 + for angle in (-0.61, 2 * pi) if reference.parameters else (0.73,): + expected = reference.assign_parameters(dict.fromkeys(reference.parameters, angle)) + for output in (normalized, optimized): + actual = output.assign_parameters(dict.fromkeys(output.parameters, angle)) + error = max(error, float(np.max(np.abs(Operator(actual).data - Operator(expected).data)))) + assert error <= 1e-10, f"phase-sensitive operator error: {error}" + result["max_operator_error"] = error + return result + + +def main() -> None: + """Write quality and median synthesis times for numeric and symbolic gates.""" + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--controls", nargs="+", type=int, default=[2, 3, 4, 5, 6, 7, 8, 9, 10, 16, 32, 64]) + parser.add_argument("--samples", type=int, default=9) + parser.add_argument("--output", type=Path, default=Path("build/bench/controlled-rotations.csv")) + args = parser.parse_args() + if args.samples < 1 or any(count < 2 for count in args.controls): + parser.error("samples must be positive and control counts must be at least two") + logging.basicConfig(level=logging.WARNING, format="%(message)s") + LOGGER.setLevel(logging.INFO) + source = Path(__file__).resolve().parents[2] / ( + "mlir/lib/Dialect/QCO/Transforms/Decomposition/DecomposeMultiControlled.cpp" + ) + LOGGER.info("Core %s; Qiskit %s; samples=%s", version("mqt-core"), version("qiskit"), args.samples) + LOGGER.info("DecomposeMultiControlled.cpp SHA256: %s", hashlib.sha256(source.read_bytes()).hexdigest()) + rows = [] + for axis, gate in (("rx", RXGate), ("ry", RYGate), ("rz", RZGate)): + for controls in args.controls: + for kind, angle in (("numeric", 0.73), ("symbolic", Parameter("theta"))): + reference = QuantumCircuit(controls + 1) + reference.append(AnnotatedOperation(gate(angle), ControlModifier(controls)), reference.qubits) + source_ir = QCProgram.from_qiskit(reference).to_qco().ir + for backend in ("core", "qiskit"): + circuit, milliseconds = ( + synthesize_core(source_ir, args.samples) + if backend == "core" + else synthesize_qiskit(axis, controls, angle, args.samples) + ) + rows.append({ + "axis": axis, + "controls": controls, + "angle": kind, + "backend": backend, + "synthesis_ms": milliseconds, + **metrics(circuit, reference), + }) + LOGGER.info("%s, controls=%s, %s", axis, controls, kind) + args.output.parent.mkdir(parents=True, exist_ok=True) + with args.output.open("w", newline="", encoding="utf-8") as output: + writer = csv.DictWriter(output, fieldnames=list(rows[0]), lineterminator="\n") + writer.writeheader() + writer.writerows(rows) + + +if __name__ == "__main__": + main()