Skip to content

Conversation

ritu-thombre99
Copy link
Contributor

Context:

Original PR: #1928 (comment)
Description of the Change:

When running a quantum circuit on a hardware with certain connectivity constraints, two-qubit gates like CNOTs can only be executed using physical qubits that are connected by an edge on the hardware.

Hence, necessary SWAPs need to be inserted in order to route the logical qubits so that they are mapped to an edge on the device, while respecting other compiling constraint i.e. order the gate dependencies from the input quantum circuit, and ensuring compiled quantum circuit is equivalent to the input quantum circuit.

Following image shows a simple example:

image

Benefits:

  1. This will support qubit mapping and routing in the runtime
  2. Analogue to qml.transpile transform
  3. Circuits with control structure will also be supported since they will already be optimized by Catalyst passes and unrolled in the runtime.

Possible Drawbacks:

  1. Modification of RuntimeCAPI
  2. Currently, not checking if device supports SWAP
  3. There are multi-qubit gates like Toffoli, MultiRZ, QubitUnitary in RuntimeCAPI.cpp. Routing is not applied to these qubits yet.

Related GitHub Issues:

Example usage:

Currently, seeing what SWAP gates are added can be viewed by using null.qubit done by printing NamedOperation in runtime/lib/backend/null_qubit/NullQubit.hpp

import pennylane as qml
import numpy as np
dev = qml.device("null.qubit", wires = [(0,1),(1,2),(2,3)])
@qml.qjit(autograph=True)
@qml.qnode(dev)
def circuit(c: bool, x: int, y: int):
    if c:
        qml.Hadamard(x)
    qml.Hadamard(y)
    qml.CRZ(0.4,[x,y])
    qml.CNOT([x,2])
    qml.CRX(0.6,[2,y])
    qml.CRY(0.2,[x,y])
    qml.IsingXX(0.24,[0,y])
    qml.CNOT([2,4])
    qml.CNOT([0,5])
    return qml.state()
print(circuit(True,0,3))

Output:

Name: Hadamard
Wires : 0,
Name: Hadamard
Wires : 3,
Name: SWAP
Wires : 0,1,
Name: SWAP
Wires : 1,2,
Name: CRZ
Wires : 2,3,
Name: CNOT
Wires : 2,1,
Name: SWAP
Wires : 1,2,
Name: CRX
Wires : 2,3,
Name: SWAP
Wires : 1,2,
Name: CRY
Wires : 2,3,
Name: IsingXX
Wires : 2,3,
Name: SWAP
Wires : 1,2,
Name: SWAP
Wires : 2,3,
Name: CNOT
Wires : 3,4,
Name: SWAP
Wires : 1,2,
Name: SWAP
Wires : 2,3,
Name: SWAP
Wires : 3,4,
Name: CNOT
Wires : 4,5,
Name: SWAP
Wires : 1,3,
Name: SWAP
Wires : 0,1,
Name: SWAP
Wires : 4,0,
[1.+0.j 0.+0.j 0.+0.j 0.+0.j 0.+0.j 0.+0.j 0.+0.j 0.+0.j 0.+0.j 0.+0.j
 0.+0.j 0.+0.j 0.+0.j 0.+0.j 0.+0.j 0.+0.j 0.+0.j 0.+0.j 0.+0.j 0.+0.j
 0.+0.j 0.+0.j 0.+0.j 0.+0.j 0.+0.j 0.+0.j 0.+0.j 0.+0.j 0.+0.j 0.+0.j
 0.+0.j 0.+0.j 0.+0.j 0.+0.j 0.+0.j 0.+0.j 0.+0.j 0.+0.j 0.+0.j 0.+0.j
 0.+0.j 0.+0.j 0.+0.j 0.+0.j 0.+0.j 0.+0.j 0.+0.j 0.+0.j 0.+0.j 0.+0.j
 0.+0.j 0.+0.j 0.+0.j 0.+0.j 0.+0.j 0.+0.j 0.+0.j 0.+0.j 0.+0.j 0.+0.j
 0.+0.j 0.+0.j 0.+0.j 0.+0.j]

@github-actions github-actions bot added the external PRs where the author is not a part of PennyLane Org (or part of external contributors team) label Oct 13, 2025
@ritu-thombre99
Copy link
Contributor Author

Updates:

  1. Fixed the initial wires permuted during routing by adding a sequence of SWAPs at the end here, which gets called before any measurement in runtime/lib/capi/RuntimeCAPI.cpp
  2. Added an integration test to verify all measurements are not affected by routing here

@ritu-thombre99
Copy link
Contributor Author

Need some help with how to write runtime tests for routing. Is it possible to use resource tracking with null.qubit to see what original operations were and what SWAPs are inserted at runtime?

