Skip to main contentIBM Quantum Documentation Preview

Introduction to primitives

Computing systems are built on multiple layers of abstraction. Abstractions allow you to focus on a particular level of detail relevant to the task at hand. The closer you get to the hardware, the lower the level of abstraction you need (for example, you might want to manipulate electrical signals), and vice versa. The more complex the task you want to perform, the higher-level the abstractions will be (for example, you could be using a programming library to perform algebraic calculations).

In this context, a primitive is the smallest processing instruction, the simplest building block from which one can create something useful for a given abstraction level.

The recent progress in quantum computing has increased the need to work at higher levels of abstraction. As we move toward larger QPUs (quantum processing units) and more complex workflows, the focus shifts from interacting with individual qubit signals to viewing quantum devices as systems that perform tasks we need.

The two most common tasks quantum computers are used for are sampling quantum states and calculating expectation values. These tasks motivated the design of the Qiskit primitives: Sampler and Estimator.

In short, the computational model introduced by the Qiskit primitives moves quantum programming one step closer to where classical programming is today, where the focus is less on the hardware details and more on the results you are trying to achieve.

Note

The Qiskit Runtime V1 primitives have been deprecated. For instructions to migrate to the V2 primitives, see the migration guide.


Implementation of Qiskit primitives

The Qiskit primitives are defined by open-source primitive base-classes, from which different providers can derive their own Sampler and Estimator implementations. Among the implementations using Qiskit, you can find reference primitive implementations for local simulation in the qiskit.primitives module. Providers like Qiskit Runtime enable access to appropriate QPUs through native implementations of their own primitives.

Important

To ensure faster and more efficient results, as of 1 March 2024, circuits and observables need to be transformed to only use instructions supported by the QPU (referred to as instruction set architecture (ISA) circuits and observables) before being submitted to the Qiskit Runtime primitives. See the transpilation documentation for instructions to transform circuits. Due to this change, the primitives will no longer perform layout or routing operations. Consequently, transpilation options referring to those tasks will no longer have any effect. By default, all V1 primitives optimize the input circuits. To bypass all optimization when using a V1 primitive, set optimization_level=0.

Exception: When you initialize the Qiskit Runtime Service with the Q-CTRL channel strategy (example below), abstract circuits are still supported.

service = QiskitRuntimeService(channel="ibm_cloud", channel_strategy="q-ctrl")

Benefits of Qiskit primitives

For Qiskit users, primitives let you write quantum code for a specific QPU without having to explicitly manage every detail. Also, because of the additional layer of abstraction, you might be able to more easily access advanced hardware capabilities of a given provider. For example, with Qiskit Runtime primitives, you can leverage the latest advancements in error mitigation and suppression by toggling options such as resilience_level, rather than building your own implementation of these techniques.

For hardware providers, implementing primitives natively means you can provide your users with a more “out-of-the-box” way to access your hardware features. It is therefore easier for your users to benefit from your hardware's best capabilities.


V2 primitives

Version 2 (available with qiskit-ibm-runtime 0.21.0 and later) is the first major interface change since the introduction of Qiskit Runtime primitives. Based on user feedback, the new version introduces the following major changes:

  • The new interface lets you specify a single circuit and multiple observables (if using Estimator) and parameter value sets for that circuit.
  • You can turn on or off individual error mitigation and suppression methods.
  • You can choose to get counts or per-shot measurements instead of quasi-probabilities from Sampler V2.
  • Due to scaling issues, Sampler V2 does not support specifying a resilience level, and Estimator V2 supports only levels 0 - 2.
  • To reduce the total execution time, V2 primitives only support ISA circuits. You can use the Qiskit transpiler (manually) or the AI-driven transpilation service (for Premium Plan users) to transform the circuits before making queries to the primitives.

See the EstimatorV2 API reference and SamplerV2 API reference for full details.

Interface changes

The updated interface uses a primitive unified bloc (PUB) for input. Each PUB is a tuple that contains a circuit and the data broadcasted to it. This greatly simplifies your ability to send complex data to a circuit. Previously, you had to specify the same circuit multiple times to match the size of the data to be combined. A summary of the changes to each primitive follows.

Estimator V1

Warning

EstimatorV1 has been deprecated in qiskit-ibm-runtime 0.23. Its support will be removed on 15 August 2024. EstimatorV2 is highly recommended instead.

  • Takes three parameters: circuits, observables, and parameter values.
  • Each parameter can be a single value or a list.
  • The length of the parameters must match.
  • Elements from each are aggregated.

