Skip to content

Conversation

@J4MMlE
Copy link

@J4MMlE J4MMlE commented Dec 28, 2025

Description

This PR extends the rotation merging pass in the MQTOpt dialect to support quaternion-based gate fusion. This is the first step toward closing #1029.

The existing rotation merge pass only merges consecutive rotation gates of the same type (e.g., rx + rx or ry + ry) by adding their angles.
This PR introduces quaternion-based merging, which can merge rotation gates of different types (currently only single qubit gates rx, ry, rz, u).

Quaternions are widely used to represent rotations in three-dimensional space and naturally map to qubit gate rotations around the Bloch sphere. The implementation:

  1. Converts rotation gates to quaternion representation
  2. Multiplies quaternions using the Hamilton product
  3. Converts the resulting quaternion back to a u gate. (This could also be done differently in the future, and directly decompose to the correct base gates by using the decomposition from ✨ Implement single-qubit gate decomposition pass #1182)

Since this optimization may only be beneficial on certain quantum architectures, it is disabled by default. It can be invoked using:

quantum-opt <input.mlir> --pass-pipeline='builtin.module(merge-rotation-gates{quaternion-folding})'

This implementation currently targets the legacy MQTOpt dialect. In the future, this PR will port this optimization to the new QCO dialect.
For future implementation in QCO, a separate pass will be added, since simple same-type rotation gate merging is implemented as a canonicalization there but it only makes sense to have quaternion merging as a separate pass.

Checklist:

  • The pull request only contains commits that are focused and relevant to this change.
  • I have added appropriate tests that cover the new/changed functionality.
  • I have updated the documentation to reflect these changes.
  • I have added entries to the changelog for any noteworthy additions, changes, fixes, or removals.
  • I have added migration instructions to the upgrade guide (if needed).
  • The changes follow the project's style guidelines and introduce no new warnings.
  • The changes are fully tested and pass the CI checks.
  • I have reviewed my own code changes.

@codecov
Copy link

codecov bot commented Dec 28, 2025

Codecov Report

❌ Patch coverage is 95.33333% with 7 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
mlir/lib/Compiler/CompilerPipeline.cpp 20.0% 4 Missing ⚠️
...ct/QCO/Transforms/QuaternionMergeRotationGates.cpp 97.9% 3 Missing ⚠️

📢 Thoughts on this report? Let us know!

@burgholzer burgholzer added feature New feature or request c++ Anything related to C++ code MLIR Anything related to MLIR labels Dec 29, 2025
@burgholzer burgholzer added this to the MLIR Support milestone Dec 29, 2025
@burgholzer
Copy link
Member

Hey @J4MMlE 👋🏻
This is great to see!

How much of an ask would it be to directly base this pass on the QCO dialect and its infrastructure?
We'd like to stop adding features to the old dialects and instead only add them to the new ones as much as possible, so that we can remove the old dialects rather sooner than later.
This is not a must, but it would be highly appreciated.

@mergify mergify bot added the conflict label Jan 14, 2026
@J4MMlE J4MMlE force-pushed the quaternion-rotation-merging branch from 106575c to 7528b05 Compare January 14, 2026 22:38
@mergify mergify bot removed the conflict label Jan 14, 2026
@burgholzer burgholzer requested a review from DRovara January 17, 2026 00:07
@burgholzer burgholzer linked an issue Jan 17, 2026 that may be closed by this pull request
Copy link
Collaborator

@DRovara DRovara left a comment

Choose a reason for hiding this comment

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

Thanks a lot @J4MMlE for the effort! The code already looks super clean in my opinion and I like the way you have implemented everything.

I do have quite a few minor comments, but they are largely just related to comments in the code (docstrings/typos).

What I did not look at in much detail is the CMake setup. I guess it would make sense if @burgholzer (after your vacation, of course) could look into that - although probably it would be most efficient to do that only once the tests are ready, too (btw, I think the point from my top-level comment below might also be interesting to consider for you).

Anyways, @J4MMlE, once you have addressed the comments and added the tests, feel free to re-request my review and I will look at the code again. Thanks a lot in the meantime!

Comment on lines 86 to 89
areUsersUnique(const mlir::ResultRange::user_range& users) {
return llvm::none_of(users,
[&](auto* user) { return user != *users.begin(); });
}
Copy link
Collaborator

Choose a reason for hiding this comment

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

I know we have had similar logic in some old passes, but could you please refresh my memory:

Is this check not the same as checking whether the number of users is equal to one? Or in other words, can op->getUsers() ever contain the same element twice?

If this is the same as the number check, then we could replace it by that, as that would reduce lines 95-100 (since the users.empty() check would also be covered by a function/operation that checks whether the number of users is exactly 1.

Copy link
Author

@J4MMlE J4MMlE Jan 21, 2026

Choose a reason for hiding this comment

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

I also stumbled across this while trying to come up with some test case for these checks. The QCOProgramBuilder enforces linearity by only allowing a value to be consumed once. However, the current compiler pipeline does not verify linearity like QCOProgramBuilder, so you could theoretically parse non-linear code like this:

module {
  func.func @multipleUsers() {
    %0 = qco.alloc : !qco.qubit
    %cst = arith.constant 1.000000e+00 : f64
    %1 = qco.ry(%cst) %0 : !qco.qubit -> !qco.qubit

    // %1 is used by BOTH operations
    %2 = qco.rz(%cst) %1 : !qco.qubit -> !qco.qubit
    %3 = qco.rz(%cst) %1 : !qco.qubit -> !qco.qubit

    qco.dealloc %2 : !qco.qubit
    qco.dealloc %3 : !qco.qubit
    return
  }
}

I guess code like that should be rejected? The intention here would be to clone state %1 which is not possible in the real world. Can we assume that initial mlir code is in a linear form (where each value is consumed only once)?
The resulting code is linear but %0 is deallocated twice:

module {
  func.func @multipleUsers() {
    %cst = arith.constant 1.000000e+00 : f64
    %0 = qc.alloc : !qc.qubit
    qc.ry(%cst) %0 : !qc.qubit
    qc.rz(%cst) %0 : !qc.qubit
    qc.rz(%cst) %0 : !qc.qubit
    qc.dealloc %0 : !qc.qubit
    qc.dealloc %0 : !qc.qubit
    return
  }
}

Given the assumption that input code is linear, no checks whatsoever would actually be needed (by definition every operation would have one and only one use).

And yes, I also think that a user can only be included once in the users. For now the best solution would be to get rid of users.empty() and areUsersUnique() and replace them by a single op->hasOneUse() to ensure op is used only once. What do you think @DRovara?

Copy link
Collaborator

Choose a reason for hiding this comment

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

So, in theory we expect linearity, as you say. We don't have a verifier because we assume this to hold by construction, no need to waste resources on verifying it. That being said, there is one situation where we just didn't find a way to enforce linearity without larger re-development efforts:

... // some classical value in %c, some qubit in %q_0
%q_1 = scf.if (%c) {
  %q_0_then = qco.x %q_0 : !qco.qubit
  scf.yield %q_0_then : !qco.qubit
} else {
  %q_0_else = qco.y %q_0 : !qco.qubit
  scf.yield %q_0_else : !qco.qubit
}

Here, %q_0 is used twice, however we know that no cloning can happen because only one of the two branches will be taken.

This should be the only case where "re-using" a qubit is valid, but that's already enough to require explicit checks in a lot of passes, unfortunately.

@mergify mergify bot added the conflict label Jan 20, 2026
@DRovara
Copy link
Collaborator

DRovara commented Jan 21, 2026

I just remembered one more comment I wanted to make that I forgot:

You talk about how you no longer have to explicitly filter for control qubits due to the new dialect structure.
However, right now, I believe your pass would not work at all with controlled gates anymore - I'm not sure if that's intended.

Imagine the following pseudo-code:

%q0_0, %q1_0 = alloc(2)
[...]
%q0_1, %q1_1 = qco.ctrl(%q0_0), (%q1c_0 = %q1_0) {
  %q1c_1 = qco.u(...) %q1c_0
  qco.yield %q1c_1 
}
%q0_2, %q1_2 = qco.ctrl(%q0_1), (%q1c2_0 = %q1_1) {
  %q1c2_1 = qco.u(...) %q1c2_0
  qco.yield %q1c2_1 
}

Here, the first u gate has qco.yield as its user. However, in short, the snippet above corresponds to:

controlled(q0) u(...) q1
controlled(q0) u(...) q1

so in theory, this can definitely be merged.

Now, I don't know if this is a flaw of the pass (maybe this situation should be checked explicitly), a flaw of the dialect implementation (maybe QCO should provide a way to get the actual successor gate, rather than the yield which we don't care much about), or if it's just out of scope for this pass.

My personal gut feeling is that it would be a nice helper method to implement for the QCO UnitaryOpInterface.

@J4MMlE J4MMlE force-pushed the quaternion-rotation-merging branch from aaa7096 to 94ea576 Compare January 21, 2026 19:33
@mergify mergify bot removed the conflict label Jan 21, 2026
@J4MMlE J4MMlE force-pushed the quaternion-rotation-merging branch from f0989ad to 045857a Compare January 21, 2026 19:57
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

c++ Anything related to C++ code feature New feature or request MLIR Anything related to MLIR

Projects

None yet

Development

Successfully merging this pull request may close these issues.

✨ MLIR - Support merging of more complex gates

3 participants