Repository: Qiskit/qiskit

Issue:
Performance regression of `BackendSamplerV2` and `BackendEstimatorV2`
### What should we add?

We updated Primitives V2 to enable SamplerV2 and EstimatorV2 to handle different numbers of shots or precision for each pub.
To address this, I implemented BackendSamplerV2 and BackendEstimatorV2 to call backend.run for each pub.
On the other hand, BackendEstimatorV1 and BackendSamplerV1 assume the same number of shots per run call and pass all circuits to backend.run only once.

Copied from https://github.com/Qiskit/qiskit-ibm-runtime/issues/1631

Qiskit main (71753689)
```python
from timeit import timeit

from qiskit_aer import AerSimulator
from qiskit_ibm_runtime.fake_provider import FakeSherbrooke

from qiskit import QuantumCircuit
from qiskit.primitives import BackendSampler, BackendSamplerV2
from qiskit.transpiler.preset_passmanagers import generate_preset_pass_manager

backend = AerSimulator.from_backend(FakeSherbrooke())
shots = 10000
num_copies = 20


def gen_circuit(num_qubits: int, reps: int):
    qc = QuantumCircuit(num_qubits)
    for _ in range(reps):
        qc.h(range(num_qubits))
        for i in range(0, num_qubits - 1, 2):
            qc.cx(i, i + 1)
    qc.measure_all()
    return qc


def bench_sampler_v1(qc: QuantumCircuit):
    print("\nBackendSamplerV1")
    sampler = BackendSampler(backend)
    print(f"{timeit(lambda: sampler.run([qc] * num_copies, shots=shots).result(), number=1)} sec")


def bench_sampler_v2(qc: QuantumCircuit):
    print("\nBackendSamplerV2")
    sampler = BackendSamplerV2(backend=backend)
    print(f"{timeit(lambda: sampler.run([qc] * num_copies, shots=shots).result(), number=1)} sec")


qc = gen_circuit(5, 5)
pm = generate_preset_pass_manager(optimization_level=2, backend=backend)
qc2 = pm.run(qc)
bench_sampler_v1(qc2)
bench_sampler_v2(qc2)
```

output
```
BackendSamplerV1
0.7136987919984676 sec

BackendSamplerV2
9.465235792002204 sec
```


Discussion:

Resolving patch:
diff --git a/qiskit/primitives/backend_estimator_v2.py b/qiskit/primitives/backend_estimator_v2.py
--- a/qiskit/primitives/backend_estimator_v2.py
+++ b/qiskit/primitives/backend_estimator_v2.py
@@ -14,10 +14,10 @@
 
 from __future__ import annotations
 
+import math
 from collections import defaultdict
 from collections.abc import Iterable
 from dataclasses import dataclass
-import math
 
 import numpy as np
 
@@ -25,6 +25,7 @@
 from qiskit.exceptions import QiskitError
 from qiskit.providers import BackendV1, BackendV2
 from qiskit.quantum_info import Pauli, PauliList
+from qiskit.result import Counts
 from qiskit.transpiler import PassManager, PassManagerConfig
 from qiskit.transpiler.passes import Optimize1qGatesDecomposition
 
