diff --git a/.agent/audits/qdmi-main-performance-2026-09-08.md b/.agent/audits/qdmi-main-performance-2026-09-08.md new file mode 100644 index 0000000000..3aa5633249 --- /dev/null +++ b/.agent/audits/qdmi-main-performance-2026-09-08.md @@ -0,0 +1,224 @@ +# Contract audit: QDMI performance on refreshed main + +Status: all five findings implemented with user authorization on 2026-09-09. +Audit date: 2026-09-08. Baseline: `0c3fac2cb2861e9e25262857a84650cba651f466`, +clean upstream `main`. The user's checkout and its branch were preserved. + +## Result + +Five actionable opportunities remain. The first also fixes a concurrency bug: + +1. Share DDSIM's dense-vector initialization guard between probabilities and + statevectors. +2. Answer dense result size queries without materializing the vector. +3. Decode PennyLane samples with NumPy instead of Python lists of integers. +4. Stop querying QDMI operation names to look up PennyLane's existing cache. +5. Read Qiskit's device-wide duration conversion once per target snapshot. + +The implementation is based on `ce608b082`, refreshed upstream `main`. +Prototypes and raw local evidence are in `/tmp/qdmi-main-audit`. + +## Scope and recent changes + +Inspected the C++ client/result decoders, driver/session and job ownership, +DDSIM and SC providers, Python bindings, Qiskit and PennyLane adapters, +`mlir/lib/Compiler/QDMIAdapter.cpp`, and the newly merged target environment. + +Merged #2460/#2472/#2475 already address driver lock/copy costs, Slurm opening, +and the Python GIL. The DD/QCO execution changes and #2219 target environment +were included in the refreshed baseline. The target environment already reuses +its prepared target through the analysis manager; another cache is not +justified. + +Open #2373 changes native multi-program jobs in the same DDSIM source. Its +inspected head `c8b4ac3d87e0db0265017b2767f470253c8ff003` still contains the +unguarded probability initialization. Coordinate a future provider fix with that +PR. The exact-payload/replaceable-driver stack remains separate work. + +## Findings + +### 1. Share the dense initialization guard + +**Priority:** high. **Confidence:** high. + +`src/qdmi/devices/dd/Device.cpp::getStateVector` initializes `stateVec_` with +`std::call_once(stateVecOnce_, ...)`. `getProbabilities` instead checks +`stateVec_.empty()` and assigns the same vector without synchronization. + +Two cold probability readers can therefore write the vector concurrently. A +probability reader can also race a statevector reader. The released Python GIL +makes these overlapping calls possible from Python as well as C++. Even +sequential probability-then-statevector queries materialize the vector twice, +because the first path does not set the once flag. + +The public C API probe used an all-zero 22-qubit state. Probability-size and +then statevector-size queries each allocated a 64 MiB vector. Four concurrent +cold probability-size queries allocated four such vectors. This confirms +duplicate initialization; the data race follows from the unprotected shared +writes. No ThreadSanitizer claim is made. + +**Smallest fix:** use the existing `stateVecOnce_` in both methods. The isolated +prototype removed the second allocation; the second size query took 0.416 us +instead of 24.7 ms. All 65 DDSIM tests passed. + +**Coverage gap:** `Concurrency.ConcurrentStatevectorReads` queries the size +before starting its workers, thereby warming the vector. Add a public-API +regression for cold probability and mixed probability/statevector readers. No +private test hooks or new synchronization abstraction are needed. + +This corrects the earlier `qdmi-python-gil.md` statement that DDSIM already +protects all lazy result materialization: its probability path was missed. + +### 2. Make dense size queries allocation-free + +**Priority:** medium. **Confidence:** high. + +Both dense accessors materialize `stateVec_` before reporting its byte count or +rejecting a too-small output buffer. The client uses the normal QDMI two-call +size/data protocol, so a caller merely sizing a buffer can trigger an +exponential allocation. The vector dimension is already available from the DD +root. + +The same 22-qubit probe returned 32 MiB for probabilities and 64 MiB for complex +amplitudes. Baseline size-only queries took 17.8 ms and 24.7 ms and each +allocated 64 MiB. A prototype computed the required size from the root, checked +arithmetic overflow, and materialized under the shared once flag only for valid +data requests. The two size queries took 0.56 us and 0.192 us with zero dense +allocations. All 65 DDSIM tests passed. + +Preserve terminal-result behavior, reported sizes, and small-buffer errors. The +implementation tests addressability and overflow through the public provider +API. Sparse sizes still depend on the number of nonzero entries and must not use +the dense rule. This removes work from size-only/rejected requests; full result +retrieval still requires dense data. + +### 3. Vectorize PennyLane sample decoding + +**Priority:** medium. **Confidence:** high for tested inputs. + +`python/mqt/core/plugins/pennylane/device.py::_samples` validates every bit in +Python, creates a list of Python integers per shot, and copies those rows into a +NumPy `int8` array. NumPy is already a required dependency in this module. + +An isolated variant retains per-shot width and binary validation, packs the +validated ASCII data with `np.frombuffer`, reverses the QDMI bit order, selects +the requested columns, and subtracts the ASCII zero. It avoids the nested Python +lists and per-bit `int` calls. + +Median of five local conversions: + +| Shots | Wires | Baseline | Prototype | +| ------: | ----: | ---------: | --------: | +| 1,000 | 2 | 0.430 ms | 0.059 ms | +| 100,000 | 2 | 50.588 ms | 5.570 ms | +| 1,000 | 32 | 2.385 ms | 0.105 ms | +| 100,000 | 32 | 272.540 ms | 12.702 ms | + +Equality checks covered dtype, row order, reordered/subset/empty/repeated column +selections, spaces, wrong shot counts, wrong widths, invalid binary characters, +and non-ASCII input. Keep validation before packing: checking only total byte +length could accept individually malformed shots. The packed representation +still needs a temporary byte buffer; this is not a zero-copy result API. + +### 4. Reuse PennyLane's local operation name + +**Priority:** medium. **Confidence:** high. + +`python/mqt/core/plugins/pennylane/converter.py::_validate_qdmi_contract` calls +`qdmi_operation.name()` for every gate to form the key of +`_operation_contracts`. The converter already resolved that operation against an +immutable advertised mapping. The Python operation name is available without +calling the provider. + +A one-line prototype uses `(operation.name, spec.wires)` as the key. Repeated +10,000-gate RX conversion made zero extra QDMI name calls instead of 10,000; +median conversion time fell from 73.022 ms to 23.887 ms with the existing test +metadata double. Payloads were identical. These timings include mock overhead; +the eliminated query count is the stronger evidence for real providers. + +Retain the per-converter cache lifetime and validation of each bound parameter +and wire. Aliases may create a few separate cache entries for one QDMI spelling; +that is harmless and avoids a new reverse map. Error-path queries can remain. + +### 5. Snapshot Qiskit duration units once + +**Priority:** medium. **Confidence:** high. + +`python/mqt/core/plugins/qiskit/backend.py::_duration_seconds` queries the +session-wide duration unit and scale for every calibrated gate placement. The +surrounding `_build_target` already creates a calibration snapshot. + +With two calibrated operations on 1,000 sites, target construction queried the +unit 2,000 times and the scale 2,000 times. A lazily initialized conversion +factor reduced each to one query, preserving the complete target duration +mapping. Median local mock time fell from 2.830 ms to 2.174 ms. At 32 sites, +each query count fell from 64 to one. + +Keep initialization lazy: absent durations currently require no unit and must +not cause an error. Preserve invalid-unit and nonpositive/nonfinite-scale +checks. Scope the factor to the target/backend snapshot, never a global device +ID cache. The implementation resets the conversion at the start of target +construction. It retains the scale and unit separately to preserve +floating-point evaluation order. + +## Retained boundaries and lower-priority candidates + +- Registry indexing remains declined for the expected couple dozen devices. +- Eager child initialization and construction-time failures remain unchanged. +- IQM timeout configuration remains excluded as requested. +- Job destruction can still block Python; it remains the previously recorded + lifetime-design question, not a newly established easy optimization. +- Sparse result parsing still uses an `istringstream`, but replacing it has not + established a benefit comparable to the confirmed findings. Do not widen the + patch into a new decoder or change public result ordering without evidence. +- Preserve frontend validation, complete preflight before batch submission, + genuine ordered Qiskit memory, session lifetime, and native provider errors. + +## Validation and reproducibility + +Environment: ARM64 DGX Spark, GCC 13 release/IPO build, LLVM/MLIR 23.1.0; Python +3.14.7, NumPy 2.5.3, PennyLane 0.45.1, Qiskit 2.5.2. + +- Configured `cmake --preset release` and built `mqt-core-qdmi-test` and + `mqt-core-qdmi-ddsim-device-test`. +- Baseline: 241 client tests and 65 DDSIM tests passed. +- Shared-guard and allocation-free-size prototypes: 65 DDSIM tests passed for + each. Restored baseline source, rebuilt, and reran all 65 successfully. +- Focused Nox session `tests-3.14`: 92 passed across the Qiskit mock-backend + tests and PennyLane converter/device tests listed in + `/tmp/qdmi-main-audit/frontend_variants_test.py`. +- The three frontend variants together passed those same 92 tests in-process, + plus the sample-decoding and payload/target equality probes. +- `/tmp/qdmi-main-audit/dense_probe.cpp` uses the public provider C API and + counts allocations matching the 64 MiB dense vector; run `dense-probe` for + sequential reads or `dense-probe concurrent` for four cold readers. +- `frontend_probe.py`, `samples_probe.py`, and `frontend_variants_test.py` in + that directory reproduce frontend call counts, timings, equality, and tests. + `dense-lazy.patch` retains the provider prototype outside the repository. +- No live cloud/device calls, hardware jobs, hosted CI, or Windows validation. + Timing probes isolate the relevant local work and do not predict end-to-end + network latency. Publication is authorized for the five confirmed findings. + +## Implementation and validation + +- Both dense accessors calculate their byte count with checked shifts and + multiplication. Size queries and rejected small buffers do not construct the + vector. Valid data requests share `stateVecOnce_`; requests beyond the + vector's addressable capacity return `QDMI_ERROR_OUTOFMEM`. +- Cold public-API regressions start probability-only and mixed readers together + and check Bell amplitudes/probabilities. Size tests cover both output formats + at representable and overflowing dimensions without allocating dense vectors. +- PennyLane retains per-shot width, binary, and shot-count checks, `int8` + output, spaces, and requested wire order. Its existing cache test also + verifies that valid gates require no further operation-name queries. +- Qiskit tests cover lazy metadata reads across multiple calibrated operations + and placements, zero durations, and independent target snapshots. Existing + invalid-unit and invalid-scale checks remain. +- Local native validation: all 67 DDSIM and 241 client tests passed. +- Focused frontend validation: all 102 Python tests passed on Python 3.14. +- Full `uvx nox -s lint` and `uvx nox -s cpp-lint -- ce608b082` passed; C++ lint + checks every line of each changed C++ file. +- Final 22-qubit probe: four concurrent size queries allocated no dense vectors; + subsequent probability/statevector size queries took 0.03-0.16 us. +- Open #2373 still has the same overlapping DDSIM source at the audited head; + this PR does not include its multi-program changes. diff --git a/python/mqt/core/plugins/pennylane/converter.py b/python/mqt/core/plugins/pennylane/converter.py index fc995b387b..7b950fd9e4 100644 --- a/python/mqt/core/plugins/pennylane/converter.py +++ b/python/mqt/core/plugins/pennylane/converter.py @@ -272,7 +272,7 @@ def _validate_qdmi_contract( Raises: PennyLaneValidationError: If arity, parameters, or topology do not match. """ - key = (qdmi_operation.name(), spec.wires) + key = (operation.name, spec.wires) if key not in self._operation_contracts: self._operation_contracts[key] = ( qdmi_operation.qubits_num(), diff --git a/python/mqt/core/plugins/pennylane/device.py b/python/mqt/core/plugins/pennylane/device.py index ccc6a014d0..213cd7c34b 100644 --- a/python/mqt/core/plugins/pennylane/device.py +++ b/python/mqt/core/plugins/pennylane/device.py @@ -325,19 +325,19 @@ def _samples(self, job: QDMIJobHandle, converted: _ConvertedProgram, shots: int) msg = f"QDMI returned {len(bitstrings)} samples for a {shots}-shot job." raise ExecutionError(msg) - rows: list[list[int]] = [] width = len(converted.wire_map) + cleaned: list[str] = [] for bitstring in bitstrings: clean = bitstring.replace(" ", "") - if len(clean) != width or any(bit not in "01" for bit in clean): + if len(clean) != width or clean.strip("01"): msg = f"QDMI returned an invalid {width}-wire shot: {bitstring!r}." raise ExecutionError(msg) - # QDMI bit strings use the conventional basis-state spelling with - # the highest-index site on the left. PennyLane sample columns use - # the declared wire order, starting with wire zero. - wire_order = clean[::-1] - rows.append([int(wire_order[index]) for index in converted.measurement_order]) - return np.asarray(rows, dtype=np.int8) + cleaned.append(clean) + if not bitstrings: + return np.asarray([], dtype=np.int8) + packed = np.frombuffer("".join(cleaned).encode("ascii"), dtype=np.int8).reshape(shots, width) + # QDMI spells the highest-index site first; PennyLane starts with wire zero. + return packed[:, ::-1][:, converted.measurement_order] - ord("0") @staticmethod def _require_done(job: QDMIJobHandle) -> None: diff --git a/python/mqt/core/plugins/qiskit/backend.py b/python/mqt/core/plugins/qiskit/backend.py index d7e732f084..316e6dc2ce 100644 --- a/python/mqt/core/plugins/qiskit/backend.py +++ b/python/mqt/core/plugins/qiskit/backend.py @@ -423,6 +423,7 @@ def _build_target(self) -> Target: Returns: Target object with device operations and properties. """ + self._duration_conversion: tuple[float, float] | None = None target = Target( description=f"QDMI device: {self._device.name()}", num_qubits=self._target_num_qubits(), @@ -537,18 +538,21 @@ def _duration_seconds(self, duration: int | None) -> float | None: """ if duration is None: return None - unit = self._device.duration_unit() - seconds_per_unit = {"s": 1.0, "ms": 1e-3, "us": 1e-6, "ns": 1e-9, "ps": 1e-12, "fs": 1e-15} - if unit not in seconds_per_unit: - msg = f"Cannot convert operation duration with device duration unit {unit!r} to seconds" - raise UnsupportedOperationError(msg) - scale = self._device.duration_scale_factor() - if scale is None: - scale = 1.0 - if not isfinite(scale) or scale <= 0: - msg = f"Device duration scale factor must be positive and finite, got {scale!r}" - raise UnsupportedOperationError(msg) - return duration * scale * seconds_per_unit[unit] + if self._duration_conversion is None: + unit = self._device.duration_unit() + seconds_per_unit = {"s": 1.0, "ms": 1e-3, "us": 1e-6, "ns": 1e-9, "ps": 1e-12, "fs": 1e-15} + if unit not in seconds_per_unit: + msg = f"Cannot convert operation duration with device duration unit {unit!r} to seconds" + raise UnsupportedOperationError(msg) + scale = self._device.duration_scale_factor() + if scale is None: + scale = 1.0 + if not isfinite(scale) or scale <= 0: + msg = f"Device duration scale factor must be positive and finite, got {scale!r}" + raise UnsupportedOperationError(msg) + self._duration_conversion = scale, seconds_per_unit[unit] + scale, seconds_per_unit_value = self._duration_conversion + return duration * scale * seconds_per_unit_value @staticmethod def _get_operation_site_tuples(op: QDMIDevice.Operation) -> Sequence[tuple[QDMIDevice.Site, ...]] | None: diff --git a/src/qdmi/devices/dd/Device.cpp b/src/qdmi/devices/dd/Device.cpp index 4363802880..82134c17fc 100644 --- a/src/qdmi/devices/dd/Device.cpp +++ b/src/qdmi/devices/dd/Device.cpp @@ -771,13 +771,23 @@ auto MQT_DDSIM_QDMI_Device_Job_impl_d::getStateVector(const size_t size, if (stateVecDD_.isTerminal()) { return reportEmptyResult(sizeRet); } - std::call_once(stateVecOnce_, - [this] { stateVec_ = stateVecDD_.getVector(); }); - const size_t reqSize = stateVec_.size() * 2 * sizeof(double); + const auto numQubits = static_cast(stateVecDD_.p->v) + 1; + constexpr size_t elementSize = 2 * sizeof(double); + if (numQubits >= std::numeric_limits::digits || + (std::numeric_limits::max() >> numQubits) < elementSize) { + return QDMI_ERROR_OUTOFMEM; + } + const size_t dimension = size_t{1} << numQubits; + const size_t reqSize = dimension * elementSize; if (data != nullptr) { if (size < reqSize) { return QDMI_ERROR_INVALIDARGUMENT; } + if (dimension > stateVec_.max_size()) { + return QDMI_ERROR_OUTOFMEM; + } + std::call_once(stateVecOnce_, + [this] { stateVec_ = stateVecDD_.getVector(); }); std::memcpy(data, stateVec_.data(), reqSize); } if (sizeRet != nullptr) { @@ -869,14 +879,23 @@ auto MQT_DDSIM_QDMI_Device_Job_impl_d::getProbabilities(const size_t size, if (stateVecDD_.isTerminal()) { return reportEmptyResult(sizeRet); } - if (stateVec_.empty()) { - stateVec_ = stateVecDD_.getVector(); + const auto numQubits = static_cast(stateVecDD_.p->v) + 1; + constexpr size_t elementSize = sizeof(double); + if (numQubits >= std::numeric_limits::digits || + (std::numeric_limits::max() >> numQubits) < elementSize) { + return QDMI_ERROR_OUTOFMEM; } - const size_t reqSize = stateVec_.size() * sizeof(double); + const size_t dimension = size_t{1} << numQubits; + const size_t reqSize = dimension * elementSize; if (data != nullptr) { if (size < reqSize) { return QDMI_ERROR_INVALIDARGUMENT; } + if (dimension > stateVec_.max_size()) { + return QDMI_ERROR_OUTOFMEM; + } + std::call_once(stateVecOnce_, + [this] { stateVec_ = stateVecDD_.getVector(); }); // NOLINTNEXTLINE(misc-const-correctness): fills a mutable output buffer. auto* dataPtr = static_cast(data); for (const auto& c : stateVec_) { diff --git a/test/python/plugins/qdmi_pennylane/test_converter.py b/test/python/plugins/qdmi_pennylane/test_converter.py index 028f8a61bb..0a93a4d750 100644 --- a/test/python/plugins/qdmi_pennylane/test_converter.py +++ b/test/python/plugins/qdmi_pennylane/test_converter.py @@ -284,6 +284,8 @@ def test_reuses_session_contract_checks_without_skipping_input_validation( qdmi = StubDevice([rx], [program_format]) patch_open_device(monkeypatch, qdmi) device = QDMIDevice("fake.qdmi", wires=2) + query_name = Mock(wraps=rx.name) + monkeypatch.setattr(rx, "name", query_name) query_sites = Mock(wraps=rx.sites) monkeypatch.setattr(rx, "sites", query_sites) @@ -293,6 +295,7 @@ def tape(angle: float, wire: int = 0): device.execute((tape(0.1), tape(0.2))) device.execute(tape(0.3)) query_sites.assert_called_once() + query_name.assert_not_called() assert len({program for program, *_ in qdmi.submissions}) == 3 with pytest.raises(PennyLaneValidationError, match="non-finite"): device.execute(tape(float("nan"))) diff --git a/test/python/plugins/qdmi_pennylane/test_device.py b/test/python/plugins/qdmi_pennylane/test_device.py index ba56c6f674..05e306a1e1 100644 --- a/test/python/plugins/qdmi_pennylane/test_device.py +++ b/test/python/plugins/qdmi_pennylane/test_device.py @@ -381,3 +381,27 @@ def circuit(): assert device.shots.total_shots is None circuit() assert tracker.totals["executions"] == 4 + + +@pytest.mark.parametrize("order", [[0, 1], [1, 0], [1]]) +def test_sample_decoding_preserves_wire_order_and_dtype(monkeypatch: pytest.MonkeyPatch, order: list[int]) -> None: + """Decode spaced bit strings in the requested PennyLane wire order.""" + qdmi = stub_device(result_factory=lambda _program, _shots: ["0 1", "10"]) + patch_open_device(monkeypatch, qdmi) + device = QDMIDevice("fake.qdmi", wires=2) + tape = qp.tape.QuantumScript([], [qp.sample(wires=order)], shots=2) + samples = device.execute(tape) + np.testing.assert_array_equal(samples, np.array([[1, 0], [0, 1]], dtype=np.int8)[:, order]) + assert isinstance(samples, np.ndarray) + assert samples.dtype == np.int8 + + +@pytest.mark.parametrize("bitstrings", [["001", "0"], ["0x", "10"], ["0é", "10"], ["01"]]) +def test_sample_decoding_rejects_malformed_shots(monkeypatch: pytest.MonkeyPatch, bitstrings: list[str]) -> None: + """Validate each shot before packing; total character count is insufficient.""" + qdmi = stub_device(result_factory=lambda _program, _shots: bitstrings) + patch_open_device(monkeypatch, qdmi) + device = QDMIDevice("fake.qdmi", wires=2) + tape = qp.tape.QuantumScript([], [qp.sample(wires=[0, 1])], shots=2) + with pytest.raises(PennyLaneExecutionError, match=r"invalid 2-wire shot|samples for a 2-shot job"): + device.execute(tape) diff --git a/test/python/plugins/qiskit/test_mock_backend.py b/test/python/plugins/qiskit/test_mock_backend.py index bf529447df..132d5db688 100644 --- a/test/python/plugins/qiskit/test_mock_backend.py +++ b/test/python/plugins/qiskit/test_mock_backend.py @@ -15,6 +15,7 @@ import string import warnings from typing import TYPE_CHECKING, ClassVar, NoReturn +from unittest.mock import Mock import pytest from qiskit import qasm2, qasm3 @@ -962,3 +963,34 @@ def test_target_rejects_incomplete_site_tuple(monkeypatch: pytest.MonkeyPatch) - monkeypatch.setattr(operation, "sites", device.sites) with pytest.raises(UnsupportedOperationError, match="incomplete 3-qubit site tuple"): QDMIBackend(device) # ty: ignore[invalid-argument-type] Intentional device double. + + +@pytest.mark.parametrize("duration", [None, 0, 20]) +def test_target_snapshots_duration_conversion_once(monkeypatch: pytest.MonkeyPatch, duration: int | None) -> None: + """Read units lazily once across placements, and refresh them for a new target.""" + device = MockQDMIDevice(num_qubits=3, operations=["x", "h", "measure"]) + unit = Mock(return_value="us") + scale = Mock(return_value=0.5) + monkeypatch.setattr(device, "duration_unit", unit, raising=False) + monkeypatch.setattr(device, "duration_scale_factor", scale, raising=False) + for operation in device.operations()[:2]: + monkeypatch.setattr(operation, "sites", device.sites) + monkeypatch.setattr(operation, "duration", lambda **_kwargs: duration) + for factor in [0.5, 2.0]: + scale.return_value = factor + unit.reset_mock() + scale.reset_mock() + backend = QDMIBackend(device) # ty: ignore[invalid-argument-type] Intentional device double. + if duration is None: + unit.assert_not_called() + scale.assert_not_called() + else: + unit.assert_called_once() + scale.assert_called_once() + for name in ["x", "h"]: + for site in range(3): + properties = backend.target[name][site,] + if duration is None: + assert properties is None + else: + assert properties.duration == pytest.approx(duration * factor * 1e-6) diff --git a/test/qdmi/devices/dd/concurrency_test.cpp b/test/qdmi/devices/dd/concurrency_test.cpp index 0cd02d134a..9dd067a2a8 100644 --- a/test/qdmi/devices/dd/concurrency_test.cpp +++ b/test/qdmi/devices/dd/concurrency_test.cpp @@ -20,8 +20,10 @@ #include #include +#include #include #include +#include #include #include #include @@ -29,37 +31,44 @@ #include #include -TEST(Concurrency, ConcurrentStatevectorReads) { +using ColdDenseReads = testing::TestWithParam; + +TEST_P(ColdDenseReads, ConcurrentProbabilityAndStatevectorReads) { const qdmi_test::SessionGuard s{}; - qdmi_test::JobGuard j{s.session}; + const qdmi_test::JobGuard j{s.session}; ASSERT_EQ(qdmi_test::setProgram(j.job, QDMI_PROGRAM_FORMAT_QASM3, qdmi_test::QASM3_BELL_STATE), QDMI_SUCCESS); ASSERT_EQ(qdmi_test::setShots(j.job, 0), QDMI_SUCCESS); ASSERT_EQ(qdmi_test::submitAndWait(j.job, 0), QDMI_SUCCESS); - const size_t stateSize = - qdmi_test::querySize(j.job, QDMI_JOB_RESULT_STATEVECTOR_DENSE); - ASSERT_GT(stateSize, 0U); - - auto const worker = [&] { - std::vector buf(stateSize / sizeof(double)); - EXPECT_EQ(MQT_DDSIM_QDMI_device_job_get_results( - j.job, QDMI_JOB_RESULT_STATEVECTOR_DENSE, stateSize, - buf.data(), nullptr), + std::barrier start{4}; + const auto worker = [&](const QDMI_Job_Result result) { + const bool probabilities = result == QDMI_JOB_RESULT_PROBABILITIES_DENSE; + std::vector buffer(probabilities ? 4 : 8); + start.arrive_and_wait(); + ASSERT_EQ(MQT_DDSIM_QDMI_device_job_get_results( + j.job, result, buffer.size() * sizeof(double), buffer.data(), + nullptr), QDMI_SUCCESS); + const auto nonzero = probabilities ? 0.5 : 1.0 / std::numbers::sqrt2; + for (size_t i = 0; i < buffer.size(); ++i) { + const auto expected = + i == 0 || i == (probabilities ? 3U : 6U) ? nonzero : 0.0; + EXPECT_NEAR(buffer[i], expected, 1e-12); + } }; - std::thread t1(worker); - std::thread t2(worker); - std::thread t3(worker); - std::thread t4(worker); - t1.join(); - t2.join(); - t3.join(); - t4.join(); + const std::jthread t1(worker, GetParam()); + const std::jthread t2(worker, GetParam()); + const std::jthread t3(worker, QDMI_JOB_RESULT_PROBABILITIES_DENSE); + const std::jthread t4(worker, QDMI_JOB_RESULT_PROBABILITIES_DENSE); } +INSTANTIATE_TEST_SUITE_P(Concurrency, ColdDenseReads, + testing::Values(QDMI_JOB_RESULT_STATEVECTOR_DENSE, + QDMI_JOB_RESULT_PROBABILITIES_DENSE)); + TEST(Concurrency, ConcurrentHistogramReads) { const qdmi_test::SessionGuard s{}; const qdmi_test::JobGuard j{s.session}; diff --git a/test/qdmi/devices/dd/results_statevector_test.cpp b/test/qdmi/devices/dd/results_statevector_test.cpp index c09350ce8f..f424dc0a26 100644 --- a/test/qdmi/devices/dd/results_statevector_test.cpp +++ b/test/qdmi/devices/dd/results_statevector_test.cpp @@ -22,7 +22,9 @@ #include #include #include +#include #include +#include #include #include @@ -230,3 +232,52 @@ attributes #1 = { "irreversible" } 0., 1e-12); } } + +TEST(ResultsStatevector, DenseSizesDoNotMaterializeUnaddressableVectors) { + constexpr size_t bits = std::numeric_limits::digits; + const qdmi_test::SessionGuard session{}; + const std::array cases{ + std::array{ + bits - 5, + size_t{1} << (bits - 1), + size_t{1} << (bits - 2), + }, + std::array{bits - 4, 0, size_t{1} << (bits - 1)}, + std::array{bits - 3, 0, 0}, + std::array{bits, 0, 0}, + }; + for (const auto& [qubits, stateSize, probabilitySize] : cases) { + SCOPED_TRACE(qubits); + const qdmi_test::JobGuard job{session.session}; + const auto program = + "OPENQASM 3.0; qubit[" + std::to_string(qubits) + "] q; x q[0];"; + ASSERT_EQ( + qdmi_test::setProgram(job.job, QDMI_PROGRAM_FORMAT_QASM3, program), + QDMI_SUCCESS); + ASSERT_EQ(qdmi_test::setShots(job.job, 0), QDMI_SUCCESS); + ASSERT_EQ(qdmi_test::submitAndWait(job.job, 0), QDMI_SUCCESS); + for (const auto result : { + QDMI_JOB_RESULT_STATEVECTOR_DENSE, + QDMI_JOB_RESULT_PROBABILITIES_DENSE, + }) { + const auto expectedSize = result == QDMI_JOB_RESULT_STATEVECTOR_DENSE + ? stateSize + : probabilitySize; + size_t size = 123; + const auto status = MQT_DDSIM_QDMI_device_job_get_results( + job.job, result, 0, nullptr, &size); + if (expectedSize == 0) { + EXPECT_EQ(status, QDMI_ERROR_OUTOFMEM); + EXPECT_EQ(size, 123); + continue; + } + ASSERT_EQ(status, QDMI_SUCCESS); + EXPECT_EQ(size, expectedSize); + double output = 42; + EXPECT_EQ(MQT_DDSIM_QDMI_device_job_get_results( + job.job, result, sizeof(output), &output, nullptr), + QDMI_ERROR_INVALIDARGUMENT); + EXPECT_EQ(output, 42); + } + } +}