Transpiler handling across backends
alecandido opened this issue · 12 comments
The main problem is the following:
Circuits often have to be transpiled for hardware execution, but they often should not be transpiled for simulation.
Stated this way, it is clear that it is mainly concerning defaults, and thus UI.
This combines with the decision about what to do in the case in which the circuit spans a subset of the platform, and it is handled very different on hardware and simulation.
On hardware, the platform is fixed, and connectivity is a subset of possible edges In simulation, the circuit itself is establishing the number of qubits of the platform, and full connectivity is assumed.
This differentiates once more the two scenarios, requiring a different padding strategy (no padding in simulation, to avoid wasting resources, suitable padding on hardware).
Another relevant observation is that, even on hardware, two different transpilations could be interesting:
- transpile the padded circuit, making use of all connections
- just transpile the circuit, with the connections among involved qubits, and then pad
(note that 2. is always less efficient, and sometimes impossible, even when 1. is possible - but it could allow running multiple circuits in parallel on the same hardware)
It is clear that having multiple building blocks would allow composing them in the preferred way, but defaults are required
The goal is to find a unified strategy, to handle transpilation uniformly across different platforms, including user-friendly defaults, but decoupling the individual backends from the transpilation (and keeping all modules as independent as possible in general).
Proposal
Let's assume the qibo-core
scenario, in which the execution backend is a completely decoupled entity, possibly not relying on qibo
itself.
Thus, transpilation can not be accessed by the backend (neither simulation nor hardware nor anything), but it can be requested.
The manual solution is:
circuit = Circuit(...)
backend = Backend(...)
# transform
transpiler = Transpiler(...)
circuit_padded0 = Circuit(n0).extend(circuit, map=...)
transpiled = transpiler.transpile(circuit_padded0, backend.connectivity)
circuit_padded1 = Circuit(n1).extend(transpiled, map=...)
# execute
backend.execute(circuit_padded1)
If transpilation is not wanted, commenting it would be sufficient. Same for padding (only one of the padding at a time is meaningful, but I added the two options to show it can happen before or after transpilation).
However, in both cases some user input is required, since the transpiler parameters (or instance) and the mapping and position of padding have to be declared.
Let's distinguish two different kinds of automated users (since the manual one is already catered for).
Advanced intermediate user (e.g. Qibocal)
In the Qibocal case, you might have no control on the specific circuit
or backend
, since they could be more or less defined by the user (e.g. controlling the platform or the number of qubits). So, it should be possible to query the objects to understand whether and what transformation are required.
To make transpilation conditional to the backend, but without manually specifying which backends, we could define some default backend.defaults.transpilation: bool
and just add the branching.
A similar strategy could be adopted for the padding, with backend.defaults.padding: bool
or a three-valued option: BEFORE
, AFTER
, or NONE
.
The other parameters could be decided or inferred by Qibocal:
- the mapping is inferred by the qubits specified in the executor (or, at a higher level, in the runcard)
- which transpiler could be decided from the platform connectivity, and local routine choices
So, something like:
def ___(executor, circuit, backend):
transpiler = executor.transpiler
if backend.defaults.transpiler and transpiler is None:
transpiler = Transpiler(...)
if backend.defaults.padding is Padding.BEFORE:
circuit = Circuit(n0).extend(circuit, map=executor.qubits)
if transpiler is not None:
circuit = transpiler.transpile(circuit, backend.connectivity)
if backend.defaults.padding is Padding.BEFORE:
circuit = Circuit(n1).extend(transpiled, map=executor.qubits)
backend.execute(circuit)
Fully automated end user
In this case, the user should just define the circuit and pick a backend, but everything else should be managed internally (though partially overwritable).
Thus, we need an execute function:
execute(circuit, on=backend)
that will take care of everything.
This execute()
function may look similar to the one above for Qibolab, but with no executor
, thus a default transpiler should be defined for it, and a default mapping for padding (that might also depend on the backend, that could select some optimal qubits for restricted execution, but missing that info, it will always default to the first n).
In the present Qibo, circuits are executed with circuit()
, i.e. the .__call__()
method, without even specifying a backend.
This mechanism rely on the GlobalBackend
, thus all the features described for the execute()
function could be exposed to it as well.
In practice, only one between execute()
and GlobalBackend
is required to be implemented, the other one just calling it passing suitable options. And, at the moment, I would just use the GlobalBackend
, since it's the Qibo-way, adding a set_transpiler()
and things like that (though I'd reconsider this strategy, to limit the global state).
@csookim it seems I had already written the issue before (in April), and forgot it ^^
There is written everything I told you, even though is slightly more general. Please, ignore the Qibocal part (instead, you will have to face the padding anyhow, so feel free to comment the proposal above).
Rereading it, I believe I could have been a bit more careful in the interface part.
We definitely need an execute()
function, but it should read more like:
execute(circuit, on=backend, transpiler=transpiler)
i.e. there should be nothing implicit in this function, and though we can have an extremely basic default transpiler, i.e. the identity, something like an instance of:
class Nop(Transpiler):
def __call__(self, c: Circuit) -> Circuit:
return c
despite it causing failures for non-trivial platforms. While we can't afford a similar trivial backend, since execution can't be trivial.
Instead, some more involved defaults can be set in the GlobalBackend
, in the same way now the default backend is set in there:
qibo/src/qibo/backends/__init__.py
Lines 69 to 75 in 540d2b6
and replace
._instance
with the ._backend
and ._transpiler
attributes.@alecandido Thanks for your clarification. I’ve read your proposal and would like to write a short proposal based on my idea as well. Please feel free to share any comments.
Assumption
Suppose we have a Transpiler
class that transpiles a given circuit.
Currently, we need to define the Passes
object and run it on the circuit.
custom_pipeline = Passes([Placer, ... , Router, ... , Unroller])
transpiled_circuit, _ = custom_pipeline(circuit)
The Transpiler
will have the functionality to set the passes and hardware constraints.
custom_pipeline = Passes([Placer, ... , Router, ... , Unroller])
tp = Transpiler()
# Load platform data into the transpiler and set the connectivity and fidelity information
tp.load_platform_config(platform)
# Set connectivity for transpilation
tp.set_connectivity(connectivity)
# Set passes
tp.set_passes(custom_pipeline)
...
# Transpilation
tp.transpile(circuit)
Implementation of Your Proposal
Based on your proposal, we can add the variable self.transpiler
as a member of the Backend
class and use it across different backends.
qibo/src/qibo/backends/abstract.py
Line 6 in 3d87ba7
class Backend(abc.ABC):
def __init__(self):
super().__init__()
self.name = "backend"
self.platform = None
self.transpiler = None # transpiler
And we can pass the transpiler using construct_backend
.
qibo/src/qibo/backends/__init__.py
Line 85 in 3d87ba7
cls._instance = construct_backend(backend, platform=platform, transpiler=transpiler)
However, this approach requires different implementations of transpilation for simulation backends and hardware backends. We would need to modify the execute_circuit
method of each Backend
to include transpilation. Instead of this, I suggest transpiling the circuit before calling execute_circuit
and then passing the fully-transpiled circuit to execute_circuit
.
Proposal
- Transpilation is done before calling
execute_circuit
. - The fully-transpiled circuit is passed into
execute_circuit
.
1. Circuit Execution
Circuit execution is done using:
execute(circuit, on=backend, transpiler=transpiler)
2. Transpilation in Circuit.execute()
Transpilation logic depends on the backend:
-
For simulation backends:
- If a custom transpiler is defined, do the transpilation.
- If no custom transpiler is defined, no transpilation is done.
-
For hardware backends:
- If a custom transpiler is defined, do the transpilation.
- If no custom transpiler is defined, do the default transpilation.
qibo/src/qibo/models/circuit.py
Line 1082 in 3d87ba7
def execute(self, initial_state=None, nshots=1000, transpiler=None):
...
from qibo.backends import GlobalBackend
### modified
gbackend = GlobalBackend()
if gbackend.sim == True:
if transpiler != None: # If a custom transpiler is defined
transpiler.transpile(self)
else:
if transpiler != None: # If a custom transpiler is defined
transpiler.transpile(self)
else: # If a custom transpiler is not defined
default_transpiler = Transpiler()
# Default transpiler using hardware configuration (connectivity, native gates, etc.)
default_transpiler.load_platform_config(gbackend.platform)
default_transpiler.transpile(self)
###
if self.accelerators: # pragma: no cover
return gbackend.execute_distributed_circuit(
self, initial_state, nshots
)
else:
return gbackend.execute_circuit(self, initial_state, nshots)
3. Simulation Flag in Backend
To enable the transpiling logic, a simulation flag is added to the Backend
class. This flag is set to True
for simulation backends (numpy, qibojit, etc.).
class Backend(abc.ABC):
def __init__(self):
super().__init__()
self.name = "backend"
self.platform = None
self.sim = None # flag
Sorry for the delay in the reply.
Suppose we have a
Transpiler
class that transpiles a given circuit.Currently, we need to define the
Passes
object and run it on the circuit.
The Transpiler
could be Passes
. They are just performing the same collection task.
Based on your proposal, we can add the variable
self.transpiler
as a member of theBackend
class and use it across different backends.
Not the Backend
, but the GlobalBackend
.
qibo/src/qibo/backends/__init__.py
Line 64 in 3d87ba7
However, this approach requires different implementations of transpilation for simulation backends and hardware backends. We would need to modify the
execute_circuit
method of eachBackend
to include transpilation. Instead of this, I suggest transpiling the circuit before callingexecute_circuit
and then passing the fully-transpiled circuit toexecute_circuit
.
And this is the exact reason to use the GlobalBackend
. Which is not really a backend itself (not sure why is subclassing the NumpyBackend
, but I'm pretty sure you can remove it, and it would keep working).
1. Circuit Execution
Circuit execution is done using:
This I agree with. But it's exactly what I wrote above :)
2. Transpilation in
Circuit.execute()
Transpilation logic depends on the backend:
Circuite.execute()
should make use of the execute()
function at the former step, simply it should use the backend and transpiler provided by the GlobalBackend
(unless explicitly passed - but I would not support this feature, because there will be an execute(circuit, ...)
function for that).
3. Simulation Flag in
Backend
To enable the transpiling logic, a simulation flag is added to the
Backend
class. This flag is set toTrue
for simulation backends (numpy, qibojit, etc.).
I would avoid a simulation flag, and instead expose a .qubits
, .topology
/.connectivity
, and .natives
as properties, which should be such that they are always interpreted as full connectivity by the transpilers.
This would have the advantage of making no distinction between simulation and hardware. Because from the point of view of the transpiler there isn't: simulation is just the same as a platform with all the qubits you want, full connectivity, and all gates as natives. Unless explicitly restricted.
Notice that the properties don't have to be necessarily properties, they could be implemented even as functions to check belonging, e.g.
class Backend:
def __contains__(self, element: Union[QubitId, QubitPairId, Gate]) -> bool:
...
and the implementation for simulation, without anything specified, should be just return True
.
However, which is the most practical alternative depends on how it will be consumed by the transpiler. Both options are valid, and there may be further ones. The importance is conveying this information.
Even non-trivial transpilers should be trivial when applied to full-connectivity all-native (unless they are tuned for a specific set of natives, in which case it is just wrong to use them in simulation, unless you really want to restrict to those natives).
@alecandido Thank you for your reply. I’d like to confirm my understanding. Please let me know if I’ve misunderstood any part of your approach.
GlobalBackend
refers to the hardware backend.- Transpilation is done using the platform data from
GlobalBackend
. - In
GlobalBackend
, we can include both QPU configuration and the transpilation process. - Using the transpiler in the simulation backend should not be supported.
The pseudocode for GlobalBackend
might look like this:
# execute the circuit in main
c.execute(initial_state=None, nshots=1000, transpiler=None)
class Circuit:
...
def execute(self, initial_state=None, nshots=1000, transpiler=None):
...
return GlobalBackend().execute_circuit(self, initial_state, nshots, transpiler)
class QibolabBackend(GlobalBackend):
...
def execute_circuit(self, circuit, initial_state=None, nshots=1000, transpiler):
transpiled_circuit = super().transpile(circuit, transpiler, self.platform)
...
class GlobalBackend(Backend):
...
def transpile(self, circuit, transpiler: Passes, platform: Platform):
connectivity = platform. ...
qubits = platform. ...
native_gates = platform. ...
if the transpiler settings do not match the platform:
raise error
if transpiler is None:
transpiler_pass = ... # default transpiler
transpiled_circuit, _ = transpiler_pass(circuit)
return transpiled_circuit
- The
transpile
function is implemented inGlobalBackend
. - Platform information (such as connectivity, ...) is defined locally since it is only used within the
transpile
function. - The
execute_circuit
function performs transpilation usingsuper().transpile
.
Here are some topics that might be discussed in the future:
- Enabling transpilation in simulation backends:
- For example, optimal circuit simulation on a star (iqm5) layout.
- There may be cases where transpilation in simulations is needed.
- The interface for defining a transpilation pass:
- Currently, connectivity and native gates need to be specified in every Placer/Router/Unroller.
- Setting connectivity and native gates for a pass all at once would simplify the code.
GlobalBackend
refers to the hardware backend.
I believe there is a misunderstanding about what GlobalBackend
is, and it's supposed to do.
You can see it's main role here:
qibo/src/qibo/models/circuit.py
Lines 1082 to 1109 in 37e1010
(forget about
self.compile
, just look at the else
branch).
The point is that in:
from qibo import Circuit
c = Circuit(1)
c.add(...)
res = c()
the last instruction c()
(alias to c.execute()
), most of the times will just call:
GlobalBackend().execute_circuit(self, initial_state, nshots)
above (it could end up in the accelerator branch, or you may pass through compilation - but that doesn't change the substance, so just forget).
Let's call the class from now on just Global
(it could be Defaults
, GlobalDefaults
, ...).
The idea is the following:
class Global:
backend: Backend
transpiler: Transpiler
@classmethod
def execute(cls, circuit: Circuit):
# the following is the function discussed above: it can be implented as
# a standalone function and used here (which I personally like) or even
# be implemented here inline
return execute(circuit, on=cls.backend, transpiler=cls.transpiler)
@classmethod
def set_backend(cls, backend: str):
cls.backend = construct_backend(backend)
@classmethod
def set_transpiler(cls, transpiler: Transpiler):
cls.transpiler = transpiler
class Circuit:
def execute(self):
return Global.execute(self)
I simplified some details (no initial state, no shots, no backend platform, no set_backend
/set_transpiler
standalone functions) and refactored a bit (Global
is used as a singleton, that's why the attributes are class attributes, not instance ones - so we can even avoid instantiating, and just use a class method).
Backend
could be whatever valid backend. This is going to be used for both hardware and simulation.
- Enabling transpilation in simulation backends:
It is fine not to address the full problem immediately. But I'd suggest to just crash for unsupported situations, in whatever way is happening.
Instead, by default, Global.transpiler
should be an identity function, just not doing anything. If you want a non-trivial transpiler, you should use set_transpiler()
.
Then, we can plan more advanced features, e.g. registering transpilers by name (as we're doing with the backends), or associating different default transpilers per-backend (such that the set_backend()
function will also set the transpiler, if there is a default one associated, and it's still the default).
But I'd start from the simpler and more fundamental features, while always preserving the symmetry between simulation and hardware.
Notice that currently GlobalBackend
is a backend since it is returning GlobalBackend._instance
every time that you call GlobalBackend()
, through the __new__
function.
However, the only two places where it's used are the one above and:
qibo/src/qibo/models/error_mitigation.py
Line 1067 in 37e1010
and in both cases is used for the circuit execution (as it should).
https://github.com/search?q=repo%3Aqiboteam%2Fqibo+GlobalBackend+path%3A%2F%5Esrc%5C%2Fqibo%5C%2F%2F&type=code
So, we can abandon the GlobalBackend.__new__()
method, and resolve the backend instance just in Global.execute()
. Which would be the only thing called after GlobalBackend()
(the only other case is the distributed version, which we could still access passing a flag).
In any case, the global backend will still be accessible as Global.backend
, instead of GlobalBackend()
. To implement the default handling, now in __new__
, we can move it to a property:
class Global:
_backend: Backend
@classmethod
def backend(cls):
if cls._backend is not None:
return cls._backend
backend = os.environ.get("QIBO_BACKEND")
if backend: # pragma: no cover
# Create backend specified by user
platform = os.environ.get("QIBO_PLATFORM")
cls._backend = construct_backend(backend, platform=platform)
else:
# Create backend according to default order
for kwargs in cls._default_order:
try:
cls._backend = construct_backend(**kwargs)
break
except (ModuleNotFoundError, ImportError):
pass
if cls._backend is None:
raise RuntimeError(...)
return cls._backend
and access it as Global.backend()
.
(Unfortunately the option of having a classmethod property has been added in py3.9, and removed again in py3.11 - so we have to choose between Global.backend()
and Global().backend
, and I prefer the former)
@alecandido Thank you for your response. I’d like to confirm my understanding again:
Global
is a singleton object that contains the execution settings (backend
,transpiler
).Global._backend
is set usingGlobal.set_backend()
(provided) or usingGlobal.backend()
(not provided).Global._backend
is retrieved usingGlobal.backend()
.- The backend can be either a hardware or a simulation backend.
- I named the execution function in
Global
asexecute_global
for clarity, but it could be namedexecute
,execute_circuit
, or something else. - Transpilation occurs when
execute_global
is called.
class Global
class Global:
_backend: Backend
_transpiler: Passes
@classmethod
def backend(cls):
if cls._instance is not None:
return cls._backend
...
backend = os.environ.get("QIBO_BACKEND")
...
platform = os.environ.get("QIBO_PLATFORM")
cls._backend = construct_backend(backend, platform=platform)
...
return cls._backend
@classmethod
def set_backend(cls, backend, **kwargs):
...
cls._backend = construct_backend(backend, **kwargs)
@classmethod
def set_transpiler(cls, transpiler: Passes):
cls._transpiler = transpiler
@classmethod
def execute_global(cls, circuit: Circuit, initial_state=None, nshots=1000):
if cls._backend is None:
cls._backend = cls.backend()
platform = cls._backend.platform
connectivity, qubits, native_gates = platform. ...
if cls._transpiler is None:
cls._transpiler = default_transpiler(connectivity, ...)
elif the cls._transpiler settings do not match the platform:
raise error
transpiled_circuit, _ = cls._transpiler(circuit)
return cls._backend.execute_circuit(circuit, initial_state=None, nshots=1000)
Usage
# in main
qibo.set_backend("qibolab", platform="default_q0")
qibo.set_transpiler(pass)
...
c.execute(initial_state=None, nshots=1000)
# in circuit.py
class Circuit:
...
def execute(self, initial_state=None, nshots=1000):
...
return Global.execute_global(self, initial_state=None, nshots=1000)
# in error_mitigation.py
if backend is None: # pragma: no cover
backend = Global.backend()
...
circuit_result = Global.execute_global(circuit, nshots=nshots)
Yes, exactly, now we are on the same page!
I believe we can discuss further about the interface. Global
it will be anyhow internal (or, at least, now it is treated as such), we could consider prepending an underscore (i.e. _Global
) - we don't have yet an established scheme to define the public API (as it is a bit fuzzy as a concept in Python).
Currently, there are "aliases" functions that the user is supposed to use, instead of accessing them through Global
.
So, the interface will be rather something like:
def set_backend(backend, **kwargs):
Global.set_backend(backend, **kwargs)
Moreover, I have nothing against Global.execute_global()
name, that's internal anyhow. But, I'd still take into account the option of having an execute()
function standalone, and use execute_global()
to just resolve the backend and transpiler, then passing the control to execute()
for the actual execution (i.e. just the transpilation and backend execution calls).
Concerning connectivity
and qubits
, at the moment they are part of the platform only for Qibolab, while the other backends may not even have a "platform" concept.
I'm not sure I would impose the platform to everyone. The alternative is to expose qubits
and connectivity
directly from the backend instance. And another advantage of doing that is that we'll expose fewer concepts to the user (e.g. not the internal structure of the platform), making the interface simpler, and retaining more freedom internally.