@dime10
Copy link
Contributor

dime10 commented Oct 14, 2025

Need some help with how to write runtime tests for routing. Is it possible to use resource tracking with null.qubit to see what original operations were and what SWAPs are inserted at runtime?

Great idea, I think that should be possible! The resource tracking is quite good now

Thank you for providing the update btw, it will be exiting functionality to have in Catalyst!

Copy link
Member

@paul0403 paul0403 left a comment

Choose a reason for hiding this comment

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

Fantastic work 💯 🚀 This functionality will be very valuable for targeting hardware!

I haven't looked into the logic of the router, just left some initial thoughts and possible improvements on the overall code structure. But I think it looks really great!

wires_from_cmap = qml.wires.Wires(list(wires_from_cmap))
# check_device_wires(wires_from_cmap) not called
# since automatic qubit management
super().__init__(wires=wires_from_cmap, shots=original_device.shots)
Copy link
Member

Choose a reason for hiding this comment

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

core PL has deprecated setting shots on devices

Suggested change
super().__init__(wires=wires_from_cmap, shots=original_device.shots)
super().__init__(wires=wires_from_cmap)

Comment on lines +351 to +352
if original_device.wires is not None:
setattr(device_capabilities, "coupling_map", original_device.wires.labels)
Copy link
Member

Choose a reason for hiding this comment

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

A couple thoughts here:

  1. An immediate issue with this logic here is that it would interfere with the regular way of wires, i.e. qml.device("lightning.qubit", wires=3), because 3 is also not None. You could do the same check as above, i.e. check that each entry in the Wires object is a tuple, but...
  2. ... I'm not so sure about the UI. So I guess your UI is to take in the map through the wires ketword on the device? This only happens to work because in PennyLane, the device wires only need to be a list of hashable objects, which are then treated as labels. However in Catalyst we simplify this a bit, by letting the device wires kwarg to just mean "the number of wires this circuit uses". This UI breaks that assumption, and could cause difficulties in other places (for example, there's work going on about formalizing device capacity and qnode algorithm wires, which will likely change the device wire UI).

If the entire purpose is to just have a UI to take in the wire map from the user, I would suggest taking in it through its own dedicated decorator. The decorator would be on the device, and its entire purpose is just set the coupling map:

dev = qml.device("lightning.qubit", wires=3)
dev = catalyst.set_coupling_map(dev, [(0,1),(1,2),(2,3)])

@qjit
@qml.qnode(dev)
def circuit():
   ...

This way, your functionality is properly modulated. The set_coupling_map function could live in a separate file, e.g. routing.py, and that module would take care of, e.g. verifiying the map makes sense on the device, etc.

Implementation wise, this also prevents the need to piggy back the map on the capabilities (which I see (a) you need to pass into other functions now, and (b) has its own complicated song and dance already). Your function could just add the map to the device_kwargs of the PL device it takes in. This is because in extract_backend_info, at the end all PL device kwargs will be moved onto the qjit device:

qjit_device.py

def extract_backend_info(device: qml.devices.QubitDevice):
   device_kwargs = {}
   ...
    for k, v in getattr(device, "device_kwargs", {}).items():
        if k not in device_kwargs:  # pragma: no branch
            device_kwargs[k] = v
    return BackendInfo(...., device_kwargs)

Copy link
Member

Choose a reason for hiding this comment

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

Of course, a restriction of doing it through kwargs in the first place is that the coupling map must be static. However as a first draft we don't need to be concerned over this. The verification in routing.py can just check this as well.

Comment on lines +332 to +333
# check_device_wires(wires_from_cmap) not called
# since automatic qubit management
Copy link
Member

Choose a reason for hiding this comment

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

I don't think your functionality is restricted to automatic management. The number of wires used by the qnode is still known, it's just that there's some connectivity between them.

The action by your functionality is just inserting SWAP gates right? So it doesn't require allocation of new qubits. The IR would look like the IR with a known number of wires.

Comment on lines +273 to +284
// Extract coupling map from the kwargs passed
// If coupling map is provided then it takes in the form {...,'couplingMap' ((a,b),(b,c))}
// else {...,'couplingMap' (a,b,c)}
size_t start = args[2].find("coupling_map': ") + 15; // Find key and opening parenthesis
size_t end = args[2].find("}", start); // Find closing parenthesis
std::string coupling_map_str = std::string(args[2].substr(start, end - start));

if (coupling_map_str.find("((") != std::string::npos) {
RT_FAIL_IF(
!initRTDevicePtr(args[0], args[1], args[2], auto_qubit_management, coupling_map_str),
"Failed initialization of the backend device");
}
Copy link
Member

Choose a reason for hiding this comment

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

