File size: 11,946 Bytes
2a87ede | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 | """
Real simulation engine for the dashboard.
OpenQASM text -> dense_evolution's own QASMParser -> executed on
dense_evolution's actual DenseSVSimulator/MPSSimulator (not Qiskit's own
simulator, and not Qiskit's QASM parser either) -> statevector,
probabilities and shot counts, all reordered into Qiskit's little-endian
qubit convention so they line up with the Circuit tab's qubit labels and
with qiskit.visualization's functions (which assume that convention).
No Qiskit QuantumCircuit is ever constructed here, not even for the
Circuit-diagram panel: qiskit.circuit.QuantumCircuit.__init__ itself
segfaults (SIGSEGV, inside qiskit's own compiled extension) on macOS,
independent of *how* the circuit is built or what's done with it
afterwards -- see tests/integration/test_interop.py::TestQiskitInterop for the full
reproduction story (QuantumCircuit(3) alone, no QASM, no method calls,
still crashes there). SimulationResult/LargeScaleMPSResult below carry
the plain gate tuples instead, and dashboard_core.circuit_diagram draws
the diagram directly from those with plain matplotlib.
No synthetic/placeholder data anywhere here: every quantity returned is
computed from a real run of the real engine.
"""
from dataclasses import dataclass
from functools import lru_cache
from typing import Optional
import numpy as np
import dense_evolution as de
__all__ = ['SimulationResult', 'run_circuit_from_qasm', 'LargeScaleMPSResult', 'run_large_circuit_mps']
def _qasm_to_tuples(qasm_text: str):
"""Parses qasm_text with dense_evolution's own QASMParser (never
Qiskit's) and returns (n_qubits, ops) where ops is the same
(name, *qubits[, param]) tuple format every other dense_evolution
entry point (run_circuit, QuantumTranspiler.transpile, ...) accepts."""
parsed = de.QASMParser().parse(qasm_text)
return parsed.n_qubits, parsed.to_tuples()
# MPSSimulator.contract_to_statevector's own hard ceiling (RAM-independent
# -- it refuses above this regardless of available memory, since a dense
# 2**n array stops being the point of using MPS at all). Circuits at or
# below this go through the normal dense-statevector path (same
# Probabilities/Q-sphere/Statevector panels as the Dense backend); above
# it, run_large_circuit_mps is the only real path.
MPS_DENSE_CONTRACTION_LIMIT = 24
@lru_cache(maxsize=None)
def _qiskit_bit_order_perm(n_qubits: int) -> np.ndarray:
"""The bit-reverse index permutation itself, cached per n_qubits --
it's O(2**n_qubits) to build and depends only on n_qubits, so a
circuit run repeatedly at the same qubit count (the common case: a
fixed molecule/preset re-run with different parameters) was
rebuilding an identical array every single call. lru_cache keys on
the (hashable) n_qubits argument only -- the returned array itself
doesn't need to be hashable."""
perm = np.array([int(format(i, f'0{n_qubits}b')[::-1], 2) for i in range(2 ** n_qubits)])
perm.setflags(write=False) # cached and shared across calls -- never mutate in place
return perm
def _to_qiskit_bit_order(values: np.ndarray, n_qubits: int) -> np.ndarray:
"""Bit-reverse index order: Dense-Evolution is MSB-first (qubit 0 =
most significant bit of the index), Qiskit is little-endian (qubit 0
= least significant bit) -- the same remap dense_evolution.interop
applies to probabilities internally, used here for the raw complex
statevector too since it is the identical index permutation."""
return values[_qiskit_bit_order_perm(n_qubits)]
@dataclass
class SimulationResult:
ops: list # dense_evolution gate tuples -- feeds dashboard_core.circuit_diagram for the Circuit tab
n_qubits: int
statevector: np.ndarray # complex128, Qiskit bit order
probabilities: np.ndarray # Qiskit bit order
counts: dict # Qiskit-style bitstring -> shot count
noise_model: str = "ideal"
noise_p: float = 0.0
fidelity_vs_ideal: Optional[float] = None
backend: str = "dense"
mps_max_bond_used: Optional[int] = None
mps_memory_mb: Optional[float] = None
mps_avg_jsd: Optional[float] = None
def run_circuit_from_qasm(
qasm_text: str,
n_shots: int = 1000,
seed: Optional[int] = None,
noise_model: str = "ideal",
noise_p: float = 0.0,
backend: str = "dense",
) -> SimulationResult:
"""Parse `qasm_text` and run it on a real dense_evolution engine,
returning every quantity the dashboard's tabs need.
noise_model/noise_p: one of dense_evolution.NoiseModel.MODELS
('ideal', 'depolarizing', 'bitflip', 'phaseflip', 'amplitude_damping',
'combined') applied as a real stochastic Kraus channel to the
statevector via NoiseModel.apply_to_sv -- not a fabricated decay
curve, the actual channel math. 'ideal'/p<=0 skips it entirely (and
leaves SimulationResult.fidelity_vs_ideal as None -- comparing a run
against itself is not a real quantity). Otherwise fidelity_vs_ideal
is the real dense_evolution.statevector_fidelity(|<ideal|noisy>|^2)
between this run's one noisy trajectory and the same circuit's ideal
statevector, both computed in this same call (not re-simulated by the
caller).
backend: 'dense' (DenseSVSimulator, the default) or 'mps'
(MPSSimulator -- adaptive SVD-truncated matrix product state, scales
to far more qubits for low-entanglement circuits; contracted back to
a dense statevector afterwards so the same Probabilities/Q-sphere/
Statevector panels work unchanged regardless of which engine ran the
circuit). Verified to match 'dense' exactly (atol=1e-6) on every
preset in QASM_LIBRARY, and to run a 12-qubit GHZ state in ~2.5s
(max_bond=2) where 'dense' would need 2**12 complex amplitudes.
"""
n_qubits, ops = _qasm_to_tuples(qasm_text)
if n_qubits < 1:
raise ValueError("circuit must declare at least 1 qubit")
if backend not in ("dense", "mps"):
raise ValueError(f"unknown backend {backend!r}, must be 'dense' or 'mps'")
# Both backends end up holding a real 2**n_qubits complex128 dense
# statevector (Dense always; MPS after contract_to_statevector() for
# display) -- check against this machine's *actual* free RAM before
# allocating it, via the same real anti-OOM guard dense_evolution.chunk
# already uses (15% safety threshold, psutil-backed), rather than
# letting a manually-typed large QASM circuit (which bypasses the
# UI's qubit cap entirely) crash the process.
required_mb = (2 ** n_qubits) * 16 / 1e6
de.chunk.SafeMemoryGuard().check_allocation(required_mb, context=f"{n_qubits}-qubit statevector")
mps_max_bond_used = mps_memory_mb = mps_avg_jsd = None
if backend == "mps":
# MPSSimulator.run_circuit_jit does not auto-transpile (unlike
# DenseSVSimulator.run_circuit) -- swap/ccx must be decomposed
# first or it raises on 'swap' not being in its native GATE_IDS
# (verified directly: the QFT preset's trailing swap fails here
# without this step).
transpiled = de.QuantumTranspiler.transpile(ops)
mps = de.MPSSimulator(n_qubits)
mps.run_circuit_jit(transpiled)
sv_native = np.asarray(mps.contract_to_statevector())
mps_max_bond_used = mps.max_bond_used()
mps_memory_mb = mps.memory_mb()
mps_avg_jsd = mps.avg_jsd()
else:
sim = de.DenseSVSimulator(n_qubits, use_float32=False)
sim.run_circuit(ops)
sv_native = np.asarray(sim.sv)
rng = np.random.default_rng(seed)
fidelity_vs_ideal = None
if noise_model != "ideal" and noise_p > 0:
# apply_to_sv mutates its numpy input in-place, so the pre-noise
# amplitudes have to be copied out first -- this is one stochastic
# Kraus-channel trajectory (a real single noisy realization, not a
# density matrix), so the result is still a valid pure state and
# dense_evolution.statevector_fidelity (the pure-state counterpart
# to the density-matrix uhlmann_fidelity already used by the ZNE
# panels below) is the right comparison against the ideal state.
sv_ideal = sv_native.copy()
sv_native = de.NoiseModel.apply_to_sv(sv_native, n_qubits, noise_model, noise_p, rng=rng)
fidelity_vs_ideal = float(de.statevector_fidelity(sv_ideal, sv_native))
statevector = _to_qiskit_bit_order(sv_native, n_qubits)
probabilities = np.abs(statevector) ** 2
# sample_counts expects/returns dense_evolution's native MSB-first
# bitstring convention -- reverse each key to relabel into Qiskit's
# little-endian convention (same physical samples, just relabeled to
# match the Circuit/Probabilities tabs' qubit numbering).
counts_native = de.sample_counts(sv_native, n_shots, rng=rng)
counts = {key[::-1]: n for key, n in counts_native.items()}
return SimulationResult(
ops=ops,
n_qubits=n_qubits,
statevector=statevector,
probabilities=probabilities,
counts=counts,
noise_model=noise_model,
noise_p=noise_p,
fidelity_vs_ideal=fidelity_vs_ideal,
backend=backend,
mps_max_bond_used=mps_max_bond_used,
mps_memory_mb=mps_memory_mb,
mps_avg_jsd=mps_avg_jsd,
)
@dataclass
class LargeScaleMPSResult:
ops: list # dense_evolution gate tuples -- feeds dashboard_core.circuit_diagram for the Circuit tab
n_qubits: int
top_k_states: list # [(bitstring, probability), ...] Qiskit bit order, sorted descending
k_requested: int
mps_max_bond_used: int
mps_memory_mb: float
mps_avg_jsd: float
def run_large_circuit_mps(qasm_text: str, k: int = 32, seed: Optional[int] = None) -> LargeScaleMPSResult:
"""For circuits beyond MPS_DENSE_CONTRACTION_LIMIT qubits, where no
dense (2**n,) statevector/probabilities array can exist at all: runs
the real MPS circuit and finds the top-k most probable basis states
via MPSSimulator.get_top_k_probable_states -- a real greedy beam
search, EXACT probabilities for the states it finds (not sampled or
approximated). Verified directly against exact dense diagonalization
at 10 qubits: every state with non-negligible probability was found,
matching to 6 decimal places (recall isn't guaranteed complete for
highly-entangled circuits at fixed k -- see MPSSimulator's own
docstring -- but the probabilities reported are always exact, never
estimated).
Chosen over MPSSimulator.get_probabilities_sampled for this UI:
measured directly, sampling 2000 shots takes 130s/257s/582s at
30/50/100 qubits (grows with n -- roughly O(n_samples * n_qubits)),
while get_top_k_probable_states stays under 2s even at 100 qubits.
"""
n_qubits, raw_ops = _qasm_to_tuples(qasm_text)
if n_qubits < 1:
raise ValueError("circuit must declare at least 1 qubit")
ops = de.QuantumTranspiler.transpile(raw_ops)
mps = de.MPSSimulator(n_qubits)
mps.run_circuit_jit(ops)
idx, probs = mps.get_top_k_probable_states(k=k)
top_k_states = []
for i, p in zip(idx.tolist(), probs.tolist()):
native_bits = format(int(i), f'0{n_qubits}b')
qiskit_bits = native_bits[::-1] # same MSB-first -> little-endian flip as _to_qiskit_bit_order
top_k_states.append((qiskit_bits, float(p)))
top_k_states.sort(key=lambda t: t[1], reverse=True)
return LargeScaleMPSResult(
ops=raw_ops,
n_qubits=n_qubits,
top_k_states=top_k_states,
k_requested=k,
mps_max_bond_used=mps.max_bond_used(),
mps_memory_mb=mps.memory_mb(),
mps_avg_jsd=mps.avg_jsd(),
)
|