Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add ContractIdleWiresInControlFlow optimisation pass #13779

Merged

Conversation

jakelishman
Copy link
Member

Summary

This transpiler pass removes data dependencies on idle qubits from control-flow operations. For example, given a circuit such as::

from qiskit.circuit import QuantumCircuit

qc = QuantumCircuit(1, 1)
qc.x(0)
with qc.if_test((qc.clbits[0], True)):
    qc.x(0)
    qc.x(0)
qc.x(0)

the current optimisation passes will collapse the inner control-flow block to the identity, but the qubit dependency will remain, preventing the outer two X gates from being cancelled. This pass removes the now-spurious dependency, making it possible to detect and remove the two X gates in a follow-up loop iteration.

As an accidental side-effect of their algorithms, the control-flow-aware routing passes currently do this when they run. This aims to move the logic into a suitable place to run before routing (so the spurious dependency never arises in routing in the first place) and in the low-level optimisation stage.

The aim of this pass is also to centralise the logic, so when the addition of the new box scope with different semantics around whether a wire is truly idle in the box or not, the routers aren't accidentally breaking them, and it's clearer when the modifications happen.

Details and comments

Built on top of #13774, since it's very convenient to have that method for writing the tests. This is one part of #13768.

I haven't hooked this up to the preset pass-managers in this commit; I wanted to do that after #13768 is fixed properly and potentially once box itself has landed.

@jakelishman jakelishman added on hold Can not fix yet Changelog: New Feature Include in the "Added" section of the changelog mod: transpiler Issues and PRs related to Transpiler labels Feb 3, 2025
@jakelishman jakelishman added this to the 2.0.0 milestone Feb 3, 2025
@jakelishman jakelishman requested a review from a team as a code owner February 3, 2025 13:07
@qiskit-bot
Copy link
Collaborator

One or more of the following people are relevant to this code:

  • @Qiskit/terra-core

@coveralls
Copy link

coveralls commented Feb 3, 2025

Pull Request Test Coverage Report for Build 13530225985

Details

  • 51 of 51 (100.0%) changed or added relevant lines in 3 files are covered.
  • 20 unchanged lines in 3 files lost coverage.
  • Overall coverage decreased (-0.01%) to 87.966%

Files with Coverage Reduction New Missed Lines %
crates/accelerate/src/unitary_synthesis.rs 1 94.39%
crates/qasm2/src/lex.rs 7 91.48%
crates/qasm2/src/parse.rs 12 96.68%
Totals Coverage Status
Change from base Build 13512719904: -0.01%
Covered Lines: 77540
Relevant Lines: 88148

💛 - Coveralls

This transpiler pass removes data dependencies on idle qubits from
control-flow operations.  For example, given a circuit such as::

    from qiskit.circuit import QuantumCircuit

    qc = QuantumCircuit(1, 1)
    qc.x(0)
    with qc.if_test((qc.clbits[0], True)):
        qc.x(0)
        qc.x(0)
    qc.x(0)

the current optimisation passes will collapse the inner control-flow
block to the identity, but the qubit dependency will remain, preventing
the outer two X gates from being cancelled.  This pass removes the
now-spurious dependency, making it possible to detect and remove the two
X gates in a follow-up loop iteration.

As an accidental side-effect of their algorithms, the control-flow-aware
routing passes currently do this when they run.  This aims to move the
logic into a suitable place to run before routing (so the spurious
dependency never arises in routing in the first place) and in the
low-level optimisation stage.

The aim of this pass is also to centralise the logic, so when the
addition of the new `box` scope with different semantics around whether
a wire is truly idle in the box or not, the routers aren't accidentally
breaking them, and it's clearer when the modifications happen.
@jakelishman jakelishman force-pushed the control-flow/strip-data-dependency branch from c3ba4bf to ff133cf Compare February 5, 2025 11:11
@jakelishman jakelishman removed the on hold Can not fix yet label Feb 5, 2025
@jakelishman
Copy link
Member Author

Rebased over main and no longer on hold.

Copy link
Contributor

