-
Notifications
You must be signed in to change notification settings - Fork 57
Runtime router #2117
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
base: main
Are you sure you want to change the base?
Runtime router #2117
Conversation
…outing to a coupling_map
Need some help with how to write runtime tests for routing. Is it possible to use resource tracking with |
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! |
There was a problem hiding this 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) |
There was a problem hiding this comment.
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
super().__init__(wires=wires_from_cmap, shots=original_device.shots) | |
super().__init__(wires=wires_from_cmap) |
if original_device.wires is not None: | ||
setattr(device_capabilities, "coupling_map", original_device.wires.labels) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
A couple thoughts here:
- 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)
, because3
is also notNone
. You could do the same check as above, i.e. check that each entry in theWires
object is a tuple, but... - ... 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)
There was a problem hiding this comment.
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.
# check_device_wires(wires_from_cmap) not called | ||
# since automatic qubit management |
There was a problem hiding this comment.
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.
// 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"); | ||
} |
There was a problem hiding this comment.
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
).
There was a problem hiding this comment.
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 { |
There was a problem hiding this comment.
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 : )
if (coupling_map_str.find("((") != std::string::npos) | ||
RUNTIME_ROUTER = std::make_unique<RoutingPass>(coupling_map_str); |
There was a problem hiding this comment.
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
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)); | ||
} |
There was a problem hiding this comment.
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.
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)); | ||
} |
There was a problem hiding this comment.
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.
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])}); | ||
} | ||
} |
There was a problem hiding this comment.
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?
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])}); | ||
} | ||
} |
There was a problem hiding this comment.
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?
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:
Benefits:
Possible Drawbacks:
Related GitHub Issues:
Example usage:
Currently, seeing what SWAP gates are added can be viewed by using
null.qubit
done by printingNamedOperation
inruntime/lib/backend/null_qubit/NullQubit.hpp
Output: