qiboteam/qibo

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:

  1. transpile the padded circuit, making use of all connections
  2. 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:

_default_order = [
{"backend": "qibojit", "platform": "cupy"},
{"backend": "qibojit", "platform": "numba"},
{"backend": "tensorflow"},
{"backend": "numpy"},
{"backend": "pytorch"},
]

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.

class Backend(abc.ABC):

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.

cls._instance = construct_backend(backend, platform=platform)

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.

def execute(self, initial_state=None, nshots=1000):

    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 the Backend class and use it across different backends.

Not the Backend, but the GlobalBackend.

class GlobalBackend(NumpyBackend):

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.

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 to True 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 in GlobalBackend.
  • Platform information (such as connectivity, ...) is defined locally since it is only used within the transpile function.
  • The execute_circuit function performs transpilation using super().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:

def execute(self, initial_state=None, nshots=1000):
"""Executes the circuit. Exact implementation depends on the backend.
Args:
initial_state (`np.ndarray` or :class:`qibo.models.circuit.Circuit`):
Initial configuration. It can be specified by the setting the state
vector using an array or a circuit. If ``None``, the initial state
is ``|000..00>``.
nshots (int): Number of shots.
Returns:
either a ``qibo.result.QuantumState``, ``qibo.result.MeasurementOutcomes``
or ``qibo.result.CircuitResult`` depending on the circuit's configuration.
"""
if self.compiled:
# pylint: disable=E1101
state = self.compiled.executor(initial_state, nshots)
self._final_state = self.compiled.result(state, nshots)
return self._final_state
else:
from qibo.backends import GlobalBackend
if self.accelerators: # pragma: no cover
return GlobalBackend().execute_distributed_circuit(
self, initial_state, nshots
)
else:
return GlobalBackend().execute_circuit(self, initial_state, nshots)

(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:

backend = GlobalBackend()

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 using Global.set_backend() (provided) or using Global.backend() (not provided).
  • Global._backend is retrieved using Global.backend().
  • The backend can be either a hardware or a simulation backend.
  • I named the execution function in Global as execute_global for clarity, but it could be named execute, 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.