@@ -56,6 +57,20 @@ class Options:
     """
 
 
+@dataclass
+class _PreprocessedData:
+    """Internal data structure to store the results of the preprocessing of a pub."""
+
+    circuits: list[QuantumCircuit]
+    """The quantum circuits generated by binding parameters of the pub's circuit."""
+
+    parameter_indices: np.ndarray
+    """The indices of the pub's bindings array broadcast to the shape of the pub."""
+
+    observables: np.ndarray
+    """The pub's observable array broadcast to the shape of the pub."""
+
+
 class BackendEstimatorV2(BaseEstimatorV2):
     """Evaluates expectation values for provided quantum circuit and observable combinations
 
@@ -144,10 +159,58 @@ def _validate_pubs(self, pubs: list[EstimatorPub]):
                 )
 
     def _run(self, pubs: list[EstimatorPub]) -> PrimitiveResult[PubResult]:
-        return PrimitiveResult([self._run_pub(pub) for pub in pubs])
+        pub_dict = defaultdict(list)
+        # consolidate pubs with the same number of shots
+        for i, pub in enumerate(pubs):
+            shots = int(math.ceil(1.0 / pub.precision**2))
+            pub_dict[shots].append(i)
+
+        results = [None] * len(pubs)
+        for shots, lst in pub_dict.items():
+            # run pubs with the same number of shots at once
+            pub_results = self._run_pubs([pubs[i] for i in lst], shots)
+            # reconstruct the result of pubs
+            for i, pub_result in zip(lst, pub_results):
+                results[i] = pub_result
+        return PrimitiveResult(results)
+
+    def _run_pubs(self, pubs: list[EstimatorPub], shots: int) -> list[PubResult]:
+        """Compute results for pubs that all require the same value of ``shots``."""
+        preprocessed_data = []
+        flat_circuits = []
+        for pub in pubs:
+            data = self._preprocess_pub(pub)
+            preprocessed_data.append(data)
+            flat_circuits.extend(data.circuits)
+
+        run_result, metadata = _run_circuits(
+            flat_circuits, self._backend, shots=shots, seed_simulator=self._options.seed_simulator
+        )
+        counts = _prepare_counts(run_result)
 
-    def _run_pub(self, pub: EstimatorPub) -> PubResult:
-        shots = math.ceil(1.0 / pub.precision**2)
+        results = []
+        start = 0
+        for pub, data in zip(pubs, preprocessed_data):
+            end = start + len(data.circuits)
+            expval_map = self._calc_expval_map(counts[start:end], metadata[start:end])
+            start = end
+            results.append(self._postprocess_pub(pub, expval_map, data, shots))
+        return results
+
+    def _preprocess_pub(self, pub: EstimatorPub) -> _PreprocessedData:
+        """Converts a pub into a list of bound circuits necessary to estimate all its observables.
+
+        The circuits contain metadata explaining which bindings array index they are with respect to,
+        and which measurement basis they are measuring.
+
+        Args:
+            pub: The pub to preprocess.
+
+        Returns:
+            The values ``(circuits, bc_param_ind, bc_obs)`` where ``circuits`` are the circuits to
+            execute on the backend, ``bc_param_ind`` are indices of the pub's bindings array and
+            ``bc_obs`` is the observables array, both broadcast to the shape of the pub.
+        """
         circuit = pub.circuit
         observables = pub.observables
         parameter_values = pub.parameter_values
@@ -161,10 +224,29 @@ def _run_pub(self, pub: EstimatorPub) -> PubResult:
         param_obs_map = defaultdict(set)
         for index in np.ndindex(*bc_param_ind.shape):
             param_index = bc_param_ind[index]
-            param_obs_map[param_index].update(bc_obs[index].keys())
-        expval_map = self._calc_expval_paulis(circuit, parameter_values, param_obs_map, shots)
+            param_obs_map[param_index].update(bc_obs[index])
+
+        bound_circuits = self._bind_and_add_measurements(circuit, parameter_values, param_obs_map)
+        return _PreprocessedData(bound_circuits, bc_param_ind, bc_obs)
+
+    def _postprocess_pub(
+        self, pub: EstimatorPub, expval_map: dict, data: _PreprocessedData, shots: int
+    ) -> PubResult:
+        """Computes expectation values (evs) and standard errors (stds).
+
+        The values are stored in arrays broadcast to the shape of the pub.
 
