You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
This epic tracks the progress of BackendV3, and documents how the various work items fit together. BackendV3 is a Rust-first iteration of the backend abstractions in qiskit.providers, with bindings to Python and C.
However, the scope of this particular epic is to only add the Rust code. Therefore, this epic contributes nothing to the public interface of Qiskit.
For simplicity, a sketch of the desired end-contract is provided against the Python bindings. The Rust implementation needs to work with these concerns, but also inform any necessary changes. Names below are for the most part preliminary and subject for debate.
BackendV3
This is the highest level of abstraction to be implemented by this epic, and so everything else in subsequent sections is a dependency.
One can see this class as a marriage of BackendV2 with the V2 primitives, EstimatorV2 and SamplerV2. Like BackendV2 (but unlike the V2 primitives), we want to tie constraints of a particular implementation/hardware provider to the API that executes. However, like the V2 primitives (but unlike BackendV2), we want a strong contract for the output types, and one that can be determined before execution. The abstractions described here are powerful enough to supersede both the estimator and the sampler, including their typical mitigation-heavy workflows.
classBackendV3(abc.ABC):
@property@abc.abstractmethoddeftarget(self) ->Target:
"""A target to specify constraints of any circuit contained in a submitted quantum program."""@property@abc.abstractmethoddefsupported_nodes(self) ->dict[str, set[str]]:
"""A map from runner names to glob patterns of quantum program nodes they support. Patterns are matched against the qualified name ``"{namespace}.{name}"`` of each node in the submitted program. Nodes whose qualified name matches a pattern for runner ``r`` will be contracted into a subprogram and sent to that runner. Example:: {"ibm_runtime": {"qiskit.circuit.*", "samplomatic.*"}} """@abc.abstractmethoddefexecute(self, program: QuantumProgram) ->Job:
"""Validate, partition, and execute a quantum program. Raises if validation returns errors. """defvalidate_program(self, program: QuantumProgram) ->list[str]:
"""Return a list of validation error messages, or an empty list if valid. The default implementation runs :func:`trace` and returns its errors. Subclasses may extend this with hardware-specific checks. """
supported_nodes determines how the program is partitioned before execution. Nodes whose qualified name matches a glob pattern are grouped by graph connectivity and each connected component is contracted into a QuantumProgram subnode sent to the corresponding runner via _execute_remote. Nodes that don't match any pattern are executed locally via call(). This design allows a program to contain both quantum (remote) and classical post-processing (local) nodes in the same graph.
Job
Note
this section is under-developed. I think we should also provide a reference Job that does graph contraction > against remotes for you, or something like that. I'm not sure how this ties to BackendV3, yet.
run() returns a Job, which orchestrates execution by walking the program's topological generations. Within each generation, remote subprograms are submitted concurrently; local ops are executed immediately via call(). Results are collected after each generation before proceeding to the next.
classJob:
defsubmit(self, inputs: DataTree[np.ndarray]) ->None:
"""Begin execution with the given program-level inputs."""defresult(self, timeout: float|None=None) ->DataTree[np.ndarray]:
"""Block until execution completes and return the program-level outputs."""
QuantumProgram
A quantum program is a DAG structure where every node represents some nodes that acts on a datatree of tensors. Being a subclass of ProgramNode provides native support for subprograms: any QuantumProgram can itself be used as a node inside another QuantumProgram.
# Specifies a traversal of a tree where every non-leaf node is keyed by an int or strTreePath=tuple[str|int, ...]
# Formal attachment point structs for ProgramNodes in a QuantumProgramInputLabel=NamedTuple("InputLabel", [("op_label", str), ("input_path", TreePath)])
OutputLabel=NamedTuple("OutputLabel", [("op_label", str), ("output_path", TreePath)])
# Convenience type aliases — allow ``"op.port"`` or ``("op", "port")`` shorthandOutputRef=OutputLabel|str|tuple[str|int, ...]
InputRef=InputLabel|str|tuple[str|int, ...]
classQuantumProgram(ProgramNode):
name="quantum_program"namespace="core.program"# Wire maps expose internal op ports as program-level I/Oinput_wire_map: dict[str, InputLabel]
output_wire_map: dict[str, OutputLabel]
# Derived from wire maps — not set directly@propertydefinput_types(self) ->DataTree[TensorType]: ...
@propertydefoutput_types(self) ->DataTree[TensorType]: ...
Builder API
Note
In the next three QuantumProgram subsections, I just blindly copied the interface from my private prototype
demo, without taking the time to think about exactly what needs to be exposed in an MVP.
defadd_op(self, label: str, op: ProgramNode) ->str:
"""Add a program node with a unique label. Returns the label."""defadd_edge(self, from_: OutputRef, to: InputRef) ->None:
"""Connect an output port to an input port."""defset_input(self, name: str, target: InputRef) ->None:
"""Expose an node's input port as a program-level input."""defset_output(self, name: str, source: OutputRef) ->None:
"""Expose an node's output port as a program-level output."""
Edge reference ergonomics. The OutputRef/InputRef types accept three notations, in increasing explicitness:
String shorthand:"op_label.port" — the first dot-separated segment is the op label; the rest is the port path (numeric segments become int). For a root (single-leaf) port, use just "op_label".
Tuple shorthand:("op_label", "port") or ("op_label", 0, "meas") — first element is the label, the rest form the path.
Three methods support the partitioning step inside BackendV3:
defsubprogram(self, labels: set[str], add_io: bool=True) ->QuantumProgram:
"""Extract a subset of ops as a new QuantumProgram. When ``add_io=True``, dangling edges (to ops outside ``labels``) are automatically wired as program-level inputs/outputs with names of the form ``"label.path"``. """defcontract(self, labels: set[str], contraction_label: str) ->None:
"""Replace a set of ops with a single nested quantum program node (mutates in place). External edges are rewired to point at the new contracted node. Raises ``DAGWouldCycle`` if the contraction would create a cycle. """defcopy(self) ->QuantumProgram:
"""Return a shallow copy with an independent graph structure."""
trace() is also available as a convenience method that calls tracing.trace(self).
ProgramNode
classProgramNode:
"""Base class for all nodes in a quantum program."""name: str"""Short name describing what this operation does, e.g. ``"add"`` or ``"shot_loop"``."""namespace: str"""Namespace grouping related node operations, e.g. ``"math"`` or ``"quantum"``. The qualified name ``"{namespace}.{name}"`` is used by ``BackendV3.supported_nodes`` for glob matching to determine which runner executes this node. """input_types: DataTree[TensorType]
"""Description of all inputs to the node."""output_types: DataTree[TensorType]
"""Description of all outputs from the node."""defcall(self, inputs: DataTree[np.ndarray]) ->DataTree[np.ndarray]:
"""Execute locally using numpy. Override when possible. Program nodes that require hardware (e.g. ``ShotLoop``) raise ``NotImplementedError``. """raiseNotImplementedError(f"ProgramNode {self.name!r} does not support local execution.")
The constants mechanism allows a quantum program node to inline fixed values without exposing them as free inputs. For example, Mul(y=Tensor(DType.F64, np.float64(-2.0))) constructs a "multiply by −2" node operation with a single free input x. The tracer reads constant types directly from the bound Tensor; Job reads the data at runtime without expecting a wired edge.
DataTree
DataTree[T] is a generic tree whose internal nodes are either dict (string-keyed) or list (int-keyed), and whose leaves are values of type T. It is used throughout to describe the structure of node ports, which can be richer than a flat name-to-type mapping.
TreePath=tuple[str|int, ...]
Paths are tuple[str | int, ...]. The helper parse_path normalizes string paths: "results.0.meas" becomes ("results", 0, "meas").
This implies certain limitations on string names.
# Constructiontree=DataTree({"x": TensorType(...), "y": TensorType(...)}) # dict-keyedtree=DataTree([TensorType(...), TensorType(...)]) # list-keyedtree=DataTree(TensorType(...)) # single leaf# Accesstree["x"] # leaf valuetree["results.0.meas"] # nested path (string shorthand)tree[("results", 0, "meas")] # nested path (tuple)
("results", 0, "meas") intree# containment checktree.is_leaf() # True if this node is a leaf# Iterationtree.leaves() # Iterator[tuple[TreePath, T]] — all (path, value) pairstree.paths() # Iterator[TreePath]tree.values() # Iterator[T]# Construction from flat listDataTree.from_leaves([(path, value), ...]) # reconstruct tree from (path, value) pairs
Rust note: Rust does not support open-ended generics across FFI boundaries. The Rust implementation may choose, for example, to expose two concrete types:
TypeTree — leaves are TensorType. Used for input_types, output_types.
DataTree — leaves are runtime tensor data. Used for constants, call() inputs/outputs, and program execution I/O.
Tensor, TensorType, and DType
DType
classDType(Enum):
F32F64BITC128U8U32U64I32I64
Each member maps to a numpy dtype via dtype.numpy_dtype, and can be recovered from a numpy dtype via DType.from_numpy(dtype).
DTypeVar and DTypePromotion
DTypeVar("T") # named dtype variable, resolved during tracingDTypePromotion(args=(DTypeVar("x"), DTypeVar("y"))) # resolves to numpy.result_type of its args
These are used in TensorType to define generic or polymorphic nodes. DTypeVar("T") acts as a placeholder: the first concrete dtype seen for "T" during tracing fixes it, and all subsequent occurrences must agree. DTypePromotion defers the output dtype to the numpy promotion rule applied to its arguments. Both are resolved at trace time and never appear in runtime data.
TensorType
Represents expected tensor data type and shape information, before anything has been executed.
This is used during program tracing to test the validity of a program.
shape entries can be concrete integers or named dimension strings (e.g. "n"). Named dimensions are resolved at trace time: if input x has TensorType(shape=("n",)) and receives a tensor of shape (5,), then n = 5. All occurrences of "n" within the same node must resolve to the same value.
broadcastable = True means the tensor may carry extra leading (extrinsic) dimensions beyond what shape specifies. These leading dimensions participate in numpy-style broadcasting across all broadcastable inputs of the same node. Non-broadcastable (broadcastable = False) inputs must match shape exactly.
Tensor
@dataclassclassTensor:
dtype: DTypedata: np.ndarray# coerced to the correct numpy dtype on construction@propertydefshape(self) ->tuple[int, ...]: ...
deftensor_type(self) ->TensorType: ...
Tensor represents a concrete value — used for constants and for runtime data passed into call().
Tracing
Tracing is a static validation pass that walks the program DAG topologically and resolves all type information before execution.
deftrace(program: QuantumProgram) ->TraceResult: ...
@dataclassclassTraceResult:
edge_types: dict[tuple[OutputLabel, InputLabel], TensorType]
"""Concrete resolved type for every edge in the graph."""output_types: dict[str, TensorType]
"""Concrete resolved type for every program-level output."""errors: list[str]
"""Validation error messages."""@propertydefok(self) ->bool:
"""True if there are no errors."""
At each node, tracing:
Collects incoming types from wired edges or program-level inputs.
Resolves named dimensions by comparing each port's TensorType.shape to the actual incoming shape. For broadcastable=True ports, named dims are matched against the trailing (intrinsic) suffix. Conflicting resolutions of the same name are errors.
Binds dtype variables (DTypeVar) from concrete incoming dtypes. Resolves DTypePromotion to a concrete DType.
Computes the broadcast shape from the leading (extrinsic) dimensions of all broadcastable=True inputs. Incompatible shapes are errors.
Propagates output types by resolving each output port's TensorType (substituting named dims and dtype variables, prepending the broadcast shape) and pushing the concrete type to downstream edges.
BackendV3.validate_program calls trace by default. QuantumProgram.trace() is a convenience wrapper.
Built-in ProgramNodes
math — locally executable
Most math nodes have broadcastable=True on all inputs and outputs.
The exact list of what needs to be here in an MVP is up for discussion.
ProgramNode
Inputs
Output dtype
Notes
Add
x, y
promoted
Binary; each arg is DType | DTypeVar | Tensor
Mul
x, y
promoted
Same
Div
x, y
promoted
Same
Pow
x, y
promoted
Same
BitwiseAnd
x, y (BIT)
BIT
Xor
x, y (BIT)
BIT
Parity
BIT, shape ("n",)
BIT, shape ()
XOR-reduce along last axis
Mean
F64, shape ("n",)
F64, shape ()
Mean along last axis
Concat
n list-indexed inputs
same dtype
Join along axis; shared DTypeVar enforces uniform type
Einsum
a, b (F64)
F64
Einstein summation; subscript is a construction-time constant
Binary op constructor pattern. Each operand of Add, Mul, Div, and Pow can be:
DTypeVar("T") (default) — a free, wireable, broadcastable scalar input with a dtype variable.
A concrete DType — a free input with a fixed dtype.
A Tensor — the value is bound as a constant; that operand disappears from free_input_types().
This enables concise partial application: Pow(x=Tensor(DType.F64, np.float64(-1.0))) produces a "take reciprocal" operation with a single free input y.
core — locally executable
ProgramNode
Inputs
Output
Notes
Store
none (free)
same as data
All data is bound as constants; used to inject fixed tensors into a graph
quantum — remote, possibly via a classical simulator
ProgramNode
Inputs
Output
Notes
ShotLoop
per-circuit parameter tensors (list-indexed)
per-circuit, per-register BIT tensors (list-of-dicts)
ShotLoop inputs are list-indexed by circuit index. Parameterless circuits get a zero-size constant for their parameter input, so they vanish from free_input_types(). Outputs are keyed as (circuit_idx, register_name).
samplomatic
ProgramNode
Inputs
Output
Notes
SamplexOp
derived from samplex interface
derived (leading named dims stripped)
Wraps a samplomatic.Samplex; supports dominant_shape for randomization axes
An alternative possibility is the introduction of enough program nodes into the namespace that would effictively allow "inlining" the entire samplex construct into a quantum program.
End-to-end Example
Computing <ZZ>, <XX>, <YY> expectation values for a parametric Bell-like state across a sweep of 50 phi values, using 4096 shots each:
The broadcast dimension (3, 50) flows from the params input all the way through to the output. Parity reduces the n-bit register to a scalar; Pow and Mean are scalar-shaped ops so they preserve all leading dimensions. Tracing validates this entire shape flow before any circuit is submitted.
Issues
Rust work:
graph TD
A["Implement DType, TensorType, DTypeVar, DTypePromotion"]
B["Implement DataTree"]
C["Implement Tensor"]
D["Implement ProgramNode trait"]
E["Implement QuantumProgram as a ProgramNode"]
F["Implement graph manipulation routines on QuantumProgram"]
G["Implement QuantumProgram tracing"]
H["Implement BackendV3 abstraction"]
I["Implement built-in math ProgramNodes"]
J["Implement ShotLoop ProgramNode"]
A-->C
A-->D
B-->D
C-->D
D-->E
E-->F
E-->G
F-->H
G-->H
D-->I
D-->J
click A "https://github.com/Qiskit/qiskit/issues/15990"
click B "https://github.com/Qiskit/qiskit/issues/15903"
click C "https://github.com/Qiskit/qiskit/issues/15992"
click D "https://github.com/Qiskit/qiskit/issues/16029"
click E "https://github.com/Qiskit/qiskit/issues/16030"
click F "https://github.com/Qiskit/qiskit/issues/16109"
click G "https://github.com/Qiskit/qiskit/issues/16110"
click H "https://github.com/Qiskit/qiskit/issues/16111"
click I "https://github.com/Qiskit/qiskit/issues/16031"
click J "https://github.com/Qiskit/qiskit/issues/16032"
This epic tracks the progress of BackendV3, and documents how the various work items fit together. BackendV3 is a Rust-first iteration of the backend abstractions in
qiskit.providers, with bindings to Python and C.However, the scope of this particular epic is to only add the Rust code. Therefore, this epic contributes nothing to the public interface of Qiskit.
For simplicity, a sketch of the desired end-contract is provided against the Python bindings. The Rust implementation needs to work with these concerns, but also inform any necessary changes. Names below are for the most part preliminary and subject for debate.
BackendV3
This is the highest level of abstraction to be implemented by this epic, and so everything else in subsequent sections is a dependency.
One can see this class as a marriage of
BackendV2with the V2 primitives,EstimatorV2andSamplerV2. LikeBackendV2(but unlike the V2 primitives), we want to tie constraints of a particular implementation/hardware provider to the API that executes. However, like the V2 primitives (but unlike BackendV2), we want a strong contract for the output types, and one that can be determined before execution. The abstractions described here are powerful enough to supersede both the estimator and the sampler, including their typical mitigation-heavy workflows.supported_nodesdetermines how the program is partitioned before execution. Nodes whose qualified name matches a glob pattern are grouped by graph connectivity and each connected component is contracted into aQuantumProgramsubnode sent to the corresponding runner via_execute_remote. Nodes that don't match any pattern are executed locally viacall(). This design allows a program to contain both quantum (remote) and classical post-processing (local) nodes in the same graph.Job
Note
this section is under-developed. I think we should also provide a reference Job that does graph contraction > against remotes for you, or something like that. I'm not sure how this ties to BackendV3, yet.
run()returns aJob, which orchestrates execution by walking the program's topological generations. Within each generation, remote subprograms are submitted concurrently; local ops are executed immediately viacall(). Results are collected after each generation before proceeding to the next.QuantumProgram
A quantum program is a DAG structure where every node represents some nodes that acts on a datatree of tensors. Being a subclass of
ProgramNodeprovides native support for subprograms: anyQuantumProgramcan itself be used as a node inside anotherQuantumProgram.Builder API
Note
In the next three
QuantumProgramsubsections, I just blindly copied the interface from my private prototypedemo, without taking the time to think about exactly what needs to be exposed in an MVP.
Edge reference ergonomics. The
OutputRef/InputReftypes accept three notations, in increasing explicitness:"op_label.port"— the first dot-separated segment is the op label; the rest is the port path (numeric segments becomeint). For a root (single-leaf) port, use just"op_label".("op_label", "port")or("op_label", 0, "meas")— first element is the label, the rest form the path.OutputLabel("op_label", ("port",))/InputLabel("op_label", (0, "meas")).All three forms are equivalent. Example program construction:
Graph Queries
Graph Manipulation
Three methods support the partitioning step inside
BackendV3:trace()is also available as a convenience method that callstracing.trace(self).ProgramNode
The
constantsmechanism allows a quantum program node to inline fixed values without exposing them as free inputs. For example,Mul(y=Tensor(DType.F64, np.float64(-2.0)))constructs a "multiply by −2" node operation with a single free inputx. The tracer reads constant types directly from the boundTensor;Jobreads the data at runtime without expecting a wired edge.DataTree
DataTree[T]is a generic tree whose internal nodes are eitherdict(string-keyed) orlist(int-keyed), and whose leaves are values of typeT. It is used throughout to describe the structure of node ports, which can be richer than a flat name-to-type mapping.Paths are
tuple[str | int, ...]. The helperparse_pathnormalizes string paths:"results.0.meas"becomes("results", 0, "meas").This implies certain limitations on string names.
Tensor, TensorType, and DType
DType
Each member maps to a numpy dtype via
dtype.numpy_dtype, and can be recovered from a numpy dtype viaDType.from_numpy(dtype).DTypeVar and DTypePromotion
These are used in
TensorTypeto define generic or polymorphic nodes.DTypeVar("T")acts as a placeholder: the first concrete dtype seen for"T"during tracing fixes it, and all subsequent occurrences must agree.DTypePromotiondefers the output dtype to the numpy promotion rule applied to its arguments. Both are resolved at trace time and never appear in runtime data.TensorType
Represents expected tensor data type and shape information, before anything has been executed.
This is used during program tracing to test the validity of a program.
shapeentries can be concrete integers or named dimension strings (e.g."n"). Named dimensions are resolved at trace time: if inputxhasTensorType(shape=("n",))and receives a tensor of shape(5,), thenn = 5. All occurrences of"n"within the same node must resolve to the same value.broadcastable = Truemeans the tensor may carry extra leading (extrinsic) dimensions beyond whatshapespecifies. These leading dimensions participate in numpy-style broadcasting across all broadcastable inputs of the same node. Non-broadcastable (broadcastable = False) inputs must matchshapeexactly.Tensor
Tensorrepresents a concrete value — used for constants and for runtime data passed intocall().Tracing
Tracing is a static validation pass that walks the program DAG topologically and resolves all type information before execution.
At each node, tracing:
TensorType.shapeto the actual incoming shape. Forbroadcastable=Trueports, named dims are matched against the trailing (intrinsic) suffix. Conflicting resolutions of the same name are errors.DTypeVar) from concrete incoming dtypes. ResolvesDTypePromotionto a concreteDType.broadcastable=Trueinputs. Incompatible shapes are errors.TensorType(substituting named dims and dtype variables, prepending the broadcast shape) and pushing the concrete type to downstream edges.BackendV3.validate_programcallstraceby default.QuantumProgram.trace()is a convenience wrapper.Built-in ProgramNodes
math— locally executableMost math nodes have
broadcastable=Trueon all inputs and outputs.The exact list of what needs to be here in an MVP is up for discussion.
Addx,yDType | DTypeVar | TensorMulx,yDivx,yPowx,yBitwiseAndx,y(BIT)Xorx,y(BIT)Parity("n",)()Mean("n",)()Concatnlist-indexed inputsaxis; sharedDTypeVarenforces uniform typeEinsuma,b(F64)Binary op constructor pattern. Each operand of
Add,Mul,Div, andPowcan be:DTypeVar("T")(default) — a free, wireable, broadcastable scalar input with a dtype variable.DType— a free input with a fixed dtype.Tensor— the value is bound as a constant; that operand disappears fromfree_input_types().This enables concise partial application:
Pow(x=Tensor(DType.F64, np.float64(-1.0)))produces a "take reciprocal" operation with a single free inputy.core— locally executableStorequantum— remote, possibly via a classical simulatorShotLooplist[QuantumCircuit]+shots;call()raisesNotImplementedErrorShotLoopinputs are list-indexed by circuit index. Parameterless circuits get a zero-size constant for their parameter input, so they vanish fromfree_input_types(). Outputs are keyed as(circuit_idx, register_name).samplomaticSamplexOpsamplomatic.Samplex; supportsdominant_shapefor randomization axesAn alternative possibility is the introduction of enough program nodes into the namespace that would effictively allow "inlining" the entire samplex construct into a quantum program.
End-to-end Example
Computing
<ZZ>,<XX>,<YY>expectation values for a parametric Bell-like state across a sweep of 50phivalues, using 4096 shots each:The broadcast dimension
(3, 50)flows from theparamsinput all the way through to the output.Parityreduces then-bit register to a scalar;PowandMeanare scalar-shaped ops so they preserve all leading dimensions. Tracing validates this entire shape flow before any circuit is submitted.Issues
Rust work:
graph TD A["Implement DType, TensorType, DTypeVar, DTypePromotion"] B["Implement DataTree"] C["Implement Tensor"] D["Implement ProgramNode trait"] E["Implement QuantumProgram as a ProgramNode"] F["Implement graph manipulation routines on QuantumProgram"] G["Implement QuantumProgram tracing"] H["Implement BackendV3 abstraction"] I["Implement built-in math ProgramNodes"] J["Implement ShotLoop ProgramNode"] A-->C A-->D B-->D C-->D D-->E E-->F E-->G F-->H G-->H D-->I D-->J click A "https://github.com/Qiskit/qiskit/issues/15990" click B "https://github.com/Qiskit/qiskit/issues/15903" click C "https://github.com/Qiskit/qiskit/issues/15992" click D "https://github.com/Qiskit/qiskit/issues/16029" click E "https://github.com/Qiskit/qiskit/issues/16030" click F "https://github.com/Qiskit/qiskit/issues/16109" click G "https://github.com/Qiskit/qiskit/issues/16110" click H "https://github.com/Qiskit/qiskit/issues/16111" click I "https://github.com/Qiskit/qiskit/issues/16031" click J "https://github.com/Qiskit/qiskit/issues/16032"PR Stack
QuantumProgramimpl ofProgramNode#16224