Example:

estimator.run([circuit1, circuit2, ...],[observable1, observable2, ...],
  [param_values1, param_values2, ...] )

Estimator V2

  • The run() method takes an array of PUBs. Each PUB is in the format (<single circuit>, <one or more observables>, <optional one or more parameter values>, <optional precision>), where the optional parameter values can be a list or a single parameter.
  • Combines elements from observables and parameter values by following NumPy broadcasting rules as described below.
  • Each input PUB has a corresponding PubResult that contains both data and metadata.

Example:

estimator.run([(circuit1, observable1, param_values1),(circuit2, observable2, param_values2)])
Broadcasting rules

Estimator V2 aggregates elements from multiple arrays (observables and parameter values) by following the same broadcasting rules as NumPy. This section summarizes those rules. For a detailed explanation, see the NumPy broadcasting rules documentation.(opens in a new tab)

Rules:

  • Input arrays do not need to have the same number of dimensions.
    • The resulting array will have the same number of dimensions as the input array with the largest dimension.
    • The size of each dimension is the largest size of the corresponding dimension.
    • Missing dimensions are assumed to have size one.
  • Shape comparisons start with the rightmost dimension and continue to the left.
  • Two dimensions are compatible if their sizes are equal or if one of them is 1.

Examples of array pairs that broadcast:

A1     (1d array):      1
A2     (2d array):  3 x 5
Result (2d array):  3 x 5


A1     (3d array):  11 x 2 x 7
A2     (3d array):  11 x 1 x 7
Result (3d array):  11 x 2 x 7

Examples of array pairs that do not broadcast:

A1     (1d array):  5
A2     (1d array):  3

A1     (2d array):      2 x 1
A2     (3d array):  6 x 5 x 4 # This would work if the middle dimension were 2, but it is 5.

EstimatorV2 returns one expectation value estimate for each element of the broadcasted shape.

Here are some examples of common patterns expressed in terms of array broadcasting. Their accompanying visual representation is shown in the figure that follows:

# Broadcast single observable
 
parameter_values = np.random.uniform(size=(5,))  # shape (5,)
observables = SparsePauliOp("ZZZ")  # shape ()
>> pub result has shape (5,)
 
# Zip
parameter_values = np.random.uniform(size=(5,))  # shape (5,)
observables = [SparsePauliOp(pauli) for pauli in ["III", "XXX", "YYY", "ZZZ", "XYZ"]]  # shape (5,)
>> pub result has shape (5,)
 
# Outer/Product
parameter_values = np.random.uniform(size=(1, 6))  # shape (1, 6)
observables = [[SparsePauliOp(pauli)] for pauli in ["III", "XXX", "YYY", "ZZZ"]]  # shape (4, 1)
>> pub result has shape (4, 6)
 
# Standard nd generalization
parameter_values = np.random.uniform(size=(3, 6))  # shape (3, 6)
observables = [
    [[SparsePauliOp(['XII'])], [SparsePauliOp(['IXI'])], [SparsePauliOp(['IIX'])]],
    [[SparsePauliOp(['ZII'])], [SparsePauliOp(['IZI'])], [SparsePauliOp(['IIZ'])]]
]  # shape (2, 3, 1)
>> pub result has shape (2, 3, 6)
Parameter value sets are represented by n x m arrays, and observable arrays are represented by one or more single-column arrays. For each example in the previous code, the parameter value sets are combined with their observable array to create the resulting EV estimates.  Example 1 (broadcast single observable) has a parameter value set that is a 5x1 array and a 1x1 observables array.  The one item in the observables array is combined with each item in the parameter value set to create a single 5x1 array where each item is a combination of the original item in the parameter value set with the item in the observables array.  Example 2 (zip) has a 5x1 parameter value set and a 5x1 observables array.  The output is a 5x1 array where each item is a combination of the nth item in the parameter value set with the nth item in the observables array.  Example 3 (outer/product) has a 1x6 parameter value set and a 4x1 observables array.  Their combination results in a 4x6 array that is created by combining each item in the parameter value set with every item in the observables array.  Thus, each parameter value becomes an entire column in the output.  Example 4 (Standard nd generalization) has a 3x6 parameter value set array and two 3x1 observables array.  They combine to create two 3x6 output arrays in a similar manner to the previous example.
Visual representation of broadcasting
SparsePauliOp

