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.
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.
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
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 optionalparameter 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)
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
- 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 multipleparameter 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 thebackend.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 usemeasure_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.
The Estimator primitive computes expectation values of observables with respect to states prepared by quantum circuits. The Estimator receives circuit-observable pairs (with the observable expressed as a weighted sum of Pauli operators) as inputs, and returns the computed expectation values per pair, as well as their variances. 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 PubResult
s. The circuits
can be parametrized, as long as the parameter values are also provided as input to the primitive.
The Sampler primitive samples from the classical output registers resulting from execution of quantum circuits. For this reason, the inputs to the Sampler are (parametrized) quantum circuits, for which it returns the corresponding quasi-probability distributions of sampled bitstrings. Quasi-probability distributions are similar to regular probabilities, except they might include negative values, which can occur when using certain error mitigation techniques.
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:
StatevectorSampler
andStatevectorEstimator
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.
BaseSampler
andBaseEstimator
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.
BackendSampler
andBackendEstimator
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
- Read Get started with primitives to implement primitives in your work.
- Review detailed primitives examples.
- Read Migrate to V2 primitives.
- Practice with primitives by working through the Cost function lesson(opens in a new tab) in IBM Quantum™ Learning.