-        # calculate expectation values (evs) and standard errors (stds)
+        Args:
+            pub: The pub to postprocess.
+            expval_map: The map
+            data: The result data of the preprocessing.
+            shots: The number of shots.
+
+        Returns:
+            The pub result.
+        """
+        bc_param_ind = data.parameter_indices
+        bc_obs = data.observables
         evs = np.zeros_like(bc_param_ind, dtype=float)
         variances = np.zeros_like(bc_param_ind, dtype=float)
         for index in np.ndindex(*bc_param_ind.shape):
@@ -178,30 +260,55 @@ def _run_pub(self, pub: EstimatorPub) -> PubResult:
         data_bin = data_bin_cls(evs=evs, stds=stds)
         return PubResult(data_bin, metadata={"target_precision": pub.precision})
 
-    def _calc_expval_paulis(
+    def _bind_and_add_measurements(
         self,
         circuit: QuantumCircuit,
         parameter_values: BindingsArray,
         param_obs_map: dict[tuple[int, ...], set[str]],
-        shots: int,
-    ) -> dict[tuple[tuple[int, ...], str], tuple[float, float]]:
-        # generate circuits
+    ) -> list[QuantumCircuit]:
+        """Bind the given circuit against each parameter value set, and add necessary measurements
+        to each.
+
+        Args:
+            circuit: The (possibly parametric) circuit of interest.
+            parameter_values: An array of parameter value sets that can be applied to the circuit.
+            param_obs_map: A mapping from locations in ``parameter_values`` to a sets of
+                Pauli terms whose expectation values are required in those locations.
+
+        Returns:
+            A flat list of circuits sufficient to measure all Pauli terms in the ``param_obs_map``
+            values at the corresponding ``parameter_values`` location, where requisite
+            book-keeping is stored as circuit metadata.
+        """
         circuits = []
         for param_index, pauli_strings in param_obs_map.items():
             bound_circuit = parameter_values.bind(circuit, param_index)
             # sort pauli_strings so that the order is deterministic
             meas_paulis = PauliList(sorted(pauli_strings))
-            new_circuits = self._preprocessing(bound_circuit, meas_paulis, param_index)
+            new_circuits = self._create_measurement_circuits(
+                bound_circuit, meas_paulis, param_index
+            )
             circuits.extend(new_circuits)
+        return circuits
 
-        # run circuits
-        result, metadata = _run_circuits(
-            circuits, self._backend, shots=shots, seed_simulator=self._options.seed_simulator
-        )
+    def _calc_expval_map(
+        self,
+        counts: list[Counts],
+        metadata: dict,
+    ) -> dict[tuple[tuple[int, ...], str], tuple[float, float]]:
+        """Computes the map of expectation values.
 