Each SparsePauliOp counts as a single element in this context, regardless of the number of Paulis contained in the SparsePauliOp. Thus, for the purpose of these broadcasting rules, all of the following elements have the same shape:

a = SparsePauliOp("Z") # shape ()
b = SparsePauliOp("IIIIZXYIZ") # shape ()
c = SparsePauliOp.from_list(["XX", "XY", "IZ"]) # shape ()

The following lists of operators, while equivalent in terms of information contained, have different shapes:

list1 = SparsePauliOp.from_list(["XX", "XY", "IZ"]) # shape ()
list2 = [SparsePauliOp("XX"), SparsePauliOp("XY"), SparsePauliOp("IZ")] # shape (3, )

Sampler V1

Warning

SamplerV1 has been deprecated in qiskit-ibm-runtime 0.23. Its support will be removed on 15 August 2024. SamplerV2 is highly recommended instead.

  • Takes two parameters: circuits and parameter values.
  • Each parameter can be a single value or a list.
  • The length of the parameters must match.
  • Elements from each are aggregated.

Example:

sampler.run([circuit1, circuit2, ...],[observable1, observable2, ...],[param_values1, param_values2, ...] )

Sampler V2

  • Takes one parameter: PUBs in the format (<single circuit>, <optional one or more parameter values>, <optional shots>), where there can be multiple parameter values items, and each item can be either an array or a single parameter, depending on the chosen circuit.
  • Elements from each are aggregated. For example, each array of parameter values in the PUB is applied to the PUB's circuit.
  • Obeys program outputs. Typically this is a bit array but can also be an array of complex numbers (measurement level 1).
  • Returns raw data type. Data from each shot is returned (analogous to memory=True in the backend.run interface), and post-processing is done by using convenience methods.
  • Output data is grouped by output registers. You need the classical register name to get the results. By default, it is named meas if you use measure_all(). You can find the classical register name by running <circuit_name>.cregs. For example, qc.cregs.
  • Supports circuits with classical feedforward and control flow (dynamic circuits).
    Note

    Dynamic circuits do not support dynamical decoupling.

Example:

sampler.run([
  (circuit1, param_values1, shots1),
  (circuit2, param_values2, shots_2),
])

Estimator

The Estimator behaves differently, depending on what version you are using.

Estimator V2 computes expectation values of observables with respect to states prepared by quantum circuits. It receives one or more PUBs as the inputs and returns the computed expectation values per pair, along with their standard error, in PubResult form. Different Estimator implementations support various configuration options. The circuits can be parametrized, as long as the parameter values are also provided as input to the primitive.


Sampler

The Sampler behaves differently, depending on what version you are using.

Sampler V2 is simplified to focus on its core task of sampling the output register from execution of quantum circuits. It returns the samples, whose type is defined by the program, without weights and therefore does not support resilience levels. The result class, however, has methods to return weighted samples, such as counts.

It receives one or more PUBs as the inputs and returns counts or per-shot measurements, as PubResults. The circuits can be parametrized, as long as the parameter values are also provided as input to the primitive.


How to use Qiskit primitives

The qiskit.primitives module enables the development of primitive-style quantum programs and was specifically designed to simplify switching between different types of quantum compute resources. The module provides three separate classes for each primitive type:

  1. StatevectorSampler and StatevectorEstimator

These classes are reference implementations of both primitives and use the simulator built in to Qiskit. They leverage the Qiskit quantum_info module in the background, producing results based on ideal statevector simulations.

  1. BaseSampler and BaseEstimator

These are abstract base classes that define a common interface for implementing primitives. All other classes in the qiskit.primitives module inherit from these base classes, and developers should use these if they are interested in developing their own primitives-based execution model for a specific provider. These classes may also be useful for those who want to do highly customized processing and find the existing primitives implementations too simple for their needs.

  1. BackendSampler and BackendEstimator

If a provider does not support primitives natively, you can use these classes to “wrap” any quantum computing resource into a primitive. Users can write primitive-style code for providers that don’t yet have a primitives-based interface. These classes can be used just like the regular Sampler and Estimator, except they should be initialized with an additional backend argument for selecting which quantum computer to run on.

The Qiskit Runtime primitives provide a more sophisticated implementation (for example, by including error mitigation) as a cloud-based service.


Next steps

Recommendations