Can we do this parsing in the runtime device's parse_kwargs util, instead of here in the stub? This way the parsing functionality is all properly modulated in one place.

This way you also don't need to change the signature of a bunch of things to take the coupling map in (here in device init capi and in the RTDevice class in ExecutionContext.hpp).

Copy link
Member

Choose a reason for hiding this comment

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

Actually, I just realized the parse_kwargs is called by the specific devices, not at the RTDevice level, so maybe it would be a bit tricky 🤔

Maybe it would then make sense to make the parsing into its own separate util function in Routing.hpp? Like parse_coupling_map_from_kwargs or something, and just call it here?


namespace Catalyst::Runtime {

class RoutingPass final {
Copy link
Member

Choose a reason for hiding this comment

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

This isn't a pass since this is runtime : )

Comment on lines +239 to +240
if (coupling_map_str.find("((") != std::string::npos)
RUNTIME_ROUTER = std::make_unique<RoutingPass>(coupling_map_str);
Copy link
Member

Choose a reason for hiding this comment

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

Just a reminder that we always use braces, even if it's one-line : )

// Don't do this 
if (blah)
   thing;

// Do this
if (blah) {
   thing;
}

@mlxd I'm spreading the good word

Comment on lines +587 to +592
if (RTD_PTR != nullptr && RTD_PTR->getRuntimeRouter() != nullptr) {
QubitIdType mapped_wire =
RTD_PTR->getRuntimeRouter()->getMappedWire(reinterpret_cast<QubitIdType>(qubit));
getQuantumDevicePtr()->NamedOperation("PauliX", {}, {mapped_wire},
MODIFIERS_ARGS(modifiers));
}
Copy link
Member

Choose a reason for hiding this comment

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

So with this functionality, I feel like we will lock down the quantum dialect as a high-level "expression of intent"?

i.e. when quantum dialect says "perform CNOT on wires 0 and 2", it no longer maps to what's actually happening on the device. The runtime will change this to "perform SWAP on wires 0 and 1, then CNOT on wires 1 and 2", and send it to the device's NamedOperation.

I am not super concerned by this, but just want to point out this subtle lock down of semantics.

But I guess this isn't too much of an issue, since the entire PL is quite ambiguous already on whether it's high-level or low-level, even at the python layer.

cc @dime10 @mlxd

Comment on lines +601 to +606
if (RTD_PTR != nullptr && RTD_PTR->getRuntimeRouter() != nullptr) {
QubitIdType mapped_wire =
RTD_PTR->getRuntimeRouter()->getMappedWire(reinterpret_cast<QubitIdType>(qubit));
getQuantumDevicePtr()->NamedOperation("PauliY", {}, {mapped_wire},
MODIFIERS_ARGS(modifiers));
}
Copy link
Member

Choose a reason for hiding this comment

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

One thing though, there is a lot of duplicate code here for all the gates. Can you outline them into its own function/macro and put it in Routing.hpp? Again, just for better modularity.

Seems like one-qubit and two-qubit gates have different code, but I'll leave you to handle the details.

Comment on lines +1616 to +1623
if (RTD_PTR != nullptr && RTD_PTR->getRuntimeRouter() != nullptr) {
std::vector<std::tuple<QubitIdType, QubitIdType>> finalSwaps =
RTD_PTR->getRuntimeRouter()->getFinalPermuteSwaps();
for (auto i = 0; i < finalSwaps.size(); i++) {
RTD_PTR->getQuantumDevicePtr()->NamedOperation(
"SWAP", {}, {std::get<0>(finalSwaps[i]), std::get<1>(finalSwaps[i])});
}
}
Copy link
Member

Choose a reason for hiding this comment

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

I'm a bit worried about swapping back only at the terminal measurement.

I haven't looked into the logic of the router itself, but I assume the getFinalPermuteSwaps can properly deal with multiple swaps occurring in the circuit? In other words, there is not need to immediately swap back in each gate, and we're ok with only swapping back at the end when a user requests terminal readouts?

What about mid circuit measurements?

Comment on lines +1655 to +1662
if (RTD_PTR != nullptr && RTD_PTR->getRuntimeRouter() != nullptr) {
std::vector<std::tuple<QubitIdType, QubitIdType>> finalSwaps =
RTD_PTR->getRuntimeRouter()->getFinalPermuteSwaps();
for (auto i = 0; i < finalSwaps.size(); i++) {
RTD_PTR->getQuantumDevicePtr()->NamedOperation(
"SWAP", {}, {std::get<0>(finalSwaps[i]), std::get<1>(finalSwaps[i])});
}
}
Copy link
Member

Choose a reason for hiding this comment

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

Similarly, can you outline the terminal measurement's routing code into its own function as well?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

external PRs where the author is not a part of PennyLane Org (or part of external contributors team)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants