# 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
nest_asyncio.apply()

2) Config & output settings#

The emulator has extensive configuration options. To get the most out of the emulator for very difficult circuits it will often be necessary to configure the emulator properly. However, for most circuits good results can be obtained by using the default options. The client can take care of this for you.

First let’s setup a circuit.

from qiskit import QuantumCircuit
circuit = QuantumCircuit(3)
circuit.h(0)
circuit.x(1)
circuit.cx(0, 1)
circuit.cx(1, 2)

print(circuit)

Standard config

Creating a job with fermioniq.EmulatorJob(circuit) will automatically generate config settings for that circuit.

To obtain these automatically generated config settings explicitly, you can use the standard_config() function:

from fermioniq.config.defaults import standard_config

# Obtain a standard config for this circuit
config = standard_config(circuit)

# Take a look
rich.print(config)

# Give this config alongside the circuit to create an emulator job
emulator_job = fermioniq.EmulatorJob(circuit=circuit, config=config)

You can modify any of the configuration options (within limitations) by giving a custom config to the EmulatorJob constructor. A good starting point is usually the standard configuration.

The standard_config() function takes 4 arguments:

  • circuit: This is the Cirq or Qiskit circuit you want to emulate.

  • effort: This parameter can be used to control how much computational effort is put into simulating the circuit by the emulator. It should be a number between 0 and 1. The default setting is 0.1 (10% ‘effort’), which should give good results on most small- to medium-sized circuits. A value of 1 will attempt a maximum effort emulation up to computational resource limitations.

  • noise: Set to True if this config is for a noisy emulation. There is also a standard_config_noisy() function for convenience.


Basic config settings

As can be seen above, standard_config() creates a dictionary with the fields:

  • mode

  • qubits

  • grouping

  • dmrg

  • noise

  • output

The fields mode, dmrg and grouping will be explained in Tutorial 3. Noise has to do with mixed state emulation, which is the topic of Tutorial 5.

The remainder of this tutorial focuses on the fields that need to be set to specify the input and output of the emulator, which are:

  • initial_state: Initial state of the emulator;

  • qubits: list of qubit objects;

  • output: dict of output options.


Initial state

This determines the initial state of the emulation.

The intial state is always a computational basis state, which can be specified by providing either:

  • an integer between 0 and 2^(num_qubits) - 1, or

  • a bitstring specifying the state of every qubit individually.

When not specified, initial_state is assumed to be the all-zero state.

from fermioniq.config.defaults import standard_config

# Obtain a standard config for this circuit
config = standard_config(circuit)

# Set initial state (integer)
config["initial_state"] = 0

# Set intial state (bitstring)
config["initial_state"] = "000"

Output settings

Perhaps the next thing you’d want to change is the output of the emulator. We support four types of output:

  • amplitudes: You can ask for the amplitudes of specific basis states, or the amplitudes of all basis states if the number of qubits is not too large (10 qubits or less).

  • sampling: You can ask for samples of basis states from the final state of the emulator, just like you’d get from a real quantum computer.

  • MPS: You can ask for the actual matrix product state (MPS) that is generated by the emulator. This is essentially a compressed version of the full statevector, and is the most general output you can receive. From here you can compute expectation values, amplitudes, or do your own sampling.

  • expectation_values: You can ask for expectation values of Qiskit SparsePauliOps or Cirq PauliSums or PauliStrings. Tutorial 8 explores this functionality in more detail.

To change what output is provided, you modify the "output" field of the config, which has the following structure (where the values within '<', '>' are for you to choose):

output_config = {"output": {
        "amplitudes": {
            "enabled": '<True / False>',
            "bitstrings": '<list of basis states (as strings or integers) / "all">',
        },

        "sampling": {
            "enabled": '<True / False>',
            "n_shots": '<integer>',
        },

        "mps":{
            "enabled": '<True / False>',
        },

        "expectation_values":{
            "enabled":  '<True / False>',
            "observables": '<dict of observables>'
        },
    }
}

For example, if you want to compute the amplitudes on the 011 and 010 states, and sample 1000 shots, you would do the following:

output_config = {"output": {
        "amplitudes": {
            "enabled": True,
            "bitstrings": [3, 2], # alternatively: ["011", "010"]
        },

        "sampling": {
            "enabled": True,
            "n_shots": 1000,
        },
    }
}

# Update the standard config generated earlier, and print it:
config = standard_config(circuit, effort=1)
config.update(output_config)
rich.print(config)


# Make and submit the job, and print the output
emulator_job = fermioniq.EmulatorJob(circuit=circuit, config=config)
result = client.schedule_and_wait(emulator_job)
rich.print(result)

Qubits

Now is a good time to demonstrate the meaning of the config setting qubits. This setting tells the emulator how to interpret bitstrings – i.e. what order the qubits should be taken to be in. The default is specific to the type of circuit that you are using. For Qiskit, the default ordering is the numerical order of the qubit objects. For Cirq, there is a default ordering obtained from Cirq itself (via the method cirq.QubitOrder.DEFAULT.order_for(...))

We can override this by setting the qubits parameter to a different order, e.g.

qubits = circuit.qubits

config = standard_config(circuit)
config.update({"qubits": [qubits[2], qubits[1], qubits[0]]})

# Run the circuit using these config settings
emulator_job = fermioniq.EmulatorJob(circuit=circuit, config=config)
result = client.schedule_and_wait(emulator_job)
rich.print(result)

As you can see, the order of bits in the the sampled bitstrings changes accordingly.


Output structure

The result of schedule_and_wait() (or schedule_async() – see Tutorial 4) is a dictionary with three keys: (A) job_outputs, (B) job_metadata, and (C) status_code.

(A) It is possible to submit several emulations within a single job. job_outputs is a list of dictionaries, where each dictionary in the list corresponds to the results of a single emulation. For each such result, the dictionary will have the entries:

  • circuit_number: integer specifying the circuit that was emulated (only 0 if the job contains one circuit, or an integer in case of a batch job – see Tutorial 8);

  • run_number: integer specifying a separate run of that same circuit (e.g. it is possible to emulate a single circuit for several values of the bond dimension, which will create outputs for each run – see Tutorial 3);

  • output: dict containing the actual emulation output (i.e. amplitudes, samples, mps or expectation_values);

  • config: the config used for the emulation;

  • metadata: dict containing additional emulation metadata for this run (explained in Tutorial 3).

(B) The job_metadata dictionary contains:

  • gpu_used: a boolean specifying if a GPU was used or not;

  • total_runs: total number of emulations performed;

  • total_runtime: total runtime of all emulations together.

(C) status_code is 0 when the job finished successfully, or -1 when the job is still running; any other value means an error occurred.