# SETUP (common to all tutorials)
import fermioniq
client = fermioniq.Client() # Initialize the client (assumes environment variables are set)

import rich # Module for pretty printing
import nest_asyncio # Necessary only for Jupyter, can be omitted in .py files

10) Batch jobs#

It is possible to submit multiple circuits simultaneously. How this works in practice is explained below.

Afterwards, we’ll discuss a benchmarking method called ‘Schrödingers Microscope’ that makes use of batch jobs.

Running a batch job is as simple as providing a list of circuits to EmulatorJob.

If a single config is provided, it will be used for all circuits in the list. Alternatively, a separate config can be provided for every individual circuit (in which case the list of circuits and the list of configs should be the same length).

For large batches of smaller jobs (say, more than 100), we have a specialized remote config backend for the task: ‘cpu-batch’. Try using this to see if it helps to speed up batch jobs. CURRENTLY UNAVAILABLE – will be added back in shortly.

from qiskit import QuantumCircuit
import numpy as np

def qiskit_bell_pair():
    circuit = QuantumCircuit(2)
    circuit.h(0)
    circuit.cx(0, 1)
    return circuit

# Create 10 qiskit circuits
all_circuits = [qiskit_bell_pair() for _ in range(10)]

# Create a single config
config = standard_config(all_circuits[0])

# Run all circuits using the same config
emulator_job = fermioniq.EmulatorJob(circuit=all_circuits, config=config)
# (Alternatively, a list of configs can be provided as the config keyword argument)

# Run batch job
output = client.schedule_and_wait(emulator_job)
rich.print(output)

When running a batch job, the output is a list of outputs, each containing a circuit_number corresponding to the circuit that was emulated.

When used in combination with the dmrg init_mode = lowerD, then each output will also have a run_number corresponding to the bond dimension in the list of bond dimensions used (see Tutorial 3 for details).


Schrödinger’s Microscope

For this tutorial we’ll play around with some slightly larger circuits, and with sending batch jobs to the emulator. We’ll use some benchmark circuits from “Scalable Benchmarks for Gate-Based Quantum Computers” by Arjan Cornelissen, Johannes Bausch, and András Gilyén.

Full details can be found in the paper, but briefly: the benchmark circuit work by preparing quantum states that encode complex numbers from the Riemann sphere \(\mathbb{C} \cup \{\infty\}\), and then performs various Mobius transformations on them. The qubits can then be measured and the resulting probability distributions compared to the ideal ones, which can be computed analytically. The probability distributions can also be plotted, and visually compared, which gives a nice way to percieve the difference in performance between different pieces of quantum hardware, or – in our case – quantum emulators.

The first circuit we’ll consider is “Schrödinger’s Microscope”. This performs, recursively, the following transformation:

\(F : z \mapsto \frac{z^2 + i}{iz^2 + 1}\).

So the ‘\(n\)th level’ of Schrödinger’s Microscope is the transformation

\(F^{\circ n} = F \circ \cdots \circ F\).

We encode complex numbers inside quantum states as

\(|\psi_z\rangle = \frac{z|0\rangle + |1\rangle}{\sqrt{|z|^2 + 1}}\), and \(|\psi_\infty\rangle = |0\rangle\).

The \(n\) th level can be implemented by the quantum circuit below, which takes as input a complex number \(z\) (because it needs to encode this as \(|\psi_z\rangle\)), and a level.

def schrodingers_microscope(z: complex, level=2):
    """
    Schrödinger's Microscope circuit, from https://arxiv.org/abs/2104.10698
    """

    def _prep(circuit: QuantumCircuit, z: complex):
        for i in range(len(circuit.qubits)):
            prep_unitary = (1 / np.sqrt(np.abs(z) ** 2 + 1)) * np.array(
                [[z, 1], [1, -np.conj(z)]]
            )
            circuit.unitary(prep_unitary, [i], label="|enc(z)>")

    def _mobius(circuit: QuantumCircuit, qa: int, qb: int):
        circuit.cx(qa, qb)
        circuit.s(qa)
        circuit.h(qa)
        circuit.s(qa)

    n_qubits = 2**level  # No. qubits is 2^level
    circuit = QuantumCircuit(n_qubits)

    _prep(circuit, z)  # Prepare the intial state

    for l in range(level):
        step = 2**l
        for i in range(0, n_qubits, 2 * step):
            _mobius(circuit, i, i + step)

    return circuit
import numpy as np
z = 0.5 + 0.5j
level = 2

circuit = schrodingers_microscope(z, level=level)

print(circuit)

The probability of interest for us will be the quantity

\(p^{(n)}(z) = \frac{1}{|F^{\circ n}|^2 + 1}\).

We will plot the distribution over the complex plane. For example, below we do this (using analytically computed values) with the real and imaginary parts ranging from -1.5 to 1.5.

