Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions doc/releases/changelog-dev.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,11 @@

<h3>Improvements 🛠</h3>

* Add adjoint support to the decomposition graph solver, enabling `Adjoint(Op)` to be decomposed either
via registered adjoint rules or by adjointing the base operator's decomposition rule,
with the solver choosing the cheapest.
[(#3001)](https://github.com/PennyLaneAI/catalyst/pull/3001)

* The `ResourceAnalysis` pass has received a new compiler hint to more accurately estimate quantum
resources in the presence of conditional operations (`scf.if` and `scf.index_switch`). The
operations in question can be annotated with either a `catalyst.estimated_probability` or
Expand Down Expand Up @@ -313,6 +318,7 @@
[(#2983)](https://github.com/PennyLaneAI/catalyst/pull/2983)
[(#3022)](https://github.com/PennyLaneAI/catalyst/pull/3022)
[(#3039)](https://github.com/PennyLaneAI/catalyst/pull/3039)
[(#3046)](https://github.com/PennyLaneAI/catalyst/pull/3046)

* The `graph-decomposition` pass eliminates three redundant IR manipulations:
the cloning, removal, and re-insertion of user rules. This optimization is particularly
Expand Down
31 changes: 31 additions & 0 deletions frontend/test/lit/GraphDecomposition/TestGraphOpId.mlir
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
// Copyright 2026 Xanadu Quantum Technologies Inc.

// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at

// http://www.apache.org/licenses/LICENSE-2.0

// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

// Test that graph-decomposition succeeds when using graphOpIds

// RUN: catalyst --tool=opt --split-input-file --pass-pipeline='builtin.module(graph-decomposition{gate-set=PauliX=1.0 alt-decomps=Hadamard=my_decomp})' %s | FileCheck %s

func.func @circuit(%q: !quantum.bit) -> !quantum.bit {
// CHECK-NOT: Hadamard
// CHECK: PauliX
// CHECK: PauliX
%out = quantum.custom "Hadamard"() %q: !quantum.bit
return %out: !quantum.bit
}

func.func private @my_decomp(%q: !quantum.bit) -> !quantum.bit attributes {target_gate="Hadamard[][1]{}"} {
%q0 = quantum.custom "PauliX"() %q : !quantum.bit
%q1 = quantum.custom "PauliX"() %q0 : !quantum.bit
return %q1 : !quantum.bit
}
Original file line number Diff line number Diff line change
Expand Up @@ -18,13 +18,23 @@

#include "DGBuilder.hpp"

#include <cstddef>
#include <cstdint>
#include <iostream>
#include <memory>
#include <unordered_map>
#include <unordered_set>
#include <utility>
#include <variant>
#include <vector>

#include "DGUtils.hpp"
#include "boost/graph/adjacency_list.hpp"
#include "boost/graph/detail/adjacency_list.hpp"
#include "boost/graph/graph_selectors.hpp"
#include "boost/graph/graph_traits.hpp"

#include <boost/graph/adjacency_list.hpp>
#include "DGTypes.hpp"
#include "DGUtils.hpp"

using namespace DecompGraph::Core;

Expand Down Expand Up @@ -101,6 +111,7 @@ struct DecompositionGraph::Impl {
fixedDecomps(std::move(_fixedDecomps)), altDecomps(std::move(_altDecomps))
{
materializeRules();
generateAdjointRules();
}

void materializeRules()
Expand Down Expand Up @@ -156,6 +167,79 @@ struct DecompositionGraph::Impl {
rules = std::move(effectiveRules);
}

/**
* @brief Generate Adjoint decomposition rules
*
* For a needed adjoint operator `Adjoint(Op)` this synthesizes, from every base decomposition
* `Op`, a rule `Adjoint(Op)` so the adjoint operator can be decomposed by adjointing the
* decomposition of its base. These coexist with any explicitly registered adjoint rules;
* the solver then compares their costs and picks the cheapest.
*
* @note Rules are synthesized only on-demand: only Adjoint operators that actually appear
* in the circuit (as roots) seed the process, and adjointing a decomposition may introduce
* new Adjoint(input) operators that require their own synthesized rules, so the process
* runs to a fixpoint. We leave the graph untouched when there aren't any adjoint ops.
*
* Two families of rules are never used as a base in this method:
* - rules whose output is already adjoint: we only derive adjoint rules from base
* decompositions.
* - empty rules in the graph: these mark basis/target gates, and the adjoint of
* a basis gate is not necessarily available for free, so it must be provided
* explicitly (e.g. a self_adjoint rule) rather than synthesized.
* TODO: we'll revisit this when integrating this with graph-decomposition.
*/
void generateAdjointRules()
{
// Index valid base decompositions by their (non-adjoint) output.
std::unordered_map<OperatorNode, std::vector<RuleNode>, OperatorNodeHash> baseByOutput;
for (const auto &rule : rules) {
if (!rule.output.adjoint && !rule.isEmpty()) {
baseByOutput[rule.output].push_back(rule);
}
}
if (baseByOutput.empty()) {
return;
}

// for every Adjoint(Op) in the circuit:
std::unordered_set<OperatorNode, OperatorNodeHash> seen;
std::vector<OperatorNode> worklist;
auto enqueue = [&](const OperatorNode &op) {
if (op.adjoint && seen.insert(op).second) {
worklist.push_back(op);
}
};
for (const auto &op : operators) {
enqueue(op);
}
for (const auto &rule : rules) {
enqueue(rule.output);
for (const auto &term : rule.inputs) {
enqueue(term.op);
}
}

std::vector<RuleNode> generated;
while (!worklist.empty()) {
const OperatorNode adjOp = worklist.back();
worklist.pop_back();
const auto it = baseByOutput.find(makeAdjoint(adjOp));
if (it == baseByOutput.end()) {
continue;
}
for (const auto &baseRule : it->second) {
RuleNode adjRule = makeAdjointRule(baseRule);
for (const auto &term : adjRule.inputs) {
enqueue(term.op);
}
generated.push_back(std::move(adjRule));
}
}
for (auto &rule : generated) {
rules.push_back(std::move(rule));
}
}

void buildGraph()
{
// Register all operators
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@

#pragma once

#include <cstddef>
#include <memory>
#include <vector>

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,12 +18,14 @@

#include "DGSolver.hpp"

#include <algorithm>
#include <optional>
#include <string>
#include <unordered_set>
#include <utility>
#include <vector>

#include "DGTypes.hpp"
#include "DGUtils.hpp"

using namespace DecompGraph::Core;

Expand Down Expand Up @@ -51,6 +53,7 @@ ChosenDecompRule DecompositionSolver::evalRule(const RuleNode &rule)
solution.isBasis = false;
solution.inputs = rule.inputs;
solution.op = rule.output;
solution.origin = rule.origin;

double total_cost = 0.0;
for (const auto &input : rule.inputs) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,14 +25,12 @@

#pragma once

#include <optional>
#include <unordered_map>
#include <unordered_set>
#include <vector>

#include "DGBuilder.hpp"
#include "DGTypes.hpp"
#include "DGUtils.hpp"

namespace DecompGraph::Solver {

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -53,39 +53,16 @@ namespace DecompGraph::Core {
* when adding support for operators with dynamic numbers of wires/params.
*/
struct OperatorNode {
std::string name;
int numWires{-1};
int numParams{-1};
std::string id;
bool adjoint{false};

// Optional static arguments for operators that require additional data.
// optional params, primarily for debug use
std::string name{""};
int numWires{-1};
int numParams{-1};
std::unordered_map<std::string, std::string> staticNamedArgs{};
std::string id{""};

bool operator==(const OperatorNode &other) const
{
// id match
if (!id.empty() && !other.id.empty() && id == other.id) {
return true;
}
// legacy fallback if either op is missing ID

// For equality, we consider numWires and numParams conditionally equal
// if they are not set to -1 (which indicates a wildcard that can match any value).
const bool default_wires =
(numWires == -1 || other.numWires == -1 || numWires == other.numWires);
const bool default_params =
(numParams == -1 || other.numParams == -1 || numParams == other.numParams);

// Static arguments are optional: if either side has no static args, they
// are treated as matching (wildcard). When both sides provide entries, the maps must
// be equal element-wise for the operators to be considered equivalent.
const bool static_args_match = staticNamedArgs.empty() || other.staticNamedArgs.empty() ||
staticNamedArgs == other.staticNamedArgs;

return name == other.name && default_wires && default_params && adjoint == other.adjoint &&
static_args_match;
}
bool operator==(const OperatorNode &other) const { return id == other.id; }
bool operator!=(const OperatorNode &other) const { return !(*this == other); }
};

Expand All @@ -107,45 +84,24 @@ struct OperatorNode {
struct OperatorNodeHash {
std::size_t operator()(const OperatorNode &node) const
{
// prefer id if available
if (!node.id.empty()) {
return std::hash<std::string>{}(node.id);
}
return std::hash<std::string>{}(node.name);
return std::hash<std::string>{}(node.id);
}
};

/**
* @brief This represents the weighted target gateset for the graph decomposition problem.
*/
struct WeightedGateset {
// TODO: using ID here mandates that gatesets specify all legal IDs, rather than generic class
// like "PauliRot". This should be updated to work on generic names
std::unordered_map<OperatorNode, double, OperatorNodeHash> ops;

[[nodiscard]] bool contains(const OperatorNode &op) const
{
// hash match
if (ops.find(op) != ops.end()) {
return true;
}

// use op-matching if hashes failed (could be id vs name)
for (auto [gatesetOp, cost] : ops) {
if (gatesetOp == op) {
return true;
}
}

return false;
}
[[nodiscard]] bool contains(const OperatorNode &op) const { return ops.find(op) != ops.end(); }

[[nodiscard]] double getCost(const OperatorNode &op) const
{
auto it = ops.find(op);
if (it != ops.end()) {
return it->second;
}

return std::numeric_limits<double>::infinity();
return it != ops.end() ? it->second : std::numeric_limits<double>::infinity();
}
};

Expand All @@ -170,8 +126,9 @@ struct RuleTerm {
* graph.
* - Fixed: A fixed rule that cannot be changed or overridden by the solver.
* - Alternative: An alternative rule that can be used in place of the default rule.
* - AdjointGenerated: A rule synthesized by adjointing a base decomposition rule.
*/
enum class RuleOrigin : uint8_t { Default = 0, Fixed = 1, Alternative = 2 };
enum class RuleOrigin : uint8_t { Default = 0, Fixed = 1, Alternative = 2, AdjointGenerated = 3 };

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Should we differentiate between adjoint genereated from default, adjoint generated from fixed and adjoint generated from alternative?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

No we don't need to consider those cases. Fixed/Alternative are consumed by the builder to enforced which rules exist for an op -- the solver works without knowing about the origin of these rule! AdjointGenerated is also consumed by the builder but it's needed in the solver as the solver needs to consider both pathways (in the ADR) and to propagate rules that are built by the solver using makeAdjointRule (Pathway 2 in the ADR).


/**
* @brief This represents the decomposition rules in the graph decomposition problem.
Expand Down Expand Up @@ -216,6 +173,54 @@ using FixedDecomps = std::unordered_map<OperatorNode, RuleNode, OperatorNodeHash
*/
using AltDecomps = std::unordered_map<OperatorNode, std::vector<RuleNode>, OperatorNodeHash>;

/**
* @brief This returns a copy of the given operator with the adjoint modifier toggled.
*
* Identity is the opaque `id` string (equality/hashing are id-only),
* so the modifier must be folded into the id: we wrap it in `Adjoint(...)`
* (or strip that wrapper to cancel adjoint).
* Applying twice cancels: `makeAdjoint(makeAdjoint(op)) == op`.
*/
inline OperatorNode makeAdjoint(OperatorNode op)
{
static constexpr char kPrefix[] = "Adjoint(";
constexpr std::size_t kPrefixLen = sizeof(kPrefix) - 1;

if (op.adjoint) {
// Cancel: strip the outermost "Adjoint( ... )" wrapper from the id.
if (op.id.size() > kPrefixLen && op.id.compare(0, kPrefixLen, kPrefix) == 0 &&
op.id.back() == ')') {
op.id = op.id.substr(kPrefixLen, op.id.size() - kPrefixLen - 1);
}
op.adjoint = false;
}
else {
op.id = std::string(kPrefix) + op.id + ")";
op.adjoint = true;
}
return op;
}

/**
* @brief Constructs the Adjoint decomposition of a base rule.
*
* Given a rule `output -> {inputs}`, produces `Adjoint(output) -> {Adjoint(input), ...}`
* with the same multiplicities: the adjoint of a decomposition is obtained by adjointing
* every produced gate (and reversing their order, which does not affect resource/cost counting).
*/
inline RuleNode makeAdjointRule(const RuleNode &base)
{
RuleNode adj;
adj.name = base.name + "_adjoint";
adj.output = makeAdjoint(base.output);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Do we have to worry about this in-place mutating base.output?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

No. makeAdjoint takes operators by "value"; it operates on a copy and returns it so the original op remains untouched.

adj.origin = RuleOrigin::AdjointGenerated;
adj.inputs.reserve(base.inputs.size());
for (const auto &term : base.inputs) {
adj.inputs.push_back({makeAdjoint(term.op), term.multiplicity});
}
return adj;
}

/**
* @brief This represents the chosen decomposition rule for an operator in
* the solution of the graph decomposition problem.
Expand All @@ -227,6 +232,9 @@ struct ChosenDecompRule {
std::vector<RuleTerm> inputs;
double totalCost{0.0};
std::unordered_map<OperatorNode, std::size_t, OperatorNodeHash> basisCounts;

// TODO: revisit this after testing..
RuleOrigin origin{RuleOrigin::Default};
};

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
#include <sstream>
#include <stdexcept>
#include <string>
#include <utility>
#include <vector>

#include "DGTypes.hpp"
Expand Down
Loading
Loading