@kevinhartman kevinhartman left a comment

Choose a reason for hiding this comment

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

This looks good! It's nice to have this logic in one place.

Presumably you were missing something in Rust and thus decided to do the initial implementation in Python?


def run(self, dag):
# `control_flow_op_nodes` is eager and doesn't borrow; we're mutating the DAG in the loop.
for node in dag.control_flow_op_nodes() or []:
Copy link
Contributor

Choose a reason for hiding this comment

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

You'll probably remember, but adding a note here to remove the or [] once it is no longer needed.

Copy link
Member Author

Choose a reason for hiding this comment

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

Good to highlight thanks, because it's now been long enough that I probably would have forgotten to do it haha.

replacement.add_qubits(list(qubits))
replacement.add_clbits(list(clbits))
for var in vars_:
replacement.add_captured_var(var)
Copy link
Contributor

Choose a reason for hiding this comment

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

Do we need to add local variables too?

Copy link
Member Author

Choose a reason for hiding this comment

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

In the replacement they're all "capturing" from the circuit they're about to be inlined into - the outer circuit defines the true "type" of them.

replacement._apply_op_node_back(DAGOpNode.from_instruction(new_inst))
# The replacement DAG is defined over all the same qubits, but with the correct
# qubits now explicitly marked as idle, so everything gets linked up correctly.
dag.substitute_node_with_dag(
Copy link
Contributor

Choose a reason for hiding this comment

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

I was a bit confused at first reading through this until I realized that replacement is a temporary DAG that gets its contents unwrapped / flattened into dag. Do we not have a simpler way to replace a node with another node in DAGCircuit? Presumably there's more going on here than I'm understanding!

Copy link
Member Author

Choose a reason for hiding this comment

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

The trick is around the node contraction - substitute_node_with_dag has enough information available to it that it can link the now-unused wires together. We have substitute_node, but it requires that the two nodes act on the same set of wires.

Copy link
Contributor

Choose a reason for hiding this comment

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

Ah, thanks for clarifying 🙂.

qc = QuantumCircuit(3, 1)
with qc.while_loop((qc.clbits[0], False)):
qc.cx(0, 1)
qc.noop(2)
Copy link
Contributor

Choose a reason for hiding this comment

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

Certainly beyond the scope of this PR, but I find it a bit strange that a noop doesn't add an operation (I did not see the introductory PR for it). I was surprised to see that there's no noop in the expected circuit of this test, and thought I may have missed something in this new pass where they get removed.

Copy link
Member Author

Choose a reason for hiding this comment

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

The entire point of noop is not to add an operation haha. id is the mathematical expression of "unitary identity", whereas noop is "mark this qubit as used, but do explicitly do not do anything with it".

Copy link
Contributor

Choose a reason for hiding this comment

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

Heh, I suppose I'm thinking of it more like a noop instruction in classical computing, which does end up getting executed by the CPU and takes some number of cycles to do nothing.

Copy link
Member Author

Choose a reason for hiding this comment

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

It kind of does here too, but qubit operations are all inherently super parallel. Putting a noop on a qubit to bring it into a box causes it to idle for the duration of the box (unless you apply other things to it).

@jakelishman
Copy link
Member Author

I did this in Python because it was faster and cleaner to write for the initial implementation, and there won't be any appreciable slowdown because all the logic is Python-space anyway - control-flow ops are still represented by Python-space objects.

The loop dag.control_flow_op_nodes() happens in Rust space, which is the only bit where the Python overhead would have hurt, I think.

kevinhartman
kevinhartman previously approved these changes Feb 25, 2025
@jakelishman jakelishman added this pull request to the merge queue Feb 25, 2025
Merged via the queue into Qiskit:main with commit ddb7670 Feb 25, 2025
19 checks passed
@jakelishman jakelishman deleted the control-flow/strip-data-dependency branch February 25, 2025 22:30
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Changelog: New Feature Include in the "Added" section of the changelog mod: transpiler Issues and PRs related to Transpiler
Projects
None yet
Development

Successfully merging this pull request may close these issues.

4 participants