def get_correct_probabilities(
    min_val: int, max_val: int, n_pixels: int, level: int = 2
) -> np.ndarray:
  """Given a minimum and maximum value for the real and imaginary parts, and a number
  of pixels, returns 2d array containing the correct probabilities for each complex number.

  This constitutes the 'target'.

  Parameters
  ----------
  min_val
    Minimum value
  max_val
    Maximum value
  n_pixels
    Number of pixels in image
  level
    The level of Schrodinger's microscope to implement (see https://arxiv.org/abs/2104.10698).

  Returns
  -------
  probs
    Array of true probabilities.
  """

  def f(_z, level):
    if level > 1:
      _z = f(_z, level - 1)
    return (_z**2 + 1j) / (1j * _z**2 + 1)

  x_range = y_range = np.linspace(min_val, max_val, n_pixels)

  probs = np.zeros((len(x_range), len(y_range)))
  for x, z_real in enumerate(x_range):
    for y, z_imag in enumerate(y_range):
      z = z_real + 1.0j * z_imag
      probs[x, y] = 1 / (np.abs(f(z, level)) ** 2 + 1)
  return probs


def plot_probabilities(probs: np.ndarray):
    import matplotlib.pyplot as plt

    x = np.arange(probs.shape[0])
    y = np.arange(probs.shape[1])
    plt.pcolormesh(x, y, probs, shading="auto", cmap=plt.cm.Greys)
    plt.show()
probs = get_correct_probabilities(-1.5, 1.5, n_pixels=500, level=level)
plot_probabilities(probs)

The probability \(p^{(n)}(z)\) is also given by the probability of measuring \(|1\rangle\) on the first qubit in the circuit, conditioned on all other qubits being in the all-zero state (read the paper for why :) ).

We can obtain the same probability distribution by running the quantum circuit, and estimating this probability for each point on the complex plane. This can be done via emulation, or by running the circuit on a real quantum device (or both).

We’ll run the circuit on the fermioniq emulator. For convenience, we made a utility function generate_schrodingers_microscope_circuits() that will generate all the circuits (i.e. for all points on the complex plane) for you.

def generate_schrodingers_microscope_circuits(
    min_val: int, max_val: int, n_pixels: int, level: int = 2
) -> list[QuantumCircuit]:
  """Given a minimum and maximum value for the real and imaginary parts, and a number
  of pixels, returns a list of circuits (one for each pixel).

  Parameters
  ----------
  min_val
    Minimum value
  max_val
    Maximum value
  n_pixels
    Number of pixels in image
  level
    The level of Schrodinger's microscope to implement (see https://arxiv.org/abs/2104.10698).

  Returns
  -------
  circuits
    List of circuits for each pixel / point in the complex plane.
  """
  x_range = y_range = np.linspace(min_val, max_val, n_pixels)

  circuits = []
  for z_real in x_range:
    for z_imag in y_range:
      z = z_real + 1.0j * z_imag
      circuit = schrodingers_microscope(z, level=level)
      circuits.append(circuit)
  return circuits
n_pixels = 7  # We recommend to set to at most 40

# Each pixels is a circuit, so 20 x 20 pixels is already 400 circuits
all_circuits = generate_schrodingers_microscope_circuits(-1.5, 1.5, n_pixels=n_pixels, level=level)

# Save this for later
n_qubits = len(all_circuits[0].qubits)

Now we can run the emulation. Note that it might take a little time to send all circuits across, and then to retrieve the results!

To make things run a little faster, we’ll use the ‘cpu-batch’ remote config backend.

# We'll override the output config for this emulation
# We don't need any samples, but we do want to compute the amplitudes on the basis states
# 10...0 and 00...0
state_00__0 = "0" * n_qubits
state_10__0 = "1" + "0" * (n_qubits-1)
basis_states = [state_00__0, state_10__0]

# Generate config
config = standard_config(all_circuits[0])

output_config_update = {
    "amplitudes": {
        "enabled": True,
        "bitstrings": basis_states,
    },
    "sampling": {
      "enabled": False
    }
}

dmrg_config_update = {
    "D": 2,
}
config["output"].update(output_config_update)
config["dmrg"].update(dmrg_config_update)

emulator_job = fermioniq.EmulatorJob(circuit=all_circuits, config=config)

output = client.schedule_and_wait(emulator_job)

We can compute the quantity \(p^{(n)}(z)\) by computing the probability of measuring \(|1\rangle\) on the first qubit conditioned on all other qubits having been measured as \(|0\rangle\). In other words,

\(p^{(n)}(z) = \frac{|\alpha_{10\dots 0}|^2}{|\alpha_{00\dots 0}|^2 + |\alpha_{10\dots 0}|^2}\),

where \(\alpha_{10\dots 0}\) and \(\alpha_{00\dots 0}\) are the amplitudes on the state \(|10\dots 0\rangle\) and \(|00\dots 0\rangle\), respectively.

We can compute this quantity for every circuit, which corresponds to a particular choice of \(z\).

import numpy as np

all_amplitudes = [output.amplitudes(circuit_number=i, run_number=0) for i in range(len(all_circuits))]

all_probs = []
for amplitudes in all_amplitudes:
    amp_0__00 = amplitudes[state_00__0]
    amp_0__01 = amplitudes[state_10__0]

    p = np.abs(amp_0__01)**2 / (np.abs(amp_0__00)**2 + np.abs(amp_0__01)**2)

    all_probs.append(p)

# Reshape the probabilities back into a grid
grid_probs = np.array(all_probs).reshape((n_pixels, n_pixels))

plot_probabilities(grid_probs)

The image looks nothing like what we expect. Try to increase the resolution (n_pixels) up to 40 to get a better image. Can you find out what setting needs to be changed to get an image that corresponds with the analytical result? (Something with entanglement…)