-        # postprocessing results
+        Args:
+            counts: The counts data.
+            metadata: The metadata.
+
+        Returns:
+            The map of expectation values takes a pair of an index of the bindings array and
+            a pauli string as a key and returns the expectation value of the pauli string
+            with the the pub's circuit bound against the parameter value set in the index of
+            the bindings array.
+        """
         expval_map: dict[tuple[tuple[int, ...], str], tuple[float, float]] = {}
-        counts = _prepare_counts(result)
         for count, meta in zip(counts, metadata):
             orig_paulis = meta["orig_paulis"]
             meas_paulis = meta["meas_paulis"]
@@ -211,10 +318,23 @@ def _calc_expval_paulis(
                 expval_map[param_index, pauli.to_label()] = (expval, variance)
         return expval_map
 
-    def _preprocessing(
+    def _create_measurement_circuits(
         self, circuit: QuantumCircuit, observable: PauliList, param_index: tuple[int, ...]
     ) -> list[QuantumCircuit]:
-        # generate measurement circuits with metadata
+        """Generate a list of circuits sufficient to estimate each of the given Paulis.
+
+        Paulis are divided into qubitwise-commuting subsets to reduce the total circuit count.
+        Metadata is attached to circuits in order to remember what each one measures, and
+        where it belongs in the output.
+
+        Args:
+            circuit: The circuit of interest.
+            observable: Which Pauli terms we would like to observe.
+            param_index: Where to put the data we estimate (only passed to metadata).
+
+        Returns:
+            A list of circuits sufficient to estimate each of the given Paulis.
+        """
         meas_circuits: list[QuantumCircuit] = []
         if self._options.abelian_grouping:
             for obs in observable.group_commuting(qubit_wise=True):
diff --git a/qiskit/primitives/backend_sampler_v2.py b/qiskit/primitives/backend_sampler_v2.py
--- a/qiskit/primitives/backend_sampler_v2.py
+++ b/qiskit/primitives/backend_sampler_v2.py
@@ -15,6 +15,7 @@
 from __future__ import annotations
 
 import warnings
+from collections import defaultdict
 from dataclasses import dataclass
 from typing import Iterable
 
@@ -142,36 +143,76 @@ def _validate_pubs(self, pubs: list[SamplerPub]):
                 )
 
     def _run(self, pubs: Iterable[SamplerPub]) -> PrimitiveResult[PubResult]:
-        results = [self._run_pub(pub) for pub in pubs]
+        pub_dict = defaultdict(list)
+        # consolidate pubs with the same number of shots
+        for i, pub in enumerate(pubs):
+            pub_dict[pub.shots].append(i)
+
+        results = [None] * len(pubs)
+        for shots, lst in pub_dict.items():
+            # run pubs with the same number of shots at once
+            pub_results = self._run_pubs([pubs[i] for i in lst], shots)
+            # reconstruct the result of pubs
+            for i, pub_result in zip(lst, pub_results):
+                results[i] = pub_result
         return PrimitiveResult(results)
 
-    def _run_pub(self, pub: SamplerPub) -> PubResult:
-        meas_info, max_num_bytes = _analyze_circuit(pub.circuit)
-        bound_circuits = pub.parameter_values.bind_all(pub.circuit)
-        arrays = {
-            item.creg_name: np.zeros(
-                bound_circuits.shape + (pub.shots, item.num_bytes), dtype=np.uint8
-            )
-            for item in meas_info
-        }
-        flatten_circuits = np.ravel(bound_circuits).tolist()
-        result_memory, _ = _run_circuits(
+    def _run_pubs(self, pubs: list[SamplerPub], shots: int) -> list[PubResult]:
+        """Compute results for pubs that all require the same value of ``shots``."""
+        # prepare circuits
+        bound_circuits = [pub.parameter_values.bind_all(pub.circuit) for pub in pubs]
+        flatten_circuits = []
+        for circuits in bound_circuits:
+            flatten_circuits.extend(np.ravel(circuits).tolist())
+
+        # run circuits
+        results, _ = _run_circuits(
             flatten_circuits,
             self._backend,
             memory=True,
-            shots=pub.shots,
+            shots=shots,
             seed_simulator=self._options.seed_simulator,
         )
-        memory_list = _prepare_memory(result_memory, max_num_bytes)
+        result_memory = _prepare_memory(results)
+
+        # pack memory to an ndarray of uint8
+        results = []
+        start = 0
+        for pub, bound in zip(pubs, bound_circuits):
+            meas_info, max_num_bytes = _analyze_circuit(pub.circuit)
+            end = start + bound.size
+            results.append(
+                self._postprocess_pub(
+                    result_memory[start:end], shots, bound.shape, meas_info, max_num_bytes
+                )
+            )
+            start = end
+
+        return results
 
-        for samples, index in zip(memory_list, np.ndindex(*bound_circuits.shape)):
+    def _postprocess_pub(
+        self,
+        result_memory: list[list[str]],
+        shots: int,
+        shape: tuple[int, ...],
+        meas_info: list[_MeasureInfo],
+        max_num_bytes: int,
+    ) -> PubResult:
+        """Converts the memory data into an array of bit arrays with the shape of the pub."""
+        arrays = {
+            item.creg_name: np.zeros(shape + (shots, item.num_bytes), dtype=np.uint8)
+            for item in meas_info
+        }
+        memory_array = _memory_array(result_memory, max_num_bytes)
+
+        for samples, index in zip(memory_array, np.ndindex(*shape)):
             for item in meas_info:
                 ary = _samples_to_packed_array(samples, item.num_bits, item.start)
                 arrays[item.creg_name][index] = ary
 
         data_bin_cls = make_data_bin(
             [(item.creg_name, BitArray) for item in meas_info],
-            shape=bound_circuits.shape,
+            shape=shape,
         )
         meas = {
             item.creg_name: BitArray(arrays[item.creg_name], item.num_bits) for item in meas_info
@@ -181,6 +222,7 @@ def _run_pub(self, pub: SamplerPub) -> PubResult:
 
 
 def _analyze_circuit(circuit: QuantumCircuit) -> tuple[list[_MeasureInfo], int]:
+    """Analyzes the information for each creg in a circuit."""
     meas_info = []
     max_num_bits = 0
     for creg in circuit.cregs:
@@ -202,17 +244,30 @@ def _analyze_circuit(circuit: QuantumCircuit) -> tuple[list[_MeasureInfo], int]:
     return meas_info, _min_num_bytes(max_num_bits)
 
 
-def _prepare_memory(results: list[Result], num_bytes: int) -> NDArray[np.uint8]:
+def _prepare_memory(results: list[Result]) -> list[list[str]]:
+    """Joins splitted results if exceeding max_experiments"""
     lst = []
     for res in results:
         for exp in res.results:
             if hasattr(exp.data, "memory") and exp.data.memory:
-                data = b"".join(int(i, 16).to_bytes(num_bytes, "big") for i in exp.data.memory)
-                data = np.frombuffer(data, dtype=np.uint8).reshape(-1, num_bytes)
+                lst.append(exp.data.memory)
             else:
                 # no measure in a circuit
-                data = np.zeros((exp.shots, num_bytes), dtype=np.uint8)
-            lst.append(data)
+                lst.append(["0x0"] * exp.shots)
+    return lst
+
+
+def _memory_array(results: list[list[str]], num_bytes: int) -> NDArray[np.uint8]:
+    """Converts the memory data into an array in an unpacked way."""
+    lst = []
+    for memory in results:
+        if num_bytes > 0:
+            data = b"".join(int(i, 16).to_bytes(num_bytes, "big") for i in memory)
+            data = np.frombuffer(data, dtype=np.uint8).reshape(-1, num_bytes)
+        else:
+            # no measure in a circuit
+            data = np.zeros((len(memory), num_bytes), dtype=np.uint8)
+        lst.append(data)
     ary = np.array(lst, copy=False)
     return np.unpackbits(ary, axis=-1, bitorder="big")
 
@@ -220,6 +275,7 @@ def _prepare_memory(results: list[Result], num_bytes: int) -> NDArray[np.uint8]:
 def _samples_to_packed_array(
     samples: NDArray[np.uint8], num_bits: int, start: int
 ) -> NDArray[np.uint8]:
+    """Converts an unpacked array of the memory data into a packed array."""
     # samples of `Backend.run(memory=True)` will be the order of
     # clbit_last, ..., clbit_1, clbit_0
     # place samples in the order of clbit_start+num_bits-1, ..., clbit_start+1, clbit_start


Test patch:
diff --git a/test/python/primitives/test_backend_estimator_v2.py b/test/python/primitives/test_backend_estimator_v2.py
--- a/test/python/primitives/test_backend_estimator_v2.py
+++ b/test/python/primitives/test_backend_estimator_v2.py
@@ -16,6 +16,7 @@
 
 import unittest
 from test import QiskitTestCase, combine
+from unittest.mock import patch
 
 import numpy as np
 from ddt import ddt
@@ -28,7 +29,7 @@
 from qiskit.primitives.containers.observables_array import ObservablesArray
 from qiskit.providers.backend_compat import BackendV2Converter
 from qiskit.providers.basic_provider import BasicSimulator
-from qiskit.providers.fake_provider import Fake7QPulseV1
+from qiskit.providers.fake_provider import Fake7QPulseV1, GenericBackendV2
 from qiskit.quantum_info import SparsePauliOp
 from qiskit.transpiler.preset_passmanagers import generate_preset_pass_manager
 from qiskit.utils import optionals
@@ -371,6 +372,22 @@ def test_precision(self, backend, abelian_grouping):
         result = job.result()
         np.testing.assert_allclose(result[0].data.evs, [1.5555572817900956], rtol=self._rtol)
 
+    @combine(backend=BACKENDS, abelian_grouping=[True, False])
+    def test_diff_precision(self, backend, abelian_grouping):
+        """Test for running different precisions at once"""
+        estimator = BackendEstimatorV2(backend=backend, options=self._options)
+        estimator.options.abelian_grouping = abelian_grouping
+        pm = generate_preset_pass_manager(optimization_level=0, backend=backend)
+        psi1 = pm.run(self.psi[0])
+        hamiltonian1 = self.hamiltonian[0].apply_layout(psi1.layout)
+        theta1 = self.theta[0]
+        job = estimator.run(
+            [(psi1, hamiltonian1, [theta1]), (psi1, hamiltonian1, [theta1], self._precision * 0.8)]
+        )
+        result = job.result()
+        np.testing.assert_allclose(result[0].data.evs, [1.901141473854881], rtol=self._rtol)
+        np.testing.assert_allclose(result[1].data.evs, [1.901141473854881], rtol=self._rtol)
+
     @unittest.skipUnless(optionals.HAS_AER, "qiskit-aer is required to run this test")
     @combine(abelian_grouping=[True, False])
     def test_aer(self, abelian_grouping):
@@ -407,6 +424,43 @@ def test_aer(self, abelian_grouping):
                 result[0].data.evs, target[0].data.evs, rtol=self._rtol, atol=1e-1
             )
 
+    def test_job_size_limit_backend_v2(self):
+        """Test BackendEstimatorV2 respects job size limit"""
+
+        class FakeBackendLimitedCircuits(GenericBackendV2):
+            """Generic backend V2 with job size limit."""
+
+            @property
+            def max_circuits(self):
+                return 1
+
+        backend = FakeBackendLimitedCircuits(num_qubits=5)
+        qc = RealAmplitudes(num_qubits=2, reps=2)
+        # Note: two qubit-wise commuting groups
+        op = SparsePauliOp.from_list([("IZ", 1), ("XI", 2), ("ZY", -1)])
+        k = 5
+        param_list = self._rng.random(qc.num_parameters).tolist()
+        estimator = BackendEstimatorV2(backend=backend)
+        with patch.object(backend, "run") as run_mock:
+            estimator.run([(qc, op, param_list)] * k).result()
+        self.assertEqual(run_mock.call_count, 10)
+
+    def test_job_size_limit_backend_v1(self):
+        """Test BackendEstimatorV2 respects job size limit"""
+        backend = Fake7QPulseV1()
+        config = backend.configuration()
+        config.max_experiments = 1
+        backend._configuration = config
+        qc = RealAmplitudes(num_qubits=2, reps=2)
+        # Note: two qubit-wise commuting groups
+        op = SparsePauliOp.from_list([("IZ", 1), ("XI", 2), ("ZY", -1)])
+        k = 5
+        param_list = self._rng.random(qc.num_parameters).tolist()
+        estimator = BackendEstimatorV2(backend=backend)
+        with patch.object(backend, "run") as run_mock:
+            estimator.run([(qc, op, param_list)] * k).result()
+        self.assertEqual(run_mock.call_count, 10)
+
 
 if __name__ == "__main__":
     unittest.main()
diff --git a/test/python/primitives/test_backend_sampler_v2.py b/test/python/primitives/test_backend_sampler_v2.py
--- a/test/python/primitives/test_backend_sampler_v2.py
+++ b/test/python/primitives/test_backend_sampler_v2.py
@@ -33,7 +33,7 @@
 from qiskit.providers import JobStatus
 from qiskit.providers.backend_compat import BackendV2Converter
 from qiskit.providers.basic_provider import BasicSimulator
-from qiskit.providers.fake_provider import Fake7QPulseV1
+from qiskit.providers.fake_provider import Fake7QPulseV1, GenericBackendV2
 from qiskit.transpiler.preset_passmanagers import generate_preset_pass_manager
 
 BACKENDS = [BasicSimulator(), Fake7QPulseV1(), BackendV2Converter(Fake7QPulseV1())]
@@ -671,6 +671,68 @@ def test_empty_creg(self, backend):
         result = sampler.run([qc], shots=self._shots).result()
         self.assertEqual(result[0].data.c1.array.shape, (self._shots, 0))
 
+    @combine(backend=BACKENDS)
+    def test_diff_shots(self, backend):
+        """Test of pubs with different shots"""
+        pm = generate_preset_pass_manager(optimization_level=0, backend=backend)
+
+        bell, _, target = self._cases[1]
+        bell = pm.run(bell)
+        sampler = BackendSamplerV2(backend=backend, options=self._options)
+        shots2 = self._shots + 2
+        target2 = {k: v + 1 for k, v in target.items()}
+        job = sampler.run([(bell, None, self._shots), (bell, None, shots2)])
+        result = job.result()
+        self.assertEqual(len(result), 2)
+        self.assertEqual(result[0].data.meas.num_shots, self._shots)
+        self._assert_allclose(result[0].data.meas, np.array(target))
+        self.assertEqual(result[1].data.meas.num_shots, shots2)
+        self._assert_allclose(result[1].data.meas, np.array(target2))
+
+    def test_job_size_limit_backend_v2(self):
+        """Test BackendSamplerV2 respects backend's job size limit."""
+
+        class FakeBackendLimitedCircuits(GenericBackendV2):
+            """Generic backend V2 with job size limit."""
+
+            @property
+            def max_circuits(self):
+                return 1
+
+        qc = QuantumCircuit(1)
+        qc.measure_all()
+        qc2 = QuantumCircuit(1)
+        qc2.x(0)
+        qc2.measure_all()
+        sampler = BackendSamplerV2(backend=FakeBackendLimitedCircuits(num_qubits=5))
+        result = sampler.run([qc, qc2], shots=self._shots).result()
+        self.assertIsInstance(result, PrimitiveResult)
+        self.assertEqual(len(result), 2)
+        self.assertIsInstance(result[0], PubResult)
+        self.assertIsInstance(result[1], PubResult)
+        self._assert_allclose(result[0].data.meas, np.array({0: self._shots}))
+        self._assert_allclose(result[1].data.meas, np.array({1: self._shots}))
+
+    def test_job_size_limit_backend_v1(self):
+        """Test BackendSamplerV2 respects backend's job size limit."""
+        backend = Fake7QPulseV1()
+        config = backend.configuration()
+        config.max_experiments = 1
+        backend._configuration = config
+        qc = QuantumCircuit(1)
+        qc.measure_all()
+        qc2 = QuantumCircuit(1)
+        qc2.x(0)
+        qc2.measure_all()
+        sampler = BackendSamplerV2(backend=backend)
+        result = sampler.run([qc, qc2], shots=self._shots).result()
+        self.assertIsInstance(result, PrimitiveResult)
+        self.assertEqual(len(result), 2)
+        self.assertIsInstance(result[0], PubResult)
+        self.assertIsInstance(result[1], PubResult)
+        self._assert_allclose(result[0].data.meas, np.array({0: self._shots}))
+        self._assert_allclose(result[1].data.meas, np.array({1: self._shots}))
+
 
 if __name__ == "__main__":
     unittest.main()