Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
4b5d10f
docs: design PennyLane PPR lowering
dwierichs Sep 3, 2026
0b3833e
docs: plan PennyLane PPR lowering
dwierichs Sep 3, 2026
ec5ef4d
feat: lower PennyLane PPR in to-ppr
dwierichs Sep 3, 2026
702d8ba
feat: preserve PPR during device preprocessing
dwierichs Sep 3, 2026
a9d6071
test: cover PennyLane PPR frontend lowering
dwierichs Sep 3, 2026
64b3482
fix: harden to-ppr PPR-operator lowering and address final review fin…
dwierichs Sep 3, 2026
ec13245
test: require PennyLane PPR support
dwierichs Sep 4, 2026
b87dca7
tiny comment on angle convention
dwierichs Sep 4, 2026
2de9ff9
remove excessive validation
dwierichs Sep 4, 2026
216ae91
test: remove obsolete PPR validation cases
dwierichs Sep 4, 2026
37fe90c
test: cover PPR Pauli word validation
dwierichs Sep 4, 2026
a608b1d
better ordering in message.
dwierichs Sep 4, 2026
0cbce94
Update doc/releases/changelog-dev.md
dwierichs Sep 4, 2026
6e88763
Merge branch 'main' into lower-ppr
dwierichs Sep 4, 2026
b0baf11
test: skip PPR tests before PennyLane support
dwierichs Sep 4, 2026
37bbec3
Revert "test: skip PPR tests before PennyLane support"
dwierichs Sep 4, 2026
11f7488
docs: design adjoint PPR lowering
dwierichs Sep 10, 2026
ba7f7cd
docs: plan adjoint PPR lowering
dwierichs Sep 10, 2026
e2b7543
Merge branch 'main' of github.com:PennyLaneAI/catalyst
dwierichs Sep 14, 2026
90cb543
Merge branch 'main' into lower-ppr
dwierichs Sep 14, 2026
6af8a39
tiny
dwierichs Sep 14, 2026
a8f598b
new angle convention
dwierichs Sep 14, 2026
642684b
test as well
dwierichs Sep 14, 2026
becba4d
update passes docs
dwierichs Sep 15, 2026
3f6aa57
remove helper files
dwierichs Sep 15, 2026
037dd82
fix error test
dwierichs Sep 15, 2026
67d67ad
reduce validation
dwierichs Sep 16, 2026
d1e8ac7
remove validation tests
dwierichs Sep 16, 2026
d22d47a
Merge branch 'main' into lower-ppr
dwierichs Sep 16, 2026
1d6fa1e
bump PL version
dwierichs Sep 16, 2026
f64c99f
Merge branch 'main' into lower-ppr
dwierichs Sep 16, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions doc/releases/changelog-dev.md
Original file line number Diff line number Diff line change
Expand Up @@ -556,6 +556,9 @@
* Added ``CZ`` support to ``to-ppr`` pass.
[(#3009)](https://github.com/PennyLaneAI/catalyst/pull/3009)

* ``to_ppr`` now directly lowers PennyLane's discrete ``PPR`` operator to ``pbc.ppr``.
[(#3185)](https://github.com/PennyLaneAI/catalyst/pull/3185)

<h3>Breaking changes 💔</h3>

* Removes :func:`~.passes.ppm_specs` and the ``--ppm-specs`` MLIR pass. Use :func:`~.specs` and
Expand Down
56 changes: 28 additions & 28 deletions frontend/catalyst/passes/builtin_passes.py
Original file line number Diff line number Diff line change
Expand Up @@ -945,6 +945,7 @@ def to_ppr_setup_inputs():
``qp.IsingZZ``,
``qp.MultiRZ``,
``qp.PauliRot``,
``qp.PPR``,
and adjoint versions thereof, as well as
``qp.measure`` and
``qp.pauli_measure``.
Expand All @@ -965,6 +966,9 @@ def to_ppr_setup_inputs():
For better compatibility with other PennyLane functionality, ensure that PennyLane program
capture is enabled with ``@qjit(capture=True)``.

Note that the angle convention of ``qp.PauliRot`` differs from Catalyst's angle convention
for PPRs by a factor of two, whereas ``qp.PPR`` follows Catalyst's convention.

**Example**

The ``to_ppr`` compilation pass can be applied as a decorator on a QNode:
Expand Down Expand Up @@ -1060,7 +1064,6 @@ def commute_ppr_setup_inputs(max_pauli_size=0):
.. code-block:: python

import pennylane as qp
import jax.numpy as jnp

@qp.qjit(capture=True)
@qp.transforms.commute_ppr(max_pauli_size=2)
Expand All @@ -1069,17 +1072,17 @@ def commute_ppr_setup_inputs(max_pauli_size=0):
def circuit():

# equivalent to a Hadamard gate
qp.PauliRot(jnp.pi / 2, pauli_word="Z", wires=0)
qp.PauliRot(jnp.pi / 2, pauli_word="X", wires=0)
qp.PauliRot(jnp.pi / 2, pauli_word="Z", wires=0)
qp.PPR(4, pauli_word="Z", wires=0)
qp.PPR(4, pauli_word="X", wires=0)
qp.PPR(4, pauli_word="Z", wires=0)

# equivalent to a CNOT gate
qp.PauliRot(jnp.pi / 2, pauli_word="ZX", wires=[0, 1])
qp.PauliRot(-jnp.pi / 2, pauli_word="Z", wires=0)
qp.PauliRot(-jnp.pi / 2, pauli_word="X", wires=1)
qp.PPR(4, pauli_word="ZX", wires=[0, 1])
qp.PPR(-4, pauli_word="Z", wires=0)
qp.PPR(-4, pauli_word="X", wires=1)

# equivalent to a T gate
qp.PauliRot(jnp.pi / 4, pauli_word="Z", wires=0)
qp.PPR(8, pauli_word="Z", wires=0)

return qp.expval(qp.Z(0))

Expand All @@ -1101,9 +1104,9 @@ def circuit():
- expval(PauliZ): 1
Depth: Not computed

In the example above, the Clifford PPRs (:class:`~.PauliRot` instances with an angle of rotation
of :math:`\tfrac{\pi}{2}`) will be commuted past the non-Clifford PPR (:class:`~.PauliRot`
instances with an angle of rotation of :math:`\tfrac{\pi}{4}`). In the above output,
In the example above, the Clifford PPRs (:class:`~.PPR` instances with an angle denominator
of :math:`\pm 4`) will be commuted past the non-Clifford PPR (:class:`~.PPR`
instances with an angle denominator :math:`\pm 8`). In the above output,
``PPR-theta-w<int>`` denotes the type of PPR present in the circuit, where ``theta`` is the PPR
angle (:math:`\theta`) and ``w<int>`` denotes the PPR weight (the number of qubits it acts on,
or the length of the Pauli word).
Expand Down Expand Up @@ -1165,15 +1168,14 @@ def merge_ppr_ppm_setup_inputs(max_pauli_size=0):
.. code-block:: python

import pennylane as qp
import jax.numpy as jnp

@qp.qjit(capture=True)
@qp.transforms.merge_ppr_ppm(max_pauli_size=2)
@qp.transforms.to_ppr
@qp.qnode(qp.device("lightning.qubit", wires=2))
def circuit():
qp.PauliRot(jnp.pi / 2, pauli_word="Z", wires=0)
qp.PauliRot(jnp.pi / 2, pauli_word="X", wires=1)
qp.PPR(4, pauli_word="Z", wires=0)
qp.PPR(4, pauli_word="X", wires=1)

ppm = qp.pauli_measure(pauli_word="ZX", wires=[0, 1])

Expand Down Expand Up @@ -1265,25 +1267,24 @@ def ppr_to_ppm_setup_inputs(decompose_method="pauli-corrected", avoid_y_measure=

import pennylane as qp
from functools import partial
import jax.numpy as jnp

@qp.qjit(capture=True)
@qp.transforms.ppr_to_ppm
@qp.transforms.to_ppr
@qp.qnode(qp.device("null.qubit", wires=2))
def circuit():
# equivalent to a Hadamard gate
qp.PauliRot(jnp.pi / 2, pauli_word="Z", wires=0)
qp.PauliRot(jnp.pi / 2, pauli_word="X", wires=0)
qp.PauliRot(jnp.pi / 2, pauli_word="Z", wires=0)
qp.PPR(4, pauli_word="Z", wires=0)
qp.PPR(4, pauli_word="X", wires=0)
qp.PPR(4, pauli_word="Z", wires=0)

# equivalent to a CNOT gate
qp.PauliRot(jnp.pi / 2, pauli_word="ZX", wires=[0, 1])
qp.PauliRot(-jnp.pi / 2, pauli_word="Z", wires=[0])
qp.PauliRot(-jnp.pi / 2, pauli_word="X", wires=[1])
qp.PPR(4, pauli_word="ZX", wires=[0, 1])
qp.PPR(-4, pauli_word="Z", wires=[0])
qp.PPR(-4, pauli_word="X", wires=[1])

# equivalent to a T gate
qp.PauliRot(jnp.pi / 4, pauli_word="Z", wires=0)
qp.PPR(8, pauli_word="Z", wires=0)

return qp.expval(qp.Z(0))

Expand Down Expand Up @@ -1487,18 +1488,17 @@ def reduce_t_depth_setup_inputs():
.. code-block:: python

import pennylane as qp
import jax.numpy as jnp

@qp.qjit(capture=True)
@qp.transforms.reduce_t_depth
@qp.transforms.to_ppr
@qp.qnode(qp.device("null.qubit", wires=4))
def circuit():
qp.PauliRot(jnp.pi / 4, pauli_word="Z", wires=1)
qp.PauliRot(-jnp.pi / 4, pauli_word="XYZ", wires=[0, 2, 3])
qp.PauliRot(-jnp.pi / 2, pauli_word="XYZY", wires=[0, 1, 2, 3])
qp.PauliRot(jnp.pi / 4, pauli_word="XZX", wires=[0, 1, 3])
qp.PauliRot(-jnp.pi / 4, pauli_word="XZY", wires=[0, 1, 2])
qp.PPR(8, pauli_word="Z", wires=1)
qp.PPR(-8, pauli_word="XYZ", wires=[0, 2, 3])
qp.PPR(-4, pauli_word="XYZY", wires=[0, 1, 2, 3])
qp.PPR(8, pauli_word="XZX", wires=[0, 1, 3])
qp.PPR(-8, pauli_word="XZY", wires=[0, 1, 2])

return qp.expval(qp.Z(0))

Expand Down
38 changes: 38 additions & 0 deletions frontend/test/pytest/test_pauli_rot_and_measure.py
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,44 @@ def f():
assert "pbc.ppr" in optimized_ir


def test_ppr_operator_capture():
"""Test that PPR remains a generic operator before applying to_ppr."""
pipe = [("pipe", ["quantum-compilation-stage"])]

@qjit(pipelines=pipe, target="mlir", capture=True)
def test_ppr_operator_capture_workflow():

@qp.qnode(qp.device("null.qubit", wires=2))
def f():
qp.PPR(4, "XY", wires=[0, 1])

return f()

optimized_ir = test_ppr_operator_capture_workflow.mlir_opt
assert 'quantum.operator "PPR"' in optimized_ir
assert "pbc.ppr" not in optimized_ir


def test_ppr_operator_to_ppr():
"""Test that to_ppr converts a PPR operator to pbc.ppr."""
pipe = [("pipe", ["quantum-compilation-stage"])]

@qjit(pipelines=pipe, target="mlir", capture=True)
@to_ppr
def test_ppr_operator_to_ppr_workflow():

@qp.qnode(qp.device("null.qubit", wires=2))
def f():
qp.PPR(4, "XY", wires=[0, 1])

return f()

optimized_ir = test_ppr_operator_to_ppr_workflow.mlir_opt
assert 'pbc.ppr ["X", "Y"](4)' in optimized_ir
assert 'quantum.operator "PPR"' not in optimized_ir
assert "quantum.paulirot" not in optimized_ir


def test_pauli_rot_with_arbitrary_angle_to_ppr():
"""Test that Pauli rotation for arbitrary angle."""
pipe = [("pipe", ["quantum-compilation-stage"])]
Expand Down
33 changes: 32 additions & 1 deletion mlir/lib/PBC/Transforms/ToPPR.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -411,6 +411,33 @@ LogicalResult convertPauliRotGate(PauliRotOp op, ConversionPatternRewriter &rewr
op.getAdjoint(), rewriter);
}

LogicalResult convertPPROperator(OperatorOp op, ConversionPatternRewriter &rewriter) {
if (!op.getAllParams().empty()) {
return op.emitOpError("PPR operator does not support dynamic parameters");
}
Comment on lines +415 to +417

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.

Is it the responsibility of this pass to check that the PPR op is malformed? 🤔

I would say no, because if it was does that mean we check such properties over and over again in any pass that interacts with an op? The line is not 100% clear to me, but generally assumptions the pass uses concretely uses could be added as assertions, but general verification should the responsibility of a different piece of code (normally the verifier, but this is where having opaque ops is a disadvantage, as we don't have any op-specific verifiers for them).

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Mhm yeah, that is a fair point. I am not so experienced with contributing at this level, so I was not aware that this is a bit out of place.
What would be your preference here? Drop all of the validation? 🤔

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I think I would maybe keep the validation that there is no dynamic parameter, similar to how the other ops are checked for having the right number (usually 1) of dynamic parameters?

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.

Yeah not so easy. I guess a good location for verification is the PPR operator in the PBC dialect itself. It already checks that rotation kind attribute, so that's superfluous. It doesn't currently check the Pauli letters, which we could add there (it's actually supposed to be a enum attribute which have verified this intrinsically, but right now it's just strings). It does verify the length of the pauli matches the qubits though.

So I think you're right, the one that couldn't be verified there is the dynamic params since those would be silently dropped at this stage. It still irks me a bit to have that here because it has nothing to do with the pass: a PPR op with dynamic params is intrinsically malformed, no matter where it appears. Compare that to control qubits which are legal but not supported by this pass, so those should be verified.

If we look into the design philosophy in mlir, passes should generally assume that any input IR is legal, and the invariant they must fulfil is that any legal IR is transformed into legal IR at the output, otherwise the pass has a bug. The legality can be enforced by the verifier, but it can also be turned off for performance if desired. I think the goal of this philosophy is to not pay the cost of legality verification repeatedly or excessively, and to have it centralized such that it can be controlled.

Imo this means that checking dynamic args in this pass is not the right place, but I'd be happy with an assertion about it (shows that this is an assumption we make, but we are not error checking it here (with a plan on what to do if didn't hold)).

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Okay, that makes a lot of sense to me 👌 From my side, we can even skip the assertion about the dynamic args, unless you'd prefer it be added? :)

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.

I think it would be good to add :)


DictionaryAttr staticData = op.getStaticData();
auto pauliWordAttr = staticData.getAs<StringAttr>("pauli_word");
StringRef pauliWord = pauliWordAttr.getValue();
SmallVector<Attribute> pauliCharacters;
pauliCharacters.reserve(pauliWord.size());
for (char pauli : pauliWord) {
pauliCharacters.push_back(rewriter.getStringAttr(StringRef(&pauli, 1)));
}
ArrayAttr pauliProduct = rewriter.getArrayAttr(pauliCharacters);

auto denominatorAttr = staticData.getAs<IntegerAttr>("angle_denominator");
int8_t rotationKind = static_cast<int8_t>(denominatorAttr.getInt());
if (op.getAdjoint()) {
Comment thread
dime10 marked this conversation as resolved.
rotationKind = -rotationKind;
}

auto pprOp =
PPRotationOp::create(rewriter, op.getLoc(), pauliProduct, rotationKind, op.getInQubits());
rewriter.replaceOp(op, pprOp.getOutQubits());
return success();
}

//===----------------------------------------------------------------------===//
// PBC Lowering Patterns
//===----------------------------------------------------------------------===//
Expand All @@ -420,7 +447,7 @@ struct PBCGateLowering : public OpInterfaceConversionPattern<QuantumOperation> {

LogicalResult matchAndRewrite(QuantumOperation operation, ArrayRef<Value> operands,
ConversionPatternRewriter &rewriter) const final {
StringRef supportedGates = "Supported gates: H, S, T, X, Y, Z, S†, T†, I, CNOT, CZ, "
StringRef supportedGates = "Supported gates: H, S, T, X, Y, Z, S†, T†, I, CNOT, CZ, PPR,"
"RX, RY, RZ, IsingXX, IsingYY, IsingZZ, MultiRZ, and PauliRot.";
Operation *op = operation.getOperation();

Expand Down Expand Up @@ -469,6 +496,10 @@ struct PBCGateLowering : public OpInterfaceConversionPattern<QuantumOperation> {
return convertMultiRZGate(originOp, rewriter);
} else if (auto originOp = dyn_cast<PauliRotOp>(op)) {
return convertPauliRotGate(originOp, rewriter);
} else if (auto originOp = dyn_cast<OperatorOp>(op)) {
Comment thread
dwierichs marked this conversation as resolved.
if (originOp.getOpName() == "PPR") {
return convertPPROperator(originOp, rewriter);
}
}

return op->emitError("Unsupported operation for PBC conversion. " + supportedGates);
Expand Down
30 changes: 30 additions & 0 deletions mlir/test/PBC/ToPPRTest.mlir
Original file line number Diff line number Diff line change
Expand Up @@ -293,3 +293,33 @@ func.func private @"some_decomp_rule"(%arg0: tensor<1xf64>, %arg1: tensor<1xi64>
%3 = quantum.insert %arg2[%extracted], %out_qubits : !quantum.reg, !quantum.bit
return %3 : !quantum.reg
}

// -----

// CHECK-LABEL: func.func @test_ppr_operator_to_ppr
func.func @test_ppr_operator_to_ppr(%q0 : !quantum.bit, %q1 : !quantum.bit) {
// CHECK-NOT: quantum.operator
// CHECK: [[out:%.+]]:2 = pbc.ppr ["X", "Y"](4) [[q0:%.+]], [[q1:%.+]]
%0:2 = quantum.operator "PPR"() qubits(%q0, %q1)
static_data = {angle_denominator = 4 : i64, pauli_word = "XY"}
// CHECK-NOT: quantum.operator
// CHECK: return
func.return
}

// -----

// CHECK-LABEL: func.func @test_negative_and_adjoint_ppr_operator
func.func @test_negative_and_adjoint_ppr_operator(%q0 : !quantum.bit) {
// CHECK-NOT: quantum.operator
// CHECK: [[q0_0:%.+]] = pbc.ppr ["Z"](-2) [[q0:%.+]]
%0 = quantum.operator "PPR"() qubits(%q0)
static_data = {angle_denominator = -2 : i64, pauli_word = "Z"}
// The second PPR consumes the first PPR's output, not the original input qubit.
// CHECK: pbc.ppr ["X"](-8) [[q0_0]]
%1 = quantum.operator "PPR"() adj qubits(%0)
static_data = {angle_denominator = 8 : i64, pauli_word = "X"}
// CHECK-NOT: quantum.operator
// CHECK: return
func.return
}
Loading